From 63b747fd4a2ee3aa5ac569a118da494e7a2aed62 Mon Sep 17 00:00:00 2001 From: DeckardDetribine Date: Sun, 11 Oct 2026 11:31:59 +0100 Subject: [PATCH] fix(music): play favorites sequentially in visible order --- Engine/Game/Build/20260929/local_music.h | 4 + .../local_music_playback_policy_tests.cpp | 29 ++ Extension/Music/local_music_playback.cpp | 279 ++++++++++++++++-- Extension/Music/local_music_playback.h | 10 + Extension/Music/local_music_playback_policy.h | 25 +- Extension/Profile/local_profile_runtime.cpp | 20 +- 6 files changed, 346 insertions(+), 21 deletions(-) diff --git a/Engine/Game/Build/20260929/local_music.h b/Engine/Game/Build/20260929/local_music.h index 33d54aca..c517d72b 100644 --- a/Engine/Game/Build/20260929/local_music.h +++ b/Engine/Game/Build/20260929/local_music.h @@ -54,6 +54,10 @@ inline constexpr Fingerprint favorite_apply_contract{0x6f51e0, {0x48,0x89,0x5c,0 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}}; // Native playlist playback-mode setter (PlaylistPlaybackMode, not MusicPlaylistType). inline constexpr Fingerprint playback_mode_setter_contract{0x443aa0, {0x44,0x8b,0x91,0xa8,0x00,0x00,0x00,0x4c,0x8b,0x99,0xa0,0x00,0x00,0x00,0x44,0x8b,0xca,0x8b,0xc2,0x33,0xd2,0x49,0xf7,0xf2,0x8b,0xc2,0x49,0x8b,0x04,0xc3,0x48,0x85}}; +// Native channel-to-playlist binding entrypoint, used by the generated UI/script thunk. +inline constexpr Fingerprint playback_channel_playlist_bind_contract{0x8ca6a0, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0xda,0x48,0x8b,0xf9,0xe8,0x2b,0xad,0xb6,0xff,0x48,0x85,0xc0,0x74,0x0e,0x4c,0x8b,0xc3,0x48,0x8b,0xd7}}; +// Candidate-admission callback called by the shared selector; arg4 is its actual channel-map key. +inline constexpr Fingerprint playback_candidate_admission_contract{0x43c460, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x6c,0x24,0x10,0x56,0x57,0x41,0x54,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x30,0x44,0x8b,0x81,0xd0,0x00,0x00,0x00,0x4c,0x8b,0xfa}}; inline constexpr std::array music_ui_contracts{{ music_ui_initialize_contract, {construct_playlist, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0xda,0x48,0x8b,0xf9,0x48,0x8d,0x15,0x21,0x95,0x32,0x04,0xe8,0x94,0x30,0x5e,0x00,0x33,0xc9,0x48,0x8d}}, diff --git a/Extension/Music/Test/local_music_playback_policy_tests.cpp b/Extension/Music/Test/local_music_playback_policy_tests.cpp index f2769c89..f719f000 100644 --- a/Extension/Music/Test/local_music_playback_policy_tests.cpp +++ b/Extension/Music/Test/local_music_playback_policy_tests.cpp @@ -12,6 +12,20 @@ int main() { check(policy::cursor_sync_sentinel == 0x7fffffffu, "native cursor synchronization sentinel is INT_MAX, distinct from queue -1"); + check(policy::resolve_playlist_binding_id(0, 1, true) == 1, + "a zero-ID dynamic Favorites binding resolves to its live native node"); + check(policy::resolve_playlist_binding_id(0, 1, false) == 0, + "a zero-ID binding stays untouched when the Favorites node is unavailable"); + check(policy::resolve_playlist_binding_id(18, 1, true) == 18, + "ordinary playlist bindings are not redirected to Favorites"); + + check(policy::ordered_member_index(0, 4, true) == 3 && + policy::ordered_member_index(3, 4, true) == 0, + "Favorites sequential order follows the visible top-to-bottom rows"); + check(policy::ordered_member_index(0, 4, false) == 0 && + policy::ordered_member_index(4, 4, true) == 4, + "native static order stays forward and invalid positions are rejected"); + // The queue's global candidate asset pool can contain hundreds of songs while // the selected native playlist contains only its authored subset. std::array two_track_members{}; @@ -67,6 +81,21 @@ int main() { policy::cursor_sync_sentinel, false, false, false, 0, 6); check(plan.cursor == 0, "an unresolvable current track safely restarts at the first entry"); + plan = policy::plan_cursor(false, policy::native_default_mode, + policy::cursor_sync_sentinel, true, false, false, 0, 3); + check(plan.set_mode && plan.set_cursor && plan.cursor == 0, + "a dynamic Favorites playlist can start sequentially without a static asset GUID"); + + plan = policy::plan_cursor(false, policy::native_sequential_mode, 3, + false, false, false, 0, 3); + check(plan.set_cursor && plan.cursor == 0, + "a favorite removal that leaves the cursor at member count restarts on a valid member"); + + plan = policy::plan_cursor(false, policy::native_sequential_mode, 3, + false, false, true, 1, 3); + check(plan.set_cursor && plan.cursor == 2, + "a stale dynamic cursor resumes after the active member when its GUID is available"); + plan = policy::plan_cursor(false, policy::native_sequential_mode, policy::cursor_sync_sentinel, false, false, false, 0, 6); check(!plan.set_mode && plan.set_cursor && plan.cursor == 0, diff --git a/Extension/Music/local_music_playback.cpp b/Extension/Music/local_music_playback.cpp index 14c056e0..d8da0338 100644 --- a/Extension/Music/local_music_playback.cpp +++ b/Extension/Music/local_music_playback.cpp @@ -2,6 +2,7 @@ #include "local_music_playback_policy.h" #include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/memory.h" #include "Engine/Game/Build/20260929/local_music.h" #include "Engine/Game/Build/supported_build.h" @@ -9,6 +10,7 @@ #include #include #include +#include #include #include @@ -17,12 +19,18 @@ namespace { using namespace dingosdk::game::build::v20260929; using local_music::playback_mode_setter_contract; using local_music::playback_select_next_contract; +using local_music::playback_channel_playlist_bind_contract; +using local_music::playback_candidate_admission_contract; using music_playback::CursorPlan; std::atomic image_base{}; std::atomic shuffle_enabled{}; std::atomic playback_available{}; MusicSelectNext select_next{}; +MusicChannelPlaylistBind bind_channel_playlist{}; +std::array, 256> last_logged_channel_bindings{}; +MusicCandidateAdmission candidate_admission{}; +std::array, 256> logged_candidate_admission_results{}; using SetPlaybackMode = void (__fastcall *)(std::uintptr_t manager, std::uint32_t playlist_id, std::uint32_t mode); SetPlaybackMode set_playback_mode{}; @@ -31,9 +39,16 @@ struct LastBinding { std::uintptr_t queue{}; std::uintptr_t node{}; std::uint32_t playlist_id{std::numeric_limits::max()}; + std::uint32_t member_count{}; }; std::array last_binding_by_channel{}; // Protected by the binding-cache lock. SRWLOCK binding_cache_lock = SRWLOCK_INIT; +std::atomic logged_policy_events{}; + +void log_policy_event_once(std::uint32_t bit, const char* json) noexcept { + if (!(logged_policy_events.fetch_or(bit, std::memory_order_relaxed) & bit)) + dingosdk::logging::event(dingosdk::logging::Channel::music, json); +} bool read_at(std::uintptr_t address, auto& value) noexcept { return dingosdk::memory::peek(address, value); @@ -59,6 +74,20 @@ bool valid_range(std::uintptr_t value, std::size_t size) noexcept { return value >= 0x10000 && value <= dingosdk::memory::highest_user_address - size; } +bool native_queue_channel(std::uintptr_t base, std::uintptr_t queue, + std::uint32_t& channel) noexcept { + std::uintptr_t vtable{}, getter{}; + if (!read_at(queue, vtable) || !valid_range(vtable, 0x20) || + !read_at(vtable + 0x18, getter) || getter < base || + getter - base >= supported_build::game_image_size) return false; + + // The engine's candidate-admission callback uses the return from this same + // virtual slot as its channel-map key (sub_1414F8ED0, call through vtable+0x18). + using GetChannel = std::uint32_t (__fastcall *)(std::uintptr_t queue); + channel = reinterpret_cast(getter)(queue); + return true; +} + bool find_channel_node(std::uintptr_t manager, std::uint32_t channel, std::uint32_t& playlist_id) noexcept { std::uintptr_t buckets{}; @@ -114,6 +143,40 @@ bool member_count(std::uintptr_t node, std::uintptr_t& begin, std::uint32_t& cou return count > 0 && valid_range(begin, static_cast(count) * 16); } +bool set_favorites_order(std::uintptr_t node, std::uint32_t member_total) noexcept { + std::uintptr_t order_begin{}, order_end{}, order_capacity{}; + if (!member_total || member_total > 8192 || + !read_at(node + 0x38, order_begin) || !read_at(node + 0x40, order_end) || + !read_at(node + 0x48, order_capacity) || !order_begin || order_end < order_begin || + order_capacity < order_end || (order_end - order_begin) % sizeof(std::uint32_t) || + (order_end - order_begin) / sizeof(std::uint32_t) != member_total || + !valid_range(order_begin, static_cast(member_total) * sizeof(std::uint32_t))) + return false; + + auto* order = reinterpret_cast(order_begin); + for (std::uint32_t position = 0; position < member_total; ++position) + order[position] = music_playback::ordered_member_index(position, member_total, true); + return true; +} + +bool favorites_order_shape_matches(std::uintptr_t node, std::uint32_t member_total) noexcept { + std::uintptr_t order_begin{}, order_end{}, order_capacity{}; + return member_total && member_total <= 8192 && + read_at(node + 0x38, order_begin) && read_at(node + 0x40, order_end) && + read_at(node + 0x48, order_capacity) && order_begin && order_end >= order_begin && + order_capacity >= order_end && (order_end - order_begin) % sizeof(std::uint32_t) == 0 && + (order_end - order_begin) / sizeof(std::uint32_t) == member_total && + valid_range(order_begin, static_cast(member_total) * sizeof(std::uint32_t)); +} + +bool favorites_node_ready(std::uintptr_t manager, std::uint32_t favorites_playlist) noexcept { + if (!favorites_playlist) return false; + const auto node = find_playlist_node(manager, favorites_playlist); + std::uintptr_t members{}; + std::uint32_t count{}; + return node && member_count(node, members, count); +} + bool current_guid(std::uintptr_t queue, std::uintptr_t playlist_asset, bool& found, std::array& guid) noexcept { found = false; @@ -172,55 +235,156 @@ bool pending_song_request(std::uintptr_t queue, bool& pending) noexcept { void apply_policy(std::uintptr_t queue, std::uintptr_t playlist_asset) noexcept { const auto base = image_base.load(std::memory_order_acquire); - if (!base || !queue || !playlist_asset) return; + if (!base || !queue || !playlist_asset) { + log_policy_event_once(1u << 0, "{\"event\":\"music_playback_policy_skipped\",\"reason\":\"queue_context_unavailable\"}"); + return; + } std::uint32_t queue_type{}, channel{}, channel_playlist{}; - if (!read_at(playlist_asset + 0x70, queue_type) || queue_type != 4 || - !read_at(queue + 0x60, channel) || channel >= last_binding_by_channel.size()) return; + if (!read_at(playlist_asset + 0x70, queue_type) || queue_type != 4) { + log_policy_event_once(1u << 1, "{\"event\":\"music_playback_policy_skipped\",\"reason\":\"unsupported_playlist_type\"}"); + return; + } + if (!native_queue_channel(base, queue, channel) || channel >= last_binding_by_channel.size()) { + log_policy_event_once(1u << 2, "{\"event\":\"music_playback_policy_skipped\",\"reason\":\"queue_channel_unavailable\"}"); + return; + } + + std::uintptr_t manager{}; + if (!read_at(base + local_music::asset_manager, manager) || !manager || + !valid_range(manager, 0xd8)) { + log_policy_event_once(1u << 3, "{\"event\":\"music_playback_policy_skipped\",\"reason\":\"asset_manager_unavailable\"}"); + return; + } + { + CriticalSectionGuard manager_lock(manager + 0x20); + std::uintptr_t current_manager{}; + if (!read_at(base + local_music::asset_manager, current_manager) || current_manager != manager || + !find_channel_node(manager, channel, channel_playlist)) { + log_policy_event_once(1u << 4, "{\"event\":\"music_playback_policy_skipped\",\"reason\":\"channel_playlist_unavailable\"}"); + return; + } + } + + // Favorites is a dynamic playlist. Its native node owns the member GUIDs, + // while its playlist asset has no compiled entries graph at +0x60. + std::uintptr_t ui_manager{}; + std::uint32_t favorites_playlist{}; + const bool has_favorites_playlist = + read_at(base + local_music::ui_manager, ui_manager) && valid_range(ui_manager, 0x1fc) && + read_at(ui_manager + 0x1f8, favorites_playlist) && favorites_playlist != 0; + const bool dynamic_favorites = has_favorites_playlist && channel_playlist == favorites_playlist; + if (dynamic_favorites) { + char message[192]{}; + std::snprintf(message, sizeof(message), + "{\"event\":\"music_playback_dynamic_favorites\",\"source\":\"native_playlist_members\",\"channel\":%u,\"playlist\":%u}", + channel, channel_playlist); + log_policy_event_once(1u << 5, message); + } // Snapshot the queue-local state under its native lock, then release it before // taking the music manager lock. The selector can re-enter this queue lock. bool pending{}, has_current_guid{}; std::array playing_guid{}; - std::uint32_t asset_songs{}; { CriticalSectionGuard queue_lock(queue + 0x150); - if (!pending_song_request(queue, pending) || !song_count(playlist_asset, asset_songs) || - !current_guid(queue, playlist_asset, has_current_guid, playing_guid)) return; + std::uint32_t current_channel{}; + if (!native_queue_channel(base, queue, current_channel) || current_channel != channel || + !pending_song_request(queue, pending)) { + log_policy_event_once(1u << 6, "{\"event\":\"music_playback_policy_skipped\",\"reason\":\"queue_snapshot_unavailable\"}"); + return; + } + if (!dynamic_favorites) { + std::uint32_t asset_songs{}; + if (!song_count(playlist_asset, asset_songs)) { + log_policy_event_once(1u << 7, "{\"event\":\"music_playback_policy_skipped\",\"reason\":\"playlist_asset_entries_unavailable\"}"); + return; + } + if (!current_guid(queue, playlist_asset, has_current_guid, playing_guid)) { + log_policy_event_once(1u << 8, "{\"event\":\"music_playback_policy_skipped\",\"reason\":\"current_track_unavailable\"}"); + return; + } + } } - std::uintptr_t manager{}; - if (!read_at(base + local_music::asset_manager, manager) || !manager || - !valid_range(manager, 0xd8)) return; CriticalSectionGuard manager_lock(manager + 0x20); std::uintptr_t current_manager{}; + std::uint32_t current_channel_playlist{}; if (!read_at(base + local_music::asset_manager, current_manager) || current_manager != manager || - !find_channel_node(manager, channel, channel_playlist)) return; + !find_channel_node(manager, channel, current_channel_playlist) || + current_channel_playlist != channel_playlist) { + log_policy_event_once(1u << 9, "{\"event\":\"music_playback_policy_skipped\",\"reason\":\"channel_playlist_changed\"}"); + return; + } const auto node = find_playlist_node(manager, channel_playlist); - if (!node) return; + if (!node) { + char message[256]{}; + std::snprintf(message, sizeof(message), + "{\"event\":\"music_playback_policy_skipped\",\"reason\":\"playlist_node_unavailable\",\"channel\":%u,\"playlist\":%u,\"favorites\":%u}", + channel, channel_playlist, favorites_playlist); + log_policy_event_once(1u << 10, message); + return; + } BindingCacheGuard cache_lock; std::uint32_t current_mode{}, current_cursor{}, member_total{}; std::uintptr_t members{}; if (!read_at(node + 0x0c, current_mode) || !read_at(node + 0x30, current_cursor) || - !member_count(node, members, member_total)) return; + !member_count(node, members, member_total)) { + log_policy_event_once(1u << 11, "{\"event\":\"music_playback_policy_skipped\",\"reason\":\"playlist_members_unavailable\"}"); + return; + } const auto& previous = last_binding_by_channel[channel]; const bool new_group = previous.manager != manager || previous.queue != queue || previous.node != node || previous.playlist_id != channel_playlist; + const auto added_favorites = dynamic_favorites && !new_group && + member_total > previous.member_count ? member_total - previous.member_count : 0; + const bool favorites_members_changed = dynamic_favorites && !new_group && + member_total != previous.member_count; bool current_found{}; std::uint32_t current_position{}; - if (!member_index(members, member_total, playing_guid, has_current_guid, current_found, current_position)) return; + if (!member_index(members, member_total, playing_guid, has_current_guid, current_found, current_position)) { + log_policy_event_once(1u << 12, "{\"event\":\"music_playback_policy_skipped\",\"reason\":\"member_identity_unavailable\"}"); + return; + } - const auto plan = music_playback::plan_cursor(shuffle_enabled.load(std::memory_order_acquire), + const auto shuffle = shuffle_enabled.load(std::memory_order_acquire); + auto plan = music_playback::plan_cursor(shuffle, current_mode, current_cursor, new_group, pending, current_found, current_position, member_total); - if (plan.set_mode) { + if (dynamic_favorites && !shuffle && !plan.set_cursor && current_mode == music_playback::native_sequential_mode && + added_favorites && current_cursor != music_playback::cursor_sync_sentinel && current_cursor > 0 && + current_cursor < member_total) { + // New favorites appear at the top of the visible list. The reversed index + // vector therefore grows at its front; advance the cursor by the insertion + // count to keep the next selection after the currently playing member. + plan.set_cursor = true; + plan.cursor = current_cursor + added_favorites; + if (plan.cursor >= member_total) plan.cursor = 0; + } + const bool rebuild_favorites_order = dynamic_favorites && !shuffle && !new_group && + current_mode == music_playback::native_sequential_mode && + (favorites_members_changed || !favorites_order_shape_matches(node, member_total)); + if (plan.set_mode || rebuild_favorites_order) { // Clear the rebuild latch only as an input. The native rebuild sets it on // success; preserving that output is required for later rebuild semantics. *reinterpret_cast(node + 8) = 0; - set_playback_mode(manager, channel_playlist, plan.mode); + set_playback_mode(manager, channel_playlist, + plan.set_mode ? plan.mode : music_playback::native_sequential_mode); + } + if (dynamic_favorites && !shuffle) { + if (!set_favorites_order(node, member_total)) { + log_policy_event_once(1u << 13, + "{\"event\":\"music_playback_favorites_order_unavailable\"}"); + } else if (plan.set_mode || rebuild_favorites_order) { + char message[144]{}; + std::snprintf(message, sizeof(message), + "{\"event\":\"music_playback_favorites_order\",\"direction\":\"top_to_bottom\",\"members\":%u}", + member_total); + dingosdk::logging::event(dingosdk::logging::Channel::music, message); + } } if (plan.set_cursor) *reinterpret_cast(node + 0x30) = plan.cursor; - last_binding_by_channel[channel] = {manager, queue, node, channel_playlist}; + last_binding_by_channel[channel] = {manager, queue, node, channel_playlist, member_total}; } } @@ -236,6 +400,11 @@ bool initialize_music_playback(std::uintptr_t base, bool shuffle) noexcept { set_playback_mode = reinterpret_cast(base + local_music::playback_mode_setter_contract.rva); shuffle_enabled.store(shuffle, std::memory_order_release); last_binding_by_channel = {}; + for (auto& binding : last_logged_channel_bindings) + binding.store(std::numeric_limits::max(), std::memory_order_relaxed); + for (auto& results : logged_candidate_admission_results) + results.store(0, std::memory_order_relaxed); + logged_policy_events.store(0, std::memory_order_relaxed); playback_available.store(false, std::memory_order_release); return true; } @@ -254,6 +423,82 @@ std::int32_t __fastcall music_select_next_hook(std::uintptr_t queue, std::uintpt return -1; } +MusicChannelPlaylistBind& music_channel_playlist_bind_original() noexcept { return bind_channel_playlist; } + +std::uintptr_t __fastcall music_channel_playlist_bind_hook(std::uintptr_t channel_pointer, + std::uintptr_t playlist_id_pointer) { + if (!bind_channel_playlist) return 0; + std::uint32_t channel{}, requested_playlist_id{}, favorites_playlist{}; + const bool have_binding = read_at(channel_pointer, channel) && + read_at(playlist_id_pointer, requested_playlist_id); + auto playlist_id = requested_playlist_id; + bool favorites_remapped{}; + if (have_binding && requested_playlist_id == 0) { + const auto base = image_base.load(std::memory_order_acquire); + std::uintptr_t manager{}, ui_manager{}; + const bool have_favorites = base && + read_at(base + local_music::asset_manager, manager) && manager && valid_range(manager, 0xd8) && + read_at(base + local_music::ui_manager, ui_manager) && valid_range(ui_manager, 0x1fc) && + read_at(ui_manager + 0x1f8, favorites_playlist); + bool node_ready{}; + if (have_favorites && favorites_playlist) { + CriticalSectionGuard manager_lock(manager + 0x20); + std::uintptr_t current_manager{}; + node_ready = read_at(base + local_music::asset_manager, current_manager) && + current_manager == manager && favorites_node_ready(manager, favorites_playlist); + } + playlist_id = music_playback::resolve_playlist_binding_id( + requested_playlist_id, favorites_playlist, node_ready); + favorites_remapped = playlist_id != requested_playlist_id; + } + + const auto result = favorites_remapped + ? bind_channel_playlist(channel_pointer, reinterpret_cast(&playlist_id)) + : bind_channel_playlist(channel_pointer, playlist_id_pointer); + + if (have_binding) { + const auto signature = (static_cast(channel) << 32) | playlist_id; + bool should_log = true; + if (channel < last_logged_channel_bindings.size()) { + const auto previous = last_logged_channel_bindings[channel].exchange( + signature, std::memory_order_relaxed); + should_log = previous != signature; + } + if (should_log) { + char message[224]{}; + std::snprintf(message, sizeof(message), + "{\"event\":\"music_channel_playlist_binding\",\"channel\":%u,\"requested_playlist_id\":%u,\"playlist_id\":%u,\"favorites\":%u,\"favorites_remapped\":%s,\"applied\":%s}", + channel, requested_playlist_id, playlist_id, favorites_playlist, + favorites_remapped ? "true" : "false", result ? "true" : "false"); + dingosdk::logging::event(dingosdk::logging::Channel::music, message); + } + } + return result; +} + +MusicCandidateAdmission& music_candidate_admission_original() noexcept { return candidate_admission; } + +bool __fastcall music_candidate_admission_hook(std::uintptr_t manager, + std::uintptr_t candidate_guid, std::uintptr_t context, std::uint32_t channel_key) { + if (!candidate_admission) return false; + const bool accepted = candidate_admission(manager, candidate_guid, context, channel_key); + const auto result_bit = static_cast(accepted ? 2 : 1); + bool should_log = true; + if (channel_key < logged_candidate_admission_results.size()) { + const auto previous = logged_candidate_admission_results[channel_key].fetch_or( + result_bit, std::memory_order_relaxed); + should_log = (previous & result_bit) == 0; + } + if (should_log) { + char message[128]{}; + std::snprintf(message, sizeof(message), + "{\"event\":\"music_candidate_admission\",\"channel_key\":%u,\"accepted\":%s}", + channel_key, accepted ? "true" : "false"); + dingosdk::logging::event(dingosdk::logging::Channel::music, message); + } + return accepted; +} + bool music_shuffle_enabled() noexcept { return shuffle_enabled.load(std::memory_order_acquire); } void set_music_shuffle_enabled(bool enabled) noexcept { diff --git a/Extension/Music/local_music_playback.h b/Extension/Music/local_music_playback.h index ee59bcbb..8c2a5598 100644 --- a/Extension/Music/local_music_playback.h +++ b/Extension/Music/local_music_playback.h @@ -4,6 +4,10 @@ namespace dingosdk::profile_runtime { using MusicSelectNext = std::int32_t (__fastcall *)(std::uintptr_t queue, std::uintptr_t playlist_asset); +using MusicChannelPlaylistBind = std::uintptr_t (__fastcall *)(std::uintptr_t channel_pointer, + std::uintptr_t playlist_id_pointer); +using MusicCandidateAdmission = bool (__fastcall *)(std::uintptr_t manager, + std::uintptr_t candidate_guid, std::uintptr_t context, std::uint32_t channel_key); // Validate the supported native selector/setter contracts and seed the lock-free // preference read used by the audio selection hook. @@ -13,6 +17,12 @@ void deactivate_music_playback() noexcept; bool music_playback_available() noexcept; MusicSelectNext& music_select_next_original() noexcept; std::int32_t __fastcall music_select_next_hook(std::uintptr_t queue, std::uintptr_t playlist_asset); +MusicChannelPlaylistBind& music_channel_playlist_bind_original() noexcept; +std::uintptr_t __fastcall music_channel_playlist_bind_hook(std::uintptr_t channel_pointer, + std::uintptr_t playlist_id_pointer); +MusicCandidateAdmission& music_candidate_admission_original() noexcept; +bool __fastcall music_candidate_admission_hook(std::uintptr_t manager, + std::uintptr_t candidate_guid, std::uintptr_t context, std::uint32_t channel_key); bool music_shuffle_enabled() noexcept; void set_music_shuffle_enabled(bool enabled) noexcept; diff --git a/Extension/Music/local_music_playback_policy.h b/Extension/Music/local_music_playback_policy.h index 8ce23088..edf97923 100644 --- a/Extension/Music/local_music_playback_policy.h +++ b/Extension/Music/local_music_playback_policy.h @@ -8,6 +8,12 @@ inline constexpr std::uint32_t native_default_mode = 0; inline constexpr std::uint32_t native_sequential_mode = 1; inline constexpr std::uint32_t cursor_sync_sentinel = 0x7fffffffu; +constexpr std::uint32_t resolve_playlist_binding_id(std::uint32_t requested_id, + std::uint32_t favorites_id, bool favorites_node_ready) noexcept { + return requested_id == 0 && favorites_id != 0 && favorites_node_ready + ? favorites_id : requested_id; +} + struct CursorPlan { std::uint32_t mode{}; bool set_mode{}; @@ -28,8 +34,15 @@ constexpr bool find_member_position(const std::uint8_t* members, std::uint32_t m return false; } -// Plan a native transition without touching the game's data. The current position -// is in the mode-1 ordered-index vector after rebuilding it as identity order. +constexpr std::uint32_t ordered_member_index(std::uint32_t position, + std::uint32_t member_count, + bool reverse) noexcept { + if (position >= member_count) return member_count; + return reverse ? member_count - position - 1 : position; +} + +// Plan a native transition without touching the game's data. Static playlist +// positions use the native mode-1 order vector after it is rebuilt as identity. constexpr CursorPlan plan_cursor(bool shuffle, std::uint32_t current_mode, std::uint32_t current_cursor, bool new_group, bool explicit_request_pending, bool current_track_found, @@ -55,6 +68,14 @@ constexpr CursorPlan plan_cursor(bool shuffle, std::uint32_t current_mode, // starts at its first authored entry. plan.set_cursor = true; plan.cursor = 0; + } else if (current_cursor >= order_count) { + // Native favorite edits set the dynamic playlist cursor to its new member + // count. That is outside the order vector and would make the selector + // reject every candidate; resume after the current member when possible. + plan.set_cursor = true; + plan.cursor = current_track_found && order_count + ? (current_order_position + 1 == order_count ? 0 : current_order_position + 1) + : 0; } return plan; } diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index 59c2f65d..7061943c 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -544,10 +544,26 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline, } const bool playback_ready = initialize_music_playback(base, local_preference("MusicShuffle").value_or(false)); - if (playback_ready) + if (playback_ready) { + std::array channel_bind_bytes{}; + if (read(base + addr::local_music::playback_channel_playlist_bind_contract.rva, + channel_bind_bytes) && + channel_bind_bytes == addr::local_music::playback_channel_playlist_bind_contract.bytes) + hook(addr::local_music::playback_channel_playlist_bind_contract, + &music_channel_playlist_bind_hook, music_channel_playlist_bind_original()); + else dingosdk::logging::event(dingosdk::logging::Channel::music, + "{\"event\":\"music_channel_playlist_binding_contract_mismatch\"}"); + std::array admission_bytes{}; + if (read(base + addr::local_music::playback_candidate_admission_contract.rva, + admission_bytes) && + admission_bytes == addr::local_music::playback_candidate_admission_contract.bytes) + hook(addr::local_music::playback_candidate_admission_contract, + &music_candidate_admission_hook, music_candidate_admission_original()); + else dingosdk::logging::event(dingosdk::logging::Channel::music, + "{\"event\":\"music_candidate_admission_contract_mismatch\"}"); hook(addr::local_music::playback_select_next_contract, &music_select_next_hook, music_select_next_original()); - else dingosdk::logging::event(dingosdk::logging::Channel::music, + } else dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"music_playback_order_contract_mismatch\"}"); hook(buildkit_text_exists_contract, &buildkit_text_exists, buildkit_text_functions().exists); hook(buildkit_text_translate_contract, &buildkit_text_translate, buildkit_text_functions().translate);