Files
ReSkate/Extension/UI/Overlay/map_download_card.cpp
T
AyeZeeBB 0ae0e71f0f Map downloads on join, faster mod merging, connect cards in the server browser
Map downloader
- A host or dedicated server now tells joiners which Thunderstore package
  its map comes from (the Mods folder name plus manifest.json).
- A player without the map gets a card: icon, name, author, version and
  description, with DOWNLOAD and CANCEL. It works with mouse, keyboard and
  controller.
- Only packages listed as Maps in the ReSkate community are offered, and
  never deprecated, rejected or NSFW ones.
- On yes the package is downloaded, installed into Mods and applied as a
  live mod, then the session loads the map. A small card on the right
  shows the progress, so the player can keep skating.
- The player stays connected while it downloads: the host gives a player
  who says they are fetching the map up to 45 minutes instead of the
  usual load time.
- When the server changes map with the player in it, the card says so.
- Protocol version 47: map package on map_offer and world_state, and a
  "fetching the map" flag on map_request and world_ready.

Joining
- The pause-menu server browser shows a CONNECTING card over the page
  while a join is under way, and a COULDN'T CONNECT card with the reason
  when it does not get in.
- The session and voice tabs no longer show before the player is in.
- Leaving without the map (declined or cancelled download) no longer
  counts towards the failed-join limit, on servers and player hosts.

Input
- A card that asks something holds the game's input until the buttons
  are released.
- Steam's overlay hooks XInput in front of ReSkate and answers the game
  itself, so a controller reached the game behind menus. ReSkate now
  hooks that hook as well.

Mod merging
- Mods are read on several threads, at launch and for live merges. A live
  merge runs below the game's priority.
- The paid-cosmetics check reads the game's files and the mods on several
  threads.
- A live merge reuses the paid-cosmetics verdicts it already has and
  reports its progress and timings.

Launcher
- NSFW mods and their categories are not shown in the mod browser.
2026-10-10 13:15:04 -05:00

320 lines
19 KiB
C++

