From 45d4c64c752e7ae219247baf5f881334ae43eab9 Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Thu, 22 Oct 2026 20:30:07 +0200 Subject: [PATCH 1/8] Plugins: a dedicated server's plugins see the skaters reskate.session.skaters() returned an empty list on a dedicated server: its game for plugins did not say where anyone was, though the host holds every player's latest pose. A server copy now gets each player in the world with a pose: their id, the pose's root position, its heading (read as a client reads it) and how long ago it arrived. A server plugin can now follow players itself, for a race's checkpoints or a game of tag, instead of trusting what each player's copy reports. Host::skater_poses() lists them for the plugins. dingosdk_server_plugin_tests drives the host with simulated players sending poses and checks what a server plugin sees, and that a plugin without session.skaters is still refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs7xxJDf6SDcQUaXnzmtL5 --- CMakeLists.txt | 19 ++ Server/Test/server_plugin_tests.cpp | 287 ++++++++++++++++++++++++++++ Server/server_host.cpp | 8 + Server/server_host.h | 8 + Server/server_plugins.cpp | 13 ++ 5 files changed, 335 insertions(+) create mode 100644 Server/Test/server_plugin_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f9d1e773..798d0960 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -151,6 +151,25 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dingosdk_plugins dl pthread) endif() add_test(NAME server_locks COMMAND dingosdk_server_lock_tests) + # What the server's plugins see of the server: its players' skaters. + add_executable(dingosdk_server_plugin_tests Server/Test/server_plugin_tests.cpp Server/server_host.cpp Server/server_party.cpp + Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/server_update.cpp Server/server_release.cpp + $<$:Launcher/updater.cpp> Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp + Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp + Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp + Extension/Multiplayer/Session/password.cpp Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp + Server/server_parks.cpp Extension/Throwdowns/throwdown_wire.cpp Server/server_plugins.cpp) + target_include_directories(dingosdk_server_plugin_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_include_directories(dingosdk_server_plugin_tests SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") + if(WIN32) + target_link_libraries(dingosdk_server_plugin_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json + dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter dingosdk_https winhttp bcrypt winmm + dingosdk_plugins) + else() + target_link_libraries(dingosdk_server_plugin_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json + dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dingosdk_plugins dl pthread) + endif() + add_test(NAME server_plugins COMMAND dingosdk_server_plugin_tests) add_executable(dingosdk_server_release_tests Server/Test/server_release_tests.cpp Server/server_release.cpp) target_include_directories(dingosdk_server_release_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_server_release_tests PRIVATE dingosdk_json) diff --git a/Server/Test/server_plugin_tests.cpp b/Server/Test/server_plugin_tests.cpp new file mode 100644 index 00000000..416317b7 --- /dev/null +++ b/Server/Test/server_plugin_tests.cpp @@ -0,0 +1,287 @@ +// The dedicated server's plugins (server_plugins.cpp) with the server's host, over a transport +// that is not Steam's and simulated players: what a server plugin sees of the players' skaters. +// +// dingosdk_server_plugin_tests +#include "Server/server_host.h" +#include "Extension/Multiplayer/Net/pose_batch.h" +#include "Extension/Multiplayer/Net/pose_codec.h" +#include "Extension/Multiplayer/Net/wire_codec.h" +#include "Extension/Multiplayer/Steam/steam_transport.h" +#include "Engine/Game/Build/supported_build.h" +#include "Engine/Game/World/world_names.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::multiplayer { +namespace { +// What the fake transport holds: what each player is sent, and what they send. +struct Wire { + TransportStatus status; + std::vector inbound; + std::map>> sent; +}; +Wire &wire() { + static Wire value; + return value; +} +} // namespace + +struct SteamTransport::Impl {}; +SteamTransport::SteamTransport() = default; +SteamTransport::~SteamTransport() = default; +bool SteamTransport::open() { return true; } +bool SteamTransport::open_game_server(void *) { return true; } +bool SteamTransport::host(unsigned) { + wire().status.ready = wire().status.hosting = true; + wire().status.local_id = (1ULL << 56) | (4ULL << 52) | 1; // a game server's SteamID, never a player's + return true; +} +bool SteamTransport::join(std::uint64_t, std::uint32_t, std::uint16_t) { return false; } +bool SteamTransport::listen_direct(std::uint16_t) { return true; } +void SteamTransport::set_packing(unsigned) {} +std::vector SteamTransport::take_direct_notes() { return {}; } +bool SteamTransport::set_steam_debug(bool) { return false; } +bool SteamTransport::connect_peer(std::uint64_t) { return false; } +void SteamTransport::allow_peers(std::span) {} +bool SteamTransport::socket_test() { return true; } +void SteamTransport::stop() {} +void SteamTransport::disconnect(std::uint64_t id, const char *) { + std::erase_if(wire().status.peers, [&](const TransportPeer &peer) { return peer.id == id; }); +} +void SteamTransport::poll() {} +bool SteamTransport::send(std::uint64_t id, std::span bytes, bool, bool, TrafficLane) { + if (std::none_of(wire().status.peers.begin(), wire().status.peers.end(), [&](const auto &peer) { return peer.id == id; })) return false; + wire().sent[id].emplace_back(bytes.begin(), bytes.end()); + return true; +} +void SteamTransport::send_batch(std::span messages) { + for (auto &message : messages) message.sent = send(message.id, message.bytes, message.reliable, message.fresh, message.lane); +} +std::vector SteamTransport::receive() { return std::exchange(wire().inbound, {}); } +std::string SteamTransport::name(std::uint64_t) { return {}; } +const TransportStatus &SteamTransport::status() const { return wire().status; } +std::string SteamTransport::take_closed(std::uint64_t) { return {}; } +std::string SteamTransport::link_report(std::uint64_t) { return {}; } +std::int64_t SteamTransport::pending(std::uint64_t) const { return 0; } +std::string SteamTransport::relay_status() const { return "OK"; } +bool SteamTransport::set_send_rate(int) { return true; } +int SteamTransport::send_rate() const { return 900 * 1024; } +std::vector SteamTransport::links() { return {}; } +bool SteamTransport::bind(void *, void *, void *) { return true; } +} // namespace dingosdk::multiplayer + +namespace { +using namespace dingosdk; +using namespace dingosdk::multiplayer; +using namespace dingosdk::server; + +int failures{}; +void check(bool ok, const std::string &what) { + if (!ok) { + std::fprintf(stderr, "FAIL: %s\n", what.c_str()); + ++failures; + } +} +bool has(const std::vector &lines, std::string_view text) { + return std::any_of(lines.begin(), lines.end(), [&](const auto &line) { return line.find(text) != std::string::npos; }); +} +void write(const std::filesystem::path &file, const std::string &text) { + std::filesystem::create_directories(file.parent_path()); + std::ofstream(file, std::ios::binary | std::ios::trunc) << text; +} + +// One player's game: its greeting, its world being ready, and its poses sent as a game sends +// them (pose_batch.h), standing at `at` and facing `heading` about world up. +struct Player { + std::uint64_t id{}, epoch{}, secret{}, map{}; + std::string name; + std::array at{}; + float heading{}; + std::uint32_t sequence{}; + struct Kept { + std::uint32_t sequence{}; + std::uint64_t time_us{}; + pose_codec::QuantPose pose; + }; + std::deque kept; + pose_batch::Sender upload; + + Packet header(PacketKind kind, std::uint64_t now) { + Packet p; + p.kind = kind; + p.session = secret; + p.map = map; + p.world = 1; + p.epoch = epoch; + p.source = id; + p.time_us = now; + p.sequence = ++sequence; + p.build = supported_build::game_sha256_bytes; + return p; + } + void hello(std::uint64_t now) { + wire().status.peers.push_back({id, true}); + auto p = header(PacketKind::hello, now); + p.text = name; + wire().inbound.push_back({id, encode_wire(p), now}); + } + void ready(std::uint64_t now) { + auto p = header(PacketKind::world_ready, now); + p.world_ready = true; + wire().inbound.push_back({id, encode_wire(p), now}); + } + void pose(std::uint64_t now) { + for (const auto &bytes : std::exchange(wire().sent[id], {})) + if (const auto read = pose_batch::Ack::read(bytes)) upload.ack(*read); + auto p = header(PacketKind::pose, now); + p.pose.root.position = at; + p.pose.root.rotation = {0.f, std::sin(heading * 0.5f), 0.f, std::cos(heading * 0.5f)}; + p.pose.skater.resize(pose_codec::finger_skeleton); + p.pose.skater[1].position = at; + p.pose.board.resize(16); + p.pose.board[0].position = at; + kept.push_back({p.sequence, p.time_us, pose_codec::quantize(p.pose)}); + while (kept.size() > 64) kept.pop_front(); + const auto find = [&](std::uint32_t sequence) -> std::optional { + for (auto it = kept.rbegin(); it != kept.rend(); ++it) + if (it->sequence == sequence) return pose_batch::KeptView{it->sequence, it->time_us, &it->pose}; + return {}; + }; + const auto emit = [&](std::span message, bool) { + wire().inbound.push_back({id, {message.begin(), message.end()}, now}); + return true; + }; + upload.begin(1, map); + upload.add(p.source, p.epoch, *find(p.sequence), find, false, false, pose_batch::Rate::full, now, emit); + upload.flush(emit); + } +}; + +int run() { + const auto root = std::filesystem::temp_directory_path() / "reskate_server_plugin_tests"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root / "Mods"); + load_levels(root / "Mods"); // the retail maps + ServerConfig config; + config.file = root / "server" / "ReSkateServer.json"; + config.mods = root / "Mods"; + config.auto_update = false; + config.activity_log = false; + config.roles = default_roles(); + check(config_error(config).empty(), "The test's config was refused: " + config_error(config)); + + // A plugin that prints the skaters it sees every 20th tick (a second here), and one without + // session.skaters. Ticks, not reskate.time: its clock is the real one, and this test's is not. + write(root / "Plugins" / "watcher" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "session.skaters"]})"); + write(root / "Plugins" / "watcher" / "plugin" / "main.lua", R"( + local ticks = 0 + reskate.on("tick", function() + ticks += 1 + if ticks % 20 ~= 0 then return end + local list = reskate.session.skaters() + reskate.console.print("skaters " .. #list) + for _, s in ipairs(list) do + reskate.console.print(string.format("skater %s at %.1f %.1f %.1f facing %.2f, %s", s.id, s.position.x, s.position.y, + s.position.z, s.heading, s.age_ms >= 0 and s.age_ms < 1000 and "fresh" or ("stale " .. s.age_ms))) + end + end) + )"); + write(root / "Plugins" / "blind" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console"]})"); + write(root / "Plugins" / "blind" / "plugin" / "main.lua", R"( + local ticks = 0 + reskate.on("tick", function() + ticks += 1 + if ticks % 20 ~= 0 then return end + local ok, why = pcall(reskate.session.skaters) + reskate.console.print(ok and "blind saw them" or "blind refused: " .. tostring(why)) + end) + )"); + + std::vector log; + SteamTransport transport; + Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); + std::string error; + if (!host.start(error)) { + std::fprintf(stderr, "The server did not start: %s\n", error.c_str()); + return 1; + } + const auto invite = parse_invite(host.invite()); + if (!invite) { + std::fprintf(stderr, "No invite.\n"); + return 1; + } + std::vector players(2); + for (std::size_t i = 0; i < players.size(); ++i) { + auto &p = players[i]; + p.id = 76561198000000001ULL + i; + p.epoch = 5000 + i; + p.secret = invite->secret; + p.map = map_hash(map_destination(config.map)); + p.name = "Player " + std::to_string(i + 1); + } + players[0].at = {10.f, 2.f, -30.f}; + players[0].heading = 1.f; + players[1].at = {-4.f, 7.5f, 12.f}; + + std::uint64_t now = 1000000000ULL, next_ready = now; + bool posing{}; + const auto run_for = [&](std::uint64_t microseconds) { + for (const auto end = now + microseconds; now < end; now += 50000) { + if (now >= next_ready) { + next_ready = now + 1000000; + for (auto &p : players) p.ready(now); + } + if (posing) + for (auto &p : players) p.pose(now); + host.tick(now); + } + }; + // Before anyone is in the world, the plugin sees nobody. + run_for(3000000); + check(has(log, "skaters 0"), "The plugin did not run, or saw skaters on an empty server"); + check(std::any_of(log.begin(), log.end(), + [](const auto &line) { return line.starts_with("blind refused: ") && line.find("session.skaters") != std::string::npos; }), + "A plugin without session.skaters was not refused"); + check(!has(log, "blind saw them"), "A plugin without session.skaters saw the skaters"); + + // Joined and in the world, but no pose yet: still nobody to see. + for (auto &p : players) p.hello(now); + run_for(3000000); + log.clear(); + run_for(1100000); + check(has(log, "skaters 0"), "A player with no pose yet was a skater"); + + // Posing: each where their latest pose put them, facing as it faces. + posing = true; + run_for(3000000); + log.clear(); + run_for(1100000); + check(has(log, "skaters 2"), "The plugin did not see both skaters"); + check(has(log, "skater 76561198000000001 at 10.0 2.0 -30.0 facing 1.00, fresh"), "The first skater's place, heading or age is wrong"); + check(has(log, "skater 76561198000000002 at -4.0 7.5 12.0 facing 0.00, fresh"), "The second skater's place, heading or age is wrong"); + // A skater who stopped sending stays where they were, older. + posing = false; + run_for(2000000); + log.clear(); + run_for(1100000); + check(has(log, "skater 76561198000000001 at 10.0 2.0 -30.0 facing 1.00, stale"), "A skater's age did not grow without poses"); + if (failures) + for (const auto &line : log) std::fprintf(stderr, "log: %s\n", line.c_str()); + return failures ? 1 : 0; +} +} // namespace + +int main() { + const int result = run(); + if (result == 0) std::printf("server plugin tests passed\n"); + return result; +} diff --git a/Server/server_host.cpp b/Server/server_host.cpp index fa501a93..6ba1a32e 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -215,6 +215,14 @@ std::optional> Host::player_position(std::uint64_t steam_id return std::nullopt; } +std::vector Host::skater_poses() const { + std::vector list; + for (const auto &[id, guest] : guests_) + if (guest && guest->handshaken && guest->world_ready && guest->latest_root && guest->pose_arrival && now_ >= guest->pose_arrival) + list.push_back({id, *guest->latest_root, now_ - guest->pose_arrival}); + return list; +} + // A player without the map is downloading it: the clock they are loading against starts over // each time they say so, for as long as map_fetch_limit_us from the first time. A slot is still // never held for good, and a player who stops saying it has the usual time to load. diff --git a/Server/server_host.h b/Server/server_host.h index ae9edd04..07d2d920 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -82,6 +82,14 @@ class Host { std::uint8_t player_locks(std::uint64_t id) const { return locks_of(id); } std::uint8_t set_locks(std::uint64_t id, std::uint8_t locks, const std::string &source); std::optional> player_position(std::uint64_t steam_id) const; + // Where each player in the world last said their skater was (their latest pose's root), and + // how long ago that arrived: what the server's plugins see of the skaters. + struct SkaterPose { + std::uint64_t id{}; + Transform root; + std::uint64_t age_us{}; + }; + std::vector skater_poses() const; void drop(std::uint64_t id, const std::string &reason, const std::string &detail = {}); void change_map(std::string_view map); // a level name, level path or destination // A plugin message from the server's plugins (server_plugins.h) to one player, or everyone (0). diff --git a/Server/server_plugins.cpp b/Server/server_plugins.cpp index 28f3c748..b2223bf9 100644 --- a/Server/server_plugins.cpp +++ b/Server/server_plugins.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -79,6 +80,18 @@ class ServerGame final : public Game { host_.set_locks(player, locks, "plugin " + std::string(plugin)); return {}; } + // The skaters of the players in the world, as their latest poses put them. The server has + // no skater of its own, so this is every player's. + std::vector peer_skaters() override { + std::vector result; + for (const auto &s : host_.skater_poses()) { + const auto &q = s.root.rotation; + // The rotation's +Z about world up, as a client's plugin_game_runtime.cpp reads it. + const float heading = std::atan2(2 * (q[0] * q[2] + q[3] * q[1]), 1 - 2 * (q[0] * q[0] + q[1] * q[1])); + result.push_back({s.id, s.root.position, heading, static_cast(s.age_us) / 1000.0}); + } + return result; + } // Through the console's own ban and unban, so they keep its checks and save the bans file. std::string ban(std::uint64_t player) override { const auto answer = host_.command("ban " + std::to_string(player)); From fd5f95467f93fcfcbb6084732f6c8d27de4fbc1d Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Thu, 22 Oct 2026 20:38:22 +0200 Subject: [PATCH 2/8] Plugins: server plugins know the map and hear it change On a dedicated server reskate.level.current() was empty and level_loaded and level_unloading never fired, so a server plugin could not tell which map ran or that it had changed. The server's plugins are now told the map's level each tick, named as players' games name it (a destination's level, after its "|"). When the server changes the map, they hear level_unloading for the old one and level_loaded for the new one, as a game's plugins do; players load it after. dingosdk_server_plugin_tests checks level.current() and both events across a map change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs7xxJDf6SDcQUaXnzmtL5 --- Server/Test/server_plugin_tests.cpp | 25 +++++++++++++++++++++++++ Server/server_host.cpp | 5 +++++ Server/server_host.h | 3 +++ Server/server_plugins.cpp | 3 +++ 4 files changed, 36 insertions(+) diff --git a/Server/Test/server_plugin_tests.cpp b/Server/Test/server_plugin_tests.cpp index 416317b7..39fc2059 100644 --- a/Server/Test/server_plugin_tests.cpp +++ b/Server/Test/server_plugin_tests.cpp @@ -206,6 +206,19 @@ int run() { end) )"); + // One that says what the level is, and when it changes. + write(root / "Plugins" / "follower" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console"]})"); + write(root / "Plugins" / "follower" / "plugin" / "main.lua", R"( + reskate.on("level_unloading", function(name) reskate.console.print("unloading " .. name) end) + reskate.on("level_loaded", function(name) reskate.console.print("loaded " .. name) end) + local ticks = 0 + reskate.on("tick", function() + ticks += 1 + if ticks % 20 == 0 then reskate.console.print("level " .. reskate.level.current().name) end + end) + )"); + std::vector log; SteamTransport transport; Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); @@ -274,6 +287,18 @@ int run() { log.clear(); run_for(1100000); check(has(log, "skater 76561198000000001 at 10.0 2.0 -30.0 facing 1.00, stale"), "A skater's age did not grow without poses"); + + // The level, as players' games name it, and its change heard when the server changes it. + const auto *first = find_level(config.map); + const auto other = std::find_if(levels().begin(), levels().end(), [&](const auto &l) { return l.asset != first->asset; }); + check(first && other != levels().end(), "The test needs two retail maps"); + check(has(log, "level " + first->asset), "level.current() is not the server's map: " + first->asset); + host.change_map(other->name); + log.clear(); + run_for(1100000); + check(has(log, "unloading " + first->asset) && has(log, "loaded " + other->asset), "The plugin did not hear the map change"); + check(has(log, "level " + other->asset) && !has(log, "level " + first->asset), "level.current() did not follow the map change"); + check(!has(log, "skaters 2"), "Skaters stayed on the old map's places after the map changed"); if (failures) for (const auto &line : log) std::fprintf(stderr, "log: %s\n", line.c_str()); return failures ? 1 : 0; diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 6ba1a32e..b4cc006b 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -240,6 +240,11 @@ void Host::fetching(Guest &guest) { } std::string Host::invite() const { return format_invite({id_, secret_}); } std::string Host::map_name() const { return map_label(config_.map); } +std::string Host::map_level() const { + const auto destination = map_destination(config_.map); + const auto split = destination.find('|'); + return split == std::string::npos ? destination : destination.substr(split + 1); +} std::string Host::wire_map_label() const { // players without the map's mod still see its name auto label = map_name(); cut_text(label, max_member_name); diff --git a/Server/server_host.h b/Server/server_host.h index 07d2d920..67507f2d 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -47,6 +47,9 @@ class Host { std::string invite() const; std::string map_name() const; + // The level players load for the map, as their games name it: a destination's level, after + // its "|". What the server's plugins see as reskate.level.current(). + std::string map_level() const; // "maps reload": reads Mods again and says what changed. Also after a map was updated. std::string reload_maps(); // The mod folder of the map in play while anyone is on (an update leaves it alone), or diff --git a/Server/server_plugins.cpp b/Server/server_plugins.cpp index b2223bf9..3f4685ed 100644 --- a/Server/server_plugins.cpp +++ b/Server/server_plugins.cpp @@ -210,6 +210,9 @@ void ServerPlugins::tick(std::uint64_t now_us) { } const double dt = s.last_tick ? static_cast(now_us - s.last_tick) / 1000.0 : 0; s.last_tick = now_us; + // The map the server runs, so plugins hear level_unloading and level_loaded when it + // changes, as a game's plugins do (when the server changes it; players load it after). + s.plugins.set_level(s.server.map_level()); s.plugins.tick(dt); for (auto &bytes : std::exchange(s.environment.outbox, {})) s.server.send_plugin_message(wire::to_of(bytes), bytes); } catch (const std::exception &error) { From 5b10469baddcc6d7e117a768fb136f22af64ee31 Mon Sep 17 00:00:00 2001 From: Robin Moeller Date: Thu, 22 Oct 2026 20:43:01 +0200 Subject: [PATCH 3/8] Plugins: server plugins spawn and remove the server's park mods A dedicated server's park mods could only be spawned from its console, an admin's chat or a vote, so a server plugin could not bring out the park its game is played in. A new permission, server.parks, gives a server copy reskate.server.parks(), which lists the park mods set up with whether each fits the map running now and is spawned (its objects and minutes left), and reskate.server.park_spawn(name, minutes?) and park_remove(name), which return ok or false and why. They go through park-mod add and remove, so the park limits, the map check and the chat lines stay the server's. Their "[parks]" lines are logged "by plugin ". A name is a park mod's own (1 to 16 of a-z 0-9 - _) and minutes are 1 to 1440, or none for until removed. Reading the park files is not part of this: a plugin brings what it needs to know about a park in its own package. dingosdk_server_plugin_tests spawns and removes a park mod through a plugin and checks what it is refused; dingosdk_plugin_tests checks the API on a server copy, and that a player's copy and a plugin without server.parks cannot use it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hs7xxJDf6SDcQUaXnzmtL5 --- CMakeLists.txt | 4 +- Extension/Plugins/Test/plugin_tests.cpp | 50 ++++++++++++++++ Extension/Plugins/plugin_api_world.cpp | 52 ++++++++++++++++- Extension/Plugins/plugin_game.h | 12 ++++ Extension/Plugins/plugin_manifest.cpp | 7 ++- Server/Test/server_plugin_tests.cpp | 76 +++++++++++++++++++++++-- Server/server_host.h | 14 +++++ Server/server_parks.cpp | 38 ++++++++++++- Server/server_plugins.cpp | 9 +++ 9 files changed, 249 insertions(+), 13 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 798d0960..b357227f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -151,7 +151,7 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dingosdk_plugins dl pthread) endif() add_test(NAME server_locks COMMAND dingosdk_server_lock_tests) - # What the server's plugins see of the server: its players' skaters. + # What the server's plugins see of the server and do there: its players' skaters, its map, its park mods. add_executable(dingosdk_server_plugin_tests Server/Test/server_plugin_tests.cpp Server/server_host.cpp Server/server_party.cpp Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/server_update.cpp Server/server_release.cpp $<$:Launcher/updater.cpp> Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp @@ -169,7 +169,7 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) target_link_libraries(dingosdk_server_plugin_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dingosdk_plugins dl pthread) endif() - add_test(NAME server_plugins COMMAND dingosdk_server_plugin_tests) + add_test(NAME server_plugins COMMAND dingosdk_server_plugin_tests "${PROJECT_SOURCE_DIR}/Server/Test/fixtures/park_mods") add_executable(dingosdk_server_release_tests Server/Test/server_release_tests.cpp Server/server_release.cpp) target_include_directories(dingosdk_server_release_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_server_release_tests PRIVATE dingosdk_json) diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 7fc6c282..203b3e76 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -510,6 +510,15 @@ struct FakeGame final : Game { calls.push_back(std::format("announce {} {}", plugin, text)); return {}; } + std::vector server_parks() override { return {{"street", "San Vansterdam", true, true, 12, 5}, {"plaza", "Isle of Grom", false, false, 0, 0}}; } + std::string spawn_park(std::string_view plugin, std::string_view name, unsigned minutes) override { + calls.push_back(std::format("park spawn {} {} {}", plugin, name, minutes)); + return name == "plaza" ? "plaza belongs on Isle of Grom, and the server is on San Vansterdam." : std::string{}; + } + std::string remove_park(std::string_view plugin, std::string_view name) override { + calls.push_back(std::format("park remove {} {}", plugin, name)); + return {}; + } std::optional camera_view() override { return CameraView{{1, 2, 3}, {0, 0, 1}, 60}; } std::string set_camera(std::string_view plugin, const std::array& pose, float fov) override { calls.push_back(std::format("camera {} {:.1f} {:.1f} {:.1f} back {:.2f} {:.2f} {:.2f} fov {:.0f}", plugin, pose[12], pose[13], pose[14], @@ -1954,6 +1963,46 @@ void plugins_server_copies_are_the_authority() { running(player_host, "mode-p", player); } +// A dedicated server's park mods, for a server copy with server.parks: listed, spawned and removed +// as the server's park-mod does. Not for a copy without the permission, nor for a player's copy. +void plugins_server_copies_spawn_park_mods() { + TestEnvironment server, player, blind; + const std::string main = R"( + reskate.console.command("parks", "x", function() + local list = reskate.server.parks() + assert(#list == 2 and list[1].name == "street" and list[1].here and list[1].spawned and list[1].objects == 12 and list[1].minutes_left == 5) + assert(list[2].map == "Isle of Grom" and not list[2].here and not list[2].spawned) + assert(reskate.server.park_spawn("street", 30)) + assert(reskate.server.park_spawn("street")) + local ok, why = reskate.server.park_spawn("plaza") + assert(not ok and why:find("belongs on Isle of Grom"), why) + assert(reskate.server.park_remove("street")) + assert(not pcall(reskate.server.park_spawn, "Street"), "a park mod's name, lower case") + assert(not pcall(reskate.server.park_spawn, "street 5; map grom"), "one name and nothing else") + assert(not pcall(reskate.server.park_spawn, "street", 0), "minutes 1 to 1440") + assert(not pcall(reskate.server.park_spawn, "street", 1441), "minutes 1 to 1440") + reskate.log("parks done") + end) + )"; + const auto parks = [&](const char* id, const char* permissions, const char* side) { + return package(id, {{manifest_file, manifest(permissions, side)}, {"plugin/main.lua", main}}); + }; + Host server_host(server, Side::server), player_host(player, Side::client), blind_host(blind, Side::server); + server_host.apply({parks("parks-s", R"("console", "server.parks")", "server")}); + player_host.apply({parks("parks-p", R"("console", "server.parks")", "client")}); + blind_host.apply({parks("parks-b", R"("console", "server")", "server")}); + for (auto* host : {&server_host, &player_host, &blind_host}) host->run_console("parks"); + check(server.logged("[parks-s] parks done"), "A server copy could not use the park mods\n" + server.all()); + check(server.fake.called("park spawn parks-s street 30") && server.fake.called("park spawn parks-s street 0") && + server.fake.called("park spawn parks-s plaza 0") && server.fake.called("park remove parks-s street"), + "The spawns and removal did not reach the server, by the plugin's id"); + check(!server.fake.called("park spawn parks-s Street") && !server.fake.called("park spawn parks-s street 1441"), "A refused call reached the server"); + check(!player.logged("parks done") && player.all().find("server copy") != std::string::npos, + "A player's copy used the server's park mods\n" + player.all()); + check(!blind.logged("parks done") && blind.all().find("server.parks") != std::string::npos, + "A plugin without server.parks used the park mods\n" + blind.all()); +} + // Clips on the skater's body: from the package or recorded, played, replacing a trick. void plugins_animate_the_skater() { TestEnvironment environment; @@ -3623,6 +3672,7 @@ int main() { run(plugins_edit_clips); run(animator_studio_records_and_saves); run(plugins_server_copies_are_the_authority); + run(plugins_server_copies_spawn_park_mods); run(plugins_animate_the_skater); run(plugins_play_their_own_sounds); run(plugins_have_menu_panels); diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index bfa4e26d..7773a4ad 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -1752,9 +1752,9 @@ struct WorldApi { return 1; } // ---- server: what only the session's authority does ---------------------------------------------- - static Plugin& authority(lua_State* L, const char* what) { + static Plugin& authority(lua_State* L, const char* what, const char* permission = "server") { auto& plugin = plugin_of(L); - need(L, plugin, "server", what); + need(L, plugin, permission, what); if (!plugin.services_.authority()) luaL_error(L, "%s is for the plugin's server copy (reskate.side is \"server\": \"side\" \"server\" or \"both\" in reskate-plugin.json)", what); if (!within(plugin.services_.now_ms(), plugin.server_second_, plugin.server_calls_, 20)) luaL_error(L, "more than 20 server calls in a second"); @@ -1795,6 +1795,51 @@ struct WorldApi { auto& plugin = authority(L, "reskate.server.unban"); return result(L, game(plugin).unban(player_of(L, 1))); } + // ---- server.parks: a dedicated server's park mods ----------------------------------------------- + // parks(): {name, map, here (fits the map running now), spawned, objects, minutes_left}[]; + // park_spawn(name, minutes?): for that long (1-1440), or until removed; park_remove(name). + static std::string park_name(lua_State* L, int index) { + const std::string name = luaL_checkstring(L, index); + const bool ok = !name.empty() && name.size() <= 16 && std::all_of(name.begin(), name.end(), [](char c) { + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'; + }); + if (!ok) luaL_argerror(L, index, "a park mod's name: 1 to 16 of a-z 0-9 - _"); + return name; + } + static int server_parks(lua_State* L) { + auto& plugin = authority(L, "reskate.server.parks", "server.parks"); + const auto parks = game(plugin).server_parks(); + lua_createtable(L, static_cast(parks.size()), 0); + for (std::size_t i = 0; i < parks.size(); ++i) { + const auto& p = parks[i]; + lua_createtable(L, 0, 6); + lua_pushlstring(L, p.name.data(), p.name.size()); + lua_setfield(L, -2, "name"); + lua_pushlstring(L, p.map.data(), p.map.size()); + lua_setfield(L, -2, "map"); + lua_pushboolean(L, p.here); + lua_setfield(L, -2, "here"); + lua_pushboolean(L, p.spawned); + lua_setfield(L, -2, "spawned"); + lua_pushinteger(L, static_cast(p.objects)); + lua_setfield(L, -2, "objects"); + lua_pushinteger(L, static_cast(p.minutes_left)); + lua_setfield(L, -2, "minutes_left"); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; + } + static int server_park_spawn(lua_State* L) { + auto& plugin = authority(L, "reskate.server.park_spawn", "server.parks"); + const auto name = park_name(L, 1); + const auto minutes = lua_isnoneornil(L, 2) ? 0 : luaL_checkinteger(L, 2); + if (!lua_isnoneornil(L, 2) && (minutes < 1 || minutes > 1440)) luaL_argerror(L, 2, "minutes, 1 to 1440 (none: until removed)"); + return result(L, game(plugin).spawn_park(plugin.id(), name, static_cast(minutes))); + } + static int server_park_remove(lua_State* L) { + auto& plugin = authority(L, "reskate.server.park_remove", "server.parks"); + return result(L, game(plugin).remove_park(plugin.id(), park_name(L, 1))); + } // A player's permissions: player.permissions is what the server holds for them now, read-only; // permissions:clone() is a copy to change; player:apply_permissions(copy) makes it theirs. static constexpr std::array, 6> permission_fields{{ @@ -2483,7 +2528,8 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { add_functions(L, "outfit", {{"set", outfit_set}, {"clear", outfit_clear}, {"find", outfit_find}, {"slots", outfit_slots}, {"status", outfit_status}}); add_functions(L, "audio", {{"play", audio_play}, {"set", audio_set}, {"stop", audio_stop}, {"playing", audio_playing}}); add_functions(L, "server", {{"kick", server_kick}, {"say", server_say}, {"tell", server_tell}, {"move", server_move}, - {"ban", server_ban}, {"unban", server_unban}}); + {"ban", server_ban}, {"unban", server_unban}, {"parks", server_parks}, {"park_spawn", server_park_spawn}, + {"park_remove", server_park_remove}}); add_functions(L, "throwdowns", {{"state", throwdown_state}, {"host", throwdown_host}, {"cancel", throwdown_cancel}, {"hosting", throwdown_hosting}, {"mode", throwdown_mode}, {"remove_mode", throwdown_remove_mode}}); add_functions(L, "music", {{"current", current}, {"play", play}, {"pause", pause}, {"next", next}, {"previous", previous}, diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index 1388066a..cb712075 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -583,6 +583,18 @@ public: }; virtual std::optional permissions(std::uint64_t) { return std::nullopt; } virtual std::string apply_permissions(std::string_view /*plugin*/, std::uint64_t, const Permissions&) { return unavailable; } + // A dedicated server's park mods (server.parks): those it has set up, whether each fits the map + // running now and is spawned (its objects, and minutes left; 0 until removed). Spawning one (for + // `minutes`, 0 until removed) or removing it goes as the server's own park-mod does. + struct ServerPark { + std::string name, map; + bool here{}, spawned{}; + std::size_t objects{}; + unsigned minutes_left{}; + }; + virtual std::vector server_parks() { return {}; } + virtual std::string spawn_park(std::string_view /*plugin*/, std::string_view /*name*/, unsigned /*minutes*/) { return unavailable; } + virtual std::string remove_park(std::string_view /*plugin*/, std::string_view /*name*/) { return unavailable; } // ---- skater.animation: clips on the local skater's body (Extension/Skater/skater_animation.h) ------ // One plugin plays at a time; none while a throwdown runs. A replacement plays when the game does // `trick` ("kickflip") with that pop ("" any, "low", "mid", "high"); a null clip takes it away. diff --git a/Extension/Plugins/plugin_manifest.cpp b/Extension/Plugins/plugin_manifest.cpp index 914fb012..58376927 100644 --- a/Extension/Plugins/plugin_manifest.cpp +++ b/Extension/Plugins/plugin_manifest.cpp @@ -12,15 +12,16 @@ namespace { // "camera" the free and pad-flown cameras and the Bone Cam (reskate.skater.bone_cam); // "session.read" spectating and the session's world (reskate.session.world); "events.tricks" the // trick events, the trick list's feed and flick diagrams (reskate.tricks); "throwdowns" the -// throwdown kit (modes, documents, the native flag); "variants" choosing which of its own mod's +// throwdown kit (modes, documents, the native flag); "server.parks" a dedicated server's park mods +// (reskate.server.parks, park_spawn, park_remove); "variants" choosing which of its own mod's // variants is in the game (reskate.package.choose). A manifest's "resident" list and "variants" are // the merge's (Engine/Vfs/mod_list.h ResidentAssets, Variants), not the plugin host's. -constexpr std::array permissions{ +constexpr std::array permissions{ "console", "storage", "overlay", "menu", "ui.screen", "input", "hud", "events.tricks", "events.skater", "skater.read", "skater.teleport", "skater.control", "skater.animation", "trainer", "camera", "world.raycast", "world.objects", "world.entities", "world.environment", "world.ai", "travel", "session.read", "session.skaters", "session.nametags", "net.session", "net.http", "voice.control", "chat.read", - "chat.send", "chat.local", "server", "throwdowns", "replay", "music", "progression.read", "settings.read", "audio.ui", "audio", "outfit", "board", "mods.read", + "chat.send", "chat.local", "server", "server.parks", "throwdowns", "replay", "music", "progression.read", "settings.read", "audio.ui", "audio", "outfit", "board", "mods.read", "events.physics", "game.speed", "files", "variants", "settings.write", "room", "capture"}; } diff --git a/Server/Test/server_plugin_tests.cpp b/Server/Test/server_plugin_tests.cpp index 39fc2059..4a682735 100644 --- a/Server/Test/server_plugin_tests.cpp +++ b/Server/Test/server_plugin_tests.cpp @@ -1,7 +1,7 @@ // The dedicated server's plugins (server_plugins.cpp) with the server's host, over a transport // that is not Steam's and simulated players: what a server plugin sees of the players' skaters. // -// dingosdk_server_plugin_tests +// dingosdk_server_plugin_tests #include "Server/server_host.h" #include "Extension/Multiplayer/Net/pose_batch.h" #include "Extension/Multiplayer/Net/pose_codec.h" @@ -164,10 +164,12 @@ struct Player { } }; -int run() { +int run(const std::filesystem::path &fixtures) { const auto root = std::filesystem::temp_directory_path() / "reskate_server_plugin_tests"; std::filesystem::remove_all(root); std::filesystem::create_directories(root / "Mods"); + for (const auto *folder : {"popular skate 2 street park", "mixed park"}) + std::filesystem::copy(fixtures / folder, root / "Mods" / folder, std::filesystem::copy_options::recursive); load_levels(root / "Mods"); // the retail maps ServerConfig config; config.file = root / "server" / "ReSkateServer.json"; @@ -175,6 +177,7 @@ int run() { config.auto_update = false; config.activity_log = false; config.roles = default_roles(); + config.park_mods = {{"street", "popular skate 2 street park", "San Vansterdam", "bam"}, {"plaza", "mixed park", "Isle of Grom", "grom"}}; check(config_error(config).empty(), "The test's config was refused: " + config_error(config)); // A plugin that prints the skaters it sees every 20th tick (a second here), and one without @@ -204,6 +207,10 @@ int run() { local ok, why = pcall(reskate.session.skaters) reskate.console.print(ok and "blind saw them" or "blind refused: " .. tostring(why)) end) + reskate.console.command("blind parks", "park mods without server.parks", function() + local ok, why = pcall(reskate.server.parks) + reskate.console.print(ok and "blind listed parks" or "blind parks refused: " .. tostring(why)) + end) )"); // One that says what the level is, and when it changes. @@ -219,6 +226,31 @@ int run() { end) )"); + // One that lists, spawns and removes park mods when the console asks it to. + write(root / "Plugins" / "parker" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "server.parks"]})"); + write(root / "Plugins" / "parker" / "plugin" / "main.lua", R"( + reskate.console.command("parker", "park mods, for the test", function(args) + local verb, name = args[1], args[2] + if verb == "list" then + for _, p in ipairs(reskate.server.parks()) do + reskate.console.print(string.format("park %s on %s, here %s, spawned %s, %d objects, %d min left", p.name, p.map, + tostring(p.here), tostring(p.spawned), p.objects, p.minutes_left)) + end + elseif verb == "spawn" then + local ok, why = reskate.server.park_spawn(name, tonumber(args[3])) + reskate.console.print("spawn " .. name .. ": " .. (ok and "done" or why)) + elseif verb == "remove" then + local ok, why = reskate.server.park_remove(name) + reskate.console.print("remove " .. name .. ": " .. (ok and "done" or why)) + elseif verb == "bad" then + local named = pcall(reskate.server.park_spawn, "Street; map grom") + local timed = pcall(reskate.server.park_spawn, "street", 5000) + reskate.console.print("bad " .. (named and "name taken" or "name refused") .. ", " .. (timed and "minutes taken" or "minutes refused")) + end + end) + )"); + std::vector log; SteamTransport transport; Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); @@ -288,10 +320,42 @@ int run() { run_for(1100000); check(has(log, "skater 76561198000000001 at 10.0 2.0 -30.0 facing 1.00, stale"), "A skater's age did not grow without poses"); + // Park mods: listed with whether they fit this map; spawned and removed through park-mod's own + // checks, logged by the plugin's name. + const auto plugin_command = [&](std::string_view line) { + log.clear(); + check(host.command(line) == "Ran by a plugin.", std::string(line) + " did not reach the plugin"); + }; + plugin_command("parker list"); + check(has(log, "park street on San Vansterdam, here true, spawned false, 0 objects, 0 min left"), "street is not listed as it is"); + check(has(log, "park plaza on Isle of Grom, here false, spawned false"), "plaza is not listed as on another map"); + plugin_command("parker spawn street 5"); + check(has(log, "spawn street: done"), "The plugin could not spawn street"); + check(has(log, "[parks] street spawned (") && has(log, "5 min) by plugin parker"), "The spawn was not logged as the plugin's"); + plugin_command("parker list"); + check(has(log, "park street on San Vansterdam, here true, spawned true, ") && has(log, " objects, 5 min left"), + "street is not listed as spawned for 5 min"); + plugin_command("parker spawn street"); + check(has(log, "spawn street: street is already spawned."), "A spawned park was spawned again"); + plugin_command("parker spawn plaza"); + check(has(log, "spawn plaza: plaza belongs on Isle of Grom"), "A park for another map was spawned"); + plugin_command("parker spawn nothing"); + check(has(log, "spawn nothing: there is no park mod called \"nothing\""), "A park that is not set up was spawned"); + plugin_command("parker bad"); + check(has(log, "bad name refused, minutes refused"), "A bad park name or time was taken"); + plugin_command("parker remove street"); + check(has(log, "remove street: done") && has(log, "[parks] street removed (by plugin parker)"), "The plugin could not remove street"); + plugin_command("parker remove street"); + check(has(log, "remove street: street is not spawned."), "A park not spawned was removed"); + plugin_command("blind parks"); + check(has(log, "blind parks refused: ") && has(log, "server.parks"), "A plugin without server.parks listed the parks"); + // The level, as players' games name it, and its change heard when the server changes it. const auto *first = find_level(config.map); const auto other = std::find_if(levels().begin(), levels().end(), [&](const auto &l) { return l.asset != first->asset; }); check(first && other != levels().end(), "The test needs two retail maps"); + log.clear(); + run_for(1100000); check(has(log, "level " + first->asset), "level.current() is not the server's map: " + first->asset); host.change_map(other->name); log.clear(); @@ -305,8 +369,12 @@ int run() { } } // namespace -int main() { - const int result = run(); +int main(int argc, char **argv) { + if (argc < 2) { + std::fprintf(stderr, "Usage: dingosdk_server_plugin_tests \n"); + return 2; + } + const int result = run(argv[1]); if (result == 0) std::printf("server plugin tests passed\n"); return result; } diff --git a/Server/server_host.h b/Server/server_host.h index 67507f2d..2ad1843c 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -95,6 +95,19 @@ class Host { std::vector skater_poses() const; void drop(std::uint64_t id, const std::string &reason, const std::string &detail = {}); void change_map(std::string_view map); // a level name, level path or destination + // The park mods set up (maps.park_mods), for the server's plugins: which fit the map running + // now, and which are spawned, with how many objects and minutes left (0: until removed). + struct ParkStatus { + std::string name, map; + bool here{}, spawned{}; + std::size_t objects{}; + unsigned minutes_left{}; + }; + std::vector park_status() const; + // One spawned (for `minutes`, 0 until removed) or removed for a server plugin, as "park-mod add" + // and "remove" do, with their checks and limits and their "[parks]" lines logged "by plugin + // ": "" when it is done, else why not. + std::string plugin_park(std::string_view plugin, bool spawn, std::string_view name, unsigned minutes); // A plugin message from the server's plugins (server_plugins.h) to one player, or everyone (0). void send_plugin_message(std::uint64_t to, const std::vector &bytes); @@ -538,6 +551,7 @@ class Host { ObjectState park_objects_; // never reset: its revisions only grow, so no game holds a newer one std::uint64_t park_object_ids_{}; bool by_vote_{}; // a passed custom vote's command is running (as the console) + std::string by_plugin_; // a server plugin's park-mod is running (plugin_park): "plugin " std::string park_mod_command(std::string_view argument, std::uint64_t admin); void publish_parks(); void remove_parks(std::string_view reason); // all of them, with the reason for the log diff --git a/Server/server_parks.cpp b/Server/server_parks.cpp index 6c73390d..75f03943 100644 --- a/Server/server_parks.cpp +++ b/Server/server_parks.cpp @@ -167,6 +167,42 @@ void Host::tick_parks() { publish_parks(); } +std::vector Host::park_status() const { + std::vector list; + for (const auto &park : config_.park_mods) { + ParkStatus status{park.name, park.map, same_map(park.map)}; + const auto up = std::find_if(parks_spawned_.begin(), parks_spawned_.end(), [&](const auto &s) { return s.name == park.name; }); + if (up != parks_spawned_.end()) { + status.spawned = true; + status.objects = up->objects.size(); + if (up->expires) status.minutes_left = static_cast((up->expires - std::min(up->expires, now_) + 59999999) / 60000000); + } + list.push_back(std::move(status)); + } + return list; +} + +std::string Host::plugin_park(std::string_view plugin, bool spawn, std::string_view name, unsigned minutes) { + const auto up = [&] { + return std::any_of(parks_spawned_.begin(), parks_spawned_.end(), [&](const auto &park) { return park.name == name; }); + }; + const bool was = up(); + by_plugin_ = "plugin " + std::string(plugin); + struct Reset { std::string &by; ~Reset() { by.clear(); } } reset{by_plugin_}; + const auto answer = park_mod_command(std::string(spawn ? "add " : "remove ") + std::string(name) + + (spawn && minutes ? " " + std::to_string(minutes) : std::string{}), 0); + // The console logs what park-mod answers; for a plugin it is logged here. + for (std::size_t at = 0; at < answer.size();) { + const auto end = std::min(answer.find('\n', at), answer.size()); + log_(answer.substr(at, end - at)); + at = end + 1; + } + if (was != spawn && up() == spawn) return {}; + // "[parks] could not be spawned: why", or a usage line + const auto colon = answer.find(": "); + return answer.starts_with("[parks] ") && colon != std::string::npos ? answer.substr(colon + 2) : answer; +} + // "park-mod list | add [...] [minutes] | remove [...] | clear | // reload [...] | config add | | [| ] | config remove ". // What it did is a "[parks]" line: the console and a vote log the reply, so it is logged here only @@ -175,7 +211,7 @@ std::string Host::park_mod_command(std::string_view argument, std::uint64_t admi const auto [action, rest] = split(argument); const auto verb = lower(action); const auto *by = admin ? find(admin) : nullptr; - const std::string who = by_vote_ ? "a vote" : by ? guest_name(*by) : admin ? std::to_string(admin) : "console"; + const std::string who = !by_plugin_.empty() ? by_plugin_ : by_vote_ ? "a vote" : by ? guest_name(*by) : admin ? std::to_string(admin) : "console"; const auto done = [&](const std::vector &lines) { std::string text; for (const auto &line : lines) { diff --git a/Server/server_plugins.cpp b/Server/server_plugins.cpp index 3f4685ed..5eeb453e 100644 --- a/Server/server_plugins.cpp +++ b/Server/server_plugins.cpp @@ -80,6 +80,15 @@ class ServerGame final : public Game { host_.set_locks(player, locks, "plugin " + std::string(plugin)); return {}; } + std::vector server_parks() override { + std::vector result; + for (auto &p : host_.park_status()) result.push_back({std::move(p.name), std::move(p.map), p.here, p.spawned, p.objects, p.minutes_left}); + return result; + } + std::string spawn_park(std::string_view plugin, std::string_view name, unsigned minutes) override { + return host_.plugin_park(plugin, true, name, minutes); + } + std::string remove_park(std::string_view plugin, std::string_view name) override { return host_.plugin_park(plugin, false, name, 0); } // The skaters of the players in the world, as their latest poses put them. The server has // no skater of its own, so this is every player's. std::vector peer_skaters() override { From 8e79fd62f5ffbe4e0eb3cce9e381b6696af3d41a Mon Sep 17 00:00:00 2001 From: snubroot <214535306+snubroot@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:02:01 -0400 Subject: [PATCH 4/8] Customization: item tabs over 1,024 items load instead of showing blank warning triangles The item browser reads grids up to 4,096 rows, but the native list writer it publishes them through refused more than 1,024, so a bigger tab failed with "Native menu list is invalid." every pass. The writer now takes up to 4,096. --- Extension/UI/NativeMenu/native_menu_data.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Extension/UI/NativeMenu/native_menu_data.cpp b/Extension/UI/NativeMenu/native_menu_data.cpp index bdf81107..28eaf008 100644 --- a/Extension/UI/NativeMenu/native_menu_data.cpp +++ b/Extension/UI/NativeMenu/native_menu_data.cpp @@ -228,7 +228,7 @@ std::vector Context::array(Value value, unsigned maximum, unsigned& c return result; } void Context::array(Value target, std::span bytes, unsigned count) const { - require(kind(target.type) == 4 && count <= 1024, "Native menu list is invalid."); + require(kind(target.type) == 4 && count <= 4096, "Native menu list is invalid."); const auto stride = size(read
(read
(target.type) + 0x30)); require(bytes.size() == static_cast(count) * stride, "Native menu list element size differs."); address(target); From 118c489fdb9efef7e834c49f9c19092e992ef028 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Thu, 22 Oct 2026 18:46:35 -0500 Subject: [PATCH 5/8] Plugins: what the Filmer needs as a plugin: carry, who sees a pose, a step in the camera cycle PR 343's Filmer becomes a Luau plugin mod (kept in contrib/plugins/filmer) on general APIs instead of its own C++: - reskate.skater.carry: something held out in one or both hands and aimed, the body crouched (feet kept) and hunched over it, turned on the spot after it (Extension/Skater/carry.cpp, from the PR's filmer_hold.cpp, with tests) - shown = "everyone" | "others" | "me" on skater.pose, reach and carry: the pose sent to the other players and the one drawn here are split when asked - reskate.camera.cycle and the camera_cycle event: a plugin's camera as the last step of the camera height button's cycle, after first person - reskate.session.nametags(false) holds the names over the other players off - board.hide(bone, {others = true}) hides it in the board the others are sent too (a sent-pose filter) - outfit.set takes hand wear slots; input.bind takes an action with no key of its own --- CMakeLists.txt | 6 + .../Skater/Test/first_person_cycle_tests.cpp | 37 ++ Engine/Game/Skater/first_person_cycle.h | 39 +- Extension/Multiplayer/Hud/custom_nametags.h | 4 + .../Multiplayer/Remote/native_skater.cpp | 6 + Extension/Multiplayer/Remote/native_skater.h | 5 + .../Remote/native_skater_internal.h | 1 + Extension/Multiplayer/Session/session.cpp | 5 +- Extension/Plugins/Test/plugin_tests.cpp | 165 ++++++- Extension/Plugins/plugin_api.cpp | 4 +- Extension/Plugins/plugin_api_world.cpp | 160 +++++- Extension/Plugins/plugin_game.h | 43 +- Extension/Plugins/plugin_game_runtime.cpp | 92 +++- Extension/Plugins/plugin_host.cpp | 5 + Extension/Skater/Test/carry_tests.cpp | 178 +++++++ Extension/Skater/board_pose.cpp | 34 +- Extension/Skater/board_pose.h | 4 +- Extension/Skater/carry.cpp | 465 ++++++++++++++++++ Extension/Skater/carry.h | 82 +++ Extension/Skater/client_debug.cpp | 14 +- Extension/Skater/outfit_overlay.cpp | 4 +- Extension/Skater/outfit_overlay.h | 5 +- Extension/Skater/skater_animation.cpp | 159 ++++-- Extension/Skater/skater_animation.h | 20 +- cmake/Runtime.cmake | 1 + 25 files changed, 1449 insertions(+), 89 deletions(-) create mode 100644 Extension/Skater/Test/carry_tests.cpp create mode 100644 Extension/Skater/carry.cpp create mode 100644 Extension/Skater/carry.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b357227f..333517cb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -406,6 +406,12 @@ if(DINGOSDK_BUILD_ANIMATION_STUDIO_TESTS) target_include_directories(dingosdk_arm_reach_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_arm_reach_tests PRIVATE dingosdk_json) add_test(NAME arm_reach COMMAND dingosdk_arm_reach_tests) + # Something carried (reskate.skater.carry): held out, aimed, both hands, crouched with the feet kept. + add_executable(dingosdk_carry_tests Extension/Skater/Test/carry_tests.cpp Extension/Skater/carry.cpp Extension/Skater/arm_reach.cpp + Extension/AnimationStudio/anim_core.cpp) + target_include_directories(dingosdk_carry_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_link_libraries(dingosdk_carry_tests PRIVATE dingosdk_json) + add_test(NAME carry COMMAND dingosdk_carry_tests) endif() option(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS "Build replay keyframe smoothing regression tests" OFF) if(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS) diff --git a/Engine/Game/Skater/Test/first_person_cycle_tests.cpp b/Engine/Game/Skater/Test/first_person_cycle_tests.cpp index ea2f4748..52c9c077 100644 --- a/Engine/Game/Skater/Test/first_person_cycle_tests.cpp +++ b/Engine/Game/Skater/Test/first_person_cycle_tests.cpp @@ -58,12 +58,49 @@ void presses() { expect(high && !first_person, "the third press of each round returns to the high camera"); } } + +// With a plugin's camera in the cycle: high -> low -> first person -> the plugin's camera -> high, and +// without first person (or on foot, where it cannot start) high -> low -> the plugin's camera -> high. +void plugin() { + for (const bool with_first_person : {true, false}) { + bool high = true, first_person = false, on = false; + const auto press = [&] { + CycleState state{with_first_person, first_person, !first_person && !on, true, on, !on}; + const auto write = cycle_height_write(state, !high); + high = write.high; + if (write.step == CycleStep::enter) first_person = true; + if (write.step == CycleStep::leave) first_person = false; + if (write.plugin == CycleStep::enter) on = true; + if (write.plugin == CycleStep::leave) on = false; + }; + for (int round = 0; round < 3; ++round) { + expect(high && !first_person && !on, "each round starts on the high camera"); + press(); + expect(!high && !first_person && !on, "first press: low camera"); + if (with_first_person) { + press(); + expect(!high && first_person && !on, "second press: first person"); + } + press(); + expect(!high && !first_person && on, "then the plugin's camera, the low camera behind it"); + press(); + } + expect(high && !first_person && !on, "out of the plugin's camera: the high camera"); + } + // No plugin in the cycle: the first-person cycle as before. + expect(same(cycle_height_write({true, true, false, false, false, true}, true), true, CycleStep::leave), + "without a plugin's camera, first person -> high"); + // Turned on by the plugin itself over the high camera: the button still takes it out to high. + const auto out = cycle_height_write({false, false, true, true, true, false}, false); + expect(out.high && out.plugin == CycleStep::leave, "a plugin's camera on by itself: the button leaves it for high"); +} } // namespace int main() { off(); steps(); presses(); + plugin(); if (failures) { std::printf("%d first-person cycle test(s) failed\n", failures); return 1; diff --git a/Engine/Game/Skater/first_person_cycle.h b/Engine/Game/Skater/first_person_cycle.h index 8ee30393..00563bf1 100644 --- a/Engine/Game/Skater/first_person_cycle.h +++ b/Engine/Game/Skater/first_person_cycle.h @@ -1,4 +1,5 @@ #pragma once +#include #include namespace dingosdk::first_person { @@ -7,20 +8,46 @@ namespace dingosdk::first_person { // (true is the high camera). With the option on, ReSkate turns that into high -> low -> first // person -> high by changing the value written: the step into first person keeps the low camera // behind it, and the step out of first person always lands on the high camera. +// +// A plugin's camera can be in the cycle too, last (reskate.camera.cycle): high -> low -> first +// person -> the plugin's camera -> high (without first person in it, or where first person cannot +// start: high -> low -> the plugin's camera -> high). Its step keeps the low camera behind it as well. enum class CycleStep : std::uint8_t { none, enter, leave }; struct CycleState { bool enabled{}; // the option is on and the local skater is in control bool first_person{}; // first person has the camera bool can_enter{}; // first person can take the camera now: on the board, no Freecam + bool plugin_enabled{}; // a plugin's camera is in the cycle now + bool plugin{}; // that camera is on + bool plugin_can_enter{}; // that camera is off and can start }; struct CycleWrite { - bool high{}; // the height the game keeps - CycleStep step{}; // what first person does + bool high{}; // the height the game keeps + CycleStep step{}; // what first person does + CycleStep plugin{}; // what the plugin's camera does }; constexpr CycleWrite cycle_height_write(const CycleState& state, bool high) noexcept { - if (!state.enabled) return {high, CycleStep::none}; - if (state.first_person) return {true, CycleStep::leave}; - if (high && state.can_enter) return {false, CycleStep::enter}; - return {high, CycleStep::none}; + // The plugin's camera is last: out of it, the high camera. + if (state.plugin_enabled && state.plugin) return {true, CycleStep::none, CycleStep::leave}; + if (state.enabled && state.first_person) { + if (state.plugin_enabled && state.plugin_can_enter) return {false, CycleStep::leave, CycleStep::enter}; + return {true, CycleStep::leave, CycleStep::none}; + } + if (high) { // the game steps low -> high: first person, else the plugin's camera, comes in between + if (state.enabled && state.can_enter) return {false, CycleStep::enter, CycleStep::none}; + if (state.plugin_enabled && state.plugin_can_enter) return {false, CycleStep::none, CycleStep::enter}; + } + return {high, CycleStep::none, CycleStep::none}; } + +// The plugin's half of the link (the plugin runtime keeps it). Each tick the plugin in the cycle has +// its flags published; the height write (on the thread that handles the button) leaves the step it +// asks of the plugin's camera in `request`, taken on the plugin runtime's next tick. +enum PluginFlag : std::uint8_t { plugin_flag_enabled = 1, plugin_flag_on = 2, plugin_flag_can_enter = 4 }; +struct PluginLink { + std::atomic flags{}; + std::atomic request{}; // a CycleStep + std::atomic published{}; // GetTickCount64 when the flags were published +}; +inline PluginLink plugin_link{}; } diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index 578f2efd..d699540c 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -2,6 +2,7 @@ #include "Extension/UI/Overlay/overlay.h" #include "Extension/Multiplayer/developer_identity.h" #include +#include #include #include #include @@ -64,6 +65,9 @@ void publish_custom_nametags(std::uintptr_t base, std::vector pla bool show_bubbles, float bubble_distance, float name_distance = 120.f, bool dots = true) noexcept; void set_custom_nametags_enabled(bool enabled) noexcept; +// Held off for now (a plugin's reskate.session.nametags(false), filming): no names drawn over the +// players while it lasts, whatever the nametag setting is; chat bubbles still show. +inline std::atomic nametags_held_off{false}; bool custom_nametags_enabled() noexcept; // The overlay's feed (any thread): nothing while off, stale, or while the game hides nametags. overlay::Nametags custom_nametags(); diff --git a/Extension/Multiplayer/Remote/native_skater.cpp b/Extension/Multiplayer/Remote/native_skater.cpp index 5f298dee..abe724f2 100644 --- a/Extension/Multiplayer/Remote/native_skater.cpp +++ b/Extension/Multiplayer/Remote/native_skater.cpp @@ -556,6 +556,9 @@ NativeFrame capture_local(std::uintptr_t base, std::uintptr_t client, bool captu cache = {base, client, frame.context, player, frame.entity, frame.parent, component, frame.board_entity}; frame.ready = true; + // The pose as it is sent, after anyone who changes only what is drawn of the board has had it. + if (capture_pose) + if (const auto filter = shared().sent_pose_filter.load(std::memory_order_acquire)) filter(component, frame.pose); } catch (const std::exception &e) { cache = {}; frame.detail = e.what(); @@ -612,6 +615,9 @@ void set_render_pose_listener(RenderPosePublished listener) noexcept { void set_animation_evaluated_listener(AnimationEvaluated listener) noexcept { shared().evaluated_listener.store(listener, std::memory_order_release); } +void set_local_sent_pose_filter(LocalSentPoseFilter filter) noexcept { + shared().sent_pose_filter.store(filter, std::memory_order_release); +} void set_local_pose_filter(LocalPoseFilter filter) noexcept { shared().pose_filter.store(filter, std::memory_order_release); } diff --git a/Extension/Multiplayer/Remote/native_skater.h b/Extension/Multiplayer/Remote/native_skater.h index 868c5753..88ea2080 100644 --- a/Extension/Multiplayer/Remote/native_skater.h +++ b/Extension/Multiplayer/Remote/native_skater.h @@ -43,6 +43,11 @@ void set_animation_evaluated_listener(AnimationEvaluated listener) noexcept; // what other players receive of the local skater. using LocalPoseFilter = void (*)(std::uintptr_t component, std::vector &skater) noexcept; void set_local_pose_filter(LocalPoseFilter filter) noexcept; +// Then, with the whole pose capture_local returns (the skater and the board), so a change to what is +// drawn of the board (Extension/Skater/board_pose.h, a bone hidden from everyone) is in what the +// others see too. One listener; nullptr for none. Client thread. +using LocalSentPoseFilter = void (*)(std::uintptr_t component, Pose &pose) noexcept; +void set_local_sent_pose_filter(LocalSentPoseFilter filter) noexcept; // A pose override of the local skater (Extension/Skater/skater_animation.cpp): called after the // listener above, so it sees (and may change) the pose first person left. void set_pose_override_listener(AnimationEvaluated listener) noexcept; diff --git a/Extension/Multiplayer/Remote/native_skater_internal.h b/Extension/Multiplayer/Remote/native_skater_internal.h index 85d9476d..0aedebc5 100644 --- a/Extension/Multiplayer/Remote/native_skater_internal.h +++ b/Extension/Multiplayer/Remote/native_skater_internal.h @@ -58,6 +58,7 @@ struct HookState { std::atomic destroyed_listener{}; std::atomic evaluated_listener{}; std::atomic pose_filter{}; + std::atomic sent_pose_filter{}; // A pose override of the local skater (Extension/Skater/skater_animation.cpp), after the one above. std::atomic override_listener{}; std::atomic override_render_listener{}; diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 6ab4bf37..93300b1c 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -485,7 +485,8 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui // each frame. std::vector nametags; const bool bubbles = s.chat_bubbles; - const bool labels = s.nametags || bubbles; + const bool names = s.nametags && !nametags_held_off.load(std::memory_order_relaxed); + const bool labels = s.nametags || bubbles; // (published even while names are held off, so none linger) if (labels) refresh_friends(s); // The newest chat lines from `sender` still inside the bubble duration, oldest first, // filtered the same way the chat panel filters them, each with the opacity it has left @@ -821,7 +822,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui if (labels) publish_custom_nametags(s.base, std::move(nametags), local.ready ? std::optional(local.pose.root.position) : std::nullopt, - s.nametags, bubbles, s.chat_bubbles_distance, s.nametag_distance, + names, bubbles, s.chat_bubbles_distance, s.nametag_distance, s.nametag_dots); } } // namespace diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 203b3e76..701b9b3b 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -45,6 +45,13 @@ struct FakeGame final : Game { return {}; } void outfit_clear(std::string_view plugin, std::string_view slot) override { calls.push_back(std::format("outfit clear {} {}", plugin, slot)); } + std::string camera_cycle(std::string_view plugin, CycleCamera place) override { + calls.push_back(std::format("cycle {} {}", plugin, place == CycleCamera::out ? "out" : place == CycleCamera::ready ? "ready" : "active")); + return {}; + } + std::vector cycle_news; + std::vector take_camera_cycle() override { return std::exchange(cycle_news, {}); } + void hide_nametags(std::string_view plugin, bool hidden) override { calls.push_back(std::format("nametags {} {}", plugin, hidden ? "off" : "on")); } std::set> installed; // items found by their whole ending, as Pack/ std::string outfit_find(std::string_view ending) override { if (installed.contains(ending)) return "Pack/" + std::string(ending); @@ -60,7 +67,10 @@ struct FakeGame final : Game { calls.push_back(std::format("board {} {} {:.1f} {}", plugin, bone, value.position[1], world ? "world" : "local")); return {}; } - std::string board_hide(std::string_view plugin, int bone) override { calls.push_back(std::format("board hide {} {}", plugin, bone)); return {}; } + std::string board_hide(std::string_view plugin, int bone, bool everyone) override { + calls.push_back(std::format("board hide {} {}{}", plugin, bone, everyone ? " everyone" : "")); + return {}; + } void board_park(std::string_view plugin, std::vector bones) override { calls.push_back(std::format("board park {} {}", plugin, bones.size())); } // The general services a game layer in Lua reads and drives (plugin_api_services.cpp). std::optional telemetry; @@ -150,8 +160,18 @@ struct FakeGame final : Game { return {}; } std::optional mouse_motion(std::string_view) override { return MouseMotion{3, -2, 1}; } - std::string skater_pose(std::string_view plugin, const std::vector& offsets, float weight) override { + static const char* shown_name(Shown shown) { return shown == Shown::others ? "others" : shown == Shown::me ? "me" : "everyone"; } + std::string skater_carry(std::string_view plugin, std::optional c) override { + calls.push_back(c ? std::format("carry {} {}{} {:.1f} {:.1f} {:.1f} front {:.2f} grip {} w {:.2f} crouch {:.2f} hunch {:.2f} turn {:.2f} step {:.2f}{}{} {}", + plugin, c->left ? "left" : "right", c->both ? " both" : "", c->front[0], c->front[1], c->front[2], + c->front_from_grip[2], c->worn.has_value(), c->weight, c->crouch, c->hunch, c->turn, c->step, + c->still ? " still" : "", c->push_straightens ? " push" : "", shown_name(c->shown)) + : std::format("carry {} none", plugin)); + return {}; + } + std::string skater_pose(std::string_view plugin, const std::vector& offsets, float weight, Shown shown) override { std::string text = std::format("pose {} {:.2f}", plugin, weight); + if (shown != Shown::everyone) text += std::format(" {}", shown_name(shown)); auto sorted = offsets; std::sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.joint < b.joint; }); for (const auto& o : sorted) text += std::format(" {}:{:.2f},{:.2f},{:.2f},{:.2f}x{:.2f}", o.joint, o.rotation[0], o.rotation[1], o.rotation[2], o.rotation[3], o.scale); @@ -181,7 +201,8 @@ struct FakeGame final : Game { } std::string skater_reach(std::string_view plugin, std::optional r) override { calls.push_back(r ? std::format("reach {} {} {:.1f} {:.1f} {:.1f} {} {:.2f}", plugin, r->left ? "left" : "right", r->at[0], r->at[1], r->at[2], - r->rotation.has_value(), r->weight) + r->rotation.has_value(), r->weight) + + (r->shown != Shown::everyone ? std::format(" {}", shown_name(r->shown)) : std::string{}) : std::format("reach {} none", plugin)); return {}; } @@ -1357,6 +1378,71 @@ void plugins_reach_cruise_and_read_the_mouse() { "skater.animation, skater.read, skater.control, input and overlay are needed\n" + environment.all()); } +// What the Filmer's port needs besides: something carried (seen by the others only), a pose only you +// see, a step in the camera height cycle and its event, the nametags held off, the board hidden from +// everyone, and a camcorder in the hand wear slot. +void plugins_carry_and_take_a_step_in_the_camera_cycle() { + TestEnvironment environment; + auto& game = environment.fake; + Host host(environment, Side::client); + const std::string main = R"( + local steps = {} + reskate.on("camera_cycle", function(step) table.insert(steps, step) end) + assert(reskate.skater.carry({at = vector.create(1, 2, 3), rotation = {0, 1, 0, 0}, both = true, front = vector.create(0, -0.143, -0.248), + grip = {rotation = {0.5, -0.5, 0.5, 0.5}, offset = vector.create(0.12, 0.04, -0.03)}, crouch = 0.22, hunch = 1.0, turn = 0.5, + step = 0.5, step_phase = 1.25, still = true, push_straightens = true, weight = 0.75, shown = "others"})) + assert(reskate.skater.carry({hand = "left", at = vector.create(0, 1, 0), rotation = {0, 0, 0, 1}})) + assert(reskate.skater.carry(nil)) + assert(not pcall(reskate.skater.carry, {at = vector.create(0, 1, 0)}), "a carry needs a rotation") + assert(not pcall(reskate.skater.carry, {at = vector.create(0, 1, 0), rotation = {0, 0, 0, 1}, crouch = 1}), "a crouch is 0 to 0.6 m") + assert(not pcall(reskate.skater.carry, {at = vector.create(0, 1, 0), rotation = {0, 0, 0, 1}, shown = "them"}), "shown is everyone, others or me") + assert(reskate.skater.pose({[48] = {scale = 0}}, 1, {shown = "me"})) + assert(reskate.skater.reach({at = vector.create(1, 2, 3), shown = "others"})) + assert(reskate.camera.cycle("ready") and reskate.camera.cycle("active") and reskate.camera.cycle(nil)) + assert(not pcall(reskate.camera.cycle, "on"), "ready or active") + reskate.session.nametags(false) + reskate.session.nametags(true) + assert(reskate.board.hide(2, {others = true})) + assert(reskate.outfit.set("handwear_l", "Own_HandwearGlove_Gen_GloveFingerless_00001")) + local ok, why = reskate.outfit.set("shoes", "Own_Shoes_00001") + assert(not ok and why == "only tattoo, moles, eyewear and hand wear slots", why) + assert(reskate.input.bind("toggle") and reskate.input.bind("slap", "Pad.LB"), "an action with no key of its own, and one with") + reskate.on("tick", function() + if #steps > 0 then reskate.log("steps " .. table.concat(steps, " ")) steps = {} end + end) + reskate.log("carried") + )"; + host.apply({package("filmer", {{manifest_file, manifest(R"("skater.animation", "camera", "session.nametags", "board", "outfit", "input")")}, + {"plugin/main.lua", main}})}); + ticks(host, environment, 1); + running(host, "filmer", environment); + check(environment.logged("[filmer] carried"), "The calls work\n" + environment.all()); + check(game.called("carry filmer right both 1.0 2.0 3.0 front -0.25 grip true w 0.75 crouch 0.22 hunch 1.00 turn 0.50 step 0.50 still push others") && + game.called("carry filmer left 0.0 1.0 0.0 front 0.00 grip false w 1.00 crouch 0.00 hunch 0.00 turn 0.00 step 0.00 everyone") && + game.called("carry filmer none"), + "Carried in both hands for the others, in one, and let go\n" + environment.all()); + check(game.called("pose filmer 1.00 me 48:0.00,0.00,0.00,1.00x0.00") && game.called("reach filmer right 1.0 2.0 3.0 false 1.00 others"), + "A pose only you see, a reach only the others see\n" + environment.all()); + check(game.called("cycle filmer ready") && game.called("cycle filmer active") && game.called("cycle filmer out"), "In the cycle, on, and out"); + check(game.called("nametags filmer off") && game.called("nametags filmer on"), "The nametags held off and given back"); + check(game.called("board hide filmer 2 everyone"), "The board hidden from everyone"); + check(game.called("outfit filmer handwear_l Own_HandwearGlove_Gen_GloveFingerless_00001 0 false"), "A camcorder in the hand wear slot"); + check(game.binds.contains("filmer/toggle") && game.binds["filmer/toggle"] == 0 && game.binds["filmer/slap"] == combo_of("Pad.LB"), + "An action the player puts on a key themselves"); + // The button's steps reach the plugin in the cycle, and nobody else. + game.cycle_news = {{"filmer", true}, {"someone", false}, {"filmer", false}}; + ticks(host, environment, 2); + check(environment.logged("[filmer] steps enter leave"), "The camera cycle's steps as events\n" + environment.all()); + // Each needs its permission. + host.apply({package("sneaky", {{manifest_file, manifest()}, {"plugin/main.lua", R"( + reskate.log(tostring(pcall(reskate.skater.carry, {at = vector.create(0, 0, 0), rotation = {0, 0, 0, 1}})) .. " " .. + tostring(pcall(reskate.camera.cycle, "ready")) .. " " .. tostring(pcall(reskate.session.nametags, false)) .. " " .. + tostring(pcall(reskate.on, "camera_cycle", function() end))) + )"}})}); + ticks(host, environment, 1); + check(environment.logged("[sneaky] false false false false"), "skater.animation, camera and session.nametags are needed\n" + environment.all()); +} + // Reading a skater's joints, and the physics tuning read and set. void plugins_read_joints_and_tune_physics() { TestEnvironment environment; @@ -3227,6 +3313,77 @@ void the_throwdowns_page_lists_the_stock_cards_and_plugin_modes() { check(again.revision() > before && again.definitions().size() == 3 && again.definitions()[0].id == "native.skate", "S.K.A.T.E. is back when it goes"); } +// The Filmer (contrib/plugins/filmer): an AI filmer follows a skater riding a carving line, then you film +// someone, with your skater carrying the camcorder for the others; all within the 2 ms a tick a plugin has. +void filmer_follows_and_films() { + TestEnvironment environment; + auto& game = environment.fake; + const std::string plugin = "filmer"; + Host host(environment, Side::client); + host.apply({contributed(plugin)}); + running(host, plugin, environment); + SkaterTelemetry t; + t.skater = true; + t.physics_state = 100; + t.speed = 6; + game.telemetry = t; + environment.local_skater = SkaterState{{0, 0.9f, 0}, 0, 6, 0, false, 0}; + host.set_level("sanvan"); + const auto ride = [&](int count) { + for (int i = 0; i < count; ++i) { + auto& skater = *environment.local_skater; + static int step = 0; + skater.heading = 0.4f * std::sin(static_cast(++step) * 0.012f); + skater.position[0] += std::sin(skater.heading) * 6.0f * 0.0167f; + skater.position[2] += std::cos(skater.heading) * 6.0f * 0.0167f; + game.telemetry->position = skater.position; + game.telemetry->heading = skater.heading * 57.29578f; + ticks(host, environment, 1); + } + }; + check(host.run_console("filmer mode follow"), "filmer mode follow"); + ride(900); + running(host, plugin, environment); + check(!environment.logged(".lua:"), "The Filmer follows without an error\n" + environment.all()); + check(game.called("camera filmer"), "It shows its camera\n" + environment.all()); + check(game.called("look filmer 1.00 100"), "with the VX look over the fisheye\n" + environment.all()); + check(game.called("cycle filmer active"), "in the camera height cycle, on"); + { + // Where the AI filmer's lens is: behind the skater (never in front), a filmer's distance off, low. + float x{}, y{}, z{}; + bool found = false; + for (auto it = game.calls.rbegin(); it != game.calls.rend() && !found; ++it) + if (it->starts_with("camera filmer ")) found = static_cast(std::istringstream(it->substr(14)) >> x >> y >> z); + const auto& at = environment.local_skater->position; + const float heading = environment.local_skater->heading; + const float dx = x - at[0], dz = z - at[2]; + const float away = std::sqrt(dx * dx + dz * dz), along = dx * std::sin(heading) + dz * std::cos(heading); + std::cout << "Filmer follow: the lens " << away << " m from the skater, " << along << " m along, " << y << " m up\n"; + check(found && away > 0.8f && away < 6.0f && along < 0.0f && y > 0.0f && y < 1.5f, + "The AI filmer rides behind the skater, a filmer's distance off, low\n" + environment.all()); + } + const auto follow_ms = status_of(host, plugin).tick_ms; + std::cout << "Filmer follow: " << follow_ms << " ms a tick (its budget is 2 ms)\n"; + check(follow_ms < 2.0, "The Filmer follows within its 2 ms a tick"); + // You film the skater ahead of you, holding the camcorder out for the others to see. + environment.lines.clear(); + check(host.run_console("filmer mode film"), "filmer mode film"); + ride(600); + running(host, plugin, environment); + check(!environment.logged(".lua:"), "You film without an error\n" + environment.all()); + check(game.called("carry filmer right both") && game.called(" push others") && game.called("hide skater filmer on") && + game.called("board hide filmer 2"), + "Your skater carries the camcorder for the others, and you, looking through it, do not see your skater or board\n" + environment.all()); + check(game.called("hide filmer false false false stick true false true"), "The right stick and its click are the camera's"); + const auto film_ms = status_of(host, plugin).tick_ms; + std::cout << "Filmer film: " << film_ms << " ms a tick\n"; + check(film_ms < 2.0, "You film within the 2 ms a tick"); + check(host.run_console("filmer slap") && environment.printed("Clip 1 marked"), "A slap marks a clip\n" + printed_lines(environment)); + check(host.run_console("filmer mode off"), "filmer mode off"); + ticks(host, environment, 30); + check(game.called("carry filmer none") && game.called("camera release filmer"), "Off: the camcorder put down and the camera given back\n" + environment.all()); +} + void smokey_throwdowns_register_and_play_one_up() { namespace kit = dingosdk::throwdowns::kit; TestEnvironment environment; @@ -3665,6 +3822,7 @@ int main() { run(plugins_place_world_entities); run(plugins_have_what_a_prop_hunt_needs); run(plugins_reach_cruise_and_read_the_mouse); + run(plugins_carry_and_take_a_step_in_the_camera_cycle); run(plugins_read_joints_and_tune_physics); run(plugins_type_and_use_the_room); run(plugins_touch_hide_and_ray_at_players); @@ -3697,6 +3855,7 @@ int main() { run(hall_of_meat_scores_a_bail); run(grind_exits_pick_the_side); run(smokey_throwdowns_register_and_play_one_up); + run(filmer_follows_and_films); run(plugins_drive_the_throwdown_kit); run(fit_check_turns_the_board_and_saves_it); run(item_tabs_keep_tabs_and_wear_on_top); diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index 609d5398..b2d618ab 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -17,7 +17,7 @@ namespace { struct Event { std::string_view name, permission; }; -constexpr std::array events{{ +constexpr std::array events{{ {"load", {}}, {"unload", {}}, {"tick", {}}, {"level_loaded", {}}, {"level_unloading", {}}, {"trick_started", "events.tricks"}, {"trick_landed", "events.tricks"}, {"line_broken", "events.tricks"}, {"draw", "overlay"}, {"player_joined", "session.read"}, {"player_left", "session.read"}, {"message", "net.session"}, @@ -25,7 +25,7 @@ constexpr std::array events{{ {"throwdown", "throwdowns"}, {"action", "input"}, {"screen_closed", "ui.screen"}, {"board_landed", "events.skater"}, {"body_hit", "events.skater"}, {"bail_end", "events.skater"}, {"physics_step", "events.physics"}, {"launch", "events.skater"}, {"trick_notes", "events.tricks"}, - {"throwdown_ui", "throwdowns"}, {"touch", "session.skaters"}, {"touch_end", "session.skaters"}, + {"throwdown_ui", "throwdowns"}, {"touch", "session.skaters"}, {"touch_end", "session.skaters"}, {"camera_cycle", "camera"}, }}; constexpr std::size_t maximum_key = 128; constexpr std::size_t maximum_command = 48; diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 7773a4ad..5af832ef 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -225,6 +225,20 @@ struct WorldApi { need(L, plugin, "camera", "reskate.camera.held"); return result(L, game(plugin).camera_status(plugin.id())); } + // camera.cycle(state): the plugin's camera as a step of the camera height button's cycle: nil (out + // of it), "ready" (in it, off) or "active" (in it, on). For 300 ms, so every tick. + static int camera_cycle(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "camera", "reskate.camera.cycle"); + auto state = Game::CycleCamera::out; + if (!lua_isnoneornil(L, 1)) { + const std::string_view s = luaL_checkstring(L, 1); + if (s == "ready") state = Game::CycleCamera::ready; + else if (s == "active") state = Game::CycleCamera::active; + else luaL_argerror(L, 1, "a camera's place in the cycle is nil, \"ready\" or \"active\""); + } + return result(L, game(plugin).camera_cycle(plugin.id(), state)); + } static int camera_get(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "camera", "reskate.camera.get"); @@ -727,6 +741,15 @@ struct WorldApi { game(plugin).set_nametag(plugin.id(), player, std::move(text), colour_at(L, 3, 0xffffffff)); return 0; } + // session.nametags(shown): false holds the names over the other players off (until true, or the + // plugin stops); chat bubbles still show. + static int nametags(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "session.nametags", "reskate.session.nametags"); + luaL_checktype(L, 1, LUA_TBOOLEAN); + game(plugin).hide_nametags(plugin.id(), !lua_toboolean(L, 1)); + return 0; + } // 0xRRGGBBAA from Lua, as the overlay draws it (IM_COL32: 0xAABBGGRR). static std::uint32_t colour_at(lua_State* L, int index, std::uint32_t fallback) { if (lua_isnoneornil(L, index)) return fallback; @@ -1223,9 +1246,100 @@ struct WorldApi { } return out; } - // skater.reach({hand = "right"|"left", at = vector, rotation = {x, y, z, w}?, weight = 0..1}): the - // hand joint taken to `at` in the world (turned to `rotation`), in the pose drawn and sent; for - // 0.3 s, so every tick. nil lets the arm go. + // A `shown` field: who sees a pose, a carry or a reach ("everyone", "others" or "me"). + static Game::Shown shown_field(lua_State* L, int table, const char* what) { + lua_getfield(L, table, "shown"); + Game::Shown shown = Game::Shown::everyone; + if (!lua_isnil(L, -1)) { + const std::string_view s = luaL_checkstring(L, -1); + if (s == "others") shown = Game::Shown::others; + else if (s == "me") shown = Game::Shown::me; + else if (s != "everyone") luaL_error(L, "%s's shown is \"everyone\", \"others\" or \"me\"", what); + } + lua_pop(L, 1); + return shown; + } + // A number field from 0 or `low` to `high`, `fallback` when not given. + static float number_field(lua_State* L, int table, const char* key, float fallback, double low, double high, const char* message) { + lua_getfield(L, table, key); + float value = fallback; + if (!lua_isnil(L, -1)) { + const double v = luaL_checknumber(L, -1); + if (!(v >= low && v <= high)) luaL_error(L, "%s", message); + value = static_cast(v); + } + lua_pop(L, 1); + return value; + } + // skater.carry({at = vector, rotation = {x, y, z, w}, hand, both, front, grip, weight, crouch, hunch, + // still, turn, step, step_phase, push_straightens, shown}): something held out in the hands and + // aimed, its front at `at`; for 0.3 s, so every tick. nil lets it go. + static int carry(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.animation", "reskate.skater.carry"); + if (lua_isnoneornil(L, 1)) return result(L, game(plugin).skater_carry(plugin.id(), std::nullopt)); + luaL_checktype(L, 1, LUA_TTABLE); + Game::Carry goal; + lua_getfield(L, 1, "hand"); + if (!lua_isnil(L, -1)) { + const std::string hand = luaL_checkstring(L, -1); + if (hand != "right" && hand != "left") luaL_error(L, "a carry's hand is \"right\" or \"left\""); + goal.left = hand == "left"; + } + lua_pop(L, 1); + lua_getfield(L, 1, "both"); + goal.both = lua_toboolean(L, -1); + lua_pop(L, 1); + lua_getfield(L, 1, "at"); + goal.front = position_of(L, lua_gettop(L), "the carry's point"); + lua_pop(L, 1); + lua_getfield(L, 1, "rotation"); + if (lua_isnil(L, -1)) luaL_error(L, "a carry needs a rotation {x, y, z, w}: the way the thing held points"); + goal.aim = quaternion_at(L, lua_gettop(L)); + lua_pop(L, 1); + lua_getfield(L, 1, "front"); + if (!lua_isnil(L, -1)) { + goal.front_from_grip = position_of(L, lua_gettop(L), "a carry's front"); + for (const auto v : goal.front_from_grip) + if (std::abs(v) > 1) luaL_error(L, "a carry's front is at most 1 m from its grip on each axis"); + } + lua_pop(L, 1); + lua_getfield(L, 1, "grip"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int grip = lua_gettop(L); + std::pair, Vec3> worn{{0, 0, 0, 1}, {}}; + lua_getfield(L, grip, "rotation"); + if (!lua_isnil(L, -1)) worn.first = quaternion_at(L, lua_gettop(L)); + lua_pop(L, 1); + lua_getfield(L, grip, "offset"); + if (!lua_isnil(L, -1)) { + worn.second = position_of(L, lua_gettop(L), "a grip's offset"); + for (const auto v : worn.second) + if (std::abs(v) > 0.5f) luaL_error(L, "a grip's offset is at most 0.5 m from the hand on each axis"); + } + lua_pop(L, 1); + goal.worn = worn; + } + lua_pop(L, 1); + goal.weight = number_field(L, 1, "weight", 1, 0, 1, "a carry's weight is 0 to 1"); + goal.crouch = number_field(L, 1, "crouch", 0, 0, 0.6, "a carry's crouch is 0 to 0.6 m"); + goal.hunch = number_field(L, 1, "hunch", 0, 0, 1.2, "a carry's hunch is 0 to 1.2 radians"); + goal.turn = number_field(L, 1, "turn", 0, -3.2, 3.2, "a carry's turn is -pi to pi radians"); + goal.step = number_field(L, 1, "step", 0, 0, 1, "a carry's step is 0 to 1"); + goal.step_phase = number_field(L, 1, "step_phase", 0, -1e6, 1e6, "a carry's step_phase is a finite number"); + lua_getfield(L, 1, "still"); + goal.still = lua_toboolean(L, -1); + lua_pop(L, 1); + lua_getfield(L, 1, "push_straightens"); + goal.push_straightens = lua_toboolean(L, -1); + lua_pop(L, 1); + goal.shown = shown_field(L, 1, "a carry"); + return result(L, game(plugin).skater_carry(plugin.id(), goal)); + } + // skater.reach({hand = "right"|"left", at = vector, rotation = {x, y, z, w}?, weight = 0..1, shown}): + // the hand joint taken to `at` in the world (turned to `rotation`), in the pose drawn and sent (or + // one of them); for 0.3 s, so every tick. nil lets the arm go. static int reach(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "skater.animation", "reskate.skater.reach"); @@ -1252,6 +1366,7 @@ struct WorldApi { goal.weight = static_cast(w); } lua_pop(L, 1); + goal.shown = shown_field(L, 1, "a reach"); return result(L, game(plugin).skater_reach(plugin.id(), goal)); } // A skater's joint: a number 0..394 of the animation skeleton, or one of a few names. @@ -1334,7 +1449,13 @@ struct WorldApi { } const double weight = luaL_optnumber(L, 2, 1); if (!(weight >= 0 && weight <= 1)) luaL_argerror(L, 2, "a weight is 0 to 1"); - return result(L, game(plugin).skater_pose(plugin.id(), offsets, static_cast(weight))); + // options = {shown = "everyone" | "others" | "me"} + auto shown = Game::Shown::everyone; + if (!lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + shown = shown_field(L, 3, "a pose"); + } + return result(L, game(plugin).skater_pose(plugin.id(), offsets, static_cast(weight), shown)); } // skater.joint(joint): {position, rotation, scale} of one of the local skater's joints, or nil. static int skater_joint(lua_State* L) { @@ -1540,7 +1661,15 @@ struct WorldApi { static int board_hide(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "board", "reskate.board.hide"); - return result(L, game(plugin).board_hide(plugin.id(), bone_number(L, 1))); + // options = {others = true}: hidden from the other players too + bool everyone = false; + if (!lua_isnoneornil(L, 2)) { + luaL_checktype(L, 2, LUA_TTABLE); + lua_getfield(L, 2, "others"); + everyone = lua_toboolean(L, -1); + lua_pop(L, 1); + } + return result(L, game(plugin).board_hide(plugin.id(), bone_number(L, 1), everyone)); } static int board_clear(lua_State* L) { auto& plugin = plugin_of(L); @@ -1596,11 +1725,13 @@ struct WorldApi { need(L, plugin, "outfit", "reskate.outfit.set"); const auto slot = checked_text(L, 1, 48, "a slot's name"); const auto item = checked_text(L, 2, 200, "an item's name"); - // Only the face and skin layers, and eyewear (Extension/Skater/outfit_overlay.h). + // Only the face and skin layers, eyewear and hand wear (Extension/Skater/outfit_overlay.h): the + // game has no menu for hand wear, so a camera or a glove a plugin puts in the hand goes there. std::string lowered = slot; for (auto& c : lowered) c = static_cast(c >= 'A' && c <= 'Z' ? c + 32 : c); - if (lowered.find("tattoo") == std::string::npos && lowered.find("moles") == std::string::npos && lowered.find("eyewear") == std::string::npos) - return result(L, "only tattoo, moles and eyewear slots"); + if (lowered.find("tattoo") == std::string::npos && lowered.find("moles") == std::string::npos && lowered.find("eyewear") == std::string::npos && + lowered.find("hand") == std::string::npos) + return result(L, "only tattoo, moles, eyewear and hand wear slots"); std::vector placement; bool covers{}; if (!lua_isnoneornil(L, 3)) { @@ -2179,10 +2310,11 @@ struct WorldApi { auto& plugin = plugin_of(L); need(L, plugin, "input", "reskate.input.bind"); const auto action = checked_text(L, 1, 48, "an action's name"); - const auto key = checked_text(L, 2, 60, "a key"); if (action.empty()) luaL_argerror(L, 1, "an action needs a name"); - const auto combo = combo_of(key); - if (!combo) luaL_error(L, "\"%s\" is not a key: 0-9, A-Z, Space, F1-F12 with Ctrl, Shift or Alt; or Pad.A, Pad.LB+Pad.RB...", key.c_str()); + // nil: no key of its own; the player can put it on one in the Binds page. + const auto key = lua_isnoneornil(L, 2) ? std::string{} : checked_text(L, 2, 60, "a key"); + const auto combo = key.empty() ? 0u : combo_of(key); + if (!key.empty() && !combo) luaL_error(L, "\"%s\" is not a key: 0-9, A-Z, Space, F1-F12 with Ctrl, Shift or Alt; or Pad.A, Pad.LB+Pad.RB...", key.c_str()); if (!plugin.binds_.contains(action) && plugin.binds_.size() >= 16) luaL_error(L, "at most 16 actions"); if (const auto why = game(plugin).bind(plugin.id(), action, combo); !why.empty()) return result(L, why); plugin.binds_.insert(action); @@ -2500,10 +2632,10 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { lua_setfield(L, -2, "__metatable"); lua_setfield(L, LUA_REGISTRYINDEX, "reskate.permissions"); 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}, {"hand", hand}, {"joint", skater_joint}, {"pose", skater_pose}}); + {"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}}); - add_functions(L, "camera", {{"look", camera_look}, {"set", camera_set}, {"follow", camera_follow}, {"release", camera_release}, {"held", camera_held}, {"get", camera_get}}); + 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}}); add_functions(L, "time", {{"slow", slow}, {"speed", speed}}); @@ -2514,7 +2646,7 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { add_functions(L, "world", {{"set_time", set_time}, {"set_control", set_control}, {"set_park", set_park}, {"pick", world_pick}}); add_functions(L, "travel", {{"locations", locations}, {"levels", levels}, {"go", go}, {"load", load}}); add_functions(L, "ai", {{"spawn", ai_spawn}, {"clear", ai_clear}, {"count", ai_count}}); - add_functions(L, "session", {{"skaters", skaters}, {"tag", tag}, {"joint", session_joint}, {"touch", session_touch}, + add_functions(L, "session", {{"skaters", skaters}, {"tag", tag}, {"nametags", nametags}, {"joint", session_joint}, {"touch", session_touch}, {"touching", session_touching}, {"hide", session_hide}, {"hidden", session_hidden}, {"raycast", session_raycast}}); add_functions(L, "voice", {{"set_volume", voice_volume}}); diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index cb712075..c5563539 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -333,6 +333,8 @@ public: virtual std::vector hidden_players(std::string_view /*plugin*/) { return {}; } // A short tag beside a player's nametag, the plugin's own (empty text takes it away). virtual void set_nametag(std::string_view /*plugin*/, std::uint64_t, std::string, std::uint32_t) {} + // The names over the other players held off while any plugin asks (chat bubbles still show). + virtual void hide_nametags(std::string_view /*plugin*/, bool /*hidden*/) {} // 0 mutes, 1 is as heard, up to 10. The local player's ears only. virtual std::string voice_volume(std::uint64_t, float) { return unavailable; } // Chat lines after `sequence`, as this player sees them (filtered when they filter). @@ -564,6 +566,17 @@ public: virtual void release_camera(std::string_view /*plugin*/) {} // "" while the plugin's view is the one shown, otherwise why not. virtual std::string camera_status(std::string_view /*plugin*/) { return unavailable; } + // The plugin's camera as a step of the game's camera height button cycle (Engine/Game/Skater/ + // first_person_cycle.h): out of it, in it and off (ready to start), or in it and on. Held 300 ms, + // so said every tick; one plugin at a time. The steps the button asks for come back from + // take_camera_cycle, for the plugin in the cycle. + enum class CycleCamera : std::uint8_t { out, ready, active }; + virtual std::string camera_cycle(std::string_view /*plugin*/, CycleCamera) { return unavailable; } + struct CycleNews { + std::string plugin; + bool enter{}; + }; + virtual std::vector take_camera_cycle() { return {}; } // Holds the skater where it is (noclip, with no input), while any plugin asks. virtual std::string freeze(std::string_view /*plugin*/, bool) { return unavailable; } // ---- the session's authority (plugins' server copies) -------------------------------------------- @@ -628,6 +641,9 @@ public: virtual void pause_clip(std::string_view /*plugin*/, bool /*paused*/) {} virtual std::optional clip_time(std::string_view /*plugin*/) { return std::nullopt; } virtual bool clip_playing(std::string_view /*plugin*/) { return false; } + // Who sees a pose, a carry or a reach of the local skater (Extension/Skater/skater_animation.h + // Shown): everyone, only the other players (sent, not drawn), or only this player (drawn, not sent). + enum class Shown : std::uint8_t { everyone, others, me }; // An arm of the local skater reaching (Extension/Skater/arm_reach.h): its hand joint to `at` in the // world, turned to `rotation` when given, `weight` of the way; held 0.3 s, set every tick. One // plugin at a time; none ends that plugin's. Empty when done, else why not. @@ -636,18 +652,36 @@ public: Vec3 at{}; std::optional> rotation; float weight{1}; + Shown shown{}; }; virtual std::string skater_reach(std::string_view /*plugin*/, std::optional) { return unavailable; } + // Something held out in the local skater's hands and aimed (Extension/Skater/carry.h): its front + // to `front` in the world, pointing as `aim` says (its -Z the way it points); held 0.3 s, set every + // tick. One plugin at a time; none ends that plugin's. Empty when done, else why not. + struct Carry { + bool left{}, both{}; + Vec3 front{}; + std::array aim{0, 0, 0, 1}; + Vec3 front_from_grip{}; + // Worn on the hand: the item's rotation in the hand joint's frame, and its grip from the hand joint. + std::optional, Vec3>> worn; + float weight{1}, crouch{}, hunch{}, turn{}, step{}, step_phase{}; + bool still{}, push_straightens{}; + Shown shown{}; + }; + virtual std::string skater_carry(std::string_view /*plugin*/, std::optional) { return unavailable; } // Joints of the local skater turned (in their own frame) and scaled on top of the animation, in - // the pose drawn and sent (Extension/Skater/skater_animation.h pose); held 0.3 s, so set every - // tick; empty ends it. One plugin at a time. + // the pose drawn and sent (Extension/Skater/skater_animation.h pose) or one of them (`shown`); held + // 0.3 s, so set every tick; empty ends it. One plugin at a time. struct JointOffset { std::uint16_t joint{}; std::array rotation{0, 0, 0, 1}; float scale{1}; Vec3 offset{}; // added to its position, in its parent's frame }; - virtual std::string skater_pose(std::string_view /*plugin*/, const std::vector&, float /*weight*/) { return unavailable; } + virtual std::string skater_pose(std::string_view /*plugin*/, const std::vector&, float /*weight*/, Shown /*shown*/) { + return unavailable; + } // Where a hand joint of the local skater is in the world, as last evaluated (after any reach). struct HandPlace { Vec3 position{}; @@ -723,7 +757,8 @@ public: }; virtual std::optional> board_bones() { return std::nullopt; } virtual std::string board_set(std::string_view /*plugin*/, int, const BoardBone&, bool /*world*/) { return unavailable; } - virtual std::string board_hide(std::string_view /*plugin*/, int) { return unavailable; } + // `everyone`: hidden from the other players too, not only on this screen. + virtual std::string board_hide(std::string_view /*plugin*/, int, bool /*everyone*/) { return unavailable; } virtual void board_clear(std::string_view /*plugin*/, int /*bone, -1: all*/) {} virtual void board_park(std::string_view /*plugin*/, std::vector) {} virtual std::string board_toss(std::string_view /*plugin*/, std::vector, const Vec3&, const Vec3&, float) { return unavailable; } diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index fe713b79..69ae664d 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -25,6 +25,8 @@ #include "Extension/Skater/game_speed.h" #include "Extension/Skater/launches.h" #include "Extension/Skater/pad_hide.h" +#include "Engine/Game/Skater/first_person_cycle.h" +#include "Extension/Multiplayer/Hud/custom_nametags.h" #include "Extension/Skater/xray.h" #include "Extension/UI/hud_corner.h" #include "Extension/Throwdowns/custom_modes.h" @@ -376,6 +378,11 @@ public: void set_nametag(std::string_view plugin, std::uint64_t player, std::string text, std::uint32_t colour) override { plugin_feed::set_tag(std::string(plugin), player, std::move(text), colour); } + void hide_nametags(std::string_view plugin, bool hidden) override { + if (hidden) nametag_hiders_.emplace(plugin); + else if (const auto at = nametag_hiders_.find(plugin); at != nametag_hiders_.end()) nametag_hiders_.erase(at); + multiplayer::nametags_held_off.store(!nametag_hiders_.empty(), std::memory_order_relaxed); + } std::string voice_volume(std::uint64_t player, float volume) override { const auto id = std::to_string(player); const auto mute = multiplayer::command("voice-mute", id + (volume <= 0 ? " on" : " off")); @@ -456,7 +463,7 @@ public: // ---- input ------------------------------------------------------------------------------------------- std::string bind(std::string_view plugin, std::string_view action, std::uint32_t combo) override { - if (!valid_action_binding(combo)) return "that is not a key or pad combo"; + if (combo && !valid_action_binding(combo)) return "that is not a key or pad combo"; // (0: none of its own) binds_[{std::string(plugin), std::string(action)}].own = combo; publish_binds(); return {}; @@ -501,7 +508,7 @@ public: std::string board_set(std::string_view plugin, int bone, const BoardBone& value, bool world) override { return board_pose::set(plugin, bone, {value.position, value.rotation, value.scale}, world ? board_pose::Space::world : board_pose::Space::local); } - std::string board_hide(std::string_view plugin, int bone) override { return board_pose::hide(plugin, bone); } + std::string board_hide(std::string_view plugin, int bone, bool everyone) override { return board_pose::hide(plugin, bone, everyone); } void board_clear(std::string_view plugin, int bone) override { board_pose::clear(plugin, bone); } void board_park(std::string_view plugin, std::vector bones) override { board_pose::park(plugin, std::move(bones)); } std::string board_toss(std::string_view plugin, std::vector bones, const Vec3& velocity, const Vec3& spin, float ground) override { @@ -978,7 +985,10 @@ public: screen_look::slap(); return {}; } - std::string skater_pose(std::string_view plugin, const std::vector& offsets, float weight) override { + static skater_animation::Shown shown_of(Shown shown) { + return shown == Shown::others ? skater_animation::Shown::others : shown == Shown::me ? skater_animation::Shown::me : skater_animation::Shown::everyone; + } + std::string skater_pose(std::string_view plugin, const std::vector& offsets, float weight, Shown shown) override { const auto now = GetTickCount64(); if (!poser_.empty() && poser_ != plugin && now < pose_at_ + 300) return offsets.empty() ? std::string{} : poser_ + " poses the skater"; if (offsets.empty()) { @@ -992,7 +1002,65 @@ public: pose_at_ = now; std::vector out; for (const auto& o : offsets) out.push_back({o.joint, o.rotation, o.scale, o.offset}); - skater_animation::pose(std::move(out), weight); + skater_animation::pose(std::move(out), weight, shown_of(shown)); + return {}; + } + std::string camera_cycle(std::string_view plugin, CycleCamera state) override { + const auto now = GetTickCount64(); + auto& link = first_person::plugin_link; + if (!cycler_.empty() && cycler_ != plugin && now < cycle_at_ + 300) + return state == CycleCamera::out ? std::string{} : cycler_ + " is in the camera cycle"; + if (state == CycleCamera::out) { + if (cycler_ == plugin) { + link.flags.store(0, std::memory_order_release); + cycler_.clear(); + } + return {}; + } + if (cycler_ != plugin) link.request.store(0, std::memory_order_release); // (a step asked of another plugin) + cycler_ = plugin; + cycle_at_ = now; + link.flags.store(static_cast(first_person::plugin_flag_enabled | + (state == CycleCamera::active ? first_person::plugin_flag_on : first_person::plugin_flag_can_enter)), + std::memory_order_release); + link.published.store(now, std::memory_order_release); + return {}; + } + std::vector take_camera_cycle() override { + const auto step = static_cast(first_person::plugin_link.request.exchange(0, std::memory_order_acq_rel)); + if (step == first_person::CycleStep::none || cycler_.empty() || GetTickCount64() >= cycle_at_ + 300) return {}; + return {{cycler_, step == first_person::CycleStep::enter}}; + } + std::string skater_carry(std::string_view plugin, std::optional goal) override { + const auto now = GetTickCount64(); + if (!carrier_.empty() && carrier_ != plugin && now < carry_at_ + 300) { + if (!goal) return {}; + return carrier_ + " has the skater carrying"; + } + if (!goal) { + if (carrier_ == plugin) { + skater_animation::carry(std::nullopt); + carrier_.clear(); + } + return {}; + } + carrier_ = plugin; + carry_at_ = now; + carry::Goal g; + g.hand = goal->left ? arm_reach::Hand::left : arm_reach::Hand::right; + g.both = goal->both; + g.front = goal->front; + g.aim = goal->aim; + g.front_from_grip = goal->front_from_grip; + if (goal->worn) g.worn = carry::Grip{goal->worn->first, goal->worn->second}; + g.weight = goal->weight; + g.crouch = goal->crouch; + g.hunch = goal->hunch; + g.still = goal->still; + g.turn = goal->turn; + g.step = goal->step; + g.step_phase = goal->step_phase; + skater_animation::carry(g, goal->push_straightens, shown_of(goal->shown)); return {}; } std::string skater_reach(std::string_view plugin, std::optional goal) override { @@ -1015,7 +1083,7 @@ public: g.at = goal->at; if (goal->rotation) g.rotation = *goal->rotation; g.weight = goal->weight; - skater_animation::reach(g); + skater_animation::reach(g, shown_of(goal->shown)); return {}; } static Tuning tuning_of(const trainer::TuningValue& v) { @@ -1611,6 +1679,15 @@ public: skater_animation::pose({}, 1); poser_.clear(); } + if (carrier_ == plugin) { + skater_animation::carry(std::nullopt); + carrier_.clear(); + } + if (cycler_ == plugin) { + first_person::plugin_link.flags.store(0, std::memory_order_release); + cycler_.clear(); + } + hide_nametags(plugin, false); if (room_owner_ == plugin) room_leave(plugin); if (const auto seen = telemetry_seen_.find(plugin); seen != telemetry_seen_.end()) telemetry_seen_.erase(seen); plugin_feed::release_tags(std::string(plugin)); @@ -1645,6 +1722,11 @@ private: std::vector enabled_mods_; std::string reacher_; // the plugin holding the skater's arm (skater_reach) std::string poser_; // the plugin turning the skater's joints (skater_pose) + std::string carrier_; // the plugin having the skater carry something (skater_carry) + std::uint64_t carry_at_{}; + std::set> nametag_hiders_; // the plugins holding the nametags off (hide_nametags) + std::string cycler_; // the plugin whose camera is in the camera height cycle (camera_cycle) + std::uint64_t cycle_at_{}; std::string room_owner_; // the plugin in the character room (room_open) std::uint64_t pose_at_{}; std::string looker_; // the plugin whose look is on the screen (screen_look) diff --git a/Extension/Plugins/plugin_host.cpp b/Extension/Plugins/plugin_host.cpp index 5827101a..4a0cdf2e 100644 --- a/Extension/Plugins/plugin_host.cpp +++ b/Extension/Plugins/plugin_host.cpp @@ -316,6 +316,11 @@ void Host::follow_game() { } if (wants("events.skater")) for (const auto& launch : game.take_launches()) each([&](Plugin& plugin, const std::string&) { plugin.launched(launch); }); + // The camera height button asking the plugin in its cycle to start or stop its camera. + for (const auto& news : game.take_camera_cycle()) + each([&](Plugin& plugin, const std::string&) { + if (plugin.id() == news.plugin) plugin.game_event("camera_cycle", "camera", Json(news.enter ? "enter" : "leave")); + }); // Touching (reskate.session.touch): the bodies read once for every plugin that has it on. { std::optional> list; diff --git a/Extension/Skater/Test/carry_tests.cpp b/Extension/Skater/Test/carry_tests.cpp new file mode 100644 index 00000000..d59600c4 --- /dev/null +++ b/Extension/Skater/Test/carry_tests.cpp @@ -0,0 +1,178 @@ +// Something carried apart from the game: held out in front of the chest when it points level, higher +// when it points up, turned the way it is aimed when worn on the hand, the other hand under it, the +// hips lowered with the feet kept where they were, the body turned, and a broken pose left alone. +#include "Extension/Skater/carry.h" +#include +#include +#include +#include +#include +#include + +using namespace dingosdk; +using namespace dingosdk::carry; +namespace { +void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } +float distance(const Vec3& a, const Vec3& b) { + return std::sqrt((a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]) + (a[2] - b[2]) * (a[2] - b[2])); +} +bool same_turn(const Quat& a, const Quat& b) { return std::abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]) > 0.999f; } + +// A skater standing, facing +Z (its right at -X), arms hanging out to the sides: upper arms 0.3 m, +// forearms 0.28 m, thighs and shins 0.45 m, the knees a little forward. +constexpr float upper_length = 0.3f, fore_length = 0.28f; +std::vector standing() { + std::vector pose(395); + pose[0].position = {4, 0, -2}; + pose[1].position = {0, 0.95f, 0}; + for (const std::size_t j : {42u, 43u, 44u, 45u}) pose[j].position = {0, 0.1f, 0}; + pose[101].position = {0, 0.1f, 0}; + pose[102].position = {0, 0.05f, 0}; + pose[103].position = {0, 0.1f, 0}; + for (const auto hand : {Hand::right, Hand::left}) { + const auto joints = arm_reach::chain(hand); + const float x = hand == Hand::right ? -1.0f : 1.0f; + pose[joints[7]].position = {0.15f * x, 0, 0}; + pose[joints[8]].position = {0.05f * x, 0, 0}; + pose[joints[9]].position = {0, -upper_length, 0}; + pose[joints[10]].position = {0, -fore_length, 0}; + } + for (const auto& leg : {right_leg, left_leg}) { + const float x = leg[3] == 8 ? -1.0f : 1.0f; + pose[leg[3]].position = {0.1f * x, 0, 0}; + pose[leg[4]].position = {0, -0.45f, 0.03f}; + pose[leg[5]].position = {0, -0.45f, -0.03f}; + } + return pose; +} +std::array arm(const std::vector& pose, Hand hand) { + std::array w{}; + check(arm_reach::compose(pose, arm_reach::chain(hand), w), "The arm composes"); + return w; +} +Placed placed(const std::vector& pose, std::span path) { + std::array w{}; + check(arm_reach::compose(pose, path, w), "The joints compose"); + return w[path.size() - 1]; +} +// Pointing at +Z, tipped up by `up` radians. +Quat aimed(float up) { return anim_studio::multiply(anim_studio::axis_angle({0, 1, 0}, std::numbers::pi_v), anim_studio::axis_angle({1, 0, 0}, up)); } + +void held_level_in_front_of_the_chest() { + auto pose = standing(); + const Vec3 shoulder = arm(pose, Hand::right)[8].position; + Goal goal; + goal.front = {shoulder[0], shoulder[1], shoulder[2] + 2.0f}; // asked for far off: held where a person holds it + goal.aim = aimed(0); + goal.front_from_grip = {0, -0.143f, -0.248f}; + goal.worn = Grip{}; + const auto held = carry::carry(pose, goal); + check(held.valid, "Carried"); + const auto w = arm(pose, Hand::right); + check(held.front[2] - w[8].position[2] > 0.25f, "out in front of the shoulder"); + check(held.front[1] < w[8].position[1], "and below it"); + check(std::abs(distance(w[8].position, w[9].position) - upper_length) < 1e-4f, "The upper arm keeps its length"); + check(std::abs(distance(w[9].position, w[10].position) - fore_length) < 1e-4f, "and the forearm its length"); + check(same_turn(held.item.rotation, goal.aim), "Worn, it points the way it is aimed"); +} + +void aimed_up_it_comes_up() { + auto level = standing(), up = standing(); + Goal goal; + goal.front = {4, 1.4f, 0}; + goal.front_from_grip = {0, -0.143f, -0.248f}; + goal.aim = aimed(0); + const auto low = carry::carry(level, goal); + goal.aim = aimed(1.2f); + const auto high = carry::carry(up, goal); + check(low.valid && high.valid, "Carried both ways"); + check(high.front[1] > low.front[1] + 0.2f, "Aimed up, it is held higher"); +} + +void the_other_hand_holds_it_too() { + auto pose = standing(); + const auto before = arm(pose, Hand::left)[10].position; + Goal goal; + goal.front = {4, 1.3f, 0.5f}; + goal.aim = aimed(0); + goal.both = true; + const auto held = carry::carry(pose, goal); + check(held.valid, "Carried in both hands"); + const auto left = arm(pose, Hand::left)[10].position; + check(distance(left, before) > 0.2f, "The other hand comes up to it"); + check(distance(left, held.item.position) < 0.3f, "and holds it"); +} + +void the_feet_stay_as_the_hips_go_down() { + auto pose = standing(); + const auto right_foot = placed(pose, right_leg).position, left_foot = placed(pose, left_leg).position; + const float hips = placed(pose, std::span(right_leg).first(3)).position[1]; + Goal goal; + goal.front = {4, 1.2f, 0.5f}; + goal.aim = aimed(-0.3f); + goal.crouch = 0.2f; + goal.hunch = 0.8f; + check(carry::carry(pose, goal).valid, "Carried, crouched"); + check(std::abs(placed(pose, std::span(right_leg).first(3)).position[1] - (hips - 0.2f)) < 1e-3f, "The hips go down"); + check(distance(placed(pose, right_leg).position, right_foot) < 2e-3f, "the right foot stays where it was"); + check(distance(placed(pose, left_leg).position, left_foot) < 2e-3f, "and the left"); + const Vec3 head = placed(pose, head_chain).position; + check(head[2] > -2.0f + 0.05f, "The back curls over toward it"); +} + +void the_body_turns() { + auto pose = standing(); + Goal goal; + goal.front = {4, 1.3f, 0.5f}; + goal.aim = aimed(0); + goal.turn = std::numbers::pi_v / 2; + check(carry::carry(pose, goal).valid, "Carried, turned"); + const auto facing = body_facing(pose); + check(std::abs(facing[0]) > 0.99f, "The body turns a quarter round"); +} + +void no_weight_and_a_broken_pose() { + auto pose = standing(); + const auto before = pose; + Goal goal; + goal.front = {4, 1.3f, 0.5f}; + goal.aim = aimed(0); + goal.crouch = 0.3f; + goal.weight = 0; + check(carry::carry(pose, goal).valid, "Carried with no weight"); + for (std::size_t i = 0; i < pose.size(); ++i) + check(same_turn(pose[i].rotation, before[i].rotation) && distance(pose[i].position, before[i].position) < 1e-5f, "leaves the pose as it was"); + auto again = standing(); + goal.weight = 1; + goal.both = true; + goal.hunch = 0.6f; + goal.turn = 0.5f; + check(carry::carry(again, goal).valid, "Carried with everything"); + for (std::size_t i = 0; i < again.size(); ++i) + if (std::find(written.begin(), written.end(), i) == written.end()) + check(same_turn(again[i].rotation, before[i].rotation) && distance(again[i].position, before[i].position) < 1e-5f, + "A carry writes only the joints it says it writes"); + auto broken = standing(); + broken[48].rotation[0] = NAN; + check(!carry::carry(broken, goal).valid, "A broken arm carries nothing"); + goal.front = {NAN, 0, 0}; + auto fine = standing(); + check(!carry::carry(fine, goal).valid, "A point that is not a number is refused"); +} +} // namespace + +int main() { + try { + held_level_in_front_of_the_chest(); + aimed_up_it_comes_up(); + the_other_hand_holds_it_too(); + the_feet_stay_as_the_hips_go_down(); + the_body_turns(); + no_weight_and_a_broken_pose(); + } catch (const std::exception& error) { + std::cerr << "FAILED: " << error.what() << '\n'; + return 1; + } + std::cout << "carry: all passed\n"; + return 0; +} diff --git a/Extension/Skater/board_pose.cpp b/Extension/Skater/board_pose.cpp index 1690a096..2a8976f1 100644 --- a/Extension/Skater/board_pose.cpp +++ b/Extension/Skater/board_pose.cpp @@ -1,6 +1,7 @@ #include "board_pose.h" #include "Extension/AnimationStudio/anim_core.h" #include "Extension/Multiplayer/Remote/native_pose_layout.h" +#include "Extension/Multiplayer/Remote/native_skater.h" #include #include #include @@ -60,6 +61,7 @@ Quat about(const Vec3& axis, float angle) { // The pose a bone is written with, as the client tick asked it. struct Wanted { bool hidden{}; + bool everyone{}; // hidden from the other players too Quat rotation{0, 0, 0, 1}; Vec3 position{}; Space space{}; @@ -90,6 +92,8 @@ Shared& shared() { } std::atomic base_now{}, holder_now{}; std::atomic any{}; +// The bones hidden from everyone, as bits (bone b is bit b), for the sent pose filter without the lock. +std::atomic hidden_from_all{}; // How every board is fitted (fit): the baseplates held on the deck, and the wheels' spread. Read by // the render hook without the lock; the owner is under it. std::atomic fitted{}, fit_plates{}; @@ -117,7 +121,24 @@ bool store_float(std::uintptr_t address, float value) noexcept { return false; } } -void refresh_any(Shared& s) { any.store(!s.wanted.empty() || !s.parked.empty() || s.piece.has_value(), std::memory_order_release); } +void refresh_any(Shared& s) { + any.store(!s.wanted.empty() || !s.parked.empty() || s.piece.has_value(), std::memory_order_release); + std::uint32_t bits = 0; + for (const auto& [b, want] : s.wanted) + if (want.hidden && want.everyone) bits |= 1u << b; + hidden_from_all.store(bits, std::memory_order_release); +} +// The local skater's pose as it is sent: the bones hidden from everyone out of sight in it too, as they +// are drawn here. board[0] is the board entity's place in the world, then its rig's bones from 0. +void on_sent_pose(std::uintptr_t, multiplayer::Pose& pose) noexcept { + const auto bits = hidden_from_all.load(std::memory_order_acquire); + if (!bits) return; + for (int b = first_writable; b < bones; ++b) + if ((bits & (1u << b)) && static_cast(b + 1) < pose.board.size()) { + auto& at = pose.board[static_cast(b + 1)].position; + if (length(at) < 100.0f) at[1] -= 2000.0f; + } +} std::string take(Shared& s, std::string_view owner) { if (!s.owner.empty() && s.owner != owner) return s.owner + " holds the board"; s.owner = owner; @@ -175,16 +196,16 @@ std::string set(std::string_view owner, int bone, const Bone& value, Space space auto& s = shared(); std::lock_guard lock(s.mutex); if (auto why = take(s, owner); !why.empty()) return why; - s.wanted[bone] = {false, normalised(value.rotation), value.position, space}; + s.wanted[bone] = {false, false, normalised(value.rotation), value.position, space}; refresh_any(s); return {}; } -std::string hide(std::string_view owner, int bone) { +std::string hide(std::string_view owner, int bone, bool everyone) { if (bone < first_writable || bone >= bones) return "a board's bone is 2 to 15"; auto& s = shared(); std::lock_guard lock(s.mutex); if (auto why = take(s, owner); !why.empty()) return why; - s.wanted[bone] = {true}; + s.wanted[bone] = {true, everyone}; refresh_any(s); return {}; } @@ -265,6 +286,11 @@ void tick(std::uintptr_t base, std::uintptr_t component) noexcept { } catch (...) {} base_now.store(base, std::memory_order_release); holder_now.store(holder, std::memory_order_release); + static bool sending{}; + if (!sending) { + multiplayer::set_local_sent_pose_filter(&on_sent_pose); + sending = true; + } } void on_render_pose(std::uintptr_t animation_interface) noexcept { diff --git a/Extension/Skater/board_pose.h b/Extension/Skater/board_pose.h index 404f396c..2e8a37a2 100644 --- a/Extension/Skater/board_pose.h +++ b/Extension/Skater/board_pose.h @@ -28,7 +28,9 @@ inline constexpr int bones = 16, first_writable = 2; // Client thread. One owner holds the local board's bones at a time; empty when taken, else why not. std::string set(std::string_view owner, int bone, const Bone& value, Space space); -std::string hide(std::string_view owner, int bone); +// `everyone`: hidden from the other players too (the board as you are sent: Extension/Multiplayer/ +// Remote/native_skater.h's sent pose filter), not only on this screen. +std::string hide(std::string_view owner, int bone, bool everyone = false); void clear(std::string_view owner, int bone = -1); // These bones go out of sight on every board, other players' included (a breakable deck's // stretches, which the game would draw as cracks). diff --git a/Extension/Skater/carry.cpp b/Extension/Skater/carry.cpp new file mode 100644 index 00000000..8ecf9c75 --- /dev/null +++ b/Extension/Skater/carry.cpp @@ -0,0 +1,465 @@ +#include "carry.h" +#include +#include + +namespace dingosdk::carry { +namespace { +using anim_studio::conjugate; +using anim_studio::multiply; +using anim_studio::normalised; +using anim_studio::rotate; +using arm_reach::chain; +using arm_reach::compose; + +Vec3 add(const Vec3& a, const Vec3& b) { return {a[0] + b[0], a[1] + b[1], a[2] + b[2]}; } +Vec3 sub(const Vec3& a, const Vec3& b) { return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; } +Vec3 scaled(const Vec3& a, float k) { return {a[0] * k, a[1] * k, a[2] * k}; } +float dot(const Vec3& a, const Vec3& b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } +Vec3 cross(const Vec3& a, const Vec3& b) { return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]}; } +float length(const Vec3& a) { return std::sqrt(dot(a, a)); } +Vec3 unit(const Vec3& a, const Vec3& fallback) { + const float l = length(a); + return l > 1e-6f && std::isfinite(l) ? scaled(a, 1.0f / l) : fallback; +} +bool finite(const Vec3& v) { return std::isfinite(v[0]) && std::isfinite(v[1]) && std::isfinite(v[2]); } +bool finite(const Quat& q) { return std::isfinite(q[0]) && std::isfinite(q[1]) && std::isfinite(q[2]) && std::isfinite(q[3]); } +// The shortest turn taking direction `from` onto direction `to`. +Quat arc(const Vec3& from, const Vec3& to) { + const Vec3 a = unit(from, {0, 0, 1}), b = unit(to, {0, 0, 1}); + const float d = dot(a, b); + if (d < -0.9999f) { // half a turn: about any axis square to `from` + Vec3 axis = cross({1, 0, 0}, a); + if (length(axis) < 1e-3f) axis = cross({0, 1, 0}, a); + axis = unit(axis, {0, 1, 0}); + return {axis[0], axis[1], axis[2], 0.0f}; + } + const Vec3 c = cross(a, b); + return normalised({c[0], c[1], c[2], 1.0f + d}); +} +// Part way from a to b, the short way round. +Quat blend(const Quat& a, Quat b, float t) { + t = std::clamp(t, 0.0f, 1.0f); + if (a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3] < 0.0f) + for (auto& v : b) v = -v; + return normalised({a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t}); +} +Quat about_up(float radians) { return {0.0f, std::sin(radians / 2), 0.0f, std::cos(radians / 2)}; } +// The world turn `world` as the local rotation of a joint whose parent is placed at `parent`. +Quat local_of(const Placed& parent, const Quat& world) { return normalised(multiply(conjugate(parent.rotation), world)); } +// The way an aimed thing faces round the upright (level, unit), steady however steeply it points up or +// down: straight down, its forward has no level part left, but its up does (the top of it is the way +// it faces). +Vec3 level_heading(const Quat& aim) { + const Quat q = normalised(aim); + const Vec3 forward = rotate(q, {0.0f, 0.0f, -1.0f}), up = rotate(q, {0.0f, 1.0f, 0.0f}); + const float sin_p = std::clamp(forward[1], -1.0f, 1.0f), cos_p = std::sqrt(std::max(0.0f, 1.0f - sin_p * sin_p)); + return unit(Vec3{forward[0] * cos_p - up[0] * sin_p, 0.0f, forward[2] * cos_p - up[2] * sin_p}, {0.0f, 0.0f, 1.0f}); +} + +// In an arm's chain: chest, shoulder, upper arm, forearm, hand. +constexpr std::size_t chest = 6, shoulder = 7, upper = 8, fore = 9, hand_at = 10; +// Held, not worn: from the wrist joint to the middle of the palm along the hand. +constexpr float palm_from_wrist = 0.075f; +// Where the other hand takes it, in the item's own frame (right, up, back; the grip at the origin), for +// a right-hand grip: the left side of it, underneath. Mirrored for a left grip. +constexpr Vec3 support_from_grip{-0.08f, -0.16f, -0.04f}; +// The body as a column the arm keeps out of: about the hips' and chest's line, this far out at most +// (and inside the shoulders). +constexpr float body_radius = 0.24f; +// Its front is held at least this far out in front of the shoulder (along the aim), and at least this +// far below it. +constexpr float min_reach_out = 0.4f, below_shoulder = 0.05f; +// Toward the holding hand's own side it comes up this much (all the way square to that side). +constexpr float side_lift = 0.2f; + +// One foot lifted `up` metres and moved by `move` (a step), the knee bending to it the way it already +// bends; the foot keeps its turn. +bool step_foot(std::span pose, std::span path, float up, const Vec3& move) noexcept { + std::array now{}; + if (!compose(pose, path, now)) return false; + const Vec3 hip = now[3].position, knee = now[4].position; + const Vec3 ankle = add(now[5].position, add(Vec3{0.0f, up, 0.0f}, move)); + const float a = length(sub(knee, hip)), b = length(sub(now[5].position, knee)); + if (!(a > 0.1f && b > 0.1f)) return true; + const float d = std::clamp(length(sub(ankle, hip)), std::abs(a - b) + 0.01f, (a + b) * 0.999f); + const Vec3 along = unit(sub(ankle, hip), {0, -1, 0}); + Vec3 pole = sub(knee, add(hip, scaled(along, dot(sub(knee, hip), along)))); + pole = unit(pole, Vec3{0.0f, 0.0f, 1.0f}); + const float cos_a = std::clamp((a * a + d * d - b * b) / (2.0f * a * d), -1.0f, 1.0f); + const float sin_a = std::sqrt(std::max(0.0f, 1.0f - cos_a * cos_a)); + const Vec3 new_knee = add(hip, add(scaled(along, a * cos_a), scaled(pole, a * sin_a))); + const Quat foot_world = now[5].rotation; + const Quat thigh_world = normalised(multiply(arc(sub(knee, hip), sub(new_knee, hip)), now[3].rotation)); + pose[path[3]].rotation = local_of(now[2], thigh_world); + if (!compose(pose, path, now)) return false; + const Quat shin_world = normalised(multiply(arc(sub(now[5].position, now[4].position), sub(ankle, now[4].position)), now[4].rotation)); + pose[path[4]].rotation = local_of(now[3], shin_world); + if (!compose(pose, path, now)) return false; + pose[path[5]].rotation = local_of(now[4], foot_world); + return true; +} + +struct One { + Hand hand = Hand::right; + Vec3 front{}; + Quat aim{0, 0, 0, 1}; + Vec3 front_from_grip{}; + std::optional worn; + float weight = 1.0f; + // The front reached for where it is (no holding it out in front and below the shoulder): the + // supporting hand's reach. + bool free{}; +}; + +// One arm holding the item (its upper arm and forearm, and the hand when it is worn). +Held hold_one(std::span pose, const One& goal) noexcept { + const auto joints = chain(goal.hand); + std::array w{}; + if (!compose(pose, joints, w)) return {}; + const Quat aim = normalised(goal.aim); + const Vec3 s = w[upper].position, e = w[fore].position, h = w[hand_at].position; + const float a = length(sub(e, s)), b = length(sub(h, e)); + if (!(a > 0.05f && b > 0.05f && a < 1.0f && b < 1.0f)) return {}; + const float scale = std::max(w[hand_at].scale, 0.1f); + // Where its front is shown held: where it was asked for, but out in front of the shoulder along + // the aim and no higher than the shoulder, as a person holds a thing they point (a front up by the + // head or tucked against the body would fold the arm up into the face). + Vec3 front = goal.front; + float to_own_side = 0.0f; + if (!goal.free) { + const Vec3 ahead = level_heading(aim); + const float out = dot(sub(front, s), ahead); + // Toward the holding hand's own side the arm would stretch across to its full length: it comes + // in closer and up a little there, the elbow bent. + Vec3 own_side = sub(s, w[chest].position); + own_side[1] = 0.0f; + own_side = unit(own_side, ahead); + const float toward_side = std::clamp(dot(ahead, own_side), 0.0f, 1.0f); + to_own_side = toward_side; + // Where it is held follows the aim in one smooth sweep: level, out in front at the chest, under + // the eyes; aimed down, lower and a little closer, all the way down to the stomach; aimed up, + // higher and closer to the face, up over the head. + const float pointing_y = std::clamp(rotate(aim, {0.0f, 0.0f, -1.0f})[1], -1.0f, 1.0f); + const float up_part = std::max(pointing_y, 0.0f), down_part = std::max(-pointing_y, 0.0f); + const float height = -below_shoulder - 0.13f * (1.0f - up_part) + 0.55f * up_part - 0.45f * down_part; + const float reach_out = (min_reach_out + 0.08f * (1.0f - up_part)) * (1.0f - 0.35f * toward_side) * (1.0f - 0.55f * up_part) * + (1.0f - 0.15f * down_part); + const Vec3 across = cross({0.0f, 1.0f, 0.0f}, ahead); + const float lateral = std::clamp(dot(sub(front, s), across), -0.3f, 0.3f); + const float out_now = std::max(out, 0.0f); + // (Further out than that only toward the other side, across the body.) + const float out_held = toward_side > 0.0f && out_now > reach_out + 0.1f ? out_now - (out_now - reach_out - 0.1f) * toward_side : reach_out; + front = add(add(s, scaled(ahead, out_held)), scaled(across, lateral)); + front[1] = s[1] + height + side_lift * toward_side; + } + const Grip worn = goal.worn.value_or(Grip{}); + const Quat hand_world = normalised(multiply(aim, conjugate(worn.item))); + const Vec3 grip_offset = scaled(rotate(hand_world, worn.from_wrist), scale); + Vec3 palm_goal = goal.worn ? sub(sub(front, rotate(aim, goal.front_from_grip)), grip_offset) : sub(front, rotate(aim, goal.front_from_grip)); + if (!goal.free) { + // The hand itself out in front of the shoulder and not up over it: with the hand tucked in over + // the shoulder the arm can only fold up, the elbow rising by the head. Aimed up it comes up and + // in a little, smoothly with the tilt. + const Vec3 forward = level_heading(aim); + const float up_part = std::clamp(rotate(aim, {0.0f, 0.0f, -1.0f})[1], 0.0f, 1.0f); + const float out_min = (0.24f - 0.06f * up_part) * (1.0f - 0.35f * to_own_side); + const float out_now = dot(sub(palm_goal, s), forward); + if (out_now < out_min) palm_goal = add(palm_goal, scaled(forward, out_min - out_now)); + const float top = s[1] + 0.04f + 0.22f * up_part + side_lift * to_own_side; + if (palm_goal[1] > top) palm_goal[1] = top; + } + { + // The body as a column through the hips and chest: the hand's target, and the straight reach to + // it from the shoulder, kept out of it (pushed out along the way they came closest). + const Vec3 low = w[2].position, high = w[chest].position; + const float bottom = low[1] - 0.35f, top = high[1] + 0.15f; + const auto axis_at = [&](float y) { + const float t = std::clamp((y - low[1]) / std::max(high[1] - low[1], 0.05f), 0.0f, 1.0f); + return Vec3{low[0] + (high[0] - low[0]) * t, y, low[2] + (high[2] - low[2]) * t}; + }; + // As wide as the body is: inside the shoulders (the arm hangs from the column's edge). + Vec3 shoulder_out = sub(s, axis_at(s[1])); + shoulder_out[1] = 0.0f; + const float radius = std::min(body_radius, length(shoulder_out) * 0.85f); + for (int round = 0; round < 3; ++round) { + float nearest = radius; + Vec3 out_dir{}; + for (int k = 3; k <= 8; ++k) { + const Vec3 p = add(s, scaled(sub(palm_goal, s), static_cast(k) / 8.0f)); + if (p[1] < bottom || p[1] > top) continue; + Vec3 radial = sub(p, axis_at(p[1])); + radial[1] = 0.0f; + const float r = length(radial); + if (r < nearest) { + nearest = r; + Vec3 side_out = sub(s, w[chest].position); + side_out[1] = 0.0f; + out_dir = unit(radial, unit(side_out, {goal.hand == Hand::left ? -1.0f : 1.0f, 0, 0})); + } + } + if (nearest >= radius) break; + palm_goal = add(palm_goal, scaled(out_dir, radius - nearest + 0.01f)); + } + } + const float palm = goal.worn ? 0.0f : palm_from_wrist * scale, b_palm = b + palm; + // Never held at full stretch: the elbow keeps a bend (it comes in a little rather than the arm + // locking straight). + const float reach = (a + b_palm) * (goal.free ? 0.995f : 0.93f); + const float wanted = length(sub(palm_goal, s)); + const float d = std::clamp(wanted, std::abs(a - b_palm) + 0.02f, reach); + const Vec3 direction = unit(sub(palm_goal, s), {0, -1, 0}); + const Vec3 target = add(s, scaled(direction, d)); + // The elbow out from the body and a little down and back: the shoulder's side of the chest. + Vec3 side = sub(s, w[chest].position); + side[1] = 0.0f; + side = unit(side, {goal.hand == Hand::left ? -1.0f : 1.0f, 0, 0}); + // (Forward for the elbow: the way the arm reaches, level; reaching nearly straight up it has almost + // none, and would flip about, so it leans on the way the item points then.) + const Vec3 forward = level_heading(aim); + Vec3 ahead{direction[0], 0.0f, direction[2]}; + ahead = add(ahead, scaled(forward, std::max(0.0f, 0.6f - length(ahead)))); + ahead = unit(ahead, forward); + Vec3 pole = add(add(scaled(side, 0.8f), Vec3{0.0f, -0.45f, 0.0f}), scaled(ahead, -0.35f)); + { + // Raised (up to the eye, up over the head): the elbow comes round to the front and a little out, + // as an arm does lifting something up in front of the face, gradually with the aim; never back + // behind the shoulder, never winged straight out. + const float lifted = std::clamp((rotate(aim, {0.0f, 0.0f, -1.0f})[1] - 0.1f) / 0.8f, 0.0f, 1.0f); + const float ease_in = lifted * lifted * (3.0f - 2.0f * lifted); + if (ease_in > 0.0f) { + const Vec3 lifted_pole = add(add(scaled(side, 0.85f), Vec3{0.0f, -0.4f, 0.0f}), scaled(forward, 0.35f)); + pole = add(scaled(pole, 1.0f - ease_in), scaled(lifted_pole, ease_in)); + } + } + pole = sub(pole, scaled(direction, dot(pole, direction))); + { + // The elbow hangs: whatever way the arm reaches, it bends with the elbow below the line from + // shoulder to hand (and out to the side), never folding up over the shoulder. (Reaching up high, + // straight down from that line would put it back behind the shoulder: there it only comes down + // as far as it can without going back.) + Vec3 hang = sub(Vec3{0.0f, -1.0f, 0.0f}, scaled(direction, -direction[1])); + const float back = dot(hang, forward); + if (back < 0.0f) hang = sub(hang, scaled(forward, back)); + hang = sub(hang, scaled(direction, dot(hang, direction))); + const float hang_len = length(hang); + if (hang_len > 0.05f) pole = add(unit(pole, side), scaled(hang, 0.6f / hang_len)); + } + pole = unit(pole, side); + const float cos_a = std::clamp((a * a + d * d - b_palm * b_palm) / (2.0f * a * d), -1.0f, 1.0f); + const float sin_a = std::sqrt(std::max(0.0f, 1.0f - cos_a * cos_a)); + const Vec3 elbow = add(s, add(scaled(direction, a * cos_a), scaled(pole, a * sin_a))); + const Vec3 wrist = add(elbow, scaled(unit(sub(target, elbow), direction), b)); + // The upper arm turned onto the new elbow, then the forearm (as the upper arm left it) onto the wrist. + const Quat turn_upper = arc(sub(e, s), sub(elbow, s)); + const Quat upper_world = normalised(multiply(turn_upper, w[upper].rotation)); + const Quat turn_fore = arc(rotate(turn_upper, sub(h, e)), sub(wrist, elbow)); + const Quat fore_world = normalised(multiply(turn_fore, multiply(turn_upper, w[fore].rotation))); + // Back to each joint's own rotation, part way by the weight. + const float weight = std::clamp(goal.weight, 0.0f, 1.0f); + auto& upper_bone = pose[joints[upper]]; + auto& fore_bone = pose[joints[fore]]; + upper_bone.rotation = blend(upper_bone.rotation, normalised(multiply(conjugate(w[shoulder].rotation), upper_world)), weight); + fore_bone.rotation = blend(fore_bone.rotation, normalised(multiply(conjugate(upper_world), fore_world)), weight); + if (goal.worn) { + // The hand, from the forearm as it now is, turned so the worn item points along the aim. + if (!compose(pose, joints, w)) return {}; + auto& hand_bone = pose[joints[hand_at]]; + hand_bone.rotation = blend(hand_bone.rotation, normalised(multiply(conjugate(w[fore].rotation), hand_world)), weight); + } + // Where the hand ended up, from the pose as it now is. + if (!compose(pose, joints, w)) return {}; + Held held; + if (goal.worn) { + held.item.rotation = normalised(multiply(w[hand_at].rotation, worn.item)); + held.item.position = add(w[hand_at].position, scaled(rotate(w[hand_at].rotation, worn.from_wrist), scale)); + } else { + // Held: the grip lies on the forearm's line, past the wrist. + const Vec3 along = unit(sub(w[hand_at].position, w[fore].position), {0, -1, 0}); + held.item.position = add(w[hand_at].position, scaled(along, palm_from_wrist * scale)); + held.item.rotation = aim; + } + held.front = add(held.item.position, rotate(held.item.rotation, goal.front_from_grip)); + held.valid = finite(held.item.position) && finite(held.front); + held.reached = wanted <= reach + 0.01f; + return held; +} +} // namespace + +bool crouch(std::span pose, float depth, float twist) noexcept { + if (!((depth > 0.001f || std::abs(twist) > 0.001f) && std::isfinite(depth) && std::isfinite(twist))) return true; + depth = std::clamp(depth, 0.0f, 0.6f); + struct Leg { + std::span path; + std::array before{}; + }; + std::array legs{{{right_leg}, {left_leg}}}; + for (auto& leg : legs) + if (!compose(pose, leg.path, leg.before)) return false; + // The hips down, in their parent's frame (the skater's placement). + const auto& parent = legs[0].before[1]; + auto& hips = pose[hips_joint]; + const Vec3 drop = rotate(conjugate(parent.rotation), Vec3{0.0f, -depth, 0.0f}); + hips.position = add(hips.position, scaled(drop, 1.0f / std::max(parent.scale, 0.1f))); + // Turned round the upright, the feet staying where they are. + if (std::abs(twist) > 0.001f) { + std::array now{}; + if (!compose(pose, legs[0].path, now)) return false; + hips.rotation = local_of(parent, normalised(multiply(about_up(twist), now[2].rotation))); + } + for (auto& leg : legs) { + std::array now{}; + if (!compose(pose, leg.path, now)) return false; + const Vec3 hip = now[3].position, knee = leg.before[4].position, ankle = leg.before[5].position; + const float a = length(sub(leg.before[4].position, leg.before[3].position)), b = length(sub(ankle, knee)); + if (!(a > 0.1f && b > 0.1f)) continue; + const float d = std::clamp(length(sub(ankle, hip)), std::abs(a - b) + 0.01f, (a + b) * 0.999f); + const Vec3 along = unit(sub(ankle, hip), {0, -1, 0}); + // The knee bends the way it already does (forward of the hip-to-ankle line). + const Vec3 old_line = unit(sub(ankle, leg.before[3].position), along); + const Vec3 old_mid = add(leg.before[3].position, scaled(old_line, dot(sub(knee, leg.before[3].position), old_line))); + Vec3 pole = sub(knee, old_mid); + pole = sub(pole, scaled(along, dot(pole, along))); + pole = unit(pole, Vec3{0.0f, 0.0f, 1.0f}); + const float cos_a = std::clamp((a * a + d * d - b * b) / (2.0f * a * d), -1.0f, 1.0f); + const float sin_a = std::sqrt(std::max(0.0f, 1.0f - cos_a * cos_a)); + const Vec3 new_knee = add(hip, add(scaled(along, a * cos_a), scaled(pole, a * sin_a))); + // Thigh, then shin, turned onto the new knee and the ankle; the foot keeps its turn in the world. + const Quat thigh_world = normalised(multiply(arc(sub(now[4].position, hip), sub(new_knee, hip)), now[3].rotation)); + pose[leg.path[3]].rotation = local_of(now[2], thigh_world); + if (!compose(pose, leg.path, now)) return false; + const Quat shin_world = normalised(multiply(arc(sub(now[5].position, now[4].position), sub(ankle, now[4].position)), now[4].rotation)); + pose[leg.path[4]].rotation = local_of(now[3], shin_world); + if (!compose(pose, leg.path, now)) return false; + pose[leg.path[5]].rotation = local_of(now[4], leg.before[5].rotation); + } + return true; +} + +bool hunch(std::span pose, const Vec3& toward, float angle, float look, float twist) noexcept { + if (!(std::abs(angle) + std::abs(look) + std::abs(twist) > 1e-4f) || !finite(toward)) return true; + Vec3 level{toward[0], 0.0f, toward[2]}; + if (length(level) < 1e-4f) return true; + level = unit(level, {0, 0, 1}); + // Turning about this bends the top of the body over toward `level`. + const Vec3 axis = unit(cross(Vec3{0.0f, 1.0f, 0.0f}, level), {1, 0, 0}); + // Spine, Spine1, Spine2, Spine3 share the back's angle; Neck, Neck1 and Head the look. + constexpr std::array share{0.15f, 0.25f, 0.3f, 0.3f, 0.35f, 0.25f, 0.4f}; + constexpr std::array twist_share{0.1f, 0.1f, 0.15f, 0.2f, 0.15f, 0.1f, 0.2f}; + for (std::size_t i = 0; i < back_joints.size(); ++i) { + std::array w{}; + if (!compose(pose, head_chain, w)) return false; + const std::size_t at = 3 + i; // its place in head_chain + const float turn = (i < 4 ? angle : look) * share[i]; + const Quat bend{axis[0] * std::sin(turn / 2), axis[1] * std::sin(turn / 2), axis[2] * std::sin(turn / 2), std::cos(turn / 2)}; + const Quat world = normalised(multiply(bend, multiply(about_up(twist * twist_share[i]), w[at].rotation))); + pose[back_joints[i]].rotation = local_of(w[at - 1], world); + } + return true; +} + +Held carry(std::span pose, const Goal& goal) noexcept { + if (pose.size() <= 343 || !finite(goal.front) || !finite(goal.aim) || !finite(goal.front_from_grip)) return {}; + const float body_weight = std::clamp(goal.weight, 0.0f, 1.0f); + if (std::abs(goal.turn) > 1e-4f && std::isfinite(goal.turn)) { + // The whole body turned round the upright through the hips: the hips (everything hangs from + // them) turned in place. + std::array w3{}; + if (!compose(pose, std::span(right_leg).first(3), w3)) return {}; + pose[hips_joint].rotation = local_of(w3[1], normalised(multiply(about_up(goal.turn * body_weight), w3[2].rotation))); + } + // Turning on the spot: the feet step round with it, one then the other, each lifted a little and + // set down a little in under the hips. + if (goal.step > 0.01f && std::isfinite(goal.step_phase)) { + const float phase = goal.step_phase - std::floor(goal.step_phase); + const bool right_foot = phase < 0.5f; + const float half = right_foot ? phase * 2.0f : (phase - 0.5f) * 2.0f; + const float lift = std::sin(half * 3.14159265f) * 0.09f * std::clamp(goal.step, 0.0f, 1.0f) * body_weight; + std::array leg{}; + const auto path = right_foot ? std::span(right_leg) : std::span(left_leg); + if (lift > 0.002f && compose(pose, path, leg)) { + Vec3 in = sub(leg[2].position, leg[5].position); + in[1] = 0.0f; + const float inward = std::min(length(in), 0.06f) * std::sin(half * 3.14159265f); + if (!step_foot(pose, path, lift, scaled(unit(in, {0, 0, 0}), inward * 0.5f))) return {}; + } + } + // Which way it points against the way the body faces (round the upright), for the body turning to + // it: the hips a quarter, the back and shoulders and head the rest. + float full = 0.0f, pitch = 0.0f; + Vec3 facing{0, 0, 1}; + if (goal.hunch > 0.0f || goal.crouch > 0.0f) { + facing = body_facing(pose); + const Vec3 level = level_heading(goal.aim); + full = std::clamp(std::atan2(dot(cross(facing, level), {0.0f, 1.0f, 0.0f}), dot(facing, level)), -1.6f, 1.6f); + pitch = std::asin(std::clamp(rotate(normalised(goal.aim), {0.0f, 0.0f, -1.0f})[1], -1.0f, 1.0f)); // down is negative + } + if (goal.still) + for (std::size_t i = 0; i < back_joints.size(); ++i) + pose[back_joints[i]].rotation = blend(pose[back_joints[i]].rotation, standing_back[i], body_weight); + constexpr float hips_share = 0.25f; + const bool turning = goal.hunch > 0.0f; + if ((goal.crouch > 0.0f || turning) && !crouch(pose, goal.crouch * body_weight, turning ? full * hips_share * body_weight : 0.0f)) return {}; + if (turning) { + // Over it, the way the body faces: toward the aim, but no more than 60 degrees off the body's own + // facing (never folded backwards). + const Vec3 toward = rotate(about_up(std::clamp(full, -1.05f, 1.05f)), facing); + // Pointed well up, it is held up to the eye instead: no curl over it, the head tilted back to + // it (eased in with the tilt). + const float up_t = std::clamp((pitch - 0.3f) / 0.6f, 0.0f, 1.0f); + // The back over it as it goes down with the aim, a little at level, more aimed down; the head + // down to it the same way, a touch at level, more as it goes down; aimed up, the head back. + const float amount = goal.hunch * std::clamp(0.45f - pitch * (pitch < 0.0f ? 0.55f : 0.7f), 0.2f, 1.2f) * body_weight * (1.0f - up_t); + const float look = std::clamp(0.2f - pitch * (pitch < 0.0f ? 0.18f : 0.45f), 0.1f, 0.65f) * body_weight * (1.0f - up_t) - + up_t * std::min(pitch, 1.2f) * 0.45f * body_weight; + // Up to the eye: the shoulders turn a little, the holding arm's shoulder coming forward, so the + // arm reaches out in front of the face. + float shoulders = 0.0f; + if (up_t > 0.0f) { + std::array arm{}; + if (compose(pose, chain(goal.hand), arm)) { + const Vec3 r = sub(arm[upper].position, arm[chest].position); + const float way = dot(cross({0.0f, 1.0f, 0.0f}, r), facing) >= 0.0f ? 1.0f : -1.0f; + shoulders = way * 0.35f * up_t * body_weight; + } + } + // The shoulders and head turn the rest of the way to it off to the side. + if (!hunch(pose, toward, amount, look, full * (1.0f - hips_share) * body_weight + shoulders)) return {}; + } + One first; + first.hand = goal.hand; + first.front = goal.front; + first.aim = goal.aim; + first.front_from_grip = goal.front_from_grip; + first.worn = goal.worn; + first.weight = goal.weight; + const auto held = hold_one(pose, first); + if (!held.valid || !goal.both) return held; + // The other hand to its side, underneath, where the first one put it. + Vec3 support = support_from_grip; + if (goal.hand == Hand::left) support[0] = -support[0]; + const Vec3 point = add(held.item.position, rotate(held.item.rotation, support)); + One other; + other.hand = goal.hand == Hand::left ? Hand::right : Hand::left; + other.aim = normalised(goal.aim); + other.weight = goal.weight; + other.free = true; + other.front = point; // (front_from_grip 0: its grip goes to `point`) + (void)hold_one(pose, other); + return held; +} + +float pushing(std::span pose) noexcept { + std::array r{}, l{}; + if (!compose(pose, right_leg, r) || !compose(pose, left_leg, l)) return 0.0f; + const float apart = std::abs(r[5].position[1] - l[5].position[1]); + return std::clamp((apart - 0.04f) / 0.06f, 0.0f, 1.0f); +} + +Vec3 body_facing(std::span pose) noexcept { + std::array right_thigh{}, left_thigh{}; + if (!compose(pose, right_leg, right_thigh) || !compose(pose, left_leg, left_thigh)) return {0, 0, 1}; + Vec3 across = sub(left_thigh[3].position, right_thigh[3].position); + across[1] = 0.0f; + return unit(cross(across, {0.0f, 1.0f, 0.0f}), {0, 0, 1}); +} +} diff --git a/Extension/Skater/carry.h b/Extension/Skater/carry.h new file mode 100644 index 00000000..8dcb2931 --- /dev/null +++ b/Extension/Skater/carry.h @@ -0,0 +1,82 @@ +#pragma once +#include "Extension/Skater/arm_reach.h" +#include +#include +#include +#include + +// Something held out in the hands and aimed, the way a person holds a camera or a phone they point: +// level, out in front at the chest; aimed down, lower and closer, down to the stomach; aimed up, up to +// the eye and over the head. The elbows keep a bend and the arms go round the body; the other hand can +// hold it too, from the side and underneath. The body can crouch over it (the feet kept where they +// are), curl its back over it and look down at it, turn on the spot after it and step round as it does. +// Pure maths on the 395-joint AnimBase pose (arm_reach.h), made from the Filmer's arm holding its +// camera (PR 343, filmer_hold.cpp); skater_animation.cpp applies it as the pose is evaluated, drawn and sent. +namespace dingosdk::carry { +using anim_studio::Joint; +using anim_studio::Quat; +using anim_studio::Vec3; +using arm_reach::Hand; +using arm_reach::Placed; + +// The legs from the root: the last three are thigh, shin and foot. The hips (7) carry them and the spine. +inline constexpr std::array right_leg{0, 1, 7, 8, 9, 10}; +inline constexpr std::array left_leg{0, 1, 7, 341, 342, 343}; +inline constexpr std::uint16_t hips_joint = 7; +// The back and head from the root: Spine, Spine1, Spine2, Spine3, Neck, Neck1, Head. +inline constexpr std::array head_chain{0, 1, 7, 42, 43, 44, 45, 101, 102, 103}; +inline constexpr std::array back_joints{42, 43, 44, 45, 101, 102, 103}; +// The back, neck and head as the skeleton stands (each joint's own turn, from its bind pose). +inline constexpr std::array standing_back{{{0.0f, 0.0f, 0.16592f, 0.98614f}, + {0.0f, 0.0f, -0.19038f, 0.98171f}, + {0.0f, 0.0f, -0.10393f, 0.99458f}, + {0.0f, 0.0f, 0.22027f, 0.97544f}, + {0.0f, 0.0f, 0.11357f, 0.99353f}, + {0.0f, 0.0f, -0.19607f, 0.98059f}, + {0.0f, 0.0f, -0.00959f, 0.99995f}}}; +// Every joint carry() may write, lowest first: the hips and legs, the back and head, both arms. +inline constexpr std::array written{7, 8, 9, 10, 42, 43, 44, 45, 46, 47, 48, 49, 101, 102, 103, 275, 276, 277, 278, 341, 342, 343}; + +// Worn on the hand (a cosmetic weighted wholly to the hand): the item's rows (right, up, back) in the +// hand joint's own frame, and where its grip is from the hand joint, in that frame, both from the +// skeleton's bind pose. The hand turns so the item points along the aim. +struct Grip { + Quat item{0, 0, 0, 1}; + Vec3 from_wrist{}; +}; +struct Goal { + Hand hand = Hand::right; + Vec3 front{}; // where the item's front (a lens, a screen) should be, in the world + Quat aim{0, 0, 0, 1}; // the item's rotation in the world: its -Z the way it points, +Y its top + Vec3 front_from_grip{}; // its front from where the hand grips it, in its own frame + std::optional worn; + float weight = 1.0f; // 0: the body as the animation has it, 1: carrying + bool both{}; // the other hand holds it too, from the side and underneath + float crouch{}; // how far the hips go down (metres), the feet kept where they are + float hunch{}; // how far the back curls over it (radians), the head looking down at it + bool still{}; // the back, neck and head from a still standing pose (no idle fidgets) + float turn{}; // the whole body turned round the upright through the hips (radians) + float step{}, step_phase{}; // turning on the spot: how much the feet step (0..1), where in the stride (one per 1) +}; +struct Held { + bool valid{}; + Placed item; // where the item's grip is, turned the way it points + Vec3 front{}; // where its front ended up (short of the goal when out of reach) + bool reached{}; +}; +// Bends the pose (in place) to carry the item as the goal says. Leaves the pose alone and returns +// invalid when the joints are not there, or the arm's bones are not an arm's length. +Held carry(std::span pose, const Goal& goal) noexcept; +// How much a pose is pushing on a board (0 to 1): one foot down on the ground well below the other. +float pushing(std::span pose) noexcept; +// Which way the body faces (level, in the world), from the line across its hips; {0, 0, 1} when the +// legs are not there. +Vec3 body_facing(std::span pose) noexcept; +// Lowers the hips by `depth` (metres) and turns them round the upright by `twist` (radians), bending +// the knees so each foot stays where it was, turned as it was. False when the legs are not there. +bool crouch(std::span pose, float depth, float twist = 0.0f) noexcept; +// Curls the back over toward `toward` (a direction in the world, level) by `angle` radians spread +// down the spine, and the neck and head on down by `look` more; first turns the upper body and the +// head round the upright by `twist` radians (most of it in the neck and head). +bool hunch(std::span pose, const Vec3& toward, float angle, float look, float twist = 0.0f) noexcept; +} diff --git a/Extension/Skater/client_debug.cpp b/Extension/Skater/client_debug.cpp index b748b7bc..dfb62b53 100644 --- a/Extension/Skater/client_debug.cpp +++ b/Extension/Skater/client_debug.cpp @@ -1276,12 +1276,22 @@ constexpr ULONGLONG cycle_flags_lifetime_ms = 250; bool first_person_camera_height_write(bool high) noexcept { const auto published = cycle_published.load(std::memory_order_acquire); const auto flags = cycle_flags.load(std::memory_order_acquire); + // A plugin's camera in the cycle (reskate.camera.cycle), last; its flags come from the plugin runtime. + auto& plugin = first_person::plugin_link; + const auto plugin_published = plugin.published.load(std::memory_order_acquire); + const auto plugin_flags = plugin.flags.load(std::memory_order_acquire); const auto now = GetTickCount64(); - if (now < published || now - published > cycle_flags_lifetime_ms) return high; + const bool fresh = now >= published && now - published <= cycle_flags_lifetime_ms; + const bool plugin_fresh = now >= plugin_published && now - plugin_published <= cycle_flags_lifetime_ms; + if (!fresh && !plugin_fresh) return high; const auto write = first_person::cycle_height_write( - {(flags & cycle_enabled) != 0, (flags & cycle_first_person) != 0, (flags & cycle_can_enter) != 0}, high); + {fresh && (flags & cycle_enabled) != 0, fresh && (flags & cycle_first_person) != 0, fresh && (flags & cycle_can_enter) != 0, + plugin_fresh && (plugin_flags & first_person::plugin_flag_enabled) != 0, plugin_fresh && (plugin_flags & first_person::plugin_flag_on) != 0, + plugin_fresh && (plugin_flags & first_person::plugin_flag_can_enter) != 0}, + high); if (write.step != first_person::CycleStep::none) cycle_request.store(static_cast(write.step), std::memory_order_release); + if (write.plugin != first_person::CycleStep::none) plugin.request.store(static_cast(write.plugin), std::memory_order_release); return write.high; } void first_person_cycle_tick(SourceTrial& trial, std::uintptr_t client, bool can_control, bool phase, DWORD error) { diff --git a/Extension/Skater/outfit_overlay.cpp b/Extension/Skater/outfit_overlay.cpp index 00d8f541..11afd8f9 100644 --- a/Extension/Skater/outfit_overlay.cpp +++ b/Extension/Skater/outfit_overlay.cpp @@ -24,7 +24,7 @@ namespace dingosdk::outfit_overlay { namespace { using skater_items::SkaterItems; using skater_items::lower; -constexpr std::string_view allowed[] = {"tattoo", "moles", "eyewear"}; +constexpr std::string_view allowed[] = {"tattoo", "moles", "eyewear", "hand"}; struct Wanted { std::string owner, item; @@ -387,7 +387,7 @@ bool allowed_slot(std::string_view slot) noexcept { std::string set(std::string owner, std::string slot, std::string item, std::vector placement, bool covers) { auto& s = state(); slot = lower(slot); - if (!allowed_slot(slot)) return "only tattoo, moles and eyewear slots"; + if (!allowed_slot(slot)) return "only tattoo, moles, eyewear and hand wear slots"; if (const auto taken = s.wanted.find(slot); taken != s.wanted.end() && taken->second.owner != owner) return taken->second.owner + " has that slot"; if (!content_cache::catalogs().available) return "the paid items are not known yet; try again in a moment"; if (profile_runtime::reserved_cosmetic(item)) return "that is a paid store item"; diff --git a/Extension/Skater/outfit_overlay.h b/Extension/Skater/outfit_overlay.h index a0a9d0ab..b55d533e 100644 --- a/Extension/Skater/outfit_overlay.h +++ b/Extension/Skater/outfit_overlay.h @@ -9,8 +9,9 @@ // owner puts an item in a slot of the skater's recipe (by the slot's name: "tattoo_legl", "moles", // "eyewear"); the skater wears them over the outfit through the game's own recipe copy, and is given // back to the game while one of its menus is up (the menus change the outfit), when nothing is worn, -// and when a plugin stops. Only the face and skin layers (tattoos, moles) and eyewear, and never a -// paid store item. Others see what the skater wears. Client thread throughout. +// and when a plugin stops. Only the face and skin layers (tattoos, moles), eyewear and hand wear (the +// game has no menu for it: a camera in the hand), and never a paid store item. Others see what the +// skater wears. Client thread throughout. namespace dingosdk::outfit_overlay { // The slots items may go in: a slot's name holds one of these. bool allowed_slot(std::string_view slot) noexcept; diff --git a/Extension/Skater/skater_animation.cpp b/Extension/Skater/skater_animation.cpp index 2cf706d5..a817ceec 100644 --- a/Extension/Skater/skater_animation.cpp +++ b/Extension/Skater/skater_animation.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include // The pose paths of Animation Studio (PR 297, anim_studio.cpp: on_animation, on_render, @@ -62,6 +63,8 @@ double now_seconds() noexcept { } // ---- transforms ------------------------------------------------------------------------------------ +// The smallest scale a posed joint is given: as small as hiding the skater makes its placement joint. +constexpr float smallest_scale = 0.001f; // One transform after another (parent, then child in the parent's frame); uniform scale. Joint compose(const Joint& parent, const Joint& child) noexcept { const float k = (parent.scale[0] + parent.scale[1] + parent.scale[2]) / 3.0f; @@ -265,10 +268,16 @@ struct Plan { std::uint64_t record_serial{}; std::optional reach; double reach_until{}, joints_until{}; // now_seconds() until which the reach holds, and the joints are read + Shown reach_shown{}; + std::optional carry; // carry(), until carry_until + double carry_until{}; + bool carry_push{}; + Shown carry_shown{}; std::vector joints_read; // joint(): the joints asked for, in order std::vector offsets; // pose(), until offsets_until float offsets_weight{1}; double offsets_until{}; + Shown offsets_shown{}; }; // The game menu's character room (PR 297's studio room): the spot of the menu's skater, how far from // it a rig counts as in the room, and the room's floor (measured 2026-10-10 on Stadium 1 and San @@ -310,6 +319,15 @@ struct Live { std::atomic base{}, component{}, board_holder{}; std::vector bones; std::vector pose, custom, scratch; + std::vector sent; // the pose the others are sent, when it is not the one drawn (Shown) + float carry_push{}; // how much a foot is down pushing, eased (carry's push_straightens) + // The game's own scale of each joint a pose() scaled in the pose drawn. The game never sets a joint's + // scale again, so the scale the last render wrote is still in the buffer at the next update: pose() + // scaled it again on top (a forearm at 0.001 was at 1e-6 the next frame, and on to 0), and it stayed + // when the pose ended (Filmer's own-view forearm, 2026-10-22: the arms gone after Filmer stopped, + // and on foot the body physics, which read the buffer, lost the skater). Put back at every update. + std::map> own_scale; + std::uintptr_t own_scale_buffer{}; // In the room: the placement written for the last render, and the game's own one before it. Joint room_written, game_placement; bool room_written_valid{}, game_placement_valid{}; @@ -544,8 +562,10 @@ void on_animation(std::uintptr_t component) noexcept { const bool reaching = snap->reach && now < snap->reach_until; const bool joints_wanted = now < snap->joints_until && !snap->joints_read.empty(); const bool posing = !snap->offsets.empty() && now < snap->offsets_until; + const bool carrying = snap->carry && now < snap->carry_until; const bool in_room = room_shown.load(std::memory_order_acquire); - if (!busy && !reaching && !joints_wanted && !posing && !in_room && (!snap->allowed || (!snap->play && snap->replacements.empty()))) { + if (!busy && !reaching && !carrying && !joints_wanted && !posing && !in_room && l.own_scale.empty() && + (!snap->allowed || (!snap->play && snap->replacements.empty()))) { std::lock_guard lock(pending().mutex); pending().bones.clear(); SetLastError(error); @@ -584,6 +604,18 @@ void on_animation(std::uintptr_t component) noexcept { l.game_placement_valid = true; } if (!room_shown.load(std::memory_order_acquire)) l.room_written_valid = false; + // Scaled joints get the game's own scale back first, in the buffer too (the body physics read it + // after this update; only the render gets the posed scale), and are posed again from it. + if (l.own_scale_buffer != layout.buffer) { + l.own_scale.clear(); // another skater's buffer: the game's scales + l.own_scale_buffer = layout.buffer; + } + for (const auto& [i, scale] : l.own_scale) { + auto& b = l.bones[i]; + if (std::memcmp(b.scale.data(), scale.data(), sizeof(scale)) == 0) continue; + std::memcpy(b.scale.data(), scale.data(), sizeof(scale)); + (void)poke(layout.buffer + i * sizeof(NativeBone), b.scale.data(), sizeof(scale)); + } l.pose.resize(layout.count); for (std::size_t i = 0; i < layout.count; ++i) l.pose[i] = joint_of(l.bones[i]); if (snap->play) { @@ -724,39 +756,78 @@ void on_animation(std::uintptr_t component) noexcept { first = 2; last = layout.count; } - // Joints turned and scaled, after the clip and before any reach: they join what is written. - if (posing) { + // [first, last) of a pose grown to take in [from, to). + const auto widen = [](std::size_t& first, std::size_t& last, std::size_t from, std::size_t to) { + if (last > first) { + first = std::min(first, from); + last = std::max(last, to); + } else { + first = from; + last = to; + } + }; + // Joints turned and scaled, after the clip; then something carried; then an arm reaching. Each + // goes into the pose drawn, the pose sent, or both (Shown), and its joints join what is written. + std::vector scaled_now; // joints scaled in the pose drawn this update + const auto pose_joints = [&](std::vector& pose, std::size_t& first, std::size_t& last) { const float w = std::clamp(snap->offsets_weight, 0.0f, 1.0f); for (const auto& o : snap->offsets) { if (o.joint < 2 || o.joint >= layout.count) continue; - auto& j = l.pose[o.joint]; + auto& j = pose[o.joint]; j.rotation = normalised(multiply(j.rotation, slerp(Quat{0, 0, 0, 1}, o.rotation, w))); - const float s = 1.0f + (o.scale - 1.0f) * w; + // Never quite 0: a joint at scale 0 has no inverse. This small, it is as good as gone. + const float s = std::max(1.0f + (o.scale - 1.0f) * w, smallest_scale); + if (s != 1.0f && &pose == &l.pose) { + l.own_scale.try_emplace(o.joint, j.scale); + scaled_now.push_back(o.joint); + } for (auto& k : j.scale) k *= s; for (std::size_t a = 0; a < 3; ++a) j.position[a] += o.offset[a] * w; - if (last > first) { - first = std::min(first, o.joint); - last = std::max(last, o.joint + 1u); - } else { - first = o.joint; - last = o.joint + 1u; - } + widen(first, last, o.joint, o.joint + 1u); } + }; + if (carrying && snap->carry_push) { + // Pushing on the board: the knees mostly straighten while the foot is down (eased, from the + // pose as the animation left it). + const float pushing = carry::pushing(std::span(l.pose.data(), l.pose.size())); + l.carry_push += (pushing - l.carry_push) * 0.256f; + } else { + l.carry_push = 0.0f; } - // An arm reaching, after the clip: its joints join what is written. - if (reaching) { + const auto carry_item = [&](std::vector& pose, std::size_t& first, std::size_t& last) { + auto goal = *snap->carry; + goal.crouch *= 1.0f - 0.65f * l.carry_push; + if (carry::carry(std::span(pose.data(), pose.size()), goal).valid) + widen(first, last, carry::written.front(), carry::written.back() + 1u); + }; + const auto reach_arm = [&](std::vector& pose, std::size_t& first, std::size_t& last) { const auto& goal = *snap->reach; - if (arm_reach::reach(std::span(l.pose.data(), l.pose.size()), goal).valid) { - const std::size_t from = arm_reach::first_written(goal.hand), to = arm_reach::last_written(goal.hand) + 1u; - if (last > first) { - first = std::min(first, from); - last = std::max(last, to); - } else { - first = from; - last = to; - } + if (arm_reach::reach(std::span(pose.data(), pose.size()), goal).valid) + widen(first, last, arm_reach::first_written(goal.hand), arm_reach::last_written(goal.hand) + 1u); + }; + const auto for_everyone = [](bool on, Shown shown) { return on && shown == Shown::everyone; }; + if (for_everyone(posing, snap->offsets_shown)) pose_joints(l.pose, first, last); + if (for_everyone(carrying, snap->carry_shown)) carry_item(l.pose, first, last); + if (for_everyone(reaching, snap->reach_shown)) reach_arm(l.pose, first, last); + // What only the others see goes on a copy that is sent; what only you see, on the pose drawn. + const bool split = (posing && snap->offsets_shown != Shown::everyone) || (carrying && snap->carry_shown != Shown::everyone) || + (reaching && snap->reach_shown != Shown::everyone); + std::size_t sent_from = first, sent_to = last; + if (split) { + l.sent = l.pose; + const auto apply = [&](Shown who, bool on, Shown shown, const auto& step) { + if (!on || shown != who) return; + if (who == Shown::others) step(l.sent, sent_from, sent_to); + else step(l.pose, first, last); + }; + for (const auto who : {Shown::others, Shown::me}) { + apply(who, posing, snap->offsets_shown, pose_joints); + apply(who, carrying, snap->carry_shown, carry_item); + apply(who, reaching, snap->reach_shown, reach_arm); } } + // A joint no longer scaled has had its own scale back since the top of this update: forgotten. + std::erase_if(l.own_scale, [&](const auto& entry) { return std::ranges::find(scaled_now, entry.first) == scaled_now.end(); }); if (joints_wanted) { const std::span pose(l.pose.data(), l.pose.size()); thread_local std::vector> placed; @@ -821,21 +892,27 @@ void on_animation(std::uintptr_t component) noexcept { pr.last_size = size; Probe::step(l.pose, pr.last_out, pr.out_turn, pr.out_move); } - // Other players are never sent the room: from joint 2 on only. - const std::size_t sent_first = std::max(first, 2); - auto& sp = sent_pose(); - std::lock_guard sent_lock(sp.mutex); - sp.component = component; - sp.first = static_cast(sent_first); - sp.last = static_cast(std::max(last, sent_first)); - sp.joints.assign(l.pose.begin() + static_cast(sent_first), - l.pose.begin() + static_cast(std::max(last, sent_first))); - sp.at = at; } else { auto& p = pending(); std::lock_guard lock(p.mutex); p.bones.clear(); } + // What the others are sent: the pose drawn, or its own copy (Shown). Other players are never + // sent the room: from joint 2 on only. + const auto& sent = split ? l.sent : l.pose; + if (!split) sent_from = first, sent_to = last; + if (sent_to > sent_from) { + for (std::size_t i = sent_from; i < sent_to; ++i) + if (!finite(sent[i])) throw std::runtime_error("a pose for the others was broken; nothing was sent"); + const std::size_t sent_first = std::max(sent_from, 2); + auto& sp = sent_pose(); + std::lock_guard sent_lock(sp.mutex); + sp.component = component; + sp.first = static_cast(sent_first); + sp.last = static_cast(std::max(sent_to, sent_first)); + sp.joints.assign(sent.begin() + static_cast(sent_first), sent.begin() + static_cast(std::max(sent_to, sent_first))); + sp.at = at; + } auto& pb = pending_board(); std::lock_guard lock(pb.mutex); pb.weight = board_weight > 0 && finite(board_target) ? board_weight : 0; @@ -1074,17 +1151,26 @@ void pause(bool paused) { bool playing() noexcept { return plan_draft.play.has_value() && finished_serial.load(std::memory_order_acquire) != plan_draft.serial; } -void pose(std::vector offsets, float weight) { +void pose(std::vector offsets, float weight, Shown shown) { plan_draft.offsets = std::move(offsets); plan_draft.offsets_weight = weight; + plan_draft.offsets_shown = shown; plan_draft.offsets_until = plan_draft.offsets.empty() ? 0 : now_seconds() + 0.3; publish(); } -void reach(std::optional goal) { +void reach(std::optional goal, Shown shown) { plan_draft.reach = std::move(goal); + plan_draft.reach_shown = shown; plan_draft.reach_until = plan_draft.reach ? now_seconds() + 0.3 : 0; publish(); } +void carry(std::optional goal, bool push_straightens, Shown shown) { + plan_draft.carry = std::move(goal); + plan_draft.carry_push = push_straightens; + plan_draft.carry_shown = shown; + plan_draft.carry_until = plan_draft.carry ? now_seconds() + 0.3 : 0; + publish(); +} std::optional joint(std::uint16_t index) { if (index >= 395) return std::nullopt; const double now = now_seconds(); @@ -1181,7 +1267,8 @@ void tick(std::uintptr_t base, std::uintptr_t component, bool allowed) { } const double now = now_seconds(); const bool wanted = plan_draft.play || !plan_draft.replacements.empty() || recording() || - (plan_draft.reach && now < plan_draft.reach_until) || now < plan_draft.joints_until || + (plan_draft.reach && now < plan_draft.reach_until) || (plan_draft.carry && now < plan_draft.carry_until) || + now < plan_draft.joints_until || (!plan_draft.offsets.empty() && now < plan_draft.offsets_until) || room_watch.load(std::memory_order_acquire) || room_shown.load(std::memory_order_acquire) || now < room_restore_until.load(std::memory_order_acquire); if (wanted && !hooked && base) { diff --git a/Extension/Skater/skater_animation.h b/Extension/Skater/skater_animation.h index 3634b814..2404fef0 100644 --- a/Extension/Skater/skater_animation.h +++ b/Extension/Skater/skater_animation.h @@ -1,6 +1,7 @@ #pragma once #include "Extension/AnimationStudio/anim_core.h" #include "Extension/Skater/arm_reach.h" +#include "Extension/Skater/carry.h" #include #include #include @@ -55,19 +56,26 @@ void set_replacements(std::vector list); bool record(float seconds); bool recording() noexcept; std::shared_ptr take_recording(); -// An arm reaching (arm_reach.h), after any clip, in the pose drawn and sent to the other players; -// none ends it. It holds for 0.3 s: set it every tick. Unlike clips it is allowed in a throwdown. -void reach(std::optional goal); +// Who sees a pose, a carry or a reach: everyone (the pose drawn here and sent), only the other players +// (sent, not drawn: you look through a camera your skater is seen holding), or only you (drawn, not sent). +enum class Shown : std::uint8_t { everyone, others, me }; +// An arm reaching (arm_reach.h), after any clip and carry; none ends it. It holds for 0.3 s: set it +// every tick. Unlike clips it is allowed in a throwdown. +void reach(std::optional goal, Shown shown = Shown::everyone); +// Something held out in the hands (carry.h), after any pose and before any reach; none ends it. It +// holds for 0.3 s: set it every tick. `push_straightens`: riding, the knees straighten (most of the +// crouch let go) while a foot is down pushing. +void carry(std::optional goal, bool push_straightens = false, Shown shown = Shown::everyone); // Joints turned (in their own frame) and scaled on top of the animation and any clip, before any -// reach, in the pose drawn and sent; `weight` 0..1 of the way. Joints 0 and 1 (the skater's world -// placement) are left alone. It holds for 0.3 s: set it every tick; empty ends it. +// carry and reach; `weight` 0..1 of the way. Joints 0 and 1 (the skater's world placement) are left +// alone. It holds for 0.3 s: set it every tick; empty ends it. struct PoseOffset { std::uint16_t joint{}; anim_studio::Quat rotation{0, 0, 0, 1}; float scale{1}; anim_studio::Vec3 offset{}; // added to its position, in its parent's frame }; -void pose(std::vector offsets, float weight); +void pose(std::vector offsets, float weight, Shown shown = Shown::everyone); // Where a joint (of the 395) was in the world in the last pose evaluated, after any clip and reach; // empty without a skater. Asking keeps that joint read for two seconds, so the first ask comes back // empty; at most 32 joints are read at once. diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 76efb470..470fc0e3 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -151,6 +151,7 @@ add_library(dingosdk_runtime SHARED Extension/Skater/pad_hide.cpp Extension/Skater/trick_rewrite.cpp Extension/Skater/arm_reach.cpp + Extension/Skater/carry.cpp Extension/UI/hud_corner.cpp Extension/Assets/morph_memory_pool.cpp Extension/Assets/native_render_resource_pool.cpp From 35824f4d1c1380a7e36f8dbedcaeba9866f32cc6 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Thu, 22 Oct 2026 19:05:51 -0500 Subject: [PATCH 6/8] Launcher: categories shown only or hidden in GET MODS and MY MODS, and the game's shader cache emptied when the mods change - The category menu on GET MODS, and a new one on MY MODS, take each category as shown only, hidden or either (a click goes round them), with how many mods are in each; each page's choice is kept in the launcher's settings. On MY MODS a mod Thunderstore does not list, or lists in no category, is under "No category". - Before a launch whose mods give the game other data than the last launch's (the mods with a layout, their files and load order, and the variants in play), the game's own shader cache (%LOCALAPPDATA%\ReSkate\Game\Skate\cache) is emptied: shaders kept from earlier mods were found to break the picture after many changes. The shader_cache folder the game ships with is left alone. --- Launcher/Test/mod_manager_tests.cpp | 25 +++++++ Launcher/Test/thunderstore_tests.cpp | 15 ++++ Launcher/gui_internal.h | 17 ++++- Launcher/gui_launcher.cpp | 51 ++++++++++++- Launcher/gui_mods.cpp | 25 ++++++- Launcher/gui_mods_browse.cpp | 103 ++++++++++++++++++++++----- Launcher/mod_manager.cpp | 27 +++++++ Launcher/mod_manager.h | 17 +++++ Launcher/thunderstore.cpp | 7 ++ Launcher/thunderstore.h | 9 +++ 10 files changed, 274 insertions(+), 22 deletions(-) diff --git a/Launcher/Test/mod_manager_tests.cpp b/Launcher/Test/mod_manager_tests.cpp index a418dec0..9d7b1013 100644 --- a/Launcher/Test/mod_manager_tests.cpp +++ b/Launcher/Test/mod_manager_tests.cpp @@ -466,6 +466,31 @@ int main() { "Only a retired mod is left out"); } + { + // What the mods give the game's data, for emptying the game's shader cache when it changes: the mods + // with a layout, in load order, with their files, and the variants in play. + const auto park = root / L"signature" / L"Park", skin = root / L"signature" / L"Skin"; + write(park / L"layout.toc", "toc"); + write(skin / L"layout.toc", "toc"); + const std::vector both{{"Park", park}, {"Skin", skin}}, swapped{{"Skin", skin}, {"Park", park}}; + const auto before = launcher_mods::game_data_signature(both, {}); + check(before == launcher_mods::game_data_signature(both, {}), "The same mods give the same signature"); + check(before != launcher_mods::game_data_signature(swapped, {}), "Another load order is another signature"); + check(before != launcher_mods::game_data_signature({{"Park", park}}, {}), "A mod taken out is another"); + check(before != launcher_mods::game_data_signature(both, {{"Skin", "red"}}), "Another variant is another"); + write(skin / L"Win32" / L"skin.sb", "bundle"); + check(before != launcher_mods::game_data_signature(both, {}), "A mod's files changing is another"); + // Emptying the cache: everything in it goes, the folder stays. + const auto cache = root / L"cache"; + write(cache / L"6803938.PcDx12", "shaders"); + write(cache / L"nested" / L"cache.manifest", "manifest"); + check(launcher_mods::empty_folder(cache).empty() && fs::is_directory(cache) && fs::is_empty(cache), "The shader cache is emptied, its folder kept"); + check(launcher_mods::empty_folder(root / L"no such folder").empty(), "No cache folder is nothing to do"); + check(launcher_mods::game_shader_cache().filename() == L"cache" && + launcher_mods::game_shader_cache().parent_path().filename() == L"Skate", + "The game's shader cache is ReSkate's own, under Game/Skate"); + } + std::error_code ignored; fs::remove_all(root, ignored); if (failures) { diff --git a/Launcher/Test/thunderstore_tests.cpp b/Launcher/Test/thunderstore_tests.cpp index b124df25..13df5082 100644 --- a/Launcher/Test/thunderstore_tests.cpp +++ b/Launcher/Test/thunderstore_tests.cpp @@ -87,6 +87,21 @@ int main() { check(desert.latest().file_size == 5368709120ull, "Sizes over 4 GB survive"); check(desert.downloads == 15 && desert.in_category("Maps") && !desert.in_category("Audio"), "Downloads add up across versions and categories are listed"); + { + // The GET MODS list's categories: hidden ones win, and with any shown only, a package needs one. + thunderstore::Package both; + both.categories = {"Maps", "Tools"}; + thunderstore::Package plain; + check(thunderstore::shown_by(both, {}) && thunderstore::shown_by(plain, {}), "No filter shows everything"); + check(thunderstore::shown_by(both, {{"Audio", "Maps"}, {}}) && !thunderstore::shown_by(plain, {{"Maps"}, {}}), + "Shown only: a package in any of them shows, one in none does not"); + check(!thunderstore::shown_by(both, {{}, {"Tools"}}) && thunderstore::shown_by(plain, {{}, {"Tools"}}), + "Hidden: a package in it is left out, one without it shows"); + check(!thunderstore::shown_by(both, {{"Maps"}, {"Tools"}}), "A hidden category wins over one shown only"); + const std::vector none{"No category"}; + check(thunderstore::shown_by(none, {{"No category"}, {}}) && !thunderstore::shown_by(none, {{}, {"No category"}}), + "An installed mod's own categories go by the same rule (MY MODS)"); + } const auto& cam = packages.at(1); check(cam.deprecated && cam.latest().icon.empty(), "Flags are read and non-HTTPS icons are dropped"); check(!error_of([] { thunderstore::parse_listing(R"({"error": "No cache available"})"); }).empty(), diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 610cf583..5d8f0756 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -218,6 +218,14 @@ struct Settings { std::uint64_t accepted_privacy{}, accepted_terms{}; // The version of the home screen's announcement the player hid (0: none): one newer shows. std::uint64_t announcement_hidden{}; + // GET MODS: the Thunderstore categories shown only, and those hidden (thunderstore.h CategoryFilter). + std::vector mod_categories_only, mod_categories_hidden; + // MY MODS: the same for the installed mods, by their Thunderstore categories ("No category" for a + // mod Thunderstore does not list, or lists in none). + std::vector installed_categories_only, installed_categories_hidden; + // What the mods gave the game's data at the last launch (launcher_mods::game_data_signature): a + // launch with other mods empties the game's shader cache first. + std::string shader_cache_mods; }; inline constexpr std::array log_levels{"trace", "debug", "info", "warning", "error", "critical", "off"}; @@ -495,7 +503,6 @@ struct Store { // The GET MODS page. std::array search{}; - std::string category; // empty = all int sort{}; std::string selected; // full_name, while its overview is open bool overview{}; // the overview popup is showing @@ -570,6 +577,8 @@ struct ModsPanel { std::array search{}; int filter{}; int order{}; + // Each Thunderstore package's categories by the folder it installs into, made again when a listing arrives. + std::map, std::less<>> categories_by_folder; std::set> marked; std::string anchor; // the last mod ticked: where a Shift-click range starts int place{}; // typed in a row's "move to position" popup @@ -700,6 +709,12 @@ ImTextureID package_icon(ModsPanel& panel, const thunderstore::Package& package) // when the mod never came from Thunderstore. void mod_icon(ModsPanel& panel, const thunderstore::Package* package, ImVec2 position, float size); void pump_icons(ModsPanel& panel); +// A category menu for a list of mods (GET MODS, MY MODS): each category shown only, hidden, or either, +// a click going round them, with how many mods are in each (category_count adds to that). One chosen +// before that no mod has now stays in the menu. True when the player changed what is chosen. +int& category_count(std::vector>& categories, const std::string& category); +bool category_menu(const char* id, std::vector& only, std::vector& hidden, + std::vector> categories, float width); // The GET MODS page body, under the page header. void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float height, bool installing); // Everything Thunderstore knows about one package, in a popup over the page. diff --git a/Launcher/gui_launcher.cpp b/Launcher/gui_launcher.cpp index 8fa842ef..a0639b0b 100644 --- a/Launcher/gui_launcher.cpp +++ b/Launcher/gui_launcher.cpp @@ -1,6 +1,8 @@ #include "gui_internal.h" #include "depot_output.h" +#include "mod_manager.h" +#include "text_encoding.h" #include "start_maps.h" #include "Engine/Core/Json/json.h" @@ -76,6 +78,20 @@ Settings load_settings(const fs::path& path) { settings.accepted_privacy = json.value("accepted_privacy_version", settings.accepted_privacy); settings.accepted_terms = json.value("accepted_terms_version", settings.accepted_terms); settings.announcement_hidden = json.value("announcement_hidden", settings.announcement_hidden); + // At most 64 categories each, every one a short name. + const auto categories = [&](const char* key) { + std::vector list; + if (const auto found = json.find(key); found != json.end() && found->is_array()) + for (const auto& item : *found) + if (item.is_string() && !item.get().empty() && item.get().size() <= 64 && list.size() < 64) + list.push_back(item.get()); + return list; + }; + settings.mod_categories_only = categories("mod_categories_only"); + settings.mod_categories_hidden = categories("mod_categories_hidden"); + settings.installed_categories_only = categories("installed_categories_only"); + settings.installed_categories_hidden = categories("installed_categories_hidden"); + settings.shader_cache_mods = json.value("shader_cache_mods", std::string()); } catch (const std::exception& exception) { logging::log(logging::Level::warning, logging::Channel::launcher, "Ignoring launcher settings: {}", exception.what()); } @@ -107,6 +123,11 @@ void save_settings(const fs::path& path, const Settings& settings) { json["accepted_privacy_version"] = settings.accepted_privacy; json["announcement_hidden"] = settings.announcement_hidden; json["accepted_terms_version"] = settings.accepted_terms; + json["mod_categories_only"] = settings.mod_categories_only; + json["mod_categories_hidden"] = settings.mod_categories_hidden; + json["installed_categories_only"] = settings.installed_categories_only; + json["installed_categories_hidden"] = settings.installed_categories_hidden; + json["shader_cache_mods"] = settings.shader_cache_mods; std::ofstream output(path, std::ios::binary | std::ios::trunc); output << json.dump(2) << '\n'; } @@ -509,13 +530,35 @@ void Launcher::run_download(bool validate) { fail(std::format("The Steam download did not finish (exit code {}). {}", code, output.message)); } +// Shaders the game built for one set of mods, kept in its shader cache and reused by the next launch, +// were found to break the picture once the mods had changed many times: a launch with other mods than +// the last one's (what they give the game's data: launcher_mods::game_data_signature) starts the game +// with its shader cache empty, and it builds the shaders again. +void clear_shader_cache_if_mods_changed(Settings& settings, const std::string& signature) { + if (signature == settings.shader_cache_mods) return; + const auto cache = launcher_mods::game_shader_cache(); + const auto failed = launcher_mods::empty_folder(cache); + if (failed.empty()) { + logging::log(logging::Level::info, logging::Channel::launcher, "The mods changed since the last launch: the game's shader cache is emptied ({}).", + launcher_text::utf8(cache.wstring())); + settings.shader_cache_mods = signature; + return; + } + // (Tried again at the next launch.) + for (const auto& line : failed) + logging::log(logging::Level::warning, logging::Channel::launcher, "The game's shader cache could not be emptied: {}", line); +} + // The merge the game does behind its splash, done here instead, so a mod that // cannot be merged is a question the launcher can ask rather than a surprise // three minutes into a loading screen. The game reuses what this builds. bool Launcher::run_mod_merge() { const auto root = mods_data_root(); std::error_code error; - if (!fs::is_directory(root / mods::mods_folder, error)) return true; + if (!fs::is_directory(root / mods::mods_folder, error)) { + clear_shader_cache_if_mods_changed(settings_, {}); + return true; + } set(Phase::merging, "Preparing mods", "Merging what is installed", 0); trace::Scope timing("launcher.mod_merge", "task"); auto catalog = mods::load_catalog(root, [this](const mods::MergeProgress& progress) { @@ -529,6 +572,12 @@ bool Launcher::run_mod_merge() { logging::write(logging::Level::info, logging::Channel::launcher, note); for (const auto& warning : catalog.warnings) logging::write(logging::Level::warning, logging::Channel::launcher, warning); + { + std::vector data; + for (const auto& mod : catalog.mods) + if (mod.provides_layout) data.push_back({mod.name, mod.directory}); + clear_shader_cache_if_mods_changed(settings_, launcher_mods::game_data_signature(data, catalog.variants_in_play)); + } std::vector problems; if (!catalog.issue.empty()) diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index cd5c7684..9dcf3908 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -401,7 +401,16 @@ std::string kind_of(const mods::Mod& mod) { // The rows the list shows, as places in the load order: what the search and the // filter leave, in the order asked for. -std::vector visible_mods(const ModsPanel& panel, const thunderstore::Installed& installed) { +// An installed mod's categories, as Thunderstore lists its package ("No category" when it lists none, +// or does not list the mod at all). +constexpr const char* no_category = "No category"; +std::vector categories_of(const ModsPanel& panel, const mods::Mod& mod) { + const auto found = panel.categories_by_folder.find(mod.name); + if (found == panel.categories_by_folder.end() || found->second.empty()) return {no_category}; + return found->second; +} + +std::vector visible_mods(const ModsPanel& panel, const thunderstore::Installed& installed, const thunderstore::CategoryFilter& categories) { const auto& entries = panel.list.entries; const auto query = folded(panel.search.data()); std::vector view; @@ -419,6 +428,7 @@ std::vector visible_mods(const ModsPanel& panel, const thunderstore::Instal case 7: keep = mod.outdated.empty() && (!conflict_winner(panel, mod.name).empty() || conflict_beats(panel, mod.name)); break; default: break; } + if (keep && !categories.empty()) keep = thunderstore::shown_by(categories_of(panel, mod), categories); if (keep && !query.empty()) keep = folded(mod.title).find(query) != std::string::npos || folded(mod.name).find(query) != std::string::npos || @@ -1124,7 +1134,7 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co // ------------------------------------------------ search, filter, order, check, refresh const float combo = S(150), check = S(160), refresh = S(96), gap = S(10); - ImGui::SetNextItemWidth(std::max(S(140), ImGui::GetContentRegionAvail().x - (combo + gap) * 2 - check - gap - refresh - gap)); + ImGui::SetNextItemWidth(std::max(S(140), ImGui::GetContentRegionAvail().x - (combo + gap) * 3 - check - gap - refresh - gap)); // Ctrl+F jumps to the search, as it does in a browser. if (!installing && ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_F, false) && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) @@ -1142,6 +1152,15 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co }; choice("##installed_filter", filter_names, panel.filter); ImGui::SameLine(0, gap); + { + std::vector> categories; + for (const auto& entry : entries) + for (const auto& category : categories_of(panel, entry.mod)) ++category_count(categories, category); + auto& settings = launcher.settings(); + if (category_menu("##installed_category", settings.installed_categories_only, settings.installed_categories_hidden, std::move(categories), combo)) + launcher.save(); + } + ImGui::SameLine(0, gap); choice("##installed_order", order_names, panel.order); ImGui::SameLine(0, gap); { @@ -1156,7 +1175,7 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co ImGui::SameLine(0, gap); if (ImGui::Button("Refresh", ImVec2(refresh, 0))) refresh_mods(launcher, panel); - const auto view = visible_mods(panel, installed); + const auto view = visible_mods(panel, installed, {launcher.settings().installed_categories_only, launcher.settings().installed_categories_hidden}); // Dragging, and typing a place, move a mod among the others in the load order: the order the // list must be shown in for where it lands to be what it looks like. const bool reorder = panel.order == 0 && !installing; diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index 3ba0f5c8..1477fe1a 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -208,7 +208,7 @@ bool nsfw(const ts::Package& package) { [](const std::string& category) { return lower(category) == "nsfw"; }); } -std::vector visible_packages(const Store& store, const ts::Installed& installed) { +std::vector visible_packages(const Store& store, const ts::Installed& installed, const ts::CategoryFilter& categories) { const auto query = lower(store.search.data()); std::vector result; for (const auto& package : store.packages) { @@ -217,7 +217,7 @@ std::vector visible_packages(const Store& store, const ts::I // or a category of that name. A deprecated one only shows once installed. if (nsfw(package)) continue; if (package.deprecated && !have) continue; - if (!store.category.empty() && !package.in_category(store.category)) continue; + if (!ts::shown_by(package, categories)) continue; if (!query.empty() && lower(package.title()).find(query) == std::string::npos && lower(package.owner).find(query) == std::string::npos && lower(package.latest().description).find(query) == std::string::npos) continue; @@ -240,6 +240,17 @@ std::vector visible_packages(const Store& store, const ts::I constexpr std::array sort_names{"Last updated", "Most downloaded", "Newest", "Top rated", "Name"}; +// GET MODS's categories: Thunderstore's, with how many mods are in each, kept in the launcher's settings. +void category_filter(Launcher& launcher, const Store& store, float width) { + std::vector> categories; + for (const auto& package : store.packages) { + if (nsfw(package)) continue; // (and no category that only they have) + for (const auto& category : package.categories) ++category_count(categories, category); + } + if (category_menu("##category", launcher.settings().mod_categories_only, launcher.settings().mod_categories_hidden, std::move(categories), width)) + launcher.save(); +} + // What installing `package` does now: install, update or reinstall. std::string action_label(const ts::Package& package, const ts::Installed& installed) { const auto found = installed.find(ts::folder_for(package.full_name)); @@ -250,6 +261,75 @@ std::string action_label(const ts::Package& package, const ts::Installed& instal } // namespace +int& category_count(std::vector>& categories, const std::string& category) { + for (auto& entry : categories) + if (entry.first == category) return entry.second; + return categories.emplace_back(category, 0).second; +} + +bool category_menu(const char* id, std::vector& only, std::vector& hidden, + std::vector> categories, float width) { + // One chosen before that no mod has now stays in the list, so it can be put back. + for (const auto* list : {&only, &hidden}) + for (const auto& category : *list) (void)category_count(categories, category); + std::sort(categories.begin(), categories.end(), [](const auto& a, const auto& b) { return lower(a.first) < lower(b.first); }); + const auto in = [](const std::vector& list, const std::string& name) { return std::find(list.begin(), list.end(), name) != list.end(); }; + std::string shown; + if (only.empty() && hidden.empty()) shown = "All categories"; + else if (hidden.empty()) shown = only.size() == 1 ? "Only " + only.front() : std::format("Only {} categories", only.size()); + else if (only.empty()) shown = hidden.size() == 1 ? "Hiding " + hidden.front() : std::format("Hiding {} categories", hidden.size()); + else shown = std::format("Only {}, hiding {}", only.size(), hidden.size()); + ImGui::SetNextItemWidth(width); + if (!ImGui::BeginCombo(id, shown.c_str(), ImGuiComboFlags_HeightLarge)) { + if (ImGui::IsItemHovered() && !(only.empty() && hidden.empty())) { + std::string tip; + if (!only.empty()) { + tip = "Showing only:"; + for (const auto& category : only) tip += "\n " + category; + } + if (!hidden.empty()) { + tip += std::string(tip.empty() ? "" : "\n") + "Hiding:"; + for (const auto& category : hidden) tip += "\n " + category; + } + ImGui::SetTooltip("%s", tip.c_str()); + } + return false; + } + bool changed = false; + ImGui::PushStyleColor(ImGuiCol_Text, color::muted); + ImGui::TextUnformatted("Click a category to show only it,"); + ImGui::TextUnformatted("again to hide it, again for either."); + ImGui::PopStyleColor(); + if (ImGui::Selectable("Every category", only.empty() && hidden.empty(), ImGuiSelectableFlags_DontClosePopups)) { + changed = !only.empty() || !hidden.empty(); + only.clear(); + hidden.clear(); + } + ImGui::Separator(); + for (const auto& [category, mods] : categories) { + const bool is_only = in(only, category), is_hidden = in(hidden, category); + const auto label = std::format("{} {} ({})##{}", is_only ? "+" : is_hidden ? "-" : " ", category, mods, category); + ImGui::PushStyleColor(ImGuiCol_Text, is_only ? color::good : is_hidden ? color::danger : color::text); + if (ImGui::Selectable(label.c_str(), is_only || is_hidden, ImGuiSelectableFlags_DontClosePopups)) { + // Either, then shown only, then hidden, then either again. + if (is_only) { + std::erase(only, category); + hidden.push_back(category); + } else if (is_hidden) { + std::erase(hidden, category); + } else { + only.push_back(category); + } + changed = true; + } + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", is_only ? "Shown only. Click to hide it." : is_hidden ? "Hidden. Click to show it as any other." : "Click to show only this category (and any others shown only)."); + } + ImGui::EndCombo(); + return changed; +} + // Placed by hand: a row lays its own icon, text and badges out. void mod_icon(ModsPanel& panel, const thunderstore::Package* package, ImVec2 position, float size) { const ImVec2 end(position.x + size, position.y + size); @@ -300,6 +380,8 @@ void collect_listing(ModsPanel& panel) { if (store.incoming_error.empty()) { store.packages = std::move(store.incoming); store.loaded = true; + panel.categories_by_folder.clear(); + for (const auto& package : store.packages) panel.categories_by_folder[ts::folder_for(package.full_name)] = package.categories; store.error.clear(); } else { store.error = std::move(store.incoming_error); @@ -484,20 +566,7 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float ImGui::SetKeyboardFocusHere(); ImGui::InputTextWithHint("##search", "Search mods (Ctrl+F)", store.search.data(), store.search.size()); ImGui::SameLine(); - std::vector categories; - for (const auto& package : store.packages) { - if (nsfw(package)) continue; // (and no category that only they have) - for (const auto& category : package.categories) - if (std::find(categories.begin(), categories.end(), category) == categories.end()) categories.push_back(category); - } - std::sort(categories.begin(), categories.end()); - ImGui::SetNextItemWidth(combo); - if (ImGui::BeginCombo("##category", store.category.empty() ? "All categories" : store.category.c_str())) { - if (ImGui::Selectable("All categories", store.category.empty())) store.category.clear(); - for (const auto& category : categories) - if (ImGui::Selectable(category.c_str(), store.category == category)) store.category = category; - ImGui::EndCombo(); - } + category_filter(launcher, store, combo); ImGui::SameLine(); ImGui::SetNextItemWidth(combo); if (ImGui::BeginCombo("##sort", sort_names[static_cast(std::clamp(store.sort, 0, 4))])) { @@ -550,7 +619,7 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float } ImGui::Spacing(); const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top)); - const auto packages = visible_packages(store, installed); + const auto packages = visible_packages(store, installed, {launcher.settings().mod_categories_only, launcher.settings().mod_categories_hidden}); // ------------------------------------------------ the list ImGui::BeginChild("##store_list", ImVec2(0, body), ImGuiChildFlags_NavFlattened); diff --git a/Launcher/mod_manager.cpp b/Launcher/mod_manager.cpp index cd44bb60..44ee9845 100644 --- a/Launcher/mod_manager.cpp +++ b/Launcher/mod_manager.cpp @@ -436,6 +436,33 @@ std::string install(const fs::path& mods, const fs::path& source, bool replace, } } +std::string game_data_signature(const std::vector& mods, const std::map>& variants) { + std::string text; + for (const auto& mod : mods) text += mod.name + '=' + mods::mod_fingerprint(mod.directory) + ';'; + for (const auto& [mod, variant] : variants) text += "variant " + mod + '=' + variant + ';'; + return text; +} + +std::filesystem::path game_shader_cache() { + std::array local{}; + const auto size = GetEnvironmentVariableW(L"LOCALAPPDATA", local.data(), static_cast(local.size())); + if (!size || size >= local.size()) return {}; + return fs::path(local.data()) / L"ReSkate" / L"Game" / L"Skate" / L"cache"; +} + +std::vector empty_folder(const std::filesystem::path& folder) { + std::vector failed; + std::error_code error; + if (folder.empty() || !fs::is_directory(folder, error)) return failed; + for (fs::directory_iterator it(folder, error), end; !error && it != end; it.increment(error)) { + std::error_code removing; + fs::remove_all(it->path(), removing); + if (removing) failed.push_back(utf8(it->path().filename().wstring()) + ": " + removing.message()); + } + if (error) failed.push_back("the folder could not be read: " + error.message()); + return failed; +} + void remove(const fs::path& mods, const std::string& name) { if (!mods::valid_mod_name(name)) fail("\"" + name + "\" is not a mod folder."); const auto target = mods / from_utf8(name); diff --git a/Launcher/mod_manager.h b/Launcher/mod_manager.h index 5bfabddb..5f9f69ae 100644 --- a/Launcher/mod_manager.h +++ b/Launcher/mod_manager.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -53,5 +54,21 @@ std::string install(const std::filesystem::path& mods_root, const std::filesyste // Moves Mods/ to the Recycle Bin. Throws a user-facing message. void remove(const std::filesystem::path& mods_root, const std::string& name); +// What the mods give the game's data, as one line: each mod that ships a layout, in load order, with +// its folder name and its files' fingerprint (Engine/Vfs/mod_list.h mod_fingerprint), then the +// variants in play. A mod that only holds a plugin, or loose files the game does not draw with, is not +// in it. When it changes between launches, the launcher empties the game's shader cache: shaders built +// for the last set of mods, kept and reused, were found to break the picture after many changes. +struct DataMod { + std::string name; + std::filesystem::path directory; +}; +std::string game_data_signature(const std::vector& mods, const std::map>& variants); +// The game's own shader cache, kept apart from the normal game's (Extension/Boot/user_data_redirect.cpp): +// %LOCALAPPDATA%\ReSkate\Game\Skate\cache. Not the shader_cache folder the game ships with. +std::filesystem::path game_shader_cache(); +// Removes everything in `folder`, never the folder itself. What could not be removed, a line each. +std::vector empty_folder(const std::filesystem::path& folder); + } // namespace dingosdk::launcher_mods diff --git a/Launcher/thunderstore.cpp b/Launcher/thunderstore.cpp index 25aefea4..86263fd9 100644 --- a/Launcher/thunderstore.cpp +++ b/Launcher/thunderstore.cpp @@ -203,6 +203,13 @@ bool Package::in_category(std::string_view category) const { return std::find(categories.begin(), categories.end(), category) != categories.end(); } +bool shown_by(const Package& package, const CategoryFilter& filter) { return shown_by(package.categories, filter); } +bool shown_by(const std::vector& categories, const CategoryFilter& filter) { + const auto in = [&](const std::string& category) { return std::find(categories.begin(), categories.end(), category) != categories.end(); }; + if (std::any_of(filter.hidden.begin(), filter.hidden.end(), in)) return false; + return filter.only.empty() || std::any_of(filter.only.begin(), filter.only.end(), in); +} + std::vector parse_listing(std::string_view json) { const auto root = Json::parse(json, listing_limits); if (!root.is_array()) fail("The Thunderstore listing is not a JSON array."); diff --git a/Launcher/thunderstore.h b/Launcher/thunderstore.h index 481db0c5..7bd9516b 100644 --- a/Launcher/thunderstore.h +++ b/Launcher/thunderstore.h @@ -63,6 +63,15 @@ struct Package { Package at_version(std::string_view number) const; }; +// The GET MODS list's categories: those shown only (a package needs one of them, when there are +// any) and those hidden (a package in any of them is left out, whatever else it is in). +struct CategoryFilter { + std::vector only, hidden; + bool empty() const { return only.empty() && hidden.empty(); } +}; +bool shown_by(const Package& package, const CategoryFilter& filter); +bool shown_by(const std::vector& categories, const CategoryFilter& filter); + // "Team-Mod-1.2.3" as {"Team-Mod", "1.2.3"}: how a dependency is named. The version is // empty when the text does not end in one. std::pair split_dependency(std::string_view text); From 16714035004467c1f483e707c098665516e09c39 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Thu, 22 Oct 2026 19:50:04 -0500 Subject: [PATCH 7/8] Plugins: board.motion gives the board's spin, and where the board and its deck are in the world --- Extension/Plugins/Test/plugin_tests.cpp | 5 +++ Extension/Plugins/plugin_api_world.cpp | 38 +++++++++++++++++++++-- Extension/Plugins/plugin_game.h | 2 ++ Extension/Plugins/plugin_game_runtime.cpp | 1 + Extension/Skater/skater_events.cpp | 24 +++++++++++--- Extension/Skater/skater_events.h | 2 ++ 6 files changed, 66 insertions(+), 6 deletions(-) diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 701b9b3b..14fe270f 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -248,6 +248,7 @@ struct FakeGame final : Game { return {}; } std::optional board_velocity() override { return Vec3{1, -2, 3}; } + std::optional board_spin() override { return Vec3{0, 4, 0}; } std::optional last_slam() override { return SlamNews{7.5f, 12, 100}; } std::string wipeout(float force) override { calls.push_back(std::format("wipeout {:.1f}", force)); @@ -2249,6 +2250,10 @@ void the_wider_set_reaches_the_game() { end) reskate.console.command("physics", "x", function() assert(reskate.board.motion().velocity.y == -2) + local m = reskate.board.motion() + assert(m.spin.y == 4, "the board's spin") + assert(m.place.position.y == 0 and m.place.rotation[4] == 1, "where the board is") + assert(math.abs(m.deck.position.y - 0.1) < 1e-6, "and its deck, on the board's place") assert(reskate.skater.wipeout(2)) local again, two = reskate.skater.wipeout(2) assert(not again and two:find("2 s"), "one wipeout every 2 s") diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 5af832ef..f6e70762 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -141,7 +141,9 @@ struct WorldApi { if (!shape.across && !shape.along && !shape.up) luaL_argerror(L, 2, "a launch's shape sets across, along or up"); return result(L, game(plugin).shape_launch(plugin.id(), from, shape)); } - // board.motion(): the board's velocity in the last physics step, or nil. + // board.motion(): the board's velocity and spin in the last physics step, and where it and its deck + // were last drawn in the world (bone 1, the board's place, is drawn in the world; the deck is bone 3 + // on bone 2 on it), or nil. static int board_motion(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "skater.read", "reskate.board.motion"); @@ -150,9 +152,41 @@ struct WorldApi { lua_pushnil(L); return 1; } - lua_createtable(L, 0, 1); + lua_createtable(L, 0, 4); push_position(L, *v); lua_setfield(L, -2, "velocity"); + if (const auto spin = game(plugin).board_spin()) { + push_position(L, *spin); + lua_setfield(L, -2, "spin"); + } + if (const auto bones = game(plugin).board_bones()) { + const auto push_place = [&](const anim_studio::Joint& at, const char* field) { + lua_createtable(L, 0, 2); + push_position(L, at.position); + lua_setfield(L, -2, "position"); + push_quaternion(L, at.rotation); + lua_setfield(L, -2, "rotation"); + lua_setfield(L, -2, field); + }; + const auto joint = [&](int b) { + const auto& bone = (*bones)[static_cast(b)]; + anim_studio::Joint j; + j.position = bone.position; + j.rotation = anim_studio::normalised(bone.rotation); + return j; + }; + // A child placed on its parent: turned by it, and moved by it from its own position. + const auto on = [](const anim_studio::Joint& parent, const anim_studio::Joint& child) { + anim_studio::Joint out; + const auto moved = anim_studio::rotate(parent.rotation, child.position); + out.position = {parent.position[0] + moved[0], parent.position[1] + moved[1], parent.position[2] + moved[2]}; + out.rotation = anim_studio::normalised(anim_studio::multiply(parent.rotation, child.rotation)); + return out; + }; + const auto place = joint(1); + push_place(place, "place"); + push_place(on(on(place, joint(2)), joint(3)), "deck"); + } return 1; } // ---- camera ------------------------------------------------------------------------------------- diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index c5563539..bbd7828e 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -783,6 +783,8 @@ public: // ---- the skater's physics ----------------------------------------------------------------------- virtual SkaterNews take_skater_news() { return {}; } virtual std::optional board_velocity() { return std::nullopt; } + // Its spin in that step: angular velocity in the world, radians a second. + virtual std::optional board_spin() { return std::nullopt; } virtual std::optional last_slam() { return std::nullopt; } // Every physics step since the last tick (kept once asked for: at most 512). virtual std::vector take_physics_steps() { return {}; } diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 69ae664d..d0f49728 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -812,6 +812,7 @@ public: pad_hide::hide(plugin, {h.left_trigger, h.right_trigger, h.left_x_grinding, h.right_stick, h.left_stick_click, h.right_stick_click}); return {}; } + std::optional board_spin() override { return skater_events::board_spin(); } std::optional board_velocity() override { skater_events::want(); return skater_events::board_velocity(); diff --git a/Extension/Skater/skater_events.cpp b/Extension/Skater/skater_events.cpp index ab40646d..45328619 100644 --- a/Extension/Skater/skater_events.cpp +++ b/Extension/Skater/skater_events.cpp @@ -30,6 +30,7 @@ struct Shared { std::deque falls; std::deque steps; std::array velocity{}; + std::optional> spin; std::uint64_t velocity_at{}; }; Shared& shared() { @@ -65,8 +66,9 @@ bool right_limb(skater_body::Bone bone) { default: return false; } } -// The board's root body velocity (as the grind exit control and Board Break read it). -bool read_board_velocity(const NoBailSkater& skater, std::array& velocity) noexcept { +// The board's root body velocity (as the grind exit control and Board Break read it), and its spin +// (angular velocity, radians a second) when that reads as one. +bool read_board_velocity(const NoBailSkater& skater, std::array& velocity, std::optional>* spin = nullptr) noexcept { std::uintptr_t holder{}, physics{}, type{}, bodies{}, owner{}; std::uint32_t count{}; if (!memory::peek(skater.core + sstate::board_holder_offset, holder) || !memory::peek(holder + sstate::board_physics_offset, physics) || @@ -76,7 +78,13 @@ bool read_board_velocity(const NoBailSkater& skater, std::array& veloc const auto body = bodies + sstate::board_root_body * sstate::physics_body_size; if (!memory::peek(body + sstate::body_owner_offset, owner) || owner != physics || !memory::peek(body + sstate::body_velocity_offset, velocity)) return false; - return std::all_of(velocity.begin(), velocity.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; }); + const auto sane = [](const std::array& value) { return std::all_of(value.begin(), value.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; }); }; + if (spin) { + std::array turning{}; + spin->reset(); + if (memory::peek(body + sstate::body_spin_offset, turning) && sane(turning)) *spin = turning; + } + return sane(velocity); } // The physics thread's own. @@ -98,12 +106,14 @@ void on_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept { std::uint32_t physics{}; if (memory::peek(skater.context + sstate::physics_state_offset, physics)) { std::array v{}; - const bool known = read_board_velocity(skater, v); + std::optional> spin; + const bool known = read_board_velocity(skater, v, &spin); const bool air = physics == 200 || physics == 201, grinding = physics >= 400 && physics < 500, rolling = physics >= 100 && physics < 200; if (known) { std::lock_guard lock(s.mutex); s.velocity = v; + s.spin = spin; s.velocity_at = now; } if (air && known) { @@ -218,6 +228,12 @@ std::optional> board_velocity() noexcept { if (!s.velocity_at || GetTickCount64() - s.velocity_at > 250) return std::nullopt; return s.velocity; } +std::optional> board_spin() noexcept { + auto& s = shared(); + std::lock_guard lock(s.mutex); + if (!s.velocity_at || GetTickCount64() - s.velocity_at > 250) return std::nullopt; + return s.spin; +} Slam last_slam() noexcept { auto& l = slam(); return {l.force.load(std::memory_order_relaxed), l.cause.load(std::memory_order_relaxed), l.at.load(std::memory_order_acquire)}; diff --git a/Extension/Skater/skater_events.h b/Extension/Skater/skater_events.h index 19bc6f09..1b7bc429 100644 --- a/Extension/Skater/skater_events.h +++ b/Extension/Skater/skater_events.h @@ -50,6 +50,8 @@ std::vector take_hits(); std::vector take_falls(); // The board's velocity in the last step (m/s), while it is known. std::optional> board_velocity() noexcept; +// Its spin then (angular velocity in the world, radians a second), while it is known. +std::optional> board_spin() noexcept; Slam last_slam() noexcept; // From No Bail's hooks (no_bail.cpp), beside Board Break's: a cause recorded, a wipeout chosen. void note_cause(std::int32_t reason, float magnitude) noexcept; From e35e33d66d94cdc18bcc3b1a511557c50c963e55 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Thu, 22 Oct 2026 20:17:09 -0500 Subject: [PATCH 8/8] 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