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.
This commit is contained in:
AyeZeeBB committed 2026-10-21 19:18:01 -05:00
1 parent 913b4ce422
commit 89a97caed7
21 files changed
+1005 -159

No files matched your search

+61 -25
View File
@@ -2,6 +2,7 @@
#include "game_bundles.h"
#include "Engine/Core/Platform/path_text.h"
#include <algorithm>
#include <optional>
#include <stdexcept>
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<GameBundle> 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<std::byte> 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<std::byte> 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<const std::string> 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;
}
+7
View File
@@ -2,7 +2,9 @@
#include "Engine/Resource/texture.h"
#include <cstdint>
#include <filesystem>
#include <span>
#include <string>
#include <string_view>
#include <vector>
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<const std::string> names, std::uint32_t side);
}
+182 -47
View File
@@ -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<std::string_view, 3> 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<std::uint32_t>(md::read<md::Address>(type)) : 0; }
@@ -140,6 +145,7 @@ struct Grid {
std::vector<std::uint64_t> 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<Grid> 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<std::uint64_t, md::Value> 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<std::pair<std::uint64_t, int>, md::Value> starred; // (the game's style, 0 star / 1 check) -> our copy
std::map<std::uint64_t, std::uint64_t> restyled; // item model -> the game's style it had
bool star_unavailable{};
bool star_unavailable{}, check_unavailable{};
std::optional<std::vector<std::string>> 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<std::string> pokes;
std::string show_owner;
std::optional<std::vector<std::string>> show;
std::map<std::string, std::vector<std::string>, std::less<>> marks; // by owner
std::atomic<bool> asked{}; // a plugin's request waits: the next tick acts on it
std::atomic<unsigned> toggles{}, cycles{};
std::atomic<int> filter{-1};
std::atomic<bool> 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<std::uint64_t, int> 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<md::Ref>(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<std::byte> take_rows(const std::vector<std::byte>& 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<Pick> picks_for(const State& s, Grid& grid) {
std::vector<Pick> 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<std::uint64_t> 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<const Grid*, std::vector<std::byte>> copies;
std::vector<std::byte> 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<unsigned> 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<int>(order.size()) - 1);
context.array(grid.items, take_rows(source, order, s.layout, focused), static_cast<unsigned>(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<std::vector<std::string>> 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<std::string> 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<std::size_t>(rows.focused) >= rows.content.size()) return;
const auto content = rows.content[static_cast<std::size_t>(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<int>(Filter::game))
s.filter = static_cast<Filter>(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<std::size_t>(grid.focused)]) focused = &item;
if (!grid.held) focused = known(s, grid.shown[static_cast<std::size_t>(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<unsigned>(active->shown.size());
next.total = static_cast<unsigned>(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<unsigned>(active->shown.size()) - (std::min)(blanks, static_cast<unsigned>(active->shown.size()));
next.total = static_cast<unsigned>(active->native.size()) - blanks;
+12
View File
@@ -1,6 +1,7 @@
#pragma once
#include "item_browser_order.h"
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
@@ -19,6 +20,8 @@ struct View {
std::string focused_title, focused_key, focused_category;
std::vector<std::string> 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<std::vector<std::string>> 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<std::string> 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).
@@ -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<std::string, std::less<>>;
inline constexpr std::string_view filter_names[]{"All", "Favorites", "Mods", "Game items"};
struct Item {
+183 -57
View File
@@ -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<int> keys_held;
bool key_down(int virtual_key) override { return keys_held.contains(virtual_key); }
std::vector<std::uint8_t> kept_keys;
std::uint16_t kept_buttons{};
void game_keys(std::string_view, std::vector<std::uint8_t> 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<std::string> on_top_now;
static std::string joined(const std::vector<std::string>& 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<std::string> 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<std::vector<std::string>> grid_shown;
std::vector<std::string> grid_marked;
std::string grid_show(std::string_view plugin, std::optional<std::vector<std::string>> 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<std::string> 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<int> keys) {
std::set<int> 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<std::string>{"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<std::string>{"hat_a"} && game.grid_marked == std::vector<std::string>{"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';
+84 -2
View File
@@ -4,7 +4,9 @@
#include "lualib.h"
#include <algorithm>
#include <cmath>
#include <optional>
#include <string>
#include <vector>
// 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<int>(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<const char*, float> 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<std::string> keys_at(lua_State* L, int index, int most) {
std::vector<std::string> 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<std::vector<std::string>> 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<std::string> 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<Game::WornItem>& list) {
lua_createtable(L, static_cast<int>(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<std::string> 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<std::string>& names, const char* field) {
lua_createtable(L, static_cast<int>(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<int>(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}});
}
};
+23 -2
View File
@@ -149,7 +149,7 @@ int key_code(lua_State* L, int index) {
static const std::pair<const char*, int> 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<std::uint8_t> 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<std::uint8_t>(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},
+16
View File
@@ -394,8 +394,14 @@ public:
std::string focused, title, category; // the highlighted item: key, title, catalog category
std::vector<std::string> 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<MenuGrid> 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<std::vector<std::string>> /*keys*/) { return unavailable; }
virtual void grid_mark(std::string_view /*plugin*/, std::vector<std::string> /*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<std::uint8_t> /*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<WornItem> 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<std::string> /*items*/) { return unavailable; }
struct OnTop {
std::vector<std::string> items, worn;
std::string note;
};
virtual OnTop outfit_on_top_state(std::string_view /*plugin*/) { return {}; }
virtual std::vector<WornItem> 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).
+26 -1
View File
@@ -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<std::string> 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<MenuGrid> 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<std::vector<std::string>> keys) override {
return item_browser::show_items(std::string(plugin), std::move(keys));
}
void grid_mark(std::string_view plugin, std::vector<std::string> 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<std::uint8_t> 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<FlightInput> flight_;
+4 -2
View File
@@ -39,9 +39,11 @@ inline constexpr std::array<std::string_view, 24> 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<std::string_view, 14> game_images{
inline constexpr std::array<std::string_view, 31> 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
+287 -6
View File
@@ -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 <Windows.h>
#include <algorithm>
#include <array>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <bit>
#include <cmath>
#include <set>
@@ -25,8 +30,40 @@ struct Wanted {
std::vector<float> 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<std::string_view, 22> 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<std::size_t, std::string>; // 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<std::string, Wanted> wanted; // by slot name
std::map<std::string, std::vector<std::string>> layers; // by owner
std::vector<Placement> placed; // layers on the skater now
std::optional<Trial> trial; // the one placed last, not yet known to load
std::set<std::string> good, bad; // "slot|item" (lower case): loaded / did not load
bool memory_read{};
std::map<std::uintptr_t, std::vector<std::uintptr_t>> native_categories; // catalog item -> its own
std::string layer_note;
std::map<std::string, std::string> 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<wchar_t, 32768> buffer{};
const auto count = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(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<SlotInfo> slot_infos(const SkaterItems& m, std::uintptr_t resource) {
std::vector<SlotInfo> 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<std::uint8_t>(manager, 0xa8) == 1,
"Cosmetic catalog is not ready.");
const auto buckets = m.ptr(manager, 0x30);
const auto size = m.get<std::uint32_t>(manager, 0x38), total = m.get<std::uint32_t>(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<std::uint32_t>(node) == hash) return m.ptr(node, 8) & ~std::uintptr_t{4};
return 0;
}
std::vector<std::uintptr_t> categories_of(const SkaterItems& m, std::uintptr_t asset) {
std::vector<std::uintptr_t> out;
for (const auto category : m.array<std::uintptr_t>(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<std::uint8_t*>(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<std::uintptr_t>(block + 16);
const std::uint32_t n = static_cast<std::uint32_t>(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<void*>(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<std::string> all_layers(const State& s) {
std::vector<std::string> 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<std::size_t> used;
for (const auto& [slot_name, want] : s.wanted) {
const int i = slot_of(s, m, resource, slot_name);
if (i < 0 || static_cast<std::size_t>(i) >= recipe.items.size()) continue;
auto& slot = recipe.items[static_cast<std::size_t>(i)];
if (!want.covers && !blank(slot.asset) && !s.ours.contains(slot.asset)) continue; // the player's own stays
worn = true;
used.insert(static_cast<std::size_t>(i));
auto parameters = slot.parameters;
for (std::size_t k = 0; k < want.placement.size() && k < parameters.size(); ++k) parameters[k] = std::bit_cast<std::uint32_t>(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<SlotInfo>{} : slot_infos(m, resource);
SkaterItems::check(wanted.empty() || slots.size() == recipe.items.size(), "The skater's recipe and its template differ.");
std::optional<Placement> trial;
std::vector<std::string> 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<std::size_t> 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<std::string> 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<Slot> 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 (...) {
+13
View File
@@ -27,6 +27,19 @@ struct Slot {
};
// The skater's recipe slots now; empty without a skater.
std::vector<Slot> 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<std::string> items);
struct Layers {
std::vector<std::string> 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();
+27 -4
View File
@@ -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 <algorithm>
@@ -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<std::string_view, picture_count> names{
"stopwatch", "wipeout", "wipeout_broken", "spread_eagle", "airtime", "gap_height", "flaming_wheel", "roll",
"thrasher", "brush_bar", "rough_tile", "scratches", "streak", "loading_board"};
"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<Source, picture_count> sources{{
constexpr std::array<Source, first_icon> sources{{
{&ui::stopwatch, 64}, {&ui::wipeout, 64}, {&ui::wipeout_broken, 64}, {&ui::spread_eagle, 64},
{&ui::airtime, 64}, {&ui::gap_height, 64}, {&ui::flaming_wheel, 64}, {&ui::roll, 64},
{&ui::thrasher_wordmark, 512}, {&ui::brush_bar, 512}, {&ui::rough_tile, 256}, {&ui::scratches, 512, true},
@@ -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<std::string> wanted;
for (std::size_t index = first_icon; index < picture_count; ++index)
wanted.push_back("ui/textures/icons/cas/img_cas_" + std::string(names[index].substr(4)) + "_128_128_128");
const auto read = vfs::read_ui_textures(xray::game_folder(), icon_bundle, wanted, icon_side);
for (std::size_t i = 0; i < wanted.size(); ++i)
for (const auto& texture : read.thumbnails)
if (texture.item == wanted[i] && texture.image.rgba.size() == std::size_t{icon_side} * icon_side * 4) result[first_icon + i] = texture.image;
std::size_t found{};
for (std::size_t index = first_icon; index < picture_count; ++index) found += result[index].has_value();
if (found < wanted.size())
logging::log(logging::Level::warning, logging::Channel::graphics, "The menu's category icons: {} of {} found.", found, wanted.size());
} catch (const std::exception& error) {
logging::log(logging::Level::warning, logging::Channel::graphics, "The menu's category icons are unavailable: {}.", error.what());
}
return result;
}
@@ -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<Picture>(at - names.begin());
if (picture == loading_board) return draw_image(draw, picture, min, max, tint, frame); // a flipbook's frame, fitted
if (picture >= first_icon) return draw_image(draw, picture, min, max, tint, 0); // an icon: always fitted
switch (fit) {
case GameImageFit::contain: return draw_image(draw, picture, min, max, tint, 0);
case GameImageFit::shape: return draw_shape(draw, picture, min, max, tint);
+37 -11
View File
@@ -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<std::atomic<std::uint64_t>, 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<unsigned> 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<SHORT(WINAPI*)(int)>(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<unsigned>(key)) ? 0 : value;
}
SHORT WINAPI captured_key(int key) {
const auto value = original<SHORT(WINAPI*)(int)>(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<unsigned>(key)) ? 0 : value;
}
BOOL WINAPI captured_keyboard(PBYTE keys) {
const auto result = original<BOOL(WINAPI*)(PBYTE)>(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<std::uint8_t*>(data)[DIK_ESCAPE]=0;
} else if (SUCCEEDED(result) && data && size == 256) {
if (challenge_menu_key(VK_ESCAPE)) static_cast<std::uint8_t*>(data)[DIK_ESCAPE] = 0;
for (DWORD scan = 1; scan < 256; ++scan)
if (scan_hidden(scan)) static_cast<std::uint8_t*>(data)[scan] = 0;
}
return result;
}
@@ -932,13 +951,15 @@ HRESULT STDMETHODCALLTYPE captured_device_data(void* device, DWORD size, LPDIDEV
const auto result = original<HRESULT(STDMETHODCALLTYPE*)(void*, DWORD, LPDIDEVICEOBJECTDATA, LPDWORD, DWORD)>(
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<const std::uint8_t> keys) noexcept {
std::array<std::uint64_t, 4> 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);
+2
View File
@@ -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.
@@ -217,6 +217,16 @@ void mod_icon_lost() {
bool item_browser_open() { return panel().view.open; }
namespace {
std::array<std::atomic<float>, 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
+11
View File
@@ -19,6 +19,7 @@
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <span>
#include <string>
#include <string_view>
#include <vector>
@@ -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<const std::uint8_t> 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 {
+1
View File
@@ -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<int>(wp)))return 0;
if (key && wp < 256 && key_hidden(static_cast<unsigned>(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;
+6 -2
View File
@@ -16,7 +16,7 @@ using Describe = void (*)(void* errors, void* text);
Describe original{};
std::uintptr_t image_begin{}, image_end{};
std::atomic<bool> installed{};
std::atomic<std::uint64_t> empties{}, faults{}, told{};
std::atomic<std::uint64_t> 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<std::uintptr_t>(text), emptied, whole);
if (!depth) {
described_loads.fetch_add(1, std::memory_order_relaxed);
tell(reinterpret_cast<std::uintptr_t>(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
+2
View File
@@ -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;
}