#include "overlay_internal.h"
#include "Engine/Game/Input/controller_bindings.h"
#include <format>
#include <mutex>
#include <wincodec.h>
// The card for a map a multiplayer session needs and this PC does not have
// (Extension/Assets/map_download.h), laid out as the mod's page is: its icon, name, author,
// version and description, then what is being asked or done. It asks whether to download it,
// in the middle of the screen with the controls under it as the game's own menus have them.
// Once the player has said yes it becomes a small card at the right edge, with the download,
// the install and the apply, so they can skate on while they wait; it goes when the map is in
// the game and starts loading. Both in the look of ReSkate's pages in the pause menu (hub_page.cpp).
//
// Answered by a controller (D-pad to pick, A to take the pick, B for no), the keyboard (arrow
// keys, Enter, Esc), the vote binds, or a click: the overlay has the pointer while
// the card asks (State::prompt_pointer). The runtime reads those and holds the game's input meanwhile.
//
// The icon is the package's own, a PNG the downloader hands over (set_map_download_icon). It
// is decoded here and goes to the GPU from the render thread (overlay_render.cpp), into the
// overlay's second texture slot; the first is ImGui's atlas, which is filled once at startup.
namespace dingosdk::overlay {
namespace {
using Microsoft::WRL::ComPtr;
std::atomic<MapDownloadFeed> feed{};
std::atomic<MapDownloadAnswer> answer{};
std::mutex image_mutex;
detail::CardPixels current; // the icon in use, kept to upload again after a device reset
bool waiting{}; // `current` has not gone to the GPU yet
unsigned image_serial{};
std::atomic<ImTextureID> image{}; // on the GPU and drawable; 0 for none
// Straight-alpha RGBA, at most 512 pixels a side (an icon is 256).
bool decode(const std::string& bytes, detail::CardPixels& out) {
const HRESULT com = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
bool ok{};
{
ComPtr<IWICImagingFactory> factory;
ComPtr<IWICStream> stream;
ComPtr<IWICBitmapDecoder> decoder;
ComPtr<IWICBitmapFrameDecode> frame;
ComPtr<IWICFormatConverter> converter;
UINT width{}, height{};
if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory))) &&
SUCCEEDED(factory->CreateStream(&stream)) &&
SUCCEEDED(stream->InitializeFromMemory(reinterpret_cast<BYTE*>(const_cast<char*>(bytes.data())), static_cast<DWORD>(bytes.size()))) &&
SUCCEEDED(factory->CreateDecoderFromStream(stream.Get(), nullptr, WICDecodeMetadataCacheOnDemand, &decoder)) &&
SUCCEEDED(decoder->GetFrame(0, &frame)) && SUCCEEDED(frame->GetSize(&width, &height)) && width && height &&
width <= 512 && height <= 512 && SUCCEEDED(factory->CreateFormatConverter(&converter)) &&
SUCCEEDED(converter->Initialize(frame.Get(), GUID_WICPixelFormat32bppRGBA, WICBitmapDitherTypeNone, nullptr, 0,
WICBitmapPaletteTypeCustom))) {
out.rgba.resize(static_cast<std::size_t>(width) * height * 4);
out.width = width;
out.height = height;
ok = SUCCEEDED(converter->CopyPixels(nullptr, width * 4, static_cast<UINT>(out.rgba.size()), out.rgba.data()));
}
}
if (SUCCEEDED(com)) CoUninitialize();
return ok;
}
}
void set_map_download_feed(MapDownloadFeed value) noexcept { feed.store(value); }
void set_map_download_answer(MapDownloadAnswer value) noexcept { answer.store(value); }
void set_map_download_icon(const std::string& png) noexcept {
try {
detail::CardPixels pixels;
const bool decoded = !png.empty() && png.size() <= 4 * 1024 * 1024 && decode(png, pixels);
std::lock_guard lock(image_mutex);
image.store(0);
pixels.serial = ++image_serial;
current = decoded ? std::move(pixels) : detail::CardPixels{};
current.serial = image_serial;
waiting = decoded;
} catch (...) {}
}
} // namespace dingosdk::overlay
namespace dingosdk::overlay::detail {
bool take_card_pixels(CardPixels& out) {
std::lock_guard lock(image_mutex);
if (!waiting) return false;
waiting = false;
out = current; // (kept: a device reset uploads it again)
return true;
}
void card_image_uploaded(ImTextureID texture, unsigned serial) {
std::lock_guard lock(image_mutex);
if (serial == image_serial) image.store(texture);
}
void card_image_lost() {
std::lock_guard lock(image_mutex);
image.store(0);
waiting = !current.rgba.empty();
}
namespace {
MapDownloadCard card; // this frame's, read by map_download_pending
ImU32 faded(ImU32 colour, float alpha) {
const auto base = static_cast<float>((colour >> IM_COL32_A_SHIFT) & 0xFF) / 255.0f;
return (colour & ~IM_COL32_A_MASK) | (static_cast<ImU32>(base * std::clamp(alpha, 0.0f, 1.0f) * 255.0f + 0.5f) << IM_COL32_A_SHIFT);
}
std::string megabytes(std::uint64_t bytes) {
const auto mb = static_cast<double>(bytes) / (1024.0 * 1024.0);
return mb >= 1024.0 ? std::format("{:.2f} GB", mb / 1024.0) : std::format("{:.0f} MB", mb);
}
}
bool map_download_pending() {
const auto read = feed.load();
card = read ? read() : MapDownloadCard{};
// (Nothing is shown once the map is in the game: its loading has the game's own screen.)
if (card.stage == 5) card.stage = 0;
state().prompt_pointer.store(card.stage == 1);
return card.stage != 0;
}
namespace {
// After the player said yes: a small card at the right edge, out of the way of skating, with
// what is being fetched and how far it is.
void draw_progress(State& s) {
auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont();
auto* body = s.menu.body ? s.menu.body : ImGui::GetFont();
const auto display = ImGui::GetIO().DisplaySize;
const float u = std::min(display.x / 3840.0f, display.y / 2160.0f);
const bool downloading = card.stage == 2, applying = card.stage == 4;
const char* title = downloading ? "DOWNLOADING MAP" : card.stage == 3 ? "INSTALLING MAP" : "APPLYING MAP";
const std::string status = downloading ? (card.total ? megabytes(card.received) + " of " + megabytes(card.total) : megabytes(card.received))
: card.stage == 3 ? std::string("Putting it in your Mods folder...")
: card.step_name.empty() ? std::string("Adding it to the game...") : card.step_name + "...";
const float width = 820 * u, inset = 30 * u, icon = 132 * u, between = 26 * u, bar = 12 * u;
const float label_size = 24 * u, name_size = 38 * u, text_size = 26 * u;
const float column = width - inset * 2 - icon - between;
const float height = inset + std::max(icon, label_size + 12 * u + name_size + 18 * u + bar + 14 * u + text_size) + inset;
// Beside where a vote's card goes (the middle of the right edge): above it.
const ImVec2 min(std::floor(display.x - 48 * u - width), std::floor(display.y * 0.2f)), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 235), 0x4d2, u * 2);
const float left = min.x + inset, top = min.y + inset;
const ImVec2 icon_min(left, top), icon_max(left + icon, top + icon);
if (const auto texture = image.load()) draw->AddImage(texture, icon_min, icon_max);
else skate_theme::rough_rect(draw, icon_min, icon_max, skate_theme::tile_light, 0x1c1, u * 2);
const float x = left + icon + between, right = max.x - inset;
float y = top;
draw->AddText(bold, label_size, ImVec2(x, y), theme::blue, title);
y += label_size + 12 * u;
draw->PushClipRect(ImVec2(x, y - name_size), ImVec2(right, y + name_size * 2), true);
draw->AddText(bold, name_size, ImVec2(x, y), theme::paper, card.map.c_str());
draw->PopClipRect();
y += name_size + 18 * u;
const float share = downloading ? (card.total ? static_cast<float>(card.received) / static_cast<float>(card.total) : -1.0f)
: (applying && card.steps ? static_cast<float>(card.step) / static_cast<float>(card.steps) : -1.0f);
draw->AddRectFilled(ImVec2(x, y), ImVec2(right, y + bar), skate_theme::tile_light, 4 * u);
if (share >= 0.0f) draw->AddRectFilled(ImVec2(x, y), ImVec2(x + column * std::clamp(share, 0.0f, 1.0f), y + bar), theme::blue, 4 * u);
else {
// How far is not known: a block going back and forth.
const float at = static_cast<float>(std::fmod(ImGui::GetTime(), 2.0));
const float where = (at < 1.0f ? at : 2.0f - at) * (column * 0.75f);
draw->AddRectFilled(ImVec2(x + where, y), ImVec2(x + where + column * 0.25f, y + bar), theme::blue, 4 * u);
}
y += bar + 14 * u;
draw->PushClipRect(ImVec2(x, y - text_size), ImVec2(right, y + text_size * 2), true);
draw->AddText(body, text_size, ImVec2(x, y), theme::muted, status.c_str());
draw->PopClipRect();
}
}
void draw_map_download() {
if (!card.stage) return;
auto& s = state();
if (card.stage != 1) return draw_progress(s);
// Drawn as the pages of the game's pause menu are (hub_page.cpp): the same unit (one of
// the game's 3840 by 2160), brushed heading, hand-cut tiles, the yellow note for what to
// press and the keycaps under it.
auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont();
auto* body = s.menu.body ? s.menu.body : ImGui::GetFont();
auto* heading = s.menu.heading ? s.menu.heading : bold;
const auto display = ImGui::GetIO().DisplaySize;
const float u = std::min(display.x / 3840.0f, display.y / 2160.0f);
constexpr ImU32 sticky = IM_COL32(236, 215, 108, 255), sticky_dim = IM_COL32(150, 140, 86, 255);
const bool asking = card.stage == 1, downloading = card.stage == 2, applying = card.stage == 4;
const bool clickable = asking || s.visible.load() || s.console_visible.load();
const char* host = card.server ? "server" : "host";
const auto width_of = [](ImFont* font, float size, const std::string& text) { return font->CalcTextSizeA(size, FLT_MAX, 0, text.c_str()).x; };
const char* title = asking ? (card.moved ? "MAP CHANGED" : "MAP NEEDED") : downloading ? "DOWNLOADING MAP" : card.stage == 3 ? "INSTALLING MAP"
: applying ? "APPLYING MAP" : "JOINING";
// Why it is asked: the session moved here with the player in it, or the player is joining.
const auto why = card.moved ? std::format("The {} changed to this map, and you need it to stay in the session.", host)
: std::format("The {} is on this map, and you need it to join.", host);
const std::string status = asking ? why + (card.installed ? " You have it, switched off. Switch it on?" : " Download it?")
: downloading ? (card.total ? megabytes(card.received) + " of " + megabytes(card.total) : megabytes(card.received))
: card.stage == 3 ? std::string("Putting it in your Mods folder...")
: applying ? (card.step_name.empty() ? std::string("Adding it to the game...") : card.step_name + "...")
: std::string("Loading the map...");
struct Fact { const char* name; std::string value; };
std::vector<Fact> facts{{"Author", card.author}};
if (!card.version.empty()) facts.push_back({"Version", card.version});
facts.push_back({"From", card.installed ? std::string("Your Mods folder, switched off") : std::string("Thunderstore")});
// The controls under the card: the pad's own names for its buttons when one is connected.
dingosdk::ControllerInput pad;
DingoSDKOverlayReadControllerInput(&pad, true);
struct Hint { std::string key; const char* what; };
std::vector<Hint> hints;
if (asking) {
hints.push_back({pad.available ? std::string("D-pad") : std::string("< >"), "Pick"});
hints.push_back({pad.available ? dingosdk::controller_combo_label(0x1000, pad.style) : std::string("Enter"), "Select"});
hints.push_back({pad.available ? dingosdk::controller_combo_label(0x2000, pad.style) : std::string("Esc"), "Cancel"});
}
const float key_scale = u * 2, hint_gap = 36 * u;
float hints_width{};
for (const auto& hint : hints)
hints_width += width_of(bold, 12 * key_scale, hint.key) + 20 * key_scale + width_of(bold, 14 * key_scale, hint.what);
if (!hints.empty()) hints_width += hint_gap * static_cast<float>(hints.size() - 1);
const float width = 1500 * u, inset = 44 * u, icon = 300 * u, between = 44 * u, tall = 110 * u, bar = 14 * u;
const float inner = width - inset * 2, column = inner - icon - between;
const float label_size = 28 * u, name_size = 64 * u, fact_size = 34 * u, text_size = 32 * u;
const auto name_extent = heading->CalcTextSizeA(name_size, FLT_MAX, column, card.map.c_str());
const auto described = card.description.empty() ? ImVec2{} : body->CalcTextSizeA(text_size, FLT_MAX, inner, card.description.c_str());
const float beside = name_extent.y + 22 * u + static_cast<float>(facts.size()) * 54 * u;
const float top_block = std::max(icon, beside);
const auto status_extent = body->CalcTextSizeA(text_size, FLT_MAX, inner, status.c_str());
const bool progress = downloading || applying;
float height = inset + label_size + 26 * u + top_block + inset + status_extent.y + inset;
if (!card.description.empty()) height += described.y + 36 * u;
if (progress) height += bar + 30 * u;
if (asking) height += 30 * u + tall;
if (downloading) height += 16 * u + label_size;
const float under = hints.empty() ? 0.0f : 34 * u + 20 * key_scale;
// In the middle while it asks; near the top, out of the way, while the player skates on.
const float top = asking ? (display.y - height - under) * 0.5f : 150 * u;
const ImVec2 min(std::floor((display.x - width) * 0.5f), std::floor(top)), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
// While it asks, what is behind is dimmed: the pause menu takes no input then, nor the game.
if (asking) draw->AddRectFilled(ImVec2(0, 0), display, IM_COL32(0, 0, 0, s.hub_pointer ? 170 : 110));
skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 250), 0x4d1, u * 2);
const float left = min.x + inset, right = max.x - inset;
float y = min.y + inset;
draw->AddText(bold, label_size, ImVec2(left, y), theme::blue, title);
y += label_size + 26 * u;
// The icon, or a plain tile until it is here (and for a mod that has none).
const ImVec2 icon_min(left, y), icon_max(left + icon, y + icon);
if (const auto texture = image.load()) draw->AddImage(texture, icon_min, icon_max);
else {
skate_theme::rough_rect(draw, icon_min, icon_max, skate_theme::tile_light, 0x1c0, u * 2);
const std::string mark = "MAP";
draw->AddText(heading, 56 * u, ImVec2(left + (icon - width_of(heading, 56 * u, mark)) * 0.5f, y + (icon - 56 * u) * 0.5f), theme::muted, mark.c_str());
}
// Beside it, the map's name and what is known of the mod, as a session's facts are listed.
const float text_left = left + icon + between;
float text_y = y;
draw->AddText(heading, name_size, ImVec2(text_left, text_y), theme::paper, card.map.c_str(), nullptr, column);
text_y += name_extent.y + 22 * u;
for (const auto& fact : facts) {
draw->AddText(body, fact_size, ImVec2(text_left, text_y), theme::muted, fact.name);
draw->PushClipRect(ImVec2(text_left + 220 * u, text_y - fact_size), ImVec2(right, text_y + fact_size * 2), true);
draw->AddText(bold, fact_size, ImVec2(std::max(text_left + 220 * u, right - width_of(bold, fact_size, fact.value)), text_y), theme::paper, fact.value.c_str());
draw->PopClipRect();
text_y += 54 * u;
}
y += top_block + inset;
if (!card.description.empty()) {
draw->AddText(body, text_size, ImVec2(left, y), theme::muted, card.description.c_str(), nullptr, inner);
y += described.y + 36 * u;
}
if (progress) {
const float share = downloading ? (card.total ? static_cast<float>(card.received) / static_cast<float>(card.total) : -1.0f)
: (card.steps ? static_cast<float>(card.step) / static_cast<float>(card.steps) : -1.0f);
draw->AddRectFilled(ImVec2(left, y), ImVec2(right, y + bar), skate_theme::tile_light, 4 * u);
if (share >= 0.0f) draw->AddRectFilled(ImVec2(left, y), ImVec2(left + inner * std::clamp(share, 0.0f, 1.0f), y + bar), theme::blue, 4 * u);
else {
// How far is not known: a block going back and forth.
const float at = static_cast<float>(std::fmod(ImGui::GetTime(), 2.0));
const float where = (at < 1.0f ? at : 2.0f - at) * (inner * 0.75f);
draw->AddRectFilled(ImVec2(left + where, y), ImVec2(left + where + inner * 0.25f, y + bar), theme::blue, 4 * u);
}
y += bar + 30 * u;
}
draw->AddText(body, text_size, ImVec2(left, y), asking ? theme::paper : theme::muted, status.c_str(), nullptr, inner);
y += status_extent.y;
if (downloading) {
y += 16 * u;
draw->AddText(body, label_size, ImVec2(left, y), theme::muted, "You can keep skating while it downloads.");
return;
}
if (!asking) return;
y += 30 * u;
// The game's two looks for a button: the yellow note for the thing to do, a tile for the
// way out. The picked one is the lit one: the note full yellow in a blue edge, the tile blue.
const float button_width = (inner - 24 * u) * 0.5f;
const auto button = [&](float at, bool yes, unsigned seed) {
const ImVec2 a(at, y), b(at + button_width, y + tall);
const bool picked = card.choice == yes;
const bool hovered = clickable && ImGui::IsMouseHoveringRect(a, b, false);
const bool lit = picked || hovered;
const ImU32 fill = yes ? (lit ? sticky : sticky_dim) : (lit ? theme::blue : skate_theme::tile_light);
skate_theme::rough_rect(draw, a, b, fill, seed, u * 2);
if (yes && picked) draw->AddRect(a, b, theme::blue, 0, 0, 6 * u);
const std::string label = yes ? (card.installed ? "SWITCH ON" : "DOWNLOAD") : "CANCEL";
const float size = 40 * u;
draw->AddText(bold, size, ImVec2(a.x + (button_width - width_of(bold, size, label)) * 0.5f, a.y + (tall - size) * 0.5f),
yes || lit ? skate_theme::black : theme::paper, label.c_str());
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left))
if (const auto reply = answer.load()) reply(yes);
};
button(left, true, 0x51);
button(left + button_width + 24 * u, false, 0x52);
// The controls, under the card and in its middle.
float x = min.x + (width - hints_width) * 0.5f;
const float hint_y = max.y + 34 * u;
for (const auto& hint : hints) x += theme::keycap(draw, bold, key_scale, ImVec2(x, hint_y), hint.key.c_str(), hint.what) + hint_gap;
}
} // namespace dingosdk::overlay::detail