From 2e8bb57d9ed7aa99cd5c28e44142cd4aaf81c91c Mon Sep 17 00:00:00 2001 From: DeckardDetribine <116809774+DeckardDetribine@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:12:42 +0100 Subject: [PATCH] Music: show the stations the content cache defines, with their names and art The music menu listed only the playlists the game's own music assets declare. The licensed stations (San Van Soundtrack, 80's Mixtape, ...) live in the content cache's music chunk: a record with the station id (field 1), its tracks (repeated field 2, "Artist - Title") and a message with its display name and cdn:/ artwork (field 10, parts 10 and 11). Read them into Catalogs, merge their membership into the music catalogue (only songs the game registered, so no unknown id is referenced), and send the name and artwork with each playlist. The catalogue test pins two stations from the cache. Co-Authored-By: Claude Sonnet 5.5 --- Engine/Vfs/Test/content_catalogs_tests.cpp | 9 ++++ Engine/Vfs/content_catalogs.cpp | 25 ++++++++++ Engine/Vfs/content_catalogs.h | 9 ++++ Extension/Music/local_music_assets.cpp | 53 +++++++++++++++++++++- Extension/Music/local_music_assets.h | 2 + Extension/Music/local_music_ui.cpp | 11 +++-- Extension/Music/local_music_ui.h | 2 +- 7 files changed, 106 insertions(+), 5 deletions(-) diff --git a/Engine/Vfs/Test/content_catalogs_tests.cpp b/Engine/Vfs/Test/content_catalogs_tests.cpp index 2b1f62f..04596a7 100644 --- a/Engine/Vfs/Test/content_catalogs_tests.cpp +++ b/Engine/Vfs/Test/content_catalogs_tests.cpp @@ -62,6 +62,15 @@ int main(int argc, char** argv) { check(stadium != travel.access_points.end() && stadium->second == std::vector{"location_stadium"}, "stadium entrance offers only the first stadium"); check(travel.access_points.size() == 5, "five access points"); + check(catalogs.music_playlists.size() == 54, "54 music playlists"); + if (const auto station = catalogs.music_playlists.find("0_Licensed_All"); station != catalogs.music_playlists.end()) + check(station->second.name == "San Van Soundtrack" && station->second.tracks.size() == 107 && + station->second.tracks.front() == "2hollis - flash" && + station->second.artwork == "cdn:/0e63e2111f9088448ae261ab0f930a4a", "San Van Soundtrack station"); + else check(false, "San Van Soundtrack station"); + if (const auto ballroom = catalogs.music_playlists.find("1920sBallroom"); ballroom != catalogs.music_playlists.end()) + check(ballroom->second.name == "Ballroom 237" && ballroom->second.tracks.size() == 3, "Ballroom station"); + else check(false, "Ballroom station"); if (argc > 2) { Json dump{{"items", catalogs.items}, {"challenges", catalogs.challenges}, {"entitlements", Json::array()}}; for (const auto& id : catalogs.entitlements) dump["entitlements"].push_back(id); diff --git a/Engine/Vfs/content_catalogs.cpp b/Engine/Vfs/content_catalogs.cpp index d6bc99e..20b4f50 100644 --- a/Engine/Vfs/content_catalogs.cpp +++ b/Engine/Vfs/content_catalogs.cpp @@ -54,6 +54,30 @@ void add_item(Catalogs& out, const Fields& record) { out.items[key] = std::move(entry); } +// Music playlists: field 1 names the playlist, repeated field 2 lists tracks +// ("Artist - Title"), and field 10 is a message carrying the display name and +// artwork. Owned-item records put a varint in field 10, so requiring the byte +// wire separates the two. First record for an id wins. +void add_music_playlist(Catalogs& out, const std::string& id, const Fields& record) { + const auto content = message(record, 10); + if (!content) return; + const auto name = text(*content, 10); + if (!name) return; + std::vector tracks; + for (const auto& field : record) { + if (field.number != 2 || field.wire != Wire::bytes) continue; + const auto value = text(&field); + if (!value || value->find(" - ") == std::string::npos) continue; + tracks.push_back(*value); + } + if (tracks.size() < 2) return; + Catalogs::MusicPlaylistEntry entry; + entry.name = *name; + entry.artwork = text(*content, 11).value_or(""); + entry.tracks = std::move(tracks); + out.music_playlists.try_emplace(id, std::move(entry)); +} + // Grant lists: repeated `number` entries, each {1: kind, 2: id}. struct Grant { std::string kind, id; }; std::vector grants(const Fields& fields, std::uint32_t number) { @@ -221,6 +245,7 @@ Catalogs read_catalogs(const std::filesystem::path& folder) { if (!id) continue; if (text(*fields, 3) == "entitlement") entitlements.insert(*id); add_item(out, *fields); + add_music_playlist(out, *id, *fields); add_challenge(out, *id, *fields); add_object_category(out, *id, *fields); add_travel(out, *id, *fields); diff --git a/Engine/Vfs/content_catalogs.h b/Engine/Vfs/content_catalogs.h index d0bcef7..c4631c7 100644 --- a/Engine/Vfs/content_catalogs.h +++ b/Engine/Vfs/content_catalogs.h @@ -2,6 +2,7 @@ #include "Engine/Core/Json/json.h" #include #include +#include #include #include #include @@ -40,6 +41,14 @@ struct Catalogs { std::vector travel_locations; // Access point id (accesspoint_*) -> the location ids it offers. std::vector>> travel_access_points; + // Music playlist id -> {display name, artwork id, track ids} from the music + // chunk. A music record carries a field-10 message (10.10 name, 10.11 + // artwork) and repeated field-2 tracks; owned items use a varint field 10. + struct MusicPlaylistEntry { + std::string name, artwork; + std::vector tracks; + }; + std::map> music_playlists; bool reserved(const std::string& key) const; }; // Read once, on first use. Empty (available == false) when no pack is installed. diff --git a/Extension/Music/local_music_assets.cpp b/Extension/Music/local_music_assets.cpp index 876a103..2720fae 100644 --- a/Extension/Music/local_music_assets.cpp +++ b/Extension/Music/local_music_assets.cpp @@ -1,8 +1,12 @@ #include "Extension/Customization/local_customization_runtime.h" #include "local_music_assets.h" +#include "Engine/Core/Log/logging.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/local_music.h" +#include "Engine/Vfs/content_catalogs.h" #include "Extension/Profile/runtime_internal.h" +#include +#include namespace dingosdk::profile_runtime { // Snapshot the game's registered music assets, as the cosmetic catalog does @@ -102,6 +106,46 @@ bool read_music_catalog(MusicCatalog& result) { if (visited.size() != count || !read(base + addr::local_music::asset_manager, current) || current != manager || !read(manager + 0x78, current_buckets) || current_buckets != buckets || !read(manager + 0x84, current_count) || current_count != count) return false; + // The registered assets only declare the playlists the game ships offline + // (the Universal production library); the licensed stations live in the + // content-cache music chunk. Merge that membership, intersected with the + // songs actually registered, so no unregistered id can ever be referenced. + const auto native_playlists = playlists.size(); + const auto& cache = content_cache::catalogs(); + std::map> playlist_names, playlist_artwork; + std::size_t cache_playlists = 0, matched = 0, unmatched = 0, added = 0; + if (!cache.music_playlists.empty()) { + std::map> index; + for (std::size_t i = 0; i < snapshot.songs.size(); ++i) index.emplace(snapshot.songs[i].id, i); + for (const auto& [id, entry] : cache.music_playlists) { + ++cache_playlists; + auto& members = playlists[id]; + for (const auto& track : entry.tracks) { + const auto found = index.find(track); + if (found == index.end()) { ++unmatched; continue; } + ++matched; + auto& song = snapshot.songs[found->second]; + if (std::find(song.playlists.begin(), song.playlists.end(), id) != song.playlists.end()) + continue; + song.playlists.push_back(id); + members.push_back(song.id); + ++added; + } + if (!members.empty() && !entry.name.empty()) playlist_names[id] = entry.name; + if (!members.empty() && !entry.artwork.empty()) playlist_artwork[id] = entry.artwork; + } + for (auto it = playlists.begin(); it != playlists.end();) + it = it->second.empty() ? playlists.erase(it) : std::next(it); + } + { + std::ostringstream event; + event << "{\"event\":\"music_catalog_membership\",\"songs\":" << snapshot.songs.size() + << ",\"native_playlists\":" << native_playlists + << ",\"cache_playlists\":" << cache_playlists + << ",\"matched\":" << matched << ",\"unmatched\":" << unmatched + << ",\"added\":" << added << ",\"playlists\":" << playlists.size() << "}"; + dingosdk::logging::event(dingosdk::logging::Channel::music, event.str()); + } // Registry order is bucket order, not authored ordering. Use stable IDs for // deterministic UI order; do not claim this is the original service order. std::sort(snapshot.songs.begin(), snapshot.songs.end(), [](const auto& a, const auto& b) { @@ -109,7 +153,14 @@ bool read_music_catalog(MusicCatalog& result) { }); for (auto& [id, songs] : playlists) { std::sort(songs.begin(), songs.end()); - snapshot.playlists.push_back({id, std::move(songs)}); + MusicPlaylist row; + row.id = id; + row.songs = std::move(songs); + if (const auto found = playlist_names.find(id); found != playlist_names.end()) + row.name = found->second; + if (const auto found = playlist_artwork.find(id); found != playlist_artwork.end()) + row.artwork = found->second; + snapshot.playlists.push_back(std::move(row)); } result = std::move(snapshot); return true; diff --git a/Extension/Music/local_music_assets.h b/Extension/Music/local_music_assets.h index 16439f7..6ce81df 100644 --- a/Extension/Music/local_music_assets.h +++ b/Extension/Music/local_music_assets.h @@ -9,6 +9,8 @@ struct MusicSong { struct MusicPlaylist { std::string id; + std::string name; // display name from the content cache; empty for native groups + std::string artwork; // cdn:/ artwork id from the content cache; empty when unknown std::vector songs; }; diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index 7d673b5..b21ed24 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -8,6 +8,7 @@ #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Build/20260929/local_music.h" +#include "Engine/Game/World/location_travel.h" #include "Extension/Profile/runtime_internal.h" namespace dingosdk::profile_runtime { @@ -106,14 +107,18 @@ bool music_ui_catalog_valid(const MusicCatalog& catalog, const std::string& favo } std::string music_ui_wire(std::string_view id, std::string_view artist, std::string_view title, - const std::vector* members) { + const std::vector* members, std::string_view name, std::string_view artwork) { std::string body, presentation, framed; cosmetic_wire_string(body, 1, id); if (members) { for (const auto& song : *members) cosmetic_wire_string(body, 2, song); // A local shelf choice for real authored groups, NOT recovered AMP classification. cosmetic_wire_number(body, 3, 1); - cosmetic_wire_string(presentation, 10, id); + // The catalogue's display name when known; the raw id otherwise. + cosmetic_wire_string(presentation, 10, name.empty() ? id : name); + // Artwork: the catalogue's cdn:/ id, resolved to the CDN rendition. + if (const auto url = travel_artwork_url(artwork); !url.empty()) + cosmetic_wire_string(presentation, 11, url); cosmetic_wire_number(presentation, 12, 0); } else { cosmetic_wire_string(presentation, 10, artist); @@ -236,7 +241,7 @@ void update_music_catalog() { ui.functions.allocator[1] = arena; MusicUiMessages playlists, songs; playlists.items.reserve(catalog.playlists.size()); songs.items.reserve(catalog.songs.size()); - for (const auto& playlist : catalog.playlists) playlists.items.push_back(music_ui_message(music_ui_wire(playlist.id, {}, {}, &playlist.songs), true)); + for (const auto& playlist : catalog.playlists) playlists.items.push_back(music_ui_message(music_ui_wire(playlist.id, {}, {}, &playlist.songs, playlist.name, playlist.artwork), true)); for (const auto& song : catalog.songs) songs.items.push_back(music_ui_message(music_ui_wire(song.id, song.artist, song.title, nullptr), false)); auto& model = game::native_data().models; auto& field = game::native_data().models.field; if (!music_ui_current(*pending)) { retry = false; return; } diff --git a/Extension/Music/local_music_ui.h b/Extension/Music/local_music_ui.h index 787cfdc..f5fa19e 100644 --- a/Extension/Music/local_music_ui.h +++ b/Extension/Music/local_music_ui.h @@ -61,7 +61,7 @@ bool music_ui_text(std::string_view value, std::size_t limit = 255); bool music_ui_catalog_valid(const MusicCatalog& catalog, const std::string& favorites); std::string music_ui_wire(std::string_view id, std::string_view artist, std::string_view title, - const std::vector* members); + const std::vector* members, std::string_view name = {}, std::string_view artwork = {}); CosmeticShared music_ui_message(const std::string& wire, bool playlist);