fix(music): persist favorites and playlist likes

This commit is contained in:
DeckardDetribine committed 2026-10-10 17:03:08 +01:00
1 parent b7936a36c6
commit 4312a08111
9 files changed
+518 -5

No files matched your search

+3
View File
@@ -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)
+8
View File
@@ -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}};
@@ -0,0 +1,148 @@
#include "Extension/Music/local_music_favorites.h"
#include <Windows.h>
#include <iostream>
#include <map>
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<std::string, std::uint64_t> playlist_contexts{{playlist, 700}};
std::vector<std::uint64_t> 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<std::uint64_t>{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<std::uint64_t>{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<std::string, std::uint64_t> current{{built_in, 100}};
std::vector<std::uint64_t> 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<std::uint64_t>{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<std::uint64_t>{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; }
}
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include "Extension/Profile/local_profile.h"
#include <algorithm>
#include <vector>
#include <stdexcept>
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<std::string>;
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<std::string>();
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<class Apply, class Verify>
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<class Resolve, class Apply>
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;
}
}
+118 -4
View File
@@ -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<decltype(f.songs)>(base + music::publish_songs);
f.insert = reinterpret_cast<decltype(f.insert)>(base + music::context_insert);
f.complete = reinterpret_cast<decltype(f.complete)>(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<unsigned char, 32> actual{};
if (!read(base + fp.rva, actual) || actual != fp.bytes) favorites_ready = false;
}
if (favorites_ready)
f.favorite_apply = reinterpret_cast<decltype(f.favorite_apply)>(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<std::uintptr_t, 3> 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<const void*>(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<MusicUiPending>();
initialized->manager = manager; initialized->model = native_model;
initialized->owner = pending->owner; initialized->generation = pending->generation;
initialized->thread = pending->thread;
InterlockedIncrement(reinterpret_cast<volatile LONG*>(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\"}"); }
+8 -1
View File
@@ -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<MusicUiPending> pending;
// Retains the weak owner and identity of the locally hydrated generation.
std::unique_ptr<MusicUiPending> 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,
+8
View File
@@ -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<std::uint64_t>() <= INT64_MAX)) ||
(value.is_number_float() && std::isfinite(value.get<double>())) ||
@@ -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<unsigned char, 32> construct_bytes{};
if (read(base + music_model_construct_contract.rva, construct_bytes) &&
construct_bytes == music_model_construct_contract.bytes)
+158
View File
@@ -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.