From 4c2b500a999905bce6199f2db1dc556ea16ad621 Mon Sep 17 00:00:00 2001 From: hypnomalice <344329436+hypnomalice@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:50:36 +0100 Subject: [PATCH 1/3] Plugins: hold declared colour inputs on mod-owned board materials --- Engine/Vfs/Test/mod_materials_tests.cpp | 138 ++++++++++ Engine/Vfs/mod_materials.cpp | 108 ++++++++ Engine/Vfs/mod_materials.h | 41 +++ .../Test/board_material_tests.cpp | 241 +++++++++++++++++- Extension/Customization/board_material.cpp | 231 +++++++++++++++-- Extension/Customization/board_material.h | 193 +++++++++++++- Extension/Customization/worn_items.cpp | 4 +- Extension/Customization/worn_items.h | 4 +- Extension/Plugins/Test/plugin_tests.cpp | 135 +++++++++- Extension/Plugins/plugin_api_world.cpp | 13 +- Extension/Plugins/plugin_game.h | 5 + Extension/Plugins/plugin_game_runtime.cpp | 10 +- cmake/Apps.cmake | 5 + cmake/Core.cmake | 1 + contrib/declared-board-materials.md | 19 ++ 15 files changed, 1104 insertions(+), 44 deletions(-) create mode 100644 Engine/Vfs/Test/mod_materials_tests.cpp create mode 100644 Engine/Vfs/mod_materials.cpp create mode 100644 Engine/Vfs/mod_materials.h create mode 100644 contrib/declared-board-materials.md diff --git a/Engine/Vfs/Test/mod_materials_tests.cpp b/Engine/Vfs/Test/mod_materials_tests.cpp new file mode 100644 index 00000000..14d645ea --- /dev/null +++ b/Engine/Vfs/Test/mod_materials_tests.cpp @@ -0,0 +1,138 @@ +// 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 +#include +#include + +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": "d317d1d20f5c745159ff5e880100dcb0", "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{.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("{}"), "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; +} diff --git a/Engine/Vfs/mod_materials.cpp b/Engine/Vfs/mod_materials.cpp new file mode 100644 index 00000000..6cda8622 --- /dev/null +++ b/Engine/Vfs/mod_materials.cpp @@ -0,0 +1,108 @@ +#include "Engine/Vfs/mod_materials.h" +#include "Engine/Core/Json/json.h" +#include +#include +#include +#include + +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 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(); + if (!std::isfinite(number)) throw std::runtime_error(std::string(what) + " is not finite"); + return static_cast(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"); + 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 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(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(bytes[14] | (bytes[15] << 8)); + return key != 0; +} + +std::vector 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() || root.at("schema").get() != 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 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 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 diff --git a/Engine/Vfs/mod_materials.h b/Engine/Vfs/mod_materials.h new file mode 100644 index 00000000..8a741c28 --- /dev/null +++ b/Engine/Vfs/mod_materials.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +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 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 items; // the item keys whose material these belong to + std::vector parameters; +}; + +// Reads a mod's reskate-materials.json: +// {"schema": 1, "materials": [{"part": "deck", "items": ["", ...], +// "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 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 diff --git a/Extension/Customization/Test/board_material_tests.cpp b/Extension/Customization/Test/board_material_tests.cpp index 55d455f7..f7d7269f 100644 --- a/Extension/Customization/Test/board_material_tests.cpp +++ b/Extension/Customization/Test/board_material_tests.cpp @@ -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 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 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(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(material + n::material_parameter_count, count + 1); + return added; + } std::uintptr_t material(const std::vector& values, std::map* 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 raw{}; const std::array 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 selectors; + for (std::uint64_t k = 1; k <= 7; ++k) selectors.push_back({0x5000 + k, k % 2 ? integer : toggle, 4, {static_cast(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,219 @@ 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 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); + 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); + 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).empty(), "another deck's do not"); + check(detail::declared_for(all, Part::grip, "Example_Deck", never_sold).empty(), "nor another part's"); + check(detail::declared_for(all, Part::deck, "", never_sold).empty(), "nor an empty slot's"); + auto sold = [](std::string_view) { return true; }; + check(detail::declared_for(all, Part::deck, "Example_Deck", sold).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}}}}); + const auto merged = detail::declared_for(both, Part::deck, "Example_Deck", never_sold); + check(std::none_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, + "A key two mods declare differently is left out; one they agree on is kept once"); +} + +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 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 saved; + std::vector holds; + int writes{}, publishes{}, inserts{}; + std::map 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 saved; + std::vector 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.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() { 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"; } diff --git a/Extension/Customization/board_material.cpp b/Extension/Customization/board_material.cpp index 5015b2d5..47803868 100644 --- a/Extension/Customization/board_material.cpp +++ b/Extension/Customization/board_material.cpp @@ -1,10 +1,20 @@ #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 "Extension/Multiplayer/Remote/native_pose_layout.h" #include "Extension/Multiplayer/Remote/native_skater.h" #include #include +#include +#include +#include +#include +#include namespace dingosdk::board_material { namespace { @@ -13,7 +23,18 @@ struct State { std::vector holds; std::vector saved; std::optional functions; // the material publisher's bytes, once checked + std::optional 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; + std::uint64_t worn_at{}; + std::array 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 reported_items; + std::array reported_counts{}; }; State& state() { static auto* value = new State; // what is held may still be out at exit @@ -34,6 +55,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( + 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 bytes{}; @@ -45,6 +79,148 @@ 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 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. +struct Declarations { + std::mutex mutex; + std::shared_ptr> ready; + std::vector stamps; + bool reading{}; + std::uint64_t looked{}; +}; +Declarations& declarations() { + static auto* value = new Declarations; + return *value; +} +std::vector declaration_stamps() { + std::vector stamps; + for (const auto& mod : mods::catalog().mods) { + std::error_code error; + const auto stamp = std::filesystem::last_write_time(mod.directory / L"reskate-materials.json", error); + stamps.push_back(error ? std::filesystem::file_time_type::min() : stamp); + } + return stamps; +} +std::vector read_declarations() { + std::vector result; + for (const auto& mod : mods::catalog().mods) { + const auto path = mod.directory / L"reskate-materials.json"; + std::error_code error; + if (!std::filesystem::is_regular_file(path, error) || std::filesystem::file_size(path, error) > 64 * 1024) continue; + try { + std::ifstream in(path, std::ios::binary); + auto read = mods::parse_material_declarations(mod.name, std::string((std::istreambuf_iterator(in)), {})); + std::size_t count{}; + for (const auto& d : read) count += d.parameters.size(); + logging::log(logging::Level::info, logging::Channel::customization, + "Board parameters: {} declares {} shader input{} for its items.", mod.name, count, count == 1 ? "" : "s"); + std::move(read.begin(), read.end(), std::back_inserter(result)); + } catch (const std::exception& failure) { + logging::log(logging::Level::warning, logging::Channel::customization, + "Board parameters: {}'s reskate-materials.json is left out: {}", mod.name, failure.what()); + } + } + return result; +} +void read_declarations_now() noexcept { + auto& d = declarations(); + std::shared_ptr> read; + std::vector stamps; + try { + stamps = declaration_stamps(); + bool same{}; + { + std::lock_guard lock(d.mutex); + same = d.ready && stamps == d.stamps; + } + if (!same) read = std::make_shared>(read_declarations()); + } catch (...) {} + std::lock_guard lock(d.mutex); + if (read) { + d.ready = std::move(read); + d.stamps = std::move(stamps); + } else if (!d.ready) { + d.ready = std::make_shared>(); + } + d.reading = false; +} +// What was last read, and a read started when the last began two seconds ago or more. +std::shared_ptr> current_declarations() { + auto& d = declarations(); + std::lock_guard lock(d.mutex); + const auto now = GetTickCount64(); + if (!d.reading && (!d.ready || now - d.looked >= 2000)) { + d.reading = true; + d.looked = now; + try { + std::thread(read_declarations_now).detach(); + } catch (...) { + d.reading = false; + } + } + return d.ready; +} +// Whether the game sells an item, or cannot tell yet (no content pack read): either way its +// declarations do not apply. Only a mod's own items take them. +bool sold(std::string_view item) { + return !content_cache::catalogs().available || profile_runtime::reserved_cosmetic(item); +} +constexpr std::array 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 || all->empty()) 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(m.part) == part) { material = m.address; break; } + if (!detail::settled(s.gates[part], item, material, now) || found.pending) continue; + auto is_sold = [](std::string_view i) { return sold(i); }; + auto add = detail::declared_for(*all, static_cast(part), item, is_sold); + // 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 board(State& s) { if (!s.base || !s.client) return std::nullopt; @@ -53,7 +229,9 @@ std::optional 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 +247,23 @@ std::optional> read(Part part) { auto& s = state(); const auto found = board(s); if (!found) return std::nullopt; - std::vector 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(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::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, std::optional 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 +274,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 diff --git a/Extension/Customization/board_material.h b/Extension/Customization/board_material.h index 6e20a3d1..219232fd 100644 --- a/Extension/Customization/board_material.h +++ b/Extension/Customization/board_material.h @@ -1,5 +1,6 @@ #pragma once #include "developer_board_material.h" +#include "Engine/Vfs/mod_materials.h" #include #include #include @@ -14,6 +15,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 part_names{"deck", "grip", "trucks", "wheels"}; @@ -24,6 +32,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 +56,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 +75,7 @@ struct Material { struct Found { std::vector materials; bool pending{}; // a recipe on its way: nothing is written + std::vector declared; // what the equipped items' mods declare (declarations below) }; // Each part's named material on the board's slot controllers; the deck slot holds two. struct Target { @@ -98,6 +118,7 @@ template std::vector 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 +134,7 @@ template std::vector 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 +197,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(part)]; std::optional has; + std::optional 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 +240,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 -void apply(std::vector& saved, const std::vector& 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 +void apply(std::vector& saved, const std::vector& holds, const Found& live, Write& write, Publish& publish, Insert& insert) { if (live.pending) return; using developer_hoodie_detail::settle_publishes; std::vector next; @@ -227,10 +276,10 @@ void apply(std::vector& saved, const std::vector& 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 +287,148 @@ void apply(std::vector& saved, const std::vector& 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(left ? left - 1 : 0)}); + next.push_back({material.address, node.address, p.kind, original, hold->value, static_cast(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 +void apply(std::vector& saved, const std::vector& 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 --------------------------------------------------------------------------------- +// The declarations of the item equipped on `part` (its key, as the game names it; any case), from +// every enabled mod's reskate-materials.json. A key two declarations 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 +std::vector declared_for(const std::vector& declarations, Part part, std::string_view item, Sold& sold) { + std::vector out; + if (item.empty() || sold(item)) return out; + const auto folded = [](std::string_view text) { + std::string s(text); + for (auto& c : s) c = static_cast(c >= 'A' && c <= 'Z' ? c + 32 : c); + return s; + }; + const auto wanted = folded(item); + std::vector conflicting; + for (const auto& d : declarations) { + if (d.part != part_names[static_cast(part)]) continue; + if (std::none_of(d.items.begin(), d.items.end(), [&](const std::string& i) { return folded(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& holds, const Found& live) { + if (live.pending) return 0; + return std::erase_if(holds, [&](const Hold& h) { + if (!h.declared) 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 listed(const Found& found, Part part) { + std::vector 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& 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(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::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& holds, std::string_view owner, std::optional part, std::optional 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 diff --git a/Extension/Customization/worn_items.cpp b/Extension/Customization/worn_items.cpp index 4b45229a..cc8442a5 100644 --- a/Extension/Customization/worn_items.cpp +++ b/Extension/Customization/worn_items.cpp @@ -48,7 +48,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_board() { +std::vector worn_board(bool titles) { std::vector parts; const auto base = reinterpret_cast(GetModuleHandleW(nullptr)); std::uintptr_t manager{}, begin{}, end{}; @@ -67,7 +67,7 @@ std::vector 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; } diff --git a/Extension/Customization/worn_items.h b/Extension/Customization/worn_items.h index bb83de86..2030f1e6 100644 --- a/Extension/Customization/worn_items.h +++ b/Extension/Customization/worn_items.h @@ -12,7 +12,9 @@ struct Worn { std::string title; // as the game shows it }; std::vector worn_outfit(); -std::vector 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_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); diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index be2901b2..f829e588 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -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 #include #include @@ -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); } @@ -59,6 +62,8 @@ struct FakeGame final : Game { return ending == "roadrash_leg1" ? "RoadRash_Leg1" : ""; } std::vector outfit_slots() override { return {{"tattoo_legl", "", {0.5f, 1}}, {"moles", "moles_empty", {}}}; } + int board_preparations{}; + void prepare_board() override { ++board_preparations; } std::optional> board_bones() override { std::array bones{}; bones[3].position = {0, 0.1f, 0}; @@ -73,7 +78,41 @@ struct FakeGame final : Game { return {}; } void board_park(std::string_view plugin, std::vector 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 declared_holds; + static constexpr std::array 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(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(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); + return found; + } + bool declared_holds_deck(int part) const { return declared_rules && part == 0; } std::optional> board_parameters(int part) override { + if (declared_holds_deck(part)) { + std::vector 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{}; return std::vector{{0x873f48af36e50d59ULL, "colour", {.2f, .3f, .4f}, 0}, {0x841c7d74837f1d39ULL, "number", {1, 0, 0}, 0}, @@ -81,11 +120,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 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 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(part)), key); + } + std::size_t declared_held(std::string_view plugin) const { + return static_cast(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 telemetry; @@ -622,7 +670,11 @@ struct FakeGame final : Game { return {}; } std::vector> 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; @@ -2426,6 +2478,85 @@ 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 board_observation_is_prepared_only_with_permission() { + TestEnvironment environment; + Host host(environment, Side::client); + host.apply({script("plain", "assert(reskate)")}); + check(environment.fake.board_preparations == 0, "No board preparation without permission"); + host.apply({script("boardreader", "assert(reskate.board.bones())", R"("board")")}); + running(host, "boardreader", environment); + check(environment.fake.board_preparations == 1, "Board observation is prepared before the script reads its pose"); +} + +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; @@ -4120,6 +4251,8 @@ 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(board_observation_is_prepared_only_with_permission); + run(declared_board_colours_through_the_api); run(key_names_become_binds); run(level_events_reach_plugins); run(timers_run_when_due); diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 05a15db3..728b1742 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -1777,7 +1777,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"); @@ -1818,12 +1820,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"); @@ -2883,6 +2891,7 @@ struct WorldApi { void install_world_api(Plugin& plugin, lua_State* L) { WorldApi::install(plugin, L); } void WorldApi::install(Plugin& plugin, lua_State* L) { + if (plugin.has("board")) game(plugin).prepare_board(); (void)plugin; // The metatables of player tables (push_player) and of permissions; their own stay hidden. lua_createtable(L, 0, 2); diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index f2076e0e..a888dbf8 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -806,6 +806,8 @@ public: std::array rotation{0, 0, 0, 1}; Vec3 scale{1, 1, 1}; }; + // Install native board observation before entering a timed Lua call. + virtual void prepare_board() {} virtual std::optional> board_bones() { return std::nullopt; } virtual std::string board_set(std::string_view /*plugin*/, int, const BoardBone&, bool /*world*/) { return unavailable; } // `everyone`: hidden from the other players too, not only on this screen. @@ -828,6 +830,9 @@ public: std::string type; std::array 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> board_parameters(int /*part*/) { return std::nullopt; } // A colour (`colour`, all three) or a number (value[0]) held there until cleared; empty when held. diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index d51114b9..53240ff0 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -19,6 +19,7 @@ #include "Extension/Skater/skater_events.h" #include "Extension/Skater/outfit_overlay.h" #include "Extension/Skater/board_pose.h" +#include "Extension/Multiplayer/Remote/native_skater.h" #include "Extension/Customization/board_material.h" #include "Engine/Vfs/mod_catalog.h" #include "Engine/Core/Log/logging.h" @@ -500,7 +501,13 @@ public: return thrower_ == plugin ? multiplayer::throwdown_relay_host_status() : std::string{}; } // ---- board ----------------------------------------------------------------------------------------- + void prepare_board() override { + if (board_hooks_ready_) return; + std::string detail; + board_hooks_ready_ = multiplayer::install_entity_hooks(base_, detail); + } std::optional> board_bones() override { + if (!board_hooks_ready_) return std::nullopt; const auto drawn = board_pose::read(); if (!drawn) return std::nullopt; std::array out{}; @@ -528,7 +535,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; } @@ -1809,6 +1816,7 @@ private: } std::uintptr_t base_; + bool board_hooks_ready_{}; std::string capture_owner_, capture_file_; // the plugin whose capture it is, and where it saved to std::string camera_holder_, follower_, camera_status_; std::array camera_pose_{}; diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 3e4e99d2..6aeae668 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -100,6 +100,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) diff --git a/cmake/Core.cmake b/cmake/Core.cmake index d4507b7d..7f66fa5f 100644 --- a/cmake/Core.cmake +++ b/cmake/Core.cmake @@ -108,6 +108,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 diff --git a/contrib/declared-board-materials.md b/contrib/declared-board-materials.md new file mode 100644 index 00000000..fe1c9c32 --- /dev/null +++ b/contrib/declared-board-materials.md @@ -0,0 +1,19 @@ +# Declared board material inputs + +Plugins already read and hold live colour/number values through `reskate.board.parameters`, `set_parameter` and `clear_parameter`. A custom shader can have inputs whose defaults are compiled into it, with no live node for that API to hold. A mod may declare these in its root `reskate-materials.json`: + +```json +{"schema":1,"materials":[{"part":"deck","items":["Own_Example_Deck"],"parameters":[{"guid":"1af346775c032d5b59ff5e88010069b6","type":"colour","default":[0.5,0.5,0.5]}]}]} +``` + +The declaration describes an existing compiled shader input; it does not install shaders, select materials, unlock items or add textures. The GUID is its 16 serialized bytes in hex. The shader and declaration must agree on identity, type and default. Only the declaring equipped mod-owned item receives the value. Store items are excluded. + +A permitted plugin sees an absent live value as `inherited = true`, with the declared default. It uses the existing `board` permission and existing set/clear API. Missing declared colours are inserted with the engine typed setter at native preset priority. A number must already have a live value; the declaration does not enable missing float insertion. Integers, switches and textures cannot be declared or changed. + +Held values are released on clear, unload and item changes. Existing nodes restore their prior values; inserted nodes restore declared defaults (the engine retains the node). Type/identity mismatches and values outside bounds are refused. Competing plugins cannot take over a held key. The native setter/descriptor are checked against the supported build before use. + +Bounds: 32 materials, 64 item keys per declaration, 32 parameters per declaration; text at most 255 bytes; colour channels 0–1 and numbers -100–100. Malformed files are skipped as a whole. + +Board observation is prepared before timed plugin execution, only for a plugin with the `board` permission. No effect-specific solver, artwork, controller or raw-memory plugin access is introduced. + +Tests: `mod_materials`, `board_material`, `plugins`. From c29cffdf9beeff56002f82b5e6f4a89d22a26d22 Mon Sep 17 00:00:00 2001 From: hypnomalice <344329436+hypnomalice@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:01:46 +0100 Subject: [PATCH 2/3] Plugins: enforce declared input ownership and preserve holds through rebuilds --- Engine/Vfs/Test/mod_materials_tests.cpp | 5 +- Engine/Vfs/mod_materials.cpp | 8 ++- .../Test/board_material_tests.cpp | 22 ++++--- Extension/Customization/board_material.cpp | 65 +++++++++---------- Extension/Customization/board_material.h | 12 ++-- Extension/Plugins/Test/plugin_tests.cpp | 14 +--- Extension/Plugins/plugin_api_world.cpp | 1 - Extension/Plugins/plugin_game.h | 2 - Extension/Plugins/plugin_game_runtime.cpp | 8 --- contrib/declared-board-materials.md | 19 ------ 10 files changed, 64 insertions(+), 92 deletions(-) delete mode 100644 contrib/declared-board-materials.md diff --git a/Engine/Vfs/Test/mod_materials_tests.cpp b/Engine/Vfs/Test/mod_materials_tests.cpp index 14d645ea..6ebeac9b 100644 --- a/Engine/Vfs/Test/mod_materials_tests.cpp +++ b/Engine/Vfs/Test/mod_materials_tests.cpp @@ -58,7 +58,7 @@ int main() try { "parameters": [ {"guid": "1af346775c032d5b59ff5e88010069b6", "type": "colour", "default": [0.5, 0.5, 0.5]}, {"guid": "126f57aa15de0b5859ff5e8801003944", "type": "color", "default": [0, 0, 0]}, - {"guid": "d317d1d20f5c745159ff5e880100dcb0", "type": "number", "default": -2.5} + {"guid": "d317d1d20f5c74512d5cb62b0100dcb0", "type": "number", "default": -2.5} ] }] })"); @@ -109,6 +109,9 @@ int main() try { "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"); diff --git a/Engine/Vfs/mod_materials.cpp b/Engine/Vfs/mod_materials.cpp index 6cda8622..adb374c8 100644 --- a/Engine/Vfs/mod_materials.cpp +++ b/Engine/Vfs/mod_materials.cpp @@ -36,6 +36,12 @@ MaterialParameter parameter(const Json& entry) { 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(c>='A'&&c<='F'?c+32: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]"); @@ -72,7 +78,7 @@ bool material_parameter_key(const std::string& guid, std::uint64_t& key, std::ui std::vector 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() || root.at("schema").get() != 1 || + if (!root.is_object() || !root.contains("schema") || !root.at("schema").is_number_integer() || root.at("schema").get() != 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"); diff --git a/Extension/Customization/Test/board_material_tests.cpp b/Extension/Customization/Test/board_material_tests.cpp index f7d7269f..a3e044ba 100644 --- a/Extension/Customization/Test/board_material_tests.cpp +++ b/Extension/Customization/Test/board_material_tests.cpp @@ -294,28 +294,30 @@ std::vector declared_declarations() { 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); + 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); + 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).empty(), "another deck's do not"); - check(detail::declared_for(all, Part::grip, "Example_Deck", never_sold).empty(), "nor another part's"); - check(detail::declared_for(all, Part::deck, "", never_sold).empty(), "nor an empty slot's"); + 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).empty(), "An item the game sells (or one it cannot tell) takes none"); + 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}}}}); - const auto merged = detail::declared_for(both, Part::deck, "Example_Deck", never_sold); - check(std::none_of(merged.begin(), merged.end(), [](const detail::Declared& d) { return d.key == liquid[0].key; }) && + 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, - "A key two mods declare differently is left out; one they agree on is kept once"); + "Another mod cannot suppress the owner's declarations"); } void lists_and_refuses_declared() { @@ -461,6 +463,8 @@ void a_change_of_deck_lets_go() { 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. diff --git a/Extension/Customization/board_material.cpp b/Extension/Customization/board_material.cpp index 47803868..a0543f92 100644 --- a/Extension/Customization/board_material.cpp +++ b/Extension/Customization/board_material.cpp @@ -6,6 +6,7 @@ #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 "Extension/Multiplayer/Remote/native_pose_layout.h" #include "Extension/Multiplayer/Remote/native_skater.h" #include @@ -104,10 +105,13 @@ bool setter_matches(State& s) { // ---- 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. +struct DeclarationSet { + std::vector materials; + std::vector sources; +}; struct Declarations { std::mutex mutex; - std::shared_ptr> ready; - std::vector stamps; + std::shared_ptr ready; bool reading{}; std::uint64_t looked{}; }; @@ -115,18 +119,11 @@ Declarations& declarations() { static auto* value = new Declarations; return *value; } -std::vector declaration_stamps() { - std::vector stamps; - for (const auto& mod : mods::catalog().mods) { - std::error_code error; - const auto stamp = std::filesystem::last_write_time(mod.directory / L"reskate-materials.json", error); - stamps.push_back(error ? std::filesystem::file_time_type::min() : stamp); - } - return stamps; -} -std::vector read_declarations() { - std::vector result; - for (const auto& mod : mods::catalog().mods) { +DeclarationSet read_declarations() { + DeclarationSet result; + const auto active=mods::load_catalog(mods::catalog().data_root,{},false); + result.sources=mods::read_item_sources(active.root/L".reskate"/mods::item_sources_file); + for (const auto& mod : active.mods) { const auto path = mod.directory / L"reskate-materials.json"; std::error_code error; if (!std::filesystem::is_regular_file(path, error) || std::filesystem::file_size(path, error) > 64 * 1024) continue; @@ -137,7 +134,7 @@ std::vector read_declarations() { for (const auto& d : read) count += d.parameters.size(); logging::log(logging::Level::info, logging::Channel::customization, "Board parameters: {} declares {} shader input{} for its items.", mod.name, count, count == 1 ? "" : "s"); - std::move(read.begin(), read.end(), std::back_inserter(result)); + std::move(read.begin(), read.end(), std::back_inserter(result.materials)); } catch (const std::exception& failure) { logging::log(logging::Level::warning, logging::Channel::customization, "Board parameters: {}'s reskate-materials.json is left out: {}", mod.name, failure.what()); @@ -147,28 +144,20 @@ std::vector read_declarations() { } void read_declarations_now() noexcept { auto& d = declarations(); - std::shared_ptr> read; - std::vector stamps; + std::shared_ptr read; try { - stamps = declaration_stamps(); - bool same{}; - { - std::lock_guard lock(d.mutex); - same = d.ready && stamps == d.stamps; - } - if (!same) read = std::make_shared>(read_declarations()); + read = std::make_shared(read_declarations()); } catch (...) {} std::lock_guard lock(d.mutex); if (read) { d.ready = std::move(read); - d.stamps = std::move(stamps); } else if (!d.ready) { - d.ready = std::make_shared>(); + d.ready = std::make_shared(); } d.reading = false; } // What was last read, and a read started when the last began two seconds ago or more. -std::shared_ptr> current_declarations() { +std::shared_ptr current_declarations() { auto& d = declarations(); std::lock_guard lock(d.mutex); const auto now = GetTickCount64(); @@ -183,10 +172,12 @@ std::shared_ptr> current_declaratio } return d.ready; } -// Whether the game sells an item, or cannot tell yet (no content pack read): either way its -// declarations do not apply. Only a mod's own items take them. +// All stock/developer items are excluded, free or paid. Unknown catalogue +// state is refused too; ownership is additionally checked against item sources. bool sold(std::string_view item) { - return !content_cache::catalogs().available || profile_runtime::reserved_cosmetic(item); + std::string key(item); for(auto& c:key)if(c>='A'&&c<='Z')c=static_cast(c+32); + const auto& catalog=content_cache::catalogs(); + return !catalog.available || catalog.items.contains(key) || key.starts_with("dev_") || key.starts_with("own_dev_") || profile_runtime::reserved_cosmetic(item); } constexpr std::array worn_slots{"Deck", "Grip tape", "Trucks", "Wheels"}; @@ -194,7 +185,7 @@ constexpr std::array worn_slots{"Deck", "Gr // have settled (detail::settled). void declare(State& s, detail::Found& found) { const auto all = current_declarations(); - if (!all || all->empty()) return; + 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); @@ -207,9 +198,17 @@ void declare(State& s, detail::Found& found) { std::uintptr_t material{}; for (const auto& m : found.materials) if (static_cast(m.part) == part) { material = m.address; break; } - if (!detail::settled(s.gates[part], item, material, now) || found.pending) continue; + 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); }; - auto add = detail::declared_for(*all, static_cast(part), item, is_sold); + std::string key(item);for(auto& c:key)if(c>='A'&&c<='Z')c=static_cast(c+32); + std::string_view owner; + for(const auto& source:all->sources) { + if(std::find(source.keys.begin(),source.keys.end(),key)!=source.keys.end()){owner=source.mod;break;} + } + auto add = detail::declared_for(all->materials, static_cast(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); diff --git a/Extension/Customization/board_material.h b/Extension/Customization/board_material.h index 219232fd..12c2f316 100644 --- a/Extension/Customization/board_material.h +++ b/Extension/Customization/board_material.h @@ -76,6 +76,7 @@ struct Found { std::vector materials; bool pending{}; // a recipe on its way: nothing is written std::vector declared; // what the equipped items' mods declare (declarations below) + std::array declarations_pending{}; }; // Each part's named material on the board's slot controllers; the deck slot holds two. struct Target { @@ -331,13 +332,14 @@ void apply(std::vector& saved, const std::vector& holds, const Foun // ---- declarations --------------------------------------------------------------------------------- // The declarations of the item equipped on `part` (its key, as the game names it; any case), from -// every enabled mod's reskate-materials.json. A key two declarations give differently is left out: +// 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 -std::vector declared_for(const std::vector& declarations, Part part, std::string_view item, Sold& sold) { +std::vector declared_for(const std::vector& declarations, Part part, std::string_view item, Sold& sold, std::string_view owner) { std::vector out; - if (item.empty() || sold(item)) return out; + if (item.empty() || owner.empty() || sold(item)) return out; const auto folded = [](std::string_view text) { std::string s(text); for (auto& c : s) c = static_cast(c >= 'A' && c <= 'Z' ? c + 32 : c); @@ -346,7 +348,7 @@ std::vector declared_for(const std::vector& const auto wanted = folded(item); std::vector conflicting; for (const auto& d : declarations) { - if (d.part != part_names[static_cast(part)]) continue; + if (d.mod != owner || d.part != part_names[static_cast(part)]) continue; if (std::none_of(d.items.begin(), d.items.end(), [&](const std::string& i) { return folded(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}; @@ -380,7 +382,7 @@ inline bool settled(Gate& gate, std::string_view item, std::uintptr_t material, inline std::size_t drop_undeclared(std::vector& holds, const Found& live) { if (live.pending) return 0; return std::erase_if(holds, [&](const Hold& h) { - if (!h.declared) return false; + if (!h.declared || live.declarations_pending[static_cast(h.part)]) return false; const auto* d = declared(live, h.part, h.key); return !d || d->kind != h.kind; }); diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index f829e588..84359238 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -62,8 +62,6 @@ struct FakeGame final : Game { return ending == "roadrash_leg1" ? "RoadRash_Leg1" : ""; } std::vector outfit_slots() override { return {{"tattoo_legl", "", {0.5f, 1}}, {"moles", "moles_empty", {}}}; } - int board_preparations{}; - void prepare_board() override { ++board_preparations; } std::optional> board_bones() override { std::array bones{}; bones[3].position = {0, 0.1f, 0}; @@ -99,7 +97,7 @@ struct FakeGame final : Game { for (const auto key : declared_keys) declaration.parameters.push_back({key, static_cast(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); + 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; } @@ -2478,15 +2476,6 @@ 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 board_observation_is_prepared_only_with_permission() { - TestEnvironment environment; - Host host(environment, Side::client); - host.apply({script("plain", "assert(reskate)")}); - check(environment.fake.board_preparations == 0, "No board preparation without permission"); - host.apply({script("boardreader", "assert(reskate.board.bones())", R"("board")")}); - running(host, "boardreader", environment); - check(environment.fake.board_preparations == 1, "Board observation is prepared before the script reads its pose"); -} void declared_board_colours_through_the_api() { TestEnvironment environment; @@ -4251,7 +4240,6 @@ 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(board_observation_is_prepared_only_with_permission); run(declared_board_colours_through_the_api); run(key_names_become_binds); run(level_events_reach_plugins); diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 728b1742..381d309e 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -2891,7 +2891,6 @@ struct WorldApi { void install_world_api(Plugin& plugin, lua_State* L) { WorldApi::install(plugin, L); } void WorldApi::install(Plugin& plugin, lua_State* L) { - if (plugin.has("board")) game(plugin).prepare_board(); (void)plugin; // The metatables of player tables (push_player) and of permissions; their own stay hidden. lua_createtable(L, 0, 2); diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index a888dbf8..6e99c7bf 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -806,8 +806,6 @@ public: std::array rotation{0, 0, 0, 1}; Vec3 scale{1, 1, 1}; }; - // Install native board observation before entering a timed Lua call. - virtual void prepare_board() {} virtual std::optional> board_bones() { return std::nullopt; } virtual std::string board_set(std::string_view /*plugin*/, int, const BoardBone&, bool /*world*/) { return unavailable; } // `everyone`: hidden from the other players too, not only on this screen. diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 53240ff0..941d3160 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -19,7 +19,6 @@ #include "Extension/Skater/skater_events.h" #include "Extension/Skater/outfit_overlay.h" #include "Extension/Skater/board_pose.h" -#include "Extension/Multiplayer/Remote/native_skater.h" #include "Extension/Customization/board_material.h" #include "Engine/Vfs/mod_catalog.h" #include "Engine/Core/Log/logging.h" @@ -501,13 +500,7 @@ public: return thrower_ == plugin ? multiplayer::throwdown_relay_host_status() : std::string{}; } // ---- board ----------------------------------------------------------------------------------------- - void prepare_board() override { - if (board_hooks_ready_) return; - std::string detail; - board_hooks_ready_ = multiplayer::install_entity_hooks(base_, detail); - } std::optional> board_bones() override { - if (!board_hooks_ready_) return std::nullopt; const auto drawn = board_pose::read(); if (!drawn) return std::nullopt; std::array out{}; @@ -1816,7 +1809,6 @@ private: } std::uintptr_t base_; - bool board_hooks_ready_{}; std::string capture_owner_, capture_file_; // the plugin whose capture it is, and where it saved to std::string camera_holder_, follower_, camera_status_; std::array camera_pose_{}; diff --git a/contrib/declared-board-materials.md b/contrib/declared-board-materials.md deleted file mode 100644 index fe1c9c32..00000000 --- a/contrib/declared-board-materials.md +++ /dev/null @@ -1,19 +0,0 @@ -# Declared board material inputs - -Plugins already read and hold live colour/number values through `reskate.board.parameters`, `set_parameter` and `clear_parameter`. A custom shader can have inputs whose defaults are compiled into it, with no live node for that API to hold. A mod may declare these in its root `reskate-materials.json`: - -```json -{"schema":1,"materials":[{"part":"deck","items":["Own_Example_Deck"],"parameters":[{"guid":"1af346775c032d5b59ff5e88010069b6","type":"colour","default":[0.5,0.5,0.5]}]}]} -``` - -The declaration describes an existing compiled shader input; it does not install shaders, select materials, unlock items or add textures. The GUID is its 16 serialized bytes in hex. The shader and declaration must agree on identity, type and default. Only the declaring equipped mod-owned item receives the value. Store items are excluded. - -A permitted plugin sees an absent live value as `inherited = true`, with the declared default. It uses the existing `board` permission and existing set/clear API. Missing declared colours are inserted with the engine typed setter at native preset priority. A number must already have a live value; the declaration does not enable missing float insertion. Integers, switches and textures cannot be declared or changed. - -Held values are released on clear, unload and item changes. Existing nodes restore their prior values; inserted nodes restore declared defaults (the engine retains the node). Type/identity mismatches and values outside bounds are refused. Competing plugins cannot take over a held key. The native setter/descriptor are checked against the supported build before use. - -Bounds: 32 materials, 64 item keys per declaration, 32 parameters per declaration; text at most 255 bytes; colour channels 0–1 and numbers -100–100. Malformed files are skipped as a whole. - -Board observation is prepared before timed plugin execution, only for a plugin with the `board` permission. No effect-specific solver, artwork, controller or raw-memory plugin access is introduced. - -Tests: `mod_materials`, `board_material`, `plugins`. From e17aabe37ce09c47db56d4bf5a2b7028915f4b9f Mon Sep 17 00:00:00 2001 From: hypnomalice <344329436+hypnomalice@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:31:57 +0000 Subject: [PATCH 3/3] Materials: refresh declarations only after applied sources or files change --- Engine/Core/Platform/shared_file.h | 60 ++++++++++ .../material_declaration_refresh_tests.cpp | 87 ++++++++++++++ Engine/Vfs/material_declaration_refresh.h | 48 ++++++++ Engine/Vfs/mod_catalog.cpp | 19 ++- Engine/Vfs/mod_catalog.h | 11 ++ Engine/Vfs/mod_item_sources.cpp | 13 +- Engine/Vfs/mod_item_sources.h | 17 +++ Engine/Vfs/mod_materials.cpp | 10 +- .../Test/board_material_tests.cpp | 8 ++ Extension/Customization/board_material.cpp | 112 +++++++++++------- Extension/Customization/board_material.h | 15 +-- cmake/Apps.cmake | 4 + 12 files changed, 343 insertions(+), 61 deletions(-) create mode 100644 Engine/Core/Platform/shared_file.h create mode 100644 Engine/Vfs/Test/material_declaration_refresh_tests.cpp create mode 100644 Engine/Vfs/material_declaration_refresh.h diff --git a/Engine/Core/Platform/shared_file.h b/Engine/Core/Platform/shared_file.h new file mode 100644 index 00000000..37c98212 --- /dev/null +++ b/Engine/Core/Platform/shared_file.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include +#include +#include +#ifdef _WIN32 +#include +#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(length.QuadPart) > limit || limit > MAXDWORD) + throw std::runtime_error("Content document exceeds its read bound"); + std::string text(static_cast(length.QuadPart), '\0'); + DWORD got{}; + if (!ReadFile(handle_, text.data(), static_cast(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(length) > limit) throw std::runtime_error("Content document exceeds its read bound"); + std::string text(static_cast(length), '\0'); + file_.seekg(0); + if (!file_.read(text.data(), static_cast(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); +} +} diff --git a/Engine/Vfs/Test/material_declaration_refresh_tests.cpp b/Engine/Vfs/Test/material_declaration_refresh_tests.cpp new file mode 100644 index 00000000..d4c9db7b --- /dev/null +++ b/Engine/Vfs/Test/material_declaration_refresh_tests.cpp @@ -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 +#include +#include +#include + +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; } diff --git a/Engine/Vfs/material_declaration_refresh.h b/Engine/Vfs/material_declaration_refresh.h new file mode 100644 index 00000000..87ff5927 --- /dev/null +++ b/Engine/Vfs/material_declaration_refresh.h @@ -0,0 +1,48 @@ +#pragma once +#include "mod_materials.h" +#include "mod_item_sources.h" +#include +#include + +namespace dingosdk::mods { +struct MaterialDeclarations { + std::uint64_t revision{}; + std::vector materials; + std::vector 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 bool update(const std::vector& signature, Load&& load) { + if (value_ && signature == signature_) return false; + auto next = std::make_shared(load()); + auto next_signature = signature; + signature_ = std::move(next_signature); + value_ = std::move(next); + return true; + } + std::shared_ptr value() const { return value_; } +private: + std::vector signature_; + std::shared_ptr value_; +}; +} diff --git a/Engine/Vfs/mod_catalog.cpp b/Engine/Vfs/mod_catalog.cpp index 61cf835d..9b74b4e0 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -141,10 +142,12 @@ using VariantMap = std::map>; struct VariantsApplied { VariantMap in_play; std::vector set_aside; + std::vector sources; }; std::mutex variants_mutex; std::optional chosen_variants; std::optional applied_variants; +std::atomic applied_revision{}; VariantMap read_choices() { VariantMap out; const auto path = variants_file(); @@ -251,10 +254,24 @@ std::vector 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 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 active_mod_sources() { + { + std::lock_guard lock(variants_mutex); + if (applied_variants) return applied_variants->sources; + } + std::vector 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 module_path{}; diff --git a/Engine/Vfs/mod_catalog.h b/Engine/Vfs/mod_catalog.h index 5e8ee795..67368492 100644 --- a/Engine/Vfs/mod_catalog.h +++ b/Engine/Vfs/mod_catalog.h @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -48,6 +49,16 @@ struct Catalog { std::vector set_aside; }; +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 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 #, 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 diff --git a/Engine/Vfs/mod_item_sources.cpp b/Engine/Vfs/mod_item_sources.cpp index ea90b233..8cc24077 100644 --- a/Engine/Vfs/mod_item_sources.cpp +++ b/Engine/Vfs/mod_item_sources.cpp @@ -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 #include @@ -11,12 +12,10 @@ namespace dingosdk::mods { namespace fs = std::filesystem; -std::vector read_item_sources(const fs::path& file) noexcept { +std::optional> read_item_sources_checked(const fs::path& file) noexcept { std::vector sources; try { - std::ifstream input(file, std::ios::binary); - if (!input) return {}; - const std::string text{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + 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 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 read_item_sources(const fs::path& file) noexcept { + return read_item_sources_checked(file).value_or(std::vector{}); +} + void write_item_sources(const fs::path& file, const std::vector& sources) { auto mods = Json::array(); for (const auto& source : sources) { diff --git a/Engine/Vfs/mod_item_sources.h b/Engine/Vfs/mod_item_sources.h index 5937feaf..0f10c2a0 100644 --- a/Engine/Vfs/mod_item_sources.h +++ b/Engine/Vfs/mod_item_sources.h @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include // 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 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(c + ('a' - 'A')); + return key; +} +inline std::string_view item_source_owner(const std::vector& 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 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> 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& sources); diff --git a/Engine/Vfs/mod_materials.cpp b/Engine/Vfs/mod_materials.cpp index adb374c8..57122199 100644 --- a/Engine/Vfs/mod_materials.cpp +++ b/Engine/Vfs/mod_materials.cpp @@ -36,10 +36,12 @@ MaterialParameter parameter(const Json& entry) { 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(c>='A'&&c<='F'?c+32:c);}); - const auto expected=(type=="colour"||type=="color")?"59ff5e88":"2d5cb62b"; - if(guid.substr(16,8)!=expected || guid.substr(24,4)!="0100") + auto guid = entry.at("guid").string(); + std::transform(guid.begin(), guid.end(), guid.begin(), [](unsigned char c) { + return static_cast(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") { diff --git a/Extension/Customization/Test/board_material_tests.cpp b/Extension/Customization/Test/board_material_tests.cpp index a3e044ba..f38f2540 100644 --- a/Extension/Customization/Test/board_material_tests.cpp +++ b/Extension/Customization/Test/board_material_tests.cpp @@ -484,6 +484,14 @@ void declarations_wait_for_the_board_to_settle() { } // namespace int main() { + const std::map 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(); diff --git a/Extension/Customization/board_material.cpp b/Extension/Customization/board_material.cpp index a0543f92..2261b480 100644 --- a/Extension/Customization/board_material.cpp +++ b/Extension/Customization/board_material.cpp @@ -7,6 +7,8 @@ #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 @@ -105,13 +107,12 @@ bool setter_matches(State& s) { // ---- 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. -struct DeclarationSet { - std::vector materials; - std::vector sources; -}; +using DeclarationSet = mods::MaterialDeclarations; struct Declarations { std::mutex mutex; std::shared_ptr ready; + mods::MaterialDeclarationRefresh refresh; + std::string error; bool reading{}; std::uint64_t looked{}; }; @@ -119,49 +120,70 @@ Declarations& declarations() { static auto* value = new Declarations; return *value; } -DeclarationSet read_declarations() { +std::vector declaration_signature(const std::vector& sources, + const std::filesystem::path& root) { + std::vector 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& signature) { DeclarationSet result; - const auto active=mods::load_catalog(mods::catalog().data_root,{},false); - result.sources=mods::read_item_sources(active.root/L".reskate"/mods::item_sources_file); - for (const auto& mod : active.mods) { - const auto path = mod.directory / L"reskate-materials.json"; - std::error_code error; - if (!std::filesystem::is_regular_file(path, error) || std::filesystem::file_size(path, error) > 64 * 1024) continue; - try { - std::ifstream in(path, std::ios::binary); - auto read = mods::parse_material_declarations(mod.name, std::string((std::istreambuf_iterator(in)), {})); - std::size_t count{}; - for (const auto& d : read) count += d.parameters.size(); - logging::log(logging::Level::info, logging::Channel::customization, - "Board parameters: {} declares {} shader input{} for its items.", mod.name, count, count == 1 ? "" : "s"); - std::move(read.begin(), read.end(), std::back_inserter(result.materials)); - } catch (const std::exception& failure) { - logging::log(logging::Level::warning, logging::Channel::customization, - "Board parameters: {}'s reskate-materials.json is left out: {}", mod.name, failure.what()); - } + 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 read; + std::string failure; try { - read = std::make_shared(read_declarations()); - } catch (...) {} - std::lock_guard lock(d.mutex); - if (read) { - d.ready = std::move(read); - } else if (!d.ready) { - d.ready = std::make_shared(); + 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(); + 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; } -// What was last read, and a read started when the last began two seconds ago or more. +// 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 current_declarations() { auto& d = declarations(); std::lock_guard lock(d.mutex); const auto now = GetTickCount64(); - if (!d.reading && (!d.ready || now - d.looked >= 2000)) { + if (!d.reading && (!d.looked || now - d.looked >= 2000)) { d.reading = true; d.looked = now; try { @@ -170,14 +192,16 @@ std::shared_ptr current_declarations() { 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; } -// All stock/developer items are excluded, free or paid. Unknown catalogue -// state is refused too; ownership is additionally checked against item sources. +// Stock/developer entries and unknown catalogue state cannot grant inputs. bool sold(std::string_view item) { - std::string key(item); for(auto& c:key)if(c>='A'&&c<='Z')c=static_cast(c+32); - const auto& catalog=content_cache::catalogs(); - return !catalog.available || catalog.items.contains(key) || key.starts_with("dev_") || key.starts_with("own_dev_") || profile_runtime::reserved_cosmetic(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 worn_slots{"Deck", "Grip tape", "Trucks", "Wheels"}; @@ -185,7 +209,10 @@ constexpr std::array worn_slots{"Deck", "Gr // have settled (detail::settled). void declare(State& s, detail::Found& found) { const auto all = current_declarations(); - if (!all) { found.pending=true; return; } + 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); @@ -199,15 +226,12 @@ void declare(State& s, detail::Found& found) { for (const auto& m : found.materials) if (static_cast(m.part) == part) { material = m.address; break; } if (!detail::settled(s.gates[part], item, material, now) || found.pending) { - found.declarations_pending[part]=true; + found.declarations_pending[part] = true; continue; } auto is_sold = [](std::string_view i) { return sold(i); }; - std::string key(item);for(auto& c:key)if(c>='A'&&c<='Z')c=static_cast(c+32); - std::string_view owner; - for(const auto& source:all->sources) { - if(std::find(source.keys.begin(),source.keys.end(),key)!=source.keys.end()){owner=source.mod;break;} - } + const auto owner = mods::item_source_owner(all->sources, item); + auto add = detail::declared_for(all->materials, static_cast(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()) { diff --git a/Extension/Customization/board_material.h b/Extension/Customization/board_material.h index 12c2f316..1d9d8bed 100644 --- a/Extension/Customization/board_material.h +++ b/Extension/Customization/board_material.h @@ -1,6 +1,7 @@ #pragma once #include "developer_board_material.h" #include "Engine/Vfs/mod_materials.h" +#include "Engine/Vfs/mod_item_sources.h" #include #include #include @@ -331,6 +332,11 @@ void apply(std::vector& saved, const std::vector& holds, const Foun } // ---- declarations --------------------------------------------------------------------------------- +template +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: @@ -340,16 +346,11 @@ template std::vector declared_for(const std::vector& declarations, Part part, std::string_view item, Sold& sold, std::string_view owner) { std::vector out; if (item.empty() || owner.empty() || sold(item)) return out; - const auto folded = [](std::string_view text) { - std::string s(text); - for (auto& c : s) c = static_cast(c >= 'A' && c <= 'Z' ? c + 32 : c); - return s; - }; - const auto wanted = folded(item); + const auto wanted = mods::item_key(item); std::vector conflicting; for (const auto& d : declarations) { if (d.mod != owner || d.part != part_names[static_cast(part)]) continue; - if (std::none_of(d.items.begin(), d.items.end(), [&](const std::string& i) { return folded(i) == wanted; })) 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; }); diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 6aeae668..e96d3533 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -80,6 +80,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_mod_variants_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_variants_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME mod_variants COMMAND dingosdk_mod_variants_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)