diff --git a/CMakeLists.txt b/CMakeLists.txt index a22900a6..a221055f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -370,6 +370,12 @@ if(DINGOSDK_BUILD_ANIMATION_STUDIO_TESTS) target_include_directories(dingosdk_animation_studio_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_animation_studio_tests PRIVATE dingosdk_json) add_test(NAME animation_studio COMMAND dingosdk_animation_studio_tests) + # An arm reaching (reskate.skater.reach) on a made-up pose. + add_executable(dingosdk_arm_reach_tests Extension/Skater/Test/arm_reach_tests.cpp Extension/Skater/arm_reach.cpp + Extension/AnimationStudio/anim_core.cpp) + 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) endif() option(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS "Build replay keyframe smoothing regression tests" OFF) if(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS) diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 1573a418..5649f61d 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -6,6 +6,7 @@ #include "Extension/Plugins/plugin_runtime.h" #include "Extension/Plugins/plugin_wire.h" #include "Extension/Plugins/plugin_session_feed.h" +#include "Extension/AnimationStudio/anim_core.h" #include "Engine/Core/Debug/trace.h" #include "Extension/Multiplayer/Hud/native_player_ui.h" #include "Extension/Multiplayer/Hud/native_indicators.h" @@ -755,6 +756,23 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui peers.push_back({p.member.id, p.latest_root->position, p.latest_root->rotation, now > p.pose_arrival ? static_cast(now - p.pose_arrival) / 1000.0 : 0.0}); plugin_feed::publish_peers(std::move(peers)); + // And the joints of theirs that plugins asked for (reskate.entities.set's attach). + std::map, plugin_feed::JointPlace> joints; + if (const auto wanted = plugin_feed::joints_wanted(); !wanted.empty()) { + std::vector pose; + for (const auto &p : active_peers(s)) { + if (!p.member.id || p.render_pose.skater.empty()) continue; + pose.clear(); + for (const auto &[player, joint] : wanted) { + if (player != p.member.id || joint >= p.render_pose.skater.size()) continue; + if (pose.empty()) + for (const auto &t : p.render_pose.skater) pose.push_back({t.scale, t.rotation, t.position}); + const auto w = anim_studio::world(pose, joint); + if (anim_studio::finite(w)) joints[{player, joint}] = {w.position, w.rotation, w.scale[0]}; + } + } + } + plugin_feed::publish_joints(std::move(joints)); } if (labels) publish_custom_nametags(s.base, std::move(nametags), diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index ab00daad..49419df7 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -134,8 +134,36 @@ struct FakeGame final : Game { } std::vector pops; std::vector take_grind_pops(std::string_view) override { return std::exchange(pops, {}); } - std::string hide_pad(std::string_view plugin, bool lt, bool rt, bool left_x) override { - calls.push_back(std::format("hide {} {} {} {}", plugin, lt, rt, left_x)); + std::string hide_pad(std::string_view plugin, const PadHidden& h) override { + calls.push_back(std::format("hide {} {} {} {}", plugin, h.left_trigger, h.right_trigger, h.left_x_grinding) + + (h.right_stick || h.left_stick_click || h.right_stick_click + ? std::format(" stick {} {} {}", h.right_stick, h.left_stick_click, h.right_stick_click) : std::string{})); + return {}; + } + std::string cruise(std::string_view plugin, float speed, bool may_slow) override { + calls.push_back(std::format("cruise {} {:.1f} {}", plugin, speed, may_slow)); + return {}; + } + std::optional mouse_motion(std::string_view) override { return MouseMotion{3, -2, 1}; } + 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) + : std::format("reach {} none", plugin)); + return {}; + } + std::optional skater_hand(bool left) override { + if (left) return std::nullopt; + return HandPlace{{1, 2, 3}, {0, 0, 0, 1}}; + } + std::string screen_look(std::string_view plugin, std::optional l) override { + calls.push_back(l ? std::format("look {} {:.2f} {:.0f} {:.2f} {}{}{}{}{}{}{}{}{}{}{} {:.1f} [{}|{}|{}|{}|{}]", plugin, l->bend, l->fov, l->age, l->bars, l->hood, + l->grade, l->soft, l->scanlines, l->grain, l->dropouts, l->head_switching, l->tracking, l->smear, l->iris, l->rolling, + l->text[0], l->text[1], l->text[2], l->text[3], l->text[4]) + : std::format("look {} none", plugin)); + return {}; + } + std::string screen_slap(std::string_view plugin) override { + calls.push_back(std::format("slap {}", plugin)); return {}; } std::optional board_velocity() override { return Vec3{1, -2, 3}; } @@ -154,10 +182,12 @@ struct FakeGame final : Game { } // World objects as the fake keeps them: by plugin and id, ready at once unless hidden. std::map, std::pair>> entities; - std::string entity_set(std::string_view plugin, std::uint32_t id, std::string_view asset, const std::array& m, bool hidden) override { + std::string entity_set(std::string_view plugin, std::uint32_t id, std::string_view asset, const std::array& m, bool hidden, + std::optional attach) override { if (entities.size() >= 600) return "too many"; entities[{std::string(plugin), id}] = {std::string(asset) + (hidden ? " hidden" : ""), m}; - calls.push_back(std::format("entity {} {} {} {:.2f} {:.2f} {:.2f} {:.2f}{}", plugin, id, asset, m[12], m[13], m[14], m[8], hidden ? " hidden" : "")); + calls.push_back(std::format("entity {} {} {} {:.2f} {:.2f} {:.2f} {:.2f}{}{}", plugin, id, asset, m[12], m[13], m[14], m[8], hidden ? " hidden" : "", + attach ? std::format(" on {} {}", attach->player, attach->joint) : std::string{})); return {}; } void entity_remove(std::string_view plugin, std::uint32_t id) override { entities.erase({std::string(plugin), id}); } @@ -945,6 +975,12 @@ void plugins_place_world_entities() { assert(not pcall(reskate.entities.set, 0, gate, {position = vector.create(0, 0, 0)}), "ids start at 1") assert(not pcall(reskate.entities.set, 4, gate, {position = vector.create(0 / 0, 0, 0)}), "a finite position") assert(not pcall(reskate.entities.set, 4, gate, {matrix = {1, 2, 3}}), "16 numbers") + -- On a skater's joint: yours by name, another player's by number. + assert(reskate.entities.set(4, "Props/Camcorder", {position = vector.create(0, 0.1, 0)}, false, {joint = "right_hand"})) + assert(reskate.entities.set(5, "Props/Camcorder", {position = vector.create(0, 0, 0)}, false, {player = "76561198000000001", joint = 103})) + assert(not pcall(reskate.entities.set, 6, gate, {position = vector.create(0, 0, 0)}, false, {joint = 395}), "joints end at 394") + assert(not pcall(reskate.entities.set, 6, gate, {position = vector.create(0, 0, 0)}, false, {joint = "elbow"}), "a joint's name") + assert(not pcall(reskate.entities.set, 6, gate, {position = vector.create(0, 0, 0)}, false, {player = "me", joint = 1}), "a player") reskate.entities.remove(2) assert(reskate.entities.status(2) == nil and reskate.entities.ready()) reskate.log("placed") @@ -957,6 +993,9 @@ void plugins_place_world_entities() { check(game.called("entity props 1 World/Props/Gate_Arch 1.00 2.00 3.00 2.00"), "a yaw and a scale\n" + environment.all()); check(game.called("entity props 2 World/Props/Gate_Arch 0.00 0.00 0.00 0.00"), "its own axes"); check(game.called("entity props 3 World/Props/Gate_Arch 7.00 8.00 9.00 0.00 hidden"), "a matrix, hidden"); + check(game.called("entity props 4 Props/Camcorder 0.00 0.10 0.00 0.00 on 0 49") && + game.called("entity props 5 Props/Camcorder 0.00 0.00 0.00 0.00 on 76561198000000001 103"), + "on your right hand, on another player's head\n" + environment.all()); host.apply({}); check(game.called("entities clear props"), "A plugin's objects go with it"); // Without the permission, refused. @@ -967,6 +1006,58 @@ void plugins_place_world_entities() { check(environment.logged("[sneaky] false"), "world.entities is needed\n" + environment.all()); } +// What a camera mode needs (the Filmer): an arm that reaches, where the hand is, cruise control, the +// mouse, and the right stick and stick clicks kept from the game. +void plugins_reach_cruise_and_read_the_mouse() { + TestEnvironment environment; + auto& game = environment.fake; + Host host(environment, Side::client); + const std::string main = R"( + assert(reskate.skater.reach({at = vector.create(1, 2, 3)})) + assert(reskate.skater.reach({hand = "left", at = vector.create(4, 5, 6), rotation = {0, 0, 0, 2}, weight = 0.5})) + assert(reskate.skater.reach(nil)) + assert(not pcall(reskate.skater.reach, {hand = "foot", at = vector.create(0, 0, 0)}), "a hand is right or left") + assert(not pcall(reskate.skater.reach, {at = vector.create(0, 0, 0), weight = 2}), "a weight is 0 to 1") + assert(not pcall(reskate.skater.reach, {}), "a reach needs a point") + local h = reskate.skater.hand() + 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(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") + assert(reskate.input.hide({right_stick = true, right_stick_click = true})) + assert(reskate.screen.look({bend = 0.8, fov = 110, bars = true, hood = true, grade = true, grain = true, age = 0.35, iris = true, + rolling = 2, text = {"#rec", "0:00:02", nil, "4:05 PM", "OCT.20 2026"}})) + assert(reskate.screen.slap() and reskate.screen.look(nil)) + assert(not pcall(reskate.screen.look, {bend = 2}), "a bend is 0 to 1") + assert(not pcall(reskate.screen.look, {text = {"far too long a line"}}), "12 characters") + assert(not pcall(reskate.screen.look, {text = {"{}"}}), "the camcorder's characters") + assert(not pcall(reskate.screen.look, {text = {"a", "b", "c", "d", "e", "f"}}), "5 lines") + reskate.log("filmed") + )"; + host.apply({package("filmer", {{manifest_file, manifest(R"("skater.animation", "skater.read", "skater.control", "input", "overlay")")}, {"plugin/main.lua", main}})}); + ticks(host, environment, 1); + running(host, "filmer", environment); + check(environment.logged("[filmer] filmed"), "A camera mode's calls work\n" + environment.all()); + check(game.called("reach filmer right 1.0 2.0 3.0 false 1.00") && game.called("reach filmer left 4.0 5.0 6.0 true 0.50") && + 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("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"), + "A camcorder look on the screen, its text in capitals, a slap, and off\n" + environment.all()); + // Each needs its permission. + host.apply({package("sneaky", {{manifest_file, manifest()}, {"plugin/main.lua", R"( + reskate.log(tostring(pcall(reskate.skater.reach, {at = vector.create(0, 0, 0)})) .. " " .. tostring(pcall(reskate.skater.hand)) .. " " .. + tostring(pcall(reskate.skater.cruise, 5)) .. " " .. tostring(pcall(reskate.input.mouse)) .. " " .. tostring(pcall(reskate.screen.look, {}))) + )"}})}); + ticks(host, environment, 1); + check(environment.logged("[sneaky] false false false false false"), + "skater.animation, skater.read, skater.control, input and overlay are needed\n" + environment.all()); +} + void plugins_draw_on_the_overlay() { TestEnvironment environment; Host host(environment, Side::client); @@ -2676,6 +2767,7 @@ int main() { plugins_draw_on_the_overlay(); plugins_draw_pictures(); plugins_place_world_entities(); + plugins_reach_cruise_and_read_the_mouse(); 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 3b923a70..b0b51062 100644 --- a/Extension/Plugins/plugin_api_services.cpp +++ b/Extension/Plugins/plugin_api_services.cpp @@ -166,6 +166,25 @@ int wheel(lua_State* L) { lua_pushnumber(L, game_of(plugin).mouse_wheel()); return 1; } +// input.mouse(): the mouse since this plugin's last read, whatever the game does with it: {x, y +// (raw counts right and down), wheel (notches, away from you positive)}; nil while the game is not in +// front or a menu is open. +int mouse(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.mouse"); + const auto motion = game_of(plugin).mouse_motion(plugin.id()); + if (!motion) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 3); + lua_pushnumber(L, motion->x); + lua_setfield(L, -2, "x"); + lua_pushnumber(L, motion->y); + lua_setfield(L, -2, "y"); + lua_pushnumber(L, motion->wheel); + lua_setfield(L, -2, "wheel"); + return 1; +} // input.game_buttons(hidden, held, pause): the game buttons (XInput bits) kept from the game while // asked, those held down for it, and all game input paused for half a second. int game_buttons(lua_State* L) { @@ -369,7 +388,7 @@ int read_content(lua_State* L) { void install_services_api(Plugin&, lua_State* L) { add_functions(L, "skater", {{"telemetry", telemetry}, {"goofy", goofy}, {"bone_cam", bone_cam}}); - add_functions(L, "input", {{"pad_bits", pad_bits}, {"key", key}, {"wheel", wheel}, {"game_buttons", game_buttons}, {"allowed", input_allowed}, {"focus", focus}}); + 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}}); diff --git a/Extension/Plugins/plugin_api_throwdowns.cpp b/Extension/Plugins/plugin_api_throwdowns.cpp index cd0e1436..253e9ac7 100644 --- a/Extension/Plugins/plugin_api_throwdowns.cpp +++ b/Extension/Plugins/plugin_api_throwdowns.cpp @@ -340,6 +340,15 @@ struct KitApi { need(L, plugin, "skater.control", "reskate.skater.push_reset"); return result(L, game(plugin).skater_push_reset(plugin.id())); } + // cruise(speed, may_slow): carried up to `speed` (m/s, 1 to 30; 0 or nil off) while rolling and not + // braking, for the next 300 ms (call it every tick); with may_slow, eased down to it when faster. + static int cruise(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.cruise"); + const double speed = luaL_optnumber(L, 1, 0); + 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)); + } static int hold(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "skater.control", "reskate.skater.hold"); @@ -360,6 +369,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}}); + add_functions(L, "skater", {{"push", push}, {"push_wait", push_wait}, {"push_reset", push_reset}, {"hold", hold}, {"cruise", cruise}}); } } diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index d506f224..37208aa0 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -443,7 +443,31 @@ struct WorldApi { const auto id = entity_id(L, 1); const auto asset = checked_text(L, 2, 200, "a blueprint's name"); const auto matrix = transform_of(L, 3); - return result(L, game(plugin).entity_set(plugin.id(), id, asset, matrix, lua_toboolean(L, 4) != 0)); + // attach = {player = id?, joint = number or name}: kept on that skater's joint, the transform + // in the joint's frame. + std::optional attach; + if (!lua_isnoneornil(L, 5)) { + luaL_checktype(L, 5, LUA_TTABLE); + attach.emplace(); + lua_getfield(L, 5, "player"); + 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); + } + lua_pop(L, 1); + } + return result(L, game(plugin).entity_set(plugin.id(), id, asset, matrix, lua_toboolean(L, 4) != 0, attach)); } static int entities_remove(lua_State* L) { auto& plugin = plugin_of(L); @@ -806,6 +830,55 @@ 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. + static int reach(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.animation", "reskate.skater.reach"); + if (lua_isnoneornil(L, 1)) return result(L, game(plugin).skater_reach(plugin.id(), std::nullopt)); + luaL_checktype(L, 1, LUA_TTABLE); + Game::Reach 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 reach's hand is \"right\" or \"left\""); + goal.left = hand == "left"; + } + lua_pop(L, 1); + lua_getfield(L, 1, "at"); + goal.at = position_of(L, lua_gettop(L), "the reach's point"); + lua_pop(L, 1); + lua_getfield(L, 1, "rotation"); + if (!lua_isnil(L, -1)) goal.rotation = quaternion_at(L, lua_gettop(L)); + lua_pop(L, 1); + lua_getfield(L, 1, "weight"); + if (!lua_isnil(L, -1)) { + const double w = luaL_checknumber(L, -1); + if (!(w >= 0 && w <= 1)) luaL_error(L, "a reach's weight is 0 to 1"); + goal.weight = static_cast(w); + } + lua_pop(L, 1); + return result(L, game(plugin).skater_reach(plugin.id(), goal)); + } + // 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); + need(L, plugin, "skater.read", "reskate.skater.hand"); + const std::string side = luaL_optstring(L, 1, "right"); + if (side != "right" && side != "left") luaL_argerror(L, 1, "\"right\" or \"left\""); + const auto placed = game(plugin).skater_hand(side == "left"); + if (!placed) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 2); + push_position(L, placed->position); + lua_setfield(L, -2, "position"); + push_quaternion(L, placed->rotation); + lua_setfield(L, -2, "rotation"); + return 1; + } static std::array quaternion_at(lua_State* L, int index) { luaL_checktype(L, index, LUA_TTABLE); std::array q{}; @@ -1551,22 +1624,87 @@ struct WorldApi { static int input_hide(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "input", "reskate.input.hide"); - bool lt{}, rt{}, left_x{}; + Game::PadHidden hidden; if (!lua_isnoneornil(L, 1)) { luaL_checktype(L, 1, LUA_TTABLE); - lua_getfield(L, 1, "left_trigger"); - lt = lua_toboolean(L, -1) != 0; - lua_getfield(L, 1, "right_trigger"); - rt = lua_toboolean(L, -1) != 0; + const auto flag = [&](const char* name) { + lua_getfield(L, 1, name); + const bool on = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + return on; + }; + hidden.left_trigger = flag("left_trigger"); + hidden.right_trigger = flag("right_trigger"); + hidden.right_stick = flag("right_stick"); + hidden.left_stick_click = flag("left_stick_click"); + hidden.right_stick_click = flag("right_stick_click"); lua_getfield(L, 1, "left_x"); if (!lua_isnil(L, -1)) { if (lua_type(L, -1) != LUA_TSTRING || std::string_view(lua_tostring(L, -1)) != "grinding") luaL_error(L, "left_x is hidden only while grinding: left_x = \"grinding\""); - left_x = true; + hidden.left_x_grinding = true; } - lua_pop(L, 3); + lua_pop(L, 1); } - return result(L, game(plugin).hide_pad(plugin.id(), lt, rt, left_x)); + return result(L, game(plugin).hide_pad(plugin.id(), hidden)); + } + // screen.look({bend, fov, bars, hood, grade, soft, scanlines, grain, age, dropouts, head_switching, + // tracking, smear, iris, rolling, text}): a camcorder look over the game's picture for 0.3 s, so + // every tick; nil ends it. One plugin at a time. + static int screen_look(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "overlay", "reskate.screen.look"); + if (lua_isnoneornil(L, 1)) return result(L, game(plugin).screen_look(plugin.id(), std::nullopt)); + luaL_checktype(L, 1, LUA_TTABLE); + Game::ScreenLook look; + const auto flag = [&](const char* name) { + lua_getfield(L, 1, name); + const bool on = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + return on; + }; + const auto number = [&](const char* name, double fallback, double low, double high, const char* why) { + lua_getfield(L, 1, name); + double v = fallback; + if (!lua_isnil(L, -1)) { + v = luaL_checknumber(L, -1); + if (!(v >= low && v <= high)) luaL_error(L, "%s", why); + } + lua_pop(L, 1); + return v; + }; + look.bars = flag("bars"), look.hood = flag("hood"), look.grade = flag("grade"), look.soft = flag("soft"); + look.scanlines = flag("scanlines"), look.grain = flag("grain"), look.dropouts = flag("dropouts"); + look.head_switching = flag("head_switching"), look.tracking = flag("tracking"), look.smear = flag("smear"), look.iris = flag("iris"); + look.bend = static_cast(number("bend", 0, 0, 1, "a look's bend is 0 to 1")); + look.fov = static_cast(number("fov", 60, 5, 170, "a look's fov is 5 to 170 degrees")); + look.age = static_cast(number("age", 0, 0, 1, "a look's tape age is 0 to 1")); + look.rolling = number("rolling", -1, 0, 1e7, "a look's rolling is the seconds since the tape started, 0 or more"); + lua_getfield(L, 1, "text"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + if (lua_objlen(L, -1) > 5) luaL_error(L, "a look's text is at most 5 lines"); + for (int line = 0; line < 5; ++line) { + lua_rawgeti(L, -1, line + 1); + if (!lua_isnil(L, -1)) { + std::string text = luaL_checkstring(L, -1); + if (text.size() > 12) luaL_error(L, "a look's text line is at most 12 characters"); + for (auto& c : text) { + c = static_cast(std::toupper(static_cast(c))); + if (c < 0x20 || c > 0x5f) luaL_error(L, "a look's text is letters, digits, spaces and !\"#$%%&'()*+,-./:;<=>?@[\\]^_"); + } + look.text[static_cast(line)] = std::move(text); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); + return result(L, game(plugin).screen_look(plugin.id(), look)); + } + static int screen_slap(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "overlay", "reskate.screen.slap"); + return result(L, game(plugin).screen_slap(plugin.id())); } // ---- ui.screen --------------------------------------------------------------------------------------- // screen(title, build): a full-screen screen, drawn while open() has it open; Esc closes it. @@ -1626,7 +1764,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}}); + {"grind_exit", grind_exit}, {"reach", reach}, {"hand", hand}}); 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}}); @@ -1657,6 +1795,7 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { 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}}); + add_functions(L, "screen", {{"look", screen_look}, {"slap", screen_slap}}); } std::uint32_t combo_of(std::string_view name) { diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index fbcd3c38..35d2ecde 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -268,7 +268,14 @@ public: // full transform (rows: right, up, forward axes with their scale, then the position), kept by the // plugin's id for it. Only this player's game shows them. Status: 0 none, 1 waiting, 2 loading, // 3 missing, 4 ready, 5 hidden. - virtual std::string entity_set(std::string_view /*plugin*/, std::uint32_t, std::string_view, const std::array&, bool) { + // With `attach`, kept on a joint of a skater (player 0: the local one) and the transform in that + // joint's frame, moved with it every frame. + struct EntityAttach { + std::uint64_t player{}; + std::uint16_t joint{}; + }; + virtual std::string entity_set(std::string_view /*plugin*/, std::uint32_t, std::string_view, const std::array&, bool, + std::optional /*attach*/) { return unavailable; } virtual void entity_remove(std::string_view /*plugin*/, std::uint32_t) {} @@ -326,6 +333,12 @@ public: virtual std::optional pad_bits(bool /*menu*/) { return std::nullopt; } virtual bool key_down(int /*virtual_key*/) { return false; } virtual int mouse_wheel() { return 0; } + // The mouse since this plugin's last read (reskate.input.mouse): raw counts right and down, the + // wheel in notches (away from you positive). Empty while the game is not in front or a menu is open. + struct MouseMotion { + double x{}, y{}, wheel{}; + }; + virtual std::optional mouse_motion(std::string_view /*plugin*/) { return std::nullopt; } // Game buttons kept from the game while asked (overlay::hide_game_buttons/hold_game_buttons), // and all game input paused for half a second at a time. virtual void game_buttons(std::string_view /*plugin*/, std::uint16_t /*hidden*/, std::uint16_t /*held*/, bool /*paused*/) {} @@ -449,6 +462,22 @@ public: virtual std::string play_clip(std::string_view /*plugin*/, ClipRef, float /*speed*/, float /*blend*/, bool /*loop*/) { return unavailable; } virtual void stop_clip(std::string_view /*plugin*/) {} virtual bool clip_playing(std::string_view /*plugin*/) { return false; } + // 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. + struct Reach { + bool left{}; + Vec3 at{}; + std::optional> rotation; + float weight{1}; + }; + virtual std::string skater_reach(std::string_view /*plugin*/, std::optional) { 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; } virtual std::string replace_trick(std::string_view /*plugin*/, std::string /*trick*/, std::string /*pop*/, ClipRef, float /*speed*/) { return unavailable; } @@ -519,7 +548,25 @@ public: virtual std::vector take_grind_pops(std::string_view /*plugin*/) { return {}; } // Pad controls kept from the game while any plugin asks: the triggers, and the left stick's // left/right while the skater grinds. - virtual std::string hide_pad(std::string_view /*plugin*/, bool /*lt*/, bool /*rt*/, bool /*left_x while grinding*/) { return unavailable; } + // 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; } + // 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 { + bool left_trigger{}, right_trigger{}, left_x_grinding{}, right_stick{}, left_stick_click{}, right_stick_click{}; + }; + virtual std::string hide_pad(std::string_view /*plugin*/, const PadHidden&) { return unavailable; } + // A camcorder look over the game's picture (reskate.screen.look; Extension/UI/Overlay/screen_look.h + // says what each is) for the next 300 ms; empty ends it. One plugin at a time. + struct ScreenLook { + float bend{}, fov{60}, age{}; + bool bars{}, hood{}, grade{}, soft{}, scanlines{}, grain{}, dropouts{}, head_switching{}, tracking{}, smear{}, iris{}; + double rolling{-1}; + std::array text; + }; + virtual std::string screen_look(std::string_view /*plugin*/, std::optional) { return unavailable; } + // A hand slaps the lens, while this plugin has the look. + virtual std::string screen_slap(std::string_view /*plugin*/) { return unavailable; } // The skater bails as from a slam of `force` (0.5 to 3); not in a throwdown. While No Bail protects // the skater the wipeout is dropped, as any other is. virtual std::string wipeout(float /*force*/) { return unavailable; } diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 79af3469..e9936004 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -56,6 +56,7 @@ #include "Extension/UI/NativeMenu/throwdown_menu.h" #include #include "Extension/UI/Overlay/overlay.h" +#include "Extension/UI/Overlay/screen_look.h" #include #include #include @@ -148,8 +149,37 @@ public: const auto status = edit_local_park(operation, model.map, model.generation, model.revision, {}, placed); return status.starts_with("error: ") ? status.substr(7) : std::string{}; } - std::string entity_set(std::string_view plugin, std::uint32_t id, std::string_view asset, const std::array& matrix, bool hidden) override { - return world_entities::set(plugin, id, std::string(asset), matrix, hidden); + std::string entity_set(std::string_view plugin, std::uint32_t id, std::string_view asset, const std::array& matrix, bool hidden, + std::optional attach) override { + if (!attach) return world_entities::set(plugin, id, std::string(asset), matrix, hidden); + world_entities::set_joint_source(&joint_matrix); + return world_entities::set(plugin, id, std::string(asset), matrix, hidden, world_entities::Attach{attach->player, attach->joint}); + } + // Where a skater's joint is (world_entities' attach): the local skater's as its pose is evaluated, + // another player's as the session draws it. + static std::optional joint_matrix(std::uint64_t player, std::uint16_t joint) noexcept { + try { + std::array position{}; + std::array q{0, 0, 0, 1}; + float scale = 1; + if (!player) { + const auto at = skater_animation::joint(joint); + if (!at) return std::nullopt; + position = at->position, q = at->rotation, scale = at->scale[0]; + } else { + const auto at = plugin_feed::joint_place(player, joint); + if (!at) return std::nullopt; + position = at->position, q = at->rotation, scale = at->scale; + } + // The rotation's axes as rows (right, up, forward), scaled, then the position. + const float x = q[0], y = q[1], z = q[2], w = q[3]; + return world_entities::Matrix{(1 - 2 * (y * y + z * z)) * scale, 2 * (x * y + w * z) * scale, 2 * (x * z - w * y) * scale, 0, + 2 * (x * y - w * z) * scale, (1 - 2 * (x * x + z * z)) * scale, 2 * (y * z + w * x) * scale, 0, + 2 * (x * z + w * y) * scale, 2 * (y * z - w * x) * scale, (1 - 2 * (x * x + y * y)) * scale, 0, + position[0], position[1], position[2], 1}; + } catch (...) { + return std::nullopt; + } } void entity_remove(std::string_view plugin, std::uint32_t id) override { world_entities::remove(plugin, id); } void entity_clear(std::string_view plugin) override { world_entities::clear(plugin); } @@ -616,9 +646,16 @@ public: for (auto& p : grind_exit::take_pops(plugin)) out.push_back({p.applied, std::move(p.reason), std::move(p.basis), p.toward, p.before, p.after}); return out; } - std::string hide_pad(std::string_view plugin, bool lt, bool rt, bool left_x) override { + // A speed boost: off where the session's host turned boosts off, and while a throwdown runs. + std::string cruise(std::string_view, float speed, bool may_slow) override { + if (speed > 0 && multiplayer_session_active() && !session_boosts_allowed()) return "the host has turned boosts off"; + if (speed > 0 && multiplayer::local_throwdown_active()) return "not while a throwdown runs"; + set_plugin_cruise(speed, may_slow); + 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, {lt, rt, left_x}); + 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}); return {}; } std::optional board_velocity() override { @@ -720,6 +757,60 @@ public: player_.clear(); } bool clip_playing(std::string_view plugin) override { return player_ == plugin && skater_animation::playing(); } + std::string screen_look(std::string_view plugin, std::optional look) override { + const auto now = GetTickCount64(); + if (!looker_.empty() && looker_ != plugin && now < look_at_ + 300) return look ? looker_ + " has the screen's look" : std::string{}; + if (!look) { + if (looker_ == plugin) { + screen_look::clear(); + looker_.clear(); + } + return {}; + } + looker_ = plugin; + look_at_ = now; + screen_look::Settings s; + s.bend = look->bend, s.fov = look->fov, s.age = look->age; + s.bars = look->bars, s.hood = look->hood, s.grade = look->grade, s.soft = look->soft, s.scanlines = look->scanlines, s.grain = look->grain; + s.dropouts = look->dropouts, s.head_switching = look->head_switching, s.tracking = look->tracking, s.smear = look->smear, s.iris = look->iris; + s.rolling = look->rolling; + s.text = look->text; + screen_look::set(s); + return {}; + } + std::string screen_slap(std::string_view plugin) override { + if (looker_ != plugin || GetTickCount64() >= look_at_ + 300) return "only over this plugin's screen look"; + screen_look::slap(); + 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) { + if (!goal) return {}; + return reacher_ + " holds the skater's arm"; + } + if (!goal) { + if (reacher_ == plugin) { + skater_animation::reach(std::nullopt); + reacher_.clear(); + } + return {}; + } + reacher_ = plugin; + reach_at_ = now; + arm_reach::Goal g; + g.hand = goal->left ? arm_reach::Hand::left : arm_reach::Hand::right; + g.at = goal->at; + if (goal->rotation) g.rotation = *goal->rotation; + g.weight = goal->weight; + skater_animation::reach(g); + return {}; + } + 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; + return HandPlace{placed->position, placed->rotation}; + } std::string replace_trick(std::string_view plugin, std::string trick, std::string pop, ClipRef clip, float speed) override { const auto key = std::make_pair(trick, pop); if (const auto found = replacements_.find(key); found != replacements_.end() && found->second.first != plugin) @@ -795,6 +886,25 @@ public: } bool key_down(int virtual_key) override { return overlay::key_down(virtual_key); } int mouse_wheel() override { return overlay::take_mouse_wheel(); } + // One read of the overlay's mouse a call, shared out: every plugin that read it in the last two + // seconds gets the movement, so two plugins never take it from each other. + std::optional mouse_motion(std::string_view plugin) override { + overlay::MouseLook look; + const bool live = overlay::read_mouse_look(look); + const auto now = GetTickCount64(); + std::erase_if(mouse_readers_, [&](const auto& entry) { return now - entry.second.at > 2000; }); + for (auto& [who, reader] : mouse_readers_) { + reader.motion.x += look.x; + reader.motion.y += look.y; + reader.motion.wheel += look.wheel / 120.0; + } + auto& mine = mouse_readers_[std::string(plugin)]; + if (!mine.at) mine.motion = {static_cast(look.x), static_cast(look.y), look.wheel / 120.0}; + mine.at = now; + const auto out = std::exchange(mine.motion, {}); + if (!live) return std::nullopt; + return out; + } void game_buttons(std::string_view plugin, std::uint16_t hidden, std::uint16_t held, bool paused) override { buttons_owner_ = plugin; overlay::hide_game_buttons(hidden); @@ -1069,6 +1179,14 @@ public: game_speed::ask(plugin, 1.0f); grind_exit::release(plugin); trick_rewrite::release(plugin); + if (reacher_ == plugin) { + skater_animation::reach(std::nullopt); + reacher_.clear(); + } + if (looker_ == plugin) { + screen_look::clear(); + looker_.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(); @@ -1099,6 +1217,15 @@ private: }; std::map> variant_reads_; std::vector enabled_mods_; + std::string reacher_; // the plugin holding the skater's arm (skater_reach) + std::string looker_; // the plugin whose look is on the screen (screen_look) + std::uint64_t look_at_{}; + std::uint64_t reach_at_{}; + struct MouseReader { + MouseMotion motion; + std::uint64_t at{}; + }; + std::map> mouse_readers_; std::uint64_t enabled_read_at_{}; // The Bone Cam's slam and strike counts each plugin last read with its telemetry. std::map, std::less<>> telemetry_seen_; diff --git a/Extension/Plugins/plugin_session_feed.h b/Extension/Plugins/plugin_session_feed.h index f836a341..027725ed 100644 --- a/Extension/Plugins/plugin_session_feed.h +++ b/Extension/Plugins/plugin_session_feed.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include #include @@ -19,6 +20,12 @@ struct Peer { std::array rotation{0, 0, 0, 1}; // x, y, z, w double age_ms{}; // since its pose arrived, when published }; +// Where a joint of another player's skater is in the world, as the session last drew it. +struct JointPlace { + std::array position{}; + std::array rotation{0, 0, 0, 1}; + float scale{1}; +}; struct Tag { std::string text; std::uint32_t colour{0xffffffffu}; // IM_COL32 layout @@ -28,6 +35,33 @@ namespace detail { inline std::mutex mutex; inline std::vector peers; inline std::map, Tag> tags; // (plugin, player) +// (player, joint): when it was last asked for, and where it is. +inline std::map, std::chrono::steady_clock::time_point> joints_asked; +inline std::map, JointPlace> joints; +} + +// Where a joint of another player is (joint_place), asked for by the plugins' side. Asking keeps it +// published for two seconds, so the first ask comes back empty; at most 64 are kept. +inline std::optional joint_place(std::uint64_t player, std::uint16_t joint) { + std::lock_guard lock(detail::mutex); + const std::pair key{player, joint}; + if (detail::joints_asked.size() < 64 || detail::joints_asked.contains(key)) detail::joints_asked[key] = std::chrono::steady_clock::now(); + const auto at = detail::joints.find(key); + if (at == detail::joints.end()) return std::nullopt; + return at->second; +} +// The joints asked for in the last two seconds, for the session to publish. +inline std::vector> joints_wanted() { + std::lock_guard lock(detail::mutex); + const auto now = std::chrono::steady_clock::now(); + std::erase_if(detail::joints_asked, [&](const auto& asked) { return now - asked.second > std::chrono::seconds(2); }); + std::vector> result; + for (const auto& [key, when] : detail::joints_asked) result.push_back(key); + return result; +} +inline void publish_joints(std::map, JointPlace> joints) { + std::lock_guard lock(detail::mutex); + detail::joints = std::move(joints); } inline void publish_peers(std::vector peers) { diff --git a/Extension/Skater/Test/arm_reach_tests.cpp b/Extension/Skater/Test/arm_reach_tests.cpp new file mode 100644 index 00000000..bc854b4f --- /dev/null +++ b/Extension/Skater/Test/arm_reach_tests.cpp @@ -0,0 +1,144 @@ +// An arm reaching apart from the game: the hand joint lands on a point in reach, stops at the arm's +// length short of one out of reach, turns to a rotation asked for, and leaves a broken pose alone. +#include "Extension/Skater/arm_reach.h" +#include +#include +#include +#include + +using namespace dingosdk; +using namespace dingosdk::arm_reach; +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.9999f; } +bool same_pose(const std::vector& a, const std::vector& b) { + for (std::size_t i = 0; i < a.size(); ++i) + if (!same_turn(a[i].rotation, b[i].rotation) || distance(a[i].position, b[i].position) > 1e-6f) return false; + return true; +} + +// A skater standing with both arms straight out: upper arms 0.3 m, forearms 0.28 m. +constexpr float upper_length = 0.3f, fore_length = 0.28f; +std::vector t_pose() { + std::vector pose(395); + pose[0].position = {10, 0, 5}; + pose[1].position = {0, 1.0f, 0}; + pose[45].position = {0, 0.4f, 0}; + pose[0].rotation = anim_studio::from_euler({0, 30, 0}); // the skater turned, to keep the maths honest + for (const auto hand : {Hand::right, Hand::left}) { + const auto joints = chain(hand); + const float x = hand == Hand::left ? -1.0f : 1.0f; + pose[joints[7]].position = {0.15f * x, 0, 0}; + pose[joints[8]].position = {0.1f * x, 0, 0}; + pose[joints[9]].position = {upper_length * x, 0, 0}; + pose[joints[10]].position = {fore_length * x, 0, 0}; + } + return pose; +} +Vec3 joint_at(const std::vector& pose, Hand hand, std::size_t index) { + std::array w{}; + check(compose(pose, chain(hand), w), "The arm composes"); + return w[index].position; +} + +void a_point_in_reach_is_reached() { + for (const auto side : {Hand::right, Hand::left}) { + auto pose = t_pose(); + const Vec3 s = joint_at(pose, side, 8); + Goal goal; + goal.hand = side; + goal.at = {s[0] + 0.1f, s[1] - 0.25f, s[2] + 0.3f}; + const auto r = reach(pose, goal); + check(r.valid && r.reached, "A point in reach is reached"); + check(distance(r.hand.position, goal.at) < 2e-3f, "and the hand is on it"); + check(distance(hand_of(pose, side).hand.position, goal.at) < 2e-3f, "as the pose now has it"); + check(std::abs(distance(joint_at(pose, side, 8), joint_at(pose, side, 9)) - upper_length) < 1e-4f, "The upper arm keeps its length"); + check(std::abs(distance(joint_at(pose, side, 9), joint_at(pose, side, 10)) - fore_length) < 1e-4f, "and the forearm its length"); + } +} + +void a_point_out_of_reach_is_pointed_at() { + auto pose = t_pose(); + const Vec3 s = joint_at(pose, Hand::right, 8); + Goal goal; + goal.at = {s[0], s[1] + 3.0f, s[2]}; + const auto r = reach(pose, goal); + check(r.valid && !r.reached, "A point out of reach is not reached"); + check(std::abs(distance(r.hand.position, s) - (upper_length + fore_length) * 0.995f) < 2e-3f, "The arm stops just short of straight"); + check(r.hand.position[1] - s[1] > 0.5f, "pointing at it"); +} + +void a_rotation_asked_for_is_turned_to() { + auto pose = t_pose(); + const Vec3 s = joint_at(pose, Hand::right, 8); + Goal goal; + goal.at = {s[0] + 0.2f, s[1] - 0.2f, s[2] + 0.2f}; + goal.rotation = anim_studio::from_euler({20, -70, 45}); + const auto r = reach(pose, goal); + check(r.valid && same_turn(r.hand.rotation, *goal.rotation), "The hand turns to the rotation asked for"); + check(distance(r.hand.position, goal.at) < 2e-3f, "without leaving the point"); +} + +void no_weight_is_the_animation() { + auto pose = t_pose(); + const auto before = pose; + Goal goal; + goal.at = joint_at(pose, Hand::right, 8); + goal.at[2] += 0.4f; + goal.weight = 0; + check(reach(pose, goal).valid && same_pose(pose, before), "A weight of 0 leaves the arm as it was"); +} + +void only_the_arm_is_written() { + auto pose = t_pose(); + const auto before = pose; + Goal goal; + goal.hand = Hand::left; + goal.at = joint_at(pose, Hand::left, 8); + goal.at[1] -= 0.4f; + goal.rotation = anim_studio::from_euler({0, 0, 90}); + check(reach(pose, goal).valid, "The left arm reaches"); + for (std::size_t i = 0; i < pose.size(); ++i) + if (i < first_written(Hand::left) || i > last_written(Hand::left)) + check(same_turn(pose[i].rotation, before[i].rotation), "A reach writes only the joints it says it writes"); +} + +void a_broken_pose_is_left_alone() { + auto pose = t_pose(); + pose[48].rotation[0] = NAN; + const auto before = pose; + Goal goal; + goal.at = {10, 1, 5}; + check(!reach(pose, goal).valid && !hand_of(pose, Hand::right).valid, "A broken arm is not reached with"); + for (std::size_t i = 0; i < pose.size(); ++i) + if (i != 48) check(same_turn(pose[i].rotation, before[i].rotation), "and nothing is written"); + auto short_pose = t_pose(); + short_pose.resize(40); + check(!reach(short_pose, goal).valid, "A pose without the arm's joints is refused"); + auto stretched = t_pose(); + stretched[48].position = {3, 0, 0}; + check(!reach(stretched, goal).valid, "An arm not an arm's length is refused"); + goal.at = {NAN, 0, 0}; + auto fine = t_pose(); + check(!reach(fine, goal).valid, "A point that is not a number is refused"); +} +} // namespace + +int main() { + try { + a_point_in_reach_is_reached(); + a_point_out_of_reach_is_pointed_at(); + a_rotation_asked_for_is_turned_to(); + no_weight_is_the_animation(); + only_the_arm_is_written(); + a_broken_pose_is_left_alone(); + } catch (const std::exception& error) { + std::cerr << "FAILED: " << error.what() << '\n'; + return 1; + } + std::cout << "arm_reach: all passed\n"; + return 0; +} diff --git a/Extension/Skater/arm_reach.cpp b/Extension/Skater/arm_reach.cpp new file mode 100644 index 00000000..54d24180 --- /dev/null +++ b/Extension/Skater/arm_reach.cpp @@ -0,0 +1,123 @@ +#include "arm_reach.h" +#include +#include + +namespace dingosdk::arm_reach { +namespace { +using anim_studio::conjugate; +using anim_studio::multiply; +using anim_studio::normalised; +using anim_studio::rotate; + +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}); +} +// In a chain: chest, shoulder, upper arm, forearm, hand. +constexpr std::size_t chest = 6, shoulder = 7, upper = 8, fore = 9, hand_at = 10; +static_assert(right_chain.size() == hand_at + 1 && left_chain.size() == hand_at + 1); +} // namespace + +bool compose(std::span pose, std::span joints, std::span out) noexcept { + if (out.size() < joints.size()) return false; + Placed at; + for (std::size_t i = 0; i < joints.size(); ++i) { + if (joints[i] >= pose.size()) return false; + const auto& bone = pose[joints[i]]; + if (!finite(bone.position) || !finite(bone.rotation) || !finite(bone.scale)) return false; + Placed next; + next.position = add(at.position, rotate(at.rotation, scaled(bone.position, at.scale))); + next.rotation = normalised(multiply(at.rotation, bone.rotation)); + next.scale = at.scale * (bone.scale[0] + bone.scale[1] + bone.scale[2]) / 3.0f; + if (!finite(next.position) || !(std::abs(next.position[0]) < 1e6f && std::abs(next.position[1]) < 1e6f && std::abs(next.position[2]) < 1e6f)) + return false; + out[i] = next; + at = next; + } + return true; +} + +Reached reach(std::span pose, const Goal& goal) noexcept { + const auto joints = chain(goal.hand); + std::array w{}; + if (!compose(pose, joints, w) || !finite(goal.at) || (goal.rotation && !finite(*goal.rotation))) return {}; + 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 reach_length = (a + b) * 0.995f; + const float wanted = length(sub(goal.at, s)); + const float d = std::clamp(wanted, std::abs(a - b) + 0.02f, reach_length); + const Vec3 direction = unit(sub(goal.at, 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}); + Vec3 ahead{direction[0], 0.0f, direction[2]}; + ahead = unit(ahead, cross(side, {0, 1, 0})); + Vec3 pole = add(add(scaled(side, 0.8f), Vec3{0.0f, -0.45f, 0.0f}), scaled(ahead, -0.35f)); + pole = sub(pole, scaled(direction, dot(pole, direction))); + pole = unit(pole, side); + 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 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.rotation) { + // The hand, from the forearm as it now is, turned to the rotation asked for. + 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), normalised(*goal.rotation))), weight); + } + if (!compose(pose, joints, w)) return {}; + Reached out; + out.valid = finite(w[hand_at].position); + out.hand = w[hand_at]; + out.reached = wanted <= reach_length + 0.01f; + return out; +} + +Reached hand_of(std::span pose, Hand hand) noexcept { + std::array w{}; + if (!compose(pose, chain(hand), w)) return {}; + return {finite(w[hand_at].position), w[hand_at], true}; +} +} diff --git a/Extension/Skater/arm_reach.h b/Extension/Skater/arm_reach.h new file mode 100644 index 00000000..f4cb9c21 --- /dev/null +++ b/Extension/Skater/arm_reach.h @@ -0,0 +1,53 @@ +#pragma once +#include "Extension/AnimationStudio/anim_core.h" +#include +#include +#include +#include + +// An arm that reaches: the hand joint of the local skater's pose taken to a point in the world, and +// turned to a rotation, by bending the upper arm and forearm (the elbow out from the body and a little +// down and back) and turning the hand. Pure maths on the 395-joint AnimBase pose, each joint relative +// to its parent and joint 0 composing into the world (Extension/Skater/client_first_person.cpp); made +// from the Filmer's arm holding its camera (neekgate/ReSkate, filmer-zee, filmer_hold.cpp). +// skater_animation.cpp applies it as the pose is evaluated, drawn and sent. +namespace dingosdk::arm_reach { +using anim_studio::Joint; +using anim_studio::Quat; +using anim_studio::Vec3; + +enum class Hand : std::uint8_t { right, left }; +// From the pose's root to the hand: chest (45), shoulder, upper arm, forearm, hand. +inline constexpr std::array right_chain{0, 1, 7, 42, 43, 44, 45, 46, 47, 48, 49}; +inline constexpr std::array left_chain{0, 1, 7, 42, 43, 44, 45, 275, 276, 277, 278}; +inline constexpr std::span chain(Hand hand) noexcept { return hand == Hand::left ? left_chain : right_chain; } +// The joints a reach writes: the upper arm to the hand, one after another in the pose. +inline constexpr std::uint16_t first_written(Hand hand) noexcept { return hand == Hand::left ? 276 : 47; } +inline constexpr std::uint16_t last_written(Hand hand) noexcept { return hand == Hand::left ? 278 : 49; } + +struct Placed { + Vec3 position{}; + Quat rotation{0, 0, 0, 1}; + float scale = 1.0f; +}; +struct Goal { + Hand hand = Hand::right; + Vec3 at{}; // where the hand joint should be, in the world + std::optional rotation; // the hand joint's rotation in the world; none: as the forearm leaves it + float weight = 1.0f; // 0: the arm as the animation has it, 1: reaching +}; +struct Reached { + bool valid{}; + Placed hand; // where the hand joint is, after the reach + bool reached{}; // the point was within the arm's reach +}; + +// Every joint along `joints` (each the parent of the next) composed into the world; false when one is +// missing or not a number. +bool compose(std::span pose, std::span joints, std::span out) noexcept; +// Bends the arm in `pose` (in place) toward the goal. Leaves the pose alone and returns invalid when the +// joints are not there, or the arm's bones are not an arm's length. +Reached reach(std::span pose, const Goal& goal) noexcept; +// Where the hand joint is in the pose as it is. +Reached hand_of(std::span pose, Hand hand) noexcept; +} diff --git a/Extension/Skater/client_noclip.cpp b/Extension/Skater/client_noclip.cpp index ca93427a..a0d6fc90 100644 --- a/Extension/Skater/client_noclip.cpp +++ b/Extension/Skater/client_noclip.cpp @@ -282,6 +282,11 @@ struct PushSpeed { std::atomic factor{1}, stock{9.25f}, cruise{}; std::uintptr_t client{}, entity{}; std::atomic expires{}; + // A plugin's cruise control (set_plugin_cruise): the speed to ride at (0 none), whether it may + // ease the skater off, and until when it holds. + std::atomic plugin{}; + std::atomic plugin_slow{}; + std::atomic plugin_expires{}; // Physics thread only. int pushing{}, since_push{hold_steps}; bool carried{}; // a held push is being carried past the game's last step @@ -290,7 +295,10 @@ PushSpeed& push_speed() { static auto* value = new PushSpeed; return *value; } void trainer_push_speed(std::uintptr_t core) noexcept { auto& p = push_speed(); const float factor = p.factor.load(std::memory_order_relaxed), cruise = p.cruise.load(std::memory_order_relaxed); - if ((factor == 1 && cruise <= 0) || GetTickCount64() >= p.expires.load(std::memory_order_acquire)) return; + const ULONGLONG ticks = GetTickCount64(); + const float plugin = ticks < p.plugin_expires.load(std::memory_order_acquire) ? p.plugin.load(std::memory_order_relaxed) : 0.0f; + const bool plugin_slow = plugin > 0 && p.plugin_slow.load(std::memory_order_relaxed); + if ((factor == 1 && cruise <= 0 && plugin <= 0) || ticks >= p.expires.load(std::memory_order_acquire)) return; const auto watch = watched_physics_state(); if (!watch.valid || watch.state != 100) return; // riding the ground SourceLastError error; @@ -335,6 +343,10 @@ void trainer_push_speed(std::uintptr_t core) noexcept { change = std::min(push_gain, tap_speed * factor - speed); } else if (pushed && factor < 1 && speed > target * factor && speed <= target * 1.1f) { change = -std::min(push_gain, speed - target * factor); // pushed speed only: a hill's is faster than the target + } else if (plugin_slow && !pushed && speed > plugin + 0.3f) { + change = -std::min(0.1f, speed - plugin); // riding up on what it keeps pace with: ease off (never while pushing) + } else if (plugin > 0 && speed >= 1.0f && speed < plugin) { + change = std::min(cruise_gain, plugin - speed); // a plugin's cruise control, before auto push } else if (cruise > 0 && speed >= 1.0f && speed < cruise) { change = std::min(cruise_gain, cruise - speed); } @@ -539,6 +551,12 @@ bool queue_jump_scale(std::uintptr_t client, std::uintptr_t entity, float factor return true; } catch (...) { return false; } } +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); + p.plugin_slow.store(may_slow, std::memory_order_relaxed); + p.plugin_expires.store(speed > 0 ? GetTickCount64() + 300 : 0, std::memory_order_release); +} void set_push_speed(std::uintptr_t client, std::uintptr_t entity, float factor, float stock, float cruise) noexcept { auto& p = push_speed(); p.client = client; diff --git a/Extension/Skater/client_source_spawn.h b/Extension/Skater/client_source_spawn.h index c7c7d8d8..2664e09d 100644 --- a/Extension/Skater/client_source_spawn.h +++ b/Extension/Skater/client_source_spawn.h @@ -29,6 +29,10 @@ JumpScaleResult take_jump_scale_result() noexcept; // rolling skater that is not braking gains speed up to it (m/s). Publish every client tick: it // expires after 500 ms. void set_push_speed(std::uintptr_t client, std::uintptr_t entity, float factor, float stock, float cruise) noexcept; +// A plugin's cruise control (reskate.skater.cruise): a rolling skater that is not braking is carried +// 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; // 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/grind_exit.cpp b/Extension/Skater/grind_exit.cpp index 78ccce27..0e5d0a40 100644 --- a/Extension/Skater/grind_exit.cpp +++ b/Extension/Skater/grind_exit.cpp @@ -48,7 +48,7 @@ struct Control { std::string wisher; std::optional wanted; std::map> hidden; - std::atomic hide_lt{}, hide_rt{}, hide_left_x{}; + std::atomic hide_lt{}, hide_rt{}, hide_left_x{}, hide_right_stick{}, hide_l3{}, hide_r3{}; std::vector pops; // under mutex std::atomic side_speed{gx::measured_side_speed}; }; @@ -131,6 +131,9 @@ void filter_pad(unsigned, overlay::GamepadValues& pad) noexcept { if (c.hide_lt.load(std::memory_order_acquire)) pad.left_trigger = 0; if (c.hide_rt.load(std::memory_order_acquire)) pad.right_trigger = 0; if (c.hide_left_x.load(std::memory_order_acquire) && grinding_now(c, qpc_now())) pad.left_x = 0; + if (c.hide_right_stick.load(std::memory_order_acquire)) pad.right_x = pad.right_y = 0; + if (c.hide_l3.load(std::memory_order_acquire)) pad.buttons &= static_cast(~0x0040u); // XINPUT_GAMEPAD_LEFT_THUMB + if (c.hide_r3.load(std::memory_order_acquire)) pad.buttons &= static_cast(~0x0080u); // XINPUT_GAMEPAD_RIGHT_THUMB } void publish_hidden(Control& c) { Hidden all; @@ -138,10 +141,16 @@ void publish_hidden(Control& c) { all.left_trigger = all.left_trigger || h.left_trigger; all.right_trigger = all.right_trigger || h.right_trigger; all.left_x_grinding = all.left_x_grinding || h.left_x_grinding; + all.right_stick = all.right_stick || h.right_stick; + all.left_stick_click = all.left_stick_click || h.left_stick_click; + all.right_stick_click = all.right_stick_click || h.right_stick_click; } c.hide_lt.store(all.left_trigger, std::memory_order_release); c.hide_rt.store(all.right_trigger, std::memory_order_release); c.hide_left_x.store(all.left_x_grinding, std::memory_order_release); + c.hide_right_stick.store(all.right_stick, std::memory_order_release); + c.hide_l3.store(all.left_stick_click, std::memory_order_release); + c.hide_r3.store(all.right_stick_click, std::memory_order_release); DingoSDKOverlaySetGamepadFilter(c.hidden.empty() ? nullptr : &filter_pad); } @@ -253,7 +262,7 @@ void hide(std::string_view owner, Hidden hidden) { auto& c = control(); if (!c.started.load(std::memory_order_acquire)) return; std::lock_guard lock(c.mutex); - if (hidden.left_trigger || hidden.right_trigger || hidden.left_x_grinding) c.hidden.insert_or_assign(std::string(owner), hidden); + if (hidden.any()) c.hidden.insert_or_assign(std::string(owner), hidden); else if (const auto found = c.hidden.find(owner); found != c.hidden.end()) c.hidden.erase(found); if (hidden.left_x_grinding) listen(c); publish_hidden(c); diff --git a/Extension/Skater/grind_exit.h b/Extension/Skater/grind_exit.h index cb16fa19..72604047 100644 --- a/Extension/Skater/grind_exit.h +++ b/Extension/Skater/grind_exit.h @@ -39,6 +39,9 @@ struct Wish { std::string wish(std::string_view owner, std::optional wish); struct Hidden { bool left_trigger{}, right_trigger{}, left_x_grinding{}; + // The right stick (reads centred) and the stick clicks (read up), whatever the skater does. + bool right_stick{}, left_stick_click{}, right_stick_click{}; + bool any() const noexcept { return left_trigger || right_trigger || left_x_grinding || right_stick || left_stick_click || right_stick_click; } }; void hide(std::string_view owner, Hidden hidden); // Whatever the plugin held. diff --git a/Extension/Skater/skater_animation.cpp b/Extension/Skater/skater_animation.cpp index af3c2449..672ff64c 100644 --- a/Extension/Skater/skater_animation.cpp +++ b/Extension/Skater/skater_animation.cpp @@ -175,7 +175,15 @@ struct Plan { bool allowed{}; float record_seconds{}; // > 0: record this long, from the next update 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 + std::vector joints_read; // joint(): the joints asked for, in order }; +// The joints asked for as last evaluated (joint()), for the client thread. +std::mutex joints_mutex; +std::vector> joints_now; +// The client thread's: each joint asked for and when it was last. +std::vector> joints_asked; std::mutex plan_mutex; std::shared_ptr plan_now = std::make_shared(); Plan plan_draft; // the client thread's, published as plan_now @@ -294,7 +302,10 @@ void on_animation(std::uintptr_t component) noexcept { const auto snap = plan(); const bool wants_record = snap->record_seconds > 0 && snap->record_serial != l.record_serial.load(std::memory_order_acquire); const bool busy = record_running.load(std::memory_order_acquire) || wants_record; - if (!busy && (!snap->allowed || (!snap->play && snap->replacements.empty()))) { + 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()))) { std::lock_guard lock(pending().mutex); pending().bones.clear(); SetLastError(error); @@ -426,6 +437,30 @@ void on_animation(std::uintptr_t component) noexcept { first = 2; last = layout.count; } + // An arm reaching, after the clip: its joints join what is written. + if (reaching) { + 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 (joints_wanted) { + const std::span pose(l.pose.data(), l.pose.size()); + thread_local std::vector> placed; + placed.clear(); + for (const auto index : snap->joints_read) + if (index < pose.size()) + if (const auto w = anim_studio::world(pose, index); anim_studio::finite(w)) placed.emplace_back(index, w); + std::lock_guard lock(joints_mutex); + joints_now.swap(placed); + } if (last > first) { for (std::size_t i = first; i < last; ++i) if (!finite(l.pose[i])) throw std::runtime_error("a clip made a broken pose; nothing was written"); @@ -587,6 +622,37 @@ 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 reach(std::optional goal) { + plan_draft.reach = std::move(goal); + plan_draft.reach_until = plan_draft.reach ? now_seconds() + 0.3 : 0; + publish(); +} +std::optional joint(std::uint16_t index) { + if (index >= 395) return std::nullopt; + const double now = now_seconds(); + std::erase_if(joints_asked, [&](const auto& asked) { return now - asked.second > 2.0; }); + const auto asked = std::find_if(joints_asked.begin(), joints_asked.end(), [&](const auto& a) { return a.first == index; }); + if (asked != joints_asked.end()) asked->second = now; + else if (joints_asked.size() < 32) joints_asked.emplace_back(index, now); + else return std::nullopt; + std::vector read; + for (const auto& a : joints_asked) read.push_back(a.first); + std::sort(read.begin(), read.end()); + if (read != plan_draft.joints_read || plan_draft.joints_until < now + 1.0) { + plan_draft.joints_read = std::move(read); + plan_draft.joints_until = now + 2.0; + publish(); + } + std::lock_guard lock(joints_mutex); + for (const auto& [at, w] : joints_now) + if (at == index) return w; + return std::nullopt; +} +std::optional hand(arm_reach::Hand side) { + const auto at = joint(arm_reach::chain(side).back()); + if (!at) return std::nullopt; + return arm_reach::Placed{at->position, at->rotation, at->scale[0]}; +} void set_replacements(std::vector list) { plan_draft.replacements = std::move(list); publish(); @@ -635,7 +701,9 @@ void tick(std::uintptr_t base, std::uintptr_t component, bool allowed) { plan_draft.play.reset(); publish(); } - const bool wanted = plan_draft.play || !plan_draft.replacements.empty() || recording(); + 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; 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 b1c6f56f..085d706f 100644 --- a/Extension/Skater/skater_animation.h +++ b/Extension/Skater/skater_animation.h @@ -1,5 +1,6 @@ #pragma once #include "Extension/AnimationStudio/anim_core.h" +#include "Extension/Skater/arm_reach.h" #include #include #include @@ -41,6 +42,15 @@ 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); +// 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. +std::optional joint(std::uint16_t index); +// The hand joint's (joint()). +std::optional hand(arm_reach::Hand side); // The game's name for the trick the skater is doing now, or empty. std::string current_trick() noexcept; // Each client tick: the local skater's animation component (0 for none), and whether clips may be diff --git a/Extension/UI/Overlay/input_capture.cpp b/Extension/UI/Overlay/input_capture.cpp index c81c08b3..44c27e93 100644 --- a/Extension/UI/Overlay/input_capture.cpp +++ b/Extension/UI/Overlay/input_capture.cpp @@ -900,7 +900,10 @@ template void note_packet(const RAWMOUSE& mouse, OwnsPointer& if (relative) { s.raw_mouse_x.fetch_add(mouse.lLastX); s.raw_mouse_y.fetch_add(mouse.lLastY); + s.look_mouse_x.fetch_add(mouse.lLastX); + s.look_mouse_y.fetch_add(mouse.lLastY); } + if (mouse.usButtonFlags & RI_MOUSE_WHEEL) s.look_wheel_raw.fetch_add(static_cast(mouse.usButtonData)); auto buttons = s.raw_mouse_buttons.load(); const auto flags = mouse.usButtonFlags; if (flags & RI_MOUSE_LEFT_BUTTON_DOWN) buttons |= 1u; diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 1969293a..4464452f 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -39,6 +39,13 @@ void hold_game_buttons(std::uint16_t held) noexcept; void pause_game_input(bool paused) noexcept; bool key_down(int virtual_key) noexcept; int take_mouse_wheel() noexcept; +// The mouse since the last call, whatever the game does with it: raw counts right and down, and the +// wheel in 120 a notch (away from you positive). False and zeros while the game is not in front or a +// menu, the console or chat is open (what moved meanwhile is dropped). One reader: the plugin runtime. +struct MouseLook { + long x = 0, y = 0, wheel = 0; +}; +bool read_mouse_look(MouseLook& look) noexcept; // reskate.ui.dialog (plugin_dialog.cpp): one dialog at a time over the game, input captured while it // is open. The same plugin and id again updates it and keeps what was typed. Empty when shown, else // why not. Answers reach the plugin as throwdown kit events (action "dialog", throwdown_kit.h). diff --git a/Extension/UI/Overlay/overlay_input.cpp b/Extension/UI/Overlay/overlay_input.cpp index 1cc329f8..ff733275 100644 --- a/Extension/UI/Overlay/overlay_input.cpp +++ b/Extension/UI/Overlay/overlay_input.cpp @@ -96,6 +96,7 @@ LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { ~NativeInputScope() { overlay_input_access = saved; } } native_input; auto& s = state(); + if (message == WM_MOUSEWHEEL) s.look_wheel_message.fetch_add(GET_WHEEL_DELTA_WPARAM(wp)); WNDPROC previous{}; { std::lock_guard lock(s.window_hook_mutex); @@ -565,6 +566,24 @@ static bool raw_mouse_live(const State& s) { return time && GetTickCount64() < time + 10000; } +namespace dingosdk::overlay { +bool read_mouse_look(MouseLook& output) noexcept { + struct PreserveError { DWORD value = GetLastError(); ~PreserveError() { SetLastError(value); } } preserve_error; + output = {}; + auto& s = state(); + // Taken whatever happens next, so nothing piles up while a menu is open. + const long x = s.look_mouse_x.exchange(0), y = s.look_mouse_y.exchange(0); + const long wheel_raw = s.look_wheel_raw.exchange(0), wheel_message = s.look_wheel_message.exchange(0); + const HWND window = s.window.load(); + if (!window || s.stop.load() || s.failed.load() || interactive_visible(s) || !game_window_foreground(window)) return false; + output.x = x; + output.y = y; + // The same notch can arrive both ways: the raw count where raw packets come in, else the message's. + output.wheel = raw_mouse_live(s) && wheel_raw != 0 ? wheel_raw : wheel_message; + return true; +} +} // namespace dingosdk::overlay + extern "C" void DingoSDKOverlayReadFlightInput(dingosdk::overlay::FlightInput* output, bool flight_active, bool player_flight) { if (!output) return; struct PreserveError { DWORD value = GetLastError(); ~PreserveError() { SetLastError(value); } } preserve_error; diff --git a/Extension/UI/Overlay/overlay_internal.h b/Extension/UI/Overlay/overlay_internal.h index 96170baa..c93038ef 100644 --- a/Extension/UI/Overlay/overlay_internal.h +++ b/Extension/UI/Overlay/overlay_internal.h @@ -114,6 +114,9 @@ struct State { // buttons use it because Skate's controller mode stops the legacy cursor // and button state from updating until the game is paused. std::atomic raw_mouse_x{0}, raw_mouse_y{0}; + // The same movement for read_mouse_look, kept apart so the editor's and the flight look's reads + // do not take it; and the wheel, from raw packets and from WM_MOUSEWHEEL (in its 120 a notch). + std::atomic look_mouse_x{0}, look_mouse_y{0}, look_wheel_raw{0}, look_wheel_message{0}; std::atomic raw_mouse_buttons{0}; // physical: 1 left, 2 right, 4 middle std::atomic raw_mouse_time{0}; // The mouse registration in effect has RIDEV_NOLEGACY: Windows does not @@ -326,6 +329,8 @@ bool take_mod_icon_pixels(CardPixels& pixels); // draw asks for a picture by its file (its id once on the GPU, 0 until then); the render thread // takes one decoded picture a frame to upload and says where it went. inline constexpr UINT plugin_image_first_slot = 4, plugin_image_slots = 16; +// Then the screen look's three (screen_look.h): its copy of the frame and the auto-iris's two targets. +inline constexpr UINT screen_look_slot = plugin_image_first_slot + plugin_image_slots, screen_look_slots = 3; ImTextureID plugin_image(const std::string& path, ImVec2& size); struct PluginImageUpload { std::string path; diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index e9d9611e..9f469a01 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -5,6 +5,7 @@ #include "Extension/Trainer/trainer_page.h" #include "Extension/UI/Overlay/game_images.h" #include "plugin_overlay.h" +#include "screen_look.h" #include "park_previews.h" #include "chat_emotes.h" #include "input_capture.h" @@ -16,6 +17,10 @@ namespace dingosdk::overlay::detail { void draw_plugin_dialog(); // reskate.ui.dialog (plugin_dialog.cpp) +namespace { +// The overlay's output transfer (setup_graphics), for the screen look: it grades colour in SDR only. +int output_transfer{}; +} // ImGui draws into the swapchain's back buffer, so its display is the buffer, in the buffer's // pixels. The Win32 backend sizes the display by the window's client area and places the mouse in @@ -85,6 +90,7 @@ void destroy_graphics() { for (auto& texture : s.plugin_textures) texture.Reset(); for (auto& upload : s.plugin_uploads) upload.Reset(); plugin_images_lost(); + screen_look::release(); s.commands.Reset(); s.rtvs.Reset(); s.srvs.Reset(); s.fence.Reset(); s.device.Reset(); if (s.fence_event) CloseHandle(s.fence_event); s.fence_event = nullptr; @@ -292,9 +298,9 @@ bool setup_graphics() { return false; } heap.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; - // ImGui's atlas, the map download card's icon, the menu's map pictures, an item's mod icon, and - // plugins' pictures (upload_image) - heap.NumDescriptors = plugin_image_first_slot + plugin_image_slots; + // ImGui's atlas, the map download card's icon, the menu's map pictures, an item's mod icon, + // plugins' pictures (upload_image), and the screen look's + heap.NumDescriptors = screen_look_slot + screen_look_slots; heap.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; graphics_result = s.device->CreateDescriptorHeap(&heap, IID_PPV_ARGS(&s.srvs)); if (FAILED(graphics_result)) { @@ -418,6 +424,7 @@ bool setup_graphics() { const int transfer = format == DXGI_FORMAT_R16G16B16A16_FLOAT ? 1 : format == DXGI_FORMAT_R10G10B10A2_UNORM && hdr_display ? 2 : 0; ImGui_ImplDX12_SetOutputTransfer(transfer, hdr_display ? hdr_white_nits : 80.0f); + output_transfer = transfer; dingosdk::logging::printf(dingosdk::logging::Level::info, dingosdk::logging::Channel::graphics, "Overlay output: %s (buffer format %d, display %s).", transfer == 1 ? "scRGB" : transfer == 2 ? "HDR10" : "SDR", static_cast(format), hdr_display ? "in HDR" : "in SDR"); @@ -764,6 +771,8 @@ void render(IDXGISwapChain* presented, UINT flags) { const bool item_browser_frame = item_browser_pending(); const bool perf_frame = perf_hud_pending() || trainer_hud_pending(); const bool hub_frame = hub_page_pending(); + // A plugin's screen look is drawn under every frame it asks for (reskate.screen.look). + const bool look_frame = screen_look::pending(); if (trainer_open_requested()) s.visible.store(true); if (dingosdk::overlay::menu_open_flag().exchange(false)) s.visible.store(true); // `menu`, or its controller bind const bool menu_frame = interactive_visible(s); @@ -782,7 +791,7 @@ void render(IDXGISwapChain* presented, UINT flags) { } // Hidden, the overlay still draws while a notice or chat line is on screen. if (s.loaded_notice_posted && !notices_pending() && !chat_frame && !game_text_frame && !skate_hud_frame && - !nametag_frame && !plugins_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame) return; + !nametag_frame && !plugins_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame && !look_frame) return; } else if (!s.ui_was_interactive) { s.ui_was_interactive = true; s.last_model = {}; // Reopening immediately reads fresh state. @@ -847,7 +856,7 @@ void render(IDXGISwapChain* presented, UINT flags) { draw_map_download(); sync_menu_cursor(); // close buttons also change visibility, without a key message ImGui::Render(); - if (ImGui::GetDrawData()->TotalVtxCount == 0) { ImGui::SetCurrentContext(previous); return; } + if (ImGui::GetDrawData()->TotalVtxCount == 0 && !look_frame) { ImGui::SetCurrentContext(previous); return; } const auto upload_slot = static_cast(s.submitted_draws % s.upload_fences.size()); // The ImGui backend rotates upload buffers by submitted draw count, while // command allocators rotate by swapchain index. Wait for both exact owners @@ -867,6 +876,22 @@ void render(IDXGISwapChain* presented, UINT flags) { upload_menu_map_image(s); upload_mod_icon(s); upload_plugin_image(s); + if (look_frame) { + // Drawn first, so ImGui draws over it. It leaves the back buffer in PRESENT, as it found it. + ID3D12DescriptorHeap* look_heaps[] = {s.srvs.Get()}; + s.commands->SetDescriptorHeaps(1, look_heaps); + screen_look::Target look; + look.device = s.device.Get(); + look.commands = s.commands.Get(); + look.buffer = frame.buffer.Get(); + look.rtv = frame.rtv; + look.srvs = s.srvs.Get(); + look.slot = screen_look_slot; + look.format = frame.buffer->GetDesc().Format; + look.transfer = output_transfer; + look.wait_idle = [] { return completed(state().next_fence, render_fence_timeout_ms); }; + (void)screen_look::record(look); + } D3D12_RESOURCE_BARRIER barrier{}; barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; barrier.Transition.pResource = frame.buffer.Get(); diff --git a/Extension/UI/Overlay/screen_look.cpp b/Extension/UI/Overlay/screen_look.cpp new file mode 100644 index 00000000..47385ebe --- /dev/null +++ b/Extension/UI/Overlay/screen_look.cpp @@ -0,0 +1,520 @@ +#include "screen_look.h" +#include "screen_look_hlsl.h" // generated from screen_look.hlsl (cmake/UI.cmake) +#include "Engine/Core/Log/logging.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// The graphics and the hand are the Filmer's (neekgate/ReSkate, filmer-zee, filmer_look.cpp); what to +// draw comes from a plugin (set) instead of the Filmer's settings. +namespace dingosdk::screen_look { +namespace { +using Microsoft::WRL::ComPtr; +// The shader's cbuffer: nine float4s, then five lines of twelve characters packed four to a uint. +constexpr std::size_t float_constants = 9 * 4, constant_count = float_constants + 16, text_lines = 5; +// The auto-iris's two 1x1 targets: x the brightness the iris settled to, y this frame's. +constexpr DXGI_FORMAT iris_format = DXGI_FORMAT_R32G32_FLOAT; +// When the hand reaches the glass, into its slap. +constexpr double slap_contact = 0.13; + +// One full-screen triangle; the pixel shader does everything. Positions are in half the frame's +// height from its centre (q), so the lens maths reads the same at any resolution. +constexpr const char *shader_source = screen_look_hlsl; + +double now() { return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); } + +// What a plugin asked for (client thread), read by the render thread. +struct Asked { + std::mutex mutex; + Settings settings; + double until{}, rolling_origin{}; + double slap_at{-1e9}; + std::uint64_t slap_serial{}; +}; +Asked &asked() { + static auto *value = new Asked; + return *value; +} + +struct Graphics { + ComPtr root; + ComPtr pipeline; + DXGI_FORMAT pipeline_format{DXGI_FORMAT_UNKNOWN}; + ComPtr copy; + D3D12_RESOURCE_DESC copy_desc{}; + D3D12_RESOURCE_STATES copy_state{D3D12_RESOURCE_STATE_COPY_DEST}; + ID3D12DescriptorHeap *view_heap{}; + unsigned view_slot{~0u}; + // The auto-iris: a pipeline of its own and two 1x1 targets it ping-pongs between, with their render + // target views (a heap of its own) and shader views (the two slots after the copy's). + ComPtr iris_pipeline; + std::array, 2> iris; + std::array iris_state{}; + ComPtr iris_rtvs; + ID3D12DescriptorHeap *iris_view_heap{}; + unsigned iris_index{}; + bool iris_cleared{}; + double last_seconds{}; + bool failed{}; +}; +Graphics &graphics() { + static auto *value = new Graphics; + return *value; +} + +void say_failed(const std::string &why) { + auto &g = graphics(); + if (g.failed) return; + g.failed = true; + logging::write(logging::Level::warning, logging::Channel::graphics, "Screen look unavailable: " + why + " Plugins asking for it get the game's own picture."); +} + +void transition(ID3D12GraphicsCommandList *list, ID3D12Resource *resource, D3D12_RESOURCE_STATES from, D3D12_RESOURCE_STATES to) { + if (from == to) return; + D3D12_RESOURCE_BARRIER barrier{}; + barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; + barrier.Transition.pResource = resource; + barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; + barrier.Transition.StateBefore = from; + barrier.Transition.StateAfter = to; + list->ResourceBarrier(1, &barrier); +} + +bool build_root(ID3D12Device *device) { + auto &g = graphics(); + if (g.root) return true; + D3D12_DESCRIPTOR_RANGE range{}; + range.RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_SRV; + range.NumDescriptors = 1; + range.OffsetInDescriptorsFromTableStart = 0; + D3D12_DESCRIPTOR_RANGE iris_range = range; + iris_range.BaseShaderRegister = 1; // t1: the auto-iris + std::array parameters{}; + parameters[0].ParameterType = D3D12_ROOT_PARAMETER_TYPE_32BIT_CONSTANTS; + parameters[0].Constants.Num32BitValues = static_cast(constant_count); + parameters[0].ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL; + parameters[1].ParameterType = D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE; + parameters[1].DescriptorTable.NumDescriptorRanges = 1; + parameters[1].DescriptorTable.pDescriptorRanges = ⦥ + parameters[1].ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL; + parameters[2] = parameters[1]; + parameters[2].DescriptorTable.pDescriptorRanges = &iris_range; + D3D12_STATIC_SAMPLER_DESC sampler{}; + sampler.Filter = D3D12_FILTER_MIN_MAG_MIP_LINEAR; + sampler.AddressU = sampler.AddressV = sampler.AddressW = D3D12_TEXTURE_ADDRESS_MODE_CLAMP; + sampler.ComparisonFunc = D3D12_COMPARISON_FUNC_ALWAYS; + sampler.BorderColor = D3D12_STATIC_BORDER_COLOR_OPAQUE_BLACK; + sampler.MaxLOD = 0.0f; + sampler.ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL; + D3D12_ROOT_SIGNATURE_DESC description{}; + description.NumParameters = static_cast(parameters.size()); + description.pParameters = parameters.data(); + description.NumStaticSamplers = 1; + description.pStaticSamplers = &sampler; + description.Flags = D3D12_ROOT_SIGNATURE_FLAG_DENY_HULL_SHADER_ROOT_ACCESS | D3D12_ROOT_SIGNATURE_FLAG_DENY_DOMAIN_SHADER_ROOT_ACCESS | + D3D12_ROOT_SIGNATURE_FLAG_DENY_GEOMETRY_SHADER_ROOT_ACCESS; + ComPtr blob, error; + if (FAILED(D3D12SerializeRootSignature(&description, D3D_ROOT_SIGNATURE_VERSION_1, &blob, &error)) || !blob) { + say_failed("its root signature could not be built."); + return false; + } + if (FAILED(device->CreateRootSignature(0, blob->GetBufferPointer(), blob->GetBufferSize(), IID_PPV_ARGS(&g.root)))) { + say_failed("its root signature was refused."); + return false; + } + return true; +} + +bool build_pipeline(ID3D12Device *device, DXGI_FORMAT format) { + auto &g = graphics(); + if (g.pipeline && g.iris_pipeline && g.pipeline_format == format) return true; + g.pipeline.Reset(); + g.iris_pipeline.Reset(); + if (!build_root(device)) return false; + ComPtr vertex, pixel, iris_pixel, error; + const auto length = std::strlen(shader_source); + if (FAILED(D3DCompile(shader_source, length, "screen_look", nullptr, nullptr, "vs_main", "vs_5_0", 0, 0, &vertex, &error)) || !vertex) { + say_failed(std::string("its vertex shader did not compile") + + (error ? std::string(": ") + static_cast(error->GetBufferPointer()) : std::string("."))); + return false; + } + error.Reset(); + if (FAILED(D3DCompile(shader_source, length, "screen_look", nullptr, nullptr, "ps_main", "ps_5_0", 0, 0, &pixel, &error)) || !pixel) { + say_failed(std::string("its pixel shader did not compile") + + (error ? std::string(": ") + static_cast(error->GetBufferPointer()) : std::string("."))); + return false; + } + error.Reset(); + if (FAILED(D3DCompile(shader_source, length, "screen_look", nullptr, nullptr, "ps_adapt", "ps_5_0", 0, 0, &iris_pixel, &error)) || !iris_pixel) { + say_failed(std::string("its auto-iris shader did not compile") + + (error ? std::string(": ") + static_cast(error->GetBufferPointer()) : std::string("."))); + return false; + } + D3D12_GRAPHICS_PIPELINE_STATE_DESC description{}; + description.pRootSignature = g.root.Get(); + description.VS = {vertex->GetBufferPointer(), vertex->GetBufferSize()}; + description.PS = {pixel->GetBufferPointer(), pixel->GetBufferSize()}; + description.BlendState.RenderTarget[0].RenderTargetWriteMask = D3D12_COLOR_WRITE_ENABLE_ALL; + description.SampleMask = UINT_MAX; + description.RasterizerState.FillMode = D3D12_FILL_MODE_SOLID; + description.RasterizerState.CullMode = D3D12_CULL_MODE_NONE; + description.RasterizerState.DepthClipEnable = TRUE; + description.DepthStencilState.DepthEnable = FALSE; + description.DepthStencilState.StencilEnable = FALSE; + description.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE; + description.NumRenderTargets = 1; + description.RTVFormats[0] = format; + description.SampleDesc.Count = 1; + description.NodeMask = 0; + if (FAILED(device->CreateGraphicsPipelineState(&description, IID_PPV_ARGS(&g.pipeline)))) { + say_failed("its pipeline was refused for this swapchain format."); + return false; + } + description.PS = {iris_pixel->GetBufferPointer(), iris_pixel->GetBufferSize()}; + description.RTVFormats[0] = iris_format; + if (FAILED(device->CreateGraphicsPipelineState(&description, IID_PPV_ARGS(&g.iris_pipeline)))) { + say_failed("its auto-iris pipeline was refused."); + return false; + } + g.pipeline_format = format; + return true; +} + +D3D12_CPU_DESCRIPTOR_HANDLE iris_rtv(unsigned index, ID3D12Device *device) { + auto handle = graphics().iris_rtvs->GetCPUDescriptorHandleForHeapStart(); + handle.ptr += static_cast(device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV)) * index; + return handle; +} + +// The auto-iris's targets, made once and cleared to "nothing measured yet" on their first use; their +// shader views go in the overlay's heap after the copy's (again when the overlay rebuilt it). +bool ensure_iris(const Target &t) { + auto &g = graphics(); + if (!g.iris[0]) { + D3D12_DESCRIPTOR_HEAP_DESC heap_description{}; + heap_description.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV; + heap_description.NumDescriptors = 2; + if (FAILED(t.device->CreateDescriptorHeap(&heap_description, IID_PPV_ARGS(&g.iris_rtvs)))) { + say_failed("the auto-iris's views could not be made."); + return false; + } + D3D12_RESOURCE_DESC description{}; + description.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D; + description.Width = 1; + description.Height = 1; + description.DepthOrArraySize = 1; + description.MipLevels = 1; + description.Format = iris_format; + description.SampleDesc.Count = 1; + description.Flags = D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET; + D3D12_HEAP_PROPERTIES heap{}; + heap.Type = D3D12_HEAP_TYPE_DEFAULT; + D3D12_CLEAR_VALUE clear{}; + clear.Format = iris_format; + for (unsigned i = 0; i < 2; ++i) { + if (FAILED(t.device->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_NONE, &description, D3D12_RESOURCE_STATE_RENDER_TARGET, &clear, + IID_PPV_ARGS(&g.iris[i])))) { + g.iris = {}; + say_failed("the auto-iris's targets could not be made."); + return false; + } + g.iris[i]->SetName(L"ReSkate screen look auto-iris"); + g.iris_state[i] = D3D12_RESOURCE_STATE_RENDER_TARGET; + t.device->CreateRenderTargetView(g.iris[i].Get(), nullptr, iris_rtv(i, t.device)); + } + g.iris_cleared = false; + g.iris_view_heap = nullptr; + } + if (!g.iris_cleared) { + const std::array zero{}; + for (unsigned i = 0; i < 2; ++i) { + transition(t.commands, g.iris[i].Get(), g.iris_state[i], D3D12_RESOURCE_STATE_RENDER_TARGET); + t.commands->ClearRenderTargetView(iris_rtv(i, t.device), zero.data(), 0, nullptr); + transition(t.commands, g.iris[i].Get(), D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); + g.iris_state[i] = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; + } + g.iris_cleared = true; + } + if (g.iris_view_heap != t.srvs) { + const auto step = t.device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV); + for (unsigned i = 0; i < 2; ++i) { + auto cpu = t.srvs->GetCPUDescriptorHandleForHeapStart(); + cpu.ptr += static_cast(step) * (t.slot + 1 + i); + D3D12_SHADER_RESOURCE_VIEW_DESC view{}; + view.Format = iris_format; + view.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D; + view.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING; + view.Texture2D.MipLevels = 1; + t.device->CreateShaderResourceView(g.iris[i].Get(), &view, cpu); + } + g.iris_view_heap = t.srvs; + } + return true; +} + +// The copy of the frame the shader reads, the size and format of the back buffer. +bool ensure_copy(const Target &t) { + auto &g = graphics(); + auto wanted = t.buffer->GetDesc(); + wanted.Flags = D3D12_RESOURCE_FLAG_NONE; + wanted.Alignment = 0; + wanted.MipLevels = 1; + wanted.DepthOrArraySize = 1; + wanted.SampleDesc = {1, 0}; + wanted.Layout = D3D12_TEXTURE_LAYOUT_UNKNOWN; + const bool same = g.copy && g.copy_desc.Width == wanted.Width && g.copy_desc.Height == wanted.Height && + g.copy_desc.Format == wanted.Format; + if (!same) { + // Frames still on the GPU may read the copy this one replaces. + if (g.copy && (!t.wait_idle || !t.wait_idle())) return false; + g.copy.Reset(); + D3D12_HEAP_PROPERTIES heap{}; + heap.Type = D3D12_HEAP_TYPE_DEFAULT; + if (FAILED(t.device->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_NONE, &wanted, D3D12_RESOURCE_STATE_COPY_DEST, nullptr, + IID_PPV_ARGS(&g.copy)))) { + say_failed("the copy of the frame could not be made."); + return false; + } + g.copy->SetName(L"ReSkate screen look frame copy"); + g.copy_desc = wanted; + g.copy_state = D3D12_RESOURCE_STATE_COPY_DEST; + g.view_heap = nullptr; + } + // Its view, in the overlay's heap (a new heap after the overlay rebuilt its graphics). + if (g.view_heap != t.srvs || g.view_slot != t.slot) { + const auto step = t.device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV); + auto cpu = t.srvs->GetCPUDescriptorHandleForHeapStart(); + cpu.ptr += static_cast(step) * t.slot; + D3D12_SHADER_RESOURCE_VIEW_DESC view{}; + view.Format = wanted.Format; + view.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D; + view.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING; + view.Texture2D.MipLevels = 1; + t.device->CreateShaderResourceView(g.copy.Get(), &view, cpu); + g.view_heap = t.srvs; + g.view_slot = t.slot; + } + return true; +} + +// The hand slapping the lens, moved along a path in front of it (metres: x right, y down, z away from +// the glass) and seen in perspective: small and off in a lower corner where the free arm reaches round +// the camera, growing fast as it accelerates onto the glass; pressed there a beat, sliding a little; +// pulled away. +struct Hand { float x{}, y{}, angle{}, size{}, blur{}; bool mirrored{}; }; +struct Key { double time; float x, y, z, angle; }; +constexpr std::array slap_keys{{{0.0, 0.55f, 0.45f, 0.36f, -0.62f}, {slap_contact, 0.015f, 0.010f, 0.050f, -0.45f}, + {0.25, 0.023f, 0.016f, 0.052f, -0.43f}, {0.60, 0.62f, 0.55f, 0.40f, -0.70f}}}; +constexpr float hand_scale = 0.225f; // size at 1 m (it fills the frame from about 3: 7.5 cm off the glass) +float smooth(float u) { u = std::clamp(u, 0.0f, 1.0f); return u * u * (3.0f - 2.0f * u); } +std::optional slap_key(double since) { + const auto &keys = slap_keys; + if (since < 0.0 || since > keys.back().time) return std::nullopt; + for (std::size_t i = 0; i + 1 < keys.size(); ++i) { + const auto &a = keys[i], &b = keys[i + 1]; + if (since > b.time) continue; + float u = static_cast((since - a.time) / (b.time - a.time)); + if (i == 0) u = std::pow(u, 2.2f); // accelerating onto the glass + else if (i != 1) u = smooth(u); // (pressed: a steady slide) + return Key{since, a.x + (b.x - a.x) * u, a.y + (b.y - a.y) * u, a.z + (b.z - a.z) * u, a.angle + (b.angle - a.angle) * u}; + } + return std::nullopt; +} +Hand hand_at(double since, std::uint64_t serial) { + Hand hand; + const auto key = slap_key(since); + if (!key) return hand; + const auto next = slap_key(since + 0.01).value_or(*key); + hand.mirrored = (serial & 1u) != 0; + const float side = hand.mirrored ? -1.0f : 1.0f; + constexpr float lens = 0.84f; // half heights per unit of x / z + hand.x = side * key->x / key->z * lens; + hand.y = key->y / key->z * lens; + hand.angle = key->angle; + hand.size = hand_scale / key->z; + const float growth = std::abs(hand_scale / next.z - hand.size) / 0.01f; // motion blur from how fast it moves + hand.blur = 0.02f + std::min(growth / 40.0f, 1.0f) * 0.08f; + return hand; +} + +// The camcorder's iris: closing down as the hand covers the lens, overshooting as the palm comes off. +float exposure_at(double since) { + const Hand hand = hand_at(since, 0); + const float covered = std::clamp((hand.size - 2.0f) / 2.5f, 0.0f, 1.0f); + float exposure = 1.0f - 0.4f * covered; + if (since > 0.25 && since < 1.5) { + const float t = static_cast(since - 0.25); + exposure += 0.3f * std::exp(-t * 4.0f) * std::min(t / 0.1f, 1.0f); + } + return exposure; +} + +std::uint32_t bits(float value) { return std::bit_cast(value); } +std::string right_aligned(std::string text) { + if (text.size() < text_columns) text.insert(0, text_columns - text.size(), ' '); + return text.substr(0, text_columns); +} +} // namespace + +void set(const Settings &settings) noexcept { + try { + auto &a = asked(); + std::lock_guard lock(a.mutex); + const double at = now(); + // The tape's roll is kept counting between ticks; a jump of more than a moment is a new roll. + const double origin = settings.rolling >= 0 ? at - settings.rolling : -1; + if (origin < 0 || a.rolling_origin < 0 || std::abs(origin - a.rolling_origin) > 0.25) a.rolling_origin = origin; + a.settings = settings; + a.until = at + 0.3; + } catch (...) {} +} +void clear() noexcept { + auto &a = asked(); + std::lock_guard lock(a.mutex); + a.until = 0; +} +void slap() noexcept { + auto &a = asked(); + std::lock_guard lock(a.mutex); + const double at = now(); + if (at - a.slap_at < slap_keys.back().time) return; // one at a time + a.slap_at = at; + ++a.slap_serial; +} + +bool pending() noexcept { + auto &a = asked(); + std::lock_guard lock(a.mutex); + return now() < a.until && !graphics().failed; +} + +bool record(const Target &t) noexcept { + try { + auto &g = graphics(); + Settings look; + double rolling_origin{}, slap_at{}; + std::uint64_t slap_serial{}; + { + auto &a = asked(); + std::lock_guard lock(a.mutex); + if (now() >= a.until) return false; + look = a.settings; + rolling_origin = a.rolling_origin; + slap_at = a.slap_at; + slap_serial = a.slap_serial; + } + if (g.failed || !t.device || !t.commands || !t.buffer || !t.srvs) return false; + if (!build_pipeline(t.device, t.format) || !ensure_copy(t) || !ensure_iris(t)) return false; + const auto desc = t.buffer->GetDesc(); + const float width = static_cast(desc.Width), height = static_cast(desc.Height); + if (width < 16.0f || height < 16.0f) return false; + // Copy the frame, then draw it back through the shader. + transition(t.commands, t.buffer, D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_COPY_SOURCE); + transition(t.commands, g.copy.Get(), g.copy_state, D3D12_RESOURCE_STATE_COPY_DEST); + t.commands->CopyResource(g.copy.Get(), t.buffer); + transition(t.commands, g.copy.Get(), D3D12_RESOURCE_STATE_COPY_DEST, D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); + g.copy_state = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; + transition(t.commands, t.buffer, D3D12_RESOURCE_STATE_COPY_SOURCE, D3D12_RESOURCE_STATE_RENDER_TARGET); + const double seconds = now(); + const float frame_seconds = static_cast(std::clamp(seconds - g.last_seconds, 0.0, 0.25)); + g.last_seconds = seconds; + const double since = seconds - slap_at; + const Hand hand = hand_at(since, slap_serial); + const float fov = std::clamp(look.fov, 5.0f, 170.0f) * std::numbers::pi_v / 180.0f; + const float screen_aspect = width / height; + // Text white: the overlay's output (SDR, scRGB in units of 80 nits, or HDR10's PQ code for 200 nits). + const float white = t.transfer == 1 ? 2.5f : t.transfer == 2 ? 0.58f : 0.95f; + std::array lines = look.text; + for (const std::size_t right : {std::size_t{1}, std::size_t{3}, std::size_t{4}}) + if (!lines[right].empty()) lines[right] = right_aligned(lines[right]); + const bool any_text = std::ranges::any_of(lines, [](const std::string &line) { return !line.empty(); }); + std::array constants{ + bits(std::clamp(look.bend, 0.0f, 1.0f)), bits(fov * 0.5f), bits(std::tan(fov * 0.5f)), bits(look.bars ? 4.0f / 3.0f : screen_aspect), + bits(screen_aspect), bits(look.bars ? 1.0f : 0.0f), bits(look.hood ? 1.0f : 0.0f), bits(static_cast(std::fmod(seconds, 1000.0))), + bits(look.grade ? 1.0f : 0.0f), bits(look.scanlines ? 1.0f : 0.0f), bits(look.grain ? 1.0f : 0.0f), bits(look.soft ? 1.0f : 0.0f), + bits(static_cast(t.transfer)), bits(exposure_at(since)), bits(width), bits(height), + bits(hand.x), bits(hand.y), bits(hand.angle), bits(hand.size), + bits(hand.mirrored ? 1.0f : 0.0f), bits(hand.blur), bits(0.0f), bits(0.0f), + bits(2.0f / 150.0f), bits(white), bits(any_text ? 1.0f : 0.0f), bits(0.0f), + bits(std::clamp(look.age, 0.0f, 1.0f)), bits(look.dropouts ? 1.0f : 0.0f), bits(look.head_switching ? 1.0f : 0.0f), bits(look.smear ? 1.0f : 0.0f), + bits(rolling_origin >= 0 ? static_cast(seconds - rolling_origin) : -1.0f), bits(look.tracking ? 1.0f : 0.0f), + bits(look.iris ? 1.0f : 0.0f), bits(frame_seconds)}; + for (std::size_t line = 0; line < text_lines; ++line) + for (std::size_t column = 0; column < text_columns && column < lines[line].size(); ++column) { + const auto code = static_cast(std::toupper(static_cast(lines[line][column]))); + const std::size_t slot = float_constants + line * 3 + column / 4; + constants[slot] |= std::uint32_t{code} << (8 * (column % 4)); + } + const auto step = t.device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV); + const auto view_at = [&](unsigned slot) { + auto gpu = t.srvs->GetGPUDescriptorHandleForHeapStart(); + gpu.ptr += static_cast(step) * slot; + return gpu; + }; + t.commands->SetGraphicsRootSignature(g.root.Get()); + t.commands->SetGraphicsRoot32BitConstants(0, static_cast(constants.size()), constants.data(), 0); + t.commands->SetGraphicsRootDescriptorTable(1, view_at(t.slot)); + t.commands->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + // The auto-iris: measure this frame and follow it, into one target from the other. + const unsigned current = g.iris_index ^ 1u, previous = g.iris_index; + if (look.iris) { + g.iris_index = current; + transition(t.commands, g.iris[current].Get(), g.iris_state[current], D3D12_RESOURCE_STATE_RENDER_TARGET); + const auto target = iris_rtv(current, t.device); + t.commands->OMSetRenderTargets(1, &target, FALSE, nullptr); + const D3D12_VIEWPORT one{0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f}; + const D3D12_RECT one_pixel{0, 0, 1, 1}; + t.commands->RSSetViewports(1, &one); + t.commands->RSSetScissorRects(1, &one_pixel); + t.commands->SetPipelineState(g.iris_pipeline.Get()); + t.commands->SetGraphicsRootDescriptorTable(2, view_at(t.slot + 1 + previous)); + t.commands->DrawInstanced(3, 1, 0, 0); + transition(t.commands, g.iris[current].Get(), D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); + g.iris_state[current] = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; + } + // The picture. + t.commands->OMSetRenderTargets(1, &t.rtv, FALSE, nullptr); + const D3D12_VIEWPORT viewport{0.0f, 0.0f, width, height, 0.0f, 1.0f}; + const D3D12_RECT scissor{0, 0, static_cast(desc.Width), static_cast(desc.Height)}; + t.commands->RSSetViewports(1, &viewport); + t.commands->RSSetScissorRects(1, &scissor); + t.commands->SetPipelineState(g.pipeline.Get()); + t.commands->SetGraphicsRootDescriptorTable(2, view_at(t.slot + 1 + g.iris_index)); + t.commands->DrawInstanced(3, 1, 0, 0); + // The overlay carries on from PRESENT, as without the look. + transition(t.commands, t.buffer, D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT); + return true; + } catch (...) { + say_failed("an exception was raised while drawing it."); + return false; + } +} + +void release() noexcept { + auto &g = graphics(); + g.copy.Reset(); + g.copy_desc = {}; + g.copy_state = D3D12_RESOURCE_STATE_COPY_DEST; + g.pipeline.Reset(); + g.pipeline_format = DXGI_FORMAT_UNKNOWN; + g.root.Reset(); + g.view_heap = nullptr; + g.view_slot = ~0u; + g.iris_pipeline.Reset(); + g.iris = {}; + g.iris_rtvs.Reset(); + g.iris_view_heap = nullptr; + g.iris_cleared = false; +} +} // namespace dingosdk::screen_look diff --git a/Extension/UI/Overlay/screen_look.h b/Extension/UI/Overlay/screen_look.h new file mode 100644 index 00000000..46a63771 --- /dev/null +++ b/Extension/UI/Overlay/screen_look.h @@ -0,0 +1,64 @@ +#pragma once +#include +#include +#include +#include + +// A camcorder look over the game's finished frame (reskate.screen.look), made from the Filmer's VX1000 +// look (neekgate/ReSkate, filmer-zee, filmer_look.cpp): the frame is copied and drawn back through one +// pixel shader (screen_look.hlsl) ahead of the overlay's own drawing. A fisheye adapter's bend and its +// dark hood edge, 4:3 bars, the camcorder's colour, standard-definition softness with its sharpening +// halo, interlaced fields, tape grain and its faults, an auto-iris, a hand that slaps the lens, and up +// to five lines of burnt-in text in the camcorder's own blocky characters. +namespace dingosdk::screen_look { +struct Settings { + float bend{}; // 0..1: how much of the fisheye's bend; 0 none + float fov{60}; // the game camera's vertical field of view (degrees), which the bend squeezes + bool bars{}; // 4:3, black at the sides + bool hood{}; // the hood's dark round edge in the corners + bool grade{}; // the camcorder's colour: punchy, warm, a little crushed (SDR only) + bool soft{}; // standard definition: soft, with a sharpening halo + bool scanlines{}; // interlaced fields + bool grain{}; // tape noise (SDR only) + float age{}; // 0..1: how often and how badly the tape misbehaves + bool dropouts{}; // now and then a white streak where the tape lost its coating + bool head_switching{}; // the torn, noisy lines at the very bottom + bool tracking{}; // a band of tearing rolls up the picture as the tape starts rolling + bool smear{}; // reds trail to the right + bool iris{}; // an auto-iris that lags the scene + double rolling{-1}; // seconds since the tape started rolling (the tracking band); < 0 none + // Burnt in: top left, top right, bottom left, then two lines bottom right; at most 12 characters + // each, ASCII 32 to 95 (lower case is shown in capitals; '#' is the record dot, "[=]" a battery). + // The right-hand lines are right-aligned. + std::array text; +}; +inline constexpr std::size_t text_columns = 12; + +// Client thread. The look for the next 300 ms (set it every tick); clear() ends it. +void set(const Settings& settings) noexcept; +void clear() noexcept; +// A hand slaps the lens (about 0.6 s); mirrored every other time. +void slap() noexcept; + +// Render thread only (the overlay's present hook). +// The look wants this frame drawn. Cheap: once a frame. +bool pending() noexcept; +struct Target { + ID3D12Device* device{}; + ID3D12GraphicsCommandList* commands{}; + ID3D12Resource* buffer{}; // the back buffer, in the PRESENT state; left in it + D3D12_CPU_DESCRIPTOR_HANDLE rtv{}; + ID3D12DescriptorHeap* srvs{}; // the overlay's shader-visible heap (already set on the list) + unsigned slot{}; // the first of three heap slots the look uses (its copy, the auto-iris's two) + DXGI_FORMAT format{}; + int transfer{}; // the overlay's output transfer: 0 SDR, 1 scRGB, 2 HDR10 + // Waits until the GPU has finished every frame submitted so far (a copy about to be replaced may + // still be read). False when it could not. + bool (*wait_idle)(){}; +}; +// Records the look on the frame's command list, ahead of ImGui's draws. False when nothing was drawn +// (the look is off, or this system cannot build it: said once in the log). +bool record(const Target& target) noexcept; +// The overlay is tearing its graphics down (a resize, a lost device, stop). +void release() noexcept; +} // namespace dingosdk::screen_look diff --git a/Extension/UI/Overlay/screen_look.hlsl b/Extension/UI/Overlay/screen_look.hlsl new file mode 100644 index 00000000..d4bfc564 --- /dev/null +++ b/Extension/UI/Overlay/screen_look.hlsl @@ -0,0 +1,391 @@ +// A camcorder look over the finished frame (screen_look.cpp, reskate.screen.look), made from the +// Filmer's VX1000 look (neekgate/ReSkate, filmer-zee). Built into the DLL as text (cmake/UI.cmake) and +// compiled when the overlay first draws it; the build checks it with fxc when the Windows SDK has one. +// +// Order, as on a real camcorder: the world through a fisheye adapter (bend), a hand in front of the +// lens, the camera's colour and iris, the hood's dark edge, then the electronics burn in the text, +// and the tape softens, interlaces and adds grain to all of it. +cbuffer Look : register(b0) +{ + float4 L0; // bend 0..1, half angle the bend covers at the frame's top edge (rad), tan(half the game's FOV), frame half width (half heights) + float4 L1; // screen aspect, 4:3 bars, hood, seconds + float4 L2; // grade, scanlines, grain, softness + float4 L3; // transfer (0 SDR), exposure, output width, output height + float4 L4; // hand centre x, y (half heights, y down), angle (rad), size (0: no hand) + float4 L5; // hand mirrored, hand blur (hand units), unused, unused + float4 L6; // text pixel (half heights), text white, text on, unused + float4 L7; // tape age 0..1, dropouts, head switching, red smear + float4 L8; // seconds since this clip started recording (tracking; < 0: none), tracking, auto-iris, seconds since the last frame + uint4 T[4]; // five lines of twelve characters, four to a uint: top left, top right, bottom left, bottom right x2 +}; +Texture2D source : register(t0); +// The auto-iris (ps_adapt): x the brightness it has adapted to, y the scene's brightness this frame. +Texture2D adapted : register(t1); +SamplerState linear_clamp : register(s0); + +float hash12(float2 p) +{ + float3 p3 = frac(float3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return frac((p3.x + p3.y) * p3.z); +} + +struct Interpolated { float4 position : SV_POSITION; float2 uv : TEXCOORD0; }; + +Interpolated vs_main(uint id : SV_VertexID) +{ + Interpolated o; + float2 p = float2((id << 1) & 2, id & 2); + o.position = float4(p * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); + o.uv = p; + return o; +} + +// ---- the camcorder's character generator: 5x7, ASCII 32..95; '#' is the record dot, '[=]' the battery +static const uint2 FONT[64] = +{ + uint2(0x00000000u, 0x0u), uint2(0x00000000u, 0x0u), uint2(0x00000000u, 0x0u), uint2(0x1dfffdc0u, 0x0u), + uint2(0x00000000u, 0x0u), uint2(0x00000000u, 0x0u), uint2(0x00000000u, 0x0u), uint2(0x00000000u, 0x0u), + uint2(0x00000000u, 0x0u), uint2(0x00000000u, 0x0u), uint2(0x00000000u, 0x0u), uint2(0x00000000u, 0x0u), + uint2(0x00000000u, 0x0u), uint2(0x000f8000u, 0x0u), uint2(0x8c000000u, 0x1u), uint2(0x42222210u, 0x0u), + uint2(0xa33ae62eu, 0x3u), uint2(0x884210c4u, 0x3u), uint2(0xc444422eu, 0x7u), uint2(0xa304111fu, 0x3u), + uint2(0x11f4a988u, 0x2u), uint2(0xa3083c3fu, 0x3u), uint2(0xa317844cu, 0x3u), uint2(0x8422221fu, 0x0u), + uint2(0xa317462eu, 0x3u), uint2(0x910f462eu, 0x1u), uint2(0x08401080u, 0x0u), uint2(0x00000000u, 0x0u), + uint2(0x00000000u, 0x0u), uint2(0x3fffffe0u, 0x0u), uint2(0x00000000u, 0x0u), uint2(0x00000000u, 0x0u), + uint2(0x00000000u, 0x0u), uint2(0x631fc62eu, 0x4u), uint2(0xe317c62fu, 0x3u), uint2(0xa210862eu, 0x3u), + uint2(0xd318c527u, 0x1u), uint2(0xc217843fu, 0x7u), uint2(0x4217843fu, 0x0u), uint2(0xa31e862eu, 0x7u), + uint2(0x631fc631u, 0x4u), uint2(0x8842108eu, 0x3u), uint2(0x9284211cu, 0x1u), uint2(0x52519531u, 0x4u), + uint2(0xc2108421u, 0x7u), uint2(0x631ad771u, 0x4u), uint2(0x639ace31u, 0x4u), uint2(0xa318c62eu, 0x3u), + uint2(0x4217c62fu, 0x0u), uint2(0x9358c62eu, 0x5u), uint2(0x5257c62fu, 0x4u), uint2(0xe107043eu, 0x3u), + uint2(0x0842109fu, 0x1u), uint2(0xa318c631u, 0x3u), uint2(0x1518c631u, 0x1u), uint2(0xab5ac631u, 0x2u), + uint2(0x62a22a31u, 0x4u), uint2(0x08422a31u, 0x1u), uint2(0xc222221fu, 0x7u), uint2(0xc210843fu, 0x7u), + uint2(0x00000000u, 0x0u), uint2(0xd18c610fu, 0x3u), uint2(0x00000000u, 0x0u), uint2(0x00000000u, 0x0u), +}; + +float glyph_bit(uint code, int fx, int fy) +{ + uint index = (code >= 32u && code < 96u) ? code - 32u : 0u; + uint bit = (uint)(fy * 5 + fx); + uint2 g = FONT[index]; + return (float)(bit < 32u ? (g.x >> bit) & 1u : (g.y >> (bit - 32u)) & 1u); +} + +uint text_char(int row_at, int col_at) +{ + uint word = (uint)(row_at * 3 + (col_at >> 2)); + uint4 v = T[word >> 2]; + uint w = v[word & 3u]; + return (w >> (8u * (uint)(col_at & 3))) & 0xffu; +} + +// x: on_bit, y: the record dot (red). +float2 text_at(float2 q) +{ + float gp = L6.x; + float left = -L0.w + 0.14, right = L0.w - 0.14 - 72.0 * gp; + float top = -0.88, bottom = 0.88 - 7.0 * gp; + int row_at = -1; + float2 origin = float2(0.0, 0.0); + if (q.y >= top && q.y < top + 7.0 * gp) { row_at = q.x < 0.0 ? 0 : 1; origin = float2(q.x < 0.0 ? left : right, top); } + else if (q.y >= bottom && q.y < bottom + 7.0 * gp) { row_at = q.x < 0.0 ? 2 : 4; origin = float2(q.x < 0.0 ? left : right, bottom); } + else if (q.x >= 0.0 && q.y >= bottom - 9.0 * gp && q.y < bottom - 2.0 * gp) { row_at = 3; origin = float2(right, bottom - 9.0 * gp); } + if (row_at < 0) return float2(0.0, 0.0); + float2 cell = (q - origin) / gp; + int col_at = (int)floor(cell.x / 6.0); + int fx = (int)floor(cell.x) - col_at * 6; + int fy = (int)floor(cell.y); + if (col_at < 0 || col_at >= 12 || fx < 0 || fx >= 5 || fy < 0 || fy >= 7) return float2(0.0, 0.0); + uint code = text_char(row_at, col_at); + float on_bit = glyph_bit(code, fx, fy); + return float2(on_bit, code == 35u ? on_bit : 0.0); +} + +// Premultiplied white (or red) text with the dark edge the camcorder gives it. +float4 text_sample(float2 q) +{ + float2 f = text_at(q); + float shadow = text_at(q - L6.x * 0.9).x * 0.75 * (1.0 - f.x); + float3 tint = f.y > 0.5 ? float3(0.95, 0.12, 0.08) : float3(1.0, 1.0, 1.0); + return float4(tint * L6.y * f.x, f.x + shadow); +} + +// ---- a hand right in front of the lens: palm towards it, fingers up (hand units: about a palm wide). +// That close it is out of focus: a soft, rounded shape with no sharp detail. +float sq(float x) { return x * x; } +// A finger: a capsule that narrows from `r0` at its base to `r1` at its tip. +float tapered(float2 p, float2 a, float2 b, float r0, float r1, out float along) +{ + float2 pa = p - a, ba = b - a; + along = saturate(dot(pa, ba) / dot(ba, ba)); + return length(pa - ba * along) - lerp(r0, r1, along); +} +float rounded_box(float2 p, float2 centre, float2 half_size, float r) +{ + float2 d = abs(p - centre) - half_size + r; + return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - r; +} +float smooth_min(float a, float b, float k) +{ + float h = saturate(0.5 + 0.5 * (b - a) / k); + return lerp(b, a, h) - k * h * (1.0 - h); +} +float ellipse(float2 p, float2 centre, float2 radii) +{ + return (length((p - centre) / radii) - 1.0) * min(radii.x, radii.y); +} +// x: distance (negative inside), y: the faint folds at the finger joints. The camera operator reaches round the +// camera with their free arm, so the hand comes in at an angle with the wrist and forearm behind it. +float2 hand(float2 p) +{ + float d = rounded_box(p, float2(0.02, -0.17), float2(0.35, 0.37), 0.24); + d = smooth_min(d, ellipse(p, float2(-0.24, -0.33), float2(0.25, 0.29)), 0.10); // the pad at the base of the thumb + // Fingers: base, direction (radians from straight up: spread apart), length, radii, and a slight + // curl of the last joints (towards the middle, and towards the lens: a little shorter). + float4 bx = float4(-0.25, -0.085, 0.085, 0.235), by = float4(0.16, 0.21, 0.21, 0.15); + float4 spread = float4(-0.17, -0.05, 0.07, 0.22), lengths = float4(0.64, 0.72, 0.68, 0.54); + float4 r0 = float4(0.097, 0.103, 0.101, 0.088), r1 = float4(0.076, 0.081, 0.079, 0.068); + float4 curl = float4(0.10, 0.05, -0.04, -0.10); + float fingers = 9.0, folds = 0.0; + [unroll] for (int i = 0; i < 4; ++i) + { + float2 base = float2(bx[i], by[i]); + float2 knuckle = base + float2(sin(spread[i]), cos(spread[i])) * lengths[i] * 0.58; + float2 tip = knuckle + float2(sin(spread[i] + curl[i]), cos(spread[i] + curl[i])) * lengths[i] * 0.42 * 0.9; + float rm = lerp(r0[i], r1[i], 0.58); + float h1, h2; + float f = smooth_min(tapered(p, base, knuckle, r0[i], rm, h1), tapered(p, knuckle, tip, rm, r1[i], h2), 0.02); + float fold = exp(-sq((h1 - 0.62) / 0.12)) + exp(-sq((h2 - 0.45) / 0.15)); + if (f < fingers) { fingers = f; folds = fold; } + } + float along; + float thumb = min(tapered(p, float2(-0.30, -0.36), float2(-0.58, -0.06), 0.12, 0.10, along), + tapered(p, float2(-0.58, -0.06), float2(-0.66, 0.18), 0.10, 0.082, along)); + float arm = tapered(p, float2(0.04, -0.55), float2(0.30, -2.4), 0.27, 0.33, along); + d = smooth_min(d, fingers, 0.09); + d = smooth_min(d, thumb, 0.08); + d = smooth_min(d, arm, 0.12); + return float2(d, fingers <= d + 0.03 ? folds : 0.0); +} + +float3 fetch(float2 uv) +{ + return source.SampleLevel(linear_clamp, uv, 0.0).rgb; +} + +// The auto-iris, a pass of its own into a 1x1 target before the picture: how bright the scene is +// now (the mean of 64 samples, in log), and the brightness the iris has settled to, which follows it +// over about a second (the previous frame's, from the other of two targets). +float2 ps_adapt(Interpolated i) : SV_Target +{ + float sum = 0.0; + [unroll] for (int y = 0; y < 8; ++y) + [unroll] for (int x = 0; x < 8; ++x) + sum += log(max(dot(fetch(float2((x + 0.5) / 8.0, (y + 0.5) / 8.0)), float3(0.299, 0.587, 0.114)), 0.002)); + float current = exp(sum / 64.0); + float previous = adapted.Load(int3(0, 0, 0)).x; + float k = 1.0 - exp(-max(L8.w, 0.0) / 1.1); + float settled = (previous > 0.0 && previous < 100.0) ? lerp(previous, current, k) : current; + return float2(settled, current); +} + +float4 ps_main(Interpolated i) : SV_Target +{ + float screen_aspect = L1.x; + // The tape: the picture's lines as recorded. Head switching tears the bottom few lines sideways; + // when a clip starts recording, the tracking settles: a band of tearing rolls up the frame. + float lines = min(L3.w, 480.0); + float scan = floor(i.uv.y * lines); + float frame = floor(L1.w * 29.97); + float age = L7.x; + float2 tape_uv = i.uv; + if (L7.z > 0.5 && i.uv.y > 0.972) + { + float k = (i.uv.y - 0.972) / 0.028; + tape_uv.x += (hash12(float2(scan, frame)) - 0.3) * 0.04 * k * (0.5 + age); + } + float tracking = 0.0; + if (L8.y > 0.5 && L8.x >= 0.0 && L8.x < 0.5) + { + float t = L8.x / 0.5; + tracking = exp(-sq((i.uv.y - (1.1 - t * 1.3)) / 0.03)) * (1.0 - t); + tape_uv.x += (hash12(float2(scan, frame + 7.0)) - 0.5) * 0.025 * tracking + sin(i.uv.y * 60.0 + L1.w * 30.0) * 0.0012 * (1.0 - t); + } + float2 c = tape_uv * 2.0 - 1.0; + float2 q = float2(c.x * screen_aspect, c.y); + // 4:3: black at the sides. + if (L1.y > 0.5 && abs(q.x) > L0.w) + return float4(0.0, 0.0, 0.0, 1.0); + bool sdr = L3.x < 0.5; + // The bend: the MK1 squeezes a wide view into the frame. A point at radius r (half heights) sees + // the direction r * the half angle off the axis; the game drew that direction at tan(angle) / tan(FOV / 2). + float r = length(q); + float2 along = r > 1e-5 ? q / r : float2(0.0, 0.0); + float rs = r; + if (L0.x > 0.0) + { + float theta = min(r * L0.y, 1.5); + rs = lerp(r, tan(theta) / L0.z, L0.x); + } + float2 s = along * rs; + float2 uv = float2(s.x / screen_aspect, s.y) * 0.5 + 0.5; + float2 inside = min(uv, 1.0 - uv); + float margin = min(inside.x, inside.y); + float2 texel = 1.0 / max(L3.zw, 1.0); + float sd = max(L3.w / 480.0, 1.0); // standard definition: a few hundred lines + float3 colour = float3(0.0, 0.0, 0.0); + if (margin >= 0.0) + { + if (L2.w > 0.5) + { + // Soft, with the camcorder's sharpening halo; colour bleeds sideways on tape. + float2 close_tap = texel * sd * 0.6; + float2 wide = texel * sd * 1.8; + float3 soft = (fetch(uv + float2(close_tap.x, 0)) + fetch(uv - float2(close_tap.x, 0)) + fetch(uv + float2(0, close_tap.y)) + fetch(uv - float2(0, close_tap.y))) * 0.25; + float3 blur = (fetch(uv + float2(wide.x, wide.y)) + fetch(uv + float2(-wide.x, wide.y)) + fetch(uv + float2(wide.x, -wide.y)) + fetch(uv - wide)) * 0.25; + colour = soft + (soft - blur) * 0.55; + float3 right = fetch(uv + float2(texel.x * sd * 2.5, 0.0)); + float3 weights = float3(0.299, 0.587, 0.114); + float luma = dot(colour, weights); + float3 mixed = lerp(colour, right, 0.5); + colour = luma + (mixed - dot(mixed, weights)); + } + else + { + colour = fetch(uv); + } + // Reds smear on tape: they trail to the right. + if (L7.w > 0.5) + colour.r = lerp(colour.r, fetch(uv + float2(texel.x * sd * 4.0, 0.0)).r, 0.3); + } + // The hand in front of the lens: lit by the scene, then dark and glowing red through the skin + // once it is pressed over the glass. + if (L4.w > 0.0) + { + float2 p = q - L4.xy; + p.x *= L5.x > 0.5 ? -1.0 : 1.0; + float ca = cos(-L4.z), sa = sin(-L4.z); + p = float2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) / L4.w; + p.y = -p.y; // the picture's y runs down; the fingers point up + // The palm is turned a little away round its long axis: narrower, its near side bigger. + p.x /= 0.9; // cos(0.45) + p /= 1.0 + 0.22 * p.x; + float2 h = hand(p); + float blur = 0.05 + L5.y; // out of focus, more so while it moves + float cover = 1.0 - smoothstep(-blur, blur, h.x); + if (cover > 0.0) + { + // Rounded shading from the shape's slope, sampled wide so it stays smooth. + const float e = 0.06; + float2 gradient = float2(hand(p + float2(e, 0.0)).x - hand(p - float2(e, 0.0)).x, + hand(p + float2(0.0, e)).x - hand(p - float2(0.0, e)).x) / (2.0 * e); + float depth = saturate(-h.x / 0.32); + float3 n = normalize(float3(gradient * (1.0 - depth) + float2(0.25, 0.0), sqrt(saturate(1.0 - sq(1.0 - depth))) * 0.85 + 0.15)); + float3 light = normalize(float3(-0.35, 0.55, 0.76)); + float wrap = saturate((dot(n, light) + 0.4) / 1.4); // soft light wrapping round the skin + float3 weights = float3(0.299, 0.587, 0.114); + float ambient = (dot(fetch(float2(0.5, 0.5)), weights) + dot(fetch(float2(0.25, 0.3)), weights) + dot(fetch(float2(0.75, 0.3)), weights) + + dot(fetch(float2(0.25, 0.75)), weights) + dot(fetch(float2(0.75, 0.75)), weights)) * 0.2; + float blotch = sin(p.x * 9.1 + sin(p.y * 7.3)) * sin(p.y * 8.7 + sin(p.x * 5.9)) * 0.5; // uneven skin tone + float3 skin = float3(0.74, 0.53, 0.43) * (1.0 + 0.06 * blotch) * (0.30 + 0.75 * wrap); + skin += float3(0.22, 0.05, 0.02) * sq(1.0 - depth) * 0.6; // light through the thin edges + skin *= 1.0 - 0.07 * saturate(h.y); + skin *= 1.0 - 0.12 * exp(-(sq(p.x) / 0.05 + sq(p.y + 0.08) / 0.06)); // the palm's hollow + skin *= 1.0 + 0.10 * pow(saturate(1.0 - length((p - float2(-0.22, -0.33)) / float2(0.22, 0.26))), 0.7); // the thumb's pad swells to the light + skin *= 0.55 + ambient; + float pressed = saturate((L4.w - 2.6) / 1.4); + float glow = exp(-abs(h.x) * 14.0); + float3 dark = float3(0.05, 0.02, 0.015) + float3(0.5, 0.1, 0.04) * glow * 0.6; + skin = lerp(skin, dark, pressed); + if (!sdr) + skin *= 0.5; + colour = lerp(colour, skin, cover); + } + } + if (sdr && L2.x > 0.5) + { + // The VX's colour: saturated, contrasty, warm, blacks a touch crushed. + colour = saturate(colour); + float luma = dot(colour, float3(0.299, 0.587, 0.114)); + colour = saturate(lerp(luma.xxx, colour, 1.2)); + float3 curve = colour * colour * (3.0 - 2.0 * colour); + colour = lerp(colour, curve, 0.4); + colour *= float3(1.05, 1.01, 0.93); + colour = saturate((colour - 0.025) / 0.975); + } + if (sdr) + colour *= L3.y; // the iris: down while the hand covers it, open too wide for a moment after + // The auto-iris lags the scene: into the sun the picture blows out for a moment, into shade it + // goes murky, then each settles. (Not in HDR10, whose values are not linear.) + if (L8.z > 0.5 && L3.x < 1.5) + { + float2 iris = adapted.Load(int3(0, 0, 0)).xy; + if (iris.x > 0.0 && iris.x < 100.0 && iris.y > 0.0) + colour *= clamp(pow(iris.y / iris.x, 0.75), 0.5, 2.0); + } + // The hood: the fisheye's picture ends in a soft dark edge, and darkens towards it. + if (L1.z > 0.5) + { + float edge = smoothstep(0.0, 0.035, margin); + float falloff = 1.0 - 0.4 * smoothstep(0.75, 1.85, r * (L0.x > 0.0 ? 1.0 : 0.85)); + colour *= edge * falloff; + } + else if (margin < 0.0) + { + colour = float3(0.0, 0.0, 0.0); + } + // The date and the viewfinder: burnt in by the electronics, so straight (no bend) and over the + // hood, but softened by the tape like everything else. + if (L6.z > 0.5 && abs(q.y) > 0.88 - 18.0 * L6.x) + { + float4 text; + if (L2.w > 0.5) + { + float px = 2.0 / max(L3.w, 1.0) * sd; + float4 near_taps = (text_sample(q + float2(px * 0.6, 0.0)) + text_sample(q - float2(px * 0.6, 0.0)) + + text_sample(q + float2(0.0, px * 0.6)) + text_sample(q - float2(0.0, px * 0.6))) * 0.25; + float4 wide_taps = (text_sample(q + px * 1.8) + text_sample(q - px * 1.8) + + text_sample(q + float2(px * 1.8, -px * 1.8)) + text_sample(q + float2(-px * 1.8, px * 1.8))) * 0.25; + text = near_taps + (near_taps - wide_taps) * 0.55; + text.a = saturate(text.a); + text.rgb = max(text.rgb, 0.0); + } + else + { + text = text_sample(q); + } + colour = colour * (1.0 - text.a) + text.rgb; + } + // Dropouts: now and then a short white streak where the tape lost its coating (more, the older it is). + if (L7.y > 0.5) + { + [unroll] for (int k = 0; k < 3; ++k) + { + if (hash12(float2(frame, k * 13.1)) < age * age * 0.05) + { + float y0 = hash12(float2(frame + 3.1, k)); + float x0 = hash12(float2(k, frame + 9.7)) * 1.1 - 0.1; + float span = 0.04 + 0.25 * hash12(float2(frame * 1.7, k + 2.0)); + if (abs(i.uv.y - y0) * lines < 1.0 && i.uv.x > x0 && i.uv.x < x0 + span) + colour = lerp(colour, ((0.6 + 0.4 * hash12(float2(i.uv.x * 900.0, frame))) * (sdr ? 1.0 : L6.y)).xxx, 0.85); + } + } + } + // Head switching: the bottom lines also carry a strip of noise. + if (L7.z > 0.5 && i.uv.y > 0.985) + colour = lerp(colour, (hash12(float2(i.uv.x * 400.0, frame + scan)) * (sdr ? 0.8 : 0.4 * L6.y)).xxx, 0.35 + 0.3 * age); + // The tracking band: its picture loses its colour and fills with noise. + if (tracking > 0.01) + { + float grey = dot(colour, float3(0.299, 0.587, 0.114)); + colour = lerp(colour, (grey + (hash12(float2(i.uv.x * 700.0, frame + scan)) - 0.5) * 0.2).xxx, saturate(tracking * 0.5)); + } + // Interlaced fields: every other line a little darker. + if (L2.y > 0.5) + colour *= (fmod(scan, 2.0) < 1.0) ? 1.0 : 0.93; + if (sdr && L2.z > 0.5) + { + float n = frac(sin(dot(floor(i.uv * float2(720.0, 480.0)) + frac(L1.w * 37.0) * 91.0, float2(12.9898, 78.233))) * 43758.5453) - 0.5; + colour += n * 0.045; + } + return float4(colour, 1.0); +} diff --git a/Extension/World/world_entities.cpp b/Extension/World/world_entities.cpp index 3cfdb117..4b62c4e8 100644 --- a/Extension/World/world_entities.cpp +++ b/Extension/World/world_entities.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,7 @@ struct Wanted { std::string asset; Matrix matrix{}; bool hidden{}; + std::optional attach; }; struct Live { std::uintptr_t entity{}, owner{}, context{}; @@ -61,8 +63,24 @@ struct State { std::uintptr_t context{}; bool verified{}; std::string error, waiting; + std::map lost; // client thread: attached objects whose joint is not known, since when }; State& state() { static auto* s = new State; return *s; } +std::atomic joint_source{}; + +// `offset` (in a joint's frame) placed on the joint: each row taken through the joint's axes. +Matrix on_joint(const Matrix& joint, const Matrix& offset) { + Matrix m{}; + for (int row = 0; row < 4; ++row) { + for (int k = 0; k < 3; ++k) { + float v = row == 3 ? joint[12 + k] : 0.0f; + for (int j = 0; j < 3; ++j) v += offset[row * 4 + j] * joint[j * 4 + k]; + m[row * 4 + k] = v; + } + } + m[15] = 1.0f; + return m; +} void note(State& s, std::string issue) { if (s.waiting == issue) return; @@ -163,9 +181,12 @@ std::uintptr_t resolve(std::uintptr_t base, Asset& a, const std::string& name, s } } // namespace -std::string set(std::string_view owner, std::uint32_t id, std::string asset, const Matrix& matrix, bool hidden) { +void set_joint_source(JointSource source) noexcept { joint_source.store(source, std::memory_order_release); } + +std::string set(std::string_view owner, std::uint32_t id, std::string asset, const Matrix& matrix, bool hidden, std::optional attach) { if (asset.empty() || asset.size() > 200) return "an object's blueprint is a name of 1 to 200 characters"; if (!finite(matrix)) return "an object's transform must be finite, within 100 km, each axis at most 200 long"; + if (attach && attach->joint >= 395) return "a skater's joints are 0 to 394"; auto& s = state(); std::lock_guard lock(s.mutex); const Key key{std::string(owner), id}; @@ -174,7 +195,7 @@ std::string set(std::string_view owner, std::uint32_t id, std::string asset, con if (static_cast(mine) >= max_per_owner) return std::format("at most {} objects", max_per_owner); if (s.wanted.size() >= max_total) return "the world holds as many objects as ReSkate keeps"; } - s.wanted[key] = {std::move(asset), matrix, hidden}; + s.wanted[key] = {std::move(asset), matrix, hidden, attach}; return {}; } void remove(std::string_view owner, std::uint32_t id) { @@ -267,11 +288,27 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept { wanted = s.wanted; live = s.live; } + // Attached objects onto their joints as they are now; one whose joint is not known stays put + // (or waits), and after a second is taken away. + const auto source = joint_source.load(std::memory_order_acquire); + std::vector unplaced; + for (auto& [key, w] : wanted) { + if (!w.attach || w.hidden) continue; + if (const auto at = source ? source(w.attach->player, w.attach->joint) : std::nullopt; at && finite(*at)) { + w.matrix = on_joint(*at, w.matrix); + s.lost.erase(key); + } else { + unplaced.push_back(key); + s.lost.try_emplace(key, now); + } + } + std::erase_if(s.lost, [&](const auto& l) { return !wanted.contains(l.first); }); // Gone or hidden, or a different blueprint now: taken away. std::vector drop; for (const auto& [key, l] : live) { const auto w = wanted.find(key); - if (w == wanted.end() || w->second.hidden || w->second.asset != l.asset || !intact(l)) drop.push_back(key); + const bool gone = w != wanted.end() && s.lost.contains(key) && now - s.lost[key] > 1000; + if (w == wanted.end() || w->second.hidden || w->second.asset != l.asset || gone || !intact(l)) drop.push_back(key); } // A blueprint whose bundle streamed out: its objects come back once it resolves again. unsigned lookups = 4; @@ -293,10 +330,11 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept { std::uintptr_t parent{}; unsigned created{}; for (const auto& [key, w] : wanted) { - if (w.hidden) continue; + if (w.hidden || std::find(unplaced.begin(), unplaced.end(), key) != unplaced.end()) continue; if (const auto l = live.find(key); l != live.end()) { // Move a resident object in place: recreating restarts its effects and makes it jump. - if (l->second.matrix != w.matrix && now >= l->second.moved + 16) { + // One on a joint follows it every frame. + if (l->second.matrix != w.matrix && (w.attach || now >= l->second.moved + 16)) { move(base, l->second, w.matrix); std::lock_guard lock(s.mutex); if (const auto at = s.live.find(key); at != s.live.end()) at->second.matrix = w.matrix, at->second.moved = now; @@ -345,6 +383,7 @@ void before_level_transition(std::uintptr_t base, unsigned next) noexcept { s.live.clear(); s.wanted.clear(); s.assets.clear(); + s.lost.clear(); s.context = 0; logging::log(logging::Level::info, logging::Channel::runtime, "World objects retired before native state {}.", next); } diff --git a/Extension/World/world_entities.h b/Extension/World/world_entities.h index d2c9209e..f3962beb 100644 --- a/Extension/World/world_entities.h +++ b/Extension/World/world_entities.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include #include #include @@ -15,8 +16,22 @@ using Matrix = std::array; enum class Status { none, waiting, loading, missing, ready, hidden }; +// An object kept on a joint of a skater (of the 395 the animation uses): `player` 0 for the local +// skater, else another player in the session. Its matrix is then in the joint's frame, and it moves +// with the joint every frame; while the joint is not known it waits, and after a second without it a +// shown object is taken away until the joint comes back. +struct Attach { + std::uint64_t player{}; + std::uint16_t joint{}; +}; +// Where a skater's joint is in the world now, or empty (set by the runtime: the local skater's pose +// and the session's drawn poses). Called on the client thread. +using JointSource = std::optional (*)(std::uint64_t player, std::uint16_t joint) noexcept; +void set_joint_source(JointSource source) noexcept; + // An object of `owner` (a plugin's id). False and why when it cannot be kept. -std::string set(std::string_view owner, std::uint32_t id, std::string asset, const Matrix& matrix, bool hidden); +std::string set(std::string_view owner, std::uint32_t id, std::string asset, const Matrix& matrix, bool hidden, + std::optional attach = std::nullopt); void remove(std::string_view owner, std::uint32_t id); void clear(std::string_view owner); Status status(std::string_view owner, std::uint32_t id); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index ece4bafe..8471e3a5 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -142,6 +142,7 @@ add_library(dingosdk_runtime SHARED Extension/Skater/game_speed.cpp Extension/Skater/grind_exit.cpp Extension/Skater/trick_rewrite.cpp + Extension/Skater/arm_reach.cpp Extension/UI/hud_corner.cpp Extension/Assets/morph_memory_pool.cpp Extension/Assets/native_render_resource_pool.cpp diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 89754254..01593b92 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -50,9 +50,53 @@ add_library(dingosdk_overlay STATIC Extension/Trainer/trainer_page.cpp Extension/Music/music_page.cpp Extension/Trainer/trainer_view.cpp + Extension/UI/Overlay/screen_look.cpp ) target_link_libraries(dingosdk_overlay PUBLIC dingosdk_logging dingosdk_profiler dingosdk_imgui dingosdk_hooks dingosdk_console_core dxguid PRIVATE dingosdk_playstation_input shell32 dingosdk_initfs dingosdk_custom_scripts dingosdk_game_archives dingosdk_mods dingosdk_json windowscodecs ole32) +# The screen look's shader (reskate.screen.look) is built into the DLL as text and compiled when it is +# first drawn. The build checks it with the Windows SDK's fxc when there is one, so a shader that would +# not compile in the game fails here instead. +set(screen_look_path "${PROJECT_SOURCE_DIR}/Extension/UI/Overlay/screen_look.hlsl") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${screen_look_path}") +file(READ "${screen_look_path}" screen_look_source) +# MSVC takes at most about 16 KB in one string literal: the shader goes in as several, which the +# compiler joins. +string(LENGTH "${screen_look_source}" screen_look_length) +set(screen_look_chunks "") +set(screen_look_at 0) +while(screen_look_at LESS screen_look_length) + string(SUBSTRING "${screen_look_source}" ${screen_look_at} 8000 screen_look_chunk) + string(APPEND screen_look_chunks "R\"screen_hlsl(${screen_look_chunk})screen_hlsl\"\n") + math(EXPR screen_look_at "${screen_look_at} + 8000") +endwhile() +configure_file(cmake/templates/screen_look_hlsl.h.in "${CMAKE_CURRENT_BINARY_DIR}/generated/screen_look_hlsl.h" @ONLY) +target_include_directories(dingosdk_overlay PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated") +if(WIN32) + file(GLOB screen_look_fxc_candidates "C:/Program Files (x86)/Windows Kits/10/bin/10.*/x64/fxc.exe") + if(screen_look_fxc_candidates) + list(SORT screen_look_fxc_candidates) + list(REVERSE screen_look_fxc_candidates) + list(GET screen_look_fxc_candidates 0 screen_look_fxc_newest) + set(DINGOSDK_FXC "${screen_look_fxc_newest}" CACHE FILEPATH "fxc.exe that checks the screen look's shader") + else() + find_program(DINGOSDK_FXC fxc) + endif() + if(DINGOSDK_FXC) + set(screen_look_checked "${CMAKE_CURRENT_BINARY_DIR}/generated/screen_look.checked") + add_custom_command(OUTPUT "${screen_look_checked}" + COMMAND "${DINGOSDK_FXC}" /nologo /T vs_5_0 /E vs_main /Fo "${CMAKE_CURRENT_BINARY_DIR}/generated/screen_look_vs.cso" "${screen_look_path}" + COMMAND "${DINGOSDK_FXC}" /nologo /T ps_5_0 /E ps_main /Fo "${CMAKE_CURRENT_BINARY_DIR}/generated/screen_look_ps.cso" "${screen_look_path}" + COMMAND "${DINGOSDK_FXC}" /nologo /T ps_5_0 /E ps_adapt /Fo "${CMAKE_CURRENT_BINARY_DIR}/generated/screen_look_iris.cso" "${screen_look_path}" + COMMAND "${CMAKE_COMMAND}" -E touch "${screen_look_checked}" + DEPENDS "${screen_look_path}" + COMMENT "Checking the screen look's shader with fxc" + VERBATIM) + add_custom_target(dingosdk_screen_look_check DEPENDS "${screen_look_checked}") + add_dependencies(dingosdk_overlay dingosdk_screen_look_check) + endif() +endif() + # The window shown from the moment ReSkate loads until the game's own window appears. add_library(dingosdk_startup_window STATIC Extension/UI/Startup/startup_window.cpp) target_link_libraries(dingosdk_startup_window PRIVATE gdi32 user32 msimg32 shell32 ole32 windowscodecs) diff --git a/cmake/templates/screen_look_hlsl.h.in b/cmake/templates/screen_look_hlsl.h.in new file mode 100644 index 00000000..64c50413 --- /dev/null +++ b/cmake/templates/screen_look_hlsl.h.in @@ -0,0 +1,6 @@ +#pragma once +// Generated from Extension/UI/Overlay/screen_look.hlsl (cmake/UI.cmake), in pieces short enough for MSVC. +namespace dingosdk::screen_look { +inline constexpr char screen_look_hlsl[] = +@screen_look_chunks@; +}