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