From e35e33d66d94cdc18bcc3b1a511557c50c963e55 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Thu, 22 Oct 2026 20:17:09 -0500 Subject: [PATCH] Plugins: board.parameters reads the board's shader values, and set_parameter holds its colours and numbers Each part of the local board (deck, grip, trucks, wheels) has a material instance with a few shader values of its own, found in the running game. reskate.board.parameters lists them by key and type. set_parameter holds a colour or a number there each tick, published until it shows, and the board's own value comes back when the plugin lets go or stops. Integers and switches pick how the board is drawn, the store's foil looks among them, so plugins only read those. Only your own screen changes. --- CMakeLists.txt | 5 + Engine/Game/Build/20260929/native_cosmetics.h | 22 ++ .../Test/board_material_tests.cpp | 256 +++++++++++++++++ Extension/Customization/board_material.cpp | 121 ++++++++ Extension/Customization/board_material.h | 265 ++++++++++++++++++ Extension/Plugins/Test/plugin_tests.cpp | 34 +++ Extension/Plugins/plugin_api_world.cpp | 106 ++++++- Extension/Plugins/plugin_game.h | 17 ++ Extension/Plugins/plugin_game_runtime.cpp | 21 ++ cmake/Runtime.cmake | 1 + 10 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 Extension/Customization/Test/board_material_tests.cpp create mode 100644 Extension/Customization/board_material.cpp create mode 100644 Extension/Customization/board_material.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 333517cb..0479c577 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -456,6 +456,11 @@ if(DINGOSDK_BUILD_PLUGIN_TESTS AND WIN32) add_executable(dingosdk_trick_watch_tests Extension/Skater/Test/trick_watch_tests.cpp Extension/Skater/trick_watch.cpp) target_include_directories(dingosdk_trick_watch_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME trick_watch COMMAND dingosdk_trick_watch_tests) + # The board's shader parameters (Extension/Customization/board_material.h): the walk over a board + # laid out as the game holds it, what plugins may set, and holding and putting back. + add_executable(dingosdk_board_material_tests Extension/Customization/Test/board_material_tests.cpp) + target_include_directories(dingosdk_board_material_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME board_material COMMAND dingosdk_board_material_tests) endif() get_property(project_targets DIRECTORY PROPERTY BUILDSYSTEM_TARGETS) foreach(target IN LISTS project_targets) diff --git a/Engine/Game/Build/20260929/native_cosmetics.h b/Engine/Game/Build/20260929/native_cosmetics.h index d4b922ee..03818f3a 100644 --- a/Engine/Game/Build/20260929/native_cosmetics.h +++ b/Engine/Game/Build/20260929/native_cosmetics.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include // Remote skater and skateboard cosmetics. @@ -26,6 +27,27 @@ inline constexpr std::array set_shader_parameter_prefix{ 0x55,0x48,0x8d,0x6c,0x24,0xc9,0x48,0x81}; // Initialized ColorRgb type descriptor; validate its type hash, size and index. inline constexpr std::uintptr_t color_rgb_type = 0x7780c50; +// A material instance's own shader parameters (an AppearanceItem's named material): a hash map at +// +0x268 of buckets, +0x270 its bucket count, +0x274 how many it holds. A node holds its ParamDbKey +// at +0 (identity, size, type index and count words, then the key's 64-bit hash at +8), the value +// at +0x10, the value's type object at +0x20, flags at +0x28 (bit 0 set on every live one) and the +// next node at +0x40. A value written there shows once the material is marked changed at +0x2f6 and +// its item publishes its materials (publish_materials). Read from the running game's board, 2026-10-22. +inline constexpr std::size_t material_parameters = 0x268; +inline constexpr std::size_t material_parameter_buckets = 0x270; +inline constexpr std::size_t material_parameter_count = 0x274; +inline constexpr std::size_t material_changed = 0x2f6; +inline constexpr std::size_t parameter_value = 0x10; +inline constexpr std::size_t parameter_type = 0x20; +inline constexpr std::size_t parameter_flags = 0x28; +inline constexpr std::size_t parameter_next = 0x40; +// The type hash (the first word of the TypeInfo a type object points to; its size is the word at +// +6) of each kind of value the board's materials hold: ColorRgb, Float32, an integer (values like +// 13 on the deck, which pick how it is drawn) and a boolean (a switch the shader was compiled with). +inline constexpr std::uint32_t color_rgb_type_hash = 0x885eff59; +inline constexpr std::uint32_t float_type_hash = 0x2bb65c2d; +inline constexpr std::uint32_t integer_type_hash = 0x8e4a817e; +inline constexpr std::uint32_t boolean_type_hash = 0x36ed3444; // Finds a named entry in a loaded table by the FNV-1a hash of its name: // (out, name, table, flag, context) -> out, the fifth passed on the stack. // The skater loader's preset lookups reach it through a Lua binding that diff --git a/Extension/Customization/Test/board_material_tests.cpp b/Extension/Customization/Test/board_material_tests.cpp new file mode 100644 index 00000000..55d455f7 --- /dev/null +++ b/Extension/Customization/Test/board_material_tests.cpp @@ -0,0 +1,256 @@ +#include "Extension/Customization/board_material.h" +#include +#include +#include +#include + +namespace { +using namespace dingosdk; +using namespace dingosdk::board_material; +namespace n = addr::native_cosmetics; + +void check(bool ok, const std::string& message) { + if (ok) return; + std::cerr << "FAILED: " << message << '\n'; + std::exit(1); +} + +// Memory as the game lays a board out, by pages; a read of a page never written fails. +struct Memory { + std::map> pages; + void put_bytes(std::uintptr_t at, const void* data, std::size_t size) { + for (std::size_t i = 0; i < size; ++i, ++at) pages[at & ~std::uintptr_t{4095}][at & 4095] = static_cast(data)[i]; + } + template void put(std::uintptr_t at, const T& value) { put_bytes(at, &value, sizeof(value)); } + bool operator()(std::uintptr_t at, void* out, std::size_t size) const { + for (std::size_t i = 0; i < size; ++i, ++at) { + const auto page = pages.find(at & ~std::uintptr_t{4095}); + if (page == pages.end()) return false; + static_cast(out)[i] = page->second[at & 4095]; + } + return true; + } +}; + +constexpr std::uintptr_t base = 0x140000000; +constexpr std::uint64_t number_key = 0x841c7d74837f1d39ULL, integer_key = 0xb00abec403499469ULL, switch_key = 0xa5f9a6b8098279adULL; + +struct Board { + Memory memory; + std::uintptr_t entity = 0x10000000; + std::uintptr_t next = 0x20000000; // where the next allocation goes + std::uintptr_t grip{}, deck{}, trucks{}; + std::uintptr_t deck_item{}; + std::map grip_nodes; + std::uintptr_t take(std::size_t size) { + const auto at = next; + next += (size + 0xfff) & ~std::size_t{0xfff}; + std::vector zero(size); + memory.put_bytes(at, zero.data(), zero.size()); + return at; + } + // A type object and its information, in the game's image as the real ones are. + std::uintptr_t type(std::uintptr_t object, std::uint32_t hash, std::uint16_t size) { + const auto info = object + 0x100; + memory.put(base + object, base + info); + memory.put(base + info, hash); + memory.put(base + info + 6, size); + return base + object; + } + struct Value { + std::uint64_t key; + std::uintptr_t type; + std::uint16_t size; + std::array raw{}; + std::uint32_t flags = 3; + }; + 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); + memory.put(at + n::material_parameter_buckets, 1); + memory.put(at + n::material_parameter_count, static_cast(values.size())); + memory.put(buckets + 8, 0xdead0000); // the map's end + std::uintptr_t previous{}; + for (const auto& v : values) { + const auto node = take(0x48); + memory.put(node, developer_hoodie_detail::ParameterKey{0x1234, v.size, 1, 1, v.key}); + memory.put(node + n::parameter_value, v.raw); + memory.put(node + n::parameter_type, v.type); + memory.put(node + n::parameter_flags, v.flags); + memory.put(previous ? previous + n::parameter_next : buckets, node); + previous = node; + if (nodes) (*nodes)[v.key] = node; + } + return at; + } + std::uintptr_t item(std::uintptr_t appearance, std::string_view slot, const std::vector>& materials) { + const auto at = take(0x200), name = take(256), buckets = take(16); + memory.put_bytes(name, slot.data(), slot.size()); + memory.put(at + 0x30, name); + memory.put(at + 0x48, appearance + 0x40); + memory.put(at + 0xb0, 2); + memory.put(at + 0x1f0, buckets); + memory.put(at + 0x1f8, 1); + memory.put(at + 0x1fc, static_cast(materials.size())); + memory.put(buckets + 8, 0xdead0000); + std::uintptr_t previous{}; + for (const auto& [material_name, material] : materials) { + const auto node = take(0x30); + memory.put(node, developer_hoodie_detail::material_hash(material_name)); + memory.put(node + 8, material); + memory.put(previous ? previous + 0x28 : buckets, node); + previous = node; + } + return at; + } + Board() { + 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); + const auto bits = [](float a, float b = 0, float c = 0) { + std::array raw{}; + const std::array f{a, b, c}; + std::memcpy(raw.data(), f.data(), sizeof(f)); + return raw; + }; + grip = material({{graphic_colour, colour, 12, bits(.2f, .3f, .4f)}, + {number_key, number, 4, bits(1)}, + {integer_key, integer, 4, {13}}, + {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)}}); + 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); + memory.put(entity + 0x70, collection); + memory.put(collection, entity); + memory.put(collection + 8, 2); + memory.put(collection + 0x20, cosmetic); + memory.put(collection + 0x40, appearance); + memory.put(cosmetic, base + n::cosmetic_component_vtable); + memory.put(cosmetic + 0x18, collection); + memory.put(appearance, base + addr::engine::skater_appearance_vtable); + memory.put(appearance + 0x18, collection); + deck_item = item(appearance, developer_board_detail::slots[0], {{"Deck_Mat", deck}, {"DeckTop_Mat", grip}}); + memory.put(list, deck_item); + memory.put(list + 8, item(appearance, developer_board_detail::slots[1], {{"Truck_Mat", trucks}})); + memory.put(appearance + 0x90, list); + memory.put(appearance + 0x98, list + 16); + memory.put(appearance + 0xa0, list + 24); + } + detail::Found walk() { return detail::walk(memory, base, entity); } + Colour colour_at(std::uintptr_t node) { + Colour c{}; + memory(node + n::parameter_value, c.data(), sizeof(c)); + return c; + } +}; + +const detail::Material* part_of(const detail::Found& found, Part part) { + for (const auto& m : found.materials) + if (m.part == part) return &m; + return nullptr; +} + +void walks_the_board() { + Board board; + const auto found = board.walk(); + check(!found.pending && found.materials.size() == 3, "The board's three materials are found"); + const auto* grip = part_of(found, Part::grip); + const auto* deck = part_of(found, Part::deck); + check(grip && deck && part_of(found, Part::trucks) && !part_of(found, Part::wheels), "each as its part, and no wheels without their slot"); + check(grip->address == board.grip && grip->item == board.deck_item && grip->nodes.size() == 5, "The grip is the deck slot's DeckTop_Mat"); + const auto& p = grip->nodes; + check(p[0].parameter.kind == Kind::colour && p[0].parameter.key == graphic_colour && p[0].parameter.value == Colour{.2f, .3f, .4f}, + "A colour reads as one"); + check(p[1].parameter.kind == Kind::number && p[1].parameter.value[0] == 1, "a number as one"); + check(p[2].parameter.kind == Kind::integer && p[2].parameter.integer == 13, "an integer as one"); + check(p[3].parameter.kind == Kind::toggle && p[3].parameter.integer == 1, "a switch as one"); + check(p[4].parameter.kind == Kind::other, "and a value not live is of no kind plugins set"); +} + +void refuses_what_picks_the_look() { + Board board; + const auto found = board.walk(); + Kind kind{}; + check(detail::refusal(found, Part::grip, graphic_colour, {1, 0, 0}, true, kind).empty() && kind == Kind::colour, "A colour may be held"); + check(detail::refusal(found, Part::grip, number_key, {-3, 0, 0}, false, kind).empty() && kind == Kind::number, "and a number"); + check(detail::refusal(found, Part::grip, integer_key, {13, 0, 0}, false, kind).find("only read") != std::string::npos, + "An integer, which picks how the board is drawn, is not"); + check(detail::refusal(found, Part::grip, switch_key, {0, 0, 0}, false, kind).find("only read") != std::string::npos, "nor a switch"); + check(!detail::refusal(found, Part::grip, 0x1111, {.5f, .5f, .5f}, true, kind).empty(), "nor a value that is not live"); + check(!detail::refusal(found, Part::grip, graphic_colour, {1.5f, 0, 0}, true, kind).empty(), "A colour is 0 to 1"); + check(!detail::refusal(found, Part::grip, graphic_colour, {1, 0, 0}, false, kind).empty(), "and a number is not a colour"); + check(!detail::refusal(found, Part::grip, number_key, {1000, 0, 0}, false, kind).empty(), "A number has bounds"); + check(detail::refusal(found, Part::wheels, base_colour, {1, 0, 0}, true, kind).find("no parameter") != std::string::npos, + "A part without the key says so"); +} + +void holds_and_puts_back() { + Board board; + std::vector saved; + std::vector holds{{"glow", Part::grip, graphic_colour, Kind::colour, {1, 0, 0}}}; + int writes{}, publishes{}; + auto write = [&](std::uintptr_t material, std::uintptr_t node, Kind kind, const Colour& value) { + ++writes; + board.memory.put_bytes(node + n::parameter_value, value.data(), kind == Kind::colour ? sizeof(Colour) : sizeof(float)); + board.memory.put(material + n::material_changed, 1); + return true; + }; + auto publish = [&](std::uintptr_t item) { + check(item == board.deck_item, "The grip's item publishes its materials"); + ++publishes; + }; + const auto tick = [&] { + const auto found = board.walk(); + detail::apply(saved, holds, found, write, publish); + }; + const auto node = board.grip_nodes[graphic_colour]; + tick(); + check(board.colour_at(node) == Colour{1, 0, 0} && writes == 1 && publishes == 1, "A held colour is written and published"); + std::uint8_t changed{}; + board.memory(board.grip + n::material_changed, &changed, 1); + check(changed == 1, "and its material marked changed"); + for (int i = 0; i < 6; ++i) tick(); + check(writes == 1 && publishes == 1 + developer_hoodie_detail::settle_publishes, "A colour standing still is published until it shows, then left"); + holds[0].value = {0, 1, 0}; + tick(); + check(board.colour_at(node) == Colour{0, 1, 0} && writes == 2, "A new value is written"); + // The game sets its own while it is held: held again. + board.memory.put(node + n::parameter_value, Colour{.7f, .7f, .7f}); + tick(); + check(board.colour_at(node) == Colour{0, 1, 0} && writes == 3, "What is held wins over a change"); + holds.clear(); + publishes = 0; + for (int i = 0; i < 8; ++i) tick(); + check(board.colour_at(node) == Colour{.7f, .7f, .7f} && writes == 4, "Let go, it goes back to the board's own (the game's latest)"); + check(saved.empty() && publishes == 1 + developer_hoodie_detail::settle_publishes, "and is published until that shows"); + + // A number, let go after the game changed it: left as the game has it. + holds = {{"glow", Part::grip, number_key, Kind::number, {4, 0, 0}}}; + tick(); + float number{}; + board.memory(board.grip_nodes[number_key] + n::parameter_value, &number, 4); + check(number == 4, "A held number is written"); + holds.clear(); + board.memory.put(board.grip_nodes[number_key] + n::parameter_value, 9.0f); + tick(); + board.memory(board.grip_nodes[number_key] + n::parameter_value, &number, 4); + check(number == 9 && saved.empty(), "and one the game changed is not put back"); + + // A recipe on its way: nothing is written. + holds = {{"glow", Part::grip, graphic_colour, Kind::colour, {0, 0, 1}}}; + auto found = board.walk(); + found.pending = true; + const auto before = writes; + detail::apply(saved, holds, found, write, publish); + check(writes == before, "Nothing is written while the board is being dressed"); +} +} // namespace + +int main() { + walks_the_board(); + refuses_what_picks_the_look(); + holds_and_puts_back(); + std::cout << "board material tests passed\n"; +} diff --git a/Extension/Customization/board_material.cpp b/Extension/Customization/board_material.cpp new file mode 100644 index 00000000..5015b2d5 --- /dev/null +++ b/Extension/Customization/board_material.cpp @@ -0,0 +1,121 @@ +#include "board_material.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Extension/Multiplayer/Remote/native_pose_layout.h" +#include "Extension/Multiplayer/Remote/native_skater.h" +#include +#include + +namespace dingosdk::board_material { +namespace { +struct State { + std::uintptr_t base{}, client{}; + std::vector holds; + std::vector saved; + std::optional functions; // the material publisher's bytes, once checked + std::uint64_t reported{}; +}; +State& state() { + static auto* value = new State; // what is held may still be out at exit + return *value; +} +const auto peek = [](std::uintptr_t address, void* out, std::size_t size) { return memory::peek_bytes(address, out, size); }; + +bool write_value(std::uintptr_t material, std::uintptr_t node, const float* value, std::size_t bytes) noexcept { + __try { + std::memcpy(reinterpret_cast(node + addr::native_cosmetics::parameter_value), value, bytes); + *reinterpret_cast(material + addr::native_cosmetics::material_changed) = 1; + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { return false; } +} +bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept { + __try { + reinterpret_cast(base + addr::native_cosmetics::publish_materials)(item, 0); + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { return false; } +} +bool functions_match(State& s) { + if (!s.functions) { + std::array bytes{}; + s.functions = peek(s.base + addr::native_cosmetics::publish_materials, bytes.data(), bytes.size()) && + bytes == addr::native_cosmetics::publish_materials_prefix; + if (!*s.functions) + logging::log(logging::Level::warning, logging::Channel::customization, + "Board parameters: the material publisher differs from the supported build; plugins cannot set them."); + } + return *s.functions; +} +// 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; + try { + const auto local = multiplayer::capture_local(s.base, s.client, false); + 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); + } 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) { + s.reported = now; + logging::log(logging::Level::debug, logging::Channel::customization, "Board parameters: {}", error.what()); + } + return std::nullopt; + } +} +} // namespace + +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; +} + +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 {}; +} + +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); }); +} + +void tick(std::uintptr_t base, std::uintptr_t client) noexcept { + try { + auto& s = state(); + s.base = base, s.client = client; + if (s.holds.empty() && s.saved.empty()) return; + if (!functions_match(s)) return; + const auto found = board(s); + if (!found) return; + 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); + } catch (...) {} +} +} // namespace dingosdk::board_material diff --git a/Extension/Customization/board_material.h b/Extension/Customization/board_material.h new file mode 100644 index 00000000..6e20a3d1 --- /dev/null +++ b/Extension/Customization/board_material.h @@ -0,0 +1,265 @@ +#pragma once +#include "developer_board_material.h" +#include +#include +#include +#include +#include +#include + +// The shader parameters of the local player's own board as plugins read and set them +// (reskate.board.parameters): each part's material instance holds a few values of its own (read +// from the running game, 2026-10-22: the deck and its grip eight each, the trucks and wheels three), +// which the renderer takes for that board alone. A plugin may hold a colour or a number there; an +// 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. +namespace dingosdk::board_material { +enum class Part : std::uint8_t { deck, grip, trucks, wheels }; +inline constexpr std::array part_names{"deck", "grip", "trucks", "wheels"}; +enum class Kind : std::uint8_t { colour, number, integer, toggle, other }; +using Colour = std::array; +struct Parameter { + std::uint64_t key{}; + 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 +}; +// The two colours known by what they colour (the developer board animates them): the graphic's and +// the base's. +inline constexpr std::uint64_t graphic_colour = developer_board_detail::graphic_color; +inline constexpr std::uint64_t base_colour = developer_board_detail::base_color; +// What a plugin may set: a colour of 0 to 1 a channel, as the board's own are, or a number. +inline constexpr float most_number = 100.0f; +inline constexpr std::size_t most_held = 32; + +// Client thread. The part's parameters as its material holds them now; none without a board. +std::optional> read(Part part); +// Held by `owner` and written every tick until cleared: a colour (all three), or a number +// (`value[0]`, `colour` false). Empty when held, else why not. +std::string set(std::string_view owner, Part part, std::uint64_t key, const Colour& value, bool colour); +// What `owner` holds of that part and key (all its parts, all its keys) put back. +void clear(std::string_view owner, std::optional part = std::nullopt, std::optional key = std::nullopt); +// Each client tick while the skater is in play: what is held written, what is let go put back. +void tick(std::uintptr_t base, std::uintptr_t client) noexcept; + +namespace detail { +struct Node { + std::uintptr_t address{}; + Parameter parameter; +}; +struct Material { + std::uintptr_t item{}, address{}; + Part part{}; + std::vector nodes; +}; +struct Found { + std::vector materials; + bool pending{}; // a recipe on its way: nothing is written +}; +// Each part's named material on the board's slot controllers; the deck slot holds two. +struct Target { + std::string_view material; + std::size_t slot; + Part part; +}; +inline constexpr std::array targets{{ + {"Deck_Mat", 0, Part::deck}, + {"DeckTop_Mat", 0, Part::grip}, + {"Truck_Mat", 1, Part::trucks}, + {"Wheel_mat", 2, Part::wheels}, +}}; +inline constexpr std::size_t most_nodes = 512; + +inline Kind kind_of(std::uint32_t type_hash, std::uint16_t type_size, const developer_hoodie_detail::ParameterKey& key, bool live) noexcept { + namespace n = addr::native_cosmetics; + if (!live || key.count != 1 || key.size != type_size) return Kind::other; + if (type_hash == n::color_rgb_type_hash && type_size == sizeof(Colour)) return Kind::colour; + if (type_hash == n::float_type_hash && type_size == 4) return Kind::number; + if (type_hash == n::integer_type_hash && type_size == 4) return Kind::integer; + if (type_hash == n::boolean_type_hash && type_size == 4) return Kind::toggle; + return Kind::other; +} +template std::vector parameters(Memory& m, std::uintptr_t material) { + namespace n = addr::native_cosmetics; + const auto buckets = m.ptr(material, n::material_parameters); + const auto size = m.template get(material, n::material_parameter_buckets); + const auto total = m.template get(material, n::material_parameter_count); + std::vector out; + if (!buckets || !size) return out; + m.check(size <= 1024 && total <= most_nodes, "Board parameter map exceeds bounds."); + const auto sentinel = m.ptr(buckets, size * 8ULL); + for (std::uint32_t b = 0; b < size; ++b) { + std::uint32_t visited{}; + for (auto node = m.ptr(buckets, b * 8ULL); node && node != sentinel; node = m.ptr(node, n::parameter_next)) { + m.check(++visited <= total && out.size() < total, "Board parameter chain exceeds bounds."); + const auto key = m.template get(node); + const auto raw = m.template get>(node, n::parameter_value); + const auto type = m.ptr(node, n::parameter_type); + Parameter p; + p.key = key.hash; + 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. + if (info >= m.base && info < m.base + 0x09144000) + p.kind = kind_of(m.template get(info), m.template get(info, 6), key, + (m.template get(node, n::parameter_flags) & 1) != 0); + } + if (p.kind == Kind::colour || p.kind == Kind::number) { + std::memcpy(p.value.data(), raw.data(), p.kind == Kind::colour ? sizeof(Colour) : sizeof(float)); + m.check(std::all_of(p.value.begin(), p.value.end(), [](float v) { return std::isfinite(v); }), "Board parameter is invalid."); + } else if (p.kind == Kind::integer) { + p.integer = static_cast(raw[0]); + } else if (p.kind == Kind::toggle) { + p.integer = raw[0] != 0; + } + out.push_back({node, p}); + } + } + return out; +} +// The board actor's parts (developer_board_detail::materials walks the same way to its colours). +template Found walk(Read& read, std::uintptr_t base, std::uintptr_t entity) { + using namespace multiplayer; + CosmeticMemory m{read, base}; + Found out; + m.check(m.ptr(entity) == base + addr::engine::board_entity_vtable, "Board actor type differs."); + const auto component = m.component(entity); + out.pending = m.template get(component, 0x10a) || m.template get(component, 0x10d); + const auto appearance = read_native_component(read, entity, base + addr::engine::skater_appearance_vtable); + const auto begin = m.ptr(appearance, 0x90), end = m.ptr(appearance, 0x98), capacity = m.ptr(appearance, 0xa0); + m.check(begin <= end && end <= capacity && (end - begin) % 8 == 0 && (capacity - begin) / 8 <= 64 && (begin || !capacity), + "Board controller array exceeds bounds."); + const auto& slots = developer_board_detail::slots; + std::array seen{}; + for (auto entry = begin; entry < end; entry += 8) { + const auto item = m.ptr(entry); + if (!item) continue; + m.check(m.ptr(item, 0x48) == appearance + 0x40, "Board controller ownership differs."); + const auto assigned = std::find(slots.begin(), slots.end(), m.text(m.ptr(item, 0x30))); + if (assigned == slots.end()) continue; + const auto slot = static_cast(assigned - slots.begin()); + m.check(!seen[slot], "Board has duplicate slot controllers."); + seen[slot] = true; + if (!(m.template get(item, 0xb0) & 2)) { out.pending = true; continue; } + const auto buckets = m.ptr(item, 0x1f0); + const auto size = m.template get(item, 0x1f8), total = m.template get(item, 0x1fc); + if (!total) { out.pending = true; continue; } + m.check(buckets && size && size <= 256 && total <= 128, "Board material map exceeds bounds."); + const auto sentinel = m.ptr(buckets, size * 8ULL); + for (const auto& target : targets) { + if (target.slot != slot) continue; + const auto hash = developer_hoodie_detail::material_hash(target.material); + auto node = m.ptr(buckets, (hash % size) * 8ULL); + std::uint32_t visited{}; + for (; node && node != sentinel && visited < total; ++visited, node = m.ptr(node, 0x28)) + if (m.template get(node) == hash) break; + if (!node || node == sentinel || visited == total) continue; + std::array variants{}; + for (std::size_t v = 0; v < variants.size(); ++v) { + const auto material = m.ptr(node, 8 + v * 8); + if (!material || std::find(variants.begin(), variants.begin() + v, material) != variants.begin() + v) continue; + variants[v] = material; + out.materials.push_back({item, material, target.part, parameters(m, material)}); + } + } + } + return out; +} + +inline const char* kind_name(Kind kind) noexcept { + switch (kind) { + case Kind::colour: return "a colour"; + case Kind::number: return "a number"; + case Kind::integer: return "an integer"; + case Kind::toggle: return "a switch"; + default: return "of a kind plugins do not set"; + } +} +// Whether `value` may be held on that key of the part: empty, and its kind in `kind`, else why not. +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; + 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 (!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)); + if ((*has == Kind::colour) != colour) return std::format("{:016x} is {}", key, kind_name(*has)); + if (colour && !std::all_of(value.begin(), value.end(), [](float c) { return c >= 0 && c <= 1; })) + return "a colour's channels are 0 to 1"; + if (!colour && !(std::abs(value[0]) <= most_number)) return std::format("a number is -{0} to {0}", most_number); + kind = *has; + return {}; +} +// A value a plugin holds, and what was found where it was written. +struct Hold { + std::string owner; + Part part{}; + std::uint64_t key{}; + Kind kind{}; + Colour value{}; +}; +struct Saved { + std::uintptr_t material{}, node{}; + Kind kind{}; + Colour original{}, last{}; + std::uint8_t settling{}; // publishes still owed for `last` +}; +inline bool same(Kind kind, const Colour& a, const Colour& b) noexcept { + return kind == Kind::colour ? a == b : a[0] == b[0]; +} +// 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) { + if (live.pending) return; + using developer_hoodie_detail::settle_publishes; + std::vector next; + std::vector shown; + for (const auto& material : live.materials) { + bool show{}; + for (const auto& node : material.nodes) { + 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; + }); + 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; + }); + const Saved* was = at == saved.end() ? nullptr : &*at; + const bool ours = was && same(p.kind, p.value, was->last); + const auto original = ours ? was->original : p.value; + 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)}); + 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}); + 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. + 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}); + show = show || back; + } + } + 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); +} +} // namespace detail +} // namespace dingosdk::board_material diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 14fe270f..0391a85e 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -72,6 +72,20 @@ 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())); } + std::optional> board_parameters(int part) override { + if (part != 1) return std::vector{}; + return std::vector{{0x873f48af36e50d59ULL, "colour", {.2f, .3f, .4f}, 0}, + {0x841c7d74837f1d39ULL, "number", {1, 0, 0}, 0}, + {0xb00abec403499469ULL, "integer", {}, 13}, + {0xa5f9a6b8098279adULL, "switch", {}, 1}}; + } + std::string board_set_parameter(std::string_view plugin, int part, std::uint64_t key, std::array value, bool colour) override { + 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")); + } // The general services a game layer in Lua reads and drives (plugin_api_services.cpp). std::optional telemetry; std::optional skater_telemetry(std::string_view) override { return telemetry; } @@ -865,6 +879,10 @@ void a_permission_must_be_declared() { assert(not ok and err:find("needs the \"skater.control\" permission"), err) ok, err = pcall(reskate.mods.content, "parks") assert(not ok and err:find("needs the \"mods.read\" permission"), err) + ok, err = pcall(reskate.board.set_parameter, "grip", "graphic", {r = 1, g = 0, b = 0}) + assert(not ok and err:find("needs the \"board\" permission"), err) + ok, err = pcall(reskate.board.parameters, "deck") + assert(not ok and err:find("needs the \"board\" permission"), err) reskate.console.print("never") )")}); stopped(host, "bare", "raised an error"); @@ -2246,6 +2264,19 @@ void the_wider_set_reaches_the_game() { assert(reskate.board.hide(3)) reskate.board.park({12, 13, 14, 15}) assert(reskate.board.toss({12, 4}, {velocity = vector.create(1, 3, 0), spin = vector.create(0, 5, 0), ground = -2})) + local grip = reskate.board.parameters("grip") + assert(#grip == 4 and grip[1].key == "873f48af36e50d59" and grip[1].name == "graphic" and grip[1].type == "colour", "the grip's parameters") + assert(math.abs(grip[1].value.g - 0.3) < 1e-6 and grip[2].value == 1 and grip[2].name == nil, "a colour and a number") + assert(grip[3].type == "integer" and grip[3].value == 13 and grip[4].type == "switch" and grip[4].value == true, "an integer and a switch") + assert(#reskate.board.parameters("wheels") == 0) + assert(reskate.board.set_parameter("grip", "graphic", {r = 1, g = 0, b = 0})) + assert(reskate.board.set_parameter("deck", "841C7D74837F1D39", 2.5)) + assert(not pcall(reskate.board.parameters, "rail"), "a part is one of four") + assert(not pcall(reskate.board.set_parameter, "grip", "123", 1), "a key is 16 hex digits") + assert(not pcall(reskate.board.set_parameter, "grip", "graphic", {r = 1}), "a colour has three channels") + assert(not pcall(reskate.board.set_parameter, "grip", "graphic", math.huge), "a value is finite") + reskate.board.clear_parameter("grip", "base") + reskate.board.clear_parameter() reskate.log("deck fine") end) reskate.console.command("physics", "x", function() @@ -2376,6 +2407,9 @@ void the_wider_set_reaches_the_game() { check(environment.logged("[wide] deck fine") && game.called("board wide 12 2.0 world") && game.called("board hide wide 3") && game.called("board park wide 4") && game.called("board toss wide 2 3 -2"), "A plugin changes what is drawn of the board\n" + environment.all()); + check(game.called("board parameter wide 1 873f48af36e50d59 colour 1.0") && game.called("board parameter wide 0 841c7d74837f1d39 number 2.5") && + game.called("board parameter clear wide 1 8c46cd354b18a9e2") && game.called("board parameter clear wide -1 all"), + "and holds its shader parameters\n" + environment.all()); host.run_console("physics"); check(environment.logged("[wide] physics fine") && game.called("wipeout 2.0"), "The board's motion reads, and a wipeout is asked for\n" + environment.all()); ticks(host, environment, 10); diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index f6e70762..40f72379 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -1752,6 +1752,109 @@ struct WorldApi { if (!within(plugin.services_.now_ms(), plugin.control_second_, plugin.controls_, 4)) return result(L, "asked more than 4 times in a second"); return result(L, game(plugin).board_toss(plugin.id(), std::move(bones), velocity, spin, static_cast(ground))); } + // ---- board parameters: the shader values of a part of the local board (board_material.h) --------------- + static constexpr std::array board_parts{"deck", "grip", "trucks", "wheels"}; + // The two colours known by what they colour (board_material.h graphic_colour and base_colour). + static constexpr std::array, 2> named_board_parameters{{ + {"graphic", 0x873f48af36e50d59ULL}, {"base", 0x8c46cd354b18a9e2ULL}}}; + static int board_part(lua_State* L, int index) { + const std::string_view name = luaL_checkstring(L, index); + for (std::size_t i = 0; i < board_parts.size(); ++i) + if (name == board_parts[i]) return static_cast(i); + luaL_argerror(L, index, "a part is \"deck\", \"grip\", \"trucks\" or \"wheels\""); + return 0; + } + static std::uint64_t board_parameter_key(lua_State* L, int index) { + std::size_t length{}; + const char* text = luaL_checklstring(L, index, &length); + for (const auto& [name, key] : named_board_parameters) + if (std::string_view(text, length) == name) return key; + std::uint64_t key{}; + const auto [end, error] = std::from_chars(text, text + length, key, 16); + if (length != 16 || error != std::errc{} || end != text + length) luaL_argerror(L, index, "a key is 16 hex digits, \"graphic\" or \"base\""); + return key; + } + // 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. + static int board_parameters(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "board", "reskate.board.parameters"); + const auto found = game(plugin).board_parameters(board_part(L, 1)); + if (!found) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, static_cast(found->size()), 0); + int at = 0; + for (const auto& p : *found) { + lua_createtable(L, 0, 4); + const auto key = std::format("{:016x}", p.key); + lua_pushlstring(L, key.data(), key.size()); + lua_setfield(L, -2, "key"); + for (const auto& [name, known] : named_board_parameters) + if (known == p.key) { + lua_pushlstring(L, name.data(), name.size()); + lua_setfield(L, -2, "name"); + } + lua_pushlstring(L, p.type.data(), p.type.size()); + lua_setfield(L, -2, "type"); + if (p.type == "colour") { + lua_createtable(L, 0, 3); + const char* channels[] = {"r", "g", "b"}; + for (std::size_t c = 0; c < 3; ++c) { + lua_pushnumber(L, p.value[c]); + lua_setfield(L, -2, channels[c]); + } + lua_setfield(L, -2, "value"); + } else if (p.type == "number") { + lua_pushnumber(L, p.value[0]); + lua_setfield(L, -2, "value"); + } else if (p.type == "integer") { + lua_pushinteger(L, p.integer); + lua_setfield(L, -2, "value"); + } else if (p.type == "switch") { + lua_pushboolean(L, p.integer != 0); + lua_setfield(L, -2, "value"); + } + 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. + static int board_set_parameter(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "board", "reskate.board.set_parameter"); + const int part = board_part(L, 1); + const auto key = board_parameter_key(L, 2); + std::array value{}; + bool colour{}; + if (lua_type(L, 3) == LUA_TTABLE) { + colour = true; + const char* channels[] = {"r", "g", "b"}; + for (std::size_t c = 0; c < 3; ++c) { + lua_getfield(L, 3, channels[c]); + if (!lua_isnumber(L, -1)) luaL_argerror(L, 3, "a colour is {r, g, b}, each 0 to 1"); + value[c] = static_cast(lua_tonumber(L, -1)); + lua_pop(L, 1); + } + } else { + value[0] = static_cast(luaL_checknumber(L, 3)); + } + if (!std::all_of(value.begin(), value.end(), [](float v) { return std::isfinite(v); })) luaL_argerror(L, 3, "a value is finite"); + return result(L, game(plugin).board_set_parameter(plugin.id(), part, key, value, colour)); + } + // clear_parameter([part [, key]]): what the plugin holds there put back (every part, every key). + static int board_clear_parameter(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "board", "reskate.board.clear_parameter"); + const int part = lua_isnoneornil(L, 1) ? -1 : board_part(L, 1); + std::optional key; + if (!lua_isnoneornil(L, 2)) key = board_parameter_key(L, 2); + game(plugin).board_clear_parameter(plugin.id(), part, key); + return 0; + } // ---- outfit ------------------------------------------------------------------------------------------ // set(slot, item, {placement = {...}, covers = bool}): the item worn over the outfit in that slot. static int outfit_set(lua_State* L) { @@ -2668,7 +2771,8 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { add_functions(L, "skater", {{"state", state}, {"noclip", noclip}, {"boost", boost}, {"freeze", freeze}, {"wipeout", wipeout}, {"xray", xray}, {"shape_launch", shape_launch}, {"hide", hide_skater}, {"reach", reach}, {"carry", carry}, {"hand", hand}, {"joint", skater_joint}, {"pose", skater_pose}}); add_functions(L, "board", {{"motion", board_motion}, {"bones", board_bones}, {"set", board_set}, {"hide", board_hide}, {"clear", board_clear}, - {"park", board_park}, {"fit", board_fit}, {"toss", board_toss}}); + {"park", board_park}, {"fit", board_fit}, {"toss", board_toss}, {"parameters", board_parameters}, + {"set_parameter", board_set_parameter}, {"clear_parameter", board_clear_parameter}}); add_functions(L, "camera", {{"look", camera_look}, {"set", camera_set}, {"follow", camera_follow}, {"release", camera_release}, {"held", camera_held}, {"get", camera_get}, {"cycle", camera_cycle}}); add_functions(L, "trainer", {{"set_point", set_point}, {"back", back}}); add_functions(L, "hud", {{"hide", hide_hud}, {"corner", hud_corner}}); diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index bbd7828e..6ea2fa9f 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -768,6 +768,23 @@ public: float axle = 1.0f; }; virtual std::string board_fit(std::string_view /*plugin*/, std::optional) { return unavailable; } + // The shader parameters of a part of the local board (Extension/Customization/board_material.h): + // part 0 the deck, 1 its grip, 2 the trucks, 3 the wheels. `type` is "colour", "number", + // "integer", "switch" or "other"; `value` a colour's three channels or a number in [0], and + // `integer` an integer's or a switch's. None without a board. + struct BoardParameter { + std::uint64_t key{}; + std::string type; + std::array value{}; + std::int32_t integer{}; + }; + 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. + virtual std::string board_set_parameter(std::string_view /*plugin*/, int /*part*/, std::uint64_t, std::array, bool /*colour*/) { + return unavailable; + } + // Part -1: every part; no key: every key. + virtual void board_clear_parameter(std::string_view /*plugin*/, int /*part*/, std::optional) {} // ---- variants: of the plugin's own mod (Engine/Vfs/mod_list.h Variants) ----------------------------- struct VariantState { std::vector installed; // its "order" first diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index d0f49728..70409d7c 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -18,6 +18,7 @@ #include "Extension/Skater/skater_events.h" #include "Extension/Skater/outfit_overlay.h" #include "Extension/Skater/board_pose.h" +#include "Extension/Customization/board_material.h" #include "Engine/Vfs/mod_catalog.h" #include "Engine/Core/Log/logging.h" #include @@ -518,6 +519,24 @@ public: if (!fit) return board_pose::fit(plugin, std::nullopt); return board_pose::fit(plugin, board_pose::Fit{fit->plates, fit->axle}); } + std::optional> board_parameters(int part) override { + const auto found = board_material::read(static_cast(part)); + if (!found) return std::nullopt; + std::vector out; + for (const auto& p : *found) { + 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}); + } + return out; + } + std::string board_set_parameter(std::string_view plugin, int part, std::uint64_t key, std::array value, bool colour) override { + return board_material::set(plugin, static_cast(part), key, value, colour); + } + void board_clear_parameter(std::string_view plugin, int part, std::optional key) override { + board_material::clear(plugin, part < 0 ? std::nullopt : std::optional(static_cast(part)), key); + } // ---- variants ------------------------------------------------------------------------------------ // The mod's folder is read again at most every 2 s: a menu page asks every frame. std::optional variants(std::string_view plugin, const std::filesystem::path& package) override { @@ -1154,6 +1173,7 @@ public: launches::tick(client); outfit_overlay::tick(); board_pose::tick(base, local_skater_component(base, client)); + board_material::tick(base, client); const bool allowed = !multiplayer::local_throwdown_active(); const auto component = local_skater_component(base, client); skater_animation::tick(base, component, allowed); @@ -1651,6 +1671,7 @@ public: board_pose::clear(plugin); board_pose::park(plugin, {}); (void)board_pose::fit(plugin, std::nullopt); + board_material::clear(plugin); cancel_throwdown(plugin); multiplayer::custom_throwdowns::remove_plugin(plugin); if (capturer_ == plugin) capturer_.clear(); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 470fc0e3..e75b228b 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -173,6 +173,7 @@ add_library(dingosdk_runtime SHARED Extension/Boot/discord_presence.cpp Extension/Customization/developer_hoodie.cpp Extension/Customization/developer_board.cpp + Extension/Customization/board_material.cpp Extension/Skater/skater_model.cpp Extension/Skater/skater_observer.cpp Extension/Skater/client_source_spawn.cpp