From b12b904c658debb0f0287be63b345fec11dd02e4 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Tue, 20 Oct 2026 15:46:54 -0500 Subject: [PATCH] Plugins: joints, physics tuning, velocity, game settings and pose offsets - reskate.skater.joint and reskate.session.joint: where any joint of your skater or another player's is. - reskate.physics.tuning, find and tune: the trainer's tuning values, a plugin's set over the player's own and never saved. - reskate.skater.set_velocity: the board's velocity set or added to at the next on-board physics step, in the air too. - reskate.settings.set and the settings.write permission: a game setting held while the plugin keeps it, then put back; gameplay settings stay locked in multiplayer. - reskate.skater.pose: joints turned and scaled on top of the animation, in the pose drawn and sent. - The mesh tests build anim_core, which the session's joint publishing now uses. --- CMakeLists.txt | 2 +- Extension/Plugins/Test/plugin_tests.cpp | 98 +++++++++++++++ Extension/Plugins/plugin_api_services.cpp | 65 +++++++++- Extension/Plugins/plugin_api_throwdowns.cpp | 12 +- Extension/Plugins/plugin_api_world.cpp | 113 ++++++++++++++--- Extension/Plugins/plugin_game.h | 37 ++++++ Extension/Plugins/plugin_game_runtime.cpp | 130 ++++++++++++++++++++ Extension/Plugins/plugin_game_runtime.h | 3 + Extension/Plugins/plugin_manifest.cpp | 4 +- Extension/Skater/client_noclip.cpp | 57 +++++++++ Extension/Skater/client_source_spawn.h | 4 + Extension/Skater/skater_animation.cpp | 33 ++++- Extension/Skater/skater_animation.h | 9 ++ Extension/Trainer/trainer.cpp | 77 +++++++++++- Extension/Trainer/trainer.h | 18 +++ Runtime/client_tick.cpp | 1 + 16 files changed, 638 insertions(+), 25 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a221055f..9dc4537a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -275,7 +275,7 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Session/password.cpp Extension/Multiplayer/Session/party_book.cpp Extension/Multiplayer/Steam/steam_lobbies.cpp Extension/Multiplayer/Steam/steam_server_browser.cpp - Extension/Multiplayer/word_lists.cpp) + Extension/Multiplayer/word_lists.cpp Extension/AnimationStudio/anim_core.cpp) target_link_libraries(dingosdk_multiplayer_mesh_tests PRIVATE dingosdk_logging dingosdk_launcher_support dingosdk_lz4 dingosdk_zstd bcrypt dingosdk_word_filter dingosdk_json) add_test(NAME multiplayer_mesh COMMAND dingosdk_multiplayer_mesh_tests) diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 49419df7..0d1958ea 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -145,6 +145,22 @@ 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 { + std::string text = std::format("pose {} {:.2f}", plugin, weight); + 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); + calls.push_back(text); + return {}; + } + std::string set_setting(std::string_view plugin, std::string_view name, std::optional value) override { + calls.push_back(std::format("setting {} {} {}", plugin, name, value ? *value : std::string("none"))); + return {}; + } + std::string set_velocity(std::string_view plugin, const Vec3& v, bool add) override { + calls.push_back(std::format("velocity {} {:.1f} {:.1f} {:.1f}{}", plugin, v[0], v[1], v[2], add ? " add" : "")); + return {}; + } 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) @@ -162,6 +178,32 @@ struct FakeGame final : Game { : std::format("look {} none", plugin)); return {}; } + std::map tunings{{"physicsmode.jumpmaxheight", {"PhysicsMode.JumpMaxHeight", "Jump height", "Jumps", "number", 1.2, 1.2, ""}}, + {"physicsmode.autopushenabled", {"PhysicsMode.AutoPushEnabled", "Auto push", "Push", "flag", 0, 0, ""}}}; + std::optional tuning(std::string_view id, std::string& why) override { + std::string key(id); + for (auto& c : key) c = static_cast(std::tolower(static_cast(c))); + const auto at = tunings.find(key); + if (at == tunings.end()) { + why = "no tuning value is called \"" + std::string(id) + "\""; + return std::nullopt; + } + return at->second; + } + std::vector find_tuning(std::string_view text, std::size_t most) override { + std::vector out; + for (const auto& [key, t] : tunings) + if (key.find(text) != std::string::npos && out.size() < most) out.push_back(t); + return out; + } + std::string tune(std::string_view plugin, std::string_view id, std::optional value) override { + calls.push_back(value ? std::format("tune {} {} {:.2f}", plugin, id, *value) : std::format("tune {} {} none", plugin, id)); + return {}; + } + std::optional skater_joint(std::uint64_t player, std::uint16_t joint) override { + if (player == 7 || joint == 300) return std::nullopt; + return JointPlace{{static_cast(player), static_cast(joint), 0}, {0, 0, 0, 1}, 1}; + } std::string screen_slap(std::string_view plugin) override { calls.push_back(std::format("slap {}", plugin)); return {}; @@ -1023,6 +1065,8 @@ void plugins_reach_cruise_and_read_the_mouse() { assert(h and h.position.x == 1 and h.position.z == 3 and h.rotation[4] == 1, "the right hand") assert(reskate.skater.hand("left") == nil and not pcall(reskate.skater.hand, "foot")) assert(reskate.skater.cruise(8, true) and reskate.skater.cruise(0)) + assert(reskate.skater.set_velocity(vector.create(1, 5, -2)) and reskate.skater.set_velocity(vector.create(0, 3, 0), true)) + assert(not pcall(reskate.skater.set_velocity, vector.create(0, 101, 0)), "100 m/s an axis") assert(not pcall(reskate.skater.cruise, 0.5) and not pcall(reskate.skater.cruise, 31), "1 to 30 m/s") local m = reskate.input.mouse() assert(m and m.x == 3 and m.y == -2 and m.wheel == 1, "the mouse") @@ -1044,6 +1088,7 @@ void plugins_reach_cruise_and_read_the_mouse() { game.called("reach filmer none"), "The arm reaches, either hand, and lets go\n" + environment.all()); check(game.called("cruise filmer 8.0 true") && game.called("cruise filmer 0.0 false"), "Cruise control on and off"); + check(game.called("velocity filmer 1.0 5.0 -2.0") && game.called("velocity filmer 0.0 3.0 0.0 add"), "A velocity set, and one added"); check(game.called("hide filmer false false false stick true false true"), "The right stick and its click kept from the game"); check(game.called("look filmer 0.80 110 0.35 truetruetruefalsefalsetruefalsefalsefalsefalsetrue 2.0 [#REC|0:00:02||4:05 PM|OCT.20 2026]") && game.called("slap filmer") && game.called("look filmer none"), @@ -1058,6 +1103,58 @@ void plugins_reach_cruise_and_read_the_mouse() { "skater.animation, skater.read, skater.control, input and overlay 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; + auto& game = environment.fake; + Host host(environment, Side::client); + const std::string main = R"( + local head = reskate.skater.joint("head") + assert(head and head.position.x == 0 and head.position.y == 103 and head.rotation[4] == 1 and head.scale == 1, "your head") + assert(reskate.skater.joint(300) == nil, "not known yet") + local theirs = reskate.session.joint("42", 49) + assert(theirs and theirs.position.x == 42 and theirs.position.y == 49, "their right hand") + assert(reskate.session.joint({id = "7"}, "hips") == nil) + assert(not pcall(reskate.skater.joint, 395) and not pcall(reskate.skater.joint, "elbow") and not pcall(reskate.session.joint, "me", 1)) + local jump = reskate.physics.tuning("PhysicsMode.JumpMaxHeight") + assert(jump and jump.value == 1.2 and jump.stock == 1.2 and jump.kind == "number" and jump.label == "Jump height" and jump.tuner == nil) + local none, why = reskate.physics.tuning("nothing.here") + assert(none == nil and why:find("no tuning value"), why) + assert(#reskate.physics.find("physicsmode") == 2) + assert(reskate.physics.tune("PhysicsMode.JumpMaxHeight", 2.5) and reskate.physics.tune("PhysicsMode.AutoPushEnabled", true)) + assert(reskate.physics.tune("PhysicsMode.JumpMaxHeight", nil)) + assert(not pcall(reskate.physics.tune, "x", 0 / 0) and not pcall(reskate.physics.tune, "", 1)) + assert(reskate.skater.pose({head = {scale = 0}, [44] = {angles = vector.create(0, 0, 90)}, [8] = {rotation = {0, 0, 0, 2}}}, 0.5)) + assert(reskate.skater.pose(nil)) + assert(not pcall(reskate.skater.pose, {[1] = {scale = 0}}), "the skater's placement") + assert(not pcall(reskate.skater.pose, {head = {scale = 11}}), "scale 0 to 10") + assert(not pcall(reskate.skater.pose, {head = {}}, 2), "weight 0 to 1") + assert(not pcall(reskate.skater.pose, {head = {}, [103] = {}}), "a joint once") + assert(reskate.settings.set("Camera.Height", 1.5) and reskate.settings.set("Chat.Show", true) and + reskate.settings.set("Render.Mode", "fast") and reskate.settings.set("Camera.Height", nil)) + reskate.log("tuned") + )"; + host.apply({package("tuner", {{manifest_file, manifest(R"("skater.read", "skater.control", "session.skaters", "settings.write", "skater.animation")")}, {"plugin/main.lua", main}})}); + ticks(host, environment, 1); + running(host, "tuner", environment); + check(environment.logged("[tuner] tuned"), "A plugin reads joints and tunes physics\n" + environment.all()); + check(game.called("tune tuner PhysicsMode.JumpMaxHeight 2.50") && game.called("tune tuner PhysicsMode.AutoPushEnabled 1.00") && + game.called("tune tuner PhysicsMode.JumpMaxHeight none"), + "A value set, a flag set, and let go\n" + environment.all()); + check(game.called("pose tuner 0.50 8:0.00,0.00,0.00,1.00x1.00 44:0.00,0.00,0.71,0.71x1.00 103:0.00,0.00,0.00,1.00x0.00") && + game.called("pose tuner 1.00"), + "Joints posed: a head shrunk, one turned 90 degrees about Z, and off\n" + environment.all()); + check(game.called("setting tuner Camera.Height 1.5") && game.called("setting tuner Chat.Show true") && + game.called("setting tuner Render.Mode fast") && game.called("setting tuner Camera.Height none"), + "Game settings held and let go\n" + environment.all()); + host.apply({package("sneaky", {{manifest_file, manifest(R"("skater.read")")}, {"plugin/main.lua", R"( + reskate.log(tostring(pcall(reskate.physics.tune, "PhysicsMode.JumpMaxHeight", 2)) .. " " .. tostring(pcall(reskate.session.joint, "42", 1)) .. + " " .. tostring(pcall(reskate.settings.set, "Camera.Height", 1))) + )"}})}); + ticks(host, environment, 1); + check(environment.logged("[sneaky] false false false"), "skater.control, session.skaters and settings.write are needed\n" + environment.all()); +} + void plugins_draw_on_the_overlay() { TestEnvironment environment; Host host(environment, Side::client); @@ -2768,6 +2865,7 @@ int main() { plugins_draw_pictures(); plugins_place_world_entities(); plugins_reach_cruise_and_read_the_mouse(); + plugins_read_joints_and_tune_physics(); plugins_server_copies_are_the_authority(); plugins_animate_the_skater(); plugins_play_their_own_sounds(); diff --git a/Extension/Plugins/plugin_api_services.cpp b/Extension/Plugins/plugin_api_services.cpp index b0b51062..266c8135 100644 --- a/Extension/Plugins/plugin_api_services.cpp +++ b/Extension/Plugins/plugin_api_services.cpp @@ -266,6 +266,68 @@ int prepare(lua_State* L) { return 0; } +// physics.tuning(id): one tuning value, {id, label, group, kind, value, stock, tuner}, or nil and why. +void push_tuning(lua_State* L, const Game::Tuning& t) { + lua_createtable(L, 0, 7); + const auto text = [&](const char* field, const std::string& value) { + lua_pushlstring(L, value.data(), value.size()); + lua_setfield(L, -2, field); + }; + text("id", t.id); + text("label", t.label); + text("group", t.group); + text("kind", t.kind); + lua_pushnumber(L, t.value); + lua_setfield(L, -2, "value"); + lua_pushnumber(L, t.stock); + lua_setfield(L, -2, "stock"); + if (!t.tuner.empty()) text("tuner", t.tuner); +} +std::string tuning_id(lua_State* L, int index) { + std::size_t length{}; + const char* id = luaL_checklstring(L, index, &length); + if (length == 0 || length > 200) luaL_argerror(L, index, "a tuning value's id is 1 to 200 characters"); + return {id, length}; +} +int tuning(lua_State* L) { + auto& plugin = allowed(L, "skater.read", "reskate.physics.tuning"); + std::string why; + const auto t = game_of(plugin).tuning(tuning_id(L, 1), why); + if (!t) { + lua_pushnil(L); + lua_pushlstring(L, why.data(), why.size()); + return 2; + } + push_tuning(L, *t); + return 1; +} +// physics.find(text): the values whose id has every word of `text`, at most 64. +int find_tuning(lua_State* L) { + auto& plugin = allowed(L, "skater.read", "reskate.physics.find"); + std::size_t length{}; + const char* text = luaL_checklstring(L, 1, &length); + if (length > 200) luaL_argerror(L, 1, "at most 200 characters"); + const auto found = game_of(plugin).find_tuning({text, length}, 64); + lua_createtable(L, static_cast(found.size()), 0); + for (std::size_t i = 0; i < found.size(); ++i) { + push_tuning(L, found[i]); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; +} +// physics.tune(id, value): the value over the player's own while the plugin keeps it; nil lets go. +int tune(lua_State* L) { + auto& plugin = allowed(L, "skater.control", "reskate.physics.tune"); + const auto id = tuning_id(L, 1); + std::optional value; + if (lua_type(L, 2) == LUA_TBOOLEAN) value = lua_toboolean(L, 2) ? 1.0 : 0.0; + else if (!lua_isnoneornil(L, 2)) { + value = luaL_checknumber(L, 2); + if (!std::isfinite(*value) || std::abs(*value) > 1e6) luaL_argerror(L, 2, "a finite number within a million"); + } + return result(L, game_of(plugin).tune(plugin.id(), id, value)); +} + // ---- tricks -------------------------------------------------------------------------------------- // tricks.feed(on): skate.'s trick list, entry by entry, as trick_notes events while on. int trick_feed(lua_State* L) { @@ -391,7 +453,8 @@ void install_services_api(Plugin&, lua_State* L) { add_functions(L, "input", {{"pad_bits", pad_bits}, {"key", key}, {"wheel", wheel}, {"mouse", mouse}, {"game_buttons", game_buttons}, {"allowed", input_allowed}, {"focus", focus}}); add_functions(L, "session", {{"spectate", spectate}, {"world", world}}); add_functions(L, "camera", {{"free", free_camera}, {"pad_flight", pad_flight}}); - add_functions(L, "physics", {{"competition", competition}, {"stock_ready", stock_ready}, {"prepare", prepare}}); + add_functions(L, "physics", {{"competition", competition}, {"stock_ready", stock_ready}, {"prepare", prepare}, {"tuning", tuning}, + {"find", find_tuning}, {"tune", tune}}); add_functions(L, "tricks", {{"feed", trick_feed}, {"gesture", gesture}, {"label", label}, {"record_as", record_as}}); add_functions(L, "mods", {{"content", content}, {"read_content", read_content}}); } diff --git a/Extension/Plugins/plugin_api_throwdowns.cpp b/Extension/Plugins/plugin_api_throwdowns.cpp index 253e9ac7..7571c323 100644 --- a/Extension/Plugins/plugin_api_throwdowns.cpp +++ b/Extension/Plugins/plugin_api_throwdowns.cpp @@ -349,6 +349,16 @@ struct KitApi { if (!(speed == 0 || (speed >= 1 && speed <= 30))) luaL_argerror(L, 1, "a cruise speed is 1 to 30 m/s, or 0 for none"); return result(L, game(plugin).cruise(plugin.id(), static_cast(speed), lua_toboolean(L, 2) != 0)); } + // set_velocity(velocity, add): the board's velocity (m/s, in the world) set, or added to, by the + // next physics step with the skater on its board, on the ground or in the air. + static int set_velocity(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.set_velocity"); + const auto v = position_of(L, 1, "a velocity"); + for (const auto a : v) + if (!(std::abs(a) <= 100)) luaL_argerror(L, 1, "a velocity is at most 100 m/s on each axis"); + return result(L, game(plugin).set_velocity(plugin.id(), v, lua_toboolean(L, 2) != 0)); + } static int hold(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "skater.control", "reskate.skater.hold"); @@ -369,6 +379,6 @@ void KitApi::install(Plugin&, lua_State* L) { lua_pop(L, 1); add_functions(L, "files", {{"list", files_list}, {"read", files_read}, {"write", files_write}, {"remove", files_remove}}); add_functions(L, "ui", {{"dialog", dialog}, {"close_dialog", close_dialog}}); - add_functions(L, "skater", {{"push", push}, {"push_wait", push_wait}, {"push_reset", push_reset}, {"hold", hold}, {"cruise", cruise}}); + add_functions(L, "skater", {{"push", push}, {"push_wait", push_wait}, {"push_reset", push_reset}, {"hold", hold}, {"cruise", cruise}, {"set_velocity", set_velocity}}); } } diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 37208aa0..f97cb0c3 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -453,18 +453,7 @@ struct WorldApi { if (!lua_isnil(L, -1)) attach->player = player_of(L, lua_gettop(L)); lua_pop(L, 1); lua_getfield(L, 5, "joint"); - if (lua_type(L, -1) == LUA_TSTRING) { - static constexpr std::pair named[] = { - {"right_hand", 49}, {"left_hand", 278}, {"head", 103}, {"hips", 7}, {"spine", 42}}; - const std::string_view name = lua_tostring(L, -1); - const auto at = std::find_if(std::begin(named), std::end(named), [&](const auto& n) { return n.first == name; }); - if (at == std::end(named)) luaL_error(L, "a joint is a number 0 to 394, or right_hand, left_hand, head, hips or spine"); - attach->joint = at->second; - } else { - const double joint = luaL_checknumber(L, -1); - if (!(joint >= 0 && joint <= 394) || joint != static_cast(joint)) luaL_error(L, "a joint is a number 0 to 394, or right_hand, left_hand, head, hips or spine"); - attach->joint = static_cast(joint); - } + attach->joint = joint_of(L, lua_gettop(L)); lua_pop(L, 1); } return result(L, game(plugin).entity_set(plugin.id(), id, asset, matrix, lua_toboolean(L, 4) != 0, attach)); @@ -861,6 +850,89 @@ struct WorldApi { lua_pop(L, 1); 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. + static std::uint16_t joint_of(lua_State* L, int index) { + if (lua_type(L, index) == LUA_TSTRING) { + static constexpr std::pair named[] = { + {"right_hand", 49}, {"left_hand", 278}, {"head", 103}, {"hips", 7}, {"spine", 42}}; + const std::string_view name = lua_tostring(L, index); + const auto at = std::find_if(std::begin(named), std::end(named), [&](const auto& n) { return n.first == name; }); + if (at == std::end(named)) luaL_error(L, "a joint is a number 0 to 394, or right_hand, left_hand, head, hips or spine"); + return at->second; + } + const double joint = luaL_checknumber(L, index); + if (!(joint >= 0 && joint <= 394) || joint != static_cast(joint)) luaL_error(L, "a joint is a number 0 to 394, or right_hand, left_hand, head, hips or spine"); + return static_cast(joint); + } + static int push_joint(lua_State* L, const std::optional& at) { + if (!at) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 3); + push_position(L, at->position); + lua_setfield(L, -2, "position"); + push_quaternion(L, at->rotation); + lua_setfield(L, -2, "rotation"); + lua_pushnumber(L, at->scale); + lua_setfield(L, -2, "scale"); + return 1; + } + // skater.pose({[joint] = {rotation = {x, y, z, w} | angles = vector (degrees), scale = n}, ...}, weight): + // joints turned in their own frame and scaled on top of the animation for 0.3 s; nil ends it. + static int skater_pose(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.animation", "reskate.skater.pose"); + std::vector offsets; + if (!lua_isnoneornil(L, 1)) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushnil(L); + while (lua_next(L, 1)) { + Game::JointOffset o; + o.joint = joint_of(L, -2); + if (o.joint < 2) luaL_error(L, "joints 0 and 1 place the skater in the world: they cannot be posed"); + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + lua_getfield(L, at, "rotation"); + if (!lua_isnil(L, -1)) o.rotation = quaternion_at(L, lua_gettop(L)); + lua_pop(L, 1); + lua_getfield(L, at, "angles"); + if (!lua_isnil(L, -1)) { + const auto a = position_of(L, lua_gettop(L), "a joint's angles"); + o.rotation = anim_studio::from_euler({a[0], a[1], a[2]}); + } + lua_pop(L, 1); + lua_getfield(L, at, "scale"); + if (!lua_isnil(L, -1)) { + const double s = luaL_checknumber(L, -1); + if (!(s >= 0 && s <= 10)) luaL_error(L, "a joint's scale is 0 to 10"); + o.scale = static_cast(s); + } + lua_pop(L, 1); + if (std::any_of(offsets.begin(), offsets.end(), [&](const auto& e) { return e.joint == o.joint; })) + luaL_error(L, "joint %d is posed twice", static_cast(o.joint)); + if (offsets.size() >= 64) luaL_error(L, "at most 64 joints are posed at once"); + offsets.push_back(o); + lua_pop(L, 1); + } + } + 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))); + } + // skater.joint(joint): {position, rotation, scale} of one of the local skater's joints, or nil. + static int skater_joint(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.read", "reskate.skater.joint"); + return push_joint(L, game(plugin).skater_joint(0, joint_of(L, 1))); + } + // session.joint(player, joint): the same of another player's skater, as this game draws it. + static int session_joint(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "session.skaters", "reskate.session.joint"); + const auto player = player_of(L, 1); + return push_joint(L, game(plugin).skater_joint(player, joint_of(L, 2))); + } // skater.hand(side): {position, rotation} of that hand joint in the world as last drawn, or nil. static int hand(lua_State* L) { auto& plugin = plugin_of(L); @@ -1511,6 +1583,17 @@ struct WorldApi { else lua_pushnil(L); return 1; } + // settings.set(name, value): a game setting held at `value` while the plugin keeps it; nil lets go. + static int setting_set(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "settings.write", "reskate.settings.set"); + const auto name = checked_text(L, 1, 120, "a setting's name"); + std::optional value; + if (lua_type(L, 2) == LUA_TBOOLEAN) value = lua_toboolean(L, 2) ? "true" : "false"; + else if (lua_type(L, 2) == LUA_TNUMBER) value = std::format("{}", lua_tonumber(L, 2)); + else if (!lua_isnoneornil(L, 2)) value = checked_text(L, 2, 200, "a setting's value"); + return result(L, game(plugin).set_setting(plugin.id(), name, std::move(value))); + } static int setting_names(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "settings.read", "reskate.settings.names"); @@ -1764,7 +1847,7 @@ 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}, - {"grind_exit", grind_exit}, {"reach", reach}, {"hand", hand}}); + {"grind_exit", grind_exit}, {"reach", reach}, {"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}}); @@ -1777,7 +1860,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}}); 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}}); + add_functions(L, "session", {{"skaters", skaters}, {"tag", tag}, {"joint", session_joint}}); add_functions(L, "voice", {{"set_volume", voice_volume}}); add_functions(L, "chat", {{"send", chat_send}, {"notice", chat_local}}); add_functions(L, "animation", {{"play", animation_play}, {"stop", animation_stop}, {"playing", animation_playing}, {"replace", animation_replace}, @@ -1791,7 +1874,7 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { add_functions(L, "music", {{"current", current}, {"play", play}, {"pause", pause}, {"next", next}, {"previous", previous}, {"game", game_current}, {"game_next", game_next}}); add_functions(L, "progression", {{"get", progress}}); - add_functions(L, "settings", {{"game", setting}, {"names", setting_names}}); + add_functions(L, "settings", {{"game", setting}, {"names", setting_names}, {"set", setting_set}}); add_functions(L, "input", {{"bind", bind}, {"flight", input_flight}, {"gamepad", input_gamepad}, {"joystick", input_joystick}, {"capture", input_capture}, {"hide", input_hide}}); add_functions(L, "ui", {{"screen", screen}, {"open", open}, {"close", close}}); diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index 35d2ecde..f2871a15 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -425,6 +425,9 @@ public: virtual Progress progress() { return {}; } virtual std::optional setting(std::string_view) { return std::nullopt; } virtual std::vector setting_names() { return {}; } + // A game setting held at `value` while the plugin keeps it, then put back as it was found + // (reskate.settings.set); no value lets go. One plugin holds a setting at a time. + virtual std::string set_setting(std::string_view /*plugin*/, std::string_view /*name*/, std::optional /*value*/) { return unavailable; } // ---- camera ------------------------------------------------------------------------------------ // The view from a pose (rows right, up, back and position, as follow_camera.h builds them) with // a vertical field of view in degrees (0 the camera's own). One plugin holds it at a time, and @@ -472,12 +475,43 @@ public: float weight{1}; }; virtual std::string skater_reach(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. + struct JointOffset { + std::uint16_t joint{}; + std::array rotation{0, 0, 0, 1}; + float scale{1}; + }; + virtual std::string skater_pose(std::string_view /*plugin*/, const std::vector&, float /*weight*/) { return unavailable; } // Where a hand joint of the local skater is in the world, as last evaluated (after any reach). struct HandPlace { Vec3 position{}; std::array rotation{0, 0, 0, 1}; }; virtual std::optional skater_hand(bool /*left*/) { return std::nullopt; } + // Where a joint (0..394) of a skater is in the world: player 0 the local skater as its pose was + // last evaluated, else another player's as the session draws it. Asking keeps it read for two + // seconds, so the first ask comes back empty. + struct JointPlace { + Vec3 position{}; + std::array rotation{0, 0, 0, 1}; + float scale{1}; + }; + virtual std::optional skater_joint(std::uint64_t /*player*/, std::uint16_t /*joint*/) { return std::nullopt; } + // The physics tuning's values, as the trainer has them (Extension/Trainer/trainer.h): read, + // found by words, and set over the player's own while the plugin keeps them (never saved). + struct Tuning { + std::string id, label, group, kind; // kind: number, integer, flag, multiplier + double value{}, stock{}; + std::string tuner; // the plugin whose value it is, if any + }; + virtual std::optional tuning(std::string_view /*id*/, std::string& why) { + why = unavailable; + return std::nullopt; + } + virtual std::vector find_tuning(std::string_view /*text*/, std::size_t /*most*/) { return {}; } + virtual std::string tune(std::string_view /*plugin*/, std::string_view /*id*/, std::optional /*value*/) { return unavailable; } virtual std::string replace_trick(std::string_view /*plugin*/, std::string /*trick*/, std::string /*pop*/, ClipRef, float /*speed*/) { return unavailable; } @@ -550,6 +584,9 @@ public: // left/right while the skater grinds. // The skater carried up to a speed (m/s, 0 off) for the next 300 ms, eased down to it with may_slow. virtual std::string cruise(std::string_view /*plugin*/, float /*speed*/, bool /*may_slow*/) { return unavailable; } + // The board's velocity set (or `add`ed to) by the next physics step with the skater on its board, + // within 500 ms. + virtual std::string set_velocity(std::string_view /*plugin*/, const Vec3& /*velocity*/, bool /*add*/) { return unavailable; } // What of the pad the game does not see (reskate.input.hide): the triggers, the left stick's // sideways while grinding, the right stick and the stick clicks. struct PadHidden { diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index e9936004..71f11e3b 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -34,6 +34,7 @@ #include "Extension/Objects/ParkEditor/park_editor_runtime.h" #include "Extension/Profile/local_profile_runtime.h" #include "Extension/Settings/named_settings.h" +#include "Engine/Game/Settings/multiplayer_settings_lock.h" #include "Extension/Skater/ai_skaters.h" #include "Extension/Skater/no_bail.h" #include "Extension/Throwdowns/native_throwdowns.h" @@ -67,6 +68,72 @@ #include namespace dingosdk::plugins { +// Game settings plugins hold (reskate.settings.set): what each found and wrote. Client thread (the +// game's update thread, which named settings need). +namespace held_settings { +namespace { +struct Held { + std::string plugin, name, found, ours; +}; +std::vector& list() { + static auto* value = new std::vector; // held settings may still be out at exit + return *value; +} +bool failed(const std::string& result) { return result.starts_with("error: "); } +std::string why_of(const std::string& result) { return failed(result) ? result.substr(7) : result; } +// Put back what was found, unless the setting no longer holds ours (changed elsewhere: it stays). +void put_back(const Held& h) { + if (named_setting_value(h.name) == h.ours) (void)change_named_setting(h.name, h.found, false); +} +} +std::string set(std::string_view plugin, std::string_view name, std::optional value) { + auto& held = list(); + const auto at = std::find_if(held.begin(), held.end(), [&](const Held& h) { return console::equal(h.name, name); }); + if (!value) { + if (at != held.end() && at->plugin == plugin) { + put_back(*at); + held.erase(at); + } + return {}; + } + const auto now = named_setting_value(name); + if (!now) return "no game setting is called \"" + std::string(name) + "\""; + if (at != held.end() && at->plugin != plugin) return at->plugin + " holds " + at->name; + if (multiplayer_settings_locked() && locked_in_multiplayer(name)) + return std::string(name) + " changes how the game plays, so it cannot be changed during a multiplayer session"; + if (at == held.end() && std::count_if(held.begin(), held.end(), [&](const Held& h) { return h.plugin == plugin; }) >= 32) + return "a plugin holds at most 32 settings"; + const auto result = change_named_setting(name, *value, false); + if (failed(result)) return why_of(result); + const auto ours = named_setting_value(name).value_or(*value); + if (at != held.end()) at->ours = ours; + else held.push_back({std::string(plugin), std::string(name), *now, ours}); + return {}; +} +void release(std::string_view plugin) { + auto& held = list(); + for (auto it = held.end(); it != held.begin();) { + --it; + if (it->plugin == plugin) { + put_back(*it); + it = held.erase(it); + } + } +} +} +void tick_plugin_settings() noexcept { + try { + if (!multiplayer_settings_locked()) return; + auto& held = held_settings::list(); + for (auto it = held.begin(); it != held.end();) { + if (locked_in_multiplayer(it->name)) { + held_settings::put_back(*it); + it = held.erase(it); + } else ++it; + } + } catch (...) {} +} + namespace { // A request on the queue the console's own debug commands use; its outcome shows in the console's // model a tick later, so "" here means it was queued. @@ -330,6 +397,9 @@ public: return p; } std::optional setting(std::string_view name) override { return named_setting_value(name); } + std::string set_setting(std::string_view plugin, std::string_view name, std::optional value) override { + return held_settings::set(plugin, name, std::move(value)); + } std::vector setting_names() override { return named_setting_names(); } // ---- input ------------------------------------------------------------------------------------------- @@ -653,6 +723,12 @@ public: set_plugin_cruise(speed, may_slow); return {}; } + std::string set_velocity(std::string_view, const Vec3& velocity, bool add) override { + if (multiplayer_session_active() && !session_boosts_allowed()) return "the host has turned boosts off"; + if (multiplayer::local_throwdown_active() && !competition_stock_physics()) return "not while a throwdown runs"; + set_plugin_velocity(velocity, add); + return {}; + } std::string hide_pad(std::string_view plugin, const PadHidden& h) override { if (!grind_exit::available()) return "hiding pad controls is unavailable for this game build"; grind_exit::hide(plugin, {h.left_trigger, h.right_trigger, h.left_x_grinding, h.right_stick, h.left_stick_click, h.right_stick_click}); @@ -783,6 +859,23 @@ public: screen_look::slap(); return {}; } + std::string skater_pose(std::string_view plugin, const std::vector& offsets, float weight) 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()) { + if (poser_ == plugin) { + skater_animation::pose({}, 1); + poser_.clear(); + } + return {}; + } + poser_ = plugin; + pose_at_ = now; + std::vector out; + for (const auto& o : offsets) out.push_back({o.joint, o.rotation, o.scale}); + skater_animation::pose(std::move(out), weight); + return {}; + } std::string skater_reach(std::string_view plugin, std::optional goal) override { const auto now = GetTickCount64(); if (!reacher_.empty() && reacher_ != plugin && now < reach_at_ + 300) { @@ -806,6 +899,35 @@ public: skater_animation::reach(g); return {}; } + static Tuning tuning_of(const trainer::TuningValue& v) { + const char* kind = v.kind == trainer::Kind::integer ? "integer" + : v.kind == trainer::Kind::flag ? "flag" + : v.kind == trainer::Kind::curve || v.kind == trainer::Kind::graph ? "multiplier" : "number"; + return {v.id, v.label, v.group, kind, v.value, v.stock, v.tuner}; + } + std::optional tuning(std::string_view id, std::string& why) override { + const auto v = trainer::tuning_value(id, &why); + if (!v) return std::nullopt; + return tuning_of(*v); + } + std::vector find_tuning(std::string_view text, std::size_t most) override { + std::vector out; + for (const auto& v : trainer::find_tuning(text, most)) out.push_back(tuning_of(v)); + return out; + } + std::string tune(std::string_view plugin, std::string_view id, std::optional value) override { + return trainer::plugin_tune(plugin, id, value); + } + std::optional skater_joint(std::uint64_t player, std::uint16_t joint) override { + if (!player) { + const auto at = skater_animation::joint(joint); + if (!at) return std::nullopt; + return JointPlace{at->position, at->rotation, at->scale[0]}; + } + const auto at = plugin_feed::joint_place(player, joint); + if (!at) return std::nullopt; + return JointPlace{at->position, at->rotation, at->scale}; + } std::optional skater_hand(bool left) override { const auto placed = skater_animation::hand(left ? arm_reach::Hand::left : arm_reach::Hand::right); if (!placed) return std::nullopt; @@ -1177,8 +1299,10 @@ public: xray::prepare(plugin, false); hud_corner::release(plugin); game_speed::ask(plugin, 1.0f); + held_settings::release(plugin); grind_exit::release(plugin); trick_rewrite::release(plugin); + trainer::release_plugin_tunes(plugin); if (reacher_ == plugin) { skater_animation::reach(std::nullopt); reacher_.clear(); @@ -1187,6 +1311,10 @@ public: screen_look::clear(); looker_.clear(); } + if (poser_ == plugin) { + skater_animation::pose({}, 1); + poser_.clear(); + } if (const auto seen = telemetry_seen_.find(plugin); seen != telemetry_seen_.end()) telemetry_seen_.erase(seen); plugin_feed::release_tags(std::string(plugin)); if (std::erase_if(binds_, [&](const auto& item) { return item.first.first == plugin; })) publish_binds(); @@ -1218,6 +1346,8 @@ private: std::map> variant_reads_; 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::uint64_t pose_at_{}; std::string looker_; // the plugin whose look is on the screen (screen_look) std::uint64_t look_at_{}; std::uint64_t reach_at_{}; diff --git a/Extension/Plugins/plugin_game_runtime.h b/Extension/Plugins/plugin_game_runtime.h index fb56a7ce..90bce664 100644 --- a/Extension/Plugins/plugin_game_runtime.h +++ b/Extension/Plugins/plugin_game_runtime.h @@ -16,6 +16,9 @@ bool plugin_freeze_active() noexcept; bool plugin_input_capture() noexcept; // Each client tick: the local skater for plugins' clips (reskate.animation). void tick_plugin_animation(std::uintptr_t image_base, std::uintptr_t client) noexcept; +// Each client tick: game settings plugins hold (reskate.settings.set) that change how the game plays +// go back as a multiplayer session starts. +void tick_plugin_settings() noexcept; // Each client tick: plugins' sounds (reskate.audio); `ready` while a level plays. void tick_plugin_sounds(std::uintptr_t image_base, std::uintptr_t client, bool ready) noexcept; } diff --git a/Extension/Plugins/plugin_manifest.cpp b/Extension/Plugins/plugin_manifest.cpp index 87016288..2fd6b963 100644 --- a/Extension/Plugins/plugin_manifest.cpp +++ b/Extension/Plugins/plugin_manifest.cpp @@ -15,13 +15,13 @@ namespace { // throwdown kit (modes, documents, the native flag); "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", - "events.physics", "game.speed", "files", "variants"}; + "events.physics", "game.speed", "files", "variants", "settings.write"}; } bool known_permission(std::string_view name) noexcept { diff --git a/Extension/Skater/client_noclip.cpp b/Extension/Skater/client_noclip.cpp index a0d6fc90..b3984901 100644 --- a/Extension/Skater/client_noclip.cpp +++ b/Extension/Skater/client_noclip.cpp @@ -407,6 +407,54 @@ void apply_push_velocity(std::uintptr_t core) noexcept { request.pending.store(false,std::memory_order_release); }catch(...){} } +// A plugin's velocity (set_plugin_velocity): written, or added, by the next physics step that has the +// local skater on its board, on the ground or in the air, within 500 ms. It rides on the trainer's +// skater (set_push_speed), as cruise control does. +struct PluginVelocity { + std::mutex mutex; + std::array velocity{}; + bool add{}; + ULONGLONG expires{}; + std::atomic pending{}; +}; +PluginVelocity& plugin_velocity() { static auto* value = new PluginVelocity; return *value; } +void apply_plugin_velocity(std::uintptr_t core) noexcept { + auto& v = plugin_velocity(); + if (!v.pending.load(std::memory_order_acquire)) return; + std::unique_lock lock(v.mutex, std::try_to_lock); + if (!lock) return; + const ULONGLONG ticks = GetTickCount64(); + if (ticks >= v.expires) { + v.pending.store(false, std::memory_order_release); + return; + } + auto& p = push_speed(); + if (ticks >= p.expires.load(std::memory_order_acquire)) return; + SourceLastError error; + auto& state = source_state(); + if (!state.initialized.load(std::memory_order_acquire) || state.busy.test_and_set(std::memory_order_acquire)) return; + SourceBusyScope scope{state.busy}; + try { + const auto bodies = debug_noclip_bodies(state.trial.base, p.client, p.entity); + if (bodies.core != core || bodies.offboard || bodies.parts.empty()) return; + SourceReader reader; + if (reader.value(p.entity, 0x7e0) != 0) return; // teleporting + std::array, 32> velocities{}; + std::array flags{}; + const auto count = std::min(bodies.parts.size(), velocities.size()); + for (std::size_t i = 0; i < count; ++i) { + velocities[i] = reader.value>(bodies.parts[i], 0x70); + flags[i] = reader.value(bodies.parts[i], 0x60); + } + reader.verify(); + for (std::size_t i = 0; i < count; ++i) { + for (std::size_t a = 0; a < 3; ++a) velocities[i][a] = v.add ? velocities[i][a] + v.velocity[a] : v.velocity[a]; + body_write(bodies.parts[i] + 0x70, velocities[i]); + body_write(bodies.parts[i] + 0x60, flags[i] | 8u); + } + v.pending.store(false, std::memory_order_release); + } catch (...) {} +} void noclip_physics_update(std::uintptr_t core) { const auto original = source_state().velocity_update_original.load(std::memory_order_acquire); if (original) original(core); @@ -414,6 +462,7 @@ void noclip_physics_update(std::uintptr_t core) { trainer_apply_jump_scale(core); trainer_push_speed(core); apply_push_velocity(core); + apply_plugin_velocity(core); } bool noclip_motion_target(std::uintptr_t rig, std::uintptr_t context, const std::array* supplied, std::array& target) noexcept { @@ -551,6 +600,14 @@ bool queue_jump_scale(std::uintptr_t client, std::uintptr_t entity, float factor return true; } catch (...) { return false; } } +void set_plugin_velocity(const std::array& velocity, bool add) noexcept { + auto& v = client_source::detail::plugin_velocity(); + std::lock_guard lock(v.mutex); + v.velocity = velocity; + v.add = add; + v.expires = GetTickCount64() + 500; + v.pending.store(true, std::memory_order_release); +} void set_plugin_cruise(float speed, bool may_slow) noexcept { auto& p = push_speed(); p.plugin.store(speed > 0 && speed <= 30 ? speed : 0.0f, std::memory_order_relaxed); diff --git a/Extension/Skater/client_source_spawn.h b/Extension/Skater/client_source_spawn.h index 2664e09d..45cbbc99 100644 --- a/Extension/Skater/client_source_spawn.h +++ b/Extension/Skater/client_source_spawn.h @@ -33,6 +33,10 @@ void set_push_speed(std::uintptr_t client, std::uintptr_t entity, float factor, // up to `speed` (m/s; 0 off, at most 30), and with `may_slow` eased down to it when not pushing. Rides // on the trainer's skater and expiry (set_push_speed); it expires after 300 ms, so set it every tick. void set_plugin_cruise(float speed, bool may_slow) noexcept; +// A plugin's velocity (reskate.skater.set_velocity): the board's bodies set to `velocity` (or it added +// to theirs) by the next physics step with the local skater on its board, on the ground or in the air, +// within 500 ms; dropped after. A new one replaces one still waiting. +void set_plugin_velocity(const std::array& velocity, bool add) noexcept; // Engine-thread-only interactive controls. Presentation callbacks only queue requests. overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t client, bool can_control, bool camera_phase_observed, const overlay::DebugRequest* request = nullptr, diff --git a/Extension/Skater/skater_animation.cpp b/Extension/Skater/skater_animation.cpp index 672ff64c..a075ccce 100644 --- a/Extension/Skater/skater_animation.cpp +++ b/Extension/Skater/skater_animation.cpp @@ -178,6 +178,9 @@ struct Plan { std::optional reach; double reach_until{}, joints_until{}; // now_seconds() until which the reach holds, and the joints are read 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{}; }; // The joints asked for as last evaluated (joint()), for the client thread. std::mutex joints_mutex; @@ -305,7 +308,8 @@ void on_animation(std::uintptr_t component) noexcept { const double now = now_seconds(); const bool reaching = snap->reach && now < snap->reach_until; const bool joints_wanted = now < snap->joints_until && !snap->joints_read.empty(); - if (!busy && !reaching && !joints_wanted && (!snap->allowed || (!snap->play && snap->replacements.empty()))) { + const bool posing = !snap->offsets.empty() && now < snap->offsets_until; + if (!busy && !reaching && !joints_wanted && !posing && (!snap->allowed || (!snap->play && snap->replacements.empty()))) { std::lock_guard lock(pending().mutex); pending().bones.clear(); SetLastError(error); @@ -437,6 +441,24 @@ 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) { + 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]; + 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; + for (auto& k : j.scale) k *= s; + if (last > first) { + first = std::min(first, o.joint); + last = std::max(last, o.joint + 1u); + } else { + first = o.joint; + last = o.joint + 1u; + } + } + } // An arm reaching, after the clip: its joints join what is written. if (reaching) { const auto& goal = *snap->reach; @@ -622,6 +644,12 @@ void play(std::optional clip) { 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) { + plan_draft.offsets = std::move(offsets); + plan_draft.offsets_weight = weight; + plan_draft.offsets_until = plan_draft.offsets.empty() ? 0 : now_seconds() + 0.3; + publish(); +} void reach(std::optional goal) { plan_draft.reach = std::move(goal); plan_draft.reach_until = plan_draft.reach ? now_seconds() + 0.3 : 0; @@ -703,7 +731,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) || now < plan_draft.joints_until || + (!plan_draft.offsets.empty() && now < plan_draft.offsets_until); if (wanted && !hooked && base) { std::string detail; if (multiplayer::install_entity_hooks(base, detail)) { diff --git a/Extension/Skater/skater_animation.h b/Extension/Skater/skater_animation.h index 085d706f..91d96770 100644 --- a/Extension/Skater/skater_animation.h +++ b/Extension/Skater/skater_animation.h @@ -45,6 +45,15 @@ 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); +// 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. +struct PoseOffset { + std::uint16_t joint{}; + anim_studio::Quat rotation{0, 0, 0, 1}; + float scale{1}; +}; +void pose(std::vector offsets, float weight); // 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/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index a292c94d..20cff1c8 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -120,6 +120,12 @@ struct State { std::string status = "Waiting for the game.", last; bool ready{}; std::map> saved; // by key + // Plugins' values (plugin_tune), by key: whose, and what was there before. + struct PluginTune { + std::string owner; + double before{}; + }; + std::map> plugin_tunes; UserPresets user; // presets std::vector active; // presets applied since Stock std::map> maps; @@ -473,11 +479,16 @@ void remember(const Entry &e) { if (e.touched || e.frozen) s.saved[e.key] = {e.value, e.frozen}; else s.saved.erase(e.key); } -void set_entry(Entry &e, double value) { +// `own`: the player's change, remembered and saved, which takes the value back from a plugin. +// Otherwise a plugin's (plugin_tune), neither. +void set_entry(Entry &e, double value, bool own = true) { e.value = sane(e, value); e.touched = e.value != e.stock; apply_entry(e); - remember(e); + if (own) { + remember(e); + state().plugin_tunes.erase(e.key); + } // A linked value scales its graphs by the same ratio (the graphs are what the game reads). if (!e.drives.empty() && e.stock != 0) { const auto ratio = e.value / e.stock; @@ -487,7 +498,7 @@ void set_entry(Entry &e, double value) { if (index < s.entries.size() && !s.entries[index].frozen) { auto &target = s.entries[index]; const bool multiplier = target.kind == Kind::curve || target.kind == Kind::graph; - set_entry(target, multiplier ? ratio : target.stock * ratio); + set_entry(target, multiplier ? ratio : target.stock * ratio, own); } } } @@ -508,6 +519,7 @@ Entry *find_entry(std::string_view id) { } void build_entries() { auto &s = state(); + s.plugin_tunes.clear(); s.entries.clear(); s.index.clear(); s.class_entries.clear(); @@ -1911,6 +1923,65 @@ std::string run(std::string_view verb, const std::vector &a) { } } // namespace +namespace { +TuningValue tuning_of(const Entry &e) { + const auto &s = state(); + const auto held = s.plugin_tunes.find(e.key); + return {e.id, e.label, e.group, e.kind, e.value, e.stock, held == s.plugin_tunes.end() ? std::string{} : held->second.owner}; +} +} // namespace + +std::optional tuning_value(std::string_view id, std::string *why) { + if (!state().model) { + if (why) *why = "the game's physics tuning is not read yet"; + return std::nullopt; + } + const auto *e = find_entry(id); + if (!e) { + if (why) *why = "no tuning value is called \"" + std::string(id) + "\""; + return std::nullopt; + } + return tuning_of(*e); +} +std::vector find_tuning(std::string_view text, std::size_t most) { + std::vector found; + const auto pattern = lower(text); + for (const auto &e : state().entries) + if (found.size() < most && matches(e.key, pattern)) found.push_back(tuning_of(e)); + return found; +} +std::string plugin_tune(std::string_view owner, std::string_view id, std::optional value) { + auto &s = state(); + if (!s.model) return "the game's physics tuning is not read yet"; + auto *e = find_entry(id); + if (!e) return "no tuning value is called \"" + std::string(id) + "\""; + const auto held = s.plugin_tunes.find(e->key); + if (held != s.plugin_tunes.end() && held->second.owner != owner) return held->second.owner + " tunes " + e->id; + if (!value) { + if (held != s.plugin_tunes.end()) { + const double before = held->second.before; + s.plugin_tunes.erase(held); + set_entry(*e, before, false); + changed(false); + } + return {}; + } + std::string why; + if (!editable(&why)) return why; + if (is_locked(*e)) return "the player has locked " + e->id + " in the trainer"; + if (!std::isfinite(*value)) return "a tuning value is a finite number"; + if (held == s.plugin_tunes.end()) s.plugin_tunes[e->key] = {std::string(owner), e->value}; + set_entry(*e, *value, false); + changed(false); + return {}; +} +void release_plugin_tunes(std::string_view owner) { + auto &s = state(); + std::vector mine; + for (const auto &[key, tune] : s.plugin_tunes) + if (tune.owner == owner) mine.push_back(key); + for (const auto &key : mine) (void)plugin_tune(owner, key, std::nullopt); +} std::string command(std::string_view verb, const std::vector &arguments) { auto result = run(verb, arguments); // The menu shows the last answer, except for the ones a dragged slider sends every frame. diff --git a/Extension/Trainer/trainer.h b/Extension/Trainer/trainer.h index 6aa72dca..7a2905af 100644 --- a/Extension/Trainer/trainer.h +++ b/Extension/Trainer/trainer.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -130,4 +131,21 @@ bool take_teleport_card_shown() noexcept; void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::string &level) noexcept; // Runs one `trainer` command and returns the line to print. Only from the console dispatcher. std::string command(std::string_view verb, const std::vector &arguments); + +// Plugins' tuning (reskate.physics.tuning, find, tune), on the game thread. A plugin's value goes over +// the player's own for as long as the plugin keeps it and is never saved; letting go puts back what +// was there. The player changing that value in the trainer takes it back from the plugin. +struct TuningValue { + std::string id, label, group; + Kind kind{}; + double value{}, stock{}; + std::string tuner; // the plugin whose value it is, if any +}; +// Empty, and why, while the game's tuning is not read or there is no such value. +std::optional tuning_value(std::string_view id, std::string *why = nullptr); +// Values whose id has every word of `text` (a word starting with ! must not be in it), at most `most`. +std::vector find_tuning(std::string_view text, std::size_t most); +// Empty when done, else why not. No value: lets go of this plugin's. +std::string plugin_tune(std::string_view owner, std::string_view id, std::optional value); +void release_plugin_tunes(std::string_view owner); } // namespace dingosdk::trainer diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 21af3cc3..002590b0 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -1107,6 +1107,7 @@ void update_model(std::uintptr_t client, TickState& frame) { { DINGO_PROFILE_ZONE("tick/plugins"); dingosdk::plugins::on_client_tick(); + dingosdk::plugins::tick_plugin_settings(); } if (!has_request && now < r.next_model && state == r.previous_state) return; r.next_model = now + 500;