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/Test/mod_materials_tests.cpp b/Engine/Vfs/Test/mod_materials_tests.cpp new file mode 100644 index 00000000..6ebeac9b --- /dev/null +++ b/Engine/Vfs/Test/mod_materials_tests.cpp @@ -0,0 +1,141 @@ +// Checks how a mod's reskate-materials.json is read: a GUID's ParamDbKey hash and identity, what a +// declaration may hold (colours and numbers in bounds, never integers, switches or textures), the +// limits, and that a document of the wrong shape is refused whole. +#include "Engine/Vfs/mod_materials.h" +#include +#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": "d317d1d20f5c74512d5cb62b0100dcb0", "type": "number", "default": -2.5} + ] + }] + })"); + check(read.size() == 1 && read[0].mod == "Example" && read[0].part == "deck" && read[0].items.size() == 2, "A material is read"); + const auto& p = read[0].parameters; + check(p.size() == 3 && p[0].colour && p[0].key == 6569911127076434714ULL && p[0].identity == 46697 && + p[0].fallback == std::array{.5f, .5f, .5f}, + "with its colours keyed from their GUIDs and their defaults"); + check(p[1].colour, "either spelling of colour"); + check(!p[2].colour && p[2].fallback[0] == -2.5f, "and a number"); + } + + { + std::string collection = R"({"schema":1,"materials":[)"; + for (int deck = 0; deck < 14; ++deck) { + if (deck) collection += ','; + collection += R"({"part":"deck","items":["Own_Test_)" + std::to_string(deck) + R"("],"parameters":[)"; + for (int channel = 0; channel < 8; ++channel) { + if (channel) collection += ','; + collection += R"({"guid":")" + std::string(known[channel].guid) + + R"(","type":"colour","default":[0,0,)" + std::to_string(deck / 16.0) + "]}"; + } + collection += "]}"; + } + const auto declarations = parse_material_declarations("Collection", collection + "]}"); + check(declarations.size() == 14, "A fourteen-deck collection is accepted as a whole"); + for (const auto& material : declarations) + check(material.items.size() == 1 && material.parameters.size() == 8, + "Each deck retains its eight defaults and item scope"); + check(declarations.front().parameters.back().fallback != declarations.back().parameters.back().fallback, + "Different deck defaults survive parsing"); + } + + // Only what a plugin may set, in bounds. + check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"integer","default":13})")), + "An integer picks how the item is drawn: not declarable"); + check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"switch","default":true})")), "nor a switch"); + check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"texture","default":0})")), "nor a texture"); + check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"colour","default":[1.5,0,0]})")), "A colour is 0 to 1"); + check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"colour","default":[1,0]})")), "with three channels"); + check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"number","default":1000})")), "A number has bounds"); + check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"number","default":"1"})")), "and is a number"); + check(refused(with_parameter(R"({"guid":"1af34677","type":"colour","default":[0,0,0]})")), "A GUID is 32 hex digits"); + check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"colour","default":[0,0,0]},{"guid":")" + + std::string(guid) + R"(","type":"colour","default":[1,1,1]})")), + "A parameter is declared once"); + check(refused(with_parameter(R"({"guid":")" + std::string(guid) + R"(","type":"colour","default":[0,0,0]})", "skater")), + "A part is one of the board's"); + + // The wrong shape is refused whole. + check(refused(R"({"schema":1.5,"materials":[]})"),"Fractional schema rejected"); + check(refused(with_parameter(R"({"guid":"1af346775c032d5b2d5cb62b010069b6","type":"colour","default":[0,0,0]})")),"GUID type must match declared colour"); + check(refused(with_parameter(R"({"guid":"1af346775c032d5b59ff5e88020069b6","type":"colour","default":[0,0,0]})")),"GUID count must be one"); + check(refused("{}"), "A document without a schema"); + check(refused(R"({"schema":2,"materials":[]})"), "or of another schema"); + check(refused(R"({"schema":1,"materials":[{"part":"deck","items":[],"parameters":[]}]})"), "A material with no items"); + check(refused(R"({"schema":1,"materials":[{"part":"deck","items":["a"],"parameters":[]}]})"), "or no parameters"); + check(refused(R"({"schema":1,"materials":[{"part":"deck","items":["bad +name"],"parameters":[{"guid":"1af346775c032d5b59ff5e88010069b6","type":"colour","default":[0,0,0]}]}]})"), + "An item's key is text without control characters"); + { + std::string many = R"({"schema":1,"materials":[)"; + for (int i = 0; i < 33; ++i) many += std::string(i ? "," : "") + R"({"part":"deck","items":["a"],"parameters":[{"guid":")" + guid + R"(","type":"colour","default":[0,0,0]}]})"; + check(refused(many + "]}"), "At most 32 material groups"); + std::string parameters; + for (int i = 0; i < 33; ++i) { + char g[33]; + std::snprintf(g, sizeof(g), "%02x%030x", i + 1, i + 1); + parameters += std::string(i ? "," : "") + R"({"guid":")" + g + R"(","type":"colour","default":[0,0,0]})"; + } + check(refused(with_parameter(parameters)), "and 32 parameters to a material"); + } + + if (failures) return 1; + std::cout << "mod materials tests passed\n"; + return 0; +} catch (const std::exception& error) { + std::cerr << "FAIL: " << error.what() << '\n'; + return 1; +} 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 0694a463..28aac280 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -143,10 +144,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(); @@ -253,10 +256,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 94ed753f..49348dca 100644 --- a/Engine/Vfs/mod_catalog.h +++ b/Engine/Vfs/mod_catalog.h @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -52,6 +53,16 @@ struct Catalog { // selected variant. Withdraw orphan donors and rebuild authority from survivors. void reconcile_variant_additions(Catalog& catalog); +struct ModSource { + std::string name; + std::filesystem::path directory; + bool operator==(const ModSource&) const = default; +}; +// The launch catalogue, or the last successfully applied catalogue. Reading +// saved mods.json alone must not activate content that is not running yet. +std::vector 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 new file mode 100644 index 00000000..57122199 --- /dev/null +++ b/Engine/Vfs/mod_materials.cpp @@ -0,0 +1,116 @@ +#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"); + 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") { + 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_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"); + 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..f38f2540 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,231 @@ void holds_and_puts_back() { detail::apply(saved, holds, found, write, publish); check(writes == before, "Nothing is written while the board is being dressed"); } + +// ---- shader inputs a mod declares for its own deck (reskate-materials.json) --------------------------------- +// The Example declared-input deck's eight inputs (parameter-contract.json): colours its compiled shader reads, +// in no live map until something sets them. +struct Liquid { std::uint64_t key; std::uint16_t identity; }; +constexpr Liquid liquid[] = { + {6569911127076434714ULL, 46697}, {5869417437401782227ULL, 45276}, {6633899794922297581ULL, 59994}, {6507148046963062431ULL, 65135}, + {6339966355936362695ULL, 20838}, {6498503899632300675ULL, 22501}, {6196286651448922263ULL, 47378}, {6344408684715011858ULL, 17465}}; +constexpr std::uint64_t declared_number = 0x7777777777777777ULL; + +std::vector declared_declarations() { + mods::MaterialDeclaration d{"Example", "deck", {"Example_Deck"}, {}}; + for (const auto& l : liquid) d.parameters.push_back({l.key, l.identity, true, {.5f, .5f, .5f}}); + d.parameters.push_back({declared_number, 0x4242, false, {.25f, 0, 0}}); + return {d}; +} +auto never_sold = [](std::string_view) { return false; }; +detail::Found declared_found(Board& board) { + auto found = board.walk(); + found.declared = detail::declared_for(declared_declarations(), Part::deck, "example_deck", never_sold, "Example"); + return found; +} + +void declarations_follow_the_equipped_item() { + const auto all = declared_declarations(); + const auto deck = detail::declared_for(all, Part::deck, "EXAMPLE_DECK", never_sold, "Example"); + check(deck.size() == 9 && deck[0].key == liquid[0].key && deck[0].identity == liquid[0].identity && deck[0].kind == Kind::colour && + deck[0].fallback == Colour{.5f, .5f, .5f} && deck[8].kind == Kind::number, + "The equipped item's declarations apply (its key in any case)"); + check(detail::declared_for(all, Part::deck, "Some_Other_Deck", never_sold, "Example").empty(), "another deck's do not"); + check(detail::declared_for(all, Part::grip, "Example_Deck", never_sold, "Example").empty(), "nor another part's"); + check(detail::declared_for(all, Part::deck, "", never_sold, "Example").empty(), "nor an empty slot's"); + auto sold = [](std::string_view) { return true; }; + check(detail::declared_for(all, Part::deck, "Example_Deck", sold, "Example").empty(), "An item the game sells (or one it cannot tell) takes none"); + // Two mods giving one key differently: which shader is right cannot be told, so neither. + auto both = all; + both.push_back({"Other", "deck", {"Example_Deck"}, {{liquid[0].key, 1, true, {0, 0, 0}}, {liquid[1].key, liquid[1].identity, true, {.5f, .5f, .5f}}}}); + check(detail::declared_for(all,Part::deck,"Example_Deck",never_sold,"Other").empty(),"Other mod cannot declare this item"); + check(detail::declared_for(all,Part::deck,"Example_Deck",never_sold,"").empty(),"Unknown ownership is refused"); + const auto merged = detail::declared_for(both, Part::deck, "Example_Deck", never_sold, "Example"); + check(std::any_of(merged.begin(), merged.end(), [](const detail::Declared& d) { return d.key == liquid[0].key; }) && + std::count_if(merged.begin(), merged.end(), [](const detail::Declared& d) { return d.key == liquid[1].key; }) == 1, + "Another mod cannot suppress the owner's declarations"); +} + +void lists_and_refuses_declared() { + Board board(true); + auto found = declared_found(board); + const auto listed = detail::listed(found, Part::deck); + check(listed.size() == 7 + 9, "The deck lists its seven live selectors and its nine declared inputs"); + check(std::all_of(listed.begin(), listed.begin() + 7, [](const Parameter& p) { return !p.inherited && (p.kind == Kind::integer || p.kind == Kind::toggle); }), + "the selectors as the material holds them"); + check(std::all_of(listed.begin() + 7, listed.end(), [](const Parameter& p) { return p.inherited; }) && listed[7].value == Colour{.5f, .5f, .5f}, + "the declared ones inherited, at their default"); + check(detail::listed(board.walk(), Part::deck).size() == 7, "Without the declaration only the live ones show"); + + Kind kind{}; + check(detail::refusal(found, Part::deck, liquid[0].key, {.1f, .2f, .3f}, true, kind).empty() && kind == Kind::colour, + "A declared colour the material does not hold may be held"); + check(detail::refusal(board.walk(), Part::deck, liquid[0].key, {.1f, .2f, .3f}, true, kind).find("no parameter") != std::string::npos, + "but not on a deck that does not declare it"); + check(!detail::refusal(found, Part::deck, liquid[0].key, {.1f, .2f, .3f}, false, kind).empty(), "A declared colour is not a number"); + check(!detail::refusal(found, Part::deck, liquid[0].key, {1.5f, 0, 0}, true, kind).empty(), "and its channels are 0 to 1"); + check(detail::refusal(found, Part::deck, declared_number, {.5f, 0, 0}, false, kind).find("only a declared colour") != std::string::npos, + "A declared number with no live value cannot be added (its type is not known to this build)"); + check(detail::refusal(found, Part::deck, 0x5001, {1, 0, 0}, false, kind).find("only read") != std::string::npos, + "A live integer stays read-only"); + // A declaration naming a key the material holds as an integer or a switch changes nothing. + auto clash = found; + clash.declared.push_back({Part::deck, 0x5002, 0x1234, Kind::colour, {0, 0, 0}}); + check(detail::refusal(clash, Part::deck, 0x5002, {1, 0, 0}, true, kind).find("not as its mod declares") != std::string::npos, + "A switch a mod declares as a colour is refused"); + // A live colour whose identity is not the declared one is not the declared parameter. + Board ordinary; + auto other = ordinary.walk(); + other.declared.push_back({Part::grip, graphic_colour, 0x9999, Kind::colour, {.5f, .5f, .5f}}); + check(detail::refusal(other, Part::grip, graphic_colour, {1, 0, 0}, true, kind).find("not as its mod declares") != std::string::npos, + "and so is a live colour of another identity"); +} + +void owners_and_limits() { + Board board(true); + const auto found = declared_found(board); + std::vector 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.declarations_pending[0]=true; + check(detail::drop_undeclared(holds,changed)==0 && holds.size()==1,"Rebuilding materials retain declared holds while settling"); + changed.pending = true; + check(detail::drop_undeclared(holds, changed) == 0 && holds.size() == 1, "nor let go while the board is being dressed"); + // Back on the declared-input deck it applies again. + detail::apply(saved, holds, declared_found(board), write, publish, insert); + check(inserts == 2, "and is added again once the declaring deck is back"); +} + +void declarations_wait_for_the_board_to_settle() { + detail::Gate gate; + check(!detail::settled(gate, "Example_Deck", 0x1000, 0), "A newly equipped item does not apply at once"); + check(!detail::settled(gate, "Example_Deck", 0x1000, detail::settle_ms - 1), "nor before it has settled"); + check(detail::settled(gate, "Example_Deck", 0x1000, detail::settle_ms), "then it does"); + check(!detail::settled(gate, "Example_Deck", 0x2000, detail::settle_ms + 5), "A new material under it starts again"); + check(!detail::settled(gate, "Other_Deck", 0x2000, 2 * detail::settle_ms + 5), "as does another item"); + check(!detail::settled(gate, "Other_Deck", 0, 9 * detail::settle_ms), "and no material never settles"); +} } // namespace int main() { + const std::map stock{{"free_stock", 1}, {"paid_stock", 1}}; + const auto not_reserved = [](std::string_view) { return false; }; + check(detail::declared_item_restricted("FREE_STOCK", true, stock, not_reserved), "free stock cannot grant declarations"); + check(detail::declared_item_restricted("paid_stock", true, stock, not_reserved), "paid stock cannot grant declarations"); + check(detail::declared_item_restricted("OWN_DEV_TEST", true, stock, not_reserved), "developer items cannot grant declarations"); + check(detail::declared_item_restricted("own_private", false, stock, not_reserved), "unknown stock catalogue state fails closed"); + check(!detail::declared_item_restricted("own_private", true, stock, not_reserved), "a non-stock private item can proceed to ownership checks"); + check(detail::declared_item_restricted("own_reserved", true, stock, [](std::string_view) { return true; }), "reservation policy is still applied"); walks_the_board(); refuses_what_picks_the_look(); holds_and_puts_back(); + declarations_follow_the_equipped_item(); + lists_and_refuses_declared(); + owners_and_limits(); + adds_and_puts_back_declared(); + a_change_of_deck_lets_go(); + declarations_wait_for_the_board_to_settle(); std::cout << "board material tests passed\n"; } diff --git a/Extension/Customization/board_material.cpp b/Extension/Customization/board_material.cpp index 5015b2d5..2261b480 100644 --- a/Extension/Customization/board_material.cpp +++ b/Extension/Customization/board_material.cpp @@ -1,10 +1,23 @@ #include "board_material.h" +#include "worn_items.h" +#include "Engine/Game/Build/supported_build.h" +#include "local_customization_runtime.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/memory.h" +#include "Engine/Vfs/content_catalogs.h" +#include "Engine/Vfs/mod_catalog.h" +#include "Engine/Vfs/mod_item_sources.h" +#include "Engine/Vfs/material_declaration_refresh.h" +#include "Engine/Core/Platform/shared_file.h" #include "Extension/Multiplayer/Remote/native_pose_layout.h" #include "Extension/Multiplayer/Remote/native_skater.h" #include #include +#include +#include +#include +#include +#include namespace dingosdk::board_material { namespace { @@ -13,7 +26,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 +58,19 @@ bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept { return true; } __except (EXCEPTION_EXECUTE_HANDLER) { return false; } } +// A declared colour the material does not hold yet, added by the engine's own typed setter +// (MaterialInstance:addEsVector's, as developer_board.cpp adds the board's colours): it allocates, +// hashes and links the node itself. Priority zero, a native preset's, so a later preset replaces it. +bool insert_colour(std::uintptr_t base, std::uint16_t type_index, std::uintptr_t material, const detail::Declared& declared, + const Colour& value) noexcept { + const developer_hoodie_detail::ParameterKey key{declared.identity, sizeof(Colour), type_index, 1, declared.key}; + const developer_board_detail::NativeColor native{value, 0, base + addr::native_cosmetics::color_rgb_type}; + __try { + return reinterpret_cast( + 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 +82,168 @@ bool functions_match(State& s) { } return *s.functions; } +// The typed setter, and the ColorRgb type it is given, as the supported build has them (checked as +// developer_board_detail::materials checks the descriptor). Without them nothing declared is added. +bool setter_matches(State& s) { + if (!s.setter) { + namespace n = addr::native_cosmetics; + std::array bytes{}; + std::uintptr_t info{}; + std::uint32_t hash{}; + std::uint16_t size{}, index{}; + const auto type = s.base + n::color_rgb_type; + s.setter = peek(s.base + n::set_shader_parameter, bytes.data(), bytes.size()) && bytes == n::set_shader_parameter_prefix && + peek(type, &info, sizeof(info)) && info >= s.base && info < s.base + supported_build::game_image_size && peek(info, &hash, sizeof(hash)) && + hash == n::color_rgb_type_hash && peek(info + 6, &size, sizeof(size)) && size == sizeof(Colour) && + peek(type + 8, &index, sizeof(index)); + s.colour_type = index; + if (!*s.setter) + logging::log(logging::Level::warning, logging::Channel::customization, + "Board parameters: the material setter differs from the supported build; declared colours cannot be added."); + } + return *s.setter; +} + +// ---- declarations: every enabled mod's reskate-materials.json ------------------------------------------- +// Read on a thread of their own and again only when a file changes, as the mods' music is +// (local_music_assets.cpp): the game's frame never waits on the disk. +using DeclarationSet = mods::MaterialDeclarations; +struct Declarations { + std::mutex mutex; + std::shared_ptr ready; + mods::MaterialDeclarationRefresh refresh; + std::string error; + bool reading{}; + std::uint64_t looked{}; +}; +Declarations& declarations() { + static auto* value = new Declarations; + return *value; +} +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; + 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 { + 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; +} +// 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.looked || now - d.looked >= 2000)) { + d.reading = true; + d.looked = now; + try { + std::thread(read_declarations_now).detach(); + } catch (...) { + d.reading = false; + } + } + // Keep old values for recovery, but never grant declarations from a source + // snapshot belonging to mods that are no longer the running catalogue. + if (d.ready && d.ready->revision != mods::active_sources_revision()) return {}; + return d.ready; +} +// Stock/developer entries and unknown catalogue state cannot grant inputs. +bool sold(std::string_view item) { + const auto& catalog = content_cache::catalogs(); + return detail::declared_item_restricted(item, catalog.available, catalog.items, + [](std::string_view key) { return profile_runtime::reserved_cosmetic(key); }); +} +constexpr std::array worn_slots{"Deck", "Grip tape", "Trucks", "Wheels"}; + +// The equipped items' declarations, on the board found: a part's apply once its item and material +// have settled (detail::settled). +void declare(State& s, detail::Found& found) { + const auto all = current_declarations(); + if (!all) { + found.pending = true; + return; + } + const auto now = GetTickCount64(); + if (now - s.worn_at >= 250 || s.worn.empty()) { + s.worn = worn_items::worn_board(false); + s.worn_at = now; + } + for (std::size_t part = 0; part < part_names.size(); ++part) { + std::string_view item; + for (const auto& w : s.worn) + if (w.slot == worn_slots[part]) item = w.item; + std::uintptr_t material{}; + for (const auto& m : found.materials) + if (static_cast(m.part) == part) { material = m.address; break; } + if (!detail::settled(s.gates[part], item, material, now) || found.pending) { + found.declarations_pending[part] = true; + continue; + } + auto is_sold = [](std::string_view i) { return sold(i); }; + const auto owner = mods::item_source_owner(all->sources, item); + + auto add = detail::declared_for(all->materials, static_cast(part), item, is_sold, owner); + // Report a hot-reloaded declaration even when the worn item did not change. + if (s.reported_items[part] != item || s.reported_counts[part] != add.size()) { + s.reported_items[part] = std::string(item); + s.reported_counts[part] = add.size(); + logging::log(logging::Level::info, logging::Channel::customization, + "Board parameters: the {} is {}; {} declared shader input{} apply.", part_names[part], item, + add.size(), add.size() == 1 ? "" : "s"); + } + found.declared.insert(found.declared.end(), add.begin(), add.end()); + } +} // The local player's board as it is now (a change of cosmetics can replace it); none out of play. std::optional board(State& s) { if (!s.base || !s.client) return std::nullopt; @@ -53,7 +252,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 +270,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 +297,33 @@ void tick(std::uintptr_t base, std::uintptr_t client) noexcept { if (!functions_match(s)) return; const auto found = board(s); if (!found) return; + if (const auto dropped = detail::drop_undeclared(s.holds, *found)) + logging::log(logging::Level::info, logging::Channel::customization, + "Board parameters: {} declared value{} let go: the item that declared {} is no longer on the board.", dropped, + dropped == 1 ? "" : "s", dropped == 1 ? "it" : "them"); auto write = [](std::uintptr_t material, std::uintptr_t node, Kind kind, const Colour& value) { return write_value(material, node, value.data(), kind == Kind::colour ? sizeof(Colour) : sizeof(float)); }; - auto publish = [&](std::uintptr_t item) { (void)publish_materials(s.base, item); }; - detail::apply(s.saved, s.holds, *found, write, publish); + std::size_t inserted{}, refused{}, published{}, publish_failed{}; + auto publish = [&](std::uintptr_t item) { + const bool ok = publish_materials(s.base, item); + if (ok) ++published; + else ++publish_failed; + }; + auto insert = [&](std::uintptr_t material, const detail::Declared& declared, const Colour& value) { + const bool added = setter_matches(s) && insert_colour(s.base, s.colour_type, material, declared, value); + if (added) ++inserted; + else ++refused; + return added; + }; + detail::apply(s.saved, s.holds, *found, write, publish, insert); + // Lua accepts a hold before this tick applies it; report native failures separately. + if ((inserted || refused || publish_failed) && GetTickCount64() - s.apply_reported >= 5000) { + s.apply_reported = GetTickCount64(); + logging::log(logging::Level::info, logging::Channel::customization, + "Board parameter apply: {} inserted, {} refused, {} published, {} publication failures.", + inserted, refused, published, publish_failed); + } } catch (...) {} } } // namespace dingosdk::board_material diff --git a/Extension/Customization/board_material.h b/Extension/Customization/board_material.h index 6e20a3d1..1d9d8bed 100644 --- a/Extension/Customization/board_material.h +++ b/Extension/Customization/board_material.h @@ -1,5 +1,7 @@ #pragma once #include "developer_board_material.h" +#include "Engine/Vfs/mod_materials.h" +#include "Engine/Vfs/mod_item_sources.h" #include #include #include @@ -14,6 +16,13 @@ // integer or a switch picks how the board is drawn (the foil looks the store sells among them), so // those are only read. Nothing of the board's items changes and nobody else sees it: other players // draw this board from its cosmetics, as they are. +// +// A mod's own board item may also declare, in its reskate-materials.json (Engine/Vfs/mod_materials.h), +// colours and numbers its compiled shader reads. Their defaults live in that shader, so the material +// has no live value of them until one is set: while that item is equipped (and is not one the game +// sells), a plugin may hold a declared colour too. The engine's typed setter adds it to the material +// at native preset priority, as the developer board adds its colours, and when it is let go the +// declared default is written back (a value once added is never taken out: the renderer copies it). namespace dingosdk::board_material { enum class Part : std::uint8_t { deck, grip, trucks, wheels }; inline constexpr std::array part_names{"deck", "grip", "trucks", "wheels"}; @@ -24,6 +33,7 @@ struct Parameter { Kind kind{Kind::other}; Colour value{}; // a colour, or a number in [0] std::int32_t integer{}; // an integer, or a switch's 0 or 1 + bool inherited{}; // declared by the item's mod, with no live value yet: `value` is its default }; // The two colours known by what they colour (the developer board animates them): the graphic's and // the base's. @@ -47,6 +57,16 @@ namespace detail { struct Node { std::uintptr_t address{}; Parameter parameter; + std::uint16_t identity{}; // its ParamDbKey identity +}; +// A colour or a number the equipped item's mod declares for the part's material +// (mods::MaterialDeclaration), as the board's walk is given it: only while that item is equipped. +struct Declared { + Part part{}; + std::uint64_t key{}; + std::uint16_t identity{}; + Kind kind{}; // colour or number + Colour fallback{}; // the shader's default, as the mod declares it }; struct Material { std::uintptr_t item{}, address{}; @@ -56,6 +76,8 @@ struct Material { struct Found { std::vector 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 { @@ -98,6 +120,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 +136,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 +199,32 @@ inline const char* kind_name(Kind kind) noexcept { default: return "of a kind plugins do not set"; } } +inline const Declared* declared(const Found& found, Part part, std::uint64_t key) noexcept { + for (const auto& d : found.declared) + if (d.part == part && d.key == key) return &d; + return nullptr; +} // Whether `value` may be held on that key of the part: empty, and its kind in `kind`, else why not. +// A key the part's material holds live is checked as it is held; one it does not hold may be a colour +// the equipped item's mod declares. Integers and switches are never set, whatever a mod declares. inline std::string refusal(const Found& found, Part part, std::uint64_t key, const Colour& value, bool colour, Kind& kind) { const auto name = part_names[static_cast(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 +242,32 @@ struct Hold { std::uint64_t key{}; Kind kind{}; Colour value{}; + bool declared{}; // held through the equipped item's declaration: only while that declares it }; struct Saved { - std::uintptr_t material{}, node{}; + std::uintptr_t material{}, node{}; // node 0: added this tick, not yet walked Kind kind{}; Colour original{}, last{}; std::uint8_t settling{}; // publishes still owed for `last` + std::uint64_t key{}; }; inline bool same(Kind kind, const Colour& a, const Colour& b) noexcept { return kind == Kind::colour ? a == b : a[0] == b[0]; } +// A held key still holds: a declared one only while the equipped item declares it, as it was. +inline bool holds_now(const Hold& hold, const Found& live, const Material& material, const Node* node) noexcept { + if (!hold.declared) return true; + const auto* d = declared(live, material.part, hold.key); + return d && d->kind == hold.kind && (!node || node->identity == d->identity); +} // One tick, as the developer board's colours go (developer_hoodie_detail::animate): what is held // written where it is not yet, and published until it shows (the renderer shows a value one publish // late); what is no longer held put back while it is still ours, a value changed by the game since -// left as the game has it. write(material, node, kind, value) -> written; publish(item). -template -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 +278,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 +289,149 @@ 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 --------------------------------------------------------------------------------- +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: +// 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::string_view owner) { + std::vector out; + if (item.empty() || owner.empty() || sold(item)) return out; + 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 mods::item_key(i) == wanted; })) continue; + for (const auto& p : d.parameters) { + const Declared next{part, p.key, p.identity, p.colour ? Kind::colour : Kind::number, p.fallback}; + const auto at = std::find_if(out.begin(), out.end(), [&](const Declared& o) { return o.key == p.key; }); + if (at == out.end()) out.push_back(next); + else if (at->identity != next.identity || at->kind != next.kind || at->fallback != next.fallback) conflicting.push_back(p.key); + } + } + std::erase_if(out, [&](const Declared& d) { return std::find(conflicting.begin(), conflicting.end(), d.key) != conflicting.end(); }); + return out; +} +// When an item's declarations apply: the same item has been equipped, on the same material, for +// `settle_ms`. A change of cosmetics replaces the board over a few ticks, during which the item read +// and the board walked can disagree; nothing declared is added to a board until they have settled. +inline constexpr std::uint64_t settle_ms = 1000; +struct Gate { + std::string item; + std::uintptr_t material{}; + std::uint64_t since{}; +}; +inline bool settled(Gate& gate, std::string_view item, std::uintptr_t material, std::uint64_t now) { + if (gate.item != item || gate.material != material || !material) { + gate = {std::string(item), material, now}; + return false; + } + return now - gate.since >= settle_ms; +} +// Holds through a declaration the equipped item no longer makes (another deck, or one being put +// on) are let go: what they added goes back to its default, and the plugin has to set it again. +// Returns how many were let go. +inline std::size_t drop_undeclared(std::vector& holds, const Found& live) { + if (live.pending) return 0; + return std::erase_if(holds, [&](const Hold& h) { + if (!h.declared || live.declarations_pending[static_cast(h.part)]) return false; + const auto* d = declared(live, h.part, h.key); + return !d || d->kind != h.kind; + }); +} + +// ---- what plugins see and hold, apart from the game (read, set and clear) ------------------------------ +inline bool live(const Found& found, Part part, std::uint64_t key) noexcept { + for (const auto& material : found.materials) + if (material.part == part) + for (const auto& node : material.nodes) + if (node.parameter.key == key) return true; + return false; +} +// The part's parameters: its first material's (another, a variant, holds the same keys), then what +// the equipped item's mod declares that the material does not hold yet, at its default. +inline std::vector 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 b22eb41a..7db58d6a 100644 --- a/Extension/Customization/worn_items.cpp +++ b/Extension/Customization/worn_items.cpp @@ -47,7 +47,7 @@ std::int32_t selected_preset(std::uintptr_t base, std::uintptr_t manager) { // The board the skater rides now: the board recipe of the selected preset (the first when none is // selected), its parts in the card's order, empty slots left out. Game thread. } // namespace -std::vector worn_board() { +std::vector worn_board(bool titles) { std::vector parts; const auto base = reinterpret_cast(GetModuleHandleW(nullptr)); std::uintptr_t manager{}, begin{}, end{}; @@ -66,7 +66,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 b0508404..46f602ac 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); } @@ -73,7 +76,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, "Example"); + 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 +118,20 @@ struct FakeGame final : Game { {0xa5f9a6b8098279adULL, "switch", {}, 1}}; } std::string board_set_parameter(std::string_view plugin, int part, std::uint64_t key, std::array 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; @@ -640,7 +686,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; @@ -2469,6 +2519,76 @@ void web_requests_go_only_to_listed_domains() { check(environment.logged("many more than 4 web requests waiting"), "4 waiting at most\n" + environment.all()); } + +void declared_board_colours_through_the_api() { + TestEnvironment environment; + environment.fake.declared_rules = true; + environment.fake.declared_deck = true; + Host host(environment, Side::client); + host.apply({script("materialapi", R"( + reskate.console.command("materialapi", "x", function(args) + local deck = reskate.board.parameters("deck") + assert(#deck == 7 + 9, "seven selectors and nine declared inputs, not " .. #deck) + local inherited = 0 + for i, p in deck do + if i <= 7 then + assert(not p.inherited and (p.type == "integer" or p.type == "switch"), "a live selector") + else + assert(p.inherited == true, "a declared input is inherited") + if p.type == "colour" then + inherited += 1 + assert(p.value.r == 0.5 and p.value.g == 0.5 and p.value.b == 0.5, "at its declared default") + end + end + end + assert(inherited == 8, "eight declared colours") + for _, key in {"5b2d035c7746f31a", "51745c0fd2d117d3", "5c1058b72fe158ed", "5a4e08ac201c369f", + "57fc15cf6583c0c7", "5a2f52ddbd12f283", "55fda1e1521b9097", "580bde15aa576f12"} do + assert(reskate.board.set_parameter("deck", key, {r = 0.25, g = 0.75, b = 0.375}), key) + end + local ok, why = reskate.board.set_parameter("deck", "0000000000005001", 3) + assert(not ok and why:find("only read"), "a selector stays read-only: " .. tostring(why)) + ok, why = reskate.board.set_parameter("deck", "5b2d035c7746f31a", 0.5) + assert(not ok and why:find("colour"), "a declared colour is not a number: " .. tostring(why)) + ok, why = reskate.board.set_parameter("deck", "5b2d035c7746f31a", {r = 2, g = 0, b = 0}) + assert(not ok and why:find("0 to 1"), "its channels are 0 to 1") + ok, why = reskate.board.set_parameter("deck", "7777777777777777", 0.5) + assert(not ok and why:find("only a declared colour"), "a declared number with no live value is not added: " .. tostring(why)) + ok, why = reskate.board.set_parameter("deck", "1234567812345678", {r = 0, g = 0, b = 0}) + assert(not ok and why:find("no parameter"), "nor anything undeclared") + reskate.log("materialapi fine") + end) + reskate.console.command("materialapi_one", "x", function() reskate.board.clear_parameter("deck", "5b2d035c7746f31a") end) + )", R"("board", "console")"), + script("thief", R"( + reskate.console.command("thief", "x", function() + local ok, why = reskate.board.set_parameter("deck", "5b2d035c7746f31a", {r = 1, g = 1, b = 1}) + assert(not ok and why:find("materialapi holds"), "another plugin's colour is not taken: " .. tostring(why)) + reskate.log("thief refused") + end) + )", R"("board", "console")"), + script("nopermission", R"( + local ok, err = pcall(reskate.board.parameters, "deck") + assert(not ok and err:find("needs the \"board\" permission"), err) + ok, err = pcall(reskate.board.set_parameter, "deck", "5b2d035c7746f31a", {r = 1, g = 1, b = 1}) + assert(not ok and err:find("needs the \"board\" permission"), err) + ok, err = pcall(reskate.board.clear_parameter, "deck") + assert(not ok and err:find("needs the \"board\" permission"), err) + reskate.log("nopermission refused") + )")}); + running(host, "materialapi", environment); + running(host, "nopermission", environment); + check(environment.logged("nopermission refused"), "A plugin without the board permission reaches no board parameter\n" + environment.all()); + check(environment.fake.declared_holds.empty(), "Nothing is held before a plugin asks"); + check(host.run_console("materialapi") && environment.logged("materialapi fine"), "Declared colours list and set\n" + environment.all()); + check(environment.fake.declared_held("materialapi") == 8, "The plugin holds the eight declared colours"); + check(host.run_console("thief") && environment.logged("thief refused"), "Another plugin cannot take them\n" + environment.all()); + check(environment.fake.declared_held("thief") == 0, "and holds none"); + check(host.run_console("materialapi_one") && environment.fake.declared_held("materialapi") == 7, "Clearing one key lets go of that one"); + host.apply({}); + check(environment.fake.declared_holds.empty(), "Unloading lets go of every one (the game puts them back to their defaults)"); +} + void the_wider_set_reaches_the_game() { TestEnvironment environment; auto& game = environment.fake; @@ -4163,6 +4283,7 @@ int main() { run(plugins_call_and_hear_each_other); run(web_requests_go_only_to_listed_domains); run(the_wider_set_reaches_the_game); + run(declared_board_colours_through_the_api); run(key_names_become_binds); run(level_events_reach_plugins); run(timers_run_when_due); diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 7bfeff0c..ab64e007 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -1830,7 +1830,9 @@ struct WorldApi { } // parameters(part): {{key = "873f48af36e50d59", name = "graphic", type = "colour", value = {r, g, b}}, ...} // as the part's material holds them now, or nil without a board. A number's value is a number, an - // integer's an integer, a switch's true or false. + // integer's an integer, a switch's true or false. A colour or a number the equipped item's mod + // declares (reskate-materials.json) that the material does not hold yet comes with inherited = + // true and its shader's default as its value; set_parameter adds a declared colour. static int board_parameters(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "board", "reskate.board.parameters"); @@ -1871,12 +1873,18 @@ struct WorldApi { lua_pushboolean(L, p.integer != 0); lua_setfield(L, -2, "value"); } + if (p.inherited) { + lua_pushboolean(L, 1); + lua_setfield(L, -2, "inherited"); + } lua_rawseti(L, -2, ++at); } return 1; } // set_parameter(part, key, value): a colour {r, g, b} (0 to 1) or a number held on the part until - // cleared, on this screen; an integer or a switch is only read. + // cleared, on this screen; an integer or a switch is only read. A colour the equipped item's mod + // declares is added to the material and goes back to its declared default when let go, or when + // that item is no longer on the board. static int board_set_parameter(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "board", "reskate.board.set_parameter"); diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index 2790d4a7..db0b729a 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -832,6 +832,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 b5c4a37a..62c1bc32 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -528,7 +528,7 @@ public: using board_material::Kind; const char* type = p.kind == Kind::colour ? "colour" : p.kind == Kind::number ? "number" : p.kind == Kind::integer ? "integer" : p.kind == Kind::toggle ? "switch" : "other"; - out.push_back({p.key, type, p.value, p.integer}); + out.push_back({p.key, type, p.value, p.integer, p.inherited}); } return out; } diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 884d0dcb..d948c309 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -91,6 +91,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) add_executable(dingosdk_variant_additions_plan_tests Engine/Vfs/Test/variant_additions_plan_tests.cpp) target_include_directories(dingosdk_variant_additions_plan_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME variant_additions_plan COMMAND dingosdk_variant_additions_plan_tests) + add_executable(dingosdk_material_declaration_refresh_tests Engine/Vfs/Test/material_declaration_refresh_tests.cpp) + target_link_libraries(dingosdk_material_declaration_refresh_tests PRIVATE dingosdk_mods) + target_include_directories(dingosdk_material_declaration_refresh_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME material_declaration_refresh COMMAND dingosdk_material_declaration_refresh_tests) add_executable(dingosdk_bundle_ref_table_tests Engine/Vfs/Test/bundle_ref_table_tests.cpp) target_link_libraries(dingosdk_bundle_ref_table_tests PRIVATE dingosdk_mods) add_test(NAME bundle_ref_table COMMAND dingosdk_bundle_ref_table_tests) @@ -111,6 +115,11 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_mod_music_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_music_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME mod_music COMMAND dingosdk_mod_music_tests) + # The board shader inputs a mod declares for its own items (Engine/Vfs/mod_materials.h). + add_executable(dingosdk_mod_materials_tests Engine/Vfs/Test/mod_materials_tests.cpp) + target_link_libraries(dingosdk_mod_materials_tests PRIVATE dingosdk_mods) + target_include_directories(dingosdk_mod_materials_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME mod_materials COMMAND dingosdk_mod_materials_tests) add_executable(dingosdk_music_safety_tests Extension/Music/Test/local_music_safety_tests.cpp) target_include_directories(dingosdk_music_safety_tests PRIVATE "${PROJECT_SOURCE_DIR}") dingosdk_configure_target(dingosdk_music_safety_tests) diff --git a/cmake/Core.cmake b/cmake/Core.cmake index d1528230..31cec016 100644 --- a/cmake/Core.cmake +++ b/cmake/Core.cmake @@ -109,6 +109,7 @@ if(WIN32) Engine/Vfs/mod_merge_grid.cpp Engine/Vfs/mod_merge_load_screens.cpp Engine/Vfs/mod_music.cpp + Engine/Vfs/mod_materials.cpp Engine/Vfs/mod_scoring.cpp Engine/Vfs/mod_store_copies.cpp Engine/Vfs/mod_item_sources.cpp