diff --git a/Engine/Game/Build/20260929/local_music.h b/Engine/Game/Build/20260929/local_music.h index da5c2da8..b472fe4b 100644 --- a/Engine/Game/Build/20260929/local_music.h +++ b/Engine/Game/Build/20260929/local_music.h @@ -41,6 +41,11 @@ 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}}; +// 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}}; +// 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}}; 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 new file mode 100644 index 00000000..f2769c89 --- /dev/null +++ b/Extension/Music/Test/local_music_playback_policy_tests.cpp @@ -0,0 +1,77 @@ +#include "Extension/Music/local_music_playback_policy.h" +#include + +namespace policy = dingosdk::profile_runtime::music_playback; + +int main() { + int failures = 0; + const auto check = [&](bool ok, const char* message) { + if (!ok) { std::printf("FAIL: %s\n", message); ++failures; } + }; + + check(policy::cursor_sync_sentinel == 0x7fffffffu, + "native cursor synchronization sentinel is INT_MAX, distinct from queue -1"); + + // 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{}; + two_track_members[0] = 0x12; + two_track_members[16] = 0x34; + const std::array second_track{0x34}; + std::uint32_t member_position{}; + check(policy::find_member_position(two_track_members.data(), 2, second_track, member_position) && + member_position == 1, + "active current-song GUID is resolved against a playlist subset, independent of candidate-pool size"); + + auto plan = policy::plan_cursor(false, policy::native_default_mode, + policy::cursor_sync_sentinel, true, false, false, 0, 0); + check(plan.mode == policy::native_sequential_mode && plan.set_mode, + "sequential is selected when shuffle is off"); + check(plan.set_cursor && plan.cursor == 0, "a new sequential playlist starts at authored entry zero"); + + plan = policy::plan_cursor(true, policy::native_sequential_mode, 4, + false, false, true, 3, 6); + check(plan.mode == policy::native_default_mode && plan.set_mode && !plan.set_cursor, + "shuffle restores native default selection without moving its cursor"); + + plan = policy::plan_cursor(false, policy::native_default_mode, + policy::cursor_sync_sentinel, false, true, true, 2, 6); + check(plan.set_mode && plan.set_cursor && plan.cursor == policy::cursor_sync_sentinel, + "a pending explicit song request keeps native GUID synchronization"); + + plan = policy::plan_cursor(false, policy::native_default_mode, + policy::cursor_sync_sentinel, false, false, true, 0, 6); + check(plan.set_mode && plan.cursor == 1, + "turning shuffle off mid-play continues after the current first track"); + + plan = policy::plan_cursor(false, policy::native_default_mode, + policy::cursor_sync_sentinel, false, false, true, 5, 6); + check(plan.cursor == 0, "sequential playback wraps after the final playlist entry"); + + plan = policy::plan_cursor(false, policy::native_sequential_mode, 3, + false, false, true, 2, 6); + check(!plan.set_mode && !plan.set_cursor && plan.cursor == 3, + "an active sequential cursor keeps progressing without being reseeded"); + + plan = policy::plan_cursor(false, policy::native_sequential_mode, 3, + true, false, true, 2, 6); + check(plan.set_cursor && plan.cursor == 0, + "a newly selected playlist starts at its first entry even if its current GUID matches"); + + plan = policy::plan_cursor(false, policy::native_sequential_mode, 0, + true, false, false, 0, 2); + check(plan.set_cursor && plan.cursor == 0, + "new playlist identity explicitly seeds entry zero even when the prior cursor was also zero"); + + plan = policy::plan_cursor(false, policy::native_default_mode, + 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_sequential_mode, + policy::cursor_sync_sentinel, false, false, false, 0, 6); + check(!plan.set_mode && plan.set_cursor && plan.cursor == 0, + "a stale sync sentinel without a pending click cannot strand sequential selection"); + + std::printf(failures ? "%d failure(s)\n" : "all passed\n", failures); + return failures ? 1 : 0; +} diff --git a/Extension/Music/local_music_playback.cpp b/Extension/Music/local_music_playback.cpp new file mode 100644 index 00000000..14c056e0 --- /dev/null +++ b/Extension/Music/local_music_playback.cpp @@ -0,0 +1,262 @@ +#include "local_music_playback.h" + +#include "local_music_playback_policy.h" +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/20260929/local_music.h" +#include "Engine/Game/Build/supported_build.h" + +#include +#include +#include +#include +#include + +namespace dingosdk::profile_runtime { +namespace { +using namespace dingosdk::game::build::v20260929; +using local_music::playback_mode_setter_contract; +using local_music::playback_select_next_contract; +using music_playback::CursorPlan; + +std::atomic image_base{}; +std::atomic shuffle_enabled{}; +std::atomic playback_available{}; +MusicSelectNext select_next{}; +using SetPlaybackMode = void (__fastcall *)(std::uintptr_t manager, std::uint32_t playlist_id, + std::uint32_t mode); +SetPlaybackMode set_playback_mode{}; +struct LastBinding { + std::uintptr_t manager{}; + std::uintptr_t queue{}; + std::uintptr_t node{}; + std::uint32_t playlist_id{std::numeric_limits::max()}; +}; +std::array last_binding_by_channel{}; // Protected by the binding-cache lock. +SRWLOCK binding_cache_lock = SRWLOCK_INIT; + +bool read_at(std::uintptr_t address, auto& value) noexcept { + return dingosdk::memory::peek(address, value); +} + +struct CriticalSectionGuard { + explicit CriticalSectionGuard(std::uintptr_t address) noexcept + : section(reinterpret_cast(address)) { EnterCriticalSection(section); } + ~CriticalSectionGuard() { LeaveCriticalSection(section); } + CriticalSectionGuard(const CriticalSectionGuard&) = delete; + CriticalSectionGuard& operator=(const CriticalSectionGuard&) = delete; + LPCRITICAL_SECTION section; +}; + +struct BindingCacheGuard { + BindingCacheGuard() noexcept { AcquireSRWLockExclusive(&binding_cache_lock); } + ~BindingCacheGuard() { ReleaseSRWLockExclusive(&binding_cache_lock); } + BindingCacheGuard(const BindingCacheGuard&) = delete; + BindingCacheGuard& operator=(const BindingCacheGuard&) = delete; +}; + +bool valid_range(std::uintptr_t value, std::size_t size) noexcept { + return value >= 0x10000 && value <= dingosdk::memory::highest_user_address - size; +} + +bool find_channel_node(std::uintptr_t manager, std::uint32_t channel, + std::uint32_t& playlist_id) noexcept { + std::uintptr_t buckets{}; + std::uint32_t capacity{}; + std::uintptr_t sentinel{}, node{}; + if (!read_at(manager + 0xc8, buckets) || !read_at(manager + 0xd0, capacity) || + !buckets || !capacity || capacity > 4096 || !valid_range(buckets, (capacity + 1ULL) * 8) || + !read_at(buckets + capacity * 8ULL, sentinel) || + !read_at(buckets + (channel % capacity) * 8ULL, node)) return false; + for (std::uint32_t seen = 0; node && node != sentinel && seen < 4096; ++seen) { + std::uint32_t key{}, id{}; + std::uintptr_t next{}; + if (!valid_range(node, 0x10) || !read_at(node, key) || !read_at(node + 4, id) || + !read_at(node + 8, next)) return false; + if (key == channel) { playlist_id = id; return true; } + node = next; + } + return false; +} + +std::uintptr_t find_playlist_node(std::uintptr_t manager, std::uint32_t playlist_id) noexcept { + std::uintptr_t buckets{}; + std::uint32_t capacity{}; + std::uintptr_t sentinel{}, node{}; + if (!read_at(manager + 0xa0, buckets) || !read_at(manager + 0xa8, capacity) || + !buckets || !capacity || capacity > 4096 || !valid_range(buckets, (capacity + 1ULL) * 8) || + !read_at(buckets + capacity * 8ULL, sentinel) || + !read_at(buckets + (playlist_id % capacity) * 8ULL, node)) return 0; + for (std::uint32_t seen = 0; node && node != sentinel && seen < 8192; ++seen) { + std::uint32_t id{}; + std::uintptr_t next{}; + if (!valid_range(node, 0x60) || !read_at(node, id) || !read_at(node + 0x58, next)) return 0; + if (id == playlist_id) return node; + node = next; + } + return 0; +} + +bool song_count(std::uintptr_t playlist_asset, std::uint32_t& count) noexcept { + std::uintptr_t entries{}; + std::uint32_t encoded_count{}; + if (!read_at(playlist_asset + 0x60, entries) || !entries || + !read_at(entries - sizeof(encoded_count), encoded_count)) return false; + count = encoded_count & 0x7fffffffu; + return count > 0 && count <= 8192 && valid_range(entries, static_cast(count) * 8); +} + +bool member_count(std::uintptr_t node, std::uintptr_t& begin, std::uint32_t& count) noexcept { + std::uintptr_t end{}; + if (!read_at(node + 0x10, begin) || !read_at(node + 0x18, end) || !begin || end < begin || + (end - begin) % 16 || (end - begin) / 16 > 8192) return false; + count = static_cast((end - begin) / 16); + return count > 0 && valid_range(begin, static_cast(count) * 16); +} + +bool current_guid(std::uintptr_t queue, std::uintptr_t playlist_asset, + bool& found, std::array& guid) noexcept { + found = false; + std::int32_t current_index{}; + std::uintptr_t candidates_begin{}, candidates_end{}, entries{}; + std::uint32_t count{}; + if (!read_at(queue + 0x70, current_index)) return false; + if (current_index < 0) return true; + if (!read_at(queue + 0x78, candidates_begin) || !read_at(queue + 0x80, candidates_end) || + !read_at(playlist_asset + 0x60, entries) || !song_count(playlist_asset, count) || + candidates_end < candidates_begin || (candidates_end - candidates_begin) % 4) return false; + if (static_cast(current_index) >= (candidates_end - candidates_begin) / 4) return false; + + std::uint32_t asset_index{}; + if (!read_at(candidates_begin + static_cast(current_index) * 4, asset_index) || asset_index >= count) + return false; + std::uintptr_t graph{}; + if (!read_at(entries + static_cast(asset_index) * 8, graph)) return false; + graph &= ~std::uintptr_t{4}; + if (!valid_range(graph, 0x20) || graph < 16) return false; + std::uint32_t flags{}; + if (!read_at(graph + 0x14, flags)) return false; + if (!(flags & 0x100)) return true; + if (!dingosdk::memory::peek_bytes(graph - 16, guid.data(), guid.size())) return false; + found = true; + return true; +} + +bool member_index(std::uintptr_t members, std::uint32_t member_count_value, + const std::array& guid, bool has_guid, + bool& found, std::uint32_t& position) noexcept { + found = false; + if (!has_guid) return true; + for (std::uint32_t i = 0; i < member_count_value; ++i) { + std::array candidate{}; + if (!dingosdk::memory::peek_bytes(members + static_cast(i) * 16, + candidate.data(), candidate.size())) return false; + std::uint32_t ignored{}; + if (music_playback::find_member_position(candidate.data(), 1, guid, ignored)) { + found = true; + position = i; + break; + } + } + return true; +} + +bool pending_song_request(std::uintptr_t queue, bool& pending) noexcept { + std::uintptr_t begin{}, end{}; + pending = false; + if (!read_at(queue + 0x98, begin) || !read_at(queue + 0xa0, end) || end < begin || + (end - begin) % 4 || (end - begin) / 4 > 4096) return false; + pending = begin != end; + return true; +} + +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; + 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; + + // 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::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{}; + if (!read_at(base + local_music::asset_manager, current_manager) || current_manager != manager || + !find_channel_node(manager, channel, channel_playlist)) return; + const auto node = find_playlist_node(manager, channel_playlist); + if (!node) 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; + + 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; + bool current_found{}; + std::uint32_t current_position{}; + if (!member_index(members, member_total, playing_guid, has_current_guid, current_found, current_position)) return; + + const auto plan = music_playback::plan_cursor(shuffle_enabled.load(std::memory_order_acquire), + current_mode, current_cursor, new_group, pending, current_found, current_position, member_total); + if (plan.set_mode) { + // 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); + } + if (plan.set_cursor) *reinterpret_cast(node + 0x30) = plan.cursor; + last_binding_by_channel[channel] = {manager, queue, node, channel_playlist}; +} +} + +bool initialize_music_playback(std::uintptr_t base, bool shuffle) noexcept { + if (!base || local_music::playback_select_next_contract.rva >= supported_build::game_image_size || + local_music::playback_mode_setter_contract.rva >= supported_build::game_image_size) return false; + std::array selector{}, setter{}; + if (!dingosdk::memory::read_bytes(base + local_music::playback_select_next_contract.rva, + selector.data(), selector.size()) || selector != local_music::playback_select_next_contract.bytes || + !dingosdk::memory::read_bytes(base + local_music::playback_mode_setter_contract.rva, + setter.data(), setter.size()) || setter != local_music::playback_mode_setter_contract.bytes) return false; + image_base.store(base, std::memory_order_release); + 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 = {}; + playback_available.store(false, std::memory_order_release); + return true; +} + +void activate_music_playback() noexcept { playback_available.store(true, std::memory_order_release); } +void deactivate_music_playback() noexcept { playback_available.store(false, std::memory_order_release); } +bool music_playback_available() noexcept { return playback_available.load(std::memory_order_acquire); } + +MusicSelectNext& music_select_next_original() noexcept { return select_next; } + +std::int32_t __fastcall music_select_next_hook(std::uintptr_t queue, std::uintptr_t playlist_asset) { + if (select_next) { + apply_policy(queue, playlist_asset); + return select_next(queue, playlist_asset); + } + return -1; +} + +bool music_shuffle_enabled() noexcept { return shuffle_enabled.load(std::memory_order_acquire); } + +void set_music_shuffle_enabled(bool enabled) noexcept { + shuffle_enabled.store(enabled, std::memory_order_release); +} +} // namespace dingosdk::profile_runtime diff --git a/Extension/Music/local_music_playback.h b/Extension/Music/local_music_playback.h new file mode 100644 index 00000000..ee59bcbb --- /dev/null +++ b/Extension/Music/local_music_playback.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace dingosdk::profile_runtime { +using MusicSelectNext = std::int32_t (__fastcall *)(std::uintptr_t queue, std::uintptr_t playlist_asset); + +// Validate the supported native selector/setter contracts and seed the lock-free +// preference read used by the audio selection hook. +bool initialize_music_playback(std::uintptr_t base, bool shuffle) noexcept; +void activate_music_playback() noexcept; +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); + +bool music_shuffle_enabled() noexcept; +void set_music_shuffle_enabled(bool enabled) noexcept; +} // namespace dingosdk::profile_runtime diff --git a/Extension/Music/local_music_playback_policy.h b/Extension/Music/local_music_playback_policy.h new file mode 100644 index 00000000..8ce23088 --- /dev/null +++ b/Extension/Music/local_music_playback_policy.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +namespace dingosdk::profile_runtime::music_playback { +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; + +struct CursorPlan { + std::uint32_t mode{}; + bool set_mode{}; + bool set_cursor{}; + std::uint32_t cursor{}; +}; + +constexpr bool find_member_position(const std::uint8_t* members, std::uint32_t member_count, + const std::array& guid, + std::uint32_t& position) noexcept { + if (!members) return false; + for (std::uint32_t i = 0; i < member_count; ++i) { + bool equal = true; + for (std::uint32_t byte = 0; byte < guid.size(); ++byte) + equal &= members[i * guid.size() + byte] == guid[byte]; + if (equal) { position = i; return true; } + } + 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 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, + std::uint32_t current_order_position, + std::uint32_t order_count) noexcept { + const auto desired_mode = shuffle ? native_default_mode : native_sequential_mode; + CursorPlan plan{desired_mode, current_mode != desired_mode, false, current_cursor}; + if (shuffle) return plan; + + if (explicit_request_pending) { + plan.set_cursor = current_cursor != cursor_sync_sentinel || plan.set_mode || new_group; + plan.cursor = cursor_sync_sentinel; + } else if (new_group) { + plan.set_cursor = true; + plan.cursor = 0; + } else if (plan.set_mode) { + plan.set_cursor = true; + plan.cursor = current_track_found && order_count + ? (current_order_position + 1 == order_count ? 0 : current_order_position + 1) + : 0; + } else if (current_cursor == cursor_sync_sentinel) { + // No queued manual song can synchronize the sentinel, so a fresh playlist + // starts at its first authored entry. + plan.set_cursor = true; + plan.cursor = 0; + } + return plan; +} +} // namespace dingosdk::profile_runtime::music_playback diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index b6c53181..903e0249 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -6,6 +6,7 @@ #include "Extension/Customization/local_player_card_runtime.h" #include "Extension/Music/local_music_ui.h" #include "Extension/Music/local_music_shelf.h" +#include "Extension/Music/local_music_playback.h" #include "Extension/News/local_news_runtime.h" #include "Extension/Objects/local_buildkit_labels.h" #include "Extension/Objects/local_buildkit_limits.h" @@ -535,6 +536,13 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline, else dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"music_model_construct_contract_mismatch\"}"); } + const bool playback_ready = initialize_music_playback(base, + local_preference("MusicShuffle").value_or(false)); + if (playback_ready) + hook(addr::local_music::playback_select_next_contract, + &music_select_next_hook, music_select_next_original()); + 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); hook(buildkit_grabber_settings_contract, &buildkit_grabber_settings_hook, buildkit_limits_runtime().grabber_settings); @@ -570,6 +578,7 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline, enable_attempted = true; if (hook_enable(target) != HookOk) throw std::runtime_error("Cannot enable local profile hook"); } + if (playback_ready) activate_music_playback(); initialize_placement_store(path); initialize_park_editor(path.parent_path()); set_park_mods_root(mods::engine_data_root()); @@ -585,6 +594,7 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline, return true; } catch (const std::exception& e) { s.active.store(false, std::memory_order_release); + deactivate_music_playback(); set_local_profile_event_provider(base, false); set_local_object_browser_provider(base, nullptr); for (auto* target : created) { diff --git a/Extension/UI/Overlay/skate_menu_settings.cpp b/Extension/UI/Overlay/skate_menu_settings.cpp index e63bdcc9..36479e13 100644 --- a/Extension/UI/Overlay/skate_menu_settings.cpp +++ b/Extension/UI/Overlay/skate_menu_settings.cpp @@ -1,6 +1,8 @@ #include "Engine/Core/Platform/launcher_support.h" #include "skate_menu_internal.h" #include "Engine/Core/Profiling/profiler.h" +#include "Extension/Music/local_music_playback.h" +#include "Extension/Profile/local_profile_runtime.h" #include #include @@ -8,6 +10,15 @@ // The SETTINGS and DEVELOPER pages. namespace dingosdk::overlay::menu { void ui_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { + begin_card(menu, "music-playback", "MUSIC PLAYBACK"); + bool shuffle = dingosdk::profile_runtime::music_shuffle_enabled(); + if (toggle_row(menu, "Shuffle playlists", "Off plays songs in playlist order. On shuffles.", shuffle, + dingosdk::profile_runtime::music_playback_available())) { + dingosdk::profile_runtime::set_music_shuffle_enabled(shuffle); + dingosdk::profile_runtime::set_local_preference("MusicShuffle", shuffle); + } + end_card(); + begin_card(menu, "on-screen", "ON SCREEN"); bool hidden = model.debug.game_ui_hidden; if (toggle_row(menu, "Hide game UI", "Keep the view clear for riding and captures.", hidden, diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index c6b73124..2f367e74 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -52,6 +52,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_include_directories(dingosdk_music_safety_tests PRIVATE "${PROJECT_SOURCE_DIR}") dingosdk_configure_target(dingosdk_music_safety_tests) add_test(NAME music_safety COMMAND dingosdk_music_safety_tests) + add_executable(dingosdk_music_playback_policy_tests Extension/Music/Test/local_music_playback_policy_tests.cpp) + target_include_directories(dingosdk_music_playback_policy_tests PRIVATE "${PROJECT_SOURCE_DIR}") + dingosdk_configure_target(dingosdk_music_playback_policy_tests) + add_test(NAME music_playback_policy COMMAND dingosdk_music_playback_policy_tests) add_executable(dingosdk_music_shelf_lifetime_tests Extension/Music/Test/local_music_shelf_lifetime_tests.cpp) target_include_directories(dingosdk_music_shelf_lifetime_tests PRIVATE "${PROJECT_SOURCE_DIR}") dingosdk_configure_target(dingosdk_music_shelf_lifetime_tests) diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index d8458a68..e9f4e7bc 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -142,6 +142,7 @@ add_library(dingosdk_runtime SHARED Extension/Objects/local_object_runtime.cpp Extension/Music/local_music_assets.cpp Extension/Music/local_music_safety.cpp + Extension/Music/local_music_playback.cpp Extension/Music/music_artwork.cpp Extension/Music/local_music_ui.cpp Extension/Music/local_music_shelf.cpp diff --git a/contrib/music-playback-order.md b/contrib/music-playback-order.md new file mode 100644 index 00000000..90d4975b --- /dev/null +++ b/contrib/music-playback-order.md @@ -0,0 +1,22 @@ +# Music playback order + +The music selector used a random candidate scan for both stock and mod playlists. The selector now resolves its queue channel to the registered native playlist and applies `PlaylistPlaybackMode::Sequential` to that native node when Shuffle is off. The registered native playlist already carries the published or authored GUID order, so this change reuses that membership and order. Queue channels and playlist IDs are looked up at runtime; neither is fixed in the hook. Only `MusicPlaylistType::SelectionBehavior` queues with a valid native channel/playlist binding are handled. + +Open **Settings → Interface → Music Playback → Shuffle playlists**. Shuffle defaults to off, so playback begins at the playlist's first entry and continues in playlist order. Turning it on restores the engine's default random selection. The value is stored as the local profile preference `ReSkate.MusicShuffle`. + +On an active playlist, turning Shuffle off seeds the sequential cursor from the current song's GUID so the next selection follows that song. A queued manual song request retains native GUID synchronization. A newly selected playlist starts at its first entry. The engine's existing per-update admission-latch reset remains responsible for allowing the following track; the hook does not clear that latch itself. + +The code targets the supported 2026-09-29 game build. Selector and mode-setter fingerprints gate installation. The selector hook returns the native signed candidate index (`-1` on failure) and forwards unchanged whenever the queue or native playlist mapping is not verified. + +## Verification + +Static/build checks: + +- Configure: `cmake --preset vs2022-x64 -DDINGOSDK_BUILD_LAUNCHER_TESTS=ON` +- Build: `cmake --build build/vs2022-x64 --config Release --parallel 8 --target dingosdk_runtime dingosdk_music_playback_policy_tests` +- Existing focused test executables: `cmake --build build/vs2022-x64 --config Release --parallel 8 --target dingosdk_mod_music_tests dingosdk_music_safety_tests dingosdk_music_shelf_lifetime_tests` +- Tests: `ctest --test-dir build/vs2022-x64 -C Release -R "^(music_shelf_lifetime|music_safety|mod_music|music_playback_policy)$" --output-on-failure` + +Configuration and both build commands completed successfully. The focused CTest selection passed 4/4 tests (`mod_music`, `music_safety`, `music_playback_policy`, and `music_shelf_lifetime`). + +Live acceptance remains pending. Check San Van Soundtrack starts with its first three published tracks; Your Mixtape follows its two authored tracks and wraps; Next and natural completion use the same order; manual song selection and Previous retain their expected behavior; toggling Shuffle on and off mid-playlist continues from the current song; the setting survives relaunch; and changing playlists or maps does not carry the prior playlist's cursor into the next one.