diff --git a/CMakeLists.txt b/CMakeLists.txt index 22c3319..64f1f4e 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 b472fe4..33d54ac 100644 --- a/Engine/Game/Build/20260929/local_music.h +++ b/Engine/Game/Build/20260929/local_music.h @@ -41,6 +41,14 @@ inline constexpr std::uintptr_t context_insert = 0x6c6120; inline constexpr std::uintptr_t complete_delegate = 0x58b1f00; // Music UI initializer (hook target). inline constexpr Fingerprint music_ui_initialize_contract{0x868760, {0x48,0x89,0x6c,0x24,0x18,0x48,0x89,0x74,0x24,0x20,0x41,0x54,0x41,0x56,0x41,0x57,0x48,0x81,0xec,0x80,0x00,0x00,0x00,0x49,0x8b,0xe9,0x4d,0x8b,0xf0,0x4c,0x8b,0xfa}}; +// CONFIRMED: queued selection setter (0x1406F51A0) calls the change queue, +// then the apply-only setter (0x1406F51E0). A nonzero fourth argument selects +// song favorites; zero selects playlist likes. The apply-only song path updates +// model field 2, the Favorites list and the playback playlist at manager +0x1F8. +// The playlist path updates field 7 and the Liked list at manager +0x110; +// disassembly at 0x1406F5206/0x1406F5238/0x1406F5310 confirms the split. +inline constexpr Fingerprint favorite_change_contract{0x6f51a0, {0x40,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x28,0x49,0x8b,0xd9,0x49,0x8b,0xf8,0x0f,0xb6,0xf2,0x48,0x8b,0xe9,0xe8,0xa6,0x0a,0x00,0x00,0x48,0x85,0xdb,0x4c,0x8b,0xc7}}; +inline constexpr Fingerprint favorite_apply_contract{0x6f51e0, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x6c,0x24,0x20,0x4c,0x89,0x44,0x24,0x18,0x88,0x54,0x24,0x10,0x56,0x57,0x41,0x54,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x30,0x45}}; // Queue selector shared by UI Next and natural end-of-track selection. The queue // supplies its own channel and registered playlist asset; do not hard-code IDs. inline constexpr Fingerprint playback_select_next_contract{0x14f8ed0, {0x48,0x89,0x54,0x24,0x10,0x55,0x53,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x8d,0x6c,0x24,0xc8,0x48,0x81,0xec,0x38,0x01,0x00,0x00,0x4c,0x8b,0xe1}}; diff --git a/Extension/Music/Test/local_music_favorites_tests.cpp b/Extension/Music/Test/local_music_favorites_tests.cpp new file mode 100644 index 0000000..ff8e293 --- /dev/null +++ b/Extension/Music/Test/local_music_favorites_tests.cpp @@ -0,0 +1,148 @@ +#include "Extension/Music/local_music_favorites.h" +#include +#include +#include + +namespace { +int failures{}; +void check(bool condition, const char* message) { + if (!condition) { std::cerr << "FAIL: " << message << '\n'; ++failures; } +} +} + +int run_tests() { + using namespace dingosdk; + namespace favorites = music_favorites; + const auto folder = std::filesystem::temp_directory_path() / + ("reskate-music-favorites-tests-" + std::to_string(GetCurrentProcessId())); + const auto save = folder / "reskate.sqlite3"; + const std::string built_in = "Artist - Track", mod = "Mod Artist - Mod Track"; + const std::string playlist = "playlist:z-last", mod_playlist = "playlist:a-mod"; + const auto likes_key = favorites::playlist_profile_key; + { + profile::Store store(save); + check(favorites::load(store).empty(), "old profiles start without favorites"); + bool rejected{}, applied{}; + try { + favorites::apply_and_save(store, built_in, true, [&] { applied = true; return -2; }, [] { return false; }); + } catch (const std::runtime_error&) { rejected = true; } + check(rejected && applied && favorites::load(store).empty(), "failed native readback never saves a favorite"); + const auto result = favorites::apply_and_save(store, built_in, true, [&] { + check(favorites::load(store).empty(), "the native change happens before the profile save"); + return -1; + }, [] { return true; }); + check(result == -1 && favorites::load(store) == favorites::Songs{built_in}, + "native add success preserves its negative return value and saves the identity"); + favorites::save_change(store, mod, true); + const auto changes = profile::Store::changes(); + favorites::save_change(store, built_in, true); + check(profile::Store::changes() == changes, "repeated favorite action does not rewrite the profile"); + favorites::save_change(store, built_in, false); + favorites::save_change(store, built_in, true); // Move it after the mod song, deliberately not alphabetical. + store.set_user_value("ReSkate.MusicShuffle", true); + store.set_user_value("Unrelated", 17); + const auto saved_songs = favorites::load(store); + check(favorites::load(store, likes_key).empty(), "old profiles have no saved playlist likes"); + rejected = false; + try { + favorites::apply_and_save(store, playlist, true, [] { return -2; }, [] { return false; }, likes_key); + } catch (const std::runtime_error&) { rejected = true; } + check(rejected && favorites::load(store, likes_key).empty(), "failed playlist readback does not save a like"); + check(favorites::apply_and_save(store, playlist, true, [] { return -1; }, [] { return true; }, likes_key) == -1, + "playlist like preserves native add success"); + favorites::save_change(store, mod_playlist, true, likes_key); + const auto like_changes = profile::Store::changes(); + favorites::save_change(store, playlist, true, likes_key); + check(profile::Store::changes() == like_changes && favorites::load(store) == saved_songs, + "repeated playlist likes do not rewrite the save or change song favorites"); + } + { + profile::Store store(save); + const auto saved = favorites::load(store); + check(saved == favorites::Songs{mod, built_in}, "song identities and selection order survive a store restart"); + const auto liked = favorites::load(store, likes_key); + check(liked == favorites::Songs{playlist, mod_playlist}, "playlist likes and their order survive a restart"); + std::map playlist_contexts{{playlist, 700}}; + std::vector liked_handles; + const auto restore_likes = [&] { + return favorites::restore(favorites::load(store, likes_key), [&](const std::string& id) { + const auto found = playlist_contexts.find(id); + return found == playlist_contexts.end() ? std::uint64_t{} : found->second; + }, [&](std::uint64_t handle) { liked_handles.push_back(handle); return true; }); + }; + const auto like_changes = profile::Store::changes(); + check(restore_likes() == 1 && liked_handles == std::vector{700}, + "playlist restoration resolves the current generation and skips an absent mod"); + check(favorites::load(store, likes_key) == liked && profile::Store::changes() == like_changes, + "restoring playlist likes retains absent mods without writing the profile"); + playlist_contexts = {{playlist, 800}, {mod_playlist, 801}}; + liked_handles.clear(); + check(restore_likes() == 2 && liked_handles == std::vector{800, 801}, + "the next map restores new playlist handles in selection order"); + favorites::save_change(store, playlist, false, likes_key); + check(favorites::load(store, likes_key) == favorites::Songs{mod_playlist} && favorites::load(store) == saved, + "unliking a playlist preserves other likes and song favorites"); + // A new map supplies new model handles. An absent mod must not be purged. + std::map current{{built_in, 100}}; + std::vector applied; + const auto changes = profile::Store::changes(); + const auto restore = [&] { + return favorites::restore(favorites::load(store), [&](const std::string& id) { + const auto found = current.find(id); + return found == current.end() ? std::uint64_t{} : found->second; + }, [&](std::uint64_t handle) { applied.push_back(handle); return true; }); + }; + check(restore() == 1 && applied == std::vector{100}, "restoration uses the current song handle only"); + check(favorites::load(store) == saved && profile::Store::changes() == changes, + "restoration retains absent mod songs and performs no profile writes"); + current = {{built_in, 201}, {mod, 202}}; + applied.clear(); + check(restore() == 2 && applied == std::vector{202, 201}, + "the next catalog generation restores fresh handles and a returning mod"); + check(favorites::restore(saved, [](const std::string&) { return 999ULL; }, + [](std::uint64_t) { return false; }) == 0, "failed native application is not counted as restored"); + favorites::save_change(store, built_in, false); + check(favorites::load(store) == favorites::Songs{mod}, "unfavorite removes only the selected song"); + check(store.user_value("ReSkate.MusicShuffle") == Json(true) && store.user_value("Unrelated") == Json(17), + "favorite changes preserve shuffle and unrelated settings"); + } + { + profile::Store store(save); + check(favorites::load(store) == favorites::Songs{mod}, "unfavorite survives another restart"); + check(favorites::load(store, likes_key) == favorites::Songs{mod_playlist}, "playlist unlike survives another restart"); + favorites::save_change(store, mod, false); + favorites::save_change(store, mod_playlist, false, likes_key); + } + { + profile::Store store(save); + check(favorites::load(store).empty() && store.user_value(favorites::profile_key) == Json::array(), + "removing the last favorite persists an explicit empty selection"); + check(favorites::load(store, likes_key).empty() && store.user_value(likes_key) == Json::array(), + "unliking the last playlist persists an explicit empty selection"); + const Json damaged = Json::array({"Artist - Track", 5}); + bool rejected{}; + try { store.set_user_value(favorites::profile_key, damaged); } catch (const std::runtime_error&) { rejected = true; } + check(rejected && favorites::load(store).empty(), "malformed favorite data rolls back without overwriting the save"); + rejected = false; + try { store.set_user_value(likes_key, damaged); } catch (const std::runtime_error&) { rejected = true; } + check(rejected && favorites::load(store, likes_key).empty(), "malformed playlist data rolls back without overwriting the save"); + rejected = false; + try { store.set_user_value("Unrelated", Json::array({1, 2})); } catch (const std::runtime_error&) { rejected = true; } + check(rejected && store.user_value("Unrelated") == Json(17), "array support is limited to the favorites key"); + for (const auto& invalid : {std::string{}, std::string(256, 'x'), std::string("bad\nidentity")}) { + rejected = false; + try { favorites::save_change(store, invalid, true); } catch (const std::runtime_error&) { rejected = true; } + check(rejected && favorites::load(store).empty(), "invalid identities never change the save"); + } + } + std::error_code error; + std::filesystem::remove_all(folder, error); + if (failures) return 1; + std::cout << "music favorites: ok\n"; + return 0; +} + +int main() { + try { return run_tests(); } + catch (const std::exception& error) { std::cerr << "FAIL: " << error.what() << '\n'; return 1; } +} diff --git a/Extension/Music/local_music_favorites.h b/Extension/Music/local_music_favorites.h new file mode 100644 index 0000000..d6f2949 --- /dev/null +++ b/Extension/Music/local_music_favorites.h @@ -0,0 +1,64 @@ +#pragma once +#include "Extension/Profile/local_profile.h" +#include +#include +#include + +namespace dingosdk::music_favorites { +inline constexpr std::string_view profile_key = "ReSkate.MusicFavoriteSongs"; +inline constexpr std::string_view playlist_profile_key = "ReSkate.MusicLikedPlaylists"; +using Songs = std::vector; + +inline bool valid_id(std::string_view id) { + return !id.empty() && id.size() <= 255 && + std::none_of(id.begin(), id.end(), [](unsigned char c) { return c < 32 || c == 127; }); +} + +inline Songs load(const profile::Store& store, std::string_view key = profile_key) { + Songs songs; + const auto saved = store.user_value(key); + if (!saved) return songs; + if (!saved->is_array()) throw std::runtime_error("Invalid saved music favorites"); + for (const auto& value : *saved) { + if (!value.is_string()) + throw std::runtime_error("Invalid saved favorite song identity"); + const auto id = value.get(); + if (!valid_id(id)) throw std::runtime_error("Invalid saved favorite song identity"); + if (std::find(songs.begin(), songs.end(), id) == songs.end()) songs.push_back(id); + } + return songs; +} + +// Change one identity, retaining favorites whose mod is temporarily unavailable. +// A malformed saved value is never silently replaced by an empty selection. +inline void save_change(profile::Store& store, const std::string& id, bool favorite, std::string_view key = profile_key) { + if (!valid_id(id)) throw std::runtime_error("Invalid favorite song identity"); + auto songs = load(store, key); + const auto found = std::find(songs.begin(), songs.end(), id); + const bool changed = favorite ? found == songs.end() : found != songs.end(); + if (changed) { + if (favorite) songs.push_back(id); + else songs.erase(found); + } + if (changed) store.set_user_value(key, Json(songs)); +} + +template +auto apply_and_save(profile::Store& store, const std::string& id, bool favorite, Apply apply, Verify verify, + std::string_view key = profile_key) { + const auto result = apply(); + if (!verify()) throw std::runtime_error("Native favorite selection readback failed"); + save_change(store, id, favorite, key); + return result; +} + +// Resolve fresh model handles for each catalog generation. Restoration uses only +// the native apply operation; it neither saves nor queues service changes. +template +std::size_t restore(const Songs& songs, Resolve resolve, Apply apply) { + std::size_t restored{}; + for (const auto& id : songs) + if (const auto handle = resolve(id); handle && apply(handle)) ++restored; + return restored; +} +} diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index d39900d..3816a87 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -5,6 +5,7 @@ #include "local_music_assets.h" #include "local_music_safety.h" #include "local_music_ui.h" +#include "local_music_favorites.h" #include "Engine/Game/Abi/native_data.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/engine.h" @@ -24,7 +25,8 @@ std::string music_artwork_url(std::string_view value) { // MusicGraphAsset metadata/TagRefs. No generated catalog, guessed memberships, -// artwork, audio-residency gate, or preference persistence. Bounds: 1024 songs, +// artwork or audio-residency gate. Favorite identities are saved separately in +// the local profile and applied after song publication. Bounds: 1024 songs, // 256 authored groups, 8192 membership edges. Include after assets/news/model helpers. @@ -61,8 +63,17 @@ bool initialize_music_functions(std::uintptr_t base) { f.songs = reinterpret_cast(base + music::publish_songs); f.insert = reinterpret_cast(base + music::context_insert); f.complete = reinterpret_cast(base + music::complete_delegate); + f.favorite_apply = nullptr; + bool favorites_ready = true; + for (const auto& fp : {music::favorite_change_contract, music::favorite_apply_contract}) { + std::array actual{}; + if (!read(base + fp.rva, actual) || actual != fp.bytes) favorites_ready = false; + } + if (favorites_ready) + f.favorite_apply = reinterpret_cast(base + music::favorite_apply_contract.rva); + else logging::event(logging::Channel::music, "{\"event\":\"music_favorites_contract_mismatch\"}"); f.allocator = {base + addr::engine::allocator_adapter_vtable, 0, 8}; - return true; // Root installs only the initialize hook after these contracts pass. + return true; // Root installs the validated UI and selection hooks. } bool music_ui_identity(const MusicUiPending& pending) { @@ -167,7 +178,7 @@ CosmeticShared music_ui_message(const std::string& wire, bool playlist) { const auto value = result.value; result.value = {}; return value; } -bool music_ui_has_context(std::uintptr_t map, const std::string& id) { +std::uint64_t music_ui_context(std::uintptr_t map, const std::string& id) { std::uintptr_t buckets{}, node{}, sentinel{}; std::uint32_t count{}, capacity{}; if (!read(map, buckets) || !buckets || !read(map + 8, capacity) || !capacity || capacity > 4096 || !read(map + 12, count) || count > 2048 || !read(buckets + capacity * 8ULL, sentinel) || @@ -176,12 +187,78 @@ bool music_ui_has_context(std::uintptr_t map, const std::string& id) { while (node && node != sentinel) { std::array row{}; std::string name; if (seen.size() >= count || !seen.insert(node).second || !read(node, row) || !cosmetic_text(row[0], name)) return false; - if (name == id) return row[1] != 0; + if (name == id) return row[1]; node = row[2]; } return false; } +bool music_ui_has_context(std::uintptr_t map, const std::string& id) { + return music_ui_context(map, id) != 0; +} + +namespace { +bool music_selection(std::uintptr_t model, std::uint64_t context, bool is_song, std::string& id, bool& favorite) { + auto& m = game::native_data().models; + const auto identity = m.field(model, context, 0, 0xffffffffU, false); + const auto selected = m.field(model, context, is_song ? 2 : 7, 0xffffffffU, false); + if (!identity || !selected) return false; + game::ModelWriteLock lock(model); + const auto text = m.value(model, identity, 0, 0); + const auto flag = m.value(model, selected, 0, 0); + std::uint8_t value{}; + if (!text || !flag || !identifier(reinterpret_cast(text), id) || + !read(flag, value) || value > 1) return false; + favorite = value != 0; + return true; +} +} + +std::int32_t music_favorite_change_hook(std::uintptr_t manager, std::uint8_t favorite, + std::uint64_t context, std::uint64_t song_parent) { + auto& s = local_runtime(); auto& ui = music_ui_runtime(); + const bool is_song = song_parent != 0; + const auto key = is_song ? music_favorites::profile_key : music_favorites::playlist_profile_key; + const auto failure_event = is_song ? "music_favorite_save_failed" : "music_playlist_like_save_failed"; + bool handled{}; + std::int32_t result = -2; // Initial native unavailable status; actual results are preserved. + try { + PreserveError preserve; std::lock_guard lock(s.native_mutex); + const auto* current = ui.initialized.get(); + if (context && favorite <= 1 && s.active.load(std::memory_order_acquire) && + s.store && ui.functions.favorite_apply && current && current->manager == manager && + music_ui_current(*current)) { + CosmeticSharedGuard lease{music_ui_lease(*current)}; + std::string id; bool selected{}; + if (lease.value.control && music_selection(current->model, context, is_song, id, selected) && + music_ui_context(manager + (is_song ? 0xd0 : 0xa8), id) == context) { + const auto model = current->model, generation = current->generation; + // Local selections must not remain queued for an unavailable service. + handled = true; + if (ui.restoring) return ui.functions.favorite_apply(manager, favorite, context, is_song); + music_favorites::apply_and_save(*s.store, id, favorite != 0, [&] { + result = ui.functions.favorite_apply(manager, favorite, context, is_song); + return result; + }, [&] { + std::string after; + return ui.initialized && ui.generation == generation && music_ui_current(*ui.initialized) && + music_selection(model, context, is_song, after, selected) && after == id && selected == (favorite != 0); + }, key); + logging::event(logging::Channel::music, Json{{"event", is_song ? "music_favorite_saved" : "music_playlist_like_saved"}, + {is_song ? "song" : "playlist", id}, {"favorite", selected}}.dump().c_str()); + } else logging::event(logging::Channel::music, Json{{"event", failure_event}, + {"reason", "selection_context_unavailable"}}.dump().c_str()); + } + } catch (const std::exception& error) { + logging::event(logging::Channel::music, Json{{"event", failure_event}, + {"reason", error.what()}}.dump().c_str()); + } catch (...) { + logging::event(logging::Channel::music, Json{{"event", failure_event}}.dump().c_str()); + } + // Generations not owned by the local provider retain their native behavior. + return handled ? result : ui.functions.favorite_change(manager, favorite, context, song_parent); +} + void music_ui_initialize_hook(std::uint64_t all, std::uint64_t hidden, std::uint64_t featured, std::uint64_t liked, std::uint64_t discovered, std::uint64_t favorites, std::uint64_t songs, const std::uint32_t* handle, const void* callback) { @@ -191,6 +268,7 @@ void music_ui_initialize_hook(std::uint64_t all, std::uint64_t hidden, std::uint // Even a forwarded initialization supersedes old local contexts. const auto generation = ++ui.generation; ui.pending.reset(); + ui.initialized.reset(); if (s.active.load(std::memory_order_acquire)) try { const auto thread = GetCurrentThreadId(); @@ -295,6 +373,42 @@ void update_music_catalog() { for (const auto& song : catalog.songs) if (!music_ui_has_context(manager + 0xd0, song.id)) throw std::runtime_error("Native song identity missing"); if (!music_ui_current(*pending)) return; + auto initialized = std::make_unique(); + initialized->manager = manager; initialized->model = native_model; + initialized->owner = pending->owner; initialized->generation = pending->generation; + initialized->thread = pending->thread; + InterlockedIncrement(reinterpret_cast(initialized->owner + 12)); + ui.initialized = std::move(initialized); + if (ui.functions.favorite_apply && s.store) { + for (const bool is_song : {true, false}) { + const auto failure_event = is_song ? "music_favorites_restore_failed" : "music_playlist_likes_restore_failed"; + try { + const bool was_restoring = ui.restoring; + ui.restoring = true; + struct RestoreScope { bool& flag; bool before; ~RestoreScope() { flag = before; } } + restore_scope{ui.restoring, was_restoring}; + const auto saved = music_favorites::load(*s.store, + is_song ? music_favorites::profile_key : music_favorites::playlist_profile_key); + const auto restored = music_favorites::restore(saved, [&](const std::string& id) -> std::uint64_t { + if (!music_ui_current(*pending)) return 0; + return music_ui_context(manager + (is_song ? 0xd0 : 0xa8), id); + }, [&](std::uint64_t context) { + if (!music_ui_current(*pending)) return false; + ui.functions.favorite_apply(manager, 1, context, is_song); + std::string id; bool selected{}; + return music_ui_current(*pending) && music_selection(native_model, context, is_song, id, selected) && selected; + }); + logging::event(logging::Channel::music, Json{{"event", is_song ? "music_favorites_restored" : "music_playlist_likes_restored"}, + {"saved", saved.size()}, {"restored", restored}}.dump().c_str()); + } catch (const std::exception& error) { + logging::event(logging::Channel::music, Json{{"event", failure_event}, + {"reason", error.what()}}.dump().c_str()); + } catch (...) { + logging::event(logging::Channel::music, Json{{"event", failure_event}}.dump().c_str()); + } + } + } + if (!music_ui_current(*pending)) return; ui.functions.complete(&pending->delegate); dingosdk::logging::event(dingosdk::logging::Channel::music, dingosdk::Json{{"event", "native_music_ui_complete"}, {"playlists", playlist_count}, {"songs", song_count}, {"source", "runtime_music_assets"}}.dump().c_str()); } catch (...) { retry = false; dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"native_music_ui_publish_failed\"}"); } diff --git a/Extension/Music/local_music_ui.h b/Extension/Music/local_music_ui.h index 5b0e599..f4f6d04 100644 --- a/Extension/Music/local_music_ui.h +++ b/Extension/Music/local_music_ui.h @@ -12,9 +12,12 @@ using addr::local_music::music_ui_initialize_contract; using MusicUiInitialize = void (*)(std::uint64_t, std::uint64_t, std::uint64_t, std::uint64_t, std::uint64_t, std::uint64_t, std::uint64_t, const std::uint32_t*, const void*); +using MusicFavoriteChange = std::int32_t (*)(std::uintptr_t, std::uint8_t, std::uint64_t, std::uint64_t); struct MusicUiFunctions { MusicUiInitialize initialize{}; + MusicFavoriteChange favorite_change{}; + std::int32_t (*favorite_apply)(std::uintptr_t, std::uint8_t, std::uint64_t, std::uint8_t){}; void* (*construct_playlist)(void*, const std::uintptr_t*){}; void* (*construct_song)(void*, const std::uintptr_t*){}; void (*playlists)(std::uintptr_t, CosmeticShared*){}; @@ -42,8 +45,10 @@ struct MusicUiPending { struct MusicUiRuntime { MusicUiFunctions functions; std::unique_ptr pending; + // Retains the weak owner and identity of the locally hydrated generation. + std::unique_ptr initialized; std::uint64_t generation{}; - bool updating{}; + bool updating{}, restoring{}; }; MusicUiRuntime& music_ui_runtime(); @@ -76,6 +81,8 @@ struct MusicUiMessages { }; bool music_ui_has_context(std::uintptr_t map, const std::string& id); +std::int32_t music_favorite_change_hook(std::uintptr_t manager, std::uint8_t favorite, + std::uint64_t context, std::uint64_t song_parent); void music_ui_initialize_hook(std::uint64_t all, std::uint64_t hidden, std::uint64_t featured, std::uint64_t liked, std::uint64_t discovered, std::uint64_t favorites, std::uint64_t songs, diff --git a/Extension/Profile/local_profile.cpp b/Extension/Profile/local_profile.cpp index 796842d..2404135 100644 --- a/Extension/Profile/local_profile.cpp +++ b/Extension/Profile/local_profile.cpp @@ -105,6 +105,14 @@ void detail::validate_settings(const Snapshot& s) { require(values.is_object() && values.size() <= 2048, "Invalid gameplay settings"); for (const auto& [key, value] : values.items()) { require(valid_text(key), "Invalid gameplay setting key"); + // Music selections are ordered identity lists, not native scalar + // option. The existing settings codec can store its JSON array. + if (key == "ReSkate.MusicFavoriteSongs" || key == "ReSkate.MusicLikedPlaylists") { + require(value.is_array(), "Invalid saved music favorites"); + for (const auto& song : value) + require(song.is_string() && valid_text(song.string()), "Invalid saved favorite song identity"); + continue; + } require((value.is_number_integer() && (!value.is_number_unsigned() || value.get() <= INT64_MAX)) || (value.is_number_float() && std::isfinite(value.get())) || diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index 903e024..c80db1f 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 0000000..906dda7 --- /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.