Files
ReSkate/Extension/UI/Overlay/store_icons.cpp
T
AyeZeeBB 725c18ef3d Mod Manager: a pause menu tab after Mod Options to browse, install, update and switch mods in game
A third ReSkate tab in the game's pause menu (native_menu.cpp slot 2, the Store's tab art), drawn by
the overlay as the other two are (mod_manager_page.inl, included into hub_page.cpp):

- BROWSE: search, categories in a panel of their own (shown only, hidden or either, kept in the
  profile), sort, a featured carousel and rows (most popular, recently updated, new, the biggest
  categories), a grid for results, and a mod's own page with its README, what it brings with it,
  and INSTALL, switch on or off and uninstall. Works from a controller: the carousel's card is
  one stop that left and right turn, and B goes back from a mod's page (the page's Back is held
  meanwhile).
- MY MODS: the Mods folder with the states the ReSkate menu shows; UPDATES: Update all.
- The foot of the page shows the install's progress and the apply's, and Apply (View on a pad).
  Leaving the menu with changes not applied asks first (a two-answer card in message_card.cpp,
  answered by the runtime as the message is); LATER closes the menu.

Behind it, Extension/Assets/mod_store: Thunderstore's listing (cached on disk), the installed mods,
and a queue that downloads and installs packages with their dependencies, updates and uninstalls
them. An update or uninstall of a mod the game holds open waits in Mods/.reskate-pending and the
launcher makes it before the next start (launcher_mods::apply_pending). Package icons load into
their own SRV slots (store_icons.cpp), sharing the launcher's icon cache.

Tests: mod_store_view (search, categories, the front page's rows, install plans), the pending
updates and uninstalls in mod_manager_tests, and the third tab in native_menu_view_tests.
2026-10-24 19:55:56 -05:00

230 lines
9.6 KiB
C++

#include "overlay_internal.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Vfs/https_download.h"
#include <algorithm>
#include <array>
#include <chrono>
#include <condition_variable>
#include <deque>
#include <filesystem>
#include <fstream>
#include <map>
#include <mutex>
#include <thread>
// 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<std::string, Entry, std::less<>> entries;
std::deque<std::string> queue;
std::size_t started{};
};
Cache& cache() {
static auto* value = new Cache;
return *value;
}
double seconds_now() {
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
fs::path icon_folder() {
std::array<wchar_t, 32768> local{};
const auto length = GetEnvironmentVariableW(L"LOCALAPPDATA", local.data(), static_cast<DWORD>(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<char>(in), std::istreambuf_iterator<char>());
}
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<std::streamsize>(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<StoreIconUpload> 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<bool, store_icon_slots> used{};
for (const auto& [url, entry] : c.entries)
if (entry.slot >= 0) used[static_cast<std::size_t>(entry.slot)] = true;
int slot = -1;
for (int i = 0; i < static_cast<int>(store_icon_slots); ++i)
if (!used[static_cast<std::size_t>(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);
}
}