From 89a97caed77f7a1c7aaa3753d6cb996e54eb9fea Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 21 Oct 2026 19:18:01 -0500 Subject: [PATCH] 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. --- Engine/Vfs/item_thumbnails.cpp | 86 +++-- Engine/Vfs/item_thumbnails.h | 7 + Extension/Customization/item_browser.cpp | 229 +++++++++++--- Extension/Customization/item_browser.h | 12 + Extension/Customization/item_browser_order.h | 1 + Extension/Plugins/Test/plugin_tests.cpp | 240 ++++++++++---- Extension/Plugins/plugin_api_capture.cpp | 86 ++++- Extension/Plugins/plugin_api_services.cpp | 25 +- Extension/Plugins/plugin_game.h | 16 + Extension/Plugins/plugin_game_runtime.cpp | 27 +- Extension/Plugins/plugin_ui.h | 6 +- Extension/Skater/outfit_overlay.cpp | 293 +++++++++++++++++- Extension/Skater/outfit_overlay.h | 13 + Extension/UI/Overlay/game_images.cpp | 31 +- Extension/UI/Overlay/input_capture.cpp | 48 ++- Extension/UI/Overlay/input_capture.h | 2 + Extension/UI/Overlay/item_browser_overlay.cpp | 20 ++ Extension/UI/Overlay/overlay.h | 11 + Extension/UI/Overlay/overlay_input.cpp | 1 + Extension/World/load_error_guard.cpp | 8 +- Extension/World/load_error_guard.h | 2 + 21 files changed, 1005 insertions(+), 159 deletions(-) diff --git a/Engine/Vfs/item_thumbnails.cpp b/Engine/Vfs/item_thumbnails.cpp index 09daeaeb..4b3753f8 100644 --- a/Engine/Vfs/item_thumbnails.cpp +++ b/Engine/Vfs/item_thumbnails.cpp @@ -2,6 +2,7 @@ #include "game_bundles.h" #include "Engine/Core/Platform/path_text.h" #include +#include #include namespace dingosdk::vfs { @@ -17,50 +18,85 @@ bool build_kit_item(std::string_view item) { } } -ThumbnailRead read_build_kit_thumbnails(const std::filesystem::path& gameRoot, std::uint32_t side) { - const GameData data(gameRoot); - // The bundle's superbundle is looked up rather than assumed, so a game - // update that moves it only costs a scan of the TOCs. +namespace { +struct Opened { fb::TocDocument toc; std::optional bundle; +}; +// The bundle's superbundle is looked up rather than assumed, so a game update that moves it only +// costs a scan of the TOCs. +Opened open_bundle(const GameData& data, const std::filesystem::path& gameRoot, std::string_view name) { + Opened opened; std::error_code error; std::string problems; for (std::filesystem::recursive_directory_iterator it(gameRoot / L"Data" / L"Win32", error), end; - it != end && !error && !bundle; it.increment(error)) { + it != end && !error && !opened.bundle; it.increment(error)) { if (!it->is_regular_file(error) || it->path().extension() != L".toc") continue; try { - toc = data.read_toc(std::filesystem::relative(it->path(), gameRoot / L"Data").generic_string()); - bundle = data.read_bundle(toc, bundle_name); + opened.toc = data.read_toc(std::filesystem::relative(it->path(), gameRoot / L"Data").generic_string()); + opened.bundle = data.read_bundle(opened.toc, name); } catch (const std::exception& failure) { if (problems.size() < 600) problems += "; " + path_utf8(it->path().filename()) + ": " + failure.what(); } } - if (!bundle) throw std::runtime_error("The game's thumbnail bundle was not found" + problems); + if (!opened.bundle) throw std::runtime_error("The game's bundle " + std::string(name) + " was not found" + problems); + return opened; +} +// One texture of the bundle, decoded and scaled. +fb::Image read_texture(const GameData& data, const Opened& opened, std::size_t index, std::uint32_t side) { + const auto& bundle = *opened.bundle; + const auto& resource = bundle.manifest.resources[index]; + const auto* payload = bundle.payload(fb::AssetKind::resource, index); + if (!payload) throw std::runtime_error("missing payload"); + const auto header = fb::read_texture_header(data.read(*payload), resource.resourceMeta); + // Pixels are usually in the same bundle; otherwise the TOC's own chunks. + std::vector pixels; + std::size_t chunk{}; + if (bundle.find_chunk(header.chunk, &chunk)) { + pixels = data.read(*bundle.payload(fb::AssetKind::chunk, chunk)); + } else { + const auto found = std::find_if(opened.toc.chunks.begin(), opened.toc.chunks.end(), + [&](const fb::TocChunk& entry) { return entry.guid == header.chunk; }); + if (found == opened.toc.chunks.end()) throw std::runtime_error("missing pixel chunk"); + pixels = data.read({found->location, found->offset, found->size}); + } + return fb::resize(fb::decode_texture(header, pixels, side), side, side); +} +} + +ThumbnailRead read_build_kit_thumbnails(const std::filesystem::path& gameRoot, std::uint32_t side) { + const GameData data(gameRoot); + const auto opened = open_bundle(data, gameRoot, bundle_name); ThumbnailRead result; - const auto& resources = bundle->manifest.resources; + const auto& resources = opened.bundle->manifest.resources; for (std::size_t index = 0; index < resources.size(); ++index) { const auto& resource = resources[index]; if (resource.resourceType != fb::texture_resource_type || !resource.name.starts_with(prefix)) continue; const auto item = std::string_view(resource.name).substr(prefix.size()); if (!build_kit_item(item)) continue; try { - const auto* payload = bundle->payload(fb::AssetKind::resource, index); - if (!payload) throw std::runtime_error("missing payload"); - const auto header = fb::read_texture_header(data.read(*payload), resource.resourceMeta); - // Pixels are usually in the same bundle; otherwise the TOC's own chunks. - std::vector pixels; - std::size_t chunk{}; - if (bundle->find_chunk(header.chunk, &chunk)) { - pixels = data.read(*bundle->payload(fb::AssetKind::chunk, chunk)); - } else { - const auto found = std::find_if(toc.chunks.begin(), toc.chunks.end(), - [&](const fb::TocChunk& entry) { return entry.guid == header.chunk; }); - if (found == toc.chunks.end()) throw std::runtime_error("missing pixel chunk"); - pixels = data.read({found->location, found->offset, found->size}); - } - auto image = fb::resize(fb::decode_texture(header, pixels, side), side, side); - result.thumbnails.push_back({std::string(item), std::move(image)}); + result.thumbnails.push_back({std::string(item), read_texture(data, opened, index, side)}); + } catch (...) { + ++result.failed; + } + } + return result; +} + +ThumbnailRead read_ui_textures(const std::filesystem::path& gameRoot, std::string_view bundle, + std::span names, std::uint32_t side) { + const GameData data(gameRoot); + const auto opened = open_bundle(data, gameRoot, bundle); + ThumbnailRead result; + const auto& resources = opened.bundle->manifest.resources; + for (std::size_t index = 0; index < resources.size(); ++index) { + const auto& resource = resources[index]; + if (resource.resourceType != fb::texture_resource_type || + std::find(names.begin(), names.end(), resource.name) == names.end()) + continue; + try { + result.thumbnails.push_back({resource.name, read_texture(data, opened, index, side)}); } catch (...) { ++result.failed; } diff --git a/Engine/Vfs/item_thumbnails.h b/Engine/Vfs/item_thumbnails.h index 0c2ef14d..eb511201 100644 --- a/Engine/Vfs/item_thumbnails.h +++ b/Engine/Vfs/item_thumbnails.h @@ -2,7 +2,9 @@ #include "Engine/Resource/texture.h" #include #include +#include #include +#include #include namespace dingosdk::vfs { @@ -17,4 +19,9 @@ struct ThumbnailRead { // The build-kit object thumbnails the game ships ("thumbnail/tool/own_bk*"), // read from the installed game's data and scaled to `side` x `side`. ThumbnailRead read_build_kit_thumbnails(const std::filesystem::path& gameRoot, std::uint32_t side); +// Textures of the game's own UI by their resource names (e.g. "ui/textures/icons/cas/img_cas_tops_128"), +// from the bundle that holds them, scaled to `side` x `side`; `item` is the name. Names that are not +// in the bundle are left out. +ThumbnailRead read_ui_textures(const std::filesystem::path& gameRoot, std::string_view bundle, + std::span names, std::uint32_t side); } diff --git a/Extension/Customization/item_browser.cpp b/Extension/Customization/item_browser.cpp index 8501a610..9799a057 100644 --- a/Extension/Customization/item_browser.cpp +++ b/Extension/Customization/item_browser.cpp @@ -62,6 +62,11 @@ constexpr std::uint32_t notice_icon = 0x3514a765; // NoticeViewModel.IconOverri constexpr char notice_info_texture[] = "ui/textures/icons/generic/img_generic_info_64"; // The game's own star (UI/Textures/Common/Shapes/Bespoke), as its asset is named. constexpr char star_texture[] = "UI/Textures/comMon/Shapes/Bespoke/img_Star_Fill_64"; +// The game's check marks, for the tiles plugins mark (the first that is loaded on the screen). The +// brushed one first: the menus' own hand-drawn look. (Pull request 269.) +constexpr std::array check_textures{"UI/Textures/comMon/Shapes/Bespoke/img_Icon_BrushCheckmark_256_256_256", + "UI/Textures/Common/Shapes/Bespoke/img_Icon_RoughCheckmark_White_128", + "UI/Textures/Icons/Generic/img_Generic_Check_64"}; std::uint32_t schema_of(md::Address type) { return type ? md::read(md::read(type)) : 0; } @@ -140,6 +145,7 @@ struct Grid { std::vector shown; // item models of the rows the game's list has now int focused{-1}; // row in `shown` bool failed{}; + bool held{}; // it shows a plugin's list with nothing in it: one tile, for the highlight }; struct State { @@ -147,11 +153,14 @@ struct State { md::Address manager{}; Layout layout; std::vector grids; - // A favorite's tile shows a star where its rarity icon is: its item model points at our copy of its - // rarity style, which has the star for an icon. One copy per rarity style, made when first needed. - std::map starred; // the game's style -> our starred copy + // A favorite's tile shows a star where its rarity icon is, and a tile a plugin marks a check: its + // item model points at our copy of its rarity style, which has the star or the check for an icon. + // One copy per rarity style and mark, made when first needed. + std::map, md::Value> starred; // (the game's style, 0 star / 1 check) -> our copy std::map restyled; // item model -> the game's style it had - bool star_unavailable{}; + bool star_unavailable{}, check_unavailable{}; + std::optional> show; // a plugin's list for the grid that has the focus, as of this update + Keys marked; // the tiles plugins mark, as of this update unsigned tick{}; Favorites favorites; std::string search; @@ -174,6 +183,10 @@ struct Shared { std::string search; bool search_pending{}; std::vector pokes; + std::string show_owner; + std::optional> show; + std::map, std::less<>> marks; // by owner + std::atomic asked{}; // a plugin's request waits: the next tick acts on it std::atomic toggles{}, cycles{}; std::atomic filter{-1}; std::atomic status{}; @@ -270,43 +283,64 @@ void release(const md::Context& context, Grid& grid) { } } -std::uint64_t starred_source(const State& s, std::uint64_t style) { +// The game's style our copy `style` was made from, and which mark it carries; {0, -1} for the game's own. +std::pair starred_source(const State& s, std::uint64_t style) { for (const auto& [source, copy] : s.starred) if (copy.handle == style) return source; - return 0; + return {0, -1}; +} +// The item a tile model shows, whichever grid it came from. +const Item* known(const State& s, std::uint64_t content) { + for (const auto& grid : s.grids) + for (const auto& item : grid.native) + if (item.content == content) return &item; + return nullptr; } -// Gives every favorite in the grid its star and takes it from the others. The game sets an item's style -// when its tile is first shown (and again when the tile comes back into view), so this repeats. +// Gives every favorite the grid shows its star and every tile a plugin marks a check (the check wins +// when it is both), and takes them from the others. The game sets an item's style when its tile is +// first shown (and again when the tile comes back into view), so this repeats. void mark(State& s, const md::Context& context, const Grid& grid) { - if (s.star_unavailable) return; - for (const auto& item : grid.native) { - const md::Value view{item.content, context.type_of(item.content)}; + for (const auto content : grid.shown) { + const auto* item = known(s, content); + if (!item) continue; + const md::Value view{item->content, context.type_of(item->content)}; if (schema_of(view.type) != ownable_view) continue; const auto at = context.address(view) + inline_offset(context, view.type, rarity_path); const auto current = md::read(at).handle & ~std::uint64_t{1}; - const auto source = starred_source(s, current); - if (s.favorites.keys.contains(item.key)) { - if (!current || source) continue; - auto copy = s.starred.find(current); - if (copy == s.starred.end()) { - const md::Value style{current, context.type_of(current)}; - if (schema_of(style.type) != rarity_style.hash) continue; - const auto made = context.create(rarity_style, (GetTickCount64() << 12) | (++s.serial & 0xfff)); - copy = s.starred.emplace(current, made).first; - context.copy(made, context.address(style)); - if (!md::publish_texture(context, context.field(made, rarity_icon), star_texture)) { - s.star_unavailable = true; - logging::log(Level::warning, Channel::customization, - "Item browser: the game's star icon is not loaded here; favorites show no star."); - return; - } - } - context.set(context.path(view, {rarity_path[0], rarity_path[1]}), md::Ref{0, copy->second.handle}); - s.restyled[item.content] = current; - } else if (source) { + const auto [source, has] = starred_source(s, current); + const int wanted = s.marked.contains(item->key) && !s.check_unavailable ? 1 + : s.favorites.keys.contains(item->key) && !s.star_unavailable ? 0 + : -1; + if (wanted == has) continue; + if (wanted < 0) { context.set(context.path(view, {rarity_path[0], rarity_path[1]}), md::Ref{0, source}); - s.restyled.erase(item.content); + s.restyled.erase(item->content); + continue; } + const auto base = source ? source : current; + if (!base) continue; + auto copy = s.starred.find({base, wanted}); + if (copy == s.starred.end()) { + const md::Value style{base, context.type_of(base)}; + if (schema_of(style.type) != rarity_style.hash) continue; + const auto made = context.create(rarity_style, (GetTickCount64() << 12) | (++s.serial & 0xfff)); + context.copy(made, context.address(style)); + bool published{}; + if (wanted == 0) published = md::publish_texture(context, context.field(made, rarity_icon), star_texture); + else + for (const auto name : check_textures) + if ((published = md::publish_texture(context, context.field(made, rarity_icon), name))) break; + if (!published) { + try { context.destroy(made); } catch (const std::exception&) {} + (wanted == 0 ? s.star_unavailable : s.check_unavailable) = true; + logging::log(Level::warning, Channel::customization, "Item browser: the game's {} icon is not loaded here; {} show none.", + wanted == 0 ? "star" : "check", wanted == 0 ? "favorites" : "marked items"); + continue; + } + copy = s.starred.emplace(std::pair{base, wanted}, made).first; + } + context.set(context.path(view, {rarity_path[0], rarity_path[1]}), md::Ref{0, copy->second.handle}); + s.restyled[item->content] = base; } } @@ -366,6 +400,45 @@ std::vector take_rows(const std::vector& from, const std:: return out; } +// A tile for a grid: row `index` of `from`'s own list (its copy in `backing`). +struct Pick { + const Grid* from{}; + unsigned index{}; +}; +// What a grid shows: its own tiles as the order and the filters have them; or, while a plugin's list +// is shown, that list's items from every item grid of the screen, in its order, less what the search +// leaves out. A list with nothing in it keeps the grid's first tile (`held`): an empty grid cannot +// take the focus back. +std::vector picks_for(const State& s, Grid& grid) { + std::vector out; + grid.held = false; + if (!s.show) { + for (const auto index : arrange(grid.native, s.favorites.keys, s.search, s.filter)) out.push_back({&grid, index}); + return out; + } + Keys seen; + const auto take = [&](const Grid& from, const std::string& key) { + if (from.failed || !from.backing.handle) return false; + for (unsigned i = 0; i < from.native.size(); ++i) + if (from.native[i].key == key) { + if (matches(from.native[i], s.search)) out.push_back({&from, i}); + return true; + } + return false; + }; + for (const auto& key : *s.show) { + if (key.empty() || !seen.insert(key).second) continue; + if (take(grid, key)) continue; + for (const auto& other : s.grids) + if (&other != &grid && take(other, key)) break; + } + if (out.empty() && !grid.native.empty()) { + out.push_back({&grid, 0}); + grid.held = true; + } + return out; +} + void sync(State& s, const md::Context& context, Grid& grid, bool verify) { if (grid.failed) return; grid.items = context.field(grid.list, owned_items); @@ -381,9 +454,8 @@ void sync(State& s, const md::Context& context, Grid& grid, bool verify) { if (ours) { // Same item models as we published: check they still show the same items. for (std::size_t i = 0; i < items.size() && ours; ++i) { - const auto known = std::find_if(grid.native.begin(), grid.native.end(), - [&](const Item& item) { return item.content == items[i].content; }); - ours = known != grid.native.end() && known->key == items[i].key; + const auto* was = known(s, items[i].content); + ours = was && was->key == items[i].key; } } if (!ours) { @@ -394,14 +466,27 @@ void sync(State& s, const md::Context& context, Grid& grid, bool verify) { grid.shown = rows.content; } } - const auto order = arrange(grid.native, s.favorites.keys, s.search, s.filter); + const auto picks = picks_for(s, grid); std::vector wanted; - wanted.reserve(order.size()); - for (const auto index : order) wanted.push_back(grid.native[index].content); + wanted.reserve(picks.size()); + for (const auto& pick : picks) wanted.push_back(pick.from->native[pick.index].content); if (wanted == grid.shown) return; - unsigned count{}, stride{}; - const auto source = context.array(context.field(grid.backing, owned_items), 4096, count, stride); - md::require(count == grid.native.size() && stride == s.layout.stride, "Item grid copy differs."); + // The rows, out of each grid's own copy. + std::map> copies; + std::vector source; + for (const auto& pick : picks) { + auto copy = copies.find(pick.from); + if (copy == copies.end()) { + unsigned count{}, stride{}; + auto bytes = context.array(context.field(pick.from->backing, owned_items), 4096, count, stride); + md::require(count == pick.from->native.size() && stride == s.layout.stride, "Item grid copy differs."); + copy = copies.emplace(pick.from, std::move(bytes)).first; + } + const auto* row = copy->second.data() + std::size_t{pick.index} * s.layout.stride; + source.insert(source.end(), row, row + s.layout.stride); + } + std::vector order(picks.size()); + for (unsigned i = 0; i < order.size(); ++i) order[i] = i; const auto focused = grid.focused < 0 ? -1 : std::min(grid.focused, static_cast(order.size()) - 1); context.array(grid.items, take_rows(source, order, s.layout, focused), static_cast(order.size())); const auto after = read_rows(context, grid.items, s.layout); @@ -559,6 +644,35 @@ View view() { return sh.view; } void toggle_focused_favorite() noexcept { shared().toggles.fetch_add(1); } +std::string show_items(std::string owner, std::optional> keys) { + auto& sh = shared(); + std::lock_guard lock(sh.mutex); + if (sh.show && !sh.show_owner.empty() && sh.show_owner != owner) { + if (!keys) return {}; + return sh.show_owner + " shows its list"; + } + sh.show_owner = keys ? owner : std::string{}; + sh.show = std::move(keys); + sh.asked.store(true); + return {}; +} +void mark_items(std::string owner, std::vector keys) { + auto& sh = shared(); + std::lock_guard lock(sh.mutex); + if (keys.empty()) sh.marks.erase(owner); + else sh.marks.insert_or_assign(std::move(owner), std::move(keys)); + sh.asked.store(true); +} +void release_owner(std::string_view owner) { + auto& sh = shared(); + std::lock_guard lock(sh.mutex); + if (sh.show_owner == owner) { + sh.show.reset(); + sh.show_owner.clear(); + } + if (const auto found = sh.marks.find(owner); found != sh.marks.end()) sh.marks.erase(found); + sh.asked.store(true); +} void set_search(std::string text) { auto& sh = shared(); std::lock_guard lock(sh.mutex); @@ -625,6 +739,7 @@ void before_level_transition(unsigned next, std::uintptr_t base) noexcept { s.restyled.clear(); s.layout = {}; s.open = false; + s.check_unavailable = s.star_unavailable = false; s.notice_info = 0; // (its texture may go with the level) }); } catch (...) {} @@ -692,8 +807,8 @@ void follow_focus(State& s, Shared& sh, std::uintptr_t base) { const auto rows = read_rows(context, context.field(grid->list, owned_items), s.layout); if (rows.focused < 0 || static_cast(rows.focused) >= rows.content.size()) return; const auto content = rows.content[static_cast(rows.focused)]; - const auto item = std::find_if(grid->native.begin(), grid->native.end(), [&](const Item& i) { return i.content == content; }); - if (item == grid->native.end() || item->key == next.focused_key) return; + const auto* item = known(s, content); + if (!item || grid->held || item->key == next.focused_key) return; next.focused_title = item->title; next.focused_key = item->key; next.focused_category = item->category; @@ -709,7 +824,8 @@ void update(std::uintptr_t base) noexcept { try { if (s.lifetime.blocked()) return; const auto now = GetTickCount64(); - if (now < s.next_sync) { + // A plugin's request waits for nothing: the next tick acts on it. + if (now < s.next_sync && !sh.asked.exchange(false)) { if (s.open && s.manager && s.active_list) follow_focus(s, sh, base); return; } @@ -742,6 +858,9 @@ void update(std::uintptr_t base) noexcept { std::lock_guard shared_lock(sh.mutex); if (std::exchange(sh.search_pending, false)) s.search = sh.search; pokes.swap(sh.pokes); + s.show = sh.show; + s.marked.clear(); + for (const auto& [owner, keys] : sh.marks) s.marked.insert(keys.begin(), keys.end()); } if (const auto filter = sh.filter.exchange(-1); filter >= 0 && filter <= static_cast(Filter::game)) s.filter = static_cast(filter); @@ -756,8 +875,7 @@ void update(std::uintptr_t base) noexcept { // Its screen closed: gone at the next scan, but no longer what the player is in now. if (context.type_of(grid.list.handle) != grid.list.type) continue; active = &grid; - for (const auto& item : grid.native) - if (item.content == grid.shown[static_cast(grid.focused)]) focused = &item; + if (!grid.held) focused = known(s, grid.shown[static_cast(grid.focused)]); break; } }; @@ -770,7 +888,17 @@ void update(std::uintptr_t base) noexcept { s.favorites.keys.contains(key) ? "added" : "removed"); } ++s.tick; + // While a plugin's list is shown only the grid that has the focus shows it: the others are out of + // sight (and some are other screens'), so they are left as they are. + const Grid* showing = nullptr; + if (s.show) + for (const auto& grid : s.grids) + if (grid.focused >= 0 && !grid.failed && context.type_of(grid.list.handle) == grid.list.type) { + showing = &grid; + break; + } for (auto& grid : s.grids) { + if (showing && &grid != showing) continue; try { sync(s, context, grid, rescan); if (rescan || (grid.focused >= 0 && s.tick % 3 == 0)) mark(s, context, grid); @@ -803,7 +931,14 @@ void update(std::uintptr_t base) noexcept { next.open = s.open; next.search = s.search; next.filter = s.filter; - if (active) { + next.showing = s.show.has_value(); + if (active && s.show) { + next.held = active->held; + next.shown = active->held ? 0 : static_cast(active->shown.size()); + next.total = static_cast(s.show->size()); + for (const auto& item : active->native) + if (!item.key.empty()) next.grid_keys.push_back(item.key); + } else if (active) { const auto blanks = leading_blanks(active->native); next.shown = static_cast(active->shown.size()) - (std::min)(blanks, static_cast(active->shown.size())); next.total = static_cast(active->native.size()) - blanks; diff --git a/Extension/Customization/item_browser.h b/Extension/Customization/item_browser.h index c2946295..5d47ced2 100644 --- a/Extension/Customization/item_browser.h +++ b/Extension/Customization/item_browser.h @@ -1,6 +1,7 @@ #pragma once #include "item_browser_order.h" #include +#include #include #include #include @@ -19,6 +20,8 @@ struct View { std::string focused_title, focused_key, focused_category; std::vector grid_keys; // every item of the grid that has the focus bool focused_favorite{}; + bool showing{}; // a plugin's list is shown in the grid that has the focus (show_items) + bool held{}; // that list has nothing in it: the grid keeps one tile only for the game's highlight }; View view(); // Requests from the keys, the overlay and the console; applied on the client thread. @@ -26,6 +29,15 @@ void toggle_focused_favorite() noexcept; void set_search(std::string text); void set_filter(Filter filter) noexcept; void cycle_filter() noexcept; +// A plugin's own list in the item grid that has the focus: these items (keys), in this order, taken +// from every item grid of the screen (the game keeps them all, one per tab), less what the search +// leaves out; nullopt gives the grid back. One owner at a time: empty when taken, else why not. Any thread. +std::string show_items(std::string owner, std::optional> keys); +// The game's check mark on these items' tiles, where the rarity icon is (it wins over a favorite's +// star); each owner's own list, replaced. Any thread. +void mark_items(std::string owner, std::vector keys); +// Lets go of what `owner` asked for (a plugin that stops). Any thread. +void release_owner(std::string_view owner); // An item's title as an item grid showed it this session (mods' items too), or empty. Any thread. std::string known_title(std::string_view key); // The table the overlay's search bar and keys use (process lifetime). diff --git a/Extension/Customization/item_browser_order.h b/Extension/Customization/item_browser_order.h index 78eadf96..dc6ecad3 100644 --- a/Extension/Customization/item_browser_order.h +++ b/Extension/Customization/item_browser_order.h @@ -10,6 +10,7 @@ // Which tiles of an item grid are shown, and in what order. Pure: no game state, so it is tested on its own. namespace dingosdk::item_browser { enum class Filter : std::uint8_t { all, favorites, mods, game }; +using Keys = std::set>; inline constexpr std::string_view filter_names[]{"All", "Favorites", "Mods", "Game items"}; struct Item { diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 7e3cde78..fb4295d0 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -571,6 +571,34 @@ struct FakeGame final : Game { calls.push_back(std::format("reveal {} {}", plugin, folder)); return {}; } + // Keys held, the keys and pad buttons kept from the game, the items worn on top and what the item + // grid was asked to show and mark (Item Tabs). + std::set keys_held; + bool key_down(int virtual_key) override { return keys_held.contains(virtual_key); } + std::vector kept_keys; + std::uint16_t kept_buttons{}; + void game_keys(std::string_view, std::vector keys) override { kept_keys = std::move(keys); } + void game_buttons(std::string_view, std::uint16_t hidden, std::uint16_t, bool) override { kept_buttons = hidden; } + std::vector on_top_now; + static std::string joined(const std::vector& list) { + std::string out; + for (const auto& one : list) out += (out.empty() ? "" : ",") + one; + return out; + } + std::string outfit_on_top(std::string_view plugin, std::vector items) override { + calls.push_back(std::format("on top {} [{}]", plugin, joined(items))); + on_top_now = std::move(items); + return {}; + } + OnTop outfit_on_top_state(std::string_view) override { return {on_top_now, on_top_now, {}}; } + std::optional> grid_shown; + std::vector grid_marked; + std::string grid_show(std::string_view plugin, std::optional> keys) override { + calls.push_back(keys ? std::format("show {} [{}]", plugin, joined(*keys)) : std::format("show {} none", plugin)); + grid_shown = std::move(keys); + return {}; + } + void grid_mark(std::string_view, std::vector keys) override { grid_marked = std::move(keys); } bool called(std::string_view part) const { return std::any_of(calls.begin(), calls.end(), [&](const auto& c) { return c.find(part) != std::string::npos; }); } @@ -2239,9 +2267,18 @@ void discovery_finds_the_plugins_among_the_mods_left_on() { check(again[0].fingerprint != before, "A changed file changes the fingerprint, which reloads it"); } -// contrib/plugins, as they are in the repository. -Candidate contributed(const std::string& id) { +// contrib/plugins, kept on this machine and out of the repository: a test of one that is not here +// is skipped (main reports it), not failed. +struct Absent { + std::string id; +}; +std::filesystem::path contrib_plugin(const std::string& id) { const auto directory = std::filesystem::path(RESKATE_SOURCE_DIR) / "contrib" / "plugins" / id; + if (!std::filesystem::exists(directory / "reskate-plugin.json")) throw Absent{id}; + return directory; +} +Candidate contributed(const std::string& id) { + const auto directory = contrib_plugin(id); return {id, directory, "1", check_package_files(directory)}; } std::string printed_lines(const TestEnvironment& environment) { @@ -2608,7 +2645,7 @@ void board_break_snaps_the_board_on_a_slam() { auto& game = environment.fake; const auto directory = scratch / "mods" / "board-break"; std::filesystem::remove_all(directory); - std::filesystem::copy(std::filesystem::path(RESKATE_SOURCE_DIR) / "contrib" / "plugins" / "board-break", directory, + std::filesystem::copy(contrib_plugin("board-break"), directory, std::filesystem::copy_options::recursive); write(directory / "sounds" / "heavy" / "snap.wav", wave_file(8000, 4000)); Host host(environment, Side::client); @@ -2742,7 +2779,7 @@ void fit_check_turns_the_board_and_saves_it() { auto& game = environment.fake; const auto directory = scratch / "mods" / "fit-check"; std::filesystem::remove_all(directory); - std::filesystem::copy(std::filesystem::path(RESKATE_SOURCE_DIR) / "contrib" / "plugins" / "fit-check", directory, + std::filesystem::copy(contrib_plugin("fit-check"), directory, std::filesystem::copy_options::recursive); Host host(environment, Side::client); host.apply({{"fit-check", directory, "1", check_package_files(directory)}}); @@ -2818,6 +2855,85 @@ void fit_check_turns_the_board_and_saves_it() { check(host.run_console("fitcheck open") && game.called("reveal fit-check fit-checks"), "The folder is shown"); } +// Item Tabs as a mod (contrib/plugins/item-tabs): its own checks pass, and over an item grid the game +// is kept from Tab and its letters; V and Enter put the highlighted item into a tab; Tab shows the +// player's tabs in the grid (and keeps Q, E and Enter too); B wears the item on top, with its check; +// Delete takes everything off; and all is given back when the grid closes. +void item_tabs_keep_tabs_and_wear_on_top() { + TestEnvironment environment; + std::filesystem::remove_all(scratch / "storage" / "item-tabs"); + auto& game = environment.fake; + game.pad.reset(); + const auto directory = scratch / "mods" / "item-tabs"; + std::filesystem::remove_all(directory); + std::filesystem::copy(contrib_plugin("item-tabs"), directory, + std::filesystem::copy_options::recursive); + Host host(environment, Side::client); + host.apply({{"item-tabs", directory, "1", check_package_files(directory)}}); + running(host, "item-tabs", environment); + check(host.run_console("itemtabs test") && environment.printed("itemtabs: 10 passed, 0 failed") && !environment.printed("FAIL"), + "Item Tabs' own checks pass:\n" + printed_lines(environment) + environment.all()); + check(game.called("on top item-tabs []"), "Nothing on top yet\n" + environment.all()); + const auto press = [&](int key) { + game.keys_held = {key}; + ticks(host, environment, 1); + game.keys_held.clear(); + ticks(host, environment, 1); + }; + const auto kept = [&](std::initializer_list keys) { + std::set wanted(keys), now(game.kept_keys.begin(), game.kept_keys.end()); + return wanted == now; + }; + + // Over a grid: Tab, V, R, B and Delete are kept from the game, and the stick clicks and View. + game.menu_now = {true, false, {}}; + game.grid_now = Game::MenuGrid{"hat_a", "Hat A", "Headwear", {"hat_a", "hat_b"}, 2, 2}; + ticks(host, environment, 2); + check(kept({9, 'V', 'R', 'B', 46}) && game.kept_buttons == 0xe0, "The grid's keys are kept from the game\n" + environment.all()); + + // A tab made at the console; V and Enter put the highlighted hat in it. + check(host.run_console("itemtabs make Hats") && environment.printed("itemtabs: tab Hats is number 1"), "A tab is made"); + press('V'); + press(13); + environment.lines.clear(); + check(host.run_console("itemtabs") && environment.printed("1. Hats (folder): 1 item(s)"), "The hat is in it:\n" + printed_lines(environment)); + game.pad = PadInput{0, 0, 0, 0, 0, 0, 0x2000, false}; // B closes the panel + ticks(host, environment, 1); + game.pad.reset(); + ticks(host, environment, 1); + + // Tab: the player's tabs, the grid showing the open one's items; Q, E and Enter are kept too. + press(9); + check(game.grid_shown && *game.grid_shown == std::vector{"hat_a"}, "The grid shows the tab\n" + environment.all()); + check(kept({9, 'V', 'R', 'B', 46, 'Q', 'E', 13}) && game.kept_buttons == 0x13e0, "and keeps Q, E, Enter, LB, RB and A\n" + environment.all()); + const auto drawn = [&](std::string_view part) { + return std::any_of(environment.layers.begin(), environment.layers.end(), [&](const plugin_ui::Layer& layer) { + return std::any_of(layer.shapes.begin(), layer.shapes.end(), [&](const auto& s) { return s.text.find(part) != std::string::npos; }); + }); + }; + check(drawn("Hats") && drawn("Game tabs"), "Its tab row is drawn\n" + environment.all()); + + // B: the hat on top, with its check; Delete: everything off. + press('B'); + check(game.on_top_now == std::vector{"hat_a"} && game.grid_marked == std::vector{"hat_a"}, + "B wears it on top, checked\n" + environment.all()); + press(46); + check(game.on_top_now.empty() && game.grid_marked.empty(), "Delete takes it off\n" + environment.all()); + + // Kept: loaded again, the tab and its hat are still there. + host.apply({}); + host.apply({{"item-tabs", directory, "1", check_package_files(directory)}}); + running(host, "item-tabs", environment); + environment.lines.clear(); + check(host.run_console("itemtabs") && environment.printed("1. Hats (folder): 1 item(s)"), "Kept:\n" + printed_lines(environment)); + + // The grid closes: everything given back. + press(9); + game.grid_now.reset(); + ticks(host, environment, 2); + check(game.kept_keys.empty() && game.kept_buttons == 0 && !game.grid_shown, "Given back when the grid closes\n" + environment.all()); +} + void plugins_drive_the_throwdown_kit() { namespace kit = dingosdk::throwdowns::kit; TestEnvironment environment; @@ -3393,63 +3509,73 @@ void reskate_hostile_is_stopped_or_refused_every_time() { } int main() { + unsigned skipped = 0; + const auto run = [&](void (*test)()) { + try { + test(); + } catch (const Absent& absent) { + ++skipped; + std::cout << "skipped: contrib/plugins/" << absent.id << " is not on this machine\n"; + } + }; try { scratch = std::filesystem::temp_directory_path() / "reskate-plugin-tests"; std::filesystem::remove_all(scratch); - a_manifest_says_what_a_plugin_is(); - a_plugin_loads_ticks_runs_its_commands_and_unloads(); - a_permission_must_be_declared(); - command_names_are_checked(); - storage_is_kept_across_loads_and_has_a_quota(); - packages_json_and_modules(); - the_sandbox_holds(); - a_runaway_plugin_is_stopped_and_the_rest_go_on(); - a_plugin_over_its_time_budget_is_stopped(); - a_plugin_reloads_when_its_files_change(); - the_skater_can_be_read_moved_and_the_world_probed(); - tricks_reach_plugins_that_ask_for_them(); - plugins_draw_on_the_overlay(); - plugins_draw_pictures(); - plugins_place_world_entities(); - plugins_reach_cruise_and_read_the_mouse(); - plugins_read_joints_and_tune_physics(); - plugins_type_and_use_the_room(); - plugins_touch_hide_and_ray_at_players(); - plugins_use_the_games_clips(); - plugins_edit_clips(); - animator_studio_records_and_saves(); - plugins_server_copies_are_the_authority(); - plugins_animate_the_skater(); - plugins_play_their_own_sounds(); - plugins_have_menu_panels(); - plugins_have_pages(); - plugins_play_sounds_and_read_the_mods(); - plugins_follow_the_session_and_message_each_other(); - plugins_call_and_hear_each_other(); - web_requests_go_only_to_listed_domains(); - the_wider_set_reaches_the_game(); - key_names_become_binds(); - level_events_reach_plugins(); - timers_run_when_due(); - a_package_with_a_program_or_on_the_wrong_side_is_not_loaded(); - discovery_finds_the_plugins_among_the_mods_left_on(); - reskate_test_passes_its_own_checks(); - road_rash_marks_the_skater_as_it_bails(); - dual_stick_edits_the_companions_settings(); - rock_to_fakie_relabels_and_records_the_stall(); - a_plugin_reads_the_content_folder_it_names(); - board_break_snaps_the_board_on_a_slam(); - fpv_drone_flies_and_follows(); - hall_of_meat_scores_a_bail(); - grind_exits_pick_the_side(); - smokey_throwdowns_register_and_play_one_up(); - plugins_drive_the_throwdown_kit(); - fit_check_turns_the_board_and_saves_it(); - the_throwdowns_page_lists_the_stock_cards_and_plugin_modes(); - plugins_add_throwdown_modes(); - reskate_hostile_is_stopped_or_refused_every_time(); + run(a_manifest_says_what_a_plugin_is); + run(a_plugin_loads_ticks_runs_its_commands_and_unloads); + run(a_permission_must_be_declared); + run(command_names_are_checked); + run(storage_is_kept_across_loads_and_has_a_quota); + run(packages_json_and_modules); + run(the_sandbox_holds); + run(a_runaway_plugin_is_stopped_and_the_rest_go_on); + run(a_plugin_over_its_time_budget_is_stopped); + run(a_plugin_reloads_when_its_files_change); + run(the_skater_can_be_read_moved_and_the_world_probed); + run(tricks_reach_plugins_that_ask_for_them); + run(plugins_draw_on_the_overlay); + run(plugins_draw_pictures); + run(plugins_place_world_entities); + run(plugins_reach_cruise_and_read_the_mouse); + run(plugins_read_joints_and_tune_physics); + run(plugins_type_and_use_the_room); + run(plugins_touch_hide_and_ray_at_players); + run(plugins_use_the_games_clips); + run(plugins_edit_clips); + run(animator_studio_records_and_saves); + run(plugins_server_copies_are_the_authority); + run(plugins_animate_the_skater); + run(plugins_play_their_own_sounds); + run(plugins_have_menu_panels); + run(plugins_have_pages); + run(plugins_play_sounds_and_read_the_mods); + run(plugins_follow_the_session_and_message_each_other); + run(plugins_call_and_hear_each_other); + run(web_requests_go_only_to_listed_domains); + run(the_wider_set_reaches_the_game); + run(key_names_become_binds); + run(level_events_reach_plugins); + run(timers_run_when_due); + run(a_package_with_a_program_or_on_the_wrong_side_is_not_loaded); + run(discovery_finds_the_plugins_among_the_mods_left_on); + run(reskate_test_passes_its_own_checks); + run(road_rash_marks_the_skater_as_it_bails); + run(dual_stick_edits_the_companions_settings); + run(rock_to_fakie_relabels_and_records_the_stall); + run(a_plugin_reads_the_content_folder_it_names); + run(board_break_snaps_the_board_on_a_slam); + run(fpv_drone_flies_and_follows); + run(hall_of_meat_scores_a_bail); + run(grind_exits_pick_the_side); + run(smokey_throwdowns_register_and_play_one_up); + run(plugins_drive_the_throwdown_kit); + run(fit_check_turns_the_board_and_saves_it); + run(item_tabs_keep_tabs_and_wear_on_top); + run(the_throwdowns_page_lists_the_stock_cards_and_plugin_modes); + run(plugins_add_throwdown_modes); + run(reskate_hostile_is_stopped_or_refused_every_time); std::filesystem::remove_all(scratch); - std::cout << "plugin tests passed\n"; + std::cout << "plugin tests passed" << (skipped ? " (" + std::to_string(skipped) + " skipped)" : std::string()) << "\n"; return 0; } catch (const std::exception& error) { std::cerr << "plugin test failed: " << error.what() << '\n'; diff --git a/Extension/Plugins/plugin_api_capture.cpp b/Extension/Plugins/plugin_api_capture.cpp index 08e138cd..8bf37b94 100644 --- a/Extension/Plugins/plugin_api_capture.cpp +++ b/Extension/Plugins/plugin_api_capture.cpp @@ -4,7 +4,9 @@ #include "lualib.h" #include #include +#include #include +#include // reskate.capture (the screen as the game draws it, saved as a GIF, APNG, PNG or WebM), the game // menu's preview turned (reskate.room.turn) and read (reskate.menu.game, reskate.menu.grid), what the @@ -262,8 +264,51 @@ struct CaptureApi { lua_setfield(L, -2, "shown"); lua_pushinteger(L, static_cast(grid->total)); lua_setfield(L, -2, "total"); + lua_pushboolean(L, grid->showing); + lua_setfield(L, -2, "showing"); + lua_pushboolean(L, grid->held); + lua_setfield(L, -2, "held"); + if (grid->prompts_unit > 0) { + lua_createtable(L, 0, 4); + const std::pair fields[] = { + {"left", grid->prompts_left}, {"top", grid->prompts_top}, {"width", grid->prompts_width}, {"unit", grid->prompts_unit}}; + for (const auto& [name, value] : fields) { + lua_pushnumber(L, value); + lua_setfield(L, -2, name); + } + lua_setfield(L, -2, "prompts"); + } return 1; } + static std::vector keys_at(lua_State* L, int index, int most) { + std::vector keys; + luaL_checktype(L, index, LUA_TTABLE); + const int count = lua_objlen(L, index); + if (count > most) luaL_argerror(L, index, most == 4000 ? "at most 4000 items" : "too many items"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, index, i); + keys.push_back(checked_text(L, lua_gettop(L), 255, "an item's key")); + lua_pop(L, 1); + } + return keys; + } + // menu.grid_show(keys): these items, in this order, in the grid that has the focus; nil gives it back. + static int grid_show(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "menu", "reskate.menu.grid_show"); + std::optional> keys; + if (!lua_isnoneornil(L, 1)) keys = keys_at(L, 1, 4000); + return result(L, game(plugin).grid_show(plugin.id(), std::move(keys))); + } + // menu.grid_mark(keys): the game's check on these items' tiles; nil or {} takes them off. + static int grid_mark(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "menu", "reskate.menu.grid_mark"); + std::vector keys; + if (!lua_isnoneornil(L, 1)) keys = keys_at(L, 1, 4000); + game(plugin).grid_mark(plugin.id(), std::move(keys)); + return 0; + } static int push_worn(lua_State* L, const std::vector& list) { lua_createtable(L, static_cast(list.size()), 0); for (std::size_t i = 0; i < list.size(); ++i) { @@ -293,6 +338,43 @@ struct CaptureApi { lua_pushlstring(L, title.data(), title.size()); return 1; } + // outfit.on_top(items): the plugin's items worn on top of the outfit, replaced; nil or {} takes them off. + static int on_top(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "outfit", "reskate.outfit.on_top"); + std::vector items; + if (!lua_isnoneornil(L, 1)) { + luaL_checktype(L, 1, LUA_TTABLE); + const int count = lua_objlen(L, 1); + if (count > 32) luaL_argerror(L, 1, "at most 32 items"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, 1, i); + items.push_back(checked_text(L, lua_gettop(L), 200, "an item's name")); + lua_pop(L, 1); + if (items.back().empty()) luaL_argerror(L, 1, "an item's name is empty"); + } + } + if (!within(plugin.services_.now_ms(), plugin.control_second_, plugin.controls_, 4)) return result(L, "asked more than 4 times in a second"); + return result(L, game(plugin).outfit_on_top(plugin.id(), std::move(items))); + } + static int on_top_state(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "outfit", "reskate.outfit.on_top_state"); + const auto now = game(plugin).outfit_on_top_state(plugin.id()); + const auto list = [&](const std::vector& names, const char* field) { + lua_createtable(L, static_cast(names.size()), 0); + for (std::size_t i = 0; i < names.size(); ++i) { + lua_pushlstring(L, names[i].data(), names[i].size()); + lua_rawseti(L, -2, static_cast(i + 1)); + } + lua_setfield(L, -2, field); + }; + lua_createtable(L, 0, 3); + list(now.items, "items"); + list(now.worn, "worn"); + if (!now.note.empty()) push_text(L, now.note, "note"); + return 1; + } static int files_reveal(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "files", "reskate.files.reveal"); @@ -304,8 +386,8 @@ struct CaptureApi { add_functions(L, "capture", {{"start", start}, {"stop", stop}, {"discard", discard}, {"state", state}, {"compare", compare}, {"kept", kept}, {"save", save}}); add_functions(L, "room", {{"turn", room_turn}}); - add_functions(L, "menu", {{"game", menu_game}, {"grid", menu_grid}}); - add_functions(L, "outfit", {{"worn", outfit_worn}, {"board", outfit_board}, {"title", outfit_title}}); + add_functions(L, "menu", {{"game", menu_game}, {"grid", menu_grid}, {"grid_show", grid_show}, {"grid_mark", grid_mark}}); + add_functions(L, "outfit", {{"worn", outfit_worn}, {"board", outfit_board}, {"title", outfit_title}, {"on_top", on_top}, {"on_top_state", on_top_state}}); add_functions(L, "files", {{"reveal", files_reveal}}); } }; diff --git a/Extension/Plugins/plugin_api_services.cpp b/Extension/Plugins/plugin_api_services.cpp index 266c8135..e1a9b69f 100644 --- a/Extension/Plugins/plugin_api_services.cpp +++ b/Extension/Plugins/plugin_api_services.cpp @@ -149,7 +149,7 @@ int key_code(lua_State* L, int index) { static const std::pair names[] = { {"Enter", 13}, {"Backspace", 8}, {"Escape", 27}, {"End", 35}, {"Home", 36}, {"PageUp", 33}, {"PageDown", 34}, {"Space", 32}, {"Shift", 16}, {"Ctrl", 17}, {"Alt", 18}, {"Tab", 9}, {"Left", 37}, {"Up", 38}, {"Right", 39}, {"Down", 40}, - {"[", 0xDB}, {"]", 0xDD}, {"LeftMouse", 1}, {"RightMouse", 2}, {"MiddleMouse", 4}}; + {"[", 0xDB}, {"]", 0xDD}, {"Delete", 46}, {"LeftMouse", 1}, {"RightMouse", 2}, {"MiddleMouse", 4}}; for (const auto& [n, vk] : names) if (name == n) return vk; if (name.size() == 1 && ((name[0] >= 'A' && name[0] <= 'Z') || (name[0] >= '0' && name[0] <= '9'))) return name[0]; @@ -197,6 +197,27 @@ int game_buttons(lua_State* L) { game_of(plugin).game_buttons(plugin.id(), mask(1), mask(2), lua_toboolean(L, 3) != 0); return 0; } +// input.game_keys(keys): the keys (names, as key() takes them, or virtual keys) whose presses the game +// does not see until the next call; nil or {} gives them back. Not Escape, nor the mouse's buttons. +int game_keys(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.game_keys"); + std::vector keys; + if (!lua_isnoneornil(L, 1)) { + luaL_checktype(L, 1, LUA_TTABLE); + const int count = lua_objlen(L, 1); + if (count > 32) luaL_argerror(L, 1, "at most 32 keys"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, 1, i); + const int vk = key_code(L, lua_gettop(L)); + lua_pop(L, 1); + if (vk == 27 || (vk >= 1 && vk <= 6 && vk != 3)) // Escape; the mouse buttons (3 is Cancel) + luaL_argerror(L, 1, "Escape and the mouse's buttons always reach the game"); + keys.push_back(static_cast(vk)); + } + } + game_of(plugin).game_keys(plugin.id(), std::move(keys)); + return 0; +} int input_allowed(lua_State* L) { auto& plugin = allowed(L, "input", "reskate.input.allowed"); lua_pushboolean(L, game_of(plugin).input_allowed()); @@ -450,7 +471,7 @@ int read_content(lua_State* L) { void install_services_api(Plugin&, lua_State* L) { add_functions(L, "skater", {{"telemetry", telemetry}, {"goofy", goofy}, {"bone_cam", bone_cam}}); - add_functions(L, "input", {{"pad_bits", pad_bits}, {"key", key}, {"wheel", wheel}, {"mouse", mouse}, {"game_buttons", game_buttons}, {"allowed", input_allowed}, {"focus", focus}}); + add_functions(L, "input", {{"pad_bits", pad_bits}, {"key", key}, {"wheel", wheel}, {"mouse", mouse}, {"game_buttons", game_buttons}, {"game_keys", game_keys}, {"allowed", input_allowed}, {"focus", focus}}); add_functions(L, "session", {{"spectate", spectate}, {"world", world}}); add_functions(L, "camera", {{"free", free_camera}, {"pad_flight", pad_flight}}); add_functions(L, "physics", {{"competition", competition}, {"stock_ready", stock_ready}, {"prepare", prepare}, {"tuning", tuning}, diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index a76cca0e..60fbcec6 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -394,8 +394,14 @@ public: std::string focused, title, category; // the highlighted item: key, title, catalog category std::vector items; // every item's key in the grid unsigned shown{}, total{}; + bool showing{}, held{}; // a plugin's list is shown; it has nothing in it (grid_show) + float prompts_left{}, prompts_top{}, prompts_width{}, prompts_unit{}; // where the grid's own prompts end }; virtual std::optional menu_grid() { return std::nullopt; } + // A plugin's own list shown in the grid that has the focus (item_browser::show_items), nullopt to + // give it back; and the tiles it marks with the game's check. Empty when taken, else why not. + virtual std::string grid_show(std::string_view /*plugin*/, std::optional> /*keys*/) { return unavailable; } + virtual void grid_mark(std::string_view /*plugin*/, std::vector /*keys*/) {} // ---- capture: the screen as the game draws it (Extension/Rendering/Capture) --------------------- // One plugin records at a time; frames are numbered from 0 as they are taken. struct CaptureStart { @@ -433,6 +439,8 @@ public: // Game buttons kept from the game while asked (overlay::hide_game_buttons/hold_game_buttons), // and all game input paused for half a second at a time. virtual void game_buttons(std::string_view /*plugin*/, std::uint16_t /*hidden*/, std::uint16_t /*held*/, bool /*paused*/) {} + // Keys (virtual key codes) whose presses the game does not see until the next call (overlay::hide_game_keys). + virtual void game_keys(std::string_view /*plugin*/, std::vector /*keys*/) {} // The game window has the focus, ReSkate's menu is shut and the native UI lets the game play. virtual bool input_allowed() { return false; } virtual std::string free_camera(std::string_view /*plugin*/, bool) { return unavailable; } @@ -666,6 +674,14 @@ public: std::string slot, item, title; }; virtual std::vector outfit_worn() { return {}; } + // Items worn on top of the outfit (outfit_overlay::set_layers): the plugin's list replaced; empty + // takes them off. Empty when taken, else why not. + virtual std::string outfit_on_top(std::string_view /*plugin*/, std::vector /*items*/) { return unavailable; } + struct OnTop { + std::vector items, worn; + std::string note; + }; + virtual OnTop outfit_on_top_state(std::string_view /*plugin*/) { return {}; } virtual std::vector board_parts() { return {}; } // An item's title as the game shows it (its catalog's, the one an item grid showed, or its key // made readable). diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 186a7c83..68ec403c 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -518,6 +518,13 @@ public: return out; } std::string outfit_status() override { return outfit_overlay::status(); } + std::string outfit_on_top(std::string_view plugin, std::vector items) override { + return outfit_overlay::set_layers(std::string(plugin), std::move(items)); + } + OnTop outfit_on_top_state(std::string_view plugin) override { + auto layers = outfit_overlay::layers(plugin); + return {std::move(layers.items), std::move(layers.worn), std::move(layers.note)}; + } // ---- the skater's physics ------------------------------------------------------------------------ SkaterNews take_skater_news() override { skater_events::want(); @@ -1129,8 +1136,14 @@ public: std::optional menu_grid() override { const auto view = item_browser::view(); if (!view.open) return std::nullopt; - return MenuGrid{view.focused_key, view.focused_title, view.focused_category, view.grid_keys, view.shown, view.total}; + const auto prompts = overlay::item_browser_prompts(); + return MenuGrid{view.focused_key, view.focused_title, view.focused_category, view.grid_keys, view.shown, view.total, + view.showing, view.held, prompts.left, prompts.top, prompts.width, prompts.unit}; } + std::string grid_show(std::string_view plugin, std::optional> keys) override { + return item_browser::show_items(std::string(plugin), std::move(keys)); + } + void grid_mark(std::string_view plugin, std::vector keys) override { item_browser::mark_items(std::string(plugin), std::move(keys)); } // ---- capture: one plugin records at a time (capture_runtime.h) -------------------------------- std::string capture_start(std::string_view plugin, const CaptureStart& request) override { auto& recorder = capture::recorder(); @@ -1241,6 +1254,11 @@ public: overlay::hold_game_buttons(held); if (paused) overlay::pause_game_input(true); } + void game_keys(std::string_view plugin, std::vector keys) override { + if (keys.empty() && keys_owner_ != plugin) return; + keys_owner_ = keys.empty() ? std::string{} : std::string(plugin); + overlay::hide_game_keys(keys); + } bool input_allowed() override { DWORD process{}; const auto window = GetForegroundWindow(); @@ -1507,6 +1525,10 @@ public: dialog_close(plugin, {}); tricks::feed::ask(std::string(plugin), false); bonecam::bone_cam_release(plugin); + if (keys_owner_ == plugin) { + overlay::hide_game_keys({}); + keys_owner_.clear(); + } if (buttons_owner_ == plugin) { overlay::hide_game_buttons(0); overlay::hold_game_buttons(0); @@ -1518,6 +1540,8 @@ public: stop_clip(plugin); plugin_sound::stop_all(plugin); outfit_overlay::clear(plugin); + item_browser::release_owner(plugin); + (void)outfit_overlay::set_layers(std::string(plugin), {}); board_pose::clear(plugin); board_pose::park(plugin, {}); (void)board_pose::fit(plugin, std::nullopt); @@ -1566,6 +1590,7 @@ private: skater_animation::set_replacements(std::move(list)); } std::string player_, recorder_, thrower_, capturer_; + std::string keys_owner_; // the plugin whose keys the game does not see std::string buttons_owner_, free_camera_owner_, pad_flight_owner_, competition_owner_, hold_owner_; std::uint64_t capture_until_{}, flight_read_at_{}; std::optional flight_; diff --git a/Extension/Plugins/plugin_ui.h b/Extension/Plugins/plugin_ui.h index 9e48a607..e327bc46 100644 --- a/Extension/Plugins/plugin_ui.h +++ b/Extension/Plugins/plugin_ui.h @@ -39,9 +39,11 @@ inline constexpr std::array body_names{ "Lower back", "Spine", "Left toes", "Left foot", "Left shin", "Left thigh", "Right toes", "Right foot", "Right shin", "Right thigh", "Pelvis"}; // The game's own pictures a plugin draws by name (game_image; Extension/UI/Overlay/game_images.h). -inline constexpr std::array game_images{ +inline constexpr std::array game_images{ "stopwatch", "wipeout", "wipeout_broken", "spread_eagle", "airtime", "gap_height", "flaming_wheel", "roll", - "thrasher", "brush_bar", "rough_tile", "scratches", "streak", "loading_board"}; + "thrasher", "brush_bar", "rough_tile", "scratches", "streak", "loading_board", + // The customization menu's category icons (its tabs'), white on clear. + "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 loading board is a flipbook: this many frames, at 30 a second (style.frame). inline constexpr int loading_board_frames = 27; // Screen shapes are in pixels from the top left; world shapes in world metres, projected through diff --git a/Extension/Skater/outfit_overlay.cpp b/Extension/Skater/outfit_overlay.cpp index dc0382c7..3e9d28fe 100644 --- a/Extension/Skater/outfit_overlay.cpp +++ b/Extension/Skater/outfit_overlay.cpp @@ -5,8 +5,13 @@ #include "Extension/Skater/no_bail.h" #include "Engine/Core/Log/logging.h" #include "Engine/Vfs/content_catalogs.h" +#include "Extension/World/load_error_guard.h" #include #include +#include +#include +#include +#include #include #include #include @@ -25,8 +30,40 @@ struct Wanted { std::vector placement; bool covers{}; }; +// The slots an item worn on top may go into when its own kind's is taken, in the order they are +// tried: the accessory slots, which hold one small item each and are empty more often than not; then +// the tattoo slots and the face's empty ones (make-up, freckles, acne...), which the skater loader +// also builds an item from. Not the full-body costume (it hides the body) and not the body itself, +// its hair, colours and skin. The left hand's slot comes last of the accessories: the loader finds no +// model there for most items that are not gloves. (Pull request 269, Outfit layers.) +constexpr std::array spare_slots{ + "cust_outfits", "cust_handwear_right", "cust_face_accessory", "cust_earwear_right", + "cust_eyebrowwear_left", "cust_eyebrowwear_right", "cust_nosewear_left", "cust_nosewear_right", + "cust_eyewear", "cust_hats", "cust_socks", "cust_handwear", + "tattoo_arml_1", "tattoo_armr_1", "tattoo_chest_1", "tattoo_legl_1", "tattoo_legr_1", + "cust_makeup", "cust_freckles", "cust_acne", "cust_vitiligo", "cust_maturity"}; +// A vest from the left hand's slot leaves the skater with no body at all (the game describes a failed +// load within half a second): a layer on trial comes off if the game describes one within this long. +constexpr std::uint64_t trial_time = 900; +using Placement = std::pair; // slot, item +struct Trial { + Placement placement; + std::uint64_t at{}, failed_loads{}; +}; +struct SlotInfo { + std::string name; + std::uintptr_t category{}; // its category's pointer, tag bit cleared +}; + struct State { std::map wanted; // by slot name + std::map> layers; // by owner + std::vector placed; // layers on the skater now + std::optional trial; // the one placed last, not yet known to load + std::set good, bad; // "slot|item" (lower case): loaded / did not load + bool memory_read{}; + std::map> native_categories; // catalog item -> its own + std::string layer_note; std::map found; // installed items by ending bool own{}, dirty{}, worn{}; std::uintptr_t skater{}, component{}, slots_of{}; @@ -56,25 +93,130 @@ int slot_of(State& s, const SkaterItems& m, std::uintptr_t resource, const std:: if (name.find(wanted) != std::string::npos) return index; return -1; } +void give_back(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t now); +// Which items load from which slots, kept between runs beside Skate.exe: a line each, "+slot|item" +// or "-slot|item". +std::filesystem::path memory_file() { + std::array buffer{}; + const auto count = GetModuleFileNameW(nullptr, buffer.data(), static_cast(buffer.size())); + if (!count || count >= buffer.size()) return {}; + return std::filesystem::path(std::wstring(buffer.data(), count)).parent_path() / L"ReSkate.layer_slots.txt"; +} +void read_memory(State& s) { + if (std::exchange(s.memory_read, true)) return; + try { + std::ifstream in(memory_file()); + for (std::string line; std::getline(in, line) && s.good.size() + s.bad.size() < 65536;) { + if (line.size() < 4 || (line[0] != '+' && line[0] != '-')) continue; + (line[0] == '+' ? s.good : s.bad).insert(lower(line.substr(1))); + } + } catch (...) {} +} +void remember(State& s, bool loads, const std::string& key) { + if (!(loads ? s.good : s.bad).insert(key).second) return; + try { + std::ofstream out(memory_file(), std::ios::app); + out << (loads ? '+' : '-') << key << '\n'; + } catch (...) {} +} +std::string pair_key(std::string_view slot, std::string_view item) { return lower(slot) + "|" + lower(item); } + +std::vector slot_infos(const SkaterItems& m, std::uintptr_t resource) { + std::vector out; + const auto table = m.ptr(resource, 0x28); + const auto count = m.count(table, 24, multiplayer::max_cosmetic_slots); + for (std::size_t i = 0; i < count; ++i) { + const auto slot = m.ptr(table, i * 24 + 8) & ~std::uintptr_t{4}; + out.push_back({m.text(m.ptr(slot, 0x20)), m.ptr(slot, 0x30) & ~std::uintptr_t{4}}); + } + return out; +} +// The catalog asset of an installed item, by its name. +std::uintptr_t catalog_asset(const SkaterItems& m, const std::string& name) { + const auto manager = m.ptr(m.base, addr::engine::cosmetics_manager); + m.check(m.ptr(manager) == m.base + addr::engine::cosmetics_manager_vtable && m.get(manager, 0xa8) == 1, + "Cosmetic catalog is not ready."); + const auto buckets = m.ptr(manager, 0x30); + const auto size = m.get(manager, 0x38), total = m.get(manager, 0x3c); + m.check(size && size <= 16384 && total && total <= 8192, "Cosmetic catalog exceeds bounds."); + const auto hash = multiplayer::cosmetic_asset_hash(name); + auto node = m.ptr(buckets, (hash % size) * 8ULL); + for (std::uint32_t visited = 0; node && visited < total; ++visited, node = m.ptr(node, 0x10)) + if (m.get(node) == hash) return m.ptr(node, 8) & ~std::uintptr_t{4}; + return 0; +} +std::vector categories_of(const SkaterItems& m, std::uintptr_t asset) { + std::vector out; + for (const auto category : m.array(m.ptr(asset, 0x20), 32)) out.push_back(category & ~std::uintptr_t{4}); + return out; +} +// Gives a catalog item one more category: a new array, its own categories and `wanted` after them, in +// memory of our own that is never freed (the item points at it for the rest of the game), laid out as +// the game's: the count in the 4 bytes before the first element. +void add_category(const SkaterItems& m, std::uintptr_t asset, std::uintptr_t wanted) { + const auto old = m.ptr(asset, 0x20); + const auto count = m.count(old, 8, 32); + for (std::size_t i = 0; i < count; ++i) + if ((m.ptr(old, i * 8) & ~std::uintptr_t{4}) == wanted) return; + auto* block = static_cast(VirtualAlloc(nullptr, 16 + (count + 1) * 8, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE)); + SkaterItems::check(block != nullptr, "No memory for the item's categories."); + const auto elements = reinterpret_cast(block + 16); + const std::uint32_t n = static_cast(count + 1); + std::memcpy(block + 12, &n, 4); + for (std::size_t i = 0; i < count; ++i) { + const auto one = m.ptr(old, i * 8); + std::memcpy(block + 16 + i * 8, &one, 8); + } + std::memcpy(block + 16 + count * 8, &wanted, 8); + SIZE_T done{}; + SkaterItems::check(WriteProcessMemory(GetCurrentProcess(), reinterpret_cast(asset + 0x20), &elements, sizeof(elements), &done) && + done == sizeof(elements), + "Cannot write the item's categories."); +} +// Every owner's layers, in one list: owners by name, each in its own order, none twice. +std::vector all_layers(const State& s) { + std::vector out; + for (const auto& [owner, items] : s.layers) + for (const auto& item : items) + if (std::none_of(out.begin(), out.end(), [&](const std::string& o) { return lower(o) == lower(item); })) out.push_back(item); + return out; +} + void wear(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t now) { const auto component = m.component(skater); auto recipe = m.capture(skater); const auto resource = m.resource(component); bool marked{}; for (const auto& item : recipe.items) marked |= s.ours.contains(item.asset); + bool changed{}, worn{}; + const auto placed_here = [&](std::size_t i) { + return std::any_of(s.placed.begin(), s.placed.end(), [&](const Placement& p) { return p.first == i && recipe.items[i].asset == p.second; }); + }; if (const auto fed = skater_items::feed(m, component); fed.follows_outfit()) { SkaterItems::check(!marked || now - s.given_back > 3000, "Waiting for the game to put the outfit back."); s.feed = fed; + // A new skater, or one given back that still holds layers until the game has put the + // outfit's own items back: what is left of them comes off here. + for (std::size_t i = 0; i < recipe.items.size(); ++i) + if (placed_here(i)) recipe.items[i].asset.clear(), changed = true; + s.placed.clear(); + s.trial.reset(); } else { SkaterItems::check(s.feed && (marked || component == s.component), "The skater does not follow its outfit: left alone."); + for (const auto& [i, item] : s.placed) + if (i >= recipe.items.size() || recipe.items[i].asset != item) { + give_back(s, m, skater, now); // the game changed the skater's items: it is the game's again + return; + } } - bool changed{}, worn{}; + std::set used; for (const auto& [slot_name, want] : s.wanted) { const int i = slot_of(s, m, resource, slot_name); if (i < 0 || static_cast(i) >= recipe.items.size()) continue; auto& slot = recipe.items[static_cast(i)]; if (!want.covers && !blank(slot.asset) && !s.ours.contains(slot.asset)) continue; // the player's own stays worn = true; + used.insert(static_cast(i)); auto parameters = slot.parameters; for (std::size_t k = 0; k < want.placement.size() && k < parameters.size(); ++k) parameters[k] = std::bit_cast(want.placement[k]); if (slot.asset == want.item && slot.parameters == parameters) continue; @@ -83,12 +225,108 @@ void wear(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t n s.ours.insert(want.item); changed = true; } - s.worn = worn; + // Layers no longer wanted come off (their slots were empty in the outfit); the rest keep theirs. + const auto wanted = all_layers(s); + const auto is_wanted = [&](const std::string& item) { + return std::any_of(wanted.begin(), wanted.end(), [&](const std::string& w) { return lower(w) == lower(item); }); + }; + std::erase_if(s.placed, [&](const Placement& p) { + if (is_wanted(p.second) && !used.contains(p.first)) return false; + recipe.items[p.first].asset.clear(); + changed = true; + return true; + }); + for (const auto& p : s.placed) used.insert(p.first); + const auto slots = wanted.empty() ? std::vector{} : slot_infos(m, resource); + SkaterItems::check(wanted.empty() || slots.size() == recipe.items.size(), "The skater's recipe and its template differ."); + std::optional trial; + std::vector left; + unsigned waiting{}; + for (const auto& name : wanted) { + if (std::any_of(s.placed.begin(), s.placed.end(), [&](const Placement& p) { return lower(p.second) == lower(name); })) continue; + if (std::any_of(recipe.items.begin(), recipe.items.end(), [&](const auto& item) { return lower(item.asset) == lower(name); })) + continue; // the outfit wears it already + const auto asset = catalog_asset(m, name); + if (!asset) { + left.push_back(name + " (not installed)"); + continue; + } + // Its own categories, as the catalog had them before any of ours were added. + auto known = s.native_categories.find(asset); + if (known == s.native_categories.end()) known = s.native_categories.emplace(asset, categories_of(m, asset)).first; + const auto& own = known->second; + const auto own_kind = [&](std::size_t i) { return std::find(own.begin(), own.end(), slots[i].category) != own.end(); }; + // The slots it may go into, in order: its own kind's, then the spare ones; free, and not known to fail. + std::vector candidates; + const auto consider = [&](std::size_t i) { + if (!used.contains(i) && blank(recipe.items[i].asset) && !s.bad.contains(pair_key(slots[i].name, name)) && + std::find(candidates.begin(), candidates.end(), i) == candidates.end()) + candidates.push_back(i); + }; + for (std::size_t i = 0; i < slots.size(); ++i) + if (own_kind(i) && std::find(spare_slots.begin(), spare_slots.end(), lower(slots[i].name)) != spare_slots.end()) consider(i); + for (const auto spare : spare_slots) + for (std::size_t i = 0; i < slots.size(); ++i) + if (lower(slots[i].name) == spare) consider(i); + if (candidates.empty()) { + left.push_back(name + " (no free slot)"); + continue; + } + // Known to load there, or its own kind's slot: no trial. + const auto proven = std::find_if(candidates.begin(), candidates.end(), + [&](std::size_t i) { return own_kind(i) || s.good.contains(pair_key(slots[i].name, name)); }); + std::size_t slot{}; + if (proven != candidates.end()) slot = *proven; + else if (!trial && !s.trial) slot = candidates.front(); + else { + ++waiting; // one trial at a time + continue; + } + if (!own_kind(slot)) add_category(m, asset, slots[slot].category); + recipe.items[slot].asset = name; + used.insert(slot); + s.placed.emplace_back(slot, name); + s.ours.insert(name); + if (proven == candidates.end()) trial = Placement{slot, name}; + changed = true; + } + std::string note; + for (const auto& one : left) note += (note.empty() ? "not worn: " : ", ") + one; + if (waiting) note += std::format("{}{} more to try on", note.empty() ? "" : "; ", waiting); + s.layer_note = std::move(note); + s.worn = worn || !s.placed.empty(); if (!changed) return; + const auto failed_loads = load_error_guard::described(); skater_items::apply(m, component, recipe); s.own = true; s.component = component; s.applied = now; + if (trial) s.trial = Trial{*trial, now, failed_loads}; +} +// A layer on trial: once its time is up, it stays (it loaded) or comes off (the game described a +// failed load meanwhile) and that slot is not tried for it again. +void judge(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t now) { + const auto trial = *s.trial; + const auto component = m.component(skater); + const auto slots = slot_infos(m, m.resource(component)); + const auto key = pair_key(trial.placement.first < slots.size() ? slots[trial.placement.first].name : std::string{}, trial.placement.second); + if (load_error_guard::described() == trial.failed_loads) { + remember(s, true, key); + s.trial.reset(); + s.dirty = true; + return; + } + remember(s, false, key); + auto recipe = m.capture(skater); // throws while the game still works on the failed build: tried again + if (trial.placement.first < recipe.items.size() && recipe.items[trial.placement.first].asset == trial.placement.second) + recipe.items[trial.placement.first].asset.clear(); + std::erase(s.placed, trial.placement); + skater_items::apply(m, component, recipe); + s.applied = now; + s.trial.reset(); + s.dirty = true; + logging::log(logging::Level::info, logging::Channel::skater, "Outfit layers: {} does not load from {}; another slot is tried.", + trial.placement.second, trial.placement.first < slots.size() ? slots[trial.placement.first].name : std::string("its slot")); } void give_back(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t now) { const auto component = m.component(skater); @@ -97,6 +335,8 @@ void give_back(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint6 s.given_back = now; } s.own = s.worn = false; + s.placed.clear(); + s.trial.reset(); } } @@ -118,6 +358,33 @@ std::string set(std::string owner, std::string slot, std::string item, std::vect s.dirty = true; return {}; } +std::string set_layers(std::string owner, std::vector items) { + auto& s = state(); + if (!items.empty() && !content_cache::catalogs().available) return "the paid items are not known yet; try again in a moment"; + for (const auto& item : items) { + if (item.empty()) return "an item has no name"; + if (profile_runtime::reserved_cosmetic(item)) return item + " is a paid store item"; + } + auto& mine = s.layers[owner]; + if (mine == items) { + if (items.empty()) s.layers.erase(owner); + return {}; + } + mine = std::move(items); + if (mine.empty()) s.layers.erase(owner); + s.dirty = true; + return {}; +} +Layers layers(std::string_view owner) { + auto& s = state(); + Layers out; + if (const auto found = s.layers.find(std::string(owner)); found != s.layers.end()) out.items = found->second; + for (const auto& item : out.items) + if (std::any_of(s.placed.begin(), s.placed.end(), [&](const Placement& p) { return lower(p.second) == lower(item); })) + out.worn.push_back(item); + if (!out.items.empty()) out.note = s.layer_note; + return out; +} void clear(std::string_view owner, std::string_view slot) { auto& s = state(); const auto name = lower(slot); @@ -161,7 +428,11 @@ std::vector slots() { void tick() noexcept { auto& s = state(); - if (s.wanted.empty() && !s.own) return; + if (s.wanted.empty() && s.layers.empty() && !s.own) { + s.placed.clear(); + s.layer_note.clear(); + return; + } try { NoBailSkater skater; if (!no_bail_skater(skater)) { @@ -169,6 +440,8 @@ void tick() noexcept { s.skater = s.slots_of = 0; s.own = s.worn = false; s.feed.reset(); + s.placed.clear(); + s.trial.reset(); s.dirty = true; return; } @@ -176,18 +449,26 @@ void tick() noexcept { s.skater = skater.entity; s.own = false; s.feed.reset(); + s.placed.clear(); + s.trial.reset(); s.dirty = true; } const auto now = GetTickCount64(); const SkaterItems m{skater_items::readable, skater.base}; const bool menu = multiplayer::sample_game_ui_state(skater.base).in_menu; if (menu) s.menu_seen = now; - if (s.wanted.empty() || menu) { + const bool nothing = s.wanted.empty() && s.layers.empty(); + if (nothing || menu) { if (s.own) give_back(s, m, skater.entity, now); - s.status = s.wanted.empty() ? "nothing worn" : "a game menu is up: the outfit is the game's"; + s.status = nothing ? "nothing worn" : "a game menu is up: the outfit is the game's"; s.dirty = true; return; } + read_memory(s); + if (s.trial) { + if (now - s.trial->at >= trial_time) judge(s, m, skater.entity, now); + return; + } // Not before the game has had a second after a menu, nor more than once a second; while worn, // a look every 3 s whether the game put its own items over ours. if (now - s.given_back < 300 || now - s.menu_seen < 1000 || now - s.applied < 1000 || !(s.dirty || (s.worn && now - s.checked > 3000))) @@ -195,7 +476,7 @@ void tick() noexcept { s.checked = now; wear(s, m, skater.entity, now); s.dirty = false; - s.status = std::format("{} item(s) worn", s.wanted.size()); + s.status = std::format("{} item(s) worn", s.wanted.size() + s.placed.size()); } catch (const std::exception& error) { s.status = std::string("waiting: ") + error.what(); } catch (...) { diff --git a/Extension/Skater/outfit_overlay.h b/Extension/Skater/outfit_overlay.h index 0ce6e786..a0a9d0ab 100644 --- a/Extension/Skater/outfit_overlay.h +++ b/Extension/Skater/outfit_overlay.h @@ -27,6 +27,19 @@ struct Slot { }; // The skater's recipe slots now; empty without a skater. std::vector slots(); +// Items worn on top of the outfit: more than one where the outfit has one (a second chain, rings on +// both hands). Each goes into a slot the outfit leaves empty, its own kind's when that one is free, +// else one of the accessory slots, then the tattoo and face slots; the catalog item is given that +// slot's category besides its own, in memory, for this run. An item never tried in a slot goes on +// alone, and comes off for the next slot if the game describes a failed load within 0.9 s; what +// loads where is kept in ReSkate.layer_slots.txt beside Skate.exe. Never a paid store item. `items` +// replaces the owner's; empty takes them off. Empty when taken, otherwise why not. +std::string set_layers(std::string owner, std::vector items); +struct Layers { + std::vector items, worn; // the owner's, and those of them on the skater now + std::string note; // why some are not, when they are not +}; +Layers layers(std::string_view owner); // Each client tick. void tick() noexcept; std::string status(); diff --git a/Extension/UI/Overlay/game_images.cpp b/Extension/UI/Overlay/game_images.cpp index e94a8f3b..acef1af7 100644 --- a/Extension/UI/Overlay/game_images.cpp +++ b/Extension/UI/Overlay/game_images.cpp @@ -4,6 +4,7 @@ #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 @@ -18,10 +19,16 @@ 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, picture_count = 14 }; +enum Picture : std::size_t { loading_board = 13, first_icon = 14, picture_count = 31 }; constexpr std::array names{ "stopwatch", "wipeout", "wipeout_broken", "spread_eagle", "airtime", "gap_height", "flaming_wheel", "roll", - "thrasher", "brush_bar", "rough_tile", "scratches", "streak", "loading_board"}; + "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 @@ -29,7 +36,7 @@ struct Source { 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 sources{{ +constexpr std::array 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}, @@ -72,7 +79,7 @@ Images decode() { Images result; try { vfs::GameTextures textures(xray::game_folder()); - for (std::size_t index = 0; index < picture_count; ++index) { + 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) { @@ -97,6 +104,21 @@ Images decode() { } catch (const std::exception& error) { logging::log(logging::Level::warning, logging::Channel::graphics, "The game's pictures are unavailable: {}.", error.what()); } + try { + std::vector 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; } @@ -250,6 +272,7 @@ bool draw_game_image(ImDrawList* draw, std::string_view name, ImVec2 min, ImVec2 if (at == names.end()) return false; const auto picture = static_cast(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); diff --git a/Extension/UI/Overlay/input_capture.cpp b/Extension/UI/Overlay/input_capture.cpp index 2595d147..5871e251 100644 --- a/Extension/UI/Overlay/input_capture.cpp +++ b/Extension/UI/Overlay/input_capture.cpp @@ -36,6 +36,20 @@ bool challenge_menu_key(int key) noexcept { bool challenge_panel_owns_input() noexcept { return challenge_native_panel.load(std::memory_order_relaxed); } +// Keys kept from the game (hide_game_keys): their presses are dropped from every way it reads the +// keyboard. Releases pass, so a key the game saw go down still comes up. +std::array, 4> hidden_keys{}; +bool key_hidden(unsigned key) noexcept { + return key < 256 && !overlay_input_access && (hidden_keys[key / 64].load(std::memory_order_relaxed) >> (key % 64) & 1) != 0; +} +namespace { +bool hidden_press(const RAWKEYBOARD& keyboard) noexcept { return !(keyboard.Flags & RI_KEY_BREAK) && key_hidden(keyboard.VKey); } +// The DirectInput keyboard's scan code (DIK_) of a hidden key. +bool scan_hidden(DWORD scan) noexcept { + const auto key = MapVirtualKeyW(scan, MAPVK_VSC_TO_VK_EX); + return key && key_hidden(key); +} +} namespace { std::atomic prompt_xinput{}, prompt_direct{}, prompt_hid{}, prompt_raw{}; // A read the game made while a card held its input: counted, by the way it read. @@ -451,16 +465,19 @@ void keep_xinput_capture_first() { } SHORT WINAPI captured_async_key(int key) { const auto value = original(input_hooks().async_key)(key); - return block_polled_input() || challenge_menu_key(key) ? 0 : value; + return block_polled_input() || challenge_menu_key(key) || key_hidden(static_cast(key)) ? 0 : value; } SHORT WINAPI captured_key(int key) { const auto value = original(input_hooks().key)(key); - return block_polled_input() || challenge_menu_key(key) ? 0 : value; + return block_polled_input() || challenge_menu_key(key) || key_hidden(static_cast(key)) ? 0 : value; } BOOL WINAPI captured_keyboard(PBYTE keys) { const auto result = original(input_hooks().keyboard)(keys); if (result && keys && block_polled_input()) std::memset(keys, 0, 256); else if(result && keys && challenge_menu_key(VK_ESCAPE))keys[VK_ESCAPE]=0; + if (result && keys) + for (unsigned key = 0; key < 256; ++key) + if (key_hidden(key)) keys[key] = 0; return result; } UINT WINAPI captured_raw_data(HRAWINPUT input, UINT command, LPVOID data, PUINT size, UINT header_size) { @@ -478,7 +495,7 @@ UINT WINAPI captured_raw_data(HRAWINPUT input, UINT command, LPVOID data, PUINT const bool mouse = raw->header.dwType == RIM_TYPEMOUSE && result >= offsetof(RAWINPUT, data) + sizeof(RAWMOUSE); const bool keyboard = raw->header.dwType == RIM_TYPEKEYBOARD && result >= offsetof(RAWINPUT, data) + sizeof(RAWKEYBOARD); - if(keyboard && challenge_menu_key(raw->data.keyboard.VKey)) { + if(keyboard && (challenge_menu_key(raw->data.keyboard.VKey) || hidden_press(raw->data.keyboard))) { raw->data.keyboard={};raw->data.keyboard.VKey=255; raw->data.keyboard.Flags=RI_KEY_BREAK;raw->data.keyboard.Message=WM_KEYUP; } else if (!block_polled_input()) { @@ -589,7 +606,7 @@ UINT WINAPI captured_raw_buffer(PRAWINPUT data, PUINT size, UINT header_size) { const bool mouse = record->header.dwType == RIM_TYPEMOUSE && bytes >= offsetof(RAWINPUT, data) + sizeof(RAWMOUSE); const bool keyboard = record->header.dwType == RIM_TYPEKEYBOARD && bytes >= offsetof(RAWINPUT, data) + sizeof(RAWKEYBOARD); - if(keyboard && challenge_menu_key(record->data.keyboard.VKey)) { + if(keyboard && (challenge_menu_key(record->data.keyboard.VKey) || hidden_press(record->data.keyboard))) { record->data.keyboard={};record->data.keyboard.VKey=255; record->data.keyboard.Flags=RI_KEY_BREAK;record->data.keyboard.Message=WM_KEYUP; } @@ -921,8 +938,10 @@ HRESULT STDMETHODCALLTYPE captured_device_state(void* device, DWORD size, LPVOID for (std::size_t offset = 8 * sizeof(DWORD); offset < 12 * sizeof(DWORD); offset += sizeof(DWORD)) std::memcpy(bytes + offset, &released_pov, sizeof(released_pov)); } - } else if(SUCCEEDED(result) && data && size==256 && challenge_menu_key(VK_ESCAPE)) { - static_cast(data)[DIK_ESCAPE]=0; + } else if (SUCCEEDED(result) && data && size == 256) { + if (challenge_menu_key(VK_ESCAPE)) static_cast(data)[DIK_ESCAPE] = 0; + for (DWORD scan = 1; scan < 256; ++scan) + if (scan_hidden(scan)) static_cast(data)[scan] = 0; } return result; } @@ -932,13 +951,15 @@ HRESULT STDMETHODCALLTYPE captured_device_data(void* device, DWORD size, LPDIDEV const auto result = original( input_hooks().device_data[I])(device, size, data, count, capture ? flags & ~DIGDD_PEEK : flags); if (SUCCEEDED(result) && capture && count) *count = 0; - else if(SUCCEEDED(result) && data && count && size==sizeof(DIDEVICEOBJECTDATA) && challenge_menu_key(VK_ESCAPE)) { + else if (SUCCEEDED(result) && data && count && size == sizeof(DIDEVICEOBJECTDATA)) { std::lock_guard lock(input_hooks().formats_mutex); - const auto format=input_hooks().formats.find(device); - if(format!=input_hooks().formats.end() && format->second.size==256) { + const auto format = input_hooks().formats.find(device); + if (format != input_hooks().formats.end() && format->second.size == 256) { + const bool escape = challenge_menu_key(VK_ESCAPE); DWORD kept{}; - for(DWORD i=0;i<*count;++i)if(data[i].dwOfs!=DIK_ESCAPE)data[kept++]=data[i]; - *count=kept; + for (DWORD i = 0; i < *count; ++i) + if (!(escape && data[i].dwOfs == DIK_ESCAPE) && !((data[i].dwData & 0x80) && scan_hidden(data[i].dwOfs))) data[kept++] = data[i]; + *count = kept; } } return result; @@ -1177,6 +1198,11 @@ void capture_challenge_menu_input(bool active,bool native_panel,bool opening_key detail::challenge_menu_capture.store(active,std::memory_order_relaxed); detail::challenge_native_panel.store(native_panel,std::memory_order_relaxed); } +void hide_game_keys(std::span keys) noexcept { + std::array bits{}; + for (const auto key : keys) bits[key / 64] |= std::uint64_t{1} << (key % 64); + for (std::size_t i = 0; i < bits.size(); ++i) detail::hidden_keys[i].store(bits[i], std::memory_order_relaxed); +} void hold_game_buttons(std::uint16_t held) noexcept { detail::mode_held_buttons.store(held, std::memory_order_relaxed); } void pause_game_input(bool paused) noexcept { detail::mode_input_paused_until.store(paused ? GetTickCount64() + 500 : 0, std::memory_order_relaxed); diff --git a/Extension/UI/Overlay/input_capture.h b/Extension/UI/Overlay/input_capture.h index 106496da..18d91aa4 100644 --- a/Extension/UI/Overlay/input_capture.h +++ b/Extension/UI/Overlay/input_capture.h @@ -13,6 +13,8 @@ struct OverlayInputAccess { extern thread_local HRAWINPUT noted_raw_input; bool block_polled_input(); bool challenge_menu_key(int key) noexcept; +// A key kept from the game now (hide_game_keys); never for ReSkate's own reads. +bool key_hidden(unsigned key) noexcept; bool challenge_panel_owns_input() noexcept; // How the game read its controllers while a card held its input (prompt_input_active), for the // log when the hold ends: which of the ways the overlay can keep input from it the game used. diff --git a/Extension/UI/Overlay/item_browser_overlay.cpp b/Extension/UI/Overlay/item_browser_overlay.cpp index 9e1a4c99..56c14fb7 100644 --- a/Extension/UI/Overlay/item_browser_overlay.cpp +++ b/Extension/UI/Overlay/item_browser_overlay.cpp @@ -217,6 +217,16 @@ void mod_icon_lost() { bool item_browser_open() { return panel().view.open; } +namespace { +std::array, 4> prompts_end{}; // left, top, width, unit +void set_prompts_end(float left, float top, float width, float unit) noexcept { + prompts_end[0].store(left, std::memory_order_relaxed); + prompts_end[1].store(top, std::memory_order_relaxed); + prompts_end[2].store(width, std::memory_order_relaxed); + prompts_end[3].store(unit, std::memory_order_relaxed); +} +} // namespace + void item_browser_key(ItemBrowserKey key) { const auto* h = host(); if (!h) return; @@ -240,6 +250,7 @@ bool item_browser_pending() { if (!p.view.open) { s.item_search_visible.store(false); p.stick_down = false; + set_prompts_end(0, 0, 0, 0); return false; } // Left stick click: favorite. Read here, once a presented frame, only while a grid is open. @@ -345,6 +356,15 @@ void draw_item_browser() { } if (view.focused_favorite && !typing) star(draw, ImVec2(at.x - 22 * scale, at.y + height * 0.5f), 13 * scale, skate_theme::bar); + set_prompts_end(at.x, y + 30 * scale, width, scale); draw_mod_card(draw, view, heading, body); } } // namespace dingosdk::overlay::detail + +namespace dingosdk::overlay { +ItemPrompts item_browser_prompts() noexcept { + using detail::prompts_end; + return {prompts_end[0].load(std::memory_order_relaxed), prompts_end[1].load(std::memory_order_relaxed), + prompts_end[2].load(std::memory_order_relaxed), prompts_end[3].load(std::memory_order_relaxed)}; +} +} // namespace dingosdk::overlay diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 9e5d6021..7443afc2 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -35,6 +36,9 @@ void set_plugin_dialog_active(bool active) noexcept; // down for it, or all game input paused for half a second; the keyboard as the overlay reads it, // and the mouse wheel's notches since the last call. void hide_game_buttons(std::uint16_t buttons) noexcept; +// Keys (virtual key codes) the game does not see go down until this is called again: their presses +// are dropped from every way it reads the keyboard, releases pass. ReSkate's own reads see them. Any thread. +void hide_game_keys(std::span keys) noexcept; void hold_game_buttons(std::uint16_t held) noexcept; void pause_game_input(bool paused) noexcept; bool key_down(int virtual_key) noexcept; @@ -546,6 +550,13 @@ struct ItemBrowserHost { }; // `host` must stay valid for the life of the process. void set_item_browser(const ItemBrowserHost *host) noexcept; +// Where the item grids' own search bar and prompts end, as the overlay last drew them, in pixels: a +// plugin's own prompts go under them. `unit` is the prompts' scale (a keycap is 20 units high). All +// zero while no grid is open. Any thread. +struct ItemPrompts { + float left{}, top{}, width{}, unit{}; +}; +ItemPrompts item_browser_prompts() noexcept; // A presented frame as the game drew it, before ReSkate's own UI goes on top: 8-bit RGBA rows // (BGRA when `bgra`), `pitch` bytes apart. Valid only during the call it is handed to. struct GrabbedFrame { diff --git a/Extension/UI/Overlay/overlay_input.cpp b/Extension/UI/Overlay/overlay_input.cpp index ff733275..9562f987 100644 --- a/Extension/UI/Overlay/overlay_input.cpp +++ b/Extension/UI/Overlay/overlay_input.cpp @@ -152,6 +152,7 @@ LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { const bool key = message == WM_KEYDOWN || message == WM_SYSKEYDOWN; const bool release = message == WM_KEYUP || message == WM_SYSKEYUP; if((key || release) && challenge_menu_key(static_cast(wp)))return 0; + if (key && wp < 256 && key_hidden(static_cast(wp))) return 0; if (console_character(message) && s.console_character_pending.exchange(false)) return 0; if (console_character(message) && s.chat_character_pending.exchange(false)) return 0; if (console_character(message) && s.item_search_character_pending.exchange(false)) return 0; diff --git a/Extension/World/load_error_guard.cpp b/Extension/World/load_error_guard.cpp index 432954f3..dbf15ec8 100644 --- a/Extension/World/load_error_guard.cpp +++ b/Extension/World/load_error_guard.cpp @@ -16,7 +16,7 @@ using Describe = void (*)(void* errors, void* text); Describe original{}; std::uintptr_t image_begin{}, image_end{}; std::atomic installed{}; -std::atomic empties{}, faults{}, told{}; +std::atomic empties{}, faults{}, told{}, described_loads{}; thread_local unsigned depth{}; // a part can be a list of its own: only the outermost is logged constexpr std::uint64_t most_parts = 4096, most_told = 40; @@ -83,7 +83,10 @@ void guarded(void* errors, void* text) { const bool whole = describe_guarded(errors, text); --depth; if (!whole) faults.fetch_add(1, std::memory_order_relaxed); - if (!depth) tell(reinterpret_cast(text), emptied, whole); + if (!depth) { + described_loads.fetch_add(1, std::memory_order_relaxed); + tell(reinterpret_cast(text), emptied, whole); + } } } // namespace @@ -112,4 +115,5 @@ bool install(std::uintptr_t base) noexcept { } std::uint64_t emptied() noexcept { return empties.load(std::memory_order_relaxed); } +std::uint64_t described() noexcept { return described_loads.load(std::memory_order_relaxed); } } // namespace dingosdk::load_error_guard diff --git a/Extension/World/load_error_guard.h b/Extension/World/load_error_guard.h index 6b30553f..6ff2a183 100644 --- a/Extension/World/load_error_guard.h +++ b/Extension/World/load_error_guard.h @@ -13,4 +13,6 @@ namespace dingosdk::load_error_guard { bool install(std::uintptr_t base) noexcept; // How many parts were emptied since the game started. std::uint64_t emptied() noexcept; +// How many failed loads the game has described since it started (0 without the guard). +std::uint64_t described() noexcept; }