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; }