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