Files
ReSkate/Extension/UI/Overlay/game_images.cpp
T
AyeZeeBB 89a97caed7 Plugins: an item grid's own list and marks, items worn on top, keys kept from the game, the menu's category icons
General APIs from pull request 269 (the player's own item tabs and outfit layers), whose tabs,
panels and prompts are now a plugin's (contrib/plugins/item-tabs, kept on this machine):
- reskate.menu.grid_show(keys): a plugin's list in the item grid that has the focus, drawn from
  every grid of the screen; reskate.menu.grid_mark(keys): the game's check on tiles. menu.grid()
  also says whether a list shows, whether it is empty, and where the grid's own prompts end.
- reskate.outfit.on_top(items) and on_top_state(): items worn on top of the outfit in the slots it
  leaves empty, the item given the slot's category, a slot tried and left if the game reports a
  failed load (ReSkate.layer_slots.txt remembers which load where); paid store items refused.
  Part of the shared outfit service, with reskate.outfit.set.
- reskate.input.game_keys(keys): presses the game does not see, by any way it reads the keyboard;
  "Delete" is a key name.
- d:game_image draws the customization menu's 17 category icons ("cas_tops", ...).
dingosdk_plugin_tests skips the test of a contrib plugin that is not on this machine.
2026-10-21 19:18:01 -05:00

285 lines
15 KiB
C++

#include "game_images.h"
#include "Extension/Skater/xray_skeleton.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/ui_textures.h"
#include "Engine/Vfs/game_textures.h"
#include "Engine/Vfs/item_thumbnails.h"
#include "Extension/UI/Overlay/overlay_internal.h"
#include "Extension/UI/skate_theme.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <future>
#include <optional>
namespace dingosdk::overlay {
namespace {
namespace ui = addr::ui_textures;
namespace theme = dingosdk::skate_theme;
// The pictures, by the names plugins give them.
enum Picture : std::size_t { loading_board = 13, first_icon = 14, picture_count = 31 };
constexpr std::array<std::string_view, picture_count> names{
"stopwatch", "wipeout", "wipeout_broken", "spread_eagle", "airtime", "gap_height", "flaming_wheel", "roll",
"thrasher", "brush_bar", "rough_tile", "scratches", "streak", "loading_board",
"cas_tops", "cas_bottoms", "cas_shoes", "cas_outfit", "cas_fullbody", "cas_headwear", "cas_headaccessories", "cas_eyewear", "cas_handwear", "cas_socks", "cas_deck", "cas_trucks", "cas_wheels", "cas_gripcolor", "cas_grippattern", "cas_sticker", "cas_decorator_vfxcosmetic"};
// The customization menu's category icons (the pictures from first_icon on): its tabs', white on clear,
// in the bundle with the rest of the core game assets, which is looked for among the game's TOCs.
// (Pull request 269, the player's own item tabs.)
constexpr std::string_view icon_bundle = "win32/levels/game/bam_levelroot/bam_coregameassets";
constexpr std::uint32_t icon_side = 96;
struct Source {
const ui::Texture* texture;
std::uint32_t side; // fitted into a side x side square, its aspect kept
bool brightness{}; // white with the texture's brightness as alpha (light marks on black), else its own alpha
bool colours{}; // its own colours, as it is (the loading board)
};
// The loading board's first place stands for it; decode tries all of ui::loading_board's.
constexpr std::array<Source, first_icon> sources{{
{&ui::stopwatch, 64}, {&ui::wipeout, 64}, {&ui::wipeout_broken, 64}, {&ui::spread_eagle, 64},
{&ui::airtime, 64}, {&ui::gap_height, 64}, {&ui::flaming_wheel, 64}, {&ui::roll, 64},
{&ui::thrasher_wordmark, 512}, {&ui::brush_bar, 512}, {&ui::rough_tile, 256}, {&ui::scratches, 512, true},
{&ui::streak, 512}, {&ui::loading_board[0], 1152, false, true},
}};
using Images = std::array<std::optional<frostbite::Image>, picture_count>;
struct Reads {
std::mutex mutex;
bool started{};
std::future<Images> pending;
Images decoded; // kept: every atlas the overlay builds (one per swapchain) needs them
};
Reads& reads() {
static auto* state = new Reads;
return *state;
}
// The picture's part of the texture, white: with its own alpha, or its brightness as alpha.
frostbite::Image cut(const frostbite::Image& image, const ui::Region& region, bool brightness) {
const auto at = [](float fraction, std::uint32_t size) { return static_cast<std::uint32_t>(fraction * static_cast<float>(size) + 0.5f); };
if (!image.width || !image.height || image.rgba.size() != std::size_t{image.width} * image.height * 4)
throw std::runtime_error("the texture is empty");
const auto x0 = std::min(at(region.left, image.width), image.width - 1), y0 = std::min(at(region.top, image.height), image.height - 1);
const auto x1 = std::clamp(at(region.right, image.width), x0 + 1, image.width);
const auto y1 = std::clamp(at(region.bottom, image.height), y0 + 1, image.height);
frostbite::Image result{x1 - x0, y1 - y0, std::vector<std::uint8_t>(std::size_t{x1 - x0} * (y1 - y0) * 4)};
for (std::uint32_t y = y0; y < y1; ++y)
std::memcpy(result.rgba.data() + std::size_t{y - y0} * result.width * 4,
image.rgba.data() + (std::size_t{y} * image.width + x0) * 4, std::size_t{result.width} * 4);
for (std::size_t i = 0; i < result.rgba.size(); i += 4) {
auto* p = result.rgba.data() + i;
if (brightness) p[3] = std::max({p[0], p[1], p[2]});
p[0] = p[1] = p[2] = 255;
}
return result;
}
Images decode() {
Images result;
try {
vfs::GameTextures textures(xray::game_folder());
for (std::size_t index = 0; index < first_icon; ++index) {
const auto& source = sources[index];
if (source.colours) {
for (const auto& place : ui::loading_board) {
try {
result[index] = textures.read(place.toc, place.bundle, place.name, source.side);
break;
} catch (const std::exception& error) {
logging::log(logging::Level::debug, logging::Channel::graphics, "The loading board is not in {}: {}.", place.bundle, error.what());
}
}
if (!result[index]) logging::write(logging::Level::warning, logging::Channel::graphics, "The game's loading board picture is unavailable.");
continue;
}
try {
result[index] = cut(textures.read(source.texture->toc, source.texture->bundle, source.texture->name, source.side),
source.texture->region, source.brightness);
} catch (const std::exception& error) {
logging::log(logging::Level::warning, logging::Channel::graphics, "The game's picture {} is unavailable: {}.",
source.texture->name, error.what());
}
}
} catch (const std::exception& error) {
logging::log(logging::Level::warning, logging::Channel::graphics, "The game's pictures are unavailable: {}.", error.what());
}
try {
std::vector<std::string> wanted;
for (std::size_t index = first_icon; index < picture_count; ++index)
wanted.push_back("ui/textures/icons/cas/img_cas_" + std::string(names[index].substr(4)) + "_128_128_128");
const auto read = vfs::read_ui_textures(xray::game_folder(), icon_bundle, wanted, icon_side);
for (std::size_t i = 0; i < wanted.size(); ++i)
for (const auto& texture : read.thumbnails)
if (texture.item == wanted[i] && texture.image.rgba.size() == std::size_t{icon_side} * icon_side * 4) result[first_icon + i] = texture.image;
std::size_t found{};
for (std::size_t index = first_icon; index < picture_count; ++index) found += result[index].has_value();
if (found < wanted.size())
logging::log(logging::Level::warning, logging::Channel::graphics, "The menu's category icons: {} of {} found.", found, wanted.size());
} catch (const std::exception& error) {
logging::log(logging::Level::warning, logging::Channel::graphics, "The menu's category icons are unavailable: {}.", error.what());
}
return result;
}
// The images in the atlas being built or drawn with: render thread only.
struct Loaded {
ImFontAtlas* atlas{};
bool filled{};
std::array<int, picture_count> rects{}; // each one's custom rect in the atlas; -1 none
};
Loaded& loaded() {
static Loaded state;
return state;
}
// A picture in the atlas the overlay draws with: where, and its texture coordinates.
struct Found {
const ImFontAtlasCustomRect* rect{};
ImVec2 uv0, uv1;
};
bool find(Picture picture, Found& found) {
const auto& l = loaded();
if (!l.filled || !l.atlas || ImGui::GetIO().Fonts != l.atlas || !l.atlas->TexID || l.rects[picture] < 0) return false;
found.rect = l.atlas->GetCustomRectByIndex(l.rects[picture]);
if (!found.rect || !found.rect->IsPacked() || !found.rect->Width || !found.rect->Height) return false;
l.atlas->CalcCustomRectUV(found.rect, &found.uv0, &found.uv1);
return true;
}
// Fitted into the box with its aspect kept, centred: an icon, a logo; of a flipbook (the loading
// board), its frame `frame`.
bool draw_image(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, ImU32 tint, int frame) {
Found found;
if (!find(picture, found)) return false;
const auto* rect = found.rect;
float width = rect->Width, height = rect->Height;
if (picture == loading_board) {
const int f = std::clamp(frame, 0, ui::loading_board_frames - 1);
const float du = (found.uv1.x - found.uv0.x) / ui::loading_board_columns, dv = (found.uv1.y - found.uv0.y) / ui::loading_board_rows;
found.uv0 = ImVec2(found.uv0.x + du * static_cast<float>(f % ui::loading_board_columns), found.uv0.y + dv * static_cast<float>(f / ui::loading_board_columns));
found.uv1 = ImVec2(found.uv0.x + du, found.uv0.y + dv);
width /= ui::loading_board_columns;
height /= ui::loading_board_rows;
}
const float scale = std::min((max.x - min.x) / width, (max.y - min.y) / height);
const ImVec2 size(width * scale, height * scale);
const ImVec2 at(min.x + (max.x - min.x - size.x) * 0.5f, min.y + (max.y - min.y - size.y) * 0.5f);
draw->AddImage(loaded().atlas->TexID, at, ImVec2(at.x + size.x, at.y + size.y), found.uv0, found.uv1, tint);
return true;
}
// Its body (ui_textures.h Texture::body) stretched onto the box, the rest of it around: a brush
// stroke's bar on the box, its splatter beyond.
bool draw_shape(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, ImU32 tint) {
Found found;
if (!find(picture, found)) return false;
const auto& body = sources[picture].texture->body;
const float width = (max.x - min.x) / (body.right - body.left), height = (max.y - min.y) / (body.bottom - body.top);
const ImVec2 at(min.x - body.left * width, min.y - body.top * height);
draw->AddImage(loaded().atlas->TexID, at, ImVec2(at.x + width, at.y + height), found.uv0, found.uv1, tint);
return true;
}
// Nine-sliced onto the box: its corners (Texture::slice of the picture) kept `edge` pixels square,
// its edges stretched along them and its middle across the rest. A box smaller than two edges
// shrinks them to half of it.
bool draw_panel(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, float edge, ImU32 tint) {
Found found;
if (!find(picture, found)) return false;
const float slice = sources[picture].texture->slice;
const float edge_x = std::clamp(edge, 0.0f, (max.x - min.x) * 0.5f), edge_y = std::clamp(edge, 0.0f, (max.y - min.y) * 0.5f);
const float uv_x = slice * (found.uv1.x - found.uv0.x), uv_y = slice * (found.uv1.y - found.uv0.y);
const std::array<float, 4> xs{min.x, min.x + edge_x, max.x - edge_x, max.x}, ys{min.y, min.y + edge_y, max.y - edge_y, max.y};
const std::array<float, 4> us{found.uv0.x, found.uv0.x + uv_x, found.uv1.x - uv_x, found.uv1.x};
const std::array<float, 4> vs{found.uv0.y, found.uv0.y + uv_y, found.uv1.y - uv_y, found.uv1.y};
for (std::size_t row = 0; row < 3; ++row)
for (std::size_t column = 0; column < 3; ++column)
draw->AddImage(loaded().atlas->TexID, ImVec2(xs[column], ys[row]), ImVec2(xs[column + 1], ys[row + 1]),
ImVec2(us[column], vs[row]), ImVec2(us[column + 1], vs[row + 1]), tint);
return true;
}
} // namespace
void prepare_game_images() noexcept {
try {
auto& state = reads();
std::lock_guard lock(state.mutex);
if (std::exchange(state.started, true)) return;
state.pending = std::async(std::launch::async, &decode);
} catch (...) {
logging::write(logging::Level::warning, logging::Channel::graphics, "The game's pictures are unavailable: their reading could not start.");
}
}
void reserve_game_images(ImFontAtlas& atlas, std::chrono::milliseconds wait) noexcept {
auto& l = loaded();
l = {};
l.rects.fill(-1);
try {
auto& state = reads();
std::lock_guard lock(state.mutex);
// A read still running stays pending for the next atlas.
if (state.pending.valid() && state.pending.wait_for(wait) == std::future_status::ready) state.decoded = state.pending.get();
for (std::size_t index = 0; index < picture_count; ++index) {
const auto& image = state.decoded[index];
if (!image) continue;
if (!l.atlas) {
atlas.TexDesiredWidth = std::max(atlas.TexDesiredWidth, 2048);
atlas.Flags |= ImFontAtlasFlags_NoPowerOfTwoHeight;
l.atlas = &atlas;
}
l.rects[index] = atlas.AddCustomRectRegular(static_cast<int>(image->width), static_cast<int>(image->height));
}
} catch (...) {
l = {};
}
}
void fill_game_images(ImFontAtlas& atlas) noexcept {
auto& l = loaded();
if (l.atlas != &atlas) return;
try {
unsigned char* pixels{};
int width{}, height{};
atlas.GetTexDataAsRGBA32(&pixels, &width, &height);
if (!pixels || width <= 0 || height <= 0) {
l = {};
return;
}
auto& state = reads();
std::lock_guard lock(state.mutex);
for (std::size_t index = 0; index < picture_count; ++index) {
if (l.rects[index] < 0) continue;
const auto& image = *state.decoded[index];
const auto* rect = atlas.GetCustomRectByIndex(l.rects[index]);
if (!rect || !rect->IsPacked() || rect->X + image.width > static_cast<unsigned>(width) ||
rect->Y + image.height > static_cast<unsigned>(height)) {
l = {};
return;
}
for (std::uint32_t y = 0; y < image.height; ++y)
std::memcpy(pixels + ((static_cast<std::size_t>(rect->Y) + y) * width + rect->X) * 4,
image.rgba.data() + static_cast<std::size_t>(y) * image.width * 4, static_cast<std::size_t>(image.width) * 4);
}
atlas.TexPixelsUseColors = true;
l.filled = true;
} catch (...) {
l = {};
}
}
bool game_image_known(std::string_view name) noexcept { return std::find(names.begin(), names.end(), name) != names.end(); }
bool draw_game_image(ImDrawList* draw, std::string_view name, ImVec2 min, ImVec2 max, ImU32 tint, GameImageFit fit, float edge, int frame) {
const auto at = std::find(names.begin(), names.end(), name);
if (at == names.end()) return false;
const auto picture = static_cast<Picture>(at - names.begin());
if (picture == loading_board) return draw_image(draw, picture, min, max, tint, frame); // a flipbook's frame, fitted
if (picture >= first_icon) return draw_image(draw, picture, min, max, tint, 0); // an icon: always fitted
switch (fit) {
case GameImageFit::contain: return draw_image(draw, picture, min, max, tint, 0);
case GameImageFit::shape: return draw_shape(draw, picture, min, max, tint);
case GameImageFit::panel: return draw_panel(draw, picture, min, max, edge, tint);
}
return false;
}
float thrasher_arch() noexcept { return ui::thrasher_wordmark_arch; }
}