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 c80db1f9..59c2f65d 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.