Files
ReSkate/Extension/UI/Overlay/item_browser_overlay.cpp
AyeZeeBB ab8c9dc6ae Customization: a mod's item card shows the mod, with its icon, inside the game's item card
A mod's item (or one of the game's own a mod changes) keeps three empty description lines, so
the game's item card, which grows upward from a fixed bottom edge, always has room in the same
place; the overlay draws the mod there: its icon (decoded off the render thread into a fourth
texture slot, the last 32 kept), a MOD or CHANGED BY MOD tag, its name, author, version and how
many items it has. The card is placed as the game places its own, measured at many window sizes:
by the smaller of its scales, a 21:9 frame centred on wider screens (the search bar follows it
too). The warning sign the game's focus script gives any description is taken off that notice
each tick while it shows the reserved lines.

The merge's item list records each mod's version and folder (an older list falls back to the mods
loaded at launch). The item grids now run every client tick instead of at the 500 ms
customization pace, follow the highlight between their 40 ms passes, and let go of a grid as soon
as its screen closes, so the card, the search bar and favorites keep up with the player.
2026-10-17 12:04:38 -05:00

349 lines
17 KiB
C++

#include "overlay_internal.h"
#include "Engine/Core/Platform/path_text.h"
#include <imgui_internal.h>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <mutex>
#include <thread>
// The skater item grids' search bar and key prompts, drawn over the game's own menu in its style.
// The grids themselves are the game's (Extension/Customization/item_browser.cpp); this only shows
// what is going on and takes the typing.
namespace dingosdk::overlay {
namespace {
std::atomic<const ItemBrowserHost*> item_browser_host{};
}
void set_item_browser(const ItemBrowserHost* host) noexcept { item_browser_host.store(host); }
} // namespace dingosdk::overlay
namespace dingosdk::overlay::detail {
namespace {
struct Panel {
ItemBrowserView view;
std::array<char, 96> input{};
bool stick_down{};
std::string pushed; // the search text the host was last given
};
Panel& panel() { static auto* value = new Panel; return *value; }
const ItemBrowserHost* host() { return item_browser_host.load(); }
// The icon of the mod the highlighted item is from: read and decoded off this thread, uploaded by
// the render thread to the SRV heap's fourth slot (overlay_render.cpp, upload_mod_icon).
struct ModIcon {
std::mutex mutex;
std::string wanted, tried; // the icon asked for, and the last one the loader finished
bool loading{};
// Icons decoded lately (a mod with none as no pixels), so going back to a mod's item shows its
// icon at once: at most `kept`, 64 KB each.
static constexpr std::size_t kept = 32;
std::vector<std::pair<std::string, CardPixels>> decoded;
CardPixels current; // kept to upload again after a device reset
std::string current_path;
bool waiting{};
unsigned serial{};
ImTextureID texture{};
std::string ready_path; // the icon the texture holds
};
ModIcon& mod_icon() { static auto* value = new ModIcon; return *value; } // process lifetime
void load_mod_icons() {
auto& m = mod_icon();
for (;;) {
std::string path;
{
std::lock_guard lock(m.mutex);
if (m.wanted == m.tried) {
m.loading = false;
return;
}
path = m.wanted;
}
CardPixels pixels;
bool ok{};
try {
std::ifstream input(std::filesystem::path(std::u8string(path.begin(), path.end())), std::ios::binary);
const std::string png{std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
ok = !png.empty() && png.size() <= 4 * 1024 * 1024 && decode_picture(png, pixels, 128);
} catch (...) {}
if (!ok) pixels = {};
std::lock_guard lock(m.mutex);
m.tried = path;
if (m.decoded.size() >= ModIcon::kept) m.decoded.erase(m.decoded.begin());
m.decoded.emplace_back(path, pixels);
if (ok && path == m.wanted) {
pixels.serial = ++m.serial;
m.current = std::move(pixels);
m.current_path = path;
m.waiting = true;
}
}
}
// Asks for this icon (empty: none); the icon's texture once it is on the GPU, else 0.
ImTextureID want_mod_icon(const std::string& path) {
auto& m = mod_icon();
std::lock_guard lock(m.mutex);
if (path.empty()) return {};
if (m.wanted != path) {
m.wanted = path;
// One decoded lately goes straight to the GPU (in the next frame).
const auto known = std::ranges::find(m.decoded, path, &std::pair<std::string, CardPixels>::first);
if (known != m.decoded.end()) {
m.tried = path;
if (!known->second.rgba.empty() && m.ready_path != path) {
m.current = known->second;
m.current.serial = ++m.serial;
m.current_path = path;
m.waiting = true;
}
} else if (!m.loading) {
try {
std::thread(load_mod_icons).detach();
m.loading = true;
} catch (...) {}
}
}
return m.ready_path == path ? m.texture : ImTextureID{};
}
// A five-pointed star, the mark favorites carry on their tiles.
void star(ImDrawList* draw, ImVec2 centre, float radius, ImU32 colour) {
std::array<ImVec2, 10> points;
for (int i = 0; i < 10; ++i) {
const float angle = -1.5707963f + static_cast<float>(i) * 0.6283185f, r = i % 2 ? radius * 0.42f : radius;
points[static_cast<std::size_t>(i)] = ImVec2(centre.x + std::cos(angle) * r, centre.y + std::sin(angle) * r);
}
// Concave: fan it from the centre.
for (int i = 0; i < 10; ++i)
draw->AddTriangleFilled(centre, points[static_cast<std::size_t>(i)], points[static_cast<std::size_t>((i + 1) % 10)], colour);
}
// Where the game's menu is across the screen: its left edge and width. The full width up to 21:9;
// on a wider screen a 21:9 frame in the middle.
std::pair<float, float> menu_frame(ImVec2 display) {
const float width = std::min(display.x, display.y * 21.0f / 9.0f);
return {(display.x - width) * 0.5f, width};
}
// `text` cut short with "..." to fit `width`.
std::string fitted(ImFont* font, float size, const std::string& text, float width) {
if (font->CalcTextSizeA(size, FLT_MAX, 0, text.c_str()).x <= width) return text;
std::string cut = text;
while (!cut.empty()) {
do cut.pop_back(); while (!cut.empty() && (static_cast<unsigned char>(cut.back()) & 0xc0) == 0x80);
while (!cut.empty() && cut.back() == ' ') cut.pop_back();
if (font->CalcTextSizeA(size, FLT_MAX, 0, (cut + "...").c_str()).x <= width) break;
}
return cut + "...";
}
// Which mod the highlighted item is from, inside the game's own item card (the rarity tag, the
// name and the description at the bottom of the screen): the mod's icon, what it is to the item,
// its name, who made it, its version and how many items it has. The card grows upward from a
// fixed bottom edge as its text wraps; a mod's item has three empty description lines
// (Customization/local_cosmetic_catalog.cpp), so its last three lines are always the same place.
// As measured on 1280x720 and on 2000 wide at nine heights: the game lays its menu out across
// the screen's width up to 21:9, centred past that (menu_frame); it sizes it by the smaller of its
// two scales (`u`); the prompt row's bottom margin goes with the screen's height, the item card
// sits on the prompt row, its left edge is 45.25% of the frame and it is 319 wide.
void draw_mod_card(ImDrawList* draw, const ItemBrowserView& view, ImFont* heading, ImFont* body) {
if (view.mod_title.empty()) return;
const auto display = ImGui::GetIO().DisplaySize;
const auto [left, width] = menu_frame(display);
const float u = std::min(width / 1280.0f, display.y / 720.0f);
const float prompts_top = display.y * (1.0f - 35.0f / 720.0f) - 37 * u; // Hide Outfit, Equip, Back
const float card_bottom = prompts_top - 6 * u; // the game's item card
const float card_left = left + width * 0.4525f, card_width = 319 * u;
// Its last three lines (20 each, over an 8 bottom margin), inset like its text. (The warning
// sign the game gives a description is taken off a mod item's: item_browser.cpp, quiet_notices.)
const float pad = 6 * u, icon = 46 * u;
const ImVec2 a(card_left + 12 * u - pad, card_bottom - 68 * u), b(card_left + card_width - 12 * u, card_bottom - 8 * u);
// The icon, or the title's first letter on a plain tile when the mod has none.
const ImVec2 i0(a.x + pad, a.y + pad), i1(i0.x + icon, i0.y + icon);
if (const auto texture = want_mod_icon(view.mod_icon)) {
draw->AddImage(texture, i0, i1);
} else {
draw->AddRectFilled(i0, i1, skate_theme::tile_light);
const char letter[2]{static_cast<char>(std::toupper(static_cast<unsigned char>(view.mod_title.front()))), 0};
const float size = 24 * u;
const auto extent = heading->CalcTextSizeA(size, FLT_MAX, 0, letter);
draw->AddText(heading, size, ImVec2(i0.x + (icon - extent.x) * 0.5f, i0.y + (icon - extent.y) * 0.5f), theme::muted, letter);
}
const float text_x = i1.x + 10 * u, right = b.x - pad;
// The tag, like the game's rarity tag, then the name beside it.
const char* tag = view.mod_changed ? "CHANGED BY MOD" : "MOD";
const float tag_size = 10 * u, title_size = 15 * u, line_size = 12 * u;
const auto tag_extent = heading->CalcTextSizeA(tag_size, FLT_MAX, 0, tag);
const float row = a.y + pad + (icon - title_size - 5 * u - line_size) * 0.5f;
const ImVec2 t0(text_x, row + (title_size - tag_extent.y - 4 * u) * 0.5f);
const ImVec2 t1(t0.x + tag_extent.x + 10 * u, t0.y + tag_extent.y + 4 * u);
draw->AddRectFilled(t0, t1, view.mod_changed ? skate_theme::bar : skate_theme::blue);
draw->AddText(heading, tag_size, ImVec2(t0.x + 5 * u, t0.y + 2 * u), view.mod_changed ? skate_theme::black : theme::paper, tag);
const float name_x = t1.x + 7 * u;
draw->AddText(heading, title_size, ImVec2(name_x, row), theme::paper,
fitted(heading, title_size, view.mod_title, right - name_x).c_str());
std::string line = view.mod_author.empty() ? std::string() : "by " + view.mod_author;
const auto add = [&](const std::string& part) { if (!part.empty()) line += (line.empty() ? "" : " | ") + part; };
if (!view.mod_version.empty()) add("v" + view.mod_version);
if (view.mod_items) add(std::to_string(view.mod_items) + (view.mod_items == 1 ? " item" : " items"));
draw->AddText(body, line_size, ImVec2(text_x, row + title_size + 5 * u), theme::muted,
fitted(body, line_size, line, right - text_x).c_str());
}
} // namespace
bool take_mod_icon_pixels(CardPixels& out) {
auto& m = mod_icon();
std::lock_guard lock(m.mutex);
if (!m.waiting) return false;
m.waiting = false;
out = m.current;
return true;
}
void mod_icon_uploaded(ImTextureID texture, unsigned serial) {
auto& m = mod_icon();
std::lock_guard lock(m.mutex);
if (serial != m.serial) return;
m.texture = texture;
m.ready_path = m.current_path;
}
void mod_icon_lost() {
auto& m = mod_icon();
std::lock_guard lock(m.mutex);
m.texture = {};
m.ready_path.clear();
if (!m.current.rgba.empty()) m.waiting = true;
}
bool item_browser_open() { return panel().view.open; }
void item_browser_key(ItemBrowserKey key) {
const auto* h = host();
if (!h) return;
if (key == ItemBrowserKey::favorite && h->toggle_favorite) h->toggle_favorite();
if (key == ItemBrowserKey::filter && h->cycle_filter) h->cycle_filter();
}
void item_browser_search_cleared() {
auto& p = panel();
p.input.fill(0);
if (const auto* h = host(); h && h->set_search) h->set_search("");
}
bool item_browser_pending() {
auto& s = state();
auto& p = panel();
const auto* h = host();
if (!h || !h->view) return false;
// Every frame: the card under the game's item card follows the highlight without a lag.
try { p.view = h->view(); } catch (...) { p.view = {}; }
if (!p.view.open) {
s.item_search_visible.store(false);
p.stick_down = false;
return false;
}
// Left stick click: favorite. Read here, once a presented frame, only while a grid is open.
dingosdk::ControllerInput pad;
DingoSDKOverlayReadControllerInput(&pad);
const bool stick = pad.available && (pad.buttons & XINPUT_GAMEPAD_LEFT_THUMB) != 0;
if (stick && !p.stick_down) item_browser_key(ItemBrowserKey::favorite);
p.stick_down = stick;
return true;
}
void draw_item_browser() {
auto& s = state();
auto& p = panel();
const auto* h = host();
if (!h || !p.view.open) return;
const auto& view = p.view;
auto* heading = s.menu.bold ? s.menu.bold : ImGui::GetFont();
auto* body = s.menu.body ? s.menu.body : ImGui::GetFont();
const auto display = ImGui::GetIO().DisplaySize;
// Sized like the game's own item card and prompts, which are laid out for a 2160-line screen.
const float scale = std::clamp(display.y / 1080.0f, 0.75f, 3.0f) * 1.35f;
const float width = 350.0f * scale, height = 42.0f * scale, margin = 0.074f * display.y;
// At the right of the game's menu, which stops widening at 20:9.
const auto [frame_left, frame_width] = menu_frame(display);
const ImVec2 at(frame_left + frame_width - margin - width, margin);
const bool typing = s.item_search_visible.load();
// The search field: a dark tile like the game's item card, blue-edged while it takes the keys.
auto* draw = ImGui::GetBackgroundDrawList();
skate_theme::rough_rect(draw, at, ImVec2(at.x + width, at.y + height), IM_COL32(18, 18, 19, 235), 71, scale);
if (typing) draw->AddRect(ImVec2(at.x - scale, at.y - scale), ImVec2(at.x + width + scale, at.y + height + scale), theme::blue, 0, 0, 2.5f * scale);
const ImVec2 lens(at.x + 24 * scale, at.y + height * 0.46f);
draw->AddCircle(lens, 8 * scale, theme::paper, 0, 2.2f * scale);
draw->AddLine(ImVec2(lens.x + 6 * scale, lens.y + 6 * scale), ImVec2(lens.x + 12 * scale, lens.y + 12 * scale), theme::paper, 2.6f * scale);
const std::string count = view.shown == view.total ? std::to_string(view.total)
: std::to_string(view.shown) + " / " + std::to_string(view.total);
const float count_size = 15 * scale;
const auto count_extent = body->CalcTextSizeA(count_size, FLT_MAX, 0, count.c_str());
draw->AddText(body, count_size, ImVec2(at.x + width - count_extent.x - 14 * scale, at.y + (height - count_extent.y) * 0.5f),
theme::muted, count.c_str());
const float text_x = at.x + 46 * scale, text_width = width - 46 * scale - count_extent.x - 28 * scale;
if (typing) {
ImGui::SetNextWindowPos(ImVec2(text_x, at.y), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(text_width, height), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, (height - 20 * scale) * 0.5f));
ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_Text, theme::paper);
ImGui::PushStyleColor(ImGuiCol_TextSelectedBg, IM_COL32(1, 131, 255, 110));
constexpr auto flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground;
if (ImGui::Begin("##reskate_item_search", nullptr, flags)) {
ImGui::PushFont(heading);
ImGui::SetWindowFontScale(20 * scale / heading->FontSize);
if (s.item_search_focus_requested.exchange(false)) {
std::snprintf(p.input.data(), p.input.size(), "%s", view.search.c_str());
const auto id = ImGui::GetID("##reskate_item_search_input");
if (auto* active = ImGui::GetInputTextState(id); active && ImGui::GetActiveID() == id)
active->ReloadUserBufAndMoveToEnd();
ImGui::SetKeyboardFocusHere();
}
ImGui::SetNextItemWidth(-FLT_MIN);
const bool entered = ImGui::InputTextWithHint("##reskate_item_search_input", "Type to search", p.input.data(),
p.input.size(), ImGuiInputTextFlags_EnterReturnsTrue);
// Every letter filters the grid at once.
if (h->set_search && p.pushed != p.input.data()) {
p.pushed = p.input.data();
h->set_search(p.pushed.c_str());
}
if (entered) s.item_search_visible.store(false);
ImGui::PopFont();
}
ImGui::End();
ImGui::PopStyleColor(4);
ImGui::PopStyleVar(3);
} else {
p.pushed = view.search;
const float size = 20 * scale;
const auto text = view.search.empty() ? std::string("Search") : view.search;
draw->PushClipRect(ImVec2(text_x, at.y), ImVec2(text_x + text_width, at.y + height), true);
draw->AddText(heading, size, ImVec2(text_x, at.y + (height - size) * 0.5f - scale),
view.search.empty() ? theme::muted : theme::paper, text.c_str());
draw->PopClipRect();
}
// Key prompts, like the game's "Esc Back".
float x = at.x;
const float y = at.y + height + 12 * scale;
const auto prompt = [&](const char* key, const std::string& label) {
x += theme::keycap(draw, body, scale, ImVec2(x, y), key, label.c_str()) + 22 * scale;
};
if (typing) {
prompt("Enter", "Done");
prompt("Esc", "Clear");
} else {
prompt("F", view.focused_favorite ? "Unfavorite" : "Favorite");
prompt("-", "Search");
prompt("X", view.filter);
}
if (view.focused_favorite && !typing)
star(draw, ImVec2(at.x - 22 * scale, at.y + height * 0.5f), 13 * scale, skate_theme::bar);
draw_mod_card(draw, view, heading, body);
}
} // namespace dingosdk::overlay::detail