Files
ReSkate/Engine/Vfs/content_catalogs.cpp
DeckardDetribineandClaude Opus 5.5 855e84993a Music: give songs their cover art from the content cache
Song records in the content cache carry a field-10 message (10 artist, 11 title,
12 cdn:/ cover art). Read the art per song and send it as the song's
presentation field 12, resolved to the CDN rendition like a station's art, so
the game's music screen and now-playing toast show covers instead of a black
square. The catalogue test pins one song's art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:12:42 +01:00

286 lines
13 KiB
C++

#include "content_catalogs.h"
#include "content_cache.h"
#include "Engine/Resource/protobuf_wire.h"
#include <algorithm>
#include <fstream>
#include <map>
#include <set>
namespace dingosdk::content_cache {
namespace {
using protobuf::Field;
using protobuf::Wire;
using Fields = std::vector<Field>;
// Catalogue strings are UTF-8 text without control characters.
std::optional<std::string> text(const Field* field) {
if (!field) return std::nullopt;
const auto value = protobuf::text(field->bytes);
if (std::any_of(value.begin(), value.end(), [](char c) {
const auto byte = static_cast<unsigned char>(c);
return byte < 0x20 ? c != '\t' && c != '\n' && c != '\r' : byte == 0x7f;
})) return std::nullopt;
return std::string(value);
}
std::optional<std::string> text(const Fields& fields, std::uint32_t number) {
return text(protobuf::first(fields, number, Wire::bytes));
}
std::optional<Fields> message(const Fields& fields, std::uint32_t number) {
const auto* field = protobuf::first(fields, number, Wire::bytes);
return field ? protobuf::parse(field->bytes) : std::nullopt;
}
std::string lower(std::string value) {
for (auto& c : value) if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + ('a' - 'A'));
return value;
}
// Owned items: the presentation message (6) names the owned asset (6.10), the
// title (6.2) and description (6.3); the rarity is field 23. First record wins.
void add_item(Catalogs& out, const Fields& record) {
const auto presentation = message(record, 6);
if (!presentation) return;
const auto asset = text(*presentation, 10);
if (!asset || lower(asset->substr(0, 4)) != "own_") return;
const auto key = lower(*asset);
if (out.items.contains(key)) return;
if (const auto* flag = protobuf::first(record, 10, Wire::varint); flag && flag->integer == 1)
out.open_items.insert(key);
Json entry = Json::object();
if (const auto description = text(*presentation, 3)) entry["description"] = *description;
if (const auto rarity = text(record, 23)) entry["rarity_id"] = *rarity;
if (const auto title = text(*presentation, 2)) entry["title"] = *title;
if (const auto group = text(*presentation, 5)) entry["group"] = *group;
if (const auto type = text(*presentation, 6)) entry["object_type"] = *type;
out.items[key] = std::move(entry);
}
// Music playlists: field 1 names the playlist, repeated field 2 lists tracks
// ("Artist - Title"), and field 10 is a message carrying the display name and
// artwork. Owned-item records put a varint in field 10, so requiring the byte
// wire separates the two. First record for an id wins.
void add_music_playlist(Catalogs& out, const std::string& id, const Fields& record) {
const auto content = message(record, 10);
if (!content) return;
const auto name = text(*content, 10);
if (!name) return;
std::vector<std::string> tracks;
for (const auto& field : record) {
if (field.number != 2 || field.wire != Wire::bytes) continue;
const auto value = text(&field);
if (!value || value->find(" - ") == std::string::npos) continue;
tracks.push_back(*value);
}
if (tracks.size() < 2) return;
Catalogs::MusicPlaylistEntry entry;
entry.name = *name;
entry.artwork = text(*content, 11).value_or("");
entry.tracks = std::move(tracks);
out.music_playlists.try_emplace(id, std::move(entry));
}
// Songs: field 1 is "Artist - Title" and field 10 a message with the artist (10), title (11) and
// cdn:/ cover art (12). First record for an id wins.
void add_music_song(Catalogs& out, const std::string& id, const Fields& record) {
if (id.find(" - ") == std::string::npos) return;
const auto content = message(record, 10);
if (!content || !text(*content, 10) || !text(*content, 11)) return;
if (const auto artwork = text(*content, 12); artwork && artwork->starts_with("cdn:/"))
out.music_song_artwork.try_emplace(id, *artwork);
}
// Grant lists: repeated `number` entries, each {1: kind, 2: id}.
struct Grant { std::string kind, id; };
std::vector<Grant> grants(const Fields& fields, std::uint32_t number) {
std::vector<Grant> out;
for (const auto& field : fields) {
if (field.number != number || field.wire != Wire::bytes) continue;
const auto list = protobuf::parse(field.bytes);
if (!list) continue;
for (const auto& entry : *list) {
if (entry.number != 1 || entry.wire != Wire::bytes) continue;
const auto grant = protobuf::parse(entry.bytes);
if (!grant) continue;
if (auto kind = text(*grant, 1), id = text(*grant, 2); kind && id)
out.push_back({std::move(*kind), std::move(*id)});
}
}
return out;
}
bool free_offer(const Fields& offer) {
const auto price = message(offer, 4);
const auto costs = price ? grants(*price, 2) : std::vector<Grant>{};
if (costs.empty() || !std::all_of(costs.begin(), costs.end(), [](const Grant& cost) {
return cost.kind == "res" && (cost.id == "influence" || cost.id == "skatepass_currency");
})) return false;
const auto prerequisites = grants(offer, 8);
return std::none_of(prerequisites.begin(), prerequisites.end(), [](const Grant& prerequisite) {
return lower(prerequisite.id).find("premium") != std::string::npos;
});
}
void add_source(std::set<std::string>& open, const std::string& id, const Fields& record) {
const auto open_grants = [&](std::uint32_t number) {
for (const auto& grant : grants(record, number))
if (grant.kind == "own-create" && lower(grant.id.substr(0, 4)) == "own_") open.insert(lower(grant.id));
};
if (id.ends_with("Price")) {
if (free_offer(record)) open_grants(11);
return;
}
open_grants(8);
const auto kind = text(record, 3).value_or("");
if (kind == "progression-item") open_grants(7);
if (kind.find("-event-") != std::string::npos) open_grants(4);
if (text(record, 4).value_or("").starts_with("neighbourhood_rank")) open_grants(20);
}
std::vector<std::string> texts(const Fields& fields, std::uint32_t number) {
std::vector<std::string> result;
for (const auto& field : fields)
if (field.number == number && field.wire == Wire::bytes)
if (auto value = text(&field)) result.push_back(std::move(*value));
return result;
}
std::uint32_t priority(const Fields& fields) {
const auto* field = protobuf::first(fields, 2, Wire::varint);
return field ? static_cast<std::uint32_t>(field->integer) : 0;
}
// Category service records: id (1), priority (2), sub-categories (5, each with
// id 1, priority 2 and presentation 6 holding the title in 1), tags (6) and a
// presentation (7) with the title (1) and icon (3). Build-kit categories carry
// the "buildkit" tag; the Object Browser lists the ones tagged "qdbuildkit".
void add_object_category(Catalogs& out, const std::string& id, const Fields& record) {
const auto tags = texts(record, 6);
if (std::find(tags.begin(), tags.end(), "buildkit") == tags.end()) return;
const auto presentation = message(record, 7);
if (!presentation) return;
ObjectCategory category{id, text(*presentation, 1).value_or(id), text(*presentation, 3).value_or(""),
priority(record), std::find(tags.begin(), tags.end(), "qdbuildkit") != tags.end(), {}};
for (const auto& field : record) {
if (field.number != 5 || field.wire != Wire::bytes) continue;
const auto group = protobuf::parse(field.bytes);
if (!group) return;
const auto group_id = text(*group, 1);
if (!group_id) return;
const auto title = message(*group, 6);
category.groups.push_back({*group_id, (title ? text(*title, 1) : std::nullopt).value_or(*group_id),
priority(*group)});
}
for (const auto& known : out.object_categories)
if (known.id == id) return;
out.object_categories.push_back(std::move(category));
}
// Challenges are the records with goals (21). Type is field 2, neighbourhood 7,
// the title key 30.10 (30.11 is the type's name, 30.12 the short description). Each goal has its id (1), order (2), optional flag (5)
// and text keys in 10: title 10, feed 11, short description 12, description 13.
void add_challenge(Catalogs& out, const std::string& id, const Fields& record) {
struct Goal { std::uint64_t order; Json value; };
std::vector<Goal> goals;
for (const auto& field : record) {
if (field.number != 21 || field.wire != Wire::bytes) continue;
const auto goal = protobuf::parse(field.bytes);
if (!goal) return;
const auto goal_id = text(*goal, 1);
if (!goal_id) return;
const auto* order = protobuf::first(*goal, 2, Wire::varint);
const auto* optional = protobuf::first(*goal, 5, Wire::varint);
Json value{{"id", *goal_id}, {"optional", optional && optional->integer != 0}};
if (const auto keys = message(*goal, 10)) {
for (const auto& [number, name] : {std::pair{10u, "title_key"}, {11u, "feed_key"},
{12u, "short_description_key"}, {13u, "description_key"}})
if (const auto key = text(*keys, number)) value[name] = *key;
}
goals.push_back({order ? order->integer : 0, std::move(value)});
}
const auto type = text(record, 2);
if (goals.empty() || !type) return;
std::stable_sort(goals.begin(), goals.end(), [](const Goal& a, const Goal& b) { return a.order < b.order; });
Json entry{{"type", *type}, {"available", true},
{"asset", "activities/challenges/prefabs/" + lower(*type) + "/" + lower(id) + "_ecsprefab"}};
auto list = Json::array();
for (auto& goal : goals) list.push_back(std::move(goal.value));
entry["goals"] = std::move(list);
if (const auto presentation = message(record, 30)) {
if (const auto title = text(*presentation, 10)) entry["title_key"] = *title;
if (const auto description = text(*presentation, 12)) entry["description_key"] = *description;
}
if (const auto neighborhood = text(record, 7)) entry["neighborhood"] = *neighborhood;
out.challenges[id] = std::move(entry);
}
// Travel: locations carry the level (3) and a presentation (30) with the title
// (2), description (3), image (5), medium (6) and white/black icons (7, 8).
// Access points list their destinations in field 2.
void add_travel(Catalogs& out, const std::string& id, const Fields& record) {
if (id.starts_with("accesspoint_")) {
for (const auto& known : out.travel_access_points)
if (known.first == id) return;
auto targets = texts(record, 2);
if (!targets.empty()) out.travel_access_points.emplace_back(id, std::move(targets));
return;
}
if (!id.starts_with("location_")) return;
const auto level = text(record, 3);
const auto presentation = message(record, 30);
if (!level || !presentation) return;
for (const auto& known : out.travel_locations)
if (known.id == id) return;
const auto field = [&](std::uint32_t number) { return text(*presentation, number).value_or(""); };
out.travel_locations.push_back({id, *level, field(2), field(3), field(6), field(7), field(8), field(5)});
}
std::vector<unsigned char> read_file(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
}
Catalogs read_catalogs(const std::filesystem::path& folder) {
Catalogs out;
std::vector<std::filesystem::path> bodies;
for (const auto& entry : std::filesystem::directory_iterator(folder))
if (entry.is_regular_file() && entry.path().extension() == L".cache") bodies.push_back(entry.path());
std::sort(bodies.begin(), bodies.end());
std::set<std::string> entitlements;
for (const auto& path : bodies) {
const auto bytes = read_file(path);
// Image bodies (DDS, PNG) and anything else that is not a framed
// record stream is skipped as a whole.
const auto records = protobuf::frames(bytes);
if (!records) continue;
for (const auto record : *records) {
const auto fields = protobuf::parse(record);
if (!fields) continue;
const auto id = text(*fields, 1);
if (!id) continue;
if (text(*fields, 3) == "entitlement") entitlements.insert(*id);
add_item(out, *fields);
add_music_playlist(out, *id, *fields);
add_music_song(out, *id, *fields);
add_challenge(out, *id, *fields);
add_object_category(out, *id, *fields);
add_travel(out, *id, *fields);
add_source(out.open_items, *id, *fields);
}
}
out.entitlements.assign(entitlements.begin(), entitlements.end());
out.available = true;
return out;
}
bool Catalogs::reserved(const std::string& key) const {
if (!items.contains(key) || open_items.contains(key)) return false;
return !key.starts_with("dev_") && key.find("_dev_") == std::string::npos && !key.ends_with("_dev");
}
const Catalogs& catalogs() {
static const Catalogs value = [] {
try {
if (installed()) return read_catalogs(directory());
} catch (...) {}
return Catalogs{};
}();
return value;
}
}