#include "modding_menu.h" #include "skate_menu.h" #include "skate_menu_internal.h" #include "Engine/Vfs/builtin_packs.h" #include "Engine/Vfs/initfs.h" #include "Engine/Vfs/mod_catalog.h" #include "Extension/Assets/live_mods.h" #include "Extension/BoardBreak/board_break.h" #include "Extension/UI/skate_theme.h" #include "Engine/Core/Log/logging.h" #include #include #include #include #include #include #include #include #include #include #include namespace dingosdk::overlay { namespace { // What the Mods folder holds, read again on a thread of its own every `period` while a page asks // for it; the page draws the last that was read. These pages are drawn on the game's render // thread, and reading the folder there (every mod's files, a whole folder's fingerprint for an // excluded one) held each frame up for tens of milliseconds to seconds, every 1.5 s (2026-10-15). template class FolderRead { public: template std::shared_ptr get(Read read, ULONGLONG period) { const auto now = GetTickCount64(); if (now >= next_.load() && !reading_.exchange(true)) { next_ = now + period; try { std::thread([this, read] { std::shared_ptr fresh; try { fresh = std::make_shared(read()); } catch (...) {} if (fresh) { std::lock_guard lock(mutex_); value_ = std::move(fresh); } reading_ = false; }).detach(); } catch (...) { reading_ = false; } } std::lock_guard lock(mutex_); return value_; } // What the page itself has just changed, shown until the next read. void set(T value) { std::lock_guard lock(mutex_); value_ = std::make_shared(std::move(value)); } void again() { next_ = 0; } private: std::mutex mutex_; std::shared_ptr value_; std::atomic reading_{}; std::atomic next_{}; }; struct DeckScan { std::vector installed, titles; // each installed deck's name for people std::string chosen; }; } namespace menu { // Board Break's decks (Extension/BoardBreak/board_break.h). The game has one deck model and each // deck is a mod's copy of it, so picking one applies the mods with that deck first and loads the // level again. A button for each deck the feature was made with, greyed out while it is not // installed, and for any other deck that is; the Mods folder is read again every 1.5 s while the // card shows. A deck goes by the name its deck.ini gives it (label), else by its folder's. void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { static auto* scans = new FolderRead; // process lifetime, as the threads that fill it const auto scan = scans->get([] { DeckScan read; read.installed = mods::installed_board_break_decks(); read.chosen = mods::wanted_board_break_deck(); for (const auto& deck : read.installed) read.titles.push_back(board_break::deck_label(deck)); return read; }, 1500); if (!scan) return; const auto& installed = scan->installed; const auto& titles = scan->titles; const auto& chosen = scan->chosen; if (!model.board_break.available || installed.empty()) return; const auto there = [&](const std::string& deck) { return std::find(installed.begin(), installed.end(), deck) != installed.end(); }; // The known decks in their own order, then the others that are installed: the numbers are the // ones `boardbreak deck` prints. std::vector decks(mods::board_break_decks.begin(), mods::board_break_decks.end()); for (const auto& deck : installed) if (std::find(decks.begin(), decks.end(), deck) == decks.end()) decks.push_back(deck); const auto label = [&](const std::string& deck) { std::string name; if (const auto at = std::find(installed.begin(), installed.end(), deck); at != installed.end() && static_cast(at - installed.begin()) < titles.size()) name = titles[static_cast(at - installed.begin())]; else { name = deck; bool start = true; for (auto& c : name) { if (c == '_') c = ' '; else if (start && c >= 'a' && c <= 'z') c = static_cast(c - 'a' + 'A'); start = c == ' '; } } const auto place = std::find(decks.begin(), decks.end(), deck) - decks.begin() + 1; return std::to_string(place) + " " + name; }; const auto in_play = mods::board_break_in_play(); begin_card(menu, "board-break-decks", "BOARD BREAK DECK", in_play.empty() ? "none in the game" : label(in_play).c_str()); const bool busy = live_mods::busy(); if (menu::console_style()) { // Full screen: one row, left / right to a deck and A to put it in the game. static int staged = -1; static std::string staged_from; std::vector names; int playing = -1; for (std::size_t i = 0; i < decks.size(); ++i) { names.push_back(label(decks[i]) + (there(decks[i]) ? "" : " (not installed)")); if (decks[i] == in_play) playing = static_cast(i); } if (staged < 0 || staged >= static_cast(decks.size()) || staged_from != in_play) staged = playing, staged_from = in_play; bool load = false; const bool waiting = staged >= 0 && decks[static_cast(staged)] != in_play; menu::option_row(menu, waiting ? "Deck - A puts it in###deck" : "Deck###deck", waiting ? "Press A to put this deck in the game (the level loads again)." : "Left / right to pick a deck, then A to put it in the game.", staged, names, !busy && callbacks.queue_console_command, &load); if (load && staged >= 0) { const auto& deck = decks[static_cast(staged)]; if (!there(deck)) menu::feedback(menu, "That deck is not installed."); else if (deck != in_play) send_console(menu, callbacks, "boardbreak deck " + deck); } } else { const float gap = ImGui::GetStyle().ItemSpacing.x; const float width = (ImGui::GetContentRegionAvail().x - gap * 3) / 4; for (std::size_t i = 0; i < decks.size(); ++i) { if (i % 4) ImGui::SameLine(); const auto& deck = decks[i]; ImGui::PushID(static_cast(i)); ImGui::BeginDisabled(!there(deck) || busy || deck == in_play || !callbacks.queue_console_command); if (ImGui::Button((label(deck) + (deck == in_play ? " *" : "")).c_str(), ImVec2(width, 0))) send_console(menu, callbacks, "boardbreak deck " + deck); ImGui::EndDisabled(); ImGui::PopID(); } } if (busy) note("Applying mods..."); else if (!in_play.empty() && chosen != in_play && there(chosen)) note((label(chosen) + " is chosen and shows once the level has loaded again.").c_str()); note("The deck every board in the game has, and the one that breaks. Picking another loads the level again; " "decks that are not installed are greyed out."); end_card(); } } namespace { std::filesystem::path game_directory() { std::array executable{}; const auto length = GetModuleFileNameW(nullptr, executable.data(), static_cast(executable.size())); if (!length || length >= executable.size()) throw std::runtime_error("Cannot locate the game directory."); return std::filesystem::path(std::wstring(executable.data(), length)).parent_path(); } // What a mod brings, as the native Mods page words it. std::string mod_contents(const mods::Mod& mod) { std::string text = mod.provides_layout && mod.provides_levels ? "Maps + game data" : mod.provides_levels ? "Maps" : mod.provides_layout ? "Game data" : ""; if (!mod.park_maps.empty()) text += text.empty() ? "Parks" : " + parks"; return text.empty() ? "Nothing to load" : text; } // Every mod folder and what mods.json says about it, re-read every 1.5 s while // the card is open, so a folder added or an edit made outside the game shows // up. Enabling or disabling saves mods.json; "Apply now" merges the change in // while the game runs (it takes effect at the next level load), including a // mod folder added since launch. void draw_installed_mods(SkateMenu& menu) { static auto* scans = new FolderRead; // process lifetime, as the threads that fill it static auto* officials = new FolderRead>; static std::string action_error; const auto scanned = scans->get([] { return mods::scan_mods(mods::engine_data_root()); }, 1500); if (!scanned) { menu::note("Reading the Mods folder..."); return; } const auto& list = *scanned; const auto& launch = mods::catalog(); const auto applied = live_mods::applied_mods(); // Official mods (builtin_packs.h) live outside the Mods folder's list: ReSkate keeps them, // always on. Without them here, the ones in effect read as mods whose folder was deleted. const auto official_read = officials->get([root = list.root] { return mods::official_packs(root); }, 1500); const auto official = official_read ? *official_read : std::vector{}; const auto kept = [&](const std::string& name) { const auto pack = mods::board_break_pack_of(name); const auto& base = pack.empty() ? name : pack; return base.starts_with("reskate-builtin-") || std::ranges::any_of(official, [&](const mods::BuiltinPack& other) { return _stricmp(other.name.c_str(), base.c_str()) == 0; }); }; // A Board Break deck pack is in effect when one of the decks inside it is (mod_catalog.h). const auto in_effect = [&](const std::string& name) { return std::ranges::any_of(applied, [&](const std::string& other) { return _stricmp(other.c_str(), name.c_str()) == 0 || _stricmp(mods::board_break_pack_of(other).c_str(), name.c_str()) == 0; }); }; const auto excluded = [&](const std::string& name) { return std::ranges::any_of(launch.excluded, [&](const mods::Mod& mod) { return _stricmp(mod.name.c_str(), name.c_str()) == 0; }); }; // Board Break's other decks and other mods that bring a deck model: enabled, not loaded, and // nothing about them waits to be applied. const auto aside = mods::set_aside_for_board_break(); const auto set_aside = [&](const mods::ModEntry& entry) { return entry.enabled && !in_effect(entry.mod.name) && std::ranges::any_of(aside, [&](const std::string& other) { return _stricmp(other.c_str(), entry.mod.name.c_str()) == 0; }); }; std::size_t loaded{}, pending{}; for (const auto& entry : list.entries) { const bool effect = in_effect(entry.mod.name); if (effect) ++loaded; if (entry.enabled != effect && !excluded(entry.mod.name) && !set_aside(entry)) ++pending; } const auto official_aside = [&](const std::string& name) { return !in_effect(name) && std::ranges::any_of(aside, [&](const std::string& other) { return _stricmp(other.c_str(), name.c_str()) == 0; }); }; for (const auto& pack : official) { if (in_effect(pack.name)) ++loaded; else if (!official_aside(pack.name)) ++pending; // put in place since the game started: applying loads it } // Mods still in effect whose folder has been deleted: applying unloads them. std::vector removed; for (const auto& name : applied) if (mods::board_break_pack_of(name).empty() && !kept(name) && std::ranges::none_of(list.entries, [&](const mods::ModEntry& entry) { return _stricmp(entry.mod.name.c_str(), name.c_str()) == 0; })) removed.push_back(name); pending += removed.size(); loaded += removed.size(); std::string count = std::to_string(loaded) + " loaded"; if (!launch.excluded.empty()) count += ", " + std::to_string(launch.excluded.size()) + " not loaded"; menu::begin_card(menu, "installed-mods", "INSTALLED MODS", count.c_str()); // Unapplied changes first, with the button that applies them. if (live_mods::busy()) { menu::note("Applying mods..."); } else if (pending) { menu::warn((std::to_string(pending) + " change(s) not applied yet.").c_str()); // One button, labelled by what the changes do to the level being played: // maps only just need applying; anything the level shows needs it // reloaded; taking away the map being played sends the player to San Van. const auto effect = live_mods::preview(list); if (ImGui::Button(live_mods::apply_label(effect), ImVec2(-FLT_MIN, 0))) { action_error.clear(); menu::feedback(menu, live_mods::apply(effect == live_mods::ApplyEffect::reload_level).c_str()); } } if (const auto last = live_mods::status(); !last.empty()) menu::note(("Last apply: " + last).c_str()); auto* draw = ImGui::GetWindowDrawList(); // A mod's tile; the button on its right enables or disables it. const auto tile = [&](int index, const std::string& title, const std::string& byline, const char* status, ImU32 colour, const std::string& contents, const std::string& hover, const char* action) { bool clicked{}; ImGui::PushID(index); const float button_width = action ? menu::px(96) : 0.f; const auto top = ImGui::GetCursorScreenPos(); const float width = ImGui::GetContentRegionAvail().x - (action ? button_width + menu::px(8) : 0.f); const float height = menu::px(54), pad = menu::px(14); skate_theme::rough_rect(draw, top, ImVec2(top.x + width, top.y + height), skate_theme::tile_grey, static_cast(index + 151), menu::px(1)); float right = top.x + width - pad; const auto contents_size = menu.body->CalcTextSizeA(menu::px(14), FLT_MAX, 0, contents.c_str()); right -= contents_size.x; draw->AddText(menu.body, menu::px(14), ImVec2(right, top.y + (height - contents_size.y) * .5f), skate_theme::grey_text, contents.c_str()); right -= menu::px(14); const float status_width = menu.bold->CalcTextSizeA(menu::px(12), FLT_MAX, 0, status).x + menu::px(14); right -= status_width; ImGui::SetCursorScreenPos(ImVec2(right, top.y + (height - ImGui::GetFrameHeight()) * .5f)); menu::tag(menu, status, colour); right -= menu::px(14); ImGui::SetCursorScreenPos(ImVec2(top.x + pad, top.y + menu::px(8))); ImGui::PushClipRect(ImGui::GetCursorScreenPos(), ImVec2(right, top.y + height), true); ImGui::BeginGroup(); ImGui::PushFont(menu.bold); ImGui::TextUnformatted(title.c_str()); ImGui::PopFont(); ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text); ImGui::TextUnformatted(byline.c_str()); ImGui::PopStyleColor(); ImGui::EndGroup(); ImGui::PopClipRect(); ImGui::SetCursorScreenPos(top); ImGui::Dummy(ImVec2(width, height)); if (!hover.empty() && ImGui::IsItemHovered()) { ImGui::BeginTooltip(); ImGui::PushTextWrapPos(ImGui::GetFontSize() * 28); ImGui::TextUnformatted(hover.c_str()); ImGui::PopTextWrapPos(); ImGui::EndTooltip(); } if (action) { ImGui::SameLine(0, menu::px(8)); ImGui::SetCursorScreenPos(ImVec2(top.x + width + menu::px(8), top.y + (height - ImGui::GetFrameHeight()) * .5f)); clicked = ImGui::Button(action, ImVec2(button_width, 0)); ImGui::SetCursorScreenPos(ImVec2(top.x, top.y + height + ImGui::GetStyle().ItemSpacing.y)); ImGui::Dummy(ImVec2(0, 0)); } ImGui::PopID(); return clicked; }; const auto byline = [](const mods::Mod& mod) { std::string text = mod.author.empty() ? "Unknown author" : "by " + mod.author; if (!mod.version.empty()) text += " - v" + mod.version; return text; }; std::optional toggle; int index{}; for (std::size_t i = 0; i < list.entries.size(); ++i) { const auto& entry = list.entries[i]; const auto& mod = entry.mod; const bool effect = in_effect(mod.name); const char* status = "DISABLED"; ImU32 colour = skate_theme::tile_light; std::string hover = mod.description; if (excluded(mod.name)) { status = "NOT LOADED"; colour = skate_theme::danger; hover = "Not loaded: it could not be merged cleanly, so none of it is used.\n\n" "Reinstall the whole mod folder, or rebuild it with a current ReSkate Studio."; } else if (set_aside(entry)) { status = "SET ASIDE"; hover = "Set aside while a Board Break deck is the one in the game: the game has one deck model, so one " "mod's deck loads at a time. Pick another deck under BOARD BREAK DECK, or disable Board Break's decks."; } else if (entry.enabled && effect) { status = "LOADED"; colour = skate_theme::good; } else if (entry.enabled) { status = "APPLY TO LOAD"; colour = skate_theme::danger; } else if (effect) { status = "APPLY TO UNLOAD"; colour = skate_theme::danger; } if (tile(index++, mod.title.empty() ? mod.name : mod.title, byline(mod), status, colour, mod_contents(mod), hover, entry.enabled ? "Disable" : "Enable")) toggle = i; } for (const auto& pack : official) { const auto* known = [&]() -> const mods::Mod* { for (const auto& mod : launch.mods) if (_stricmp(mod.name.c_str(), pack.name.c_str()) == 0) return &mod; return nullptr; }(); auto title = known && !known->title.empty() ? known->title : pack.name.substr(pack.name.find('-') + 1); if (!known) std::replace(title.begin(), title.end(), '_', ' '); auto line = known ? byline(*known) : "by " + pack.name.substr(0, pack.name.find('-')); line += " - always on"; const bool effect = in_effect(pack.name), set_by_deck = official_aside(pack.name); tile(index++, title, line, effect ? "OFFICIAL" : set_by_deck ? "SET ASIDE" : "APPLY TO LOAD", effect ? skate_theme::good : set_by_deck ? skate_theme::tile_light : skate_theme::danger, known ? mod_contents(*known) : "", "An official mod, made for one of ReSkate's own features. It is installed, updated and turned off " "on the launcher's OFFICIAL tab, not here.", nullptr); } for (const auto& name : removed) tile(index++, name, "Folder deleted", "APPLY TO UNLOAD", skate_theme::danger, "", "This mod's folder is gone, but it stays loaded until you apply.", nullptr); if (toggle && list.issue.empty()) { try { auto entries = list.entries; entries[*toggle].enabled = !entries[*toggle].enabled; mods::save_mod_order(list.root, entries); auto shown = list; shown.entries = std::move(entries); scans->set(std::move(shown)); scans->again(); action_error.clear(); } catch (const std::exception& error) { action_error = error.what(); } } if (!index) menu::note(list.present ? "No mods installed yet." : "No Mods folder yet. Create one next to Skate.exe."); if (!list.issue.empty()) menu::warn(("mods.json problem: " + list.issue).c_str()); if (!action_error.empty()) menu::warn(("Could not save mods.json: " + action_error).c_str()); menu::note("Order mods in the launcher's Mods panel. Maps, loading screens and cosmetics change at the next level load; " "other changes to game settings or the UI take effect after a restart."); menu::end_card(); } void draw_custom_scripts(SkateMenu& menu) { menu::begin_card(menu, "custom-scripts", "CUSTOM SCRIPTS"); menu::note("Drop .lua files or folders with init.lua into scripts/Custom/. Scripts and saved changes load on the next launch."); if (ImGui::Button("Refresh script list", ImVec2(-FLT_MIN, 0))) menu.custom_scripts_scanned = false; if (!menu.custom_scripts_scanned) { menu.custom_scripts_scanned = true; menu.custom_script_rows.clear(); menu.custom_script_error.clear(); try { menu.custom_script_rows = custom_scripts::discover(game_directory()); } catch (const std::exception& error) { menu.custom_script_error = error.what(); } } if (!menu.loose_files_saved) menu::warn("Turn on loose Lua / config files in the launcher to load custom scripts."); menu::note(custom_scripts::status("").c_str()); if (!menu.custom_script_error.empty()) menu::warn(("Script settings error: " + menu.custom_script_error).c_str()); if (menu.custom_script_rows.empty()) { menu::note("No custom scripts found. Names starting with _ or . are skipped."); menu::end_card(); return; } std::optional> move; if (ImGui::BeginTable("custom_scripts", 4, ImGuiTableFlags_RowBg)) { ImGui::TableSetupColumn("On", ImGuiTableColumnFlags_WidthFixed, menu::px(32)); ImGui::TableSetupColumn("Script", ImGuiTableColumnFlags_WidthStretch, 1.0f); ImGui::TableSetupColumn("Order", ImGuiTableColumnFlags_WidthFixed, menu::px(100)); ImGui::TableSetupColumn("This session", ImGuiTableColumnFlags_WidthStretch, 1.0f); ImGui::TableHeadersRow(); for (std::size_t index = 0; index < menu.custom_script_rows.size(); ++index) { auto& script = menu.custom_script_rows[index]; ImGui::PushID(script.id.c_str()); ImGui::TableNextRow(); ImGui::TableNextColumn(); bool enabled = script.enabled; if (ImGui::Checkbox("##enabled", &enabled)) { try { custom_scripts::save_enabled(game_directory(), script.id, enabled); script.enabled = enabled; menu.custom_script_error.clear(); menu::feedback(menu, "Script selection saved for the next launch."); } catch (const std::exception& error) { menu.custom_script_error = error.what(); } } ImGui::TableNextColumn(); ImGui::TextWrapped("%s", script.id.c_str()); ImGui::TableNextColumn(); ImGui::BeginDisabled(index == 0); if (ImGui::SmallButton("Up")) move = {{index, index - 1}}; ImGui::EndDisabled(); ImGui::SameLine(); ImGui::BeginDisabled(index + 1 == menu.custom_script_rows.size()); if (ImGui::SmallButton("Down")) move = {{index, index + 1}}; ImGui::EndDisabled(); ImGui::TableNextColumn(); ImGui::TextWrapped("%s", custom_scripts::status(script.id).c_str()); ImGui::PopID(); } ImGui::EndTable(); } if (move) { try { auto ordered = menu.custom_script_rows; std::swap(ordered[move->first], ordered[move->second]); custom_scripts::save_order(game_directory(), ordered); menu.custom_script_rows = std::move(ordered); menu.custom_script_error.clear(); menu::feedback(menu, "Script order saved for the next launch."); } catch (const std::exception& error) { menu.custom_script_error = error.what(); } } menu::end_card(); } } void draw_modding_menu(SkateMenu& menu, int tab, const Model&, const CallbacksV3&) { // Loose files are switched in the launcher; custom scripts only need to know // whether they are on. if (!menu.loose_files_settings_loaded) { menu.loose_files_settings_loaded = true; try { menu.loose_files_saved = initfs::loose_files_preference(game_directory()); } catch (const std::exception&) {} } if (tab == 1) draw_custom_scripts(menu); else draw_installed_mods(menu); } }