From cf87c9d3292120cb21084db72468ca15130e2f18 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Thu, 6 Aug 2026 15:42:37 +0300 Subject: [PATCH 1/3] add first-run disc image installer wizard Extract the XDVDFS game partition of a user-supplied Xbox 360 disc image straight into game_data_root on first launch, so a fresh install is: run the game, pick your .iso, play. Redump (XGD2), XGD3, and bare game-partition images are supported; extraction resumes if interrupted and preserves already-extracted files. After extraction the wizard best-effort auto-stages Title Update 1 via the existing download path, so the usual first run needs exactly one user action. If TU staging fails (offline), the existing TU wizard still appears on next boot as the fallback. Also honor DOWNPOUR_INSTALL_ISO / DOWNPOUR_INSTALL_TU env overrides in OnFinalizePaths for headless installs (the TU override previously only existed in RunTitleUpdateInstallWizardBlocking, which the app never calls), and factor the post-install process relaunch into a shared RelaunchSelfOrResume helper. --- CMakeLists.txt | 1 + src/downpour_app.h | 63 +++- src/downpour_iso_installer.cpp | 392 ++++++++++++++++++++++++ src/downpour_iso_installer.h | 39 +++ src/downpour_title_update_installer.cpp | 83 +++-- src/downpour_title_update_installer.h | 11 + 6 files changed, 548 insertions(+), 41 deletions(-) create mode 100644 src/downpour_iso_installer.cpp create mode 100644 src/downpour_iso_installer.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 1db4ec7..97decae 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,7 @@ include(generated/rexglue.cmake) # Sources set(DOWNPOUR_SOURCES src/main.cpp + src/downpour_iso_installer.cpp src/downpour_title_update_installer.cpp ) diff --git a/src/downpour_app.h b/src/downpour_app.h index 2811467..6cf0a87 100644 --- a/src/downpour_app.h +++ b/src/downpour_app.h @@ -4,14 +4,17 @@ #pragma once +#include #include #include #include +#include #include #include #include +#include "downpour_iso_installer.h" #include "downpour_title_update_installer.h" class DownpourApp : public rex::ReXApp { @@ -24,22 +27,68 @@ class DownpourApp : public rex::ReXApp { downpour_PPCImageConfig)); } - // Gate the runtime launch behind Title Update 1: if the user has not yet - // staged default.xexp next to the game files, open the installer wizard and - // pause path finalization until they finish. Mirrors the Skate 3 / EA - // installer pattern (see mchughalex/skate3recomp/src/skate3_app_common.cpp). - // Honors a DOWNPOUR_INSTALL_TU env override for headless installs. + // Gate the runtime launch behind the two first-run install steps: + // + // 1. Game data — if assets/default.xex is missing, open the disc image + // installer wizard: the user picks their own Downpour .iso and its + // XDVDFS game partition is extracted into game_data_root. The wizard + // also best-effort auto-stages TU1 so a fresh install usually needs + // exactly one user action (picking the ISO). + // 2. Title Update 1 — if default.xexp is still not staged (offline + // install, mirror down), open the TU installer wizard. + // + // Mirrors the Skate 3 / EA installer pattern (see + // mchughalex/skate3recomp/src/skate3_app_common.cpp). Honors + // DOWNPOUR_INSTALL_ISO and DOWNPOUR_INSTALL_TU env overrides for headless + // installs (a path to the source file, or "download" for the TU). std::optional OnFinalizePaths( const rex::PathConfig& defaults, std::function resume) override { rex::PathConfig runtime_paths = defaults; - if (downpour::IsTitleUpdateInstalled(runtime_paths.game_data_root)) { + const auto& game_root = runtime_paths.game_data_root; + + if (!downpour::IsGameDataInstalled(game_root)) { + if (const char* iso = std::getenv("DOWNPOUR_INSTALL_ISO"); + iso != nullptr && *iso != '\0') { + std::string error; + REXLOG_INFO("Installing game data from DOWNPOUR_INSTALL_ISO={}", iso); + if (!downpour::InstallGameDataFromIso(iso, game_root, nullptr, nullptr, + error)) { + REXLOG_ERROR("Automated game data installation failed: {}", error); + } + } + } + if (!downpour::IsGameDataInstalled(game_root)) { + REXLOG_INFO( + "Silent Hill: Downpour game data not found at {}; launching the " + "disc image installer.", + game_root.string()); + downpour::ShowIsoInstallWizard(imgui_drawer(), std::move(runtime_paths), + std::move(resume)); + return std::nullopt; + } + + if (!downpour::IsTitleUpdateInstalled(game_root)) { + if (const char* tu = std::getenv("DOWNPOUR_INSTALL_TU"); + tu != nullptr && *tu != '\0') { + std::string error; + REXLOG_INFO("Installing title update from DOWNPOUR_INSTALL_TU={}", tu); + const bool ok = + std::string_view(tu) == "download" + ? downpour::TryDownloadAndStageTitleUpdate(game_root, error) + : downpour::StageTitleUpdateFromFile(tu, game_root, error); + if (!ok) { + REXLOG_ERROR("Automated title update installation failed: {}", error); + } + } + } + if (downpour::IsTitleUpdateInstalled(game_root)) { return runtime_paths; } REXLOG_INFO( "Silent Hill: Downpour Title Update 1 not staged at {}; launching the " "title update installer.", - runtime_paths.game_data_root.string()); + game_root.string()); downpour::ShowTitleUpdateInstallWizard( imgui_drawer(), std::move(runtime_paths), std::move(resume)); return std::nullopt; diff --git a/src/downpour_iso_installer.cpp b/src/downpour_iso_installer.cpp new file mode 100644 index 0000000..5c3bb10 --- /dev/null +++ b/src/downpour_iso_installer.cpp @@ -0,0 +1,392 @@ +#include "downpour_iso_installer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "downpour_title_update_installer.h" + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#elif defined(__APPLE__) +#else +#include +#endif + +namespace downpour { + +namespace { + +// ---------------------------------------------------------------------------- +// Minimal read-only XDVDFS (GDF) reader — the filesystem used by Xbox and +// Xbox 360 game discs. Directory tables are AVL trees of 4-byte-aligned +// entries; files are stored as contiguous sector runs, which keeps extraction +// a plain seek + copy per file. Layout reference: the freely documented +// XDVDFS volume format as implemented by extract-xiso and Xenia's GDFX code. +// ---------------------------------------------------------------------------- + +constexpr uint32_t kSectorSize = 2048; +constexpr std::string_view kVolumeMagic = "MICROSOFT*XBOX*MEDIA"; +// The volume descriptor lives at sector 32 of the game partition. Redump-style +// Xbox 360 images place the game partition at 0xFD90000 (XGD2) or 0x2080000 +// (XGD3); a bare partition dump has it at 0. +constexpr std::array kPartitionBases = {0x0ull, 0xFD90000ull, 0x2080000ull}; +constexpr uint8_t kAttributeDirectory = 0x10; +constexpr uint16_t kEmptyDirectorySentinel = 0xFFFF; + +uint16_t Le16(const uint8_t* p) { + return static_cast(p[0] | (p[1] << 8)); +} + +uint32_t Le32(const uint8_t* p) { + return static_cast(p[0]) | (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16) | (static_cast(p[3]) << 24); +} + +struct DiscFileEntry { + std::filesystem::path relative_path; + uint32_t start_sector = 0; + uint32_t size = 0; +}; + +class XdvdfsImageReader { + public: + bool Open(const std::filesystem::path& iso_path, std::string& error) { + file_.open(iso_path, std::ios::binary); + if (!file_) { + error = "Unable to open " + iso_path.string() + "."; + return false; + } + std::array magic{}; + for (const uint64_t base : kPartitionBases) { + file_.clear(); + file_.seekg(static_cast(base + 32ull * kSectorSize)); + if (!file_.read(magic.data(), magic.size())) { + continue; + } + if (std::string_view(magic.data(), magic.size()) == kVolumeMagic) { + partition_base_ = base; + return true; + } + } + error = + "The selected file is not an Xbox 360 disc image (no XDVDFS game " + "partition found). Select a full-disc .iso dumped from your copy of " + "Silent Hill: Downpour."; + return false; + } + + bool ListFiles(std::vector& files, std::string& error) { + files.clear(); + std::array descriptor{}; + file_.clear(); + file_.seekg( + static_cast(partition_base_ + 32ull * kSectorSize + kVolumeMagic.size())); + if (!file_.read(reinterpret_cast(descriptor.data()), descriptor.size())) { + error = "The disc image's volume descriptor is truncated."; + return false; + } + const uint32_t root_sector = Le32(descriptor.data()); + const uint32_t root_size = Le32(descriptor.data() + 4); + return WalkDirectory(root_sector, root_size, {}, files, error); + } + + bool ExtractFile(const DiscFileEntry& entry, const std::filesystem::path& destination, + std::atomic* copied_bytes, std::string& error) { + std::error_code ec; + std::filesystem::create_directories(destination.parent_path(), ec); + if (ec) { + error = "Unable to create " + destination.parent_path().string() + "."; + return false; + } + std::ofstream out(destination, std::ios::binary | std::ios::trunc); + if (!out) { + error = "Unable to create " + destination.string() + "."; + return false; + } + file_.clear(); + file_.seekg(static_cast(partition_base_ + + static_cast(entry.start_sector) * + kSectorSize)); + std::vector buffer(4 * 1024 * 1024); + uint64_t remaining = entry.size; + while (remaining > 0) { + const auto chunk = + static_cast(std::min(buffer.size(), remaining)); + if (!file_.read(buffer.data(), chunk)) { + error = "Unexpected end of disc image while extracting " + + entry.relative_path.string() + "."; + return false; + } + out.write(buffer.data(), chunk); + remaining -= static_cast(chunk); + if (copied_bytes) { + copied_bytes->fetch_add(static_cast(chunk), std::memory_order_relaxed); + } + } + out.flush(); + if (!out) { + error = "Failed to write " + destination.string() + "."; + return false; + } + return true; + } + + private: + bool WalkDirectory(uint32_t table_sector, uint32_t table_size, + const std::filesystem::path& relative_dir, + std::vector& files, std::string& error) { + if (table_size == 0) { + return true; // Empty directory. + } + std::vector table(table_size); + file_.clear(); + file_.seekg(static_cast(partition_base_ + + static_cast(table_sector) * kSectorSize)); + if (!file_.read(reinterpret_cast(table.data()), table.size())) { + error = "The disc image's directory table for '" + relative_dir.string() + + "' is truncated."; + return false; + } + // Iterative AVL walk; the visited set guards against malformed images with + // cyclic child offsets. + std::vector pending{0}; + std::unordered_set visited; + while (!pending.empty()) { + const uint32_t dword_offset = pending.back(); + pending.pop_back(); + if (!visited.insert(dword_offset).second) { + continue; + } + const uint64_t offset = static_cast(dword_offset) * 4; + if (offset + 14 > table.size()) { + continue; + } + const uint16_t left = Le16(table.data() + offset); + const uint16_t right = Le16(table.data() + offset + 2); + if (left == kEmptyDirectorySentinel) { + continue; + } + const uint32_t start_sector = Le32(table.data() + offset + 4); + const uint32_t size = Le32(table.data() + offset + 8); + const uint8_t attributes = table[offset + 12]; + const uint8_t name_length = table[offset + 13]; + if (name_length > 0 && offset + 14 + name_length <= table.size()) { + const std::string name(reinterpret_cast(table.data() + offset + 14), + name_length); + const auto child_path = relative_dir / name; + if (attributes & kAttributeDirectory) { + if (!WalkDirectory(start_sector, size, child_path, files, error)) { + return false; + } + } else { + files.push_back({child_path, start_sector, size}); + } + } + if (left != 0 && left != kEmptyDirectorySentinel) { + pending.push_back(left); + } + if (right != 0 && right != kEmptyDirectorySentinel) { + pending.push_back(right); + } + } + return true; + } + + std::ifstream file_; + uint64_t partition_base_ = 0; +}; + +// ---------------------------------------------------------------------------- +// File picker +// ---------------------------------------------------------------------------- + +#if defined(_WIN32) +std::filesystem::path PickIsoFile() { + wchar_t filename[MAX_PATH] = {}; + OPENFILENAMEW ofn{}; + ofn.lStructSize = sizeof(ofn); + ofn.hwndOwner = GetActiveWindow(); + ofn.lpstrFile = filename; + ofn.nMaxFile = static_cast(std::size(filename)); + ofn.lpstrFilter = L"Xbox 360 disc image (*.iso)\0*.iso\0All files (*.*)\0*.*\0"; + ofn.lpstrTitle = L"Select your Silent Hill: Downpour Xbox 360 disc image"; + ofn.Flags = OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR | + OFN_DONTADDTORECENT; + if (!GetOpenFileNameW(&ofn)) { + return {}; + } + return filename; +} +#else +std::filesystem::path PickIsoFile() { + GtkWidget* dialog = gtk_file_chooser_dialog_new( + "Select your Silent Hill: Downpour Xbox 360 disc image", nullptr, + GTK_FILE_CHOOSER_ACTION_OPEN, "_Cancel", GTK_RESPONSE_CANCEL, "_Open", GTK_RESPONSE_ACCEPT, + nullptr); + if (!dialog) { + return {}; + } + + GtkFileFilter* iso_filter = gtk_file_filter_new(); + gtk_file_filter_set_name(iso_filter, "Xbox 360 disc image (*.iso)"); + gtk_file_filter_add_pattern(iso_filter, "*.iso"); + gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), iso_filter); + GtkFileFilter* all_filter = gtk_file_filter_new(); + gtk_file_filter_set_name(all_filter, "All files"); + gtk_file_filter_add_pattern(all_filter, "*"); + gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), all_filter); + + std::filesystem::path result; + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + char* filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); + if (filename) { + result = filename; + g_free(filename); + } + } + + gtk_widget_destroy(dialog); + while (gtk_events_pending()) { + gtk_main_iteration_do(FALSE); + } + return result; +} +#endif + +} // namespace + +bool IsGameDataInstalled(const std::filesystem::path& game_root) { + std::error_code ec; + return std::filesystem::is_regular_file(game_root / "default.xex", ec) && !ec; +} + +bool InstallGameDataFromIso(const std::filesystem::path& iso_path, + const std::filesystem::path& game_root, + std::atomic* copied_bytes, + std::atomic* total_bytes, std::string& error) { + XdvdfsImageReader reader; + if (!reader.Open(iso_path, error)) { + return false; + } + std::vector files; + if (!reader.ListFiles(files, error)) { + return false; + } + const bool has_xex = std::any_of(files.begin(), files.end(), [](const DiscFileEntry& f) { + return f.relative_path == "default.xex"; + }); + if (!has_xex) { + error = + "The disc image does not contain default.xex at its root; it is not a " + "Silent Hill: Downpour game disc."; + return false; + } + + uint64_t total = 0; + for (const auto& f : files) { + total += f.size; + } + if (total_bytes) { + total_bytes->store(total, std::memory_order_relaxed); + } + std::error_code ec; + const auto space = std::filesystem::space( + std::filesystem::exists(game_root, ec) ? game_root : game_root.parent_path(), ec); + if (!ec && space.available < total + (512ull << 20)) { + error = "Not enough free disk space to extract the game data (need ~" + + std::to_string((total >> 30) + 1) + " GiB free)."; + return false; + } + + // Extract in disc order for sequential reads. Files already extracted with + // the right size are skipped so an interrupted install can resume. + std::sort(files.begin(), files.end(), [](const DiscFileEntry& a, const DiscFileEntry& b) { + return a.start_sector < b.start_sector; + }); + for (const auto& f : files) { + const auto destination = game_root / f.relative_path; + std::error_code exists_ec; + if (std::filesystem::is_regular_file(destination, exists_ec) && + std::filesystem::file_size(destination, exists_ec) == f.size && !exists_ec) { + if (copied_bytes) { + copied_bytes->fetch_add(f.size, std::memory_order_relaxed); + } + continue; + } + if (!reader.ExtractFile(f, destination, copied_bytes, error)) { + return false; + } + } + REXLOG_INFO("Extracted {} files ({} MiB) from {} into {}", files.size(), total >> 20, + iso_path.string(), game_root.string()); + return true; +} + +void ShowIsoInstallWizard(rex::ui::ImGuiDrawer* drawer, rex::PathConfig runtime_paths, + std::function complete) { + const auto game_root = runtime_paths.game_data_root; + + rex::ui::AcquireWizardDialog::Options options; + options.title = "Silent Hill: Downpour — Game Data"; + options.intro = + "This port needs the game files from your own legally-owned Xbox 360 copy " + "of Silent Hill: Downpour. Select your disc image (.iso) and its contents " + "will be extracted here — nothing else to do."; + options.target_directory = game_root.string(); + options.initial_status = + "Select the disc image dumped from your copy of the game (USA or Europe " + "release). Extraction needs ~4.5 GiB of free space."; + // No fetch button: the game data cannot be downloaded, only extracted from + // the user's own dump. + options.pick_button_label = "Select disc image..."; + options.install_working_status = "Extracting game data... (a few minutes)"; + options.done_status = "Game data installed."; + options.done_button_label = "Continue"; + + auto install = [game_root](const std::filesystem::path& source, + std::atomic& copied_bytes, + std::atomic& total_bytes, std::string& error) { + if (!InstallGameDataFromIso(source, game_root, &copied_bytes, &total_bytes, error)) { + return false; + } + if (!IsGameDataInstalled(game_root)) { + error = "The game data could not be verified after extraction."; + return false; + } + // Best-effort: stage Title Update 1 right away so the freshly-installed + // game boots without a second wizard. On failure (offline, mirror down) + // the existing TU wizard still appears on the next boot as the fallback. + std::string tu_error; + if (!IsTitleUpdateInstalled(game_root) && + !TryDownloadAndStageTitleUpdate(game_root, tu_error)) { + REXLOG_WARN("Title update auto-staging after ISO install failed: {}", tu_error); + } + return true; + }; + + new rex::ui::AcquireWizardDialog( + drawer, std::move(options), /*fetch=*/nullptr, []() { return PickIsoFile(); }, + std::move(install), + [runtime_paths = std::move(runtime_paths), complete = std::move(complete)]() mutable { + // Same rationale as the title update wizard: resuming the runtime + // inline hangs on Win32, so restart the process; the fresh launch + // sees the game data (and usually TU1) already installed. + RelaunchSelfOrResume(std::move(runtime_paths), std::move(complete)); + }); +} + +} // namespace downpour diff --git a/src/downpour_iso_installer.h b/src/downpour_iso_installer.h new file mode 100644 index 0000000..65edac4 --- /dev/null +++ b/src/downpour_iso_installer.h @@ -0,0 +1,39 @@ +/** + * @file downpour_iso_installer.h + * + * @brief First-run game data installer: extracts the XDVDFS (GDF) game + * partition of a user-supplied Silent Hill: Downpour Xbox 360 + * disc image straight into game_data_root, so a fresh install is + * "pick your .iso, wait, play" instead of requiring a separately + * extracted file tree. Accepts full Redump-style images (game + * partition at 0xFD90000), XGD3 images (0x2080000), and bare + * game-partition dumps (XDVDFS at offset 0). Modelled on the + * Title Update 1 wizard in downpour_title_update_installer.*. + */ +#pragma once + +#include +#include +#include + +#include + +namespace downpour { + +// True when the extracted game data tree is present in game_root (the base +// executable default.xex is the marker the rest of the runtime keys off). +bool IsGameDataInstalled(const std::filesystem::path& game_root); + +// Extracts every file of the disc image's game partition into game_root. +// copied_bytes / total_bytes may be null (headless use). Files already +// present with the correct size are skipped, so an interrupted extraction +// resumes instead of starting over. +bool InstallGameDataFromIso(const std::filesystem::path& iso_path, + const std::filesystem::path& game_root, + std::atomic* copied_bytes, + std::atomic* total_bytes, std::string& error); + +void ShowIsoInstallWizard(rex::ui::ImGuiDrawer* drawer, rex::PathConfig runtime_paths, + std::function complete); + +} // namespace downpour diff --git a/src/downpour_title_update_installer.cpp b/src/downpour_title_update_installer.cpp index 57e18dc..8c37642 100644 --- a/src/downpour_title_update_installer.cpp +++ b/src/downpour_title_update_installer.cpp @@ -642,6 +642,54 @@ bool DownloadAndStageTitleUpdate(const std::filesystem::path& game_root, } // namespace +bool TryDownloadAndStageTitleUpdate(const std::filesystem::path& game_root, std::string& error) { + std::atomic copied_bytes{0}; + std::atomic total_bytes{0}; + if (!DownloadAndStageTitleUpdate(game_root, copied_bytes, total_bytes, error)) { + return false; + } + if (!IsTitleUpdateInstalled(game_root)) { + error = "The title update could not be verified after installation."; + return false; + } + return true; +} + +void RelaunchSelfOrResume(rex::PathConfig runtime_paths, + std::function complete) { +#if defined(_WIN32) + wchar_t exe_path[MAX_PATH]; + DWORD len = GetModuleFileNameW(nullptr, exe_path, MAX_PATH); + if (len > 0 && len < MAX_PATH) { + // Reuse our current command line verbatim so any --flags + // (game_data_root override, dev cvars, etc.) survive. + std::wstring cmd = GetCommandLineW(); + // CreateProcessW needs a mutable buffer for lpCommandLine. + std::vector cmd_buf(cmd.begin(), cmd.end()); + cmd_buf.push_back(L'\0'); + STARTUPINFOW si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + if (CreateProcessW(exe_path, cmd_buf.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, + &si, &pi)) { + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + REXLOG_INFO( + "Install step complete; restarting downpour.exe to pick up the " + "freshly-staged files. Process exits now."); + ExitProcess(0); + } + REXLOG_ERROR( + "Failed to spawn fresh downpour.exe after install (Win32 error {}); " + "falling back to inline resume callback.", + GetLastError()); + } +#endif + if (complete) { + complete(std::move(runtime_paths)); + } +} + bool IsTitleUpdateInstalled(const std::filesystem::path& game_root) { for (const auto& payload : kPayloads) { const auto staged = game_root / std::filesystem::path(std::string(payload.staged_path)); @@ -783,40 +831,7 @@ void ShowTitleUpdateInstallWizard(rex::ui::ImGuiDrawer* drawer, rex::PathConfig // restart the process. The fresh launch sees TU1 already // installed (IsTitleUpdateInstalled == true), skips the wizard, // and boots straight into the game. - // - // We still keep `complete` and `runtime_paths` captured so the - // non-Windows fallback below has a chance to work the old way. -#if defined(_WIN32) - wchar_t exe_path[MAX_PATH]; - DWORD len = GetModuleFileNameW(nullptr, exe_path, MAX_PATH); - if (len > 0 && len < MAX_PATH) { - // Reuse our current command line verbatim so any --flags - // (game_data_root override, dev cvars, etc.) survive. - std::wstring cmd = GetCommandLineW(); - // CreateProcessW needs a mutable buffer for lpCommandLine. - std::vector cmd_buf(cmd.begin(), cmd.end()); - cmd_buf.push_back(L'\0'); - STARTUPINFOW si{}; - si.cb = sizeof(si); - PROCESS_INFORMATION pi{}; - if (CreateProcessW(exe_path, cmd_buf.data(), nullptr, nullptr, FALSE, - 0, nullptr, nullptr, &si, &pi)) { - CloseHandle(pi.hThread); - CloseHandle(pi.hProcess); - REXLOG_INFO( - "Title update installed; restarting downpour.exe to pick up " - "the freshly-staged payload. Process exits now."); - ExitProcess(0); - } - REXLOG_ERROR( - "Failed to spawn fresh downpour.exe after TU install (Win32 " - "error {}); falling back to inline resume callback.", - GetLastError()); - } -#endif - if (complete) { - complete(std::move(runtime_paths)); - } + RelaunchSelfOrResume(std::move(runtime_paths), std::move(complete)); }); } diff --git a/src/downpour_title_update_installer.h b/src/downpour_title_update_installer.h index 1211a5a..0c53622 100644 --- a/src/downpour_title_update_installer.h +++ b/src/downpour_title_update_installer.h @@ -30,6 +30,17 @@ bool IsTitleUpdateInstalled(const std::filesystem::path& game_root); bool StageTitleUpdateFromFile(const std::filesystem::path& source, const std::filesystem::path& game_root, std::string& error); +// Downloads the TU container from the configured downpour_title_update_url, +// stages its payload into game_root, and verifies the result. Used to chain +// the title update automatically behind the game data (ISO) installer. +bool TryDownloadAndStageTitleUpdate(const std::filesystem::path& game_root, std::string& error); + +// Shared installer-wizard completion: restart the process so the fresh launch +// picks up freshly-staged files (resuming the runtime inline hangs on Win32); +// falls back to the SDK resume callback where restarting is unavailable. +void RelaunchSelfOrResume(rex::PathConfig runtime_paths, + std::function complete); + void ShowTitleUpdateInstallWizard(rex::ui::ImGuiDrawer* drawer, rex::PathConfig runtime_paths, std::function complete); bool RunTitleUpdateInstallWizardBlocking(rex::ui::WindowedAppContext& app_context, From 7d7b00aae38dc6b91637ca3232e384d1028aeb1f Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Thu, 6 Aug 2026 16:06:00 +0300 Subject: [PATCH 2/3] extract default.xex last so interrupted installs resume default.xex is the install-complete marker (IsGameDataInstalled), so it must be the final file written: if extraction dies midway, the next launch re-opens the installer and the skip-existing logic finishes the remaining files in seconds instead of booting with a partial tree. --- src/downpour_iso_installer.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/downpour_iso_installer.cpp b/src/downpour_iso_installer.cpp index 5c3bb10..b7836fb 100644 --- a/src/downpour_iso_installer.cpp +++ b/src/downpour_iso_installer.cpp @@ -312,9 +312,18 @@ bool InstallGameDataFromIso(const std::filesystem::path& iso_path, return false; } - // Extract in disc order for sequential reads. Files already extracted with - // the right size are skipped so an interrupted install can resume. + // Extract in disc order for sequential reads, except default.xex, which + // goes last: it doubles as the "install complete" marker + // (IsGameDataInstalled), so writing it only after everything else + // guarantees an interrupted extraction re-opens the installer on next + // launch and resumes. Files already extracted with the right size are + // skipped, so that resume is cheap. std::sort(files.begin(), files.end(), [](const DiscFileEntry& a, const DiscFileEntry& b) { + const bool a_is_marker = a.relative_path == "default.xex"; + const bool b_is_marker = b.relative_path == "default.xex"; + if (a_is_marker != b_is_marker) { + return b_is_marker; + } return a.start_sector < b.start_sector; }); for (const auto& f : files) { From 6e100e8537f19668466c655fa7ee67d15efe9261 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Sun, 9 Aug 2026 02:29:19 +0300 Subject: [PATCH 3/3] address ISO installer review feedback --- src/downpour_iso_installer.cpp | 56 ++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/src/downpour_iso_installer.cpp b/src/downpour_iso_installer.cpp index b7836fb..f7d3073 100644 --- a/src/downpour_iso_installer.cpp +++ b/src/downpour_iso_installer.cpp @@ -46,6 +46,7 @@ constexpr std::string_view kVolumeMagic = "MICROSOFT*XBOX*MEDIA"; constexpr std::array kPartitionBases = {0x0ull, 0xFD90000ull, 0x2080000ull}; constexpr uint8_t kAttributeDirectory = 0x10; constexpr uint16_t kEmptyDirectorySentinel = 0xFFFF; +constexpr uint32_t kMaxDirectoryDepth = 32; uint16_t Le16(const uint8_t* p) { return static_cast(p[0] | (p[1] << 8)); @@ -101,7 +102,24 @@ class XdvdfsImageReader { } const uint32_t root_sector = Le32(descriptor.data()); const uint32_t root_size = Le32(descriptor.data() + 4); - return WalkDirectory(root_sector, root_size, {}, files, error); + return WalkDirectory(root_sector, root_size, {}, 0, files, error); + } + + bool HasXex2Magic(const DiscFileEntry& entry, std::string& error) { + std::array magic{}; + file_.clear(); + file_.seekg(static_cast(partition_base_ + + static_cast(entry.start_sector) * + kSectorSize)); + if (!file_.read(magic.data(), magic.size())) { + error = "The disc image's default.xex is truncated."; + return false; + } + if (std::string_view(magic.data(), magic.size()) != "XEX2") { + error = "The disc image's default.xex is corrupt (missing XEX2 header)."; + return false; + } + return true; } bool ExtractFile(const DiscFileEntry& entry, const std::filesystem::path& destination, @@ -148,7 +166,14 @@ class XdvdfsImageReader { private: bool WalkDirectory(uint32_t table_sector, uint32_t table_size, const std::filesystem::path& relative_dir, + uint32_t depth, std::vector& files, std::string& error) { + if (depth > kMaxDirectoryDepth) { + error = "The disc image's directory tree exceeds the supported depth of " + + std::to_string(kMaxDirectoryDepth) + "."; + REXLOG_ERROR("{}", error); + return false; + } if (table_size == 0) { return true; // Empty directory. } @@ -184,12 +209,23 @@ class XdvdfsImageReader { const uint32_t size = Le32(table.data() + offset + 8); const uint8_t attributes = table[offset + 12]; const uint8_t name_length = table[offset + 13]; - if (name_length > 0 && offset + 14 + name_length <= table.size()) { + if (offset + 14 + name_length <= table.size()) { const std::string name(reinterpret_cast(table.data() + offset + 14), name_length); - const auto child_path = relative_dir / name; + const std::filesystem::path name_path(name); + if (name.empty() || name == "." || name == ".." || + name.find('\0') != std::string::npos || name.find('/') != std::string::npos || + name.find('\\') != std::string::npos || name_path.is_absolute() || + name_path.has_root_name() || name_path.has_root_directory()) { + error = "The disc image contains an unsafe directory entry name."; + REXLOG_ERROR("Rejecting unsafe XDVDFS entry name '{}' under '{}'.", name, + relative_dir.string()); + return false; + } + const auto child_path = relative_dir / name_path; if (attributes & kAttributeDirectory) { - if (!WalkDirectory(start_sector, size, child_path, files, error)) { + if (!WalkDirectory(start_sector, size, child_path, depth + 1, files, + error)) { return false; } } else { @@ -231,6 +267,11 @@ std::filesystem::path PickIsoFile() { } return filename; } +#elif defined(__APPLE__) +std::filesystem::path PickIsoFile() { + REXLOG_ERROR("The ISO file picker is not implemented on macOS."); + return {}; +} #else std::filesystem::path PickIsoFile() { GtkWidget* dialog = gtk_file_chooser_dialog_new( @@ -286,15 +327,18 @@ bool InstallGameDataFromIso(const std::filesystem::path& iso_path, if (!reader.ListFiles(files, error)) { return false; } - const bool has_xex = std::any_of(files.begin(), files.end(), [](const DiscFileEntry& f) { + const auto xex = std::find_if(files.begin(), files.end(), [](const DiscFileEntry& f) { return f.relative_path == "default.xex"; }); - if (!has_xex) { + if (xex == files.end()) { error = "The disc image does not contain default.xex at its root; it is not a " "Silent Hill: Downpour game disc."; return false; } + if (!reader.HasXex2Magic(*xex, error)) { + return false; + } uint64_t total = 0; for (const auto& f : files) {