diff --git a/Extension/Assets/Test/mod_store_view_tests.cpp b/Extension/Assets/Test/mod_store_view_tests.cpp new file mode 100644 index 00000000..e35532e4 --- /dev/null +++ b/Extension/Assets/Test/mod_store_view_tests.cpp @@ -0,0 +1,162 @@ +// The in-game Mod Manager's view of Thunderstore's listing: search, the front page's rows, what a +// package's button does, and what installing one brings with it. +#include "Extension/Assets/mod_store_view.h" + +#include +#include +#include + +using namespace dingosdk; +namespace ms = dingosdk::mod_store; +namespace ts = dingosdk::thunderstore; + +namespace { + +int failures = 0; + +void check(bool condition, const char* what) { + if (condition) return; + std::cerr << "FAIL: " << what << '\n'; + ++failures; +} + +// One package row of a listing, with one version. +std::string package(const std::string& owner, const std::string& name, const std::string& version, const std::string& categories, + const std::string& updated, unsigned downloads, const std::string& dependencies = {}, bool pinned = false, + bool deprecated = false, bool nsfw = false, const std::string& description = "A mod.") { + const auto full = owner + '-' + name; + return R"({"name": ")" + name + R"(", "full_name": ")" + full + R"(", "owner": ")" + owner + R"(", + "package_url": "https://thunderstore.io/c/reskate/p/)" + owner + '/' + name + R"(/", + "date_created": ")" + updated + R"(", "date_updated": ")" + updated + R"(", + "rating_score": 1, "is_pinned": )" + (pinned ? "true" : "false") + R"(, "is_deprecated": )" + (deprecated ? "true" : "false") + + R"(, "has_nsfw_content": )" + (nsfw ? "true" : "false") + R"(, "categories": [)" + categories + R"(], + "versions": [{"name": ")" + name + R"(", "full_name": ")" + full + '-' + version + R"(", "description": ")" + description + R"(", + "icon": "https://gcdn.thunderstore.io/live/repository/icons/)" + full + '-' + version + R"(.png", + "version_number": ")" + version + R"(", "dependencies": [)" + dependencies + R"(], "downloads": )" + std::to_string(downloads) + R"(, + "download_url": "https://thunderstore.io/package/download/)" + owner + '/' + name + '/' + version + R"(/", + "date_created": ")" + updated + R"(", "website_url": "", "is_active": true, "file_size": 1048576}]})"; +} + +bool has(const std::vector& list, std::string_view full_name) { + return std::any_of(list.begin(), list.end(), [&](const ts::Package* p) { return p->full_name == full_name; }); +} + +} // namespace + +int main() { + const auto text = "[" + + package("Zee", "Desert_Springs", "1.2.0", R"("Maps")", "2026-10-20T10:00:00Z", 5000) + "," + + package("Sandos", "Vancouver_Plaza", "2.0.0", R"("Maps")", "2026-06-01T10:00:00Z", 9000) + "," + + package("Fourth", "Board_Pack", "1.0.0", R"("Skateboards", "Cosmetics")", "2026-10-22T10:00:00Z", 300, + R"("Fourth-Board_Lib-1.0.0", "Missing-Thing-1.0.0")") + "," + + package("Fourth", "Board_Lib", "1.0.0", R"("Libraries")", "2026-10-01T10:00:00Z", 100) + "," + + package("Team", "Pinned_Pack", "1.0.0", R"("Modpacks")", "2025-01-01T10:00:00Z", 10, {}, true) + "," + + package("Old", "Deprecated_Map", "1.0.0", R"("Maps")", "2025-01-01T10:00:00Z", 80000, {}, false, true) + "," + + package("Bad", "Not_Safe", "1.0.0", R"("Cosmetics")", "2026-10-23T10:00:00Z", 90000, {}, false, false, true) + "," + + package("Bad", "Also_Not_Safe", "1.0.0", R"("NSFW")", "2026-10-23T10:00:00Z", 90000) + "," + + package("Zee", "Hoodie", "1.0.0", R"("Cosmetics")", "2026-10-10T10:00:00Z", 700, {}, false, false, false, "A warm desert hoodie") + "]"; + const auto packages = ts::parse_listing(text); + check(packages.size() == 9, "the fixture listing parses"); + ts::Installed none; + + // ---- search + { + const auto all = ms::search(packages, {}, none); + check(!has(all, "Bad-Not_Safe") && !has(all, "Bad-Also_Not_Safe"), "NSFW packages are never listed, by mark or by category"); + check(!has(all, "Old-Deprecated_Map"), "a deprecated package is not listed when it is not installed"); + ts::Installed have{{"Old-Deprecated_Map", "1.0.0"}}; + check(has(ms::search(packages, {}, have), "Old-Deprecated_Map"), "a deprecated package is listed once it is installed"); + check(!all.empty() && all.front()->full_name == "Team-Pinned_Pack", "pinned packages come first"); + check(all.size() > 2 && all[1]->full_name == "Sandos-Vancouver_Plaza", "then the most downloaded"); + const auto desert = ms::search(packages, {"DESERT"}, none); + check(desert.size() == 2 && has(desert, "Zee-Desert_Springs") && has(desert, "Zee-Hoodie"), + "search finds the title and the description, in any case"); + check(ms::search(packages, {"sandos"}, none).size() == 1, "search finds the owner"); + const auto maps = ms::search(packages, {{}, {"Maps"}}, none); + check(maps.size() == 2 && has(maps, "Zee-Desert_Springs") && has(maps, "Sandos-Vancouver_Plaza"), "a category shown only keeps only its packages"); + const auto several = ms::search(packages, {{}, {"Maps", "Skateboards"}}, none); + check(several.size() == 3 && has(several, "Fourth-Board_Pack"), "several categories shown only keep the packages in any of them"); + const auto without = ms::search(packages, {{}, {}, {"Cosmetics"}}, none); + check(!has(without, "Fourth-Board_Pack") && !has(without, "Zee-Hoodie") && has(without, "Zee-Desert_Springs"), + "a hidden category leaves out every package in it, whatever else it is in"); + const auto both = ms::search(packages, {{}, {"Skateboards", "Maps"}, {"Cosmetics"}}, none); + check(both.size() == 2 && !has(both, "Fourth-Board_Pack"), "hidden wins over shown only"); + const auto updated = ms::search(packages, {{}, {"Maps"}, {}, ms::Sort::updated}, none); + check(updated.size() == 2 && updated.front()->full_name == "Zee-Desert_Springs", "LAST UPDATED puts the newest update first"); + const auto by_name = ms::search(packages, {{}, {}, {}, ms::Sort::name}, none); + check(by_name.size() > 2 && by_name[1]->full_name == "Fourth-Board_Lib", "NAME sorts A to Z after the pinned ones"); + } + + // ---- categories + { + const auto categories = ms::categories(packages); + const auto count = [&](std::string_view name) { + for (const auto& [category, n] : categories) + if (category == name) return n; + return -1; + }; + check(count("Maps") == 3 && count("Cosmetics") == 2, "categories count their packages, NSFW ones left out"); + check(count("NSFW") == -1, "a category only NSFW packages have is not offered"); + check(!categories.empty() && categories.front().first == "Maps", "the biggest category comes first"); + } + + // ---- the front page + { + const auto front = ms::front(packages, none, "2026-10-24", 3, 10); + check(front.featured.size() == 3, "featured holds at most what was asked for"); + check(!front.featured.empty() && front.featured.front()->full_name == "Team-Pinned_Pack", "a pinned package is featured first"); + check(front.featured.size() == 3 && front.featured[1]->full_name == "Zee-Desert_Springs", + "then the most downloaded of those updated in the last 60 days (not the older, more downloaded one)"); + check(std::none_of(front.featured.begin(), front.featured.end(), [](const ts::Package* p) { return ms::nsfw(*p) || p->deprecated; }), + "nothing NSFW or deprecated is featured"); + const auto shelf = [&](std::string_view title) -> const ms::Shelf* { + for (const auto& row : front.shelves) + if (row.title == title) return &row; + return nullptr; + }; + const auto* popular = shelf("MOST POPULAR"); + check(popular && !popular->packages.empty() && popular->packages.front()->full_name == "Sandos-Vancouver_Plaza", "MOST POPULAR is by downloads"); + const auto* fresh = shelf("NEW"); + check(fresh && !fresh->packages.empty() && fresh->packages.front()->full_name == "Fourth-Board_Pack", "NEW is by when it was made"); + check(shelf("MAPS") && shelf("COSMETICS"), "the biggest categories get rows of their own"); + check(!shelf("LIBRARIES") && !shelf("MODPACKS"), "libraries and mod packs get no row"); + check(ms::front(packages, none, "2026-10-24", 8, 2).shelves.front().packages.size() == 2, "a row holds at most what was asked for"); + } + + // ---- what a package's button does + { + const auto* desert = ts::find_package(packages, "Zee-Desert_Springs"); + check(desert && ms::action_for(*desert, none) == ms::Action::install, "not installed: INSTALL"); + check(desert && ms::action_for(*desert, {{"Zee-Desert_Springs", "1.1.0"}}) == ms::Action::update, "older: UPDATE"); + check(desert && ms::action_for(*desert, {{"Zee-Desert_Springs", "1.2.0"}}) == ms::Action::installed, "the newest: INSTALLED"); + } + + // ---- what installing brings + { + const auto* pack = ts::find_package(packages, "Fourth-Board_Pack"); + check(pack != nullptr, "the pack is listed"); + if (pack) { + const auto plan = ms::plan(std::vector{*pack}, packages, none); + check(plan.packages.size() == 2 && plan.packages.front().full_name == "Fourth-Board_Lib" && plan.packages.back().full_name == "Fourth-Board_Pack", + "what a package needs comes first"); + check(plan.unknown.size() == 1 && plan.unknown.front().starts_with("Missing-Thing"), "a dependency the listing lacks is named"); + check(plan.bytes == 2 * 1048576, "the download size adds up"); + const auto again = ms::plan(std::vector{*pack}, packages, {{"Fourth-Board_Lib", "1.0.0"}}); + check(again.packages.size() == 1, "a dependency already installed at its version is not fetched again"); + const auto twice = ms::plan(std::vector{*pack, *ts::find_package(packages, "Fourth-Board_Lib")}, packages, none); + check(twice.packages.size() == 2, "each package is fetched once"); + } + } + + // ---- text + check(ms::size_text(1536 * 1024) == "1.5 MB" && ms::size_text(2048) == "2 KB", "sizes read as KB and MB"); + check(ms::count_text(950) == "950" && ms::count_text(12300) == "12.3K" && ms::count_text(2500000) == "2.5M", "counts read short"); + check(ms::date_text("2026-10-24T12:00:00Z") == "Oct 24, 2026", "dates read as a player would write them"); + + if (failures) { + std::cerr << failures << " mod store view check(s) failed\n"; + return 1; + } + std::cout << "mod store view: all checks passed\n"; + return 0; +} diff --git a/Extension/Assets/mod_store.cpp b/Extension/Assets/mod_store.cpp new file mode 100644 index 00000000..869954d7 --- /dev/null +++ b/Extension/Assets/mod_store.cpp @@ -0,0 +1,663 @@ +#include "mod_store.h" +#include "map_download.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Vfs/https_download.h" +#include "Engine/Vfs/mod_catalog.h" +#include "Engine/Vfs/mod_conflicts.h" +#include "Engine/Vfs/mod_list.h" +#include "Engine/Vfs/thunderstore_package.h" +#include "Launcher/mod_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::mod_store { +namespace { +namespace fs = std::filesystem; +namespace package = thunderstore_package; + +constexpr wchar_t agent[] = L"ReSkate"; +constexpr std::uint64_t listing_most = 256ull << 20, index_most = 4ull << 20; +constexpr std::uint64_t stale_seconds = 10 * 60; +constexpr std::uint64_t largest_download = 8ull << 30; +constexpr std::uint32_t download_seconds = 4 * 60 * 60; + +std::uint64_t unix_now() { + return static_cast(std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); +} +fs::path mods_root() { return mods::engine_data_root() / mods::mods_folder; } +fs::path local_folder() { + std::array local{}; + const auto size = GetEnvironmentVariableW(L"LOCALAPPDATA", local.data(), static_cast(local.size())); + if (!size || size >= local.size()) return {}; + return fs::path(local.data()) / L"ReSkate" / L"thunderstore"; +} +std::wstring wide(std::string_view ascii) { return std::wstring(ascii.begin(), ascii.end()); } +bool same(std::string_view a, std::string_view b) { + return a.size() == b.size() && _strnicmp(a.data(), b.data(), a.size()) == 0; +} +void log(logging::Level level, const std::string& text) { logging::write(level, logging::Channel::assets, "Mod Manager: " + text); } + +// ---------------------------------------------------------------- the listing + +std::mutex listing_mutex; +std::shared_ptr current_listing = std::make_shared(); // under the mutex +std::atomic fetching{}; +std::atomic next_fetch{}; + +fs::path listing_file() { + const auto folder = local_folder(); + return folder.empty() ? fs::path() : folder / wide("listing-" + thunderstore::community() + ".json"); +} +// One listing chunk (a JSON array) without its brackets, for joining chunks into one array. +std::string_view inner(std::string_view json) { + while (!json.empty() && std::isspace(static_cast(json.front()))) json.remove_prefix(1); + while (!json.empty() && std::isspace(static_cast(json.back()))) json.remove_suffix(1); + if (json.size() < 2 || json.front() != '[' || json.back() != ']') return {}; + json = json.substr(1, json.size() - 2); + while (!json.empty() && std::isspace(static_cast(json.front()))) json.remove_prefix(1); + return json; +} +void publish(std::vector packages, std::uint64_t fetched, std::string error) { + std::lock_guard lock(listing_mutex); + auto next = std::make_shared(); + if (packages.empty() && !current_listing->packages.empty()) { + // A failed fetch keeps what was there. + next->packages = current_listing->packages; + next->fetched = current_listing->fetched; + } else { + next->packages = std::move(packages); + next->fetched = fetched; + } + next->error = std::move(error); + next->fetching = fetching.load(); + next->serial = current_listing->serial + 1; + current_listing = std::move(next); +} +// The whole listing as one JSON array: the index's chunks, else the plain listing. Empty on failure. +std::string download_listing(std::string& error) { + const auto community = thunderstore::community(); + https::Download result; + if (const auto index = https::get_text(thunderstore::listing_index_url(community), index_most, 30, agent, &result)) { + try { + const auto urls = thunderstore::parse_index(thunderstore::gunzip(*index, index_most)); + std::string joined = "["; + bool all = !urls.empty(); + for (const auto& url : urls) { + const auto chunk = https::get_text(url, listing_most, 120, agent, &result); + if (!chunk) { all = false; break; } + const auto text = thunderstore::gunzip(*chunk, listing_most); + const auto body = inner(text); + if (body.empty()) continue; + if (joined.size() > 1) joined += ','; + joined += body; + } + if (all) return joined + "]"; + } catch (const std::exception&) {} + } + const auto plain = https::get_text(thunderstore::listing_url(community), listing_most, 180, agent, &result); + if (!plain) { + error = result.http_status && result.http_status != 200 + ? "Thunderstore answered " + std::to_string(result.http_status) + "." + : "Thunderstore could not be reached (Windows error " + std::to_string(result.error) + ")."; + return {}; + } + try { return thunderstore::gunzip(*plain, listing_most); } catch (const std::exception& failure) { error = failure.what(); } + return {}; +} +void fetch_listing(bool from_disk) { + try { + const auto file = listing_file(); + if (from_disk && !file.empty()) { + std::error_code error; + if (fs::is_regular_file(file, error)) { + std::ifstream in(file, std::ios::binary); + std::string text((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + auto packages = thunderstore::parse_listing(text); + const auto written = fs::last_write_time(file, error); + const auto age = error ? stale_seconds : static_cast(std::max(0, + std::chrono::duration_cast(fs::file_time_type::clock::now() - written).count())); + const auto when = unix_now() - std::min(age, unix_now()); + if (!packages.empty()) { + publish(std::move(packages), when, {}); + if (age < stale_seconds) { + next_fetch.store(when + stale_seconds); + fetching.store(false); + publish({}, 0, {}); + return; + } + } + } + } + std::string error; + const auto text = download_listing(error); + auto packages = text.empty() ? std::vector() : thunderstore::parse_listing(text); + if (!text.empty() && packages.empty()) error = "Thunderstore's listing could not be read."; + if (!packages.empty() && !file.empty()) { + std::error_code ignored; + fs::create_directories(file.parent_path(), ignored); + const auto temporary = fs::path(file).concat(L".tmp"); + { + std::ofstream out(temporary, std::ios::binary | std::ios::trunc); + out.write(text.data(), static_cast(text.size())); + } + MoveFileExW(temporary.c_str(), file.c_str(), MOVEFILE_REPLACE_EXISTING); + } + log(packages.empty() ? logging::Level::warning : logging::Level::info, + packages.empty() ? "the listing could not be fetched: " + error : std::to_string(packages.size()) + " packages listed."); + const auto now = unix_now(); + // A failure is tried again in a minute. + next_fetch.store(now + (packages.empty() ? 60 : stale_seconds)); + fetching.store(false); + publish(std::move(packages), now, std::move(error)); + } catch (const std::exception& failure) { + next_fetch.store(unix_now() + 60); + fetching.store(false); + publish({}, 0, failure.what()); + } catch (...) { + next_fetch.store(unix_now() + 60); + fetching.store(false); + publish({}, 0, "the listing could not be read"); + } +} +void start_fetch(bool from_disk) { + if (fetching.exchange(true)) return; + publish({}, 0, {}); + try { std::thread([from_disk] { fetch_listing(from_disk); }).detach(); } + catch (...) { fetching.store(false); } +} + +// ---------------------------------------------------------------- the installed mods + +std::mutex mods_mutex; +std::shared_ptr current_mods; // under the mutex +std::atomic scanning{}; +std::atomic next_scan{}; // GetTickCount64 +std::atomic mods_serial{}; +// mods.json is written by one change at a time. +std::mutex write_mutex; +// Mods in the game whose new version went in here since the mods were last applied: the game still +// has the old one merged until they are. +std::mutex updated_mutex; +std::set> updated_since_apply; // under the mutex +bool updated_since(const std::string& folder) { + std::lock_guard lock(updated_mutex); + return std::any_of(updated_since_apply.begin(), updated_since_apply.end(), [&](const std::string& name) { return same(name, folder); }); +} + +std::atomic applied_at{}; // GetTickCount64 when apply() last started a merge +Mods read_mods() { + Mods result; + result.settled = !live_mods::busy() && GetTickCount64() > applied_at.load() + 100; + const auto list = mods::scan_mods(mods::engine_data_root()); + result.present = list.present; + result.issue = list.issue; + const auto& launch = mods::catalog(); + const auto applied = live_mods::applied_mods(); + const auto waiting = launcher_mods::pending(list.root); + result.waiting = waiting.updates.size() + waiting.removals.size(); + const auto named = [](const std::vector& names, const std::string& name) { + return std::any_of(names.begin(), names.end(), [&](const std::string& other) { return same(other, name); }); + }; + // ReSkate's own packs live outside the Mods folder's list, always on. + const auto kept = [&](const std::string& name) { + const auto owner = mods::variant_owner(name); + return (owner.empty() ? name : owner).starts_with("reskate-builtin-"); + }; + // A mod with variants is in effect when one of its variants is (mod_catalog.h). + const auto in_effect = [&](const std::string& name) { + return std::any_of(applied.begin(), applied.end(), [&](const std::string& other) { + return same(other, name) || same(mods::variant_owner(other), name); + }); + }; + const auto excluded = [&](const std::string& name) -> const mods::Mod* { + for (const auto& mod : launch.excluded) + if (same(mod.name, name)) return &mod; + return nullptr; + }; + const auto aside = mods::set_aside_for_variants(); + const auto title_of = [&](const std::string& name) { + for (const auto& entry : list.entries) + if (same(entry.mod.name, name)) return entry.mod.title.empty() ? name : entry.mod.title; + return name; + }; + bool reload_for_update{}; + for (const auto& entry : list.entries) { + const auto& mod = entry.mod; + Mod row{mod.name, mod.title.empty() ? mod.name : mod.title, mod.author, mod.version, mod.description, + mods::mod_contents(mod), entry.enabled, in_effect(mod.name)}; + result.versions[mod.name] = mod.version; + const auto* left_out = excluded(mod.name); + const bool set_aside = entry.enabled && !row.in_effect && named(aside, mod.name); + if (named(waiting.removals, mod.name)) { + row.state = State::removal_waiting; + row.why = "It is uninstalled the next time the game starts: the game has its files open now."; + } else if (named(waiting.updates, mod.name)) { + row.state = State::update_waiting; + row.why = "Its update goes in the next time the game starts: the game has its files open now."; + } else if (left_out && !mods::conflict_winner(*left_out).empty()) { + row.state = State::left_out; + row.why = "It changes the same parts of the game as " + title_of(mods::conflict_winner(*left_out)) + + ", which is higher in the load order, so only that one loads. Change the order in the launcher."; + } else if (left_out) { + row.state = State::not_loaded; + row.why = !left_out->outdated.empty() ? left_out->outdated + : "It could not be merged cleanly, so none of it is used. Reinstall it, or ask its author for an update."; + } else if (set_aside) { + row.state = State::set_aside; + row.why = "It changes what another mod's chosen variant replaces, and only one of them can be in the game."; + } else if (entry.enabled && row.in_effect && updated_since(mod.name) && (mod.provides_layout || mod.provides_levels)) { + row.state = State::apply_to_load; + row.why = "Its new version is installed: it goes in the game when the mods are applied."; + reload_for_update |= !live_mods::only_maps(mod.directory); + } else if (entry.enabled && row.in_effect) row.state = State::loaded; + else if (entry.enabled) row.state = State::apply_to_load; + else if (row.in_effect) row.state = State::apply_to_unload; + else row.state = State::disabled; + if (row.state == State::apply_to_load || row.state == State::apply_to_unload) ++result.changes; + result.mods.push_back(std::move(row)); + } + // In effect, but its folder has been deleted: applying unloads it. + for (const auto& name : applied) + if (mods::variant_owner(name).empty() && !kept(name) && + std::none_of(list.entries.begin(), list.entries.end(), [&](const mods::ModEntry& entry) { return same(entry.mod.name, name); })) { + Mod row{name, name}; + row.in_effect = row.gone = true; + row.state = State::apply_to_unload; + row.why = "Its folder is gone, but it stays in the game until the mods are applied."; + ++result.changes; + result.mods.push_back(std::move(row)); + } + if (result.changes) result.effect = live_mods::preview(list); + // (preview only sees mods going in or out; a new version of one in the game is neither.) + if (reload_for_update && result.effect == live_mods::ApplyEffect::maps_only) result.effect = live_mods::ApplyEffect::reload_level; + return result; +} +void start_scan() { + if (scanning.exchange(true)) return; + next_scan.store(GetTickCount64() + 1500); + try { + std::thread([] { + std::shared_ptr fresh; + try { + auto read = read_mods(); + read.serial = ++mods_serial; + fresh = std::make_shared(std::move(read)); + } catch (...) {} + if (fresh) { + std::lock_guard lock(mods_mutex); + current_mods = std::move(fresh); + } + scanning.store(false); + }).detach(); + } catch (...) { scanning.store(false); } +} +// After a change made here: read again at once (or as soon as the read under way ends). +void rescan() { next_scan.store(0); } + +// ---------------------------------------------------------------- the queue + +std::mutex progress_mutex; +Progress current_progress; // under the mutex +std::atomic busy{}, stop{}; +https::Watch watch; + +void set_activity(std::string text, float fraction = -1) { + std::lock_guard lock(progress_mutex); + current_progress.activity = std::move(text); + current_progress.fraction = fraction; +} +// The package in hand: what it is, and where the job is with it. +void set_package(Progress::Step step, const ts::Package* wanted, std::size_t at, std::size_t of, std::vector queued = {}) { + std::lock_guard lock(progress_mutex); + current_progress.step = step; + current_progress.at = at; + current_progress.of = of; + current_progress.received = current_progress.total = 0; + if (wanted) { + current_progress.package = wanted->full_name; + current_progress.title = wanted->title(); + current_progress.version = wanted->latest().number; + current_progress.icon = wanted->latest().icon; + current_progress.queued = std::move(queued); + } +} +void set_bytes(std::uint64_t received, std::uint64_t total) { + std::lock_guard lock(progress_mutex); + current_progress.received = received; + current_progress.total = total; +} +void finish(std::string result, bool failed) { + log(failed ? logging::Level::warning : logging::Level::info, result); + { + std::lock_guard lock(progress_mutex); + current_progress.busy = false; + current_progress.activity.clear(); + current_progress.fraction = -1; + current_progress.step = Progress::Step::starting; + current_progress.package.clear(); + current_progress.queued.clear(); + current_progress.result = std::move(result); + current_progress.failed = failed; + ++current_progress.serial; + } + busy.store(false); + rescan(); +} +std::string begin(const char* what) { + if (map_download::view().stage != map_download::Stage::idle) return "A map for a session is being downloaded. Wait for it to finish."; + if (live_mods::busy()) return "The mods are being applied. Wait for it to finish."; + if (busy.exchange(true)) return std::string("Wait for the ") + what + " under way to finish."; + stop.store(false); + watch.cancel.store(false); + std::lock_guard lock(progress_mutex); + current_progress.busy = true; + current_progress.result.clear(); + current_progress.failed = false; + current_progress.activity = "Starting..."; + current_progress.fraction = -1; + current_progress.step = Progress::Step::starting; + current_progress.package.clear(); + current_progress.queued.clear(); + return {}; +} + +// Downloads and installs one package (its newest version). Returns what happened ("" when it went +// in now, "later" when it waits for the next start) or throws why not. +std::string install_one(const ts::Package& wanted, std::size_t at, std::size_t of, std::vector queued) { + const auto& version = wanted.latest(); + const auto root = mods_root(); + const auto counter = of > 1 ? " (" + std::to_string(at + 1) + " of " + std::to_string(of) + ")" : std::string(); + const auto archive_folder = root / L".reskate-download"; + std::error_code error; + fs::create_directories(archive_folder, error); + const auto archive = archive_folder / (wide(wanted.full_name) + L"-ingame.zip"); + struct Cleanup { + fs::path archive, folder; + ~Cleanup() { std::error_code ignored; fs::remove(archive, ignored); fs::remove(folder, ignored); } + } cleanup{archive, archive_folder}; + fs::remove(archive, error); + // Always from thunderstore.io, whatever the listing names (as the map download does). + const package::Name name{wanted.owner, wanted.name, version.number}; + set_package(Progress::Step::downloading, &wanted, at, of, std::move(queued)); + set_activity("Downloading " + wanted.title() + " v" + version.number + counter, 0); + watch.received.store(0); + watch.total.store(version.file_size); + https::Download result; + { + // How far it is, said a few times a second while the download runs. + struct Meter { + std::atomic done{}; + std::thread thread; + ~Meter() { done.store(true); if (thread.joinable()) thread.join(); } + } meter; + meter.thread = std::thread([&] { + while (!meter.done.load()) { + const auto total = watch.total.load(), received = watch.received.load(); + set_bytes(received, total); + if (total) set_activity("Downloading " + wanted.title() + " v" + version.number + counter + " " + size_text(received) + " of " + size_text(total), + static_cast(static_cast(received) / static_cast(total))); + Sleep(100); + } + }); + result = https::get(package::download_url(name, version.number), archive, version.file_size ? version.file_size + (1 << 20) : largest_download, + download_seconds, agent, &watch); + } + if (stop.load()) throw std::runtime_error("Cancelled."); + if (!result.ok) + throw std::runtime_error(result.http_status && result.http_status != 200 + ? "the download failed (Thunderstore answered " + std::to_string(result.http_status) + ")" + : "the download failed (Windows error " + std::to_string(result.error) + ")"); + set_package(Progress::Step::installing, nullptr, at, of); + set_activity("Installing " + wanted.title() + " v" + version.number + counter, 0); + launcher_mods::InstallOptions options; + options.folder = ts::folder_for(wanted.full_name); + options.author = wanted.owner; + options.require_content = true; + const auto meter_install = [&](float fraction) { set_activity("Installing " + wanted.title() + " v" + version.number + counter, fraction); }; + const bool update = fs::exists(root / wide(options.folder), error); + try { + launcher_mods::install(root, archive, true, meter_install, stop, options); + if (update) { + std::lock_guard lock(updated_mutex); + updated_since_apply.insert(options.folder); + } + return {}; + } catch (const std::exception& failure) { + if (!update || stop.load()) throw; + // The game holds the files of the copy it replaces: it goes in at the next start. + log(logging::Level::info, wanted.full_name + " is in use (" + failure.what() + "); its update waits for the next start."); + } + launcher_mods::install_later(root, archive, meter_install, stop, options); + return "later"; +} + +// ---------------------------------------------------------------- READMEs + +std::mutex readme_mutex; +std::map, std::less<>> readmes; // under the mutex; null while fetching +} // namespace + +const char* state_label(State state) { + switch (state) { + case State::loaded: return "LOADED"; + case State::disabled: return "DISABLED"; + case State::apply_to_load: return "APPLY TO LOAD"; + case State::apply_to_unload: return "APPLY TO UNLOAD"; + case State::left_out: return "LEFT OUT"; + case State::not_loaded: return "NOT LOADED"; + case State::set_aside: return "SET ASIDE"; + case State::update_waiting: return "RESTART TO UPDATE"; + case State::removal_waiting: return "RESTART TO REMOVE"; + } + return ""; +} + +std::shared_ptr listing() { + static std::atomic started{}; + if (!started.exchange(true)) start_fetch(true); + else if (!fetching.load() && unix_now() >= next_fetch.load()) start_fetch(false); + std::lock_guard lock(listing_mutex); + return current_listing; +} +void refresh() { + if (!fetching.load()) start_fetch(false); +} + +std::shared_ptr installed() { + if (!scanning.load() && GetTickCount64() >= next_scan.load()) start_scan(); + std::lock_guard lock(mods_mutex); + return current_mods; +} + +void set_enabled(const std::string& folder, bool on) { + try { + std::thread([folder, on] { + try { + std::lock_guard lock(write_mutex); + auto list = mods::scan_mods(mods::engine_data_root()); + if (!list.issue.empty()) throw std::runtime_error("mods.json could not be read: " + list.issue); + for (auto& entry : list.entries) + if (same(entry.mod.name, folder)) entry.enabled = on; + mods::save_mod_order(list.root, list.entries); + log(logging::Level::info, folder + (on ? " switched on." : " switched off.")); + } catch (const std::exception& failure) { + std::lock_guard lock(progress_mutex); + current_progress.result = std::string("Could not save mods.json: ") + failure.what(); + current_progress.failed = true; + ++current_progress.serial; + } + rescan(); + }).detach(); + } catch (...) {} +} + +std::string apply() { + if (busy.load()) return "Wait for the install under way to finish."; + std::shared_ptr now; + { + std::lock_guard lock(mods_mutex); + now = current_mods; + } + const bool reload = now && now->effect == live_mods::ApplyEffect::reload_level; + const auto said = live_mods::apply(reload); + applied_at.store(GetTickCount64()); + if (live_mods::busy()) { + std::lock_guard lock(updated_mutex); + updated_since_apply.clear(); + } + rescan(); + return said; +} + +Progress progress() { + std::lock_guard lock(progress_mutex); + return current_progress; +} + +std::string install(const std::vector& wanted) { + const auto shown = listing(); + std::vector packages; + for (const auto& name : wanted) + if (const auto* found = ts::find_package(shown->packages, name)) packages.push_back(*found); + if (packages.empty()) return "That mod is not in Thunderstore's listing."; + std::shared_ptr mods_now; + { + std::lock_guard lock(mods_mutex); + mods_now = current_mods; + } + // The ones asked for are taken at their newest, installed or not; what they need only when it + // is not here at the version they ask for. + ts::Installed have = mods_now ? mods_now->versions : ts::Installed{}; + for (const auto& package : packages) have.erase(ts::folder_for(package.full_name)); + auto plan = mod_store::plan(packages, shown->packages, have); + if (plan.packages.empty()) return "Nothing to install."; + if (auto refused = begin("install"); !refused.empty()) return refused; + try { + std::thread([plan = std::move(plan)] { + std::size_t now_in{}, later{}; + std::vector failed; + for (std::size_t i = 0; i < plan.packages.size(); ++i) { + if (stop.load()) break; + const auto& package = plan.packages[i]; + try { + std::vector queued; + for (std::size_t j = i + 1; j < plan.packages.size(); ++j) queued.push_back(plan.packages[j].full_name); + const auto how = install_one(package, i, plan.packages.size(), std::move(queued)); + (how.empty() ? now_in : later) += 1; + log(logging::Level::info, package.full_name + " v" + package.latest().number + (how.empty() ? " installed." : " waits for the next start.")); + } catch (const std::exception& failure) { + if (stop.load()) break; + failed.push_back(package.title() + ": " + failure.what()); + } + } + std::string result; + const auto title = plan.packages.back().title(); + if (stop.load()) result = "Cancelled. " + std::to_string(now_in) + " of " + std::to_string(plan.packages.size()) + " installed."; + else if (failed.empty() && later == 0) + result = plan.packages.size() == 1 ? title + " is installed." : std::to_string(now_in) + " mods installed."; + else if (failed.empty()) + result = later == plan.packages.size() ? "The game has the old files open: the update goes in the next time it starts." + : std::to_string(now_in) + " installed; " + std::to_string(later) + " go in the next time the game starts."; + else { + result = failed.size() == 1 ? failed.front() : std::to_string(failed.size()) + " could not be installed: " + failed.front(); + if (now_in) result += " (" + std::to_string(now_in) + " installed)"; + } + if (!plan.unknown.empty()) result += " Not on Thunderstore: " + plan.unknown.front() + (plan.unknown.size() > 1 ? " and more." : "."); + finish(std::move(result), stop.load() || !failed.empty() || !plan.unknown.empty()); + }).detach(); + } catch (...) { + finish("The install could not start.", true); + } + return {}; +} + +std::string uninstall(const std::string& folder) { + if (!mods::valid_mod_name(folder)) return "That is not a mod folder."; + if (auto refused = begin("install"); !refused.empty()) return refused; + try { + std::thread([folder] { + set_package(Progress::Step::uninstalling, nullptr, 0, 1); + { + std::lock_guard lock(progress_mutex); + current_progress.package = current_progress.title = folder; + } + set_activity("Uninstalling " + folder); + const auto root = mods_root(); + try { + launcher_mods::remove(root, folder); + finish(folder + " is uninstalled (it is in the Recycle Bin).", false); + return; + } catch (const std::exception& failure) { + log(logging::Level::info, folder + " could not be removed now (" + failure.what() + "); it goes at the next start."); + } + try { + { + // Off now, so the next apply takes it out of the game; gone at the next start. + std::lock_guard lock(write_mutex); + auto list = mods::scan_mods(mods::engine_data_root()); + if (list.issue.empty()) { + for (auto& entry : list.entries) + if (same(entry.mod.name, folder)) entry.enabled = false; + mods::save_mod_order(list.root, list.entries); + } + } + launcher_mods::remove_later(root, folder); + finish(folder + " is switched off, and uninstalled the next time the game starts.", false); + } catch (const std::exception& failure) { + finish(std::string("Could not uninstall it: ") + failure.what(), true); + } + }).detach(); + } catch (...) { + finish("The uninstall could not start.", true); + } + return {}; +} + +void cancel() { + stop.store(true); + watch.cancel.store(true); +} + +std::shared_ptr readme(const ts::Package& wanted) { + const auto key = wanted.full_name + '-' + wanted.latest().number; + { + std::lock_guard lock(readme_mutex); + if (const auto found = readmes.find(key); found != readmes.end()) return found->second; + if (readmes.size() >= 64) readmes.clear(); + readmes.emplace(key, nullptr); + } + try { + std::thread([key, url = ts::readme_url(wanted)] { + auto result = std::make_shared(); + if (const auto json = https::get_text(url, 1 << 20, 20, agent)) { + try { result->text = ts::readme_lines(ts::parse_readme(*json), 300); } + catch (...) { result->failed = true; } + } else result->failed = true; + std::lock_guard lock(readme_mutex); + readmes[key] = std::move(result); + }).detach(); + } catch (...) {} + return nullptr; +} + +std::string today() { + SYSTEMTIME now{}; + GetSystemTime(&now); + char text[16]; + std::snprintf(text, sizeof(text), "%04u-%02u-%02u", now.wYear, now.wMonth, now.wDay); + return text; +} +} diff --git a/Extension/Assets/mod_store.h b/Extension/Assets/mod_store.h new file mode 100644 index 00000000..acd06f81 --- /dev/null +++ b/Extension/Assets/mod_store.h @@ -0,0 +1,107 @@ +#pragma once +#include "mod_store_view.h" +#include "Extension/Assets/live_mods.h" +#include +#include +#include +#include + +// The in-game Mod Manager (the pause menu's Mod Manager tab, Extension/UI/Overlay/mod_manager_page.inl): +// Thunderstore's listing for ReSkate's community, the installed mods, and a queue that downloads and +// installs packages with what they need, updates them and uninstalls them, as the launcher's Mods +// panel does. Everything that reads the disk or the network runs on a thread of its own; the page +// reads snapshots, every frame if it likes. +// +// A change takes effect as any change to the Mods folder does in game: plugins within two seconds +// (the plugin host looks for them), everything else when the mods are applied (live_mods.h), which +// the page offers. The game holds the files of mods it has loaded open, so an update or uninstall +// of one of those cannot be made while it runs: it waits in Mods/.reskate-pending and the launcher +// makes it before the next start (Launcher/mod_manager.h apply_pending). +namespace dingosdk::mod_store { + +// The listing, as it was last read: at once from the copy kept on disk +// (%LOCALAPPDATA%\ReSkate\thunderstore\listing-.json), and from Thunderstore when that is +// over 10 minutes old. +struct Listing { + std::vector packages; + std::uint64_t fetched{}; // when this copy came from Thunderstore (Unix seconds); 0: none yet + bool fetching{}; + std::string error; // why the last fetch failed; empty when it did not + unsigned serial{}; // changes with every new copy +}; +// Any thread. Starts a fetch when one is due. +std::shared_ptr listing(); +// Fetches it again now (unless a fetch is under way). +void refresh(); + +// An installed mod as the page shows it, its state as the Mods page in the ReSkate menu tells it. +enum class State { loaded, disabled, apply_to_load, apply_to_unload, left_out, not_loaded, set_aside, update_waiting, removal_waiting }; +const char* state_label(State state); +struct Mod { + std::string folder, title, author, version, description; + std::vector contents; // "Map", "Plugin"... (mod_list.h mod_contents) + bool enabled{}, in_effect{}; + State state{}; + std::string why; // what the state means for this mod, when it needs saying + bool gone{}; // its folder was deleted, but it is in effect until applied +}; +struct Mods { + std::vector mods; // in load order, highest first + ts::Installed versions; // every installed folder and its version + std::size_t changes{}; // what the Apply button would apply + live_mods::ApplyEffect effect{}; // and what it does to the level being played + std::size_t waiting{}; // updates and uninstalls waiting for the next start + std::string issue; // mods.json could not be read + bool present{}; // the Mods folder exists + // Read after the last apply had finished: only then do `changes` say what applying would still + // change (a reading from before or during one still has the applied ones in it). + bool settled{}; + unsigned serial{}; +}; +// Any thread. Read on a thread of its own: again every 1.5 s while asked for, and at once after a +// change made here. Null until the first read is done. +std::shared_ptr installed(); + +// Switches a mod on or off in mods.json (on a thread of its own). Takes effect when applied. +void set_enabled(const std::string& folder, bool on); +// Applies the mods as they are now (live_mods::apply), reloading the level when that is needed for +// the change to show. Returns what happened, for the page. +std::string apply(); + +// The queue: one job at a time. +struct Progress { + bool busy{}; + enum class Step { starting, downloading, installing, uninstalling } step{}; + std::string activity; // "Downloading Vancouver Plaza (2 of 3)" + float fraction{-1}; // of the one in hand; below 0 when not known + // The package in hand (Namespace-Name; the folder, uninstalling), its name, the version going + // in and its icon's URL; which of the job's packages it is, of how many (from 0); while it + // downloads, how much has come of how much (0: not known); and the packages still to come. + std::string package, title, version, icon; + std::size_t at{}, of{}; + std::uint64_t received{}, total{}; + std::vector queued; + std::string result; // what the last job did, for the page; empty while busy + bool failed{}; // the last job did not do all it was asked to + unsigned serial{}; // changes when a job ends +}; +Progress progress(); +// Installs the packages (Namespace-Name each, as in the listing) at their newest version, with the +// packages they need. An installed one is updated. Empty, or why it cannot start now. +std::string install(const std::vector& packages); +// Uninstalls Mods/: to the Recycle Bin, or at the next start when the game holds its files. +std::string uninstall(const std::string& folder); +// Stops the download under way; what is installed stays. +void cancel(); + +// A package's README, fetched on a thread of its own the first time it is asked for; null until +// then (and `failed` when it could not be had). +struct Readme { + ts::Readme text; + bool failed{}; +}; +std::shared_ptr readme(const ts::Package& package); + +// "YYYY-MM-DD" today (UTC), for the front page. +std::string today(); +} diff --git a/Extension/Assets/mod_store_view.cpp b/Extension/Assets/mod_store_view.cpp new file mode 100644 index 00000000..dfdca655 --- /dev/null +++ b/Extension/Assets/mod_store_view.cpp @@ -0,0 +1,186 @@ +#include "mod_store_view.h" +#include +#include +#include +#include +#include + +namespace dingosdk::mod_store { +namespace { +std::string lower(std::string_view text) { + std::string result(text); + for (auto& c : result) if (c >= 'A' && c <= 'Z') c = static_cast(c + 32); + return result; +} +bool installed_here(const ts::Package& package, const ts::Installed& installed) { + return installed.contains(ts::folder_for(package.full_name)); +} +// Listed at all: never NSFW, and deprecated only when it is installed. +bool listed(const ts::Package& package, const ts::Installed& installed) { + return !nsfw(package) && (!package.deprecated || installed_here(package, installed)); +} +// "YYYY-MM-DD..." as its year, month and day. +bool ymd(std::string_view text, int& year, int& month, int& day) { + if (text.size() < 10 || text[4] != '-' || text[7] != '-') return false; + const auto number = [&](std::size_t at, std::size_t length, int& out) { + return std::from_chars(text.data() + at, text.data() + at + length, out).ec == std::errc{}; + }; + return number(0, 4, year) && number(5, 2, month) && number(8, 2, day); +} +bool before(const ts::Package& a, const ts::Package& b, Sort sort) { + switch (sort) { + case Sort::popular: if (a.downloads != b.downloads) return a.downloads > b.downloads; break; + case Sort::updated: if (a.date_updated != b.date_updated) return a.date_updated > b.date_updated; break; + case Sort::newest: if (a.date_created != b.date_created) return a.date_created > b.date_created; break; + case Sort::rated: if (a.rating != b.rating) return a.rating > b.rating; break; + case Sort::name: break; + } + return lower(a.title()) < lower(b.title()); +} +// The date `days` before "YYYY-MM-DD", in the same form (months taken as 30 days: close enough for +// what counts as recent). +std::string days_before(std::string_view today, int days) { + int year{}, month{}, day{}; + if (!ymd(today, year, month, day)) return {}; + int total = year * 360 + (month - 1) * 30 + (day - 1) - days; + if (total < 0) return {}; + char text[16]; + std::snprintf(text, sizeof(text), "%04d-%02d-%02d", total / 360, total % 360 / 30 + 1, total % 30 + 1); + return text; +} +} // namespace + +const char* sort_label(Sort sort) { + static constexpr std::array labels{"MOST DOWNLOADED", "LAST UPDATED", "NEWEST", "TOP RATED", "NAME"}; + return labels[static_cast(sort)]; +} + +bool nsfw(const ts::Package& package) { + return package.nsfw || std::any_of(package.categories.begin(), package.categories.end(), + [](const std::string& category) { return lower(category) == "nsfw"; }); +} + +std::vector search(std::span packages, const Query& query, const ts::Installed& installed) { + const auto wanted = lower(query.search); + std::vector result; + for (const auto& package : packages) { + if (!listed(package, installed)) continue; + if (!ts::shown_by(package, ts::CategoryFilter{query.only, query.hidden})) continue; + if (!wanted.empty() && lower(package.title()).find(wanted) == std::string::npos && + lower(package.owner).find(wanted) == std::string::npos && + lower(package.latest().description).find(wanted) == std::string::npos) + continue; + result.push_back(&package); + } + std::stable_sort(result.begin(), result.end(), [&](const ts::Package* a, const ts::Package* b) { + if (a->pinned != b->pinned) return a->pinned; + return before(*a, *b, query.sort); + }); + return result; +} + +std::vector> categories(std::span packages) { + std::vector> result; + for (const auto& package : packages) { + if (nsfw(package)) continue; + for (const auto& category : package.categories) { + const auto found = std::find_if(result.begin(), result.end(), [&](const auto& entry) { return entry.first == category; }); + if (found == result.end()) result.emplace_back(category, 1); + else ++found->second; + } + } + std::stable_sort(result.begin(), result.end(), [](const auto& a, const auto& b) { + if (a.second != b.second) return a.second > b.second; + return a.first < b.first; + }); + return result; +} + +Front front(std::span packages, const ts::Installed& installed, std::string_view today, + std::size_t featured_count, std::size_t per_shelf) { + Front result; + std::vector shown; + for (const auto& package : packages) + if (listed(package, installed) && !package.deprecated) shown.push_back(&package); + const auto sorted = [&](Sort sort, auto keep) { + std::vector list; + for (const auto* package : shown) + if (keep(*package)) list.push_back(package); + std::stable_sort(list.begin(), list.end(), [&](const ts::Package* a, const ts::Package* b) { return before(*a, *b, sort); }); + if (list.size() > per_shelf) list.resize(per_shelf); + return list; + }; + const auto any = [](const ts::Package&) { return true; }; + + // Featured: pinned first, then what is popular and still looked after. + const auto recent = days_before(today, 60); + std::set taken; + for (const auto* package : shown) + if (package->pinned && result.featured.size() < featured_count) result.featured.push_back(package), taken.insert(package); + for (const auto* package : sorted(Sort::popular, [&](const ts::Package& p) { return recent.empty() || p.date_updated >= recent; })) + if (result.featured.size() < featured_count && taken.insert(package).second) result.featured.push_back(package); + for (const auto* package : sorted(Sort::popular, any)) + if (result.featured.size() < featured_count && taken.insert(package).second) result.featured.push_back(package); + + const auto shelf = [&](std::string title, std::vector list) { + if (!list.empty()) result.shelves.push_back({std::move(title), std::move(list)}); + }; + shelf("MOST POPULAR", sorted(Sort::popular, any)); + shelf("RECENTLY UPDATED", sorted(Sort::updated, any)); + shelf("NEW", sorted(Sort::newest, any)); + // Then the biggest categories, each its own row. + std::size_t rows{}; + for (const auto& [category, count] : categories(packages)) { + if (rows >= 4) break; + const auto key = lower(category); + if (key == "modpacks" || key == "libraries" || key == "misc") continue; + shelf([&] { std::string title = category; for (auto& c : title) if (c >= 'a' && c <= 'z') c = static_cast(c - 32); return title; }(), + sorted(Sort::popular, [&](const ts::Package& p) { return p.in_category(category); })); + ++rows; + } + return result; +} + +Action action_for(const ts::Package& package, const ts::Installed& installed) { + if (!installed_here(package, installed)) return Action::install; + return ts::update_available(package, installed) ? Action::update : Action::installed; +} + +Plan plan(std::span wanted, std::span listing, const ts::Installed& installed) { + Plan result; + for (const auto& package : wanted) + for (auto& needed : ts::with_dependencies(package, listing, installed, &result.unknown)) + if (std::none_of(result.packages.begin(), result.packages.end(), + [&](const ts::Package& queued) { return queued.full_name == needed.full_name; })) { + result.bytes += needed.latest().file_size; + result.packages.push_back(std::move(needed)); + } + std::sort(result.unknown.begin(), result.unknown.end()); + result.unknown.erase(std::unique(result.unknown.begin(), result.unknown.end()), result.unknown.end()); + return result; +} + +std::string size_text(std::uint64_t bytes) { + char text[32]; + if (bytes >= (1ull << 30)) std::snprintf(text, sizeof(text), "%.1f GB", static_cast(bytes) / (1ull << 30)); + else if (bytes >= (1ull << 20)) std::snprintf(text, sizeof(text), "%.1f MB", static_cast(bytes) / (1ull << 20)); + else std::snprintf(text, sizeof(text), "%llu KB", static_cast((bytes + 1023) / 1024)); + return text; +} + +std::string count_text(std::uint64_t count) { + char text[32]; + if (count >= 1000000) std::snprintf(text, sizeof(text), "%.1fM", static_cast(count) / 1e6); + else if (count >= 1000) std::snprintf(text, sizeof(text), "%.1fK", static_cast(count) / 1e3); + else std::snprintf(text, sizeof(text), "%llu", static_cast(count)); + return text; +} + +std::string date_text(std::string_view iso) { + static constexpr std::array months{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + int year{}, month{}, day{}; + if (!ymd(iso, year, month, day) || month < 1 || month > 12) + return std::string(iso.substr(0, std::min(iso.size(), 10))); + return std::string(months[static_cast(month - 1)]) + ' ' + std::to_string(day) + ", " + std::to_string(year); +} +} diff --git a/Extension/Assets/mod_store_view.h b/Extension/Assets/mod_store_view.h new file mode 100644 index 00000000..5319fc8f --- /dev/null +++ b/Extension/Assets/mod_store_view.h @@ -0,0 +1,72 @@ +#pragma once +#include "Launcher/thunderstore.h" +#include +#include +#include +#include +#include +#include +#include + +// What the in-game Mod Manager (mod_store.h) shows of Thunderstore's listing, worked out from the +// listing alone: no game, disk or network here, so it is tested on its own. +namespace dingosdk::mod_store { +namespace ts = thunderstore; + +enum class Sort { popular, updated, newest, rated, name }; +inline constexpr std::size_t sort_count = 5; +const char* sort_label(Sort sort); + +// Not safe for work: Thunderstore's own mark on the package, or a category of that name. Never +// listed in game, installed or not (as in the launcher). +bool nsfw(const ts::Package& package); + +struct Query { + std::string search; // in the title, owner and description, any case + // The categories shown only (a package needs one of them, when there are any) and those hidden (a + // package in any of them is left out), as the launcher's GET MODS has them (thunderstore.h). + std::vector only, hidden; + Sort sort{Sort::popular}; +}; +// The packages that match, pinned first, then in `sort` order. A deprecated package only shows once +// installed. +std::vector search(std::span packages, const Query& query, const ts::Installed& installed); + +// The categories the listing uses (its NSFW packages left out), with how many packages are in each, +// most first. +std::vector> categories(std::span packages); + +// BROWSE's front page: a few packages to show big, then rows of them. +struct Shelf { + std::string title; + std::vector packages; +}; +struct Front { + std::vector featured; + std::vector shelves; +}; +// `today`: "YYYY-MM-DD", for what counts as recent. Featured: the pinned packages, then the most +// downloaded of those updated in the last 60 days, at most `featured_count`. Each shelf holds at +// most `per_shelf`. +Front front(std::span packages, const ts::Installed& installed, std::string_view today, + std::size_t featured_count = 8, std::size_t per_shelf = 24); + +// What pressing a package's button does. +enum class Action { install, update, installed }; +Action action_for(const ts::Package& package, const ts::Installed& installed); + +// What installing `wanted` brings, needed packages first, each once: the packages to download, and +// the names of those the listing does not have. +struct Plan { + std::vector packages; + std::vector unknown; + std::uint64_t bytes{}; // what they download together (0 for those whose size is not listed) +}; +Plan plan(std::span wanted, std::span listing, const ts::Installed& installed); + +// "1.2 MB", "340 KB"; "4.1K", "12M" downloads. +std::string size_text(std::uint64_t bytes); +std::string count_text(std::uint64_t count); +// "2026-10-24T12:00:00Z" as "Oct 24, 2026". +std::string date_text(std::string_view iso); +} diff --git a/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp index d6e5d213..5f5ba48f 100644 --- a/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp @@ -50,16 +50,18 @@ void map_names() { int main() { try { map_names(); - check(missing_tabs({1, 2, 3, 4}, 8, true) == std::vector{0, 1}, + check(missing_tabs({1, 2, 3, 4}, 8, true) == std::vector{0, 1, 2}, "All pages insert in canonical order together"); - check(missing_tabs({1, 2, 3, 4, 8, 264}, 8, true).empty(), + check(missing_tabs({1, 2, 3, 4, 8, 264, 520}, 8, true).empty(), "Existing tabs are never reordered or reinserted"); - check(missing_tabs({1, 2, 3, 4, 8}, 8, true) == std::vector{1}, - "A missing tools page appends without moving existing indices"); - check(missing_tabs({1, 2, 3, 4}, 8, true, false) == std::vector{1}, + check(missing_tabs({1, 2, 3, 4, 8}, 8, true) == std::vector{1, 2}, + "A missing tools page appends without moving existing indices, the Mod Manager after it"); + check(missing_tabs({1, 2, 3, 4, 8, 264}, 8, true) == std::vector{2}, + "A missing Mod Manager appends after Mod Options"); + check(missing_tabs({1, 2, 3, 4}, 8, true, false) == std::vector{1, 2}, "offline mode leaves the Multiplayer tab out"); check(missing_tabs({1, 2, 3, 4}, 8, false) == std::vector{0}, - "The Multiplayer page does not wait for the overlay's tool callbacks"); + "The Multiplayer page does not wait for the overlay's tool callbacks; the Mod Manager waits for Mod Options"); MultiplayerModel model; const BrowserOptions by_name{{}, false, Sort::name}; model.local_id = 77; model.map = "Levels/San_Vansterdam"; diff --git a/Extension/UI/NativeMenu/native_menu.cpp b/Extension/UI/NativeMenu/native_menu.cpp index 15ab9fb1..4d1a67d1 100644 --- a/Extension/UI/NativeMenu/native_menu.cpp +++ b/Extension/UI/NativeMenu/native_menu.cpp @@ -25,7 +25,7 @@ using menu_view::page_count, menu_view::tools_page; // back before our models are released. Native tabs are found by their label, not index. constexpr std::string_view store_tab_label = "ID_MENU_STORE", social_tab_label = "ID_MENU_SOCIAL"; constexpr unsigned social_tab_index = 4; // Before the Store is removed. -constexpr std::array page_slots{0, 1}; +constexpr std::array page_slots{0, 1, 2}; // The Store item taken out of the pause menu, its index there, and that menu. struct RemovedStore { Value item; unsigned index{}; Handle core{}; Address manager{};std::uint32_t key{}; }; RemovedStore removed_store; @@ -76,7 +76,7 @@ Address action(const Context& context, std::string command, std::string argument const bool unused = *slot == count; if (unused) { static const std::array functions{callbacks<0>(std::make_index_sequence{}), - callbacks<1>(std::make_index_sequence{})}; + callbacks<1>(std::make_index_sequence{}), callbacks<2>(std::make_index_sequence{})}; // Native void-action descriptor used by this build. Only the invokers are // replaced; it is never added to the game's global function registry. const auto prototype = context.base + addr::native_menu::action_prototype; @@ -191,8 +191,9 @@ namespace { // Which of ReSkate's pages is on screen, for the overlay (native_menu_page): when one was last // seen selected with the pause menu up (GetTickCount64; 0: not now), and which. std::atomic page_seen{}; -std::atomic page_shown{}; // 0: Multiplayer, 1: Mod Options +std::atomic page_shown{}; // 0: Multiplayer, 1: Mod Options, 2: Mod Manager std::atomic game_in_menu{}; +std::atomic back_wanted{}; // hold_native_menu_back // Returns the key of the tab the pause menu has selected (0: none of ours can be told). std::uint32_t restore_last_tab(const Context& context, Value menu) { static std::uint32_t remembered{}; @@ -234,6 +235,21 @@ std::uint32_t restore_last_tab(const Context& context, Value menu) { } return key_at(selected); } +void build_back_action(const Context& context, State& s); +// The Mod Manager's Back while a mod's page is open: active, with no input and no navigation, so it +// swallows Escape and B (see build_back_action) and the overlay takes them; given back otherwise. +void hold_back(const Context& context, State& s, bool held) { + if (!held) { + build_back_action(context, s); + return; + } + const auto base = context.path(s.page, {0x10810f0b, button_field, 0xa704272a, 0xc52416ef, 0xa704272a}); + const auto back = context.field(base, 0xa2b71c93); + context.set(context.field(back, 0x3d00de27), Address{}); + context.text(context.field(back, 0xb1e14cd6), ""); + context.set(context.field(base, 0x4b3146e9), true); + s.back_held = true; +} // Page Back is a button action: input UI_Back, navigation "ToggleMenu", no // delegate, handled by the shared navigation handler. An active action with // neither swallows Escape/controller B. Build it directly rather than copying @@ -248,6 +264,7 @@ void build_back_action(const Context& context, State& s) { context.set(context.field(back, 0x3d00de27), back_input); context.text(context.field(back, 0xb1e14cd6), "ToggleMenu"); context.set(context.field(base, 0x4b3146e9), true); + s.back_held = false; } void insert(const Context& context) { auto& s = state(); @@ -464,8 +481,8 @@ void initialize(const Context& context, Root root, const MultiplayerModel& model context.set(context.field(backdrop_stack, 0x3b14d43c), false); context.set(context.field(backdrop_stack, 0x2a98baa3), false); build_back_action(context, s); - static constexpr std::array titles{"MULTIPLAYER", "MOD OPTIONS"}, - tab_names{"Multiplayer", "Mod Options"}; + static constexpr std::array titles{"MULTIPLAYER", "MOD OPTIONS", "MOD MANAGER"}, + tab_names{"Multiplayer", "Mod Options", "Mod Manager"}; context.text(context.path(s.page, {0x3781b603, text_field}), titles[s.slot]); context.set(context.field(s.page, 0xe1d821a3), true); context.set(context.field(s.page, 0x616cb924), false); @@ -512,28 +529,41 @@ void initialize(const Context& context, Root root, const MultiplayerModel& model context.copy(context.field(s.menu_item, 0xd78d240b), context.address(context.path(s.core, {content, 0x54c9abee}))); tab_label(context, s.menu_item, tab_names[s.slot], menu_key()); - // Seed both states with an already-live native fallback: Social for - // Multiplayer, Hub for ReSkate. Optional art must never gate either page. - // Copy only visual fields; actions remain attached to our own models. + // Seed every state with an already-live native fallback: Social for + // Multiplayer, Hub for ReSkate, and the Store's own art for the Mod Manager + // (the Store tab ReSkate takes out; Hub's when it is gone already). Optional + // art must never gate a page. Copy only visual fields; actions remain + // attached to our own models. const auto core_items = context.path(s.core, {content, items}); - const auto source_tab = context.field(context.element(core_items, s.slot ? 1 : social_index(context, s.core)), tab_data); + Value source_item = context.element(core_items, s.slot ? 1 : social_index(context, s.core)); + if (s.slot == menu_view::mods_page) { + unsigned core_count{}, core_stride{}; + context.array(core_items, 16, core_count, core_stride); + if (const auto store = find_tab(context, core_items, core_count, store_tab_label)) source_item = context.element(core_items, *store); + else if (removed_store.manager == context.manager && removed_store.item.handle && + context.type_of(removed_store.item.handle) == removed_store.item.type) source_item = removed_store.item; + } + const auto source_tab = context.field(source_item, tab_data); const auto target_tab = context.field(s.menu_item, tab_data); for (const auto hash : {0xd4f0f044U, 0xbc92d1faU, 0xa3c18619U}) context.copy(context.field(target_tab, hash), context.address(context.field(source_tab, hash))); // Prefer the exact requested glyph, resolving it from the locked native // image registry rather than searching neighboring widget allocations. + // (The Mod Manager asks for none: the Store's art is what it wants.) static constexpr std::array idle_icons{"UI/Textures/Icons/HUB/img_Icon_SocialDefault_512", - "UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Idle_1024"}; + "UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Idle_1024", nullptr}; static constexpr std::array focused_icons{"UI/Textures/Icons/HUB/img_Icon_SocialFocused_512", - "UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Focused_1024"}; + "UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Focused_1024", nullptr}; const auto* idle = idle_icons[s.slot]; const auto* focused = focused_icons[s.slot]; bool requested_icon{}; - try { requested_icon = publish_tab_icons(context, target_tab, idle, focused); } - catch (const std::exception&) { /* The authored fallback is already retained. */ } - if (!requested_icon) logging::printf(logging::Level::info, logging::Channel::ui, - "Native %s menu: using native fallback icons; optional pair unavailable: %s / %s", - tab_names[s.slot], idle, focused); + if (idle && focused) { + try { requested_icon = publish_tab_icons(context, target_tab, idle, focused); } + catch (const std::exception&) { /* The authored fallback is already retained. */ } + if (!requested_icon) logging::printf(logging::Level::info, logging::Channel::ui, + "Native %s menu: using native fallback icons; optional pair unavailable: %s / %s", + tab_names[s.slot], idle, focused); + } render(context, model); insert(context); status(std::string("Native ") + tab_names[s.slot] + " menu: tab registered."); @@ -594,8 +624,9 @@ void tick_page(std::uintptr_t base, bool loading) noexcept { hide_top_bar_currency(context, active.model); update_hub(context); // Tell the overlay while this page is the one on screen: it draws the body. - const bool ours = shown_tab == our_key || shown_tab == our_key + tools_page * 0x100; - const auto& shown_page = page_state(shown_tab == our_key ? 0 : tools_page); + const bool ours = shown_tab >= our_key && shown_tab < our_key + page_count * 0x100 && (shown_tab - our_key) % 0x100 == 0; + const unsigned shown_slot = ours ? (shown_tab - our_key) / 0x100 : 0; + const auto& shown_page = page_state(shown_slot); bool on_screen = ours && game_in_menu.load(std::memory_order_relaxed) && shown_page.page.handle && context.type_of(shown_page.page.handle) == shown_page.page.type; // The game's side menu (pause pressed again) and what opens from it (Settings...) go @@ -613,9 +644,12 @@ void tick_page(std::uintptr_t base, bool loading) noexcept { } catch (...) {} } // (Said at once when another page takes its place, not left to go stale.) - page_shown.store(shown_tab == our_key ? 0 : 1, std::memory_order_relaxed); + page_shown.store(static_cast(shown_slot), std::memory_order_relaxed); page_seen.store(on_screen ? now : 0, std::memory_order_relaxed); } + if (s.slot == menu_view::mods_page && active.model.handle == s.core.handle && s.page.handle && + context.type_of(s.page.handle) == s.page.type && back_wanted.load(std::memory_order_relaxed) != s.back_held) + hold_back(context, s, !s.back_held); bool pending{}; { std::lock_guard pending_lock(s.mutex); pending = !s.pending.empty(); } if (active.model.handle == s.core.handle && s.page.handle && now < s.next_render && !pending && @@ -750,6 +784,8 @@ overlay::HubPage native_menu_page() noexcept { if (!seen || GetTickCount64() - seen > 200) return {}; return {true, page_shown.load(std::memory_order_relaxed)}; } +void hold_native_menu_back(bool held) noexcept { back_wanted.store(held, std::memory_order_relaxed); } +bool native_pause_menu_open() noexcept { return game_in_menu.load(std::memory_order_relaxed); } void tick_native_menu(std::uintptr_t base, bool loading) noexcept { const ui::OwnUiWrites own_writes; // Custom labels are not the native trick list. run_pending_ui_dump(base); diff --git a/Extension/UI/NativeMenu/native_menu.h b/Extension/UI/NativeMenu/native_menu.h index a99c485c..28d026cc 100644 --- a/Extension/UI/NativeMenu/native_menu.h +++ b/Extension/UI/NativeMenu/native_menu.h @@ -17,8 +17,13 @@ void native_menu_before_level_transition(std::uintptr_t base, unsigned next) noe // Uses the same validated runtime queues as the overlay. The reader must be a // snapshot reader; it must not consume the overlay's console log cursor. void set_native_menu_callbacks(const overlay::CallbacksV3& callbacks); -// Whether one of ReSkate's pages (Multiplayer, Mod Options) is the pause menu's page on +// Whether one of ReSkate's pages (Multiplayer, Mod Options, Mod Manager) is the pause menu's page on // screen right now, and which. Any thread: the overlay draws the page from it // (overlay::HubPageFeed). overlay::HubPage native_menu_page() noexcept; +// While held, the Mod Manager page's Back does nothing, so B and Escape stay the overlay's: they +// leave a mod's page for the list instead of closing the pause menu. Any thread. +void hold_native_menu_back(bool held) noexcept; +// Whether the game's own pause menu is up, on any of its pages. Any thread. +bool native_pause_menu_open() noexcept; } // namespace dingosdk::multiplayer diff --git a/Extension/UI/NativeMenu/native_menu_internal.h b/Extension/UI/NativeMenu/native_menu_internal.h index 249fde13..a0da5056 100644 --- a/Extension/UI/NativeMenu/native_menu_internal.h +++ b/Extension/UI/NativeMenu/native_menu_internal.h @@ -82,6 +82,7 @@ struct State { std::uint64_t voice_pending_until{}; bool public_lobby{true}, was_active{}, host_seeded{}; bool browsing{}; // the lobby browser was on screen at the last render + bool back_held{}; // its Back is held by the overlay (hold_native_menu_back) std::uint64_t next_id = 1, next_scan{}, next_retry{}, next_update{}, next_render{}, owner{}, feedback_until{}; std::string feedback, feedback_status; }; diff --git a/Extension/UI/NativeMenu/native_menu_view.h b/Extension/UI/NativeMenu/native_menu_view.h index d17385dc..eb9855c8 100644 --- a/Extension/UI/NativeMenu/native_menu_view.h +++ b/Extension/UI/NativeMenu/native_menu_view.h @@ -12,17 +12,19 @@ enum class Sort { name, players, map }; // column sets reverse, which flips only that column. struct BrowserOptions { std::string query; bool same_map{}; Sort sort = Sort::players; bool reverse{}; }; -// Owned pages: Multiplayer and Mod Options (needs the overlay's tool callbacks). -inline constexpr unsigned page_count = 2, tools_page = 1; +// Owned pages: Multiplayer, Mod Options (needs the overlay's tool callbacks) and the Mod Manager +// (Extension/Assets/mod_store.h), which sits after Mod Options. +inline constexpr unsigned page_count = 3, tools_page = 1, mods_page = 2; // Return missing owned slots in canonical order. Existing native stack indices // remain untouched, including their active focus bindings. Offline mode has -// no Multiplayer tab. +// no Multiplayer tab. The Mod Manager waits for Mod Options, so that it is never +// appended before it. inline std::vector missing_tabs(const std::vector& keys, std::uint32_t multiplayer_key, bool has_tools, bool has_multiplayer = true) { std::vector result; for (unsigned slot = 0; slot < page_count; ++slot) - if ((has_tools || slot != tools_page) && (has_multiplayer || slot != 0) && + if ((has_tools || (slot != tools_page && slot != mods_page)) && (has_multiplayer || slot != 0) && std::find(keys.begin(), keys.end(), multiplayer_key + slot * 0x100) == keys.end()) result.push_back(slot); return result; } diff --git a/Extension/UI/Overlay/hub_page.cpp b/Extension/UI/Overlay/hub_page.cpp index 715a604b..4d361fc5 100644 --- a/Extension/UI/Overlay/hub_page.cpp +++ b/Extension/UI/Overlay/hub_page.cpp @@ -3,9 +3,13 @@ #include "skate_menu_internal.h" #include "cursor.h" #include "input_capture.h" +#include "scripted_pad.h" +#include "Extension/UI/NativeMenu/native_menu.h" #include "Extension/UI/NativeMenu/native_menu_view.h" #include "Extension/UI/NativeMenu/native_tools_view.h" #include "Extension/Boot/discord_presence.h" +#include "Extension/Assets/map_download.h" +#include "Extension/Assets/mod_store.h" #include "Extension/Music/local_music_playback.h" #include "Extension/Profile/local_profile_runtime.h" #include "Engine/Game/Input/controller_bindings.h" @@ -16,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -1422,7 +1427,12 @@ void settings_page(Ui &ui, ImVec2 a, ImVec2 z) { // ---------------------------------------------------------------- focus // Moves the focus one control over, by where the controls were drawn last frame. -void move_focus(Hub &h, int dx, int dy) { +#include "mod_manager_page.inl" + +// `in_list`: up and down from a control in a scrolling list stay in that list while it has anything +// that way (the Mod Manager's rows, whose next row up can be out of view and further than the bar +// over the list). +void move_focus(Hub &h, int dx, int dy, bool in_list = false) { const Item *from{}; for (const auto &entry : h.last) if (entry.id == h.focus) from = &entry; @@ -1433,8 +1443,13 @@ void move_focus(Hub &h, int dx, int dy) { const ImVec2 centre((from->a.x + from->b.x) * .5f, (from->a.y + from->b.y) * .5f); const Item *best{}; float best_score = FLT_MAX; + const auto same_list = [&](const Item &entry) { return from->list && entry.list == from->list; }; + bool list_only{}; + if (in_list && dy && from->list) + for (const auto &entry : h.last) + if (entry.id != from->id && same_list(entry) && (dy > 0 ? entry.a.y - from->b.y : from->a.y - entry.b.y) >= -2) list_only = true; for (const auto &entry : h.last) { - if (entry.id == from->id) continue; + if (entry.id == from->id || (list_only && !same_list(entry))) continue; // Measured from the nearest point of the other control, so a wide row is as close as it looks. const ImVec2 closest(std::clamp(centre.x, entry.a.x, entry.b.x), std::clamp(centre.y, entry.a.y, entry.b.y)); const float along = dx ? (closest.x - centre.x) * static_cast(dx) : (closest.y - centre.y) * static_cast(dy); @@ -1467,12 +1482,20 @@ bool hub_page_pending() { if (!h.page.visible) h.dismissed = false; } const bool shown = h.page.visible && !h.dismissed; + // The Mod Manager's changes that are not applied are asked about once the pause menu has closed + // (not when another of its pages comes up), if it was open this time. + if (auto &mods = mod_page(); mods.visited && !shown && !dingosdk::multiplayer::native_pause_menu_open()) { + mods.visited = false; + offer_apply(mods); + } if (shown != h.shown) { h.shown = shown; h.next_model = {}; h.searched = false; h.last.clear(); if (!shown) { + dingosdk::multiplayer::hold_native_menu_back(false); + if (!mod_page().leaving) mod_page().declined = false; h.recording_bind = 0; set_typing(h, 0); wipe(h.password), wipe(h.code_password), wipe(h.host_password); @@ -1578,9 +1601,18 @@ void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs if (ui.moved && !h.typing) h.ring = false; h.focus_moved = false; int previous_tab{}, next_tab{}; + // On the Mod Manager, a mod's page holds the menu's Back (hold_native_menu_back): B and Escape go + // back to the list there, and keep holding it until they are let go. + auto &mods_page = mod_page(); + const bool on_mods = !embedded && h.page.section == 2; + const bool back_down = keys[7] || (ui.pad.buttons & 0x2000); if (ui.input && !typing_before) { // Back closes the game's menu, which takes a moment to say so: this goes at once. - if (!embedded && (key_new(7) || (pressed & 0x2000))) h.dismissed = true; + if (!embedded && (key_new(7) || (pressed & 0x2000))) { + if (on_mods && mods_hold_back(mods_page)) mods_back(h, mods_page), mods_page.hold_back_until = GetTickCount64() + 300, h.sound = sound_select; + else if (on_mods && guard_leave(mods_page)) offer_leave(mods_page), h.sound = sound_select; + else if (!on_mods || GetTickCount64() >= mods_page.hold_back_until) h.dismissed = true; + } const std::array held{keys[0] || (ui.pad.buttons & 1), keys[1] || (ui.pad.buttons & 2), keys[2] || (ui.pad.buttons & 4), keys[3] || (ui.pad.buttons & 8)}; const std::array fresh{key_new(0) || (pressed & 1), key_new(1) || (pressed & 2), key_new(2) || (pressed & 4), key_new(3) || (pressed & 8)}; const auto tick = GetTickCount64(); @@ -1590,9 +1622,14 @@ void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs if (direction < 0 && tick >= h.repeat_at) for (int i = 0; i < 4; ++i) if (held[static_cast(i)]) direction = i, h.repeat_at = tick + (embedded ? 160 : 70); - if (direction >= 0) { + if (direction >= 0 && on_mods && h.ring && h.focus == featured_card && (direction == 2 || direction == 3)) { + // Left and right on the Mod Manager's carousel turn it, at a pace to see each card go by. + mods_page.carousel_turn = direction == 3 ? 1 : -1; + h.repeat_at = std::max(h.repeat_at, tick + 260); + h.sound = sound_navigate; + } else if (direction >= 0) { // The first press only shows where the focus is. - if (h.ring || !h.focus) move_focus(h, direction == 2 ? -1 : direction == 3 ? 1 : 0, direction == 0 ? -1 : direction == 1 ? 1 : 0); + if (h.ring || !h.focus) move_focus(h, direction == 2 ? -1 : direction == 3 ? 1 : 0, direction == 0 ? -1 : direction == 1 ? 1 : 0, on_mods); h.ring = true; h.focus_moved = true; } @@ -1602,6 +1639,26 @@ void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs previous_tab = key_new(5) || (pressed & 0x10000); next_tab = key_new(6) || (pressed & 0x20000); } + if (back_down && GetTickCount64() < mods_page.hold_back_until) mods_page.hold_back_until = GetTickCount64() + 300; + // The answer to leaving with changes not applied: LATER (or an apply that reloads nothing) lets go + // of Back, and a moment later presses it for the player, on the pad only the game reads. + if (const int answer = mods_page.leave_answer.exchange(0)) { + mods_page.leaving = false; + mods_page.declined = true; + mods_page.visited = false; // (asked already) + const bool reloads = answer == 1 && mods_page.mods && mods_page.mods->effect != live_mods::ApplyEffect::maps_only; + if (!reloads) mods_page.close_at = GetTickCount64() + 250; + } + if (mods_page.close_at && GetTickCount64() >= mods_page.close_at) { + mods_page.close_at = 0; + if (dingosdk::multiplayer::native_pause_menu_open()) { + XINPUT_GAMEPAD back{}, neutral{}; + back.wButtons = XINPUT_GAMEPAD_B; + overlay::play_pad({{0, back}, {6, neutral}, {12, neutral}}); + } + } + if (!embedded) + dingosdk::multiplayer::hold_native_menu_back(on_mods && (mods_hold_back(mods_page) || guard_leave(mods_page) || GetTickCount64() < mods_page.hold_back_until)); h.keys_before = keys; // A key that ended the typing (Enter, Escape) is still down: it is not a fresh press of anything. // And so is one held while the game was not the window in front. @@ -1611,12 +1668,15 @@ void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs // ---- the page's tabs const bool tools = !embedded && h.page.section == 1; + const bool mods = !embedded && h.page.section == 2; { // (Each page keeps the tab it was left on.) std::vector> tabs; int multiplayer_tab = static_cast(h.tab); - int &open = tools ? h.tools_tab : multiplayer_tab; - if (tools) { + int &open = tools ? h.tools_tab : mods ? mod_page().tab : multiplayer_tab; + if (mods) { + tabs = {{browse_tab, "BROWSE"}, {installed_tab, "MY MODS"}, {updates_tab, "UPDATES"}}; + } else if (tools) { if (h.tools_tab == custom_tab) h.tools_tab = official_tab; tabs = {{official_tab, "MAPS"}, {world_tab, "WORLD"}, {parks_tab, "PARKS"}, {player_tab, "PLAYER"}, {online_tab, "ONLINE"}, {visuals_tab, "VISUALS"}, {binds_tab, "BINDS"}}; @@ -1657,14 +1717,15 @@ void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs x = q.x + 20 * u; } cap(0x20000, "C"); - if (!tools) h.tab = static_cast(multiplayer_tab); + if (!tools && !mods) h.tab = static_cast(multiplayer_tab); } // Under the connecting card the page is still drawn, to look at and not to use. const bool page_input = ui.input, page_clicked = ui.clicked, page_doubled = ui.doubled; const bool failed = !connecting && !h.failed.empty() && !in_session; - if ((connecting || failed) && !tools) ui.input = ui.clicked = ui.doubled = false; + if ((connecting || failed) && !tools && !mods) ui.input = ui.clicked = ui.doubled = false; if (tools) tools_page(ui, a, z); + else if (mods) mod_manager_page(ui, a, z); else switch (h.tab) { case Tab::browser: browser_page(ui, a, z); break; case Tab::host: host_page(ui, a, z); break; @@ -1672,7 +1733,7 @@ void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs case Tab::voice: voice_page(ui, a, z); break; case Tab::settings: settings_page(ui, a, z); break; } - if ((connecting || failed) && !tools) { + if ((connecting || failed) && !tools && !mods) { ui.input = page_input, ui.clicked = page_clicked, ui.doubled = page_doubled; h.items.clear(); // nothing of the page takes the focus: only the card's way out if (connecting) connecting_page(ui, a, z); @@ -1751,4 +1812,57 @@ void draw_online_embedded(ImDrawList *draw, ImVec2 a, ImVec2 z, const Model &mod hub_frame(h, draw, u, ImVec2(a.x, a.y + (tabs_height + 64) * u), z, a.y, input, true); } bool online_embedded_typing() { return hub().typing != 0; } + +// Mods being applied with the pause menu closed (most often after the Mod Manager's question): a +// small card at the right edge, out of the way of skating, as the map download's is, then a few +// seconds of how it went. +namespace { +bool apply_card_seen{}; +ULONGLONG apply_card_until{}; +std::string apply_card_result; +} +bool mod_apply_card_pending() { + const bool applying = live_mods::busy() && dingosdk::map_download::view().stage == dingosdk::map_download::Stage::idle; + const bool menu = hub().shown || dingosdk::multiplayer::native_pause_menu_open(); + if (applying && !menu) apply_card_seen = true, apply_card_until = 0; + else if (apply_card_seen && !applying) { + apply_card_seen = false; + apply_card_until = GetTickCount64() + 4000; + apply_card_result = live_mods::status(); + } + return (applying && !menu) || GetTickCount64() < apply_card_until; +} +void draw_mod_apply_card() { + auto &s = state(); + auto *bold = s.menu.bold ? s.menu.bold : ImGui::GetFont(); + auto *body = s.menu.body ? s.menu.body : ImGui::GetFont(); + const auto display = ImGui::GetIO().DisplaySize; + const float u = std::min(display.x / layout_width, display.y / layout_height); + const bool applying = live_mods::busy(); + const auto progress = live_mods::progress(); + const float width = 820 * u, inset = 30 * u, bar = 12 * u; + const float height = inset + 24 * u + 12 * u + 38 * u + 18 * u + bar + 14 * u + 26 * u + inset; + const ImVec2 min(std::floor(display.x - 48 * u - width), std::floor(display.y * 0.2f)), max(min.x + width, min.y + height); + auto *draw = ImGui::GetForegroundDrawList(); + skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 235), 0x4d3, u * 2); + draw->AddRectFilled(min, ImVec2(min.x + 8 * u, max.y), applying ? theme::blue : skate_theme::good); + const float left = min.x + inset + 8 * u, right = max.x - inset; + float y = min.y + inset; + draw->AddText(bold, 24 * u, ImVec2(left, y), theme::blue, "MOD MANAGER"); + y += 36 * u; + draw->AddText(bold, 38 * u, ImVec2(left, y), theme::paper, applying ? "APPLYING MODS" : "MODS APPLIED"); + y += 56 * u; + const float fraction = !applying ? 1.f : progress.total ? static_cast(progress.done) / static_cast(progress.total) : -1.f; + draw->AddRectFilled(ImVec2(left, y), ImVec2(right, y + bar), skate_theme::tile_light, 6 * u); + if (fraction >= 0) draw->AddRectFilled(ImVec2(left, y), ImVec2(left + (right - left) * std::clamp(fraction, 0.f, 1.f), y + bar), applying ? theme::blue : skate_theme::good, 6 * u); + else { + const float t = static_cast(std::fmod(ImGui::GetTime(), 2.0)) / 2.f, w = (right - left) * .25f; + const float x = left + (right - left - w) * (t < .5f ? t * 2 : 2 - t * 2); + draw->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + bar), theme::blue, 6 * u); + } + y += bar + 14 * u; + const std::string line = applying ? (progress.step.empty() ? std::string("Merging the mods into the game...") : progress.step) + : (apply_card_result.empty() ? std::string("Done.") : apply_card_result); + label(draw, body, 26 * u, ImVec2(left, y), right, theme::muted, line); +} } // namespace dingosdk::overlay::detail diff --git a/Extension/UI/Overlay/message_card.cpp b/Extension/UI/Overlay/message_card.cpp index d6ca7690..827dc8f6 100644 --- a/Extension/UI/Overlay/message_card.cpp +++ b/Extension/UI/Overlay/message_card.cpp @@ -1,6 +1,7 @@ #include "overlay_internal.h" #include "Engine/Game/Input/controller_bindings.h" #include +#include #include #include @@ -15,6 +16,14 @@ namespace { std::mutex mutex; std::string title_text, body_text; // under the mutex std::atomic up{}; +// The question (ask_question): what it says and its answers, under the mutex; whether it is up and +// which answer is picked. +struct Question { + std::string label, title, text, yes, no; + std::function answer; +}; +Question question; // under the mutex +std::atomic question_up{}, question_yes{true}; } void show_message(std::string title, std::string text) noexcept { @@ -27,12 +36,102 @@ void show_message(std::string title, std::string text) noexcept { } bool message_asking() noexcept { return up.load(); } void message_dismiss() noexcept { up.store(false); } + +void ask_question(std::string label, std::string title, std::string text, std::string yes, std::string no, + std::function answer) noexcept { + try { + std::lock_guard lock(mutex); + question = {std::move(label), std::move(title), std::move(text), std::move(yes), std::move(no), std::move(answer)}; + question_yes.store(true); + question_up.store(true); + } catch (...) {} +} +bool question_asking() noexcept { return question_up.load(); } +void question_pick(bool yes) noexcept { question_yes.store(yes); } +bool question_picked() noexcept { return question_yes.load(); } +void question_answer(bool yes) noexcept { + std::function answer; + { + std::lock_guard lock(mutex); + if (!question_up.exchange(false)) return; + answer = std::move(question.answer); + question.answer = nullptr; + } + try { if (answer) answer(yes); } catch (...) {} +} } // namespace dingosdk::overlay namespace dingosdk::overlay::detail { -bool message_pending() { return up.load(); } +bool message_pending() { return up.load() || question_up.load(); } +namespace { +// The question (ask_question), drawn as the message is, with its two answers side by side: the +// picked one blue, as the map download's card does it. +void draw_question() { + Question shown; + { + std::lock_guard lock(mutex); + shown = {question.label, question.title, question.text, question.yes, question.no, nullptr}; + } + auto& s = state(); + auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont(); + auto* body = s.menu.body ? s.menu.body : ImGui::GetFont(); + auto* heading = s.menu.heading ? s.menu.heading : bold; + const auto display = ImGui::GetIO().DisplaySize; + const float u = std::min(display.x / 3840.0f, display.y / 2160.0f); + const auto width_of = [](ImFont* font, float size, const std::string& what) { return font->CalcTextSizeA(size, FLT_MAX, 0, what.c_str()).x; }; + dingosdk::ControllerInput pad; + DingoSDKOverlayReadControllerInput(&pad, true); + const float key_scale = u * 2; + const float inset = 56 * u, label_size = 28 * u, title_size = 64 * u, text_size = 38 * u; + const float width = std::min(1600 * u, display.x - 200 * u), wrap = width - inset * 2; + const float text_height = body->CalcTextSizeA(text_size, FLT_MAX, wrap, shown.text.c_str()).y; + const float button_width = 520 * u, button_height = 96 * u, between = 30 * u; + const float height = inset + label_size + 20 * u + title_size + 34 * u + text_height + 48 * u + button_height + inset; + const float under = 34 * u + 20 * key_scale; + const ImVec2 min(std::floor((display.x - width) * 0.5f), std::floor((display.y - height - under) * 0.5f)), max(min.x + width, min.y + height); + auto* draw = ImGui::GetForegroundDrawList(); + draw->AddRectFilled(ImVec2(0, 0), display, IM_COL32(0, 0, 0, 170)); + skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 250), 0x5e3, u * 2); + draw->AddRectFilled(min, ImVec2(min.x + 10 * u, max.y), theme::blue); + const float left = min.x + inset; + float y = min.y + inset; + draw->AddText(bold, label_size, ImVec2(left, y), theme::blue, shown.label.c_str()); + y += label_size + 20 * u; + draw->AddText(heading, title_size, ImVec2(left, y), theme::paper, shown.title.c_str()); + y += title_size + 34 * u; + draw->AddText(body, text_size, ImVec2(left, y), theme::paper, shown.text.c_str(), nullptr, wrap); + y += text_height + 48 * u; + // The two answers: the picked one blue. Pointing at one picks it, a click answers with it. + const float row = button_width * 2 + between; + const ImVec2 yes_min(std::floor(min.x + (width - row) * 0.5f), y), yes_max(yes_min.x + button_width, y + button_height); + const ImVec2 no_min(yes_max.x + between, y), no_max(no_min.x + button_width, y + button_height); + const bool over_yes = ImGui::IsMouseHoveringRect(yes_min, yes_max, false), over_no = ImGui::IsMouseHoveringRect(no_min, no_max, false); + if (over_yes) question_pick(true); + else if (over_no) question_pick(false); + const bool yes = question_picked(); + const float size = 40 * u; + const auto answer_button = [&](ImVec2 a, ImVec2 b, const std::string& text, bool picked, unsigned seed) { + skate_theme::rough_rect(draw, a, b, picked ? theme::blue : skate_theme::tile_light, seed, u * 2); + draw->AddText(bold, size, ImVec2(a.x + (button_width - width_of(bold, size, text)) * 0.5f, a.y + (button_height - size) * 0.5f), + picked ? skate_theme::black : theme::paper, text.c_str()); + }; + answer_button(yes_min, yes_max, shown.yes, yes, 0x5e4); + answer_button(no_min, no_max, shown.no, !yes, 0x5e5); + const auto take = pad.available ? dingosdk::controller_combo_label(0x1000, pad.style) : std::string("Enter"); + const auto back = pad.available ? dingosdk::controller_combo_label(0x2000, pad.style) : std::string("Esc"); + const float hints = width_of(bold, 12 * key_scale, take) + width_of(bold, 14 * key_scale, "Choose") + width_of(bold, 12 * key_scale, back) + + width_of(bold, 14 * key_scale, shown.no) + 80 * key_scale; + float x = min.x + (width - hints) * 0.5f; + x += theme::keycap(draw, bold, key_scale, ImVec2(x, max.y + 34 * u), take.c_str(), "Choose") + 40 * key_scale; + theme::keycap(draw, bold, key_scale, ImVec2(x, max.y + 34 * u), back.c_str(), shown.no.c_str()); + if ((over_yes || over_no) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) question_answer(over_yes); +} +} void draw_message() { - if (!up.load()) return; + if (!up.load()) { + if (question_up.load()) draw_question(); + return; + } std::string title, text; { std::lock_guard lock(mutex); diff --git a/Extension/UI/Overlay/mod_manager_page.inl b/Extension/UI/Overlay/mod_manager_page.inl new file mode 100644 index 00000000..69708c2a --- /dev/null +++ b/Extension/UI/Overlay/mod_manager_page.inl @@ -0,0 +1,1187 @@ +// The pause menu's Mod Manager page (native_menu.cpp's third page), drawn here as the other two are: +// Thunderstore's mods to browse and install, the installed ones to switch on and off, update and +// uninstall, and the Apply that puts the changes in the game. Included into hub_page.cpp, inside its +// namespace, for its controls (item, button, field, Scroller) and its focus. The work behind it is +// Extension/Assets/mod_store.h; nothing here reads the disk or the network. +// +// BROWSE is a front page like a console store's: a few mods big in a carousel, then rows of them +// (most popular, recently updated, new, the biggest categories). Searching or choosing categories +// (several, shown only or hidden, in a panel of their own) shows a grid instead. Pressing a mod +// opens its page: its picture, what it is, its README, and INSTALL. MY MODS is the Mods folder; +// UPDATES what Thunderstore has newer. + +namespace ms = dingosdk::mod_store; +namespace ts = dingosdk::thunderstore; + +enum ModTab : int { browse_tab, installed_tab, updates_tab }; + +struct ModPage { + int tab{browse_tab}; + std::string search, installed_search; + // The categories shown only and those hidden (Query), kept in the profile; and whether the + // panel that picks them is open. + std::vector only, hidden; + bool categories_loaded{}, categories_open{}; + ms::Sort sort{ms::Sort::popular}; + int installed_filter{}; // 0 all, 1 on, 2 off, 3 waiting to be applied + std::string details; // the package whose page is open (Namespace-Name); empty: none + // Where it was opened from, to go back to: the tab, and the control (a tile, mostly) pressed. + int return_tab{browse_tab}; + std::uint32_t return_focus{}; + // B and Escape stay the page's this long after leaving a mod's page with one, so the press + // that left it does not then close the pause menu (hold_native_menu_back). + ULONGLONG hold_back_until{}; + int carousel_turn{}; // left or right on the D-pad with the carousel's card focused (hub_frame) + std::size_t featured{}; // the carousel's card in the middle + ULONGLONG featured_at{}; // when it last moved by itself + std::map rows; // each shelf's scroll across + std::string selected; // MY MODS: the mod the panel shows (its folder) + std::string confirm; // a mod whose UNINSTALL was pressed once + ULONGLONG confirm_until{}; + // What the page shows, worked out again only when the listing, the mods or the query change. + std::shared_ptr listing; + std::shared_ptr mods; + unsigned listing_serial{~0U}, mods_serial{~0U}; + std::string query_key; + ms::Front front; + std::vector results, updates; + std::vector> categories; + std::map> by_folder; + unsigned progress_serial{}; + ms::Progress progress; // the queue as it is this frame (mod_manager_page) + bool visited{}; // shown since the pause menu last closed (hub_page_pending asks about its changes then) + // Leaving the pause menu from here with changes not applied: Back is held and the player asked + // first (offer_leave). `leaving` while that is asked; its answer comes from whichever thread + // answered (1 apply, 2 later); `declined` once it was later, for the rest of this visit; and when + // to close the menu for the player after that (GetTickCount64; 0: not to). + bool leaving{}, declined{}; + std::atomic leave_answer{}; + ULONGLONG close_at{}; + std::string notice; + bool notice_bad{}; + ULONGLONG notice_until{}; + // The README laid out for the width it was laid out at. + std::shared_ptr readme; + float readme_width{}; + struct Line { std::string text; ts::ReadmeLine::Kind kind; bool first{}; }; + std::vector readme_lines; +}; +ModPage &mod_page() { + static ModPage value; + return value; +} +const std::uint32_t featured_card = hash("mods-featured-card"), categories_button = hash("mods-categories-open"); + +// The categories chosen, kept in the profile as the launcher keeps its own: a name a line. +void load_categories(ModPage &m) { + if (std::exchange(m.categories_loaded, true)) return; + const auto read = [](const char *key, std::vector &into) { + const auto saved = dingosdk::profile_runtime::local_value(key); + if (!saved || !saved->is_string()) return; + std::string line; + for (const char c : saved->string() + "\n") { + if (c != '\n') line += c; + else if (!line.empty() && into.size() < 64) into.push_back(std::exchange(line, {})); + } + }; + read("ModCategoriesOnly", m.only); + read("ModCategoriesHidden", m.hidden); +} +void save_categories(const ModPage &m) { + const auto joined = [](const std::vector &names) { + std::string text; + for (const auto &name : names) text += (text.empty() ? "" : "\n") + name; + return text; + }; + dingosdk::profile_runtime::set_local_values({{"ModCategoriesOnly", dingosdk::Json(joined(m.only))}, {"ModCategoriesHidden", dingosdk::Json(joined(m.hidden))}}); +} +// What the CATEGORIES button says: "Maps, Cosmetics, not Audio", or ALL. +std::string category_summary(const ModPage &m) { + std::string text; + for (const auto &name : m.only) text += (text.empty() ? "" : ", ") + name; + for (const auto &name : m.hidden) text += (text.empty() ? "not " : ", not ") + name; + return text.empty() ? std::string("ALL") : text; +} +void mod_say(ModPage &m, std::string text, bool bad = false) { + m.notice = std::move(text); + m.notice_bad = bad; + m.notice_until = GetTickCount64() + 7000; +} + +// ---------------------------------------------------------------- drawing + +// `text` broken into lines no wider than `width`; at most `most` (0: any), the last ending in "..." +// when there was more. +std::vector wrap_text(ImFont *font, float size, std::string_view text, float width, std::size_t most = 0) { + std::vector lines; + std::string line; + std::size_t at = 0; + bool more{}; + while (at < text.size()) { + if (most && lines.size() >= most) { more = true; break; } + if (text[at] == '\n' || text[at] == '\r') { + if (text[at] == '\n') lines.push_back(std::exchange(line, {})); + ++at; + continue; + } + std::size_t end = at; + while (end < text.size() && text[end] != ' ' && text[end] != '\n' && text[end] != '\r') ++end; + const std::string word(text.substr(at, end - at)); + const auto candidate = line.empty() ? word : line + ' ' + word; + if (!line.empty() && width_of(font, size, candidate) > width) lines.push_back(std::exchange(line, word)); + else line = candidate; + while (end < text.size() && text[end] == ' ') ++end; + at = end; + } + if (!line.empty()) { + if (most && lines.size() >= most) more = true; + else lines.push_back(std::move(line)); + } + if (more && !lines.empty()) { + auto &last = lines.back(); + while (!last.empty() && width_of(font, size, last + "...") > width) { + last.pop_back(); + while (!last.empty() && (static_cast(last.back()) & 0xc0) == 0x80) last.pop_back(); + if (!last.empty() && static_cast(last.back()) >= 0xc0) last.pop_back(); + } + last += "..."; + } + return lines; +} +// Wrapped text; returns how tall it was. +float paragraph(ImDrawList *draw, ImFont *font, float size, ImVec2 at, float width, ImU32 colour, std::string_view text, std::size_t most = 0) { + const auto lines = wrap_text(font, size, text, width, most); + for (std::size_t i = 0; i < lines.size(); ++i) + draw->AddText(font, size, ImVec2(at.x, at.y + static_cast(i) * size * 1.3f), colour, lines[i].c_str()); + return static_cast(lines.size()) * size * 1.3f; +} +// A small label on a plate; returns its width. +float plate(Ui &ui, ImVec2 at, float height, const std::string &text, ImU32 fill, ImU32 ink) { + const float u = ui.u, size = height * .62f, w = width_of(ui.bold, size, text) + 24 * u; + ui.draw->AddRectFilled(at, ImVec2(at.x + w, at.y + height), fill, 4 * u); + ui.draw->AddText(ui.bold, size, ImVec2(at.x + 12 * u, at.y + (height - size) * .5f), ink, text.c_str()); + return w; +} +// A package's icon, or its name's first letter on a tile while the icon is on its way. +void package_icon(Ui &ui, ImVec2 a, ImVec2 b, const ts::Package *package, std::string_view title, bool dim = false) { + if (package) + if (const auto id = store_icon(package->latest().icon)) { + ui.draw->AddImage(id, a, b, ImVec2(0, 0), ImVec2(1, 1), dim ? IM_COL32(150, 150, 150, 255) : IM_COL32_WHITE); + return; + } + skate_theme::rough_rect(ui.draw, a, b, skate_theme::tile_light, static_cast(hash(title)) & 0xfff, ui.u * 2); + const float size = (b.y - a.y) * .5f; + const std::string letter = title.empty() ? std::string("?") : std::string(1, static_cast(std::toupper(static_cast(title.front())))); + ui.draw->AddText(ui.heading, size, ImVec2(a.x + (b.x - a.x - width_of(ui.heading, size, letter)) * .5f, a.y + (b.y - a.y - size) * .5f), + theme::muted, letter.c_str()); +} +std::string action_label(ms::Action action) { + return action == ms::Action::install ? "INSTALL" : action == ms::Action::update ? "UPDATE" : "INSTALLED"; +} +ms::Action action_of(const ModPage &m, const ts::Package &package) { + return m.mods ? ms::action_for(package, m.mods->versions) : ms::Action::install; +} +void start_install(ModPage &m, const std::vector &packages) { + if (const auto refused = ms::install(packages); !refused.empty()) mod_say(m, refused, true); +} +void open_details(Ui &ui, ModPage &m, const ts::Package &package) { + m.return_tab = m.tab; + m.return_focus = ui.h.focus; + m.tab = browse_tab; + m.details = package.full_name; + m.readme.reset(); + m.readme_lines.clear(); + ui.h.focus_next = hash("mods-detail-action"); +} +// Back from a mod's page to where it was opened from, the focus on what opened it. +void close_details(Hub &h, ModPage &m) { + m.details.clear(); + m.tab = m.return_tab; + h.focus_next = m.return_focus ? m.return_focus : hash("mods-search"); + m.featured_at = GetTickCount64(); // (the carousel stays where it was left) +} +void close_categories(Hub &h, ModPage &m) { + m.categories_open = false; + h.focus_next = categories_button; + h.scrolls[hash("mods-browse")] = 0; +} +// Whether B and Escape are the page's (a mod's page, or the categories, is open: hub_frame), and +// what they do then. +bool mods_hold_back(const ModPage &m) { return m.tab == browse_tab && (!m.details.empty() || m.categories_open); } +void mods_back(Hub &h, ModPage &m) { + if (m.categories_open) close_categories(h, m); + else close_details(h, m); +} + +// A package as a tile in a row or the grid: its icon, name and maker, and what it is to this PC. +// Returns whether it was pressed. A tile out of view still takes its place in the focus order, so +// the D-pad can move onto it, but is not drawn: drawing it would ask for its icon, and icons have +// only so many places on the GPU. +bool package_tile(Ui &ui, ModPage &m, std::uint32_t id, ImVec2 p, float w, const ts::Package &package, const Item *clip) { + const float u = ui.u, h = w + 104 * u; + const ImVec2 q(p.x + w, p.y + h); + const Hit hit = item(ui, id, p, q, clip); + if (clip && (q.x < clip->a.x || p.x > clip->b.x || q.y < clip->a.y || p.y > clip->b.y)) return hit.press; + const bool lit = hit.focus || hit.hover; + skate_theme::rough_rect(ui.draw, p, q, lit ? theme::blue : skate_theme::tile, id & 0xfff, u * 2); + const float inset = 10 * u; + package_icon(ui, ImVec2(p.x + inset, p.y + inset), ImVec2(q.x - inset, p.y + w - inset), &package, package.title()); + // Being installed, or waiting its turn: a strip over the foot of its icon says so. + const auto &progress = m.progress; + const bool queued = progress.busy && std::find(progress.queued.begin(), progress.queued.end(), package.full_name) != progress.queued.end(); + if (progress.busy && (progress.package == package.full_name || queued)) { + const ImVec2 s0(p.x + inset, p.y + w - inset - 46 * u), s1(q.x - inset, p.y + w - inset); + ui.draw->AddRectFilled(s0, s1, IM_COL32(0, 0, 0, 200)); + if (!queued && progress.fraction >= 0) ui.draw->AddRectFilled(s0, ImVec2(s0.x + (s1.x - s0.x) * std::clamp(progress.fraction, 0.f, 1.f), s1.y), theme::blue); + const auto text = queued ? std::string("WAITING") : progress.step == ms::Progress::Step::downloading ? std::string("DOWNLOADING") : std::string("INSTALLING"); + ui.draw->AddText(ui.bold, 26 * u, ImVec2(s0.x + 12 * u, s0.y + 10 * u), theme::paper, text.c_str()); + } + const auto action = action_of(m, package); + if (action != ms::Action::install) + plate(ui, ImVec2(p.x + inset + 8 * u, p.y + inset + 8 * u), 40 * u, action == ms::Action::update ? "UPDATE" : "INSTALLED", + action == ms::Action::update ? skate_theme::warning : skate_theme::good, skate_theme::black); + label(ui.draw, ui.bold, 32 * u, ImVec2(p.x + 16 * u, p.y + w + 4 * u), q.x - 12 * u, theme::paper, package.title()); + label(ui.draw, ui.body, 26 * u, ImVec2(p.x + 16 * u, p.y + w + 50 * u), q.x - 12 * u, lit ? theme::paper : theme::muted, + package.owner + " - " + ms::count_text(package.downloads)); + return hit.press; +} + +// A round arrow button, for the carousel and the rows. +bool arrow(Ui &ui, std::uint32_t id, ImVec2 centre, float radius, int direction, const Item *clip = nullptr) { + const Hit hit = item(ui, id, ImVec2(centre.x - radius, centre.y - radius), ImVec2(centre.x + radius, centre.y + radius), clip); + const bool lit = hit.focus || hit.hover; + ui.draw->AddCircleFilled(centre, radius, lit ? theme::blue : IM_COL32(245, 245, 245, 235), 32); + const float s = radius * .38f, d = static_cast(direction); + ui.draw->AddTriangleFilled(ImVec2(centre.x + s * d, centre.y), ImVec2(centre.x - s * .7f * d, centre.y - s), + ImVec2(centre.x - s * .7f * d, centre.y + s), lit ? theme::paper : skate_theme::black); + return hit.press; +} + +// ---------------------------------------------------------------- what the page shows + +void refresh_view(ModPage &m) { + m.listing = ms::listing(); + m.mods = ms::installed(); + const auto mods_serial = m.mods ? m.mods->serial : 0U; + load_categories(m); + std::string key = m.search + '\n' + std::to_string(static_cast(m.sort)); + for (const auto &name : m.only) key += "\n+" + name; + for (const auto &name : m.hidden) key += "\n-" + name; + if (m.listing->serial == m.listing_serial && mods_serial == m.mods_serial && key == m.query_key) return; + const bool listing_changed = m.listing->serial != m.listing_serial; + m.listing_serial = m.listing->serial; + m.mods_serial = mods_serial; + m.query_key = key; + const auto &packages = m.listing->packages; + const ts::Installed none; + const auto &have = m.mods ? m.mods->versions : none; + m.results = ms::search(packages, {m.search, m.only, m.hidden, m.sort}, have); + if (listing_changed || m.front.shelves.empty()) { + m.front = ms::front(packages, have, ms::today()); + m.categories = ms::categories(packages); + m.by_folder.clear(); + for (const auto &package : packages) m.by_folder[ts::folder_for(package.full_name)] = &package; + } + m.updates.clear(); + for (const auto &package : packages) + if (ts::update_available(package, have)) m.updates.push_back(&package); +} +const ts::Package *package_named(const ModPage &m, std::string_view full_name) { + return m.listing ? ts::find_package(m.listing->packages, full_name) : nullptr; +} + +// ---------------------------------------------------------------- BROWSE + +// The bar over BROWSE: search, the categories (a panel of their own), the order. +void browse_bar(Ui &ui, ModPage &m, ImVec2 a, ImVec2 z, float bar) { + const float u = ui.u, gap = 28 * u; + const float search_width = std::min(900 * u, (z.x - a.x) * .28f); + field(ui, hash("mods-search"), a, ImVec2(a.x + search_width, a.y + bar), m.search, "Search mods", false, 64); + const float sort_width = 640 * u; + const ImVec2 c(a.x + search_width + gap, a.y), d(z.x - sort_width - gap, a.y + bar); + if (button(ui, categories_button, c, d, "CATEGORIES", m.categories_open ? Look::chosen : Look::tile, true, category_summary(m))) { + if (m.categories_open) close_categories(ui.h, m); + else { + m.categories_open = true; + ui.h.focus_next = m.categories.empty() ? hash("mods-categories-done") : hash(m.categories.front().first, 71); + } + } + if (button(ui, hash("mods-sort"), ImVec2(z.x - sort_width, a.y), ImVec2(z.x, a.y + bar), "SORT", Look::tile, true, ms::sort_label(m.sort))) + m.sort = static_cast((static_cast(m.sort) + 1) % ms::sort_count); +} + +// The categories, all of them, as tiles: pressing one shows only it (with any others shown only), +// again hides it, again leaves it either way, as the launcher's category menu does. Made for the +// D-pad and A as much as the mouse. +void categories_panel(Ui &ui, ModPage &m, ImVec2 a, ImVec2 z) { + auto *draw = ui.draw; + const float u = ui.u, inset = 40 * u; + skate_theme::rough_rect(draw, a, z, skate_theme::tile, 0x3c1, u * 2); + draw->AddText(ui.heading, 56 * u, ImVec2(a.x + inset, a.y + inset), theme::paper, "CATEGORIES"); + const float bh = 92 * u, bw = 400 * u; + const ImVec2 done(z.x - inset - bw, a.y + inset), clear(done.x - 20 * u - bw, a.y + inset); + label(draw, ui.body, 30 * u, ImVec2(a.x + inset, a.y + inset + 78 * u), clear.x - 30 * u, theme::muted, + "Press a category to show only it, again to hide it, and again for either. Mods in any category shown are listed."); + if (button(ui, hash("mods-categories-clear"), clear, ImVec2(clear.x + bw, clear.y + bh), "CLEAR", Look::plain, !m.only.empty() || !m.hidden.empty())) { + m.only.clear(); + m.hidden.clear(); + save_categories(m); + } + if (button(ui, hash("mods-categories-done"), done, ImVec2(done.x + bw, done.y + bh), "DONE", Look::sticky)) close_categories(ui.h, m); + const ImVec2 la(a.x + inset, a.y + inset + 150 * u), lz(z.x - inset, z.y - inset); + const float spacing = 16 * u, tile_h = 104 * u; + const auto columns = std::max(1, static_cast((lz.x - la.x + spacing) / (600 * u + spacing))); + const float tile_w = (lz.x - la.x - 20 * u - spacing * static_cast(columns - 1)) / static_cast(columns); + Scroller list(ui, hash("mods-categories"), la, lz); + const auto has = [](const std::vector &names, const std::string &name) { return std::find(names.begin(), names.end(), name) != names.end(); }; + for (std::size_t i = 0; i < m.categories.size(); i += columns) { + bool drawn{}; + const float top = list.next(tile_h, spacing, drawn); + for (std::size_t j = i; j < std::min(m.categories.size(), i + columns); ++j) { + const auto &[name, count] = m.categories[j]; + const auto id = hash(name, 71); + const ImVec2 p(la.x + static_cast(j - i) * (tile_w + spacing), top), q(p.x + tile_w, top + tile_h); + const Hit hit = item(ui, id, p, q, &list.box); + list.reveal(id, top, tile_h); + const int state = has(m.only, name) ? 1 : has(m.hidden, name) ? 2 : 0; + if (hit.press) { + std::erase(m.only, name); + std::erase(m.hidden, name); + if (state == 0) m.only.push_back(name); + else if (state == 1) m.hidden.push_back(name); + save_categories(m); + } + if (!drawn) continue; + const bool lit = hit.focus || hit.hover; + skate_theme::rough_rect(draw, p, q, lit ? theme::blue : state == 1 ? skate_theme::friends_tile : state == 2 ? IM_COL32(70, 24, 24, 240) : skate_theme::tile_grey, + id & 0xfff, u * 2); + // Its state on the left, as a mark: a tick shown only, a cross hidden, nothing either way. + const ImVec2 mark(p.x + 30 * u, p.y + tile_h * .5f); + if (state == 1) { + draw->AddLine(ImVec2(mark.x - 14 * u, mark.y), ImVec2(mark.x - 3 * u, mark.y + 12 * u), skate_theme::good, 7 * u); + draw->AddLine(ImVec2(mark.x - 3 * u, mark.y + 12 * u), ImVec2(mark.x + 18 * u, mark.y - 14 * u), skate_theme::good, 7 * u); + } else if (state == 2) { + draw->AddLine(ImVec2(mark.x - 14 * u, mark.y - 14 * u), ImVec2(mark.x + 14 * u, mark.y + 14 * u), skate_theme::danger, 7 * u); + draw->AddLine(ImVec2(mark.x - 14 * u, mark.y + 14 * u), ImVec2(mark.x + 14 * u, mark.y - 14 * u), skate_theme::danger, 7 * u); + } else draw->AddCircle(mark, 14 * u, theme::muted, 20, 3 * u); + std::string upper = name; + for (auto &c : upper) c = static_cast(std::toupper(static_cast(c))); + const auto counted = std::to_string(count); + const float count_w = width_of(ui.body, 30 * u, counted); + label(draw, ui.bold, 36 * u, ImVec2(p.x + 64 * u, p.y + (tile_h - 36 * u) * .5f), q.x - count_w - 40 * u, state == 2 && !lit ? theme::muted : theme::paper, upper); + draw->AddText(ui.body, 30 * u, ImVec2(q.x - count_w - 24 * u, p.y + (tile_h - 30 * u) * .5f), lit ? theme::paper : theme::muted, counted.c_str()); + } + } + list.end(); +} + +// Pointed at and clicked with the mouse only: not a stop for the D-pad. The carousel's extras (its +// neighbours, arrows and the card's own buttons) are these, so the D-pad has one place to land on +// it, the card, and turns it with left and right (hub_frame). +struct Pointed { + bool hover{}, press{}; +}; +Pointed pointed(Ui &ui, ImVec2 a, ImVec2 b, const Item *clip) { + const bool over = ui.input && inside(ui.mouse, a, b) && (!clip || inside(ui.mouse, clip->a, clip->b)); + Pointed result{over && !ui.h.ring, over && ui.clicked}; + if (result.press) ui.h.sound = sound_select; + return result; +} +// A button drawn as button() draws one, for the mouse only (Pointed). +bool pointer_button(Ui &ui, ImVec2 a, ImVec2 b, const std::string &text, Look look, bool enabled, const Item *clip) { + const float u = ui.u, size = std::min(40 * u, (b.y - a.y) * .42f); + const auto hit = enabled ? pointed(ui, a, b, clip) : Pointed{}; + ImU32 fill = skate_theme::tile, ink = enabled ? theme::paper : theme::muted; + if (look == Look::sticky) fill = !enabled ? sticky_off : hit.hover ? sticky_hover : sticky, ink = skate_theme::black; + else if (hit.hover) fill = theme::blue; + else if (look == Look::plain) fill = skate_theme::tile_light; + skate_theme::rough_rect(ui.draw, a, b, fill, hash(text) & 0xfff, u * 2); + ui.draw->AddText(ui.bold, size, ImVec2(a.x + (b.x - a.x - width_of(ui.bold, size, text)) * .5f, a.y + (b.y - a.y - size) * .5f), ink, text.c_str()); + return hit.press; +} +bool pointer_arrow(Ui &ui, ImVec2 centre, float radius, int direction, const Item *clip) { + const auto hit = pointed(ui, ImVec2(centre.x - radius, centre.y - radius), ImVec2(centre.x + radius, centre.y + radius), clip); + ui.draw->AddCircleFilled(centre, radius, hit.hover ? theme::blue : IM_COL32(245, 245, 245, 235), 32); + const float s = radius * .38f, d = static_cast(direction); + ui.draw->AddTriangleFilled(ImVec2(centre.x + s * d, centre.y), ImVec2(centre.x - s * .7f * d, centre.y - s), + ImVec2(centre.x - s * .7f * d, centre.y + s), hit.hover ? theme::paper : skate_theme::black); + return hit.press; +} + +// The carousel: the card in the middle big, its neighbours to each side, arrows over them. On a +// controller the card is the one stop: A opens the mod, X installs it, left and right turn it. +float featured(Ui &ui, ModPage &m, ImVec2 a, float right, float top, const Item &clip) { + auto *draw = ui.draw; + const float u = ui.u, width = right - a.x; + const auto &list = m.front.featured; + if (list.empty()) return 0; + const float card_w = std::min(1900 * u, width * .58f), card_h = 520 * u; + const float side_w = 1150 * u, side_h = 420 * u, spacing = 48 * u; + const float centre = a.x + width * .5f; + const auto count = list.size(); + const auto now = GetTickCount64(); + const auto index = [&](int offset) { return (m.featured + count * 4 + static_cast(offset + static_cast(count) * 2)) % count; }; + // Turned by the D-pad (hub_frame), or by the mouse below. + const auto turn = [&](int by) { + m.featured = index(by); + m.featured_at = now; + }; + m.featured %= count; + if (const int by = std::exchange(m.carousel_turn, 0)) turn(std::clamp(by, -1, 1)); + bool held{}; + // The neighbours, drawn first and dimmed: clicking one brings it to the middle. + for (const int side : {-1, 1}) { + if (count < 2) break; + const auto *package = list[index(side)]; + const float x = side < 0 ? centre - card_w * .5f - spacing - side_w : centre + card_w * .5f + spacing; + const ImVec2 p(x, top + (card_h - side_h) * .5f), q(x + side_w, p.y + side_h); + const auto hit = pointed(ui, p, q, &clip); + held |= hit.hover; + skate_theme::rough_rect(draw, p, q, hit.hover ? skate_theme::tile_light : IM_COL32(20, 20, 20, 220), hash(package->full_name) & 0xfff, u * 2); + const float icon = side_h - 60 * u; + package_icon(ui, ImVec2(p.x + 30 * u, p.y + 30 * u), ImVec2(p.x + 30 * u + icon, p.y + 30 * u + icon), package, package->title(), true); + label(draw, ui.heading, 48 * u, ImVec2(p.x + icon + 70 * u, p.y + 40 * u), q.x - 30 * u, theme::muted, package->title()); + label(draw, ui.body, 30 * u, ImVec2(p.x + icon + 70 * u, p.y + 104 * u), q.x - 30 * u, theme::muted, "by " + package->owner); + if (hit.press) turn(side); + } + // The card in the middle. + const auto *package = list[index(0)]; + const ImVec2 p(centre - card_w * .5f, top), q(centre + card_w * .5f, top + card_h); + const float inset = 36 * u, icon = card_h - inset * 2; + const auto action = action_of(m, *package); + const bool busy = ms::progress().busy; + const float bh = 92 * u, bw = 380 * u; + const ImVec2 b1(q.x - inset - bw * 2 - 20 * u, q.y - inset - bh), b2(q.x - inset - bw, q.y - inset - bh); + // Its buttons are the mouse's; the D-pad has the card, with its ring and its prompts. + const bool on_button = ui.input && (inside(ui.mouse, b1, ImVec2(b1.x + bw, b1.y + bh)) || inside(ui.mouse, b2, ImVec2(b2.x + bw, b2.y + bh))); + // To the D-pad the card is as wide as the page, so up from any tile of the row under it lands + // here, not on the bar above it; to the mouse it is the card itself. + const Hit card = item(ui, featured_card, ImVec2(clip.a.x, p.y), ImVec2(clip.b.x, q.y), &clip); + const bool ringed = card.focus && ui.h.ring; + held |= card.focus; + skate_theme::rough_rect(draw, p, q, skate_theme::tile, hash(package->full_name) & 0xfff, u * 2); + draw->AddRect(p, q, ringed ? theme::blue : IM_COL32(245, 245, 245, 60), 0, 0, (ringed ? 8.f : 3.f) * u); + package_icon(ui, ImVec2(p.x + inset, p.y + inset), ImVec2(p.x + inset + icon, p.y + inset + icon), package, package->title()); + const float tx = p.x + inset * 2 + icon, tw = q.x - inset - tx; + float y = p.y + inset; + draw->AddText(ui.bold, 26 * u, ImVec2(tx, y), theme::blue, package->pinned ? "PINNED ON THUNDERSTORE" : "FEATURED"); + y += 40 * u; + label(draw, ui.heading, 64 * u, ImVec2(tx, y), q.x - inset, theme::paper, package->title()); + y += 80 * u; + label(draw, ui.body, 32 * u, ImVec2(tx, y), q.x - inset, theme::muted, + "by " + package->owner + " " + ms::count_text(package->downloads) + " downloads updated " + ms::date_text(package->date_updated)); + y += 56 * u; + float cx = tx; + for (std::size_t i = 0; i < package->categories.size() && i < 4; ++i) { + std::string upper = package->categories[i]; + for (auto &c : upper) c = static_cast(std::toupper(static_cast(c))); + cx += plate(ui, ImVec2(cx, y), 40 * u, upper, skate_theme::tile_light, theme::paper) + 10 * u; + } + y += 64 * u; + paragraph(draw, ui.body, 32 * u, ImVec2(tx, y), tw, soft, package->latest().description, 3); + const bool can_install = action != ms::Action::installed && !busy; + if (ringed && ui.pad.available) { + // The controller's buttons for them, over them. + const auto prompt = [&](ImVec2 at, std::uint32_t pad_button, const char *what) { + theme::keycap(draw, ui.bold, u * 2, at, controller_combo_label(pad_button, ui.pad.style).c_str(), what); + }; + if (can_install) prompt(ImVec2(b1.x, b1.y - 56 * u), 0x4000, action == ms::Action::update ? "Update" : "Install"); + prompt(ImVec2(b2.x, b2.y - 56 * u), 0x1000, "Details"); + } + bool install = pointer_button(ui, b1, ImVec2(b1.x + bw, b1.y + bh), busy && action != ms::Action::installed ? "BUSY..." : action_label(action), + action == ms::Action::installed ? Look::plain : Look::sticky, can_install, &clip); + bool details = pointer_button(ui, b2, ImVec2(b2.x + bw, b2.y + bh), "DETAILS", Look::plain, true, &clip); + const std::uint32_t pressed = ui.input && !ui.h.typing ? ui.pad.buttons & ~ui.h.pad_before : 0; + install |= card.focus && can_install && (pressed & 0x4000); + details |= card.press && !on_button && (!ui.clicked || inside(ui.mouse, p, q)); + if (install) start_install(m, {package->full_name}); + else if (details) open_details(ui, m, *package); + // The arrows, over the neighbours. + if (count > 1) { + if (pointer_arrow(ui, ImVec2(p.x - spacing * .5f - 10 * u, top + card_h * .5f), 46 * u, -1, &clip)) turn(-1); + if (pointer_arrow(ui, ImVec2(q.x + spacing * .5f + 10 * u, top + card_h * .5f), 46 * u, 1, &clip)) turn(1); + } + // The dots under it: which card of how many. + const float dot = 9 * u, dots_w = static_cast(count) * dot * 3; + for (std::size_t i = 0; i < count; ++i) + draw->AddCircleFilled(ImVec2(centre - dots_w * .5f + (static_cast(i) + .5f) * dot * 3, top + card_h + 30 * u), dot, + i == m.featured ? theme::blue : skate_theme::tile_light, 16); + // Moves on by itself every 8 s, unless the player is on it. + held |= ui.input && inside(ui.mouse, p, q); + if (held || !m.featured_at) m.featured_at = now; + else if (now - m.featured_at > 8000) turn(1); + return card_h + 60 * u; +} + +// One row of tiles that scrolls across, with arrows at its ends. Returns how tall it was. +float shelf(Ui &ui, ModPage &m, const ms::Shelf &row, Scroller &list, ImVec2 a, float right) { + auto *draw = ui.draw; + const float u = ui.u, tile_w = 270 * u, tile_h = tile_w + 104 * u, gap = 24 * u, title_h = 66 * u; + bool drawn{}; + const float top = list.next(title_h + tile_h, 40 * u, drawn); + const auto id = hash(row.title, 31); + float &offset = m.rows[id]; + const float row_left = a.x + 70 * u, row_right = right - 70 * u, view = row_right - row_left; + const float content = static_cast(row.packages.size()) * (tile_w + gap) - gap; + offset = std::clamp(offset, 0.f, std::max(0.f, content - view)); + draw->AddText(ui.heading, 46 * u, ImVec2(a.x, top), theme::paper, row.title.c_str()); + // A row well out of view takes no part at all; one just out of view only its place in the focus + // order, so the D-pad can move down onto it. + if (top + title_h + tile_h < list.box.a.y - tile_h || top > list.box.b.y + tile_h) return title_h + tile_h; + // The row's own box, inside the page's: where its tiles can be pointed at. (It is the page's list + // to the D-pad, so up and down go from row to row: move_focus.) + const Item box{list.box.id, ImVec2(row_left, std::max(top + title_h, list.box.a.y)), ImVec2(row_right, std::min(top + title_h + tile_h, list.box.b.y))}; + draw->PushClipRect(ImVec2(row_left - 4 * u, list.box.a.y), ImVec2(row_right + 4 * u, list.box.b.y), true); + for (std::size_t i = 0; i < row.packages.size(); ++i) { + const float x = row_left - offset + static_cast(i) * (tile_w + gap); + // The tiles in view, and one to each side so the D-pad can move onto it. + if (x + tile_w < row_left - tile_w - gap || x > row_right + tile_w + gap) continue; + const auto tile_id = hash(row.packages[i]->full_name, id); + if (package_tile(ui, m, tile_id, ImVec2(x, top + title_h), tile_w, *row.packages[i], &box)) open_details(ui, m, *row.packages[i]); + // Moving onto a tile out of view brings it in. + if (ui.h.focus == tile_id && ui.h.focus_moved) { + if (x < row_left) offset -= row_left - x; + else if (x + tile_w > row_right) offset += x + tile_w - row_right; + } + list.reveal(tile_id, top, title_h + tile_h); + } + draw->PopClipRect(); + if (offset > 0 && arrow(ui, hash("mods-row-left", id), ImVec2(a.x + 30 * u, top + title_h + tile_w * .5f), 32 * u, -1, &list.box)) + offset = std::max(0.f, offset - view * .8f); + if (offset < content - view - 1 && arrow(ui, hash("mods-row-right", id), ImVec2(right - 30 * u, top + title_h + tile_w * .5f), 32 * u, 1, &list.box)) + offset = std::min(content - view, offset + view * .8f); + return title_h + tile_h; +} + +void package_details(Ui &ui, ModPage &m, const ts::Package &package, ImVec2 a, ImVec2 z) { + auto *draw = ui.draw; + const float u = ui.u, inset = 40 * u; + const auto &version = package.latest(); + const auto action = action_of(m, package); + const auto folder = ts::folder_for(package.full_name); + const ms::Mod *mine{}; + if (m.mods) + for (const auto &mod : m.mods->mods) + if (_stricmp(mod.folder.c_str(), folder.c_str()) == 0) mine = &mod; + // ---- the left column: the picture and what to do, fitted to the window. The picture is as big + // as the buttons under it leave room for; in a window too short for that, the buttons go two to a row. + const float left_w = std::min(760 * u, (z.x - a.x) * .3f); + const ImVec2 la(a.x, a.y), lz(a.x + left_w, z.y); + skate_theme::rough_rect(draw, la, lz, skate_theme::tile, 0x3a1, u * 2); + const bool changes = m.mods && m.mods->changes && !live_mods::busy(); + const bool mine_here = mine && !mine->gone; + const int count = 2 + (changes ? 1 : 0) + (mine_here ? 2 : 0); // the action, BACK, and the rest + const float bh = 88 * u, spacing = 18 * u, inner = left_w - inset * 2, room = lz.y - la.y - inset * 2; + int columns = 1; + const auto rows = [&] { return (count + columns - 1) / columns; }; + float icon = std::min(inner, room - static_cast(rows()) * (bh + spacing)); + if (icon < 240 * u) { + columns = 2; + icon = std::min(inner, room - static_cast(rows()) * (bh + spacing)); + } + float y = la.y + inset; + if (icon >= 120 * u) { + const float ix = la.x + (left_w - icon) * .5f; + package_icon(ui, ImVec2(ix, y), ImVec2(ix + icon, y + icon), &package, package.title()); + y += icon + spacing; + } + const float bw = (inner - spacing * static_cast(columns - 1)) / static_cast(columns); + int placed{}; + const auto place = [&]() { + const ImVec2 p(la.x + inset + static_cast(placed % columns) * (bw + spacing), y + static_cast(placed / columns) * (bh + spacing)); + ++placed; + return std::pair{p, ImVec2(p.x + bw, p.y + bh)}; + }; + { + // INSTALL, UPDATE or REINSTALL; while the queue is busy, what it is doing with this mod (how far + // is at the foot of the page). + const auto [p, q] = place(); + const auto &progress = m.progress; + const bool queued = progress.busy && std::find(progress.queued.begin(), progress.queued.end(), package.full_name) != progress.queued.end(); + const bool in_hand = progress.busy && progress.package == package.full_name; + const char *does = queued ? "WAITING ITS TURN" : in_hand ? (progress.step == ms::Progress::Step::downloading ? "DOWNLOADING..." : "INSTALLING...") + : progress.busy ? "BUSY..." : action == ms::Action::install ? "INSTALL" : action == ms::Action::update ? "UPDATE" : "REINSTALL"; + if (button(ui, hash("mods-detail-action"), p, q, does, action == ms::Action::installed || progress.busy ? Look::plain : Look::sticky, !progress.busy)) + start_install(m, {package.full_name}); + } + if (changes) { + const auto [p, q] = place(); + if (button(ui, hash("mods-detail-apply"), p, q, "APPLY CHANGES", Look::sticky, !m.progress.busy)) mod_say(m, ms::apply()); + } + if (mine_here) { + { + const auto [p, q] = place(); + if (button(ui, hash("mods-detail-toggle"), p, q, mine->enabled ? "SWITCH OFF" : "SWITCH ON", Look::plain)) ms::set_enabled(mine->folder, !mine->enabled); + } + const auto [p, q] = place(); + const bool confirming = m.confirm == mine->folder && GetTickCount64() < m.confirm_until; + if (button(ui, hash("mods-detail-remove"), p, q, confirming ? "PRESS AGAIN" : "UNINSTALL", Look::plain, + !m.progress.busy && mine->state != ms::State::removal_waiting)) { + if (confirming) { + m.confirm.clear(); + if (const auto refused = ms::uninstall(mine->folder); !refused.empty()) mod_say(m, refused, true); + } else m.confirm = mine->folder, m.confirm_until = GetTickCount64() + 4000; + } + } + { + const auto [p, q] = place(); + if (button(ui, hash("mods-detail-back"), p, q, "BACK TO MODS", Look::plain)) close_details(ui.h, m); + } + + // ---- the right: what it is, and its README + const ImVec2 ra(lz.x + 28 * u, a.y), rz(z.x, z.y); + skate_theme::rough_rect(draw, ra, rz, skate_theme::tile, 0x3a2, u * 2); + const float left = ra.x + inset, edge = rz.x - inset; + y = ra.y + inset; + label(draw, ui.heading, 72 * u, ImVec2(left, y), edge, theme::paper, package.title()); + y += 92 * u; + std::string facts = "by " + package.owner + " v" + version.number + " " + ms::count_text(package.downloads) + " downloads"; + if (version.file_size) facts += " " + ms::size_text(version.file_size); + facts += " updated " + ms::date_text(package.date_updated); + if (package.rating) facts += " " + std::to_string(package.rating) + " likes"; + label(draw, ui.body, 32 * u, ImVec2(left, y), edge, theme::muted, facts); + y += 54 * u; + float x = left; + if (mine) { + const bool good = mine->state == ms::State::loaded; + x += plate(ui, ImVec2(x, y), 42 * u, ms::state_label(mine->state), good ? skate_theme::good : skate_theme::warning, skate_theme::black) + 12 * u; + if (mine->version != version.number && !mine->version.empty()) + x += plate(ui, ImVec2(x, y), 42 * u, "YOU HAVE v" + mine->version, skate_theme::tile_light, theme::paper) + 12 * u; + } + for (std::size_t i = 0; i < package.categories.size() && i < 5; ++i) { + std::string upper = package.categories[i]; + for (auto &c : upper) c = static_cast(std::toupper(static_cast(c))); + x += plate(ui, ImVec2(x, y), 42 * u, upper, skate_theme::tile_light, theme::paper) + 12 * u; + } + y += 66 * u; + if (package.deprecated) { + label(draw, ui.bold, 30 * u, ImVec2(left, y), edge, skate_theme::warning, "Its author marked it deprecated: it is no longer looked after."); + y += 48 * u; + } + if (!version.dependencies.empty()) { + std::string needs; + std::size_t named{}; + for (const auto &dependency : version.dependencies) { + const auto name = ts::split_dependency(dependency).first; + if (name.ends_with("-ReSkate") || name == "ReSkate-ReSkate") continue; + if (named++ < 6) needs += (needs.empty() ? "" : ", ") + name; + } + if (named > 6) needs += " and " + std::to_string(named - 6) + " more"; + if (!needs.empty()) { + y += paragraph(draw, ui.body, 30 * u, ImVec2(left, y), edge - left, theme::muted, "Brings what it needs with it: " + needs, 2) + 14 * u; + } + } + y += 6 * u; + draw->AddRectFilled(ImVec2(left, y), ImVec2(edge, y + 3 * u), skate_theme::tile_light); + y += 24 * u; + // The README, scrolled with the wheel, or by moving the focus down its paragraphs. + if (!m.readme) m.readme = ms::readme(package); + const ImVec2 ba(left, y), bz(edge, rz.y - inset); + if (!m.readme) { + draw->AddText(ui.body, 32 * u, ba, theme::muted, "Reading its page on Thunderstore..."); + return; + } + if (m.readme->failed || m.readme->text.lines.empty()) { + paragraph(draw, ui.body, 32 * u, ba, bz.x - ba.x, soft, m.readme->failed ? version.description + "\n\n(Its page on Thunderstore could not be read.)" : version.description); + return; + } + const float text_w = bz.x - ba.x - 30 * u; + if (m.readme_width != text_w || m.readme_lines.empty()) { + m.readme_width = text_w; + m.readme_lines.clear(); + using Kind = ts::ReadmeLine::Kind; + for (const auto &line : m.readme->text.lines) { + const float size = line.kind == Kind::heading ? 44 * u : 32 * u; + ImFont *font = line.kind == Kind::heading ? ui.bold : ui.body; + const float indent = line.kind == Kind::bullet ? 40 * u : line.kind == Kind::code ? 28 * u : 0; + if (line.kind == Kind::gap || line.kind == Kind::rule || line.text.empty()) { + m.readme_lines.push_back({{}, line.kind, true}); + continue; + } + bool first = true; + for (auto &piece : wrap_text(font, size, line.text, text_w - indent)) m.readme_lines.push_back({std::move(piece), line.kind, std::exchange(first, false)}); + } + if (m.readme->text.cut) m.readme_lines.push_back({"(The rest is on its Thunderstore page.)", ts::ReadmeLine::Kind::text, true}); + } + Scroller list(ui, hash("mods-readme"), ba, bz); + // A focus stop every few lines, so a controller can read down it. + std::size_t since_stop = 99; + for (std::size_t i = 0; i < m.readme_lines.size(); ++i) { + const auto &line = m.readme_lines[i]; + using Kind = ts::ReadmeLine::Kind; + const float size = line.kind == Kind::heading ? 44 * u : 32 * u; + const float height = line.kind == Kind::gap ? 20 * u : line.kind == Kind::rule ? 30 * u : size * 1.35f + (line.kind == Kind::heading && line.first ? 16 * u : 0); + bool shown{}; + const float top = list.next(height, 0, shown); + if (line.first && line.kind != Kind::gap && line.kind != Kind::rule && ++since_stop >= 4) { + since_stop = 0; + const auto stop = hash("mods-readme-line", i); + const Hit hit = item(ui, stop, ImVec2(ba.x, top), ImVec2(bz.x - 20 * u, top + height), &list.box); + list.reveal(stop, top, std::min(height * 4, bz.y - ba.y)); + if (hit.focus && ui.h.ring) draw->AddRectFilled(ImVec2(ba.x, top), ImVec2(ba.x + 6 * u, top + height), theme::blue); + } + if (!shown) continue; + const float text_top = top + (line.kind == Kind::heading && line.first ? 16 * u : 0); + switch (line.kind) { + case Kind::heading: draw->AddText(ui.bold, size, ImVec2(ba.x + 16 * u, text_top), theme::paper, line.text.c_str()); break; + case Kind::bullet: + if (line.first) draw->AddCircleFilled(ImVec2(ba.x + 28 * u, text_top + size * .55f), 6 * u, theme::blue, 12); + draw->AddText(ui.body, size, ImVec2(ba.x + 56 * u, text_top), soft, line.text.c_str()); + break; + case Kind::code: + draw->AddRectFilled(ImVec2(ba.x + 16 * u, top), ImVec2(bz.x - 24 * u, top + height), IM_COL32(10, 10, 10, 200)); + draw->AddText(ui.body, size, ImVec2(ba.x + 44 * u, text_top), IM_COL32(190, 220, 255, 255), line.text.c_str()); + break; + case Kind::rule: draw->AddRectFilled(ImVec2(ba.x + 16 * u, top + 13 * u), ImVec2(bz.x - 24 * u, top + 16 * u), skate_theme::tile_light); break; + case Kind::gap: break; + default: draw->AddText(ui.body, size, ImVec2(ba.x + 16 * u, text_top), soft, line.text.c_str()); break; + } + } + list.end(); +} + +void browse_page(Ui &ui, ModPage &m, ImVec2 a, ImVec2 z) { + auto *draw = ui.draw; + const float u = ui.u, gap = 28 * u, bar = 96 * u; + if (!m.details.empty()) { + if (const auto *package = package_named(m, m.details)) return package_details(ui, m, *package, a, z); + m.details.clear(); + } + browse_bar(ui, m, a, z, bar); + const ImVec2 ca(a.x, a.y + bar + gap), cz(z.x, z.y); + if (m.categories_open) return categories_panel(ui, m, ca, cz); + const auto &listing = *m.listing; + if (listing.packages.empty()) { + const auto line = !listing.error.empty() && !listing.fetching ? "Thunderstore could not be reached: " + listing.error + : std::string("Getting the mods from Thunderstore..."); + draw->AddText(ui.body, 40 * u, ImVec2(ca.x, ca.y + 30 * u), theme::muted, line.c_str()); + if (!listing.fetching && button(ui, hash("mods-retry"), ImVec2(ca.x, ca.y + 110 * u), ImVec2(ca.x + 500 * u, ca.y + 206 * u), "TRY AGAIN", Look::sticky)) + ms::refresh(); + return; + } + Scroller list(ui, hash("mods-browse"), ca, cz); + const bool front = m.search.empty() && m.only.empty() && m.hidden.empty(); + if (front) { + bool shown{}; + const float top = list.next(560 * u + 60 * u, 30 * u, shown); + const Item clip{hash("mods-browse"), ca, cz}; + featured(ui, m, ImVec2(ca.x, ca.y), cz.x, top, clip); + list.reveal(featured_card, top, 580 * u); + for (const auto &row : m.front.shelves) shelf(ui, m, row, list, ca, cz.x - 12 * u); + } else { + // Searching, or categories chosen: every match in a grid. + bool shown{}; + const float head = list.next(56 * u, 10 * u, shown); + const auto count = std::to_string(m.results.size()) + (m.results.size() == 1 ? " mod" : " mods") + + (m.only.empty() && m.hidden.empty() ? std::string() : " in " + category_summary(m)) + + (m.search.empty() ? std::string() : " matching \"" + m.search + "\""); + if (shown) draw->AddText(ui.body, 34 * u, ImVec2(ca.x, head), theme::muted, count.c_str()); + const float tile_w = 270 * u, tile_h = tile_w + 104 * u, spacing = 24 * u; + const auto columns = std::max(1, static_cast((cz.x - ca.x - 20 * u + spacing) / (tile_w + spacing))); + for (std::size_t i = 0; i < m.results.size(); i += columns) { + const float top = list.next(tile_h, spacing, shown); + for (std::size_t j = i; j < std::min(m.results.size(), i + columns); ++j) { + const auto id = hash(m.results[j]->full_name, 41); + // (Rows out of view still take their place in the focus order, near it, for the D-pad.) + if (!shown && (top + tile_h < ca.y - tile_h * 2 || top > cz.y + tile_h * 2)) continue; + if (package_tile(ui, m, id, ImVec2(ca.x + static_cast(j - i) * (tile_w + spacing), top), tile_w, *m.results[j], &list.box)) + open_details(ui, m, *m.results[j]); + list.reveal(id, top, tile_h); + } + } + if (m.results.empty()) { + const float top = list.next(60 * u, 0, shown); + draw->AddText(ui.body, 38 * u, ImVec2(ca.x, top), theme::muted, "Nothing matches. Try other words, or ALL."); + } + } + list.end(); +} + +// ---------------------------------------------------------------- MY MODS + +ImU32 state_colour(ms::State state) { + switch (state) { + case ms::State::loaded: return skate_theme::good; + case ms::State::disabled: return skate_theme::tile_light; + case ms::State::not_loaded: return skate_theme::danger; + default: return skate_theme::warning; + } +} + +void installed_page(Ui &ui, ModPage &m, ImVec2 a, ImVec2 z) { + auto *draw = ui.draw; + const float u = ui.u, gap = 28 * u, bar = 96 * u, list_width = (z.x - a.x) * .62f; + if (!m.mods) { + draw->AddText(ui.body, 40 * u, ImVec2(a.x, a.y + 30 * u), theme::muted, "Reading the Mods folder..."); + return; + } + const auto &mods = *m.mods; + // ---- the bar: search and which to show + { + const float search_width = std::min(900 * u, list_width * .45f); + field(ui, hash("mods-installed-search"), a, ImVec2(a.x + search_width, a.y + bar), m.installed_search, "Search your mods", false, 64); + float x = a.x + search_width + gap; + const auto chip = [&](const char *text, int filter) { + const float size = 34 * u, w = width_of(ui.bold, size, text) + 44 * u; + const ImVec2 c(x, a.y + 10 * u), d(x + w, a.y + bar - 10 * u); + const Hit hit = item(ui, hash(text, 23), c, d); + const bool lit = hit.focus || hit.hover, on = m.installed_filter == filter; + skate_theme::rough_rect(draw, c, d, lit ? theme::blue : on ? theme::paper : skate_theme::tile, hash(text) & 0xfff, u * 2); + draw->AddText(ui.bold, size, ImVec2(c.x + 22 * u, c.y + (d.y - c.y - size) * .5f), on && !lit ? skate_theme::black : theme::paper, text); + x = d.x + 12 * u; + if (hit.press) m.installed_filter = filter, ui.h.scrolls[hash("mods-installed")] = 0; + }; + chip("ALL", 0); + chip("ON", 1); + chip("OFF", 2); + chip("NOT APPLIED", 3); + } + const auto lower = [](std::string text) { + for (auto &c : text) c = static_cast(std::tolower(static_cast(c))); + return text; + }; + const auto wanted = lower(m.installed_search); + std::vector rows; + for (const auto &mod : mods.mods) { + if (m.installed_filter == 1 && !mod.enabled) continue; + if (m.installed_filter == 2 && mod.enabled) continue; + if (m.installed_filter == 3 && mod.state != ms::State::apply_to_load && mod.state != ms::State::apply_to_unload) continue; + if (!wanted.empty() && lower(mod.title).find(wanted) == std::string::npos && lower(mod.author).find(wanted) == std::string::npos && + lower(mod.folder).find(wanted) == std::string::npos) + continue; + rows.push_back(&mod); + } + if (std::none_of(rows.begin(), rows.end(), [&](const ms::Mod *mod) { return mod->folder == m.selected; })) + m.selected = rows.empty() ? std::string() : rows.front()->folder; + const auto package_of = [&](const std::string &folder) -> const ts::Package * { + const auto found = m.by_folder.find(folder); + return found == m.by_folder.end() ? nullptr : found->second; + }; + + // ---- the list + const ImVec2 la(a.x, a.y + bar + gap), lz(a.x + list_width, z.y); + const float row_height = 112 * u, row_gap = 8 * u, pad = 20 * u; + { + Scroller list(ui, hash("mods-installed"), la, lz); + for (const auto *mod : rows) { + const auto id = hash(mod->folder, 51); + bool drawn{}; + const float top = list.next(row_height, row_gap, drawn); + const ImVec2 p(la.x, top), q(lz.x - 20 * u, top + row_height); + const Hit hit = item(ui, id, p, q, &list.box); + list.reveal(id, top, row_height); + if (hit.focus && (ui.h.ring || ui.moved || ui.clicked)) m.selected = mod->folder; + // Pressing a row switches it on or off. + if (hit.press && !mod->gone) ms::set_enabled(mod->folder, !mod->enabled); + if (!drawn) continue; + const bool lit = hit.focus || hit.hover, chosen = m.selected == mod->folder; + skate_theme::rough_rect(draw, p, q, lit ? theme::blue : skate_theme::tile, 0xa00 + (id & 0xff), u * 2); + if (chosen && !lit) draw->AddRectFilled(ImVec2(p.x, p.y + 6 * u), ImVec2(p.x + 10 * u, q.y - 6 * u), theme::blue); + const float icon = row_height - 20 * u; + package_icon(ui, ImVec2(p.x + pad, p.y + 10 * u), ImVec2(p.x + pad + icon, p.y + 10 * u + icon), package_of(mod->folder), mod->title, !mod->enabled); + const float tx = p.x + pad * 2 + icon; + const auto tag = ms::state_label(mod->state); + const float tag_w = width_of(ui.bold, 26 * u, tag) + 24 * u; + const float tag_x = q.x - pad - tag_w; + plate(ui, ImVec2(tag_x, p.y + (row_height - 44 * u) * .5f), 44 * u, tag, lit ? IM_COL32(0, 0, 0, 90) : state_colour(mod->state), + lit || mod->state == ms::State::disabled ? theme::paper : skate_theme::black); + // The switch: on or off at a glance. + const float sw = 84 * u, sx = tag_x - sw - 24 * u, sy = p.y + (row_height - 44 * u) * .5f; + if (!mod->gone) { + draw->AddRectFilled(ImVec2(sx, sy), ImVec2(sx + sw, sy + 44 * u), mod->enabled ? skate_theme::good : skate_theme::tile_light, 22 * u); + draw->AddCircleFilled(ImVec2(mod->enabled ? sx + sw - 22 * u : sx + 22 * u, sy + 22 * u), 17 * u, theme::paper, 20); + } + label(draw, ui.bold, 38 * u, ImVec2(tx, p.y + 14 * u), sx - 20 * u, mod->enabled || lit ? theme::paper : theme::muted, mod->title); + std::string byline = mod->author.empty() ? std::string("Unknown author") : "by " + mod->author; + if (!mod->version.empty()) byline += " v" + mod->version; + for (std::size_t i = 0; i < mod->contents.size() && i < 3; ++i) byline += (i ? ", " : " ") + mod->contents[i]; + label(draw, ui.body, 28 * u, ImVec2(tx, p.y + 64 * u), sx - 20 * u, lit ? theme::paper : theme::muted, byline); + } + if (rows.empty()) { + bool shown{}; + const float top = list.next(60 * u, 0, shown); + draw->AddText(ui.body, 38 * u, ImVec2(la.x + pad, top), theme::muted, + !mods.present ? "There is no Mods folder yet: install a mod from BROWSE." : mods.mods.empty() ? "No mods installed yet. Find some in BROWSE." : "No mod matches."); + } + list.end(); + } + if (!mods.issue.empty()) label(draw, ui.body, 30 * u, ImVec2(la.x, z.y + 6 * u), lz.x, skate_theme::danger, "mods.json: " + mods.issue); + + // ---- beside it: the chosen mod + const ImVec2 da(lz.x + gap, la.y), dz(z.x, z.y); + skate_theme::rough_rect(draw, da, dz, skate_theme::tile, 0x7a, u * 2); + const float inset = 40 * u, left = da.x + inset, edge = dz.x - inset; + const ms::Mod *mod{}; + for (const auto *row : rows) + if (row->folder == m.selected) mod = row; + if (!mod) { + draw->AddText(ui.body, 36 * u, ImVec2(left, da.y + inset), theme::muted, "Pick a mod to see it here."); + return; + } + const auto *package = package_of(mod->folder); + float y = da.y + inset; + const float icon = 220 * u; + package_icon(ui, ImVec2(left, y), ImVec2(left + icon, y + icon), package, mod->title, !mod->enabled); + label(draw, ui.heading, 52 * u, ImVec2(left + icon + 30 * u, y + 4 * u), edge, theme::paper, mod->title); + label(draw, ui.body, 30 * u, ImVec2(left + icon + 30 * u, y + 76 * u), edge, theme::muted, + (mod->author.empty() ? std::string("Unknown author") : "by " + mod->author) + (mod->version.empty() ? std::string() : " v" + mod->version)); + plate(ui, ImVec2(left + icon + 30 * u, y + 130 * u), 44 * u, ms::state_label(mod->state), state_colour(mod->state), + mod->state == ms::State::disabled ? theme::paper : skate_theme::black); + y += icon + 30 * u; + if (!mod->why.empty()) y += paragraph(draw, ui.body, 30 * u, ImVec2(left, y), edge - left, skate_theme::warning, mod->why, 3) + 16 * u; + if (!mod->description.empty()) y += paragraph(draw, ui.body, 30 * u, ImVec2(left, y), edge - left, soft, mod->description, 4) + 16 * u; + const float bh = 88 * u; + float by = dz.y - inset - bh; + const auto progress = ms::progress(); + if (!mod->gone) { + const bool confirming = m.confirm == mod->folder && GetTickCount64() < m.confirm_until; + if (button(ui, hash("mods-installed-remove"), ImVec2(left, by), ImVec2(edge, by + bh), confirming ? "PRESS AGAIN TO UNINSTALL" : "UNINSTALL", + Look::plain, !progress.busy && mod->state != ms::State::removal_waiting)) { + if (confirming) { + m.confirm.clear(); + if (const auto refused = ms::uninstall(mod->folder); !refused.empty()) mod_say(m, refused, true); + } else m.confirm = mod->folder, m.confirm_until = GetTickCount64() + 4000; + } + by -= bh + 16 * u; + } + if (package) { + if (button(ui, hash("mods-installed-page"), ImVec2(left, by), ImVec2(edge, by + bh), "ITS PAGE", Look::plain)) + open_details(ui, m, *package); + by -= bh + 16 * u; + if (ts::update_available(*package, mods.versions) && + button(ui, hash("mods-installed-update"), ImVec2(left, by), ImVec2(edge, by + bh), "UPDATE TO v" + package->latest().number, Look::sticky, !progress.busy)) + start_install(m, {package->full_name}); + by -= bh + 16 * u; + } + if (!mod->gone && button(ui, hash("mods-installed-toggle"), ImVec2(left, by), ImVec2(edge, by + bh), mod->enabled ? "SWITCH OFF" : "SWITCH ON", + mod->enabled ? Look::plain : Look::sticky)) + ms::set_enabled(mod->folder, !mod->enabled); +} + +// ---------------------------------------------------------------- UPDATES + +void updates_page(Ui &ui, ModPage &m, ImVec2 a, ImVec2 z) { + auto *draw = ui.draw; + const float u = ui.u, gap = 28 * u, bar = 96 * u, pad = 24 * u; + const auto progress = ms::progress(); + const auto count = m.updates.size(); + std::string title = count == 0 ? "EVERYTHING IS UP TO DATE" : count == 1 ? "1 MOD HAS AN UPDATE" : std::to_string(count) + " MODS HAVE UPDATES"; + draw->AddText(ui.heading, 56 * u, ImVec2(a.x, a.y + (bar - 56 * u) * .5f), theme::paper, title.c_str()); + if (count) { + std::vector all; + std::uint64_t bytes{}; + for (const auto *package : m.updates) all.push_back(package->full_name), bytes += package->latest().file_size; + if (button(ui, hash("mods-update-all"), ImVec2(z.x - 640 * u, a.y), ImVec2(z.x, a.y + bar), progress.busy ? "BUSY..." : "UPDATE ALL (" + ms::size_text(bytes) + ")", + Look::sticky, !progress.busy)) + start_install(m, all); + } + const ImVec2 la(a.x, a.y + bar + gap), lz(z.x, z.y); + const float row_height = 120 * u; + Scroller list(ui, hash("mods-updates"), la, lz); + const auto installed_version = [&](const ts::Package &package) -> std::string { + if (!m.mods) return {}; + const auto found = m.mods->versions.find(ts::folder_for(package.full_name)); + return found == m.mods->versions.end() ? std::string() : found->second; + }; + for (const auto *package : m.updates) { + const auto id = hash(package->full_name, 61); + bool drawn{}; + const float top = list.next(row_height, 8 * u, drawn); + const ImVec2 p(la.x, top), q(lz.x - 20 * u, top + row_height); + const Hit hit = item(ui, id, p, q, &list.box); + list.reveal(id, top, row_height); + if (hit.press && !progress.busy) start_install(m, {package->full_name}); + if (!drawn) continue; + const bool lit = hit.focus || hit.hover; + skate_theme::rough_rect(draw, p, q, lit ? theme::blue : skate_theme::tile, 0xb00 + (id & 0xff), u * 2); + const float icon = row_height - 20 * u; + package_icon(ui, ImVec2(p.x + pad, p.y + 10 * u), ImVec2(p.x + pad + icon, p.y + 10 * u + icon), package, package->title()); + const float tx = p.x + pad * 2 + icon; + const char *does = progress.busy ? "BUSY..." : "UPDATE"; + const float dw = width_of(ui.bold, 32 * u, does) + 60 * u; + const ImVec2 c(q.x - pad - dw, p.y + 24 * u), d(q.x - pad, q.y - 24 * u); + skate_theme::rough_rect(draw, c, d, progress.busy ? sticky_off : sticky, 0x52, u * 2); + draw->AddText(ui.bold, 32 * u, ImVec2(c.x + 30 * u, c.y + (d.y - c.y - 32 * u) * .5f), skate_theme::black, does); + label(draw, ui.bold, 40 * u, ImVec2(tx, p.y + 16 * u), c.x - 30 * u, theme::paper, package->title()); + const auto have = installed_version(*package); + label(draw, ui.body, 30 * u, ImVec2(tx, p.y + 70 * u), c.x - 30 * u, lit ? theme::paper : theme::muted, + "v" + (have.empty() ? std::string("?") : have) + " > v" + package->latest().number + " " + ms::date_text(package->date_updated) + + (package->latest().file_size ? " " + ms::size_text(package->latest().file_size) : std::string())); + } + if (!count) { + bool shown{}; + const float top = list.next(60 * u, 0, shown); + draw->AddText(ui.body, 38 * u, ImVec2(la.x, top), theme::muted, + m.listing->packages.empty() ? "Getting the mods from Thunderstore..." : "Every mod you have from Thunderstore is its newest version."); + } + list.end(); +} + +// Changes not applied that are worth asking about: a reading of the mods from after the last apply, +// and nothing being applied or installed. +bool unapplied(const ModPage &m) { + return m.mods && m.mods->settled && m.mods->changes && !live_mods::busy() && !m.progress.busy; +} +// The question about them (offer_apply, offer_leave): what applying them does to the level. +std::string apply_question(const ModPage &m) { + const auto changes = m.mods->changes; + const char *effect = m.mods->effect == live_mods::ApplyEffect::reload_level ? "Applying them now reloads the level you are on, so they show straight away." + : m.mods->effect == live_mods::ApplyEffect::leave_level ? "Applying them now takes you to San Van: the map you are on is going." + : "Applying them now adds the maps to the map list. Nothing is reloaded."; + return std::to_string(changes) + (changes == 1 ? " change to your mods is" : " changes to your mods are") + " not in the game yet. " + effect + + " You can also apply them later, from the Mod Manager."; +} +// Backing out of the pause menu from the Mod Manager with changes not applied: asked over the menu, +// which stays up (its Back is held) until the answer: LATER closes it (hub_frame). +bool guard_leave(const ModPage &m) { return m.leaving || (!m.declined && unapplied(m)); } +void offer_leave(ModPage &m) { + if (question_asking() || message_asking()) return; + m.leaving = true; + ask_question("MOD MANAGER", "APPLY YOUR MOD CHANGES?", apply_question(m), "APPLY NOW", "LATER", [](bool yes) { + if (yes) (void)ms::apply(); + mod_page().leave_answer.store(yes ? 1 : 2); + }); +} +// Asked once the pause menu has closed some other way after the Mod Manager was open (hub_page_pending). +void offer_apply(ModPage &m) { + if (m.declined || m.leaving || !unapplied(m) || question_asking() || message_asking()) return; + const auto changes = m.mods->changes; + (void)changes; + ask_question("MOD MANAGER", "APPLY YOUR MOD CHANGES?", apply_question(m), "APPLY NOW", "LATER", [](bool yes) { + if (yes) (void)ms::apply(); + }); +} + +// ---------------------------------------------------------------- the page + +void mod_manager_page(Ui &ui, ImVec2 a, ImVec2 z) { + auto &m = mod_page(); + auto &h = ui.h; + auto *draw = ui.draw; + const float u = ui.u; + refresh_view(m); + // A mod's page belongs to BROWSE: changing tab (LB / RB) leaves it. + if (m.tab != browse_tab) m.details.clear(), m.categories_open = false; + m.progress = ms::progress(); + const auto &progress = m.progress; + if (progress.serial != m.progress_serial) { + // A job ended: say how it went. + if (m.progress_serial && !progress.result.empty()) mod_say(m, progress.result, progress.failed); + m.progress_serial = progress.serial; + } + if (m.notice_until && GetTickCount64() >= m.notice_until) m.notice.clear(), m.notice_until = 0; + // (Asked about its changes when the pause menu closes: hub_page_pending. Not once they were asked + // about as the player backed out, and said later.) + if (!m.declined && !m.leaving) m.visited = true; + // Y changes the order on BROWSE; View (Select) applies the changes, from anywhere here. (B and + // Escape going back from a mod's page are hub_frame's.) + const std::uint32_t pressed = ui.input && !h.typing ? ui.pad.buttons & ~h.pad_before : 0; + if (m.tab == browse_tab && m.details.empty() && !m.categories_open && (pressed & 0x8000)) + m.sort = static_cast((static_cast(m.sort) + 1) % ms::sort_count); + const auto changes = m.mods ? m.mods->changes : 0; + const bool can_apply = changes && !live_mods::busy() && !progress.busy; + if (can_apply && (pressed & 0x0020)) mod_say(m, ms::apply()), h.sound = sound_select; + + // The foot of the page: what is going on, and Apply. + const float foot = 84 * u, gap = 24 * u; + const ImVec2 body_z(z.x, z.y - foot - gap); + switch (m.tab) { + case browse_tab: browse_page(ui, m, a, body_z); break; + case installed_tab: installed_page(ui, m, a, body_z); break; + case updates_tab: updates_page(ui, m, a, body_z); break; + default: m.tab = browse_tab; break; + } + const float fy = z.y - foot; + float right = z.x; + if (changes && !live_mods::busy()) { + std::string apply = live_mods::apply_label(m.mods->effect); + for (auto &c : apply) c = static_cast(std::toupper(static_cast(c))); + const float w = width_of(ui.bold, 34 * u, apply) + 100 * u; + if (button(ui, hash("mods-apply"), ImVec2(right - w, fy), ImVec2(right, z.y), apply, Look::sticky, !progress.busy)) mod_say(m, ms::apply()); + right -= w + 20 * u; + } + // A bar with how far something is: `fraction` below 0 is not known, and a block goes back and forth. + const auto bar = [&](ImVec2 b0, ImVec2 b1, float fraction) { + draw->AddRectFilled(b0, b1, skate_theme::tile_light, 6 * u); + if (fraction >= 0) { + draw->AddRectFilled(b0, ImVec2(b0.x + (b1.x - b0.x) * std::clamp(fraction, 0.f, 1.f), b1.y), theme::blue, 6 * u); + return; + } + const float t = static_cast(std::fmod(ImGui::GetTime(), 2.0)) / 2.f, w = (b1.x - b0.x) * .25f; + const float x = b0.x + (b1.x - b0.x - w) * (t < .5f ? t * 2 : 2 - t * 2); + draw->AddRectFilled(ImVec2(x, b0.y), ImVec2(x + w, b1.y), theme::blue, 6 * u); + }; + if (progress.busy) { + // What is being installed: its icon and name, the step and which of how many, how much has + // come, and a bar, with CANCEL at the end. + const ImVec2 sa(a.x, fy), sz(right - 20 * u, z.y); + skate_theme::rough_rect(draw, sa, sz, skate_theme::tile, 0x3b7, u * 2); + const ImVec2 cancel_a(sz.x - 300 * u, fy + 10 * u), cancel_z(sz.x - 10 * u, z.y - 10 * u); + if (button(ui, hash("mods-cancel"), cancel_a, cancel_z, "CANCEL", Look::plain, progress.step == ms::Progress::Step::downloading)) ms::cancel(); + float x = sa.x + 10 * u; + const ts::Package *in_hand = package_named(m, progress.package); + if (!in_hand) + if (const auto found = m.by_folder.find(progress.package); found != m.by_folder.end()) in_hand = found->second; + const float icon = foot - 20 * u; + if (!progress.title.empty()) { + package_icon(ui, ImVec2(x, fy + 10 * u), ImVec2(x + icon, fy + 10 * u + icon), in_hand, progress.title); + x += icon + 20 * u; + } + using Step = ms::Progress::Step; + const char *step = progress.step == Step::downloading ? "DOWNLOADING" : progress.step == Step::installing ? "INSTALLING" + : progress.step == Step::uninstalling ? "UNINSTALLING" : "STARTING"; + const float bar_w = std::min(900 * u, (cancel_a.x - x) * .42f), bar_x = cancel_a.x - 30 * u - bar_w; + const std::string name = progress.title + (progress.version.empty() || progress.step == Step::uninstalling ? std::string() : " v" + progress.version); + const std::string where = std::string(step) + (progress.of > 1 ? " " + std::to_string(progress.at + 1) + " OF " + std::to_string(progress.of) : std::string()); + draw->AddText(ui.bold, 26 * u, ImVec2(x, fy + 8 * u), theme::blue, where.c_str()); + label(draw, ui.bold, 36 * u, ImVec2(x, fy + 38 * u), bar_x - 20 * u, theme::paper, name); + std::string amount = progress.step == Step::downloading + ? (progress.total ? ms::size_text(progress.received) + " of " + ms::size_text(progress.total) : ms::size_text(progress.received)) + : progress.step == Step::installing ? std::string("Putting it in your Mods folder") : std::string(); + if (progress.fraction >= 0 && progress.step != Step::uninstalling) amount += (amount.empty() ? "" : " ") + std::to_string(static_cast(progress.fraction * 100)) + "%"; + draw->AddText(ui.body, 26 * u, ImVec2(bar_x, fy + 10 * u), theme::muted, amount.c_str()); + bar(ImVec2(bar_x, fy + 46 * u), ImVec2(bar_x + bar_w, fy + 64 * u), progress.fraction); + return; + } + if (live_mods::busy()) { + // The merge: its step and how far, and what happens at the end of it. + const auto applying = live_mods::progress(); + const ImVec2 sa(a.x, fy), sz(right - 20 * u, z.y); + skate_theme::rough_rect(draw, sa, sz, skate_theme::tile, 0x3b8, u * 2); + const float x = sa.x + 24 * u, bar_w = std::min(900 * u, (sz.x - x) * .42f), bar_x = sz.x - 24 * u - bar_w; + const auto shown = std::min(applying.done + 1, applying.total); + const std::string where = applying.total ? "APPLYING MODS " + std::to_string(shown) + " OF " + std::to_string(applying.total) : std::string("APPLYING MODS"); + draw->AddText(ui.bold, 26 * u, ImVec2(x, fy + 8 * u), theme::blue, where.c_str()); + label(draw, ui.bold, 34 * u, ImVec2(x, fy + 40 * u), bar_x - 20 * u, theme::paper, applying.step.empty() ? std::string("Merging the mods into the game...") : applying.step); + draw->AddText(ui.body, 26 * u, ImVec2(bar_x, fy + 10 * u), theme::muted, + m.mods && m.mods->effect == live_mods::ApplyEffect::reload_level ? "The level reloads when it is done" : "It takes effect at the next level load"); + bar(ImVec2(bar_x, fy + 46 * u), ImVec2(bar_x + bar_w, fy + 64 * u), applying.total ? static_cast(applying.done) / static_cast(applying.total) : -1.f); + return; + } + std::string line = m.notice; + ImU32 colour = m.notice_bad ? skate_theme::danger : theme::paper; + if (line.empty() && changes) line = std::to_string(changes) + (changes == 1 ? " change is" : " changes are") + " not in the game yet: apply them when you are ready.", colour = skate_theme::warning; + if (line.empty() && m.mods && m.mods->waiting) + line = std::to_string(m.mods->waiting) + (m.mods->waiting == 1 ? " change goes" : " changes go") + " in the next time the game starts.", colour = skate_theme::warning; + if (line.empty() && m.listing) { + const auto &listing = *m.listing; + if (listing.fetching && listing.packages.empty()) line = "Getting the mods from Thunderstore..."; + else if (!listing.packages.empty()) + line = std::to_string(listing.packages.size()) + " mods on Thunderstore" + (listing.fetching ? ", checking for new ones..." : "") + + (listing.error.empty() || listing.fetching ? std::string() : " (the last check failed: " + listing.error + ")"); + colour = theme::muted; + } + float x = a.x; + const auto hint = [&](std::uint32_t pad_button, const char *key, const char *what) { + const auto name = ui.pad.available ? controller_combo_label(pad_button, ui.pad.style) : std::string(key); + if (!name.empty()) x += theme::keycap(draw, ui.bold, u * 2, ImVec2(x, fy + 20 * u), name.c_str(), what) + 36 * u; + }; + if (m.tab == browse_tab && m.categories_open) hint(0x1000, "Enter", "Show only / hide / either"), hint(0x2000, "Esc", "Done"); + else if (m.tab == browse_tab && !m.details.empty()) hint(0x2000, "Esc", "Back to mods"); + else if (m.tab == browse_tab && h.focus == featured_card && h.ring) hint(0x1000, "Enter", "Details"), hint(0x4000, "", "Install"), hint(0x000C, "Left / Right", "More"); + else if (m.tab == browse_tab) hint(0x1000, "Enter", "Open"), hint(0x8000, "", "Sort"); + else if (m.tab == installed_tab) hint(0x1000, "Enter", "Switch on / off"); + else hint(0x1000, "Enter", "Update"); + if (can_apply) hint(0x0020, "", "Apply"); + label(draw, ui.body, 30 * u, ImVec2(x + 10 * u, fy + 22 * u), right - 20 * u, colour, line); +} diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 0f8c1b3f..b96b8ca6 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -1,5 +1,6 @@ #pragma once #include "Engine/Game/World/park_rotation.h" +#include #include "Engine/Game/World/park_editor.h" #include "Engine/Game/World/world_layers.h" #include "Engine/Game/World/world_controls.h" @@ -452,6 +453,16 @@ void set_menu_map_pictures(const std::vector& pictures) noexcept; void show_message(std::string title, std::string text) noexcept; bool message_asking() noexcept; void message_dismiss() noexcept; +// A question with two answers, in the message's look (message_card.cpp), held and answered by the +// runtime as the message is: left and right pick, A takes the pick, B is the second answer. `label` +// says what is asking ("MOD MANAGER"); `answer` is called once, with true for the first answer, on +// whichever thread answers it. A message to say OK to goes over it. Any thread. +void ask_question(std::string label, std::string title, std::string text, std::string yes, std::string no, + std::function answer) noexcept; +bool question_asking() noexcept; +void question_pick(bool yes) noexcept; +bool question_picked() noexcept; +void question_answer(bool yes) noexcept; bool map_vote_asking() noexcept; void map_vote_move(int by) noexcept; void map_vote_take() noexcept; @@ -460,7 +471,7 @@ void map_vote_dismiss() noexcept; using ChatFeed = MultiplayerChat (*)(); void set_chat_feed(ChatFeed) noexcept; // The game's pause menu, as far as ReSkate's own pages in it go: whether one of them is the -// page on screen, and which (`section` 0: Multiplayer, 1: Mod Options). The overlay draws +// page on screen, and which (`section` 0: Multiplayer, 1: Mod Options, 2: Mod Manager). The overlay draws // everything in such a page itself, its tabs too (hub_page.cpp). Read every presented frame // (thread-safe, cheap). struct HubPage { diff --git a/Extension/UI/Overlay/overlay_internal.h b/Extension/UI/Overlay/overlay_internal.h index 07c6c29d..d7cbbf7d 100644 --- a/Extension/UI/Overlay/overlay_internal.h +++ b/Extension/UI/Overlay/overlay_internal.h @@ -192,6 +192,8 @@ struct State { ComPtr mod_icon_texture, mod_icon_upload; // Plugins' pictures (plugin_images.cpp), from the fifth slot on. std::array, 16> plugin_textures, plugin_uploads; + // Thunderstore icons for the Mod Manager page (store_icons.cpp), after the screen look's slots. + std::array, 64> store_textures, store_uploads; // store_icon_slots ComPtr commands; ComPtr fence; HANDLE fence_event = nullptr; @@ -358,6 +360,22 @@ std::optional take_plugin_image(); // id 0: it failed; `later`: the slot was busy, so it waits for another frame. void plugin_image_uploaded(const std::string& path, int slot, ImTextureID id, bool later = false); void plugin_images_lost(); +// Thunderstore packages' icons for the pause menu's Mod Manager page (store_icons.cpp), the same way, +// by their URL, in the SRV heap's slots from store_icon_first_slot on. +inline constexpr UINT store_icon_first_slot = screen_look_slot + screen_look_slots, store_icon_slots = 64; +ImTextureID store_icon(const std::string& url); +struct StoreIconUpload { + std::string url; + int slot{}; + CardPixels pixels; +}; +std::optional take_store_icon(); +void store_icon_uploaded(const std::string& url, int slot, ImTextureID id, bool later = false); +void store_icons_lost(); +// Mods being applied with the pause menu closed (hub_page.cpp): whether its card is up, and drawing it. +bool mod_apply_card_pending(); +void draw_mod_apply_card(); +static_assert(std::tuple_size_v == store_icon_slots); void mod_icon_uploaded(ImTextureID texture, unsigned serial); void mod_icon_lost(); // Game debug text (game_text_overlay.cpp): polled every presented frame. diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index 0e8e1f5b..82b00709 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -91,6 +91,9 @@ void destroy_graphics() { for (auto& texture : s.plugin_textures) texture.Reset(); for (auto& upload : s.plugin_uploads) upload.Reset(); plugin_images_lost(); + for (auto& texture : s.store_textures) texture.Reset(); + for (auto& upload : s.store_uploads) upload.Reset(); + store_icons_lost(); screen_look::release(); s.commands.Reset(); s.rtvs.Reset(); s.srvs.Reset(); s.fence.Reset(); s.device.Reset(); if (s.fence_event) CloseHandle(s.fence_event); @@ -300,8 +303,8 @@ bool setup_graphics() { } heap.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; // ImGui's atlas, the map download card's icon, the menu's map pictures, an item's mod icon, - // plugins' pictures (upload_image), and the screen look's - heap.NumDescriptors = screen_look_slot + screen_look_slots; + // plugins' pictures (upload_image), the screen look's, and the Mod Manager's icons + heap.NumDescriptors = store_icon_first_slot + store_icon_slots; heap.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; graphics_result = s.device->CreateDescriptorHeap(&heap, IID_PPV_ARGS(&s.srvs)); if (FAILED(graphics_result)) { @@ -724,6 +727,20 @@ void upload_plugin_image(State& s) { plugin_image_uploaded(next->path, next->slot, id); } +// A Thunderstore icon for the Mod Manager page (store_icons.cpp), one a frame, into the slot it was given. +void upload_store_icon(State& s) { + if (!s.device || !s.srvs) return; + auto next = take_store_icon(); + if (!next) return; + const auto slot = static_cast(next->slot); + if (s.store_textures[slot] && s.next_fence && !completed(s.next_fence, render_fence_timeout_ms)) { + store_icon_uploaded(next->url, next->slot, {}, true); + return; + } + const auto id = upload_image(s, next->pixels, store_icon_first_slot + static_cast(slot), s.store_textures[slot], s.store_uploads[slot]); + store_icon_uploaded(next->url, next->slot, id); +} + void render(IDXGISwapChain* presented, UINT flags) { auto& s = state(); if (flags & DXGI_PRESENT_TEST) return; @@ -766,6 +783,7 @@ void render(IDXGISwapChain* presented, UINT flags) { // Refreshes the chat feed every frame, which also decides whether T opens it. const bool chat_frame = chat_pending(); const bool map_download_frame = map_download_pending(); + const bool mod_apply_frame = mod_apply_card_pending(); const bool game_text_frame = game_text_pending(); const bool skate_hud_frame = skate_hud_pending(); const bool nametag_frame = nametags_pending(); @@ -794,7 +812,7 @@ void render(IDXGISwapChain* presented, UINT flags) { } // Hidden, the overlay still draws while a notice or chat line is on screen. if (s.loaded_notice_posted && !notices_pending() && !chat_frame && !game_text_frame && !skate_hud_frame && - !nametag_frame && !plugins_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame && !look_frame && + !nametag_frame && !plugins_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame && !mod_apply_frame && !look_frame && !grab_frame) return; } else if (!s.ui_was_interactive) { s.ui_was_interactive = true; @@ -867,6 +885,7 @@ void render(IDXGISwapChain* presented, UINT flags) { draw_item_browser(); draw_plugin_dialog(); draw_chat(); + if (mod_apply_frame) draw_mod_apply_card(); draw_map_download(); sync_menu_cursor(); // close buttons also change visibility, without a key message ImGui::Render(); @@ -890,6 +909,7 @@ void render(IDXGISwapChain* presented, UINT flags) { upload_menu_map_image(s); upload_mod_icon(s); upload_plugin_image(s); + upload_store_icon(s); if (look_frame) { // Drawn first, so ImGui draws over it. It leaves the back buffer in PRESENT, as it found it. ID3D12DescriptorHeap* look_heaps[] = {s.srvs.Get()}; diff --git a/Extension/UI/Overlay/store_icons.cpp b/Extension/UI/Overlay/store_icons.cpp new file mode 100644 index 00000000..88e04a58 --- /dev/null +++ b/Extension/UI/Overlay/store_icons.cpp @@ -0,0 +1,229 @@ +#include "overlay_internal.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Vfs/https_download.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Thunderstore packages' icons for the Mod Manager page (mod_manager_page.inl), by their URL. They +// are kept on disk where the launcher keeps them (%LOCALAPPDATA%\ReSkate\thunderstore\icons, named +// as gui_mods_browse.cpp names them), so an icon either of them fetched is not fetched again. Three +// worker threads read, fetch and decode them (WIC, 256 pixels square), the one asked for last first: +// it is what the player is looking at now. The render thread uploads one a frame into the SRV heap's +// store icon slots, and an icon not drawn for 2 s gives its slot up when another needs it. Only the +// tiles on screen ask (mod_manager_page.inl), so the slots are enough for a full page of them. +namespace dingosdk::overlay::detail { +namespace { +namespace fs = std::filesystem; +constexpr std::size_t most_icons = 192; // known at once, decoded or waiting +constexpr std::size_t workers = 3; +constexpr double retry_seconds = 20; // an icon that could not be had is tried again after this +constexpr std::uint64_t most_icon_bytes = 4 * 1024 * 1024; +constexpr double idle_seconds = 2; + +struct Entry { + enum class Stage { waiting, decoded, uploaded, failed } stage{Stage::waiting}; + CardPixels pixels; + int slot{-1}; + ImTextureID id{}; + double drawn{}, failed{}; +}; +struct Cache { + std::mutex mutex; + std::condition_variable wake; + std::map> entries; + std::deque queue; + std::size_t started{}; +}; +Cache& cache() { + static auto* value = new Cache; + return *value; +} +double seconds_now() { + return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); +} +fs::path icon_folder() { + std::array local{}; + const auto length = GetEnvironmentVariableW(L"LOCALAPPDATA", local.data(), static_cast(local.size())); + if (!length || length >= local.size()) return {}; + return fs::path(local.data()) / L"ReSkate" / L"thunderstore" / L"icons"; +} +// Icon URLs end in Namespace-Name-1.2.3.png, so a file named after that never goes stale. +std::wstring icon_file(std::string_view url) { + const auto slash = url.rfind('/'); + std::string name(url.substr(slash == std::string_view::npos ? 0 : slash + 1)); + for (auto& ch : name) + if (!((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '.' || ch == '-' || ch == '_')) + ch = '_'; + if (name.empty() || name.front() == '.') name.insert(name.begin(), 'i'); + return std::wstring(name.begin(), name.end()); +} +// Only Thunderstore's own hosts: a listing names its icons, and nothing else is fetched from here. +bool thunderstore_host(std::string_view url) { + constexpr std::string_view scheme = "https://"; + if (!url.starts_with(scheme)) return false; + auto host = url.substr(scheme.size()); + host = host.substr(0, host.find('/')); + return host == "thunderstore.io" || host == "gcdn.thunderstore.io" || host.ends_with(".thunderstore.io"); +} + +void decode_loop() { + auto& c = cache(); + const auto folder = icon_folder(); + for (;;) { + std::string url; + { + std::unique_lock lock(c.mutex); + c.wake.wait(lock, [&] { return !c.queue.empty(); }); + url = std::move(c.queue.back()); + c.queue.pop_back(); + // Asked for long enough ago that it went out of view again: let it wait for the next ask. + const auto found = c.entries.find(url); + if (found == c.entries.end()) continue; + if (seconds_now() - found->second.drawn > idle_seconds) { + c.entries.erase(found); + continue; + } + } + CardPixels pixels; + bool ok{}; + try { + const auto cached = folder.empty() ? fs::path() : folder / icon_file(url); + std::string bytes; + std::error_code error; + if (!cached.empty() && fs::is_regular_file(cached, error) && fs::file_size(cached, error) <= most_icon_bytes) { + std::ifstream in(cached, std::ios::binary); + bytes.assign(std::istreambuf_iterator(in), std::istreambuf_iterator()); + } + if (bytes.empty() && thunderstore_host(url)) { + if (auto body = https::get_text(std::wstring(url.begin(), url.end()), most_icon_bytes, 20, L"ReSkate")) { + bytes = std::move(*body); + if (!cached.empty() && !bytes.empty()) { + fs::create_directories(folder, error); + std::ofstream out(cached, std::ios::binary | std::ios::trunc); + out.write(bytes.data(), static_cast(bytes.size())); + } + } + } + ok = !bytes.empty() && decode_picture(bytes, pixels, 256); + } catch (...) { + ok = false; + } + std::lock_guard lock(c.mutex); + const auto found = c.entries.find(url); + if (found == c.entries.end()) continue; + if (!ok) found->second.stage = Entry::Stage::failed, found->second.failed = seconds_now(); + else { + found->second.pixels = std::move(pixels); + found->second.stage = Entry::Stage::decoded; + } + } +} +} + +ImTextureID store_icon(const std::string& url) { + if (url.empty()) return {}; + auto& c = cache(); + std::lock_guard lock(c.mutex); + const double now = seconds_now(); + auto found = c.entries.find(url); + if (found == c.entries.end()) { + if (c.entries.size() >= most_icons) { + // Make room: an icon that failed long enough ago, or the one drawn longest ago and idle. + auto oldest = c.entries.end(); + for (auto it = c.entries.begin(); it != c.entries.end(); ++it) + if (it->second.slot < 0 && it->second.stage != Entry::Stage::waiting && now - it->second.drawn >= idle_seconds && + (oldest == c.entries.end() || it->second.drawn < oldest->second.drawn)) + oldest = it; + if (oldest == c.entries.end()) return {}; + c.entries.erase(oldest); + } + found = c.entries.emplace(url, Entry{}).first; + c.queue.push_back(url); + if (c.started < workers && c.queue.size() > c.started) { + try { std::thread(decode_loop).detach(); ++c.started; } catch (...) {} + } + c.wake.notify_one(); + } else if (found->second.stage == Entry::Stage::failed && now - found->second.failed >= retry_seconds) { + found->second.stage = Entry::Stage::waiting; + c.queue.push_back(url); + c.wake.notify_one(); + } + auto& entry = found->second; + entry.drawn = now; + return entry.stage == Entry::Stage::uploaded ? entry.id : ImTextureID{}; +} + +std::optional take_store_icon() { + auto& c = cache(); + std::lock_guard lock(c.mutex); + // The most recently asked for first: what is on screen now. + auto waiting = c.entries.end(); + for (auto it = c.entries.begin(); it != c.entries.end(); ++it) + if (it->second.stage == Entry::Stage::decoded && (waiting == c.entries.end() || it->second.drawn > waiting->second.drawn)) waiting = it; + if (waiting == c.entries.end()) return std::nullopt; + std::array used{}; + for (const auto& [url, entry] : c.entries) + if (entry.slot >= 0) used[static_cast(entry.slot)] = true; + int slot = -1; + for (int i = 0; i < static_cast(store_icon_slots); ++i) + if (!used[static_cast(i)]) { + slot = i; + break; + } + if (slot < 0) { + // Every slot is in use: the icon drawn longest ago gives it up, if it is idle. Its pixels + // are gone, so drawn again it is read again. + const double now = seconds_now(); + auto oldest = c.entries.end(); + for (auto it = c.entries.begin(); it != c.entries.end(); ++it) + if (it->second.slot >= 0 && now - it->second.drawn >= idle_seconds && + (oldest == c.entries.end() || it->second.drawn < oldest->second.drawn)) + oldest = it; + if (oldest == c.entries.end()) return std::nullopt; + slot = oldest->second.slot; + c.entries.erase(oldest); + } + waiting->second.slot = slot; + waiting->second.stage = Entry::Stage::waiting; // until store_icon_uploaded + return StoreIconUpload{waiting->first, slot, waiting->second.pixels}; +} + +void store_icon_uploaded(const std::string& url, int slot, ImTextureID id, bool later) { + auto& c = cache(); + std::lock_guard lock(c.mutex); + const auto found = c.entries.find(url); + if (found == c.entries.end() || found->second.slot != slot) return; + auto& entry = found->second; + if (later) { + entry.stage = Entry::Stage::decoded; + entry.slot = -1; + return; + } + if (!id) { + entry.stage = Entry::Stage::failed; + entry.slot = -1; + return; + } + entry.stage = Entry::Stage::uploaded; + entry.id = id; + entry.pixels.rgba.clear(); + entry.pixels.rgba.shrink_to_fit(); +} + +void store_icons_lost() { + auto& c = cache(); + std::lock_guard lock(c.mutex); + // Drawn again, they are read again; a read under way finds its entry gone and is dropped. + for (auto it = c.entries.begin(); it != c.entries.end();) + it = it->second.stage == Entry::Stage::waiting && it->second.slot < 0 ? std::next(it) : c.entries.erase(it); +} +} diff --git a/Launcher/Test/mod_manager_tests.cpp b/Launcher/Test/mod_manager_tests.cpp index 48f3146d..156c5191 100644 --- a/Launcher/Test/mod_manager_tests.cpp +++ b/Launcher/Test/mod_manager_tests.cpp @@ -548,6 +548,49 @@ int main() { "The game's shader cache is ReSkate's own, under Game/Skate"); } + // Changes the game cannot make while it holds a mod's files: they wait for the next start. + { + const auto waiting_mods = root / L"waiting" / L"Mods"; + make_zip(sources / L"held-v1.zip", {{"Held Mod/layout.toc", "v1"}}); + make_zip(sources / L"held-v2.zip", {{"Held Mod/layout.toc", "v2"}}); + check(install(waiting_mods, sources / L"held-v1.zip") == "Held Mod", "pending: the first copy installs"); + auto held = CreateFileW((waiting_mods / L"Held Mod" / L"layout.toc").c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + check(!error_of([&] { install(waiting_mods, sources / L"held-v2.zip", true); }).empty(), "pending: a copy in use cannot be replaced now"); + std::atomic cancel{}; + check(launcher_mods::install_later(waiting_mods, sources / L"held-v2.zip", {}, cancel) == "Held Mod", "pending: the update is kept for later"); + auto waiting = launcher_mods::pending(waiting_mods); + check(waiting.updates == std::vector{"Held Mod"} && waiting.removals.empty(), "pending: the update is what waits"); + check(read(waiting_mods / L"Held Mod" / L"layout.toc") == "v1", "pending: the copy in use is untouched"); + check(dingosdk::mods::scan_mods(waiting_mods.parent_path()).entries.size() == 1, "pending: the waiting copy is not a mod of its own"); + const auto still_held = launcher_mods::apply_pending(waiting_mods); + check(still_held.size() == 1 && still_held.front().find("waits") != std::string::npos && + read(waiting_mods / L"Held Mod" / L"layout.toc") == "v1" && launcher_mods::pending(waiting_mods).updates.size() == 1, + "pending: while the game still holds it, the update keeps waiting and the old copy stays"); + CloseHandle(held); + const auto applied = launcher_mods::apply_pending(waiting_mods); + check(applied.size() == 1 && read(waiting_mods / L"Held Mod" / L"layout.toc") == "v2", "pending: at the next start the update goes in"); + check(!fs::exists(waiting_mods / launcher_mods::pending_folder), "pending: nothing is left behind once it is all done"); + check(launcher_mods::apply_pending(waiting_mods).empty(), "pending: nothing waiting is nothing to do"); + + launcher_mods::remove_later(waiting_mods, "Held Mod"); + launcher_mods::remove_later(waiting_mods, "Held Mod"); + waiting = launcher_mods::pending(waiting_mods); + check(waiting.removals == std::vector{"Held Mod"} && waiting.updates.empty(), "pending: an uninstall waits, once"); + check(launcher_mods::install_later(waiting_mods, sources / L"held-v1.zip", {}, cancel) == "Held Mod" && + launcher_mods::pending(waiting_mods).removals.empty(), + "pending: installing it again takes back the uninstall"); + launcher_mods::remove_later(waiting_mods, "Held Mod"); + waiting = launcher_mods::pending(waiting_mods); + check(waiting.updates.empty() && waiting.removals.size() == 1, "pending: uninstalling it takes back the update"); + check(!error_of([&] { launcher_mods::remove_later(waiting_mods, "..\\elsewhere"); }).empty(), "pending: only mod folder names are uninstalled"); + // (A mod to uninstall that is gone already is simply dropped; the Recycle Bin is not tried here.) + launcher_mods::remove_later(waiting_mods, "Never Installed"); + fs::remove_all(waiting_mods / L"Held Mod"); + launcher_mods::apply_pending(waiting_mods); + check(!fs::exists(waiting_mods / launcher_mods::pending_folder), "pending: uninstalls of mods already gone are dropped"); + } + std::error_code ignored; fs::remove_all(root, ignored); if (failures) { diff --git a/Launcher/gui_launcher.cpp b/Launcher/gui_launcher.cpp index a0639b0b..bcfbe848 100644 --- a/Launcher/gui_launcher.cpp +++ b/Launcher/gui_launcher.cpp @@ -607,6 +607,10 @@ void Launcher::run_play() { fail("Skate is already running from this folder. Close it before starting it again."); return; } + // Updates and uninstalls the game's Mod Manager could not make while it ran (mod_manager.h), + // now that nothing holds the mods' files. + for (const auto& line : launcher_mods::apply_pending(mods_data_root() / mods::mods_folder)) + logging::write(logging::Level::info, logging::Channel::launcher, line); if (!ignore_mod_problems_ && !run_mod_merge()) return; ignore_mod_problems_ = false; set(Phase::launching, "Starting Skate"); diff --git a/Launcher/mod_manager.cpp b/Launcher/mod_manager.cpp index b02e5637..ab6f187b 100644 --- a/Launcher/mod_manager.cpp +++ b/Launcher/mod_manager.cpp @@ -485,4 +485,112 @@ void remove(const fs::path& mods, const std::string& name) { recycle(target); } +namespace { +constexpr wchar_t removals_file[] = L"remove.txt"; +fs::path pending_root(const fs::path& mods) { return mods / from_utf8(pending_folder); } +std::vector read_removals(const fs::path& mods) { + std::vector names; + std::ifstream in(pending_root(mods) / removals_file); + for (std::string line; std::getline(in, line);) { + while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) line.pop_back(); + if (mods::valid_mod_name(line) && std::find(names.begin(), names.end(), line) == names.end()) names.push_back(line); + } + return names; +} +// Replaces remove.txt (or removes it when there is nothing in it). +void write_removals(const fs::path& mods, const std::vector& names) { + const auto file = pending_root(mods) / removals_file; + std::error_code error; + if (names.empty()) { + fs::remove(file, error); + return; + } + fs::create_directories(file.parent_path()); + const auto temporary = fs::path(file).concat(L".tmp"); + { + std::ofstream out(temporary, std::ios::binary | std::ios::trunc); + for (const auto& name : names) out << name << '\n'; + if (!out) fail("Could not note the mod to uninstall (is the disk full?)."); + } + if (!MoveFileExW(temporary.c_str(), file.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + fail("Could not note the mod to uninstall."); +} +} // namespace + +std::string install_later(const fs::path& mods, const fs::path& source, const Progress& progress, + const std::atomic& cancel, const InstallOptions& options) { + const auto name = install(pending_root(mods), source, true, progress, cancel, options); + auto removals = read_removals(mods); + if (std::erase(removals, name)) write_removals(mods, removals); + return name; +} + +void remove_later(const fs::path& mods, const std::string& name) { + if (!mods::valid_mod_name(name)) fail("\"" + name + "\" is not a mod folder."); + std::error_code error; + fs::remove_all(pending_root(mods) / from_utf8(name), error); + auto removals = read_removals(mods); + if (std::find(removals.begin(), removals.end(), name) == removals.end()) removals.push_back(name); + write_removals(mods, removals); +} + +Pending pending(const fs::path& mods) noexcept { + Pending result; + try { + std::error_code error; + for (fs::directory_iterator it(pending_root(mods), error), end; !error && it != end; it.increment(error)) { + const auto name = utf8(it->path().filename().wstring()); + // (install()'s own staging folder is a dot folder too.) + if (it->is_directory(error) && mods::valid_mod_name(name) && !name.ends_with(".replaced")) result.updates.push_back(name); + } + result.removals = read_removals(mods); + } catch (...) {} + return result; +} + +std::vector apply_pending(const fs::path& mods) noexcept { + std::vector lines; + try { + const auto waiting = pending(mods); + if (waiting.updates.empty() && waiting.removals.empty()) return lines; + const auto root = pending_root(mods); + for (const auto& name : waiting.updates) { + const auto target = mods / from_utf8(name), copy = root / from_utf8(name); + const auto aside = root / (from_utf8(name).wstring() + L".replaced"); + std::error_code error; + fs::remove_all(aside, error); + if (fs::exists(target, error) && !MoveFileExW(target.c_str(), aside.c_str(), MOVEFILE_WRITE_THROUGH)) { + lines.push_back("The update of " + name + " waits: its folder is in use (Windows error " + std::to_string(GetLastError()) + ")."); + continue; + } + if (!MoveFileExW(copy.c_str(), target.c_str(), MOVEFILE_WRITE_THROUGH)) { + const auto code = GetLastError(); + MoveFileExW(aside.c_str(), target.c_str(), MOVEFILE_WRITE_THROUGH); + lines.push_back("The update of " + name + " waits: it could not be moved in (Windows error " + std::to_string(code) + ")."); + continue; + } + fs::remove_all(aside, error); + lines.push_back("Updated " + name + " (installed in game, waiting for this start)."); + } + std::vector kept; + for (const auto& name : waiting.removals) { + std::error_code error; + if (!fs::is_directory(mods / from_utf8(name), error)) continue; + try { + recycle(mods / from_utf8(name)); + lines.push_back("Uninstalled " + name + " (asked for in game)."); + } catch (const std::exception& failure) { + kept.push_back(name); + lines.push_back("Uninstalling " + name + " waits: " + failure.what()); + } + } + try { write_removals(mods, kept); } catch (const std::exception& failure) { lines.push_back(failure.what()); } + std::error_code error; + if (fs::is_empty(root, error)) fs::remove(root, error); + } catch (const std::exception& failure) { + lines.push_back(std::string("Mod changes waiting from the game could not be made: ") + failure.what()); + } catch (...) {} + return lines; +} + } // namespace dingosdk::launcher_mods diff --git a/Launcher/mod_manager.h b/Launcher/mod_manager.h index b1e22d8d..e27b8022 100644 --- a/Launcher/mod_manager.h +++ b/Launcher/mod_manager.h @@ -54,6 +54,28 @@ std::string install(const std::filesystem::path& mods_root, const std::filesyste // Moves Mods/ to the Recycle Bin. Throws a user-facing message. void remove(const std::filesystem::path& mods_root, const std::string& name); +// Changes the game cannot make while it runs, because it holds the mod's files open (an update or +// uninstall of a mod it has loaded), wait in Mods/.reskate-pending until the next start: the new +// copy of a mod as a folder there, and the mods to uninstall in its remove.txt, a folder name a +// line. apply_pending makes them; the launcher calls it before each launch's merge. +inline constexpr char pending_folder[] = ".reskate-pending"; +// Installs `source` as install() does, into the pending folder, and returns the mod's name. A +// removal waiting for the same mod is dropped: the update is what was asked for last. +std::string install_later(const std::filesystem::path& mods_root, const std::filesystem::path& source, + const Progress& progress, const std::atomic& cancel, const InstallOptions& options = {}); +// Marks Mods/ to be uninstalled at the next start, dropping an update waiting for it. +// Throws a user-facing message. +void remove_later(const std::filesystem::path& mods_root, const std::string& name); +struct Pending { + std::vector updates, removals; +}; +// What waits. A missing or unreadable folder is nothing; never throws. +Pending pending(const std::filesystem::path& mods_root) noexcept; +// Moves each waiting copy into place (the old copy deleted for good, as an update's is) and moves +// each mod to remove to the Recycle Bin. What could not be done stays for the next try. Never +// throws; returns what happened, a line each, for the log. +std::vector apply_pending(const std::filesystem::path& mods_root) noexcept; + // What the mods give the game's data, as one line: each mod that ships a layout, in load order, with // its folder name and its files' fingerprint (Engine/Vfs/mod_list.h mod_fingerprint), then the // variants in play. A mod that only holds a plugin, or loose files the game does not draw with, is not diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 11b935ab..b22f712e 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -712,14 +712,16 @@ void update_model(std::uintptr_t client, TickState& frame) { const bool map_prompt = dingosdk::map_download::asking(); // The vote for the next map comes up the same way (overlay.h: map_vote_asking), when no map is being asked about. const bool map_vote = !map_prompt && dingosdk::overlay::map_vote_asking(); - // And a message to say OK to (overlay.h: message_asking), over either. + // And a message to say OK to (overlay.h: message_asking), over either; and a question with two + // answers (overlay.h: ask_question), under the message. const bool message_up = dingosdk::overlay::message_asking(); + const bool question_up = !message_up && dingosdk::overlay::question_asking(); // The hold on the game's input outlasts the card's question until everything pressed is let // go (or a moment has passed): the button that answered must not also be a press in the // game, or in the pause menu behind the card. static bool prompt_held{}; static ULONGLONG prompt_release_by{}; - if (map_prompt || map_vote || message_up) { + if (map_prompt || map_vote || message_up || question_up) { prompt_held = true; prompt_release_by = 0; } else if (prompt_held) { @@ -730,7 +732,7 @@ void update_model(std::uintptr_t client, TickState& frame) { std::ranges::any_of(held.keys, [](std::uint64_t word) { return word != 0; }); if (!pressed || GetTickCount64() >= prompt_release_by) prompt_held = false; } - DingoSDKOverlaySetPromptInputCapture(map_prompt || map_vote || message_up || prompt_held); + DingoSDKOverlaySetPromptInputCapture(map_prompt || map_vote || message_up || question_up || prompt_held); const unsigned poll_answers = dingosdk::multiplayer::server_poll_answers(); const bool placement_flight = dingosdk::pad_flight::requested(); bool freecam_controller = dingosdk::local_freecam_controller() || placement_flight; @@ -761,10 +763,10 @@ void update_model(std::uintptr_t client, TickState& frame) { dingosdk::plugins::plugin_input_capture()); dingosdk::ControllerInput controller; if (freecam_controller_combo || freecam_combo || tp_to_freecam_combo || noclip_combo || forward_velocity_combo || up_velocity_combo || offboard_up_velocity_combo || - vote_yes_combo || vote_no_combo || poll_answers || map_prompt || map_vote || message_up || + vote_yes_combo || vote_no_combo || poll_answers || map_prompt || map_vote || message_up || question_up || std::ranges::any_of(action_combos, [](auto combo) { return combo != 0; })) // (The card that asks has the pointer, as a menu does: it is still read for.) - DingoSDKOverlayReadControllerInput(&controller, map_prompt || map_vote || message_up); + DingoSDKOverlayReadControllerInput(&controller, map_prompt || map_vote || message_up || question_up); for (std::size_t i = 0; i < action_combos.size(); ++i) if (r.action_bind_latches[i].update(action_combos[i], controller, r.observer_failed || placement_flight)) { std::array result{}; @@ -790,7 +792,7 @@ void update_model(std::uintptr_t client, TickState& frame) { static bool prompt_before{}; static std::uint32_t buttons_before{}; // The keyboard's arrows, Enter and Esc do the same, above the pad's bits. - const bool prompt = map_prompt || map_vote || message_up; + const bool prompt = map_prompt || map_vote || message_up || question_up; const std::uint32_t buttons = !prompt ? 0 : (controller.available ? controller.buttons : 0) | (DingoSDKOverlayReadPromptKeys() << 20); // The message is over the others and is answered first: A or B, Enter or Esc, is its OK. @@ -804,6 +806,16 @@ void update_model(std::uintptr_t client, TickState& frame) { if (pressed & (0x1000 | 1u << 22)) dingosdk::map_download::answer(dingosdk::map_download::picked()); else if (pressed & (0x2000 | 1u << 23)) dingosdk::map_download::answer(false); } + // The question: left and right pick, A takes the pick, B is its second answer. + static bool question_before{}; + if (question_up && question_before) { + const auto pressed = buttons & ~buttons_before; + if (pressed & (0x0004 | 1u << 20)) dingosdk::overlay::question_pick(true); + if (pressed & (0x0008 | 1u << 21)) dingosdk::overlay::question_pick(false); + if (pressed & (0x1000 | 1u << 22)) dingosdk::overlay::question_answer(dingosdk::overlay::question_picked()); + else if (pressed & (0x2000 | 1u << 23)) dingosdk::overlay::question_answer(false); + } + question_before = question_up; // The vote's popup: left and right move the pick, A votes for it, B closes it. static bool vote_before{}; if (!message_up && map_vote && vote_before) { diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 3e4e99d2..45eb0da2 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -32,6 +32,11 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_thunderstore_tests PRIVATE dingosdk_json dingosdk_miniz) target_include_directories(dingosdk_thunderstore_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME launcher_thunderstore COMMAND dingosdk_thunderstore_tests) + add_executable(dingosdk_mod_store_view_tests Extension/Assets/Test/mod_store_view_tests.cpp Extension/Assets/mod_store_view.cpp + Launcher/thunderstore.cpp) + target_link_libraries(dingosdk_mod_store_view_tests PRIVATE dingosdk_json dingosdk_miniz) + target_include_directories(dingosdk_mod_store_view_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME mod_store_view COMMAND dingosdk_mod_store_view_tests) add_executable(dingosdk_mod_profiles_tests Launcher/Test/mod_profiles_tests.cpp Launcher/mod_profiles.cpp) target_link_libraries(dingosdk_mod_profiles_tests PRIVATE dingosdk_mod_list dingosdk_json dingosdk_miniz) target_include_directories(dingosdk_mod_profiles_tests PRIVATE "${PROJECT_SOURCE_DIR}") diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index e75b228b..f4dddf6b 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -128,6 +128,9 @@ add_library(dingosdk_runtime SHARED Extension/Assets/map_icons.cpp Engine/Vfs/thunderstore_package.cpp Launcher/mod_manager.cpp + Launcher/thunderstore.cpp + Extension/Assets/mod_store.cpp + Extension/Assets/mod_store_view.cpp Extension/Assets/loose_files.cpp Extension/Scripting/lua_startup.cpp Extension/Scripting/lua_native_block.cpp diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 36ab9f5a..27a1fb60 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -32,6 +32,7 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/game_images.cpp Extension/UI/Overlay/xray_overlay.cpp Extension/UI/Overlay/plugin_images.cpp + Extension/UI/Overlay/store_icons.cpp Extension/UI/Overlay/menu_map_pictures.cpp Extension/UI/Overlay/skate_menu_skater.cpp Extension/UI/Overlay/skate_menu_settings.cpp