Merge pull request #358 from hypnomalice/runtime/declared-board-materials

Plugins: support declared colour inputs on mod-owned board materials

Conflicts with #363 resolved by keeping both: reconcile_variant_additions beside
active_mod_sources in mod_catalog.h, and both test targets in cmake/Apps.cmake.
This commit is contained in:
AyeZeeBB committed 2026-10-25 14:38:17 -05:00
commit 7170938799
21 files changed
+1364 -50

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <filesystem>
#include <fstream>
#include <string>
#include <stdexcept>
#ifdef _WIN32
#include <Windows.h>
#endif
namespace dingosdk {
// Bounded reads share delete access so a writer can rename the old document.
class SharedTextFile {
public:
explicit SharedTextFile(const std::filesystem::path& path)
#ifdef _WIN32
: handle_(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)) {
if (handle_ == INVALID_HANDLE_VALUE) throw std::runtime_error("Cannot open content document");
#else
: file_(path, std::ios::binary | std::ios::ate) {
if (!file_) throw std::runtime_error("Cannot open content document");
#endif
}
SharedTextFile(const SharedTextFile&) = delete;
SharedTextFile& operator=(const SharedTextFile&) = delete;
~SharedTextFile() {
#ifdef _WIN32
CloseHandle(handle_);
#endif
}
std::string read(std::size_t limit) {
#ifdef _WIN32
LARGE_INTEGER length{};
if (!GetFileSizeEx(handle_, &length) || length.QuadPart < 0 ||
static_cast<unsigned long long>(length.QuadPart) > limit || limit > MAXDWORD)
throw std::runtime_error("Content document exceeds its read bound");
std::string text(static_cast<std::size_t>(length.QuadPart), '\0');
DWORD got{};
if (!ReadFile(handle_, text.data(), static_cast<DWORD>(text.size()), &got, nullptr) || got != text.size())
throw std::runtime_error("Cannot finish content document read");
#else
const auto length = file_.tellg();
if (length < 0 || static_cast<std::uintmax_t>(length) > limit) throw std::runtime_error("Content document exceeds its read bound");
std::string text(static_cast<std::size_t>(length), '\0');
file_.seekg(0);
if (!file_.read(text.data(), static_cast<std::streamsize>(text.size()))) throw std::runtime_error("Cannot finish content document read");
#endif
return text;
}
private:
#ifdef _WIN32
HANDLE handle_;
#else
std::ifstream file_;
#endif
};
inline std::string read_shared_text(const std::filesystem::path& path, std::size_t limit) {
return SharedTextFile(path).read(limit);
}
}
@@ -0,0 +1,87 @@
#include "Engine/Vfs/material_declaration_refresh.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Engine/Core/Platform/shared_file.h"
#include <Windows.h>
#include <fstream>
#include <iostream>
#include <stdexcept>
using namespace dingosdk;
namespace fs = std::filesystem;
namespace {
void check(bool ok, const char* message) { if (!ok) throw std::runtime_error(message); }
void write(const fs::path& path, std::string_view text) {
std::ofstream file(path, std::ios::binary | std::ios::trunc);
file << text;
if (!file) throw std::runtime_error("fixture write failed");
}
}
int main() try {
const auto root = fs::temp_directory_path() / ("reskate-declaration-refresh-" + std::to_string(GetCurrentProcessId()));
fs::create_directories(root);
const auto file = root / "reskate-items.json";
write(file, R"({"mods":[{"mod":"Owner","items":["own_test"]},{"mod":"LowerPriority","items":["own_test"]}]})");
auto sources = mods::read_item_sources_checked(file);
check(sources && mods::item_source_owner(*sources, "OWN_TEST") == "Owner", "actual ownership reader resolves a case-insensitive item to the highest-priority source");
check(mods::item_source_owner(*sources, "own_missing").empty(), "unknown ownership grants nothing");
mods::MaterialDeclarationRefresh refresh;
std::size_t loads{};
const auto load = [&] {
++loads;
auto parsed = mods::read_item_sources_checked(file);
if (!parsed) throw std::runtime_error("partial ownership write");
mods::MaterialDeclarations value;
value.sources = std::move(*parsed);
return value;
};
auto signature = std::vector{mods::declaration_file_stamp(file)};
check(refresh.update(signature, load), "initial declarations are loaded");
const auto good = refresh.value();
for (int poll = 0; poll < 100; ++poll) check(!refresh.update(signature, load), "unchanged polls do not reload");
check(loads == 1, "unchanged polling never invokes the JSON loader");
signature[0].revision = 1;
check(refresh.update(signature, load) && loads == 2, "a live apply invalidates ownership even when file metadata is unchanged");
const auto after_apply = refresh.value();
write(file, "{partial");
signature = {mods::declaration_file_stamp(file)};
bool failed{};
try { refresh.update(signature, load); } catch (const std::exception&) { failed = true; }
check(failed && refresh.value() == after_apply && good != after_apply, "partial reads retain the exact last good ownership snapshot");
write(file, R"({"mods":[]})");
signature = {mods::declaration_file_stamp(file)};
check(refresh.update(signature, load) && refresh.value()->sources.empty(), "valid empty ownership differs from failed reads");
const auto replacement = root / "replacement.json";
write(replacement, R"({"mods":[{"mod":"NewOwner","items":["own_test"]}]})");
{
SharedTextFile held(file);
// Windows may refuse replacing an open target even with delete sharing,
// but the read must at least allow the old document to be renamed away.
const auto retired = root / "retired.json";
fs::rename(file, retired);
fs::rename(replacement, file);
check(held.read(1024) == R"({"mods":[]})", "an open ownership read permits renaming the old document and retains its old file");
}
sources = mods::read_item_sources_checked(file);
check(sources && mods::item_source_owner(*sources, "own_test") == "NewOwner", "the next read sees the replaced ownership document");
failed = false;
try { (void)read_shared_text(file, 1); } catch (const std::exception&) { failed = true; }
check(failed, "shared reads retain their size bound");
mods::Catalog active;
mods::Mod running; running.name = "Running"; running.directory = root / "running";
active.mods.push_back(running);
mods::variants_applied(active);
const auto before = mods::active_mod_sources();
write(root / "mods.json", R"({"mods":[{"name":"Unapplied","enabled":true}]})");
check(mods::active_mod_sources() == before && before.size() == 1 && before[0].name == "Running",
"editing saved settings does not activate unapplied mod sources");
active.mods[0].name = "Applied";
active.mods[0].directory = root / "applied";
mods::variants_applied(active);
check(mods::active_mod_sources()[0].directory == root / "applied", "successful live apply publishes the actual source directory");
fs::remove(file); fs::remove(root / "retired.json"); fs::remove(root / "mods.json"); fs::remove(root);
std::cout << "material declaration refresh tests passed\n";
return 0;
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }
+141
View File
@@ -0,0 +1,141 @@
// Checks how a mod's reskate-materials.json is read: a GUID's ParamDbKey hash and identity, what a
// declaration may hold (colours and numbers in bounds, never integers, switches or textures), the
// limits, and that a document of the wrong shape is refused whole.
#include "Engine/Vfs/mod_materials.h"
#include <iostream>
#include <stdexcept>
#include <string>
namespace {
int failures = 0;
void check(bool condition, const std::string& message) {
if (!condition) { std::cerr << "FAIL: " << message << '\n'; ++failures; }
}
using dingosdk::mods::parse_material_declarations;
bool refused(const std::string& json) {
try { parse_material_declarations("mod", json); } catch (const std::exception&) { return true; }
return false;
}
std::string with_parameter(const std::string& parameter, const std::string& part = "deck") {
return R"({"schema":1,"materials":[{"part":")" + part + R"(","items":["Example_Deck"],"parameters":[)" + parameter + "]}]}";
}
constexpr const char* guid = "1af346775c032d5b59ff5e88010069b6";
}
int main() try {
// The eight inputs of the Example declared-input deck: their GUIDs and the hash and identity the game
// keys them by (parameter-contract.json).
struct Known { const char* guid; std::uint64_t key; std::uint16_t identity; };
const Known known[] = {
{"1af346775c032d5b59ff5e88010069b6", 6569911127076434714ULL, 46697},
{"d317d1d20f5c745159ff5e880100dcb0", 5869417437401782227ULL, 45276},
{"ed58e12fb758105c59ff5e8801005aea", 6633899794922297581ULL, 59994},
{"9f361c20ac084e5a59ff5e8801006ffe", 6507148046963062431ULL, 65135},
{"c7c08365cf15fc5759ff5e8801006651", 6339966355936362695ULL, 20838},
{"83f212bddd522f5a59ff5e880100e557", 6498503899632300675ULL, 22501},
{"97901b52e1a1fd5559ff5e88010012b9", 6196286651448922263ULL, 47378},
{"126f57aa15de0b5859ff5e8801003944", 6344408684715011858ULL, 17465},
};
for (const auto& k : known) {
std::uint64_t key{};
std::uint16_t identity{};
check(dingosdk::mods::material_parameter_key(k.guid, key, identity) && key == k.key && identity == k.identity,
std::string("GUID ") + k.guid + " keys as the game keys it");
}
std::uint64_t key{};
std::uint16_t identity{};
check(!dingosdk::mods::material_parameter_key("1af34677", key, identity), "A GUID is 32 hex digits");
check(!dingosdk::mods::material_parameter_key("zzf346775c032d5b59ff5e88010069b6", key, identity), "of hex");
{
const auto read = parse_material_declarations("Example", R"({
"schema": 1,
"materials": [{
"part": "deck",
"items": ["Example_Deck", "Example_Deck_Wide"],
"parameters": [
{"guid": "1af346775c032d5b59ff5e88010069b6", "type": "colour", "default": [0.5, 0.5, 0.5]},
{"guid": "126f57aa15de0b5859ff5e8801003944", "type": "color", "default": [0, 0, 0]},
{"guid": "d317d1d20f5c74512d5cb62b0100dcb0", "type": "number", "default": -2.5}
]
}]
})");
check(read.size() == 1 && read[0].mod == "Example" && read[0].part == "deck" && read[0].items.size() == 2, "A material is read");
const auto& p = read[0].parameters;
check(p.size() == 3 && p[0].colour && p[0].key == 6569911127076434714ULL && p[0].identity == 46697 &&
p[0].fallback == std::array<float, 3>{.5f, .5f, .5f},
"with its colours keyed from their GUIDs and their defaults");
check(p[1].colour, "either spelling of colour");
check(!p[2].colour && p[2].fallback[0] == -2.5f, "and a number");
}
{
std::string collection = R"({"schema":1,"materials":[)";
for (int deck = 0; deck < 14; ++deck) {
if (deck) collection += ',';
collection += R"({"part":"deck","items":["Own_Test_)" + std::to_string(deck) + R"("],"parameters":[)";
for (int channel = 0; channel < 8; ++channel) {
if (channel) collection += ',';
collection += R"({"guid":")" + std::string(known[channel].guid) +
R"(","type":"colour","default":[0,0,)" + std::to_string(deck / 16.0) + "]}";
}
collection += "]}";
}
const auto declarations = parse_material_declarations("Collection", collection + "]}");
check(declarations.size() == 14, "A fourteen-deck collection is accepted as a whole");
for (const auto& material : declarations)
check(material.items.size() == 1 && material.parameters.size() == 8,
"Each deck retains its eight defaults and item scope");
check(declarations.front().parameters.back().fallback != declarations.back().parameters.back().fallback,
"Different deck defaults survive parsing");
}
// Only what a plugin may set, in bounds.
check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"integer","default":13})")),
"An integer picks how the item is drawn: not declarable");
check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"switch","default":true})")), "nor a switch");
check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"texture","default":0})")), "nor a texture");
check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"colour","default":[1.5,0,0]})")), "A colour is 0 to 1");
check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"colour","default":[1,0]})")), "with three channels");
check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"number","default":1000})")), "A number has bounds");
check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"number","default":"1"})")), "and is a number");
check(refused(with_parameter(R"({"guid":"1af34677","type":"colour","default":[0,0,0]})")), "A GUID is 32 hex digits");
check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"colour","default":[0,0,0]},{"guid":")" +
std::string(guid) + R"(","type":"colour","default":[1,1,1]})")),
"A parameter is declared once");
check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"colour","default":[0,0,0]})", "skater")),
"A part is one of the board's");
// The wrong shape is refused whole.
check(refused(R"({"schema":1.5,"materials":[]})"),"Fractional schema rejected");
check(refused(with_parameter(R"({"guid":"1af346775c032d5b2d5cb62b010069b6","type":"colour","default":[0,0,0]})")),"GUID type must match declared colour");
check(refused(with_parameter(R"({"guid":"1af346775c032d5b59ff5e88020069b6","type":"colour","default":[0,0,0]})")),"GUID count must be one");
check(refused("{}"), "A document without a schema");
check(refused(R"({"schema":2,"materials":[]})"), "or of another schema");
check(refused(R"({"schema":1,"materials":[{"part":"deck","items":[],"parameters":[]}]})"), "A material with no items");
check(refused(R"({"schema":1,"materials":[{"part":"deck","items":["a"],"parameters":[]}]})"), "or no parameters");
check(refused(R"({"schema":1,"materials":[{"part":"deck","items":["bad
name"],"parameters":[{"guid":"1af346775c032d5b59ff5e88010069b6","type":"colour","default":[0,0,0]}]}]})"),
"An item's key is text without control characters");
{
std::string many = R"({"schema":1,"materials":[)";
for (int i = 0; i < 33; ++i) many += std::string(i ? "," : "") + R"({"part":"deck","items":["a"],"parameters":[{"guid":")" + guid + R"(","type":"colour","default":[0,0,0]}]})";
check(refused(many + "]}"), "At most 32 material groups");
std::string parameters;
for (int i = 0; i < 33; ++i) {
char g[33];
std::snprintf(g, sizeof(g), "%02x%030x", i + 1, i + 1);
parameters += std::string(i ? "," : "") + R"({"guid":")" + g + R"(","type":"colour","default":[0,0,0]})";
}
check(refused(with_parameter(parameters)), "and 32 parameters to a material");
}
if (failures) return 1;
std::cout << "mod materials tests passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << "FAIL: " << error.what() << '\n';
return 1;
}
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include "mod_materials.h"
#include "mod_item_sources.h"
#include <memory>
#include <utility>
namespace dingosdk::mods {
struct MaterialDeclarations {
std::uint64_t revision{};
std::vector<MaterialDeclaration> materials;
std::vector<ItemSource> sources;
};
struct DeclarationFileStamp {
std::filesystem::path path;
std::string owner;
bool present{};
std::uintmax_t size{};
std::filesystem::file_time_type written{};
std::uint64_t revision{};
bool operator==(const DeclarationFileStamp&) const = default;
};
inline DeclarationFileStamp declaration_file_stamp(const std::filesystem::path& path, std::string owner = {}) {
DeclarationFileStamp stamp{path, std::move(owner)};
stamp.present = std::filesystem::exists(path);
if (stamp.present) {
stamp.size = std::filesystem::file_size(path);
stamp.written = std::filesystem::last_write_time(path);
}
return stamp;
}
// Owned by the single refresh worker. No successful signature or value changes
// until a complete load succeeds, so failed/transient reads can be retried.
class MaterialDeclarationRefresh {
public:
template<class Load> bool update(const std::vector<DeclarationFileStamp>& signature, Load&& load) {
if (value_ && signature == signature_) return false;
auto next = std::make_shared<const MaterialDeclarations>(load());
auto next_signature = signature;
signature_ = std::move(next_signature);
value_ = std::move(next);
return true;
}
std::shared_ptr<const MaterialDeclarations> value() const { return value_; }
private:
std::vector<DeclarationFileStamp> signature_;
std::shared_ptr<const MaterialDeclarations> value_;
};
}
+18 -1
View File
@@ -12,6 +12,7 @@
#include <Windows.h>
#include <shellapi.h>
#include <algorithm>
#include <atomic>
#include <array>
#include <cstdint>
#include <chrono>
@@ -143,10 +144,12 @@ using VariantMap = std::map<std::string, std::string, std::less<>>;
struct VariantsApplied {
VariantMap in_play;
std::vector<std::string> set_aside;
std::vector<ModSource> sources;
};
std::mutex variants_mutex;
std::optional<VariantMap> chosen_variants;
std::optional<VariantsApplied> applied_variants;
std::atomic<std::uint64_t> applied_revision{};
VariantMap read_choices() {
VariantMap out;
const auto path = variants_file();
@@ -253,10 +256,24 @@ std::vector<std::string> set_aside_for_variants() {
void variants_applied(const Catalog& catalog) noexcept {
try {
std::lock_guard lock(variants_mutex);
applied_variants = VariantsApplied{catalog.variants_in_play, catalog.set_aside};
std::vector<ModSource> sources;
for (const auto& mod : catalog.mods) sources.push_back({mod.name, mod.directory});
applied_variants = VariantsApplied{catalog.variants_in_play, catalog.set_aside, std::move(sources)};
applied_revision.fetch_add(1, std::memory_order_release);
} catch (...) {}
}
std::vector<ModSource> active_mod_sources() {
{
std::lock_guard lock(variants_mutex);
if (applied_variants) return applied_variants->sources;
}
std::vector<ModSource> result;
for (const auto& mod : catalog().mods) result.push_back({mod.name, mod.directory});
return result;
}
std::uint64_t active_sources_revision() noexcept { return applied_revision.load(std::memory_order_acquire); }
std::filesystem::path engine_data_root() noexcept {
try {
std::array<wchar_t, 32768> module_path{};
+11
View File
@@ -5,6 +5,7 @@
#include <array>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <string>
#include <string_view>
@@ -52,6 +53,16 @@ struct Catalog {
// selected variant. Withdraw orphan donors and rebuild authority from survivors.
void reconcile_variant_additions(Catalog& catalog);
struct ModSource {
std::string name;
std::filesystem::path directory;
bool operator==(const ModSource&) const = default;
};
// The launch catalogue, or the last successfully applied catalogue. Reading
// saved mods.json alone must not activate content that is not running yet.
std::vector<ModSource> active_mod_sources();
std::uint64_t active_sources_revision() noexcept;
// Variants (mod_list.h). For the run each variant is a mod of its own, listed after the mod that
// holds it as <mod's folder>#<variant>, with that mod's build stamp; mods.json and the mod list only
// know the mod. One variant of each such mod loads, the one the player chose (else the first of its
+8 -5
View File
@@ -2,6 +2,7 @@
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Platform/path_text.h"
#include "Engine/Core/Platform/shared_file.h"
#include <algorithm>
#include <fstream>
@@ -11,12 +12,10 @@
namespace dingosdk::mods {
namespace fs = std::filesystem;
std::vector<ItemSource> read_item_sources(const fs::path& file) noexcept {
std::optional<std::vector<ItemSource>> read_item_sources_checked(const fs::path& file) noexcept {
std::vector<ItemSource> sources;
try {
std::ifstream input(file, std::ios::binary);
if (!input) return {};
const std::string text{std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
const auto text = read_shared_text(file, 64u << 20);
const auto root = Json::parse(text, {64u << 20, 32, 1u << 24});
const auto& mods = root.at("mods");
for (std::size_t i = 0; i < mods.size(); ++i) {
@@ -28,11 +27,15 @@ std::vector<ItemSource> read_item_sources(const fs::path& file) noexcept {
sources.push_back(std::move(source));
}
} catch (...) {
return {}; // an unreadable file only means no card says where its item is from
return std::nullopt;
}
return sources;
}
std::vector<ItemSource> read_item_sources(const fs::path& file) noexcept {
return read_item_sources_checked(file).value_or(std::vector<ItemSource>{});
}
void write_item_sources(const fs::path& file, const std::vector<ItemSource>& sources) {
auto mods = Json::array();
for (const auto& source : sources) {
+17
View File
@@ -1,7 +1,9 @@
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
// Which mod each cosmetic item came from, so its card in the customization menu can say so:
@@ -18,9 +20,24 @@ struct ItemSource {
std::vector<std::string> keys; // the items it adds or changes, lower case
};
inline std::string item_key(std::string_view item) {
std::string key(item);
for (auto& c : key) if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + ('a' - 'A'));
return key;
}
inline std::string_view item_source_owner(const std::vector<ItemSource>& sources, std::string_view item) {
const auto key = item_key(item);
for (const auto& source : sources)
for (const auto& candidate : source.keys) if (candidate == key) return source.mod;
return {};
}
// Highest priority first, as the merge orders the mods: where two list one item, the game uses
// the first one's, so that is where it is from. Empty when there is no file or it cannot be read.
[[nodiscard]] std::vector<ItemSource> read_item_sources(const std::filesystem::path& file) noexcept;
// Unlike the card's best-effort reader, ownership-sensitive callers must retain
// their last good snapshot when a document cannot be read or parsed.
[[nodiscard]] std::optional<std::vector<ItemSource>> read_item_sources_checked(const std::filesystem::path& file) noexcept;
// Throws when the file cannot be written.
void write_item_sources(const std::filesystem::path& file, const std::vector<ItemSource>& sources);
+116
View File
@@ -0,0 +1,116 @@
#include "Engine/Vfs/mod_materials.h"
#include "Engine/Core/Json/json.h"
#include <algorithm>
#include <cmath>
#include <set>
#include <stdexcept>
namespace dingosdk::mods {
namespace {
constexpr std::size_t most_materials = 32, most_items = 64, most_parameters = 32;
constexpr float most_number = 100.0f; // board_material::most_number
constexpr std::array<const char*, 4> parts{"deck", "grip", "trucks", "wheels"};
bool text_ok(const std::string& value) {
return !value.empty() && value.size() <= 255 &&
std::none_of(value.begin(), value.end(), [](unsigned char c) { return c < 32 || c == 127; });
}
int hex_digit(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
float finite(const Json& value, const char* what) {
if (!value.is_number()) throw std::runtime_error(std::string(what) + " is not a number");
const auto number = value.get<double>();
if (!std::isfinite(number)) throw std::runtime_error(std::string(what) + " is not finite");
return static_cast<float>(number);
}
MaterialParameter parameter(const Json& entry) {
if (!entry.is_object() || !entry.contains("guid") || !entry.at("guid").is_string() || !entry.contains("type") ||
!entry.at("type").is_string() || !entry.contains("default"))
throw std::runtime_error("a parameter needs a guid, a type and a default");
MaterialParameter out;
if (!material_parameter_key(entry.at("guid").string(), out.key, out.identity))
throw std::runtime_error("a parameter's guid is 32 hex digits");
const auto type = entry.at("type").string();
const auto& fallback = entry.at("default");
auto guid = entry.at("guid").string();
std::transform(guid.begin(), guid.end(), guid.begin(), [](unsigned char c) {
return static_cast<char>(c >= 'A' && c <= 'F' ? c + ('a' - 'A') : c);
});
const auto expected = (type == "colour" || type == "color") ? "59ff5e88" : "2d5cb62b";
if (guid.substr(16, 8) != expected || guid.substr(24, 4) != "0100")
throw std::runtime_error("parameter GUID must match its declared type and have count 1");
if (type == "colour" || type == "color") {
out.colour = true;
if (!fallback.is_array() || fallback.size() != 3) throw std::runtime_error("a colour's default is [r, g, b]");
for (std::size_t c = 0; c < 3; ++c) {
out.fallback[c] = finite(fallback.at(c), "a colour channel");
if (out.fallback[c] < 0 || out.fallback[c] > 1) throw std::runtime_error("a colour's channels are 0 to 1");
}
} else if (type == "number") {
out.fallback[0] = finite(fallback, "a number's default");
if (std::abs(out.fallback[0]) > most_number) throw std::runtime_error("a number is -100 to 100");
} else {
// An integer, a switch or a texture picks how the item is drawn: not declarable.
throw std::runtime_error("a parameter's type is \"colour\" or \"number\"");
}
return out;
}
} // namespace
bool material_parameter_key(const std::string& guid, std::uint64_t& key, std::uint16_t& identity) {
if (guid.size() != 32) return false;
std::array<std::uint8_t, 16> bytes{};
for (std::size_t i = 0; i < bytes.size(); ++i) {
const int high = hex_digit(guid[i * 2]), low = hex_digit(guid[i * 2 + 1]);
if (high < 0 || low < 0) return false;
bytes[i] = static_cast<std::uint8_t>(high * 16 + low);
}
// As the game serializes a ParamDbKey: the hash is the GUID's first eight bytes, little-endian,
// and the identity its last two (developer_hoodie_detail::ParameterKey).
key = 0;
for (std::size_t i = 0; i < 8; ++i) key |= std::uint64_t{bytes[i]} << (8 * i);
identity = static_cast<std::uint16_t>(bytes[14] | (bytes[15] << 8));
return key != 0;
}
std::vector<MaterialDeclaration> parse_material_declarations(const std::string& mod, const std::string& json) {
const auto root = Json::parse(json);
if (!root.is_object() || !root.contains("schema") || !root.at("schema").is_number_integer() || root.at("schema").get<int>() != 1 ||
!root.contains("materials") || !root.at("materials").is_array())
throw std::runtime_error("expected schema 1 with a materials array");
const auto& list = root.at("materials");
if (list.size() > most_materials) throw std::runtime_error("at most 32 materials");
std::vector<MaterialDeclaration> result;
for (std::size_t i = 0; i < list.size(); ++i) {
const auto& entry = list.at(i);
if (!entry.is_object() || !entry.contains("part") || !entry.at("part").is_string() || !entry.contains("items") ||
!entry.at("items").is_array() || !entry.contains("parameters") || !entry.at("parameters").is_array())
throw std::runtime_error("a material needs a part, items and parameters");
MaterialDeclaration declaration{mod, entry.at("part").string(), {}, {}};
if (std::find(parts.begin(), parts.end(), declaration.part) == parts.end())
throw std::runtime_error("a material's part is \"deck\", \"grip\", \"trucks\" or \"wheels\"");
const auto& items = entry.at("items");
const auto& parameters = entry.at("parameters");
if (items.empty() || items.size() > most_items) throw std::runtime_error("a material names 1 to 64 items");
if (parameters.empty() || parameters.size() > most_parameters) throw std::runtime_error("a material declares 1 to 32 parameters");
for (std::size_t k = 0; k < items.size(); ++k) {
if (!items.at(k).is_string() || !text_ok(items.at(k).string())) throw std::runtime_error("an item is its key, as text");
declaration.items.push_back(items.at(k).string());
}
std::set<std::uint64_t> keys;
for (std::size_t k = 0; k < parameters.size(); ++k) {
auto p = parameter(parameters.at(k));
if (!keys.insert(p.key).second) throw std::runtime_error("a parameter is declared twice");
declaration.parameters.push_back(p);
}
result.push_back(std::move(declaration));
}
return result;
}
} // namespace dingosdk::mods
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <array>
#include <cstdint>
#include <string>
#include <vector>
namespace dingosdk::mods {
// A shader input a mod's own board item declares for plugins (reskate.board.set_parameter): a
// colour or a number its compiled shader reads, whose default lives in that shader and so is in
// no material's live parameters until something sets it. Integers, switches and textures pick how
// an item is drawn and cannot be declared.
struct MaterialParameter {
std::uint64_t key{}; // the ParamDbKey hash: the GUID's first eight bytes
std::uint16_t identity{}; // the ParamDbKey identity: the GUID's last two bytes
bool colour{}; // a ColorRgb, else a Float32
std::array<float, 3> fallback{}; // the shader's own default (a number in [0])
};
struct MaterialDeclaration {
std::string mod;
std::string part; // "deck", "grip", "trucks" or "wheels"
std::vector<std::string> items; // the item keys whose material these belong to
std::vector<MaterialParameter> parameters;
};
// Reads a mod's reskate-materials.json:
// {"schema": 1, "materials": [{"part": "deck", "items": ["<item key>", ...],
// "parameters": [{"guid": "<32 hex digits>", "type": "colour", "default": [r, g, b]},
// {"guid": "...", "type": "number", "default": 0.5}]}]}
// A GUID is the parameter's 16 serialized bytes as hex, as ReSkate Studio shows them. A colour's
// channels are 0 to 1; a number is -100 to 100 (board_material::most_number). Text is non-empty, at
// most 255 bytes and free of control characters. At most 32 materials, 64 items and 32 parameters
// each. Throws std::exception on a document that is not this shape (the whole file is skipped:
// half a declaration could hand a shader a value it does not expect).
std::vector<MaterialDeclaration> parse_material_declarations(const std::string& mod, const std::string& json);
// The ParamDbKey hash and identity of a GUID given as 32 hex digits; false if it is not one.
bool material_parameter_key(const std::string& guid, std::uint64_t& key, std::uint16_t& identity);
} // namespace dingosdk::mods
@@ -37,6 +37,7 @@ constexpr std::uint64_t number_key = 0x841c7d74837f1d39ULL, integer_key = 0xb00a
struct Board {
Memory memory;
std::uintptr_t colour_type{};
std::uintptr_t entity = 0x10000000;
std::uintptr_t next = 0x20000000; // where the next allocation goes
std::uintptr_t grip{}, deck{}, trucks{};
@@ -64,6 +65,26 @@ struct Board {
std::array<std::uint32_t, 4> raw{};
std::uint32_t flags = 3;
};
// What the engine's typed setter does with a key a material does not hold: a node of its own,
// linked into the material's map (developer_board.cpp, the native set_shader_parameter).
std::uintptr_t add(std::uintptr_t material, std::uint64_t key, std::uint16_t identity, const Colour& value) {
std::uintptr_t buckets{}, node{};
std::uint32_t count{};
memory(material + n::material_parameters, &buckets, sizeof(buckets));
memory(material + n::material_parameter_count, &count, sizeof(count));
const auto added = take(0x48);
memory.put(added, developer_hoodie_detail::ParameterKey{identity, 12, 1, 1, key});
std::array<std::uint32_t, 4> raw{};
std::memcpy(raw.data(), value.data(), sizeof(value));
memory.put(added + n::parameter_value, raw);
memory.put(added + n::parameter_type, colour_type);
memory.put<std::uint32_t>(added + n::parameter_flags, 3);
memory(buckets, &node, sizeof(node));
memory.put(added + n::parameter_next, node); // first in its bucket
memory.put(buckets, added);
memory.put<std::uint32_t>(material + n::material_parameter_count, count + 1);
return added;
}
std::uintptr_t material(const std::vector<Value>& values, std::map<std::uint64_t, std::uintptr_t>* nodes = nullptr) {
const auto at = take(0x300), buckets = take(16);
memory.put(at + n::material_parameters, buckets);
@@ -103,9 +124,11 @@ struct Board {
}
return at;
}
Board() {
// `declared`: the deck as the Example declared-input deck's material holds it live: integers and switches only.
explicit Board(bool declared = false) {
const auto colour = type(0x7780c50, n::color_rgb_type_hash, 12), number = type(0x765db28, n::float_type_hash, 4),
integer = type(0x765da68, n::integer_type_hash, 4), toggle = type(0x7780f50, n::boolean_type_hash, 4);
colour_type = colour;
const auto bits = [](float a, float b = 0, float c = 0) {
std::array<std::uint32_t, 4> raw{};
const std::array<float, 3> f{a, b, c};
@@ -118,7 +141,13 @@ struct Board {
{switch_key, toggle, 4, {1}},
{0x1111, colour, 12, bits(.5f, .5f, .5f), 0}}, // not live: bit 0 of its flags clear
&grip_nodes);
deck = material({{base_colour, colour, 12, bits(.5f, .5f, .5f)}});
if (declared) {
std::vector<Value> selectors;
for (std::uint64_t k = 1; k <= 7; ++k) selectors.push_back({0x5000 + k, k % 2 ? integer : toggle, 4, {static_cast<std::uint32_t>(k)}});
deck = material(selectors);
} else {
deck = material({{base_colour, colour, 12, bits(.5f, .5f, .5f)}});
}
trucks = material({{base_colour, colour, 12, bits(.1f, .1f, .1f)}});
const auto collection = take(0x80), cosmetic = take(0x200), appearance = take(0x100), list = take(24);
memory.put(entity, base + addr::engine::board_entity_vtable);
@@ -246,11 +275,231 @@ void holds_and_puts_back() {
detail::apply(saved, holds, found, write, publish);
check(writes == before, "Nothing is written while the board is being dressed");
}
// ---- shader inputs a mod declares for its own deck (reskate-materials.json) ---------------------------------
// The Example declared-input deck's eight inputs (parameter-contract.json): colours its compiled shader reads,
// in no live map until something sets them.
struct Liquid { std::uint64_t key; std::uint16_t identity; };
constexpr Liquid liquid[] = {
{6569911127076434714ULL, 46697}, {5869417437401782227ULL, 45276}, {6633899794922297581ULL, 59994}, {6507148046963062431ULL, 65135},
{6339966355936362695ULL, 20838}, {6498503899632300675ULL, 22501}, {6196286651448922263ULL, 47378}, {6344408684715011858ULL, 17465}};
constexpr std::uint64_t declared_number = 0x7777777777777777ULL;
std::vector<mods::MaterialDeclaration> declared_declarations() {
mods::MaterialDeclaration d{"Example", "deck", {"Example_Deck"}, {}};
for (const auto& l : liquid) d.parameters.push_back({l.key, l.identity, true, {.5f, .5f, .5f}});
d.parameters.push_back({declared_number, 0x4242, false, {.25f, 0, 0}});
return {d};
}
auto never_sold = [](std::string_view) { return false; };
detail::Found declared_found(Board& board) {
auto found = board.walk();
found.declared = detail::declared_for(declared_declarations(), Part::deck, "example_deck", never_sold, "Example");
return found;
}
void declarations_follow_the_equipped_item() {
const auto all = declared_declarations();
const auto deck = detail::declared_for(all, Part::deck, "EXAMPLE_DECK", never_sold, "Example");
check(deck.size() == 9 && deck[0].key == liquid[0].key && deck[0].identity == liquid[0].identity && deck[0].kind == Kind::colour &&
deck[0].fallback == Colour{.5f, .5f, .5f} && deck[8].kind == Kind::number,
"The equipped item's declarations apply (its key in any case)");
check(detail::declared_for(all, Part::deck, "Some_Other_Deck", never_sold, "Example").empty(), "another deck's do not");
check(detail::declared_for(all, Part::grip, "Example_Deck", never_sold, "Example").empty(), "nor another part's");
check(detail::declared_for(all, Part::deck, "", never_sold, "Example").empty(), "nor an empty slot's");
auto sold = [](std::string_view) { return true; };
check(detail::declared_for(all, Part::deck, "Example_Deck", sold, "Example").empty(), "An item the game sells (or one it cannot tell) takes none");
// Two mods giving one key differently: which shader is right cannot be told, so neither.
auto both = all;
both.push_back({"Other", "deck", {"Example_Deck"}, {{liquid[0].key, 1, true, {0, 0, 0}}, {liquid[1].key, liquid[1].identity, true, {.5f, .5f, .5f}}}});
check(detail::declared_for(all,Part::deck,"Example_Deck",never_sold,"Other").empty(),"Other mod cannot declare this item");
check(detail::declared_for(all,Part::deck,"Example_Deck",never_sold,"").empty(),"Unknown ownership is refused");
const auto merged = detail::declared_for(both, Part::deck, "Example_Deck", never_sold, "Example");
check(std::any_of(merged.begin(), merged.end(), [](const detail::Declared& d) { return d.key == liquid[0].key; }) &&
std::count_if(merged.begin(), merged.end(), [](const detail::Declared& d) { return d.key == liquid[1].key; }) == 1,
"Another mod cannot suppress the owner's declarations");
}
void lists_and_refuses_declared() {
Board board(true);
auto found = declared_found(board);
const auto listed = detail::listed(found, Part::deck);
check(listed.size() == 7 + 9, "The deck lists its seven live selectors and its nine declared inputs");
check(std::all_of(listed.begin(), listed.begin() + 7, [](const Parameter& p) { return !p.inherited && (p.kind == Kind::integer || p.kind == Kind::toggle); }),
"the selectors as the material holds them");
check(std::all_of(listed.begin() + 7, listed.end(), [](const Parameter& p) { return p.inherited; }) && listed[7].value == Colour{.5f, .5f, .5f},
"the declared ones inherited, at their default");
check(detail::listed(board.walk(), Part::deck).size() == 7, "Without the declaration only the live ones show");
Kind kind{};
check(detail::refusal(found, Part::deck, liquid[0].key, {.1f, .2f, .3f}, true, kind).empty() && kind == Kind::colour,
"A declared colour the material does not hold may be held");
check(detail::refusal(board.walk(), Part::deck, liquid[0].key, {.1f, .2f, .3f}, true, kind).find("no parameter") != std::string::npos,
"but not on a deck that does not declare it");
check(!detail::refusal(found, Part::deck, liquid[0].key, {.1f, .2f, .3f}, false, kind).empty(), "A declared colour is not a number");
check(!detail::refusal(found, Part::deck, liquid[0].key, {1.5f, 0, 0}, true, kind).empty(), "and its channels are 0 to 1");
check(detail::refusal(found, Part::deck, declared_number, {.5f, 0, 0}, false, kind).find("only a declared colour") != std::string::npos,
"A declared number with no live value cannot be added (its type is not known to this build)");
check(detail::refusal(found, Part::deck, 0x5001, {1, 0, 0}, false, kind).find("only read") != std::string::npos,
"A live integer stays read-only");
// A declaration naming a key the material holds as an integer or a switch changes nothing.
auto clash = found;
clash.declared.push_back({Part::deck, 0x5002, 0x1234, Kind::colour, {0, 0, 0}});
check(detail::refusal(clash, Part::deck, 0x5002, {1, 0, 0}, true, kind).find("not as its mod declares") != std::string::npos,
"A switch a mod declares as a colour is refused");
// A live colour whose identity is not the declared one is not the declared parameter.
Board ordinary;
auto other = ordinary.walk();
other.declared.push_back({Part::grip, graphic_colour, 0x9999, Kind::colour, {.5f, .5f, .5f}});
check(detail::refusal(other, Part::grip, graphic_colour, {1, 0, 0}, true, kind).find("not as its mod declares") != std::string::npos,
"and so is a live colour of another identity");
}
void owners_and_limits() {
Board board(true);
const auto found = declared_found(board);
std::vector<detail::Hold> holds;
check(detail::take(holds, found, "liquid", Part::deck, liquid[0].key, {.1f, .2f, .3f}, true).empty() && holds.size() == 1 &&
holds[0].declared && holds[0].kind == Kind::colour,
"A plugin takes a declared colour");
check(detail::take(holds, found, "other", Part::deck, liquid[0].key, {1, 1, 1}, true).find("liquid holds") != std::string::npos,
"another plugin cannot take it from it");
check(detail::take(holds, found, "liquid", Part::deck, liquid[0].key, {.4f, .5f, .6f}, true).empty() && holds.size() == 1 &&
holds[0].value == Colour{.4f, .5f, .6f},
"its owner sets it again");
check(!detail::take(holds, found, "liquid", Part::deck, 0x5001, {1, 0, 0}, false).empty() && holds.size() == 1, "A refusal holds nothing");
for (std::size_t i = 1; i < most_held; ++i) holds.push_back({"liquid", Part::grip, 0x9000 + i, Kind::colour, {}, false});
check(detail::take(holds, found, "liquid", Part::deck, liquid[1].key, {0, 0, 0}, true).find("at most") != std::string::npos,
"A plugin holds at most 32, declared ones included");
check(detail::take(holds, found, "other", Part::deck, liquid[1].key, {0, 0, 0}, true).empty(), "another has its own 32");
detail::release(holds, "liquid", Part::grip, std::nullopt);
check(holds.size() == 2, "Letting go of a part lets go of the plugin's holds there only");
detail::release(holds, "liquid", std::nullopt, std::nullopt);
check(holds.size() == 1 && holds[0].owner == "other", "and letting go of all (an unload) leaves the others' alone");
}
void adds_and_puts_back_declared() {
Board board(true);
std::vector<detail::Saved> saved;
std::vector<detail::Hold> holds;
int writes{}, publishes{}, inserts{};
std::map<std::uint64_t, std::uintptr_t> added;
auto write = [&](std::uintptr_t material, std::uintptr_t node, Kind kind, const Colour& value) {
check(material == board.deck, "Only the deck is written");
++writes;
board.memory.put_bytes(node + n::parameter_value, value.data(), kind == Kind::colour ? sizeof(Colour) : sizeof(float));
return true;
};
auto publish = [&](std::uintptr_t item) {
check(item == board.deck_item, "The deck's item publishes");
++publishes;
};
auto insert = [&](std::uintptr_t material, const detail::Declared& d, const Colour& value) {
check(material == board.deck && d.part == Part::deck && d.kind == Kind::colour, "A declared colour is added to the deck's material");
++inserts;
added[d.key] = board.add(material, d.key, d.identity, value);
return true;
};
const auto found = [&] { return declared_found(board); };
const auto tick = [&] { detail::apply(saved, holds, found(), write, publish, insert); };
// The liquid controller's eight colours, all held at once.
for (std::size_t i = 0; i < 8; ++i)
check(detail::take(holds, found(), "liquid", Part::deck, liquid[i].key, {.1f * i, .2f, .3f}, true).empty(), "each liquid colour is taken");
tick();
check(inserts == 8 && writes == 0 && publishes == 1, "The eight are added by the engine's setter, and the deck published once");
for (std::size_t i = 0; i < 8; ++i) check(board.colour_at(added[liquid[i].key]) == Colour{.1f * i, .2f, .3f}, "at the held value");
const auto walked = board.walk();
const auto* deck = part_of(walked, Part::deck);
check(deck && deck->nodes.size() == 15 && std::count_if(deck->nodes.begin(), deck->nodes.end(), [&](const detail::Node& node) {
return node.parameter.kind == Kind::colour && node.identity == liquid[0].identity;
}) == 1,
"and then the material holds them live, with their identities");
for (int i = 0; i < 6; ++i) tick();
check(inserts == 8 && writes == 0, "Once added they are not added again");
// About 30 times a second, the controller's new values: written in place.
holds[0].value = {.9f, .8f, .7f};
tick();
check(inserts == 8 && writes == 1 && board.colour_at(added[liquid[0].key]) == Colour{.9f, .8f, .7f}, "A new value is written in place");
// Let go (clear_parameter, or the plugin unloading): each goes back to its declared default.
detail::release(holds, "liquid", std::nullopt, std::nullopt);
for (int i = 0; i < 8; ++i) tick();
for (std::size_t i = 0; i < 8; ++i)
check(board.colour_at(added[liquid[i].key]) == Colour{.5f, .5f, .5f}, "Let go, a declared colour goes back to its shader's default");
check(saved.empty(), "and nothing is kept once that shows");
const auto after = board.walk();
check(part_of(after, Part::deck)->nodes.size() == 15, "What was added stays (the renderer copies it), at its default");
check(std::all_of(part_of(after, Part::deck)->nodes.begin(), part_of(after, Part::deck)->nodes.begin() + 15, [](const detail::Node& node) {
return node.parameter.kind != Kind::integer || node.parameter.integer != 0;
}),
"and the selectors were never touched");
}
void a_change_of_deck_lets_go() {
Board board(true);
std::vector<detail::Saved> saved;
std::vector<detail::Hold> holds;
std::uintptr_t node{};
int inserts{};
auto write = [&](std::uintptr_t, std::uintptr_t at, Kind, const Colour& value) {
board.memory.put_bytes(at + n::parameter_value, value.data(), sizeof(Colour));
return true;
};
auto publish = [](std::uintptr_t) {};
auto insert = [&](std::uintptr_t material, const detail::Declared& d, const Colour& value) {
++inserts;
node = board.add(material, d.key, d.identity, value);
return true;
};
check(detail::take(holds, declared_found(board), "liquid", Part::deck, liquid[0].key, {1, 0, 0}, true).empty(), "taken");
detail::apply(saved, holds, declared_found(board), write, publish, insert);
check(inserts == 1 && board.colour_at(node) == Colour{1, 0, 0}, "added on the declared-input deck");
// The deck is changed for one that declares nothing: the walk finds no declaration.
auto changed = board.walk();
check(detail::drop_undeclared(holds, changed) == 1 && holds.empty(), "The hold is let go when its deck no longer declares it");
for (int i = 0; i < 8; ++i) detail::apply(saved, holds, changed, write, publish, insert);
check(inserts == 1 && board.colour_at(node) == Colour{.5f, .5f, .5f} && saved.empty(), "what it added goes back, and nothing more is added");
// A hold kept (taken while declared) is never written to a deck that does not declare it.
holds = {{"liquid", Part::deck, liquid[1].key, Kind::colour, {0, 1, 0}, true}};
detail::apply(saved, holds, changed, write, publish, insert);
check(inserts == 1 && saved.empty(), "A declared hold is not carried onto an incompatible deck");
// A board being dressed: nothing is let go or written while the recipe is on its way.
changed.declarations_pending[0]=true;
check(detail::drop_undeclared(holds,changed)==0 && holds.size()==1,"Rebuilding materials retain declared holds while settling");
changed.pending = true;
check(detail::drop_undeclared(holds, changed) == 0 && holds.size() == 1, "nor let go while the board is being dressed");
// Back on the declared-input deck it applies again.
detail::apply(saved, holds, declared_found(board), write, publish, insert);
check(inserts == 2, "and is added again once the declaring deck is back");
}
void declarations_wait_for_the_board_to_settle() {
detail::Gate gate;
check(!detail::settled(gate, "Example_Deck", 0x1000, 0), "A newly equipped item does not apply at once");
check(!detail::settled(gate, "Example_Deck", 0x1000, detail::settle_ms - 1), "nor before it has settled");
check(detail::settled(gate, "Example_Deck", 0x1000, detail::settle_ms), "then it does");
check(!detail::settled(gate, "Example_Deck", 0x2000, detail::settle_ms + 5), "A new material under it starts again");
check(!detail::settled(gate, "Other_Deck", 0x2000, 2 * detail::settle_ms + 5), "as does another item");
check(!detail::settled(gate, "Other_Deck", 0, 9 * detail::settle_ms), "and no material never settles");
}
} // namespace
int main() {
const std::map<std::string, int> stock{{"free_stock", 1}, {"paid_stock", 1}};
const auto not_reserved = [](std::string_view) { return false; };
check(detail::declared_item_restricted("FREE_STOCK", true, stock, not_reserved), "free stock cannot grant declarations");
check(detail::declared_item_restricted("paid_stock", true, stock, not_reserved), "paid stock cannot grant declarations");
check(detail::declared_item_restricted("OWN_DEV_TEST", true, stock, not_reserved), "developer items cannot grant declarations");
check(detail::declared_item_restricted("own_private", false, stock, not_reserved), "unknown stock catalogue state fails closed");
check(!detail::declared_item_restricted("own_private", true, stock, not_reserved), "a non-stock private item can proceed to ownership checks");
check(detail::declared_item_restricted("own_reserved", true, stock, [](std::string_view) { return true; }), "reservation policy is still applied");
walks_the_board();
refuses_what_picks_the_look();
holds_and_puts_back();
declarations_follow_the_equipped_item();
lists_and_refuses_declared();
owners_and_limits();
adds_and_puts_back_declared();
a_change_of_deck_lets_go();
declarations_wait_for_the_board_to_settle();
std::cout << "board material tests passed\n";
}
+231 -23
View File
@@ -1,10 +1,23 @@
#include "board_material.h"
#include "worn_items.h"
#include "Engine/Game/Build/supported_build.h"
#include "local_customization_runtime.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Platform/memory.h"
#include "Engine/Vfs/content_catalogs.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Engine/Vfs/mod_item_sources.h"
#include "Engine/Vfs/material_declaration_refresh.h"
#include "Engine/Core/Platform/shared_file.h"
#include "Extension/Multiplayer/Remote/native_pose_layout.h"
#include "Extension/Multiplayer/Remote/native_skater.h"
#include <Windows.h>
#include <format>
#include <fstream>
#include <iterator>
#include <memory>
#include <mutex>
#include <thread>
namespace dingosdk::board_material {
namespace {
@@ -13,7 +26,18 @@ struct State {
std::vector<detail::Hold> holds;
std::vector<detail::Saved> saved;
std::optional<bool> functions; // the material publisher's bytes, once checked
std::optional<bool> setter; // the typed setter's bytes and the ColorRgb descriptor, once checked
std::uint16_t colour_type{}; // the ColorRgb descriptor's type index
std::uint64_t reported{};
std::uint64_t apply_reported{};
// The board's items as last read (worn_items::worn_board), and when.
std::vector<worn_items::Worn> worn;
std::uint64_t worn_at{};
std::array<detail::Gate, part_names.size()> gates;
// The item each part was last reported as wearing: once per change, so a mod's maker can read
// the key their reskate-materials.json has to name from the log.
std::array<std::string, part_names.size()> reported_items;
std::array<std::size_t, part_names.size()> reported_counts{};
};
State& state() {
static auto* value = new State; // what is held may still be out at exit
@@ -34,6 +58,19 @@ bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept {
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
}
// A declared colour the material does not hold yet, added by the engine's own typed setter
// (MaterialInstance:addEsVector's, as developer_board.cpp adds the board's colours): it allocates,
// hashes and links the node itself. Priority zero, a native preset's, so a later preset replaces it.
bool insert_colour(std::uintptr_t base, std::uint16_t type_index, std::uintptr_t material, const detail::Declared& declared,
const Colour& value) noexcept {
const developer_hoodie_detail::ParameterKey key{declared.identity, sizeof(Colour), type_index, 1, declared.key};
const developer_board_detail::NativeColor native{value, 0, base + addr::native_cosmetics::color_rgb_type};
__try {
return reinterpret_cast<std::uint64_t (*)(std::uintptr_t, const developer_hoodie_detail::ParameterKey*,
const developer_board_detail::NativeColor*, std::uint8_t)>(
base + addr::native_cosmetics::set_shader_parameter)(material, &key, &native, 0) != 0;
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
}
bool functions_match(State& s) {
if (!s.functions) {
std::array<unsigned char, addr::native_cosmetics::publish_materials_prefix.size()> bytes{};
@@ -45,6 +82,168 @@ bool functions_match(State& s) {
}
return *s.functions;
}
// The typed setter, and the ColorRgb type it is given, as the supported build has them (checked as
// developer_board_detail::materials checks the descriptor). Without them nothing declared is added.
bool setter_matches(State& s) {
if (!s.setter) {
namespace n = addr::native_cosmetics;
std::array<unsigned char, n::set_shader_parameter_prefix.size()> bytes{};
std::uintptr_t info{};
std::uint32_t hash{};
std::uint16_t size{}, index{};
const auto type = s.base + n::color_rgb_type;
s.setter = peek(s.base + n::set_shader_parameter, bytes.data(), bytes.size()) && bytes == n::set_shader_parameter_prefix &&
peek(type, &info, sizeof(info)) && info >= s.base && info < s.base + supported_build::game_image_size && peek(info, &hash, sizeof(hash)) &&
hash == n::color_rgb_type_hash && peek(info + 6, &size, sizeof(size)) && size == sizeof(Colour) &&
peek(type + 8, &index, sizeof(index));
s.colour_type = index;
if (!*s.setter)
logging::log(logging::Level::warning, logging::Channel::customization,
"Board parameters: the material setter differs from the supported build; declared colours cannot be added.");
}
return *s.setter;
}
// ---- declarations: every enabled mod's reskate-materials.json -------------------------------------------
// Read on a thread of their own and again only when a file changes, as the mods' music is
// (local_music_assets.cpp): the game's frame never waits on the disk.
using DeclarationSet = mods::MaterialDeclarations;
struct Declarations {
std::mutex mutex;
std::shared_ptr<const DeclarationSet> ready;
mods::MaterialDeclarationRefresh refresh;
std::string error;
bool reading{};
std::uint64_t looked{};
};
Declarations& declarations() {
static auto* value = new Declarations;
return *value;
}
std::vector<mods::DeclarationFileStamp> declaration_signature(const std::vector<mods::ModSource>& sources,
const std::filesystem::path& root) {
std::vector<mods::DeclarationFileStamp> signature;
signature.push_back(mods::declaration_file_stamp(root / mods::order_file));
signature.back().revision = mods::active_sources_revision();
signature.push_back(mods::declaration_file_stamp(root / mods::generated_folder / mods::item_sources_file));
for (const auto& source : sources)
signature.push_back(mods::declaration_file_stamp(source.directory / L"reskate-materials.json", source.name));
return signature;
}
DeclarationSet read_declarations(const std::vector<mods::DeclarationFileStamp>& signature) {
DeclarationSet result;
result.revision = signature[0].revision;
const auto has_declarations = std::any_of(signature.begin() + 2, signature.end(), [](const auto& file) { return file.present; });
if (!has_declarations) return result;
auto sources = mods::read_item_sources_checked(signature[1].path);
if (!sources) throw std::runtime_error("Item ownership could not be read; retaining previous declarations");
result.sources = std::move(*sources);
for (std::size_t at = 2; at < signature.size(); ++at) {
const auto& file = signature[at];
if (!file.present) continue;
auto read = mods::parse_material_declarations(file.owner, read_shared_text(file.path, 64 * 1024));
std::move(read.begin(), read.end(), std::back_inserter(result.materials));
}
return result;
}
void read_declarations_now() noexcept {
auto& d = declarations();
std::shared_ptr<const DeclarationSet> read;
std::string failure;
try {
const auto sources = mods::active_mod_sources();
const auto root = mods::catalog().root;
const auto signature = declaration_signature(sources, root);
const bool changed = d.refresh.update(signature, [&] {
auto next = read_declarations(signature);
if (sources != mods::active_mod_sources() || signature != declaration_signature(sources, root))
throw std::runtime_error("Content changed during declaration refresh; retaining previous declarations");
return next;
});
read = d.refresh.value();
if (changed)
logging::log(logging::Level::info, logging::Channel::customization,
"Board parameters: refreshed {} material declaration(s).", read->materials.size());
} catch (const std::exception& error) {
failure = error.what();
} catch (...) {
failure = "Declaration refresh failed; retaining previous declarations";
}
std::lock_guard lock(d.mutex);
if (read) d.ready = std::move(read);
else if (!d.ready) d.ready = std::make_shared<const DeclarationSet>();
if (!failure.empty() && failure != d.error)
logging::log(logging::Level::warning, logging::Channel::customization, "Board parameters: {}", failure);
d.error = std::move(failure);
d.reading = false;
}
// Only file metadata and the applied source snapshot are polled. Unchanged
// polls never parse declarations, reload the catalogue, or unpack built-ins.
std::shared_ptr<const DeclarationSet> current_declarations() {
auto& d = declarations();
std::lock_guard lock(d.mutex);
const auto now = GetTickCount64();
if (!d.reading && (!d.looked || now - d.looked >= 2000)) {
d.reading = true;
d.looked = now;
try {
std::thread(read_declarations_now).detach();
} catch (...) {
d.reading = false;
}
}
// Keep old values for recovery, but never grant declarations from a source
// snapshot belonging to mods that are no longer the running catalogue.
if (d.ready && d.ready->revision != mods::active_sources_revision()) return {};
return d.ready;
}
// Stock/developer entries and unknown catalogue state cannot grant inputs.
bool sold(std::string_view item) {
const auto& catalog = content_cache::catalogs();
return detail::declared_item_restricted(item, catalog.available, catalog.items,
[](std::string_view key) { return profile_runtime::reserved_cosmetic(key); });
}
constexpr std::array<std::string_view, part_names.size()> worn_slots{"Deck", "Grip tape", "Trucks", "Wheels"};
// The equipped items' declarations, on the board found: a part's apply once its item and material
// have settled (detail::settled).
void declare(State& s, detail::Found& found) {
const auto all = current_declarations();
if (!all) {
found.pending = true;
return;
}
const auto now = GetTickCount64();
if (now - s.worn_at >= 250 || s.worn.empty()) {
s.worn = worn_items::worn_board(false);
s.worn_at = now;
}
for (std::size_t part = 0; part < part_names.size(); ++part) {
std::string_view item;
for (const auto& w : s.worn)
if (w.slot == worn_slots[part]) item = w.item;
std::uintptr_t material{};
for (const auto& m : found.materials)
if (static_cast<std::size_t>(m.part) == part) { material = m.address; break; }
if (!detail::settled(s.gates[part], item, material, now) || found.pending) {
found.declarations_pending[part] = true;
continue;
}
auto is_sold = [](std::string_view i) { return sold(i); };
const auto owner = mods::item_source_owner(all->sources, item);
auto add = detail::declared_for(all->materials, static_cast<Part>(part), item, is_sold, owner);
// Report a hot-reloaded declaration even when the worn item did not change.
if (s.reported_items[part] != item || s.reported_counts[part] != add.size()) {
s.reported_items[part] = std::string(item);
s.reported_counts[part] = add.size();
logging::log(logging::Level::info, logging::Channel::customization,
"Board parameters: the {} is {}; {} declared shader input{} apply.", part_names[part], item,
add.size(), add.size() == 1 ? "" : "s");
}
found.declared.insert(found.declared.end(), add.begin(), add.end());
}
}
// The local player's board as it is now (a change of cosmetics can replace it); none out of play.
std::optional<detail::Found> board(State& s) {
if (!s.base || !s.client) return std::nullopt;
@@ -53,7 +252,9 @@ std::optional<detail::Found> board(State& s) {
if (!local.ready) return std::nullopt;
const auto entity = multiplayer::read_native_board(peek, s.base, local.entity).entity;
if (!entity) return std::nullopt;
return detail::walk(peek, s.base, entity);
auto found = detail::walk(peek, s.base, entity);
declare(s, found);
return found;
} catch (const std::exception& error) {
// (Once in a while: a board being built reads as unfinished for a few ticks.)
if (const auto now = GetTickCount64(); now - s.reported > 10000) {
@@ -69,38 +270,23 @@ std::optional<std::vector<Parameter>> read(Part part) {
auto& s = state();
const auto found = board(s);
if (!found) return std::nullopt;
std::vector<Parameter> out;
// The first of the part's materials: another (a variant) holds the same keys.
for (const auto& material : found->materials)
if (material.part == part) {
for (const auto& node : material.nodes) out.push_back(node.parameter);
break;
}
return out;
return detail::listed(*found, part);
}
std::string set(std::string_view owner, Part part, std::uint64_t key, const Colour& value, bool colour) {
auto& s = state();
const auto name = part_names[static_cast<std::size_t>(part)];
const auto found = board(s);
if (!found) return "there is no board in play";
Kind kind{};
if (auto why = detail::refusal(*found, part, key, value, colour, kind); !why.empty()) return why;
if (!functions_match(s)) return "board parameters are unavailable with this game build";
const auto held = std::find_if(s.holds.begin(), s.holds.end(), [&](const detail::Hold& h) { return h.part == part && h.key == key; });
if (held != s.holds.end() && held->owner != owner) return std::format("{} holds the {}'s {:016x}", held->owner, name, key);
if (held == s.holds.end() &&
static_cast<std::size_t>(std::count_if(s.holds.begin(), s.holds.end(), [&](const detail::Hold& h) { return h.owner == owner; })) >= most_held)
return std::format("a plugin holds at most {} board parameters", most_held);
const Colour kept = colour ? value : Colour{value[0], 0, 0};
if (held != s.holds.end()) held->value = kept;
else s.holds.push_back({std::string(owner), part, key, kind, kept});
return {};
if (detail::declared(*found, part, key) && !detail::live(*found, part, key) && !setter_matches(s))
return "declared colours cannot be added with this game build";
return detail::take(s.holds, *found, owner, part, key, value, colour);
}
void clear(std::string_view owner, std::optional<Part> part, std::optional<std::uint64_t> key) {
auto& holds = state().holds;
std::erase_if(holds, [&](const detail::Hold& h) { return h.owner == owner && (!part || h.part == *part) && (!key || h.key == *key); });
detail::release(state().holds, owner, part, key);
}
void tick(std::uintptr_t base, std::uintptr_t client) noexcept {
@@ -111,11 +297,33 @@ void tick(std::uintptr_t base, std::uintptr_t client) noexcept {
if (!functions_match(s)) return;
const auto found = board(s);
if (!found) return;
if (const auto dropped = detail::drop_undeclared(s.holds, *found))
logging::log(logging::Level::info, logging::Channel::customization,
"Board parameters: {} declared value{} let go: the item that declared {} is no longer on the board.", dropped,
dropped == 1 ? "" : "s", dropped == 1 ? "it" : "them");
auto write = [](std::uintptr_t material, std::uintptr_t node, Kind kind, const Colour& value) {
return write_value(material, node, value.data(), kind == Kind::colour ? sizeof(Colour) : sizeof(float));
};
auto publish = [&](std::uintptr_t item) { (void)publish_materials(s.base, item); };
detail::apply(s.saved, s.holds, *found, write, publish);
std::size_t inserted{}, refused{}, published{}, publish_failed{};
auto publish = [&](std::uintptr_t item) {
const bool ok = publish_materials(s.base, item);
if (ok) ++published;
else ++publish_failed;
};
auto insert = [&](std::uintptr_t material, const detail::Declared& declared, const Colour& value) {
const bool added = setter_matches(s) && insert_colour(s.base, s.colour_type, material, declared, value);
if (added) ++inserted;
else ++refused;
return added;
};
detail::apply(s.saved, s.holds, *found, write, publish, insert);
// Lua accepts a hold before this tick applies it; report native failures separately.
if ((inserted || refused || publish_failed) && GetTickCount64() - s.apply_reported >= 5000) {
s.apply_reported = GetTickCount64();
logging::log(logging::Level::info, logging::Channel::customization,
"Board parameter apply: {} inserted, {} refused, {} published, {} publication failures.",
inserted, refused, published, publish_failed);
}
} catch (...) {}
}
} // namespace dingosdk::board_material
+184 -12
View File
@@ -1,5 +1,7 @@
#pragma once
#include "developer_board_material.h"
#include "Engine/Vfs/mod_materials.h"
#include "Engine/Vfs/mod_item_sources.h"
#include <cstring>
#include <format>
#include <optional>
@@ -14,6 +16,13 @@
// integer or a switch picks how the board is drawn (the foil looks the store sells among them), so
// those are only read. Nothing of the board's items changes and nobody else sees it: other players
// draw this board from its cosmetics, as they are.
//
// A mod's own board item may also declare, in its reskate-materials.json (Engine/Vfs/mod_materials.h),
// colours and numbers its compiled shader reads. Their defaults live in that shader, so the material
// has no live value of them until one is set: while that item is equipped (and is not one the game
// sells), a plugin may hold a declared colour too. The engine's typed setter adds it to the material
// at native preset priority, as the developer board adds its colours, and when it is let go the
// declared default is written back (a value once added is never taken out: the renderer copies it).
namespace dingosdk::board_material {
enum class Part : std::uint8_t { deck, grip, trucks, wheels };
inline constexpr std::array<std::string_view, 4> part_names{"deck", "grip", "trucks", "wheels"};
@@ -24,6 +33,7 @@ struct Parameter {
Kind kind{Kind::other};
Colour value{}; // a colour, or a number in [0]
std::int32_t integer{}; // an integer, or a switch's 0 or 1
bool inherited{}; // declared by the item's mod, with no live value yet: `value` is its default
};
// The two colours known by what they colour (the developer board animates them): the graphic's and
// the base's.
@@ -47,6 +57,16 @@ namespace detail {
struct Node {
std::uintptr_t address{};
Parameter parameter;
std::uint16_t identity{}; // its ParamDbKey identity
};
// A colour or a number the equipped item's mod declares for the part's material
// (mods::MaterialDeclaration), as the board's walk is given it: only while that item is equipped.
struct Declared {
Part part{};
std::uint64_t key{};
std::uint16_t identity{};
Kind kind{}; // colour or number
Colour fallback{}; // the shader's default, as the mod declares it
};
struct Material {
std::uintptr_t item{}, address{};
@@ -56,6 +76,8 @@ struct Material {
struct Found {
std::vector<Material> materials;
bool pending{}; // a recipe on its way: nothing is written
std::vector<Declared> declared; // what the equipped items' mods declare (declarations below)
std::array<bool,4> declarations_pending{};
};
// Each part's named material on the board's slot controllers; the deck slot holds two.
struct Target {
@@ -98,6 +120,7 @@ template <class Memory> std::vector<Node> parameters(Memory& m, std::uintptr_t m
const auto type = m.ptr(node, n::parameter_type);
Parameter p;
p.key = key.hash;
const auto identity = key.identity;
if (type) {
const auto info = m.ptr(type);
// A type's information is in the game's image, as the board's colour descriptor is.
@@ -113,7 +136,7 @@ template <class Memory> std::vector<Node> parameters(Memory& m, std::uintptr_t m
} else if (p.kind == Kind::toggle) {
p.integer = raw[0] != 0;
}
out.push_back({node, p});
out.push_back({node, p, identity});
}
}
return out;
@@ -176,14 +199,32 @@ inline const char* kind_name(Kind kind) noexcept {
default: return "of a kind plugins do not set";
}
}
inline const Declared* declared(const Found& found, Part part, std::uint64_t key) noexcept {
for (const auto& d : found.declared)
if (d.part == part && d.key == key) return &d;
return nullptr;
}
// Whether `value` may be held on that key of the part: empty, and its kind in `kind`, else why not.
// A key the part's material holds live is checked as it is held; one it does not hold may be a colour
// the equipped item's mod declares. Integers and switches are never set, whatever a mod declares.
inline std::string refusal(const Found& found, Part part, std::uint64_t key, const Colour& value, bool colour, Kind& kind) {
const auto name = part_names[static_cast<std::size_t>(part)];
std::optional<Kind> has;
std::optional<std::uint16_t> identity;
for (const auto& material : found.materials)
if (material.part == part)
for (const auto& node : material.nodes)
if (node.parameter.key == key) has = node.parameter.kind;
if (node.parameter.key == key) has = node.parameter.kind, identity = node.identity;
const auto* mod = declared(found, part, key);
if (has && mod && (*has != mod->kind || *identity != mod->identity))
return std::format("the {}'s material holds {:016x} as {}, not as its mod declares it", name, key, kind_name(*has));
if (!has && mod) {
// Added through the engine's typed setter, which takes a colour's type (a number's is not
// known to this build's addresses): a number has to have a live value first.
if (mod->kind != Kind::colour)
return std::format("{:016x} is a number the {}'s shader declares; only a declared colour can be added", key, name);
has = mod->kind;
}
if (!has) return std::format("the {} has no parameter {:016x}", name, key);
if (*has != Kind::colour && *has != Kind::number)
return std::format("{:016x} is {}, which picks how the board is drawn; plugins only read it", key, kind_name(*has));
@@ -201,22 +242,32 @@ struct Hold {
std::uint64_t key{};
Kind kind{};
Colour value{};
bool declared{}; // held through the equipped item's declaration: only while that declares it
};
struct Saved {
std::uintptr_t material{}, node{};
std::uintptr_t material{}, node{}; // node 0: added this tick, not yet walked
Kind kind{};
Colour original{}, last{};
std::uint8_t settling{}; // publishes still owed for `last`
std::uint64_t key{};
};
inline bool same(Kind kind, const Colour& a, const Colour& b) noexcept {
return kind == Kind::colour ? a == b : a[0] == b[0];
}
// A held key still holds: a declared one only while the equipped item declares it, as it was.
inline bool holds_now(const Hold& hold, const Found& live, const Material& material, const Node* node) noexcept {
if (!hold.declared) return true;
const auto* d = declared(live, material.part, hold.key);
return d && d->kind == hold.kind && (!node || node->identity == d->identity);
}
// One tick, as the developer board's colours go (developer_hoodie_detail::animate): what is held
// written where it is not yet, and published until it shows (the renderer shows a value one publish
// late); what is no longer held put back while it is still ours, a value changed by the game since
// left as the game has it. write(material, node, kind, value) -> written; publish(item).
template <class Write, class Publish>
void apply(std::vector<Saved>& saved, const std::vector<Hold>& holds, const Found& live, Write& write, Publish& publish) {
// left as the game has it. A declared colour the material does not hold yet is added
// (insert(material, declared, value) -> added), and is put back to its declared default when let go.
// write(material, node, kind, value) -> written; publish(item).
template <class Write, class Publish, class Insert>
void apply(std::vector<Saved>& saved, const std::vector<Hold>& holds, const Found& live, Write& write, Publish& publish, Insert& insert) {
if (live.pending) return;
using developer_hoodie_detail::settle_publishes;
std::vector<Saved> next;
@@ -227,10 +278,10 @@ void apply(std::vector<Saved>& saved, const std::vector<Hold>& holds, const Foun
const auto& p = node.parameter;
if (p.kind != Kind::colour && p.kind != Kind::number) continue;
const auto hold = std::find_if(holds.begin(), holds.end(), [&](const Hold& h) {
return h.part == material.part && h.key == p.key && h.kind == p.kind;
return h.part == material.part && h.key == p.key && h.kind == p.kind && holds_now(h, live, material, &node);
});
const auto at = std::find_if(saved.begin(), saved.end(), [&](const Saved& s) {
return s.material == material.address && s.node == node.address && s.kind == p.kind;
return s.material == material.address && s.key == p.key && s.kind == p.kind;
});
const Saved* was = at == saved.end() ? nullptr : &*at;
const bool ours = was && same(p.kind, p.value, was->last);
@@ -238,28 +289,149 @@ void apply(std::vector<Saved>& saved, const std::vector<Hold>& holds, const Foun
if (hold != holds.end()) {
if (same(p.kind, p.value, hold->value)) {
const std::uint8_t left = ours ? was->settling : 0;
next.push_back({material.address, node.address, p.kind, original, hold->value, static_cast<std::uint8_t>(left ? left - 1 : 0)});
next.push_back({material.address, node.address, p.kind, original, hold->value, static_cast<std::uint8_t>(left ? left - 1 : 0), p.key});
show = show || left != 0;
} else if (write(material.address, node.address, p.kind, hold->value)) {
next.push_back({material.address, node.address, p.kind, original, hold->value, settle_publishes});
next.push_back({material.address, node.address, p.kind, original, hold->value, settle_publishes, p.key});
show = true;
} else if (was) {
next.push_back(*was);
}
} else if (was && (ours || same(p.kind, p.value, was->original))) {
// Back to the board's own value, and published until it shows.
// Back to the board's own value (a declared colour's default, for one added), and
// published until it shows.
const bool first = !same(p.kind, was->last, was->original);
const bool back = same(p.kind, p.value, was->original) || write(material.address, node.address, p.kind, was->original);
const std::uint8_t left = first ? settle_publishes : was->settling ? was->settling - 1 : 0;
if (!back) next.push_back(*was);
else if (left) next.push_back({material.address, node.address, p.kind, was->original, was->original, left});
else if (left) next.push_back({material.address, node.address, p.kind, was->original, was->original, left, p.key});
show = show || back;
}
}
// A declared colour this material does not hold yet: added, with its declared default as
// what it goes back to.
for (const auto& hold : holds) {
if (!hold.declared || hold.part != material.part || hold.kind != Kind::colour) continue;
if (std::any_of(material.nodes.begin(), material.nodes.end(), [&](const Node& n) { return n.parameter.key == hold.key; })) continue;
const auto* d = declared(live, material.part, hold.key);
if (!d || d->kind != Kind::colour || !holds_now(hold, live, material, nullptr)) continue;
if (insert(material.address, *d, hold.value)) {
next.push_back({material.address, 0, Kind::colour, d->fallback, hold.value, settle_publishes, hold.key});
show = true;
}
}
if (show && std::find(shown.begin(), shown.end(), material.item) == shown.end()) shown.push_back(material.item);
}
saved = std::move(next);
for (const auto item : shown) publish(item);
}
template <class Write, class Publish>
void apply(std::vector<Saved>& saved, const std::vector<Hold>& holds, const Found& live, Write& write, Publish& publish) {
auto none = [](std::uintptr_t, const Declared&, const Colour&) { return false; };
apply(saved, holds, live, write, publish, none);
}
// ---- declarations ---------------------------------------------------------------------------------
template<class Items, class Reserved>
bool declared_item_restricted(std::string_view item, bool available, const Items& items, Reserved&& reserved) {
const auto key = mods::item_key(item);
return !available || items.contains(key) || key.starts_with("dev_") || key.starts_with("own_dev_") || reserved(item);
}
// The declarations of the item equipped on `part` (its key, as the game names it; any case), from
// the owning enabled mod's reskate-materials.json. Other mods cannot grant or suppress keys.
// A key two declarations from the owner give differently is left out:
// which shader is right cannot be told. `sold` says whether the game sells an item (or cannot tell
// yet): its declarations never apply.
template <class Sold>
std::vector<Declared> declared_for(const std::vector<mods::MaterialDeclaration>& declarations, Part part, std::string_view item, Sold& sold, std::string_view owner) {
std::vector<Declared> out;
if (item.empty() || owner.empty() || sold(item)) return out;
const auto wanted = mods::item_key(item);
std::vector<std::uint64_t> conflicting;
for (const auto& d : declarations) {
if (d.mod != owner || d.part != part_names[static_cast<std::size_t>(part)]) continue;
if (std::none_of(d.items.begin(), d.items.end(), [&](const std::string& i) { return mods::item_key(i) == wanted; })) continue;
for (const auto& p : d.parameters) {
const Declared next{part, p.key, p.identity, p.colour ? Kind::colour : Kind::number, p.fallback};
const auto at = std::find_if(out.begin(), out.end(), [&](const Declared& o) { return o.key == p.key; });
if (at == out.end()) out.push_back(next);
else if (at->identity != next.identity || at->kind != next.kind || at->fallback != next.fallback) conflicting.push_back(p.key);
}
}
std::erase_if(out, [&](const Declared& d) { return std::find(conflicting.begin(), conflicting.end(), d.key) != conflicting.end(); });
return out;
}
// When an item's declarations apply: the same item has been equipped, on the same material, for
// `settle_ms`. A change of cosmetics replaces the board over a few ticks, during which the item read
// and the board walked can disagree; nothing declared is added to a board until they have settled.
inline constexpr std::uint64_t settle_ms = 1000;
struct Gate {
std::string item;
std::uintptr_t material{};
std::uint64_t since{};
};
inline bool settled(Gate& gate, std::string_view item, std::uintptr_t material, std::uint64_t now) {
if (gate.item != item || gate.material != material || !material) {
gate = {std::string(item), material, now};
return false;
}
return now - gate.since >= settle_ms;
}
// Holds through a declaration the equipped item no longer makes (another deck, or one being put
// on) are let go: what they added goes back to its default, and the plugin has to set it again.
// Returns how many were let go.
inline std::size_t drop_undeclared(std::vector<Hold>& holds, const Found& live) {
if (live.pending) return 0;
return std::erase_if(holds, [&](const Hold& h) {
if (!h.declared || live.declarations_pending[static_cast<std::size_t>(h.part)]) return false;
const auto* d = declared(live, h.part, h.key);
return !d || d->kind != h.kind;
});
}
// ---- what plugins see and hold, apart from the game (read, set and clear) ------------------------------
inline bool live(const Found& found, Part part, std::uint64_t key) noexcept {
for (const auto& material : found.materials)
if (material.part == part)
for (const auto& node : material.nodes)
if (node.parameter.key == key) return true;
return false;
}
// The part's parameters: its first material's (another, a variant, holds the same keys), then what
// the equipped item's mod declares that the material does not hold yet, at its default.
inline std::vector<Parameter> listed(const Found& found, Part part) {
std::vector<Parameter> out;
for (const auto& material : found.materials)
if (material.part == part) {
for (const auto& node : material.nodes) out.push_back(node.parameter);
break;
}
for (const auto& d : found.declared)
if (d.part == part && std::none_of(out.begin(), out.end(), [&](const Parameter& p) { return p.key == d.key; }))
out.push_back({d.key, d.kind, d.fallback, 0, true});
return out;
}
// `owner` holds `value` on that key from now on: empty, else why not. A key is one plugin's at a
// time, and a plugin holds at most `most_held`.
inline std::string take(std::vector<Hold>& holds, const Found& found, std::string_view owner, Part part, std::uint64_t key,
const Colour& value, bool colour) {
const auto name = part_names[static_cast<std::size_t>(part)];
Kind kind{};
if (auto why = refusal(found, part, key, value, colour, kind); !why.empty()) return why;
const bool by_declaration = declared(found, part, key) != nullptr;
const auto held = std::find_if(holds.begin(), holds.end(), [&](const Hold& h) { return h.part == part && h.key == key; });
if (held != holds.end() && held->owner != owner) return std::format("{} holds the {}'s {:016x}", held->owner, name, key);
if (held == holds.end() &&
static_cast<std::size_t>(std::count_if(holds.begin(), holds.end(), [&](const Hold& h) { return h.owner == owner; })) >= most_held)
return std::format("a plugin holds at most {} board parameters", most_held);
const Colour kept = colour ? value : Colour{value[0], 0, 0};
if (held != holds.end()) held->value = kept, held->declared = by_declaration;
else holds.push_back({std::string(owner), part, key, kind, kept, by_declaration});
return {};
}
// What `owner` holds of that part and key (all its parts, all its keys) let go: apply() puts it back.
inline void release(std::vector<Hold>& holds, std::string_view owner, std::optional<Part> part, std::optional<std::uint64_t> key) {
std::erase_if(holds, [&](const Hold& h) { return h.owner == owner && (!part || h.part == *part) && (!key || h.key == *key); });
}
} // namespace detail
} // namespace dingosdk::board_material
+2 -2
View File
@@ -47,7 +47,7 @@ std::int32_t selected_preset(std::uintptr_t base, std::uintptr_t manager) {
// The board the skater rides now: the board recipe of the selected preset (the first when none is
// selected), its parts in the card's order, empty slots left out. Game thread.
} // namespace
std::vector<Worn> worn_board() {
std::vector<Worn> worn_board(bool titles) {
std::vector<Worn> parts;
const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
std::uintptr_t manager{}, begin{}, end{};
@@ -66,7 +66,7 @@ std::vector<Worn> worn_board() {
for (const auto& wanted : board_slots)
for (const auto& item : recipe.items) {
if (item.slot != wanted.slot || item.asset.empty()) continue;
parts.push_back({wanted.label, item.asset, item_title(item.asset)});
parts.push_back({wanted.label, item.asset, titles ? item_title(item.asset) : std::string{}});
}
break;
}
+3 -1
View File
@@ -12,7 +12,9 @@ struct Worn {
std::string title; // as the game shows it
};
std::vector<Worn> worn_outfit();
std::vector<Worn> worn_board();
// `titles` off leaves each title empty: what the board's material declarations need is only the
// items' keys, read every quarter second (board_material.cpp).
std::vector<Worn> worn_board(bool titles = true);
// Its catalog title, the title an item grid showed this session (a mod's item has no catalog
// entry), or its key made readable the way the game does for a mod's item.
std::string item_title(const std::string& asset);
+122 -1
View File
@@ -10,6 +10,7 @@
#include "Extension/Throwdowns/custom_modes.h"
#include "Extension/Throwdowns/throwdown_kit.h"
#include "Engine/Vfs/mod_list.h"
#include "Extension/Customization/board_material.h"
#include <algorithm>
#include <chrono>
#include <format>
@@ -22,6 +23,8 @@
using namespace dingosdk::plugins;
namespace plugin_ui = dingosdk::plugin_ui;
namespace board_material = dingosdk::board_material;
namespace mods = dingosdk::mods;
namespace {
void check(bool value, const std::string& message) { if (!value) throw std::runtime_error(message); }
@@ -73,7 +76,41 @@ struct FakeGame final : Game {
return {};
}
void board_park(std::string_view plugin, std::vector<int> bones) override { calls.push_back(std::format("board park {} {}", plugin, bones.size())); }
// The Example declared-input deck as the game holds it: seven live selectors, and the eight colours (and a
// number) its mod declares for its shader. What plugins hold of it is kept by the same rules as
// the game's (board_material.h: take, listed, release). Off: no deck declares anything.
bool declared_rules{}; // the deck answers as the Example deck (else as before: everything is taken)
bool declared_deck{}; // with declared_rules: the Example deck is equipped (else a deck that declares nothing)
std::vector<board_material::detail::Hold> declared_holds;
static constexpr std::array<std::uint64_t, 8> declared_keys{
0x5b2d035c7746f31aULL, 0x51745c0fd2d117d3ULL, 0x5c1058b72fe158edULL, 0x5a4e08ac201c369fULL,
0x57fc15cf6583c0c7ULL, 0x5a2f52ddbd12f283ULL, 0x55fda1e1521b9097ULL, 0x580bde15aa576f12ULL};
static constexpr std::uint64_t declared_number = 0x7777777777777777ULL;
board_material::detail::Found declared_board() const {
using namespace board_material;
detail::Found found;
detail::Material deck{0x100, 0x200, Part::deck, {}};
for (std::uint64_t k = 1; k <= 7; ++k)
deck.nodes.push_back({0x300 + k, {0x5000 + k, k % 2 ? Kind::integer : Kind::toggle, {}, static_cast<std::int32_t>(k)}, 0x1234});
found.materials.push_back(deck);
mods::MaterialDeclaration declaration{"Example", "deck", {"Example_Deck"}, {}};
for (const auto key : declared_keys) declaration.parameters.push_back({key, static_cast<std::uint16_t>(key), true, {.5f, .5f, .5f}});
declaration.parameters.push_back({declared_number, 0x4242, false, {.25f, 0, 0}});
auto never_sold = [](std::string_view) { return false; };
found.declared = detail::declared_for({declaration}, Part::deck, declared_deck ? "Example_Deck" : "Plain_Deck", never_sold, "Example");
return found;
}
bool declared_holds_deck(int part) const { return declared_rules && part == 0; }
std::optional<std::vector<BoardParameter>> board_parameters(int part) override {
if (declared_holds_deck(part)) {
std::vector<BoardParameter> out;
for (const auto& p : board_material::detail::listed(declared_board(), board_material::Part::deck)) {
using board_material::Kind;
out.push_back({p.key, p.kind == Kind::colour ? "colour" : p.kind == Kind::number ? "number" : p.kind == Kind::integer ? "integer" : "switch",
p.value, p.integer, p.inherited});
}
return out;
}
if (part != 1) return std::vector<BoardParameter>{};
return std::vector<BoardParameter>{{0x873f48af36e50d59ULL, "colour", {.2f, .3f, .4f}, 0},
{0x841c7d74837f1d39ULL, "number", {1, 0, 0}, 0},
@@ -81,11 +118,20 @@ struct FakeGame final : Game {
{0xa5f9a6b8098279adULL, "switch", {}, 1}};
}
std::string board_set_parameter(std::string_view plugin, int part, std::uint64_t key, std::array<float, 3> value, bool colour) override {
if (declared_holds_deck(part)) {
auto why = board_material::detail::take(declared_holds, declared_board(), plugin, board_material::Part::deck, key, value, colour);
if (why.empty()) calls.push_back(std::format("board parameter {} {} {:016x} {} {:.1f}", plugin, part, key, colour ? "colour" : "number", value[0]));
return why;
}
calls.push_back(std::format("board parameter {} {} {:016x} {} {:.1f}", plugin, part, key, colour ? "colour" : "number", value[0]));
return {};
}
void board_clear_parameter(std::string_view plugin, int part, std::optional<std::uint64_t> key) override {
calls.push_back(std::format("board parameter clear {} {} {}", plugin, part, key ? std::format("{:016x}", *key) : "all"));
board_material::detail::release(declared_holds, plugin, part < 0 ? std::nullopt : std::optional(static_cast<board_material::Part>(part)), key);
}
std::size_t declared_held(std::string_view plugin) const {
return static_cast<std::size_t>(std::count_if(declared_holds.begin(), declared_holds.end(), [&](const auto& h) { return h.owner == plugin; }));
}
// The general services a game layer in Lua reads and drives (plugin_api_services.cpp).
std::optional<SkaterTelemetry> telemetry;
@@ -640,7 +686,11 @@ struct FakeGame final : Game {
return {};
}
std::vector<std::pair<std::string, std::string>> take_presses() override { return std::exchange(presses, {}); }
void release(std::string_view plugin) override { released.emplace_back(plugin); entity_clear(plugin); }
void release(std::string_view plugin) override {
released.emplace_back(plugin);
entity_clear(plugin);
board_material::detail::release(declared_holds, plugin, std::nullopt, std::nullopt);
}
// The game menu, its item grid, what is worn, and a screen capture of something turning once
// every `turning` frames (reskate.capture, room.turn, menu.game/grid, outfit.board).
GameMenu menu_now;
@@ -2469,6 +2519,76 @@ void web_requests_go_only_to_listed_domains() {
check(environment.logged("many more than 4 web requests waiting"), "4 waiting at most\n" + environment.all());
}
void declared_board_colours_through_the_api() {
TestEnvironment environment;
environment.fake.declared_rules = true;
environment.fake.declared_deck = true;
Host host(environment, Side::client);
host.apply({script("materialapi", R"(
reskate.console.command("materialapi", "x", function(args)
local deck = reskate.board.parameters("deck")
assert(#deck == 7 + 9, "seven selectors and nine declared inputs, not " .. #deck)
local inherited = 0
for i, p in deck do
if i <= 7 then
assert(not p.inherited and (p.type == "integer" or p.type == "switch"), "a live selector")
else
assert(p.inherited == true, "a declared input is inherited")
if p.type == "colour" then
inherited += 1
assert(p.value.r == 0.5 and p.value.g == 0.5 and p.value.b == 0.5, "at its declared default")
end
end
end
assert(inherited == 8, "eight declared colours")
for _, key in {"5b2d035c7746f31a", "51745c0fd2d117d3", "5c1058b72fe158ed", "5a4e08ac201c369f",
"57fc15cf6583c0c7", "5a2f52ddbd12f283", "55fda1e1521b9097", "580bde15aa576f12"} do
assert(reskate.board.set_parameter("deck", key, {r = 0.25, g = 0.75, b = 0.375}), key)
end
local ok, why = reskate.board.set_parameter("deck", "0000000000005001", 3)
assert(not ok and why:find("only read"), "a selector stays read-only: " .. tostring(why))
ok, why = reskate.board.set_parameter("deck", "5b2d035c7746f31a", 0.5)
assert(not ok and why:find("colour"), "a declared colour is not a number: " .. tostring(why))
ok, why = reskate.board.set_parameter("deck", "5b2d035c7746f31a", {r = 2, g = 0, b = 0})
assert(not ok and why:find("0 to 1"), "its channels are 0 to 1")
ok, why = reskate.board.set_parameter("deck", "7777777777777777", 0.5)
assert(not ok and why:find("only a declared colour"), "a declared number with no live value is not added: " .. tostring(why))
ok, why = reskate.board.set_parameter("deck", "1234567812345678", {r = 0, g = 0, b = 0})
assert(not ok and why:find("no parameter"), "nor anything undeclared")
reskate.log("materialapi fine")
end)
reskate.console.command("materialapi_one", "x", function() reskate.board.clear_parameter("deck", "5b2d035c7746f31a") end)
)", R"("board", "console")"),
script("thief", R"(
reskate.console.command("thief", "x", function()
local ok, why = reskate.board.set_parameter("deck", "5b2d035c7746f31a", {r = 1, g = 1, b = 1})
assert(not ok and why:find("materialapi holds"), "another plugin's colour is not taken: " .. tostring(why))
reskate.log("thief refused")
end)
)", R"("board", "console")"),
script("nopermission", R"(
local ok, err = pcall(reskate.board.parameters, "deck")
assert(not ok and err:find("needs the \"board\" permission"), err)
ok, err = pcall(reskate.board.set_parameter, "deck", "5b2d035c7746f31a", {r = 1, g = 1, b = 1})
assert(not ok and err:find("needs the \"board\" permission"), err)
ok, err = pcall(reskate.board.clear_parameter, "deck")
assert(not ok and err:find("needs the \"board\" permission"), err)
reskate.log("nopermission refused")
)")});
running(host, "materialapi", environment);
running(host, "nopermission", environment);
check(environment.logged("nopermission refused"), "A plugin without the board permission reaches no board parameter\n" + environment.all());
check(environment.fake.declared_holds.empty(), "Nothing is held before a plugin asks");
check(host.run_console("materialapi") && environment.logged("materialapi fine"), "Declared colours list and set\n" + environment.all());
check(environment.fake.declared_held("materialapi") == 8, "The plugin holds the eight declared colours");
check(host.run_console("thief") && environment.logged("thief refused"), "Another plugin cannot take them\n" + environment.all());
check(environment.fake.declared_held("thief") == 0, "and holds none");
check(host.run_console("materialapi_one") && environment.fake.declared_held("materialapi") == 7, "Clearing one key lets go of that one");
host.apply({});
check(environment.fake.declared_holds.empty(), "Unloading lets go of every one (the game puts them back to their defaults)");
}
void the_wider_set_reaches_the_game() {
TestEnvironment environment;
auto& game = environment.fake;
@@ -4163,6 +4283,7 @@ int main() {
run(plugins_call_and_hear_each_other);
run(web_requests_go_only_to_listed_domains);
run(the_wider_set_reaches_the_game);
run(declared_board_colours_through_the_api);
run(key_names_become_binds);
run(level_events_reach_plugins);
run(timers_run_when_due);
+10 -2
View File
@@ -1830,7 +1830,9 @@ struct WorldApi {
}
// parameters(part): {{key = "873f48af36e50d59", name = "graphic", type = "colour", value = {r, g, b}}, ...}
// as the part's material holds them now, or nil without a board. A number's value is a number, an
// integer's an integer, a switch's true or false.
// integer's an integer, a switch's true or false. A colour or a number the equipped item's mod
// declares (reskate-materials.json) that the material does not hold yet comes with inherited =
// true and its shader's default as its value; set_parameter adds a declared colour.
static int board_parameters(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "board", "reskate.board.parameters");
@@ -1871,12 +1873,18 @@ struct WorldApi {
lua_pushboolean(L, p.integer != 0);
lua_setfield(L, -2, "value");
}
if (p.inherited) {
lua_pushboolean(L, 1);
lua_setfield(L, -2, "inherited");
}
lua_rawseti(L, -2, ++at);
}
return 1;
}
// set_parameter(part, key, value): a colour {r, g, b} (0 to 1) or a number held on the part until
// cleared, on this screen; an integer or a switch is only read.
// cleared, on this screen; an integer or a switch is only read. A colour the equipped item's mod
// declares is added to the material and goes back to its declared default when let go, or when
// that item is no longer on the board.
static int board_set_parameter(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "board", "reskate.board.set_parameter");
+3
View File
@@ -832,6 +832,9 @@ public:
std::string type;
std::array<float, 3> value{};
std::int32_t integer{};
// Declared by the equipped item's mod (reskate-materials.json) and not held by the material
// yet: `value` is the default its shader has; set_parameter adds it.
bool inherited{};
};
virtual std::optional<std::vector<BoardParameter>> board_parameters(int /*part*/) { return std::nullopt; }
// A colour (`colour`, all three) or a number (value[0]) held there until cleared; empty when held.
+1 -1
View File
@@ -528,7 +528,7 @@ public:
using board_material::Kind;
const char* type = p.kind == Kind::colour ? "colour" : p.kind == Kind::number ? "number" : p.kind == Kind::integer ? "integer"
: p.kind == Kind::toggle ? "switch" : "other";
out.push_back({p.key, type, p.value, p.integer});
out.push_back({p.key, type, p.value, p.integer, p.inherited});
}
return out;
}
+9
View File
@@ -91,6 +91,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32)
add_executable(dingosdk_variant_additions_plan_tests Engine/Vfs/Test/variant_additions_plan_tests.cpp)
target_include_directories(dingosdk_variant_additions_plan_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME variant_additions_plan COMMAND dingosdk_variant_additions_plan_tests)
add_executable(dingosdk_material_declaration_refresh_tests Engine/Vfs/Test/material_declaration_refresh_tests.cpp)
target_link_libraries(dingosdk_material_declaration_refresh_tests PRIVATE dingosdk_mods)
target_include_directories(dingosdk_material_declaration_refresh_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME material_declaration_refresh COMMAND dingosdk_material_declaration_refresh_tests)
add_executable(dingosdk_bundle_ref_table_tests Engine/Vfs/Test/bundle_ref_table_tests.cpp)
target_link_libraries(dingosdk_bundle_ref_table_tests PRIVATE dingosdk_mods)
add_test(NAME bundle_ref_table COMMAND dingosdk_bundle_ref_table_tests)
@@ -111,6 +115,11 @@ 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)
# The board shader inputs a mod declares for its own items (Engine/Vfs/mod_materials.h).
add_executable(dingosdk_mod_materials_tests Engine/Vfs/Test/mod_materials_tests.cpp)
target_link_libraries(dingosdk_mod_materials_tests PRIVATE dingosdk_mods)
target_include_directories(dingosdk_mod_materials_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME mod_materials COMMAND dingosdk_mod_materials_tests)
add_executable(dingosdk_music_safety_tests Extension/Music/Test/local_music_safety_tests.cpp)
target_include_directories(dingosdk_music_safety_tests PRIVATE "${PROJECT_SOURCE_DIR}")
dingosdk_configure_target(dingosdk_music_safety_tests)
+1
View File
@@ -109,6 +109,7 @@ if(WIN32)
Engine/Vfs/mod_merge_grid.cpp
Engine/Vfs/mod_merge_load_screens.cpp
Engine/Vfs/mod_music.cpp
Engine/Vfs/mod_materials.cpp
Engine/Vfs/mod_scoring.cpp
Engine/Vfs/mod_store_copies.cpp
Engine/Vfs/mod_item_sources.cpp