diff --git a/CMakeLists.txt b/CMakeLists.txt index 22c3319..6b1ac58 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,6 +52,9 @@ if(DINGOSDK_BUILD_PARK_EDITOR_TESTS AND WIN32) add_executable(dingosdk_profile_change_tests Extension/Profile/Test/profile_change_tests.cpp) target_link_libraries(dingosdk_profile_change_tests PRIVATE dingosdk_local_profile) add_test(NAME profile_changes COMMAND dingosdk_profile_change_tests) + add_executable(dingosdk_music_favorites_tests Extension/Music/Test/local_music_favorites_tests.cpp) + target_link_libraries(dingosdk_music_favorites_tests PRIVATE dingosdk_local_profile) + add_test(NAME music_favorites COMMAND dingosdk_music_favorites_tests) add_executable(dingosdk_object_categories_tests Extension/Objects/Test/object_categories_tests.cpp) target_link_libraries(dingosdk_object_categories_tests PRIVATE dingosdk_local_profile) add_test(NAME object_categories COMMAND dingosdk_object_categories_tests) @@ -274,6 +277,14 @@ if(DINGOSDK_BUILD_TRAINER_TESTS) target_include_directories(dingosdk_trainer_asset_list PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_trainer_asset_list PRIVATE dingosdk_game_archives) endif() +option(DINGOSDK_BUILD_ROAD_RASH_TESTS "Build Road Rash regression tests" OFF) +if(DINGOSDK_BUILD_ROAD_RASH_TESTS) + enable_testing() + # The side of a fall, the marks a count of bails leaves, the items' names and where a decal goes. + add_executable(dingosdk_road_rash_tests Extension/RoadRash/Test/road_rash_tests.cpp) + target_include_directories(dingosdk_road_rash_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME road_rash COMMAND dingosdk_road_rash_tests) +endif() option(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS "Build Hall of Meat regression tests" OFF) if(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS) enable_testing() diff --git a/Engine/Game/Build/20260929/local_music.h b/Engine/Game/Build/20260929/local_music.h index b472fe4..33d54ac 100644 --- a/Engine/Game/Build/20260929/local_music.h +++ b/Engine/Game/Build/20260929/local_music.h @@ -41,6 +41,14 @@ inline constexpr std::uintptr_t context_insert = 0x6c6120; inline constexpr std::uintptr_t complete_delegate = 0x58b1f00; // Music UI initializer (hook target). inline constexpr Fingerprint music_ui_initialize_contract{0x868760, {0x48,0x89,0x6c,0x24,0x18,0x48,0x89,0x74,0x24,0x20,0x41,0x54,0x41,0x56,0x41,0x57,0x48,0x81,0xec,0x80,0x00,0x00,0x00,0x49,0x8b,0xe9,0x4d,0x8b,0xf0,0x4c,0x8b,0xfa}}; +// CONFIRMED: queued selection setter (0x1406F51A0) calls the change queue, +// then the apply-only setter (0x1406F51E0). A nonzero fourth argument selects +// song favorites; zero selects playlist likes. The apply-only song path updates +// model field 2, the Favorites list and the playback playlist at manager +0x1F8. +// The playlist path updates field 7 and the Liked list at manager +0x110; +// disassembly at 0x1406F5206/0x1406F5238/0x1406F5310 confirms the split. +inline constexpr Fingerprint favorite_change_contract{0x6f51a0, {0x40,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x28,0x49,0x8b,0xd9,0x49,0x8b,0xf8,0x0f,0xb6,0xf2,0x48,0x8b,0xe9,0xe8,0xa6,0x0a,0x00,0x00,0x48,0x85,0xdb,0x4c,0x8b,0xc7}}; +inline constexpr Fingerprint favorite_apply_contract{0x6f51e0, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x6c,0x24,0x20,0x4c,0x89,0x44,0x24,0x18,0x88,0x54,0x24,0x10,0x56,0x57,0x41,0x54,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x30,0x45}}; // Queue selector shared by UI Next and natural end-of-track selection. The queue // supplies its own channel and registered playlist asset; do not hard-code IDs. inline constexpr Fingerprint playback_select_next_contract{0x14f8ed0, {0x48,0x89,0x54,0x24,0x10,0x55,0x53,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x8d,0x6c,0x24,0xc8,0x48,0x81,0xec,0x38,0x01,0x00,0x00,0x4c,0x8b,0xe1}}; diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index 7103eb5..42b626d 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -223,8 +223,10 @@ 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); + register_road_rash_commands(*result); return result; }(); return *registry; diff --git a/Extension/Console/commands.h b/Extension/Console/commands.h index 1afee71..2d554f0 100644 --- a/Extension/Console/commands.h +++ b/Extension/Console/commands.h @@ -22,8 +22,10 @@ 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 &); +void register_road_rash_commands(Commands &); // Runtime adapters. Invoked only by the verified game-thread dispatcher. void request_debug(overlay::DebugAction, bool enabled = false, float value = 0); void request_feature(overlay::OfflineFeatureGroup, bool enabled); diff --git a/Extension/Customization/console_commands.cpp b/Extension/Customization/console_commands.cpp new file mode 100644 index 0000000..e0c181e --- /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 0000000..6ab318a --- /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 0000000..f2bb1b4 --- /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 0000000..78eadf9 --- /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/Music/Test/local_music_favorites_tests.cpp b/Extension/Music/Test/local_music_favorites_tests.cpp new file mode 100644 index 0000000..ff8e293 --- /dev/null +++ b/Extension/Music/Test/local_music_favorites_tests.cpp @@ -0,0 +1,148 @@ +#include "Extension/Music/local_music_favorites.h" +#include +#include +#include + +namespace { +int failures{}; +void check(bool condition, const char* message) { + if (!condition) { std::cerr << "FAIL: " << message << '\n'; ++failures; } +} +} + +int run_tests() { + using namespace dingosdk; + namespace favorites = music_favorites; + const auto folder = std::filesystem::temp_directory_path() / + ("reskate-music-favorites-tests-" + std::to_string(GetCurrentProcessId())); + const auto save = folder / "reskate.sqlite3"; + const std::string built_in = "Artist - Track", mod = "Mod Artist - Mod Track"; + const std::string playlist = "playlist:z-last", mod_playlist = "playlist:a-mod"; + const auto likes_key = favorites::playlist_profile_key; + { + profile::Store store(save); + check(favorites::load(store).empty(), "old profiles start without favorites"); + bool rejected{}, applied{}; + try { + favorites::apply_and_save(store, built_in, true, [&] { applied = true; return -2; }, [] { return false; }); + } catch (const std::runtime_error&) { rejected = true; } + check(rejected && applied && favorites::load(store).empty(), "failed native readback never saves a favorite"); + const auto result = favorites::apply_and_save(store, built_in, true, [&] { + check(favorites::load(store).empty(), "the native change happens before the profile save"); + return -1; + }, [] { return true; }); + check(result == -1 && favorites::load(store) == favorites::Songs{built_in}, + "native add success preserves its negative return value and saves the identity"); + favorites::save_change(store, mod, true); + const auto changes = profile::Store::changes(); + favorites::save_change(store, built_in, true); + check(profile::Store::changes() == changes, "repeated favorite action does not rewrite the profile"); + favorites::save_change(store, built_in, false); + favorites::save_change(store, built_in, true); // Move it after the mod song, deliberately not alphabetical. + store.set_user_value("ReSkate.MusicShuffle", true); + store.set_user_value("Unrelated", 17); + const auto saved_songs = favorites::load(store); + check(favorites::load(store, likes_key).empty(), "old profiles have no saved playlist likes"); + rejected = false; + try { + favorites::apply_and_save(store, playlist, true, [] { return -2; }, [] { return false; }, likes_key); + } catch (const std::runtime_error&) { rejected = true; } + check(rejected && favorites::load(store, likes_key).empty(), "failed playlist readback does not save a like"); + check(favorites::apply_and_save(store, playlist, true, [] { return -1; }, [] { return true; }, likes_key) == -1, + "playlist like preserves native add success"); + favorites::save_change(store, mod_playlist, true, likes_key); + const auto like_changes = profile::Store::changes(); + favorites::save_change(store, playlist, true, likes_key); + check(profile::Store::changes() == like_changes && favorites::load(store) == saved_songs, + "repeated playlist likes do not rewrite the save or change song favorites"); + } + { + profile::Store store(save); + const auto saved = favorites::load(store); + check(saved == favorites::Songs{mod, built_in}, "song identities and selection order survive a store restart"); + const auto liked = favorites::load(store, likes_key); + check(liked == favorites::Songs{playlist, mod_playlist}, "playlist likes and their order survive a restart"); + std::map playlist_contexts{{playlist, 700}}; + std::vector liked_handles; + const auto restore_likes = [&] { + return favorites::restore(favorites::load(store, likes_key), [&](const std::string& id) { + const auto found = playlist_contexts.find(id); + return found == playlist_contexts.end() ? std::uint64_t{} : found->second; + }, [&](std::uint64_t handle) { liked_handles.push_back(handle); return true; }); + }; + const auto like_changes = profile::Store::changes(); + check(restore_likes() == 1 && liked_handles == std::vector{700}, + "playlist restoration resolves the current generation and skips an absent mod"); + check(favorites::load(store, likes_key) == liked && profile::Store::changes() == like_changes, + "restoring playlist likes retains absent mods without writing the profile"); + playlist_contexts = {{playlist, 800}, {mod_playlist, 801}}; + liked_handles.clear(); + check(restore_likes() == 2 && liked_handles == std::vector{800, 801}, + "the next map restores new playlist handles in selection order"); + favorites::save_change(store, playlist, false, likes_key); + check(favorites::load(store, likes_key) == favorites::Songs{mod_playlist} && favorites::load(store) == saved, + "unliking a playlist preserves other likes and song favorites"); + // A new map supplies new model handles. An absent mod must not be purged. + std::map current{{built_in, 100}}; + std::vector applied; + const auto changes = profile::Store::changes(); + const auto restore = [&] { + return favorites::restore(favorites::load(store), [&](const std::string& id) { + const auto found = current.find(id); + return found == current.end() ? std::uint64_t{} : found->second; + }, [&](std::uint64_t handle) { applied.push_back(handle); return true; }); + }; + check(restore() == 1 && applied == std::vector{100}, "restoration uses the current song handle only"); + check(favorites::load(store) == saved && profile::Store::changes() == changes, + "restoration retains absent mod songs and performs no profile writes"); + current = {{built_in, 201}, {mod, 202}}; + applied.clear(); + check(restore() == 2 && applied == std::vector{202, 201}, + "the next catalog generation restores fresh handles and a returning mod"); + check(favorites::restore(saved, [](const std::string&) { return 999ULL; }, + [](std::uint64_t) { return false; }) == 0, "failed native application is not counted as restored"); + favorites::save_change(store, built_in, false); + check(favorites::load(store) == favorites::Songs{mod}, "unfavorite removes only the selected song"); + check(store.user_value("ReSkate.MusicShuffle") == Json(true) && store.user_value("Unrelated") == Json(17), + "favorite changes preserve shuffle and unrelated settings"); + } + { + profile::Store store(save); + check(favorites::load(store) == favorites::Songs{mod}, "unfavorite survives another restart"); + check(favorites::load(store, likes_key) == favorites::Songs{mod_playlist}, "playlist unlike survives another restart"); + favorites::save_change(store, mod, false); + favorites::save_change(store, mod_playlist, false, likes_key); + } + { + profile::Store store(save); + check(favorites::load(store).empty() && store.user_value(favorites::profile_key) == Json::array(), + "removing the last favorite persists an explicit empty selection"); + check(favorites::load(store, likes_key).empty() && store.user_value(likes_key) == Json::array(), + "unliking the last playlist persists an explicit empty selection"); + const Json damaged = Json::array({"Artist - Track", 5}); + bool rejected{}; + try { store.set_user_value(favorites::profile_key, damaged); } catch (const std::runtime_error&) { rejected = true; } + check(rejected && favorites::load(store).empty(), "malformed favorite data rolls back without overwriting the save"); + rejected = false; + try { store.set_user_value(likes_key, damaged); } catch (const std::runtime_error&) { rejected = true; } + check(rejected && favorites::load(store, likes_key).empty(), "malformed playlist data rolls back without overwriting the save"); + rejected = false; + try { store.set_user_value("Unrelated", Json::array({1, 2})); } catch (const std::runtime_error&) { rejected = true; } + check(rejected && store.user_value("Unrelated") == Json(17), "array support is limited to the favorites key"); + for (const auto& invalid : {std::string{}, std::string(256, 'x'), std::string("bad\nidentity")}) { + rejected = false; + try { favorites::save_change(store, invalid, true); } catch (const std::runtime_error&) { rejected = true; } + check(rejected && favorites::load(store).empty(), "invalid identities never change the save"); + } + } + std::error_code error; + std::filesystem::remove_all(folder, error); + if (failures) return 1; + std::cout << "music favorites: ok\n"; + return 0; +} + +int main() { + try { return run_tests(); } + catch (const std::exception& error) { std::cerr << "FAIL: " << error.what() << '\n'; return 1; } +} diff --git a/Extension/Music/local_music_favorites.h b/Extension/Music/local_music_favorites.h new file mode 100644 index 0000000..d6f2949 --- /dev/null +++ b/Extension/Music/local_music_favorites.h @@ -0,0 +1,64 @@ +#pragma once +#include "Extension/Profile/local_profile.h" +#include +#include +#include + +namespace dingosdk::music_favorites { +inline constexpr std::string_view profile_key = "ReSkate.MusicFavoriteSongs"; +inline constexpr std::string_view playlist_profile_key = "ReSkate.MusicLikedPlaylists"; +using Songs = std::vector; + +inline bool valid_id(std::string_view id) { + return !id.empty() && id.size() <= 255 && + std::none_of(id.begin(), id.end(), [](unsigned char c) { return c < 32 || c == 127; }); +} + +inline Songs load(const profile::Store& store, std::string_view key = profile_key) { + Songs songs; + const auto saved = store.user_value(key); + if (!saved) return songs; + if (!saved->is_array()) throw std::runtime_error("Invalid saved music favorites"); + for (const auto& value : *saved) { + if (!value.is_string()) + throw std::runtime_error("Invalid saved favorite song identity"); + const auto id = value.get(); + if (!valid_id(id)) throw std::runtime_error("Invalid saved favorite song identity"); + if (std::find(songs.begin(), songs.end(), id) == songs.end()) songs.push_back(id); + } + return songs; +} + +// Change one identity, retaining favorites whose mod is temporarily unavailable. +// A malformed saved value is never silently replaced by an empty selection. +inline void save_change(profile::Store& store, const std::string& id, bool favorite, std::string_view key = profile_key) { + if (!valid_id(id)) throw std::runtime_error("Invalid favorite song identity"); + auto songs = load(store, key); + const auto found = std::find(songs.begin(), songs.end(), id); + const bool changed = favorite ? found == songs.end() : found != songs.end(); + if (changed) { + if (favorite) songs.push_back(id); + else songs.erase(found); + } + if (changed) store.set_user_value(key, Json(songs)); +} + +template +auto apply_and_save(profile::Store& store, const std::string& id, bool favorite, Apply apply, Verify verify, + std::string_view key = profile_key) { + const auto result = apply(); + if (!verify()) throw std::runtime_error("Native favorite selection readback failed"); + save_change(store, id, favorite, key); + return result; +} + +// Resolve fresh model handles for each catalog generation. Restoration uses only +// the native apply operation; it neither saves nor queues service changes. +template +std::size_t restore(const Songs& songs, Resolve resolve, Apply apply) { + std::size_t restored{}; + for (const auto& id : songs) + if (const auto handle = resolve(id); handle && apply(handle)) ++restored; + return restored; +} +} diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index d39900d..3816a87 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -5,6 +5,7 @@ #include "local_music_assets.h" #include "local_music_safety.h" #include "local_music_ui.h" +#include "local_music_favorites.h" #include "Engine/Game/Abi/native_data.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/engine.h" @@ -24,7 +25,8 @@ std::string music_artwork_url(std::string_view value) { // MusicGraphAsset metadata/TagRefs. No generated catalog, guessed memberships, -// artwork, audio-residency gate, or preference persistence. Bounds: 1024 songs, +// artwork or audio-residency gate. Favorite identities are saved separately in +// the local profile and applied after song publication. Bounds: 1024 songs, // 256 authored groups, 8192 membership edges. Include after assets/news/model helpers. @@ -61,8 +63,17 @@ bool initialize_music_functions(std::uintptr_t base) { f.songs = reinterpret_cast(base + music::publish_songs); f.insert = reinterpret_cast(base + music::context_insert); f.complete = reinterpret_cast(base + music::complete_delegate); + f.favorite_apply = nullptr; + bool favorites_ready = true; + for (const auto& fp : {music::favorite_change_contract, music::favorite_apply_contract}) { + std::array actual{}; + if (!read(base + fp.rva, actual) || actual != fp.bytes) favorites_ready = false; + } + if (favorites_ready) + f.favorite_apply = reinterpret_cast(base + music::favorite_apply_contract.rva); + else logging::event(logging::Channel::music, "{\"event\":\"music_favorites_contract_mismatch\"}"); f.allocator = {base + addr::engine::allocator_adapter_vtable, 0, 8}; - return true; // Root installs only the initialize hook after these contracts pass. + return true; // Root installs the validated UI and selection hooks. } bool music_ui_identity(const MusicUiPending& pending) { @@ -167,7 +178,7 @@ CosmeticShared music_ui_message(const std::string& wire, bool playlist) { const auto value = result.value; result.value = {}; return value; } -bool music_ui_has_context(std::uintptr_t map, const std::string& id) { +std::uint64_t music_ui_context(std::uintptr_t map, const std::string& id) { std::uintptr_t buckets{}, node{}, sentinel{}; std::uint32_t count{}, capacity{}; if (!read(map, buckets) || !buckets || !read(map + 8, capacity) || !capacity || capacity > 4096 || !read(map + 12, count) || count > 2048 || !read(buckets + capacity * 8ULL, sentinel) || @@ -176,12 +187,78 @@ bool music_ui_has_context(std::uintptr_t map, const std::string& id) { while (node && node != sentinel) { std::array row{}; std::string name; if (seen.size() >= count || !seen.insert(node).second || !read(node, row) || !cosmetic_text(row[0], name)) return false; - if (name == id) return row[1] != 0; + if (name == id) return row[1]; node = row[2]; } return false; } +bool music_ui_has_context(std::uintptr_t map, const std::string& id) { + return music_ui_context(map, id) != 0; +} + +namespace { +bool music_selection(std::uintptr_t model, std::uint64_t context, bool is_song, std::string& id, bool& favorite) { + auto& m = game::native_data().models; + const auto identity = m.field(model, context, 0, 0xffffffffU, false); + const auto selected = m.field(model, context, is_song ? 2 : 7, 0xffffffffU, false); + if (!identity || !selected) return false; + game::ModelWriteLock lock(model); + const auto text = m.value(model, identity, 0, 0); + const auto flag = m.value(model, selected, 0, 0); + std::uint8_t value{}; + if (!text || !flag || !identifier(reinterpret_cast(text), id) || + !read(flag, value) || value > 1) return false; + favorite = value != 0; + return true; +} +} + +std::int32_t music_favorite_change_hook(std::uintptr_t manager, std::uint8_t favorite, + std::uint64_t context, std::uint64_t song_parent) { + auto& s = local_runtime(); auto& ui = music_ui_runtime(); + const bool is_song = song_parent != 0; + const auto key = is_song ? music_favorites::profile_key : music_favorites::playlist_profile_key; + const auto failure_event = is_song ? "music_favorite_save_failed" : "music_playlist_like_save_failed"; + bool handled{}; + std::int32_t result = -2; // Initial native unavailable status; actual results are preserved. + try { + PreserveError preserve; std::lock_guard lock(s.native_mutex); + const auto* current = ui.initialized.get(); + if (context && favorite <= 1 && s.active.load(std::memory_order_acquire) && + s.store && ui.functions.favorite_apply && current && current->manager == manager && + music_ui_current(*current)) { + CosmeticSharedGuard lease{music_ui_lease(*current)}; + std::string id; bool selected{}; + if (lease.value.control && music_selection(current->model, context, is_song, id, selected) && + music_ui_context(manager + (is_song ? 0xd0 : 0xa8), id) == context) { + const auto model = current->model, generation = current->generation; + // Local selections must not remain queued for an unavailable service. + handled = true; + if (ui.restoring) return ui.functions.favorite_apply(manager, favorite, context, is_song); + music_favorites::apply_and_save(*s.store, id, favorite != 0, [&] { + result = ui.functions.favorite_apply(manager, favorite, context, is_song); + return result; + }, [&] { + std::string after; + return ui.initialized && ui.generation == generation && music_ui_current(*ui.initialized) && + music_selection(model, context, is_song, after, selected) && after == id && selected == (favorite != 0); + }, key); + logging::event(logging::Channel::music, Json{{"event", is_song ? "music_favorite_saved" : "music_playlist_like_saved"}, + {is_song ? "song" : "playlist", id}, {"favorite", selected}}.dump().c_str()); + } else logging::event(logging::Channel::music, Json{{"event", failure_event}, + {"reason", "selection_context_unavailable"}}.dump().c_str()); + } + } catch (const std::exception& error) { + logging::event(logging::Channel::music, Json{{"event", failure_event}, + {"reason", error.what()}}.dump().c_str()); + } catch (...) { + logging::event(logging::Channel::music, Json{{"event", failure_event}}.dump().c_str()); + } + // Generations not owned by the local provider retain their native behavior. + return handled ? result : ui.functions.favorite_change(manager, favorite, context, song_parent); +} + void music_ui_initialize_hook(std::uint64_t all, std::uint64_t hidden, std::uint64_t featured, std::uint64_t liked, std::uint64_t discovered, std::uint64_t favorites, std::uint64_t songs, const std::uint32_t* handle, const void* callback) { @@ -191,6 +268,7 @@ void music_ui_initialize_hook(std::uint64_t all, std::uint64_t hidden, std::uint // Even a forwarded initialization supersedes old local contexts. const auto generation = ++ui.generation; ui.pending.reset(); + ui.initialized.reset(); if (s.active.load(std::memory_order_acquire)) try { const auto thread = GetCurrentThreadId(); @@ -295,6 +373,42 @@ void update_music_catalog() { for (const auto& song : catalog.songs) if (!music_ui_has_context(manager + 0xd0, song.id)) throw std::runtime_error("Native song identity missing"); if (!music_ui_current(*pending)) return; + auto initialized = std::make_unique(); + initialized->manager = manager; initialized->model = native_model; + initialized->owner = pending->owner; initialized->generation = pending->generation; + initialized->thread = pending->thread; + InterlockedIncrement(reinterpret_cast(initialized->owner + 12)); + ui.initialized = std::move(initialized); + if (ui.functions.favorite_apply && s.store) { + for (const bool is_song : {true, false}) { + const auto failure_event = is_song ? "music_favorites_restore_failed" : "music_playlist_likes_restore_failed"; + try { + const bool was_restoring = ui.restoring; + ui.restoring = true; + struct RestoreScope { bool& flag; bool before; ~RestoreScope() { flag = before; } } + restore_scope{ui.restoring, was_restoring}; + const auto saved = music_favorites::load(*s.store, + is_song ? music_favorites::profile_key : music_favorites::playlist_profile_key); + const auto restored = music_favorites::restore(saved, [&](const std::string& id) -> std::uint64_t { + if (!music_ui_current(*pending)) return 0; + return music_ui_context(manager + (is_song ? 0xd0 : 0xa8), id); + }, [&](std::uint64_t context) { + if (!music_ui_current(*pending)) return false; + ui.functions.favorite_apply(manager, 1, context, is_song); + std::string id; bool selected{}; + return music_ui_current(*pending) && music_selection(native_model, context, is_song, id, selected) && selected; + }); + logging::event(logging::Channel::music, Json{{"event", is_song ? "music_favorites_restored" : "music_playlist_likes_restored"}, + {"saved", saved.size()}, {"restored", restored}}.dump().c_str()); + } catch (const std::exception& error) { + logging::event(logging::Channel::music, Json{{"event", failure_event}, + {"reason", error.what()}}.dump().c_str()); + } catch (...) { + logging::event(logging::Channel::music, Json{{"event", failure_event}}.dump().c_str()); + } + } + } + if (!music_ui_current(*pending)) return; ui.functions.complete(&pending->delegate); dingosdk::logging::event(dingosdk::logging::Channel::music, dingosdk::Json{{"event", "native_music_ui_complete"}, {"playlists", playlist_count}, {"songs", song_count}, {"source", "runtime_music_assets"}}.dump().c_str()); } catch (...) { retry = false; dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"native_music_ui_publish_failed\"}"); } diff --git a/Extension/Music/local_music_ui.h b/Extension/Music/local_music_ui.h index 5b0e599..f4f6d04 100644 --- a/Extension/Music/local_music_ui.h +++ b/Extension/Music/local_music_ui.h @@ -12,9 +12,12 @@ using addr::local_music::music_ui_initialize_contract; using MusicUiInitialize = void (*)(std::uint64_t, std::uint64_t, std::uint64_t, std::uint64_t, std::uint64_t, std::uint64_t, std::uint64_t, const std::uint32_t*, const void*); +using MusicFavoriteChange = std::int32_t (*)(std::uintptr_t, std::uint8_t, std::uint64_t, std::uint64_t); struct MusicUiFunctions { MusicUiInitialize initialize{}; + MusicFavoriteChange favorite_change{}; + std::int32_t (*favorite_apply)(std::uintptr_t, std::uint8_t, std::uint64_t, std::uint8_t){}; void* (*construct_playlist)(void*, const std::uintptr_t*){}; void* (*construct_song)(void*, const std::uintptr_t*){}; void (*playlists)(std::uintptr_t, CosmeticShared*){}; @@ -42,8 +45,10 @@ struct MusicUiPending { struct MusicUiRuntime { MusicUiFunctions functions; std::unique_ptr pending; + // Retains the weak owner and identity of the locally hydrated generation. + std::unique_ptr initialized; std::uint64_t generation{}; - bool updating{}; + bool updating{}, restoring{}; }; MusicUiRuntime& music_ui_runtime(); @@ -76,6 +81,8 @@ struct MusicUiMessages { }; bool music_ui_has_context(std::uintptr_t map, const std::string& id); +std::int32_t music_favorite_change_hook(std::uintptr_t manager, std::uint8_t favorite, + std::uint64_t context, std::uint64_t song_parent); void music_ui_initialize_hook(std::uint64_t all, std::uint64_t hidden, std::uint64_t featured, std::uint64_t liked, std::uint64_t discovered, std::uint64_t favorites, std::uint64_t songs, diff --git a/Extension/Profile/local_profile.cpp b/Extension/Profile/local_profile.cpp index 796842d..2404135 100644 --- a/Extension/Profile/local_profile.cpp +++ b/Extension/Profile/local_profile.cpp @@ -105,6 +105,14 @@ void detail::validate_settings(const Snapshot& s) { require(values.is_object() && values.size() <= 2048, "Invalid gameplay settings"); for (const auto& [key, value] : values.items()) { require(valid_text(key), "Invalid gameplay setting key"); + // Music selections are ordered identity lists, not native scalar + // option. The existing settings codec can store its JSON array. + if (key == "ReSkate.MusicFavoriteSongs" || key == "ReSkate.MusicLikedPlaylists") { + require(value.is_array(), "Invalid saved music favorites"); + for (const auto& song : value) + require(song.is_string() && valid_text(song.string()), "Invalid saved favorite song identity"); + continue; + } require((value.is_number_integer() && (!value.is_number_unsigned() || value.get() <= INT64_MAX)) || (value.is_number_float() && std::isfinite(value.get())) || diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index 903e024..59c2f65 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(); @@ -529,6 +532,9 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline, hook(object_subscribe_contract, &object_categories_hook, object_runtime().functions.subscribe); if (music_ready) { hook(music_ui_initialize_contract, &music_ui_initialize_hook, music_ui_runtime().functions.initialize); + if (music_ui_runtime().functions.favorite_apply) + hook(addr::local_music::favorite_change_contract, &music_favorite_change_hook, + music_ui_runtime().functions.favorite_change); std::array construct_bytes{}; if (read(base + music_model_construct_contract.rva, construct_bytes) && construct_bytes == music_model_construct_contract.bytes) diff --git a/Extension/RoadRash/Test/road_rash_tests.cpp b/Extension/RoadRash/Test/road_rash_tests.cpp new file mode 100644 index 0000000..f133c00 --- /dev/null +++ b/Extension/RoadRash/Test/road_rash_tests.cpp @@ -0,0 +1,160 @@ +// Road Rash's rules, with made-up falls: the side of a fall, the marks a count of bails leaves, the +// names of the items that draw them and where a limb's decal goes. +#include "Extension/RoadRash/road_rash_model.h" +#include +#include +#include +#include +#include +#include +#include + +using namespace dingosdk; +using namespace dingosdk::road_rash; +using skater_body::Bone; +namespace { +void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } +bool near(float a, float b) { return std::abs(a - b) < 1e-4f; } + +// One physics step in which `bone` hits the ground this hard and slides along it this fast. +skater_body::Contacts hit(Bone bone, float impact, float slide = 0.0f) { + skater_body::Contacts contacts; + auto& body = contacts.bodies[skater_body::index(bone)]; + body.touching = true; + body.impact = impact; + body.slide = {slide, 0.0f, 0.0f}; + body.hit.world = true; + return contacts; +} +int level(const Tally& tally, Part part) { return tally.levels[index(part)]; } +Tally after(unsigned bails, Side side) { + Tally tally; + for (unsigned i = 0; i < bails; ++i) count(tally, side); + return tally; +} + +void a_fall_is_on_the_side_whose_limbs_took_it() { + Fall fall; + check(side(fall) == Side::both, "A fall nothing felt is on neither side"); + add(fall, hit(Bone::left_forearm, 3.0f, 2.0f)); + add(fall, hit(Bone::left_upleg, 2.0f)); + add(fall, hit(Bone::right_hand, 1.0f)); + check(near(fall.left, 7.0f) && near(fall.right, 1.0f), "Impact and slide of each side's limbs add up"); + check(side(fall) == Side::left, "The left limbs took most of it"); + Fall other; + add(other, hit(Bone::right_leg, 4.0f)); + add(other, hit(Bone::right_arm, 1.0f, 3.0f)); + check(side(other) == Side::right, "and the right ones of this one"); + Fall even; + add(even, hit(Bone::left_leg, 3.0f)); + add(even, hit(Bone::right_leg, 2.6f)); + check(side(even) == Side::both, "A fall both sides felt alike is on both"); + Fall light; + add(light, hit(Bone::left_hand, 0.5f)); + check(side(light) == Side::both, "A touch is no fall on a side"); +} + +void feet_trunk_head_and_the_own_board_count_for_no_side() { + Fall fall; + for (const auto bone : {Bone::left_foot, Bone::left_toe, Bone::right_foot, Bone::right_toe, Bone::hips, Bone::spine2, Bone::neck1}) + add(fall, hit(bone, 9.0f, 9.0f)); + check(fall.left == 0.0f && fall.right == 0.0f, "Feet stand on the ground all the time; trunk and head have no side"); + auto board = hit(Bone::left_hand, 5.0f); + board.bodies[skater_body::index(Bone::left_hand)].hit = {}; + board.bodies[skater_body::index(Bone::left_hand)].hit.board = true; + add(fall, board); + check(fall.left == 0.0f, "A hand on the skater's own board hit nothing"); + auto untouched = hit(Bone::right_leg, 5.0f); + untouched.bodies[skater_body::index(Bone::right_leg)].touching = false; + add(fall, untouched); + check(fall.right == 0.0f, "A body that touches nothing hit nothing"); +} + +void marks_come_with_the_bails() { + check(after(9, Side::left).levels == std::array{}, "Nine bails leave no mark"); + const auto ten = after(10, Side::left); + check(level(ten, Part::left_leg) == 1 && level(ten, Part::left_arm) == 0 && level(ten, Part::right_leg) == 0, + "The tenth marks the leg of the side that took them"); + const auto eleven = after(11, Side::left); + check(level(eleven, Part::left_arm) == 1 && level(eleven, Part::right_leg) == 0, "the arm follows one bail later"); + const auto fourteen = after(14, Side::left); + check(level(fourteen, Part::left_leg) == 2 && level(fourteen, Part::right_leg) == 1 && level(fourteen, Part::right_arm) == 0, + "At fourteen the leg is worse and the other side's leg starts"); + const auto twenty = after(20, Side::left); + check(level(twenty, Part::left_leg) == 3 && level(twenty, Part::left_arm) == 2 && level(twenty, Part::face) == 1, + "At twenty the leg is at its worst and the face shows it"); + const auto all = after(25, Side::left); + check(level(all, Part::left_leg) == 3 && level(all, Part::left_arm) == 3 && level(all, Part::right_leg) == 3 && + level(all, Part::right_arm) == 3 && level(all, Part::face) == 2, "At twenty-five everything is at its worst"); + check(after(200, Side::both).levels == all.levels, "and stays there"); +} + +void the_side_that_took_more_goes_first_and_no_mark_gets_smaller() { + const auto right = after(10, Side::right); + check(level(right, Part::right_leg) == 1 && level(right, Part::left_leg) == 0, "Falls on the right mark the right leg first"); + // Ten on the left, then the right side overtakes it: the left keeps what it has. + auto tally = after(10, Side::left); + for (int i = 0; i < 11; ++i) count(tally, Side::right); + check(!left_first(tally) && tally.bails == 21, "The right side took more by now"); + check(level(tally, Part::right_leg) == 3 && level(tally, Part::left_leg) >= 2, "It is ahead now, and the left leg kept its marks"); + // Falls on both sides count half for each; the last one-sided fall breaks the tie. + Tally even; + count(even, Side::right); + count(even, Side::left); + for (int i = 0; i < 8; ++i) count(even, Side::both); + check(near(even.left, even.right) && left_first(even) && level(even, Part::left_leg) == 1 && level(even, Part::right_leg) == 0, + "A tie goes to the side of the last one-sided fall"); +} + +void items_are_named_by_set_design_and_level() { + check(item_name(Part::left_leg, false, 0, 1, true) == "roadrash_leg_1", "The first design has no letter"); + check(item_name(Part::right_leg, true, 0, 2, true) == "roadrash_legr_2", "The right leg has its own, mirrored set"); + check(item_name(Part::right_leg, false, 0, 2, true) == "roadrash_leg_2", "or shares the left one's"); + check(item_name(Part::left_arm, false, 1, 3, true) == "roadrash_arm_b_3_blood", "The worst level comes with blood"); + check(item_name(Part::right_arm, true, 2, 3, false) == "roadrash_arm_c_3_dry", "or dry; arms share one set"); + check(item_name(Part::face, false, 0, 1, true) == "roadrash_face_1" && item_name(Part::face, false, 1, 2, false) == "roadrash_face_b_2_dry", + "The face's worst level is its second"); + check(item_name(Part::left_leg, false, 9, 1, true) == "roadrash_leg_1", "An unknown design is the first"); +} + +void a_limbs_decal_sits_on_its_knee_or_elbow() { + const auto left = placement(Part::left_leg, 1), right = placement(Part::right_leg, 1); + check(near(left[0] + right[0], 1.0f) && near(left[1], right[1]) && near(left[2], right[2]), "The right limb mirrors the left across the layout"); + check(near(left[2], 20.0f / 76.0f) && near(left[3], 0.5f), "A level's size is its centimetres over the limb's scale, upright"); + const auto worst = placement(Part::left_leg, 3); + check(worst[2] > left[2] && worst[1] < left[1], "A worse level is bigger and reaches further down the limb"); + check(placement(Part::left_arm, 3)[2] > placement(Part::left_arm, 1)[2] && placement(Part::left_arm, 9)[2] == placement(Part::left_arm, 3)[2], + "Arms likewise; a level past the worst is the worst"); +} + +void parameters_the_game_worked_out_again_are_the_same() { + const auto words = [](std::initializer_list values) { + std::vector out; + for (const float value : values) out.push_back(std::bit_cast(value)); + return out; + }; + const auto given = words({0.365f, 0.505f, 16.0f / 51.0f, 0.5f, 0.5f}); + auto back = given; + back[2] = std::bit_cast(80.0f / 255.0f) + 1; // the same size, one bit off + check(same_parameters(given, given) && same_parameters(given, back), "A last bit of difference is no change"); + check(!same_parameters(given, words({0.365f, 0.515f, 16.0f / 51.0f, 0.5f, 0.5f})), "A centimetre down the limb is one"); + check(!same_parameters(given, words({0.365f, 0.505f, 16.0f / 51.0f, 0.5f})), "and so is another count of them"); +} +} + +int main() { + try { + a_fall_is_on_the_side_whose_limbs_took_it(); + feet_trunk_head_and_the_own_board_count_for_no_side(); + marks_come_with_the_bails(); + the_side_that_took_more_goes_first_and_no_mark_gets_smaller(); + items_are_named_by_set_design_and_level(); + a_limbs_decal_sits_on_its_knee_or_elbow(); + parameters_the_game_worked_out_again_are_the_same(); + } catch (const std::exception& error) { + std::cerr << "FAILED: " << error.what() << '\n'; + return 1; + } + std::cout << "Road Rash tests passed.\n"; + return 0; +} diff --git a/Extension/RoadRash/road_rash.cpp b/Extension/RoadRash/road_rash.cpp new file mode 100644 index 0000000..dcc81a6 --- /dev/null +++ b/Extension/RoadRash/road_rash.cpp @@ -0,0 +1,369 @@ +#include "road_rash.h" +#include "road_rash_model.h" +#include "skater_items.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Game/Build/20260929/no_bail.h" +#include "Extension/HallOfMeat/hall_of_meat_skater.h" +#include "Extension/Multiplayer/Hud/game_ui_state.h" +#include "Extension/Profile/local_profile_runtime.h" +#include "Extension/Skater/no_bail.h" +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::road_rash { +namespace { +constexpr const char* enabled_preference = "RoadRash"; +constexpr const char* blood_preference = "RoadRashBlood"; +constexpr const char* tattoos_preference = "RoadRashCoversTattoos"; +// Part of the name of the recipe slot each part's item goes into. +constexpr std::array slot_names{"tattoo_legl", "tattoo_arml", "tattoo_legr", "tattoo_armr", "moles"}; +constexpr std::uint64_t fall_at_most = 3000, fall_at_least = 400; // milliseconds a fall is watched for its side + +struct State { + // Any thread. + std::atomic ready{}, on{true}, blood{true}, covers{}; + std::atomic heal{}, changed{}; // asked for from another thread, done at the next tick + std::atomic wanted_bails{-1}; + std::atomic bails{}; + // Client thread from here on. + Tally tally; + std::array designs{}; + bool designs_drawn{}; + std::uint64_t chance{}; + // The bail being watched for its side. + bool counted{}; + std::uint64_t wipeouts{}, last_bail{}; + std::optional fall_since; + Fall fall; + // What is on the skater. + std::uintptr_t skater{}; + bool dirty{true}, worn{}, missing_told{}; + bool own{}; // it wears items of ours, as a rule in a recipe of its own: the game passes + // nothing of the outfit on to it then + bool afresh{}; // the marks are to be worked out from the outfit again + std::optional feed; // how the game feeds the skater its outfit, as last seen + std::uintptr_t component{}; // the item component that was given a recipe + std::uint64_t checked{}, last_error{}, given_back{}, menu_seen{}; + std::uintptr_t slots_of{}; + std::array slots{}; + std::map items; // wanted name -> installed item, empty when missing +}; +State& state() { + static auto* value = new State; + return *value; +} + +template void say(logging::Level level, std::format_string text, Args&&... args) { + logging::log(level, logging::Channel::skater, "Road Rash: {}", std::format(text, std::forward(args)...)); +} + +bool ours(const std::string& asset) { return lower(asset).find(item_prefix) != std::string::npos; } +// A slot with nothing to see in it: empty, or the game's own "none" item of a face layer. +bool blank(const std::string& asset) { return asset.empty() || lower(asset).ends_with("_empty"); } + +// Which design each part wears: drawn afresh whenever the skater is clean. +void draw_designs(State& s) { + if (!s.chance) { + LARGE_INTEGER now{}; + QueryPerformanceCounter(&now); + s.chance = static_cast(now.QuadPart) | 1; + } + for (auto& design : s.designs) { + s.chance ^= s.chance << 13; + s.chance ^= s.chance >> 7; + s.chance ^= s.chance << 17; + design = static_cast((s.chance >> 20) % design_count); + } + s.designs_drawn = true; +} +void clean(State& s) { + s.tally = {}; + s.fall_since.reset(); + s.fall = {}; + s.bails.store(0, std::memory_order_release); + draw_designs(s); + s.dirty = s.afresh = true; +} +std::string levels(const Tally& tally) { + std::string out; + for (const auto part : parts) out += std::format("{}{} {}", out.empty() ? "" : ", ", part_names[index(part)], tally.levels[index(part)]); + return out; +} +void count_bail(State& s, Side side) { + const auto before = s.tally.levels; + count(s.tally, side); + s.bails.store(s.tally.bails, std::memory_order_release); + if (before != s.tally.levels) s.dirty = true; + if (logging::enabled(logging::Level::debug)) + say(logging::Level::debug, "bail {} on {} (limbs hit: left {:.1f}, right {:.1f}): {}.", s.tally.bails, + side == Side::left ? "the left side" : side == Side::right ? "the right side" : "both sides", s.fall.left, s.fall.right, + levels(s.tally)); +} + +// The installed item called `name` at its end; empty when the content mod is not installed. +const std::string& item(State& s, const SkaterItems& m, const std::string& name) { + if (const auto known = s.items.find(name); known != s.items.end()) return known->second; + return s.items.emplace(name, installed_item(m, name)).first->second; +} +// The item a part wears at a level: of its own design or else the first, from its own set or else +// the unmirrored one, at that level or else the nearest lower one that is installed. +std::string wanted_item(State& s, const SkaterItems& m, Part part, int& level) { + for (; level > 0; --level) + for (const auto design : {s.designs[index(part)], std::size_t{0}}) + for (const bool mirrored : {part == Part::right_leg, false}) { + const auto& found = item(s, m, item_name(part, mirrored, design, level, s.blood.load(std::memory_order_acquire))); + if (!found.empty()) return found; + } + return {}; +} +// The recipe slot of each part, by the slot's name in the skater's recipe template. +void find_slots(State& s, const SkaterItems& m, std::uintptr_t resource) { + s.slots.fill(-1); + const auto slots = m.ptr(resource, 0x28); + const auto count = m.count(slots, 24, multiplayer::max_cosmetic_slots); + for (std::size_t i = 0; i < count; ++i) { + const auto slot = m.ptr(slots, i * 24 + 8) & ~std::uintptr_t{4}; + const auto name = lower(m.text(m.ptr(slot, 0x20))); + for (std::size_t part = 0; part < part_count; ++part) + if (s.slots[part] < 0 && name.find(slot_names[part]) != std::string::npos) s.slots[part] = static_cast(i); + } + s.slots_of = resource; +} +// Makes the skater wear the marks of its levels, one item per part in the part's own slot, over +// the outfit it wears. Throws while the skater is still being built: tried again. +void wear(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t now) { + const auto component = m.component(skater); + auto recipe = m.capture(skater); + const auto resource = m.resource(component); + if (s.slots_of != resource) find_slots(s, m, resource); + bool marked{}; + for (const auto& item : recipe.items) marked |= ours(item.asset); + // Whose recipe the skater wears is read from the skater: a respawn hands over what looks like a + // new one and is the same, items and all. + if (const auto fed = feed(m, component); fed.follows_outfit()) { + // The game feeds it its outfit: a new skater, or one given back, which holds our items until + // the game has put the outfit's own back. + SkaterItems::check(!marked || now - s.given_back > 3000, "Waiting for the game to put the outfit back."); + s.own = marked; + s.feed = fed; + } else { + // A recipe of its own: ours when our items are in it or it went to this component. Whose + // else it could be is not ours to change. + SkaterItems::check(s.feed && (marked || component == s.component), "The skater does not follow its outfit: left alone."); + s.own = true; + } + const bool covers = s.covers.load(std::memory_order_acquire); + bool changed{}, missing{}, worn{}; + for (const auto part : parts) { + const auto i = index(part); + if (s.slots[i] < 0 || static_cast(s.slots[i]) >= recipe.items.size()) continue; + auto& slot = recipe.items[static_cast(s.slots[i])]; + int level = s.tally.levels[i]; + const auto wanted = level ? wanted_item(s, m, part, level) : std::string{}; + if (s.tally.levels[i] && wanted.empty()) missing = true; + // The player's own tattoo or moles stay, unless the wounds may cover them. + if (wanted.empty() || (!ours(slot.asset) && !blank(slot.asset) && !covers)) continue; + worn = true; + auto parameters = slot.parameters; + if (part != Part::face && parameters.size() == std::tuple_size_v) { + const auto place = placement(part, level); + for (std::size_t k = 0; k < place.size(); ++k) parameters[k] = std::bit_cast(place[k]); + } + if (slot.asset == wanted && same_parameters(slot.parameters, parameters)) continue; + slot.asset = wanted; + slot.parameters = std::move(parameters); + changed = true; + } + if (missing && !s.missing_told) { + s.missing_told = true; + say(logging::Level::info, "the skater has marks to show, but the Road Rash items are not installed (a content mod holds them)."); + } + s.worn = worn; + if (!changed) return; + apply(m, component, recipe); + s.own = true; + s.component = component; + say(logging::Level::debug, "marks changed after {} bail(s): {}.", s.tally.bails, levels(s.tally)); +} +// Gives the skater back to the game: the outfit's own items return, the player's own tattoos and +// moles with them, and what the player changes in the game's menus reaches the skater again. +// Throws while its items are updating: tried again. +void give_back(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t now, const char* why) { + const auto component = m.component(skater); + // One the game feeds already only has to put the outfit's own items back. + if (const auto fed = feed(m, component); fed.follows_outfit() || s.feed) { + hand_back(m, component, fed.follows_outfit() ? fed : *s.feed); + s.given_back = now; + say(logging::Level::debug, "the skater is the game's again ({}).", why); + } + s.own = s.worn = false; +} + +// The fall: its side from what the limbs hit, for as long as the body is a ragdoll. +void watch_fall(State& s, const NoBailSkater& skater, std::uint64_t now, bool wiping_out) { + bool ragdoll = wiping_out; + if (hall_of_meat::skater_available()) { + const auto step = hall_of_meat::read_step(skater, 0.0f, false); + add(s.fall, step.body); + ragdoll = step.ragdoll.value_or(wiping_out) || wiping_out; + } + const auto lasted = now - *s.fall_since; + if (lasted < fall_at_least || (ragdoll && lasted < fall_at_most)) return; + count_bail(s, side(s.fall)); + s.fall_since.reset(); + s.fall = {}; +} +} + +bool start(std::uintptr_t base) noexcept { + auto& s = state(); + if (s.ready.load(std::memory_order_acquire)) return true; + constexpr auto prefix = addr::native_cosmetics::recipe_copy_prefix; + std::array actual{}; + if (!memory::peek(base + addr::native_cosmetics::recipe_copy, actual) || actual != prefix) { + logging::write(logging::Level::warning, logging::Channel::skater, + "Road Rash cannot change the skater's items: the native recipe copy did not match."); + return false; + } + if (!no_bail_available()) { + logging::write(logging::Level::warning, logging::Channel::skater, + "Road Rash cannot follow the skater: its bails come through No Bail's hooks, which did not start."); + return false; + } + s.on.store(profile_runtime::local_preference(enabled_preference).value_or(true), std::memory_order_release); + s.blood.store(profile_runtime::local_preference(blood_preference).value_or(true), std::memory_order_release); + s.covers.store(profile_runtime::local_preference(tattoos_preference).value_or(false), std::memory_order_release); + s.ready.store(true, std::memory_order_release); + logging::log(logging::Level::info, logging::Channel::skater, "Road Rash ready ({}, blood {}).", s.on.load() ? "on" : "off", + s.blood.load() ? "on" : "off"); + return true; +} + +void on_client_tick(std::uintptr_t client) noexcept { + auto& s = state(); + if (!s.ready.load(std::memory_order_acquire)) return; + try { + if (!s.designs_drawn) draw_designs(s); + if (s.heal.exchange(false, std::memory_order_acq_rel)) clean(s); + if (s.changed.exchange(false, std::memory_order_acq_rel)) s.dirty = s.afresh = true; + if (const int wanted = s.wanted_bails.exchange(-1, std::memory_order_acq_rel); wanted >= 0) { + clean(s); + s.tally.bails = static_cast(wanted); + s.tally.left = s.tally.right = static_cast(wanted) / 2; + grow(s.tally); + s.bails.store(s.tally.bails, std::memory_order_release); + } + NoBailSkater skater; + if (!no_bail_skater(skater)) { + // No local skater (a respawn, a level change). After a level change the game builds the + // next one from the profile, with nothing of ours on it; the count stays, so the marks + // go back on. + s.skater = 0; + s.fall_since.reset(); + s.fall = {}; + s.counted = false; + s.slots_of = 0; + s.dirty = true; + return; + } + if (skater.entity != s.skater) { + s.skater = skater.entity; + s.dirty = true; + } + const bool on = s.on.load(std::memory_order_acquire); + if (!on && !s.own) return; // switched off, and the skater is the game's: nothing to do + const auto now = GetTickCount64(); + bool down{}; + if (on) { + watch_physics_state(client, skater.entity); + const auto watch = watched_physics_state(); + if (watch.valid) { + const bool wiping_out = watch.state == addr::no_bail::wipeout_physics_state; + if (!s.counted) { + s.wipeouts = watch.wipeouts; + s.counted = true; + } + // One slam can enter the wipeout more than once. + if (watch.wipeouts != s.wipeouts) { + s.wipeouts = watch.wipeouts; + if (!s.fall_since && now - s.last_bail > 1500) { + s.last_bail = now; + s.fall_since = now; + s.fall = {}; + } + } + if (s.fall_since) watch_fall(s, skater, now, wiping_out); + down = wiping_out && now - s.last_bail < 6000; + } + } else { + s.fall_since.reset(); + } + bool marks{}; + if (on) + for (const int level : s.tally.levels) marks |= level > 0; + // The game's menus change the outfit, and a skater that wears a recipe of ours gets none of + // it. So while a menu is up the skater is the game's, as it is with nothing to show or when + // the marks are worked out again; they go back on, over the outfit as it is by then. + const char* why = !marks ? "nothing to show" : s.afresh ? "the marks are worked out again" : nullptr; + if (!why && multiplayer::sample_game_ui_state(skater.base).in_menu) { + why = "a game menu is up"; + s.menu_seen = now; + } + if (why) { + if (s.own) give_back(s, SkaterItems{readable, skater.base}, skater.entity, now, why); + s.afresh = false; + s.dirty = true; + return; + } + // New marks go on once the skater is up again, and not before the game has had a second for + // what it still does to the skater as a menu closes. While some are on, a look every few + // seconds whether the game has put the outfit's items over them or built the skater anew. + if (down || s.fall_since || now - s.given_back < 300 || now - s.menu_seen < 1000 || + !(s.dirty || (s.worn && now - s.checked > 3000))) + return; + s.checked = now; + wear(s, SkaterItems{readable, skater.base}, skater.entity, now); + s.dirty = false; + } catch (const std::exception& error) { + // The skater is being rebuilt, or the catalog is not ready: tried again at a later tick. + const auto now = GetTickCount64(); + if (now - s.last_error > 10000 && logging::enabled(logging::Level::debug)) { + s.last_error = now; + say(logging::Level::debug, "waiting ({}).", error.what()); + } + } catch (...) { /* Marks are never worth the client tick. */ } +} + +bool available() noexcept { return state().ready.load(std::memory_order_acquire); } +bool enabled() noexcept { return available() && state().on.load(std::memory_order_acquire); } +bool blood() noexcept { return state().blood.load(std::memory_order_acquire); } +bool covers_tattoos() noexcept { return state().covers.load(std::memory_order_acquire); } + +void set_enabled(bool enabled) noexcept { + auto& s = state(); + s.on.store(enabled, std::memory_order_release); + // Off: the marks go, and so does the count. + (enabled ? s.changed : s.heal).store(true, std::memory_order_release); + profile_runtime::set_local_preference(enabled_preference, enabled); +} +void set_blood(bool blood) noexcept { + auto& s = state(); + s.blood.store(blood, std::memory_order_release); + s.changed.store(true, std::memory_order_release); + profile_runtime::set_local_preference(blood_preference, blood); +} +void set_covers_tattoos(bool covers) noexcept { + auto& s = state(); + s.covers.store(covers, std::memory_order_release); + s.changed.store(true, std::memory_order_release); + profile_runtime::set_local_preference(tattoos_preference, covers); +} +void heal() noexcept { state().heal.store(true, std::memory_order_release); } +void set_bails(unsigned count) noexcept { state().wanted_bails.store(static_cast((std::min)(count, 1000u)), std::memory_order_release); } +unsigned bails() noexcept { return state().bails.load(std::memory_order_acquire); } +} diff --git a/Extension/RoadRash/road_rash.h b/Extension/RoadRash/road_rash.h new file mode 100644 index 0000000..f3c86a6 --- /dev/null +++ b/Extension/RoadRash/road_rash.h @@ -0,0 +1,50 @@ +#pragma once +#include + +// Road Rash: bails leave marks on the local skater that build up over a session. After ten bails +// the leg and then the arm of the side that took more of them show road dust and a graze, later +// real scrapes and bruises, and from twenty raw wounds on knee, shin, elbow, forearm and knuckles +// and a bruised face; the other side follows a few bails behind. A mark never gets smaller until +// the skater is healed (the console's `roadrash heal`) or the game is closed. +// +// The marks are not drawn by ReSkate: they are cosmetic items of a content mod, tattoo items for +// the limbs and moles items for the face (road_rash_model.h has their names), which this puts into +// the skater's recipe while it plays, through the game's own recipe copy (skater_items.h). Without +// that mod installed nothing ever shows. A slot that holds one of the player's own tattoos or moles +// keeps it, unless the player lets the wounds cover it; what was there comes back with healing. +// Nothing is saved to the profile's outfit: the game builds the skater from the profile again at +// every level change, and the marks are put back on from the session's count. +// +// The game feeds a skater's items either from the player's outfit or from a recipe of the skater's +// own, never both: one that wears a recipe handed to it gets nothing of what the player changes in +// the game's menus. So the marks are on only while the game is played. While a game menu is up, +// with nothing to show, on healing and when switched off, the skater is given back to the game, +// which puts the outfit's own items back and feeds it again (skater_items.h); the marks go back on, +// over the outfit as it is by then, a second after the menu has gone. +// +// Which side a bail hit comes from the ragdoll's own contacts, as Hall of Meat reads them +// (Extension/HallOfMeat/hall_of_meat_skater.h); a bail is the physics state selector choosing +// Wipeout (Extension/Skater/no_bail.h). Nothing is hooked for it. +namespace dingosdk::road_rash { +// Requires the validated build, No Bail started and the local profile loaded: the switches start +// from the saved choices (on, with blood, tattoos kept, until a player changes them). +bool start(std::uintptr_t image_base) noexcept; +// Client thread, every tick, with the local client. +void on_client_tick(std::uintptr_t client) noexcept; + +// The switches, any thread; a change applies at the next tick and is saved with the profile. +bool available() noexcept; +bool enabled() noexcept; // off: the marks come off and no bail is counted +bool blood() noexcept; // off: the worst wounds are dry, with scabs +bool covers_tattoos() noexcept; // on: wounds take the place of the player's own tattoos and moles +void set_enabled(bool enabled) noexcept; +void set_blood(bool blood) noexcept; +void set_covers_tattoos(bool covers) noexcept; +// Any thread, done at the next tick: takes every mark off and starts counting again. +void heal() noexcept; +// Any thread, done at the next tick: counts as if the skater had bailed this often, half of it on +// each side, to look at a stage without slamming for it. +void set_bails(unsigned count) noexcept; +// How many bails are counted now. +unsigned bails() noexcept; +} diff --git a/Extension/RoadRash/road_rash_commands.cpp b/Extension/RoadRash/road_rash_commands.cpp new file mode 100644 index 0000000..5485791 --- /dev/null +++ b/Extension/RoadRash/road_rash_commands.cpp @@ -0,0 +1,49 @@ +#include "road_rash.h" +#include "Extension/Console/commands.h" + +// Road Rash's console switches; the menu's (Mod Options > Player) send them too. +namespace dingosdk::console { +void register_road_rash_commands(Commands ®istry) { + const auto unavailable = "Road Rash is unavailable for this game build."; + auto rash = variable("roadrash", "Bails leave marks on your skater that build up: dust, scrapes, bruises, wounds " + "(needs the Road Rash item pack)", + Group::gameplay, argument("0|1", Type::boolean)); + rash.inspect = [unavailable](const Model &) { + return boolean_state(road_rash::available(), road_rash::enabled(), unavailable, + std::to_string(road_rash::bails()) + " bail(s) counted"); + }; + rash.run = [](const Model &, const Values &args, const Output &) { road_rash::set_enabled(std::get(args[0])); }; + registry.add(std::move(rash)); + + auto blood = variable("roadrashblood", "Fresh blood on Road Rash's worst wounds; off, they are dry and scabbed", + Group::gameplay, argument("0|1", Type::boolean)); + blood.inspect = [unavailable](const Model &) { return boolean_state(road_rash::available(), road_rash::blood(), unavailable); }; + blood.run = [](const Model &, const Values &args, const Output &) { road_rash::set_blood(std::get(args[0])); }; + registry.add(std::move(blood)); + + auto tattoos = variable("roadrashtattoos", "Road Rash's wounds take the place of your own tattoos and moles until you heal; " + "off, a limb that wears a tattoo gets no wounds", + Group::gameplay, argument("0|1", Type::boolean)); + tattoos.inspect = [unavailable](const Model &) { + return boolean_state(road_rash::available(), road_rash::covers_tattoos(), unavailable); + }; + tattoos.run = [](const Model &, const Values &args, const Output &) { road_rash::set_covers_tattoos(std::get(args[0])); }; + registry.add(std::move(tattoos)); + + auto heal = action("roadrash heal", "Take Road Rash's marks off and start counting bails again", Group::gameplay); + heal.run = [](const Model &, const Values &, const Output &out) { + road_rash::heal(); + out("Healed."); + }; + registry.add(std::move(heal)); + + auto bails = action("roadrash bails", "Count as if you had bailed this often, to look at a stage (10, 14 and 20 are the steps)", + Group::gameplay, {argument("count", Type::unsigned_integer)}); + bails.run = [](const Model &, const Values &args, const Output &out) { + const auto count = std::get(args[0]); + road_rash::set_bails(static_cast(count > 1000 ? 1000 : count)); + out("Counted."); + }; + registry.add(std::move(bails)); +} +} // namespace dingosdk::console diff --git a/Extension/RoadRash/road_rash_model.h b/Extension/RoadRash/road_rash_model.h new file mode 100644 index 0000000..44a41b7 --- /dev/null +++ b/Extension/RoadRash/road_rash_model.h @@ -0,0 +1,166 @@ +#pragma once +#include "Engine/Game/Skater/skater_body.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Road Rash's rules, apart from the game: which side of the body a bail hit, how many bails it +// takes for a limb's marks to show and to get worse, what the items that draw them are called and +// where on a limb they sit. Plain data and pure functions (Test/road_rash_tests.cpp). +namespace dingosdk::road_rash { +// Where marks can sit: the skater has one tattoo layer per arm and leg, and one moles layer on the face. +enum class Part : std::uint8_t { left_leg, left_arm, right_leg, right_arm, face }; +inline constexpr std::size_t part_count = 5; +inline constexpr std::array parts{Part::left_leg, Part::left_arm, Part::right_leg, Part::right_arm, Part::face}; +inline constexpr std::array part_names{"left leg", "left arm", "right leg", "right arm", "face"}; +constexpr std::size_t index(Part part) noexcept { return static_cast(part); } +constexpr bool leg(Part part) noexcept { return part == Part::left_leg || part == Part::right_leg; } +constexpr bool left(Part part) noexcept { return part == Part::left_leg || part == Part::left_arm; } + +// A limb's marks have three levels, the face's two. +inline constexpr int worst_limb = 3, worst_face = 2; +constexpr int worst(Part part) noexcept { return part == Part::face ? worst_face : worst_limb; } +// The bails it takes for the first marks, the bigger ones and the worst ones. +inline constexpr std::array steps{10, 14, 20}; +// The face follows the worst level of the limbs, and gets worse four bails later. +inline constexpr unsigned face_later = 4; +// How many bails after the leg of the side that took more falls a limb follows: that side's arm, +// then the other side's leg and arm. +inline constexpr unsigned arm_later = 1, other_side_later = 4; + +// ---- a fall ------------------------------------------------------------------------------- + +enum class Side : std::uint8_t { both, left, right }; +// How hard the limbs of each side hit something in a fall, summed over its physics steps: the speed +// into the surface and the speed along it, metres per second. +struct Fall { + float left{}, right{}; +}; +// The side a body is on; feet and toes (they touch the ground all the time), the trunk and the +// head count for neither. +constexpr Side side_of(skater_body::Bone bone) noexcept { + using skater_body::Bone; + switch (bone) { + case Bone::left_hand: case Bone::left_forearm: case Bone::left_arm: case Bone::left_shoulder: + case Bone::left_leg: case Bone::left_upleg: return Side::left; + case Bone::right_hand: case Bone::right_forearm: case Bone::right_arm: case Bone::right_shoulder: + case Bone::right_leg: case Bone::right_upleg: return Side::right; + default: return Side::both; + } +} +// Adds one physics step's contacts. A body that only touches the skater's own board hit nothing. +inline void add(Fall& fall, const skater_body::Contacts& contacts) noexcept { + for (std::size_t i = 0; i < skater_body::count; ++i) { + const auto& body = contacts.bodies[i]; + const auto side = side_of(static_cast(i)); + if (side == Side::both || !body.touching || (body.hit.board && !body.hit.world && !body.hit.vehicle)) continue; + const float hit = body.impact + game::length(body.slide); + (side == Side::left ? fall.left : fall.right) += hit; + } +} +// The side that took the fall: the one whose limbs took three fifths of it or more. A fall the +// limbs hardly felt, or felt alike, is on both. +inline Side side(const Fall& fall) noexcept { + const float total = fall.left + fall.right; + if (!(total >= 2.0f)) return Side::both; + const float share = fall.left / total; + return share >= 0.6f ? Side::left : share <= 0.4f ? Side::right : Side::both; +} + +// ---- the session -------------------------------------------------------------------------- + +// The level `bails` give a part, when the left (or the right) side took more of them. +constexpr int level_for(Part part, unsigned bails, bool left_first) noexcept { + if (part == Part::face) return bails >= steps[2] + face_later ? 2 : bails >= steps[2] ? 1 : 0; + const unsigned later = (left(part) == left_first ? 0 : other_side_later) + (leg(part) ? 0 : arm_later); + if (bails < later) return 0; + const unsigned count = bails - later; + return count >= steps[2] ? 3 : count >= steps[1] ? 2 : count >= steps[0] ? 1 : 0; +} +// Bails counted since the skater was last clean, and the marks they left. A mark never gets +// smaller: only healing takes it off. +struct Tally { + unsigned bails{}; + float left{}, right{}; // falls on each side; one on both counts half for each + bool last_left{}; // the side of the last one-sided fall: it breaks a tie + std::array levels{}; +}; +constexpr bool left_first(const Tally& tally) noexcept { + return tally.left > tally.right || (tally.left == tally.right && tally.last_left); +} +constexpr void grow(Tally& tally) noexcept { + const bool first = left_first(tally); + for (const auto part : parts) + tally.levels[index(part)] = (std::max)(tally.levels[index(part)], level_for(part, tally.bails, first)); +} +constexpr void count(Tally& tally, Side side) noexcept { + ++tally.bails; + if (side == Side::both) { + tally.left += 0.5f; + tally.right += 0.5f; + } else { + (side == Side::left ? tally.left : tally.right) += 1.0f; + tally.last_left = side == Side::left; + } + grow(tally); +} + +// ---- the items ---------------------------------------------------------------------------- + +// The marks are cosmetic items of a content mod: tattoo items for the limbs, moles items for the +// face, found by the end of their names: RoadRash__[_][_Dry|_Blood]. +// Set Leg, Arm, Face; LegR is the left leg's set mirrored, for the right leg: a decal sits +// on a limb like a sticker read from the front, so only a mirrored one puts the marks +// of the left leg's outer side on the right leg's outer side. +// design nothing for the first, B_ and C_ for the others: each part wears one design for as +// long as its marks last. +// level 1 .. the part's worst; the worst comes dry (scabs) and with fresh blood. +inline constexpr std::string_view item_prefix = "roadrash_"; +inline constexpr std::size_t design_count = 3; +inline constexpr std::array design_letters{"", "b_", "c_"}; +// In lower case, as names are compared. `mirrored` asks for the right leg's own set. +inline std::string item_name(Part part, bool mirrored, std::size_t design, int level, bool blood) { + std::string name(item_prefix); + name += part == Part::face ? "face_" : !leg(part) ? "arm_" : mirrored ? "legr_" : "leg_"; + name += design_letters[design < design_count ? design : 0]; + name += std::to_string(level); + if (level >= worst(part)) name += blood ? "_blood" : "_dry"; + return name; +} + +// ---- where a limb's decal goes ------------------------------------------------------------ + +// A tattoo slot places its item with five numbers: x across and y up the limb's piece of the +// body's tattoo layout (0..1), its size, its turn (0.5 is upright) and one unused. +using Placement = std::array; +// The middle of the kneecap or the point of the elbow on a left limb (the right one mirrors x), +// how much y changes per centimetre down the limb, and how many centimetres a size of 1 covers. +struct Limb { + float x, y, y_per_cm, cm_per_size; +}; +inline constexpr Limb leg_spot{0.37f, 0.59f, 0.01073f, 76.0f}, arm_spot{0.635f, 0.505f, 0.01192f, 51.0f}; +// A level's picture covers `side` centimetres with its centre `down` centimetres below that point. +struct Cut { + float side, down; +}; +inline constexpr std::array leg_cuts{{{20, 0}, {27, 2}, {38, 7}}}, arm_cuts{{{16, 0}, {24, 3}, {46, 16}}}; +constexpr Placement placement(Part part, int level) noexcept { + const auto& spot = leg(part) ? leg_spot : arm_spot; + const auto& cut = (leg(part) ? leg_cuts : arm_cuts)[static_cast(std::clamp(level, 1, worst_limb) - 1)]; + return {left(part) ? spot.x : 1.0f - spot.x, spot.y - cut.down * spot.y_per_cm, cut.side / spot.cm_per_size, 0.5f, 0.5f}; +} +// A slot's parameters are words that hold floats. The game works them out again when it updates the +// skater: what it hands back is as good as what it was given, but not equal to the last bit. +inline bool same_parameters(const std::vector& a, const std::vector& b) noexcept { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) + if (a[i] != b[i] && !(std::abs(std::bit_cast(a[i]) - std::bit_cast(b[i])) < 0.002f)) return false; + return true; +} +} diff --git a/Extension/RoadRash/skater_items.h b/Extension/RoadRash/skater_items.h new file mode 100644 index 0000000..19d56f9 --- /dev/null +++ b/Extension/RoadRash/skater_items.h @@ -0,0 +1,125 @@ +#pragma once +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/engine.h" +#include "Engine/Game/Build/20260929/native_cosmetics.h" +#include "Extension/Multiplayer/Remote/native_cosmetics_layout.h" +#include +#include +#include +#include +#include +#include +#include + +// The local skater's cosmetic recipe while it plays: what Road Rash needs to read it, to hand the +// game a changed one and to give the skater back to the game afterwards. Client thread only. Everything is read through guarded reads and checked +// as the multiplayer code checks a peer's outfit (native_cosmetics_layout.h); a failed check throws. +namespace dingosdk::road_rash { +using multiplayer::CosmeticRecipe; +using multiplayer::CosmeticSlot; +inline bool readable(std::uintptr_t address, void* out, std::size_t size) { + return size ? memory::peek_bytes(address, out, size) : address >= 0x10000; +} +using SkaterItems = multiplayer::CosmeticMemory; + +inline std::string lower(std::string_view text) { + std::string out(text); + for (auto& c : out) + if (c >= 'A' && c <= 'Z') c = static_cast(c + ('a' - 'A')); + return out; +} +// The installed cosmetic whose name ends in `ending` (lower case), or nothing. +inline std::string installed_item(const SkaterItems& m, std::string_view ending) { + const auto manager = m.ptr(m.base, addr::engine::cosmetics_manager); + m.check(m.ptr(manager) == m.base + addr::engine::cosmetics_manager_vtable && m.get(manager, 0xa8) == 1, + "Cosmetic catalog is not ready."); + const auto buckets = m.ptr(manager, 0x30); + const auto size = m.get(manager, 0x38), total = m.get(manager, 0x3c); + m.check(size && size <= 16384 && total && total <= 8192, "Cosmetic catalog exceeds bounds."); + std::uint32_t visited{}; + for (std::uint32_t bucket = 0; bucket < size && visited < total; ++bucket) { + auto node = m.ptr(buckets, bucket * 8ULL); + for (std::uint32_t chain = 0; node && chain < total && visited < total; ++chain, ++visited, node = m.ptr(node, 0x10)) { + const auto asset = m.ptr(node, 8) & ~std::uintptr_t{4}; + if (!asset) continue; + auto name = m.text(m.ptr(asset, 0x38)); + if (lower(name).ends_with(ending)) return name; + } + } + return {}; +} +// A recipe as the game keeps the local skater's: every item by its hash, with no name beside it. +// The game changes an item of that skater where it lies, hash and parameters only; a name the +// recipe carried would stay behind and no longer be the item's. +struct LentRecipe { + multiplayer::CosmeticArray scalars, mask{0}; + multiplayer::CosmeticArray items; + std::vector> parameters; + multiplayer::NativeCosmeticRecipe value; + // Holds arrays of its own in the game's layout; the native copy copies out of them. + explicit LentRecipe(const CosmeticRecipe& recipe) : scalars(recipe.scalars.size()), items(recipe.items.size()) { + std::copy(recipe.scalars.begin(), recipe.scalars.end(), scalars.data()); + parameters.reserve(recipe.items.size()); + for (std::size_t i = 0; i < recipe.items.size(); ++i) { + const auto& item = recipe.items[i]; + parameters.emplace_back(item.parameters.size()); + std::copy(item.parameters.begin(), item.parameters.end(), parameters.back().data()); + items.data()[i] = {"", parameters.back().data(), multiplayer::cosmetic_asset_hash(item.asset), item.slot}; + } + value = {scalars.data(), items.data(), mask.data()}; + } + LentRecipe(const LentRecipe&) = delete; + LentRecipe& operator=(const LentRecipe&) = delete; +}; +// Hands the skater a recipe through the game's own recipe copy, the call the multiplayer code makes +// for other players' outfits: the copy marks the skater for its normal rebuild. +inline void apply(const SkaterItems& m, std::uintptr_t component, const CosmeticRecipe& recipe) { + constexpr auto prefix = addr::native_cosmetics::recipe_copy_prefix; + m.check(m.get>(m.base, addr::native_cosmetics::recipe_copy) == prefix, + "Native cosmetic copy function differs."); + m.validate(m.resource(component), recipe); + LentRecipe lent(recipe); + reinterpret_cast( + m.base + addr::native_cosmetics::recipe_copy)(component, &lent.value); + m.check(m.ptr(component, 0x158) != reinterpret_cast(lent.value.items), + "Native cosmetic copy did not take ownership."); +} + +// How the game feeds a skater's items. The local skater's follow the player's outfit: an item +// chosen in the game's menus reaches the skater in the world as it is chosen. A recipe handed over +// with `apply` becomes the skater's own, and from then on the game passes nothing of the outfit on +// to it (the multiplayer code relies on that for other players' skaters). These are the four words +// of the item component the recipe copy changes for it: on build 20260929 they read 2, 0, 2, 0 +// while the outfit is followed and 1, 2, 1, 2 with a recipe of the skater's own. +struct Feed { + std::array words{}; + bool operator==(const Feed&) const = default; + // The copy leaves a 2 in the second word (the multiplayer code checks that one too). + bool follows_outfit() const noexcept { return words[1] != 2; } +}; +inline constexpr std::array feed_words{0x138, 0x13c, 0x144, 0x148}; +// What the component still has to work through: changed items, and a changed feed. +inline constexpr std::size_t items_changed = 0x10a, feed_changed = 0x10d; +inline Feed feed(const SkaterItems& m, std::uintptr_t component) { + Feed out; + for (std::size_t i = 0; i < feed_words.size(); ++i) out.words[i] = m.get(component, feed_words[i]); + return out; +} +// Gives a skater that wears a recipe of its own back to the game. `before` is how the game fed it +// before the recipe; with those words back and the component told that its feed changed, the game +// puts the outfit's own items back at its next update and passes the outfit's changes on again. +inline void hand_back(const SkaterItems& m, std::uintptr_t component, const Feed& before) { + m.check(!m.get(component, items_changed) && !m.get(component, feed_changed), + "Waiting for the skater's items to finish updating."); + (void)feed(m, component); // all four can be read, so they can be written + const auto put = [](std::uintptr_t address, const auto& value) { + SIZE_T done{}; + SkaterItems::check(WriteProcessMemory(GetCurrentProcess(), reinterpret_cast(address), &value, sizeof(value), &done) && + done == sizeof(value), + "Cannot update the skater's item component."); + }; + for (std::size_t i = 0; i < feed_words.size(); ++i) put(component + feed_words[i], before.words[i]); + put(component + feed_changed, std::uint8_t{1}); +} +} diff --git a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp index 355074c..739ae79 100644 --- a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp @@ -124,6 +124,22 @@ int main() { model.hall_of_meat = {true, true}; activate(state, model, cb, "hall-of-meat", ""); check(meat_row().title == "Hall of Meat: On" && capture.command == "hallofmeat 0", "and off again"); + // Road Rash's switches sit there too; its blood and its healing wait for it to be on. + const auto rash_row = [&](std::string_view id) { + const auto page = render(state, model, cb, player_section); + return *find(page.main, id); + }; + check(rash_row("road-rash").title == "Road Rash: Off" && rash_row("road-rash").command.empty(), "Road Rash waits until it started"); + model.road_rash = {true, false, true}; + check(rash_row("road-rash-blood").command.empty() && rash_row("road-rash-heal").command.empty(), "Its blood and healing wait for it"); + activate(state, model, cb, "road-rash", ""); + check(rash_row("road-rash").command == "road-rash" && capture.command == "roadrash 1", "Road Rash turns on"); + model.road_rash = {true, true, true}; + check(rash_row("road-rash").title == "Road Rash: On" && rash_row("road-rash-blood").title == "Road Rash blood: On", "and shows it"); + activate(state, model, cb, "road-rash-blood", ""); + check(capture.command == "roadrashblood 0", "The blood switches off"); + activate(state, model, cb, "road-rash-heal", ""); + check(capture.command == "roadrash heal", "and the skater heals"); model.multiplayer.local_name = "steam_name"; model.player_card = {true, "Card Name", {}}; const auto card_page = render(state, model, cb, player_section); diff --git a/Extension/UI/NativeMenu/native_menu_data.cpp b/Extension/UI/NativeMenu/native_menu_data.cpp index ba2e0be..72cf05a 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 54e58da..1c73812 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/NativeMenu/native_tools_view.h b/Extension/UI/NativeMenu/native_tools_view.h index e9fa7f9..b481381 100644 --- a/Extension/UI/NativeMenu/native_tools_view.h +++ b/Extension/UI/NativeMenu/native_tools_view.h @@ -201,6 +201,12 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks cb.queue_offline_feature(cb.user, request, result.data(), result.size())); } else if (command == "hall-of-meat") { console(m.hall_of_meat.enabled ? "hallofmeat 0" : "hallofmeat 1", m.hall_of_meat.available); + } else if (command == "road-rash") { + console(m.road_rash.enabled ? "roadrash 0" : "roadrash 1", m.road_rash.available); + } else if (command == "road-rash-blood") { + console(m.road_rash.blood ? "roadrashblood 0" : "roadrashblood 1", m.road_rash.available && m.road_rash.enabled); + } else if (command == "road-rash-heal") { + console("roadrash heal", m.road_rash.available && m.road_rash.enabled); } else if (command == "board-wear-reset") { console("boardwear reset", m.offline.board_wear.available && m.offline.board_wear.effective); } else if (command == "challenges") { @@ -296,6 +302,11 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3 toggle(p.main, "no-bail", "No bail", m.debug.no_bail, (m.debug.no_bail_available || m.debug.no_bail) && cb.queue_debug); toggle(p.main, "freecam", "Freecam", m.debug.free_camera, m.debug.available && m.debug.camera_available && cb.queue_debug); toggle(p.main, "hall-of-meat", "Hall of Meat", m.hall_of_meat.enabled, m.hall_of_meat.available && cb.queue_console_command); + toggle(p.main, "road-rash", "Road Rash", m.road_rash.enabled, m.road_rash.available && cb.queue_console_command); + toggle(p.main, "road-rash-blood", "Road Rash blood", m.road_rash.blood, + m.road_rash.available && m.road_rash.enabled && cb.queue_console_command); + button(p.main, "road-rash-heal", "Heal Road Rash", "road-rash-heal", + m.road_rash.available && m.road_rash.enabled && cb.queue_console_command); toggle(p.main, "board-wear", "Board wear", m.offline.board_wear.effective, m.offline.board_wear.available && cb.queue_offline_feature); button(p.main, "board-wear-reset", "Reset board wear", "board-wear-reset", diff --git a/Extension/UI/Overlay/hub_page.cpp b/Extension/UI/Overlay/hub_page.cpp index dc7aca9..29106b1 100644 --- a/Extension/UI/Overlay/hub_page.cpp +++ b/Extension/UI/Overlay/hub_page.cpp @@ -1092,6 +1092,9 @@ void tools_page(Ui &ui, ImVec2 a, ImVec2 z) { if (list.setting("Noclip", on_off(d.noclip), (d.noclip_available || d.noclip) && debugging)) debug({DebugAction::set_noclip, !d.noclip}); if (list.setting("No bail", on_off(d.no_bail), (d.no_bail_available || d.no_bail) && debugging)) debug({DebugAction::set_no_bail, !d.no_bail}); if (list.setting("Hall of Meat", on_off(m.hall_of_meat.enabled), m.hall_of_meat.available && console)) act("hall-of-meat"); + if (list.setting("Road Rash", on_off(m.road_rash.enabled), m.road_rash.available && console)) act("road-rash"); + if (list.setting("Road Rash blood", on_off(m.road_rash.blood), m.road_rash.available && m.road_rash.enabled && console)) act("road-rash-blood"); + if (list.setting("Heal Road Rash", {}, m.road_rash.available && m.road_rash.enabled && console)) act("road-rash-heal"); const auto &wear = m.offline.board_wear; if (list.setting("Board wear", on_off(wear.effective), wear.available && cb.queue_offline_feature)) act("board-wear"); if (list.setting("Reset board wear", {}, wear.available && wear.effective && console)) act("board-wear-reset"); diff --git a/Extension/UI/Overlay/item_browser_overlay.cpp b/Extension/UI/Overlay/item_browser_overlay.cpp new file mode 100644 index 0000000..f5e6f5b --- /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 dc21bea..e5e1325 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.prompt_pointer.load(); + s.hub_typing.load() || s.prompt_pointer.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 53296f4..a3eeee8 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -209,6 +209,12 @@ struct HallOfMeatModel { bool available = false, enabled = false; }; +// Road Rash's switches (Extension/RoadRash/road_rash.h): started, switched on, and whether its +// worst wounds bleed. +struct RoadRashModel { + bool available = false, enabled = false, blood = false; +}; + struct Model { std::string state = "Waiting for native state"; std::string detail; @@ -228,6 +234,7 @@ struct Model { ProgressionModel progression; PlayerCardModel player_card; HallOfMeatModel hall_of_meat; + RoadRashModel road_rash; ObjectPersistenceModel object_persistence; ParkEditorModel editor; float menu_scale = default_menu_scale; @@ -378,6 +385,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 283d9df..ff84664 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 cff7756..1a44544 100644 --- a/Extension/UI/Overlay/overlay_internal.h +++ b/Extension/UI/Overlay/overlay_internal.h @@ -134,6 +134,9 @@ struct State { // The map download card is asking (map_download_card.cpp): the overlay has the pointer, with // its own cursor, so the card's buttons can be clicked whatever is behind it. std::atomic prompt_pointer{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}; @@ -304,6 +307,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 5654c4e..4f52f0c 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -564,6 +564,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); @@ -583,7 +584,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 && !map_download_frame) return; + !nametag_frame && !meat_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame) return; } else if (!s.ui_was_interactive) { s.ui_was_interactive = true; s.last_model = {}; // Reopening immediately reads fresh state. @@ -640,6 +641,7 @@ void render(IDXGISwapChain* presented, UINT flags) { draw_perf_hud(); draw_trainer_hud(); draw_notices(); + draw_item_browser(); draw_chat(); draw_map_download(); sync_menu_cursor(); // close buttons also change visibility, without a key message diff --git a/Extension/UI/Overlay/skate_menu_skater.cpp b/Extension/UI/Overlay/skate_menu_skater.cpp index 5609442..d13898d 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -164,6 +164,15 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c : "Your bails show the bones they hurt and score a Meat card. A break slows the game in single player."; if (toggle_row(menu, "Hall of Meat", meat_help, hall_of_meat, model.hall_of_meat.available && callbacks.queue_console_command)) send_console(menu, callbacks, hall_of_meat ? "hallofmeat 1" : "hallofmeat 0"); + bool road_rash = model.road_rash.enabled; + const bool rash_console = model.road_rash.available && callbacks.queue_console_command; + if (toggle_row(menu, "Road Rash", "Bails leave marks that build up: dust, scrapes, bruises, wounds. Needs the Road Rash item pack.", + road_rash, rash_console)) + send_console(menu, callbacks, road_rash ? "roadrash 1" : "roadrash 0"); + bool rash_blood = model.road_rash.blood; + if (toggle_row(menu, "Road Rash blood", "Fresh blood on the worst wounds. Off, they are dry and scabbed.", rash_blood, + rash_console && model.road_rash.enabled)) + send_console(menu, callbacks, rash_blood ? "roadrashblood 1" : "roadrashblood 0"); end_card(); begin_card(menu, "boosts", "BOOSTS", "Buttons are set in Settings > Controls"); diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index e6f0ebf..d02d180 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -8,6 +8,7 @@ #include "Extension/Settings/job_spin.h" #include "Engine/Game/World/client_state.h" #include "Extension/HallOfMeat/hall_of_meat.h" +#include "Extension/RoadRash/road_rash.h" #include "Extension/Skater/camera_observer.h" #include "Extension/UI/NativeMenu/native_menu.h" #include "Extension/Multiplayer/Hud/native_party.h" @@ -786,6 +787,7 @@ void update_model(std::uintptr_t client, TickState& frame) { } if (r.observer_failed) return; // Keep the bounded restore/telemetry path available after catalog failure. dingosdk::hall_of_meat::on_client_tick(); + dingosdk::road_rash::on_client_tick(client); if (!has_request && now < r.next_model && state == r.previous_state) return; r.next_model = now + 500; DINGO_PROFILE_ZONE("tick/update_model/world model (500 ms)"); @@ -923,6 +925,7 @@ void update_model(std::uintptr_t client, TickState& frame) { model.progression = dingosdk::local_profile_progression(); model.player_card = dingosdk::local_profile_player_card(); model.hall_of_meat = {dingosdk::hall_of_meat::available(), dingosdk::hall_of_meat::enabled()}; + model.road_rash = {dingosdk::road_rash::available(), dingosdk::road_rash::enabled(), dingosdk::road_rash::blood()}; model.bindings = dingosdk::local_profile_controller_bindings(); model.parks = dingosdk::local_profile_parks(); model.world = dingosdk::local_profile_world_layers(); diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 6d49cd4..a1aad9c 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" @@ -12,6 +13,7 @@ #include "Extension/Assets/map_download.h" #include "Extension/Boot/offline_boot.h" #include "Extension/HallOfMeat/hall_of_meat.h" +#include "Extension/RoadRash/road_rash.h" #include "Extension/Skater/camera_observer.h" #include "Extension/Progression/entitlement_request_hook.h" #include "Extension/Skater/skater_observer.h" @@ -221,6 +223,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); @@ -404,6 +407,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { (void)dingosdk::start_no_bail(r.base); const bool meat = dingosdk::hall_of_meat::start(r.base); record("{\"event\":\"hall_of_meat_initialized\",\"active\":" + std::string(meat ? "true" : "false") + "}"); + const bool rash = dingosdk::road_rash::start(r.base); + record("{\"event\":\"road_rash_initialized\",\"active\":" + std::string(rash ? "true" : "false") + "}"); const bool noclip_velocity = dingosdk::start_client_noclip_velocity(r.base); record("{\"event\":\"noclip_velocity_initialized\",\"active\":" + std::string(noclip_velocity ? "true" : "false") + "}"); const bool loading_screens = dingosdk::loading_screen::start(r.base); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 5423b04..ae49022 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 @@ -127,6 +128,8 @@ add_library(dingosdk_runtime SHARED Extension/HallOfMeat/hall_of_meat_skater.cpp Extension/HallOfMeat/hall_of_meat_skeleton.cpp Extension/HallOfMeat/hall_of_meat_slow_motion.cpp + Extension/RoadRash/road_rash.cpp + Extension/RoadRash/road_rash_commands.cpp Extension/Trainer/trainer.cpp Extension/Trainer/trainer_presets.cpp Extension/Trainer/trainer_jump.cpp @@ -152,6 +155,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 eca99be..34634f2 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/map_download_card.cpp diff --git a/contrib/music-favorites-persistence.md b/contrib/music-favorites-persistence.md new file mode 100644 index 0000000..b7a7358 --- /dev/null +++ b/contrib/music-favorites-persistence.md @@ -0,0 +1,156 @@ +# Music selection persistence investigation + +Investigated and implemented on `investigate/music-favorites-persistence`, starting at +`3d5650e`, on 2026-10-10. Reported symptom: favorited tracks disappear after +map loads and game restarts. This is a static source/binary investigation; +the original symptom was not reproduced in a fresh game session here. After the +song implementation, the user confirmed songs persist across map loads and +restarts, but liked playlists do not. The findings below +describe the starting tree; the implementation section describes the change. + +## Finding + +**CONFIRMED (source):** the local music path publishes catalog and Favorites +model contexts, but does not persist favorite selections or restore them from +the local profile. `music_ui_initialize_hook` queues local hydration and returns +without calling the original initializer when the local path accepts the +request (`Extension/Music/local_music_ui.cpp:185-221`). +`update_music_catalog` publishes playlist/song DTOs and completes initialization +(`:223-301`); it never reads saved favorite song identities. The `favorites` +member of `MusicUiPending` is a native playlist handle, not a saved song list. +The generated song wire contains identity and presentation metadata, not saved +favorite state (`music_ui_wire`, `:117-142`). Searches of `Extension/Music`, +`Extension/Profile`, and `Extension/Settings` found no local favorite-selection +save/restore implementation. + +The existing local profile persists preferences through +`Store::set_user_value`/`user_value` +(`Extension/Profile/local_profile.cpp:345-361`). MusicShuffle uses this local +store, but favorites do not use it. + +## Native boundary + +Addresses below are virtual addresses in the available +`F:\Games\ReSkate-1.0.0\Skate.exe.i64` database. The initializer's first 32 bytes +at `0x140868760` match the current tree's supported-build fingerprint. Relevant +setter and save-path control flow was checked in disassembly as well as +decompilation. No database annotations or binary patches were applied. + +- **CONFIRMED:** `0x140868F80` takes a Boolean and two nonzero context handles, + and forwards to the music manager at `0x1471F5ED0` (IDA's symbol names the + overlapping storage `qword_1471F5EC0`). It moves the second argument into + `r9` and retains the third in `r8`. +- **CONFIRMED:** `0x1406F51A0` first calls `0x1406F5C60`, which queues a + 24-byte change in the manager's vector at `+0x200/+0x208/+0x210`, then calls + `0x1406F51E0` to update the live model. +- **CONFIRMED:** with the additional context present, `0x1406F51E0` changes + field 2 of the selected model and the list addressed through manager + `+0x130`; it also adds/removes the song through the native playback manager + (`0x140433AF0`/`0x14043E860`) using the Favorites handle at `+0x1F8`. + The local initializer sets `+0x130` to the Favorites song-list field and + `+0x1F8` to its native playlist handle. +- **CONFIRMED:** playlist-like wrapper `0x140868F50` calls the same queued + setter with `r9 = 0`. The apply-only setter uses field 7 and the Liked list + at manager `+0x110` for playlists, versus field 2 and `+0x130` for songs + (`0x1406F5206`, `0x1406F5238`). Playlist changes skip song playback + membership updates (`0x1406F5310`). +- **CONFIRMED:** the separate flush routine `0x1406F53D0`, reached through + `0x140868F30`, requires a nonempty queue, the object returned by + `0x1407466B0`, a playback manager, and two service-object lookups before + processing queued changes. It ultimately calls `0x140275900` at + `0x1406F5B87` and clears the queued vector. This is distinct from the + immediate model update. +- **LIKELY:** this flush is the original service-backed persistence route. + Its object lookup and request/callback flow support this interpretation; + its complete transport and durable server-side behavior were not traced. + There is no ReSkate local profile write in the corresponding source path. +- **CONFIRMED:** original initialization `0x140868760` performs the same + service-object lookup and calls `0x1406E0530`. That function installs + playlist/song service callback machinery via `0x140274EF0` and two calls + to `0x1402B7740`. Accepted local initialization bypasses this machinery. +- **CONFIRMED:** the manager constructor `0x1406CA9A0` starts with empty + selection containers and an empty pending-change vector. Construction is + reached through `0x1406D5E60` from `0x14094E328`; destruction through + `0x1406D75C0` from `0x14095A186` frees the manager and clears its global. +- **LIKELY:** manager/model recreation at a map load loses the session-only + selection, explaining the reported map-load symptom. The lifecycle call + sites are statically confirmed, but their execution during the user's + specific transition has not been logged. + +## Cause and bounded next change + +The credible mechanism is a missing local persistence bridge: the native +favorite action changes session state, while ReSkate's replacement catalog +initialization does not recover saved selections. Restart necessarily discards +that session state; manager/model recreation can do the same during travel. +This investigation does not establish which original service prerequisite +fails during a live save attempt. + +A focused fix should capture successful favorite/unfavorite actions, save +stable song identities in the existing local profile, then restore available +selections after song publication on each new UI generation. Song/context +handles and pointers must not be stored. Restoration should not produce new +save requests, should retain identities for temporarily absent mod songs, and +should leave shuffle behavior alone. Verify the exact +native mutation boundary and all Favorites/model/playback bookkeeping before +choosing a restore call; publishing a checked icon alone is insufficient. + +Required proof for the fix: persistence after closing/reopening the store, +unfavorite persistence, stable identity across recreated handles, unavailable +mod-song retention, and live favorite/unfavorite behavior after a map change +and full game relaunch. + +## Implementation + +The local provider intercepts the queued selection setter at `0x1406F51A0` +only for a song or playlist context belonging to its currently hydrated manager/model. +The first implementation handled only songs; playlist likes still used the +original service route, explaining the user's follow-up report. +The extended implementation calls the apply-only setter at `0x1406F51E0`, +checks the identity and selected flag afterwards, and saves stable identities +under `ReSkate.MusicFavoriteSongs` or `ReSkate.MusicLikedPlaylists`. +The settings codec already stores JSON +arrays, but its gameplay validator allowed only scalar values. The change permits +an ordered array of valid identities for these two keys, leaving validation +of other gameplay settings intact. Its native return value is preserved: +native add success is `-1`, while unchanged returns the manager's cached status +(initially `-2`); a conventional nonnegative-success check would be incorrect. +Generations outside the local provider continue through the original setter. +Both native entry points are fingerprint-checked before enabling the bridge. + +After publishing the catalog, each initialization resolves saved identities +through the new song/playlist context maps and calls the apply-only setter +with the corresponding song flag. +Disassembly confirms that operation updates the song's field 2, Favorites list, +and native playback membership, without calling the service change queue. +For playlists it updates field 7 and Liked shelf membership. Restoration also +suppresses persistence for reentrant selection callbacks. +Selection order is retained. Missing mod songs and playlists remain saved for a later initialization. Invalid saved +data is reported rather than overwritten. The generation identity retains a +weak owner; native calls take a strong lease so the saved identity does not +keep an unloaded manager alive. + +Logs: `music_favorite_saved`, `music_favorite_save_failed`, +`music_favorites_restored` (saved/restored counts), +`music_favorites_restore_failed`, and `music_favorites_contract_mismatch`. +Playlist events: `music_playlist_like_saved`, `music_playlist_like_save_failed`, +`music_playlist_likes_restored`, and `music_playlist_likes_restore_failed`. + +The `music_favorites` regression uses a scratch SQLite profile and tests +favorite/unfavorite across reopen, failed native readback, unchanged writes, +new model handles, missing/returning mod songs and playlists, independent song +and playlist selections, ordered playlist restoration, and preservation of +unrelated settings. Its native apply callbacks are test doubles; it does not demonstrate +in-game model or audio behavior. + +## Validation + +Release `dingosdk_runtime` build passed, producing +`build/vs2022-x64/Release/ReSkate.dll`. CTest passed 4/4: +`music_favorites`, `profile_changes`, `music_playback_policy`, and +`music_shelf_lifetime`. `git diff --check` passed. + +The user confirmed both song favorites and playlist likes work in-game, +including persistence across map loads and game restarts. This is user-reported +live validation of the implementation before rebasing onto current upstream +main; the rebased build is validated separately with the focused checks above. diff --git a/contrib/native-item-browser.md b/contrib/native-item-browser.md new file mode 100644 index 0000000..beb1a15 --- /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. diff --git a/contrib/road-rash.md b/contrib/road-rash.md new file mode 100644 index 0000000..60b13cd --- /dev/null +++ b/contrib/road-rash.md @@ -0,0 +1,95 @@ +# Road Rash + +Bails leave marks on the local skater that build up over a session: road dust and a graze first, +then scrapes and bruises, then raw wounds, a bruised face and (if the player leaves it on) blood. +Built into ReSkate; the pictures themselves come from a content mod. + +Code: [road_rash.cpp](../Extension/RoadRash/road_rash.cpp) (client thread), +[road_rash_model.h](../Extension/RoadRash/road_rash_model.h) (the rules on their own, with +[tests](../Extension/RoadRash/Test/road_rash_tests.cpp)), +[skater_items.h](../Extension/RoadRash/skater_items.h) (reading and changing the skater's recipe), +[road_rash_commands.cpp](../Extension/RoadRash/road_rash_commands.cpp) (console). + +## What the player gets + +- After 10 bails the leg of the side that took more of them shows the first marks, its arm one bail + later; at 14 they are bigger and the other side starts; at 20 they are at their worst and the face + is bruised, at 24 the face is worse too. A mark never gets smaller. +- `roadrash 0|1`, `roadrashblood 0|1` (the worst wounds bleed, or are dry and scabbed), + `roadrashtattoos 0|1` (wounds may take the place of the player's own tattoos and moles), + `roadrash heal`, and `roadrash bails ` to look at a stage. The first two and the healing + are in the menus beside Hall of Meat (Mod Options > Player). The choices are saved with the profile. +- Without the item pack installed nothing shows and nothing is changed. + +## How it works + +- **A bail** is the physics state selector choosing Wipeout (`watched_physics_state`, no_bail.h). +- **Its side** comes from the ragdoll's contacts, read with Hall of Meat's reader + (`hall_of_meat::read_step`) on every client tick while the body is a ragdoll: the impact and the + slide of each arm and leg body that touches something other than the skater's own board are summed + per side. The side with three fifths of it or more took the fall; otherwise it counts half for + each. Read outside the physics step a contact can be half written; the sums are only compared. +- **The marks** are cosmetic items: tattoo items for the limbs (the skater has one tattoo slot per + arm and leg) and moles items for the face. Road Rash puts them into the local skater's recipe + with the game's own recipe copy, the call the multiplayer code makes for peers, after the same + validation. The game rebuilds the skater in about one frame. +- **Where a limb's decal sits** is the slot's five parameters (x, y, size, turn, unused). The + numbers for knee and elbow are in road_rash_model.h: x and y are a position in the limb's + rectangle of the body mesh's second UV set, the right limb mirrors x. +- **Nothing is saved** to the outfit. The game builds the skater from the profile at every level + change; the marks are put back from the session's count. A slot that holds the player's own + tattoo or moles is left alone unless `roadrashtattoos` is on, and what was covered comes back + with healing or switching off. +- **The outfit menus.** The game feeds a skater's items either from the player's outfit or from a + recipe of the skater's own, never both. After the recipe copy the local skater no longer gets + what the player changes in the menus: the new top shows on the menu's skater and is saved, and + the skater in the world keeps the old one. So Road Rash gives the skater back to the game while + a game menu is up (`sample_game_ui_state`, game_ui_state.h), and also with nothing to show, on + healing, when a switch changes and when switched off. Giving back (`hand_back`, skater_items.h) + writes the four words of the item component the copy had changed back to what they were and + sets the component's "feed changed" byte; at its next update the game puts the outfit's own + items back and feeds the skater again. The marks go back on, over the outfit as it is by then, a + second after the menu has gone. A skater whose four words do not read as "follows its outfit" + before the first marks never gets a recipe. +- **Respawns.** After a bail the game hands over what looks like a new skater and is the same one, + items and all. So whose recipe a skater wears is read from the skater at every look (the four + words, and whether Road Rash items are in the recipe), not remembered from before. +- **Hashes, no names.** The recipe handed to the copy names no item, like the game's own for the + local skater: the game changes an item of that skater where it lies (hash and parameters only), + and a name left beside it would no longer match. That made every later read of the skater's + recipe fail, the multiplayer code's too. +- **Parameters come back rounded.** The game works a slot's parameters out again when it updates + the skater; they are compared with a tolerance, not bit for bit. +- Off, it does nothing. On and without marks, a tick costs one physics-state read. + +## The item pack + +A content mod (no code) with items named `RoadRash__[_][_Dry|_Blood]`: + +| Set | Kind of item | Levels | +| --- | --- | --- | +| `Leg`, `LegR` (the left leg's pictures mirrored, for the right leg) | tattoo | 1, 2, 3_Dry, 3_Blood | +| `Arm` | tattoo | 1, 2, 3_Dry, 3_Blood | +| `Face` | moles | 1, 2_Dry, 2_Blood | + +A design letter (`B_`, `C_`) after the set names a second and third look; each limb draws one per +session. A missing design falls back to the first, a missing `LegR` to `Leg`, a missing level to +the one below. Any pack that follows the names works. + +## Seen working in game + +Offline, on main at b7936a3 with a 45 item pack: every stage through `roadrash bails`, the blood +switch, healing, switching off and on, and real bails counted with their side read from the +contacts (falls on the left and on the right both came out as such), the marks growing in place +through respawns. With marks on: a top changed in the apparel menu and a switch to another skater +preset both show on the skater in the world, with the marks back over them; no wound item ends up +in a saved outfit. A female and a male body. + +## Not tried yet + +- Multiplayer: what peers see, with and without the pack; dedicated servers. +- The online route: all of the above ran on the offline profile. +- A game menu that keeps the skater in view (the marks are off while any game menu is up), and + anything outside the menus that changes the outfit while marks are on. +- A level change in the middle of a bail. +- No marks for the torso or the neck: the chest tattoo slot is not measured.