diff --git a/CMakeLists.txt b/CMakeLists.txt
index b75ad19..2a12c35 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -13,6 +13,9 @@ include(generated/rexglue.cmake)
# Sources
set(CONDEMNED2RECOMP_SOURCES
src/main.cpp
+ src/condemned2recomp_iso_installer.cpp
+ src/ui/acquire_wizard_dialog.cpp
+ src/ui/wizard_screen.cpp
)
if(WIN32)
diff --git a/README.md b/README.md
index 593d827..f66145f 100644
--- a/README.md
+++ b/README.md
@@ -8,11 +8,17 @@
This is a static recompilation of **Condemned 2: Bloodshot (Xbox 360)** for Native PC built through RexGlue-SDK.
## Installation
-* Using [extract-xiso](https://github.com/XboxDev/extract-xiso) or [xdvdfs](https://github.com/antangelo/xdvdfs), extract all game files from your legally owned copy of **Condemned 2: Bloodshot**.
+* Run *"condemned2recomp.exe"*. On first launch it asks for the disc image (*.iso*) dumped from your legally owned copy of **Condemned 2: Bloodshot** and extracts the game files into the *"Assets"* folder for you (~7 GiB of free space needed).
+* **(Optional)** Configure your graphic settings by modifying *"condemned2recomp.toml"*.
+
+
+Manual installation (alternative)
+
+* Using [extract-xiso](https://github.com/XboxDev/extract-xiso) or [xdvdfs](https://github.com/antangelo/xdvdfs), extract all game files from your legally owned copy of **Condemned 2: Bloodshot**.
* In the same folder as *"condemned2recomp.exe"*, create a new folder named *"Assets"*.
* Place extracted game files into the *"Assets"* folder.
-* **(Optional)** Configure your graphic settings by modifying *"condemned2recomp.toml"*.
-* Run *"condemned2recomp.exe"* to start **Condemned 2: Bloodshot**.
+* For an unattended/headless install, set the environment variable `CONDEMNED2_INSTALL_ISO` to the path of your disc image before launching.
+
## Controls
diff --git a/src/condemned2recomp_app.h b/src/condemned2recomp_app.h
index dee9414..380ccc4 100644
--- a/src/condemned2recomp_app.h
+++ b/src/condemned2recomp_app.h
@@ -4,8 +4,16 @@
#pragma once
+#include
+#include
+#include
+
+#include
#include
+#include "condemned2recomp_iso_installer.h"
+#include "ui/wizard_screen.h"
+
class Condemned2recompApp : public rex::ReXApp {
public:
using rex::ReXApp::ReXApp;
@@ -20,13 +28,55 @@ class Condemned2recompApp : public rex::ReXApp {
config.gpu_plugin = "xenos";
}
+ void OnConfigureFonts(ImFontAtlas* atlas) override {
+ // Scalable fonts for the first-run installer wizard; the SDK default is
+ // a 10 px bitmap font that upscales poorly to heading sizes.
+ rex::ui::ConfigureWizardFonts(atlas);
+ }
+
void OnConfigurePaths(rex::PathConfig& paths) override {
if (paths.game_data_root.empty()) { // Use default assets directory path if one isn't provided!
- const auto assets_dir = paths.config_path.parent_path() / "Assets";
- if (std::filesystem::is_regular_file(assets_dir / "default.xex")) {
- paths.game_data_root = assets_dir;
- }
+ // Defaulted even when the game files are not there yet, so the
+ // first-run installer knows where to extract them.
+ paths.game_data_root = paths.config_path.parent_path() / "Assets";
+ }
+ }
+
+ // Gate the runtime launch behind the game data: if Assets/default.xex is
+ // missing, open the disc image installer wizard — the user picks their own
+ // Condemned 2 .iso and its XDVDFS game partition is extracted into
+ // game_data_root, so a fresh install is one user action instead of a manual
+ // extract-xiso run. Mirrors the first-run installer pattern of other
+ // ReXGlue recomps (LittleBitUA/DownpourRecomp, mchughalex/skate3recomp).
+ // Honors a CONDEMNED2_INSTALL_ISO env override (path to the .iso) for
+ // headless installs.
+ std::optional OnFinalizePaths(
+ const rex::PathConfig& defaults,
+ std::function resume) override {
+ rex::PathConfig runtime_paths = defaults;
+ const auto& game_root = runtime_paths.game_data_root;
+
+ if (!condemned2::IsGameDataInstalled(game_root)) {
+ if (const char* iso = std::getenv("CONDEMNED2_INSTALL_ISO");
+ iso != nullptr && *iso != '\0') {
+ std::string error;
+ REXLOG_INFO("Installing game data from CONDEMNED2_INSTALL_ISO={}", iso);
+ if (!condemned2::InstallGameDataFromIso(iso, game_root, nullptr, nullptr,
+ error)) {
+ REXLOG_ERROR("Automated game data installation failed: {}", error);
+ }
}
+ }
+ if (condemned2::IsGameDataInstalled(game_root)) {
+ return runtime_paths;
+ }
+ REXLOG_INFO(
+ "Condemned 2: Bloodshot game data not found at {}; launching the "
+ "disc image installer.",
+ game_root.string());
+ condemned2::ShowIsoInstallWizard(imgui_drawer(), std::move(runtime_paths),
+ std::move(resume));
+ return std::nullopt;
}
// Override virtual hooks for customization:
diff --git a/src/condemned2recomp_iso_installer.cpp b/src/condemned2recomp_iso_installer.cpp
new file mode 100644
index 0000000..7b1aeef
--- /dev/null
+++ b/src/condemned2recomp_iso_installer.cpp
@@ -0,0 +1,438 @@
+#include "condemned2recomp_iso_installer.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "ui/acquire_wizard_dialog.h"
+
+#if defined(_WIN32)
+#define WIN32_LEAN_AND_MEAN
+#define NOMINMAX
+#include
+#include
+#else
+#include
+#endif
+
+namespace condemned2 {
+
+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, which is what
+// Condemned 2 shipped on) 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 "
+ "Condemned 2: Bloodshot.";
+ 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 Condemned 2: Bloodshot 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 Condemned 2: Bloodshot 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 "
+ "Condemned 2: Bloodshot game disc.";
+ return false;
+ }
+ // The disc carries an Xbox 360 dashboard update ($SystemUpdate) alongside
+ // the game files; it is meaningless to the recomp, so don't copy it (or any
+ // other $-prefixed system directory) into the game data tree.
+ files.erase(std::remove_if(files.begin(), files.end(),
+ [](const DiscFileEntry& f) {
+ const std::string first =
+ f.relative_path.begin()->string();
+ return !first.empty() && first.front() == '$';
+ }),
+ files.end());
+
+ 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, 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) {
+ 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 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 condemned2recomp.exe to pick up "
+ "the freshly-staged files. Process exits now.");
+ ExitProcess(0);
+ }
+ REXLOG_ERROR(
+ "Failed to spawn fresh condemned2recomp.exe after install (Win32 "
+ "error {}); falling back to inline resume callback.",
+ GetLastError());
+ }
+#endif
+ if (complete) {
+ complete(std::move(runtime_paths));
+ }
+}
+
+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 = "Condemned 2: Bloodshot";
+ options.section_label = "Game Data";
+ options.intro =
+ "This port needs the game files from your own legally-owned Xbox 360 copy "
+ "of Condemned 2: Bloodshot. 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. Extraction "
+ "needs ~7 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;
+ }
+ 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 {
+ // Resuming the runtime inline hangs on Win32, so restart the process;
+ // the fresh launch sees the game data already installed and boots
+ // straight into the game.
+ RelaunchSelfOrResume(std::move(runtime_paths), std::move(complete));
+ });
+}
+
+} // namespace condemned2
diff --git a/src/condemned2recomp_iso_installer.h b/src/condemned2recomp_iso_installer.h
new file mode 100644
index 0000000..999e523
--- /dev/null
+++ b/src/condemned2recomp_iso_installer.h
@@ -0,0 +1,45 @@
+/**
+ * @file condemned2recomp_iso_installer.h
+ *
+ * @brief First-run game data installer: extracts the XDVDFS (GDF) game
+ * partition of a user-supplied Condemned 2: Bloodshot 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
+ * disc image installer in LittleBitUA/DownpourRecomp.
+ */
+#pragma once
+
+#include
+#include
+#include
+
+#include
+
+namespace condemned2 {
+
+// 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);
+
+// Installer-wizard completion: restart the process so the fresh launch picks
+// up the 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);
+
+} // namespace condemned2
diff --git a/src/ui/acquire_wizard_dialog.cpp b/src/ui/acquire_wizard_dialog.cpp
new file mode 100644
index 0000000..5554647
--- /dev/null
+++ b/src/ui/acquire_wizard_dialog.cpp
@@ -0,0 +1,207 @@
+/**
+ * @file src/ui/acquire_wizard_dialog.cpp
+ *
+ * @brief Generic pre-runtime acquisition dialog.
+ *
+ * @note Vendored from the ReXGlue SDK overlay used by other recomps
+ * (see acquire_wizard_dialog.h).
+ */
+#include "acquire_wizard_dialog.h"
+
+#include
+#include
+
+#include
+
+#include "wizard_screen.h"
+
+namespace rex::ui {
+
+AcquireWizardDialog::AcquireWizardDialog(ImGuiDrawer* drawer, Options options, FetchCallback fetch,
+ PickSourceCallback pick_source, InstallCallback install,
+ CompleteCallback complete)
+ : ImGuiDialog(drawer),
+ options_(std::move(options)),
+ fetch_(std::move(fetch)),
+ pick_source_(std::move(pick_source)),
+ install_(std::move(install)),
+ complete_(std::move(complete)),
+ status_(options_.initial_status) {}
+
+void AcquireWizardDialog::OnClose() {
+ if (work_thread_.joinable()) {
+ work_thread_.join();
+ }
+}
+
+void AcquireWizardDialog::StartWork(std::function work,
+ std::string busy_status) {
+ if (work_thread_.joinable()) {
+ work_thread_.join();
+ }
+ copied_bytes_ = 0;
+ total_bytes_ = 0;
+ work_done_ = false;
+ work_ok_ = false;
+ error_.clear();
+ state_ = State::kWorking;
+ status_ = std::move(busy_status);
+ work_thread_ = std::thread([this, work = std::move(work)]() {
+ std::string error;
+ const bool ok = work(error);
+ error_ = std::move(error);
+ work_ok_ = ok;
+ work_done_ = true;
+ });
+}
+
+void AcquireWizardDialog::StartFetch() {
+ if (!fetch_) {
+ return;
+ }
+ source_path_.clear();
+ working_is_fetch_ = true;
+ StartWork([this](std::string& error) { return fetch_(copied_bytes_, total_bytes_, error); },
+ options_.fetch_working_status);
+}
+
+void AcquireWizardDialog::PickSourceAndInstall() {
+ if (!pick_source_ || !install_) {
+ return;
+ }
+ auto source_path = pick_source_();
+ // The modal picker swallows the release of whatever input activated this
+ // action; balance ImGui's state so the stuck "down" doesn't eat the next
+ // press.
+ ImGuiIO& io = ImGui::GetIO();
+ io.AddMouseButtonEvent(0, false);
+ io.AddKeyEvent(ImGuiKey_Enter, false);
+ io.AddKeyEvent(ImGuiKey_KeypadEnter, false);
+ io.AddKeyEvent(ImGuiKey_Space, false);
+ if (source_path.empty()) {
+ return;
+ }
+ source_path_ = std::move(source_path);
+ working_is_fetch_ = false;
+ StartWork(
+ [this](std::string& error) {
+ return install_(source_path_, copied_bytes_, total_bytes_, error);
+ },
+ options_.install_working_status);
+}
+
+const std::string& AcquireWizardDialog::WorkingStatus() const {
+ // Before any bytes arrive during a fetch, the connection may be waiting on
+ // the server's first byte; surface that instead of an idle "downloading".
+ if (working_is_fetch_ && !options_.fetch_connecting_status.empty() &&
+ copied_bytes_.load(std::memory_order_relaxed) == 0) {
+ return options_.fetch_connecting_status;
+ }
+ return status_;
+}
+
+void AcquireWizardDialog::FinishWorkIfNeeded() {
+ if (state_ != State::kWorking || !work_done_.load(std::memory_order_acquire)) {
+ return;
+ }
+
+ if (work_thread_.joinable()) {
+ work_thread_.join();
+ }
+
+ if (work_ok_.load(std::memory_order_acquire)) {
+ state_ = State::kDone;
+ status_ = options_.done_status;
+ } else {
+ state_ = State::kFailed;
+ status_ = options_.initial_status;
+ }
+}
+
+void AcquireWizardDialog::OnDraw(ImGuiIO& io) {
+ FinishWorkIfNeeded();
+
+ // The completion callback hands off to the (lengthy) game boot, freezing
+ // the last presented frame. Acknowledge the activation visually first:
+ // draw frames with the action row gone and a launch status, and only
+ // invoke the callback once one has presented - AFTER this frame's draw,
+ // so no empty frame flashes between this screen and whatever follows.
+ bool run_complete = false;
+ if (launch_frames_ >= 0) {
+ if (launch_frames_ == 0) {
+ launch_frames_ = -1;
+ run_complete = true;
+ } else {
+ --launch_frames_;
+ }
+ }
+
+ WizardScreenSpec spec;
+ spec.title = options_.title.c_str();
+ spec.section = options_.section_label.c_str();
+ if (!options_.intro.empty()) {
+ spec.paragraphs.push_back({options_.intro, WizardScreenSpec::Emphasis::kNormal});
+ }
+ // The connecting-status substitution only applies while a fetch is live.
+ const std::string& status = state_ == State::kWorking ? WorkingStatus() : status_;
+ spec.paragraphs.push_back({status, options_.intro.empty()
+ ? WizardScreenSpec::Emphasis::kNormal
+ : WizardScreenSpec::Emphasis::kDim});
+ if (state_ == State::kFailed && !error_.empty()) {
+ spec.paragraphs.push_back({error_, WizardScreenSpec::Emphasis::kDanger});
+ }
+ if (!options_.target_directory.empty()) {
+ spec.info_rows.push_back({"Install Directory", options_.target_directory});
+ }
+ if (!source_path_.empty()) {
+ spec.info_rows.push_back({"Source", source_path_.string()});
+ }
+ if (state_ == State::kWorking) {
+ spec.show_progress = true;
+ spec.progress_copied = copied_bytes_.load(std::memory_order_relaxed);
+ spec.progress_total = total_bytes_.load(std::memory_order_relaxed);
+ }
+
+ int fetch_action = -1;
+ int pick_action = -1;
+ int done_action = -1;
+ if (state_ == State::kWaitingForChoice || state_ == State::kFailed) {
+ if (fetch_ && !options_.fetch_button_label.empty()) {
+ fetch_action = static_cast(spec.actions.size());
+ spec.actions.push_back(options_.fetch_button_label);
+ }
+ if (pick_source_ && install_ && !options_.pick_button_label.empty()) {
+ pick_action = static_cast(spec.actions.size());
+ spec.actions.push_back(options_.pick_button_label);
+ }
+ } else if (state_ == State::kDone && launch_frames_ < 0) {
+ done_action = static_cast(spec.actions.size());
+ spec.actions.push_back(options_.done_button_label);
+ }
+
+ const int activated =
+ DrawWizardScreen(imgui_drawer(), io, spec, focus_index_, highlight_anim_y_);
+ if (run_complete) {
+ auto complete = std::move(complete_);
+ Close();
+ if (complete) {
+ complete();
+ }
+ return;
+ }
+ if (activated < 0) {
+ return;
+ }
+ if (activated == fetch_action) {
+ StartFetch();
+ } else if (activated == pick_action) {
+ PickSourceAndInstall();
+ } else if (activated == done_action) {
+ if (!options_.launching_status.empty()) {
+ status_ = options_.launching_status;
+ }
+ launch_frames_ = 1;
+ }
+}
+
+} // namespace rex::ui
diff --git a/src/ui/acquire_wizard_dialog.h b/src/ui/acquire_wizard_dialog.h
new file mode 100644
index 0000000..cc5d1cf
--- /dev/null
+++ b/src/ui/acquire_wizard_dialog.h
@@ -0,0 +1,105 @@
+/**
+ * @file src/ui/acquire_wizard_dialog.h
+ *
+ * @brief Generic pre-runtime acquisition dialog: install a payload either
+ * by fetching it automatically (e.g. a download) or from a
+ * user-selected source file.
+ *
+ * @note Vendored from the ReXGlue SDK overlay used by other recomps
+ * (include/rex/ui/overlay/acquire_wizard_overlay.h in
+ * mchughalex/rexglue-skate3 and LittleBitUA/rexglue-sdk-dpour).
+ * Kept in rex::ui so this copy can be dropped as-is once the
+ * stock SDK ships it.
+ */
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+namespace rex::ui {
+
+class AcquireWizardDialog final : public ImGuiDialog {
+ public:
+ struct Options {
+ std::string title;
+ std::string section_label; // accent section bar above the dialog body
+ std::string intro;
+ std::string target_directory;
+ std::string initial_status;
+ // Leave a label empty to hide the corresponding button.
+ std::string fetch_button_label;
+ std::string pick_button_label;
+ // Shown while a fetch is in progress but no bytes have arrived yet (e.g.
+ // waiting on the server's first byte). Falls back to fetch_working_status
+ // if empty.
+ std::string fetch_connecting_status;
+ std::string fetch_working_status;
+ std::string install_working_status;
+ std::string done_status;
+ std::string done_button_label;
+ // Shown after the done button is activated, while the completion
+ // callback (usually the game boot) takes over.
+ std::string launching_status;
+ };
+
+ using PickSourceCallback = std::function;
+ using InstallCallback = std::function& copied_bytes,
+ std::atomic& total_bytes,
+ std::string& error)>;
+ using FetchCallback = std::function& copied_bytes,
+ std::atomic& total_bytes,
+ std::string& error)>;
+ using CompleteCallback = std::function;
+
+ AcquireWizardDialog(ImGuiDrawer* drawer, Options options, FetchCallback fetch,
+ PickSourceCallback pick_source, InstallCallback install,
+ CompleteCallback complete);
+
+ protected:
+ void OnClose() override;
+ void OnDraw(ImGuiIO& io) override;
+
+ private:
+ enum class State {
+ kWaitingForChoice,
+ kWorking,
+ kDone,
+ kFailed,
+ };
+
+ void StartWork(std::function work, std::string busy_status);
+ void StartFetch();
+ void PickSourceAndInstall();
+ void FinishWorkIfNeeded();
+ const std::string& WorkingStatus() const;
+
+ Options options_;
+ FetchCallback fetch_;
+ PickSourceCallback pick_source_;
+ InstallCallback install_;
+ CompleteCallback complete_;
+ std::thread work_thread_;
+ std::atomic work_done_{false};
+ std::atomic work_ok_{false};
+ std::atomic copied_bytes_{0};
+ std::atomic total_bytes_{0};
+ State state_ = State::kWaitingForChoice;
+ bool working_is_fetch_ = false;
+ std::filesystem::path source_path_;
+ std::string status_;
+ std::string error_;
+ // Wizard-screen navigation state (see src/ui/wizard_screen.h).
+ int focus_index_ = 0;
+ float highlight_anim_y_ = -1.0f;
+ // >= 0 while the completion handoff is pending: counts down the frames
+ // drawn to acknowledge the activation before the (blocking) callback runs.
+ int launch_frames_ = -1;
+};
+
+} // namespace rex::ui
diff --git a/src/ui/wizard_screen.cpp b/src/ui/wizard_screen.cpp
new file mode 100644
index 0000000..fe7d816
--- /dev/null
+++ b/src/ui/wizard_screen.cpp
@@ -0,0 +1,643 @@
+/**
+ * @file src/ui/wizard_screen.cpp
+ *
+ * @brief Shared renderer for the pre-runtime wizard dialogs.
+ *
+ * @note Vendored from the ReXGlue SDK overlay used by other recomps
+ * (see wizard_screen.h). Only change: the stock 0.9.0
+ * ImGuiDrawer has no ui_font accessors, so the default ImGui
+ * font is used for every text role.
+ */
+#include "wizard_screen.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+namespace rex::ui {
+namespace {
+
+// ---- Style ----------------------------------------------------------------
+// Same palette and metrics as the settings menu (simple_settings_overlay.cpp).
+
+// Menu-only scale on top of the viewport scale; the footer legend stays at
+// the base size. Keep in sync with the settings overlay's kMenuScale.
+constexpr float kMenuScale = 0.92f;
+
+constexpr ImU32 kColSelFill = IM_COL32(10, 12, 12, 255);
+constexpr ImU32 kColSelText = IM_COL32(250, 252, 252, 255);
+constexpr ImU32 kColPanel = IM_COL32(235, 236, 236, 255);
+constexpr ImU32 kColPanelHover = IM_COL32(248, 250, 250, 255);
+constexpr ImU32 kColPanelBorder = IM_COL32(0, 0, 0, 26);
+constexpr ImU32 kColRowText = IM_COL32(15, 17, 18, 255);
+constexpr ImU32 kColRowTextDim = IM_COL32(108, 118, 118, 255);
+constexpr ImU32 kColRailBorder = IM_COL32(255, 255, 255, 26);
+constexpr ImU32 kColText = IM_COL32(235, 242, 241, 255);
+constexpr ImU32 kColTextDim = IM_COL32(224, 235, 234, 191);
+constexpr ImU32 kColAccent = IM_COL32(213, 235, 10, 255);
+constexpr ImU32 kColAccentDark = IM_COL32(13, 15, 5, 255);
+constexpr ImU32 kColInteract = IM_COL32(230, 0, 120, 255);
+constexpr ImU32 kColDanger = IM_COL32(233, 88, 76, 255);
+constexpr ImU32 kColDescPanel = IM_COL32(11, 46, 43, 140);
+constexpr ImU32 kColLegendChip = IM_COL32(238, 240, 240, 255);
+constexpr ImU32 kColLegendText = IM_COL32(15, 18, 20, 255);
+constexpr ImU32 kColLegendLabel = IM_COL32(228, 236, 235, 255);
+
+// ---- Aurora backdrop field ------------------------------------------------
+// A smooth procedural color field behind the wizard (there is no game scene
+// yet at this point in boot): a vertical base gradient in the menu's
+// dusk/plaza palette plus gaussian color clouds on slow orbits, breathing as
+// they drift. Evaluated at the corners of a coarse quad grid and rendered
+// with per-corner interpolation.
+
+constexpr float kAuroraBase[3][3] = {
+ {0x2d, 0x49, 0x5e}, // top
+ {0x3f, 0x65, 0x6b}, // middle
+ {0x57, 0x70, 0x63}, // bottom
+};
+struct AuroraBlob {
+ float col[3];
+ float amp;
+ float r;
+ float cx[3]; // center, amplitude, angular speed (rad/s)
+ float cy[3];
+ float ph;
+};
+constexpr AuroraBlob kAuroraBlobs[] = {
+ {{127, 178, 216}, 0.50f, 0.55f, {0.30f, 0.16f, 0.050f}, {0.30f, 0.14f, 0.037f}, 0.0f},
+ {{216, 207, 192}, 0.42f, 0.50f, {0.72f, 0.18f, 0.041f}, {0.72f, 0.16f, 0.031f}, 2.1f},
+ {{62, 219, 190}, 0.26f, 0.40f, {0.62f, 0.22f, 0.033f}, {0.28f, 0.18f, 0.047f}, 4.2f},
+ {{213, 235, 10}, 0.14f, 0.34f, {0.22f, 0.20f, 0.059f}, {0.74f, 0.16f, 0.043f}, 1.3f},
+};
+
+ImU32 AuroraFieldColor(float x, float y, float t) {
+ // Base: two-segment vertical gradient.
+ const int seg = y < 0.5f ? 0 : 1;
+ const float f = (y - float(seg) * 0.5f) * 2.0f;
+ float c[3];
+ for (int k = 0; k < 3; ++k) {
+ c[k] = kAuroraBase[seg][k] + (kAuroraBase[seg + 1][k] - kAuroraBase[seg][k]) * f;
+ }
+ for (const AuroraBlob& b : kAuroraBlobs) {
+ const float cx = b.cx[0] + b.cx[1] * std::sin(b.cx[2] * t + b.ph);
+ const float cy = b.cy[0] + b.cy[1] * std::sin(b.cy[2] * t + b.ph * 1.7f);
+ const float r = b.r * (1.0f + 0.18f * std::sin(0.05f * t + b.ph));
+ const float dx = (x - cx) * (16.0f / 9.0f);
+ const float dy = y - cy;
+ const float w = std::min(1.0f, b.amp * std::exp(-(dx * dx + dy * dy) / (r * r)));
+ for (int k = 0; k < 3; ++k) {
+ c[k] += (b.col[k] - c[k]) * w;
+ }
+ }
+ return IM_COL32(int(c[0] + 0.5f), int(c[1] + 0.5f), int(c[2] + 0.5f), 255);
+}
+
+void DrawAuroraBackdrop(ImDrawList* dl, ImVec2 display, float t) {
+ // Coarse grid; AddRectFilledMultiColor interpolates within each cell, so
+ // the field stays smooth.
+ constexpr int kGridX = 48;
+ constexpr int kGridY = 27;
+ ImU32 corners[kGridX + 1][2]; // two rows: previous and current
+ for (int gy = 0; gy <= kGridY; ++gy) {
+ const int row = gy & 1;
+ const float y = float(gy) / kGridY;
+ for (int gx = 0; gx <= kGridX; ++gx) {
+ corners[gx][row] = AuroraFieldColor(float(gx) / kGridX, y, t);
+ }
+ if (gy == 0) {
+ continue;
+ }
+ const float y0 = display.y * float(gy - 1) / kGridY;
+ const float y1 = display.y * float(gy) / kGridY;
+ for (int gx = 0; gx < kGridX; ++gx) {
+ const float x0 = display.x * float(gx) / kGridX;
+ const float x1 = display.x * float(gx + 1) / kGridX;
+ dl->AddRectFilledMultiColor(ImVec2(x0, y0), ImVec2(x1, y1), corners[gx][row ^ 1],
+ corners[gx + 1][row ^ 1], corners[gx + 1][row],
+ corners[gx][row]);
+ }
+ }
+}
+
+// ---- Small draw helpers ----------------------------------------------------
+// Local copies of the settings overlay's pixel-exact helpers (that file keeps
+// them in its anonymous namespace); see simple_settings_overlay.cpp for the
+// derivations behind the snapping and hard-edge rules.
+
+constexpr float kPi = 3.14159265358979323846f;
+
+float Snap(float value) {
+ return std::floor(value + 0.5f);
+}
+
+void AddTextVCentered(ImDrawList* dl, ImFont* font, float size, float x, float center_y,
+ ImU32 col, const char* text) {
+ ImVec2 extent = font->CalcTextSizeA(size, FLT_MAX, 0.0f, text);
+ dl->AddText(font, size, ImVec2(Snap(x), Snap(center_y - extent.y * 0.5f)), col, text);
+}
+
+void AddTextCenteredCap(ImDrawList* dl, ImFont* font, float size, ImVec2 center, ImU32 col,
+ const char* text) {
+ ImFontBaked* baked = font->GetFontBaked(size);
+ float pen = 0.0f;
+ for (const char* p = text; *p; ++p) {
+ if (const ImFontGlyph* glyph = baked->FindGlyph(ImWchar(uint8_t(*p)))) {
+ pen += glyph->AdvanceX;
+ }
+ }
+ float band_top = 0.0f;
+ float band_bottom = size;
+ if (const ImFontGlyph* cap = baked->FindGlyphNoFallback(ImWchar('H'))) {
+ band_top = cap->Y0;
+ band_bottom = cap->Y1;
+ }
+ const float x = center.x - pen * 0.5f;
+ const float y = center.y - (band_top + band_bottom) * 0.5f;
+ dl->AddText(font, size, ImVec2(Snap(x), std::ceil(y - 0.5f)), col, text);
+}
+
+void DrawHardRingBand(ImDrawList* dl, ImVec2 p_min, ImVec2 p_max, float e0, float e1,
+ float corner_r, ImU32 col) {
+ const float x0 = Snap(p_min.x), y0 = Snap(p_min.y);
+ const float x1 = Snap(p_max.x), y1 = Snap(p_max.y);
+ const float rr = Snap(corner_r);
+ const float o0 = Snap(e0), o1 = Snap(e1);
+ const float a0 = x0 + rr, a1 = x1 - rr;
+ const float b0 = y0 + rr, b1 = y1 - rr;
+ dl->AddRectFilled(ImVec2(a0, y0 - o1), ImVec2(a1, y0 - o0), col);
+ dl->AddRectFilled(ImVec2(a0, y1 + o0), ImVec2(a1, y1 + o1), col);
+ dl->AddRectFilled(ImVec2(x0 - o1, b0), ImVec2(x0 - o0, b1), col);
+ dl->AddRectFilled(ImVec2(x1 + o0, b0), ImVec2(x1 + o1, b1), col);
+ const float rm = rr + (o0 + o1) * 0.5f;
+ const float t = o1 - o0;
+ const struct {
+ ImVec2 c;
+ float ang0;
+ } corners[4] = {
+ {ImVec2(a0, b0), kPi},
+ {ImVec2(a1, b0), kPi * 1.5f},
+ {ImVec2(a1, b1), 0.0f},
+ {ImVec2(a0, b1), kPi * 0.5f},
+ };
+ for (const auto& c : corners) {
+ dl->PathArcTo(c.c, rm, c.ang0, c.ang0 + kPi * 0.5f);
+ dl->PathStroke(col, 0, t);
+ }
+}
+
+void DrawHardRoundedFill(ImDrawList* dl, ImVec2 p_min, ImVec2 p_max, float corner_r, ImU32 col) {
+ const float x0 = Snap(p_min.x), y0 = Snap(p_min.y);
+ const float x1 = Snap(p_max.x), y1 = Snap(p_max.y);
+ const float rr = Snap(corner_r);
+ const float a0 = x0 + rr, a1 = x1 - rr;
+ const float b0 = y0 + rr, b1 = y1 - rr;
+ dl->AddRectFilled(ImVec2(x0, b0), ImVec2(x1, b1), col);
+ dl->AddRectFilled(ImVec2(a0, y0), ImVec2(a1, b0), col);
+ dl->AddRectFilled(ImVec2(a0, b1), ImVec2(a1, y1), col);
+ const struct {
+ ImVec2 c;
+ float ang0;
+ } corners[4] = {
+ {ImVec2(a0, b0), kPi},
+ {ImVec2(a1, b0), kPi * 1.5f},
+ {ImVec2(a1, b1), 0.0f},
+ {ImVec2(a0, b1), kPi * 0.5f},
+ };
+ for (const auto& c : corners) {
+ dl->PathLineTo(c.c);
+ dl->PathArcTo(c.c, rr, c.ang0, c.ang0 + kPi * 0.5f);
+ dl->PathFillConvex(col);
+ }
+}
+
+// Focused-action highlight: rounded black fill, lime ring, black outer edge.
+void DrawFocusHighlight(ImDrawList* dl, ImVec2 p_min, ImVec2 p_max, float s) {
+ const float radius = 6.0f * s;
+ DrawHardRoundedFill(dl, p_min, p_max, radius, kColSelFill);
+ DrawHardRingBand(dl, p_min, p_max, 0.0f, 2.0f * s, radius, kColAccent);
+ DrawHardRingBand(dl, p_min, p_max, 2.0f * s, 6.0f * s, radius, kColSelFill);
+}
+
+// Greedy word wrap; returns [begin, end) ranges into text, one per line.
+std::vector> WrapLines(ImFont* font, float size,
+ float wrap_w,
+ const std::string& text) {
+ std::vector> lines;
+ const char* p = text.c_str();
+ while (*p) {
+ while (*p == ' ') {
+ ++p;
+ }
+ if (!*p) {
+ break;
+ }
+ const char* line_start = p;
+ const char* line_end = p;
+ while (*p) {
+ const char* word_start = p;
+ while (*p && *p != ' ') {
+ ++p;
+ }
+ if (font->CalcTextSizeA(size, FLT_MAX, 0.0f, line_start, p).x > wrap_w &&
+ line_end > line_start) {
+ p = word_start;
+ break;
+ }
+ line_end = p;
+ while (*p == ' ') {
+ ++p;
+ }
+ }
+ lines.emplace_back(line_start, line_end);
+ }
+ return lines;
+}
+
+// Scalable system fonts registered by ConfigureWizardFonts(); null when none
+// was found, in which case the drawer's default font is used.
+ImFont* g_wizard_font = nullptr;
+ImFont* g_wizard_font_bold = nullptr;
+
+std::string FormatBytes(uint64_t bytes) {
+ char buf[32];
+ if (bytes >= 1000000000ull) {
+ std::snprintf(buf, sizeof(buf), "%.1f GB", double(bytes) / 1e9);
+ } else if (bytes >= 1000000ull) {
+ std::snprintf(buf, sizeof(buf), "%.1f MB", double(bytes) / 1e6);
+ } else {
+ std::snprintf(buf, sizeof(buf), "%u KB", unsigned(bytes / 1000));
+ }
+ return buf;
+}
+
+} // namespace
+
+int DrawWizardScreen(ImGuiDrawer* drawer, ImGuiIO& io, const WizardScreenSpec& spec,
+ int& focus_index, float& highlight_anim_y) {
+ (void)drawer;
+ ImFont* font = g_wizard_font ? g_wizard_font : ImGui::GetFont();
+ ImFont* bold = g_wizard_font_bold ? g_wizard_font_bold : font;
+ ImFont* bold_ol = bold;
+
+ const ImVec2 display = io.DisplaySize;
+ const float base_s = std::clamp(display.y / 1080.0f, 0.6f, 3.0f);
+ const float s = base_s * kMenuScale;
+
+ // Font-size quantization; see the settings overlay for the derivation.
+ constexpr float kEmPerSize = 2048.0f / 2478.0f; // Inter upm / (asc - desc)
+ auto font_px = [](float size) {
+ const float em_quantized = std::round(size * kEmPerSize) / kEmPerSize;
+ return std::round(em_quantized * 64.0f) / 64.0f;
+ };
+
+ const int action_count = static_cast(spec.actions.size());
+ focus_index = action_count ? std::clamp(focus_index, 0, action_count - 1) : 0;
+
+ // ---- Input ----
+ int activated = -1;
+ if (action_count) {
+ if (ImGui::IsKeyPressed(ImGuiKey_UpArrow, true)) {
+ focus_index = std::max(0, focus_index - 1);
+ }
+ if (ImGui::IsKeyPressed(ImGuiKey_DownArrow, true)) {
+ focus_index = std::min(action_count - 1, focus_index + 1);
+ }
+ if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) ||
+ ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false) ||
+ ImGui::IsKeyPressed(ImGuiKey_Space, false)) {
+ activated = focus_index;
+ }
+ }
+ const ImVec2 mouse = io.MousePos;
+ const bool mouse_moved = io.MouseDelta.x != 0.0f || io.MouseDelta.y != 0.0f;
+ // Activate on RELEASE (ImGui button semantics): activating on the press
+ // edge opens the modal file picker while the button is still down, the
+ // release lands in the picker, and ImGui's stuck-down state then eats the
+ // next click's press edge.
+ const bool clicked = ImGui::IsMouseReleased(0);
+ auto mouse_in = [&mouse](float x0, float y0, float x1, float y1) {
+ return mouse.x >= x0 && mouse.x < x1 && mouse.y >= y0 && mouse.y < y1;
+ };
+
+ // ---- Window ----
+ ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f));
+ ImGui::SetNextWindowSize(display);
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f));
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
+ if (!ImGui::Begin("##rexglue_wizard_screen", nullptr,
+ ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
+ ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
+ ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoCollapse |
+ ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoSavedSettings |
+ ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus)) {
+ ImGui::End();
+ ImGui::PopStyleVar(2);
+ return -1;
+ }
+ ImDrawList* dl = ImGui::GetWindowDrawList();
+
+ // ---- Backdrop: animated aurora field (final look, no scrim) ----
+ DrawAuroraBackdrop(dl, display, float(ImGui::GetTime()));
+
+ // ---- Layout ----
+ const float margin_x = std::max(56.0f * s, display.x * 0.05f);
+ const float col_w = Snap(std::min(640.0f * s, display.x - 2.0f * margin_x));
+ const float col_x = Snap((display.x - col_w) * 0.5f);
+ const float row_h = Snap(52.0f * s);
+ const float row_gap = Snap(5.0f * s);
+ const float footer_h = Snap(96.0f * base_s);
+ const float content_bottom = display.y - footer_h - 18.0f * s;
+ const float title_size = font_px(42.0f * s);
+ const float label_size = font_px(22.0f * s);
+ const float desc_size = font_px(20.0f * s);
+ const float desc_line_h = Snap(desc_size * 1.35f);
+ const float para_gap = Snap(10.0f * s);
+ const float panel_pad = Snap(16.0f * s);
+
+ // Wrap the paragraph text up front so the panel (and the whole block) can
+ // be measured before anything draws.
+ const float wrap_w = col_w - 2.0f * panel_pad;
+ std::vector>> para_lines;
+ float text_h = 0.0f;
+ for (const WizardScreenSpec::Paragraph& para : spec.paragraphs) {
+ para_lines.push_back(WrapLines(font, desc_size, wrap_w, para.text));
+ if (text_h > 0.0f) {
+ text_h += para_gap;
+ }
+ text_h += float(para_lines.back().size()) * desc_line_h;
+ }
+ const float panel_h = Snap(text_h + 2.0f * panel_pad);
+
+ float total_h = row_h + row_gap + panel_h; // section bar + text panel
+ total_h += float(spec.info_rows.size()) * (row_gap + row_h);
+ if (spec.show_progress) {
+ total_h += row_gap + row_h;
+ }
+ if (action_count) {
+ total_h += row_gap + Snap(13.0f * s); // spacer before the actions
+ total_h += float(action_count) * (row_gap + row_h);
+ }
+
+ // min after max: on very short windows the footer bound wins over the
+ // keep-the-title-visible bound (std::clamp would be UB with inverted
+ // bounds).
+ const float col_y = Snap(std::min(
+ std::max((display.y - total_h) * 0.5f, (98.0f + 74.0f) * s), content_bottom - total_h));
+
+ // ---- Title ----
+ dl->AddText(bold, title_size, ImVec2(col_x, Snap(col_y - 74.0f * s)), kColText, spec.title);
+
+ float y = col_y;
+
+ // ---- Section bar ----
+ dl->AddRectFilled(ImVec2(col_x, y), ImVec2(col_x + col_w, y + row_h), kColAccent);
+ AddTextVCentered(dl, bold_ol, label_size, col_x + 18.0f * s, y + row_h * 0.5f, kColAccentDark,
+ spec.section);
+ y += row_h + row_gap;
+
+ // ---- Intro / status panel ----
+ dl->AddRectFilled(ImVec2(col_x, y), ImVec2(col_x + col_w, y + panel_h), kColDescPanel);
+ dl->AddRect(ImVec2(col_x, y), ImVec2(col_x + col_w, y + panel_h), kColRailBorder);
+ {
+ float text_y = y + panel_pad;
+ for (size_t i = 0; i < spec.paragraphs.size(); ++i) {
+ ImU32 col = kColText;
+ ImFont* para_font = font;
+ if (spec.paragraphs[i].emphasis == WizardScreenSpec::Emphasis::kDim) {
+ col = kColTextDim;
+ } else if (spec.paragraphs[i].emphasis == WizardScreenSpec::Emphasis::kDanger) {
+ col = kColDanger;
+ para_font = bold;
+ }
+ for (const auto& [begin, end] : para_lines[i]) {
+ const std::string line(begin, end);
+ // Center each line's glyph box within its line slot (CSS line-height
+ // behavior); top-anchoring reads visibly high in the panel.
+ dl->AddText(para_font, desc_size,
+ ImVec2(Snap(col_x + panel_pad),
+ Snap(text_y + (desc_line_h - desc_size) * 0.5f)),
+ col, line.c_str());
+ text_y += desc_line_h;
+ }
+ text_y += para_gap;
+ }
+ }
+ y += panel_h + row_gap;
+
+ // ---- Info rows (read-only label/value) ----
+ for (const WizardScreenSpec::InfoRow& row : spec.info_rows) {
+ dl->AddRectFilled(ImVec2(col_x, y), ImVec2(col_x + col_w, y + row_h), kColPanel);
+ dl->AddRect(ImVec2(col_x, y), ImVec2(col_x + col_w, y + row_h), kColPanelBorder);
+ const float cy = y + row_h * 0.5f;
+ AddTextVCentered(dl, bold_ol, label_size, col_x + 18.0f * s, cy, kColRowText, row.label);
+ // Long paths shrink toward 15*s, then ellipsize from the FRONT - the
+ // filename tail is the informative part. The step walks the unquantized
+ // size so it always makes progress: font_px() snaps to buckets a little
+ // over a pixel wide, so at small viewport scales a step is narrower than
+ // one bucket and re-quantizing the previous result returns it unchanged.
+ const float label_w = bold_ol->CalcTextSizeA(label_size, FLT_MAX, 0.0f, row.label).x;
+ const float max_w = col_w - 36.0f * s - label_w - 24.0f * s;
+ const float min_vsize = 15.0f * s;
+ float raw_vsize = 18.0f * s;
+ float vsize = font_px(raw_vsize);
+ while (raw_vsize > min_vsize &&
+ font->CalcTextSizeA(vsize, FLT_MAX, 0.0f, row.value.c_str()).x > max_w) {
+ raw_vsize = std::max(min_vsize, raw_vsize - 0.5f * s);
+ vsize = font_px(raw_vsize);
+ }
+ std::string shown = row.value;
+ while (shown.size() > 1 &&
+ font->CalcTextSizeA(vsize, FLT_MAX, 0.0f,
+ (shown == row.value ? shown : "..." + shown).c_str())
+ .x > max_w) {
+ shown.erase(0, 1);
+ }
+ if (shown != row.value) {
+ shown = "..." + shown;
+ }
+ const float shown_w = font->CalcTextSizeA(vsize, FLT_MAX, 0.0f, shown.c_str()).x;
+ AddTextVCentered(dl, font, vsize, col_x + col_w - 18.0f * s - shown_w, cy, kColRowTextDim,
+ shown.c_str());
+ y += row_h + row_gap;
+ }
+
+ // ---- Progress row ----
+ if (spec.show_progress) {
+ dl->AddRectFilled(ImVec2(col_x, y), ImVec2(col_x + col_w, y + row_h), kColPanel);
+ dl->AddRect(ImVec2(col_x, y), ImVec2(col_x + col_w, y + row_h), kColPanelBorder);
+ const float cy = y + row_h * 0.5f;
+ std::string bytes_text;
+ if (spec.progress_copied > 0) {
+ bytes_text = FormatBytes(spec.progress_copied) + " / " + FormatBytes(spec.progress_total);
+ }
+ const float bytes_size = font_px(17.0f * s);
+ const float bytes_w =
+ bytes_text.empty()
+ ? 0.0f
+ : bold_ol->CalcTextSizeA(bytes_size, FLT_MAX, 0.0f, bytes_text.c_str()).x;
+ const float tx0 = Snap(col_x + 18.0f * s);
+ const float tx1 =
+ Snap(col_x + col_w - 18.0f * s - (bytes_w > 0.0f ? bytes_w + 16.0f * s : 0.0f));
+ dl->AddRectFilled(ImVec2(tx0, Snap(cy - 3.0f * s)), ImVec2(tx1, Snap(cy + 3.0f * s)),
+ IM_COL32(0, 0, 0, 46));
+ if (spec.progress_copied > 0 && spec.progress_total > 0) {
+ const float frac = std::clamp(
+ float(double(spec.progress_copied) / double(spec.progress_total)), 0.0f, 1.0f);
+ dl->AddRectFilled(ImVec2(tx0, Snap(cy - 3.0f * s)),
+ ImVec2(Snap(tx0 + (tx1 - tx0) * frac), Snap(cy + 3.0f * s)),
+ kColInteract);
+ } else {
+ // No bytes yet (connecting / spinning up): an indeterminate marching
+ // band, so a slow first response still reads as activity rather than a
+ // stalled click.
+ const float track_w = tx1 - tx0;
+ const float band_w = track_w * 0.22f;
+ const float cycle = float(std::fmod(ImGui::GetTime() * 0.55, 1.0));
+ const float band_x = tx0 - band_w + cycle * (track_w + band_w);
+ const float b0 = std::max(tx0, band_x);
+ const float b1 = std::min(tx1, band_x + band_w);
+ if (b1 > b0) {
+ dl->AddRectFilled(ImVec2(Snap(b0), Snap(cy - 3.0f * s)),
+ ImVec2(Snap(b1), Snap(cy + 3.0f * s)), kColInteract);
+ }
+ }
+ if (!bytes_text.empty()) {
+ AddTextVCentered(dl, bold_ol, bytes_size, col_x + col_w - 18.0f * s - bytes_w, cy,
+ kColRowText, bytes_text.c_str());
+ }
+ y += row_h + row_gap;
+ }
+
+ // ---- Action rows ----
+ if (action_count) {
+ y += Snap(13.0f * s) + row_gap;
+ const float actions_y0 = y;
+ for (int i = 0; i < action_count; ++i) {
+ const float y0 = y;
+ const float y1 = y0 + row_h;
+ const bool hovered = mouse_in(col_x, y0, col_x + col_w, y1);
+ if (hovered && mouse_moved) {
+ focus_index = i;
+ }
+ if (hovered && clicked) {
+ focus_index = i;
+ activated = i;
+ }
+ const bool focused = focus_index == i;
+ if (!focused) {
+ dl->AddRectFilled(ImVec2(col_x, y0), ImVec2(col_x + col_w, y1),
+ hovered ? kColPanelHover : kColPanel);
+ dl->AddRect(ImVec2(col_x, y0), ImVec2(col_x + col_w, y1), kColPanelBorder);
+ AddTextVCentered(dl, bold_ol, label_size, col_x + 18.0f * s, (y0 + y1) * 0.5f,
+ kColRowText, spec.actions[i].c_str());
+ }
+ y += row_h + row_gap;
+ }
+ // Sliding highlight, drawn over the neighbouring rows like the settings
+ // menu's focused row; the focused label rides on top of it.
+ {
+ const float target = actions_y0 + float(focus_index) * (row_h + row_gap);
+ if (highlight_anim_y < 0.0f || std::abs(highlight_anim_y - target) > 160.0f * s) {
+ highlight_anim_y = target;
+ }
+ highlight_anim_y += (target - highlight_anim_y) * std::min(1.0f, io.DeltaTime * 22.0f);
+ if (std::abs(highlight_anim_y - target) < 0.5f) {
+ highlight_anim_y = target;
+ }
+ const ImVec2 hi_min(col_x, Snap(highlight_anim_y));
+ const ImVec2 hi_max(col_x + col_w, Snap(highlight_anim_y) + row_h);
+ DrawFocusHighlight(dl, hi_min, hi_max, s);
+ AddTextVCentered(dl, bold, label_size, col_x + 18.0f * s, (hi_min.y + hi_max.y) * 0.5f,
+ kColSelText, spec.actions[focus_index].c_str());
+ }
+ } else {
+ highlight_anim_y = -1.0f;
+ }
+
+ // ---- Footer legend (base viewport scale, like the settings menu) ----
+ if (action_count) {
+ const float fs = base_s;
+ const float legend_y = Snap(display.y - footer_h + 14.0f * fs);
+ const float glyph_size = font_px(15.0f * fs);
+ const float label_text_size = font_px(16.0f * fs);
+ const float chip_h = Snap(26.0f * fs);
+ struct LegendGlyph {
+ const char* glyph;
+ const char* label;
+ };
+ std::vector glyphs;
+ glyphs.push_back({"Enter", "Select"});
+ if (action_count > 1) {
+ glyphs.push_back({"Up / Down", "Navigate"});
+ }
+ float x = col_x;
+ for (const LegendGlyph& glyph : glyphs) {
+ ImVec2 glyph_extent = bold->CalcTextSizeA(glyph_size, FLT_MAX, 0.0f, glyph.glyph);
+ const float cy = legend_y + chip_h * 0.5f;
+ x = Snap(x);
+ const float chip_w = Snap(glyph_extent.x + 18.0f * fs);
+ DrawHardRoundedFill(dl, ImVec2(x, legend_y), ImVec2(x + chip_w, legend_y + chip_h),
+ 4.0f * fs, kColLegendChip);
+ AddTextCenteredCap(dl, bold_ol, glyph_size, ImVec2(x + chip_w * 0.5f, cy), kColLegendText,
+ glyph.glyph);
+ x += chip_w + 8.0f * fs;
+ ImVec2 label_extent = bold->CalcTextSizeA(label_text_size, FLT_MAX, 0.0f, glyph.label);
+ dl->AddText(bold, label_text_size, ImVec2(Snap(x), Snap(cy - label_extent.y * 0.5f)),
+ kColLegendLabel, glyph.label);
+ x += label_extent.x + 26.0f * fs;
+ }
+ }
+
+ ImGui::End();
+ ImGui::PopStyleVar(2);
+ return activated;
+}
+
+void ConfigureWizardFonts(ImFontAtlas* atlas) {
+ struct Candidate {
+ const char* regular;
+ const char* bold;
+ };
+#if defined(_WIN32)
+ std::string fonts_dir = "C:\\Windows\\Fonts\\";
+ if (const char* windir = std::getenv("WINDIR"); windir != nullptr && *windir != '\0') {
+ fonts_dir = std::string(windir) + "\\Fonts\\";
+ }
+ const std::string regular_candidates[] = {fonts_dir + "segoeui.ttf"};
+ const std::string bold_candidates[] = {fonts_dir + "seguisb.ttf", fonts_dir + "segoeuib.ttf"};
+#else
+ const std::string regular_candidates[] = {
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+ "/usr/share/fonts/TTF/DejaVuSans.ttf",
+ "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
+ };
+ const std::string bold_candidates[] = {
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
+ "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
+ "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
+ };
+#endif
+ auto add_first_existing = [atlas](const auto& candidates) -> ImFont* {
+ for (const std::string& path : candidates) {
+ std::error_code ec;
+ if (std::filesystem::is_regular_file(path, ec) && !ec) {
+ if (ImFont* font = atlas->AddFontFromFileTTF(path.c_str(), 16.0f)) {
+ return font;
+ }
+ }
+ }
+ return nullptr;
+ };
+ g_wizard_font = add_first_existing(regular_candidates);
+ g_wizard_font_bold = add_first_existing(bold_candidates);
+}
+
+} // namespace rex::ui
diff --git a/src/ui/wizard_screen.h b/src/ui/wizard_screen.h
new file mode 100644
index 0000000..26998e6
--- /dev/null
+++ b/src/ui/wizard_screen.h
@@ -0,0 +1,66 @@
+/**
+ * @file src/ui/wizard_screen.h
+ *
+ * @brief Shared renderer for the pre-runtime wizard dialogs (installer,
+ * acquisition), drawing them in the settings-menu visual style.
+ *
+ * @note Vendored from the ReXGlue SDK overlay used by other recomps
+ * (src/ui/overlay/wizard_screen.* in mchughalex/rexglue-skate3
+ * and LittleBitUA/rexglue-sdk-dpour). Kept in rex::ui so this
+ * copy can be dropped as-is once the stock SDK ships it.
+ */
+#pragma once
+
+#include
+#include
+#include
+
+#include
+
+namespace rex::ui {
+
+class ImGuiDrawer;
+
+// One frame's worth of wizard content. The owning dialog rebuilds this every
+// frame from its state machine; the renderer owns no state beyond what the
+// dialog passes back in through focus_index / highlight_anim_y.
+struct WizardScreenSpec {
+ enum class Emphasis {
+ kNormal, // primary text
+ kDim, // secondary text (status under an intro)
+ kDanger, // error text
+ };
+ struct Paragraph {
+ std::string text;
+ Emphasis emphasis = Emphasis::kNormal;
+ };
+ struct InfoRow {
+ const char* label;
+ std::string value;
+ };
+
+ const char* title = ""; // page title above the block
+ const char* section = ""; // accent section bar label
+ std::vector paragraphs;
+ std::vector info_rows;
+ bool show_progress = false;
+ uint64_t progress_copied = 0;
+ uint64_t progress_total = 0;
+ std::vector actions; // focusable action rows, top to bottom
+};
+
+// Draws a full-screen wizard frame (plaza gradient backdrop, scrim, centered
+// content column, footer legend) and handles mouse + keyboard navigation over
+// the action rows. focus_index and highlight_anim_y persist across frames in
+// the owning dialog. Returns the index of the action activated this frame, or
+// -1 if none.
+int DrawWizardScreen(ImGuiDrawer* drawer, ImGuiIO& io, const WizardScreenSpec& spec,
+ int& focus_index, float& highlight_anim_y);
+
+// Loads scalable system UI fonts for the wizard into the atlas (the stock SDK
+// only registers a 10 px bitmap font, which upscales poorly to the wizard's
+// heading sizes). Call from ReXApp::OnConfigureFonts(); a no-op leaving the
+// default font in use when no known system font is found.
+void ConfigureWizardFonts(ImFontAtlas* atlas);
+
+} // namespace rex::ui