From 6f41a16f1b394ee639cc948eb36646325a863b92 Mon Sep 17 00:00:00 2001 From: Fourth Studio Date: Fri, 9 Oct 2026 10:08:08 +0200 Subject: [PATCH] Item grids: favorites, search and filters In the skater's item grids (Apparel, Accessories, Board, Gestures) F favorites the highlighted item, - (or Ctrl+F) opens a search box that filters as you type, and X steps a filter (All, Favorites, Mods, Game items). Favorites sit right after the worn item, carry the game's star where the tile shows its rarity icon, and are kept in ReSkate.favorites.json beside Skate.exe. The grids stay the game's own: their tile rows are reordered and filtered through the native model functions, as the music screen's Mods shelf does. Nothing is hooked for it and no file of the game or of a mod is changed. See contrib/native-item-browser.md for how it works and what is not tried yet. This is the Item Favorites mod done natively, so it no longer needs a DLL loaded through a custom script. Co-Authored-By: Claude Opus 5.5 --- Extension/Console/commands.cpp | 1 + Extension/Console/commands.h | 1 + Extension/Customization/console_commands.cpp | 44 ++ Extension/Customization/item_browser.cpp | 683 ++++++++++++++++++ Extension/Customization/item_browser.h | 36 + Extension/Customization/item_browser_order.h | 83 +++ Extension/Profile/local_profile_runtime.cpp | 3 + Extension/UI/NativeMenu/native_menu_data.cpp | 3 + Extension/UI/NativeMenu/native_menu_data.h | 2 + Extension/UI/Overlay/item_browser_overlay.cpp | 171 +++++ Extension/UI/Overlay/overlay.cpp | 3 +- Extension/UI/Overlay/overlay.h | 17 + Extension/UI/Overlay/overlay_input.cpp | 36 + Extension/UI/Overlay/overlay_internal.h | 10 + Extension/UI/Overlay/overlay_render.cpp | 4 +- Runtime/runtime_init.cpp | 2 + cmake/Runtime.cmake | 2 + cmake/UI.cmake | 1 + contrib/native-item-browser.md | 61 ++ 19 files changed, 1161 insertions(+), 2 deletions(-) create mode 100644 Extension/Customization/console_commands.cpp create mode 100644 Extension/Customization/item_browser.cpp create mode 100644 Extension/Customization/item_browser.h create mode 100644 Extension/Customization/item_browser_order.h create mode 100644 Extension/UI/Overlay/item_browser_overlay.cpp create mode 100644 contrib/native-item-browser.md diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index 7103eb5b..45d4b646 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -223,6 +223,7 @@ const Commands &game_commands() { register_park_editor_commands(*result); register_multiplayer_commands(*result); register_perf_commands(*result); + register_item_commands(*result); register_trainer_commands(*result); register_hall_of_meat_commands(*result); return result; diff --git a/Extension/Console/commands.h b/Extension/Console/commands.h index 1afee710..eeab6e49 100644 --- a/Extension/Console/commands.h +++ b/Extension/Console/commands.h @@ -22,6 +22,7 @@ void register_object_commands(Commands &); void register_park_editor_commands(Commands &); void register_multiplayer_commands(Commands &); void register_perf_commands(Commands &); +void register_item_commands(Commands &); void register_trainer_commands(Commands &); void register_hall_of_meat_commands(Commands &); // Runtime adapters. Invoked only by the verified game-thread dispatcher. diff --git a/Extension/Customization/console_commands.cpp b/Extension/Customization/console_commands.cpp new file mode 100644 index 00000000..e0c181e8 --- /dev/null +++ b/Extension/Customization/console_commands.cpp @@ -0,0 +1,44 @@ +#include "Extension/Console/commands.h" +#include "Extension/Customization/item_browser.h" + +namespace dingosdk::console { +void register_item_commands(Commands ®istry) { + auto arguments = argument("verb_and_arguments", Type::text, true); + arguments.rest = true; + arguments.complete = [](const Model &, auto) { + return std::vector{"fav", "search", "filter", "status", "peek", "poke"}; + }; + auto items = action("items", "Skater item grids: fav (favorite the highlighted item), search , " + "filter all|favorites|mods|game, status; peek/poke are research aids", + Group::console, {std::move(arguments)}); + items.run = [](const Model &, const Values &values, const Output &out) { + const auto text = values.empty() ? std::string{} : std::get(values[0]); + const auto space = text.find(' '); + const auto verb = text.substr(0, space); + const auto rest = space == std::string::npos ? std::string{} : text.substr(space + 1); + if (verb == "fav") { + item_browser::toggle_focused_favorite(); + out("Favorite toggled for the highlighted item."); + } else if (verb == "search") { + item_browser::set_search(rest); + out(rest.empty() ? "Search cleared." : "Searching for \"" + rest + "\"."); + } else if (verb == "filter") { + for (std::size_t i = 0; i < std::size(item_browser::filter_names); ++i) + if (item_browser::folded(item_browser::filter_names[i]).starts_with(item_browser::folded(rest)) && !rest.empty()) { + item_browser::set_filter(static_cast(i)); + out("Filter: " + std::string(item_browser::filter_names[i]) + "."); + return; + } + item_browser::cycle_filter(); + out("Next filter."); + } else if (verb == "peek" || verb == "poke") { + item_browser::request_poke(text); + out("Queued; the answer is in the log."); + } else { + item_browser::request_status(); + out("Status written to the log."); + } + }; + registry.add(std::move(items)); +} +} // namespace dingosdk::console diff --git a/Extension/Customization/item_browser.cpp b/Extension/Customization/item_browser.cpp new file mode 100644 index 00000000..6ab318a2 --- /dev/null +++ b/Extension/Customization/item_browser.cpp @@ -0,0 +1,683 @@ +#include "item_browser.h" +#include "local_customization_runtime.h" +#include "Engine/Core/Json/json.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Game/Abi/native_data.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/engine.h" +#include "Engine/Vfs/content_catalogs.h" +#include "Extension/Multiplayer/Hud/game_ui_state.h" +#include "Extension/UI/NativeMenu/native_menu_data.h" +#include "Extension/UI/NativeMenu/native_menu_lifetime.h" +#include "Extension/UI/Overlay/overlay.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::item_browser { +namespace { +namespace md = dingosdk::multiplayer::menu_data; +using logging::Channel; +using logging::Level; + +// Schema and field hashes are the game's own: a data-defined type's TypeNameHash and its fields' +// NameHash (UI/Foundations/Components/Lists/GridList/Widget/GridListViewModel and the types it holds). +constexpr md::Schema grid_list{0x7422aa5b, 440}; +constexpr std::uint32_t owned_items = 0x345b75b1; // GridListViewModel.OwnedItems +constexpr std::uint32_t tile_row = 0xcbe47f59; // ContentPresenterTileViewModel, one per tile, inline +constexpr std::uint32_t ownable_view = 0x6e591955; // UI/Features/Ownables/Widgets/OwnableViewModel +constexpr std::uint32_t ownable_data = 0x389117ef; // the catalog item a tile shows +constexpr std::uint32_t reference = 0x62088281; // DataModelReference +// ContentPresenterTileViewModel.ContentPresenter -> the model its widget shows. +constexpr std::array content_path{0x214d4984, 0x25e4d6c8}; +// ContentPresenterTileViewModel.Button -> ... -> IsFocused, which the tile's widget keeps up to date. +constexpr std::array focused_path{0x0fb0d794, 0xa704272a, 0xc52416ef, 0xa704272a, 0x85a0b4e8}; +constexpr std::uint32_t ownable_data_ref = 0xbbe34156; // OwnableViewModel.UIOwnableDataRef +constexpr std::uint32_t data_key = 0x7199d8ed, data_title = 0xec725743, data_category = 0x3389e313; +// OwnableViewModel.OwnableStyleSet.RarityStyle -> the style whose Icon the tile shows in its corner. +constexpr std::array rarity_path{0x35994418, 0x85839b5a}; +constexpr md::Schema rarity_style{0xb6800075, 416}; +constexpr std::uint32_t rarity_icon = 0xd4f0f044; +// 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"; + +std::uint32_t schema_of(md::Address type) { return type ? md::read(md::read(type)) : 0; } + +unsigned inline_offset(const md::Context& context, md::Address type, std::span hashes, + md::Address* last = nullptr) { + unsigned offset = 0; + for (const auto hash : hashes) { + const auto field = context.member(type, hash); + offset += field.offset; + type = field.type; + } + if (last) *last = type; + return offset; +} + +// The favorites file: beside Skate.exe, where the launcher keeps its own settings. +struct Favorites { + std::set> keys; + std::filesystem::path file; + bool loaded{}; + + void load() { + if (loaded) return; + loaded = true; + std::array buffer{}; + if (const auto length = GetEnvironmentVariableW(L"RESKATE_FAVORITES_FILE", buffer.data(), static_cast(buffer.size())); + length && length < buffer.size()) { + file = std::wstring(buffer.data(), length); + } else { + const auto count = GetModuleFileNameW(nullptr, buffer.data(), static_cast(buffer.size())); + if (!count || count >= buffer.size()) return; + file = std::filesystem::path(std::wstring(buffer.data(), count)).parent_path() / L"ReSkate.favorites.json"; + } + try { + std::ifstream in(file, std::ios::binary); + if (!in) return; + const std::string text((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + const auto json = Json::parse(text); + if (json.is_object() && json.contains("favorites") && json.at("favorites").is_array()) + for (std::size_t i = 0; i < json.at("favorites").size(); ++i) + if (json.at("favorites").at(i).is_string()) keys.insert(json.at("favorites").at(i).string()); + logging::log(Level::info, Channel::customization, "Item favorites: {} loaded.", keys.size()); + } catch (const std::exception& error) { + logging::log(Level::warning, Channel::customization, "Item favorites could not be read ({}); starting empty.", error.what()); + } + } + void save() const { + if (file.empty()) return; + try { + Json json = Json::object(); + json["version"] = 1; + json["favorites"] = Json::array(); + for (const auto& key : keys) json["favorites"].push_back(key); + auto temporary = file; + temporary += L".tmp"; + { std::ofstream out(temporary, std::ios::binary | std::ios::trunc); out << json.dump(2) << '\n'; } + if (!MoveFileExW(temporary.c_str(), file.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + throw std::runtime_error("Windows error " + std::to_string(GetLastError())); + } catch (const std::exception& error) { + logging::log(Level::warning, Channel::customization, "Item favorites could not be saved: {}.", error.what()); + } + } +}; + +struct Layout { + md::Address row_type{}; + unsigned stride{}, content{}, focused{}; +}; + +// One item grid of the game (a slot's tab), and our copy of every row it was built with. +struct Grid { + md::Value list; // the game's GridListViewModel root + md::Value items; // its OwnedItems + md::Value backing; // ours: every row in the game's order, so a search can give rows back + std::vector native; // what the backing rows are + std::vector shown; // item models of the rows the game's list has now + int focused{-1}; // row in `shown` + bool failed{}; +}; + +struct State { + md::MenuLifetime lifetime; + 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 + std::map restyled; // item model -> the game's style it had + bool star_unavailable{}; + unsigned tick{}; + Favorites favorites; + std::string search; + Filter filter{}; + ULONGLONG next_sync{}, next_scan{}, last_open{}; + std::uint64_t serial{}; + bool open{}; + std::string last_error; +}; +State& state() { static auto* value = new State; return *value; } + +struct Shared { + std::mutex mutex; + View view; + std::string search; + bool search_pending{}; + std::vector pokes; + std::atomic toggles{}, cycles{}; + std::atomic filter{-1}; + std::atomic status{}; +}; +Shared& shared() { static auto* value = new Shared; return *value; } + +void report(State& s, const std::string& what) { + if (what == s.last_error) return; + s.last_error = what; + logging::log(Level::warning, Channel::customization, "Item browser: {}", what); +} + +Layout layout_of(const md::Context& context, md::Value items) { + Layout layout; + layout.row_type = md::read(md::read(items.type) + 0x30); + md::require(schema_of(layout.row_type) == tile_row, "Item grid rows are not tiles."); + layout.stride = md::size(layout.row_type); + md::Address last{}; + layout.content = inline_offset(context, layout.row_type, content_path, &last); + md::require(schema_of(last) == reference && md::size(last) == sizeof(md::Ref), "Item tile content differs."); + layout.focused = inline_offset(context, layout.row_type, focused_path, &last); + md::require(md::size(last) == 1, "Item tile focus differs."); + return layout; +} + +struct Rows { + std::vector bytes; + unsigned count{}; + std::vector content; + int focused{-1}; +}; +Rows read_rows(const md::Context& context, md::Value items, const Layout& layout) { + Rows rows; + unsigned stride{}; + rows.bytes = context.array(items, 4096, rows.count, stride); + md::require(!rows.count || stride == layout.stride, "Item grid row size differs."); + rows.content.reserve(rows.count); + for (unsigned i = 0; i < rows.count; ++i) { + const auto row = rows.bytes.data() + std::size_t{i} * stride; + md::Ref ref{}; + std::memcpy(&ref, row + layout.content, sizeof(ref)); + rows.content.push_back(ref.handle & ~std::uint64_t{1}); + if (rows.focused < 0 && row[layout.focused] != std::byte{0}) rows.focused = static_cast(i); + } + return rows; +} + +// The catalog item behind a tile's item model. Empty key: the tile does not show a cosmetic. +Item resolve(const md::Context& context, std::uint64_t content) { + Item item; + item.content = content; + if (!content) return item; + const md::Value view{content, context.type_of(content)}; + if (schema_of(view.type) != ownable_view) return item; + const auto ref = md::read(context.address(view) + context.member(view.type, ownable_data_ref).offset); + const auto handle = ref.handle & ~std::uint64_t{1}; + const md::Value data{handle, handle ? context.type_of(handle) : 0}; + if (schema_of(data.type) != ownable_data) return item; + const auto at = context.address(data); + item.key = md::string(md::read(at + context.member(data.type, data_key).offset), 255); + item.title = md::string(md::read(at + context.member(data.type, data_title).offset), 255); + item.category = md::string(md::read(at + context.member(data.type, data_category).offset), 96); + const auto& catalogs = content_cache::catalogs(); + item.modded = catalogs.available && !catalogs.items.contains(folded(item.key)); + return item; +} + +bool cosmetic(const Item& item) { + if (item.key.empty()) return false; + const auto& items = profile_runtime::cosmetic_runtime().items; + const auto found = items.find(item.key); + return found != items.end() && !found->second.build_kit; +} + +void release(const md::Context& context, Grid& grid) { + if (grid.backing.handle) { + try { context.destroy(grid.backing); } catch (const std::exception&) {} + grid.backing = {}; + } +} + +std::uint64_t starred_source(const State& s, std::uint64_t style) { + for (const auto& [source, copy] : s.starred) if (copy.handle == style) return source; + return 0; +} + +// 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. +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)}; + 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) { + context.set(context.path(view, {rarity_path[0], rarity_path[1]}), md::Ref{0, source}); + s.restyled.erase(item.content); + } + } +} + +// Our style copies go when the screens that used them have: nothing may point at a destroyed model. +void release_styles(State& s, const md::Context& context) { + for (const auto& [content, style] : s.restyled) { + try { + const md::Value view{content, context.type_of(content)}; + if (schema_of(view.type) == ownable_view) + context.set(context.path(view, {rarity_path[0], rarity_path[1]}), md::Ref{0, style}); + } catch (const std::exception&) {} + } + s.restyled.clear(); + for (auto& [source, copy] : s.starred) { + try { context.destroy(copy); } catch (const std::exception&) {} + } + s.starred.clear(); +} + +// Finds the game's item grids: every grid list one of whose first tiles shows a cosmetic (the +// accessory grids start with a "none" tile, which shows no item). +void scan(State& s, const md::Context& context) { + std::erase_if(s.grids, [&](Grid& grid) { + if (context.type_of(grid.list.handle) == grid.list.type) return false; + release(context, grid); // the game closed the screen this grid was on + return true; + }); + if (s.grids.empty() && !s.starred.empty()) release_styles(s, context); + for (const auto& root : context.roots({grid_list.hash})) { + if (std::any_of(s.grids.begin(), s.grids.end(), [&](const Grid& grid) { + return grid.list.handle == root.model.handle || grid.backing.handle == root.model.handle; })) continue; + try { + if (md::size(root.model.type) != grid_list.size) continue; + Grid grid; + grid.list = root.model; + grid.items = context.field(grid.list, owned_items); + if (!s.layout.row_type) s.layout = layout_of(context, grid.items); + const auto rows = read_rows(context, grid.items, s.layout); + bool items = false; + for (unsigned i = 0; i < rows.count && i < 3 && !items; ++i) items = cosmetic(resolve(context, rows.content[i])); + if (!items) continue; + s.grids.push_back(std::move(grid)); + } catch (const std::exception&) { /* Another screen's grid list. */ } + } +} + +std::vector take_rows(const std::vector& from, const std::vector& order, const Layout& layout, + int focused) { + std::vector out; + out.reserve(order.size() * layout.stride); + for (unsigned position = 0; position < order.size(); ++position) { + const auto row = from.data() + std::size_t{order[position]} * layout.stride; + out.insert(out.end(), row, row + layout.stride); + // The copy we keep recorded the focus as it was when the game built the list. + out[out.size() - layout.stride + layout.focused] = static_cast(static_cast(position) == focused); + } + 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); + auto rows = read_rows(context, grid.items, s.layout); + grid.focused = rows.focused; + if (rows.content != grid.shown || verify) { + // Not what we last put there: the game built the list again (the screen opened, an item was + // equipped, mods changed). Its list is complete and in its own order, so it is the new source. + std::vector items; + items.reserve(rows.count); + for (const auto content : rows.content) items.push_back(resolve(context, content)); + bool ours = rows.content == grid.shown && !grid.native.empty(); + 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; + } + } + if (!ours) { + if (!grid.backing.handle) + grid.backing = context.create(grid_list, (GetTickCount64() << 12) | (++s.serial & 0xfff)); + context.array(context.field(grid.backing, owned_items), rows.bytes, rows.count); + grid.native = std::move(items); + grid.shown = rows.content; + } + } + const auto order = arrange(grid.native, s.favorites.keys, s.search, s.filter); + std::vector wanted; + wanted.reserve(order.size()); + for (const auto index : order) wanted.push_back(grid.native[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."); + 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); + if (after.content != wanted) { + grid.failed = true; + throw std::runtime_error("the game did not take the reordered tiles; this grid is left alone."); + } + grid.shown = std::move(wanted); + grid.focused = after.focused; +} + +md::Value deref(const md::Context& context, md::Value value) { + md::require(schema_of(value.type) == reference, "Not a model reference."); + const auto handle = md::read(context.address(value)).handle & ~std::uint64_t{1}; + md::require(handle != 0, "The reference is empty."); + return {handle, context.type_of(handle)}; +} + +std::string hex(std::uint64_t value, int width) { + static constexpr char digits[] = "0123456789abcdef"; + std::string out(static_cast(width), '0'); + for (int i = width - 1; i >= 0; --i, value >>= 4) out[static_cast(i)] = digits[value & 15]; + return "0x" + out; +} + +// Research aid: "peek [path]" and "poke :" on live models. +// root: grid | row | item | data (of the focused tile) or a model handle; path: field hashes, [n] and +// ">" (follow a reference), separated by dots. +std::string poke(State& s, const md::Context& context, const std::string& arguments) { + std::vector words; + for (std::size_t at = 0; at < arguments.size();) { + const auto end = std::min(arguments.find(' ', at), arguments.size()); + if (end > at) words.push_back(arguments.substr(at, end - at)); + at = end + 1; + if (words.size() == 3 && at < arguments.size()) { words.push_back(arguments.substr(at)); break; } + } + md::require(words.size() >= 2, "usage: peek [path] | poke :"); + const bool write = words[0] == "poke"; + const auto focused = std::find_if(s.grids.begin(), s.grids.end(), [](const Grid& grid) { return grid.focused >= 0; }); + md::Value value; + if (words[1].starts_with("0x")) { + const auto handle = std::stoull(words[1].substr(2), nullptr, 16); + value = {handle, context.type_of(handle)}; + } else { + md::require(focused != s.grids.end(), "No item tile has the focus."); + if (words[1] == "grid") value = focused->list; + else if (words[1] == "back") value = focused->backing; + else { + value = context.element(focused->items, static_cast(focused->focused)); + if (words[1] != "row") { + value = deref(context, context.path(value, {content_path[0], content_path[1]})); + if (words[1] == "data") value = deref(context, context.field(value, ownable_data_ref)); + else md::require(words[1] == "item", "root: grid, back, row, item, data or a 0x handle"); + } + } + } + md::require(value.handle && value.type, "That model does not exist."); + const std::string path = words.size() > 2 && (write || words.size() == 3) ? words[2] : std::string{}; + for (std::size_t at = 0; at < path.size();) { + const auto end = std::min(path.find('.', at), path.size()); + const auto token = path.substr(at, end - at); + at = end + 1; + if (token.empty()) continue; + if (token == ">") value = deref(context, value); + else if (token.front() == '[') value = context.element(value, static_cast(std::stoul(token.substr(1)))); + else value = context.field(value, static_cast(std::stoul(token, nullptr, 16))); + } + const auto kind = md::kind(value.type), size = md::size(value.type); + if (write) { + md::require(words.size() == 4, "poke needs :"); + const auto colon = words[3].find(':'); + md::require(colon != std::string::npos, "poke needs :"); + const auto how = words[3].substr(0, colon), text = words[3].substr(colon + 1); + if (how == "u8") context.set(value, static_cast(std::stoul(text, nullptr, 0))); + else if (how == "u32") context.set(value, static_cast(std::stoul(text, nullptr, 0))); + else if (how == "f32") context.set(value, std::stof(text)); + else if (how == "text") context.text(value, text); + else if (how == "ref") context.set(value, md::Ref{0, std::stoull(text, nullptr, 0)}); + else if (how == "tex") md::require(md::publish_texture(context, value, text), "That texture is not loaded."); + else throw std::runtime_error("kinds: u8, u32, f32, text, ref, tex"); + } + std::string out = (write ? "poked " : "peek ") + hex(value.handle, 16) + " <" + hex(schema_of(value.type), 8) + " k" + + std::to_string(kind) + ' ' + std::to_string(size) + ">"; + const auto at = context.address(value); + if (kind == 7) out += " = \"" + md::string(md::read(at), 255) + '"'; + else if (kind == 4) { + const auto pointer = md::read(at); + out += " [" + std::to_string(pointer ? md::read(pointer - 4) & 0x7fffffff : 0) + "] of <" + + hex(schema_of(md::read(md::read(value.type) + 0x30)), 8) + ">"; + } else if (kind == 2) { + const auto meta = md::read(value.type), fields = md::read(meta + 0x60); + const auto count = md::read(meta + 0x2a); + for (unsigned i = 0; i < count && i < 64; ++i) { + const auto entry = fields + i * 24; + const auto type = md::read(entry + 16); + const auto offset = md::read(entry + 8); + out += "\n " + hex(md::read(entry), 8) + '@' + std::to_string(offset) + " <" + + hex(schema_of(type), 8) + " k" + std::to_string(md::kind(type)) + ' ' + std::to_string(md::size(type)) + ">"; + if (md::kind(type) == 7) out += " \"" + md::string(md::read(at + offset), 96) + '"'; + else if (md::kind(type) != 2 && md::size(type) <= 16) { + out += " "; + for (unsigned b = 0; b < md::size(type); ++b) out += hex(md::read(at + offset + b), 2).substr(2); + } + } + } else { + out += " = "; + for (unsigned b = 0; b < std::min(size, 32U); ++b) out += hex(md::read(at + b), 2).substr(2); + } + return out; +} + +// Research aid, with the status: every grid list the game has open, taken or not, and what its +// first tiles show. Says why a menu's grid was passed over. +void log_roots(State& s, const md::Context& context) { + for (const auto& root : context.roots({grid_list.hash})) { + std::string line = " root " + hex(root.model.handle, 16) + ": size " + std::to_string(md::size(root.model.type)); + try { + const auto items = context.field(root.model, owned_items); + const auto row_type = md::read(md::read(items.type) + 0x30); + line += ", rows of " + hex(schema_of(row_type), 8); + if (schema_of(row_type) == tile_row) { + if (!s.layout.row_type) s.layout = layout_of(context, items); + const auto rows = read_rows(context, items, s.layout); + line += ", " + std::to_string(rows.count) + " tile(s)"; + for (unsigned i = 0; i < rows.count && i < 4; ++i) { + const auto content = rows.content[i]; + const auto item = resolve(context, content); + line += "; [" + std::to_string(i) + "] " + (content ? hex(schema_of(context.type_of(content)), 8) : std::string("-")) + + " \"" + item.key + "\" in \"" + item.category + "\"" + (cosmetic(item) ? "" : " (no cosmetic)"); + } + } + } catch (const std::exception& error) { line += std::string(": ") + error.what(); } + logging::log(Level::info, Channel::customization, "{}", line); + } +} +void log_status(const State& s) { + logging::log(Level::info, Channel::customization, "Item browser: {} grid(s), search \"{}\", filter {}, {} favorite(s), file {}.", + s.grids.size(), s.search, filter_names[static_cast(s.filter)], s.favorites.keys.size(), + s.favorites.file.string()); + for (const auto& grid : s.grids) { + std::string focus = "-"; + if (grid.focused >= 0 && static_cast(grid.focused) < grid.shown.size()) + for (const auto& item : grid.native) + if (item.content == grid.shown[static_cast(grid.focused)]) focus = std::to_string(grid.focused) + " " + item.key; + logging::log(Level::info, Channel::customization, " grid {}: {} of {} shown, first \"{}\", focus {}{}.", + hex(grid.list.handle, 16), grid.shown.size(), grid.native.size(), + grid.native.empty() ? std::string{} : grid.native.front().title, focus, grid.failed ? ", FAILED" : ""); + } +} +} // namespace + +View view() { + auto& sh = shared(); + std::lock_guard lock(sh.mutex); + return sh.view; +} +void toggle_focused_favorite() noexcept { shared().toggles.fetch_add(1); } +void set_search(std::string text) { + auto& sh = shared(); + std::lock_guard lock(sh.mutex); + sh.search = std::move(text); + sh.search_pending = true; +} +void set_filter(Filter filter) noexcept { shared().filter.store(static_cast(filter)); } +const overlay::ItemBrowserHost* overlay_host() noexcept { + static const overlay::ItemBrowserHost host{ + [] { + const auto current = view(); + overlay::ItemBrowserView out; + out.open = current.open; + out.search = current.search; + out.filter = std::string(filter_names[static_cast(current.filter)]); + out.shown = current.shown; + out.total = current.total; + out.focused_favorite = current.focused_favorite; + return out; + }, + [] { toggle_focused_favorite(); }, + [](const char* text) { set_search(text ? text : ""); }, + [] { cycle_filter(); }}; + return &host; +} +void cycle_filter() noexcept { shared().cycles.fetch_add(1); } +void request_status() noexcept { shared().status.store(true); } +void request_poke(std::string arguments) { + auto& sh = shared(); + std::lock_guard lock(sh.mutex); + if (sh.pokes.size() < 32) sh.pokes.push_back(std::move(arguments)); +} + +void before_level_transition(unsigned next, std::uintptr_t base) noexcept { + try { + auto& s = state(); + s.lifetime.before_transition(next, [&] { + // Our copies hold references to the screen's widgets and styles: let go while those are loaded. + md::Address ui{}, manager{}; + if (memory::read(base + addr::engine::ui_manager, ui) && ui && memory::read(ui + 0x140, manager) && + manager && manager == s.manager) { + game::ModelWriteLock lock(manager); + const md::Context context(base, manager); + for (auto& grid : s.grids) release(context, grid); + release_styles(s, context); + } + s.grids.clear(); + s.starred.clear(); + s.restyled.clear(); + s.layout = {}; + s.open = false; + }); + } catch (...) {} +} + +void update(std::uintptr_t base) noexcept { + auto& s = state(); + auto& sh = shared(); + try { + if (s.lifetime.blocked()) return; + const auto now = GetTickCount64(); + if (now < s.next_sync) return; + s.next_sync = now + (s.open ? 40 : 200); + s.favorites.load(); + const bool in_menu = multiplayer::sample_game_ui_state(base).in_menu; + md::Address ui{}, manager{}; + if (!memory::read(base + addr::engine::ui_manager, ui) || !ui || !memory::read(ui + 0x140, manager) || !manager) return; + if (manager != s.manager) { + s.grids.clear(); s.starred.clear(); s.restyled.clear(); + s.layout = {}; s.manager = manager; + } + if (!in_menu && s.grids.empty() && !sh.status.load()) { + std::lock_guard lock(sh.mutex); + if (sh.pokes.empty()) { sh.view = {}; return; } + } + game::ModelWriteLock lock(manager); + const md::Context context(base, manager); + const bool rescan = now >= s.next_scan; + if (rescan) { + s.next_scan = now + 400; + scan(s, context); + } + // Requests. + std::vector pokes; + { + std::lock_guard shared_lock(sh.mutex); + if (std::exchange(sh.search_pending, false)) s.search = sh.search; + pokes.swap(sh.pokes); + } + if (const auto filter = sh.filter.exchange(-1); filter >= 0 && filter <= static_cast(Filter::game)) + s.filter = static_cast(filter); + for (auto cycles = sh.cycles.exchange(0); cycles; --cycles) + s.filter = static_cast((static_cast(s.filter) + 1) % std::size(filter_names)); + const Grid* active = nullptr; + const Item* focused = nullptr; + const auto find_focus = [&] { + active = nullptr; focused = nullptr; + for (const auto& grid : s.grids) { + if (grid.focused < 0 || static_cast(grid.focused) >= grid.shown.size()) continue; + active = &grid; + for (const auto& item : grid.native) + if (item.content == grid.shown[static_cast(grid.focused)]) focused = &item; + break; + } + }; + find_focus(); + if (const auto toggles = sh.toggles.exchange(0); (toggles & 1) && focused && !focused->key.empty()) { + const auto key = focused->key; + if (!s.favorites.keys.erase(key)) s.favorites.keys.insert(key); + s.favorites.save(); + logging::log(Level::info, Channel::customization, "Item favorites: {} {}.", key, + s.favorites.keys.contains(key) ? "added" : "removed"); + } + ++s.tick; + for (auto& grid : s.grids) { + try { + sync(s, context, grid, rescan); + if (rescan || (grid.focused >= 0 && s.tick % 3 == 0)) mark(s, context, grid); + } catch (const std::exception& error) { report(s, error.what()); } + } + find_focus(); + for (const auto& arguments : pokes) { + try { logging::log(Level::info, Channel::customization, "Item browser: {}", poke(s, context, arguments)); } + catch (const std::exception& error) { logging::log(Level::warning, Channel::customization, "Item browser: {}: {}", arguments, error.what()); } + } + if (sh.status.exchange(false)) { log_status(s); log_roots(s, context); } + // The search belongs to one visit: it is cleared when the grids are gone, or have been out of + // focus for longer than one of the game's own cards (a dialog over the grid) usually stays up. + if (active) s.last_open = now; + s.open = active != nullptr; + if (!s.open && (s.grids.empty() || now > s.last_open + 8000) && (!s.search.empty() || s.filter != Filter::all)) { + s.search.clear(); + s.filter = Filter::all; + } + View next; + next.open = s.open; + next.search = s.search; + next.filter = s.filter; + 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; + for (const auto& item : active->native) next.favorites += s.favorites.keys.contains(item.key); + } + if (focused) { + next.focused_title = focused->title; + next.focused_favorite = s.favorites.keys.contains(focused->key); + } + std::lock_guard shared_lock(sh.mutex); + sh.view = std::move(next); + } catch (const std::exception& error) { + try { report(s, error.what()); } catch (...) {} + } catch (...) {} +} +} // namespace dingosdk::item_browser diff --git a/Extension/Customization/item_browser.h b/Extension/Customization/item_browser.h new file mode 100644 index 00000000..f2bb1b4d --- /dev/null +++ b/Extension/Customization/item_browser.h @@ -0,0 +1,36 @@ +#pragma once +#include "item_browser_order.h" +#include +#include +#include + +// Favorites, search and filters for the skater's item grids (Apparel, Accessories, Board, Tattoos, +// Gestures, Appearance). The grids stay the game's own: their tile rows are reordered and filtered in +// place, the way the music screen's Mods shelf filters playlist tiles (contrib/native-music-ui.md). +namespace dingosdk::overlay { struct ItemBrowserHost; } +namespace dingosdk::item_browser { +// What the overlay draws: read every presented frame (thread-safe, cheap). +struct View { + bool open{}; // an item grid has the focus + std::string search; + Filter filter{}; + unsigned shown{}, total{}, favorites{}; // of the grid that has the focus + std::string focused_title; + bool focused_favorite{}; +}; +View view(); +// Requests from the keys, the overlay and the console; applied on the client thread. +void toggle_focused_favorite() noexcept; +void set_search(std::string text); +void set_filter(Filter filter) noexcept; +void cycle_filter() noexcept; +// The table the overlay's search bar and keys use (process lifetime). +const overlay::ItemBrowserHost* overlay_host() noexcept; +// Console research aids; their answers go to the log. +void request_status() noexcept; +void request_poke(std::string arguments); + +// Client thread, from the customization update, with the profile's native mutex held. +void update(std::uintptr_t base) noexcept; +void before_level_transition(unsigned next, std::uintptr_t base) noexcept; +} // namespace dingosdk::item_browser diff --git a/Extension/Customization/item_browser_order.h b/Extension/Customization/item_browser_order.h new file mode 100644 index 00000000..78eadf96 --- /dev/null +++ b/Extension/Customization/item_browser_order.h @@ -0,0 +1,83 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +// 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 }; +inline constexpr std::string_view filter_names[]{"All", "Favorites", "Mods", "Game items"}; + +struct Item { + std::string key, title, category; + bool modded{}; + std::uint64_t content{}; // the tile's native item model +}; + +inline std::string folded(std::string_view text) { + std::string result(text); + for (auto& c : result) if (c >= 'A' && c <= 'Z') c = static_cast(c + ('a' - 'A')); + return result; +} + +// Every word typed must be in the item's name. From three letters on a word may also be in the item's +// asset name, where the brand of a stock item is ("Own_ShoeSneaker_Vans_..."); shorter ones would match +// the "Own_" every asset name starts with. +inline bool matches(const Item& item, std::string_view search) { + const auto title = folded(item.title); + auto key = folded(item.key); + if (key.starts_with("own_")) key.erase(0, 4); + std::replace(key.begin(), key.end(), '_', ' '); + const auto text = folded(search); + for (std::size_t at = 0; at < text.size();) { + const auto end = std::min(text.find(' ', at), text.size()); + const std::string_view word(text.data() + at, end - at); + at = end + 1; + if (word.empty()) continue; + if (title.find(word) == std::string::npos && (word.size() < 3 || key.find(word) == std::string::npos)) return false; + } + return true; +} + +// The game lists the item being worn first and the rest by name. When the first tile is out of that +// order it is the worn one, and it keeps its place ahead of the favorites. +inline bool worn_first(std::span native) { + return native.size() >= 2 && folded(native[0].title) > folded(native[1].title); +} + +// How many tiles at the front of a grid show no item: the "none" tile that takes an accessory off. +inline unsigned leading_blanks(std::span native) { + unsigned count = 0; + while (count < native.size() && native[count].key.empty()) ++count; + return count; +} + +// Indices into `native` (the game's own order): the tiles that show no item (they stay where they +// are, in front, whatever is searched for), the worn item, the favorites, then everything else, +// each kept in the game's order, less what the search and the filter leave out. Never empty for a +// grid that has tiles: a grid with nothing in it cannot take the focus back. +inline std::vector arrange(std::span native, const std::set>& favorites, + std::string_view search, Filter filter) { + const auto shown = [&](const Item& item) { + if (filter == Filter::favorites && !favorites.contains(item.key)) return false; + if (filter == Filter::mods && !item.modded) return false; + if (filter == Filter::game && item.modded) return false; + return matches(item, search); + }; + std::vector result; + result.reserve(native.size()); + const unsigned blanks = leading_blanks(native); + for (unsigned i = 0; i < blanks; ++i) result.push_back(i); + const unsigned first = blanks + (worn_first(native.subspan(blanks)) ? 1 : 0); + if (first > blanks && shown(native[blanks])) result.push_back(blanks); + for (unsigned pass = 0; pass < 2; ++pass) + for (unsigned i = first; i < native.size(); ++i) + if (favorites.contains(native[i].key) == (pass == 0) && shown(native[i])) result.push_back(i); + if (result.empty() && !native.empty()) result.push_back(0); + return result; +} +} // namespace dingosdk::item_browser diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index 903e0249..951b1d1c 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -6,6 +6,7 @@ #include "Extension/Customization/local_player_card_runtime.h" #include "Extension/Music/local_music_ui.h" #include "Extension/Music/local_music_shelf.h" +#include "Extension/Customization/item_browser.h" #include "Extension/Music/local_music_playback.h" #include "Extension/News/local_news_runtime.h" #include "Extension/Objects/local_buildkit_labels.h" @@ -328,6 +329,7 @@ void update_local_customization() noexcept { catch (...) { dingosdk::logging::event(dingosdk::logging::Channel::profile, "{\"event\":\"local_object_categories_failed\",\"operation\":\"update\"}"); } update_music_catalog(); update_music_shelf(); + item_browser::update(s.base); try { if (cosmetic_runtime().update_thread == GetCurrentThreadId() && !cosmetic_runtime().items.empty()) update_player_card(); @@ -342,6 +344,7 @@ void local_profile_before_level_transition(unsigned next) noexcept { if (!s.active.load(std::memory_order_acquire)) return; std::lock_guard lock(s.native_mutex); music_shelf_before_level_transition(next); + item_browser::before_level_transition(next, s.base); news_runtime().pending.before_transition(next); object_runtime().pending.before_transition(next); auto& placements = placements_runtime(); diff --git a/Extension/UI/NativeMenu/native_menu_data.cpp b/Extension/UI/NativeMenu/native_menu_data.cpp index ba2e0bed..72cf05a9 100644 --- a/Extension/UI/NativeMenu/native_menu_data.cpp +++ b/Extension/UI/NativeMenu/native_menu_data.cpp @@ -455,4 +455,7 @@ bool publish_tab_icons(const Context& context, Value tab, std::string_view idle, context.set(context.field(tab, 0xbc92d1fa), assets[1]); // FocusIcon. }); } +bool publish_texture(const Context& context, Value target, std::string_view name) { + return with_texture_assets(context, std::array{name}, [&](const auto& assets) { context.set(target, assets[0]); }); +} } // namespace dingosdk::multiplayer::menu_data diff --git a/Extension/UI/NativeMenu/native_menu_data.h b/Extension/UI/NativeMenu/native_menu_data.h index 54e58da0..1c73812a 100644 --- a/Extension/UI/NativeMenu/native_menu_data.h +++ b/Extension/UI/NativeMenu/native_menu_data.h @@ -70,4 +70,6 @@ Address named_asset(const Context& context, Address anchor, const char* name); // Keep the registry lock until native publication has retained the icon. Missing // optional art leaves the caller's already-published fallback untouched. bool publish_tab_icons(const Context& context, Value tab, std::string_view idle, std::string_view focused); +// Publishes one loaded texture or image asset, by name, into an asset reference field. +bool publish_texture(const Context& context, Value target, std::string_view name); } // namespace dingosdk::multiplayer::menu_data diff --git a/Extension/UI/Overlay/item_browser_overlay.cpp b/Extension/UI/Overlay/item_browser_overlay.cpp new file mode 100644 index 00000000..f5e6f5b3 --- /dev/null +++ b/Extension/UI/Overlay/item_browser_overlay.cpp @@ -0,0 +1,171 @@ +#include "overlay_internal.h" +#include + +// The skater item grids' search bar and key prompts, drawn over the game's own menu in its style. +// The grids themselves are the game's (Extension/Customization/item_browser.cpp); this only shows +// what is going on and takes the typing. +namespace dingosdk::overlay { +namespace { +std::atomic item_browser_host{}; +} +void set_item_browser(const ItemBrowserHost* host) noexcept { item_browser_host.store(host); } +} // namespace dingosdk::overlay + +namespace dingosdk::overlay::detail { +namespace { +using Clock = std::chrono::steady_clock; +struct Panel { + ItemBrowserView view; + Clock::time_point next_poll{}; + std::array input{}; + bool stick_down{}; + std::string pushed; // the search text the host was last given +}; +Panel& panel() { static auto* value = new Panel; return *value; } +const ItemBrowserHost* host() { return item_browser_host.load(); } + +// A five-pointed star, the mark favorites carry on their tiles. +void star(ImDrawList* draw, ImVec2 centre, float radius, ImU32 colour) { + std::array points; + for (int i = 0; i < 10; ++i) { + const float angle = -1.5707963f + static_cast(i) * 0.6283185f, r = i % 2 ? radius * 0.42f : radius; + points[static_cast(i)] = ImVec2(centre.x + std::cos(angle) * r, centre.y + std::sin(angle) * r); + } + // Concave: fan it from the centre. + for (int i = 0; i < 10; ++i) + draw->AddTriangleFilled(centre, points[static_cast(i)], points[static_cast((i + 1) % 10)], colour); +} +} // namespace + +bool item_browser_open() { return panel().view.open; } + +void item_browser_key(ItemBrowserKey key) { + const auto* h = host(); + if (!h) return; + if (key == ItemBrowserKey::favorite && h->toggle_favorite) h->toggle_favorite(); + if (key == ItemBrowserKey::filter && h->cycle_filter) h->cycle_filter(); +} + +void item_browser_search_cleared() { + auto& p = panel(); + p.input.fill(0); + if (const auto* h = host(); h && h->set_search) h->set_search(""); +} + +bool item_browser_pending() { + auto& s = state(); + auto& p = panel(); + const auto* h = host(); + if (!h || !h->view) return false; + if (const auto now = Clock::now(); now >= p.next_poll) { + p.next_poll = now + std::chrono::milliseconds(50); + try { p.view = h->view(); } catch (...) { p.view = {}; } + } + if (!p.view.open) { + s.item_search_visible.store(false); + p.stick_down = false; + return false; + } + // Left stick click: favorite. Read here, once a presented frame, only while a grid is open. + dingosdk::ControllerInput pad; + DingoSDKOverlayReadControllerInput(&pad); + const bool stick = pad.available && (pad.buttons & XINPUT_GAMEPAD_LEFT_THUMB) != 0; + if (stick && !p.stick_down) item_browser_key(ItemBrowserKey::favorite); + p.stick_down = stick; + return true; +} + +void draw_item_browser() { + auto& s = state(); + auto& p = panel(); + const auto* h = host(); + if (!h || !p.view.open) return; + const auto& view = p.view; + auto* heading = s.menu.bold ? s.menu.bold : ImGui::GetFont(); + auto* body = s.menu.body ? s.menu.body : ImGui::GetFont(); + const auto display = ImGui::GetIO().DisplaySize; + // Sized like the game's own item card and prompts, which are laid out for a 2160-line screen. + const float scale = std::clamp(display.y / 1080.0f, 0.75f, 3.0f) * 1.35f; + const float width = 350.0f * scale, height = 42.0f * scale, margin = 0.074f * display.y; + const ImVec2 at(display.x - margin - width, margin); + const bool typing = s.item_search_visible.load(); + + // The search field: a dark tile like the game's item card, blue-edged while it takes the keys. + auto* draw = ImGui::GetBackgroundDrawList(); + skate_theme::rough_rect(draw, at, ImVec2(at.x + width, at.y + height), IM_COL32(18, 18, 19, 235), 71, scale); + if (typing) draw->AddRect(ImVec2(at.x - scale, at.y - scale), ImVec2(at.x + width + scale, at.y + height + scale), theme::blue, 0, 0, 2.5f * scale); + const ImVec2 lens(at.x + 24 * scale, at.y + height * 0.46f); + draw->AddCircle(lens, 8 * scale, theme::paper, 0, 2.2f * scale); + draw->AddLine(ImVec2(lens.x + 6 * scale, lens.y + 6 * scale), ImVec2(lens.x + 12 * scale, lens.y + 12 * scale), theme::paper, 2.6f * scale); + const std::string count = view.shown == view.total ? std::to_string(view.total) + : std::to_string(view.shown) + " / " + std::to_string(view.total); + const float count_size = 15 * scale; + const auto count_extent = body->CalcTextSizeA(count_size, FLT_MAX, 0, count.c_str()); + draw->AddText(body, count_size, ImVec2(at.x + width - count_extent.x - 14 * scale, at.y + (height - count_extent.y) * 0.5f), + theme::muted, count.c_str()); + + const float text_x = at.x + 46 * scale, text_width = width - 46 * scale - count_extent.x - 28 * scale; + if (typing) { + ImGui::SetNextWindowPos(ImVec2(text_x, at.y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(text_width, height), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, (height - 20 * scale) * 0.5f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_Text, theme::paper); + ImGui::PushStyleColor(ImGuiCol_TextSelectedBg, IM_COL32(1, 131, 255, 110)); + constexpr auto flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground; + if (ImGui::Begin("##reskate_item_search", nullptr, flags)) { + ImGui::PushFont(heading); + ImGui::SetWindowFontScale(20 * scale / heading->FontSize); + if (s.item_search_focus_requested.exchange(false)) { + std::snprintf(p.input.data(), p.input.size(), "%s", view.search.c_str()); + const auto id = ImGui::GetID("##reskate_item_search_input"); + if (auto* active = ImGui::GetInputTextState(id); active && ImGui::GetActiveID() == id) + active->ReloadUserBufAndMoveToEnd(); + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(-FLT_MIN); + const bool entered = ImGui::InputTextWithHint("##reskate_item_search_input", "Type to search", p.input.data(), + p.input.size(), ImGuiInputTextFlags_EnterReturnsTrue); + // Every letter filters the grid at once. + if (h->set_search && p.pushed != p.input.data()) { + p.pushed = p.input.data(); + h->set_search(p.pushed.c_str()); + } + if (entered) s.item_search_visible.store(false); + ImGui::PopFont(); + } + ImGui::End(); + ImGui::PopStyleColor(4); + ImGui::PopStyleVar(3); + } else { + p.pushed = view.search; + const float size = 20 * scale; + const auto text = view.search.empty() ? std::string("Search") : view.search; + draw->PushClipRect(ImVec2(text_x, at.y), ImVec2(text_x + text_width, at.y + height), true); + draw->AddText(heading, size, ImVec2(text_x, at.y + (height - size) * 0.5f - scale), + view.search.empty() ? theme::muted : theme::paper, text.c_str()); + draw->PopClipRect(); + } + + // Key prompts, like the game's "Esc Back". + float x = at.x; + const float y = at.y + height + 12 * scale; + const auto prompt = [&](const char* key, const std::string& label) { + x += theme::keycap(draw, body, scale, ImVec2(x, y), key, label.c_str()) + 22 * scale; + }; + if (typing) { + prompt("Enter", "Done"); + prompt("Esc", "Clear"); + } else { + prompt("F", view.focused_favorite ? "Unfavorite" : "Favorite"); + prompt("-", "Search"); + prompt("X", view.filter); + } + if (view.focused_favorite && !typing) + star(draw, ImVec2(at.x - 22 * scale, at.y + height * 0.5f), 13 * scale, skate_theme::bar); +} +} // namespace dingosdk::overlay::detail diff --git a/Extension/UI/Overlay/overlay.cpp b/Extension/UI/Overlay/overlay.cpp index 51bac695..4b828e03 100644 --- a/Extension/UI/Overlay/overlay.cpp +++ b/Extension/UI/Overlay/overlay.cpp @@ -44,7 +44,7 @@ bool is_input(UINT message) { bool interactive_visible(const State& s) { return s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load() || - s.hub_typing.load(); + s.hub_typing.load() || s.item_search_visible.load(); } void restore_input(bool hide_menu) { @@ -54,6 +54,7 @@ void restore_input(bool hide_menu) { s.console_visible.store(false); s.chat_visible.store(false); s.hub_typing.store(false); + s.item_search_visible.store(false); s.editor_visible.store(false); s.editor_flight.store(false); } diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 6bc69c42..f48b465a 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -361,6 +361,23 @@ struct Nametags { }; using NametagFeed = Nametags (*)(); void set_nametag_feed(NametagFeed) noexcept; +// The skater item grids' favorites, search and filter (item_browser_overlay.cpp): what the host's +// grids show now, and what the keys ask of them. The view is read while a grid is open; every +// function is thread-safe and only queues work for the client thread. +struct ItemBrowserView { + bool open{}; // an item grid has the focus + std::string search, filter; // the search text and the filter's name + unsigned shown{}, total{}; // tiles in the grid that has the focus + bool focused_favorite{}; // the highlighted item is a favorite +}; +struct ItemBrowserHost { + ItemBrowserView (*view)(){}; + void (*toggle_favorite)(){}; + void (*set_search)(const char *text){}; + void (*cycle_filter)(){}; +}; +// `host` must stay valid for the life of the process. +void set_item_browser(const ItemBrowserHost *host) noexcept; using ParkSurfaceQueue = bool (*)(const EditorSurfaceRequest &); void set_park_surface_queue(ParkSurfaceQueue) noexcept; using ParkPreviewQueue = bool (*)(const EditorPreviewRequest &); diff --git a/Extension/UI/Overlay/overlay_input.cpp b/Extension/UI/Overlay/overlay_input.cpp index ef128b24..cff05a4a 100644 --- a/Extension/UI/Overlay/overlay_input.cpp +++ b/Extension/UI/Overlay/overlay_input.cpp @@ -150,6 +150,42 @@ LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { const bool release = message == WM_KEYUP || message == WM_SYSKEYUP; 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; + // The skater item grids (item_browser_overlay.cpp): while one has the focus and nothing of ours + // is open, F favorites the highlighted item, X steps the filter and "-" (or Ctrl+F) opens the + // search box, which then takes the keys until Enter or Esc. (The game uses Q, E, T and G here: + // G opens its feedback card.) + if (s.item_search_visible.load() || s.item_search_escape_pending.load()) { + if (wp == VK_ESCAPE && (key || release) && !s.console_visible.load() && !s.visible.load()) { + if (key && (static_cast(lp) & (1ull << 30)) == 0) { + s.item_search_escape_pending.store(true); + s.item_search_visible.store(false); + item_browser_search_cleared(); + sync_menu_cursor(); + window_cursor_input(window, message, wp); + std::lock_guard lock(s.input_mutex); + s.input.clear(); + } else if (release) s.item_search_escape_pending.store(false); + return 0; + } + } else if (key && (static_cast(lp) & (1ull << 30)) == 0 && !interactive_visible(s) && + !(GetKeyState(VK_MENU) & 0x8000) && item_browser_open()) { + const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + const bool minus = wp == VK_SUBTRACT || (MapVirtualKeyW(static_cast(wp), MAPVK_VK_TO_CHAR) == L'-' && + !(GetKeyState(VK_SHIFT) & 0x8000)); + if ((minus && !control) || (wp == 'F' && control)) { + s.item_search_character_pending.store(true); + s.item_search_visible.store(true); + s.item_search_focus_requested.store(true); + sync_menu_cursor(); + window_cursor_input(window, message, wp); + std::lock_guard lock(s.input_mutex); + s.input.clear(); + return 0; + } + if (!control && wp == 'F') item_browser_key(ItemBrowserKey::favorite); + if (!control && wp == 'X') item_browser_key(ItemBrowserKey::filter); + } // T opens the chat, the key the game itself reserves for it // (Processor_Keyboard_Gameplay_Chat), while in a session and nothing // else of ours is open. Its character is swallowed like the console's. diff --git a/Extension/UI/Overlay/overlay_internal.h b/Extension/UI/Overlay/overlay_internal.h index 6b85f96e..bfc0ed0c 100644 --- a/Extension/UI/Overlay/overlay_internal.h +++ b/Extension/UI/Overlay/overlay_internal.h @@ -131,6 +131,9 @@ struct State { // keeps it. hub_typing: one of its text boxes has the keyboard, which the overlay takes // for that long, as for chat. std::atomic hub_pointer{false}, hub_typing{false}; + // The item grids' search box (item_browser_overlay.cpp): "-" opens it while a grid has the focus. + std::atomic item_search_visible{false}, item_search_focus_requested{false}; + std::atomic item_search_character_pending{false}, item_search_escape_pending{false}; std::atomic input_attached{false}; std::atomic stop{false}; std::atomic window{nullptr}; @@ -284,6 +287,13 @@ void draw_nametags(); bool perf_hud_pending(); void draw_perf_hud(); void draw_perf_window(); +// The skater item grids' search bar and prompts (item_browser_overlay.cpp): polled every presented frame. +enum class ItemBrowserKey { favorite, filter }; +bool item_browser_pending(); +bool item_browser_open(); +void item_browser_key(ItemBrowserKey key); +void item_browser_search_cleared(); // Esc in the search box +void draw_item_browser(); // Queues a private multiplayer action from outside the menu (multiplayer_menu.cpp). bool queue_multiplayer_action(const char* action, const std::string& argument); bool queue_multiplayer_action(const char* action, const std::string& argument, const std::string& password); diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index 051c24b7..606689ae 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -481,6 +481,7 @@ void render(IDXGISwapChain* presented, UINT flags) { const bool skate_hud_frame = skate_hud_pending(); const bool nametag_frame = nametags_pending(); const bool meat_frame = hall_of_meat_pending(); + const bool item_browser_frame = item_browser_pending(); const bool perf_frame = perf_hud_pending() || trainer_hud_pending(); const bool hub_frame = hub_page_pending(); if (trainer_open_requested()) s.visible.store(true); @@ -500,7 +501,7 @@ void render(IDXGISwapChain* presented, UINT flags) { } // Hidden, the overlay still draws while a notice or chat line is on screen. if (s.loaded_notice_posted && !notices_pending() && !chat_frame && !game_text_frame && !skate_hud_frame && - !nametag_frame && !meat_frame && !perf_frame && !hub_frame) return; + !nametag_frame && !meat_frame && !item_browser_frame && !perf_frame && !hub_frame) return; } else if (!s.ui_was_interactive) { s.ui_was_interactive = true; s.last_model = {}; // Reopening immediately reads fresh state. @@ -557,6 +558,7 @@ void render(IDXGISwapChain* presented, UINT flags) { draw_perf_hud(); draw_trainer_hud(); draw_notices(); + draw_item_browser(); draw_chat(); sync_menu_cursor(); // close buttons also change visibility, without a key message ImGui::Render(); diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 34a0fca8..0d4f9155 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -1,4 +1,5 @@ #include "runtime_internal.h" +#include "Extension/Customization/item_browser.h" #include "bootstrap.h" #include "Engine/Core/Hooks/hooks.h" #include "Engine/Core/Log/logging.h" @@ -192,6 +193,7 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { dingosdk::overlay::set_hub_page_feed(dingosdk::multiplayer::native_menu_page); dingosdk::overlay::set_hub_callbacks(native_callbacks); dingosdk::overlay::set_ui_sound(dingosdk::multiplayer::queue_ui_sound); + dingosdk::overlay::set_item_browser(dingosdk::item_browser::overlay_host()); dingosdk::overlay::set_game_text_feed(dingosdk::multiplayer::skate_debug_text); dingosdk::overlay::set_skate_hud_feed(dingosdk::multiplayer::skate_hud); dingosdk::overlay::set_nametag_feed(dingosdk::multiplayer::custom_nametags); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 31b4081a..547f94e1 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -13,6 +13,7 @@ add_library(dingosdk_runtime SHARED Extension/Console/perf_commands.cpp Extension/Skater/console_commands.cpp Extension/Multiplayer/console_commands.cpp + Extension/Customization/console_commands.cpp Extension/Multiplayer/Session/session.cpp Extension/Multiplayer/Session/session_view.cpp Extension/Multiplayer/Session/session_send.cpp @@ -149,6 +150,7 @@ add_library(dingosdk_runtime SHARED Extension/Profile/local_profile_missions.cpp Extension/Customization/local_cosmetic_catalog.cpp Extension/Customization/local_customization_runtime.cpp + Extension/Customization/item_browser.cpp Extension/Progression/local_entitlement_trigger_runtime.cpp Extension/Progression/local_neighborhood_runtime.cpp Extension/Customization/local_player_card_runtime.cpp diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 9afc4552..1204f345 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -8,6 +8,7 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/overlay_render.cpp Extension/UI/Overlay/overlay_console.cpp Extension/UI/Overlay/overlay_input.cpp + Extension/UI/Overlay/item_browser_overlay.cpp Extension/UI/Overlay/overlay_notices.cpp Extension/UI/Overlay/chat_overlay.cpp Extension/UI/Overlay/hub_page.cpp diff --git a/contrib/native-item-browser.md b/contrib/native-item-browser.md new file mode 100644 index 00000000..beb1a158 --- /dev/null +++ b/contrib/native-item-browser.md @@ -0,0 +1,61 @@ +# Favorites, search and filters in the skater item grids + +Built into ReSkate in the same way the music screen's Mods shelf was added +([native-music-ui.md](native-music-ui.md)): the grids stay the game's own and their data models are +changed through the native model functions. Nothing is hooked for it and no file of the game or of +a mod is touched. + +Code: [item_browser.cpp](../Extension/Customization/item_browser.cpp) (client thread), +[item_browser_order.h](../Extension/Customization/item_browser_order.h) (the pure ordering rule), +[item_browser_overlay.cpp](../Extension/UI/Overlay/item_browser_overlay.cpp) (search bar, prompts, keys). + +## What the player gets + +- **F** (left stick click on a pad): the highlighted item becomes a favorite, or stops being one. + Favorites sit right after the worn item and carry a star where the tile shows its rarity icon. +- **-** or **Ctrl+F**: a search box; every letter filters the grid at once. Enter keeps the search, + Esc clears it. +- **X**: steps a filter (All, Favorites, Mods, Game items). +- Favorites are saved to `ReSkate.favorites.json` beside Skate.exe (`RESKATE_FAVORITES_FILE` overrides + the path), keyed by the item's asset name, so items added later just work. + +## How it works + +Each slot tab is a `GridListViewModel` root (schema `0x7422aa5b`). Its `OwnedItems` (`0x345b75b1`) are +inline `ContentPresenterTileViewModel` rows (`0xcbe47f59`, 1184 bytes). A row's content reference +(`0x214d4984` -> `0x25e4d6c8`) is the tile's `OwnableViewModel` (`0x6e591955`), whose +`UIOwnableDataRef` (`0xbbe34156`) is the catalog item (`0x389117ef`: key `0x7199d8ed`, title +`0xec725743`). The tile's widget keeps `IsFocused` in the row up to date +(`0x0fb0d794 -> 0xa704272a -> 0xc52416ef -> 0xa704272a -> 0x85a0b4e8`), which is how the highlighted +item is known without hooking anything. + +Schema and field hashes are the `TypeNameHash` and `NameHash` of the data-defined types under +`ui/features/mystuff/cas` and `ui/foundations/components` (readable with `reskate_cli ebx-values`). + +- The game lists the worn item first and the rest by title. When a grid is first seen, or its rows are + no longer the ones we published (the game rebuilt it), its rows are copied into a grid list model of + our own. The visible list is then republished from that copy: worn item, favorites, the rest, less + what the search and filter leave out. Publishing is the engine's typed copy, as for the Mods shelf. +- The accessory grids start with a "none" tile that shows no item. Such tiles stay where they are, + in front, whatever is searched for, and cannot be made a favorite. +- The star: a favorite's `OwnableStyleSet.RarityStyle` reference is pointed at our copy of that rarity + style (`0xb6800075`) whose `Icon` is the game's `img_Star_Fill_64`. The game sets the reference when + a tile is shown, so this is re-applied while the grid is open. +- Our copies are destroyed before level transitions and when the grids are gone. +- Nothing runs outside the game's menus except a cheap check of `sample_game_ui_state().in_menu`. + +## Seen working in game + +Keyboard, offline, 3440x1440, in Apparel (Tops, Bottoms, Shoes), Accessories, Board and Gestures: +favoriting and unfavoriting, the star, reordering, the search box, the filter, favorites loaded from +the file on the next launch, leaving the menus and coming back. + +## Not tried yet + +- A controller (the left stick click is read, but that path was not checked). +- Tattoos (the same grid type is expected; untested). +- Multiplayer sessions, level changes with a grid open, live mod apply. +- Other screen sizes for the overlay's placement. +- The star replaces the rarity icon (bottom left). The tile has no icon slot bottom right. +- No automated tests yet; `arrange()` in item_browser_order.h is written to be tested on its own. +- `items peek` / `items poke` are research aids used to find the fields above; drop or keep as wanted.