Merge pull request #48 from DeckardDetribine/pr/mod-music-artwork

Music: custom playlist and song cover artwork for mods
This commit is contained in:
zeex64 authored and GitHub committed 2026-10-05 16:57:56 -05:00
commit f6e191cfbd
13 files changed
+920 -9

No files matched your search

+14
View File
@@ -55,6 +55,20 @@ int main() try {
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");
{
const auto lists = parse_music_playlists("mod", R"({"schema":1,"playlists":[
{"name":"Mix","artwork":"artwork/playlist.png","songs":["A - One","B - Two"]}],
"song_artwork":{"A - One":"artwork/track.png","B - Two":"../outside.png","Unlisted":"artwork/other.png"}})");
check(lists[0].artwork == "artwork/playlist.png", "playlist cover is read");
check(lists[0].song_artwork.size() == 1 && lists[0].song_artwork.at("A - One") == "artwork/track.png",
"song covers are limited to members and safe paths");
for (const auto* path : {"../cover.png", "/cover.png", "C:/cover.png", "a\\cover.png", "a/../cover.png",
"a//cover.png", "a/./cover.png", "cover.png:stream", "a%2fb.png", "cover.jpg"})
check(!dingosdk::mods::music_artwork_path(path), std::string("unsafe artwork path: ") + path);
const auto bad = parse_music_playlists("mod", R"({"schema":1,"playlists":[
{"name":"Mix","artwork":42,"songs":["A - One"]}],"song_artwork":[]})");
check(bad[0].artwork.empty() && bad[0].songs.size() == 1, "bad optional artwork does not discard music");
}
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");
+25
View File
@@ -11,6 +11,20 @@ bool text_ok(const std::string& value) {
}
}
bool music_artwork_path(const std::string& path) {
if (!text_ok(path) || path.front() == '/' || path.find_first_of("\\:%?#") != std::string::npos ||
!path.ends_with(".png")) return false;
std::size_t start = 0;
while (start < path.size()) {
const auto end = path.find('/', start);
const auto part = path.substr(start, end == std::string::npos ? end : end - start);
if (part.empty() || part == "." || part == ".." || part.back() == ' ' || part.back() == '.') return false;
if (end == std::string::npos) break;
start = end + 1;
}
return true;
}
std::vector<MusicPlaylist> parse_music_playlists(const std::string& mod, const std::string& json) {
std::vector<MusicPlaylist> result;
const auto root = Json::parse(json);
@@ -18,15 +32,26 @@ std::vector<MusicPlaylist> parse_music_playlists(const std::string& mod, const s
!root.at("playlists").is_array())
throw std::runtime_error("expected schema 1 with a playlists array");
const auto& list = root.at("playlists");
std::map<std::string, std::string> covers;
if (root.contains("song_artwork") && root.at("song_artwork").is_object()) {
for (const auto& [id, value] : root.at("song_artwork").items()) {
if (covers.size() >= 4096) break;
if (text_ok(id) && value.is_string() && music_artwork_path(value.string())) covers.emplace(id, value.string());
}
}
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(), {}};
if (entry.contains("artwork") && entry.at("artwork").is_string() && music_artwork_path(entry.at("artwork").string()))
playlist.artwork = entry.at("artwork").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());
for (const auto& id : playlist.songs)
if (const auto found = covers.find(id); found != covers.end()) playlist.song_artwork.emplace(*found);
if (text_ok(playlist.id)) result.push_back(std::move(playlist));
}
return result;
+8
View File
@@ -1,6 +1,7 @@
#pragma once
#include <string>
#include <map>
#include <vector>
namespace dingosdk::mods {
@@ -11,13 +12,20 @@ struct MusicPlaylist {
std::string id; // "mod:<mod name>:<playlist name>"
std::string name; // shown in the music screen
std::vector<std::string> songs;
std::string artwork; // relative PNG path inside the mod; empty means no cover
std::map<std::string, std::string> song_artwork;
};
// Reads a mod's reskate-music.json:
// {"schema":1,"playlists":[{"name":"...","songs":["artist - title", ...]}]}
// Optional playlist "artwork" and root "song_artwork" (song-id -> PNG path) use mod-relative paths.
// Invalid optional artwork is ignored, without dropping valid music membership.
// 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);
// A portable mod-relative PNG path: no traversal, drive names or alternate streams.
bool music_artwork_path(const std::string& path);
} // namespace dingosdk::mods
@@ -0,0 +1,141 @@
#include <winsock2.h>
#include <Windows.h>
#include <winhttp.h>
#include "Extension/Music/music_artwork.h"
#include <array>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
namespace {
int failures = 0;
void check(bool ok, const char* message) {
if (!ok) { ++failures; std::cerr << "FAIL: " << message << '\n'; }
}
struct Handle {
HINTERNET value{};
~Handle() { if (value) WinHttpCloseHandle(value); }
};
std::pair<DWORD, std::string> fetch(const std::string& url) {
const std::wstring wide(url.begin(), url.end());
URL_COMPONENTS parts{sizeof(parts)};
parts.dwHostNameLength = parts.dwUrlPathLength = static_cast<DWORD>(-1);
if (!WinHttpCrackUrl(wide.c_str(), 0, 0, &parts)) return {};
Handle session{WinHttpOpen(L"Artwork test", WINHTTP_ACCESS_TYPE_NO_PROXY, nullptr, nullptr, 0)};
if (!session.value) return {};
WinHttpSetTimeouts(session.value, 3000, 3000, 3000, 3000);
const std::wstring host(parts.lpszHostName, parts.dwHostNameLength), path(parts.lpszUrlPath, parts.dwUrlPathLength);
Handle connection{WinHttpConnect(session.value, host.c_str(), parts.nPort, 0)};
if (!connection.value) return {};
Handle request{WinHttpOpenRequest(connection.value, L"GET", path.c_str(), nullptr, nullptr, nullptr, 0)};
if (!request.value || !WinHttpSendRequest(request.value, nullptr, 0, nullptr, 0, 0, 0) ||
!WinHttpReceiveResponse(request.value, nullptr)) return {};
DWORD status{}, size = sizeof(status);
if (!WinHttpQueryHeaders(request.value, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
nullptr, &status, &size, nullptr)) return {};
std::string body;
std::array<char, 1024> buffer{};
DWORD received{};
while (WinHttpReadData(request.value, buffer.data(), static_cast<DWORD>(buffer.size()), &received) && received)
body.append(buffer.data(), received);
return {status, body};
}
// A minimal HTTP/2 cleartext (h2c) client, the way the game's client connects: send the connection
// preface, SETTINGS and a HEADERS frame, then collect the DATA frames of the response.
std::pair<int, std::string> fetch_h2c(unsigned short port, const std::string& path) {
SOCKET socket_value = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (socket_value == INVALID_SOCKET) return {};
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = htons(port);
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (connect(socket_value, reinterpret_cast<const sockaddr*>(&address), sizeof(address)) != 0) {
closesocket(socket_value);
return {};
}
constexpr char preface[] = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
send(socket_value, preface, 24, 0);
const char settings[] = {0, 0, 0, 4, 0, 0, 0, 0, 0};
send(socket_value, settings, 9, 0);
// HPACK block: :method GET (indexed 2), :scheme http (indexed 6), :path as a literal without
// indexing with the static name index 4 and a raw (non-Huffman) value.
std::string block;
block.push_back(static_cast<char>(0x82));
block.push_back(static_cast<char>(0x86));
block.push_back(static_cast<char>(0x04));
block.push_back(static_cast<char>(path.size()));
block.insert(block.end(), path.begin(), path.end());
const char headers[] = {0, 0, static_cast<char>(block.size()), 1, 4, 0, 0, 0, 1};
send(socket_value, headers, 9, 0);
send(socket_value, block.data(), static_cast<int>(block.size()), 0);
std::string buffer, body;
int status = 0;
for (;;) {
char chunk[16384];
const int got = recv(socket_value, chunk, sizeof(chunk), 0);
if (got <= 0) break;
buffer.append(chunk, got);
bool done = false;
while (buffer.size() >= 9) {
const auto length = (std::size_t(static_cast<unsigned char>(buffer[0])) << 16) |
(std::size_t(static_cast<unsigned char>(buffer[1])) << 8) | std::size_t(static_cast<unsigned char>(buffer[2]));
const auto type = static_cast<unsigned char>(buffer[3]);
const auto flags = static_cast<unsigned char>(buffer[4]);
const auto stream = ((static_cast<unsigned>(static_cast<unsigned char>(buffer[5])) << 24) |
(static_cast<unsigned>(static_cast<unsigned char>(buffer[6])) << 16) |
(static_cast<unsigned>(static_cast<unsigned char>(buffer[7])) << 8) |
static_cast<unsigned>(static_cast<unsigned char>(buffer[8]))) & 0x7fffffffu;
if (buffer.size() < 9 + length) break;
const std::string payload = buffer.substr(9, length);
buffer.erase(0, 9 + length);
if (type == 0x1 && stream == 1 && !payload.empty() && static_cast<unsigned char>(payload[0]) == 0x88) status = 200;
if (type == 0x0 && stream == 1) {
body += payload;
if (flags & 0x1) done = true;
}
}
if (done) break;
}
closesocket(socket_value);
return {status, body};
}
}
int main() try {
namespace fs = std::filesystem;
const auto root = fs::temp_directory_path() / (L"ReSkateArtworkTest-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::to_wstring(GetTickCount64()));
fs::create_directories(root / L"mod" / L"artwork");
struct Cleanup { fs::path root; ~Cleanup() { std::error_code ec; fs::remove_all(root, ec); } } cleanup{root};
// A real one-pixel grayscale PNG. Bytes returned by HTTP must match it exactly.
constexpr unsigned char png[]{
137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,4,0,0,0,181,28,12,2,
0,0,0,11,73,68,65,84,120,218,99,100,248,15,0,1,5,1,1,39,24,227,102,0,0,0,0,73,69,78,68,174,66,96,130};
const std::string bytes(reinterpret_cast<const char*>(png), sizeof(png));
{ std::ofstream out(root / L"mod/artwork/cover.png", std::ios::binary); out.write(bytes.data(), bytes.size()); }
{ std::ofstream out(root / L"mod/artwork/bad.png", std::ios::binary); out << "not an image"; }
{ std::ofstream out(root / L"outside.png", std::ios::binary); out.write(bytes.data(), bytes.size()); }
dingosdk::profile_runtime::MusicArtworkServer server;
const auto url = server.add(root / L"mod", "artwork/cover.png");
check(url.starts_with("http://127.0.0.1:"), "server uses an ephemeral loopback URL");
check(server.add(root / L"mod", "artwork/cover.png") == url, "repeated registration keeps the URL");
check(server.add(root / L"mod", "../outside.png").empty(), "parent traversal is refused");
check(server.add(root / L"mod", "artwork/missing.png").empty(), "missing image is ignored");
check(server.add(root / L"mod", "artwork/bad.png").empty(), "non-PNG bytes are ignored");
if (!url.empty()) {
const auto [status, body] = fetch(url);
check(status == 200 && body == bytes, "HTTP returns the exact registered PNG");
const auto base = url.substr(0, url.find('/', 7));
check(fetch(base + "/music-art/unknown.png").first == 404, "unknown route is not served");
check(fetch(base + "/outside.png").first == 404, "HTTP cannot read arbitrary files");
const auto colon = url.find(':', 7), slash = url.find('/', colon);
const auto port = static_cast<unsigned short>(std::stoi(url.substr(colon + 1, slash - colon - 1)));
const auto [h2status, h2body] = fetch_h2c(port, url.substr(slash));
check(h2status == 200 && h2body == bytes, "h2c returns the exact registered PNG");
}
std::error_code ec;
fs::create_directory_symlink(root, root / L"mod" / L"escape", ec);
if (!ec) check(server.add(root / L"mod", "escape/outside.png").empty(), "symlink escape is refused");
else std::cout << "SKIP: symlink test needs Windows symlink privileges\n";
if (!failures) std::cout << "music artwork tests passed\n";
return failures ? 1 : 0;
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }
+139
View File
@@ -0,0 +1,139 @@
// Generated from RFC 7541 (Appendix A static table, Appendix B Huffman code). Do not edit.
#pragma once
#include <array>
#include <cstdint>
#include <string_view>
namespace dingosdk::profile_runtime::hpack {
// HPACK static table; entry N is index N+1 (HPACK index 0 is invalid).
struct StaticEntry { std::string_view name, value; };
inline constexpr std::array<StaticEntry, 61> static_table{{
{":authority", ""}, // 1
{":method", "GET"}, // 2
{":method", "POST"}, // 3
{":path", "/"}, // 4
{":path", "/index.html"}, // 5
{":scheme", "http"}, // 6
{":scheme", "https"}, // 7
{":status", "200"}, // 8
{":status", "204"}, // 9
{":status", "206"}, // 10
{":status", "304"}, // 11
{":status", "400"}, // 12
{":status", "404"}, // 13
{":status", "500"}, // 14
{"accept-charset", ""}, // 15
{"accept-encoding", "gzip, deflate"}, // 16
{"accept-language", ""}, // 17
{"accept-ranges", ""}, // 18
{"accept", ""}, // 19
{"access-control-allow-origin", ""}, // 20
{"age", ""}, // 21
{"allow", ""}, // 22
{"authorization", ""}, // 23
{"cache-control", ""}, // 24
{"content-disposition", ""}, // 25
{"content-encoding", ""}, // 26
{"content-language", ""}, // 27
{"content-length", ""}, // 28
{"content-location", ""}, // 29
{"content-range", ""}, // 30
{"content-type", ""}, // 31
{"cookie", ""}, // 32
{"date", ""}, // 33
{"etag", ""}, // 34
{"expect", ""}, // 35
{"expires", ""}, // 36
{"from", ""}, // 37
{"host", ""}, // 38
{"if-match", ""}, // 39
{"if-modified-since", ""}, // 40
{"if-none-match", ""}, // 41
{"if-range", ""}, // 42
{"if-unmodified-since", ""}, // 43
{"last-modified", ""}, // 44
{"link", ""}, // 45
{"location", ""}, // 46
{"max-forwards", ""}, // 47
{"proxy-authenticate", ""}, // 48
{"proxy-authorization", ""}, // 49
{"range", ""}, // 50
{"referer", ""}, // 51
{"refresh", ""}, // 52
{"retry-after", ""}, // 53
{"server", ""}, // 54
{"set-cookie", ""}, // 55
{"strict-transport-security", ""}, // 56
{"transfer-encoding", ""}, // 57
{"user-agent", ""}, // 58
{"vary", ""}, // 59
{"via", ""}, // 60
{"www-authenticate", ""}, // 61
}};
// Huffman code per symbol, MSB-first in `code` using `bits` bits; symbol 256 is EOS.
inline constexpr std::array<std::uint32_t, 257> huffman_code{{
8184, 8388568, 268435426, 268435427, 268435428, 268435429,
268435430, 268435431, 268435432, 16777194, 1073741820, 268435433,
268435434, 1073741821, 268435435, 268435436, 268435437, 268435438,
268435439, 268435440, 268435441, 268435442, 1073741822, 268435443,
268435444, 268435445, 268435446, 268435447, 268435448, 268435449,
268435450, 268435451, 20, 1016, 1017, 4090,
8185, 21, 248, 2042, 1018, 1019,
249, 2043, 250, 22, 23, 24,
0, 1, 2, 25, 26, 27,
28, 29, 30, 31, 92, 251,
32764, 32, 4091, 1020, 8186, 33,
93, 94, 95, 96, 97, 98,
99, 100, 101, 102, 103, 104,
105, 106, 107, 108, 109, 110,
111, 112, 113, 114, 252, 115,
253, 8187, 524272, 8188, 16380, 34,
32765, 3, 35, 4, 36, 5,
37, 38, 39, 6, 116, 117,
40, 41, 42, 7, 43, 118,
44, 8, 9, 45, 119, 120,
121, 122, 123, 32766, 2044, 16381,
8189, 268435452, 1048550, 4194258, 1048551, 1048552,
4194259, 4194260, 4194261, 8388569, 4194262, 8388570,
8388571, 8388572, 8388573, 8388574, 16777195, 8388575,
16777196, 16777197, 4194263, 8388576, 16777198, 8388577,
8388578, 8388579, 8388580, 2097116, 4194264, 8388581,
4194265, 8388582, 8388583, 16777199, 4194266, 2097117,
1048553, 4194267, 4194268, 8388584, 8388585, 2097118,
8388586, 4194269, 4194270, 16777200, 2097119, 4194271,
8388587, 8388588, 2097120, 2097121, 4194272, 2097122,
8388589, 4194273, 8388590, 8388591, 1048554, 4194274,
4194275, 4194276, 8388592, 4194277, 4194278, 8388593,
67108832, 67108833, 1048555, 524273, 4194279, 8388594,
4194280, 33554412, 67108834, 67108835, 67108836, 134217694,
134217695, 67108837, 16777201, 33554413, 524274, 2097123,
67108838, 134217696, 134217697, 67108839, 134217698, 16777202,
2097124, 2097125, 67108840, 67108841, 268435453, 134217699,
134217700, 134217701, 1048556, 16777203, 1048557, 2097126,
4194281, 2097127, 2097128, 8388595, 4194282, 4194283,
33554414, 33554415, 16777204, 16777205, 67108842, 8388596,
67108843, 134217702, 67108844, 67108845, 134217703, 134217704,
134217705, 134217706, 134217707, 268435454, 134217708, 134217709,
134217710, 134217711, 134217712, 67108846, 1073741823,
}};
inline constexpr std::array<std::uint8_t, 257> huffman_bits{{
13, 23, 28, 28, 28, 28, 28, 28, 28, 24, 30, 28, 28, 30, 28, 28,
28, 28, 28, 28, 28, 28, 30, 28, 28, 28, 28, 28, 28, 28, 28, 28,
6, 10, 10, 12, 13, 6, 8, 11, 10, 10, 8, 11, 8, 6, 6, 6,
5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 7, 8, 15, 6, 12, 10,
13, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 13, 19, 13, 14, 6,
15, 5, 6, 5, 6, 5, 6, 6, 6, 5, 7, 7, 6, 6, 6, 5,
6, 7, 6, 5, 5, 6, 7, 7, 7, 7, 7, 15, 11, 14, 13, 28,
20, 22, 20, 20, 22, 22, 22, 23, 22, 23, 23, 23, 23, 23, 24, 23,
24, 24, 22, 23, 24, 23, 23, 23, 23, 21, 22, 23, 22, 23, 23, 24,
22, 21, 20, 22, 22, 23, 23, 21, 23, 22, 22, 24, 21, 22, 23, 23,
21, 21, 22, 21, 23, 22, 23, 23, 20, 22, 22, 22, 23, 22, 22, 23,
26, 26, 20, 19, 22, 23, 22, 25, 26, 26, 26, 27, 27, 26, 24, 25,
19, 21, 26, 27, 27, 26, 27, 24, 21, 21, 26, 26, 28, 27, 27, 27,
20, 24, 20, 21, 22, 21, 21, 23, 22, 22, 25, 25, 24, 24, 26, 23,
26, 27, 26, 26, 27, 27, 27, 27, 27, 28, 27, 27, 27, 27, 27, 26,
30,
}};
} // namespace dingosdk::profile_runtime::hpack
+17 -4
View File
@@ -1,5 +1,6 @@
#include "Extension/Customization/local_customization_runtime.h"
#include "local_music_assets.h"
#include "music_artwork.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/local_music.h"
@@ -30,7 +31,8 @@ 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",..]}]}.
// 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() {
@@ -42,6 +44,10 @@ std::vector<mods::MusicPlaylist> mod_music_playlists() {
try {
std::ifstream in(path, std::ios::binary);
auto playlists = mods::parse_music_playlists(mod.name, std::string((std::istreambuf_iterator<char>(in)), {}));
for (auto& playlist : playlists) {
if (!playlist.artwork.empty()) playlist.artwork = mod_music_artwork_url(mod.directory, playlist.artwork);
for (auto& [id, artwork] : playlist.song_artwork) artwork = mod_music_artwork_url(mod.directory, artwork);
}
std::move(playlists.begin(), playlists.end(), std::back_inserter(result));
} catch (const std::exception& failure) {
dingosdk::logging::event(dingosdk::logging::Channel::music,
@@ -171,7 +177,12 @@ bool read_music_catalog(MusicCatalog& result) {
// 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);
merge(playlist.id, playlist.name, playlist.artwork, playlist.songs);
for (const auto& [id, artwork] : playlist.song_artwork)
if (const auto found = index.find(id); found != index.end() && !artwork.empty() && snapshot.songs[found->second].artwork.empty()) {
snapshot.songs[found->second].artwork = artwork;
++song_artwork;
}
}
for (auto it = playlists.begin(); it != playlists.end();)
it = it->second.empty() ? playlists.erase(it) : std::next(it);
@@ -187,11 +198,13 @@ bool read_music_catalog(MusicCatalog& result) {
}
// Registry order is bucket order, not authored ordering. Use stable IDs for
// deterministic UI order; do not claim this is the original service order.
// Authored mod playlists keep their defined track positioning.
std::sort(snapshot.songs.begin(), snapshot.songs.end(), [](const auto& a, const auto& b) {
return a.id < b.id;
});
for (auto& [id, songs] : playlists) {
std::sort(songs.begin(), songs.end());
if (!id.starts_with("mod:"))
std::sort(songs.begin(), songs.end());
MusicPlaylist row;
row.id = id;
row.songs = std::move(songs);
@@ -204,4 +217,4 @@ bool read_music_catalog(MusicCatalog& result) {
result = std::move(snapshot);
return true;
}
}
}
+2 -2
View File
@@ -5,14 +5,14 @@
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::string artwork; // content-cache cdn:/ id or registered mod loopback URL; 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::string artwork; // content-cache cdn:/ id or registered mod loopback URL; empty when unknown
std::vector<std::string> songs;
};
+10 -3
View File
@@ -12,6 +12,13 @@
#include "Extension/Profile/runtime_internal.h"
namespace dingosdk::profile_runtime {
namespace {
std::string music_artwork_url(std::string_view value) {
// Only URLs produced by the local artwork server bypass CDN resolution.
if (value.starts_with("http://127.0.0.1:")) return std::string(value);
return travel_artwork_url(value);
}
}
// Runtime-only music UI hydration: read_music_catalog copies actual registered
// MusicGraphAsset metadata/TagRefs. No generated catalog, guessed memberships,
@@ -117,14 +124,14 @@ std::string music_ui_wire(std::string_view id, std::string_view artist, std::str
// 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())
if (const auto url = music_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())
if (const auto url = music_artwork_url(artwork); !url.empty())
cosmetic_wire_string(presentation, 12, url);
}
cosmetic_wire_string(body, 10, presentation);
@@ -287,4 +294,4 @@ void update_music_catalog() {
dingosdk::logging::event(dingosdk::logging::Channel::music, dingosdk::Json{{"event", "native_music_ui_complete"}, {"playlists", playlist_count}, {"songs", song_count}, {"source", "runtime_music_assets"}}.dump().c_str());
} catch (...) { retry = false; dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"native_music_ui_publish_failed\"}"); }
}
}
}
+523
View File
@@ -0,0 +1,523 @@
#include <winsock2.h>
#include <Windows.h>
#include "music_artwork.h"
#include "hpack_tables.h"
#include "Engine/Vfs/mod_music.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <deque>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <map>
#include <memory>
#include <mutex>
#include <span>
#include <string>
#include <string_view>
#include <thread>
#include <utility>
#include <vector>
namespace dingosdk::profile_runtime {
namespace {
constexpr std::size_t max_image = 4 * 1024 * 1024, max_total = 64 * 1024 * 1024;
constexpr unsigned short fixed_port = 47823; // stable origin so the game's HTTP cache is reused
constexpr std::size_t max_frame = 16384;
// HTTP/2 (RFC 7540) cleartext, because the game's HTTP client speaks h2c prior-knowledge.
constexpr char h2_preface[24] = {'P', 'R', 'I', ' ', '*', ' ', 'H', 'T', 'T', 'P', '/', '2', '.', '0',
'\r', '\n', '\r', '\n', 'S', 'M', '\r', '\n', '\r', '\n'};
constexpr std::uint8_t f_data = 0x0, f_headers = 0x1, f_settings = 0x4, f_ping = 0x6,
f_goaway = 0x7, f_window_update = 0x8, f_continuation = 0x9;
constexpr std::uint8_t flag_ack = 0x1, flag_end_stream = 0x1, flag_end_headers = 0x4, flag_padded = 0x8, flag_priority = 0x20;
struct Socket {
SOCKET value = INVALID_SOCKET;
Socket() = default;
explicit Socket(SOCKET socket) : value(socket) {}
Socket(Socket&& other) noexcept : value(other.value) { other.value = INVALID_SOCKET; }
Socket& operator=(Socket&& other) noexcept {
if (this != &other) { if (value != INVALID_SOCKET) closesocket(value); value = other.value; other.value = INVALID_SOCKET; }
return *this;
}
Socket(const Socket&) = delete;
Socket& operator=(const Socket&) = delete;
~Socket() { if (value != INVALID_SOCKET) closesocket(value); }
};
bool send_all(SOCKET socket, const char* bytes, std::size_t count) {
while (count) {
const auto sent = send(socket, bytes, static_cast<int>(count), 0);
if (sent <= 0) return false;
bytes += sent;
count -= static_cast<std::size_t>(sent);
}
return true;
}
bool png_ok(const std::vector<char>& bytes) {
constexpr unsigned char magic[]{137, 80, 78, 71, 13, 10, 26, 10};
if (bytes.size() < 33) return false;
for (std::size_t i = 0; i < 8; ++i)
if (static_cast<unsigned char>(bytes[i]) != magic[i]) return false;
if (std::string_view(bytes.data() + 12, 4) != "IHDR") return false;
const auto dimension = [&](std::size_t offset) {
std::uint32_t value = 0;
for (std::size_t i = offset; i < offset + 4; ++i) value = (value << 8) | static_cast<unsigned char>(bytes[i]);
return value;
};
const auto w = dimension(16), h = dimension(20);
return w && h && w <= 2048 && h <= 2048;
}
// ---- minimal HPACK decoder (RFC 7541), enough to read the request's :path ----------------------
using Octets = std::span<const unsigned char>;
bool decode_integer(Octets data, std::size_t& at, unsigned prefix_bits, std::uint64_t& out) {
if (at >= data.size()) return false;
const std::uint64_t mask = (1u << prefix_bits) - 1;
std::uint64_t value = data[at++] & mask;
if (value < mask) { out = value; return true; }
unsigned shift = 0;
for (;;) {
if (at >= data.size()) return false;
const auto byte = data[at++];
value += std::uint64_t(byte & 0x7f) << shift;
if (!(byte & 0x80)) break;
shift += 7;
if (shift > 56) return false;
}
out = value;
return true;
}
bool huffman_decode(std::string_view input, std::string& out) {
struct Node { std::int32_t child[2] = {-1, -1}; std::int32_t symbol = -1; };
static const auto tree = [] {
std::vector<Node> nodes(1);
for (std::size_t symbol = 0; symbol < hpack::huffman_bits.size(); ++symbol) {
const auto bits = hpack::huffman_bits[symbol];
const auto code = hpack::huffman_code[symbol];
if (!bits) continue;
std::int32_t node = 0;
for (int i = bits - 1; i >= 0; --i) {
const auto bit = (code >> i) & 1;
if (nodes[node].child[bit] < 0) {
nodes[node].child[bit] = static_cast<std::int32_t>(nodes.size());
nodes.emplace_back();
}
node = nodes[node].child[bit];
}
nodes[node].symbol = static_cast<std::int32_t>(symbol);
}
return nodes;
}();
std::int32_t node = 0;
for (const auto byte : input) {
for (int i = 7; i >= 0; --i) {
node = tree[node].child[(static_cast<unsigned char>(byte) >> i) & 1];
if (node < 0) return false;
if (tree[node].symbol >= 0) {
if (tree[node].symbol == 256) return false; // EOS must not appear in a value
out.push_back(static_cast<char>(tree[node].symbol));
node = 0;
}
}
}
return true; // trailing padding bits (EOS prefix) are ignored
}
bool decode_string(Octets data, std::size_t& at, std::string& out) {
if (at >= data.size()) return false;
const bool huffman = data[at] & 0x80;
std::uint64_t length = 0;
if (!decode_integer(data, at, 7, length) || at + length > data.size()) return false;
const std::string_view bytes(reinterpret_cast<const char*>(data.data() + at), static_cast<std::size_t>(length));
at += length;
return huffman ? huffman_decode(bytes, out) : (out.assign(bytes), true);
}
// Returns the request's :path, or empty on a malformed block. Dynamic table state is per connection.
std::string decode_request_path(Octets block) {
std::deque<std::pair<std::string, std::string>> dynamic;
const auto name_at = [&](std::uint64_t index, std::string& name) {
if (index == 0) return false;
if (index <= 61) { name = hpack::static_table[index - 1].name; return true; }
const auto position = index - 62;
if (position >= dynamic.size()) return false;
name = dynamic[position].first;
return true;
};
const auto value_at = [&](std::uint64_t index, std::string& name, std::string& value) {
if (index == 0) return false;
if (index <= 61) { name = hpack::static_table[index - 1].name; value = hpack::static_table[index - 1].value; return true; }
const auto position = index - 62;
if (position >= dynamic.size()) return false;
name = dynamic[position].first;
value = dynamic[position].second;
return true;
};
std::size_t at = 0;
while (at < block.size()) {
const auto byte = block[at];
std::string name, value;
if (byte & 0x80) {
std::uint64_t index = 0;
if (!decode_integer(block, at, 7, index) || !value_at(index, name, value)) return {};
} else if ((byte & 0xc0) == 0x40) {
std::uint64_t index = 0;
if (!decode_integer(block, at, 6, index)) return {};
if (index == 0) { if (!decode_string(block, at, name)) return {}; }
else if (!name_at(index, name)) return {};
if (!decode_string(block, at, value)) return {};
dynamic.emplace_front(name, value);
} else if ((byte & 0xe0) == 0x20) {
std::uint64_t size = 0;
if (!decode_integer(block, at, 5, size)) return {};
if (size == 0) dynamic.clear();
continue;
} else {
std::uint64_t index = 0;
if (!decode_integer(block, at, 4, index)) return {};
if (index == 0) { if (!decode_string(block, at, name)) return {}; }
else if (!name_at(index, name)) return {};
if (!decode_string(block, at, value)) return {};
}
if (name == ":path") return value;
}
return {};
}
// ---- HTTP/2 framing ----------------------------------------------------------------------------
std::uint32_t be32(const char* p) {
return (std::uint32_t(static_cast<unsigned char>(p[0])) << 24) | (std::uint32_t(static_cast<unsigned char>(p[1])) << 16) |
(std::uint32_t(static_cast<unsigned char>(p[2])) << 8) | std::uint32_t(static_cast<unsigned char>(p[3]));
}
bool send_frame(SOCKET socket, std::uint8_t type, std::uint8_t flags, std::uint32_t stream, std::span<const char> payload) {
std::array<char, 9> header{};
const auto length = payload.size();
header[0] = static_cast<char>((length >> 16) & 0xff);
header[1] = static_cast<char>((length >> 8) & 0xff);
header[2] = static_cast<char>(length & 0xff);
header[3] = static_cast<char>(type);
header[4] = static_cast<char>(flags);
header[5] = static_cast<char>((stream >> 24) & 0x7f);
header[6] = static_cast<char>((stream >> 16) & 0xff);
header[7] = static_cast<char>((stream >> 8) & 0xff);
header[8] = static_cast<char>(stream & 0xff);
return send_all(socket, header.data(), header.size()) && (payload.empty() || send_all(socket, payload.data(), payload.size()));
}
// HPACK literals without indexing (no Huffman). Static-table names use the indexed-name form.
void append_indexed_name(std::vector<char>& out, unsigned name_index, std::string_view value) {
if (name_index < 15) out.push_back(static_cast<char>(name_index));
else { out.push_back(0x0f); out.push_back(static_cast<char>(name_index - 15)); }
out.push_back(static_cast<char>(value.size()));
out.insert(out.end(), value.begin(), value.end());
}
void append_new_name(std::vector<char>& out, std::string_view name, std::string_view value) {
out.push_back(0x00);
out.push_back(static_cast<char>(name.size()));
out.insert(out.end(), name.begin(), name.end());
out.push_back(static_cast<char>(value.size()));
out.insert(out.end(), value.begin(), value.end());
}
std::string etag_for(const std::vector<char>& bytes) {
std::uint64_t hash = 1469598103934665603ull;
for (const auto byte : bytes) { hash ^= static_cast<unsigned char>(byte); hash *= 1099511628211ull; }
char text[24];
std::snprintf(text, sizeof(text), "\"%016llx\"", static_cast<unsigned long long>(hash));
return text;
}
// :status 200 (indexed 8), content-type (31), content-length (28), cache-control (24), etag.
std::vector<char> response_header_block(std::size_t length, const std::string& etag) {
std::vector<char> out;
out.push_back(static_cast<char>(0x88));
append_indexed_name(out, 31, "image/png");
append_indexed_name(out, 28, std::to_string(length));
append_indexed_name(out, 24, "public, max-age=31536000, immutable");
if (!etag.empty()) append_new_name(out, "etag", etag);
return out;
}
constexpr char status_404_block = static_cast<char>(0x80 | 13); // :status 404 (static index 13)
struct Buffered {
SOCKET socket{};
std::string buffer;
bool take(std::size_t count, std::string& out) {
while (buffer.size() < count) {
std::array<char, 16384> chunk{};
const auto received = recv(socket, chunk.data(), static_cast<int>(chunk.size()), 0);
if (received <= 0) return false;
buffer.append(chunk.data(), static_cast<std::size_t>(received));
}
out.assign(buffer, 0, count);
buffer.erase(0, count);
return true;
}
};
std::string sanitize(std::string_view value) {
std::string out;
for (const auto c : value) out.push_back(std::isalnum(static_cast<unsigned char>(c)) ? c : '_');
return out;
}
std::string mod_folder_name(const std::filesystem::path& mod) {
const auto name = mod.filename().wstring();
std::string out;
out.reserve(name.size());
for (const auto c : name) out.push_back(static_cast<char>(c));
return out;
}
} // namespace
struct MusicArtworkServer::Impl {
bool winsock = false;
Socket listener;
unsigned short port = 0;
std::mutex mutex;
std::map<std::string, std::shared_ptr<const std::vector<char>>, std::less<>> images; // route -> bytes
std::map<std::filesystem::path, std::pair<std::filesystem::file_time_type, std::string>> paths;
std::size_t total = 0;
std::vector<std::jthread> workers;
Impl() {
WSADATA data{};
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return;
winsock = true;
listener = bind_listener(fixed_port);
if (listener.value == INVALID_SOCKET) listener = bind_listener(0); // port taken: fall back
if (listener.value == INVALID_SOCKET) return;
const auto count = std::clamp(std::thread::hardware_concurrency(), 4u, 8u);
for (unsigned i = 0; i < count; ++i)
workers.emplace_back([this](std::stop_token stop) { serve(stop); });
}
~Impl() {
for (auto& worker : workers) worker.request_stop();
for (auto& worker : workers) if (worker.joinable()) worker.join();
if (winsock) WSACleanup();
}
Socket bind_listener(unsigned short wanted) {
Socket bound{socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)};
if (bound.value == INVALID_SOCKET) return {};
const BOOL exclusive = TRUE;
if (setsockopt(bound.value, SOL_SOCKET, SO_EXCLUSIVEADDRUSE,
reinterpret_cast<const char*>(&exclusive), sizeof(exclusive)) != 0) return {};
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
address.sin_port = htons(wanted);
if (bind(bound.value, reinterpret_cast<const sockaddr*>(&address), sizeof(address)) != 0 ||
listen(bound.value, SOMAXCONN) != 0) return {};
int size = sizeof(address);
if (getsockname(bound.value, reinterpret_cast<sockaddr*>(&address), &size) != 0) return {};
u_long nonblocking = 1; // several pollers share the listener; a racing accept must not block
ioctlsocket(bound.value, FIONBIO, &nonblocking);
port = ntohs(address.sin_port);
return bound;
}
void serve(std::stop_token stop) noexcept {
while (!stop.stop_requested()) {
fd_set ready;
FD_ZERO(&ready);
FD_SET(listener.value, &ready);
timeval timeout{0, 100000};
if (select(0, &ready, nullptr, nullptr, &timeout) <= 0) continue;
if (!FD_ISSET(listener.value, &ready)) continue;
Socket client{accept(listener.value, nullptr, nullptr)};
if (client.value == INVALID_SOCKET) continue;
u_long blocking = 0;
ioctlsocket(client.value, FIONBIO, &blocking); // accepted sockets inherit the listener mode
const DWORD milliseconds = 2000;
setsockopt(client.value, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&milliseconds), sizeof(milliseconds));
setsockopt(client.value, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast<const char*>(&milliseconds), sizeof(milliseconds));
try { handle(client.value); } catch (...) {}
}
}
void handle(SOCKET client) {
std::array<char, sizeof(h2_preface)> peek{};
for (;;) {
const auto available = recv(client, peek.data(), static_cast<int>(peek.size()), MSG_PEEK);
if (available <= 0) return;
if (static_cast<std::size_t>(available) >= peek.size()) break;
}
if (std::memcmp(peek.data(), h2_preface, sizeof(h2_preface)) == 0) serve_h2c(client);
else respond(client);
}
std::shared_ptr<const std::vector<char>> find(std::string_view route) {
std::lock_guard lock(mutex);
const auto found = images.find(route);
return found == images.end() ? nullptr : found->second;
}
// Graceful close: closing with unread frames queued makes Windows send RST and the client can
// drop the response tail.
static void finish(SOCKET client) {
shutdown(client, SD_SEND);
const DWORD drain_ms = 300;
setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&drain_ms), sizeof(drain_ms));
std::array<char, 1024> discard{};
while (recv(client, discard.data(), static_cast<int>(discard.size()), 0) > 0) {}
}
void serve_h2c(SOCKET client) {
Buffered reader{client, {}};
std::string preface;
if (!reader.take(sizeof(h2_preface), preface) || !send_frame(client, f_settings, 0, 0, {})) return;
const auto read_frame = [&](std::uint8_t& type, std::uint8_t& flags, std::uint32_t& stream, std::string& payload) {
std::string header;
if (!reader.take(9, header)) return false;
const auto length = (std::size_t(static_cast<unsigned char>(header[0])) << 16) |
(std::size_t(static_cast<unsigned char>(header[1])) << 8) | std::size_t(static_cast<unsigned char>(header[2]));
type = static_cast<std::uint8_t>(header[3]);
flags = static_cast<std::uint8_t>(header[4]);
stream = be32(header.data() + 5) & 0x7fffffffu;
payload.clear();
return length == 0 || reader.take(length, payload);
};
std::uint32_t request_stream = 0;
std::int64_t connection_window = 65535, stream_window = 65535;
std::string block;
for (;;) {
std::uint8_t type{}, flags{}; std::uint32_t stream{}; std::string payload;
if (!read_frame(type, flags, stream, payload)) return;
if (type == f_settings) {
if (!(flags & flag_ack)) {
for (std::size_t at = 0; at + 6 <= payload.size(); at += 6)
if (((std::uint16_t(static_cast<unsigned char>(payload[at])) << 8) | static_cast<unsigned char>(payload[at + 1])) == 4)
stream_window = be32(payload.data() + at + 2);
if (!send_frame(client, f_settings, flag_ack, 0, {})) return;
}
} else if (type == f_ping && !(flags & flag_ack)) {
if (!send_frame(client, f_ping, flag_ack, 0, payload)) return;
} else if (type == f_window_update) {
if (payload.size() >= 4) (stream == 0 ? connection_window : stream_window) += be32(payload.data()) & 0x7fffffffu;
} else if (type == f_headers) {
request_stream = stream;
std::size_t at = 0;
if ((flags & flag_padded) && at < payload.size()) at += 1 + static_cast<unsigned char>(payload[at]);
if (flags & flag_priority) at += 5;
if (at < payload.size()) block.append(payload, at, std::string::npos);
if (flags & flag_end_headers) break;
} else if (type == f_continuation) {
block.append(payload);
if (flags & flag_end_headers) break;
} else if (type == f_goaway) {
return;
}
}
if (!request_stream) return;
const auto route = decode_request_path(Octets(reinterpret_cast<const unsigned char*>(block.data()), block.size()));
const auto image = route.empty() ? nullptr : find(route);
if (!image) {
send_frame(client, f_headers, flag_end_headers | flag_end_stream, request_stream, std::span<const char>(&status_404_block, 1));
send_frame(client, f_goaway, 0, 0, {});
finish(client);
return;
}
const auto headers = response_header_block(image->size(), etag_for(*image));
if (!send_frame(client, f_headers, flag_end_headers, request_stream, std::span<const char>(headers.data(), headers.size()))) return;
std::size_t sent = 0;
while (sent < image->size()) {
if (connection_window <= 0 || stream_window <= 0) {
std::uint8_t type{}, flags{}; std::uint32_t stream{}; std::string payload;
if (!read_frame(type, flags, stream, payload)) return;
if (type == f_window_update) {
if (payload.size() >= 4) (stream == 0 ? connection_window : stream_window) += be32(payload.data()) & 0x7fffffffu;
} else if (type == f_ping && !(flags & flag_ack)) {
if (!send_frame(client, f_ping, flag_ack, 0, payload)) return;
} else if (type == f_settings && !(flags & flag_ack)) {
if (!send_frame(client, f_settings, flag_ack, 0, {})) return;
} else if (type == f_goaway) {
return;
}
continue;
}
const auto chunk = std::min({std::size_t(connection_window), std::size_t(stream_window), max_frame, image->size() - sent});
const bool last = sent + chunk == image->size();
if (!send_frame(client, f_data, last ? flag_end_stream : std::uint8_t(0), request_stream,
std::span<const char>(image->data() + sent, chunk))) return;
sent += chunk;
connection_window -= static_cast<std::int64_t>(chunk);
stream_window -= static_cast<std::int64_t>(chunk);
}
send_frame(client, f_goaway, 0, 0, {});
finish(client);
}
void respond(SOCKET client) {
std::string request;
std::array<char, 1024> buffer{};
const auto started = GetTickCount64();
while (request.find("\r\n\r\n") == std::string::npos && request.size() < 8192) {
if (GetTickCount64() - started > 2000) return;
const auto received = recv(client, buffer.data(), static_cast<int>(buffer.size()), 0);
if (received <= 0) return;
request.append(buffer.data(), static_cast<std::size_t>(received));
}
const auto line = request.substr(0, request.find("\r\n"));
std::shared_ptr<const std::vector<char>> image;
if (request.find("\r\n\r\n") != std::string::npos && line.starts_with("GET ")) {
const auto space = line.find(' ', 4);
if (space != std::string::npos && (line.substr(space) == " HTTP/1.1" || line.substr(space) == " HTTP/1.0"))
image = find(line.substr(4, space - 4));
}
if (!image) {
constexpr std::string_view missing = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
send_all(client, missing.data(), missing.size());
return;
}
const auto header = "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: " + std::to_string(image->size()) +
"\r\nCache-Control: public, max-age=31536000, immutable\r\nConnection: close\r\n\r\n";
if (send_all(client, header.data(), header.size())) send_all(client, image->data(), image->size());
}
};
MusicArtworkServer::MusicArtworkServer() : impl_(std::make_unique<Impl>()) {}
MusicArtworkServer::~MusicArtworkServer() = default;
std::string MusicArtworkServer::add(const std::filesystem::path& mod, const std::string& relative) {
if (!impl_->winsock || impl_->port == 0 || !mods::music_artwork_path(relative)) return {};
try {
const auto root = std::filesystem::canonical(mod);
const auto file = std::filesystem::canonical(root / std::filesystem::path(std::u8string(relative.begin(), relative.end())));
auto a = root.begin(), b = file.begin();
for (; a != root.end(); ++a, ++b)
if (b == file.end() || _wcsicmp(a->c_str(), b->c_str()) != 0) return {};
if (b == file.end()) return {};
const auto stamp = std::filesystem::last_write_time(file);
std::lock_guard lock(impl_->mutex);
if (const auto found = impl_->paths.find(file); found != impl_->paths.end() && found->second.first == stamp)
return found->second.second;
const auto size = std::filesystem::file_size(file);
if (size > max_image || impl_->total + size > max_total) return {};
std::ifstream input(file, std::ios::binary);
auto bytes = std::make_shared<std::vector<char>>(static_cast<std::size_t>(size));
if (!input.read(bytes->data(), static_cast<std::streamsize>(size)) || !png_ok(*bytes)) return {};
// A stable, readable route so the URL is identical every launch (and the game's HTTP cache
// can be reused) and the image identity is in the path, like the game's own CDN links.
const auto route = "/music-art/" + sanitize(mod_folder_name(mod)) + "/" + relative;
const auto url = "http://127.0.0.1:" + std::to_string(impl_->port) + route;
impl_->images[route] = bytes;
impl_->total += bytes->size();
impl_->paths[file] = {stamp, url};
return url;
} catch (const std::exception&) { return {}; }
}
std::string mod_music_artwork_url(const std::filesystem::path& mod, const std::string& relative) {
// The injected runtime has process lifetime. Pin it before starting workers, so its code cannot
// be unloaded while the image endpoint is serving requests. Avoid joining a worker from CRT
// teardown under the loader lock. Tests use the owned server above.
static auto* server = []() -> MusicArtworkServer* {
HMODULE module{};
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
reinterpret_cast<LPCWSTR>(&mod_music_artwork_url), &module)) return nullptr;
return new MusicArtworkServer;
}();
return server ? server->add(mod, relative) : std::string{};
}
} // namespace dingosdk::profile_runtime
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <filesystem>
#include <memory>
#include <string>
namespace dingosdk::profile_runtime {
// Serves only registered PNG bytes on an ephemeral loopback port. No filesystem HTTP routes.
class MusicArtworkServer {
public:
MusicArtworkServer();
~MusicArtworkServer();
MusicArtworkServer(const MusicArtworkServer&) = delete;
MusicArtworkServer& operator=(const MusicArtworkServer&) = delete;
std::string add(const std::filesystem::path& mod, const std::string& relative);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
std::string mod_music_artwork_url(const std::filesystem::path& mod, const std::string& relative);
}
+16
View File
@@ -86,6 +86,22 @@ The menu and console keys can be changed in the launcher's Settings.
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.
Optional playlist and track covers are PNG files packaged inside the mod:
```json
{
"schema": 1,
"playlists": [{"name": "My Playlist", "artwork": "artwork/playlist.png", "songs": ["Artist - Title"]}],
"song_artwork": {"Artist - Title": "artwork/track.png"}
}
```
Artwork paths are relative to the mod folder and use forward slashes. Images must be PNGs, at most
2048x2048 and 4 MiB each. Missing or invalid covers are ignored without removing the playlist or song.
ReSkate serves registered image bytes through a process-local HTTP endpoint bound only to `127.0.0.1`
on an automatically selected port; no internet hosting is needed. The endpoint exposes no filesystem
routes and retains at most 64 MiB of artwork. Existing content-cache covers keep their priority.
Only install mods you trust. Mods change game data, and custom scripts can run code.
## Dedicated servers
+4
View File
@@ -48,6 +48,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32)
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_music_artwork_tests Extension/Music/Test/music_artwork_tests.cpp Extension/Music/music_artwork.cpp)
target_link_libraries(dingosdk_music_artwork_tests PRIVATE dingosdk_mods ws2_32 winhttp)
dingosdk_configure_target(dingosdk_music_artwork_tests)
add_test(NAME music_artwork COMMAND dingosdk_music_artwork_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
@@ -141,6 +141,7 @@ add_library(dingosdk_runtime SHARED
Extension/News/live_news.cpp
Extension/Objects/local_object_runtime.cpp
Extension/Music/local_music_assets.cpp
Extension/Music/music_artwork.cpp
Extension/Music/local_music_ui.cpp
Extension/Objects/local_buildkit_labels.cpp
Extension/Objects/local_buildkit_limits.cpp