diff --git a/Engine/Vfs/Test/mod_music_tests.cpp b/Engine/Vfs/Test/mod_music_tests.cpp index d5b99910..79495b4f 100644 --- a/Engine/Vfs/Test/mod_music_tests.cpp +++ b/Engine/Vfs/Test/mod_music_tests.cpp @@ -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"); diff --git a/Engine/Vfs/mod_music.cpp b/Engine/Vfs/mod_music.cpp index 18a6c4bf..5fa56da7 100644 --- a/Engine/Vfs/mod_music.cpp +++ b/Engine/Vfs/mod_music.cpp @@ -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 parse_music_playlists(const std::string& mod, const std::string& json) { std::vector result; const auto root = Json::parse(json); @@ -18,15 +32,26 @@ std::vector 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 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; diff --git a/Engine/Vfs/mod_music.h b/Engine/Vfs/mod_music.h index 3f93941e..ea30e0f8 100644 --- a/Engine/Vfs/mod_music.h +++ b/Engine/Vfs/mod_music.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace dingosdk::mods { @@ -11,13 +12,20 @@ struct MusicPlaylist { std::string id; // "mod::" std::string name; // shown in the music screen std::vector songs; + std::string artwork; // relative PNG path inside the mod; empty means no cover + std::map 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 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 diff --git a/Extension/Music/Test/music_artwork_tests.cpp b/Extension/Music/Test/music_artwork_tests.cpp new file mode 100644 index 00000000..62fabf39 --- /dev/null +++ b/Extension/Music/Test/music_artwork_tests.cpp @@ -0,0 +1,141 @@ +#include +#include +#include +#include "Extension/Music/music_artwork.h" +#include +#include +#include +#include +#include + +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 fetch(const std::string& url) { + const std::wstring wide(url.begin(), url.end()); + URL_COMPONENTS parts{sizeof(parts)}; + parts.dwHostNameLength = parts.dwUrlPathLength = static_cast(-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 buffer{}; + DWORD received{}; + while (WinHttpReadData(request.value, buffer.data(), static_cast(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 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(&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(0x82)); + block.push_back(static_cast(0x86)); + block.push_back(static_cast(0x04)); + block.push_back(static_cast(path.size())); + block.insert(block.end(), path.begin(), path.end()); + const char headers[] = {0, 0, static_cast(block.size()), 1, 4, 0, 0, 0, 1}; + send(socket_value, headers, 9, 0); + send(socket_value, block.data(), static_cast(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(buffer[0])) << 16) | + (std::size_t(static_cast(buffer[1])) << 8) | std::size_t(static_cast(buffer[2])); + const auto type = static_cast(buffer[3]); + const auto flags = static_cast(buffer[4]); + const auto stream = ((static_cast(static_cast(buffer[5])) << 24) | + (static_cast(static_cast(buffer[6])) << 16) | + (static_cast(static_cast(buffer[7])) << 8) | + static_cast(static_cast(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(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(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(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; } diff --git a/Extension/Music/hpack_tables.h b/Extension/Music/hpack_tables.h new file mode 100644 index 00000000..2429fe91 --- /dev/null +++ b/Extension/Music/hpack_tables.h @@ -0,0 +1,139 @@ +// Generated from RFC 7541 (Appendix A static table, Appendix B Huffman code). Do not edit. +#pragma once +#include +#include +#include + +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 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 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 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 diff --git a/Extension/Music/local_music_assets.cpp b/Extension/Music/local_music_assets.cpp index 84d2ab5c..3201d638 100644 --- a/Extension/Music/local_music_assets.cpp +++ b/Extension/Music/local_music_assets.cpp @@ -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 mod_music_playlists() { @@ -42,6 +44,10 @@ std::vector mod_music_playlists() { try { std::ifstream in(path, std::ios::binary); auto playlists = mods::parse_music_playlists(mod.name, std::string((std::istreambuf_iterator(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; } -} \ No newline at end of file +} diff --git a/Extension/Music/local_music_assets.h b/Extension/Music/local_music_assets.h index 85352478..f3ba05d1 100644 --- a/Extension/Music/local_music_assets.h +++ b/Extension/Music/local_music_assets.h @@ -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 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 songs; }; diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index b249242d..a5e1aef9 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -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\"}"); } } -} \ No newline at end of file +} diff --git a/Extension/Music/music_artwork.cpp b/Extension/Music/music_artwork.cpp new file mode 100644 index 00000000..9fba0ae6 --- /dev/null +++ b/Extension/Music/music_artwork.cpp @@ -0,0 +1,523 @@ +#include +#include +#include "music_artwork.h" +#include "hpack_tables.h" +#include "Engine/Vfs/mod_music.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(count), 0); + if (sent <= 0) return false; + bytes += sent; + count -= static_cast(sent); + } + return true; +} +bool png_ok(const std::vector& 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(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(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; +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 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(nodes.size()); + nodes.emplace_back(); + } + node = nodes[node].child[bit]; + } + nodes[node].symbol = static_cast(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(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(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(data.data() + at), static_cast(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> 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(p[0])) << 24) | (std::uint32_t(static_cast(p[1])) << 16) | + (std::uint32_t(static_cast(p[2])) << 8) | std::uint32_t(static_cast(p[3])); +} +bool send_frame(SOCKET socket, std::uint8_t type, std::uint8_t flags, std::uint32_t stream, std::span payload) { + std::array header{}; + const auto length = payload.size(); + header[0] = static_cast((length >> 16) & 0xff); + header[1] = static_cast((length >> 8) & 0xff); + header[2] = static_cast(length & 0xff); + header[3] = static_cast(type); + header[4] = static_cast(flags); + header[5] = static_cast((stream >> 24) & 0x7f); + header[6] = static_cast((stream >> 16) & 0xff); + header[7] = static_cast((stream >> 8) & 0xff); + header[8] = static_cast(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& out, unsigned name_index, std::string_view value) { + if (name_index < 15) out.push_back(static_cast(name_index)); + else { out.push_back(0x0f); out.push_back(static_cast(name_index - 15)); } + out.push_back(static_cast(value.size())); + out.insert(out.end(), value.begin(), value.end()); +} +void append_new_name(std::vector& out, std::string_view name, std::string_view value) { + out.push_back(0x00); + out.push_back(static_cast(name.size())); + out.insert(out.end(), name.begin(), name.end()); + out.push_back(static_cast(value.size())); + out.insert(out.end(), value.begin(), value.end()); +} +std::string etag_for(const std::vector& bytes) { + std::uint64_t hash = 1469598103934665603ull; + for (const auto byte : bytes) { hash ^= static_cast(byte); hash *= 1099511628211ull; } + char text[24]; + std::snprintf(text, sizeof(text), "\"%016llx\"", static_cast(hash)); + return text; +} +// :status 200 (indexed 8), content-type (31), content-length (28), cache-control (24), etag. +std::vector response_header_block(std::size_t length, const std::string& etag) { + std::vector out; + out.push_back(static_cast(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(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 chunk{}; + const auto received = recv(socket, chunk.data(), static_cast(chunk.size()), 0); + if (received <= 0) return false; + buffer.append(chunk.data(), static_cast(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(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(c)); + return out; +} +} // namespace + +struct MusicArtworkServer::Impl { + bool winsock = false; + Socket listener; + unsigned short port = 0; + std::mutex mutex; + std::map>, std::less<>> images; // route -> bytes + std::map> paths; + std::size_t total = 0; + std::vector 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(&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(&address), sizeof(address)) != 0 || + listen(bound.value, SOMAXCONN) != 0) return {}; + int size = sizeof(address); + if (getsockname(bound.value, reinterpret_cast(&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(&milliseconds), sizeof(milliseconds)); + setsockopt(client.value, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&milliseconds), sizeof(milliseconds)); + try { handle(client.value); } catch (...) {} + } + } + + void handle(SOCKET client) { + std::array peek{}; + for (;;) { + const auto available = recv(client, peek.data(), static_cast(peek.size()), MSG_PEEK); + if (available <= 0) return; + if (static_cast(available) >= peek.size()) break; + } + if (std::memcmp(peek.data(), h2_preface, sizeof(h2_preface)) == 0) serve_h2c(client); + else respond(client); + } + + std::shared_ptr> 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(&drain_ms), sizeof(drain_ms)); + std::array discard{}; + while (recv(client, discard.data(), static_cast(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(header[0])) << 16) | + (std::size_t(static_cast(header[1])) << 8) | std::size_t(static_cast(header[2])); + type = static_cast(header[3]); + flags = static_cast(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(payload[at])) << 8) | static_cast(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(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(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(&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(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(image->data() + sent, chunk))) return; + sent += chunk; + connection_window -= static_cast(chunk); + stream_window -= static_cast(chunk); + } + send_frame(client, f_goaway, 0, 0, {}); + finish(client); + } + + void respond(SOCKET client) { + std::string request; + std::array 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(buffer.size()), 0); + if (received <= 0) return; + request.append(buffer.data(), static_cast(received)); + } + const auto line = request.substr(0, request.find("\r\n")); + std::shared_ptr> 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()) {} +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>(static_cast(size)); + if (!input.read(bytes->data(), static_cast(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(&mod_music_artwork_url), &module)) return nullptr; + return new MusicArtworkServer; + }(); + return server ? server->add(mod, relative) : std::string{}; +} +} // namespace dingosdk::profile_runtime diff --git a/Extension/Music/music_artwork.h b/Extension/Music/music_artwork.h new file mode 100644 index 00000000..c3e9d442 --- /dev/null +++ b/Extension/Music/music_artwork.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include +#include + +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_; +}; +std::string mod_music_artwork_url(const std::filesystem::path& mod, const std::string& relative); +} diff --git a/README.md b/README.md index 2bd7b2ba..ad3c5e0e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 0ed7c0aa..080f587a 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -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}") diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 5989d22a..cd34199d 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -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