From 6002edf85c06e0271d87fefe0d2285d319eeb99b Mon Sep 17 00:00:00 2001 From: Graine25 Date: Tue, 7 Jul 2026 19:42:56 -0700 Subject: [PATCH 01/11] refactor: config patching works when building --- reeot_default_xex.toml | 8 +++++--- reeot_gamelogic.toml | 2 ++ reeot_manifest.toml | 6 +++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/reeot_default_xex.toml b/reeot_default_xex.toml index ac103b8e..262aaa75 100644 --- a/reeot_default_xex.toml +++ b/reeot_default_xex.toml @@ -6,8 +6,9 @@ # Run codegen with the manifest: rexglue codegen reeot_manifest.toml project_name = "reeot" -file_path = "assets/default.xex" -out_directory_path = "generated/default_xex" +file_path = "assets/Default.xex" +patch_file_path = "assets/Update/Default.xexp" +out_directory_path = "generated/default" [rexcrt] # Heap @@ -1823,6 +1824,7 @@ strcpy_s = 0x8233E498 0x82342C18 = { name = "eot_Thread_GetTLS_OrDefault" } 0x822471C8 = { name = "eot_Thread_SetAffinity" } 0x8224B8E0 = { name = "eot_Thread_SleepMs" } +0x82247460 = { name = "eot_Sleep" } 0x8233BEA8 = { name = "eot_threadstartex" } 0x82247388 = { name = "eot_WaitForSingleObject" } 0x8224B688 = { name = "eot_WaitForSingleObjectEx" } @@ -2741,4 +2743,4 @@ strcpy_s = 0x8233E498 0x8229c6b0 = { name = "XGUntileVolumeTextureLevel" } 0x8238D9A8 = { size = 0x4D8 } -0x8238DE80 = { size = 0x86C } \ No newline at end of file +0x8238DE80 = { size = 0x86C } diff --git a/reeot_gamelogic.toml b/reeot_gamelogic.toml index f35e9876..7d69e59a 100644 --- a/reeot_gamelogic.toml +++ b/reeot_gamelogic.toml @@ -6,7 +6,9 @@ project_name = "reeot" file_path = "assets/Data/GameLogic.dll" +patch_file_path = "assets/Update/Data/GameLogic.dllp" out_directory_path = "generated/gamelogic" +is_dll = true # [rexcrt] # Heap diff --git a/reeot_manifest.toml b/reeot_manifest.toml index 1f9dbe7a..8548ff25 100644 --- a/reeot_manifest.toml +++ b/reeot_manifest.toml @@ -7,11 +7,15 @@ game_root = "assets" [entrypoint] file_path = "assets/Default.xex" +patch_file_path = "assets/Update/Default.xexp" +patched_file_path = "assets/Default_patched.xex" out_directory_path = "generated/default" includes = ["reeot_default_xex.toml"] [[modules]] -guest_path = "data/gamelogic.dll" +guest_path = "Data/GameLogic.dll" file_path = "assets/Data/GameLogic.dll" +patch_file_path = "assets/Update/Data/GameLogic.dllp" +patched_file_path = "assets/Data/GameLogic_patched.dll" out_directory_path = "generated/gamelogic" includes = ["reeot_gamelogic.toml"] From e5f548b6ec76d3c649af02f1304a991e1efc37f0 Mon Sep 17 00:00:00 2001 From: Graine25 Date: Wed, 8 Jul 2026 15:31:22 -0700 Subject: [PATCH 02/11] feat: add installer resources --- .gitmodules | 3 +++ res | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 res diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..c8a84be2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "res"] + path = res + url = https://github.com/goliath-tilers/reeot-resources.git diff --git a/res b/res new file mode 160000 index 00000000..041c4a7f --- /dev/null +++ b/res @@ -0,0 +1 @@ +Subproject commit 041c4a7f7431691928a3a54fddd5489634b529d0 From 4d02ac394f77872e7351a9817dfeecfef6eebe1f Mon Sep 17 00:00:00 2001 From: Graine25 Date: Wed, 8 Jul 2026 15:31:31 -0700 Subject: [PATCH 03/11] feat: add installer dependencies --- .gitmodules | 12 + thirdparty/TinySHA1/TinySHA1.hpp | 223 +++++++ thirdparty/ddspp | 1 + thirdparty/libmspack | 1 + thirdparty/nativefiledialog-extended | 1 + thirdparty/tiny-AES-c | 1 + thirdparty/xenon_utils/byteswap.h | 25 + thirdparty/xenon_utils/memory_mapped_file.cpp | 169 ++++++ thirdparty/xenon_utils/memory_mapped_file.h | 32 + thirdparty/xenon_utils/xbox.h | 409 +++++++++++++ thirdparty/xenon_utils/xex.h | 267 ++++++++ thirdparty/xenon_utils/xex_patcher.cpp | 568 ++++++++++++++++++ thirdparty/xenon_utils/xex_patcher.h | 37 ++ 13 files changed, 1746 insertions(+) create mode 100644 thirdparty/TinySHA1/TinySHA1.hpp create mode 160000 thirdparty/ddspp create mode 160000 thirdparty/libmspack create mode 160000 thirdparty/nativefiledialog-extended create mode 160000 thirdparty/tiny-AES-c create mode 100644 thirdparty/xenon_utils/byteswap.h create mode 100644 thirdparty/xenon_utils/memory_mapped_file.cpp create mode 100644 thirdparty/xenon_utils/memory_mapped_file.h create mode 100644 thirdparty/xenon_utils/xbox.h create mode 100644 thirdparty/xenon_utils/xex.h create mode 100644 thirdparty/xenon_utils/xex_patcher.cpp create mode 100644 thirdparty/xenon_utils/xex_patcher.h diff --git a/.gitmodules b/.gitmodules index c8a84be2..9e0d8524 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,15 @@ [submodule "res"] path = res url = https://github.com/goliath-tilers/reeot-resources.git +[submodule "thirdparty/nativefiledialog-extended"] + path = thirdparty/nativefiledialog-extended + url = https://github.com/btzy/nativefiledialog-extended.git +[submodule "thirdparty/ddspp"] + path = thirdparty/ddspp + url = https://github.com/redorav/ddspp.git +[submodule "thirdparty/libmspack"] + path = thirdparty/libmspack + url = https://github.com/kyz/libmspack +[submodule "thirdparty/tiny-AES-c"] + path = thirdparty/tiny-AES-c + url = https://github.com/kokke/tiny-AES-c.git diff --git a/thirdparty/TinySHA1/TinySHA1.hpp b/thirdparty/TinySHA1/TinySHA1.hpp new file mode 100644 index 00000000..2493e88f --- /dev/null +++ b/thirdparty/TinySHA1/TinySHA1.hpp @@ -0,0 +1,223 @@ +/* + * + * TinySHA1 - a header only implementation of the SHA1 algorithm in C++. Based + * on the implementation in boost::uuid::details. + * + * SHA1 Wikipedia Page: http://en.wikipedia.org/wiki/SHA-1 + * + * Copyright (c) 2012-22 SAURAV MOHAPATRA + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + * Taken from https://github.com/mohaps/TinySHA1 + * Modified for use by Xenia + */ +#ifndef _TINY_SHA1_HPP_ +#define _TINY_SHA1_HPP_ + +#include +#include +#include +#include + +namespace sha1 { +class SHA1 { + public: + typedef uint32_t digest32_t[5]; + typedef uint8_t digest8_t[20]; + inline static uint32_t LeftRotate(uint32_t value, size_t count) { + return (value << count) ^ (value >> (32 - count)); + } + SHA1() { reset(); } + virtual ~SHA1() {} + SHA1(const SHA1& s) { *this = s; } + const SHA1& operator=(const SHA1& s) { + memcpy(m_digest, s.m_digest, 5 * sizeof(uint32_t)); + memcpy(m_block, s.m_block, 64); + m_blockByteIndex = s.m_blockByteIndex; + m_byteCount = s.m_byteCount; + + return *this; + } + + SHA1& init(const uint32_t digest[5], const uint8_t block[64], + uint32_t count) { + std::memcpy(m_digest, digest, 20); + std::memcpy(m_block, block, count % 64); + m_byteCount = count; + m_blockByteIndex = count % 64; + + return *this; + } + + const uint32_t* getDigest() const { return m_digest; } + const uint8_t* getBlock() const { return m_block; } + size_t getBlockByteIndex() const { return m_blockByteIndex; } + size_t getByteCount() const { return m_byteCount; } + + SHA1& reset() { + m_digest[0] = 0x67452301; + m_digest[1] = 0xEFCDAB89; + m_digest[2] = 0x98BADCFE; + m_digest[3] = 0x10325476; + m_digest[4] = 0xC3D2E1F0; + m_blockByteIndex = 0; + m_byteCount = 0; + return *this; + } + + SHA1& processByte(uint8_t octet) { + this->m_block[this->m_blockByteIndex++] = octet; + ++this->m_byteCount; + if (m_blockByteIndex == 64) { + this->m_blockByteIndex = 0; + processBlock(); + } + + return *this; + } + + SHA1& processBlock(const void* const start, const void* const end) { + const uint8_t* begin = static_cast(start); + const uint8_t* finish = static_cast(end); + while (begin != finish) { + processByte(*begin); + begin++; + } + return *this; + } + + SHA1& processBytes(const void* const data, size_t len) { + const uint8_t* block = static_cast(data); + processBlock(block, block + len); + return *this; + } + + const uint32_t* finalize(digest32_t digest) { + size_t bitCount = this->m_byteCount * 8; + processByte(0x80); + if (this->m_blockByteIndex > 56) { + while (m_blockByteIndex != 0) { + processByte(0); + } + while (m_blockByteIndex < 56) { + processByte(0); + } + } else { + while (m_blockByteIndex < 56) { + processByte(0); + } + } + processByte(0); + processByte(0); + processByte(0); + processByte(0); + processByte(static_cast((bitCount >> 24) & 0xFF)); + processByte(static_cast((bitCount >> 16) & 0xFF)); + processByte(static_cast((bitCount >> 8) & 0xFF)); + processByte(static_cast((bitCount)&0xFF)); + + memcpy(digest, m_digest, 5 * sizeof(uint32_t)); + return digest; + } + + const uint8_t* finalize(digest8_t digest) { + digest32_t d32; + finalize(d32); + size_t di = 0; + digest[di++] = ((d32[0] >> 24) & 0xFF); + digest[di++] = ((d32[0] >> 16) & 0xFF); + digest[di++] = ((d32[0] >> 8) & 0xFF); + digest[di++] = ((d32[0]) & 0xFF); + + digest[di++] = ((d32[1] >> 24) & 0xFF); + digest[di++] = ((d32[1] >> 16) & 0xFF); + digest[di++] = ((d32[1] >> 8) & 0xFF); + digest[di++] = ((d32[1]) & 0xFF); + + digest[di++] = ((d32[2] >> 24) & 0xFF); + digest[di++] = ((d32[2] >> 16) & 0xFF); + digest[di++] = ((d32[2] >> 8) & 0xFF); + digest[di++] = ((d32[2]) & 0xFF); + + digest[di++] = ((d32[3] >> 24) & 0xFF); + digest[di++] = ((d32[3] >> 16) & 0xFF); + digest[di++] = ((d32[3] >> 8) & 0xFF); + digest[di++] = ((d32[3]) & 0xFF); + + digest[di++] = ((d32[4] >> 24) & 0xFF); + digest[di++] = ((d32[4] >> 16) & 0xFF); + digest[di++] = ((d32[4] >> 8) & 0xFF); + digest[di++] = ((d32[4]) & 0xFF); + return digest; + } + + protected: + void processBlock() { + uint32_t w[80]; + for (size_t i = 0; i < 16; i++) { + w[i] = (m_block[i * 4 + 0] << 24); + w[i] |= (m_block[i * 4 + 1] << 16); + w[i] |= (m_block[i * 4 + 2] << 8); + w[i] |= (m_block[i * 4 + 3]); + } + for (size_t i = 16; i < 80; i++) { + w[i] = LeftRotate((w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]), 1); + } + + uint32_t a = m_digest[0]; + uint32_t b = m_digest[1]; + uint32_t c = m_digest[2]; + uint32_t d = m_digest[3]; + uint32_t e = m_digest[4]; + + for (std::size_t i = 0; i < 80; ++i) { + uint32_t f = 0; + uint32_t k = 0; + + if (i < 20) { + f = (b & c) | (~b & d); + k = 0x5A827999; + } else if (i < 40) { + f = b ^ c ^ d; + k = 0x6ED9EBA1; + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d); + k = 0x8F1BBCDC; + } else { + f = b ^ c ^ d; + k = 0xCA62C1D6; + } + uint32_t temp = LeftRotate(a, 5) + f + e + k + w[i]; + e = d; + d = c; + c = LeftRotate(b, 30); + b = a; + a = temp; + } + + m_digest[0] += a; + m_digest[1] += b; + m_digest[2] += c; + m_digest[3] += d; + m_digest[4] += e; + } + + private: + digest32_t m_digest; + uint8_t m_block[64]; + size_t m_blockByteIndex; + size_t m_byteCount; +}; +} +#endif diff --git a/thirdparty/ddspp b/thirdparty/ddspp new file mode 160000 index 00000000..96ce542e --- /dev/null +++ b/thirdparty/ddspp @@ -0,0 +1 @@ +Subproject commit 96ce542e732c0930b6410620b2c41f35b03c3f60 diff --git a/thirdparty/libmspack b/thirdparty/libmspack new file mode 160000 index 00000000..4d868504 --- /dev/null +++ b/thirdparty/libmspack @@ -0,0 +1 @@ +Subproject commit 4d868504114346aa767d435fbb89a8b76603493f diff --git a/thirdparty/nativefiledialog-extended b/thirdparty/nativefiledialog-extended new file mode 160000 index 00000000..3cd252a8 --- /dev/null +++ b/thirdparty/nativefiledialog-extended @@ -0,0 +1 @@ +Subproject commit 3cd252a8f7ca32419b1ca235c2990ba6a0ecba7c diff --git a/thirdparty/tiny-AES-c b/thirdparty/tiny-AES-c new file mode 160000 index 00000000..23856752 --- /dev/null +++ b/thirdparty/tiny-AES-c @@ -0,0 +1 @@ +Subproject commit 23856752fbd139da0b8ca6e471a13d5bcc99a08d diff --git a/thirdparty/xenon_utils/byteswap.h b/thirdparty/xenon_utils/byteswap.h new file mode 100644 index 00000000..33e959fc --- /dev/null +++ b/thirdparty/xenon_utils/byteswap.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +template +inline T ByteSwap(T value) +{ + if constexpr (sizeof(T) == 1) + return value; + else if constexpr (sizeof(T) == 2) + return static_cast(__builtin_bswap16(static_cast(value))); + else if constexpr (sizeof(T) == 4) + return static_cast(__builtin_bswap32(static_cast(value))); + else if constexpr (sizeof(T) == 8) + return static_cast(__builtin_bswap64(static_cast(value))); + + assert(false && "Unexpected byte size."); + return value; +} + +template +inline void ByteSwapInplace(T& value) +{ + value = ByteSwap(value); +} diff --git a/thirdparty/xenon_utils/memory_mapped_file.cpp b/thirdparty/xenon_utils/memory_mapped_file.cpp new file mode 100644 index 00000000..ba3c5d8a --- /dev/null +++ b/thirdparty/xenon_utils/memory_mapped_file.cpp @@ -0,0 +1,169 @@ +#include "memory_mapped_file.h" + +#if !defined(_WIN32) +# include +# include +# include +# include +#endif + +MemoryMappedFile::MemoryMappedFile() +{ + // Default constructor. +} + +MemoryMappedFile::MemoryMappedFile(const std::filesystem::path &path) +{ + open(path); +} + +MemoryMappedFile::~MemoryMappedFile() +{ + close(); +} + +MemoryMappedFile::MemoryMappedFile(MemoryMappedFile &&other) +{ +#if defined(_WIN32) + fileHandle = other.fileHandle; + fileMappingHandle = other.fileMappingHandle; + fileView = other.fileView; + fileSize = other.fileSize; + + other.fileHandle = nullptr; + other.fileMappingHandle = nullptr; + other.fileView = nullptr; + other.fileSize.QuadPart = 0; +#else + fileHandle = other.fileHandle; + fileView = other.fileView; + fileSize = other.fileSize; + + other.fileHandle = -1; + other.fileView = MAP_FAILED; + other.fileSize = 0; +#endif +} + +bool MemoryMappedFile::open(const std::filesystem::path &path) +{ +#if defined(_WIN32) + fileHandle = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (fileHandle == INVALID_HANDLE_VALUE) + { + fprintf(stderr, "CreateFileW failed with error %lu.\n", GetLastError()); + fileHandle = nullptr; + return false; + } + + if (!GetFileSizeEx(fileHandle, &fileSize)) + { + fprintf(stderr, "GetFileSizeEx failed with error %lu.\n", GetLastError()); + CloseHandle(fileHandle); + fileHandle = nullptr; + return false; + } + + fileMappingHandle = CreateFileMappingW(fileHandle, nullptr, PAGE_READONLY, 0, 0, nullptr); + if (fileMappingHandle == nullptr) + { + fprintf(stderr, "CreateFileMappingW failed with error %lu.\n", GetLastError()); + CloseHandle(fileHandle); + fileHandle = nullptr; + return false; + } + + fileView = MapViewOfFile(fileMappingHandle, FILE_MAP_READ, 0, 0, 0); + if (fileView == nullptr) + { + fprintf(stderr, "MapViewOfFile failed with error %lu.\n", GetLastError()); + CloseHandle(fileMappingHandle); + CloseHandle(fileHandle); + fileMappingHandle = nullptr; + fileHandle = nullptr; + return false; + } + + return true; +#else + fileHandle = ::open(path.c_str(), O_RDONLY); + if (fileHandle == -1) + { + fprintf(stderr, "open for %s failed with error %s.\n", path.c_str(), strerror(errno)); + return false; + } + + fileSize = lseek(fileHandle, 0, SEEK_END); + if (fileSize == (off_t)(-1)) + { + fprintf(stderr, "lseek failed with error %s.\n", strerror(errno)); + ::close(fileHandle); + fileHandle = -1; + return false; + } + + fileView = mmap(nullptr, fileSize, PROT_READ, MAP_PRIVATE, fileHandle, 0); + if (fileView == MAP_FAILED) + { + fprintf(stderr, "mmap failed with error %s.\n", strerror(errno)); + ::close(fileHandle); + fileHandle = -1; + return false; + } + + return true; +#endif +} + +void MemoryMappedFile::close() +{ +#if defined(_WIN32) + if (fileView != nullptr) + { + UnmapViewOfFile(fileView); + } + + if (fileMappingHandle != nullptr) + { + CloseHandle(fileMappingHandle); + } + + if (fileHandle != nullptr) + { + CloseHandle(fileHandle); + } +#else + if (fileView != MAP_FAILED) + { + munmap(fileView, fileSize); + } + + if (fileHandle != -1) + { + ::close(fileHandle); + } +#endif +} + +bool MemoryMappedFile::isOpen() const +{ +#if defined(_WIN32) + return (fileView != nullptr); +#else + return (fileView != MAP_FAILED); +#endif +} + +uint8_t *MemoryMappedFile::data() const +{ + return reinterpret_cast(fileView); +} + +size_t MemoryMappedFile::size() const +{ +#if defined(_WIN32) + return fileSize.QuadPart; +#else + return static_cast(fileSize); +#endif +} diff --git a/thirdparty/xenon_utils/memory_mapped_file.h b/thirdparty/xenon_utils/memory_mapped_file.h new file mode 100644 index 00000000..a3de88b2 --- /dev/null +++ b/thirdparty/xenon_utils/memory_mapped_file.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#if defined(_WIN32) +# include +#else +# include +#endif + +struct MemoryMappedFile { +#if defined(_WIN32) + HANDLE fileHandle = nullptr; + HANDLE fileMappingHandle = nullptr; + LPVOID fileView = nullptr; + LARGE_INTEGER fileSize = {}; +#else + int fileHandle = -1; + void *fileView = MAP_FAILED; + off_t fileSize = 0; +#endif + + MemoryMappedFile(); + MemoryMappedFile(const std::filesystem::path &path); + MemoryMappedFile(MemoryMappedFile &&other); + ~MemoryMappedFile(); + bool open(const std::filesystem::path &path); + void close(); + bool isOpen() const; + uint8_t *data() const; + size_t size() const; +}; diff --git a/thirdparty/xenon_utils/xbox.h b/thirdparty/xenon_utils/xbox.h new file mode 100644 index 00000000..0361b0f4 --- /dev/null +++ b/thirdparty/xenon_utils/xbox.h @@ -0,0 +1,409 @@ +#pragma once +#include +#include +#include +#include +#include "byteswap.h" + +#ifdef _WIN32 + #include +#endif + +// real win32 handles will never use the upper bits unless something goes really wrong +#define CHECK_GUEST_HANDLE(HANDLE) (((HANDLE) & 0x80000000) == 0x80000000) +#define GUEST_HANDLE(HANDLE) ((HANDLE) | 0x80000000) +#define HOST_HANDLE(HANDLE) ((HANDLE) & ~0x80000000) + +// Return true to free the associated memory +typedef bool(*TypeDestructor_t)(void*); + +template +bool DestroyObject(void* obj) +{ + static_cast(obj)->~T(); + return true; +} + +template +struct be +{ + T value; + + be() : value(0) + { + } + + be(const T v) + { + set(v); + } + + static T byteswap(T value) + { + if constexpr (std::is_same_v) + { + const uint64_t swapped = ByteSwap(*reinterpret_cast(&value)); + return *reinterpret_cast(&swapped); + } + else if constexpr (std::is_same_v) + { + const uint32_t swapped = ByteSwap(*reinterpret_cast(&value)); + return *reinterpret_cast(&swapped); + } + else if constexpr (std::is_enum_v) + { + const std::underlying_type_t swapped = ByteSwap(*reinterpret_cast*>(&value)); + return *reinterpret_cast(&swapped); + } + else + { + return ByteSwap(value); + } + } + + void set(const T v) + { + value = byteswap(v); + } + + T get() const + { + return byteswap(value); + } + + be& operator| (T value) + { + set(get() | value); + return *this; + } + + be& operator& (T value) + { + set(get() & value); + return *this; + } + + operator T() const + { + return get(); + } + + be& operator=(T v) + { + set(v); + return *this; + } +}; + +extern "C" void* MmGetHostAddress(uint32_t ptr); +template +struct xpointer +{ + be ptr; + + xpointer() : ptr(0) + { + } + + xpointer(T* p) : ptr(p != nullptr ? (reinterpret_cast(p) - reinterpret_cast(MmGetHostAddress(0))) : 0) + { + } + + T* get() const + { + if (!ptr.value) + { + return nullptr; + } + + return reinterpret_cast(MmGetHostAddress(ptr)); + } + + operator T* () const + { + return get(); + } + + T* operator->() const + { + return get(); + } +}; + +template +struct HostObject +{ + typedef TGuest guest_type; +}; + +struct _XLIST_ENTRY; +typedef _XLIST_ENTRY XLIST_ENTRY; +typedef xpointer PXLIST_ENTRY; + +typedef struct _IMAGE_CE_RUNTIME_FUNCTION +{ + uint32_t BeginAddress; + + union + { + uint32_t Data; + struct + { + uint32_t PrologLength : 8; + uint32_t FunctionLength : 22; + uint32_t ThirtyTwoBit : 1; + uint32_t ExceptionFlag : 1; + }; + }; +} IMAGE_CE_RUNTIME_FUNCTION; + +static_assert(sizeof(IMAGE_CE_RUNTIME_FUNCTION) == 8); + +typedef struct _XLIST_ENTRY +{ + be Flink; + be Blink; +} XLIST_ENTRY; + +typedef struct _XDISPATCHER_HEADER +{ + union + { + struct + { + uint8_t Type; + union + { + uint8_t Abandoned; + uint8_t Absolute; + uint8_t NpxIrql; + uint8_t Signalling; + }; + union + { + uint8_t Size; + uint8_t Hand; + }; + union + { + uint8_t Inserted; + uint8_t DebugActive; + uint8_t DpcActive; + }; + }; + be Lock; + }; + + be SignalState; + XLIST_ENTRY WaitListHead; +} XDISPATCHER_HEADER, * XPDISPATCHER_HEADER; + +// These variables are never accessed in guest code, we can safely use them in little endian +typedef struct _XRTL_CRITICAL_SECTION +{ + XDISPATCHER_HEADER Header; + int32_t LockCount; + int32_t RecursionCount; + uint32_t OwningThread; +} XRTL_CRITICAL_SECTION; + +typedef struct _XANSI_STRING { + be Length; + be MaximumLength; + xpointer Buffer; +} XANSI_STRING; + +typedef struct _XOBJECT_ATTRIBUTES +{ + be RootDirectory; + xpointer Name; + xpointer Attributes; +} XOBJECT_ATTRIBUTES; + +typedef XDISPATCHER_HEADER XKEVENT; + +typedef struct _XIO_STATUS_BLOCK +{ + union { + be Status; + be Pointer; + }; + be Information; +} XIO_STATUS_BLOCK; + +typedef struct _XOVERLAPPED { + be Internal; + be InternalHigh; + be Offset; + be OffsetHigh; + be hEvent; +} XOVERLAPPED; + +// this name is so dumb +typedef struct _XXOVERLAPPED { + union + { + struct + { + be Error; + be Length; + }; + + struct + { + uint32_t InternalLow; + uint32_t InternalHigh; + }; + }; + uint32_t InternalContext; + be hEvent; + be pCompletionRoutine; + be dwCompletionContext; + be dwExtendedError; +} XXOVERLAPPED, *PXXOVERLAPPED; + +static_assert(sizeof(_XXOVERLAPPED) == 0x1C); + +// https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-memorystatus +typedef struct _XMEMORYSTATUS { + be dwLength; + be dwMemoryLoad; + be dwTotalPhys; + be dwAvailPhys; + be dwTotalPageFile; + be dwAvailPageFile; + be dwTotalVirtual; + be dwAvailVirtual; +} XMEMORYSTATUS, * XLPMEMORYSTATUS; + +typedef struct _XVIDEO_MODE +{ + be DisplayWidth; + be DisplayHeight; + be IsInterlaced; + be IsWidescreen; + be IsHighDefinition; + be RefreshRate; + be VideoStandard; + be Unknown4A; + be Unknown01; + be reserved[3]; +} XVIDEO_MODE; + +typedef struct _XKSEMAPHORE +{ + XDISPATCHER_HEADER Header; + be Limit; +} XKSEMAPHORE; + +typedef struct _XUSER_SIGNIN_INFO { + be xuid; + be dwField08; + be SigninState; + be dwField10; + be dwField14; + char Name[16]; +} XUSER_SIGNIN_INFO; + +typedef struct _XTIME_FIELDS +{ + be Year; + be Month; + be Day; + be Hour; + be Minute; + be Second; + be Milliseconds; + be Weekday; +} XTIME_FIELDS, * PXTIME_FIELDS; + +// Content types +#define XCONTENTTYPE_SAVEDATA 1 +#define XCONTENTTYPE_DLC 2 +#define XCONTENTTYPE_RESERVED 3 + +#define XCONTENT_NEW 1 +#define XCONTENT_EXISTING 2 + +#define XCONTENT_MAX_DISPLAYNAME 128 +#define XCONTENT_MAX_FILENAME 42 +#define XCONTENTDEVICE_MAX_NAME 27 + +typedef struct _XCONTENT_DATA +{ + be DeviceID; + be dwContentType; + be szDisplayName[XCONTENT_MAX_DISPLAYNAME]; + char szFileName[XCONTENT_MAX_FILENAME]; +} XCONTENT_DATA, * PXCONTENT_DATA; + +typedef struct _XHOSTCONTENT_DATA : _XCONTENT_DATA +{ + // This is a host exclusive type so we don't care what goes on + std::string szRoot{}; +} XHOSTCONTENT_DATA, *PXHOSTCONTENT_DATA; + + +#define XCONTENTDEVICETYPE_HDD 1 +#define XCONTENTDEVICETYPE_MU 2 + +typedef struct _XDEVICE_DATA +{ + be DeviceID; + be DeviceType; + be ulDeviceBytes; + be ulDeviceFreeBytes; + be wszName[XCONTENTDEVICE_MAX_NAME]; +} XDEVICE_DATA, *PXDEVICE_DATA; + +// Direct reflection of XInput structures + +#define XAMINPUT_DEVTYPE_GAMEPAD 0x01 +#define XAMINPUT_DEVSUBTYPE_GAMEPAD 0x01 + +#define XAMINPUT_GAMEPAD_DPAD_UP 0x0001 +#define XAMINPUT_GAMEPAD_DPAD_DOWN 0x0002 +#define XAMINPUT_GAMEPAD_DPAD_LEFT 0x0004 +#define XAMINPUT_GAMEPAD_DPAD_RIGHT 0x0008 +#define XAMINPUT_GAMEPAD_START 0x0010 +#define XAMINPUT_GAMEPAD_BACK 0x0020 +#define XAMINPUT_GAMEPAD_LEFT_THUMB 0x0040 +#define XAMINPUT_GAMEPAD_RIGHT_THUMB 0x0080 +#define XAMINPUT_GAMEPAD_LEFT_SHOULDER 0x0100 +#define XAMINPUT_GAMEPAD_RIGHT_SHOULDER 0x0200 +#define XAMINPUT_GAMEPAD_A 0x1000 +#define XAMINPUT_GAMEPAD_B 0x2000 +#define XAMINPUT_GAMEPAD_X 0x4000 +#define XAMINPUT_GAMEPAD_Y 0x8000 + +typedef struct _XAMINPUT_GAMEPAD +{ + uint16_t wButtons; + uint8_t bLeftTrigger; + uint8_t bRightTrigger; + int16_t sThumbLX; + int16_t sThumbLY; + int16_t sThumbRX; + int16_t sThumbRY; +} XAMINPUT_GAMEPAD, *PXAMINPUT_GAMEPAD; + +typedef struct _XAMINPUT_VIBRATION +{ + uint16_t wLeftMotorSpeed; + uint16_t wRightMotorSpeed; +} XAMINPUT_VIBRATION, * PXAMINPUT_VIBRATION; + +typedef struct _XAMINPUT_CAPABILITIES +{ + uint8_t Type; + uint8_t SubType; + uint16_t Flags; + XAMINPUT_GAMEPAD Gamepad; + XAMINPUT_VIBRATION Vibration; +} XAMINPUT_CAPABILITIES, * PXAMINPUT_CAPABILITIES; + +typedef struct _XAMINPUT_STATE +{ + uint32_t dwPacketNumber; + XAMINPUT_GAMEPAD Gamepad; +} XAMINPUT_STATE, * PXAMINPUT_STATE; diff --git a/thirdparty/xenon_utils/xex.h b/thirdparty/xenon_utils/xex.h new file mode 100644 index 00000000..363efff2 --- /dev/null +++ b/thirdparty/xenon_utils/xex.h @@ -0,0 +1,267 @@ +#pragma once +#include +#include "xbox.h" + +inline constexpr uint8_t Xex2RetailKey[16] = { 0x20, 0xB1, 0x85, 0xA5, 0x9D, 0x28, 0xFD, 0xC3, 0x40, 0x58, 0x3F, 0xBB, 0x08, 0x96, 0xBF, 0x91 }; +inline constexpr uint8_t AESBlankIV[16] = {}; + +enum Xex2ModuleFlags +{ + XEX_MODULE_MODULE_PATCH = 0x10, + XEX_MODULE_PATCH_FULL = 0x20, + XEX_MODULE_PATCH_DELTA = 0x40, +}; + +enum Xex2HeaderKeys +{ + XEX_HEADER_RESOURCE_INFO = 0x000002FF, + XEX_HEADER_FILE_FORMAT_INFO = 0x000003FF, + XEX_HEADER_DELTA_PATCH_DESCRIPTOR = 0x000005FF, + XEX_HEADER_BASE_REFERENCE = 0x00000405, + XEX_HEADER_BOUNDING_PATH = 0x000080FF, + XEX_HEADER_DEVICE_ID = 0x00008105, + XEX_HEADER_ORIGINAL_BASE_ADDRESS = 0x00010001, + XEX_HEADER_ENTRY_POINT = 0x00010100, + XEX_HEADER_IMAGE_BASE_ADDRESS = 0x00010201, + XEX_HEADER_IMPORT_LIBRARIES = 0x000103FF, + XEX_HEADER_CHECKSUM_TIMESTAMP = 0x00018002, + XEX_HEADER_ENABLED_FOR_CALLCAP = 0x00018102, + XEX_HEADER_ENABLED_FOR_FASTCAP = 0x00018200, + XEX_HEADER_ORIGINAL_PE_NAME = 0x000183FF, + XEX_HEADER_STATIC_LIBRARIES = 0x000200FF, + XEX_HEADER_TLS_INFO = 0x00020104, + XEX_HEADER_DEFAULT_STACK_SIZE = 0x00020200, + XEX_HEADER_DEFAULT_FILESYSTEM_CACHE_SIZE = 0x00020301, + XEX_HEADER_DEFAULT_HEAP_SIZE = 0x00020401, + XEX_HEADER_PAGE_HEAP_SIZE_AND_FLAGS = 0x00028002, + XEX_HEADER_SYSTEM_FLAGS = 0x00030000, + XEX_HEADER_EXECUTION_INFO = 0x00040006, + XEX_HEADER_TITLE_WORKSPACE_SIZE = 0x00040201, + XEX_HEADER_GAME_RATINGS = 0x00040310, + XEX_HEADER_LAN_KEY = 0x00040404, + XEX_HEADER_XBOX360_LOGO = 0x000405FF, + XEX_HEADER_MULTIDISC_MEDIA_IDS = 0x000406FF, + XEX_HEADER_ALTERNATE_TITLE_IDS = 0x000407FF, + XEX_HEADER_ADDITIONAL_TITLE_MEMORY = 0x00040801, + XEX_HEADER_EXPORTS_BY_NAME = 0x00E10402, +}; + +enum Xex2EncryptionType +{ + XEX_ENCRYPTION_NONE = 0, + XEX_ENCRYPTION_NORMAL = 1, +}; + +enum Xex2CompressionType +{ + XEX_COMPRESSION_NONE = 0, + XEX_COMPRESSION_BASIC = 1, + XEX_COMPRESSION_NORMAL = 2, + XEX_COMPRESSION_DELTA = 3, +}; + +enum Xex2SectionType +{ + XEX_SECTION_CODE = 1, + XEX_SECTION_DATA = 2, + XEX_SECTION_READONLY_DATA = 3, +}; + +enum Xex2ThunkTypes +{ + XEX_THUNK_VARIABLE = 0, + XEX_THUNK_FUNCTION = 1, +}; + +struct Xex2OptHeader +{ + be key; + + union + { + be value; + be offset; + }; +}; + +struct Xex2Header +{ + be magic; + be moduleFlags; + be headerSize; + be reserved; + be securityOffset; + be headerCount; +}; + +struct Xex2PageDescriptor +{ + union + { + // Must be endian-swapped before reading the bitfield. + uint32_t beValue; + struct + { + uint32_t info : 4; + uint32_t pageCount : 28; + }; + }; + + char dataDigest[0x14]; +}; + +struct Xex2SecurityInfo +{ + be headerSize; + be imageSize; + char rsaSignature[0x100]; + be unknown; + be imageFlags; + be loadAddress; + char sectionDigest[0x14]; + be importTableCount; + char importTableDigest[0x14]; + char xgd2MediaId[0x10]; + char aesKey[0x10]; + be exportTable; + char headerDigest[0x14]; + be region; + be allowedMediaTypes; + be pageDescriptorCount; +}; + +struct Xex2DeltaPatch +{ + be oldAddress; + be newAddress; + be uncompressedLength; + be compressedLength; + char patchData[1]; +}; + +struct Xex2OptDeltaPatchDescriptor +{ + be size; + be targetVersionValue; + be sourceVersionValue; + uint8_t digestSource[0x14]; + uint8_t imageKeySource[0x10]; + be sizeOfTargetHeaders; + be deltaHeadersSourceOffset; + be deltaHeadersSourceSize; + be deltaHeadersTargetOffset; + be deltaImageSourceOffset; + be deltaImageSourceSize; + be deltaImageTargetOffset; + Xex2DeltaPatch info; +}; + +struct Xex2FileBasicCompressionBlock +{ + be dataSize; + be zeroSize; +}; + +struct Xex2FileBasicCompressionInfo +{ + Xex2FileBasicCompressionBlock firstBlock; +}; + +struct Xex2CompressedBlockInfo +{ + be blockSize; + uint8_t blockHash[20]; +}; + +struct Xex2FileNormalCompressionInfo +{ + be windowSize; + Xex2CompressedBlockInfo firstBlock; +}; + +struct Xex2OptFileFormatInfo +{ + be infoSize; + be encryptionType; + be compressionType; +}; + +struct Xex2ImportHeader +{ + be sizeOfHeader; + be sizeOfStringTable; + be numImports; +}; + +struct Xex2ImportLibrary +{ + be size; + char nextImportDigest[0x14]; + be id; + be version; + be minVersion; + be name; + be numberOfImports; +}; + +struct Xex2ImportDescriptor +{ + be firstThunk; // VA XEX_THUNK_DATA +}; + +struct Xex2ThunkData +{ + union + { + struct + { + uint16_t ordinal : 16; + uint16_t hint : 8; + uint16_t type : 8; + } originalData; + + be ordinal; + be function; + be addressOfData; + + // For easier swapping + uint32_t data; + }; +}; + +struct Xex2ResourceInfo +{ + be sizeOfHeader; + uint8_t resourceID[8]; + be offset; + be sizeOfData; +}; + +inline const void* getOptHeaderPtr(const uint8_t* moduleBytes, uint32_t headerKey) +{ + const Xex2Header* xex2Header = (const Xex2Header*)(moduleBytes); + for (uint32_t i = 0; i < xex2Header->headerCount; i++) + { + const Xex2OptHeader& optHeader = ((const Xex2OptHeader*)(xex2Header + 1))[i]; + if (optHeader.key == headerKey) + { + if((headerKey & 0xFF) == 0) + { + return reinterpret_cast(&optHeader.value); + } + else if ((headerKey & 0xFF) == 1) + { + return reinterpret_cast(&optHeader.value); + } + else + { + return reinterpret_cast(reinterpret_cast(moduleBytes) + optHeader.offset); + } + } + } + + return nullptr; +} + +struct Image; +Image Xex2LoadImage(const uint8_t* data, size_t dataSize); diff --git a/thirdparty/xenon_utils/xex_patcher.cpp b/thirdparty/xenon_utils/xex_patcher.cpp new file mode 100644 index 00000000..8492a5d1 --- /dev/null +++ b/thirdparty/xenon_utils/xex_patcher.cpp @@ -0,0 +1,568 @@ +// Referenced from: https://github.com/xenia-canary/xenia-canary/blob/canary_experimental/src/xenia/cpu/xex_module.cc + +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2023 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xex_patcher.h" +#include "xex.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "memory_mapped_file.h" + +struct mspack_memory_file +{ + mspack_system sys; + void *buffer; + size_t bufferSize; + size_t offset; +}; + +static mspack_memory_file *mspack_memory_open(mspack_system *sys, void *buffer, size_t bufferSize) +{ + assert(bufferSize < INT_MAX); + + if (bufferSize >= INT_MAX) + { + return nullptr; + } + + mspack_memory_file *memoryFile = (mspack_memory_file *)(std::calloc(1, sizeof(mspack_memory_file))); + if (memoryFile == nullptr) + { + return memoryFile; + } + + memoryFile->buffer = buffer; + memoryFile->bufferSize = bufferSize; + memoryFile->offset = 0; + return memoryFile; +} + +static void mspack_memory_close(mspack_memory_file *file) +{ + std::free(file); +} + +static int mspack_memory_read(mspack_file *file, void *buffer, int chars) +{ + mspack_memory_file *memoryFile = (mspack_memory_file *)(file); + const size_t remaining = memoryFile->bufferSize - memoryFile->offset; + const size_t total = std::min(size_t(chars), remaining); + std::memcpy(buffer, (uint8_t *)(memoryFile->buffer) + memoryFile->offset, total); + memoryFile->offset += total; + return int(total); +} + +static int mspack_memory_write(mspack_file *file, void *buffer, int chars) +{ + mspack_memory_file *memoryFile = (mspack_memory_file *)(file); + const size_t remaining = memoryFile->bufferSize - memoryFile->offset; + const size_t total = std::min(size_t(chars), remaining); + std::memcpy((uint8_t *)(memoryFile->buffer) + memoryFile->offset, buffer, total); + memoryFile->offset += total; + return int(total); +} + +static void *mspack_memory_alloc(mspack_system *sys, size_t chars) +{ + return std::calloc(chars, 1); +} + +static void mspack_memory_free(void *ptr) +{ + std::free(ptr); +} + +static void mspack_memory_copy(void *src, void *dest, size_t chars) +{ + std::memcpy(dest, src, chars); +} + +static mspack_system *mspack_memory_sys_create() +{ + auto sys = (mspack_system *)(std::calloc(1, sizeof(mspack_system))); + if (!sys) + { + return nullptr; + } + + sys->read = mspack_memory_read; + sys->write = mspack_memory_write; + sys->alloc = mspack_memory_alloc; + sys->free = mspack_memory_free; + sys->copy = mspack_memory_copy; + return sys; +} + +static void mspack_memory_sys_destroy(struct mspack_system *sys) +{ + free(sys); +} + +#if defined(_WIN32) +inline bool bitScanForward(uint32_t v, uint32_t *outFirstSetIndex) +{ + return _BitScanForward((unsigned long *)(outFirstSetIndex), v) != 0; +} + +inline bool bitScanForward(uint64_t v, uint32_t *outFirstSetIndex) +{ + return _BitScanForward64((unsigned long *)(outFirstSetIndex), v) != 0; +} + +#else +inline bool bitScanForward(uint32_t v, uint32_t *outFirstSetIndex) +{ + int i = ffs(v); + *outFirstSetIndex = i - 1; + return i != 0; +} + +inline bool bitScanForward(uint64_t v, uint32_t *outFirstSetIndex) +{ + int i = __builtin_ffsll(v); + *outFirstSetIndex = i - 1; + return i != 0; +} +#endif + +int lzxDecompress(const void *lzxData, size_t lzxLength, void *dst, size_t dstLength, uint32_t windowSize, void *windowData, size_t windowDataLength) +{ + int resultCode = 1; + uint32_t windowBits; + if (!bitScanForward(windowSize, &windowBits)) { + return resultCode; + } + + mspack_system *sys = mspack_memory_sys_create(); + mspack_memory_file *lzxSrc = mspack_memory_open(sys, (void *)(lzxData), lzxLength); + mspack_memory_file *lzxDst = mspack_memory_open(sys, dst, dstLength); + lzxd_stream *lzxd = lzxd_init(sys, (mspack_file *)(lzxSrc), (mspack_file *)(lzxDst), windowBits, 0, 0x8000, dstLength, 0); + if (lzxd != nullptr) { + if (windowData != nullptr) { + size_t paddingLength = windowSize - windowDataLength; + std::memset(&lzxd->window[0], 0, paddingLength); + std::memcpy(&lzxd->window[paddingLength], windowData, windowDataLength); + lzxd->ref_data_size = windowSize; + } + + resultCode = lzxd_decompress(lzxd, dstLength); + lzxd_free(lzxd); + } + + if (lzxSrc) { + mspack_memory_close(lzxSrc); + } + + if (lzxDst) { + mspack_memory_close(lzxDst); + } + + if (sys) { + mspack_memory_sys_destroy(sys); + } + + return resultCode; +} + +static int lzxDeltaApplyPatch(const Xex2DeltaPatch *deltaPatch, uint32_t patchLength, uint32_t windowSize, uint8_t *dstData) +{ + const void *patchEnd = (const uint8_t *)(deltaPatch) + patchLength; + const Xex2DeltaPatch *curPatch = deltaPatch; + while (patchEnd > curPatch) + { + int patchSize = -4; + if (curPatch->compressedLength == 0 && curPatch->uncompressedLength == 0 && curPatch->newAddress == 0 && curPatch->oldAddress == 0) + { + // End of patch. + break; + } + + switch (curPatch->compressedLength) + { + case 0: + // Set the data to zeroes. + std::memset(&dstData[curPatch->newAddress], 0, curPatch->uncompressedLength); + break; + case 1: + // Move the data. + std::memcpy(&dstData[curPatch->newAddress], &dstData[curPatch->oldAddress], curPatch->uncompressedLength); + break; + default: + // Decompress the data into the destination. + patchSize = curPatch->compressedLength - 4; + int result = lzxDecompress(curPatch->patchData, curPatch->compressedLength, &dstData[curPatch->newAddress], curPatch->uncompressedLength, windowSize, &dstData[curPatch->oldAddress], curPatch->uncompressedLength); + if (result != 0) + { + return result; + } + + break; + } + + curPatch++; + curPatch = (const Xex2DeltaPatch *)((const uint8_t *)(curPatch) + patchSize); + } + + return 0; +} + +XexPatcher::Result XexPatcher::apply(const uint8_t* xexBytes, size_t xexBytesSize, const uint8_t* patchBytes, size_t patchBytesSize, std::vector &outBytes, bool skipData) +{ + // Validate headers. + static const char Xex2Magic[] = "XEX2"; + const Xex2Header *xexHeader = (const Xex2Header *)(xexBytes); + if (memcmp(xexBytes, Xex2Magic, 4) != 0) + { + return Result::XexFileInvalid; + } + + const Xex2Header *patchHeader = (const Xex2Header *)(patchBytes); + if (memcmp(patchBytes, Xex2Magic, 4) != 0) + { + return Result::PatchFileInvalid; + } + + if ((patchHeader->moduleFlags & (XEX_MODULE_MODULE_PATCH | XEX_MODULE_PATCH_DELTA | XEX_MODULE_PATCH_FULL)) == 0) + { + return Result::PatchFileInvalid; + } + + // Validate patch. + const Xex2OptDeltaPatchDescriptor *patchDescriptor = (const Xex2OptDeltaPatchDescriptor *)(getOptHeaderPtr(patchBytes, XEX_HEADER_DELTA_PATCH_DESCRIPTOR)); + if (patchDescriptor == nullptr) + { + return Result::PatchFileInvalid; + } + + const Xex2OptFileFormatInfo *patchFileFormatInfo = (const Xex2OptFileFormatInfo *)(getOptHeaderPtr(patchBytes, XEX_HEADER_FILE_FORMAT_INFO)); + if (patchFileFormatInfo == nullptr) + { + return Result::PatchFileInvalid; + } + + if (patchFileFormatInfo->compressionType != XEX_COMPRESSION_DELTA) + { + return Result::PatchFileInvalid; + } + + if (patchDescriptor->deltaHeadersSourceOffset > xexHeader->headerSize) + { + return Result::PatchIncompatible; + } + + if (patchDescriptor->deltaHeadersSourceSize > (xexHeader->headerSize - patchDescriptor->deltaHeadersSourceOffset)) + { + return Result::PatchIncompatible; + } + + if (patchDescriptor->deltaHeadersTargetOffset > patchDescriptor->sizeOfTargetHeaders) + { + return Result::PatchIncompatible; + } + + uint32_t deltaTargetSize = patchDescriptor->sizeOfTargetHeaders - patchDescriptor->deltaHeadersTargetOffset; + if (patchDescriptor->deltaHeadersSourceSize > deltaTargetSize) + { + return Result::PatchIncompatible; + } + + // Apply patch. + uint32_t headerTargetSize = patchDescriptor->sizeOfTargetHeaders; + if (headerTargetSize == 0) + { + headerTargetSize = patchDescriptor->deltaHeadersTargetOffset + patchDescriptor->deltaHeadersSourceSize; + } + + // Create the bytes for the new XEX header. Copy over the existing data. + uint32_t newXexHeaderSize = std::max(headerTargetSize, xexHeader->headerSize.get()); + outBytes.resize(newXexHeaderSize); + memset(outBytes.data(), 0, newXexHeaderSize); + memcpy(outBytes.data(), xexBytes, headerTargetSize); + + Xex2Header *newXexHeader = (Xex2Header *)(outBytes.data()); + if (patchDescriptor->deltaHeadersSourceOffset > 0) + { + memcpy(&outBytes[patchDescriptor->deltaHeadersTargetOffset], &outBytes[patchDescriptor->deltaHeadersSourceOffset], patchDescriptor->deltaHeadersSourceSize); + } + + int resultCode = lzxDeltaApplyPatch(&patchDescriptor->info, patchDescriptor->size, ((const Xex2FileNormalCompressionInfo*)(patchFileFormatInfo + 1))->windowSize, outBytes.data()); + if (resultCode != 0) + { + return Result::PatchFailed; + } + + // Make the header the specified size by the patch. + outBytes.resize(headerTargetSize); + newXexHeader = (Xex2Header *)(outBytes.data()); + + // Copy the rest of the data. + const Xex2SecurityInfo *newSecurityInfo = (const Xex2SecurityInfo *)(&outBytes[newXexHeader->securityOffset]); + outBytes.resize(outBytes.size() + newSecurityInfo->imageSize); + memset(&outBytes[headerTargetSize], 0, outBytes.size() - headerTargetSize); + memcpy(&outBytes[headerTargetSize], &xexBytes[xexHeader->headerSize], xexBytesSize - xexHeader->headerSize); + newXexHeader = (Xex2Header *)(outBytes.data()); + newSecurityInfo = (const Xex2SecurityInfo *)(&outBytes[newXexHeader->securityOffset]); + + // Decrypt the keys and validate that the patch is compatible with the base file. + constexpr uint32_t KeySize = 16; + const Xex2SecurityInfo *originalSecurityInfo = (const Xex2SecurityInfo *)(&xexBytes[xexHeader->securityOffset]); + const Xex2SecurityInfo *patchSecurityInfo = (const Xex2SecurityInfo *)(&patchBytes[patchHeader->securityOffset]); + uint8_t decryptedOriginalKey[KeySize]; + uint8_t decryptedNewKey[KeySize]; + uint8_t decryptedPatchKey[KeySize]; + uint8_t decrpytedImageKeySource[KeySize]; + memcpy(decryptedOriginalKey, originalSecurityInfo->aesKey, KeySize); + memcpy(decryptedNewKey, newSecurityInfo->aesKey, KeySize); + memcpy(decryptedPatchKey, patchSecurityInfo->aesKey, KeySize); + memcpy(decrpytedImageKeySource, patchDescriptor->imageKeySource, KeySize); + + AES_ctx aesContext; + AES_init_ctx_iv(&aesContext, Xex2RetailKey, AESBlankIV); + AES_CBC_decrypt_buffer(&aesContext, decryptedOriginalKey, KeySize); + + AES_ctx_set_iv(&aesContext, AESBlankIV); + AES_CBC_decrypt_buffer(&aesContext, decryptedNewKey, KeySize); + + AES_init_ctx_iv(&aesContext, decryptedNewKey, AESBlankIV); + AES_CBC_decrypt_buffer(&aesContext, decryptedPatchKey, KeySize); + + AES_ctx_set_iv(&aesContext, AESBlankIV); + AES_CBC_decrypt_buffer(&aesContext, decrpytedImageKeySource, KeySize); + + // Validate the patch's key matches the one from the original XEX. + if (memcmp(decrpytedImageKeySource, decryptedOriginalKey, KeySize) != 0) + { + return Result::PatchIncompatible; + } + + // Don't process the rest of the patch. + if (skipData) + { + return Result::Success; + } + + // Decrypt base XEX if necessary. + const Xex2OptFileFormatInfo *fileFormatInfo = (const Xex2OptFileFormatInfo *)(getOptHeaderPtr(xexBytes, XEX_HEADER_FILE_FORMAT_INFO)); + if (fileFormatInfo == nullptr) + { + return Result::XexFileInvalid; + } + + if (fileFormatInfo->encryptionType == XEX_ENCRYPTION_NORMAL) + { + AES_init_ctx_iv(&aesContext, decryptedOriginalKey, AESBlankIV); + AES_CBC_decrypt_buffer(&aesContext, &outBytes[headerTargetSize], xexBytesSize - xexHeader->headerSize); + } + else if (fileFormatInfo->encryptionType != XEX_ENCRYPTION_NONE) + { + return Result::XexFileInvalid; + } + + // Decompress base XEX if necessary. + if (fileFormatInfo->compressionType == XEX_COMPRESSION_BASIC) + { + const Xex2FileBasicCompressionBlock *blocks = &((const Xex2FileBasicCompressionInfo*)(fileFormatInfo + 1))->firstBlock; + int32_t numBlocks = (fileFormatInfo->infoSize / sizeof(Xex2FileBasicCompressionBlock)) - 1; + int32_t baseCompressedSize = 0; + int32_t baseImageSize = 0; + for (int32_t i = 0; i < numBlocks; i++) { + baseCompressedSize += blocks[i].dataSize; + baseImageSize += blocks[i].dataSize + blocks[i].zeroSize; + } + + if (outBytes.size() < (headerTargetSize + baseImageSize)) + { + return Result::XexFileInvalid; + } + + // Reverse iteration allows to perform this decompression in place. + uint8_t *srcDataCursor = outBytes.data() + headerTargetSize + baseCompressedSize; + uint8_t *outDataCursor = outBytes.data() + headerTargetSize + baseImageSize; + for (int32_t i = numBlocks - 1; i >= 0; i--) + { + outDataCursor -= blocks[i].zeroSize; + memset(outDataCursor, 0, blocks[i].zeroSize); + outDataCursor -= blocks[i].dataSize; + srcDataCursor -= blocks[i].dataSize; + memmove(outDataCursor, srcDataCursor, blocks[i].dataSize); + } + } + else if (fileFormatInfo->compressionType == XEX_COMPRESSION_NORMAL) + { + const Xex2CompressedBlockInfo* blocks = &((const Xex2FileNormalCompressionInfo*)(fileFormatInfo + 1))->firstBlock; + const uint32_t exeLength = xexBytesSize - xexHeader->headerSize.get(); + const uint8_t* exeBuffer = &outBytes[headerTargetSize]; + + auto compressBuffer = std::make_unique(exeLength); + const uint8_t* p = NULL; + uint8_t* d = NULL; + sha1::SHA1 s; + + p = exeBuffer; + d = compressBuffer.get(); + + uint8_t blockCalcedDigest[0x14]; + while (blocks->blockSize) + { + const uint8_t* pNext = p + blocks->blockSize; + const auto* nextBlock = (const Xex2CompressedBlockInfo*)p; + + s.reset(); + s.processBytes(p, blocks->blockSize); + s.finalize(blockCalcedDigest); + + if (memcmp(blockCalcedDigest, blocks->blockHash, 0x14) != 0) + return Result::PatchFailed; + + p += 4; + p += 20; + + while (true) + { + const size_t chunkSize = (p[0] << 8) | p[1]; + p += 2; + + if (!chunkSize) + break; + + memcpy(d, p, chunkSize); + p += chunkSize; + d += chunkSize; + } + + p = pNext; + blocks = nextBlock; + } + + int resultCode = 0; + uint32_t uncompressedSize = originalSecurityInfo->imageSize; + uint8_t* buffer = outBytes.data() + newXexHeaderSize; + + resultCode = lzxDecompress(compressBuffer.get(), d - compressBuffer.get(), buffer, uncompressedSize, ((const Xex2FileNormalCompressionInfo*)(fileFormatInfo + 1))->windowSize, nullptr, 0); + + if (resultCode) + return Result::PatchFailed; + } + else if (fileFormatInfo->compressionType == XEX_COMPRESSION_DELTA) + { + return Result::XexFileUnsupported; + } + else if (fileFormatInfo->compressionType != XEX_COMPRESSION_NONE) + { + return Result::XexFileInvalid; + } + + Xex2OptFileFormatInfo *newFileFormatInfo = (Xex2OptFileFormatInfo *)(getOptHeaderPtr(outBytes.data(), XEX_HEADER_FILE_FORMAT_INFO)); + if (newFileFormatInfo == nullptr) + { + return Result::PatchFailed; + } + + // Update the header to indicate no encryption or compression is used. + newFileFormatInfo->encryptionType = XEX_ENCRYPTION_NONE; + newFileFormatInfo->compressionType = XEX_COMPRESSION_NONE; + + // Copy and decrypt patch data if necessary. + std::vector patchData; + patchData.resize(patchBytesSize - patchHeader->headerSize); + memcpy(patchData.data(), &patchBytes[patchHeader->headerSize], patchData.size()); + + if (patchFileFormatInfo->encryptionType == XEX_ENCRYPTION_NORMAL) + { + AES_init_ctx_iv(&aesContext, decryptedPatchKey, AESBlankIV); + AES_CBC_decrypt_buffer(&aesContext, patchData.data(), patchData.size()); + } + else if (patchFileFormatInfo->encryptionType != XEX_ENCRYPTION_NONE) + { + return Result::PatchFileInvalid; + } + + const Xex2CompressedBlockInfo *currentBlock = &((const Xex2FileNormalCompressionInfo*)(patchFileFormatInfo + 1))->firstBlock; + uint8_t *outExe = &outBytes[newXexHeader->headerSize]; + if (patchDescriptor->deltaImageSourceOffset > 0) + { + memcpy(&outExe[patchDescriptor->deltaImageTargetOffset], &outExe[patchDescriptor->deltaImageSourceOffset], patchDescriptor->deltaImageSourceSize); + } + + static const uint32_t DigestSize = 20; + uint8_t sha1Digest[DigestSize]; + sha1::SHA1 sha1Context; + uint8_t *patchDataCursor = patchData.data(); + while (currentBlock->blockSize > 0) + { + const Xex2CompressedBlockInfo *nextBlock = (const Xex2CompressedBlockInfo *)(patchDataCursor); + + // Hash and validate the block. + sha1Context.reset(); + sha1Context.processBytes(patchDataCursor, currentBlock->blockSize); + sha1Context.finalize(sha1Digest); + if (memcmp(sha1Digest, currentBlock->blockHash, DigestSize) != 0) + { + return Result::PatchFailed; + } + + patchDataCursor += 24; + + // Apply the block's patch data. + uint32_t blockDataSize = currentBlock->blockSize - 24; + if (lzxDeltaApplyPatch((const Xex2DeltaPatch *)(patchDataCursor), blockDataSize, ((const Xex2FileNormalCompressionInfo*)(patchFileFormatInfo + 1))->windowSize, outExe) != 0) + { + return Result::PatchFailed; + } + + patchDataCursor += blockDataSize; + currentBlock = nextBlock; + } + + return Result::Success; +} + +XexPatcher::Result XexPatcher::apply(const std::filesystem::path &baseXexPath, const std::filesystem::path &patchXexPath, const std::filesystem::path &newXexPath) +{ + MemoryMappedFile baseXexFile(baseXexPath); + MemoryMappedFile patchFile(patchXexPath); + if (!baseXexFile.isOpen() || !patchFile.isOpen()) + { + return Result::FileOpenFailed; + } + + std::vector newXexBytes; + Result result = apply(baseXexFile.data(), baseXexFile.size(), patchFile.data(), patchFile.size(), newXexBytes, false); + if (result != Result::Success) + { + return result; + } + + std::ofstream newXexFile(newXexPath, std::ios::binary); + if (!newXexFile.is_open()) + { + return Result::FileOpenFailed; + } + + newXexFile.write((const char *)(newXexBytes.data()), newXexBytes.size()); + newXexFile.close(); + + if (newXexFile.bad()) + { + std::filesystem::remove(newXexPath); + return Result::FileWriteFailed; + } + + return Result::Success; +} diff --git a/thirdparty/xenon_utils/xex_patcher.h b/thirdparty/xenon_utils/xex_patcher.h new file mode 100644 index 00000000..44b2bf01 --- /dev/null +++ b/thirdparty/xenon_utils/xex_patcher.h @@ -0,0 +1,37 @@ +// Referenced from: https://github.com/xenia-canary/xenia-canary/blob/canary_experimental/src/xenia/cpu/xex_module.cc + +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2023 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#pragma once + +#include +#include +#include +#include + +extern int lzxDecompress(const void* lzxData, size_t lzxLength, void* dst, size_t dstLength, uint32_t windowSize, void* windowData, size_t windowDataLength); + +struct XexPatcher +{ + enum class Result { + Success, + FileOpenFailed, + FileWriteFailed, + XexFileUnsupported, + XexFileInvalid, + PatchFileInvalid, + PatchIncompatible, + PatchFailed, + PatchUnsupported + }; + + static Result apply(const uint8_t* xexBytes, size_t xexBytesSize, const uint8_t* patchBytes, size_t patchBytesSize, std::vector &outBytes, bool skipData); + static Result apply(const std::filesystem::path &baseXexPath, const std::filesystem::path &patchXexPath, const std::filesystem::path &newXexPath); +}; From 890c32ba84168ac01b9c623c14d05e7fa5155704 Mon Sep 17 00:00:00 2001 From: Graine25 Date: Wed, 8 Jul 2026 15:31:39 -0700 Subject: [PATCH 04/11] feat: add goliath engine common --- src/goliath_engine/common/crash_handler.cpp | 158 ++++++++++++++++ src/goliath_engine/common/crash_handler.h | 24 +++ src/goliath_engine/common/cvars.cpp | 196 ++++++++++++++++++++ src/goliath_engine/common/cvars.h | 54 ++++++ src/goliath_engine/common/encoding.cpp | 39 ++++ src/goliath_engine/common/encoding.h | 13 ++ src/goliath_engine/common/guest_memory.h | 94 ++++++++++ src/goliath_engine/common/hooks.h | 29 +++ src/goliath_engine/common/logging.h | 20 ++ src/goliath_engine/common/misc.cpp | 11 ++ src/goliath_engine/common/reboot.cpp | 127 +++++++++++++ src/goliath_engine/common/reboot.h | 44 +++++ src/goliath_engine/common/threading.cpp | 80 ++++++++ src/goliath_engine/common/threading.h | 22 +++ 14 files changed, 911 insertions(+) create mode 100644 src/goliath_engine/common/crash_handler.cpp create mode 100644 src/goliath_engine/common/crash_handler.h create mode 100644 src/goliath_engine/common/cvars.cpp create mode 100644 src/goliath_engine/common/cvars.h create mode 100644 src/goliath_engine/common/encoding.cpp create mode 100644 src/goliath_engine/common/encoding.h create mode 100644 src/goliath_engine/common/guest_memory.h create mode 100644 src/goliath_engine/common/hooks.h create mode 100644 src/goliath_engine/common/logging.h create mode 100644 src/goliath_engine/common/misc.cpp create mode 100644 src/goliath_engine/common/reboot.cpp create mode 100644 src/goliath_engine/common/reboot.h create mode 100644 src/goliath_engine/common/threading.cpp create mode 100644 src/goliath_engine/common/threading.h diff --git a/src/goliath_engine/common/crash_handler.cpp b/src/goliath_engine/common/crash_handler.cpp new file mode 100644 index 00000000..79ae2be4 --- /dev/null +++ b/src/goliath_engine/common/crash_handler.cpp @@ -0,0 +1,158 @@ +/** + * @file goliath_engine/common/crash_handler.cpp + * @brief See header. + * + * @copyright Copyright (c) 2026 Tom Clay + * All rights reserved. + * @license BSD 3-Clause License + * See LICENSE file in the project root for full license text. + * @edit Modifications Copyright (c) 2026 Rien Gupta + * All rights reserved. + */ +#include "goliath_engine/common/crash_handler.h" + +#include +#include + +#include +#include +#include +#include + +#include "goliath_engine/common/logging.h" + +// Linker-provided base of THIS module (reblue.exe). Its ADDRESS is the post-ASLR +// load base; using it avoids including Windows.h (project rule) while letting us +// reduce a faulting host PC to an RVA that the .map/.pdb resolves offline. +extern "C" const unsigned char __ImageBase; + +// Exported by kernel32/ntdll. Declared locally to avoid pulling in Windows.h; x64 +// has a single calling convention so __stdcall is a no-op there. +extern "C" __declspec(dllimport) unsigned short __stdcall RtlCaptureStackBackTrace( + unsigned long frames_to_skip, unsigned long frames_to_capture, void** back_trace, + unsigned long* back_trace_hash); + +namespace eot { +namespace { + +// Guest virtual address space is the low 4 GB of the host mapping. +constexpr uint64_t kGuestAddressSpaceSize = 0x100000000ull; + +// Generous upper bound on the reblue.exe image span (it embeds all recompiled guest +// code, so it is large). Used only to decide whether a host address reduces to an +// in-module RVA; oversizing it never misclassifies an in-module address. +constexpr uint64_t kHostImageSpan = 0x20000000ull; // 512 MiB + +uint64_t HostModuleBase() { return reinterpret_cast(&__ImageBase); } + +const char* ExceptionCodeName(rex::arch::Exception::Code code) { + using Code = rex::arch::Exception::Code; + switch (code) { + case Code::kAccessViolation: return "ACCESS_VIOLATION"; + case Code::kIllegalInstruction: return "ILLEGAL_INSTRUCTION"; + default: return "UNKNOWN"; + } +} + +const char* AvOperationName(rex::arch::Exception::AccessViolationOperation op) { + using Op = rex::arch::Exception::AccessViolationOperation; + switch (op) { + case Op::kRead: return "read"; + case Op::kWrite: return "write"; + default: return "unknown"; + } +} + +// Walk the faulting thread's stack (the VEH runs inline on it) and log each return +// address with its reblue.exe-relative RVA. The faulting PC is already logged +// separately; this gives the call chain that led there. The top frames are the +// crash handler itself - scan for the first reblue.exe+RVA below the OS dispatch +// frames to find the faulting caller. +void LogBacktrace(uint64_t base) { + void* frames[32] = {}; + const unsigned short n = RtlCaptureStackBackTrace(0, 32, frames, nullptr); + if (!n) return; + EOT_CRITICAL("backtrace ({} frames; top frames are the crash handler):", n); + for (unsigned short i = 0; i < n; ++i) { + const uint64_t a = reinterpret_cast(frames[i]); + if (a >= base && a - base < kHostImageSpan) { + EOT_CRITICAL(" [{:>2}] {:#018x} (reblue.exe+{:#010x})", i, a, a - base); + } else { + EOT_CRITICAL(" [{:>2}] {:#018x}", i, a); + } + } +} + +// Read the register file straight off the public HostThreadContext members - +// the SDK's GetRegisterName/GetStringFromValue helpers are header-declared but +// not exported by the runtime lib, so format the values here instead. +void LogRegisters(const rex::arch::HostThreadContext* ctx) { + if (!ctx) return; +#if REX_ARCH_AMD64 + static const char* const kNames[16] = { + "rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi", + "r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15"}; + EOT_CRITICAL(" rip = {:#018x} eflags = {:#010x}", ctx->rip, ctx->eflags); + for (int i = 0; i < 16; ++i) { + EOT_CRITICAL(" {:>3} = {:#018x}", kNames[i], ctx->int_registers[i]); + } +#elif REX_ARCH_ARM64 + EOT_CRITICAL(" pc = {:#018x} sp = {:#018x} pstate = {:#010x}", ctx->pc, + ctx->sp, ctx->pstate); + for (int i = 0; i < 31; ++i) { + EOT_CRITICAL(" x{:<2} = {:#018x}", i, ctx->x[i]); + } +#endif +} + +// Chained after the SDK MMIO handler; only sees faults the runtime declined. +// Returns false: never recover, let the OS produce its default termination. +bool CrashHandler(rex::arch::Exception* ex, void* /*data*/) { + // A fault inside the handler must not loop; bail straight to the OS. + static std::atomic_flag s_in_handler = ATOMIC_FLAG_INIT; + if (s_in_handler.test_and_set(std::memory_order_acq_rel)) return false; + + const uint64_t host_base = HostModuleBase(); + + EOT_CRITICAL("================ reblue host crash ================"); + EOT_CRITICAL("build: {}", REXGLUE_BUILD_TITLE); + EOT_CRITICAL("module base: reblue.exe @ {:#018x}", host_base); + EOT_CRITICAL("exception: {} @ host pc {:#018x}", + ExceptionCodeName(ex->code()), ex->pc()); + if (ex->pc() >= host_base && ex->pc() - host_base < kHostImageSpan) { + EOT_CRITICAL("faulting RVA: reblue.exe+{:#010x}", ex->pc() - host_base); + } + + if (ex->code() == rex::arch::Exception::Code::kAccessViolation) { + const uint64_t fa = ex->fault_address(); + EOT_CRITICAL("fault address: {:#018x} ({})", fa, + AvOperationName(ex->access_violation_operation())); + + // Map a guest-range fault back to its guest VA - the actionable triage line + // for a fault inside recompiled code. + auto* rt = rex::Runtime::instance(); + uint8_t* membase = rt ? rt->virtual_membase() : nullptr; + const uint64_t base = reinterpret_cast(membase); + if (membase && fa >= base && fa < base + kGuestAddressSpaceSize) { + EOT_CRITICAL("guest fault VA: {:#010x}", static_cast(fa - base)); + } + } + + EOT_CRITICAL("registers:"); + LogRegisters(ex->thread_context()); + LogBacktrace(host_base); + EOT_CRITICAL("==================================================="); + + // Drain sinks before the OS tears us down; a crash bypasses OnShutdown. + rex::FlushLogging(); + return false; +} + +} // namespace + +void InstallCrashHandler() { + rex::arch::ExceptionHandler::Install(&CrashHandler, nullptr); + EOT_INFO("[crash] last-chance handler installed"); +} + +} // namespace eot diff --git a/src/goliath_engine/common/crash_handler.h b/src/goliath_engine/common/crash_handler.h new file mode 100644 index 00000000..2931588a --- /dev/null +++ b/src/goliath_engine/common/crash_handler.h @@ -0,0 +1,24 @@ +/** + * @file goliath_engine/common/crash_handler.h + * @brief Last-chance host crash logger: dumps the faulting PC, fault address, + * guest VA, and register state to the eot log before the process dies, + * so crashes in the wild are triageable. + * + * @copyright Copyright (c) 2026 Tom Clay + * All rights reserved. + * @license BSD 3-Clause License + * See LICENSE file in the project root for full license text. + * @edit Modifications Copyright (c) 2026 Rien Gupta + * All rights reserved. + */ +#pragma once + +namespace eot { + +// Install the last-chance host exception handler. Call once, after the Runtime +// and logging are up: it chains *after* the SDK's guest-MMIO handler, so it only +// fires on faults the runtime did not resolve (i.e. real crashes), logs them via +// EOT_CRITICAL, flushes the log, and returns control to the OS for termination. +void InstallCrashHandler(); + +} // namespace eot diff --git a/src/goliath_engine/common/cvars.cpp b/src/goliath_engine/common/cvars.cpp new file mode 100644 index 00000000..3935e94a --- /dev/null +++ b/src/goliath_engine/common/cvars.cpp @@ -0,0 +1,196 @@ +/** + * @file goliath_engine/common/cvars.cpp + * @copyright Copyright (c) 2026 Tom Clay + * All rights reserved. + * @license BSD 3-Clause License + * See LICENSE file in the project root for full license text. + * @edit Modifications Copyright (c) 2026 Rien Gupta + * All rights reserved. + */ +#include "goliath_engine/common/cvars.h" + +#include +#include + +REXCVAR_DEFINE_BOOL( + skip_installer, false, "Installer", + "Skip the ReEoT installer gate once a valid install has been prepared. " + "The installer enables this after validation so normal launches go " + "straight to the game."); + +REXCVAR_DEFINE_BOOL(bd_devmode, false, kCvarGroup, + "Enable developer mode: debug menu boot, Mindows config " + "overlay (F11 to toggle), and keyboard bridge for debug " + "input. Off by default in retail."); +REXCVAR_DEFINE_BOOL(bd_dbgprint, false, kCvarGroup, + "Print DbgPrint output to host log"); + +REXCVAR_DEFINE_BOOL(bd_mod_log, false, kCvarGroup, + "Log file accesses to mods/file_access_summary.csv plus " + "per-session detail logs under mods/mod_access_logs/"); +REXCVAR_DEFINE_INT32(bd_mod_log_keep, 10, kCvarGroup, + "Previous-session detail logs to retain at boot (the " + "active session writes one more)"); +REXCVAR_DEFINE_INT32(bd_mod_log_max_mb, 64, kCvarGroup, + "Per-session detail log size cap in MB (0 = unlimited)"); + +REXCVAR_DEFINE_BOOL( + pso_precache, true, kCvarGroup, + "Master switch for the load-time PSO precache (background compiler pool + " + "load-time predictor + boot-time residual replay). Off falls back to lazy " + "draw-time PSO compilation."); + +REXCVAR_DEFINE_INT32( + bd_anisotropy, 16, kCvarGroup, + "Anisotropic texture filtering") + .range(0, 16); + +REXCVAR_DEFINE_INT32( + bd_supersampling, 2, kCvarGroup, + "3D scene supersampling (anti-aliasing) factor, power of two. The scene " + "renders at the output resolution x this factor, then resolves down as a " + "clean integer downsample. 1 = no SSAA, 2/4 = progressively cleaner edges " + "at GPU cost. Scene = output x factor, so a high factor on a high output " + "resolution is large. Only 1/2/4 accepted. Applies live (the scene surface " + "is recreated each frame).") + .range(1, 4) + .validator([](std::string_view v) { + int n = 0; + auto r = std::from_chars(v.data(), v.data() + v.size(), n); + return r.ec == std::errc() && (n == 1 || n == 2 || n == 4); + }); + +REXCVAR_DEFINE_INT32( + bd_shadow_dimension, 4096, kCvarGroup, + "Sun shadow-map resolution in pixels (square, power of two). Sets BOTH the " + "render depth surface (Visual__UnitShadow__vf03) and its resolved " + "sampleable copy (Visual__ShadowMapInfo__vf08) to this dimension so the " + "resolve is 1:1 and the higher resolution reaches the receiver (sharper, " + "less shadow-edge shimmer). 1024 = X360 native; 4096 default. Only " + "512/1024/2048/4096/8192 accepted. Requires restart. The Settings Shadow " + "Quality step sets this together with bd_shadow_distance to hold texel " + "density as coverage grows.") + .range(512, 8192) + .validator([](std::string_view v) { + int n = 0; + auto r = std::from_chars(v.data(), v.data() + v.size(), n); + return r.ec == std::errc() && + (n == 512 || n == 1024 || n == 2048 || n == 4096 || n == 8192); + }) + .lifecycle(rex::cvar::Lifecycle::kRequiresRestart); + +REXCVAR_DEFINE_DOUBLE( + bd_shadow_distance, 2.0, kCvarGroup, + "Sun shadow draw distance: multiplier on the light-frustum coverage scale " + "built in Visual__ShadowMapInfo__vf10. BD's dynamic coverage scale " + "saturates at a half-extent of ~512 world units, so casters beyond that " + "fall outside the shadow map and their shadows pop in as the camera nears. " + "Both frustum modes converge on one projection call, so one multiplier " + "widens both. The receiver PCF tap stride is recompiled to scale by " + "1/distance (SharedConstants.shadowPcfScale) so the wider frustum keeps a " + "constant world-space penumbra instead of combing the shadow edge; pair " + "with bd_shadow_dimension (Settings Shadow Quality couples them) to hold " + "texel density. 1.0 = X360 native. Applies live (density/kernel need the " + "restart-gated dimension to land).") + .range(1.0, 4.0); + +REXCVAR_DEFINE_BOOL( + bd_bloom_mask_clamp, true, kCvarGroup, + "Clamp the bloom bright-mask shaders (bd_pe_ps_brightpass / " + "bd_pe_ps_ms_bright) to [0,1] at the export, restoring the LDR saturation " + "the X360 EDRAM tile applied before the bloom blur. Unclamped, maps with a " + "high BLOOM BriMulti (wc01 ships 50) push mask values of ~59 into the FP16 " + "chain and the gaussian blur spreads them into giant white blobs. false = " + "faithful FP16 (blobby). Read once at shader link; needs restart."); + +REXCVAR_DEFINE_DOUBLE( + bd_dof_strength, 1.0, kCvarGroup, + "Scale for the depth-of-field blur. mcl_dof_filter_vf00 derives the DOF " + "circle-of-confusion from composite pixel-shader constant c27.y (blur " + "scale); the naive full-image blur pyramid it drives bleeds the in-focus " + "foreground into the blurred sky, ringing character/geometry silhouettes " + "with a dark halo against clear sky. This multiplies c27.y on the DOF " + "setup flush, shrinking the blur radius (the shader squares c27.y and " + "scales by 711, so the visible blur falls off faster than the value). 1.0 " + "= faithful, ~0.5 = softer halo, 0 = DOF off. Read live each flush."); + +REXCVAR_DEFINE_INT32( + bd_render_resolution, 0, kCvarGroup, + "Internal render resolution. 0 = Display: BD renders its whole pipeline at " + "the output (window / fullscreen) resolution so the present is 1:1. 1 = " + "720p: BD's native 1280x720 composite, upscaled to the output. Requires " + "restart (BD builds its composite chain once at init).") + .range(0, 1) + .lifecycle(rex::cvar::Lifecycle::kRequiresRestart); + +REXCVAR_DEFINE_INT32( + bd_aspect_ratio, 0, kCvarGroup, + "Output aspect ratio. 0 = 16:9 (native; letterbox/pillarbox bars on a " + "non-16:9 display), 1 = 4:3, 2 = Stretch (fill the output, may distort). " + "Applies live.") + .range(0, 2); + +REXCVAR_DEFINE_STRING( + bd_language, "auto", kCvarGroup, + "UI/voice language, from the game's Language Set (bd_boot.ini). 'auto' " + "follows the Windows display language. Otherwise: us English, jp Japanese, " + "de German, fr French, es Spanish, it Italian, kr Korean, tw Chinese " + "(Traditional), cn Chinese (Simplified), po Portuguese. Which languages are " + "shown depends on the installed assets (bd_boot.ini [Language]). Read once " + "at guest boot, so a change takes effect after restart.") + .lifecycle(rex::cvar::Lifecycle::kRequiresRestart); + +REXCVAR_DEFINE_INT32( + bd_fps_limit, 60, kCvarGroup, + "Frame rate cap; 0 = unlimited. Above 30 (or unlimited) the fixed 30Hz " + "simulation is decoupled from rendering and the camera and animated objects " + "are interpolated, so the game renders smoothly above 30fps at correct speed. " + "30 or below = native coupled behavior."); + +REXCVAR_DEFINE_BOOL( + bd_vsync, true, kCvarGroup, "Vertical sync"); + +REXCVAR_DEFINE_INT32( + bd_geometry_heap, 0, kCvarGroup, + "Heap placement for guest geometry mirrors (VB/IB). 0 = auto (default): " + "load-time-static geometry (physical arenas, quadlist IB) in device-local " + "GPU_UPLOAD when supported, per-unlock dynamic buffers in UPLOAD sysmem - " + "avoids both AMD floors (GPU re-reading static geometry over PCIe every " + "pass, and the CPU byte-swapping into write-combined VRAM every unlock). " + "1 = force UPLOAD for everything; 2 = force GPU_UPLOAD for everything " + "(A/B diagnostics). Applies to buffers created after the change: restart " + "to fully apply.") + .range(0, 2); + +REXCVAR_DEFINE_INT32( + bd_diag_verbosity, 0, kCvarGroup, + "Render diagnostic logging verbosity. 0 = silent (default); 1 = " + "resolve/draw fallback and dropped-draw diagnostics (the rate-limited " + "[resolve-diag]/[draw-diag] messages); 2 = also per-frame telemetry such as " + "[occlusion] readback counts for tuning light coronas. Live-tunable.") + .range(0, 2); + +REXCVAR_DEFINE_INT32( + bd_force_constant_upload, 0, kCvarGroup, + "Diagnostic: force shader-constant re-upload every draw, bypassing the " + "dirty-flag gate. Bit0 (1) = VS float constants, bit1 (2) = PS float " + "constants, 3 = both; 0 = off (default). Isolates stale-CBV bugs where BD " + "writes a constant inline (e.g. a bone/world matrix) without a hooked " + "Set*ShaderConstantF, so reblue keeps a previous draw's constants. If a draw " + "renders correctly only with this on, an inline constant write is being " + "missed. Live-tunable.") + .range(0, 3); + +REXCVAR_DEFINE_INT32( + bd_diag_stale_constants, 0, kCvarGroup, + "Diagnostic (read-only): watch the WHOLE VS float-constant block (all 256 " + "registers, device+0x700) for inline writes that bypass the dirty flag. On " + "each draw that reuses the bound VS constant CBV, compare the guest's live " + "block against what was last uploaded; a mismatch means BD wrote a constant " + "without a hooked SetVertexShaderConstantF, so the draw renders with stale " + "(previous draw's) constants - the random geometry-offset bug. Logs the " + "first differing register (c20-c23 = the world matrix), its before/after " + "values and the VS hash (rate-limited). ~4KB compare per reuse draw, " + "cache-hot (~0.5-1% worst case). Does NOT alter rendering. 1 = on " + "(default); 0 = off. Live-tunable.") + .range(0, 1); diff --git a/src/goliath_engine/common/cvars.h b/src/goliath_engine/common/cvars.h new file mode 100644 index 00000000..176efd7f --- /dev/null +++ b/src/goliath_engine/common/cvars.h @@ -0,0 +1,54 @@ +/** + * @file goliath_engine/common/cvars.h + * @copyright Copyright (c) 2026 Tom Clay + * All rights reserved. + * @license BSD 3-Clause License + * See LICENSE file in the project root for full license text. + * @edit Modifications Copyright (c) 2026 Rien Gupta + * All rights reserved. + */ +#pragma once + +#include +#include + +#include + +inline constexpr char kCvarGroup[] = "Blue Dragon"; + +// Names also consumed as raw strings (settings UIs, change-callback). Use these +// instead of string literals so a rename is a compile error. +inline constexpr char kCvDevmode[] = "bd_devmode"; +inline constexpr char kCvAspectRatio[] = "bd_aspect_ratio"; +inline constexpr char kCvRenderResolution[] = "bd_render_resolution"; +inline constexpr char kCvFpsLimit[] = "bd_fps_limit"; +inline constexpr char kCvVsync[] = "bd_vsync"; +inline constexpr char kCvSupersampling[] = "bd_supersampling"; +inline constexpr char kCvAnisotropy[] = "bd_anisotropy"; +inline constexpr char kCvShadowDimension[] = "bd_shadow_dimension"; +inline constexpr char kCvShadowDistance[] = "bd_shadow_distance"; +inline constexpr char kCvSkipInstaller[] = "skip_installer"; +inline constexpr char kCvEotDataRoot[] = "eot_data_root"; + +REXCVAR_DECLARE(bool, skip_installer); +REXCVAR_DECLARE(bool, bd_devmode); +REXCVAR_DECLARE(bool, bd_dbgprint); +REXCVAR_DECLARE(bool, bd_mod_log); +REXCVAR_DECLARE(int32_t, bd_mod_log_keep); +REXCVAR_DECLARE(int32_t, bd_mod_log_max_mb); +REXCVAR_DECLARE(bool, pso_precache); +REXCVAR_DECLARE(int32_t, bd_anisotropy); +REXCVAR_DECLARE(int32_t, bd_supersampling); +REXCVAR_DECLARE(int32_t, bd_shadow_dimension); +REXCVAR_DECLARE(double, bd_shadow_distance); +REXCVAR_DECLARE(bool, bd_bloom_mask_clamp); +REXCVAR_DECLARE(double, bd_dof_strength); +REXCVAR_DECLARE(int32_t, bd_render_resolution); +REXCVAR_DECLARE(int32_t, bd_aspect_ratio); +REXCVAR_DECLARE(std::string, bd_language); +REXCVAR_DECLARE(int32_t, bd_fps_limit); +REXCVAR_DECLARE(bool, bd_vsync); +REXCVAR_DECLARE(int32_t, bd_geometry_heap); +REXCVAR_DECLARE(int32_t, bd_diag_verbosity); +REXCVAR_DECLARE(int32_t, bd_force_constant_upload); +REXCVAR_DECLARE(int32_t, bd_diag_stale_constants); diff --git a/src/goliath_engine/common/encoding.cpp b/src/goliath_engine/common/encoding.cpp new file mode 100644 index 00000000..85313948 --- /dev/null +++ b/src/goliath_engine/common/encoding.cpp @@ -0,0 +1,39 @@ +/** + * @file goliath_engine/common/encoding.cpp + * @license BSD 3-Clause, see LICENSE + */ +#include "goliath_engine/common/encoding.h" +#include +#if REX_PLATFORM_WIN32 +#define WIN32_LEAN_AND_MEAN +#include +namespace eot { +std::wstring Utf8ToWide(std::string_view s) { + if (s.empty()) return {}; + int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0); + std::wstring o((size_t)n, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), o.data(), n); + return o; +} +std::string WideToUtf8(std::wstring_view s) { + if (s.empty()) return {}; + int n = WideCharToMultiByte(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0, nullptr, nullptr); + std::string o((size_t)n, '\0'); + WideCharToMultiByte(CP_UTF8, 0, s.data(), (int)s.size(), o.data(), n, nullptr, nullptr); + return o; +} +std::string SjisToUtf8(std::string_view s) { + if (s.empty()) return {}; + int n = MultiByteToWideChar(932, 0, s.data(), (int)s.size(), nullptr, 0); + if (n <= 0) return std::string(s); + std::wstring w((size_t)n, L'\0'); + MultiByteToWideChar(932, 0, s.data(), (int)s.size(), w.data(), n); + return WideToUtf8(w); +} +std::string U16ToUtf8(std::u16string_view s) { + if (s.empty()) return {}; + static_assert(sizeof(wchar_t) == sizeof(char16_t)); + return WideToUtf8(std::wstring_view(reinterpret_cast(s.data()), s.size())); +} +} // namespace eot +#endif diff --git a/src/goliath_engine/common/encoding.h b/src/goliath_engine/common/encoding.h new file mode 100644 index 00000000..e915edd4 --- /dev/null +++ b/src/goliath_engine/common/encoding.h @@ -0,0 +1,13 @@ +/** + * @file goliath_engine/common/encoding.h + * @license BSD 3-Clause, see LICENSE + */ +#pragma once +#include +#include +namespace eot { +std::wstring Utf8ToWide(std::string_view s); +std::string WideToUtf8(std::wstring_view s); +std::string SjisToUtf8(std::string_view s); +std::string U16ToUtf8(std::u16string_view s); +} diff --git a/src/goliath_engine/common/guest_memory.h b/src/goliath_engine/common/guest_memory.h new file mode 100644 index 00000000..c54796de --- /dev/null +++ b/src/goliath_engine/common/guest_memory.h @@ -0,0 +1,94 @@ +/** + * @file goliath_engine/common/guest_memory.h + * @license BSD 3-Clause, see LICENSE + */ +#pragma once + +#include +#if __has_include() +#include +#define EOT_REX_HAS_KERNEL_NAMESPACE 1 +#else +#include +#define EOT_REX_HAS_KERNEL_NAMESPACE 0 +#endif + +namespace eot::guest { + +using Address = rex::guest_addr_t; + +using U8 = rex::be_u8; +using U16 = rex::be_u16; +using U32 = rex::be_u32; +using U64 = rex::be_u64; +using I8 = rex::be_i8; +using I16 = rex::be_i16; +using I32 = rex::be_i32; +using I64 = rex::be_i64; +using F32 = rex::be_f32; +using F64 = rex::be_f64; + +namespace detail { + +inline rex::memory::Memory* Memory() { +#if EOT_REX_HAS_KERNEL_NAMESPACE + auto* kernel_state = rex::kernel::kernel_state(); +#else + auto* kernel_state = REX_KERNEL_STATE(); +#endif + return kernel_state ? kernel_state->memory() : nullptr; +} + +} // namespace detail + +inline rex::memory::Memory* Memory() { return detail::Memory(); } + +inline rex::u8* Base() { + auto* memory = Memory(); + return memory ? memory->virtual_membase() : nullptr; +} + +template +inline T ToHost(Address address) { + auto* memory = Memory(); + return (memory && address) ? memory->TranslateVirtual(address) : nullptr; +} + +template +inline T Read(Address address, T fallback = {}) { + const auto* value = ToHost*>(address); + return value ? static_cast(*value) : fallback; +} + +template +inline void Write(Address address, T value) { + if (auto* dst = ToHost*>(address)) { + *dst = value; + } +} + +inline void OrU32(Address address, rex::u32 mask) { + Write(address, Read(address) | mask); +} + +} // namespace eot::guest + +namespace eot { + +inline rex::u8* GuestBase() { return guest::Base(); } + +// Mark an X360 task dead (flags |= 0xDEAD0000 at +0x58, destroyFlag=1 at +0x60). +inline void KillGuestTask(guest::Address task_address) { + if (!task_address) return; + guest::Write(task_address + 0x60, 1u); + guest::OrU32(task_address + 0x58, 0xDEAD0000u); +} + +inline void KillGuestTask(rex::u8* base, guest::Address task_address) { + (void)base; + KillGuestTask(task_address); +} + +} // namespace eot + +#undef EOT_REX_HAS_KERNEL_NAMESPACE diff --git a/src/goliath_engine/common/hooks.h b/src/goliath_engine/common/hooks.h new file mode 100644 index 00000000..4527d5a5 --- /dev/null +++ b/src/goliath_engine/common/hooks.h @@ -0,0 +1,29 @@ +/** + * @file goliath_engine/common/hooks.h + * @brief Macros for marking recompiled functions as intentional no-ops. + * + * @copyright Copyright (c) 2026 Tom Clay + * All rights reserved. + * @license BSD 3-Clause License + * See LICENSE file in the project root for full license text. + * @edit Modifications Copyright (c) 2026 Rien Gupta + * All rights reserved. + */ +#pragma once + +#include + +// Intentional no-op with no log output, for calls the host need not model. +// (REX_STUB warns on every call; use it for unimplemented placeholders.) +#define EOT_NOOP(subroutine) \ + extern "C" REX_FUNC(subroutine) { \ + (void)ctx; \ + (void)base; \ + } + +// EOT_NOOP that returns a constant u32 in r3, for callers comparing the result. +#define EOT_NOOP_RETURN(subroutine, value) \ + extern "C" REX_FUNC(subroutine) { \ + (void)base; \ + ctx.r3.u64 = (value); \ + } diff --git a/src/goliath_engine/common/logging.h b/src/goliath_engine/common/logging.h new file mode 100644 index 00000000..e88b0327 --- /dev/null +++ b/src/goliath_engine/common/logging.h @@ -0,0 +1,20 @@ +/** + * @file goliath_engine/common/logging.h + * @brief EOT_* logging macros bound to the eot log category. + * + * @copyright Copyright (c) 2026 Tom Clay + * All rights reserved. + * @license BSD 3-Clause License + * See LICENSE file in the project root for full license text. + */ +#pragma once +#include + +REXLOG_DEFINE_CATEGORY(eot) + +#define EOT_TRACE(...) REXLOG_CAT_TRACE(::rex::log::eot(), __VA_ARGS__) +#define EOT_DEBUG(...) REXLOG_CAT_DEBUG(::rex::log::eot(), __VA_ARGS__) +#define EOT_INFO(...) REXLOG_CAT_INFO(::rex::log::eot(), __VA_ARGS__) +#define EOT_WARN(...) REXLOG_CAT_WARN(::rex::log::eot(), __VA_ARGS__) +#define EOT_ERROR(...) REXLOG_CAT_ERROR(::rex::log::eot(), __VA_ARGS__) +#define EOT_CRITICAL(...) REXLOG_CAT_CRITICAL(::rex::log::eot(), __VA_ARGS__) diff --git a/src/goliath_engine/common/misc.cpp b/src/goliath_engine/common/misc.cpp new file mode 100644 index 00000000..5c22461e --- /dev/null +++ b/src/goliath_engine/common/misc.cpp @@ -0,0 +1,11 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +REXCVAR_DEFINE_STRING(eot_data_root, "", "EdgeOfTime/config", + "Path to game asset directory. Overrides the default data root when non-empty."); diff --git a/src/goliath_engine/common/reboot.cpp b/src/goliath_engine/common/reboot.cpp new file mode 100644 index 00000000..5e42d6e0 --- /dev/null +++ b/src/goliath_engine/common/reboot.cpp @@ -0,0 +1,127 @@ +/** + * @file goliath_engine/common/reboot.cpp + * @brief Warm reboot: persist settings, quiesce the guest, relaunch the exe. + * + * @copyright Copyright (c) 2026 Tom Clay + * All rights reserved. + * @license BSD 3-Clause License + * See LICENSE file in the project root for full license text. + * @edit Modifications Copyright (c) 2026 Rien Gupta + * All rights reserved. + */ +#include "goliath_engine/common/reboot.h" +#include "goliath_engine/common/logging.h" + +#include +#include +#include +#include +#include + +#include +#include + +#define WIN32_LEAN_AND_MEAN +#include + +namespace eot { +namespace { + +std::function g_handler; +std::mutex g_handler_mutex; +std::atomic g_requested{false}; +std::string g_active_profile; +std::filesystem::path g_config_path; + +std::filesystem::path ExecutablePath() { + wchar_t buf[MAX_PATH]; + DWORD n = ::GetModuleFileNameW(nullptr, buf, MAX_PATH); + return std::filesystem::path(buf, buf + n); +} + +bool SpawnFreshInstance(const std::filesystem::path &exe) { + std::wstring cmdline = L"\"" + exe.wstring() + L"\""; + if (!g_active_profile.empty()) { + std::wstring wprofile(g_active_profile.begin(), g_active_profile.end()); + cmdline += L" --profile \"" + wprofile + L"\""; + } + std::wstring workdir = exe.parent_path().wstring(); + + STARTUPINFOW si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + + if (!::CreateProcessW(exe.c_str(), cmdline.data(), nullptr, nullptr, FALSE, 0, + nullptr, workdir.c_str(), &si, &pi)) { + EOT_ERROR("[reboot] CreateProcessW failed (err {})", ::GetLastError()); + return false; + } + ::CloseHandle(pi.hThread); + ::CloseHandle(pi.hProcess); + return true; +} + +} // namespace + +std::filesystem::path ConfigFilePath() { + if (!g_config_path.empty()) return g_config_path; + return ExecutablePath().parent_path() / "reblue.toml"; +} + +void SetProfileContext(std::string profile_name, + std::filesystem::path config_path) { + g_active_profile = std::move(profile_name); + g_config_path = std::move(config_path); +} + +void SetWarmRebootHandler(std::function handler) { + std::lock_guard lk(g_handler_mutex); + g_handler = std::move(handler); +} + +void RequestWarmReboot() { + bool expected = false; + if (!g_requested.compare_exchange_strong(expected, true)) + return; + + std::function handler; + { + std::lock_guard lk(g_handler_mutex); + handler = g_handler; + } + if (!handler) { + EOT_ERROR("[reboot] no handler registered; cannot relaunch"); + g_requested.store(false); + return; + } + EOT_WARN("[reboot] warm reboot requested"); + handler(); +} + +void PerformWarmReboot(const std::function &quiesce) { + auto exe = ExecutablePath(); + + // 1. Settings are not auto-persisted; write them before relaunch. + rex::cvar::SaveConfig(ConfigFilePath()); + + // 2. Spawn first: if it fails, stay in the running session rather than kill + // it with no replacement. + if (!SpawnFreshInstance(exe)) { + EOT_ERROR("[reboot] relaunch failed; staying in current session"); + g_requested.store(false); + return; + } + + // 3. Cooperatively stop guest threads so none is mid-I/O (no force-kill). + if (quiesce) + quiesce(); + + // 4. Flush the writers we keep; no subsystem teardown (it would deadlock on a + // host lock a straggler still holds). The OS reclaims the rest. + rex::FlushLogging(); + + // 5. Exit without running destructors. + std::_Exit(0); +} + +} // namespace eot diff --git a/src/goliath_engine/common/reboot.h b/src/goliath_engine/common/reboot.h new file mode 100644 index 00000000..e6d0e40b --- /dev/null +++ b/src/goliath_engine/common/reboot.h @@ -0,0 +1,44 @@ +/** + * @file bdengine/common/reboot.h + * @brief Clean warm reboot (host process relaunch) for applying restart-bound + * settings and DLC changes. + * + * @copyright Copyright (c) 2026 Tom Clay + * All rights reserved. + * @license BSD 3-Clause License + * See LICENSE file in the project root for full license text. + * @edit Modifications Copyright (c) 2026 Rien Gupta + * All rights reserved. + */ +#pragma once + +#include +#include +#include + +namespace eot { + +// Where cvar settings are persisted: the active profile's reblue.toml once set +// via SetProfileContext, else /reblue.toml (pre-profile fallback). +std::filesystem::path ConfigFilePath(); + +// Record the active profile so warm reboot persists to the profile's config and +// re-passes --profile to the fresh instance (SpawnFreshInstance forwards no +// other args). Call once during path setup. +void SetProfileContext(std::string profile_name, + std::filesystem::path config_path); + +// Registered once by the host app. Marshals the relaunch onto the UI thread, +// where the kernel state is reachable (as in ReXApp::OnClosing). +void SetWarmRebootHandler(std::function handler); + +// Request a clean warm reboot. Safe to call from a guest thread; idempotent. +// Invokes the registered host handler, which relaunches the process. +void RequestWarmReboot(); + +// Persist cvars, quiesce the guest (via `quiesce`), flush logs, spawn a fresh +// instance, and exit this one. UI thread only. Returns only if the relaunch +// could not be started (current session is left untouched). +void PerformWarmReboot(const std::function &quiesce); + +} // namespace eot diff --git a/src/goliath_engine/common/threading.cpp b/src/goliath_engine/common/threading.cpp new file mode 100644 index 00000000..024d3352 --- /dev/null +++ b/src/goliath_engine/common/threading.cpp @@ -0,0 +1,80 @@ +/** + * @file goliath_engine/common/threading.cpp + * @brief Native threading, sleep, and frame timing hooks. + * + * @copyright Copyright (c) 2026 Tom Clay + * All rights reserved. + * @license BSD 3-Clause License + * See LICENSE file in the project root for full license text. + * @edit Modifications Copyright (c) 2026 Rien Gupta + * All rights reserved. + */ + + +#include "goliath_engine/common/threading.h" + +#include +#include +#include +#include + +#include "goliath_engine/common/logging.h" + +#include +#include +#include +#define WIN32_LEAN_AND_MEAN +#include +#include + +namespace { +std::once_flag g_timer_init; +} + +namespace eot { + +void EnableHighResTimer() { + std::call_once(g_timer_init, [] { + timeBeginPeriod(1); + }); +} + +void DisableHighResTimer() { + timeEndPeriod(1); +} + +void TerminateProcessNow() { + ::TerminateProcess(::GetCurrentProcess(), 0); + // TerminateProcess can return before the caller halts; spin so nothing runs past here. + for (;;) { + _mm_pause(); + } +} + +} // namespace eot + +// Sleep() - PPC kernel bypass. +u32 Sleep_hook(u32 ms) { + eot::EnableHighResTimer(); + + if (ms == 0) { + SwitchToThread(); + return 0; + } + + auto target = std::chrono::steady_clock::now() + + std::chrono::milliseconds(u32(ms)); + + if (ms >= 2) { + std::this_thread::sleep_for( + std::chrono::milliseconds(u32(ms)) - std::chrono::microseconds(1500)); + } else { + SwitchToThread(); + } + + while (std::chrono::steady_clock::now() < target) + _mm_pause(); + + return 0; +} +REX_HOOK(eot_Sleep, Sleep_hook); diff --git a/src/goliath_engine/common/threading.h b/src/goliath_engine/common/threading.h new file mode 100644 index 00000000..fdfc079e --- /dev/null +++ b/src/goliath_engine/common/threading.h @@ -0,0 +1,22 @@ +/** + * @file goliath_engine/common/threading.h + * @brief Native threading, sleep, and frame timing hooks. + * + * @copyright Copyright (c) 2026 Tom Clay + * All rights reserved. + * @license BSD 3-Clause License + * See LICENSE file in the project root for full license text. + * @edit Modifications Copyright (c) 2026 Rien Gupta + * All rights reserved. + */ + #pragma once + +namespace eot { + +void EnableHighResTimer(); +void DisableHighResTimer(); + +// TerminateProcess and spin; never returns, runs no destructors (not a clean exit). +[[noreturn]] void TerminateProcessNow(); + +} // namespace eot From feab5e4ea451d78826d06f50e8773b5dead3d58f Mon Sep 17 00:00:00 2001 From: Graine25 Date: Wed, 8 Jul 2026 15:31:42 -0700 Subject: [PATCH 05/11] feat: add patched manifests --- reeot_default_xex.toml | 1 + reeot_gamelogic.toml | 3 +++ reeot_manifest.toml | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/reeot_default_xex.toml b/reeot_default_xex.toml index 262aaa75..a54456cb 100644 --- a/reeot_default_xex.toml +++ b/reeot_default_xex.toml @@ -8,6 +8,7 @@ project_name = "reeot" file_path = "assets/Default.xex" patch_file_path = "assets/Update/Default.xexp" +patched_file_path = "assets/Default_Patched.xex" out_directory_path = "generated/default" [rexcrt] diff --git a/reeot_gamelogic.toml b/reeot_gamelogic.toml index 7d69e59a..bd4ec35e 100644 --- a/reeot_gamelogic.toml +++ b/reeot_gamelogic.toml @@ -7,6 +7,7 @@ project_name = "reeot" file_path = "assets/Data/GameLogic.dll" patch_file_path = "assets/Update/Data/GameLogic.dllp" +patched_file_path = "assets/Data/GameLogic_Patched.dll" out_directory_path = "generated/gamelogic" is_dll = true @@ -34,6 +35,8 @@ is_dll = true 0x882F3338 = { size = 0x10} 0x88260CC8 = {} 0x88120340 = {} +0x882F3388 = {} +0x8821FB30 = {} # IDA Generated 0x880A0040 = { name = "eot_GameLogic_0001", size = 0x6EF0 } diff --git a/reeot_manifest.toml b/reeot_manifest.toml index 8548ff25..1a3c088a 100644 --- a/reeot_manifest.toml +++ b/reeot_manifest.toml @@ -8,7 +8,7 @@ game_root = "assets" [entrypoint] file_path = "assets/Default.xex" patch_file_path = "assets/Update/Default.xexp" -patched_file_path = "assets/Default_patched.xex" +patched_file_path = "assets/Default_Patched.xex" out_directory_path = "generated/default" includes = ["reeot_default_xex.toml"] @@ -16,6 +16,6 @@ includes = ["reeot_default_xex.toml"] guest_path = "Data/GameLogic.dll" file_path = "assets/Data/GameLogic.dll" patch_file_path = "assets/Update/Data/GameLogic.dllp" -patched_file_path = "assets/Data/GameLogic_patched.dll" +patched_file_path = "assets/Data/GameLogic_Patched.dll" out_directory_path = "generated/gamelogic" includes = ["reeot_gamelogic.toml"] From a72e0c767a97d341be563310e43f32389f8936fc Mon Sep 17 00:00:00 2001 From: Graine25 Date: Wed, 8 Jul 2026 15:31:50 -0700 Subject: [PATCH 06/11] feat: add installer core --- .../installer/install_constants.h | 50 ++ .../installer/install_manifest.cpp | 99 ++++ .../installer/install_manifest.h | 20 + .../installer/install_paths.cpp | 150 +++++ src/goliath_engine/installer/install_paths.h | 32 + src/goliath_engine/installer/installer.cpp | 255 ++++++++ src/goliath_engine/installer/installer.h | 31 + src/goliath_engine/installer/source_vfs.cpp | 555 ++++++++++++++++++ src/goliath_engine/installer/source_vfs.h | 37 ++ 9 files changed, 1229 insertions(+) create mode 100644 src/goliath_engine/installer/install_constants.h create mode 100644 src/goliath_engine/installer/install_manifest.cpp create mode 100644 src/goliath_engine/installer/install_manifest.h create mode 100644 src/goliath_engine/installer/install_paths.cpp create mode 100644 src/goliath_engine/installer/install_paths.h create mode 100644 src/goliath_engine/installer/installer.cpp create mode 100644 src/goliath_engine/installer/installer.h create mode 100644 src/goliath_engine/installer/source_vfs.cpp create mode 100644 src/goliath_engine/installer/source_vfs.h diff --git a/src/goliath_engine/installer/install_constants.h b/src/goliath_engine/installer/install_constants.h new file mode 100644 index 00000000..2cb14f7b --- /dev/null +++ b/src/goliath_engine/installer/install_constants.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +namespace eot::installer { + +inline constexpr char kInstallerUserDirectory[] = "EdgeOfTimeRecomp"; +inline constexpr char kLegacyInstallerUserDirectory[] = "EdgeOfTimeRecompiled"; + +inline constexpr char kEntrypointXex[] = "Default.xex"; +inline constexpr char kGameLogicDll[] = "Data/GameLogic.dll"; +inline constexpr char kEntrypointPatch[] = "Default.xexp"; +inline constexpr char kGameLogicPatch[] = "Data/GameLogic.dllp"; +inline constexpr char kPatchedEntrypointXex[] = "Default_Patched.xex"; +inline constexpr char kPatchedGameLogicDll[] = "Data/GameLogic_Patched.dll"; + +inline constexpr char kMountedPatchedEntrypointXex[] = + "game:\\patched\\Default_Patched.xex"; +inline constexpr char kMountedLocalPatchedEntrypointXex[] = + "game:\\Default_Patched.xex"; +inline constexpr char kMountedEntrypointXex[] = "game:\\Default.xex"; + +inline constexpr char kDlcPackageFileName[] = + "564E216B8B02133E5F0FFB3D2E3CD412CBA2D0FF41"; +inline constexpr char kDlcDisplayName[] = "IDENTITY CRISIS SUITS"; +inline constexpr char kTitleId[] = "415608B2"; +inline constexpr char kMarketplaceContentType[] = "00000002"; +inline constexpr char kCommonContentXuid[] = "0000000000000000"; + +inline constexpr char kSeuratFontFile[] = "FOT-SeuratPro-M.otf"; +inline constexpr char kFallbackFontFile[] = "Roboto-Medium.ttf"; + +inline constexpr std::array kKnownEntrypointXexHashes = { + 0xb8e9fb69b9d04b8bULL, +}; +inline constexpr std::array kKnownGameLogicDllHashes = { + 0xb60c9189331fa25bULL, +}; +inline constexpr std::array kKnownEntrypointPatchHashes = { + 0x03600b84a464aeb0ULL, +}; +inline constexpr std::array kKnownGameLogicPatchHashes = { + 0xbc098335b78a4958ULL, +}; +inline constexpr std::array kKnownDlcPackageHashes = { + 0x8813256b16fcc0a7ULL, +}; + +} // namespace eot::installer diff --git a/src/goliath_engine/installer/install_manifest.cpp b/src/goliath_engine/installer/install_manifest.cpp new file mode 100644 index 00000000..291c3d97 --- /dev/null +++ b/src/goliath_engine/installer/install_manifest.cpp @@ -0,0 +1,99 @@ +#include "goliath_engine/installer/install_manifest.h" + +#include +#include + +namespace eot::installer { +namespace { + +bool LineIsTrue(std::string_view line, std::string_view key) { + auto pos = line.find('='); + if (pos == std::string_view::npos) { + return false; + } + auto lhs = line.substr(0, pos); + auto rhs = line.substr(pos + 1); + auto trim = [](std::string_view s) { + const auto first = s.find_first_not_of(" \t"); + if (first == std::string_view::npos) { + return std::string_view{}; + } + const auto last = s.find_last_not_of(" \t"); + return s.substr(first, last - first + 1); + }; + return trim(lhs) == key && trim(rhs) == "true"; +} + +std::string Quoted(std::string value) { + std::string out = "\""; + for (char c : value) { + if (c == '\\' || c == '"') { + out.push_back('\\'); + } + out.push_back(c); + } + out.push_back('"'); + return out; +} + +} // namespace + +bool LoadInstallManifest(const std::filesystem::path& path, + InstallManifest* manifest) { + if (!manifest) { + return false; + } + *manifest = {}; + std::ifstream in(path); + if (!in) { + return false; + } + + std::string line; + while (std::getline(in, line)) { + if (LineIsTrue(line, "installed")) { + manifest->installed = true; + } + } + return true; +} + +bool SaveInstallManifest(const std::filesystem::path& path, + const InstallManifest& manifest, + std::string* error) { + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + if (ec) { + if (error) { + *error = "Failed to create manifest directory: " + ec.message(); + } + return false; + } + + const auto tmp = path.string() + ".tmp"; + { + std::ofstream out(tmp, std::ios::binary); + if (!out) { + if (error) { + *error = "Failed to open install manifest for writing: " + tmp; + } + return false; + } + out << "# ReEoT install manifest\n"; + out << "installed = " << (manifest.installed ? "true" : "false") << "\n"; + out << "runtime_root = " << Quoted(manifest.runtime_root) << "\n"; + out << "note = " << Quoted(manifest.note) << "\n"; + } + + std::filesystem::rename(tmp, path, ec); + if (ec) { + if (error) { + *error = "Failed to commit install manifest: " + ec.message(); + } + std::filesystem::remove(tmp); + return false; + } + return true; +} + +} // namespace eot::installer diff --git a/src/goliath_engine/installer/install_manifest.h b/src/goliath_engine/installer/install_manifest.h new file mode 100644 index 00000000..d0140743 --- /dev/null +++ b/src/goliath_engine/installer/install_manifest.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +namespace eot::installer { + +struct InstallManifest { + bool installed = false; + std::string runtime_root; + std::string note; +}; + +bool LoadInstallManifest(const std::filesystem::path& path, + InstallManifest* manifest); +bool SaveInstallManifest(const std::filesystem::path& path, + const InstallManifest& manifest, + std::string* error); + +} // namespace eot::installer diff --git a/src/goliath_engine/installer/install_paths.cpp b/src/goliath_engine/installer/install_paths.cpp new file mode 100644 index 00000000..508a6270 --- /dev/null +++ b/src/goliath_engine/installer/install_paths.cpp @@ -0,0 +1,150 @@ +#include "goliath_engine/installer/install_paths.h" + +#include +#include + +#include + +#include "goliath_engine/installer/install_constants.h" + +namespace eot::installer { + +std::filesystem::path BuildInstallerUserRoot() { + auto migrate_legacy = [](const std::filesystem::path& root) { + const auto legacy = root.parent_path() / kLegacyInstallerUserDirectory; + std::error_code ec; + if (!std::filesystem::exists(root, ec) && std::filesystem::exists(legacy, ec)) { + std::filesystem::rename(legacy, root, ec); + } + return root; + }; + const char* home = std::getenv("HOME"); +#if defined(_WIN32) + const char* appdata = std::getenv("APPDATA"); + if (appdata && *appdata) { + return migrate_legacy(std::filesystem::path(appdata) / kInstallerUserDirectory); + } +#elif defined(__APPLE__) + if (home && *home) { + const auto app_support = std::filesystem::path(home) / "Library" / "Application Support"; + if (std::filesystem::exists(app_support)) { + return migrate_legacy(app_support / kInstallerUserDirectory); + } + return migrate_legacy(std::filesystem::path(home) / + ("." + std::string(kInstallerUserDirectory))); + } +#else + if (home && *home) { + const auto config = std::filesystem::path(home) / ".config"; + if (std::filesystem::exists(config)) { + return migrate_legacy(config / kInstallerUserDirectory); + } + return migrate_legacy(std::filesystem::path(home) / + ("." + std::string(kInstallerUserDirectory))); + } +#endif + return migrate_legacy(std::filesystem::current_path() / kInstallerUserDirectory); +} + +std::filesystem::path InstallerConfigPath() { + return rex::filesystem::GetExecutableFolder() / "reeot.toml"; +} + +std::filesystem::path InstallerResourceRoot() { + return (rex::filesystem::GetExecutableFolder() / "../../../res").lexically_normal(); +} + +std::filesystem::path InstallerFontRoot() { + return InstallerResourceRoot() / "font"; +} + +InstallLayout ResolveInstallLayout(const std::filesystem::path& user_data_root) { + InstallLayout layout; + layout.root = user_data_root.empty() ? BuildInstallerUserRoot() : user_data_root; + layout.game = layout.root / "game"; + layout.update = layout.root / "update"; + layout.dlc = layout.root / "dlc"; + layout.mods = layout.root / "mods"; + layout.patched = layout.root / "patched"; + layout.manifest = layout.root / "install_manifest.toml"; + return layout; +} + +namespace { + +bool HasFile(const std::filesystem::path& root, const char* relative, + std::string* missing) { + std::error_code ec; + const auto path = root / std::filesystem::path(relative); + if (std::filesystem::is_regular_file(path, ec)) { + return true; + } + if (missing) { + *missing = path.string(); + } + return false; +} + +bool HasPatchedBinaries(const std::filesystem::path& root, std::string* missing) { + return HasFile(root, kPatchedEntrypointXex, missing) && + HasFile(root, kPatchedGameLogicDll, missing); +} + +} // namespace + +std::filesystem::path ResolveRuntimeRoot(const InstallLayout& layout) { + if (HasRequiredRuntimeFiles(layout.game, nullptr)) { + return layout.game; + } + return {}; +} + +std::filesystem::path ResolvePatchedEntrypoint(const InstallLayout& layout) { + if (!HasRequiredPatchedFiles(layout.patched, nullptr)) { + return {}; + } + return layout.patched / kPatchedEntrypointXex; +} + +std::filesystem::path ResolvePatchedGameLogic(const InstallLayout& layout) { + if (!HasRequiredPatchedFiles(layout.patched, nullptr)) { + return {}; + } + return layout.patched / std::filesystem::path(kPatchedGameLogicDll); +} + +bool HasRequiredRuntimeFiles(const std::filesystem::path& root, std::string* missing) { + return HasFile(root, kEntrypointXex, missing) && + HasFile(root, kGameLogicDll, missing); +} + +bool HasRequiredPatchFiles(const std::filesystem::path& root, std::string* missing) { + return HasFile(root, kEntrypointPatch, missing) && + HasFile(root, kGameLogicPatch, missing); +} + +bool HasRequiredPatchedFiles(const std::filesystem::path& root, std::string* missing) { + return HasPatchedBinaries(root, missing); +} + +bool HasMountedPatchedFiles(const InstallLayout& layout, std::string* missing) { + return HasPatchedBinaries(layout.game / "patched", missing); +} + +bool EnsureInstallDirectories(const InstallLayout& layout, std::string* error) { + std::error_code ec; + for (const auto& path : {layout.root, layout.game, layout.update, layout.dlc, + layout.mods, layout.patched, + layout.game / "Data", layout.patched / "Data"}) { + std::filesystem::create_directories(path, ec); + if (ec) { + if (error) { + *error = "Failed to create " + path.string() + ": " + ec.message(); + } + return false; + } + } + return true; +} + +} // namespace eot::installer diff --git a/src/goliath_engine/installer/install_paths.h b/src/goliath_engine/installer/install_paths.h new file mode 100644 index 00000000..57cc22d3 --- /dev/null +++ b/src/goliath_engine/installer/install_paths.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +namespace eot::installer { + +struct InstallLayout { + std::filesystem::path root; + std::filesystem::path game; + std::filesystem::path update; + std::filesystem::path dlc; + std::filesystem::path mods; + std::filesystem::path patched; + std::filesystem::path manifest; +}; + +std::filesystem::path BuildInstallerUserRoot(); +std::filesystem::path InstallerConfigPath(); +std::filesystem::path InstallerResourceRoot(); +std::filesystem::path InstallerFontRoot(); +InstallLayout ResolveInstallLayout(const std::filesystem::path& user_data_root); +std::filesystem::path ResolveRuntimeRoot(const InstallLayout& layout); +std::filesystem::path ResolvePatchedEntrypoint(const InstallLayout& layout); +std::filesystem::path ResolvePatchedGameLogic(const InstallLayout& layout); +bool HasRequiredRuntimeFiles(const std::filesystem::path& root, std::string* missing); +bool HasRequiredPatchFiles(const std::filesystem::path& root, std::string* missing); +bool HasRequiredPatchedFiles(const std::filesystem::path& root, std::string* missing); +bool HasMountedPatchedFiles(const InstallLayout& layout, std::string* missing); +bool EnsureInstallDirectories(const InstallLayout& layout, std::string* error); + +} // namespace eot::installer diff --git a/src/goliath_engine/installer/installer.cpp b/src/goliath_engine/installer/installer.cpp new file mode 100644 index 00000000..0cf358da --- /dev/null +++ b/src/goliath_engine/installer/installer.cpp @@ -0,0 +1,255 @@ +#include "goliath_engine/installer/installer.h" + +#include +#include +#include + +#include + +#include "goliath_engine/common/cvars.h" +#include "goliath_engine/common/logging.h" +#include "goliath_engine/installer/install_constants.h" +#include "goliath_engine/installer/install_manifest.h" + +namespace eot::installer { +namespace { + +bool IsRegularFile(const std::filesystem::path& path) { + std::error_code ec; + return std::filesystem::is_regular_file(path, ec); +} + +std::filesystem::path NormalPath(const std::filesystem::path& path) { + std::error_code ec; + auto canonical = std::filesystem::weakly_canonical(path, ec); + return ec ? path.lexically_normal() : canonical; +} + +bool SamePath(const std::filesystem::path& lhs, const std::filesystem::path& rhs) { + return NormalPath(lhs) == NormalPath(rhs); +} + +bool IsManagedRuntimeRoot(const std::filesystem::path& root, + const InstallLayout& layout) { + return SamePath(root, layout.game) || SamePath(root, layout.patched); +} + +bool HasCompletedManagedManifest(const InstallLayout& layout, std::string* message) { + InstallManifest manifest; + if (!LoadInstallManifest(layout.manifest, &manifest)) { + if (message) { + *message = "Install manifest is missing."; + } + return false; + } + if (!manifest.installed) { + if (message) { + *message = "Install manifest exists but is not marked installed."; + } + return false; + } + return true; +} + +std::filesystem::path LocatePatchRoot(const std::filesystem::path& runtime_root, + std::string* missing) { + if (HasRequiredPatchFiles(runtime_root, missing)) { + return runtime_root; + } + + const auto update_root = runtime_root / "Update"; + if (HasRequiredPatchFiles(update_root, missing)) { + return update_root; + } + + const auto sibling_update_root = runtime_root.parent_path() / "update"; + if (HasRequiredPatchFiles(sibling_update_root, missing)) { + return sibling_update_root; + } + + return {}; +} + +void MarkInstallerSkipped(const std::filesystem::path& config_path) { + if (!REXCVAR_GET(skip_installer)) { + EOT_INFO("[installer] enabling skip_installer"); + REXCVAR_SET(skip_installer, true); + if (!config_path.empty()) { + EOT_INFO("[installer] saving config after validation: {}", config_path.string()); + rex::cvar::SaveConfig(config_path); + } + } +} + +std::filesystem::path ResolveConfigRelativePath(std::filesystem::path path, + const std::filesystem::path& config_dir) { + if (path.empty() || path.is_absolute()) { + return path; + } + return (config_dir / path).lexically_normal(); +} + +ValidationResult ValidateConfiguredRoot(const std::filesystem::path& config_dir) { + auto root = ResolveConfigRelativePath( + std::filesystem::path(rex::cvar::GetFlagByName(kCvEotDataRoot)), config_dir); + if (root.empty()) { + EOT_INFO("[installer] eot_data_root is empty"); + return {false, {}, {}, "eot_data_root is empty."}; + } + + EOT_INFO("[installer] validating eot_data_root: {}", root.string()); + const auto managed_layout = ResolveInstallLayout(BuildInstallerUserRoot()); + const bool is_managed_runtime_root = IsManagedRuntimeRoot(root, managed_layout); + if (is_managed_runtime_root) { + std::string manifest_error; + if (!HasCompletedManagedManifest(managed_layout, &manifest_error)) { + EOT_WARN("[installer] configured managed root is incomplete: {}", manifest_error); + return {false, {}, {}, manifest_error}; + } + if (SamePath(root, managed_layout.patched)) { + EOT_INFO("[installer] migrating managed eot_data_root from patched to game root"); + root = managed_layout.game; + } + std::string missing; + if (!HasRequiredPatchedFiles(managed_layout.patched, &missing)) { + EOT_WARN("[installer] configured managed root missing patched runtime file: {}", missing); + return {false, {}, {}, "Missing patched runtime file: " + missing}; + } + if (!HasMountedPatchedFiles(managed_layout, &missing)) { + EOT_WARN("[installer] configured managed root missing mounted patched runtime file: {}", + missing); + return {false, {}, {}, "Missing mounted patched runtime file: " + missing}; + } + } + + std::string missing; + if (!HasRequiredRuntimeFiles(root, &missing)) { + EOT_WARN("[installer] configured root missing runtime file: {}", missing); + return { + false, {}, {}, + "eot_data_root is missing required game file: " + missing, + }; + } + + EOT_INFO("[installer] configured root valid. runtime={}", root.string()); + return {true, root, is_managed_runtime_root ? managed_layout.update : std::filesystem::path{}, + "eot_data_root is ready."}; +} + +} // namespace + +ValidationResult ValidateInstall(const InstallLayout& layout) { + EOT_INFO("[installer] validating install layout root={}", layout.root.string()); + InstallManifest manifest; + const bool has_manifest = LoadInstallManifest(layout.manifest, &manifest); + EOT_DEBUG("[installer] manifest path={} present={} installed={}", + layout.manifest.string(), has_manifest, manifest.installed); + + std::string manifest_error; + if (!HasCompletedManagedManifest(layout, &manifest_error)) { + EOT_WARN("[installer] install layout manifest invalid: {}", manifest_error); + return {false, {}, {}, manifest_error}; + } + + std::string missing; + auto runtime_root = ResolveRuntimeRoot(layout); + if (runtime_root.empty()) { + EOT_WARN("[installer] install layout missing runtime files. game={} patched={}", + layout.game.string(), layout.patched.string()); + return { + false, + {}, + {}, + "Missing runtime files. Expected " + std::string(kEntrypointXex) + + " and " + kGameLogicDll + " under " + layout.game.string() + ".", + }; + } + + if (!HasRequiredPatchedFiles(layout.patched, &missing)) { + EOT_WARN("[installer] install layout missing patched runtime file: {}", missing); + return {false, {}, {}, "Missing patched runtime file: " + missing}; + } + if (!HasMountedPatchedFiles(layout, &missing)) { + EOT_WARN("[installer] install layout missing mounted patched runtime file: {}", missing); + return {false, {}, {}, "Missing mounted patched runtime file: " + missing}; + } + + if (!HasRequiredPatchFiles(layout.update, &missing)) { + EOT_WARN("[installer] install layout missing patch file: {}", missing); + return {false, {}, {}, "Missing title update patch file: " + missing}; + } + + EOT_INFO("[installer] install layout valid. runtime={} patched={} update={}", + runtime_root.string(), layout.patched.string(), layout.update.string()); + return {true, runtime_root, layout.update, "Install is ready."}; +} + +StartupResult PrepareStartupPaths(rex::PathConfig defaults, + const std::filesystem::path& config_path) { + const auto active_config = config_path; + EOT_INFO("[installer] preparing startup paths. config={}", active_config.string()); + if (IsRegularFile(active_config)) { + EOT_INFO("[installer] loading config: {}", active_config.string()); + rex::cvar::LoadConfig(active_config); + } else { + EOT_INFO("[installer] config not found yet: {}", active_config.string()); + } + + auto configured = ValidateConfiguredRoot(active_config.parent_path()); + if (configured.valid) { + const auto managed_layout = ResolveInstallLayout(BuildInstallerUserRoot()); + rex::cvar::SetFlagByName(kCvEotDataRoot, configured.runtime_root.string()); + defaults.game_data_root = configured.runtime_root; + defaults.update_data_root = configured.patch_root; + if (IsManagedRuntimeRoot(configured.runtime_root, managed_layout)) { + defaults.user_data_root = managed_layout.root; + } + if (!REXCVAR_GET(skip_installer)) { + MarkInstallerSkipped(active_config); + } else { + rex::cvar::SaveConfig(active_config); + } + return {std::move(defaults), true, false, {}, configured.message}; + } + + defaults.user_data_root = BuildInstallerUserRoot(); + + auto layout = ResolveInstallLayout(defaults.user_data_root); + std::string error; + if (!EnsureInstallDirectories(layout, &error)) { + EOT_ERROR("[installer] failed to create install directories: {}", error); + } else { + EOT_INFO("[installer] install directories ready. root={}", layout.root.string()); + } + + // The installer target layout is still useful for first-run installs, but + // runtime startup is cvar-driven: a completed install should set + // eot_data_root to the chosen patched/game root and then enable + // skip_installer. + auto installed = ValidateInstall(layout); + if (installed.valid) { + rex::cvar::SetFlagByName(kCvEotDataRoot, installed.runtime_root.string()); + defaults.game_data_root = installed.runtime_root; + defaults.update_data_root = installed.patch_root; + defaults.user_data_root = layout.root; + MarkInstallerSkipped(active_config); + rex::cvar::SaveConfig(active_config); + return {std::move(defaults), true, false, layout, installed.message}; + } + + defaults.game_data_root.clear(); + defaults.update_data_root = layout.update; + EOT_WARN("[installer] startup blocked for installer. configured='{}' installed='{}'", + configured.message, installed.message); + return { + std::move(defaults), + false, + false, + layout, + "ReEoT assets were not found through eot_data_root. " + + configured.message + " Installer target is " + layout.root.string() + + " (game/, update/, patched/). " + installed.message, + }; +} + +} // namespace eot::installer diff --git a/src/goliath_engine/installer/installer.h b/src/goliath_engine/installer/installer.h new file mode 100644 index 00000000..c4aee3d0 --- /dev/null +++ b/src/goliath_engine/installer/installer.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include + +#include "goliath_engine/installer/install_paths.h" + +namespace eot::installer { + +struct ValidationResult { + bool valid = false; + std::filesystem::path runtime_root; + std::filesystem::path patch_root; + std::string message; +}; + +struct StartupResult { + rex::PathConfig paths; + bool ready = false; + bool used_development_assets = false; + InstallLayout install_layout; + std::string message; +}; + +ValidationResult ValidateInstall(const InstallLayout& layout); +StartupResult PrepareStartupPaths(rex::PathConfig defaults, + const std::filesystem::path& config_path); + +} // namespace eot::installer diff --git a/src/goliath_engine/installer/source_vfs.cpp b/src/goliath_engine/installer/source_vfs.cpp new file mode 100644 index 00000000..aaa74f86 --- /dev/null +++ b/src/goliath_engine/installer/source_vfs.cpp @@ -0,0 +1,555 @@ +#include "goliath_engine/installer/source_vfs.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "goliath_engine/common/logging.h" + +namespace eot::installer { +namespace { + +std::string Lower(std::string value) { + std::replace(value.begin(), value.end(), '\\', '/'); + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} + +uint16_t ReadLE16(const uint8_t* p) { + rex::le value; + std::memcpy(&value, p, sizeof(value)); + return value; +} + +uint32_t ReadLE24(const uint8_t* p) { + return static_cast(p[0] | (p[1] << 8) | (p[2] << 16)); +} + +uint32_t ReadLE32(const uint8_t* p) { + rex::le value; + std::memcpy(&value, p, sizeof(value)); + return value; +} + +uint16_t ReadBE16(const uint8_t* p) { + rex::be_u16 value; + std::memcpy(&value, p, sizeof(value)); + return value; +} + +uint32_t ReadBE32(const uint8_t* p) { + rex::be_u32 value; + std::memcpy(&value, p, sizeof(value)); + return value; +} + +uint64_t ReadBE64(const uint8_t* p) { + rex::be_u64 value; + std::memcpy(&value, p, sizeof(value)); + return value; +} + +class FileReader { + public: + explicit FileReader(std::filesystem::path path) : path_(std::move(path)) { + stream_.open(path_, std::ios::binary); + if (!stream_) { + return; + } + stream_.seekg(0, std::ios::end); + size_ = static_cast(stream_.tellg()); + } + + bool IsOpen() const { return stream_.is_open(); } + uint64_t Size() const { return size_; } + const std::filesystem::path& path() const { return path_; } + + bool Read(uint64_t offset, void* out, size_t size) const { + if (!stream_ || offset + size > size_) { + return false; + } + stream_.seekg(static_cast(offset), std::ios::beg); + stream_.read(static_cast(out), static_cast(size)); + return !stream_.bad() && static_cast(stream_.gcount()) == size; + } + + bool ReadVector(uint64_t offset, size_t size, std::vector* out) const { + out->resize(size); + return Read(offset, out->data(), size); + } + + private: + std::filesystem::path path_; + mutable std::ifstream stream_; + uint64_t size_ = 0; +}; + +class DirectorySourceVfs final : public SourceVfs { + public: + explicit DirectorySourceVfs(std::filesystem::path root) : root_(std::move(root)) { + name_ = root_.filename().string(); + } + + bool Exists(const std::string& path) const override { + std::error_code ec; + return std::filesystem::is_regular_file(Resolve(path), ec); + } + + bool Load(const std::string& path, std::vector* out) const override { + const auto file_path = Resolve(path); + std::ifstream file(file_path, std::ios::binary); + if (!file) { + return false; + } + out->assign(std::istreambuf_iterator(file), std::istreambuf_iterator()); + return true; + } + + uintmax_t Size(const std::string& path) const override { + std::error_code ec; + const auto size = std::filesystem::file_size(Resolve(path), ec); + return ec ? 0 : size; + } + + std::vector ListFiles() const override { + std::vector files; + std::error_code ec; + if (!std::filesystem::is_directory(root_, ec)) { + return files; + } + for (const auto& entry : + std::filesystem::recursive_directory_iterator(root_, ec)) { + if (ec) { + break; + } + if (!entry.is_regular_file(ec)) { + continue; + } + auto relative = std::filesystem::relative(entry.path(), root_, ec); + if (ec) { + continue; + } + const auto size = entry.file_size(ec); + if (ec) { + continue; + } + files.push_back({relative.generic_string(), size}); + } + return files; + } + + std::string Name() const override { return name_; } + + private: + std::filesystem::path Resolve(const std::string& path) const { + std::filesystem::path rel; + for (const auto& part : std::filesystem::path(path)) { + if (part == "/" || part == "." || part == "..") { + continue; + } + rel /= part; + } + auto direct = root_ / rel; + if (std::filesystem::exists(direct)) { + return direct; + } + + std::filesystem::path cur = root_; + for (const auto& part : rel) { + const auto wanted = Lower(part.string()); + bool found = false; + std::error_code ec; + for (const auto& entry : std::filesystem::directory_iterator(cur, ec)) { + if (Lower(entry.path().filename().string()) == wanted) { + cur = entry.path(); + found = true; + break; + } + } + if (!found) { + return direct; + } + } + return cur; + } + + std::filesystem::path root_; + std::string name_; +}; + +class MapSourceVfs : public SourceVfs { + protected: + struct Entry { + uint64_t offset = 0; + uint64_t size = 0; + uint32_t block = 0; + uint32_t block_count = 0; + }; + + const Entry* Find(const std::string& path) const { + auto it = files_.find(Lower(path)); + return it == files_.end() ? nullptr : &it->second; + } + + void AddFile(std::string path, Entry entry) { + listed_files_.push_back({path, entry.size}); + files_[Lower(path)] = entry; + } + + std::vector ListMapFiles() const { return listed_files_; } + + std::map files_; + std::vector listed_files_; +}; + +class IsoSourceVfs final : public MapSourceVfs { + public: + explicit IsoSourceVfs(std::filesystem::path path) : file_(std::move(path)) { + name_ = file_.path().filename().string(); + if (!file_.IsOpen()) { + return; + } + Parse(); + } + + bool ok() const { return ok_; } + bool Exists(const std::string& path) const override { return Find(path) != nullptr; } + + bool Load(const std::string& path, std::vector* out) const override { + const Entry* entry = Find(path); + return entry && file_.ReadVector(entry->offset, static_cast(entry->size), out); + } + + uintmax_t Size(const std::string& path) const override { + const Entry* entry = Find(path); + return entry ? entry->size : 0; + } + + std::vector ListFiles() const override { return ListMapFiles(); } + + std::string Name() const override { return name_; } + + private: + void Parse() { + static constexpr uint64_t kSectorSize = 2048; + static constexpr std::array kOffsets = { + 0x00000000ULL, 0x0000FB20ULL, 0x00020600ULL, 0x02080000ULL, 0x0FD90000ULL}; + static constexpr const char* kMagic = "MICROSOFT*XBOX*MEDIA"; + + uint64_t game_offset = 0; + bool magic_found = false; + std::array magic{}; + for (uint64_t offset : kOffsets) { + const uint64_t file_offset = offset + 32 * kSectorSize; + if (file_.Read(file_offset, magic.data(), std::strlen(kMagic)) && + std::memcmp(magic.data(), kMagic, std::strlen(kMagic)) == 0) { + game_offset = offset; + magic_found = true; + break; + } + } + if (!magic_found) { + return; + } + + std::array root_info{}; + if (!file_.Read(game_offset + 32 * kSectorSize + 20, root_info.data(), root_info.size())) { + return; + } + const uint32_t root_sector = ReadLE32(root_info.data()); + const uint32_t root_size = ReadLE32(root_info.data() + 4); + if (root_size < 13 || root_size > 32 * 1024 * 1024) { + return; + } + + struct Step { + std::string base; + uint64_t node_offset = 0; + uint32_t entry_offset = 0; + }; + std::stack stack; + stack.push({"", game_offset + root_sector * kSectorSize, 0}); + + while (!stack.empty()) { + Step step = stack.top(); + stack.pop(); + + std::array buf{}; + const uint64_t info_offset = step.node_offset + step.entry_offset; + if (!file_.Read(info_offset, buf.data(), 14)) { + return; + } + const uint16_t node_l = ReadLE16(buf.data()); + const uint16_t node_r = ReadLE16(buf.data() + 2); + const uint32_t sector = ReadLE32(buf.data() + 4); + const uint32_t length = ReadLE32(buf.data() + 8); + const uint8_t attributes = buf[12]; + const uint8_t name_length = buf[13]; + if (name_length >= 255 || !file_.Read(info_offset + 14, buf.data(), name_length)) { + return; + } + std::string file_name(reinterpret_cast(buf.data()), name_length); + if (node_l) { + stack.push({step.base, step.node_offset, static_cast(node_l * 4)}); + } + if (node_r) { + stack.push({step.base, step.node_offset, static_cast(node_r * 4)}); + } + const std::string full_name = step.base + file_name; + if (attributes & 0x10) { + if (length > 0) { + stack.push({full_name + "/", game_offset + sector * kSectorSize, 0}); + } + } else { + AddFile(full_name, {game_offset + sector * kSectorSize, length, 0, 0}); + } + } + ok_ = !files_.empty(); + EOT_INFO("[installer-source] opened ISO {} files={}", name_, files_.size()); + } + + FileReader file_; + std::string name_; + bool ok_ = false; +}; + +class StfsSourceVfs final : public MapSourceVfs { + public: + explicit StfsSourceVfs(std::filesystem::path path) : file_(std::move(path)) { + name_ = file_.path().filename().string(); + if (file_.IsOpen()) { + Parse(); + } + } + + bool ok() const { return ok_; } + bool Exists(const std::string& path) const override { return Find(path) != nullptr; } + + bool Load(const std::string& path, std::vector* out) const override { + const Entry* entry = Find(path); + if (!entry) { + return false; + } + out->resize(static_cast(entry->size)); + uint64_t dst = 0; + uint64_t remaining = entry->size; + uint32_t block = entry->block; + for (uint32_t i = 0; i < entry->block_count && block != kEndOfChain && remaining > 0; ++i) { + const uint64_t chunk = std::min(kBlockSize, remaining); + if (!file_.Read(BlockOffset(block), out->data() + dst, static_cast(chunk))) { + return false; + } + block = NextBlock(block); + dst += chunk; + remaining -= chunk; + } + return remaining == 0; + } + + uintmax_t Size(const std::string& path) const override { + const Entry* entry = Find(path); + return entry ? entry->size : 0; + } + + std::vector ListFiles() const override { return ListMapFiles(); } + + std::string Name() const override { return name_; } + + static bool Check(const std::filesystem::path& path) { + std::ifstream file(path, std::ios::binary); + if (!file) { + return false; + } + std::array magic{}; + file.read(reinterpret_cast(magic.data()), magic.size()); + const uint32_t value = ReadBE32(magic.data()); + return value == 0x434F4E20 || value == 0x50495253 || value == 0x4C495645; + } + + private: + static constexpr uint32_t kBlockSize = 0x1000; + static constexpr uint32_t kEndOfChain = 0xFFFFFF; + static constexpr uint32_t kBlocksPerHashLevel0 = 170; + static constexpr uint32_t kBlocksPerHashLevel1 = 28900; + + uint64_t BlockOffset(uint64_t block_index) const { + uint64_t block = block_index; + block += ((block_index + kBlocksPerHashLevel0) / kBlocksPerHashLevel0); + if (block_index >= kBlocksPerHashLevel0) { + block += ((block_index + kBlocksPerHashLevel1) / kBlocksPerHashLevel1); + } + return base_offset_ + (block << 12); + } + + uint32_t HashBlockNumber(uint32_t block_index) const { + if (block_index < kBlocksPerHashLevel0) { + return 0; + } + uint32_t block = + (block_index / kBlocksPerHashLevel0) * (kBlocksPerHashLevel0 + 1); + block += ((block_index / kBlocksPerHashLevel1) + 1); + return block_index < kBlocksPerHashLevel1 ? block : block + 1; + } + + uint32_t NextBlock(uint32_t block_index) const { + const uint64_t hash_offset = base_offset_ + (static_cast(HashBlockNumber(block_index)) << 12); + std::array entry{}; + if (!file_.Read(hash_offset + (block_index % kBlocksPerHashLevel0) * 0x18, + entry.data(), entry.size())) { + return kEndOfChain; + } + return ReadBE32(entry.data() + 0x14) & 0xFFFFFF; + } + + void Parse() { + std::array header{}; + if (!file_.Read(0, header.data(), header.size())) { + return; + } + const uint32_t magic = ReadBE32(header.data()); + if (magic != 0x434F4E20 && magic != 0x50495253 && magic != 0x4C495645) { + return; + } + const uint32_t header_size = ReadBE32(header.data() + 0x340); + const uint32_t volume_type = ReadBE32(header.data() + 0x3A9); + if (volume_type != 0) { + EOT_WARN("[installer-source] SVOD packages are not supported yet: {}", name_); + return; + } + + const uint64_t descriptor_offset = 0x379; + if (header[descriptor_offset] != 0x24 || (header[descriptor_offset + 2] & 1) == 0) { + return; + } + const uint16_t file_table_block_count = ReadLE16(header.data() + descriptor_offset + 3); + uint32_t table_block = ReadLE24(header.data() + descriptor_offset + 5); + base_offset_ = ((header_size + kBlockSize - 1) / kBlockSize) * kBlockSize; + + uint32_t entry_count = 0; + std::map directory_names; + for (uint32_t table_i = 0; table_i < file_table_block_count; ++table_i) { + const uint64_t block_offset = BlockOffset(table_block); + for (uint32_t entry_i = 0; entry_i < 0x40; ++entry_i) { + std::array entry{}; + if (!file_.Read(block_offset + entry_i * 0x40, entry.data(), entry.size())) { + return; + } + if (entry[0] == 0) { + break; + } + const uint8_t name_length = entry[0x28] & 0x3F; + const bool is_directory = (entry[0x28] & 0x80) != 0; + std::string file_name(reinterpret_cast(entry.data()), name_length); + const uint16_t dir_index = ReadBE16(entry.data() + 0x32); + const std::string base = directory_names[dir_index]; + if (is_directory) { + directory_names[entry_count++] = base + file_name + "/"; + continue; + } + const uint32_t allocated_blocks = ReadLE24(entry.data() + 0x2C); + const uint32_t start_block = ReadLE24(entry.data() + 0x2F); + const uint32_t length = ReadBE32(entry.data() + 0x34); + AddFile(base + file_name, {0, length, start_block, allocated_blocks}); + entry_count++; + } + table_block = NextBlock(table_block); + if (table_block == kEndOfChain) { + break; + } + } + ok_ = !files_.empty(); + EOT_INFO("[installer-source] opened STFS {} files={}", name_, files_.size()); + } + + FileReader file_; + std::string name_; + uint64_t base_offset_ = 0; + bool ok_ = false; +}; + +} // namespace + +std::unique_ptr OpenSourceVfs(const std::filesystem::path& path) { + std::error_code ec; + if (std::filesystem::is_directory(path, ec)) { + return std::make_unique(path); + } + if (StfsSourceVfs::Check(path)) { + auto stfs = std::make_unique(path); + if (stfs->ok()) { + return stfs; + } + } + if (Lower(path.extension().string()) == ".iso") { + auto iso = std::make_unique(path); + if (iso->ok()) { + return iso; + } + } + return {}; +} + +bool SourceHasFile(const std::filesystem::path& path, const std::string& relative) { + auto vfs = OpenSourceVfs(path); + return vfs && vfs->Exists(relative); +} + +uintmax_t SourceFileSize(const std::filesystem::path& path, const std::string& relative) { + auto vfs = OpenSourceVfs(path); + return vfs ? vfs->Size(relative) : 0; +} + +uintmax_t SourceTotalSize(const std::filesystem::path& path) { + auto vfs = OpenSourceVfs(path); + if (!vfs) { + return 0; + } + uintmax_t total = 0; + for (const auto& file : vfs->ListFiles()) { + total += file.size; + } + return total; +} + +bool ListSourceFiles(const std::filesystem::path& path, + std::vector* files, + std::string* error) { + auto vfs = OpenSourceVfs(path); + if (!vfs) { + if (error) { + *error = "Unable to open source: " + path.string(); + } + return false; + } + *files = vfs->ListFiles(); + return true; +} + +bool LoadSourceFile(const std::filesystem::path& path, const std::string& relative, + std::vector* out, std::string* error) { + auto vfs = OpenSourceVfs(path); + if (!vfs) { + if (error) { + *error = "Unable to open source: " + path.string(); + } + return false; + } + if (!vfs->Load(relative, out)) { + if (error) { + *error = "Unable to read " + relative + " from " + path.string(); + } + return false; + } + return true; +} + +} // namespace eot::installer diff --git a/src/goliath_engine/installer/source_vfs.h b/src/goliath_engine/installer/source_vfs.h new file mode 100644 index 00000000..b3466bd3 --- /dev/null +++ b/src/goliath_engine/installer/source_vfs.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace eot::installer { + +struct SourceFileInfo { + std::string path; + uintmax_t size = 0; +}; + +class SourceVfs { + public: + virtual ~SourceVfs() = default; + + virtual bool Exists(const std::string& path) const = 0; + virtual bool Load(const std::string& path, std::vector* out) const = 0; + virtual uintmax_t Size(const std::string& path) const = 0; + virtual std::vector ListFiles() const = 0; + virtual std::string Name() const = 0; +}; + +std::unique_ptr OpenSourceVfs(const std::filesystem::path& path); +bool SourceHasFile(const std::filesystem::path& path, const std::string& relative); +uintmax_t SourceFileSize(const std::filesystem::path& path, const std::string& relative); +uintmax_t SourceTotalSize(const std::filesystem::path& path); +bool ListSourceFiles(const std::filesystem::path& path, + std::vector* files, + std::string* error); +bool LoadSourceFile(const std::filesystem::path& path, const std::string& relative, + std::vector* out, std::string* error); + +} // namespace eot::installer From f3dfd28ce5f0308b05f8255cbf7484c1099b3578 Mon Sep 17 00:00:00 2001 From: Graine25 Date: Wed, 8 Jul 2026 15:31:54 -0700 Subject: [PATCH 07/11] feat: add installer ui --- .../installer/installer_dialog.cpp | 1729 +++++++++++++++++ .../installer/installer_dialog.h | 24 + 2 files changed, 1753 insertions(+) create mode 100644 src/goliath_engine/installer/installer_dialog.cpp create mode 100644 src/goliath_engine/installer/installer_dialog.h diff --git a/src/goliath_engine/installer/installer_dialog.cpp b/src/goliath_engine/installer/installer_dialog.cpp new file mode 100644 index 00000000..4b33c076 --- /dev/null +++ b/src/goliath_engine/installer/installer_dialog.cpp @@ -0,0 +1,1729 @@ +#include "goliath_engine/installer/installer_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__APPLE__) +#include +#include +#include +#include + +extern char** environ; +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "goliath_engine/common/cvars.h" +#include "goliath_engine/common/logging.h" +#include "goliath_engine/installer/install_constants.h" +#include "goliath_engine/installer/install_manifest.h" +#include "goliath_engine/installer/source_vfs.h" + +#ifndef XXH_INLINE_ALL +#define XXH_INLINE_ALL +#endif +#include + +namespace eot::installer { +namespace { + +constexpr float kCanvasWidth = 1280.0f; +constexpr float kCanvasHeight = 720.0f; +constexpr float kPi = 3.14159265358979323846f; + +constexpr double kScanlinesAnimationTime = 0.0; +constexpr double kScanlinesAnimationDuration = 15.0; +constexpr double kIconAnimationTime = kScanlinesAnimationTime + 10.0; +constexpr double kIconAnimationDuration = 15.0; +constexpr double kImageAnimationTime = kIconAnimationTime + kIconAnimationDuration; +constexpr double kImageAnimationDuration = 15.0; +constexpr double kTitleAnimationTime = kScanlinesAnimationDuration; +constexpr double kTitleAnimationDuration = 30.0; +constexpr double kContainerLineAnimationTime = kScanlinesAnimationDuration; +constexpr double kContainerLineAnimationDuration = 23.0; +constexpr double kContainerOuterTime = + kScanlinesAnimationDuration + kContainerLineAnimationDuration; +constexpr double kContainerOuterDuration = 23.0; +constexpr double kContainerInnerTime = + kScanlinesAnimationDuration + kContainerLineAnimationDuration + 8.0; +constexpr double kContainerInnerDuration = 15.0; +constexpr double kAllAnimationsFullDuration = + kContainerInnerTime + kContainerInnerDuration; +constexpr double kQuittingExtraDuration = 60.0; + +constexpr float kImageX = 161.5f; +constexpr float kImageY = 103.5f; +constexpr float kImageWidth = 512.0f; +constexpr float kImageHeight = 512.0f; +constexpr float kContainerX = 513.0f; +constexpr float kContainerY = 226.0f; +constexpr float kContainerWidth = 526.5f; +constexpr float kContainerHeight = 246.0f; +constexpr float kSideContainerWidth = kContainerWidth / 2.0f; +constexpr float kBottomXGap = 4.0f; +constexpr float kBottomYGap = 5.0f; +constexpr float kContainerButtonWidth = 250.0f; +constexpr float kContainerButtonGap = 9.0f; +constexpr float kButtonHeight = 22.0f; +constexpr float kButtonTextGap = 28.0f; +constexpr float kBorderSize = 1.0f; +constexpr float kBorderOvershoot = 36.0f; +constexpr float kGridSize = 9.0f; + +enum class WizardPage { + SelectLanguage, + Introduction, + SelectGameAndUpdate, + SelectDLC, + CheckSpace, + Installing, + InstallSucceeded, + InstallFailed, +}; + +enum class MessageSource { + Unknown, + Next, + Back, + PickerTutorialFile, + PickerTutorialFolder, +}; + +enum class Language { + French, + German, + English, + Spanish, + Italian, + Japanese, +}; + +struct SourceSelection { + std::filesystem::path game; + std::filesystem::path update; + std::filesystem::path dlc; +}; + +ImU32 Color(int r, int g, int b, int a = 255) { return IM_COL32(r, g, b, a); } + +float Saturate(float value) { return std::clamp(value, 0.0f, 1.0f); } + +float EaseInOut(float value) { + const float t = Saturate(value); + return 0.5f - 0.5f * std::cos(t * kPi); +} + +std::string PathString(const std::filesystem::path& path) { + return path.string(); +} + +std::string LowerPath(std::string value) { + std::replace(value.begin(), value.end(), '\\', '/'); + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} + +std::string HexHash(uint64_t hash) { + std::array text{}; + std::snprintf(text.data(), text.size(), "0x%016llx", + static_cast(hash)); + return text.data(); +} + +const std::array* ExpectedHashesFor(const char* relative) { + const auto path = LowerPath(relative); + if (path == LowerPath(kEntrypointXex)) { + return &kKnownEntrypointXexHashes; + } + if (path == LowerPath(kGameLogicDll)) { + return &kKnownGameLogicDllHashes; + } + if (path == LowerPath(kEntrypointPatch)) { + return &kKnownEntrypointPatchHashes; + } + if (path == LowerPath(kGameLogicPatch)) { + return &kKnownGameLogicPatchHashes; + } + return nullptr; +} + +std::string CanonicalInstallRelative(std::string relative) { + const auto path = LowerPath(relative); + if (path == LowerPath(kEntrypointXex)) { + return kEntrypointXex; + } + if (path == LowerPath(kEntrypointPatch)) { + return kEntrypointPatch; + } + if (path == LowerPath(kGameLogicDll)) { + return kGameLogicDll; + } + if (path == LowerPath(kGameLogicPatch)) { + return kGameLogicPatch; + } + return relative; +} + +bool VerifyFileHash(const std::filesystem::path& source, const char* relative, + const std::vector& bytes, std::string* error) { + const auto* expected_hashes = ExpectedHashesFor(relative); + if (!expected_hashes) { + return true; + } + + const uint64_t hash = XXH3_64bits(bytes.data(), bytes.size()); + if (std::find(expected_hashes->begin(), expected_hashes->end(), hash) != + expected_hashes->end()) { + EOT_INFO("[installer-ui] verified {} from {} hash={}", + relative, source.string(), HexHash(hash)); + return true; + } + + *error = "The selected " + std::string(relative) + + " did not match a known Edge of Time file.\n\n" + "Please make sure all the specified files are correct and try again."; + EOT_ERROR("[installer-ui] hash verification failed file={} source={} size={} hash={}", + relative, source.string(), bytes.size(), HexHash(hash)); + return false; +} + +bool SourceFileMatchesKnownHash(const std::filesystem::path& source, + const char* relative) { + std::vector bytes; + std::string error; + if (!LoadSourceFile(source, relative, &bytes, &error)) { + return false; + } + + const auto* expected_hashes = ExpectedHashesFor(relative); + if (!expected_hashes) { + return true; + } + + const uint64_t hash = XXH3_64bits(bytes.data(), bytes.size()); + const bool matches = + std::find(expected_hashes->begin(), expected_hashes->end(), hash) != + expected_hashes->end(); + if (!matches) { + EOT_WARN("[installer-ui] rejected {} from {} hash={}", + relative, source.string(), HexHash(hash)); + } + return matches; +} + +bool RegularFileMatchesHash(const std::filesystem::path& source, + const std::array& expected_hashes, + std::string* error) { + std::ifstream in(source, std::ios::binary); + if (!in) { + if (error) { + *error = "Unable to open " + source.string(); + } + return false; + } + std::vector bytes{std::istreambuf_iterator(in), + std::istreambuf_iterator()}; + const uint64_t hash = XXH3_64bits(bytes.data(), bytes.size()); + if (std::find(expected_hashes.begin(), expected_hashes.end(), hash) != + expected_hashes.end()) { + EOT_INFO("[installer-ui] verified package {} hash={}", + source.string(), HexHash(hash)); + return true; + } + if (error) { + *error = "The selected DLC package did not match the known Identity Crisis Suits package."; + } + EOT_ERROR("[installer-ui] DLC package hash verification failed file={} size={} hash={}", + source.string(), bytes.size(), HexHash(hash)); + return false; +} + +std::vector ReadBytes(const std::filesystem::path& path) { + std::ifstream file(path, std::ios::binary); + if (!file) { + return {}; + } + return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; +} + +std::vector& AudioPids() { + static std::vector pids; + return pids; +} + +void ReapInstallerAudio() { +#if defined(__APPLE__) + auto& pids = AudioPids(); + pids.erase(std::remove_if(pids.begin(), pids.end(), + [](pid_t pid) { + int status = 0; + const pid_t result = waitpid(pid, &status, WNOHANG); + return result == pid || result == -1; + }), + pids.end()); +#endif +} + +void HandleAudioShutdownSignal(int signal) { +#if defined(__APPLE__) + StopInstallerAudio(); + ::signal(signal, SIG_DFL); + raise(signal); +#else + (void)signal; +#endif +} + +void RegisterAudioShutdown() { +#if defined(__APPLE__) + static bool registered = false; + if (!registered) { + registered = true; + std::atexit([] { StopInstallerAudio(); }); + ::signal(SIGINT, HandleAudioShutdownSignal); + ::signal(SIGTERM, HandleAudioShutdownSignal); + ::signal(SIGHUP, HandleAudioShutdownSignal); + } +#endif +} + +void TrackInstallerAudio(pid_t pid) { +#if defined(__APPLE__) + if (pid <= 0) { + return; + } + RegisterAudioShutdown(); + ReapInstallerAudio(); + AudioPids().push_back(pid); +#else + (void)pid; +#endif +} + +int LaunchResourceAudio(const std::filesystem::path& relative_path) { +#if defined(__APPLE__) + const auto path = InstallerResourceRoot() / relative_path; + std::error_code ec; + if (!std::filesystem::is_regular_file(path, ec)) { + return -1; + } + const std::string path_string = path.string(); + const std::string parent_pid = std::to_string(getpid()); + char program[] = "/bin/sh"; + char script[] = + "trap 'kill \"$child\" 2>/dev/null; wait \"$child\" 2>/dev/null; exit 0' " + "TERM INT HUP EXIT; " + "afplay \"$1\" & child=$!; " + "while kill -0 \"$2\" 2>/dev/null && kill -0 \"$child\" 2>/dev/null; " + "do sleep 0.2; done; " + "kill \"$child\" 2>/dev/null; " + "wait \"$child\" 2>/dev/null"; + char arg0[] = "reeot-audio-supervisor"; + char* argv[] = {program, + const_cast("-c"), + script, + arg0, + const_cast(path_string.c_str()), + const_cast(parent_pid.c_str()), + nullptr}; + pid_t pid = -1; + const int result = posix_spawnp(&pid, program, nullptr, nullptr, argv, environ); + if (result != 0) { + EOT_WARN("[installer-ui] failed to start audio {}: {}", path.string(), result); + return -1; + } + TrackInstallerAudio(pid); + EOT_DEBUG("[installer-ui] started audio pid={} path={}", pid, path.string()); + return static_cast(pid); +#else + (void)relative_path; + return -1; +#endif +} + +std::string Truncate(std::string text, size_t max_chars) { + if (text.size() <= max_chars) { + return text; + } + return text.substr(0, max_chars / 2) + "..." + + text.substr(text.size() - (max_chars / 2)); +} + +std::filesystem::path SourceRootFor(const std::filesystem::path& selected) { + std::error_code ec; + if (std::filesystem::is_directory(selected, ec)) { + return selected; + } + return selected.parent_path(); +} + +std::vector SourceCandidates(const std::filesystem::path& selected) { + std::vector candidates; + candidates.push_back(selected); + + std::error_code ec; + if (std::filesystem::is_regular_file(selected, ec)) { + candidates.push_back(selected.parent_path()); + } else if (std::filesystem::is_directory(selected, ec)) { + for (const auto& entry : std::filesystem::directory_iterator(selected, ec)) { + if (entry.is_regular_file(ec) || entry.is_directory(ec)) { + candidates.push_back(entry.path()); + } + } + } + + return candidates; +} + +bool HasFile(const std::filesystem::path& root, const char* relative) { + return SourceHasFile(root, relative) && SourceFileMatchesKnownHash(root, relative); +} + +bool HasGameFiles(const std::filesystem::path& root) { + return HasFile(root, kEntrypointXex) && HasFile(root, kGameLogicDll); +} + +bool HasPatchFiles(const std::filesystem::path& root) { + return HasFile(root, kEntrypointPatch) && HasFile(root, kGameLogicPatch); +} + +bool HasDlcFiles(const std::filesystem::path& root) { + std::error_code ec; + if (std::filesystem::is_regular_file(root, ec)) { + std::string error; + return root.filename() == kDlcPackageFileName && + RegularFileMatchesHash(root, kKnownDlcPackageHashes, &error) && + SourceTotalSize(root) > 0; + } + + if (std::filesystem::is_directory(root, ec)) { + return SourceTotalSize(root) > 0; + } + return false; +} + +std::filesystem::path FindGameRoot(const std::filesystem::path& selected) { + for (const auto& root : SourceCandidates(selected)) { + if (HasGameFiles(root)) { + return root; + } + } + return {}; +} + +std::filesystem::path FindPatchRoot(const std::filesystem::path& selected) { + for (const auto& root : SourceCandidates(selected)) { + if (HasPatchFiles(root)) { + return root; + } + const auto update = root / "Update"; + if (HasPatchFiles(update)) { + return update; + } + } + return {}; +} + +std::filesystem::path FindDlcRoot(const std::filesystem::path& selected) { + for (const auto& root : SourceCandidates(selected)) { + if (HasDlcFiles(root)) { + return root; + } + } + return {}; +} + +bool CopyOne(const std::filesystem::path& source, const char* relative, + const std::filesystem::path& target_root, std::string* error) { + const auto target = target_root / std::filesystem::path(relative); + std::error_code ec; + std::filesystem::create_directories(target.parent_path(), ec); + if (ec) { + *error = "Failed to create directory: " + target.parent_path().string(); + return false; + } + + std::vector bytes; + if (!LoadSourceFile(source, relative, &bytes, error)) { + return false; + } + if (!VerifyFileHash(source, relative, bytes, error)) { + return false; + } + + std::ofstream out(target, std::ios::binary); + if (!out) { + *error = "Failed to create " + target.string(); + return false; + } + out.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (!out) { + *error = "Failed to write " + target.string(); + return false; + } + EOT_INFO("[installer-ui] copied {} from {} to {} ({} bytes)", + relative, source.string(), target.string(), bytes.size()); + return true; +} + +bool CreateRelativeDirectorySymlink(const std::filesystem::path& target, + const std::filesystem::path& link, + std::string* error) { + std::error_code ec; + std::filesystem::remove_all(link, ec); + if (ec) { + *error = "Failed to clean symlink target " + link.string() + ": " + ec.message(); + return false; + } + std::filesystem::create_directories(link.parent_path(), ec); + if (ec) { + *error = "Failed to create symlink parent " + link.parent_path().string() + + ": " + ec.message(); + return false; + } + const auto relative_target = std::filesystem::relative(target, link.parent_path(), ec); + if (ec) { + *error = "Failed to compute relative symlink target: " + ec.message(); + return false; + } + std::filesystem::create_directory_symlink(relative_target, link, ec); + if (ec) { + *error = "Failed to create symlink " + link.string() + " -> " + + relative_target.string() + ": " + ec.message(); + return false; + } + EOT_INFO("[installer-ui] created symlink {} -> {}", link.string(), + relative_target.string()); + return true; +} + +bool CopySourceTree(const std::filesystem::path& source, + const std::filesystem::path& target_root, + std::string* error) { + std::vector files; + if (!ListSourceFiles(source, &files, error)) { + return false; + } + if (files.empty()) { + *error = "No files were found in " + source.string(); + return false; + } + + EOT_INFO("[installer-ui] copying source tree source={} target={} files={} bytes={}", + source.string(), target_root.string(), files.size(), SourceTotalSize(source)); + for (const auto& file : files) { + const auto relative = CanonicalInstallRelative(file.path); + if (!CopyOne(source, relative.c_str(), target_root, error)) { + return false; + } + } + return true; +} + +bool CopyDlcSource(const std::filesystem::path& source, + const InstallLayout& layout, + std::string* error) { + if (source.empty()) { + EOT_INFO("[installer-ui] no DLC source selected; skipping DLC install"); + return true; + } + + const auto package_root = + layout.root / kCommonContentXuid / kTitleId / kMarketplaceContentType / + kDlcPackageFileName; + std::error_code ec; + std::filesystem::remove_all(package_root, ec); + if (ec) { + *error = "Failed to clean DLC directory: " + ec.message(); + return false; + } + + std::string hash_error; + if (std::filesystem::is_regular_file(source, ec) && + !RegularFileMatchesHash(source, kKnownDlcPackageHashes, &hash_error)) { + *error = hash_error; + return false; + } + + if (!CopySourceTree(source, package_root, error)) { + return false; + } + + std::filesystem::create_directories(layout.dlc, ec); + if (ec) { + *error = "Failed to create DLC directory: " + ec.message(); + return false; + } + if (!CreateRelativeDirectorySymlink(package_root, + layout.dlc / kDlcPackageFileName, + error)) { + return false; + } + if (!CreateRelativeDirectorySymlink(layout.dlc, layout.game / "DLC", error)) { + return false; + } + + EOT_INFO("[installer-ui] installed DLC source={} target={}", + source.string(), package_root.string()); + return true; +} + +bool PreparePatchedDirectory(const std::filesystem::path& game_root, + const std::filesystem::path& target_root, + std::string* error) { + std::error_code ec; + std::filesystem::remove_all(target_root, ec); + if (ec) { + *error = "Failed to clean patched directory: " + ec.message(); + return false; + } + std::filesystem::create_directories(target_root, ec); + if (ec) { + *error = "Failed to create patched directory: " + ec.message(); + return false; + } + + std::filesystem::create_directories(target_root / "Data", ec); + if (ec) { + *error = "Failed to create patched Data directory: " + ec.message(); + return false; + } + + const auto game_link = game_root / "patched"; + std::filesystem::remove_all(game_link, ec); + if (ec) { + *error = "Failed to clean game patched link: " + ec.message(); + return false; + } + std::filesystem::create_directory_symlink("../patched", game_link, ec); + if (ec) { + *error = "Failed to create game patched symlink: " + ec.message(); + return false; + } + EOT_INFO("[installer-ui] created game patched symlink {} -> ../patched", + game_link.string()); + + EOT_INFO("[installer-ui] prepared patched binary directory {}", target_root.string()); + return true; +} + +std::string PatcherResultText(XexPatcher::Result result) { + switch (result) { + case XexPatcher::Result::Success: + return "Success"; + case XexPatcher::Result::FileOpenFailed: + return "FileOpenFailed"; + case XexPatcher::Result::FileWriteFailed: + return "FileWriteFailed"; + case XexPatcher::Result::XexFileUnsupported: + return "XexFileUnsupported"; + case XexPatcher::Result::XexFileInvalid: + return "XexFileInvalid"; + case XexPatcher::Result::PatchFileInvalid: + return "PatchFileInvalid"; + case XexPatcher::Result::PatchIncompatible: + return "PatchIncompatible"; + case XexPatcher::Result::PatchFailed: + return "PatchFailed"; + case XexPatcher::Result::PatchUnsupported: + return "PatchUnsupported"; + } + return "Unknown"; +} + +bool ApplyPatchFile(const std::filesystem::path& base, + const std::filesystem::path& patch, + const std::filesystem::path& patched, + std::string* error) { + std::error_code ec; + std::filesystem::create_directories(patched.parent_path(), ec); + if (ec) { + *error = "Failed to create patched directory: " + ec.message(); + return false; + } + std::filesystem::remove(patched, ec); + ec.clear(); + + EOT_INFO("[installer-ui] applying patch base={} patch={} output={}", + base.string(), patch.string(), patched.string()); + const auto result = XexPatcher::apply(base, patch, patched); + if (result != XexPatcher::Result::Success) { + *error = "Patch process failed for " + base.filename().string() + + " using " + patch.filename().string() + ": " + + PatcherResultText(result); + EOT_ERROR("[installer-ui] patch failed base={} patch={} result={}", + base.string(), patch.string(), PatcherResultText(result)); + return false; + } + EOT_INFO("[installer-ui] patch succeeded output={}", patched.string()); + return true; +} + +bool CopyInstallSources(const SourceSelection& selection, const InstallLayout& layout, + std::string* error) { + if (selection.game.empty() || selection.update.empty()) { + *error = "Some of the files that have\nbeen provided are not valid.\n\nPlease make sure all the\nspecified files are correct\nand try again."; + return false; + } + + std::error_code ec; + std::filesystem::create_directories(layout.game, ec); + std::filesystem::create_directories(layout.update, ec); + if (ec) { + *error = "Failed to create install directories: " + ec.message(); + return false; + } + + if (!CopySourceTree(selection.game, layout.game, error) || + !CopyOne(selection.update, kGameLogicPatch, layout.update, error) || + !CopyOne(selection.update, kEntrypointPatch, layout.update, error) || + !CopyDlcSource(selection.dlc, layout, error) || + !PreparePatchedDirectory(layout.game, layout.patched, error)) { + return false; + } + + return ApplyPatchFile(layout.game / kEntrypointXex, + layout.update / kEntrypointPatch, + layout.patched / kPatchedEntrypointXex, + error) && + ApplyPatchFile(layout.game / kGameLogicDll, + layout.update / kGameLogicPatch, + layout.patched / kPatchedGameLogicDll, + error); +} + +std::vector RunNativePicker(bool folder_mode) { + std::vector paths; + + const nfdpathset_t* path_set = nullptr; + nfdresult_t result = folder_mode ? NFD_PickFolderMultipleN(&path_set, nullptr) + : NFD_OpenDialogMultipleN(&path_set, nullptr, 0, nullptr); + if (result != NFD_OKAY) { + if (result == NFD_ERROR) { + EOT_ERROR("[installer-ui] native picker failed: {}", NFD_GetError()); + } else { + EOT_INFO("[installer-ui] native picker canceled"); + } + return paths; + } + + nfdpathsetsize_t count = 0; + if (NFD_PathSet_GetCount(path_set, &count) == NFD_OKAY) { + for (nfdpathsetsize_t i = 0; i < count; ++i) { + nfdnchar_t* path = nullptr; + if (NFD_PathSet_GetPathN(path_set, i, &path) == NFD_OKAY && path) { + paths.emplace_back(path); + EOT_INFO("[installer-ui] native picker returned path={}", std::filesystem::path(path).string()); + NFD_PathSet_FreePathN(path); + } + } + } + NFD_PathSet_Free(path_set); + return paths; +} + +struct TextureAsset { + std::unique_ptr texture; + uint32_t width = 0; + uint32_t height = 0; + + explicit operator bool() const { return texture != nullptr; } +}; + +class InstallerDialog final : public rex::ui::ImGuiDialog { + public: + InstallerDialog(rex::ui::ImGuiDrawer* drawer, + rex::ui::ImmediateDrawer* immediate_drawer, + rex::PathConfig defaults, + std::filesystem::path config_path, StartupResult initial_result, + std::function resume) + : rex::ui::ImGuiDialog(drawer), + immediate_drawer_(immediate_drawer), + defaults_(std::move(defaults)), + config_path_(std::move(config_path)), + result_(std::move(initial_result)), + resume_(std::move(resume)) { + if (NFD_Init() == NFD_OKAY) { + nfd_ready_ = true; + } else { + EOT_ERROR("[installer-ui] NFD_Init failed: {}", NFD_GetError()); + } + } + + ~InstallerDialog() override { + StopAudio(); + } + + protected: + void OnClose() override { + StopAudio(); + if (nfd_ready_) { + NFD_Quit(); + nfd_ready_ = false; + } + EOT_INFO("[installer-ui] closed installer"); + } + + void OnDraw(ImGuiIO& io) override { + if (!has_appeared_) { + has_appeared_ = true; + appear_time_ = ImGui::GetTime(); + LoadTextures(); + PlayMusic(); + EOT_INFO("[installer-ui] showing Unleashed-style wizard target={}", + result_.install_layout.root.string()); + } + + UpdateCanvas(io.DisplaySize); + HandleKeyboard(); + + ImGui::SetNextWindowPos({0.0f, 0.0f}); + ImGui::SetNextWindowSize(io.DisplaySize); + ImGui::Begin("##reeot_installer_wizard", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoBringToFrontOnFocus); + + draw_ = ImGui::GetWindowDrawList(); + DrawBackground(); + DrawLeftImage(); + DrawScanlineBars(); + DrawDescriptionContainer(); + DrawPageControls(); + DrawInstallingProgress(); + DrawNavigationButton(); + DrawBorders(); + DrawButtonGuide(); + DrawMessagePrompt(); + + if (is_disappearing_ && ImGui::GetTime() > + disappear_time_ + kAllAnimationsFullDuration / 60.0 + + (is_quitting_ ? kQuittingExtraDuration / 60.0 : 0.0)) { + if (is_quitting_) { + Close(); + } else { + CompleteAndResume(); + } + } + + ImGui::End(); + } + + private: + void UpdateCanvas(ImVec2 display) { + scale_ = std::min(display.x / kCanvasWidth, display.y / kCanvasHeight); + if (scale_ <= 0.0f || !std::isfinite(scale_)) { + scale_ = 1.0f; + } + offset_ = {(display.x - kCanvasWidth * scale_) * 0.5f, + (display.y - kCanvasHeight * scale_) * 0.5f}; + } + + ImVec2 P(float x, float y) const { + return {offset_.x + x * scale_, offset_.y + y * scale_}; + } + float S(float value) const { return value * scale_; } + + float Motion(double offset_frames, double duration_frames) const { + const double elapsed_frames = (ImGui::GetTime() - appear_time_) * 60.0; + return EaseInOut(static_cast((elapsed_frames - offset_frames) / + duration_frames)); + } + + void HandleKeyboard() { + if (!message_.empty()) { + return; + } + if (ImGui::IsKeyPressed(ImGuiKey_Escape)) { + Back(); + } + if (ImGui::IsKeyPressed(ImGuiKey_Enter) || + ImGui::IsKeyPressed(ImGuiKey_KeypadEnter)) { + Next(); + } + } + + const char* HeaderText() const { + return page_ == WizardPage::Installing ? "INSTALLING" : "INSTALLER"; + } + + const char* PageText() const { + switch (page_) { + case WizardPage::SelectLanguage: + return "Please select a language."; + case WizardPage::Introduction: + return "Welcome to\nEdge of Time Recompiled!\n\nYou'll need an Xbox 360 copy\nof Spider-Man: Edge of Time in order to proceed with the installation."; + case WizardPage::SelectGameAndUpdate: + return "Add the sources for the game and its title update."; + case WizardPage::SelectDLC: + return "Add the source for the optional\nIdentity Crisis Suits DLC."; + case WizardPage::CheckSpace: + return "The content will be installed to the program's folder.\n\n"; + case WizardPage::Installing: + return "Please wait while the content is being installed..."; + case WizardPage::InstallSucceeded: + return "Installation complete!\nThis project is brought to you by:"; + case WizardPage::InstallFailed: + return "Installation failed.\n\nError: "; + } + return ""; + } + + void DrawBackground() { + draw_->AddRectFilled(P(0.0f, 0.0f), P(kCanvasWidth, kCanvasHeight), Color(0, 0, 0)); + } + + void DrawLeftImage() { + const float alpha = Motion(kImageAnimationTime, kImageAnimationDuration); + const ImVec2 min = P(kImageX, kImageY); + const ImVec2 max = P(kImageX + kImageWidth, kImageY + kImageHeight); + draw_->AddRectFilled(min, max, Color(3, 7, 4, static_cast(255 * alpha))); + + const int texture_index = TextureIndex(); + if (install_textures_[texture_index]) { + draw_->AddImage(reinterpret_cast(install_textures_[texture_index].texture.get()), + min, max, {0.0f, 0.0f}, {1.0f, 1.0f}, + Color(255, 255, 255, static_cast(255 * alpha))); + return; + } + + const ImU32 colors[] = { + Color(39, 124, 45, 180), Color(36, 90, 130, 180), Color(130, 104, 30, 180), + Color(86, 53, 135, 180), Color(33, 116, 90, 180), Color(135, 55, 38, 180), + Color(108, 108, 108, 180), Color(55, 80, 143, 180), + }; + draw_->AddRectFilledMultiColor(min, max, colors[texture_index], Color(0, 0, 0, 210), + Color(0, 0, 0, 240), colors[(texture_index + 2) % 8]); + DrawGrid(min, max, Color(203, 255, 0, static_cast(38 * alpha)), S(16.0f)); + + const ImVec2 center = {min.x + S(256.0f), min.y + S(256.0f)}; + const float pulse = 0.65f + 0.35f * std::sin(static_cast(ImGui::GetTime() * 4.0)); + for (int i = 0; i < 10; ++i) { + const float angle = (static_cast(i) / 10.0f + + static_cast(ImGui::GetTime()) * 0.035f) * + kPi * 2.0f; + draw_->AddLine(center, + {center.x + std::cos(angle) * S(230.0f), + center.y + std::sin(angle) * S(230.0f)}, + Color(203, 255, 0, static_cast(24 * alpha)), S(2.0f)); + } + draw_->AddCircle(center, S(80.0f + pulse * 14.0f), Color(203, 255, 0, 80), 96, + S(3.0f)); + DrawOutlinedText({center.x - S(45.0f), center.y - S(19.0f)}, "EOT", + Color(255, 195, 0, static_cast(255 * alpha)), 2.7f); + } + + void LoadTextures() { + if (!immediate_drawer_) { + EOT_WARN("[installer-ui] immediate drawer unavailable; installer images disabled"); + return; + } + for (size_t i = 0; i < install_textures_.size(); ++i) { + char name[64]; + std::snprintf(name, sizeof(name), "images/installer/raw/install_%03zu.png", i + 1); + install_textures_[i] = LoadPngTexture(name); + if (!install_textures_[i]) { + EOT_WARN("[installer-ui] failed to load installer texture {}", name); + } + } + ProbeDds("images/installer/install_001.dds"); + } + + TextureAsset LoadPngTexture(const std::filesystem::path& relative_path) { + TextureAsset asset; + const auto path = InstallerResourceRoot() / relative_path; + const auto bytes = ReadBytes(path); + if (bytes.empty()) { + return asset; + } + int width = 0; + int height = 0; + auto rgba = rex::ui::DecodeImageRGBA(bytes.data(), bytes.size(), width, height); + if (rgba.empty() || width <= 0 || height <= 0) { + return asset; + } + asset.texture = immediate_drawer_->CreateTexture( + static_cast(width), static_cast(height), + rex::ui::ImmediateTextureFilter::kLinear, false, rgba.data()); + if (asset.texture) { + asset.width = static_cast(width); + asset.height = static_cast(height); + } + return asset; + } + + void ProbeDds(const std::filesystem::path& relative_path) { + const auto bytes = ReadBytes(InstallerResourceRoot() / relative_path); + if (bytes.empty()) { + return; + } + ddspp::Descriptor descriptor{}; + if (ddspp::decode_header(bytes.data(), descriptor) == ddspp::Success) { + EOT_INFO("[installer-ui] DDS available {}: {}x{} format={} (compressed upload not " + "supported by ImmediateTexture)", + relative_path.string(), descriptor.width, descriptor.height, + static_cast(descriptor.format)); + } + } + + int TextureIndex() const { + static constexpr int kPageTexture[] = {0, 0, 1, 2, 3, 4, 7, 5}; + int index = kPageTexture[static_cast(page_)]; + if (page_ == WizardPage::Installing) { + index += static_cast((ImGui::GetTime() - install_start_time_) / 15.0); + } + return index % 8; + } + + void DrawScanlineBars() { + const float motion = Motion(kScanlinesAnimationTime, kScanlinesAnimationDuration); + const float height = S(105.0f) * motion; + if (height <= 0.0f) { + return; + } + const ImVec2 display = ImGui::GetIO().DisplaySize; + DrawScanlineBand({0.0f, 0.0f}, {display.x, height}, motion); + DrawScanlineBand({display.x, display.y}, {0.0f, display.y - height}, motion); + + const float title_alpha = Motion(kTitleAnimationTime, kTitleAnimationDuration); + const float breathe = + page_ == WizardPage::Installing + ? 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime() * 4.18)) + : 1.0f; + DrawOutlinedText(P(288.0f, 54.5f), HeaderText(), + Color(255, 195, 0, static_cast(255 * title_alpha * breathe)), + 3.2f); + DrawOutlinedText(P(256.0f, 80.0f), page_ == WizardPage::Installing ? "..." : "*", + Color(255, 255, 255, static_cast(235 * title_alpha)), 2.0f); + } + + void DrawScanlineBand(ImVec2 min, ImVec2 max, float alpha) { + draw_->AddRectFilledMultiColor(min, max, Color(203, 255, 0, 0), + Color(203, 255, 0, 0), + Color(203, 255, 0, static_cast(55 * alpha)), + Color(203, 255, 0, static_cast(55 * alpha))); + const float y0 = std::min(min.y, max.y); + const float y1 = std::max(min.y, max.y); + for (float y = y0; y <= y1; y += S(4.0f)) { + draw_->AddLine({0.0f, y}, {ImGui::GetIO().DisplaySize.x, y}, + Color(203, 255, 0, static_cast(35 * alpha)), S(1.0f)); + } + } + + void DrawDescriptionContainer() { + const ImVec2 desc_min = P(kContainerX + 0.5f, kContainerY + 0.5f); + const ImVec2 desc_max = + P(kContainerX + 0.5f + kContainerWidth, kContainerY + 0.5f + kContainerHeight); + const ImVec2 side_min = {desc_max.x, desc_min.y}; + const ImVec2 side_max = {ImGui::GetIO().DisplaySize.x, desc_max.y}; + DrawContainer(desc_min, desc_max, true); + DrawContainer(side_min, side_max, false); + + std::string text = PageText(); + if (page_ == WizardPage::CheckSpace) { + const double required = RequiredBytes() / (1024.0 * 1024.0 * 1024.0); + const double available = AvailableBytes() / (1024.0 * 1024.0 * 1024.0); + char extra[160]; + std::snprintf(extra, sizeof(extra), "Required space: %2.2f GiB\nAvailable space: %2.2f GiB", + required, available); + text += extra; + } else if (page_ == WizardPage::InstallFailed) { + text += install_error_; + } + + DrawWrappedText(P(kContainerX + 28.0f, kContainerY + 26.0f), text, + S(kContainerWidth - 50.0f), 1.35f, + Color(255, 255, 255, + static_cast(255 * Motion(kContainerInnerTime, + kContainerInnerDuration)))); + + if (page_ == WizardPage::InstallSucceeded) { + DrawOutlinedText(P(kContainerX + 176.0f, kContainerY + 150.0f), "hedge-dev", + Color(255, 255, 255), 1.35f); + DrawWrappedText(P(kContainerX + 20.0f, kContainerY + 205.0f), + "Tom Clay HyperBE32 goliath-tilers", S(kContainerWidth - 40.0f), + 0.85f, Color(255, 255, 255, 210)); + } + } + + void DrawContainer(ImVec2 min, ImVec2 max, bool text_area) { + const float alpha = Motion(text_area ? kContainerInnerTime : kContainerOuterTime, + text_area ? kContainerInnerDuration : kContainerOuterDuration); + draw_->AddRectFilled(min, max, + text_area ? Color(0, 32, 0, static_cast(128 * alpha)) + : Color(0, 33, 0, static_cast(180 * alpha))); + DrawGrid(min, max, Color(0, 80, 0, static_cast(115 * alpha)), S(kGridSize)); + } + + void DrawGrid(ImVec2 min, ImVec2 max, ImU32 color, float step) { + for (float x = min.x; x <= max.x; x += step) { + draw_->AddLine({x, min.y}, {x, max.y}, color, S(1.0f)); + } + for (float y = min.y; y <= max.y; y += step) { + draw_->AddLine({min.x, y}, {max.x, y}, color, S(1.0f)); + } + } + + void DrawPageControls() { + switch (page_) { + case WizardPage::SelectLanguage: + DrawLanguagePicker(); + break; + case WizardPage::SelectGameAndUpdate: + DrawSourcePickers(); + DrawSourceButton(0, 0, "GAME", !sources_.game.empty()); + DrawSourceButton(2, 0, "UPDATE", !sources_.update.empty()); + break; + case WizardPage::SelectDLC: + DrawSourcePickers(); + DrawDlcSources(); + break; + default: + break; + } + } + + void DrawLanguagePicker() { + static constexpr std::array kLanguageText = { + "FRANÇAIS", "DEUTSCH", "ENGLISH", "ESPAÑOL", "ITALIANO", "日本語"}; + for (int i = 0; i < 6; ++i) { + const int column = i < 3 ? 0 : 2; + const int row = i % 3; + bool pressed = false; + DrawButton(ButtonRect(column, row), kLanguageText[i], true, pressed, i == 2); + DrawToggleLight(ButtonLightPos(column, row), static_cast(language_) == i); + if (pressed) { + language_ = static_cast(i); + PlaySound("sys_worldmap_cursor"); + EOT_INFO("[installer-ui] language selected index={}", i); + } + } + } + + void DrawSourcePickers() { + bool add_files = false; + bool add_folder = false; + const float y = kContainerY + kContainerHeight + kBottomYGap; + DrawAutoWidthButton(P(kContainerX + kBottomXGap, y), "ADD FILES", add_files); + DrawAutoWidthButton(P(kContainerX + kBottomXGap + 150.0f, y), "ADD FOLDER", add_folder); + if (add_files) { + ShowPickerTutorial(false); + } + if (add_folder) { + ShowPickerTutorial(true); + } + } + + void DrawDlcSources() { + DrawSourceButton(0, 0, kDlcDisplayName, !sources_.dlc.empty()); + } + + ImVec2 ButtonMin(int column, int row) const { + float min_x = kContainerX + kContainerButtonGap; + if (column == 1) { + min_x = kContainerX + kContainerWidth / 2.0f - kContainerButtonWidth / 2.0f; + } else if (column == 2) { + min_x = kContainerX + kContainerWidth - kContainerButtonGap - kContainerButtonWidth; + } + const float minus_y = (kContainerButtonGap + kButtonHeight) * row; + return P(min_x, kContainerY + kContainerHeight - kContainerButtonGap - kButtonHeight - + minus_y); + } + + ImVec4 ButtonRect(int column, int row) const { + const ImVec2 min = ButtonMin(column, row); + return {min.x, min.y, min.x + S(kContainerButtonWidth), min.y + S(kButtonHeight)}; + } + + ImVec2 ButtonLightPos(int column, int row) const { + const ImVec2 min = ButtonMin(column, row); + return {min.x + S(14.0f), min.y + S(12.0f)}; + } + + void DrawSourceButton(int column, int row, const char* text, bool set) { + bool ignored = false; + DrawButton(ButtonRect(column, row), text, set, ignored, false, true); + DrawToggleLight(ButtonLightPos(column, row), set); + } + + void DrawInstallingProgress() { + if (page_ != WizardPage::Installing) { + return; + } + install_progress_ += std::min(install_progress_target_ - install_progress_, + 0.25f * ImGui::GetIO().DeltaTime); + DrawProgressBar(install_progress_); + if (install_finished_ && ImGui::GetTime() - install_start_time_ > 1.0) { + page_ = install_failed_ ? WizardPage::InstallFailed : WizardPage::InstallSucceeded; + install_end_time_ = ImGui::GetTime(); + } + } + + void DrawProgressBar(float ratio) { + const ImVec2 min = + P(kContainerX + kBottomXGap + 1.0f, kContainerY + kContainerHeight + kBottomYGap); + const ImVec2 max = P(kContainerX + kContainerWidth - kBottomXGap, + kContainerY + kContainerHeight + kBottomYGap + kButtonHeight); + DrawButtonContainer(min, max, true); + const ImVec2 inner_min = {min.x + S(5.0f), min.y + S(4.5f)}; + const ImVec2 inner_max = {max.x - S(5.0f), max.y - S(4.5f)}; + draw_->AddRectFilledMultiColor(inner_min, inner_max, Color(0, 65, 0), Color(0, 65, 0), + Color(0, 32, 0), Color(0, 32, 0)); + ImVec2 slider_max = inner_max; + slider_max.x = inner_min.x + (inner_max.x - inner_min.x) * Saturate(ratio); + draw_->AddRectFilledMultiColor(inner_min, slider_max, Color(57, 241, 0), + Color(57, 241, 0), Color(2, 106, 0), + Color(2, 106, 0)); + } + + void DrawNavigationButton() { + if (page_ == WizardPage::Installing) { + return; + } + std::string text = "NEXT"; + if (page_ == WizardPage::SelectDLC && DlcSelectionsEmpty()) { + text = "SKIP"; + } else if (page_ == WizardPage::InstallFailed) { + text = "RETRY"; + } + + const float width = std::max(86.0f, 18.0f + text.size() * 14.0f); + const ImVec2 min = + P(kContainerX + kContainerWidth - width - kBottomXGap, kContainerY + kContainerHeight + + kBottomYGap); + bool pressed = false; + DrawButton({min.x, min.y, min.x + S(width), min.y + S(kButtonHeight)}, text.c_str(), + NextEnabled(), pressed); + if (pressed) { + Next(); + } + } + + bool NextEnabled() const { + return !is_disappearing_ && + (page_ != WizardPage::SelectGameAndUpdate || + (!sources_.game.empty() && !sources_.update.empty())); + } + + void DrawBorders() { + const float border_motion = + 1.0f - Motion(kContainerLineAnimationTime, kContainerLineAnimationDuration); + const float mid_x = offset_.x + S(kContainerX + kContainerWidth / 5.0f); + const float min_x = std::lerp(offset_.x + S(kContainerX - kBorderSize - kBorderOvershoot), + mid_x, border_motion); + const float max_x = std::lerp( + offset_.x + S(kContainerX + kContainerWidth + kSideContainerWidth + kBorderOvershoot), + mid_x, border_motion); + const float top_y = offset_.y + S(kContainerY - kBorderSize); + const float bottom_y = offset_.y + S(kContainerY + kContainerHeight); + draw_->AddLine({min_x, top_y}, {max_x, top_y}, Color(155, 200, 155), S(1.0f)); + draw_->AddLine({min_x, bottom_y}, {max_x, bottom_y}, Color(155, 225, 155), S(1.0f)); + + const float mid_y = offset_.y + S(kContainerY + kContainerHeight / 2.0f); + const float min_y = std::lerp(offset_.y + S(kContainerY - kBorderOvershoot), mid_y, + border_motion); + const float max_y = std::lerp(offset_.y + S(kContainerY + kContainerHeight + kBorderOvershoot), + mid_y, border_motion); + draw_->AddLine(P(kContainerX - kBorderSize, kContainerY), {P(kContainerX, 0).x, max_y}, + Color(155, 155, 155), S(1.0f)); + draw_->AddLine({P(kContainerX + kContainerWidth, 0).x, min_y}, + {P(kContainerX + kContainerWidth, 0).x, max_y}, Color(155, 225, 155), + S(1.0f)); + } + + void DrawButtonGuide() { + const char* back = (page_ == first_page_ || page_ == WizardPage::InstallFailed) ? "QUIT" + : "BACK"; + if (page_ == WizardPage::Installing) { + back = "CANCEL"; + } + DrawOutlinedText(P(1010.0f, 650.0f), "SELECT", Color(210, 255, 130, 210), 0.9f); + DrawOutlinedText(P(1115.0f, 650.0f), back, Color(210, 255, 130, 210), 0.9f); + } + + void DrawMessagePrompt() { + if (message_.empty()) { + return; + } + drawing_message_prompt_ = true; + draw_->AddRectFilled(P(0, 0), P(kCanvasWidth, kCanvasHeight), Color(0, 0, 0, 190)); + const ImVec2 min = P(420.0f, 220.0f); + const ImVec2 max = P(860.0f, 500.0f); + draw_->AddRectFilled(min, max, Color(0, 28, 0, 245)); + DrawGrid(min, max, Color(0, 110, 0, 80), S(kGridSize)); + draw_->AddRect(min, max, Color(155, 225, 155), 0.0f, 0, S(1.0f)); + DrawWrappedText(P(450.0f, 250.0f), message_, S(380.0f), 1.05f, Color(255, 255, 255)); + bool yes = false; + bool no = false; + if (message_confirmation_) { + DrawButton({P(510, 450).x, P(510, 450).y, P(625, 472).x, P(625, 472).y}, "YES", + true, yes); + DrawButton({P(650, 450).x, P(650, 450).y, P(765, 472).x, P(765, 472).y}, "NO", + true, no); + } else { + DrawButton({P(585, 450).x, P(585, 450).y, P(700, 472).x, P(700, 472).y}, "OK", + true, yes); + } + if (yes || no) { + PlaySound(yes ? "sys_worldmap_finaldecide" : "sys_actstg_pausecansel"); + const bool accepted = yes; + const MessageSource source = message_source_; + EOT_INFO("[installer-ui] message dismissed accepted={} source={}", + accepted, static_cast(source)); + ClearMessage(); + if (accepted) { + if (source == MessageSource::Back) { + is_quitting_ = true; + BeginDisappear(); + } else if (source == MessageSource::Next) { + page_ = WizardPage::CheckSpace; + } else if (source == MessageSource::PickerTutorialFile) { + RunPicker(false); + } else if (source == MessageSource::PickerTutorialFolder) { + RunPicker(true); + } + } + } + drawing_message_prompt_ = false; + } + + void DrawAutoWidthButton(ImVec2 min, const char* text, bool& pressed) { + const float width = std::max(120.0f, 20.0f + std::strlen(text) * 11.0f); + DrawButton({min.x, min.y, min.x + S(width), min.y + S(kButtonHeight)}, text, true, + pressed); + } + + void DrawButton(ImVec4 rect, const char* text, bool enabled, bool& pressed, + bool make_default = false, bool source_button = false) { + pressed = false; + const ImVec2 min = {rect.x, rect.y}; + const ImVec2 max = {rect.z, rect.w}; + ImGui::PushID(text); + ImGui::SetCursorScreenPos(min); + if (enabled && !source_button && (message_.empty() || drawing_message_prompt_)) { + pressed = ImGui::InvisibleButton("##button", {max.x - min.x, max.y - min.y}); + } else { + ImGui::Dummy({max.x - min.x, max.y - min.y}); + } + const bool hovered = enabled && ImGui::IsItemHovered(); + if (hovered && hovered_button_ != text) { + hovered_button_ = text; + PlaySound("sys_worldmap_cursor"); + } + DrawButtonContainer(min, max, hovered || make_default); + const int alpha = enabled ? 255 : 128; + DrawOutlinedText({min.x + (max.x - min.x) * 0.5f, min.y + S(1.0f)}, text, + Color(255, 255, 255, alpha), source_button ? 0.82f : 1.0f, true); + ImGui::PopID(); + } + + void DrawButtonContainer(ImVec2 min, ImVec2 max, bool selected) { + const int baser = selected ? 48 : 0; + const int baseg = selected ? 32 : 0; + draw_->AddRectFilledMultiColor(min, max, Color(baser, baseg + 130, 0, 223), + Color(baser, baseg + 130, 0, 178), + Color(baser, baseg + 130, 0, 223), + Color(baser, baseg + 130, 0, 178)); + for (float y = min.y; y < max.y; y += S(4.0f)) { + draw_->AddLine({min.x, y}, {max.x, y}, Color(0, 0, 0, 35), S(1.0f)); + } + } + + void DrawToggleLight(ImVec2 center, bool enabled) { + draw_->AddCircleFilled(center, S(6.0f), + enabled ? Color(203, 255, 0, 255) : Color(60, 90, 35, 140), 24); + draw_->AddCircle(center, S(7.0f), Color(0, 0, 0, 220), 24, S(1.0f)); + } + + void DrawOutlinedText(ImVec2 pos, const char* text, ImU32 color, float font_scale, + bool centered = false) { + ImFont* font = ImGui::GetFont(); + const float size = ImGui::GetFontSize() * font_scale * scale_; + ImVec2 draw_pos = pos; + if (centered) { + const ImVec2 text_size = font->CalcTextSizeA(size, FLT_MAX, 0.0f, text); + draw_pos.x -= text_size.x * 0.5f; + } + const float o = std::max(1.0f, scale_); + draw_->AddText(font, size, {draw_pos.x - o, draw_pos.y}, Color(0, 0, 0), text); + draw_->AddText(font, size, {draw_pos.x + o, draw_pos.y}, Color(0, 0, 0), text); + draw_->AddText(font, size, {draw_pos.x, draw_pos.y - o}, Color(0, 0, 0), text); + draw_->AddText(font, size, {draw_pos.x, draw_pos.y + o}, Color(0, 0, 0), text); + draw_->AddText(font, size, draw_pos, color, text); + } + + void DrawWrappedText(ImVec2 pos, const std::string& text, float width, float font_scale, + ImU32 color) { + ImGui::PushFont(ImGui::GetFont()); + ImGui::SetCursorScreenPos(pos); + ImGui::PushTextWrapPos(pos.x + width); + ImGui::PushStyleColor(ImGuiCol_Text, color); + ImGui::SetWindowFontScale(font_scale * scale_); + ImGui::TextUnformatted(text.c_str()); + ImGui::SetWindowFontScale(1.0f); + ImGui::PopStyleColor(); + ImGui::PopTextWrapPos(); + ImGui::PopFont(); + } + + void Next() { + if (!NextEnabled() || !message_.empty()) { + return; + } + if (page_ == WizardPage::SelectDLC) { + PlaySound("sys_worldmap_finaldecide"); + const bool skip = DlcSelectionsEmpty(); + if (!skip) { + page_ = WizardPage::CheckSpace; + } else if (sources_.game.empty()) { + BeginDisappear(); + } else { + ShowMessage("The Identity Crisis Suits DLC\nwas not selected.\n\nAre you sure you want to\nskip this step?", + true, MessageSource::Next); + } + return; + } + if (page_ == WizardPage::CheckSpace) { + PlaySound("sys_worldmap_finaldecide"); + StartInstall(); + return; + } + if (page_ == WizardPage::InstallSucceeded) { + BeginDisappear(); + return; + } + if (page_ == WizardPage::InstallFailed) { + page_ = first_page_; + return; + } + page_ = static_cast(static_cast(page_) + 1); + PlaySound("sys_worldmap_finaldecide"); + } + + void Back() { + if (!message_.empty() || page_ == WizardPage::InstallSucceeded) { + return; + } + if (page_ == WizardPage::Installing) { + PlaySound("sys_actstg_pausecansel"); + ShowMessage("Are you sure you want to cancel the installation?", true, + MessageSource::Back); + return; + } + if (page_ == first_page_ || page_ == WizardPage::InstallFailed) { + PlaySound("sys_actstg_pausecansel"); + ShowMessage("Are you sure you want to quit?", true, MessageSource::Back); + return; + } + page_ = static_cast(static_cast(page_) - 1); + PlaySound("sys_actstg_pausecansel"); + } + + void ShowPickerTutorial(bool folder) { + EOT_INFO("[installer-ui] showing picker tutorial mode={}", folder ? "folder" : "file"); + ShowMessage(folder ? "Select a folder that contains the\nunmodified files that have been\nextracted from the game.\n\nThese files can be obtained from\nyour Xbox 360 hard drive by\nfollowing the instructions on\nthe GitHub page.\n\nFor choosing a digital dump,\nuse the \"Add Files\" option instead." + : "Select a digital dump with\ncontent from the game.\n\nThese files can be obtained from\nyour Xbox 360 hard drive by\nfollowing the instructions on\nthe GitHub page.\n\nFor choosing a folder with extracted\nand unmodified game files, use\nthe \"Add Folder\" option instead.", + false, folder ? MessageSource::PickerTutorialFolder + : MessageSource::PickerTutorialFile); + } + + void ShowMessage(std::string text, bool confirmation, MessageSource source) { + message_ = std::move(text); + message_confirmation_ = confirmation; + message_source_ = source; + } + + void ClearMessage() { + message_.clear(); + message_confirmation_ = false; + message_source_ = MessageSource::Unknown; + } + + void RunPicker(bool folder) { + EOT_INFO("[installer-ui] opening {} picker", folder ? "folder" : "file"); + if (!nfd_ready_) { + ShowMessage("The native file picker is not available.", false, + MessageSource::Unknown); + return; + } + auto paths = RunNativePicker(folder); + if (paths.empty()) { + EOT_INFO("[installer-ui] picker returned no paths"); + return; + } + + std::vector failed; + for (const auto& path : paths) { + if (page_ == WizardPage::SelectGameAndUpdate) { + if (auto game = FindGameRoot(path); !game.empty()) { + sources_.game = game; + EOT_INFO("[installer-ui] selected game source={}", game.string()); + } else if (auto update = FindPatchRoot(path); !update.empty()) { + sources_.update = update; + EOT_INFO("[installer-ui] selected update source={}", update.string()); + } else { + failed.push_back(path); + } + } else if (page_ == WizardPage::SelectDLC) { + if (auto dlc = FindDlcRoot(path); !dlc.empty()) { + sources_.dlc = dlc; + EOT_INFO("[installer-ui] selected DLC source={}", dlc.string()); + } else { + failed.push_back(path); + } + } + } + + if (!failed.empty()) { + std::stringstream ss; + ss << "The following selected files are invalid:"; + for (size_t i = 0; i < std::min(failed.size(), 5); ++i) { + ss << "\n\n- " << Truncate(failed[i].filename().string(), 32); + } + if (failed.size() > 5) { + ss << "\n- [...]"; + } + ShowMessage(ss.str(), false, MessageSource::Unknown); + } + } + + bool DlcSelectionsEmpty() const { + return sources_.dlc.empty(); + } + + uintmax_t RequiredBytes() const { + if (cached_game_size_source_ != sources_.game) { + cached_game_size_source_ = sources_.game; + cached_game_size_ = sources_.game.empty() ? 0 : SourceTotalSize(sources_.game); + } + if (cached_update_size_source_ != sources_.update) { + cached_update_size_source_ = sources_.update; + cached_update_size_ = sources_.update.empty() + ? 0 + : SourceFileSize(sources_.update, kEntrypointPatch) + + SourceFileSize(sources_.update, kGameLogicPatch); + } + static constexpr uintmax_t kPatchScratchBytes = 512ull * 1024ull * 1024ull; + return cached_game_size_ + cached_update_size_ + kPatchScratchBytes + + SourceFileSize(sources_.game, kEntrypointXex) + + SourceFileSize(sources_.game, kGameLogicDll) + + (sources_.dlc.empty() ? 0 : SourceTotalSize(sources_.dlc)); + } + + uintmax_t AvailableBytes() const { + std::error_code ec; + const auto info = std::filesystem::space(result_.install_layout.root, ec); + return ec ? 0 : info.available; + } + + void StartInstall() { + page_ = WizardPage::Installing; + PlaySound("sys_actstg_pausedecide"); + install_start_time_ = ImGui::GetTime(); + install_progress_ = 0.0f; + install_progress_target_ = 0.25f; + install_finished_ = false; + install_failed_ = false; + install_error_.clear(); + + std::string error; + EOT_INFO("[installer-ui] installing sources game={} update={} dlc={} target={}", + sources_.game.string(), sources_.update.string(), sources_.dlc.string(), + result_.install_layout.root.string()); + install_progress_target_ = 0.65f; + if (!CopyInstallSources(sources_, result_.install_layout, &error)) { + install_failed_ = true; + install_error_ = error; + EOT_ERROR("[installer-ui] install failed: {}", error); + } else { + InstallManifest manifest; + manifest.installed = true; + manifest.runtime_root = result_.install_layout.game.string(); + manifest.note = "Installed by the ReEoT first-run installer."; + if (!SaveInstallManifest(result_.install_layout.manifest, manifest, &error)) { + install_failed_ = true; + install_error_ = error; + EOT_ERROR("[installer-ui] manifest write failed: {}", error); + } + } + + if (!install_failed_) { + rex::cvar::SetFlagByName(kCvEotDataRoot, result_.install_layout.game.string()); + REXCVAR_SET(skip_installer, true); + rex::cvar::SaveConfig(config_path_); + result_ = PrepareStartupPaths(defaults_, config_path_); + if (!result_.ready) { + install_failed_ = true; + install_error_ = result_.message; + EOT_ERROR("[installer-ui] install validation failed: {}", result_.message); + } + } + install_progress_target_ = 1.0f; + install_finished_ = true; + } + + void BeginDisappear() { + is_disappearing_ = true; + disappear_time_ = ImGui::GetTime(); + PlaySound("sys_actstg_pausewinclose"); + } + + void PlayMusic() { + if (music_started_) { + return; + } + music_started_ = true; + TrackAudio(LaunchResourceAudio("music/raw/installer.wav")); + } + + void PlaySound(const char* name) { + if (!name || !*name) { + return; + } + if (std::strcmp(name, "sys_worldmap_cursor") == 0) { + const double now = ImGui::GetTime(); + if (now - last_cursor_sound_time_ < 0.08) { + return; + } + last_cursor_sound_time_ = now; + } + TrackAudio(LaunchResourceAudio(std::filesystem::path("sounds/raw") / + (std::string(name) + ".wav"))); + } + + void TrackAudio(int pid) { + (void)pid; + } + + void StopAudio() { + StopInstallerAudio(); + } + + void CompleteAndResume() { + if (result_.ready) { + auto paths = result_.paths; + Close(); + if (resume_) { + resume_(std::move(paths)); + } + } else { + Close(); + } + } + + rex::ui::ImmediateDrawer* immediate_drawer_ = nullptr; + rex::PathConfig defaults_; + std::filesystem::path config_path_; + StartupResult result_; + std::function resume_; + ImDrawList* draw_ = nullptr; + ImVec2 offset_ = {}; + float scale_ = 1.0f; + double appear_time_ = 0.0; + double disappear_time_ = 0.0; + double install_start_time_ = 0.0; + double install_end_time_ = 0.0; + float install_progress_ = 0.0f; + float install_progress_target_ = 0.0f; + bool install_finished_ = false; + bool install_failed_ = false; + bool has_appeared_ = false; + bool is_disappearing_ = false; + bool is_quitting_ = false; + bool nfd_ready_ = false; + bool music_started_ = false; + bool drawing_message_prompt_ = false; + double last_cursor_sound_time_ = 0.0; + WizardPage first_page_ = WizardPage::SelectLanguage; + WizardPage page_ = WizardPage::SelectLanguage; + Language language_ = Language::English; + SourceSelection sources_; + mutable std::filesystem::path cached_game_size_source_; + mutable std::filesystem::path cached_update_size_source_; + mutable uintmax_t cached_game_size_ = 0; + mutable uintmax_t cached_update_size_ = 0; + std::string install_error_; + std::string message_; + std::string hovered_button_; + MessageSource message_source_ = MessageSource::Unknown; + bool message_confirmation_ = false; + std::array install_textures_; +}; + +} // namespace + +void ShowInstallerDialog(rex::ui::ImGuiDrawer* drawer, + rex::ui::ImmediateDrawer* immediate_drawer, + rex::PathConfig defaults, + std::filesystem::path config_path, + StartupResult initial_result, + std::function resume) { + if (!drawer) { + EOT_ERROR("[installer-ui] cannot show installer without ImGui drawer"); + return; + } + new InstallerDialog(drawer, immediate_drawer, std::move(defaults), + std::move(config_path), std::move(initial_result), + std::move(resume)); +} + +void StopInstallerAudio() { +#if defined(__APPLE__) + ReapInstallerAudio(); + auto& pids = AudioPids(); + if (pids.empty()) { + return; + } + + for (pid_t pid : pids) { + EOT_DEBUG("[installer-ui] stopping audio pid={}", pid); + kill(pid, SIGTERM); + } + + for (int attempt = 0; attempt < 20; ++attempt) { + ReapInstallerAudio(); + if (pids.empty()) { + return; + } + usleep(10000); + } + + for (pid_t pid : pids) { + EOT_WARN("[installer-ui] force stopping audio pid={}", pid); + kill(pid, SIGKILL); + } + + for (pid_t pid : pids) { + int status = 0; + waitpid(pid, &status, 0); + } + pids.clear(); +#endif +} + +} // namespace eot::installer diff --git a/src/goliath_engine/installer/installer_dialog.h b/src/goliath_engine/installer/installer_dialog.h new file mode 100644 index 00000000..57cccbd5 --- /dev/null +++ b/src/goliath_engine/installer/installer_dialog.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include "goliath_engine/installer/installer.h" + +namespace eot::installer { + +void ShowInstallerDialog(rex::ui::ImGuiDrawer* drawer, + rex::ui::ImmediateDrawer* immediate_drawer, + rex::PathConfig defaults, + std::filesystem::path config_path, + StartupResult initial_result, + std::function resume); + +void StopInstallerAudio(); + +} // namespace eot::installer From 874a192972c5968bbf99bd967715aefbc030687a Mon Sep 17 00:00:00 2001 From: Graine25 Date: Wed, 8 Jul 2026 15:32:03 -0700 Subject: [PATCH 08/11] feat: wire installer startup --- .gitignore | 6 +++ CMakeLists.txt | 40 ++++++++++++++++- src/reeot_app.h | 113 ++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 153 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 0ba1c630..1d672515 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,9 @@ vcpkg_installed/ # test output & cache Testing/ .cache/ +/ref_installer +SDKWIKI.md +game_iso/Spider-Man - Edge of Time (USA, Europe).iso +game_iso/Vimm's Lair.txt +game_tu/TU_10LC25I_0000008000000.0000000000082 +game_dlc/564E216B8B02133E5F0FFB3D2E3CD412CBA2D0FF41 diff --git a/CMakeLists.txt b/CMakeLists.txt index 88c25581..8ea9b67a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,16 +3,42 @@ # SDK boilerplate lives in generated/rexglue.cmake. cmake_minimum_required(VERSION 3.25) -project(reeot LANGUAGES CXX) +project(reeot LANGUAGES C CXX OBJC) set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) include(generated/rexglue.cmake) +set(NFD_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(NFD_INSTALL OFF CACHE BOOL "" FORCE) +add_subdirectory(thirdparty/nativefiledialog-extended) +add_library(ddspp INTERFACE) +target_include_directories(ddspp INTERFACE thirdparty/ddspp) + +add_library(xenon_patcher STATIC + thirdparty/xenon_utils/xex_patcher.cpp + thirdparty/xenon_utils/memory_mapped_file.cpp + thirdparty/libmspack/libmspack/mspack/lzxd.c + thirdparty/tiny-AES-c/aes.c +) +target_include_directories(xenon_patcher PUBLIC + thirdparty/xenon_utils + thirdparty/libmspack/libmspack/mspack + thirdparty/tiny-AES-c + thirdparty/TinySHA1 +) + # Sources set(REEOT_SOURCES src/main.cpp + src/goliath_engine/common/cvars.cpp + src/goliath_engine/common/misc.cpp # Eventually merge this into cvars.cpp + src/goliath_engine/installer/installer_dialog.cpp + src/goliath_engine/installer/install_manifest.cpp + src/goliath_engine/installer/install_paths.cpp + src/goliath_engine/installer/installer.cpp + src/goliath_engine/installer/source_vfs.cpp ) if(WIN32) @@ -21,4 +47,14 @@ else() add_executable(reeot ${REEOT_SOURCES}) endif() -rexglue_setup_target(reeot) +rexglue_setup_target(reeot GPU_PLUGINS xenos) +target_link_libraries(reeot PRIVATE nfd::nfd ddspp xenon_patcher) + +if(APPLE AND TARGET reeot_GameLogic) + add_custom_command(TARGET reeot_GameLogic POST_BUILD + COMMAND ${CMAKE_COMMAND} -E create_symlink + "$" + "$/reeot_GameLogic" + COMMENT "Creating macOS dlopen alias for reeot_GameLogic" + ) +endif() diff --git a/src/reeot_app.h b/src/reeot_app.h index 5ad9a3fd..8a915e3f 100644 --- a/src/reeot_app.h +++ b/src/reeot_app.h @@ -5,7 +5,22 @@ #pragma once +#include +#include +#include +#include +#include + +#include #include +#include +#include + +#include "goliath_engine/common/cvars.h" +#include "goliath_engine/common/logging.h" +#include "goliath_engine/installer/install_constants.h" +#include "goliath_engine/installer/installer_dialog.h" +#include "goliath_engine/installer/installer.h" class ReeotApp : public rex::ReXApp { public: @@ -17,11 +32,101 @@ class ReeotApp : public rex::ReXApp { PPCImageConfig)); } + std::optional OnFinalizePaths( + const rex::PathConfig& defaults, + std::function resume) override { + (void)resume; + const auto config_path = eot::installer::InstallerConfigPath(); + EOT_INFO("[app] finalizing paths. config={}", config_path.string()); + auto startup = eot::installer::PrepareStartupPaths(defaults, config_path); + if (!startup.ready) { + EOT_WARN("[app] startup blocked by installer: {}", startup.message); + if (imgui_drawer_) { + eot::installer::ShowInstallerDialog(imgui_drawer_, immediate_drawer(), defaults, + config_path, std::move(startup), + std::move(resume)); + } else { + EOT_ERROR("[app] ImGui drawer unavailable; falling back to native message box"); + rex::ShowSimpleMessageBox(rex::SimpleMessageBoxType::Error, + startup.message); + } + return std::nullopt; + } + EOT_INFO("[app] startup paths ready. game={} update={} user={}", + startup.paths.game_data_root.string(), + startup.paths.update_data_root.string(), + startup.paths.user_data_root.string()); + return std::move(startup.paths); + } + + void OnLoadXexImage(std::string& xex_image) override { + const auto root = std::filesystem::path( + rex::cvar::GetFlagByName(kCvEotDataRoot)); + if (!root.empty()) { + const auto mounted_patched = root / "patched" / + eot::installer::kPatchedEntrypointXex; + const auto local_patched = root / eot::installer::kPatchedEntrypointXex; + std::error_code ec; + if (std::filesystem::is_regular_file(mounted_patched, ec)) { + xex_image = eot::installer::kMountedPatchedEntrypointXex; + EOT_INFO("[app] loading patched XEX image: {}", xex_image); + return; + } + ec.clear(); + if (std::filesystem::is_regular_file(local_patched, ec)) { + xex_image = eot::installer::kMountedLocalPatchedEntrypointXex; + EOT_INFO("[app] loading patched XEX image: {}", xex_image); + return; + } + } + + xex_image = eot::installer::kMountedEntrypointXex; + EOT_INFO("[app] loading default XEX image: {}", xex_image); + } + // Override virtual hooks for customization: - // void OnPreSetup(rex::RuntimeConfig& config) override {} - // void OnLoadXexImage(std::string& xex_image) override {} + void OnPreSetup(rex::RuntimeConfig& config) override { + if (config.gpu_plugin.empty()) { + config.gpu_plugin = "xenos"; + EOT_INFO("[app] gpu_plugin was empty; using xenos so installer UI can render"); + } + } // void OnPostSetup() override {} - // void OnCreateDialogs(rex::ui::ImGuiDrawer* drawer) override {} - // void OnShutdown() override {} + void OnCreateDialogs(rex::ui::ImGuiDrawer* drawer) override { + EOT_INFO("[app] ImGui drawer created for installer dialogs"); + imgui_drawer_ = drawer; + } + void OnShutdown() override { + eot::installer::StopInstallerAudio(); + } + void OnConfigureFonts(ImFontAtlas* atlas) override { + const auto font_root = eot::installer::InstallerFontRoot(); + auto font_path = font_root / eot::installer::kSeuratFontFile; + const bool using_seurat = std::filesystem::is_regular_file(font_path); + if (!using_seurat) { + font_path = font_root / eot::installer::kFallbackFontFile; + } + if (std::filesystem::is_regular_file(font_path)) { + ImFontConfig config; + config.OversampleH = config.OversampleV = 1; + config.PixelSnapH = true; + config.MergeMode = false; + config.Name[0] = '\0'; + std::snprintf(config.Name, sizeof(config.Name), "%s", + using_seurat ? eot::installer::kSeuratFontFile + : eot::installer::kFallbackFontFile); + atlas->Clear(); + atlas->AddFontFromFileTTF(font_path.string().c_str(), 20.0f, &config, + atlas->GetGlyphRangesDefault()); + EOT_INFO("[app] configured installer font: {}{}", + font_path.string(), using_seurat ? "" : " (fallback)"); + } else { + EOT_WARN("[app] installer font not found under {}; using ReXGlue default font", + font_root.string()); + } + } // void OnConfigurePaths(rex::PathConfig& paths) override {} + + private: + rex::ui::ImGuiDrawer* imgui_drawer_ = nullptr; }; From 4468f5e76495b7911d977387a4881b51194711b5 Mon Sep 17 00:00:00 2001 From: Graine25 Date: Fri, 10 Jul 2026 13:03:26 -0700 Subject: [PATCH 09/11] fix: correct STFS block offset, ISO magic sector, and vfs issue --- .../installer/installer_dialog.cpp | 221 +++++++++++++++--- src/goliath_engine/installer/source_vfs.cpp | 5 +- 2 files changed, 196 insertions(+), 30 deletions(-) diff --git a/src/goliath_engine/installer/installer_dialog.cpp b/src/goliath_engine/installer/installer_dialog.cpp index 4b33c076..bfd03bdc 100644 --- a/src/goliath_engine/installer/installer_dialog.cpp +++ b/src/goliath_engine/installer/installer_dialog.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include #include +#include #include #include @@ -457,6 +459,48 @@ std::filesystem::path FindDlcRoot(const std::filesystem::path& selected) { return {}; } +// Tracks the top-level directories an install run has (re)created from +// scratch, so a failed or cancelled run can be rolled back to the +// pre-install state by deleting each one wholesale. +struct InstallJournal { + std::vector created_roots; +}; + +void RecordInstallRoot(InstallJournal* journal, std::filesystem::path root) { + if (journal) { + journal->created_roots.push_back(std::move(root)); + } +} + +void RollbackInstall(const InstallJournal& journal) { + std::error_code ec; + for (const auto& root : journal.created_roots) { + EOT_INFO("[installer-ui] rolling back {}", root.string()); + std::filesystem::remove_all(root, ec); + } +} + +// Shared between the UI thread and the background install thread: progress +// and cancellation are polled every frame, so they must be atomic. +struct InstallControl { + std::atomic cancel_requested{false}; + std::atomic halted{false}; + std::atomic progress{0.0f}; + + // Returns false if the install should stop as soon as possible. Blocks + // while the UI is showing the "cancel installation?" confirmation so the + // copy doesn't race ahead of a decision the user hasn't made yet. + bool ShouldContinue() { + halted.wait(true); + return !cancel_requested.load(); + } + + void SetProgress(float range_start, float range_end, size_t done, size_t total) { + const float local = total > 0 ? static_cast(done) / static_cast(total) : 1.0f; + progress.store(range_start + (range_end - range_start) * local); + } +}; + bool CopyOne(const std::filesystem::path& source, const char* relative, const std::filesystem::path& target_root, std::string* error) { const auto target = target_root / std::filesystem::path(relative); @@ -523,6 +567,7 @@ bool CreateRelativeDirectorySymlink(const std::filesystem::path& target, bool CopySourceTree(const std::filesystem::path& source, const std::filesystem::path& target_root, + InstallControl* control, float progress_start, float progress_end, std::string* error) { std::vector files; if (!ListSourceFiles(source, &files, error)) { @@ -535,17 +580,26 @@ bool CopySourceTree(const std::filesystem::path& source, EOT_INFO("[installer-ui] copying source tree source={} target={} files={} bytes={}", source.string(), target_root.string(), files.size(), SourceTotalSize(source)); - for (const auto& file : files) { - const auto relative = CanonicalInstallRelative(file.path); + for (size_t i = 0; i < files.size(); ++i) { + if (control && !control->ShouldContinue()) { + *error = "Installation was cancelled."; + return false; + } + const auto relative = CanonicalInstallRelative(files[i].path); if (!CopyOne(source, relative.c_str(), target_root, error)) { return false; } + if (control) { + control->SetProgress(progress_start, progress_end, i + 1, files.size()); + } } return true; } bool CopyDlcSource(const std::filesystem::path& source, const InstallLayout& layout, + InstallControl* control, InstallJournal* journal, + float progress_start, float progress_end, std::string* error) { if (source.empty()) { EOT_INFO("[installer-ui] no DLC source selected; skipping DLC install"); @@ -561,6 +615,7 @@ bool CopyDlcSource(const std::filesystem::path& source, *error = "Failed to clean DLC directory: " + ec.message(); return false; } + RecordInstallRoot(journal, package_root); std::string hash_error; if (std::filesystem::is_regular_file(source, ec) && @@ -569,10 +624,16 @@ bool CopyDlcSource(const std::filesystem::path& source, return false; } - if (!CopySourceTree(source, package_root, error)) { + if (!CopySourceTree(source, package_root, control, progress_start, progress_end, error)) { return false; } + std::filesystem::remove_all(layout.dlc, ec); + if (ec) { + *error = "Failed to clean DLC directory: " + ec.message(); + return false; + } + RecordInstallRoot(journal, layout.dlc); std::filesystem::create_directories(layout.dlc, ec); if (ec) { *error = "Failed to create DLC directory: " + ec.message(); @@ -594,6 +655,7 @@ bool CopyDlcSource(const std::filesystem::path& source, bool PreparePatchedDirectory(const std::filesystem::path& game_root, const std::filesystem::path& target_root, + InstallJournal* journal, std::string* error) { std::error_code ec; std::filesystem::remove_all(target_root, ec); @@ -601,6 +663,7 @@ bool PreparePatchedDirectory(const std::filesystem::path& game_root, *error = "Failed to clean patched directory: " + ec.message(); return false; } + RecordInstallRoot(journal, target_root); std::filesystem::create_directories(target_root, ec); if (ec) { *error = "Failed to create patched directory: " + ec.message(); @@ -684,13 +747,21 @@ bool ApplyPatchFile(const std::filesystem::path& base, } bool CopyInstallSources(const SourceSelection& selection, const InstallLayout& layout, + InstallControl* control, InstallJournal* journal, std::string* error) { if (selection.game.empty() || selection.update.empty()) { *error = "Some of the files that have\nbeen provided are not valid.\n\nPlease make sure all the\nspecified files are correct\nand try again."; return false; } + // Start every install attempt from a clean slate: a prior failed or + // cancelled attempt may have left a partial/mismatched tree behind, and + // reusing it would let stale files leak into the new install. std::error_code ec; + std::filesystem::remove_all(layout.game, ec); + std::filesystem::remove_all(layout.update, ec); + RecordInstallRoot(journal, layout.game); + RecordInstallRoot(journal, layout.update); std::filesystem::create_directories(layout.game, ec); std::filesystem::create_directories(layout.update, ec); if (ec) { @@ -698,22 +769,71 @@ bool CopyInstallSources(const SourceSelection& selection, const InstallLayout& l return false; } - if (!CopySourceTree(selection.game, layout.game, error) || - !CopyOne(selection.update, kGameLogicPatch, layout.update, error) || - !CopyOne(selection.update, kEntrypointPatch, layout.update, error) || - !CopyDlcSource(selection.dlc, layout, error) || - !PreparePatchedDirectory(layout.game, layout.patched, error)) { + if (control && !control->ShouldContinue()) { + *error = "Installation was cancelled."; + return false; + } + + if (!CopySourceTree(selection.game, layout.game, control, 0.05f, 0.65f, error)) { + return false; + } + if (!CopyOne(selection.update, kGameLogicPatch, layout.update, error) || + !CopyOne(selection.update, kEntrypointPatch, layout.update, error)) { + return false; + } + if (control) { + control->SetProgress(0.65f, 0.72f, 1, 1); + } + + if (!CopyDlcSource(selection.dlc, layout, control, journal, 0.72f, 0.90f, error)) { + return false; + } + + if (control && !control->ShouldContinue()) { + *error = "Installation was cancelled."; + return false; + } + + if (!PreparePatchedDirectory(layout.game, layout.patched, journal, error)) { return false; } - return ApplyPatchFile(layout.game / kEntrypointXex, - layout.update / kEntrypointPatch, - layout.patched / kPatchedEntrypointXex, - error) && - ApplyPatchFile(layout.game / kGameLogicDll, - layout.update / kGameLogicPatch, - layout.patched / kPatchedGameLogicDll, - error); + const bool patched = ApplyPatchFile(layout.game / kEntrypointXex, + layout.update / kEntrypointPatch, + layout.patched / kPatchedEntrypointXex, + error) && + ApplyPatchFile(layout.game / kGameLogicDll, + layout.update / kGameLogicPatch, + layout.patched / kPatchedGameLogicDll, + error); + if (control) { + control->SetProgress(0.90f, 1.0f, 1, 1); + } + return patched; +} + +// Result of a background install run. `finished` is the synchronization +// point: the worker thread writes `failed`/`error`/`journal` before storing +// it, and the UI thread must not read them until it observes `finished` as +// true (release/acquire via the atomic). +struct InstallOutcome { + std::atomic finished{false}; + bool failed = false; + std::string error; + InstallJournal journal; +}; + +void RunInstallAsync(SourceSelection selection, InstallLayout layout, + std::shared_ptr control, + std::shared_ptr outcome) { + std::string error; + if (!CopyInstallSources(selection, layout, control.get(), &outcome->journal, &error)) { + EOT_ERROR("[installer-ui] install failed: {}", error); + RollbackInstall(outcome->journal); + outcome->failed = true; + outcome->error = std::move(error); + } + outcome->finished.store(true); } std::vector RunNativePicker(bool folder_mode) { @@ -775,6 +895,14 @@ class InstallerDialog final : public rex::ui::ImGuiDialog { } ~InstallerDialog() override { + if (install_thread_ && install_thread_->joinable()) { + if (install_control_) { + install_control_->cancel_requested.store(true); + install_control_->halted.store(false); + install_control_->halted.notify_all(); + } + install_thread_->join(); + } StopAudio(); } @@ -1175,6 +1303,16 @@ class InstallerDialog final : public rex::ui::ImGuiDialog { if (page_ != WizardPage::Installing) { return; } + if (install_outcome_ && install_outcome_->finished.load() && !install_finished_) { + if (install_thread_ && install_thread_->joinable()) { + install_thread_->join(); + } + install_thread_.reset(); + FinishInstall(); + } + if (install_control_) { + install_progress_target_ = install_control_->progress.load(); + } install_progress_ += std::min(install_progress_target_ - install_progress_, 0.25f * ImGui::GetIO().DeltaTime); DrawProgressBar(install_progress_); @@ -1296,7 +1434,17 @@ class InstallerDialog final : public rex::ui::ImGuiDialog { EOT_INFO("[installer-ui] message dismissed accepted={} source={}", accepted, static_cast(source)); ClearMessage(); - if (accepted) { + if (source == MessageSource::Back && page_ == WizardPage::Installing) { + if (accepted && install_control_) { + install_control_->cancel_requested.store(true); + } + // Regardless of the answer, the install thread must be resumed: it + // was halted the moment this confirmation was raised. + if (install_control_) { + install_control_->halted.store(false); + install_control_->halted.notify_all(); + } + } else if (accepted) { if (source == MessageSource::Back) { is_quitting_ = true; BeginDisappear(); @@ -1400,8 +1548,6 @@ class InstallerDialog final : public rex::ui::ImGuiDialog { const bool skip = DlcSelectionsEmpty(); if (!skip) { page_ = WizardPage::CheckSpace; - } else if (sources_.game.empty()) { - BeginDisappear(); } else { ShowMessage("The Identity Crisis Suits DLC\nwas not selected.\n\nAre you sure you want to\nskip this step?", true, MessageSource::Next); @@ -1430,7 +1576,14 @@ class InstallerDialog final : public rex::ui::ImGuiDialog { return; } if (page_ == WizardPage::Installing) { + if (install_control_ && install_control_->cancel_requested.load()) { + // Already cancelling; no need to ask again. + return; + } PlaySound("sys_actstg_pausecansel"); + if (install_control_) { + install_control_->halted.store(true); + } ShowMessage("Are you sure you want to cancel the installation?", true, MessageSource::Back); return; @@ -1530,8 +1683,6 @@ class InstallerDialog final : public rex::ui::ImGuiDialog { } static constexpr uintmax_t kPatchScratchBytes = 512ull * 1024ull * 1024ull; return cached_game_size_ + cached_update_size_ + kPatchScratchBytes + - SourceFileSize(sources_.game, kEntrypointXex) + - SourceFileSize(sources_.game, kGameLogicDll) + (sources_.dlc.empty() ? 0 : SourceTotalSize(sources_.dlc)); } @@ -1546,25 +1697,34 @@ class InstallerDialog final : public rex::ui::ImGuiDialog { PlaySound("sys_actstg_pausedecide"); install_start_time_ = ImGui::GetTime(); install_progress_ = 0.0f; - install_progress_target_ = 0.25f; + install_progress_target_ = 0.0f; install_finished_ = false; install_failed_ = false; install_error_.clear(); - std::string error; EOT_INFO("[installer-ui] installing sources game={} update={} dlc={} target={}", sources_.game.string(), sources_.update.string(), sources_.dlc.string(), result_.install_layout.root.string()); - install_progress_target_ = 0.65f; - if (!CopyInstallSources(sources_, result_.install_layout, &error)) { - install_failed_ = true; - install_error_ = error; - EOT_ERROR("[installer-ui] install failed: {}", error); - } else { + + install_control_ = std::make_shared(); + install_outcome_ = std::make_shared(); + install_thread_ = std::make_unique( + RunInstallAsync, sources_, result_.install_layout, install_control_, install_outcome_); + } + + // Called once on the UI thread after the background copy thread has been + // joined. Config/cvar access here is intentionally kept off the worker + // thread since rex::cvar isn't meant to be touched concurrently. + void FinishInstall() { + install_failed_ = install_outcome_->failed; + install_error_ = install_outcome_->error; + + if (!install_failed_) { InstallManifest manifest; manifest.installed = true; manifest.runtime_root = result_.install_layout.game.string(); manifest.note = "Installed by the ReEoT first-run installer."; + std::string error; if (!SaveInstallManifest(result_.install_layout.manifest, manifest, &error)) { install_failed_ = true; install_error_ = error; @@ -1652,6 +1812,9 @@ class InstallerDialog final : public rex::ui::ImGuiDialog { float install_progress_target_ = 0.0f; bool install_finished_ = false; bool install_failed_ = false; + std::unique_ptr install_thread_; + std::shared_ptr install_control_; + std::shared_ptr install_outcome_; bool has_appeared_ = false; bool is_disappearing_ = false; bool is_quitting_ = false; diff --git a/src/goliath_engine/installer/source_vfs.cpp b/src/goliath_engine/installer/source_vfs.cpp index aaa74f86..76787d3d 100644 --- a/src/goliath_engine/installer/source_vfs.cpp +++ b/src/goliath_engine/installer/source_vfs.cpp @@ -254,7 +254,6 @@ class IsoSourceVfs final : public MapSourceVfs { std::memcmp(magic.data(), kMagic, std::strlen(kMagic)) == 0) { game_offset = offset; magic_found = true; - break; } } if (!magic_found) { @@ -380,12 +379,16 @@ class StfsSourceVfs final : public MapSourceVfs { static constexpr uint32_t kEndOfChain = 0xFFFFFF; static constexpr uint32_t kBlocksPerHashLevel0 = 170; static constexpr uint32_t kBlocksPerHashLevel1 = 28900; + static constexpr uint32_t kBlocksPerHashLevel2 = 4913000; uint64_t BlockOffset(uint64_t block_index) const { uint64_t block = block_index; block += ((block_index + kBlocksPerHashLevel0) / kBlocksPerHashLevel0); if (block_index >= kBlocksPerHashLevel0) { block += ((block_index + kBlocksPerHashLevel1) / kBlocksPerHashLevel1); + if (block_index >= kBlocksPerHashLevel1) { + block += ((block_index + kBlocksPerHashLevel2) / kBlocksPerHashLevel2); + } } return base_offset_ + (block << 12); } From 15201a464269682bad9e52b1e0a5d73778fc1169 Mon Sep 17 00:00:00 2001 From: Graine25 Date: Fri, 10 Jul 2026 13:49:58 -0700 Subject: [PATCH 10/11] ci: add GitHub Actions workflows for Windows, Linux, and macOS builds --- .github/workflows/_build.yml | 460 ++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 20 ++ .github/workflows/release.yml | 17 ++ 3 files changed, 497 insertions(+) create mode 100644 .github/workflows/_build.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml new file mode 100644 index 00000000..b44ac562 --- /dev/null +++ b/.github/workflows/_build.yml @@ -0,0 +1,460 @@ +name: Build reeot + +on: + workflow_call: + inputs: + publish_mode: + description: "'artifact' for CI workflow-artifact, 'release' for GitHub release" + required: true + type: string + artifact_name: + description: "Artifact name when publish_mode is 'artifact'" + required: false + type: string + default: "" + +env: + # This nightly's release assets all report their CMake package version as + # 0.8.0.0 (version stamping is broken on this fork past nightly-20260421) + # even though generated/rexglue.cmake requires 0.8.1+. Pin REXSDK_VERSION + # to force an EXACT-match find_package() instead of the default >=0.8.1 + # check, which would otherwise hit a FATAL_ERROR at configure time. + REXSDK_TAG: nightly-20260710-f496e67a + REXSDK_VERSION: 0.8.0.0 + REXSDK_REPO: Graine25/rexglue-sdk + REXSDK_WINDOWS_ASSET: rexglue-sdk-0.8.0.0-dev.unknown-win-amd64.zip + REXSDK_LINUX_ASSET: rexglue-sdk-0.8.0.0-dev.unknown-linux-amd64.zip + REXSDK_MAC_ASSET: rexglue-sdk-0.8.0.0-dev.unknown-mac-arm64.zip + ASSETS_REPO: Graine25/assets + ASSET_REF: main + # reeot needs two guest binaries for codegen (see reeot_manifest.toml): + # entrypoint XEX -> assets/Default.xex + # GameLogic DLL -> assets/Data/GameLogic.dll + # Both live under reeot/ in the private assets repo. + XEX_SOURCE_PATH: reeot/assets/Default.xex + XEX_LOCAL_PATH: assets/Default.xex + DLL_SOURCE_PATH: reeot/assets/Data/GameLogic.dll + DLL_LOCAL_PATH: assets/Data/GameLogic.dll + +jobs: + build-windows: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install LLVM, CMake, Ninja + run: choco install llvm cmake ninja -y + + - name: Set up sccache + uses: hendrikmuhs/ccache-action@v1.2.22 + with: + variant: sccache + key: win-amd64 + + - name: Download ReXGlue SDK (${{ env.REXSDK_TAG }}) + shell: pwsh + run: | + $url = "https://github.com/${{ env.REXSDK_REPO }}/releases/download/${{ env.REXSDK_TAG }}/${{ env.REXSDK_WINDOWS_ASSET }}" + Invoke-WebRequest -Uri $url -OutFile sdk.zip + Expand-Archive -Path sdk.zip -DestinationPath sdk + + - name: Checkout private assets + uses: actions/checkout@v6 + with: + repository: ${{ env.ASSETS_REPO }} + ref: ${{ env.ASSET_REF }} + token: ${{ secrets.ASSETS_TOKEN }} + path: private-assets + lfs: true + sparse-checkout: | + ${{ env.XEX_SOURCE_PATH }} + ${{ env.DLL_SOURCE_PATH }} + + - name: Copy required assets for codegen + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path (Split-Path "${{ env.XEX_LOCAL_PATH }}") | Out-Null + New-Item -ItemType Directory -Force -Path (Split-Path "${{ env.DLL_LOCAL_PATH }}") | Out-Null + $pairs = @( + @{ Src = "private-assets/${{ env.XEX_SOURCE_PATH }}"; Dst = "${{ env.XEX_LOCAL_PATH }}" }, + @{ Src = "private-assets/${{ env.DLL_SOURCE_PATH }}"; Dst = "${{ env.DLL_LOCAL_PATH }}" } + ) + foreach ($p in $pairs) { + Copy-Item $p.Src $p.Dst + $size = (Get-Item $p.Dst).Length + Write-Host "Fetched $($p.Src) -> $($p.Dst) ($size bytes)" + if ($size -lt 1048576) { + throw "Fetched asset $($p.Dst) is smaller than 1 MiB - fetch likely failed" + } + } + + - name: Configure + shell: pwsh + run: | + $sdk = Join-Path $PWD "sdk/win-amd64" + cmake --preset win-amd64-release "-DCMAKE_PREFIX_PATH=$sdk" "-DREXSDK_VERSION=${{ env.REXSDK_VERSION }}" "-DCMAKE_C_COMPILER_LAUNCHER=sccache" "-DCMAKE_CXX_COMPILER_LAUNCHER=sccache" + + - name: Codegen + run: cmake --build --preset win-amd64-release --target reeot_codegen + + - name: Reconfigure (pick up generated/sources.cmake) + shell: pwsh + run: | + $sdk = Join-Path $PWD "sdk/win-amd64" + cmake --preset win-amd64-release "-DCMAKE_PREFIX_PATH=$sdk" "-DREXSDK_VERSION=${{ env.REXSDK_VERSION }}" "-DCMAKE_C_COMPILER_LAUNCHER=sccache" "-DCMAKE_CXX_COMPILER_LAUNCHER=sccache" + + - name: Build + run: cmake --build --preset win-amd64-release -j + + - name: sccache stats + if: always() + shell: bash + run: sccache --show-stats || true + + - name: Stage workflow artifact + if: inputs.publish_mode == 'artifact' + shell: pwsh + run: | + $out = "artifact-staging/windows-amd64" + New-Item -ItemType Directory -Force -Path $out | Out-Null + $buildDir = "out/build/win-amd64-release" + Copy-Item "$buildDir/reeot.exe" $out + Copy-Item "reeot.toml" $out + Get-ChildItem "$buildDir" -Filter *.dll -ErrorAction SilentlyContinue | + ForEach-Object { Copy-Item $_.FullName $out } + + - name: Upload workflow artifact + if: inputs.publish_mode == 'artifact' + uses: actions/upload-artifact@v6 + with: + name: ${{ inputs.artifact_name }}-windows-amd64 + path: artifact-staging/windows-amd64/* + if-no-files-found: error + + - name: Stage release assets + if: inputs.publish_mode == 'release' + shell: pwsh + run: | + $out = "release-assets/windows-amd64" + New-Item -ItemType Directory -Force -Path $out | Out-Null + $buildDir = "out/build/win-amd64-release" + Copy-Item "$buildDir/reeot.exe" $out + Copy-Item "reeot.toml" $out + Get-ChildItem "$buildDir" -Filter *.dll -ErrorAction SilentlyContinue | + ForEach-Object { Copy-Item $_.FullName $out } + Get-ChildItem "$buildDir" -Filter *.pdb -ErrorAction SilentlyContinue | + ForEach-Object { Copy-Item $_.FullName $out } + + - name: Upload staged release assets + if: inputs.publish_mode == 'release' + uses: actions/upload-artifact@v6 + with: + name: release-windows-amd64 + path: release-assets/windows-amd64/* + if-no-files-found: error + + build-linux: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Clang 20 + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y wget gnupg lsb-release + wget https://apt.llvm.org/llvm.sh + chmod +x llvm.sh + sudo ./llvm.sh 20 + sudo apt-get install -y clang-20 lld-20 + + - name: Install build dependencies + shell: bash + run: | + wget -qO- https://packages.lunarg.com/lunarg-signing-key-pub.asc | sudo tee /etc/apt/trusted.gpg.d/lunarg.asc + sudo wget -qO /etc/apt/sources.list.d/lunarg-vulkan-noble.list http://packages.lunarg.com/vulkan/lunarg-vulkan-noble.list + sudo apt-get update + sudo apt-get install -y cmake ninja-build build-essential git curl unzip autoconf python3.12-venv \ + libgtk-3-dev libx11-xcb-dev wine vulkan-sdk + + - name: Set up sccache + uses: hendrikmuhs/ccache-action@v1.2.22 + with: + variant: sccache + key: linux-amd64 + + - name: Download ReXGlue SDK (${{ env.REXSDK_TAG }}) + shell: bash + run: | + url="https://github.com/${{ env.REXSDK_REPO }}/releases/download/${{ env.REXSDK_TAG }}/${{ env.REXSDK_LINUX_ASSET }}" + curl -fsSL "$url" -o sdk.zip + unzip -q sdk.zip -d sdk + + - name: Checkout private assets + uses: actions/checkout@v6 + with: + repository: ${{ env.ASSETS_REPO }} + ref: ${{ env.ASSET_REF }} + token: ${{ secrets.ASSETS_TOKEN }} + path: private-assets + lfs: true + sparse-checkout: | + ${{ env.XEX_SOURCE_PATH }} + ${{ env.DLL_SOURCE_PATH }} + + - name: Copy required assets for codegen + shell: bash + run: | + copy_asset() { + local src="private-assets/$1" + local dst="$2" + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" + local size + size="$(stat -c%s "$dst")" + echo "Fetched $src -> $dst ($size bytes)" + if [ "$size" -lt 1048576 ]; then + echo "Fetched asset $dst is smaller than 1 MiB - fetch likely failed" >&2 + exit 1 + fi + } + copy_asset "${{ env.XEX_SOURCE_PATH }}" "${{ env.XEX_LOCAL_PATH }}" + copy_asset "${{ env.DLL_SOURCE_PATH }}" "${{ env.DLL_LOCAL_PATH }}" + + - name: Configure + shell: bash + run: | + sdk="$PWD/sdk/linux-amd64" + cmake --preset linux-amd64-release "-DCMAKE_PREFIX_PATH=$sdk" "-DREXSDK_VERSION=${{ env.REXSDK_VERSION }}" "-DCMAKE_C_COMPILER_LAUNCHER=sccache" "-DCMAKE_CXX_COMPILER_LAUNCHER=sccache" + + - name: Codegen + shell: bash + run: cmake --build --preset linux-amd64-release --target reeot_codegen + + - name: Reconfigure (pick up generated/sources.cmake) + shell: bash + run: | + sdk="$PWD/sdk/linux-amd64" + cmake --preset linux-amd64-release "-DCMAKE_PREFIX_PATH=$sdk" "-DREXSDK_VERSION=${{ env.REXSDK_VERSION }}" "-DCMAKE_C_COMPILER_LAUNCHER=sccache" "-DCMAKE_CXX_COMPILER_LAUNCHER=sccache" + + - name: Build + shell: bash + run: cmake --build --preset linux-amd64-release -j + + - name: Inspect binary + shell: bash + run: | + file out/build/linux-amd64-release/reeot + ldd out/build/linux-amd64-release/reeot + + - name: Stage Linux artifacts + shell: bash + run: | + pkg="dist/reeot-linux-amd64" + mkdir -p "$pkg" + buildDir="out/build/linux-amd64-release" + sdk="$PWD/sdk/linux-amd64" + + copy_release_lib() { + local name="$1" + local source_dir + for source_dir in "$buildDir" "$sdk/lib" "$sdk/bin"; do + [ -e "$source_dir/$name" ] || continue + cp -L -f "$source_dir/$name" "$pkg/" + return 0 + done + echo "Missing ${name} in release build, SDK lib, or SDK bin directories" >&2 + exit 1 + } + + cp "$buildDir/reeot" "$pkg/reeot" + cp reeot.toml "$pkg/reeot.toml" + copy_release_lib "librexruntime.so" + copy_release_lib "libTracyClient.so" + + find "$pkg" -maxdepth 1 -type f -printf "%f\n" | sort + + - name: Package Linux executable + shell: bash + run: | + tar -C dist -czf dist/reeot-linux-amd64.tar.gz reeot-linux-amd64 + + - name: sccache stats + if: always() + shell: bash + run: sccache --show-stats || true + + - name: Upload workflow artifact + if: inputs.publish_mode == 'artifact' + uses: actions/upload-artifact@v6 + with: + name: ${{ inputs.artifact_name }}-linux-amd64 + path: dist/reeot-linux-amd64.tar.gz + if-no-files-found: error + + - name: Upload staged release assets + if: inputs.publish_mode == 'release' + uses: actions/upload-artifact@v6 + with: + name: release-linux-amd64 + path: dist/reeot-linux-amd64.tar.gz + if-no-files-found: error + + build-mac: + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install LLVM, CMake, Ninja + shell: bash + run: brew install llvm cmake ninja + + - name: Set up sccache + uses: hendrikmuhs/ccache-action@v1.2.22 + with: + variant: sccache + key: mac-arm64 + + - name: Download ReXGlue SDK (${{ env.REXSDK_TAG }}) + shell: bash + run: | + url="https://github.com/${{ env.REXSDK_REPO }}/releases/download/${{ env.REXSDK_TAG }}/${{ env.REXSDK_MAC_ASSET }}" + curl -fsSL "$url" -o sdk.zip + unzip -q sdk.zip -d sdk + + - name: Checkout private assets + uses: actions/checkout@v6 + with: + repository: ${{ env.ASSETS_REPO }} + ref: ${{ env.ASSET_REF }} + token: ${{ secrets.ASSETS_TOKEN }} + path: private-assets + lfs: true + sparse-checkout: | + ${{ env.XEX_SOURCE_PATH }} + ${{ env.DLL_SOURCE_PATH }} + + - name: Copy required assets for codegen + shell: bash + run: | + copy_asset() { + local src="private-assets/$1" + local dst="$2" + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" + local size + size="$(stat -f%z "$dst")" + echo "Fetched $src -> $dst ($size bytes)" + if [ "$size" -lt 1048576 ]; then + echo "Fetched asset $dst is smaller than 1 MiB - fetch likely failed" >&2 + exit 1 + fi + } + copy_asset "${{ env.XEX_SOURCE_PATH }}" "${{ env.XEX_LOCAL_PATH }}" + copy_asset "${{ env.DLL_SOURCE_PATH }}" "${{ env.DLL_LOCAL_PATH }}" + + - name: Configure + shell: bash + run: | + sdk="$PWD/sdk/mac-arm64" + cmake --preset mac-arm64-release "-DCMAKE_PREFIX_PATH=$sdk" "-DREXSDK_VERSION=${{ env.REXSDK_VERSION }}" "-DCMAKE_C_COMPILER_LAUNCHER=sccache" "-DCMAKE_CXX_COMPILER_LAUNCHER=sccache" + + - name: Codegen + shell: bash + run: cmake --build --preset mac-arm64-release --target reeot_codegen + + - name: Reconfigure (pick up generated/sources.cmake) + shell: bash + run: | + sdk="$PWD/sdk/mac-arm64" + cmake --preset mac-arm64-release "-DCMAKE_PREFIX_PATH=$sdk" "-DREXSDK_VERSION=${{ env.REXSDK_VERSION }}" "-DCMAKE_C_COMPILER_LAUNCHER=sccache" "-DCMAKE_CXX_COMPILER_LAUNCHER=sccache" + + - name: Build + shell: bash + run: cmake --build --preset mac-arm64-release -j + + - name: Inspect binary + shell: bash + run: | + file out/build/mac-arm64-release/reeot + otool -L out/build/mac-arm64-release/reeot + + - name: Stage macOS artifacts + shell: bash + run: | + pkg="dist/reeot-macos-arm64" + mkdir -p "$pkg" + buildDir="out/build/mac-arm64-release" + sdk="$PWD/sdk/mac-arm64" + + copy_release_lib() { + local name="$1" + local source_dir + for source_dir in "$buildDir" "$sdk/lib" "$sdk/bin"; do + [ -e "$source_dir/$name" ] || continue + cp -L -f "$source_dir/$name" "$pkg/" + return 0 + done + echo "Missing ${name} in release build, SDK lib, or SDK bin directories" >&2 + exit 1 + } + + cp "$buildDir/reeot" "$pkg/reeot" + cp reeot.toml "$pkg/reeot.toml" + copy_release_lib "librexruntime.dylib" + copy_release_lib "libTracyClient.dylib" + + find "$pkg" -maxdepth 1 -type f -exec basename {} \; | sort + + - name: Package macOS executable + shell: bash + run: | + tar -C dist -czf dist/reeot-macos-arm64.tar.gz reeot-macos-arm64 + + - name: sccache stats + if: always() + shell: bash + run: sccache --show-stats || true + + - name: Upload workflow artifact + if: inputs.publish_mode == 'artifact' + uses: actions/upload-artifact@v6 + with: + name: ${{ inputs.artifact_name }}-macos-arm64 + path: dist/reeot-macos-arm64.tar.gz + if-no-files-found: error + + - name: Upload staged release assets + if: inputs.publish_mode == 'release' + uses: actions/upload-artifact@v6 + with: + name: release-macos-arm64 + path: dist/reeot-macos-arm64.tar.gz + if-no-files-found: error + + publish-release: + if: inputs.publish_mode == 'release' + needs: + - build-windows + - build-linux + - build-mac + runs-on: ubuntu-latest + steps: + - name: Download staged release assets + uses: actions/download-artifact@v6 + with: + pattern: release-* + path: release-assets + merge-multiple: true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: release-assets/* + generate_release_notes: true + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..87b9d762 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + actions: write + +jobs: + build: + uses: ./.github/workflows/_build.yml + with: + publish_mode: artifact + artifact_name: reeot-${{ github.sha }} + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..cf9780a5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,17 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + actions: write + +jobs: + build: + uses: ./.github/workflows/_build.yml + with: + publish_mode: release + secrets: inherit From cb62b326781a6ae6cce6946c4d146242a6bcf521 Mon Sep 17 00:00:00 2001 From: Graine25 Date: Fri, 10 Jul 2026 14:51:18 -0700 Subject: [PATCH 11/11] ci: check out submodules and bundle MoltenVK on macOS --- .github/workflows/_build.yml | 8 +++++++- CMakeLists.txt | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index b44ac562..3c25f3a3 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -42,6 +42,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + submodules: recursive - name: Install LLVM, CMake, Ninja run: choco install llvm cmake ninja -y @@ -159,6 +161,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + submodules: recursive - name: Install Clang 20 shell: bash @@ -307,10 +311,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + submodules: recursive - name: Install LLVM, CMake, Ninja shell: bash - run: brew install llvm cmake ninja + run: brew install llvm cmake ninja molten-vk - name: Set up sccache uses: hendrikmuhs/ccache-action@v1.2.22 diff --git a/CMakeLists.txt b/CMakeLists.txt index 8ea9b67a..ac2a7f84 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,7 +3,11 @@ # SDK boilerplate lives in generated/rexglue.cmake. cmake_minimum_required(VERSION 3.25) -project(reeot LANGUAGES C CXX OBJC) +if(APPLE) + project(reeot LANGUAGES C CXX OBJC) +else() + project(reeot LANGUAGES C CXX) +endif() set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON)