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