Merge pull request #36 from DeckardDetribine/pr/music-mod-playlists

Music: let a mod name a playlist for its songs (reskate-music.json)
This commit is contained in:
zeex64 authored and GitHub committed 2026-10-04 13:35:06 -05:00
commit fb448059cc
13 files changed
+320 -7

No files matched your search

@@ -62,6 +62,20 @@ int main(int argc, char** argv) {
check(stadium != travel.access_points.end() && stadium->second == std::vector<std::string>{"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 (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);
+73
View File
@@ -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 <iostream>
#include <stdexcept>
#include <string>
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:<mod>:<name>");
check(lists.at(0).name == "Kevin MacLeod", "name is the shown name");
check(lists.at(0).songs == std::vector<std::string>{"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<std::string>{"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;
}
+36
View File
@@ -54,6 +54,40 @@ 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<std::string> 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));
}
// 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<Grant> grants(const Fields& fields, std::uint32_t number) {
@@ -221,6 +255,8 @@ 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_music_song(out, *id, *fields);
add_challenge(out, *id, *fields);
add_object_category(out, *id, *fields);
add_travel(out, *id, *fields);
+12
View File
@@ -2,6 +2,7 @@
#include "Engine/Core/Json/json.h"
#include <cstdint>
#include <filesystem>
#include <map>
#include <set>
#include <string>
#include <vector>
@@ -40,6 +41,17 @@ struct Catalogs {
std::vector<TravelLocation> travel_locations;
// Access point id (accesspoint_*) -> the location ids it offers.
std::vector<std::pair<std::string, std::vector<std::string>>> 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<std::string> tracks;
};
std::map<std::string, MusicPlaylistEntry, std::less<>> 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<std::string, std::string, std::less<>> music_song_artwork;
bool reserved(const std::string& key) const;
};
// Read once, on first use. Empty (available == false) when no pack is installed.
+35
View File
@@ -0,0 +1,35 @@
#include "Engine/Vfs/mod_music.h"
#include "Engine/Core/Json/json.h"
#include <algorithm>
#include <stdexcept>
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<MusicPlaylist> parse_music_playlists(const std::string& mod, const std::string& json) {
std::vector<MusicPlaylist> result;
const auto root = Json::parse(json);
if (!root.contains("schema") || root.at("schema").get<int>() != 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
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <string>
#include <vector>
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:<mod name>:<playlist name>"
std::string name; // shown in the music screen
std::vector<std::string> 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<MusicPlaylist> parse_music_playlists(const std::string& mod, const std::string& json);
} // namespace dingosdk::mods
+91 -1
View File
@@ -1,8 +1,15 @@
#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 "Engine/Vfs/mod_catalog.h"
#include "Engine/Vfs/mod_music.h"
#include "Extension/Profile/runtime_internal.h"
#include <fstream>
#include <iterator>
#include <sstream>
namespace dingosdk::profile_runtime {
// Snapshot the game's registered music assets, as the cosmetic catalog does
@@ -23,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<mods::MusicPlaylist> mod_music_playlists() {
std::vector<mods::MusicPlaylist> 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<char>(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();
@@ -102,6 +130,61 @@ 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<std::string, std::string, std::less<>> playlist_names, playlist_artwork;
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;
++song_artwork;
}
std::map<std::string, std::size_t, std::less<>> 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<std::string>& 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;
}
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\":" << 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
// 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 +192,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;
+7
View File
@@ -1,14 +1,18 @@
#pragma once
#include "Engine/Vfs/mod_music.h"
#include "Extension/Profile/runtime_internal.h"
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<std::string> playlists;
};
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<std::string> songs;
};
@@ -25,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<mods::MusicPlaylist> mod_music_playlists();
bool read_music_catalog(MusicCatalog& result);
}
+13 -5
View File
@@ -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,20 +107,27 @@ 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<std::string>* members) {
const std::vector<std::string>* 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);
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;
}
@@ -236,8 +244,8 @@ 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& song : catalog.songs) songs.items.push_back(music_ui_message(music_ui_wire(song.id, song.artist, song.title, nullptr), false));
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, {}, song.artwork), false));
auto& model = game::native_data().models; auto& field = game::native_data().models.field;
if (!music_ui_current(*pending)) { retry = false; return; }
{
+1 -1
View File
@@ -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<std::string>* members);
const std::vector<std::string>* members, std::string_view name = {}, std::string_view artwork = {});
CosmeticShared music_ui_message(const std::string& wire, bool playlist);
+10
View File
@@ -75,6 +75,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.
+4
View File
@@ -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}")
+1
View File
@@ -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)