Files
ReSkate/Launcher/gui_mods.cpp
T
AyeZeeBB db99ed4d72 Mods: two mods that conflict load only the higher one, found before the merge
Where two mods change one of the game's assets in ways that cannot both be
in the game (a resource or chunk changed differently, an EBX whose changes
cannot be combined, different assets added under one name), the one lower
in the load order is left out whole, so neither loads half its changes.
The merge used to keep the higher copy of each asset and lose the rest of
the lower mod's change silently, which broke Skaterize beside deckorate.

A check before the merge (find_conflicts) reads only the bundles two or
more mods ship and applies the merge's rule: about 2 s for 296 mods, on
several threads, each mod's copy of a bundle by itself. When it finds
conflicts nothing is built, the catalog leaves the losing mods out and
merges once. The merge keeps its own check behind it, tracking per asset
the last change a mod made itself, so a change carried through other mods'
copies is still caught. A conflict is remembered in .reskate-excluded.json
with the mod it lost to, and stays out while both are unchanged and the
winner still loads above it.

The launcher says "Checking the mods" and "Checking the mods for
conflicts" during those steps. Its mod problems screen says MOD CONFLICT
when that is all there is, and the Mod Manager shows a CONFLICT badge, an
N CONFLICTS banner, a Conflicts filter and, on the mod, which mod it lost
to and how to load it instead.
2026-10-18 01:35:31 -05:00

1339 lines
68 KiB
C++

