Plugins: enforce declared input ownership and preserve holds through rebuilds

This commit is contained in:
hypnomalice committed 2026-10-25 00:01:46 +01:00
1 parent 4c2b500a99
commit c29cffdf9b
10 files changed
+64 -92

No files matched your search

+4 -1
View File
@@ -58,7 +58,7 @@ int main() try {
"parameters": [
{"guid": "1af346775c032d5b59ff5e88010069b6", "type": "colour", "default": [0.5, 0.5, 0.5]},
{"guid": "126f57aa15de0b5859ff5e8801003944", "type": "color", "default": [0, 0, 0]},
{"guid": "d317d1d20f5c745159ff5e880100dcb0", "type": "number", "default": -2.5}
{"guid": "d317d1d20f5c74512d5cb62b0100dcb0", "type": "number", "default": -2.5}
]
}]
})");
@@ -109,6 +109,9 @@ int main() try {
"A part is one of the board's");
// The wrong shape is refused whole.
check(refused(R"({"schema":1.5,"materials":[]})"),"Fractional schema rejected");
check(refused(with_parameter(R"({"guid":"1af346775c032d5b2d5cb62b010069b6","type":"colour","default":[0,0,0]})")),"GUID type must match declared colour");
check(refused(with_parameter(R"({"guid":"1af346775c032d5b59ff5e88020069b6","type":"colour","default":[0,0,0]})")),"GUID count must be one");
check(refused("{}"), "A document without a schema");
check(refused(R"({"schema":2,"materials":[]})"), "or of another schema");
check(refused(R"({"schema":1,"materials":[{"part":"deck","items":[],"parameters":[]}]})"), "A material with no items");
+7 -1
View File
@@ -36,6 +36,12 @@ MaterialParameter parameter(const Json& entry) {
throw std::runtime_error("a parameter's guid is 32 hex digits");
const auto type = entry.at("type").string();
const auto& fallback = entry.at("default");
auto guid=entry.at("guid").string();
std::transform(guid.begin(),guid.end(),guid.begin(),[](unsigned char c){return static_cast<char>(c>='A'&&c<='F'?c+32:c);});
const auto expected=(type=="colour"||type=="color")?"59ff5e88":"2d5cb62b";
if(guid.substr(16,8)!=expected || guid.substr(24,4)!="0100")
throw std::runtime_error("parameter GUID must match its declared type and have count 1");
if (type == "colour" || type == "color") {
out.colour = true;
if (!fallback.is_array() || fallback.size() != 3) throw std::runtime_error("a colour's default is [r, g, b]");
@@ -72,7 +78,7 @@ bool material_parameter_key(const std::string& guid, std::uint64_t& key, std::ui
std::vector<MaterialDeclaration> parse_material_declarations(const std::string& mod, const std::string& json) {
const auto root = Json::parse(json);
if (!root.is_object() || !root.contains("schema") || !root.at("schema").is_number() || root.at("schema").get<int>() != 1 ||
if (!root.is_object() || !root.contains("schema") || !root.at("schema").is_number_integer() || root.at("schema").get<int>() != 1 ||
!root.contains("materials") || !root.at("materials").is_array())
throw std::runtime_error("expected schema 1 with a materials array");
const auto& list = root.at("materials");
@@ -294,28 +294,30 @@ std::vector<mods::MaterialDeclaration> declared_declarations() {
auto never_sold = [](std::string_view) { return false; };
detail::Found declared_found(Board& board) {
auto found = board.walk();
found.declared = detail::declared_for(declared_declarations(), Part::deck, "example_deck", never_sold);
found.declared = detail::declared_for(declared_declarations(), Part::deck, "example_deck", never_sold, "Example");
return found;
}
void declarations_follow_the_equipped_item() {
const auto all = declared_declarations();
const auto deck = detail::declared_for(all, Part::deck, "EXAMPLE_DECK", never_sold);
const auto deck = detail::declared_for(all, Part::deck, "EXAMPLE_DECK", never_sold, "Example");
check(deck.size() == 9 && deck[0].key == liquid[0].key && deck[0].identity == liquid[0].identity && deck[0].kind == Kind::colour &&
deck[0].fallback == Colour{.5f, .5f, .5f} && deck[8].kind == Kind::number,
"The equipped item's declarations apply (its key in any case)");
check(detail::declared_for(all, Part::deck, "Some_Other_Deck", never_sold).empty(), "another deck's do not");
check(detail::declared_for(all, Part::grip, "Example_Deck", never_sold).empty(), "nor another part's");
check(detail::declared_for(all, Part::deck, "", never_sold).empty(), "nor an empty slot's");
check(detail::declared_for(all, Part::deck, "Some_Other_Deck", never_sold, "Example").empty(), "another deck's do not");
check(detail::declared_for(all, Part::grip, "Example_Deck", never_sold, "Example").empty(), "nor another part's");
check(detail::declared_for(all, Part::deck, "", never_sold, "Example").empty(), "nor an empty slot's");
auto sold = [](std::string_view) { return true; };
check(detail::declared_for(all, Part::deck, "Example_Deck", sold).empty(), "An item the game sells (or one it cannot tell) takes none");
check(detail::declared_for(all, Part::deck, "Example_Deck", sold, "Example").empty(), "An item the game sells (or one it cannot tell) takes none");
// Two mods giving one key differently: which shader is right cannot be told, so neither.
auto both = all;
both.push_back({"Other", "deck", {"Example_Deck"}, {{liquid[0].key, 1, true, {0, 0, 0}}, {liquid[1].key, liquid[1].identity, true, {.5f, .5f, .5f}}}});
const auto merged = detail::declared_for(both, Part::deck, "Example_Deck", never_sold);
check(std::none_of(merged.begin(), merged.end(), [](const detail::Declared& d) { return d.key == liquid[0].key; }) &&
check(detail::declared_for(all,Part::deck,"Example_Deck",never_sold,"Other").empty(),"Other mod cannot declare this item");
check(detail::declared_for(all,Part::deck,"Example_Deck",never_sold,"").empty(),"Unknown ownership is refused");
const auto merged = detail::declared_for(both, Part::deck, "Example_Deck", never_sold, "Example");
check(std::any_of(merged.begin(), merged.end(), [](const detail::Declared& d) { return d.key == liquid[0].key; }) &&
std::count_if(merged.begin(), merged.end(), [](const detail::Declared& d) { return d.key == liquid[1].key; }) == 1,
"A key two mods declare differently is left out; one they agree on is kept once");
"Another mod cannot suppress the owner's declarations");
}
void lists_and_refuses_declared() {
@@ -461,6 +463,8 @@ void a_change_of_deck_lets_go() {
detail::apply(saved, holds, changed, write, publish, insert);
check(inserts == 1 && saved.empty(), "A declared hold is not carried onto an incompatible deck");
// A board being dressed: nothing is let go or written while the recipe is on its way.
changed.declarations_pending[0]=true;
check(detail::drop_undeclared(holds,changed)==0 && holds.size()==1,"Rebuilding materials retain declared holds while settling");
changed.pending = true;
check(detail::drop_undeclared(holds, changed) == 0 && holds.size() == 1, "nor let go while the board is being dressed");
// Back on the declared-input deck it applies again.
+32 -33
View File
@@ -6,6 +6,7 @@
#include "Engine/Core/Platform/memory.h"
#include "Engine/Vfs/content_catalogs.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Engine/Vfs/mod_item_sources.h"
#include "Extension/Multiplayer/Remote/native_pose_layout.h"
#include "Extension/Multiplayer/Remote/native_skater.h"
#include <Windows.h>
@@ -104,10 +105,13 @@ bool setter_matches(State& s) {
// ---- declarations: every enabled mod's reskate-materials.json -------------------------------------------
// Read on a thread of their own and again only when a file changes, as the mods' music is
// (local_music_assets.cpp): the game's frame never waits on the disk.
struct DeclarationSet {
std::vector<mods::MaterialDeclaration> materials;
std::vector<mods::ItemSource> sources;
};
struct Declarations {
std::mutex mutex;
std::shared_ptr<const std::vector<mods::MaterialDeclaration>> ready;
std::vector<std::filesystem::file_time_type> stamps;
std::shared_ptr<const DeclarationSet> ready;
bool reading{};
std::uint64_t looked{};
};
@@ -115,18 +119,11 @@ Declarations& declarations() {
static auto* value = new Declarations;
return *value;
}
std::vector<std::filesystem::file_time_type> declaration_stamps() {
std::vector<std::filesystem::file_time_type> stamps;
for (const auto& mod : mods::catalog().mods) {
std::error_code error;
const auto stamp = std::filesystem::last_write_time(mod.directory / L"reskate-materials.json", error);
stamps.push_back(error ? std::filesystem::file_time_type::min() : stamp);
}
return stamps;
}
std::vector<mods::MaterialDeclaration> read_declarations() {
std::vector<mods::MaterialDeclaration> result;
for (const auto& mod : mods::catalog().mods) {
DeclarationSet read_declarations() {
DeclarationSet result;
const auto active=mods::load_catalog(mods::catalog().data_root,{},false);
result.sources=mods::read_item_sources(active.root/L".reskate"/mods::item_sources_file);
for (const auto& mod : active.mods) {
const auto path = mod.directory / L"reskate-materials.json";
std::error_code error;
if (!std::filesystem::is_regular_file(path, error) || std::filesystem::file_size(path, error) > 64 * 1024) continue;
@@ -137,7 +134,7 @@ std::vector<mods::MaterialDeclaration> read_declarations() {
for (const auto& d : read) count += d.parameters.size();
logging::log(logging::Level::info, logging::Channel::customization,
"Board parameters: {} declares {} shader input{} for its items.", mod.name, count, count == 1 ? "" : "s");
std::move(read.begin(), read.end(), std::back_inserter(result));
std::move(read.begin(), read.end(), std::back_inserter(result.materials));
} catch (const std::exception& failure) {
logging::log(logging::Level::warning, logging::Channel::customization,
"Board parameters: {}'s reskate-materials.json is left out: {}", mod.name, failure.what());
@@ -147,28 +144,20 @@ std::vector<mods::MaterialDeclaration> read_declarations() {
}
void read_declarations_now() noexcept {
auto& d = declarations();
std::shared_ptr<const std::vector<mods::MaterialDeclaration>> read;
std::vector<std::filesystem::file_time_type> stamps;
std::shared_ptr<const DeclarationSet> read;
try {
stamps = declaration_stamps();
bool same{};
{
std::lock_guard lock(d.mutex);
same = d.ready && stamps == d.stamps;
}
if (!same) read = std::make_shared<const std::vector<mods::MaterialDeclaration>>(read_declarations());
read = std::make_shared<const DeclarationSet>(read_declarations());
} catch (...) {}
std::lock_guard lock(d.mutex);
if (read) {
d.ready = std::move(read);
d.stamps = std::move(stamps);
} else if (!d.ready) {
d.ready = std::make_shared<const std::vector<mods::MaterialDeclaration>>();
d.ready = std::make_shared<const DeclarationSet>();
}
d.reading = false;
}
// What was last read, and a read started when the last began two seconds ago or more.
std::shared_ptr<const std::vector<mods::MaterialDeclaration>> current_declarations() {
std::shared_ptr<const DeclarationSet> current_declarations() {
auto& d = declarations();
std::lock_guard lock(d.mutex);
const auto now = GetTickCount64();
@@ -183,10 +172,12 @@ std::shared_ptr<const std::vector<mods::MaterialDeclaration>> current_declaratio
}
return d.ready;
}
// Whether the game sells an item, or cannot tell yet (no content pack read): either way its
// declarations do not apply. Only a mod's own items take them.
// All stock/developer items are excluded, free or paid. Unknown catalogue
// state is refused too; ownership is additionally checked against item sources.
bool sold(std::string_view item) {
return !content_cache::catalogs().available || profile_runtime::reserved_cosmetic(item);
std::string key(item); for(auto& c:key)if(c>='A'&&c<='Z')c=static_cast<char>(c+32);
const auto& catalog=content_cache::catalogs();
return !catalog.available || catalog.items.contains(key) || key.starts_with("dev_") || key.starts_with("own_dev_") || profile_runtime::reserved_cosmetic(item);
}
constexpr std::array<std::string_view, part_names.size()> worn_slots{"Deck", "Grip tape", "Trucks", "Wheels"};
@@ -194,7 +185,7 @@ constexpr std::array<std::string_view, part_names.size()> worn_slots{"Deck", "Gr
// have settled (detail::settled).
void declare(State& s, detail::Found& found) {
const auto all = current_declarations();
if (!all || all->empty()) return;
if (!all) { found.pending=true; return; }
const auto now = GetTickCount64();
if (now - s.worn_at >= 250 || s.worn.empty()) {
s.worn = worn_items::worn_board(false);
@@ -207,9 +198,17 @@ void declare(State& s, detail::Found& found) {
std::uintptr_t material{};
for (const auto& m : found.materials)
if (static_cast<std::size_t>(m.part) == part) { material = m.address; break; }
if (!detail::settled(s.gates[part], item, material, now) || found.pending) continue;
if (!detail::settled(s.gates[part], item, material, now) || found.pending) {
found.declarations_pending[part]=true;
continue;
}
auto is_sold = [](std::string_view i) { return sold(i); };
auto add = detail::declared_for(*all, static_cast<Part>(part), item, is_sold);
std::string key(item);for(auto& c:key)if(c>='A'&&c<='Z')c=static_cast<char>(c+32);
std::string_view owner;
for(const auto& source:all->sources) {
if(std::find(source.keys.begin(),source.keys.end(),key)!=source.keys.end()){owner=source.mod;break;}
}
auto add = detail::declared_for(all->materials, static_cast<Part>(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);
+7 -5
View File
@@ -76,6 +76,7 @@ struct Found {
std::vector<Material> materials;
bool pending{}; // a recipe on its way: nothing is written
std::vector<Declared> declared; // what the equipped items' mods declare (declarations below)
std::array<bool,4> declarations_pending{};
};
// Each part's named material on the board's slot controllers; the deck slot holds two.
struct Target {
@@ -331,13 +332,14 @@ void apply(std::vector<Saved>& saved, const std::vector<Hold>& holds, const Foun
// ---- declarations ---------------------------------------------------------------------------------
// The declarations of the item equipped on `part` (its key, as the game names it; any case), from
// every enabled mod's reskate-materials.json. A key two declarations give differently is left out:
// the owning enabled mod's reskate-materials.json. Other mods cannot grant or suppress keys.
// A key two declarations from the owner give differently is left out:
// which shader is right cannot be told. `sold` says whether the game sells an item (or cannot tell
// yet): its declarations never apply.
template <class Sold>
std::vector<Declared> declared_for(const std::vector<mods::MaterialDeclaration>& declarations, Part part, std::string_view item, Sold& sold) {
std::vector<Declared> declared_for(const std::vector<mods::MaterialDeclaration>& declarations, Part part, std::string_view item, Sold& sold, std::string_view owner) {
std::vector<Declared> out;
if (item.empty() || sold(item)) return out;
if (item.empty() || owner.empty() || sold(item)) return out;
const auto folded = [](std::string_view text) {
std::string s(text);
for (auto& c : s) c = static_cast<char>(c >= 'A' && c <= 'Z' ? c + 32 : c);
@@ -346,7 +348,7 @@ std::vector<Declared> declared_for(const std::vector<mods::MaterialDeclaration>&
const auto wanted = folded(item);
std::vector<std::uint64_t> conflicting;
for (const auto& d : declarations) {
if (d.part != part_names[static_cast<std::size_t>(part)]) continue;
if (d.mod != owner || d.part != part_names[static_cast<std::size_t>(part)]) continue;
if (std::none_of(d.items.begin(), d.items.end(), [&](const std::string& i) { return folded(i) == wanted; })) continue;
for (const auto& p : d.parameters) {
const Declared next{part, p.key, p.identity, p.colour ? Kind::colour : Kind::number, p.fallback};
@@ -380,7 +382,7 @@ inline bool settled(Gate& gate, std::string_view item, std::uintptr_t material,
inline std::size_t drop_undeclared(std::vector<Hold>& holds, const Found& live) {
if (live.pending) return 0;
return std::erase_if(holds, [&](const Hold& h) {
if (!h.declared) return false;
if (!h.declared || live.declarations_pending[static_cast<std::size_t>(h.part)]) return false;
const auto* d = declared(live, h.part, h.key);
return !d || d->kind != h.kind;
});
+1 -13
View File
@@ -62,8 +62,6 @@ struct FakeGame final : Game {
return ending == "roadrash_leg1" ? "RoadRash_Leg1" : "";
}
std::vector<OutfitSlot> outfit_slots() override { return {{"tattoo_legl", "", {0.5f, 1}}, {"moles", "moles_empty", {}}}; }
int board_preparations{};
void prepare_board() override { ++board_preparations; }
std::optional<std::array<BoardBone, 16>> board_bones() override {
std::array<BoardBone, 16> bones{};
bones[3].position = {0, 0.1f, 0};
@@ -99,7 +97,7 @@ struct FakeGame final : Game {
for (const auto key : declared_keys) declaration.parameters.push_back({key, static_cast<std::uint16_t>(key), true, {.5f, .5f, .5f}});
declaration.parameters.push_back({declared_number, 0x4242, false, {.25f, 0, 0}});
auto never_sold = [](std::string_view) { return false; };
found.declared = detail::declared_for({declaration}, Part::deck, declared_deck ? "Example_Deck" : "Plain_Deck", never_sold);
found.declared = detail::declared_for({declaration}, Part::deck, declared_deck ? "Example_Deck" : "Plain_Deck", never_sold, "Example");
return found;
}
bool declared_holds_deck(int part) const { return declared_rules && part == 0; }
@@ -2478,15 +2476,6 @@ void web_requests_go_only_to_listed_domains() {
check(environment.logged("many more than 4 web requests waiting"), "4 waiting at most\n" + environment.all());
}
void board_observation_is_prepared_only_with_permission() {
TestEnvironment environment;
Host host(environment, Side::client);
host.apply({script("plain", "assert(reskate)")});
check(environment.fake.board_preparations == 0, "No board preparation without permission");
host.apply({script("boardreader", "assert(reskate.board.bones())", R"("board")")});
running(host, "boardreader", environment);
check(environment.fake.board_preparations == 1, "Board observation is prepared before the script reads its pose");
}
void declared_board_colours_through_the_api() {
TestEnvironment environment;
@@ -4251,7 +4240,6 @@ int main() {
run(plugins_call_and_hear_each_other);
run(web_requests_go_only_to_listed_domains);
run(the_wider_set_reaches_the_game);
run(board_observation_is_prepared_only_with_permission);
run(declared_board_colours_through_the_api);
run(key_names_become_binds);
run(level_events_reach_plugins);
-1
View File
@@ -2891,7 +2891,6 @@ struct WorldApi {
void install_world_api(Plugin& plugin, lua_State* L) { WorldApi::install(plugin, L); }
void WorldApi::install(Plugin& plugin, lua_State* L) {
if (plugin.has("board")) game(plugin).prepare_board();
(void)plugin;
// The metatables of player tables (push_player) and of permissions; their own stay hidden.
lua_createtable(L, 0, 2);
-2
View File
@@ -806,8 +806,6 @@ public:
std::array<float, 4> rotation{0, 0, 0, 1};
Vec3 scale{1, 1, 1};
};
// Install native board observation before entering a timed Lua call.
virtual void prepare_board() {}
virtual std::optional<std::array<BoardBone, 16>> board_bones() { return std::nullopt; }
virtual std::string board_set(std::string_view /*plugin*/, int, const BoardBone&, bool /*world*/) { return unavailable; }
// `everyone`: hidden from the other players too, not only on this screen.
@@ -19,7 +19,6 @@
#include "Extension/Skater/skater_events.h"
#include "Extension/Skater/outfit_overlay.h"
#include "Extension/Skater/board_pose.h"
#include "Extension/Multiplayer/Remote/native_skater.h"
#include "Extension/Customization/board_material.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Engine/Core/Log/logging.h"
@@ -501,13 +500,7 @@ public:
return thrower_ == plugin ? multiplayer::throwdown_relay_host_status() : std::string{};
}
// ---- board -----------------------------------------------------------------------------------------
void prepare_board() override {
if (board_hooks_ready_) return;
std::string detail;
board_hooks_ready_ = multiplayer::install_entity_hooks(base_, detail);
}
std::optional<std::array<BoardBone, 16>> board_bones() override {
if (!board_hooks_ready_) return std::nullopt;
const auto drawn = board_pose::read();
if (!drawn) return std::nullopt;
std::array<BoardBone, 16> out{};
@@ -1816,7 +1809,6 @@ private:
}
std::uintptr_t base_;
bool board_hooks_ready_{};
std::string capture_owner_, capture_file_; // the plugin whose capture it is, and where it saved to
std::string camera_holder_, follower_, camera_status_;
std::array<float, 16> camera_pose_{};
-19
View File
@@ -1,19 +0,0 @@
# Declared board material inputs
Plugins already read and hold live colour/number values through `reskate.board.parameters`, `set_parameter` and `clear_parameter`. A custom shader can have inputs whose defaults are compiled into it, with no live node for that API to hold. A mod may declare these in its root `reskate-materials.json`:
```json
{"schema":1,"materials":[{"part":"deck","items":["Own_Example_Deck"],"parameters":[{"guid":"1af346775c032d5b59ff5e88010069b6","type":"colour","default":[0.5,0.5,0.5]}]}]}
```
The declaration describes an existing compiled shader input; it does not install shaders, select materials, unlock items or add textures. The GUID is its 16 serialized bytes in hex. The shader and declaration must agree on identity, type and default. Only the declaring equipped mod-owned item receives the value. Store items are excluded.
A permitted plugin sees an absent live value as `inherited = true`, with the declared default. It uses the existing `board` permission and existing set/clear API. Missing declared colours are inserted with the engine typed setter at native preset priority. A number must already have a live value; the declaration does not enable missing float insertion. Integers, switches and textures cannot be declared or changed.
Held values are released on clear, unload and item changes. Existing nodes restore their prior values; inserted nodes restore declared defaults (the engine retains the node). Type/identity mismatches and values outside bounds are refused. Competing plugins cannot take over a held key. The native setter/descriptor are checked against the supported build before use.
Bounds: 32 materials, 64 item keys per declaration, 32 parameters per declaration; text at most 255 bytes; colour channels 0–1 and numbers -100–100. Malformed files are skipped as a whole.
Board observation is prepared before timed plugin execution, only for a plugin with the `board` permission. No effect-specific solver, artwork, controller or raw-memory plugin access is introduced.
Tests: `mod_materials`, `board_material`, `plugins`.