mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-28 23:06:54 +01:00
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:
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user