mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-23 05:45:53 +02:00
Merge pull request #48 from DeckardDetribine/pr/mod-music-artwork
Music: custom playlist and song cover artwork for mods
This commit is contained in:
13 files changed
+920
-9
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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\"}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user