mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 11:57:01 +02:00
fix(music): persist favorites and playlist likes
This commit is contained in:
1 parent
b7936a36c6
commit
4312a08111
9 files changed
+518
-5
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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\"}"); }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user