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);