mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 00:27:06 +02:00
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>
286 lines
13 KiB
C++
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;
|
|
}
|
|
}
|