mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-19 17:55:22 +02:00
The music menu listed only the playlists the game's own music assets declare. The licensed stations (San Van Soundtrack, 80's Mixtape, ...) live in the content cache's music chunk: a record with the station id (field 1), its tracks (repeated field 2, "Artist - Title") and a message with its display name and cdn:/ artwork (field 10, parts 10 and 11). Read them into Catalogs, merge their membership into the music catalogue (only songs the game registered, so no unknown id is referenced), and send the name and artwork with each playlist. The catalogue test pins two stations from the cache. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
275 lines
13 KiB
C++
275 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));
|
|
}
|
|
|
|
// 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_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;
|
|
}
|
|
}
|