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 1/3] 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 2b1f62ff..04596a7f 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 d6bc99e3..20b4f50d 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 d0bcef71..c4631c70 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 876a103f..2720faea 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 16439f76..6ce81df8 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 7d673b5d..b21ed24f 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 787cfdc6..f5fa19ed 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); From 855e84993ad8c16574c49b329d87430070b15a76 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 2/3] Music: give songs their cover art from the content cache Song records in the content cache carry a field-10 message (10 artist, 11 title, 12 cdn:/ cover art). Read the art per song and send it as the song's presentation field 12, resolved to the CDN rendition like a station's art, so the game's music screen and now-playing toast show covers instead of a black square. The catalogue test pins one song's art. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- Engine/Vfs/Test/content_catalogs_tests.cpp | 5 +++++ Engine/Vfs/content_catalogs.cpp | 11 +++++++++++ Engine/Vfs/content_catalogs.h | 3 +++ Extension/Music/local_music_assets.cpp | 8 +++++++- Extension/Music/local_music_assets.h | 1 + Extension/Music/local_music_ui.cpp | 7 +++++-- 6 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Engine/Vfs/Test/content_catalogs_tests.cpp b/Engine/Vfs/Test/content_catalogs_tests.cpp index 04596a7f..798c0667 100644 --- a/Engine/Vfs/Test/content_catalogs_tests.cpp +++ b/Engine/Vfs/Test/content_catalogs_tests.cpp @@ -71,6 +71,11 @@ int main(int argc, char** argv) { 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 (const auto art = catalogs.music_song_artwork.find("Freddie Gibbs, The Alchemist, Anderson .Paak - Ensalada"); + art != catalogs.music_song_artwork.end()) + check(art->second == "cdn:/ad89945108fd5d7d6fc94ccb33c0f500", "a song's cover art"); + else check(false, "a song's cover art"); + check(catalogs.music_song_artwork.size() >= 100, "the licensed songs have cover art"); 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 20b4f50d..0000e9bd 100644 --- a/Engine/Vfs/content_catalogs.cpp +++ b/Engine/Vfs/content_catalogs.cpp @@ -78,6 +78,16 @@ void add_music_playlist(Catalogs& out, const std::string& id, const Fields& reco out.music_playlists.try_emplace(id, std::move(entry)); } +// Songs: field 1 is "Artist - Title" and field 10 a message with the artist (10), title (11) and +// cdn:/ cover art (12). First record for an id wins. +void add_music_song(Catalogs& out, const std::string& id, const Fields& record) { + if (id.find(" - ") == std::string::npos) return; + const auto content = message(record, 10); + if (!content || !text(*content, 10) || !text(*content, 11)) return; + if (const auto artwork = text(*content, 12); artwork && artwork->starts_with("cdn:/")) + out.music_song_artwork.try_emplace(id, *artwork); +} + // 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) { @@ -246,6 +256,7 @@ Catalogs read_catalogs(const std::filesystem::path& folder) { if (text(*fields, 3) == "entitlement") entitlements.insert(*id); add_item(out, *fields); add_music_playlist(out, *id, *fields); + add_music_song(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 c4631c70..71f8b850 100644 --- a/Engine/Vfs/content_catalogs.h +++ b/Engine/Vfs/content_catalogs.h @@ -49,6 +49,9 @@ struct Catalogs { std::vector tracks; }; std::map> music_playlists; + // Song ("Artist - Title") -> its cdn:/ cover art: a song record carries a field-10 message + // (10.10 artist, 10.11 title, 10.12 artwork). + std::map> music_song_artwork; 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 2720faea..1f767d84 100644 --- a/Extension/Music/local_music_assets.cpp +++ b/Extension/Music/local_music_assets.cpp @@ -114,6 +114,12 @@ bool read_music_catalog(MusicCatalog& result) { const auto& cache = content_cache::catalogs(); std::map> playlist_names, playlist_artwork; std::size_t cache_playlists = 0, matched = 0, unmatched = 0, added = 0; + std::size_t artwork = 0; + for (auto& song : snapshot.songs) + if (const auto found = cache.music_song_artwork.find(song.id); found != cache.music_song_artwork.end()) { + song.artwork = found->second; + ++artwork; + } 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); @@ -143,7 +149,7 @@ bool read_music_catalog(MusicCatalog& result) { << ",\"native_playlists\":" << native_playlists << ",\"cache_playlists\":" << cache_playlists << ",\"matched\":" << matched << ",\"unmatched\":" << unmatched - << ",\"added\":" << added << ",\"playlists\":" << playlists.size() << "}"; + << ",\"added\":" << added << ",\"artwork\":" << artwork << ",\"playlists\":" << playlists.size() << "}"; dingosdk::logging::event(dingosdk::logging::Channel::music, event.str()); } // Registry order is bucket order, not authored ordering. Use stable IDs for diff --git a/Extension/Music/local_music_assets.h b/Extension/Music/local_music_assets.h index 6ce81df8..ab19ab88 100644 --- a/Extension/Music/local_music_assets.h +++ b/Extension/Music/local_music_assets.h @@ -4,6 +4,7 @@ namespace dingosdk::profile_runtime { struct MusicSong { std::string id, artist, title; + std::string artwork; // cdn:/ cover art from the content cache; empty when unknown std::vector playlists; }; diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index b21ed24f..b249242d 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -123,8 +123,11 @@ std::string music_ui_wire(std::string_view id, std::string_view artist, std::str } else { cosmetic_wire_string(presentation, 10, artist); cosmetic_wire_string(presentation, 11, title); + // Cover art: the content cache song record's cdn:/ id (its field 10.12), resolved like a playlist's. + if (const auto url = travel_artwork_url(artwork); !url.empty()) + cosmetic_wire_string(presentation, 12, url); } - cosmetic_wire_string(body, 10, presentation); // Artwork intentionally absent. + cosmetic_wire_string(body, 10, presentation); cosmetic_varint(framed, body.size()); framed += body; return framed; } @@ -242,7 +245,7 @@ void update_music_catalog() { 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, 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)); + for (const auto& song : catalog.songs) songs.items.push_back(music_ui_message(music_ui_wire(song.id, song.artist, song.title, nullptr, {}, song.artwork), false)); auto& model = game::native_data().models; auto& field = game::native_data().models.field; if (!music_ui_current(*pending)) { retry = false; return; } { From c7f54b76805e9c9ffa97f2d86432b96d044fc7b1 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 3/3] Music: let a mod name a playlist for its songs A mod that adds songs can ship reskate-music.json ({"schema":1,"playlists":[{"name","songs":["artist - title", ...]}]}). Each playlist is merged into the game's music screen the way a content-cache station is, under the id "mod::", with its songs matched against the songs the game has registered; entries that match nothing are ignored and a playlist left empty is dropped. A file of the wrong shape is logged and skipped. The file is read by parse_music_playlists in Engine/Vfs/mod_music, which a new test covers (ids, which entries survive, the limits, and that a malformed document is refused). The format is described in the README. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- Engine/Vfs/Test/mod_music_tests.cpp | 73 ++++++++++++++++++++++ Engine/Vfs/mod_music.cpp | 35 +++++++++++ Engine/Vfs/mod_music.h | 23 +++++++ Extension/Music/local_music_assets.cpp | 83 ++++++++++++++++++-------- Extension/Music/local_music_assets.h | 4 ++ README.md | 10 ++++ cmake/Apps.cmake | 4 ++ cmake/Core.cmake | 1 + 8 files changed, 208 insertions(+), 25 deletions(-) create mode 100644 Engine/Vfs/Test/mod_music_tests.cpp create mode 100644 Engine/Vfs/mod_music.cpp create mode 100644 Engine/Vfs/mod_music.h diff --git a/Engine/Vfs/Test/mod_music_tests.cpp b/Engine/Vfs/Test/mod_music_tests.cpp new file mode 100644 index 00000000..d5b99910 --- /dev/null +++ b/Engine/Vfs/Test/mod_music_tests.cpp @@ -0,0 +1,73 @@ +// Checks how a mod's reskate-music.json is read: the playlist ids and names, which song entries are +// kept, the limits, and that a document of the wrong shape is refused rather than half-read. +#include "Engine/Vfs/mod_music.h" +#include +#include +#include + +namespace { +int failures = 0; +void check(bool condition, const std::string& message) { + if (!condition) { std::cerr << "FAIL: " << message << '\n'; ++failures; } +} + +using dingosdk::mods::parse_music_playlists; + +bool refused(const std::string& json) { + try { parse_music_playlists("mod", json); } catch (const std::exception&) { return true; } + return false; +} + +std::string playlist_of(const std::string& songs) { + return R"({"schema":1,"playlists":[{"name":"Mix","songs":)" + songs + "}]}"; +} +} + +int main() try { + { + const auto lists = parse_music_playlists("KevinMacLeod", R"({ + "schema": 1, + "playlists": [ + {"name": "Kevin MacLeod", "songs": ["Kevin MacLeod - Cipher", "Kevin MacLeod - Sneaky Snitch"]}, + {"name": "Second", "songs": []} + ]})"); + check(lists.size() == 2, "both playlists are read"); + check(lists.at(0).id == "mod:KevinMacLeod:Kevin MacLeod", "id is mod::"); + check(lists.at(0).name == "Kevin MacLeod", "name is the shown name"); + check(lists.at(0).songs == std::vector{"Kevin MacLeod - Cipher", "Kevin MacLeod - Sneaky Snitch"}, + "songs keep their order and spelling"); + check(lists.at(1).songs.empty(), "an empty playlist is kept (the catalog drops it when nothing matches)"); + } + { + const auto lists = parse_music_playlists("mod", playlist_of( + R"(["A - One", "", 7, null, "B - Tw\to", ")" + std::string(256, 'x') + R"(", "C - Three"])")); + check(lists.at(0).songs == std::vector{"A - One", "C - Three"}, + "empty, non-string, control-character and over-long entries are dropped, the rest kept"); + } + { + std::string songs = "["; + for (int i = 0; i < 1100; ++i) songs += (i ? "," : "") + std::string("\"A - ") + std::to_string(i) + "\""; + const auto lists = parse_music_playlists("mod", playlist_of(songs + "]")); + check(lists.at(0).songs.size() == 1024, "at most 1024 songs per playlist"); + + std::string many = R"({"schema":1,"playlists":[)"; + for (int i = 0; i < 70; ++i) many += (i ? "," : "") + std::string(R"({"name":"P)") + std::to_string(i) + R"(","songs":[]})"; + check(parse_music_playlists("mod", many + "]}").size() == 64, "at most 64 playlists"); + } + check(parse_music_playlists("mod", R"({"schema":1,"playlists":[]})").empty(), "no playlists is fine"); + check(refused(R"({"schema":2,"playlists":[]})"), "another schema version is refused"); + check(refused(R"({"playlists":[]})"), "a missing schema is refused"); + check(refused(R"({"schema":1})"), "missing playlists is refused"); + check(refused(R"({"schema":1,"playlists":{}})"), "playlists that is not an array is refused"); + check(refused(R"({"schema":1,"playlists":[{"songs":[]}]})"), "a playlist without a name is refused"); + check(refused(R"({"schema":1,"playlists":[{"name":"","songs":[]}]})"), "an empty name is refused"); + check(refused(R"({"schema":1,"playlists":[{"name":"X"}]})"), "a playlist without songs is refused"); + check(refused(R"({"schema":1,"playlists":[{"name":"X","songs":"A - B"}]})"), "songs that is not an array is refused"); + check(refused("not json"), "text that is not JSON is refused"); + + if (failures == 0) std::cout << "mod music tests passed\n"; + return failures == 0 ? 0 : 1; +} catch (const std::exception& error) { + std::cerr << "FAIL: uncaught: " << error.what() << '\n'; + return 1; +} diff --git a/Engine/Vfs/mod_music.cpp b/Engine/Vfs/mod_music.cpp new file mode 100644 index 00000000..18a6c4bf --- /dev/null +++ b/Engine/Vfs/mod_music.cpp @@ -0,0 +1,35 @@ +#include "Engine/Vfs/mod_music.h" +#include "Engine/Core/Json/json.h" +#include +#include + +namespace dingosdk::mods { +namespace { +bool text_ok(const std::string& value) { + return !value.empty() && value.size() <= 255 && + std::none_of(value.begin(), value.end(), [](unsigned char c) { return c < 32 || c == 127; }); +} +} + +std::vector parse_music_playlists(const std::string& mod, const std::string& json) { + std::vector result; + const auto root = Json::parse(json); + if (!root.contains("schema") || root.at("schema").get() != 1 || !root.contains("playlists") || + !root.at("playlists").is_array()) + throw std::runtime_error("expected schema 1 with a playlists array"); + const auto& list = root.at("playlists"); + for (std::size_t i = 0; i < list.size() && i < 64; ++i) { + const auto& entry = list.at(i); + if (!entry.contains("name") || !entry.at("name").is_string() || !text_ok(entry.at("name").string()) || + !entry.contains("songs") || !entry.at("songs").is_array()) + throw std::runtime_error("a playlist needs a name and a songs array"); + MusicPlaylist playlist{"mod:" + mod + ":" + entry.at("name").string(), entry.at("name").string(), {}}; + const auto& songs = entry.at("songs"); + for (std::size_t s = 0; s < songs.size() && s < 1024; ++s) + if (songs.at(s).is_string() && text_ok(songs.at(s).string())) playlist.songs.push_back(songs.at(s).string()); + if (text_ok(playlist.id)) result.push_back(std::move(playlist)); + } + return result; +} + +} // namespace dingosdk::mods diff --git a/Engine/Vfs/mod_music.h b/Engine/Vfs/mod_music.h new file mode 100644 index 00000000..3f93941e --- /dev/null +++ b/Engine/Vfs/mod_music.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +namespace dingosdk::mods { + +// A playlist a mod declares for the game's music screen. `songs` are song ids, the +// "artist - title" the game registers a song under. +struct MusicPlaylist { + std::string id; // "mod::" + std::string name; // shown in the music screen + std::vector songs; +}; + +// Reads a mod's reskate-music.json: +// {"schema":1,"playlists":[{"name":"...","songs":["artist - title", ...]}]} +// Text must be non-empty, at most 255 bytes and free of control characters; a song entry that is +// not such text is dropped, as are playlists beyond 64 and songs beyond 1024 per playlist. +// Throws std::exception on a document that is not this shape, so the caller can skip the file. +std::vector parse_music_playlists(const std::string& mod, const std::string& json); + +} // namespace dingosdk::mods diff --git a/Extension/Music/local_music_assets.cpp b/Extension/Music/local_music_assets.cpp index 1f767d84..84d2ab5c 100644 --- a/Extension/Music/local_music_assets.cpp +++ b/Extension/Music/local_music_assets.cpp @@ -4,7 +4,10 @@ #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/local_music.h" #include "Engine/Vfs/content_catalogs.h" +#include "Engine/Vfs/mod_catalog.h" +#include "Engine/Vfs/mod_music.h" #include "Extension/Profile/runtime_internal.h" +#include #include #include @@ -27,6 +30,27 @@ bool music_asset_type(std::uintptr_t asset, std::uintptr_t vtable_rva) { return asset && read(asset, vtable) && vtable == local_runtime().base + vtable_rva; } +// reskate-music.json in each enabled mod: {"schema":1,"playlists":[{"name":..,"songs":["artist - title",..]}]}. +// Read on every call: it only runs while the music page waits for its catalog. A bad +// file is logged and skipped; its songs still play from the master playlist. +std::vector mod_music_playlists() { + std::vector result; + for (const auto& mod : mods::catalog().mods) { + const auto path = mod.directory / L"reskate-music.json"; + std::error_code error; + if (!std::filesystem::is_regular_file(path, error)) continue; + try { + std::ifstream in(path, std::ios::binary); + auto playlists = mods::parse_music_playlists(mod.name, std::string((std::istreambuf_iterator(in)), {})); + std::move(playlists.begin(), playlists.end(), std::back_inserter(result)); + } catch (const std::exception& failure) { + dingosdk::logging::event(dingosdk::logging::Channel::music, + dingosdk::Json{{"event", "mod_music_playlists_skipped"}, {"mod", mod.name}, {"reason", failure.what()}}.dump().c_str()); + } + } + return result; +} + bool read_music_catalog(MusicCatalog& result) { const auto base = local_runtime().base; auto& native = music_asset_functions(); @@ -113,43 +137,52 @@ bool read_music_catalog(MusicCatalog& result) { 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; - std::size_t artwork = 0; + std::size_t cache_playlists = 0, mod_playlists = 0, matched = 0, unmatched = 0, added = 0; + std::size_t song_artwork = 0; for (auto& song : snapshot.songs) if (const auto found = cache.music_song_artwork.find(song.id); found != cache.music_song_artwork.end()) { song.artwork = found->second; - ++artwork; + ++song_artwork; } - 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; + std::map> index; + for (std::size_t i = 0; i < snapshot.songs.size(); ++i) index.emplace(snapshot.songs[i].id, i); + const auto merge = [&](const std::string& id, const std::string& name, const std::string& artwork, + const std::vector& tracks) { + auto& members = playlists[id]; + for (const auto& track : 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; } - for (auto it = playlists.begin(); it != playlists.end();) - it = it->second.empty() ? playlists.erase(it) : std::next(it); + if (!members.empty() && !name.empty()) playlist_names[id] = name; + if (!members.empty() && !artwork.empty()) playlist_artwork[id] = artwork; + }; + for (const auto& [id, entry] : cache.music_playlists) { + ++cache_playlists; + merge(id, entry.name, entry.artwork, entry.tracks); } + // Playlists music mods declare in reskate-music.json (ReSkateMusicPacker writes it): + // a name and the ids of songs the mod adds, which carry no station tag of their own. + for (const auto& playlist : mod_music_playlists()) { + ++mod_playlists; + merge(playlist.id, playlist.name, {}, playlist.songs); + } + 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 + << ",\"mod_playlists\":" << mod_playlists << ",\"matched\":" << matched << ",\"unmatched\":" << unmatched - << ",\"added\":" << added << ",\"artwork\":" << artwork << ",\"playlists\":" << playlists.size() << "}"; + << ",\"added\":" << added << ",\"artwork\":" << song_artwork << ",\"playlists\":" << playlists.size() << "}"; dingosdk::logging::event(dingosdk::logging::Channel::music, event.str()); } // Registry order is bucket order, not authored ordering. Use stable IDs for diff --git a/Extension/Music/local_music_assets.h b/Extension/Music/local_music_assets.h index ab19ab88..85352478 100644 --- a/Extension/Music/local_music_assets.h +++ b/Extension/Music/local_music_assets.h @@ -1,4 +1,5 @@ #pragma once +#include "Engine/Vfs/mod_music.h" #include "Extension/Profile/runtime_internal.h" namespace dingosdk::profile_runtime { @@ -28,5 +29,8 @@ MusicAssetFunctions& music_asset_functions(); bool music_asset_type(std::uintptr_t asset, std::uintptr_t vtable_rva); +// Playlists the enabled mods declare in reskate-music.json (see Engine/Vfs/mod_music.h). +std::vector mod_music_playlists(); + bool read_music_catalog(MusicCatalog& result); } diff --git a/README.md b/README.md index a092aea7..1483c21d 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,16 @@ The menu and console keys can be changed in the launcher's Settings. - In game, the **MODS** tab of the ReSkate menu (**Insert**) turns mods on and off and applies the changes. - Mods are checked against the game build they were made for. Outdated mods, or mods that can't be merged cleanly, are left out with a message naming them, and the rest still load. +- A mod that adds songs can give them their own playlist in the game's music screen with a + `reskate-music.json` in its folder: + + ```json + {"schema": 1, "playlists": [{"name": "My Playlist", "songs": ["Artist - Title", "Artist - Other Title"]}]} + ``` + + Each entry is the song's artist and title exactly as the mod registers them, joined by ` - `. Entries that + match no song are ignored, and a playlist with no matching songs is not shown. A file that does not follow + this shape is skipped and logged. The songs themselves still have to be added by the mod. Only install mods you trust. Mods change game data, and custom scripts can run code. diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index be7f09f2..c951698d 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -44,6 +44,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) add_executable(dingosdk_mod_scoring_tests Engine/Vfs/Test/mod_scoring_tests.cpp) target_link_libraries(dingosdk_mod_scoring_tests PRIVATE dingosdk_mods) add_test(NAME mod_scoring COMMAND dingosdk_mod_scoring_tests "${DINGOSDK_TEST_GAME_ROOT}") + add_executable(dingosdk_mod_music_tests Engine/Vfs/Test/mod_music_tests.cpp) + target_link_libraries(dingosdk_mod_music_tests PRIVATE dingosdk_mods) + target_include_directories(dingosdk_mod_music_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME mod_music COMMAND dingosdk_mod_music_tests) add_executable(dingosdk_mod_merge_unshift_tests Engine/Vfs/Test/mod_merge_unshift_tests.cpp) target_link_libraries(dingosdk_mod_merge_unshift_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_merge_unshift_tests PRIVATE "${PROJECT_SOURCE_DIR}") diff --git a/cmake/Core.cmake b/cmake/Core.cmake index c3143fbc..e7f6d4f8 100644 --- a/cmake/Core.cmake +++ b/cmake/Core.cmake @@ -107,6 +107,7 @@ if(WIN32) Engine/Vfs/mod_merge_overrides.cpp Engine/Vfs/mod_merge_grid.cpp Engine/Vfs/mod_merge_load_screens.cpp + Engine/Vfs/mod_music.cpp Engine/Vfs/mod_scoring.cpp) target_link_libraries(dingosdk_mods PUBLIC dingosdk_json dingosdk_mod_list PRIVATE dingosdk_game_archives dingosdk_native_db dingosdk_frostbite shell32 bcrypt)