mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-28 09:06:52 +01:00
Merge pull request #162 from FourthStudio/favorites/native-item-grids
Item grids: favorites, search and filters (Item Favorites, native)
This commit is contained in:
19 files changed
+1161
-2
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<std::string>{"fav", "search", "filter", "status", "peek", "poke"};
|
||||
};
|
||||
auto items = action("items", "Skater item grids: fav (favorite the highlighted item), search <text>, "
|
||||
"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<std::string>(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<item_browser::Filter>(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
|
||||
@@ -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 <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <charconv>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <span>
|
||||
#include <utility>
|
||||
|
||||
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<std::uint32_t, 2> content_path{0x214d4984, 0x25e4d6c8};
|
||||
// ContentPresenterTileViewModel.Button -> ... -> IsFocused, which the tile's widget keeps up to date.
|
||||
constexpr std::array<std::uint32_t, 5> 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<std::uint32_t, 2> 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<std::uint32_t>(md::read<md::Address>(type)) : 0; }
|
||||
|
||||
unsigned inline_offset(const md::Context& context, md::Address type, std::span<const std::uint32_t> 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<std::string, std::less<>> keys;
|
||||
std::filesystem::path file;
|
||||
bool loaded{};
|
||||
|
||||
void load() {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
std::array<wchar_t, 32768> buffer{};
|
||||
if (const auto length = GetEnvironmentVariableW(L"RESKATE_FAVORITES_FILE", buffer.data(), static_cast<DWORD>(buffer.size()));
|
||||
length && length < buffer.size()) {
|
||||
file = std::wstring(buffer.data(), length);
|
||||
} else {
|
||||
const auto count = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(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<char>(in)), std::istreambuf_iterator<char>());
|
||||
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<Item> native; // what the backing rows are
|
||||
std::vector<std::uint64_t> shown; // item models of the rows the game's list has now
|
||||
int focused{-1}; // row in `shown`
|
||||
bool failed{};
|
||||
};
|
||||
|
||||
struct State {
|
||||
md::MenuLifetime lifetime;
|
||||
md::Address manager{};
|
||||
Layout layout;
|
||||
std::vector<Grid> grids;
|
||||
// A favorite's tile shows a star where its rarity icon is: its item model points at our copy of its
|
||||
// rarity style, which has the star for an icon. One copy per rarity style, made when first needed.
|
||||
std::map<std::uint64_t, md::Value> starred; // the game's style -> our starred copy
|
||||
std::map<std::uint64_t, std::uint64_t> 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<std::string> pokes;
|
||||
std::atomic<unsigned> toggles{}, cycles{};
|
||||
std::atomic<int> filter{-1};
|
||||
std::atomic<bool> 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::Address>(md::read<md::Address>(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<std::byte> bytes;
|
||||
unsigned count{};
|
||||
std::vector<std::uint64_t> 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<int>(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<md::Ref>(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<md::Address>(at + context.member(data.type, data_key).offset), 255);
|
||||
item.title = md::string(md::read<md::Address>(at + context.member(data.type, data_title).offset), 255);
|
||||
item.category = md::string(md::read<md::Address>(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<md::Ref>(at).handle & ~std::uint64_t{1};
|
||||
const auto source = starred_source(s, current);
|
||||
if (s.favorites.keys.contains(item.key)) {
|
||||
if (!current || source) continue;
|
||||
auto copy = s.starred.find(current);
|
||||
if (copy == s.starred.end()) {
|
||||
const md::Value style{current, context.type_of(current)};
|
||||
if (schema_of(style.type) != rarity_style.hash) continue;
|
||||
const auto made = context.create(rarity_style, (GetTickCount64() << 12) | (++s.serial & 0xfff));
|
||||
copy = s.starred.emplace(current, made).first;
|
||||
context.copy(made, context.address(style));
|
||||
if (!md::publish_texture(context, context.field(made, rarity_icon), star_texture)) {
|
||||
s.star_unavailable = true;
|
||||
logging::log(Level::warning, Channel::customization,
|
||||
"Item browser: the game's star icon is not loaded here; favorites show no star.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
context.set(context.path(view, {rarity_path[0], rarity_path[1]}), md::Ref{0, copy->second.handle});
|
||||
s.restyled[item.content] = current;
|
||||
} else if (source) {
|
||||
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<std::byte> take_rows(const std::vector<std::byte>& from, const std::vector<unsigned>& order, const Layout& layout,
|
||||
int focused) {
|
||||
std::vector<std::byte> 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<std::byte>(static_cast<int>(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<Item> 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<std::uint64_t> 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<int>(order.size()) - 1);
|
||||
context.array(grid.items, take_rows(source, order, s.layout, focused), static_cast<unsigned>(order.size()));
|
||||
const auto after = read_rows(context, grid.items, s.layout);
|
||||
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<md::Ref>(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<std::size_t>(width), '0');
|
||||
for (int i = width - 1; i >= 0; --i, value >>= 4) out[static_cast<std::size_t>(i)] = digits[value & 15];
|
||||
return "0x" + out;
|
||||
}
|
||||
|
||||
// Research aid: "peek <root> [path]" and "poke <root> <path> <kind>:<value>" 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<std::string> 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 <root> [path] | poke <root> <path> <kind>:<value>");
|
||||
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<unsigned>(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<unsigned>(std::stoul(token.substr(1))));
|
||||
else value = context.field(value, static_cast<std::uint32_t>(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 <kind>:<value>");
|
||||
const auto colon = words[3].find(':');
|
||||
md::require(colon != std::string::npos, "poke needs <kind>:<value>");
|
||||
const auto how = words[3].substr(0, colon), text = words[3].substr(colon + 1);
|
||||
if (how == "u8") context.set(value, static_cast<std::uint8_t>(std::stoul(text, nullptr, 0)));
|
||||
else if (how == "u32") context.set(value, static_cast<std::uint32_t>(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<md::Address>(at), 255) + '"';
|
||||
else if (kind == 4) {
|
||||
const auto pointer = md::read<md::Address>(at);
|
||||
out += " [" + std::to_string(pointer ? md::read<std::uint32_t>(pointer - 4) & 0x7fffffff : 0) + "] of <" +
|
||||
hex(schema_of(md::read<md::Address>(md::read<md::Address>(value.type) + 0x30)), 8) + ">";
|
||||
} else if (kind == 2) {
|
||||
const auto meta = md::read<md::Address>(value.type), fields = md::read<md::Address>(meta + 0x60);
|
||||
const auto count = md::read<std::uint16_t>(meta + 0x2a);
|
||||
for (unsigned i = 0; i < count && i < 64; ++i) {
|
||||
const auto entry = fields + i * 24;
|
||||
const auto type = md::read<md::Address>(entry + 16);
|
||||
const auto offset = md::read<std::uint16_t>(entry + 8);
|
||||
out += "\n " + hex(md::read<std::uint32_t>(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<md::Address>(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<std::uint8_t>(at + offset + b), 2).substr(2);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out += " = ";
|
||||
for (unsigned b = 0; b < std::min(size, 32U); ++b) out += hex(md::read<std::uint8_t>(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::Address>(md::read<md::Address>(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<std::size_t>(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<std::size_t>(grid.focused) < grid.shown.size())
|
||||
for (const auto& item : grid.native)
|
||||
if (item.content == grid.shown[static_cast<std::size_t>(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<int>(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<std::size_t>(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<std::string> 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<int>(Filter::game))
|
||||
s.filter = static_cast<Filter>(filter);
|
||||
for (auto cycles = sh.cycles.exchange(0); cycles; --cycles)
|
||||
s.filter = static_cast<Filter>((static_cast<unsigned>(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<std::size_t>(grid.focused) >= grid.shown.size()) continue;
|
||||
active = &grid;
|
||||
for (const auto& item : grid.native)
|
||||
if (item.content == grid.shown[static_cast<std::size_t>(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<unsigned>(active->shown.size()) - (std::min)(blanks, static_cast<unsigned>(active->shown.size()));
|
||||
next.total = static_cast<unsigned>(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
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
#include "item_browser_order.h"
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
// 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
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <set>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// 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<char>(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<const Item> 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<const Item> 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<unsigned> arrange(std::span<const Item> native, const std::set<std::string, std::less<>>& 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<unsigned> 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
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,171 @@
|
||||
#include "overlay_internal.h"
|
||||
#include <imgui_internal.h>
|
||||
|
||||
// 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<const ItemBrowserHost*> 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<char, 96> 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<ImVec2, 10> points;
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
const float angle = -1.5707963f + static_cast<float>(i) * 0.6283185f, r = i % 2 ? radius * 0.42f : radius;
|
||||
points[static_cast<std::size_t>(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<std::size_t>(i)], points[static_cast<std::size_t>((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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 &);
|
||||
|
||||
@@ -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<ULONG_PTR>(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<ULONG_PTR>(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<UINT>(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.
|
||||
|
||||
@@ -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<bool> 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<bool> item_search_visible{false}, item_search_focus_requested{false};
|
||||
std::atomic<bool> item_search_character_pending{false}, item_search_escape_pending{false};
|
||||
std::atomic<bool> input_attached{false};
|
||||
std::atomic<bool> stop{false};
|
||||
std::atomic<HWND> 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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user