#include "gui_internal.h"
#include "mod_manager.h"
#include "official_mods.h"
#include "Engine/Core/Log/logging.h"
#include <cstring>
#include <shobjidl.h>
#include <wrl/client.h>
#include <format>
using Microsoft::WRL::ComPtr;
// The Mods page: a nav rail on the left, and a list where every mod is a
// full-width row that opens where it sits. MY MODS is install order and
// enabled state; GET MODS is Thunderstore (gui_mods_browse.cpp).
namespace dingosdk::launcher_gui::detail {
namespace {
void save(ModsPanel& panel) {
try {
mods::save_mod_order(panel.root, panel.list.entries);
panel.list.issue.clear();
panel.message = "Saved. Changes apply the next time Skate starts.";
panel.message_error = false;
} catch (const std::exception& failure) {
panel.message = failure.what();
panel.message_error = true;
}
}
// Picks up a finished install: rescan and select the new mod, or ask to replace.
void collect_install(ModsPanel& panel, const launcher_app::Session& session) {
std::lock_guard lock(panel.mutex);
if (!panel.finished) return;
panel.finished = false;
panel.progress = -1;
if (!panel.finished_conflict.empty()) {
panel.conflict_name = panel.finished_conflict;
panel.conflict_source = panel.finished_source;
panel.message.clear();
return;
}
scan(panel, session);
if (!panel.finished_error.empty()) {
panel.message = panel.finished_error;
panel.message_error = true;
return;
}
for (std::size_t i = 0; i < panel.list.entries.size(); ++i)
if (panel.list.entries[i].mod.name == panel.finished_name) panel.selected = static_cast<int>(i);
panel.message_error = false;
if (!panel.finished_note.empty()) {
panel.message = panel.finished_note; // Thunderstore installs say what they did and log it themselves
panel.finished_note.clear();
return;
}
panel.message = "Installed " + panel.finished_name + ". It loads the next time Skate starts.";
logging::write(logging::Level::info, logging::Channel::launcher, "Mod installed: " + panel.finished_name);
}
// The picker for a mod to install: a .zip, or a folder.
fs::path pick(HWND owner, bool folder) {
return folder ? pick_path(owner, true, L"Choose a mod folder to install", nullptr, nullptr)
: pick_path(owner, false, L"Choose a mod .zip to install", L"Mod archive (*.zip)", L"*.zip");
}
// Everything in the mod's folder, as Explorer would count it. The game's merged
// patch hard links a mod's archives rather than copying them, so this is also
// what removing the mod gives back.
std::uint64_t folder_size(const fs::path& directory) {
std::uint64_t total{};
std::error_code error;
for (fs::recursive_directory_iterator it(directory, fs::directory_options::skip_permission_denied, error), end;
it != end && !error; it.increment(error)) {
std::error_code ignored;
if (it->is_regular_file(ignored) && !ignored) {
const auto size = it->file_size(ignored);
if (!ignored) total += size;
}
}
return total;
}
std::string summary(const ModsPanel& panel, const mods::Mod& mod) {
std::vector<std::string> parts;
if (!mod.version.empty()) parts.push_back("v" + mod.version);
if (const auto size = panel.sizes.find(mod.name); size != panel.sizes.end() && size->second)
parts.push_back(size_text(size->second));
if (!mod.author.empty()) parts.push_back("by " + mod.author);
if (!mod.levels.empty()) parts.push_back(mod.levels.size() == 1 ? "1 map" : std::to_string(mod.levels.size()) + " maps");
else if (mod.provides_layout) parts.push_back("game data");
if (!mod.park_maps.empty()) parts.push_back(mod.park_maps.size() == 1 ? "1 park" : std::to_string(mod.park_maps.size()) + " parks");
if (!mod.provides_layout && !mod.provides_levels && mod.park_maps.empty()) parts.push_back("nothing to load");
std::string text;
for (const auto& part : parts) text += (text.empty() ? "" : " / ") + part;
return text;
}
// ---------------------------------------------------------------- nav rail
// The rail: the two pages, then what you can do to the Mods folder. Returns
// true when BACK was pressed.
// The way out to Thunderstore's website, in Thunderstore's colours (its green on its dark blue),
// taller than the plain buttons beside it.
bool thunderstore_button(const Fonts& fonts, const char* label, float height) {
const ImVec2 position = ImGui::GetCursorScreenPos(), size(ImGui::GetContentRegionAvail().x, height);
const bool pressed = ImGui::InvisibleButton(label, size);
const bool hovered = ImGui::IsItemHovered();
if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
auto* draw = ImGui::GetWindowDrawList();
const ImVec2 end(position.x + size.x, position.y + size.y);
draw->AddRectFilled(position, end, hovered ? rgba(28, 44, 74) : rgba(19, 30, 52), S(4));
draw->AddRect(position, end, rgba(35, 255, 176, hovered ? 1.f : 0.6f), S(4), 0, S(1.5f));
// [ link Thunderstore mark ]: the link sign first, so it reads as a way out to a website,
// the name, and Thunderstore's mark at the far end.
const ImU32 green = rgba(35, 255, 176);
const float pad = S(14), mark = g_icon_thunderstore.id ? height - S(20) : 0;
{
// A box with its top right corner open and an arrow leaving through it.
const float box = S(13), stroke = S(1.8f);
const ImVec2 at(position.x + pad, position.y + (height - box) * 0.5f);
const ImVec2 outline[]{ImVec2(at.x + box * 0.45f, at.y + box * 0.15f), ImVec2(at.x, at.y + box * 0.15f),
ImVec2(at.x, at.y + box), ImVec2(at.x + box * 0.85f, at.y + box),
ImVec2(at.x + box * 0.85f, at.y + box * 0.55f)};
draw->AddPolyline(outline, 5, green, 0, stroke);
draw->AddLine(ImVec2(at.x + box * 0.4f, at.y + box * 0.6f), ImVec2(at.x + box, at.y), green, stroke);
const ImVec2 head[]{ImVec2(at.x + box * 0.6f, at.y), ImVec2(at.x + box, at.y), ImVec2(at.x + box, at.y + box * 0.4f)};
draw->AddPolyline(head, 3, green, 0, stroke);
}
const char* shown = std::strstr(label, "##"); // what follows only names the button
if (!shown) shown = label + std::strlen(label);
const auto text = fonts.body->CalcTextSizeA(fonts.body->FontSize, FLT_MAX, 0, label, shown);
const float text_x = position.x + pad + S(13) + S(10);
const ImVec4 clip(position.x, position.y, end.x - pad - mark - (mark ? S(8) : 0), end.y);
draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, position.y + (height - text.y) * 0.5f), green, label, shown, 0,
&clip);
if (mark) {
const ImVec2 at(end.x - pad - mark, position.y + (height - mark) * 0.5f);
draw->AddImage(g_icon_thunderstore.id, at, ImVec2(at.x + mark, at.y + mark));
}
return pressed;
}
bool rail(const Fonts& fonts, ModsPanel& panel, HWND window, float width,
const std::vector<const thunderstore::Package*>& pending, bool installing) {
const auto installed = panel.list.entries.size();
const auto& store = panel.store;
bool leave = false;
ImGui::BeginDisabled(installing);
if (nav_tile(fonts, width, "MY MODS", panel.tab == 0,
pending.empty() ? std::to_string(installed) : std::to_string(pending.size()), !pending.empty(),
pending.empty() ? std::string()
: pending.size() == 1 ? std::string("1 update on Thunderstore")
: std::format("{} updates on Thunderstore", pending.size())))
panel.tab = 0;
// A controller starts on the open tab's tile.
if (panel.tab == 0) default_focus();
if (nav_tile(fonts, width, "GET MODS", panel.tab == 1,
store.loaded ? std::to_string(store.packages.size()) : std::string("..."), false,
store.loaded ? std::string() : std::string("Loading the Thunderstore listing")))
panel.tab = 1;
if (panel.tab == 1) default_focus();
if (nav_tile(fonts, width, "PROFILES", panel.tab == 2,
panel.profiles_loaded ? std::to_string(panel.profiles.list.size()) : std::string(), false, std::string()))
panel.tab = 2;
if (panel.tab == 2) default_focus();
{
const auto official = official_mods::snapshot();
if (nav_tile(fonts, width, "OFFICIAL", panel.tab == 3, std::to_string(official.mods.size()), false,
official.syncing ? std::string("Getting the official mods") : std::string()))
panel.tab = 3;
if (panel.tab == 3) default_focus();
}
ImGui::Dummy(ImVec2(0, S(8)));
if (!pending.empty()) {
push_primary_button();
if (ImGui::Button(std::format("UPDATE ALL ({})", pending.size()).c_str(), ImVec2(-1, S(34)))) {
std::vector<thunderstore::Package> packages;
for (const auto* package : pending) packages.push_back(*package);
start_store_install(panel, std::move(packages));
}
pop_primary_button();
ImGui::Dummy(ImVec2(0, S(4)));
}
if (ImGui::Button("Install .zip", ImVec2(-1, S(32))))
if (const auto path = pick(window, false); !path.empty()) start_install(panel, path, false);
if (ImGui::Button("Install folder", ImVec2(-1, S(32))))
if (const auto path = pick(window, true); !path.empty()) start_install(panel, path, false);
if (ImGui::Button("Open Mods folder", ImVec2(-1, S(32)))) open_path(panel.root);
ImGui::Dummy(ImVec2(0, S(4)));
if (thunderstore_button(fonts, "Thunderstore##visit", S(48)))
open_url(utf8(thunderstore::community_page(thunderstore::community())));
ImGui::EndDisabled();
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - S(38));
push_primary_button();
if (ImGui::Button("\xe2\x86\x90 BACK", ImVec2(-1, S(34)))) leave = true;
pop_primary_button();
return leave;
}
// ---------------------------------------------------------------- MY MODS
constexpr std::array<const char*, 8> filter_names{"All mods", "Enabled", "Disabled", "Maps", "Game data", "Updates",
"Problems", "Conflicts"};
constexpr std::array<const char*, 3> order_names{"Load order", "Name", "Largest first"};
// Something the page was asked to do to one mod or to all the ticked ones.
// Carried out once the list is drawn: most of it reorders or shrinks the list.
struct Request {
enum class Kind { none, enable, disable, top, bottom, up, down, update, uninstall };
Kind kind{Kind::none};
std::vector<std::string> mods; // folder names
};
std::string folded(std::string_view text) {
std::string result(text);
for (auto& letter : result) letter = static_cast<char>(std::tolower(static_cast<unsigned char>(letter)));
return result;
}
bool update_waiting(const ModsPanel& panel, const thunderstore::Installed& installed, const std::string& name) {
const auto* package = package_for(panel.store, name);
return package && thunderstore::update_available(*package, installed);
}
// Left out because it conflicts with a mod higher in the load order (mod_catalog.cpp): that
// mod's folder name, else empty.
std::string conflict_winner(const ModsPanel& panel, const std::string& name) {
constexpr std::string_view prefix = "conflicts with ";
const auto found = panel.list.excluded.find(name);
if (found == panel.list.excluded.end() || found->second.empty() || !found->second.front().starts_with(prefix)) return {};
const auto& problem = found->second.front();
return problem.substr(prefix.size(), problem.find(':') - prefix.size());
}
// A mod's title, by its folder name.
std::string title_of(const ModsPanel& panel, const std::string& name) {
for (const auto& entry : panel.list.entries)
if (entry.mod.name == name) return entry.mod.title.empty() ? name : entry.mod.title;
return name;
}
// The rows the list shows, as places in the load order: what the search and the
// filter leave, in the order asked for.
std::vector<int> visible_mods(const ModsPanel& panel, const thunderstore::Installed& installed) {
const auto& entries = panel.list.entries;
const auto query = folded(panel.search.data());
std::vector<int> view;
for (int index = 0; index < static_cast<int>(entries.size()); ++index) {
const auto& entry = entries[static_cast<std::size_t>(index)];
const auto& mod = entry.mod;
bool keep = true;
switch (panel.filter) {
case 1: keep = entry.enabled; break;
case 2: keep = !entry.enabled; break;
case 3: keep = !mod.levels.empty(); break;
case 4: keep = mod.levels.empty() && mod.provides_layout; break;
case 5: keep = update_waiting(panel, installed, mod.name); break;
case 6: keep = !mod.outdated.empty() || (panel.list.excluded.contains(mod.name) && conflict_winner(panel, mod.name).empty()); break;
case 7: keep = mod.outdated.empty() && !conflict_winner(panel, mod.name).empty(); break;
default: break;
}
if (keep && !query.empty())
keep = folded(mod.title).find(query) != std::string::npos ||
folded(mod.name).find(query) != std::string::npos ||
folded(mod.author).find(query) != std::string::npos;
if (keep) view.push_back(index);
}
const auto size = [&](int index) {
const auto found = panel.sizes.find(entries[static_cast<std::size_t>(index)].mod.name);
return found == panel.sizes.end() ? std::uint64_t{} : found->second;
};
if (panel.order == 1)
std::stable_sort(view.begin(), view.end(), [&](int a, int b) {
return folded(entries[static_cast<std::size_t>(a)].mod.title) <
folded(entries[static_cast<std::size_t>(b)].mod.title);
});
else if (panel.order == 2)
std::stable_sort(view.begin(), view.end(), [&](int a, int b) { return size(a) > size(b); });
// Mods with an update waiting come first, whatever the order: they are what the player has
// something to do about. Among themselves, and the rest among themselves, the order stands.
std::stable_partition(view.begin(), view.end(), [&](int index) {
return update_waiting(panel, installed, entries[static_cast<std::size_t>(index)].mod.name);
});
return view;
}
// Ticks or unticks the row at `position`; with `range`, every row from the last
// one ticked to it, the way Shift-click works in a file list.
void mark(ModsPanel& panel, const std::vector<int>& view, int position, bool on, bool range) {
const auto name = [&](int at) -> const std::string& {
return panel.list.entries[static_cast<std::size_t>(view[static_cast<std::size_t>(at)])].mod.name;
};
int from = position;
if (range)
for (int at = 0; at < static_cast<int>(view.size()); ++at)
if (name(at) == panel.anchor) from = at;
for (int at = std::min(from, position); at <= std::max(from, position); ++at) {
if (on) panel.marked.insert(name(at));
else panel.marked.erase(name(at));
}
panel.anchor = name(position);
}
void carry_out(ModsPanel& panel, const thunderstore::Installed& installed, const Request& request) {
auto& entries = panel.list.entries;
const auto asked = [&](const mods::ModEntry& entry) {
return std::find(request.mods.begin(), request.mods.end(), entry.mod.name) != request.mods.end();
};
const auto count = request.mods.size();
const auto saved = [&](const char* one, const char* many) {
save(panel);
if (!panel.message_error)
panel.message = (count == 1 ? std::string(one) : std::format("{} {}", count, many)) +
" Changes apply the next time Skate starts.";
};
// The open overview follows its mod through a reorder.
const std::string open = panel.selected >= 0 && panel.selected < static_cast<int>(entries.size())
? entries[static_cast<std::size_t>(panel.selected)].mod.name : std::string();
switch (request.kind) {
case Request::Kind::enable:
case Request::Kind::disable: {
const bool on = request.kind == Request::Kind::enable;
for (auto& entry : entries)
if (asked(entry)) entry.enabled = on;
saved(on ? "Mod enabled." : "Mod disabled.", on ? "mods enabled." : "mods disabled.");
break;
}
case Request::Kind::top:
std::stable_partition(entries.begin(), entries.end(), asked);
saved("Moved to the top.", "mods moved to the top.");
break;
case Request::Kind::bottom:
std::stable_partition(entries.begin(), entries.end(),
[&](const mods::ModEntry& entry) { return !asked(entry); });
saved("Moved to the bottom.", "mods moved to the bottom.");
break;
case Request::Kind::up:
case Request::Kind::down: {
const auto found = std::find_if(entries.begin(), entries.end(), asked);
if (found == entries.end()) break;
const bool up = request.kind == Request::Kind::up;
if (up ? found == entries.begin() : found + 1 == entries.end()) break;
std::iter_swap(found, up ? found - 1 : found + 1);
save(panel);
break;
}
case Request::Kind::update: {
std::vector<thunderstore::Package> packages;
for (const auto& name : request.mods)
if (update_waiting(panel, installed, name)) packages.push_back(*package_for(panel.store, name));
start_store_install(panel, std::move(packages));
break;
}
case Request::Kind::uninstall:
panel.confirm_remove = request.mods;
break;
case Request::Kind::none:
break;
}
if (!open.empty())
for (std::size_t i = 0; i < entries.size(); ++i)
if (entries[i].mod.name == open) panel.selected = static_cast<int>(i);
}
// One installed mod. The tick on the left picks it for the bar above the list,
// the switch on the right is whether it loads, and a click anywhere else opens
// everything the mod says about itself.
void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed,
const std::vector<int>& view, int position, float tall, bool reorder, Request& request) {
const int index = view[static_cast<std::size_t>(position)];
auto& entry = panel.list.entries[static_cast<std::size_t>(index)];
const auto& mod = entry.mod;
ImGui::PushID(mod.name.c_str());
auto* draw = ImGui::GetWindowDrawList();
const ImVec2 start = ImGui::GetCursorScreenPos();
const float width = ImGui::GetContentRegionAvail().x;
const bool ticked = panel.marked.contains(mod.name);
begin_row();
const bool pressed = list_row("##row", width, tall, ticked);
bool menu = ImGui::IsItemClicked(ImGuiMouseButton_Right);
row_buttons();
if (pressed) {
// Ctrl and Shift pick, as they do in a file list; a plain click opens the mod.
if (ImGui::GetIO().KeyShift) mark(panel, view, position, true, true);
else if (ImGui::GetIO().KeyCtrl) mark(panel, view, position, !ticked, false);
else {
panel.selected = index;
panel.overview = false;
}
}
const float right = start.x + width;
const bool left_out = panel.list.excluded.contains(mod.name);
const bool conflicted = left_out && !conflict_winner(panel, mod.name).empty();
const auto* package = package_for(panel.store, mod.name);
const bool update = package && thunderstore::update_available(*package, installed);
ImGui::SetCursorScreenPos(ImVec2(start.x + S(14), start.y + (tall - ImGui::GetFrameHeight()) * 0.5f));
bool tick = ticked;
if (ImGui::Checkbox("##pick", &tick)) mark(panel, view, position, tick, ImGui::GetIO().KeyShift);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Tick mods to change several at once.\nShift-click ticks every mod in between.");
// Right to left: the menu, whether it loads, its place in the load order.
const float more = S(30);
const float more_x = right - S(12) - more;
ImGui::SetCursorScreenPos(ImVec2(more_x, start.y + (tall - more) * 0.5f));
if (more_button("##more", more)) menu = true;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("More");
const float switch_x = more_x - S(12) - S(42);
ImGui::SetCursorScreenPos(ImVec2(switch_x, start.y + (tall - S(22)) * 0.5f));
bool enabled = entry.enabled;
if (toggle("##enabled", &enabled))
request = {enabled ? Request::Kind::enable : Request::Kind::disable, {mod.name}};
if (ImGui::IsItemHovered())
ImGui::SetTooltip(entry.enabled ? "Enabled: loads when Skate starts" : "Disabled: stays installed, does not load");
const float arrow = ImGui::GetFrameHeight();
const float arrows_x = switch_x - S(18) - arrow * 2 - S(6);
const char* fixed = "To move mods, show all of them in load order, with no update waiting (those are listed first)";
ImGui::SetCursorScreenPos(ImVec2(arrows_x, start.y + (tall - arrow) * 0.5f));
ImGui::BeginDisabled(!reorder || index == 0);
if (ImGui::ArrowButton("##up", ImGuiDir_Up)) request = {Request::Kind::up, {mod.name}};
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip("%s", reorder ? "Load earlier: where two mods change the same thing, the higher one wins" : fixed);
ImGui::SameLine(0, S(6));
ImGui::BeginDisabled(!reorder || index + 1 == static_cast<int>(panel.list.entries.size()));
if (ImGui::ArrowButton("##down", ImGuiDir_Down)) request = {Request::Kind::down, {mod.name}};
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", reorder ? "Load later" : fixed);
float next = arrows_x - S(14);
if (update) {
const float button = S(96);
next -= button;
ImGui::SetCursorScreenPos(ImVec2(next, start.y + (tall - S(30)) * 0.5f));
push_primary_button();
if (ImGui::Button("UPDATE", ImVec2(button, S(30)))) request = {Request::Kind::update, {mod.name}};
pop_primary_button();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Thunderstore has v%s", package->latest().number.c_str());
next -= S(14);
}
const float pill_y = start.y + (tall - fonts.caption->FontSize - S(8)) * 0.5f;
const auto pill = [&](const char* label, ImU32 fill) {
next -= badge_width(fonts, label);
badge(draw, fonts, ImVec2(next, pill_y), label, fill, color::ink);
next -= S(12);
};
if (!mod.outdated.empty()) pill("OUTDATED", color::warning);
else if (conflicted) pill("CONFLICT", color::warning);
else if (left_out) pill("NOT LOADED", color::danger);
// The same icon the store shows, so a mod looks like itself on both pages.
mod_icon(panel, package, ImVec2(start.x + S(48), start.y + (tall - S(48)) * 0.5f), S(48));
const float text_x = start.x + S(108);
const ImVec4 clip(text_x, start.y, next, start.y + tall);
// The number is its place in the load order, whatever order the list is shown in.
const auto title = std::to_string(index + 1) + ". " + mod.title;
draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(text_x, start.y + S(13)),
entry.enabled ? color::text : color::muted, title.c_str(), nullptr, 0, &clip);
if (const auto detail = summary(panel, mod); !detail.empty())
draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, start.y + S(37)), color::muted, detail.c_str(),
nullptr, 0, &clip);
end_row();
if (menu) ImGui::OpenPopup("##menu");
if (ImGui::BeginPopup("##menu")) {
// Opened on one of several ticked mods, the menu is about all of them.
const bool group = ticked && panel.marked.size() > 1;
const std::vector<std::string> names = group
? std::vector<std::string>(panel.marked.begin(), panel.marked.end()) : std::vector<std::string>{mod.name};
const auto ask = [&](Request::Kind kind) { request = {kind, names}; };
if (group) {
ImGui::TextDisabled("%s", std::format("{} mods selected", names.size()).c_str());
ImGui::Separator();
if (ImGui::MenuItem("Enable")) ask(Request::Kind::enable);
if (ImGui::MenuItem("Disable")) ask(Request::Kind::disable);
} else {
if (ImGui::MenuItem("Details")) {
panel.selected = index;
panel.overview = false;
}
if (ImGui::MenuItem(entry.enabled ? "Disable" : "Enable"))
ask(entry.enabled ? Request::Kind::disable : Request::Kind::enable);
}
if (std::any_of(names.begin(), names.end(),
[&](const std::string& name) { return update_waiting(panel, installed, name); }) &&
ImGui::MenuItem("Update")) ask(Request::Kind::update);
ImGui::Separator();
if (ImGui::MenuItem("Move to top")) ask(Request::Kind::top);
if (ImGui::MenuItem("Move to bottom")) ask(Request::Kind::bottom);
if (!group) {
ImGui::Separator();
if (ImGui::MenuItem("Open folder")) open_path(mod.directory);
if (package && !package->package_url.empty() && ImGui::MenuItem("Thunderstore page"))
open_url(package->package_url);
}
ImGui::Separator();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger));
if (ImGui::MenuItem("Uninstall")) ask(Request::Kind::uninstall);
ImGui::PopStyleColor();
ImGui::EndPopup();
}
ImGui::PopID();
}
void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed,
float height, bool installing) {
auto& entries = panel.list.entries;
const float top = ImGui::GetCursorPosY();
{
const auto enabled = std::count_if(entries.begin(), entries.end(), [](const auto& entry) { return entry.enabled; });
// A mod left out for a conflict is no fault of its own: counted apart from the problems.
const auto conflicts = std::count_if(entries.begin(), entries.end(), [&](const auto& entry) {
return entry.enabled && entry.mod.outdated.empty() && !conflict_winner(panel, entry.mod.name).empty();
});
const auto problems = std::count_if(entries.begin(), entries.end(), [&](const auto& entry) {
return !entry.mod.outdated.empty() || (entry.enabled && panel.list.excluded.contains(entry.mod.name));
}) - conflicts;
const auto waiting = std::count_if(entries.begin(), entries.end(),
[&](const auto& entry) { return update_waiting(panel, installed, entry.mod.name); });
std::uint64_t bytes{};
for (const auto& [name, size] : panel.sizes) bytes += size;
auto caption = std::format("{} {} / {} on", entries.size(), entries.size() == 1 ? "mod" : "mods", enabled);
if (bytes) caption += " / " + size_text(bytes);
page_banner(fonts, "MY MODS", nullptr,
"Your installed mods. They load top to bottom: where two change the same thing, the higher one wins. "
"Changes apply the next time Skate starts.",
problems ? std::format("{} {}", problems, problems == 1 ? "PROBLEM" : "PROBLEMS")
: conflicts ? std::format("{} {}", conflicts, conflicts == 1 ? "CONFLICT" : "CONFLICTS")
: waiting ? std::format("{} {} WAITING", waiting, waiting == 1 ? "UPDATE" : "UPDATES")
: entries.empty() ? std::string("NONE YET") : std::string("ALL GOOD"),
problems ? color::danger : conflicts ? color::warning : waiting ? color::blue
: entries.empty() ? color::tile_grey : color::good, caption);
}
// ------------------------------------------------ search, filter, order, refresh
const float combo = S(170), refresh = S(110);
ImGui::SetNextItemWidth(std::max(S(140), ImGui::GetContentRegionAvail().x - (combo + S(10)) * 2 - refresh - S(10)));
ImGui::InputTextWithHint("##installed_search", "Search your mods", panel.search.data(), panel.search.size());
ImGui::SameLine();
const auto choice = [&](const char* id, const auto& names, int& value) {
const int last = static_cast<int>(names.size()) - 1;
ImGui::SetNextItemWidth(combo);
if (ImGui::BeginCombo(id, names[static_cast<std::size_t>(std::clamp(value, 0, last))])) {
for (int i = 0; i <= last; ++i)
if (ImGui::Selectable(names[static_cast<std::size_t>(i)], value == i)) value = i;
ImGui::EndCombo();
}
};
choice("##installed_filter", filter_names, panel.filter);
ImGui::SameLine();
choice("##installed_order", order_names, panel.order);
ImGui::SameLine();
if (ImGui::Button("Refresh", ImVec2(refresh, 0))) refresh_mods(launcher, panel);
const auto view = visible_mods(panel, installed);
// Arrows move a mod past its neighbour in the load order, which is only
// what the list shows when nothing is filtered out or sorted another way.
// (Mods moved to the top for their updates are out of that order too.)
const bool in_load_order = [&] {
for (int at = 0; at < static_cast<int>(view.size()); ++at)
if (view[static_cast<std::size_t>(at)] != at) return false;
return true;
}();
const bool reorder = panel.order == 0 && view.size() == entries.size() && in_load_order;
const auto shown = [&](int at) -> const std::string& {
return entries[static_cast<std::size_t>(view[static_cast<std::size_t>(at)])].mod.name;
};
Request request;
// ------------------------------------------------ tick all, and what to do with the ticked
ImGui::Spacing();
ImGui::BeginDisabled(installing);
bool all = !view.empty();
for (int at = 0; at < static_cast<int>(view.size()) && all; ++at) all = panel.marked.contains(shown(at));
ImGui::BeginDisabled(view.empty());
if (ImGui::Checkbox("##all", &all))
for (int at = 0; at < static_cast<int>(view.size()); ++at) {
if (all) panel.marked.insert(shown(at));
else panel.marked.erase(shown(at));
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered())
ImGui::SetTooltip(view.size() == entries.size() ? "Tick every mod" : "Tick every mod shown");
ImGui::SameLine(0, S(10));
ImGui::AlignTextToFramePadding();
const auto total = [&](auto&& counted) {
std::uint64_t bytes{};
for (const auto& [name, size] : panel.sizes)
if (counted(name)) bytes += size;
return bytes;
};
if (panel.marked.empty()) {
ImGui::TextDisabled("%s", view.size() != entries.size()
? std::format("{} of {} shown", view.size(), entries.size()).c_str()
: "Tick mods to change several at once. Shift-click ticks a run of them, Ctrl+A every one.");
} else {
const std::vector<std::string> names(panel.marked.begin(), panel.marked.end());
auto line = std::format("{} selected", names.size());
if (const auto bytes = total([&](const std::string& name) { return panel.marked.contains(name); }))
line += " / " + size_text(bytes);
ImGui::TextUnformatted(line.c_str());
const auto waiting = std::count_if(names.begin(), names.end(),
[&](const std::string& name) { return update_waiting(panel, installed, name); });
const auto update_label = std::format("UPDATE {}", waiting);
struct Action { const char* label; float width; Request::Kind kind; };
const std::array actions{Action{"Enable", S(84), Request::Kind::enable},
Action{"Disable", S(84), Request::Kind::disable}, Action{"Move to top", S(112), Request::Kind::top},
Action{"Move to bottom", S(132), Request::Kind::bottom},
Action{update_label.c_str(), S(104), Request::Kind::update},
Action{"Uninstall", S(96), Request::Kind::uninstall}};
const float gap = S(8), clear = S(70);
float across = clear;
for (const auto& action : actions)
if (action.kind != Request::Kind::update || waiting) across += action.width + gap;
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetContentRegionMax().x - across));
for (const auto& action : actions) {
if (action.kind == Request::Kind::update && !waiting) continue;
const bool primary = action.kind == Request::Kind::update, danger = action.kind == Request::Kind::uninstall;
if (primary) push_primary_button();
if (danger) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger));
if (ImGui::Button(action.label, ImVec2(action.width, 0))) request = {action.kind, names};
if (danger) ImGui::PopStyleColor();
if (primary) pop_primary_button();
ImGui::SameLine(0, gap);
}
if (ImGui::Button("Clear", ImVec2(clear, 0))) panel.marked.clear();
}
ImGui::EndDisabled();
// ------------------------------------------------ what the player should know
ImGui::PushTextWrapPos(0);
if (launcher.game())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning),
"Skate is running: restart it to apply changes.");
if (!panel.list.issue.empty())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger),
"mods.json could not be read (%s), so the game loads no mods. Any change here rewrites it.",
panel.list.issue.c_str());
std::vector<std::string> dropped, conflicted;
std::string winner; // with one conflicted mod: the mod it lost to
for (const auto& entry : entries) {
if (!entry.enabled || !panel.list.excluded.contains(entry.mod.name) || !entry.mod.outdated.empty()) continue;
if (auto other = conflict_winner(panel, entry.mod.name); !other.empty()) {
conflicted.push_back(entry.mod.title);
winner = title_of(panel, other);
} else {
dropped.push_back(entry.mod.title);
}
}
if (!conflicted.empty()) {
std::string names;
for (const auto& title : conflicted) names += (names.empty() ? "" : ", ") + title;
if (conflicted.size() == 1)
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning),
"%s did not load: it conflicts with %s, which is higher in the load order, so only that one loads. "
"Open it to see more.", names.c_str(), winner.c_str());
else
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning),
"%s did not load: each conflicts with a mod higher in the load order, so only that one loads. "
"Open one to see which.", names.c_str());
}
if (!dropped.empty()) {
std::string names;
for (const auto& title : dropped) names += (names.empty() ? "" : ", ") + title;
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger),
dropped.size() == 1 ? "%s did not load: the game could not merge it cleanly, so none of its content is "
"used. Open it to see what went wrong."
: "%s did not load: the game could not merge them cleanly, so none of their content "
"is used. Open one to see what went wrong.", names.c_str());
}
if (!panel.list.missing.empty()) {
std::string missing;
for (const auto& name : panel.list.missing) missing += (missing.empty() ? "" : ", ") + name;
ImGui::TextDisabled("mods.json also lists folders that are not installed: %s", missing.c_str());
}
ImGui::PopTextWrapPos();
ImGui::Spacing();
// ------------------------------------------------ the list, and under it how its order works
const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top));
ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_NavFlattened);
const auto note = [](const char* first, const char* second = nullptr) {
ImGui::Spacing();
ImGui::Indent(S(14));
ImGui::TextDisabled("%s", first);
if (second) ImGui::TextDisabled("%s", second);
ImGui::Unindent(S(14));
};
if (entries.empty())
note(panel.list.present ? "No mods installed yet." : "No Mods folder yet.",
"Use GET MODS to browse Thunderstore, or drop a mod .zip on the window.");
else if (view.empty()) note("No mods match.");
const float row = S(72) + row_gap();
ImGui::BeginDisabled(installing);
virtual_rows(static_cast<int>(view.size()), [&](int) { return row; }, [&](int position, float slot) {
installed_row(fonts, panel, installed, view, position, slot - row_gap(), reorder, request);
});
ImGui::EndDisabled();
keep_focus_in_list();
ImGui::EndChild();
// Ctrl+A and Delete, as in a file list, while nothing is being typed.
if (!installing && !ImGui::GetIO().WantTextInput && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) {
if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A, false))
for (int at = 0; at < static_cast<int>(view.size()); ++at) panel.marked.insert(shown(at));
if (!panel.marked.empty() && ImGui::IsKeyPressed(ImGuiKey_Delete, false))
request = {Request::Kind::uninstall, std::vector<std::string>(panel.marked.begin(), panel.marked.end())};
}
if (request.kind != Request::Kind::none) carry_out(panel, installed, request);
}
// Everything an installed mod says about itself, in a popup over the page.
void mod_overview(const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed, ImVec2 size,
bool installing) {
if (panel.selected < 0 || panel.selected >= static_cast<int>(panel.list.entries.size())) {
panel.selected = -1;
return;
}
auto& entry = panel.list.entries[static_cast<std::size_t>(panel.selected)];
const auto& mod = entry.mod;
const auto* package = package_for(panel.store, mod.name);
const bool update = package && thunderstore::update_available(*package, installed);
if (!panel.overview) {
ImGui::OpenPopup("##mod_overview");
panel.overview = true;
}
const ImVec2 extent(std::min(S(1080), size.x - S(80)), std::min(S(740), size.y - S(60)));
ImGui::SetNextWindowPos(ImVec2((size.x - extent.x) * 0.5f, (size.y - extent.y) * 0.5f));
ImGui::SetNextWindowSize(extent);
if (!ImGui::BeginPopupModal("##mod_overview", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings)) {
panel.overview = false; // dismissed with Escape
panel.selected = -1;
return;
}
const auto close = [&] {
panel.overview = false;
panel.selected = -1;
ImGui::CloseCurrentPopup();
};
// The icon (the package's, when the mod is on Thunderstore), with the name, a line of what
// it is and its description beside it.
const float icon = S(104), header_top = ImGui::GetCursorPosY();
mod_icon(panel, package, ImGui::GetCursorScreenPos(), icon);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + icon + S(20));
ImGui::BeginGroup();
ImGui::PushTextWrapPos(0);
ImGui::PushFont(fonts.heading);
ImGui::TextUnformatted(mod.title.c_str());
ImGui::PopFont();
ImGui::TextDisabled("%s", summary(panel, mod).c_str());
if (!mod.description.empty()) {
ImGui::Dummy(ImVec2(0, S(4)));
ImGui::TextUnformatted(mod.description.c_str());
}
ImGui::PopTextWrapPos();
ImGui::EndGroup();
ImGui::SetCursorPosY(std::max(ImGui::GetCursorPosY(), header_top + icon) + S(14));
if (update) {
ImGui::BeginDisabled(installing);
push_primary_button();
if (ImGui::Button(("UPDATE TO v" + package->latest().number).c_str(), ImVec2(0, S(34)))) {
start_store_install(panel, {*package});
close();
}
pop_primary_button();
ImGui::EndDisabled();
ImGui::SameLine();
}
if (ImGui::Button("Open folder", ImVec2(0, S(34)))) open_path(mod.directory);
if (package && !package->package_url.empty()) {
ImGui::SameLine();
if (ImGui::Button("Thunderstore page", ImVec2(0, S(34)))) open_url(package->package_url);
}
ImGui::SameLine();
ImGui::BeginDisabled(installing);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger));
// The confirmation is its own modal, so this one has to go first.
if (ImGui::Button("Uninstall", ImVec2(0, S(34)))) {
const auto name = mod.name;
close();
panel.confirm_remove = {name};
}
ImGui::PopStyleColor();
ImGui::EndDisabled();
ImGui::Spacing();
ImGui::Dummy(ImVec2(0, S(6)));
const auto columns = overview_columns(extent.y);
ImGui::BeginChild("##mod_overview_body", ImVec2(columns.readme, columns.height), ImGuiChildFlags_NavFlattened);
ImGui::PushTextWrapPos(0);
if (!mod.outdated.empty())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger),
"Outdated: this mod does not load. It was made for another version of Skate (%s). Get an updated "
"version, or rebuild it with the latest ReSkate Studio.", mod.outdated.c_str());
if (const auto other = conflict_winner(panel, mod.name); !other.empty()) {
const auto winner = title_of(panel, other);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning),
"Not loaded: this mod changes the same parts of the game as %s, which is higher in the load order, in a "
"way the two cannot share, so only %s loads. To use this mod instead, move it above %s, or switch one "
"of them off.", winner.c_str(), winner.c_str(), winner.c_str());
if (const auto& problems = panel.list.excluded.at(mod.name); !problems.empty())
ImGui::TextDisabled("%s", problems.front().c_str());
} else if (const auto missing = panel.list.excluded.find(mod.name); missing != panel.list.excluded.end()) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger),
"Not loaded: the game could not merge this mod cleanly, so none of it is used. Reinstall the whole "
"mod folder, or rebuild it with a current ReSkate Studio.");
if (!missing->second.empty()) ImGui::TextDisabled("%s", missing->second.front().c_str());
}
if (!mod.provides_layout && !mod.provides_levels && mod.park_maps.empty())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning),
"This folder has no layout.toc or reskate-levels.json, so the game has nothing to load from it.");
if (!readme_field(fonts, panel, package, &mod)) ImGui::TextDisabled("This mod has no README.");
ImGui::PopTextWrapPos();
ImGui::EndChild();
begin_overview_details("##mod_overview_details", columns);
overview_fact(fonts, "VERSION", mod.version.empty() ? std::string() : "v" + mod.version);
overview_fact(fonts, "AUTHOR", mod.author);
if (package)
overview_fact(fonts, "THUNDERSTORE", package->full_name + (update ? "\nv" + package->latest().number + " available"
: std::string("\nUp to date")));
overview_fact(fonts, "FOLDER", "Mods\\" + mod.name);
if (!mod.tool.empty() || !mod.built.empty())
overview_fact(fonts, "BUILT WITH", mod.tool + (mod.built.empty() ? "" : (mod.tool.empty() ? "" : ", ") + mod.built));
std::string levels;
for (const auto& level : mod.levels) {
const auto slash = level.rfind('/');
levels += (levels.empty() ? "" : "\n") + (slash == std::string::npos ? level : level.substr(slash + 1));
}
overview_fact(fonts, "MAPS", levels);
std::string parks;
for (const auto& map : mod.park_maps) parks += (parks.empty() ? "" : ", ") + map;
overview_fact(fonts, "PARKS", parks);
end_overview_details();
ImGui::SetCursorPosY(extent.y - S(24) - ImGui::GetFrameHeight());
bool enabled = entry.enabled;
if (ImGui::Checkbox("Loads when Skate starts", &enabled)) {
entry.enabled = enabled;
save(panel);
}
ImGui::SameLine(extent.x - S(28) - S(110));
// Escape too, and so a controller's B: the modal has no other way out but CLOSE.
if (ImGui::Button("CLOSE", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) close();
ImGui::EndPopup();
}
// ---------------------------------------------------------------- installing
// What a mod manager shows while it works: everything else dimmed, and one
// panel saying what is being downloaded.
void install_modal(const Fonts& fonts, ModsPanel& panel, ImVec2 size) {
std::string activity;
{
std::lock_guard lock(panel.mutex);
activity = panel.activity;
}
const float fraction = std::clamp(panel.progress.load(), 0.0f, 1.0f);
const auto time = static_cast<float>(ImGui::GetTime());
// Cancelling is not instant: the worker notices on its next chunk or file.
// Say so, or the button looks dead and gets pressed again.
const bool cancelling = panel.cancel.load();
// Its own window over the page: the page's own draw list renders under
// its children, so a panel drawn there would sit beneath the mod list.
ImGui::SetNextWindowPos(ImVec2(0, 0));
ImGui::SetNextWindowSize(size);
ImGui::SetNextWindowFocus();
ImGui::Begin("##installing", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBackground);
auto* draw = ImGui::GetWindowDrawList();
draw->AddRectFilled(ImVec2(0, 0), size, rgba(4, 6, 9, 0.72f));
const ImVec2 extent(S(540), S(206));
const ImVec2 origin((size.x - extent.x) * 0.5f, (size.y - extent.y) * 0.5f);
const ImVec2 end(origin.x + extent.x, origin.y + extent.y);
skate_theme::rough_rect(draw, origin, end, color::blue, 11, g_scale);
draw->AddText(fonts.tile, fonts.tile->FontSize, ImVec2(origin.x + S(26), origin.y + S(18)), color::ink,
cancelling ? "CANCELLING" : "INSTALLING");
const auto line = cancelling ? std::string("Stopping as soon as the current file is done. "
"The Mods folder is left as it was.")
: activity;
if (!line.empty())
draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(origin.x + S(26), origin.y + S(70)),
rgba(0, 0, 0, 0.78f), line.c_str(), nullptr, extent.x - S(52));
skate_theme::striped_bar(draw, ImVec2(origin.x + S(26), origin.y + S(118)),
ImVec2(end.x - S(26), origin.y + S(134)), fraction, time, g_scale);
draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(origin.x + S(26), origin.y + S(142)), rgba(0, 0, 0, 0.78f),
std::format("{:.0f}%", fraction * 100).c_str());
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(rgba(0, 0, 0, 0.38f)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(rgba(0, 0, 0, 0.52f)));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(rgba(0, 0, 0, 0.66f)));
ImGui::SetCursorScreenPos(ImVec2(end.x - S(26) - S(130), end.y - S(24) - S(34)));
ImGui::BeginDisabled(cancelling);
const bool pressed = ImGui::Button(cancelling ? "CANCELLING" : "CANCEL", ImVec2(S(130), S(34)));
ImGui::EndDisabled();
ImGui::PopStyleColor(3);
// Escape too: the button is the only way out of this panel otherwise.
if (!cancelling && (pressed || ImGui::IsKeyPressed(ImGuiKey_Escape, false))) {
panel.cancel = true;
logging::write(logging::Level::info, logging::Channel::launcher,
pressed ? "Mod install cancelled by the Cancel button."
: "Mod install cancelled with Escape.");
}
ImGui::End();
}
} // namespace
fs::path pick_path(HWND owner, bool folder, const wchar_t* title, const wchar_t* filter_name, const wchar_t* filter_pattern) {
ComPtr<IFileOpenDialog> dialog;
if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) return {};
FILEOPENDIALOGOPTIONS options{};
dialog->GetOptions(&options);
dialog->SetOptions(options | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | (folder ? FOS_PICKFOLDERS : FOS_FILEMUSTEXIST));
if (!folder && filter_name && filter_pattern) {
const COMDLG_FILTERSPEC filter[]{{filter_name, filter_pattern}};
dialog->SetFileTypes(1, filter);
}
dialog->SetTitle(title);
if (FAILED(dialog->Show(owner))) return {};
ComPtr<IShellItem> item;
PWSTR path{};
if (FAILED(dialog->GetResult(&item)) || FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) return {};
fs::path result(path);
CoTaskMemFree(path);
return result;
}
void refresh_mods(Launcher& launcher, ModsPanel& panel) {
scan(panel, launcher.session());
refresh_listing(panel, ImGui::GetTime(), true);
panel.message.clear();
}
void scan(ModsPanel& panel, const launcher_app::Session& session) {
panel.root = launcher_mods::mods_root(session.paths.directory);
panel.list = mods::scan_mods(panel.root.parent_path());
panel.sizes.clear();
for (const auto& entry : panel.list.entries) panel.sizes[entry.mod.name] = folder_size(entry.mod.directory);
panel.scanned = true;
if (panel.selected >= static_cast<int>(panel.list.entries.size())) panel.selected = -1;
std::erase_if(panel.marked, [&](const std::string& name) {
return std::none_of(panel.list.entries.begin(), panel.list.entries.end(),
[&](const mods::ModEntry& entry) { return entry.mod.name == name; });
});
}
void start_install(ModsPanel& panel, const fs::path& source, bool replace) {
if (panel.installing) return;
if (panel.worker.joinable()) panel.worker.join();
panel.installing = true;
panel.cancel = false;
panel.progress = 0;
panel.message.clear();
panel.message_error = false;
{
std::lock_guard lock(panel.mutex);
panel.activity = "Installing " + utf8(source.filename().wstring());
}
const auto root = panel.root;
panel.worker = std::thread([&panel, root, source, replace] {
std::string name, error, conflict;
try {
name = launcher_mods::install(root, source, replace,
[&panel](float fraction) { panel.progress = fraction; }, panel.cancel);
} catch (const launcher_mods::AlreadyInstalled& existing) {
conflict = existing.name;
} catch (const std::exception& failure) {
error = failure.what();
}
std::lock_guard lock(panel.mutex);
panel.finished = true;
panel.finished_name = name;
panel.finished_error = error;
panel.finished_conflict = conflict;
panel.finished_source = source;
panel.activity.clear();
panel.installing = false;
});
}
// What PLAY shows instead of launching when the merge left mods out. The game
// would leave them out too, silently, three minutes into a loading screen.
void mods_broken_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, ModsPanel& panel,
const std::vector<ModProblem>& problems) {
const bool one = problems.size() == 1;
// A conflict is two mods changing the same game files: only the one higher in the load order loads.
const bool conflicts = std::all_of(problems.begin(), problems.end(), [](const ModProblem& problem) { return problem.conflict; });
const float rows = static_cast<float>(problems.size()) * S(52);
const auto frame = begin_panel("##mods_broken_panel", size,
ImVec2(S(620), std::min(size.y - S(80), S(300) + rows)));
panel_title(fonts, conflicts ? (one ? "MOD CONFLICT" : "MOD CONFLICTS") : one ? "A MOD COULD NOT BE MERGED" : "MODS COULD NOT BE MERGED");
ImGui::PushTextWrapPos(0);
if (conflicts)
ImGui::TextDisabled(one
? "This mod changes the same parts of the game as a mod above it in your load order, in a way the two "
"cannot share. Only the higher one loads, so this one is left out."
: "These mods change the same parts of the game as mods above them in your load order, in ways they "
"cannot share. Only the higher ones load, so these are left out.");
else
ImGui::TextDisabled(one
? "This mod cannot be combined with the game's files, so none of its content would load. Skate would "
"start without it and never say why."
: "These mods cannot be combined with the game's files, so none of their content would load. Skate would "
"start without them and never say why.");
ImGui::PopTextWrapPos();
ImGui::Spacing();
const float footer = ImGui::GetFrameHeight() + ImGui::GetTextLineHeight() + S(56);
ImGui::BeginChild("##broken_list", ImVec2(0, frame.y - ImGui::GetCursorPosY() - footer),
ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened);
for (const auto& problem : problems) {
ImGui::PushFont(fonts.bold);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(problem.conflict ? color::warning : color::danger));
ImGui::TextUnformatted(problem.title.c_str());
ImGui::PopStyleColor();
ImGui::PopFont();
ImGui::PushTextWrapPos(0);
ImGui::TextDisabled("%s", problem.reason.c_str());
ImGui::PopTextWrapPos();
ImGui::Spacing();
}
ImGui::EndChild();
ImGui::Spacing();
ImGui::PushFont(fonts.caption);
ImGui::TextDisabled("%s", conflicts
? "To use a left-out mod instead, move it above the other in the Mod Manager, or switch one of them off."
: "Reinstall the whole mod folder, or rebuild it with a current ReSkate Studio.");
ImGui::PopFont();
ImGui::SetCursorPosY(frame.y - S(24) - ImGui::GetFrameHeight());
// A rejected mods.json is not a mod, so there is nothing to switch off.
const bool switchable = std::any_of(problems.begin(), problems.end(),
[](const ModProblem& problem) { return !problem.name.empty(); });
bool disable = false;
if (switchable) {
push_primary_button();
disable = ImGui::Button(one ? "SWITCH IT OFF AND PLAY" : "SWITCH THEM OFF AND PLAY", ImVec2(S(260), 0));
pop_primary_button();
ImGui::SameLine(0, S(8));
}
if (disable) {
// mods.json is the game's own switch, so the next launch skips them
// without the merge finding out the hard way again.
const auto root = launcher_mods::mods_root(launcher.session().paths.directory);
auto list = mods::scan_mods(root.parent_path());
for (auto& entry : list.entries)
for (const auto& problem : problems)
if (entry.mod.name == problem.name) entry.enabled = false;
try {
mods::save_mod_order(root, list.entries);
logging::write(logging::Level::info, logging::Channel::launcher,
"Mods switched off after a failed merge; launching without them");
} catch (const std::exception& failure) {
logging::write(logging::Level::warning, logging::Channel::launcher,
std::string("Could not switch the mods off: ") + failure.what());
}
panel.scanned = false;
launcher.play_anyway();
}
if (ImGui::Button("Open Mod Manager", ImVec2(S(160), 0))) {
launcher.dismiss_mod_problems();
ui.mods = true;
panel.tab = 0;
panel.scanned = false;
}
ImGui::SameLine(frame.x - S(28) - S(110));
if (ImGui::Button("Play anyway", ImVec2(S(110), 0))) launcher.play_anyway();
ImGui::End();
}
// What PLAY shows while mods have updates waiting. Playing on the version you
// have is a real choice -- a map you are mid-way through, an update you do not
// trust -- so it stays on offer, and taking it is remembered for the session.
void mods_outdated_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, ModsPanel& panel) {
if (!panel.scanned) scan(panel, launcher.session());
const auto installed = installed_versions(panel.list, true);
const auto pending = updates(panel.store, installed);
// Nothing left to update (an update ran, or the listing changed): just go.
if (pending.empty()) {
ui.mods_update_prompt = false;
launcher.play();
return;
}
const bool one = pending.size() == 1;
const auto frame = begin_panel("##mods_outdated_panel", size,
ImVec2(S(600), std::min(size.y - S(80), S(250) + static_cast<float>(pending.size()) * S(44))));
panel_title(fonts, one ? "A MOD HAS AN UPDATE" : "MODS HAVE UPDATES");
ImGui::PushTextWrapPos(0);
ImGui::TextDisabled(one ? "Thunderstore has a newer version of this mod."
: "Thunderstore has newer versions of these mods.");
ImGui::PopTextWrapPos();
ImGui::Spacing();
const float footer = ImGui::GetFrameHeight() + S(44);
ImGui::BeginChild("##outdated_list", ImVec2(0, frame.y - ImGui::GetCursorPosY() - footer),
ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened);
for (const auto* package : pending) {
const auto found = installed.find(thunderstore::folder_for(package->full_name));
ImGui::PushFont(fonts.bold);
ImGui::TextUnformatted(package->title().c_str());
ImGui::PopFont();
ImGui::SameLine();
if (found != installed.end() && !found->second.empty())
ImGui::TextDisabled("v%s -> v%s", found->second.c_str(), package->latest().number.c_str());
else
ImGui::TextDisabled("-> v%s", package->latest().number.c_str());
ImGui::Spacing();
}
ImGui::EndChild();
ImGui::SetCursorPosY(frame.y - S(24) - ImGui::GetFrameHeight());
push_primary_button();
const bool update = ImGui::Button(one ? "UPDATE IT AND PLAY" : "UPDATE THEM AND PLAY", ImVec2(S(230), 0));
pop_primary_button();
if (update) {
// The install panel lives on the mod manager page, so open it: an
// update with no sign of it running is the same as a dead button.
std::vector<thunderstore::Package> packages;
for (const auto* package : pending) packages.push_back(*package);
start_store_install(panel, std::move(packages));
// Only promise the launch if the install really started.
ui.play_after_install = panel.installing;
ui.mods_update_prompt = false;
ui.mods = true;
panel.tab = 0;
}
ImGui::SameLine(0, S(8));
if (ImGui::Button("Open Mod Manager", ImVec2(S(160), 0))) {
ui.mods_update_prompt = false;
ui.mods = true;
panel.tab = 0;
panel.scanned = false;
}
ImGui::SameLine(frame.x - S(28) - S(110));
if (ImGui::Button("Play anyway", ImVec2(S(110), 0))) {
ui.mods_update_prompt = false;
ui.mods_updates_ignored = true; // asked and answered; do not nag again
launcher.play();
}
ImGui::End();
}
void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, ModsPanel& panel, HWND window) {
const auto& session = launcher.session();
if (!panel.scanned) scan(panel, session);
collect_install(panel, session);
settle_profile(panel);
refresh_listing(panel, ImGui::GetTime());
// A page, not a panel: the mod list and the Thunderstore browser both want
// the whole window. It sits at (0, 0), so window and screen space agree.
const auto frame = begin_page("##mods_panel", size);
// Both lists draw mod icons, so both need finished ones uploaded.
pump_icons(panel);
auto* draw = ImGui::GetWindowDrawList();
const bool installing = panel.installing;
hold_focus(installing);
auto& entries = panel.list.entries;
const auto installed = installed_versions(panel.list);
const auto pending = updates(panel.store, installed);
const auto close = [&] {
ui.mods = false;
panel.message.clear();
panel.scanned = false;
ui.play_after_install = false; // leaving the page is a change of mind
};
// The page covers the main screen's buttons, so it draws its own.
window_buttons(draw, window, frame);
const float rail_x = S(28), rail_width = S(236);
const float content_x = rail_x + rail_width + S(26);
const float top = S(52), bottom = frame.y - S(24);
const float title_size = S(44);
page_title(draw, fonts, ImVec2(rail_x + S(2), top - S(4)), "MOD\nMANAGER", title_size);
const float rail_top = top + title_size * 2 + S(16);
ImGui::SetCursorPos(ImVec2(rail_x, rail_top));
// Flattened, so a controller's D-pad crosses from the rail into the list and back.
ImGui::BeginChild("##rail", ImVec2(rail_width, bottom - rail_top), ImGuiChildFlags_NavFlattened);
const bool leave = rail(fonts, panel, window, rail_width, pending, installing);
ImGui::EndChild();
ImGui::SetCursorPos(ImVec2(content_x, top));
ImGui::BeginChild("##content", ImVec2(frame.x - content_x - S(28), bottom - top), ImGuiChildFlags_NavFlattened);
// With nothing to say, the list runs all the way down to BACK's bottom
// edge; a message takes two lines off it until it is gone.
const float status = panel.message.empty() ? S(4) : ImGui::GetTextLineHeight() * 2 + S(12);
const float body = ImGui::GetWindowHeight() - status;
if (panel.tab == 1) browse_page(launcher, fonts, panel, body, installing);
else if (panel.tab == 2) profiles_page(fonts, panel, window, body, installing);
else if (panel.tab == 3) official_page(fonts, panel, body);
else installed_page(launcher, fonts, panel, installed, body, installing);
if (!panel.message.empty()) {
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - status + S(8));
ImGui::PushTextWrapPos(0);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(panel.message_error ? color::danger : color::good), "%s",
panel.message.c_str());
ImGui::PopTextWrapPos();
}
ImGui::EndChild();
// Read before the overviews: one closing on Escape must not also leave the page.
const bool popup_open = ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId);
if (panel.tab == 1) package_overview(fonts, panel, frame, installing);
else if (panel.tab == 0) mod_overview(fonts, panel, installed, frame, installing);
if (leave) close();
if (!installing && !popup_open && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId) && !ImGui::IsAnyItemActive() &&
ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
// Escape lets go of the ticked mods first, and leaves the page after that.
if (panel.tab == 0 && !panel.marked.empty()) panel.marked.clear();
else close();
}
// ------------------------------------------------ confirmations
// One title for both, so the popup is the same window whether it is one mod or several.
const char* uninstall = panel.confirm_remove.size() > 1 ? "Uninstall mods###uninstall" : "Uninstall mod###uninstall";
if (!panel.confirm_remove.empty() && !ImGui::IsPopupOpen(uninstall)) ImGui::OpenPopup(uninstall);
if (!panel.conflict_name.empty() && !ImGui::IsPopupOpen("Replace mod")) ImGui::OpenPopup("Replace mod");
ImGui::SetNextWindowSize(ImVec2(S(460), 0));
if (ImGui::BeginPopupModal(uninstall, nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) {
const auto names = panel.confirm_remove;
const auto title = [&](const std::string& name) -> const std::string& {
for (const auto& entry : entries)
if (entry.mod.name == name) return entry.mod.title;
return name;
};
std::uint64_t bytes{};
for (const auto& name : names)
if (const auto found = panel.sizes.find(name); found != panel.sizes.end()) bytes += found->second;
ImGui::PushTextWrapPos(0);
if (names.size() == 1) {
ImGui::Text("Uninstall \"%s\"? Its folder goes to the Recycle Bin.", title(names.front()).c_str());
} else {
ImGui::Text("Uninstall these %d mods? Their folders go to the Recycle Bin.", static_cast<int>(names.size()));
ImGui::Spacing();
const std::size_t listed = std::min<std::size_t>(names.size(), 8);
for (std::size_t i = 0; i < listed; ++i) ImGui::TextDisabled("%s", title(names[i]).c_str());
if (names.size() > listed) ImGui::TextDisabled("and %d more", static_cast<int>(names.size() - listed));
}
if (bytes) ImGui::TextDisabled("That frees %s.", size_text(bytes).c_str());
ImGui::PopTextWrapPos();
ImGui::Spacing();
// Escape (and a controller's B) answers Cancel, never the destructive choice.
if (ImGui::Button("Cancel", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
panel.confirm_remove.clear();
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
push_primary_button();
if (ImGui::Button("UNINSTALL", ImVec2(S(110), 0))) {
// Stops at the first folder that will not go: what went is gone, the rest is untouched.
std::vector<std::string> removed;
std::string failure;
for (const auto& name : names) {
try {
launcher_mods::remove(panel.root, name);
removed.push_back(name);
logging::write(logging::Level::info, logging::Channel::launcher, "Mod removed: " + name);
} catch (const std::exception& error) {
failure = error.what();
break;
}
}
const auto gone = [&](const std::string& name) {
return std::find(removed.begin(), removed.end(), name) != removed.end();
};
const auto first = removed.empty() ? std::string() : title(removed.front());
std::erase_if(entries, [&](const auto& entry) { return gone(entry.mod.name); });
if (!removed.empty()) save(panel);
auto message = !failure.empty() ? failure
: removed.size() == 1 ? "Uninstalled " + first + ". It is in the Recycle Bin if you want it back."
: std::format("Uninstalled {} mods. They are in the Recycle Bin if you want them back.", removed.size());
const bool error = !failure.empty() || panel.message_error;
if (failure.empty() && panel.message_error) message = panel.message; // mods.json could not be rewritten
panel.selected = -1;
scan(panel, session);
panel.message = message;
panel.message_error = error;
panel.confirm_remove.clear();
ImGui::CloseCurrentPopup();
}
pop_primary_button();
ImGui::EndPopup();
}
ImGui::SetNextWindowSize(ImVec2(S(440), 0));
if (ImGui::BeginPopupModal("Replace mod", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) {
ImGui::PushTextWrapPos(0);
ImGui::Text("\"%s\" is already installed. Replace it? The installed copy is deleted.",
panel.conflict_name.c_str());
ImGui::PopTextWrapPos();
ImGui::Spacing();
if (ImGui::Button("Cancel", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
panel.conflict_name.clear();
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
push_primary_button();
if (ImGui::Button("REPLACE", ImVec2(S(110), 0))) {
const auto source = panel.conflict_source;
panel.conflict_name.clear();
ImGui::CloseCurrentPopup();
start_install(panel, source, true);
}
pop_primary_button();
ImGui::EndPopup();
}
profile_popups(panel);
g_drag_allowed = !ImGui::IsAnyItemHovered() && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId);
ImGui::End();
if (installing) install_modal(fonts, panel, frame);
// collect_install at the top of this frame has already taken the result, so
// an install that is no longer running is settled: go and play, as the
// button that sent us here said it would.
if (ui.play_after_install && !panel.installing) {
ui.play_after_install = false;
if (panel.message_error) {
// It failed. Stay on the page, with the reason still on screen.
logging::write(logging::Level::warning, logging::Channel::launcher,
"Not launching after the mod update: " + panel.message);
} else {
close();
launcher.play();
}
}
}
} // namespace dingosdk::launcher_gui::detail