From 37516e24fca710d6a2131a4c7407d1cb92651d62 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Mon, 19 Oct 2026 19:04:11 -0500 Subject: [PATCH 1/9] Throwdowns: a plugin mode's flag is offered to the session, joined with the game's own Join When a plugin mode's flag goes down in a session, the relay offers it to the other players as that mode's: a new mode message (kind 17) names it, its flag and its player count, and its offer's series is one older versions do not show. A player whose plugin has the same mode gets a copy with the mode's own flag mesh and joins it natively; the copies are held at ten players so none fills and starts a Spot Battle, and the mode's player count decides who may still join. throwdown_mode events now carry host, leader, player and players: the host hears joined and left, a player in another's flag hears joined, left, closed and start, and start names who plays, the leader first. The plugins run the match from there. A mode can take a stock throwdown's place on the picker (replaces = "skate", "spot_battle" or "jam"), so his R.E.S.K.A.T.E. sits where S.K.A.T.E. does. --- CMakeLists.txt | 2 +- Extension/Multiplayer/Test/protocol_tests.cpp | 13 +++ Extension/Plugins/Test/plugin_tests.cpp | 14 ++- Extension/Plugins/plugin_api_world.cpp | 3 + Extension/Plugins/plugin_game.h | 7 ++ Extension/Plugins/plugin_game_runtime.cpp | 5 + Extension/Plugins/plugin_host.cpp | 8 ++ .../Test/custom_throwdown_tests.cpp | 3 + .../Throwdowns/Test/throwdown_relay_tests.cpp | 94 ++++++++++++++- Extension/Throwdowns/custom_modes.cpp | 2 + Extension/Throwdowns/custom_modes.h | 10 ++ Extension/Throwdowns/custom_placement.cpp | 33 +++++- Extension/Throwdowns/custom_placement.h | 7 ++ Extension/Throwdowns/throwdown_lab.cpp | 25 +++- Extension/Throwdowns/throwdown_lab.h | 5 +- Extension/Throwdowns/throwdown_relay.cpp | 108 ++++++++++++++++-- Extension/Throwdowns/throwdown_relay.h | 7 ++ Extension/Throwdowns/throwdown_wire.cpp | 31 ++++- Extension/Throwdowns/throwdown_wire.h | 9 +- Extension/UI/NativeMenu/throwdown_menu.cpp | 29 ++++- 20 files changed, 386 insertions(+), 29 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8033bb8c..a22900a6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -254,7 +254,7 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) target_link_libraries(dingosdk_multiplayer_tests PRIVATE dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_word_filter dingosdk_json) add_test(NAME multiplayer_protocol COMMAND dingosdk_multiplayer_tests) add_executable(dingosdk_throwdown_relay_tests Extension/Throwdowns/Test/throwdown_relay_tests.cpp - Extension/Throwdowns/throwdown_relay.cpp Extension/Throwdowns/throwdown_wire.cpp) + Extension/Throwdowns/throwdown_relay.cpp Extension/Throwdowns/throwdown_wire.cpp Extension/Throwdowns/custom_modes.cpp) target_include_directories(dingosdk_throwdown_relay_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_throwdown_relay_tests PRIVATE dingosdk_logging) add_test(NAME throwdown_relay COMMAND dingosdk_throwdown_relay_tests) diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index f331c2c4..f524b548 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -725,6 +725,19 @@ void throwdown_codec() { ThrowdownMessage m; m.kind = kind; m.leader = leader; m.id = 5; check(decode_throwdown(encode_throwdown(m)) == m, "Throwdown control message failed to round-trip"); } + // A plugin mode's flag. + ThrowdownMessage mode; + mode.kind = ThrowdownMessage::Kind::mode; mode.leader = leader; mode.id = 0x7c8bf0b2; + mode.challenge = "SmokeyDawg58-ReSkate_Throwdown/oneup"; mode.series = "oneup"; mode.value = 6; + check(decode_throwdown(encode_throwdown(mode)) == mode, "Throwdown mode failed to round-trip"); + auto stock_flag = mode; stock_flag.series.clear(); + check(decode_throwdown(encode_throwdown(stock_flag)) == stock_flag, "A mode on Spot Battle's flag failed to round-trip"); + auto no_plugin = mode; no_plugin.challenge = "oneup"; + check(!valid_throwdown(no_plugin), "A mode without its plugin was valid"); + auto crowded = mode; crowded.value = 11; + check(!valid_throwdown(crowded), "A mode for 11 players was valid"); + auto spaced_flag = mode; spaced_flag.series = "one up"; + check(!valid_throwdown(spaced_flag), "A flag name with a space was valid"); // Coop challenges. ThrowdownMessage challenge; challenge.kind = ThrowdownMessage::Kind::challenge_start; challenge.leader = leader; challenge.id = 3; diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 4180fee5..91b73778 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -1746,16 +1746,18 @@ void plugins_add_throwdown_modes() { auto& game = environment.fake; Host host(environment, Side::client); host.apply({script("modes", R"( - assert(reskate.throwdowns.mode{id = "oneup", name = "1-UP", description = "Beat the last total.", art = "OneUp", flag = "oneup", + assert(reskate.throwdowns.mode{id = "oneup", name = "1-UP", description = "Beat the last total.", art = "OneUp", flag = "oneup", replaces = "skate", players = {min = 1, max = 6, default = 2}, timer = {caption = "Turn", min = 10, max = 120, step = 10, default = 20}, rows = {{id = "letters", caption = "Letters", min = 1, max = 7, default = 3}, {id = "tricks", kind = "choice", caption = "Tricks", options = {"All", "Flips"}, default = 2}, {id = "area", kind = "button", caption = "Area", text = "Set area"}}}) assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", rows = {{id = "r", kind = "dial"}}}), "a row kind") assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", flag = "two words"}), "a flag name") + assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", replaces = "bowling"}), "a stock throwdown") reskate.on("throwdown_mode", function(e) reskate.log(e.mode .. " " .. e.action .. " " .. tostring(e.settings.players) .. " " .. tostring(e.settings.tricks) .. (e.position and string.format(" at %.0f %.0f %.0f facing %.0f", e.position[1], e.position[2], e.position[3], e.heading) or "")) + if e.players then reskate.log(e.mode .. " players " .. table.concat(e.players, ",") .. " led by " .. e.leader .. (e.host and " here" or " elsewhere")) end if e.action == "start" then reskate.throwdowns.release_flag() end end) reskate.console.command("tdgone", "Removes the mode", function() reskate.throwdowns.remove_mode("oneup") end) @@ -1763,17 +1765,23 @@ void plugins_add_throwdown_modes() { running(host, "modes", environment); check(game.called("mode modes oneup 1-UP rows 3 timer") && game.modes.size() == 1, "The mode reaches the game\n" + environment.all()); const auto& mode = game.modes.front(); - check(mode.players == 2 && mode.min_players == 1 && mode.max_players == 6 && mode.flag == "oneup" && mode.art == "OneUp", + check(mode.players == 2 && mode.min_players == 1 && mode.max_players == 6 && mode.flag == "oneup" && mode.art == "OneUp" && mode.replaces == "skate", "with its players, flag and art"); check(mode.timer && mode.timer->caption == "Turn" && mode.timer->step == 10 && mode.timer->value == 20, "its timer"); check(mode.rows[0].kind == "slider" && mode.rows[0].maximum == 7 && mode.rows[0].value == 3, "a slider row"); check(mode.rows[1].kind == "choice" && mode.rows[1].options.size() == 2 && mode.rows[1].value == 1, "a choice, counted from 0"); check(mode.rows[2].kind == "button" && mode.rows[2].text == "Set area", "a button"); game.mode_news.push_back({"oneup", "placed", "", 0, {{"players", 2}, {"tricks", 1}}, Vec3{1, 2, 3}, 90}); - game.mode_news.push_back({"oneup", "start", "", 0, {{"players", 2}, {"tricks", 1}}}); + ThrowdownModeNews start{"oneup", "start", "", 0, {{"players", 2}, {"tricks", 1}}}; + start.host = false; + start.leader = 76561198000000001ULL; + start.players = {76561198000000001ULL, 76561198000000002ULL}; + game.mode_news.push_back(start); ticks(host, environment, 1); check(environment.logged("[modes] oneup placed 2 1 at 1 2 3 facing 90"), "The flag's place reaches the plugin\n" + environment.all()); check(environment.logged("[modes] oneup start 2 1") && game.called("release flag modes"), "and its Start, and it takes the flag down"); + check(environment.logged("[modes] oneup players 76561198000000001,76561198000000002 led by 76561198000000001 elsewhere"), + "A start in another player's flag says who plays"); check(host.run_console("tdgone") && game.called("remove mode modes oneup"), "A mode can go"); host.apply({contributed("throwdown-modes-test")}); running(host, "throwdown-modes-test", environment); diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index d0cd307a..5e058784 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -1112,6 +1112,9 @@ struct WorldApi { mode.description = text_field(L, 1, "description"); mode.art = text_field(L, 1, "art"); mode.flag = text_field(L, 1, "flag"); + mode.replaces = text_field(L, 1, "replaces"); + if (!mode.replaces.empty() && mode.replaces != "skate" && mode.replaces != "spot_battle" && mode.replaces != "jam") + luaL_argerror(L, 1, "replaces is \"skate\", \"spot_battle\" or \"jam\""); for (const char c : mode.flag) if (!std::isalnum(static_cast(c)) && c != '_') luaL_argerror(L, 1, "flag is letters, digits and _"); lua_getfield(L, 1, "players"); diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index c01dcaf8..8cbbebc8 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -167,18 +167,25 @@ struct ThrowdownMode { std::string id, name, description; std::string art; // the logo: a texture or image asset the plugin's mod adds, by path; empty: Spot Battle's std::string flag; // the mod's own flag (InstanceId ReSkate.); empty: Spot Battle's + std::string replaces; // the stock throwdown whose place it takes: "skate", "spot_battle", "jam" or empty int min_players = 1, max_players = 6, players = 4; std::optional timer; std::vector rows; }; // "opened", "setting", "button", "placed", "start", "leave" or "cancelled" for one of the // plugin's modes, with every setting (players, timer and the rows by id). +// In a session the flag is offered to the other players with the same plugin mode: the host hears +// "joined" and "left" (`player`); a player in another's flag hears "joined" and "left" (themselves), +// "closed" (taken down) and "start", whose `players` (the leader first) also come to the host's. struct ThrowdownModeNews { std::string mode, action, row; int value{}; std::map settings; std::optional at; float heading{}; // degrees + bool host = true; + std::uint64_t leader{}, player{}; + std::vector players; }; struct Progress { bool available{}; diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 111e855e..108d6459 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -404,6 +404,7 @@ public: mode.description = m.description; mode.art = m.art; mode.flag = m.flag; + mode.replaces = m.replaces; mode.min_players = m.min_players; mode.max_players = m.max_players; mode.players = m.players; @@ -421,6 +422,10 @@ public: ThrowdownModeNews news{std::move(n.mode), std::move(n.action), std::move(n.row), n.value, std::move(n.settings)}; if (n.at) news.at = Vec3{(*n.at)[0], (*n.at)[1], (*n.at)[2]}; news.heading = n.heading; + news.host = n.host; + news.leader = n.leader; + news.player = n.player; + news.players = std::move(n.players); out.push_back(std::move(news)); } return out; diff --git a/Extension/Plugins/plugin_host.cpp b/Extension/Plugins/plugin_host.cpp index 2d548ca5..272c72e5 100644 --- a/Extension/Plugins/plugin_host.cpp +++ b/Extension/Plugins/plugin_host.cpp @@ -364,6 +364,14 @@ void Host::follow_game() { data["position"] = Json::array({(*news.at)[0], (*news.at)[1], (*news.at)[2]}); data["heading"] = news.heading; } + data["host"] = news.host; + if (news.leader) data["leader"] = std::to_string(news.leader); + if (news.player) data["player"] = std::to_string(news.player); + if (!news.players.empty()) { + auto players = Json::array(); + for (const auto who : news.players) players.push_back(std::to_string(who)); + data["players"] = std::move(players); + } plugin.game_event("throwdown_mode", "throwdowns", data); } }); diff --git a/Extension/Throwdowns/Test/custom_throwdown_tests.cpp b/Extension/Throwdowns/Test/custom_throwdown_tests.cpp index 07b181a3..1b2a9d97 100644 --- a/Extension/Throwdowns/Test/custom_throwdown_tests.cpp +++ b/Extension/Throwdowns/Test/custom_throwdown_tests.cpp @@ -37,6 +37,9 @@ void modes_are_checked_and_listed() { bad = one_up(); bad.rows.push_back({custom_throwdowns::Row::Kind::slider, "huge", "Huge", 0, 100000, 1}); check(!custom_throwdowns::set_mode(bad).empty(), "a slider has at most 600 steps"); + bad = one_up(); + bad.replaces = "bowling"; + check(!custom_throwdowns::set_mode(bad).empty(), "replaces names a stock throwdown"); const auto mode = custom_throwdowns::find("smokey/oneup"); check(mode && mode->players == 6 && mode->rows[0].value == 1, "players and a choice are clamped"); const auto settings = custom_throwdowns::settings("smokey/oneup"); diff --git a/Extension/Throwdowns/Test/throwdown_relay_tests.cpp b/Extension/Throwdowns/Test/throwdown_relay_tests.cpp index f8d4b3f9..b0ab95d4 100644 --- a/Extension/Throwdowns/Test/throwdown_relay_tests.cpp +++ b/Extension/Throwdowns/Test/throwdown_relay_tests.cpp @@ -1,6 +1,7 @@ // The throwdown relay's state machine without the game: the natives it drives are // recorded here, and messages are exchanged by hand as other players would send them. #include "Extension/Throwdowns/throwdown_relay.h" +#include "Extension/Throwdowns/custom_modes.h" #include "Extension/Throwdowns/throwdown_lab.h" #include "Extension/Throwdowns/throwdown_wire.h" #include "Extension/Throwdowns/native_throwdowns.h" @@ -40,9 +41,14 @@ std::uint32_t local_native_player_id() noexcept { return 2; } std::uint64_t native_party_player_info(std::uintptr_t, std::size_t) noexcept { return 0; } void spectate_party_member(std::uint64_t) noexcept {} bool prepare_throwdown_injection() noexcept { calls.push_back({"prepare"}); return types_ready; } +std::string last_instance; +std::uint32_t last_capacity{}; bool queue_throwdown_spawn(std::uint64_t token, std::uint32_t host, const std::string &series, - const std::vector &, const std::vector &) noexcept { + const std::vector &, const std::vector &, + const std::string &instance, std::uint32_t capacity) noexcept { last_token = token; + last_instance = instance; + last_capacity = capacity; calls.push_back({"spawn", series, host, 0, 0, token}); return true; } @@ -818,6 +824,89 @@ void plugin_host_flow() { end_session(); } +// A plugin throwdown mode's flag (custom_modes.h): offered as that mode's, shown only where the +// plugin has the mode too, held open on every copy, and its joins, leaves and start reach the +// plugins instead of starting a Spot Battle. +void plugin_mode_flow() { + using namespace dingosdk::multiplayer::custom_throwdowns; + Mode mode; + mode.plugin = "smokey"; mode.id = "oneup"; mode.name = "1-UP"; mode.flag = "oneup"; mode.players = 3; + check(set_mode(mode).empty(), "The test mode registers"); + (void)take("smokey"); + // Hosting: the flag is offered as the mode's, with an offer older versions do not show. + { + const ThrowdownRelayInput in{me, {{other, 4}}, true, 81}; + tick(in); + ThrowdownLocalAction exited{Local::exited}; + exited.series = "SpotBattle"; exited.mmid = 0x61; exited.placement = {9}; exited.settings = {8}; + exited.mode = "smokey/oneup"; exited.flag = "oneup"; exited.mode_players = 2; + throwdown_relay_local(std::move(exited)); + auto out = tick(in); + check(out.size() == 2 && out[0].kind == Kind::mode && out[0].challenge == "smokey/oneup" && out[0].series == "oneup" && + out[0].value == 2 && out[1].kind == Kind::offer && out[1].series == "ReSkateMode", + "A mode's flag was not offered as that mode's"); + receive(other, make(Kind::join, me, 0x61)); + tick(in); + const auto *add = find("add"); + check(add && add->id == 0x304 && add->mmid == 0x61, "A join did not enter the flag's queue"); + auto news = take("smokey"); + check(news.size() == 1 && news[0].action == "joined" && news[0].host && news[0].player == other, + "The host's plugin did not hear who joined"); + check(throwdown_relay_mode_players(0x61) == std::vector{me, other}, "The flag's players were not published"); + local(Local::force_started, 0x61); + out = tick(in); + check(out.size() == 1 && out[0].kind == Kind::start && out[0].order == std::vector{me, other}, + "The host's Start did not reach the other players"); + check(throwdown_relay_mode_players(0x61).empty(), "The flag was still up after Start"); + end_session(); + } + // Another player's flag, seen where the mode is: held open with the mode's flag. + { + calls.clear(); + const ThrowdownRelayInput in{me, {{leader, 3}, {other, 5}}, true, 82}; + tick(in); + auto tag = make(Kind::mode, leader, 0x71); + tag.challenge = "smokey/oneup"; tag.series = "oneup"; tag.value = 3; + receive(leader, tag); + receive(leader, offer(leader, 0x71, "ReSkateMode")); + tick(in); + const auto *spawn = find("spawn"); + check(spawn && spawn->series == "SpotBattle" && last_instance == "ReSkate.oneup" && last_capacity == 10, + "A mode's flag was not shown as the mode's, held open"); + throwdown_relay_spawned(last_token, 0x7777, 10); + tick(in); + local(Local::joined, 0x7777); + auto out = tick(in); + check(out.size() == 1 && out[0].kind == Kind::join, "Joining a mode's flag was not reported to its leader"); + auto news = take("smokey"); + check(news.size() == 1 && news[0].action == "joined" && !news[0].host && news[0].leader == leader, + "The plugin did not hear the local player joined"); + auto start = make(Kind::start, leader, 0x71); + start.order = {leader, me}; + receive(leader, start); + tick(in); + check(!find("start") && find("destroy"), "A mode's flag started a Spot Battle instead of going"); + news = take("smokey"); + check(news.size() == 1 && news[0].action == "start" && news[0].players == std::vector{leader, me}, + "The plugin did not hear who plays"); + end_session(); + } + // Where the plugin does not have that mode, nothing shows. + { + calls.clear(); + const ThrowdownRelayInput in{me, {{leader, 3}}, true, 83}; + tick(in); + auto tag = make(Kind::mode, leader, 0x72); + tag.challenge = "elsewhere/race"; tag.value = 4; + receive(leader, tag); + receive(leader, offer(leader, 0x72, "ReSkateMode")); + tick(in); + check(!find("spawn"), "A mode this player does not have was shown"); + end_session(); + } + remove_plugin("smokey"); +} + void level_change_flow() { using namespace std::chrono_literals; const ThrowdownRelayInput in{me, {{leader, 3}}, true, 77}; @@ -869,7 +958,8 @@ int main() { beacon_flow(); level_change_flow(); plugin_host_flow(); - std::cout << "Throwdown relay: guest, leader, removal, Spot Battle, S.K.A.T.E., last-player, coop challenge, party beacon, level change and plugin drop flows passed.\n"; + plugin_mode_flow(); + std::cout << "Throwdown relay: guest, leader, removal, Spot Battle, S.K.A.T.E., last-player, coop challenge, party beacon, level change, plugin drop and plugin mode flows passed.\n"; return 0; } catch (const std::exception &e) { std::cerr << e.what() << '\n'; diff --git a/Extension/Throwdowns/custom_modes.cpp b/Extension/Throwdowns/custom_modes.cpp index ec83e467..bc26bfae 100644 --- a/Extension/Throwdowns/custom_modes.cpp +++ b/Extension/Throwdowns/custom_modes.cpp @@ -31,6 +31,8 @@ std::string set_mode(Mode mode) { return "a mode needs an id of 1 to 32 characters, without /"; if (mode.name.empty() || mode.name.size() > 32) return "a mode needs a name of 1 to 32 characters"; if (mode.description.size() > 240) return "a description is at most 240 characters"; + if (!mode.replaces.empty() && mode.replaces != "skate" && mode.replaces != "spot_battle" && mode.replaces != "jam") + return "replaces is \"skate\", \"spot_battle\" or \"jam\""; if (mode.min_players < 1 || mode.max_players > 10 || mode.min_players > mode.max_players) return "players are 1 to 10, min no more than max"; mode.players = std::clamp(mode.players, mode.min_players, mode.max_players); diff --git a/Extension/Throwdowns/custom_modes.h b/Extension/Throwdowns/custom_modes.h index a25e7e07..8211a4fd 100644 --- a/Extension/Throwdowns/custom_modes.h +++ b/Extension/Throwdowns/custom_modes.h @@ -29,6 +29,9 @@ struct Mode { std::string art; // The mod's flag (InstanceId ReSkate.). Empty: Spot Battle's own. std::string flag; + // The stock throwdown whose place on the picker it takes: "skate", + // "spot_battle" or "jam"; empty: after them. + std::string replaces; int min_players = 1, max_players = 6, players = 4; std::optional timer; // the setup page's Timer row, when the mode has one std::vector rows; @@ -38,12 +41,19 @@ struct Mode { // changed), button, placed (the flag is down: `at` and `heading`), start (the // host held Start on the waiting card), leave, cancelled (Back out of setup or // placement). `settings` holds players, timer and every row by id. +// In a session the flag is offered to the other players who have the mode +// (throwdown_relay.cpp). The host hears joined and left (`player`); a player in +// another's flag hears joined and left (themselves), closed (it was taken down) +// and start. start names who plays, `players`, the leader first. struct News { std::string mode, action, row; int value{}; std::map settings; std::optional> at; float heading{}; // degrees + bool host = true; // the local player's own flag, else `leader`'s + std::uint64_t leader{}, player{}; // Steam IDs + std::vector players; }; // Empty when accepted, else why not. diff --git a/Extension/Throwdowns/custom_placement.cpp b/Extension/Throwdowns/custom_placement.cpp index 3c3bf629..6ca679f7 100644 --- a/Extension/Throwdowns/custom_placement.cpp +++ b/Extension/Throwdowns/custom_placement.cpp @@ -1,6 +1,7 @@ #include "custom_placement.h" #include "custom_modes.h" #include "throwdown_lab.h" +#include "throwdown_relay.h" #include "Extension/UI/NativeMenu/native_menu_data.h" #include "Engine/Core/Log/logging.h" #include @@ -35,6 +36,11 @@ void reset(Placement& p) { } void cleanup(Placement& p, std::uint64_t now) { if (p.step != Step::cleanup) p.sent = 0; + if (p.mmid && p.step == Step::waiting) { + ThrowdownLocalAction closed{ThrowdownLocalAction::Kind::destroy_requested}; + closed.mmid = p.mmid; + throwdown_relay_local(std::move(closed)); + } p.cancelling = true; p.step = Step::cleanup; p.until = now + 15000; } // News for the plugin, once per flag: cancelled before the flag was down. @@ -122,6 +128,22 @@ std::string flag_key() { const auto mode = find(key); return mode ? mode->flag : std::string{}; } +std::optional flag_offer() { + auto& p = placement(); + std::string key; + std::uint32_t mmid{}; + { + std::lock_guard lock(p.mutex); + if ((p.step != Step::waiting && p.step != Step::closing) || !p.at || !p.mmid || p.cancelling) return std::nullopt; + key = p.key; + mmid = p.mmid; + } + const auto mode = find(key); + if (!mode) return std::nullopt; + const auto values = settings(key); + const auto players = values.find("players"); + return Offer{key, mode->flag, static_cast(players == values.end() ? mode->players : players->second), mmid}; +} bool known_queue(std::uint32_t mmid) noexcept { auto& p = placement(); std::lock_guard lock(p.mutex); @@ -203,7 +225,16 @@ void waiting_start() noexcept { // Start can come before the setup page's exit has moved us to waiting. if ((p.step != Step::waiting && p.step != Step::closing) || !p.at || !p.mmid) return; if (!p.handed_off) { p.handed_off = true; tell(p, "placed"); } - tell(p, "start"); + if (!p.key.empty() && !p.told) { + p.told = true; + custom_throwdowns::News news; + news.action = "start"; + news.host = true; + news.players = throwdown_relay_mode_players(p.mmid); + if (!news.players.empty()) news.leader = news.players.front(); + post(p.key, std::move(news)); + } + throwdown_relay_local({ThrowdownLocalAction::Kind::force_started, {}, p.mmid}); // The flag has done its job: the plugin runs the match from here. cleanup(p, GetTickCount64()); } diff --git a/Extension/Throwdowns/custom_placement.h b/Extension/Throwdowns/custom_placement.h index 15275b17..e2abfd4d 100644 --- a/Extension/Throwdowns/custom_placement.h +++ b/Extension/Throwdowns/custom_placement.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include #include #include @@ -28,6 +29,12 @@ bool registration_waiting() noexcept; bool flag_down() noexcept; // The InstanceId suffix of the owning mode's own flag, when it has one. std::string flag_key(); +// The flag that is down, as the other players are offered it: its mode, flag and player count. +struct Offer { + std::string key, flag; + std::uint32_t players{}, mmid{}; +}; +std::optional flag_offer(); // A queue we created; late events for it are not a new stock throwdown. bool known_queue(std::uint32_t mmid) noexcept; // The plugin is done with its flag (its match started, or it gave up). diff --git a/Extension/Throwdowns/throwdown_lab.cpp b/Extension/Throwdowns/throwdown_lab.cpp index 0b6a48dc..45bd8667 100644 --- a/Extension/Throwdowns/throwdown_lab.cpp +++ b/Extension/Throwdowns/throwdown_lab.cpp @@ -145,6 +145,7 @@ struct Job { beacon_move, beacon_remove } kind; std::uint32_t uid{}, max_players{}; std::string series; // spawn: mode when no placement is recorded + std::string instance; // spawn: its flag's InstanceId (empty: the series' own) float score{}; bool flag{true}; // score: value and the native's flag std::optional> position; // spawn: fresh parameters at this spot std::string challenge; // challenge_start: its Id (series in `series`) @@ -1124,7 +1125,7 @@ std::string run_job(const Job& job) { // Party-only resolves the host's game group through its native player, // which a virtual host lacks, so the host would never enter the queue. if (const auto f = find_field(p->type, field_party_only); f && f->size == 1) *reinterpret_cast(at + f->offset) = 0; - if (const auto f = find_field(p->type, field_instance); f && f->kind == kind_string) assign_string(at + f->offset, ""); + if (const auto f = find_field(p->type, field_instance); f && f->kind == kind_string) assign_string(at + f->offset, job.instance); e.set_u32(field_number_of_events, 1); // A plugin's drop: its parameters, as the stream other players' copies are spawned from. if (job.token && job.position) { @@ -1563,8 +1564,22 @@ void observe_throwdown_send(std::uint32_t hash, Address type, Address payload) n ThrowdownLocalAction action{ThrowdownLocalAction::Kind::exited}; if (const auto f = find_field(type, field_is_cancelling); f && f->size == 1) action.cancelling = read(payload + f->offset) != 0; - // Leaving a plugin mode's flag placement: not a drop to relay. - if (custom_throwdowns::placement_active()) { custom_throwdowns::observe_exit(action.cancelling); return; } + // Leaving a plugin mode's flag placement. A flag that is down is offered to the other + // players as that mode's (throwdown_relay.cpp), never as a Spot Battle. + if (custom_throwdowns::placement_active()) { + custom_throwdowns::observe_exit(action.cancelling); + const auto offer = custom_throwdowns::flag_offer(); + if (!offer) return; + { + std::lock_guard lock(l.mutex); + action.series = l.series; action.mmid = l.hosted_mmid; + action.placement = l.params; action.settings = l.host_params; + } + if (action.mmid != offer->mmid) return; + action.mode = offer->key; action.flag = offer->flag; action.mode_players = offer->players; + throwdown_relay_local(std::move(action)); + return; + } { std::lock_guard lock(l.mutex); action.series = l.series; action.mmid = l.hosted_mmid; @@ -2081,11 +2096,13 @@ bool prepare_throwdown_injection() noexcept { return false; } bool queue_throwdown_spawn(std::uint64_t token, std::uint32_t host, const std::string& series, - const std::vector& placement, const std::vector& settings) noexcept { + const std::vector& placement, const std::vector& settings, + const std::string& instance, std::uint32_t capacity) noexcept { if (!token || !host || placement.empty()) return false; try { Job job{Job::Kind::spawn, host}; job.token = token; job.series = series; job.placement = placement; job.settings = settings; + job.instance = instance; job.max_players = capacity; return queue_relay_job(std::move(job), event_debug_spawn); } catch (...) { return false; } } diff --git a/Extension/Throwdowns/throwdown_lab.h b/Extension/Throwdowns/throwdown_lab.h index 60840c86..1c78130b 100644 --- a/Extension/Throwdowns/throwdown_lab.h +++ b/Extension/Throwdowns/throwdown_lab.h @@ -43,8 +43,11 @@ std::string challenge_lab_command(std::string_view arguments); bool prepare_throwdown_injection() noexcept; // DebugSpawnThrowdownWithParams hosted by `host`; the queue's MMID comes back through // throwdown_relay_spawned(token, ...). +// `instance` names the drop's flag (InstanceId, a plugin mode's ReSkate.; empty: the +// series' own), and `capacity` holds its queue at that size (0: as the settings say). bool queue_throwdown_spawn(std::uint64_t token, std::uint32_t host, const std::string &series, - const std::vector &placement, const std::vector &settings) noexcept; + const std::vector &placement, const std::vector &settings, + const std::string &instance = {}, std::uint32_t capacity = 0) noexcept; // A plain `series` queue at `at`, hosted by the local player (a plugin's drop): its MMID comes back // through throwdown_relay_spawned(token, ...), its parameters through throwdown_relay_host_placement. bool queue_throwdown_host(std::uint64_t token, const std::string &series, const std::array &at, std::uint32_t most_players) noexcept; diff --git a/Extension/Throwdowns/throwdown_relay.cpp b/Extension/Throwdowns/throwdown_relay.cpp index 062de4eb..a282a8c2 100644 --- a/Extension/Throwdowns/throwdown_relay.cpp +++ b/Extension/Throwdowns/throwdown_relay.cpp @@ -1,4 +1,5 @@ #include "throwdown_relay.h" +#include "custom_modes.h" #include "throwdown_lab.h" #include "throwdown_wire.h" #include "native_throwdowns.h" @@ -92,6 +93,14 @@ struct ChallengePlan { std::optional leader_start; // the hook's answer to a leader start, for the tick }; +// A plugin throwdown mode a drop is the flag of (ThrowdownMessage::Kind::mode). +struct ModeTag { + std::string key, flag; // "plugin/mode"; the InstanceId suffix of its flag, or empty + std::uint32_t players{}; +}; +// Every machine holds a plugin mode's flag copy open (it never fills and starts a Spot Battle): +// the mode's own player count decides who may still join. +constexpr std::uint32_t mode_queue_capacity = 10; // The local player's own drop. struct Hosted { std::uint32_t id{}; // its MMID @@ -103,6 +112,7 @@ struct Hosted { // A plugin's drop (throwdown_relay_host): only these may join, and it starts once all have. std::vector invited; bool plugin{}, start_asked{}; + std::optional mode; // the flag of a plugin's throwdown mode }; // A plugin's drop on its way: spawned by the lab, its MMID not known yet. struct PluginSpawn { @@ -121,6 +131,7 @@ struct Mirror { std::uint32_t mmid{}, max_players{}; bool joined{}; // the local player is in it std::optional> start; // the leader started before the copy existed + std::optional mode; // the flag of a plugin's throwdown mode }; const char *state_name(Mirror::State s) { switch (s) { @@ -173,6 +184,8 @@ struct Relay { std::string host_status; // under `mutex` // Drops shown only to chosen players: who may see each (an invite before its offer). std::map> invites; + std::map mode_tags; // plugin modes' flags: a mode message before each offer + std::map> mode_players; // own mode flag's players; under `mutex` std::map mirrors; std::optional running; std::string running_series; @@ -289,8 +302,14 @@ void send_offer(Relay &r, std::uint64_t now) { invite.order = h.invited; send(r, invite); } + if (h.mode) { + auto mode = message(Kind::mode, r.local, h.id); + mode.challenge = h.mode->key; mode.series = h.mode->flag; mode.value = static_cast(h.mode->players); + send(r, mode); + } auto m = message(Kind::offer, r.local, h.id); - m.series = h.series; m.placement = h.placement; m.settings = h.settings; m.order = h.members; + m.series = h.mode ? std::string(mode_series) : h.series; + m.placement = h.placement; m.settings = h.settings; m.order = h.members; const auto before = r.outgoing.size(); send(r, m); if (r.outgoing.size() == before) { @@ -300,6 +319,27 @@ void send_offer(Relay &r, std::uint64_t now) { h.next_offer = now + offer_interval_ms; } // The drop is gone without having started (cancelled, left, replaced, world change). +// News for the plugin whose mode a flag is (custom_modes.h). +void mode_news(const ModeTag &mode, std::string action, std::uint64_t leader, bool host, std::uint64_t player = 0, + std::vector players = {}) { + custom_throwdowns::News news; + news.action = std::move(action); + news.leader = leader; + news.host = host; + news.player = player; + news.players = std::move(players); + custom_throwdowns::post(mode.key, std::move(news)); +} +void publish_mode_players(Relay &r) { + std::map> players; + if (r.hosted && r.hosted->mode) { + auto &list = players[r.hosted->id]; + list.push_back(r.local); + list.insert(list.end(), r.hosted->members.begin(), r.hosted->members.end()); + } + std::lock_guard lock(r.mutex); + r.mode_players = std::move(players); +} void close_hosted(Relay &r) { if (r.hosted && r.hosted->relayed && !r.hosted->started) { send(r, message(Kind::close, r.local, r.hosted->id)); @@ -480,7 +520,8 @@ void on_local(Relay &r, ThrowdownLocalAction &a, std::uint64_t now) { if (!r.hosted || r.hosted->id != a.mmid) { close_hosted(r); Hosted h; - h.id = a.mmid; h.series = a.series; h.relayed = relayed_mode(a.series); + h.id = a.mmid; h.series = a.series; h.relayed = relayed_mode(a.series) || !a.mode.empty(); + if (!a.mode.empty()) h.mode = ModeTag{a.mode, a.flag, std::clamp(a.mode_players, 1, mode_queue_capacity)}; r.hosted = std::move(h); if (r.hosted->relayed) note("your {} (MMID {:#x}) is shown to the other players.", a.series, a.mmid); @@ -502,9 +543,12 @@ void on_local(Relay &r, ThrowdownLocalAction &a, std::uint64_t now) { m.joined = true; send(r, message(Kind::join, key.leader, key.id)); note("you joined a linked {} (MMID {:#x}).", m.series, m.mmid); + if (m.mode) mode_news(*m.mode, "joined", key.leader, false, r.local); } break; case Local::left: + for (const auto &[key, m] : r.mirrors) + if (m.joined && m.mode) mode_news(*m.mode, "left", key.leader, false, r.local); leave_mirrors(r, 0); if (r.hosted && !r.hosted->started) close_hosted(r); // the host leaving destroys its queue break; @@ -513,6 +557,8 @@ void on_local(Relay &r, ThrowdownLocalAction &a, std::uint64_t now) { std::vector order{r.local}; order.insert(order.end(), r.hosted->members.begin(), r.hosted->members.end()); send_start(r, std::move(order)); + // A mode's flag is done with: its plugin runs the match (custom_placement.cpp). + if (r.hosted->mode) r.hosted.reset(); } break; case Local::destroy_requested: @@ -648,7 +694,7 @@ void on_beacon_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std::uint64_t now) { if (!r.peers.contains(sender)) return; if (m.kind == Kind::beacon) { on_beacon_message(r, sender, m); return; } - if (m.kind >= Kind::challenge_start && m.kind != Kind::invite) { on_challenge_message(r, sender, m, now); return; } + if (m.kind >= Kind::challenge_start && m.kind != Kind::invite && m.kind != Kind::mode) { on_challenge_message(r, sender, m, now); return; } const Key key{m.leader, m.id}; const auto mirror = r.mirrors.find(key); switch (m.kind) { @@ -659,8 +705,20 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std:: if (!contains(m.order, r.local) && mirror != r.mirrors.end()) destroy_mirror(r, mirror->second, now); return; } + case Kind::mode: { + if (m.leader != sender) return; + r.mode_tags[key] = ModeTag{m.challenge, m.series, static_cast(m.value)}; + return; + } case Kind::offer: { - if (m.leader != sender || !relayed_mode(m.series)) return; + if (m.leader != sender) return; + std::optional mode; + if (m.series == mode_series) { + // A plugin mode's flag: shown only where that plugin has the mode too. + const auto tag = r.mode_tags.find(key); + if (tag == r.mode_tags.end() || !custom_throwdowns::find(tag->second.key)) return; + mode = tag->second; + } else if (!relayed_mode(m.series)) return; if (const auto invite = r.invites.find(key); invite != r.invites.end() && !contains(invite->second, r.local)) return; if (mirror != r.mirrors.end()) { // A copy removed here (full, or left) stays removed while the leader keeps offering it. @@ -674,15 +732,18 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std:: for (auto &[other, copy] : r.mirrors) if (other.leader == m.leader) destroy_mirror(r, copy, now); Mirror copy; - copy.series = m.series; copy.placement = m.placement; copy.settings = m.settings; + copy.series = mode ? std::string(spot_battle_mode) : m.series; + copy.placement = m.placement; copy.settings = m.settings; + copy.mode = mode; for (const auto player : m.order) if (player != r.local && player != m.leader && !contains(copy.members, player)) copy.members.push_back(player); r.mirrors.emplace(key, std::move(copy)); - note("{} is offered by another player (their MMID {:#x}); showing it here.", m.series, m.id); + note("{} is offered by another player (their MMID {:#x}); showing it here.", mode ? mode->key : m.series, m.id); return; } case Kind::close: if (m.leader != sender || mirror == r.mirrors.end()) return; + if (mirror->second.mode && mirror->second.joined) mode_news(*mirror->second.mode, "closed", key.leader, false, r.local); destroy_mirror(r, mirror->second, now); return; case Kind::join: @@ -690,9 +751,12 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std:: if (!r.hosted || r.hosted->id != m.id || r.hosted->started || !r.hosted->relayed || contains(r.hosted->members, sender) || (!r.hosted->invited.empty() && !contains(r.hosted->invited, sender))) return; + // A mode's flag takes as many players as the mode does. + if (r.hosted->mode && 1 + r.hosted->members.size() >= r.hosted->mode->players) return; r.hosted->members.push_back(sender); add_to_hosted(r); note("a player joined your {} (virtual id {:#x}).", r.hosted->series, virtual_id(r, sender)); + if (r.hosted->mode) mode_news(*r.hosted->mode, "joined", r.local, true, sender); } else if (mirror != r.mirrors.end()) { mirror_member(r, mirror->second, sender, now); } @@ -714,8 +778,10 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std:: if (r.in_world) queue_throwdown_remove(virtual_id(r, sender)); } }; - if (m.leader == r.local && r.hosted && r.hosted->id == m.id && !r.hosted->started) + if (m.leader == r.local && r.hosted && r.hosted->id == m.id && !r.hosted->started) { + if (r.hosted->mode && contains(r.hosted->members, sender)) mode_news(*r.hosted->mode, "left", r.local, true, sender); remove(r.hosted->members, r.hosted->added); + } else if (mirror != r.mirrors.end() && mirror->second.state != Mirror::State::started) remove(mirror->second.members, mirror->second.added); return; @@ -723,6 +789,13 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std:: case Kind::start: { if (m.leader != sender || mirror == r.mirrors.end()) return; auto © = mirror->second; + if (copy.mode) { + // The leader's plugin starts its match: the copy's flag goes, and a joined player's + // plugin hears who plays. + if (copy.joined) mode_news(*copy.mode, "start", key.leader, false, r.local, m.order); + destroy_mirror(r, copy, now); + return; + } if (!copy.joined) { destroy_mirror(r, copy, now); return; } if (copy.state == Mirror::State::open) start_mirror(r, key, copy, m.order); else if (copy.state == Mirror::State::waiting || copy.state == Mirror::State::spawning) copy.start = m.order; @@ -800,7 +873,7 @@ void on_spawned(Relay &r, std::uint64_t token, std::uint32_t mmid, std::uint32_t return; } m.mmid = mmid; - m.max_players = capacity; + m.max_players = m.mode ? m.mode->players : capacity; if (m.state == Mirror::State::gone) { // closed while it was spawning queue_throwdown_destroy(mmid); return; @@ -1120,6 +1193,7 @@ void reset(Relay &r, bool destroy_copies, std::uint64_t now) { for (auto &[key, m] : r.mirrors) destroy_mirror(r, m, now); r.mirrors.clear(); r.invites.clear(); + r.mode_tags.clear(); r.plugin_spawn.reset(); r.spawn_refused.clear(); r.declined.clear(); @@ -1151,7 +1225,12 @@ void update_peers(Relay &r, const ThrowdownRelayInput &in, std::uint64_t now) { if (r.in_world) queue_throwdown_remove(id); } }; - if (r.hosted && !r.hosted->started) drop(r.hosted->members, r.hosted->added); + if (r.hosted && !r.hosted->started) { + if (r.hosted->mode && contains(r.hosted->members, player)) mode_news(*r.hosted->mode, "left", r.local, true, player); + drop(r.hosted->members, r.hosted->added); + } + for (auto &[key, m] : r.mirrors) + if (key.leader == player && m.joined && m.mode) mode_news(*m.mode, "closed", key.leader, false, r.local); for (auto &[key, m] : r.mirrors) { if (key.leader == player) destroy_mirror(r, m, now); else if (m.state != Mirror::State::started) drop(m.members, m.added); @@ -1336,7 +1415,9 @@ void maintain(Relay &r, std::uint64_t now) { const auto host = virtual_id(r, key.leader); if (!host) { m.state = Mirror::State::gone; m.gone_at = now; continue; } m.token = r.next_token++; - if (queue_throwdown_spawn(m.token, host, m.series, m.placement, m.settings)) { + if (queue_throwdown_spawn(m.token, host, m.series, m.placement, m.settings, + m.mode && !m.mode->flag.empty() ? "ReSkate." + m.mode->flag : std::string{}, + m.mode ? mode_queue_capacity : 0)) { m.state = Mirror::State::spawning; m.spawning_since = now; } @@ -1523,6 +1604,7 @@ std::vector> tick_throwdown_relay(std::uintptr_t base, update_celebration(r, now); publish_challenge_plan(r, input); publish_order(r); + publish_mode_players(r); follow_turn(r); // Who is up as the local client shows it: it changes seconds before the turn // start teleport, so that teleport already knows whether to land on foot. @@ -1548,6 +1630,12 @@ void receive_throwdown_relay(std::uint64_t sender, std::span if (r.inbox.size() < 256) r.inbox.emplace_back(sender, std::vector(message.begin(), message.end())); } +std::vector throwdown_relay_mode_players(std::uint32_t mmid) { + auto &r = relay(); + std::lock_guard lock(r.mutex); + const auto found = r.mode_players.find(mmid); + return found == r.mode_players.end() ? std::vector{} : found->second; +} void throwdown_relay_local(ThrowdownLocalAction action) noexcept { try { auto &r = relay(); diff --git a/Extension/Throwdowns/throwdown_relay.h b/Extension/Throwdowns/throwdown_relay.h index ebd23a96..5ebaacc3 100644 --- a/Extension/Throwdowns/throwdown_relay.h +++ b/Extension/Throwdowns/throwdown_relay.h @@ -101,8 +101,15 @@ struct ThrowdownLocalAction { std::string challenge; // challenge_started: the challenge's Id std::vector criteria, indexes; // challenge_attempt std::array location{}; // beacon_placed: the LinearTransform + // exited: the drop is a plugin mode's flag (custom_modes.h): its "plugin/mode", its flag + // (empty: Spot Battle's) and how many players it takes. + std::string mode, flag; + std::uint32_t mode_players{}; }; void throwdown_relay_local(ThrowdownLocalAction action) noexcept; +// The players in the local player's own plugin mode flag `mmid`: the local player first, then +// the others in the order they joined. Any thread. +std::vector throwdown_relay_mode_players(std::uint32_t mmid); // A mirror spawn queued with `token` ran on the local server (mmid 0: it did not). void throwdown_relay_spawned(std::uint64_t token, std::uint32_t mmid, std::uint32_t capacity) noexcept; // UIPlayerInfo model of a relayed player's virtual id (rows, host card, name), or 0. diff --git a/Extension/Throwdowns/throwdown_wire.cpp b/Extension/Throwdowns/throwdown_wire.cpp index 246841aa..903fbcd4 100644 --- a/Extension/Throwdowns/throwdown_wire.cpp +++ b/Extension/Throwdowns/throwdown_wire.cpp @@ -15,6 +15,14 @@ bool valid_series(const std::string &series) noexcept { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; }); } +// A plugin mode's key: the plugin's folder name, a /, and the mode's id. +bool valid_mode_key(const std::string &key) noexcept { + return key.size() >= 3 && key.size() <= max_challenge_id && key.find('/') != std::string::npos && + std::all_of(key.begin(), key.end(), [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || + c == '.' || c == '/' || c == ' '; + }); +} bool valid_challenge_id(const std::string &id) noexcept { return !id.empty() && id.size() <= max_challenge_id && std::all_of(id.begin(), id.end(), [](char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.'; @@ -84,6 +92,8 @@ bool valid_throwdown(const ThrowdownMessage &m) noexcept { case ThrowdownMessage::Kind::beacon: return m.leader && std::all_of(m.location.begin(), m.location.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1e6f; }); + case ThrowdownMessage::Kind::mode: + return valid_mode_key(m.challenge) && (m.series.empty() || valid_series(m.series)) && m.value >= 1 && m.value <= 10; } return false; } @@ -137,6 +147,13 @@ std::vector encode_throwdown(const ThrowdownMessage &m) { if (m.add) for (const auto v : m.location) w.integer(std::bit_cast(v), 4); break; + case ThrowdownMessage::Kind::mode: + w.integer(m.challenge.size(), 1); + w.bytes.insert(w.bytes.end(), m.challenge.begin(), m.challenge.end()); + w.integer(m.series.size(), 1); + w.bytes.insert(w.bytes.end(), m.series.begin(), m.series.end()); + w.integer(static_cast(m.value), 1); + break; default: break; } return std::move(w.bytes); @@ -147,7 +164,7 @@ std::optional decode_throwdown(std::span b Reader r{bytes}; ThrowdownMessage m; const auto kind = r.integer(1); - if (kind < 1 || kind > 16) return {}; + if (kind < 1 || kind > 17) return {}; m.kind = static_cast(kind); m.leader = r.integer(8); m.id = static_cast(r.integer(4)); @@ -216,6 +233,18 @@ std::optional decode_throwdown(std::span b for (auto &v : m.location) v = std::bit_cast(static_cast(r.integer(4))); break; } + case ThrowdownMessage::Kind::mode: { + const auto text = [&](std::size_t limit, std::string &out) { + const auto length = r.integer(1); + if (!r.ok || length > limit || length > bytes.size() - r.at) return false; + out.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); + r.at += static_cast(length); + return true; + }; + if (!text(max_challenge_id, m.challenge) || !text(max_throwdown_series, m.series)) return {}; + m.value = static_cast(r.integer(1)); + break; + } default: break; } if (!r.ok || r.at != bytes.size() || !valid_throwdown(m)) return {}; diff --git a/Extension/Throwdowns/throwdown_wire.h b/Extension/Throwdowns/throwdown_wire.h index 76210a79..c72689fd 100644 --- a/Extension/Throwdowns/throwdown_wire.h +++ b/Extension/Throwdowns/throwdown_wire.h @@ -4,6 +4,7 @@ #include #include #include +#include #include // Messages that link the players' own throwdowns (analysis/throwdowns-native-mp.md). @@ -32,7 +33,12 @@ struct ThrowdownMessage { beacon = 15, // A drop for chosen players only (a plugin's, reskate.throwdowns.host): order = who may see // and join the leader's drop `id`; sent before each of its offers. - invite = 16 + invite = 16, + // The leader's drop `id` is the flag of a plugin's throwdown mode (custom_modes.h), sent + // before each of its offers: challenge = the mode ("plugin/mode"), series = its flag (may + // be empty), value = how many players it takes. Its offers' series is mode_series, which + // a ReSkate without plugin modes does not show. + mode = 17 }; Kind kind = Kind::offer; std::uint64_t leader{}; // Steam ID of the player hosting the throwdown @@ -55,6 +61,7 @@ struct ThrowdownMessage { std::array location{}; bool operator==(const ThrowdownMessage &) const = default; }; +inline constexpr std::string_view mode_series = "ReSkateMode"; constexpr std::size_t max_throwdown_series = 48, max_throwdown_params = 1536, max_throwdown_order = 32, max_throwdown_boards = 16, max_challenge_id = 96, max_challenge_criteria = 64, challenge_criteria_size = 0x14, max_challenge_players = 4; diff --git a/Extension/UI/NativeMenu/throwdown_menu.cpp b/Extension/UI/NativeMenu/throwdown_menu.cpp index ac38fbb9..6c585b02 100644 --- a/Extension/UI/NativeMenu/throwdown_menu.cpp +++ b/Extension/UI/NativeMenu/throwdown_menu.cpp @@ -571,6 +571,16 @@ Value picker_logo(const Context& c, Value tile, Value cat, bool stock, const std c.set(c.field(tile, 0x868043e1), std::array{424.f, 424.f}); return logo; } +// The stock throwdowns' places (S.K.A.T.E., Spot Battle, Skate Jam), each the +// first mode that replaces it, or none. +std::array stock_places(const std::vector& modes) { + std::array places{}; + constexpr std::array names{"skate", "spot_battle", "jam"}; + for (const auto& mode : modes) + for (unsigned i = 0; i < 3; ++i) + if (!places[i] && mode.replaces == names[i]) places[i] = &mode; + return places; +} // SmokeyDawg58's 4-column logo grid in place of the three stock cards. void logo_picker(const Context& c, const std::vector& modes) { auto& s = state(); @@ -628,13 +638,18 @@ void logo_picker(const Context& c, const std::vector& m "Throwdown grid tile model differs."); refs.push_back(inner.handle); }; - for (const auto& tile : s.picker_stock) append_tile(tile); - for (const auto& mode : modes) { + const auto stock = stock_places(modes); + const auto mode_tile = [&](const custom_throwdowns::Mode& mode) { auto& card = s.mode_cards.at(mode.key()); if (!card.logo.handle) card.logo = picker_logo(c, card.tile, card.category, mode.art.empty(), mode.name); mode_art(c, std::array{c.field(card.logo, 0x6e469d2a)}, mode.art); append_tile(card.tile); - } + }; + for (unsigned i = 0; i < 3; ++i) + if (stock[i]) mode_tile(*stock[i]); + else append_tile(s.picker_stock[i]); + for (const auto& mode : modes) + if (std::none_of(stock.begin(), stock.end(), [&](const auto* m) { return m == &mode; })) mode_tile(mode); unsigned count{}, stride{}; const auto previous = c.array(c.field(s.picker_grid, 0xf2c90867), 64, count, stride); require(stride == sizeof(Handle), "Throwdown grid item handle schema differs."); @@ -653,8 +668,12 @@ void logo_picker(const Context& c, const std::vector& m void append_cards(const Context& c, const std::vector& modes) { auto& s = state(); std::vector rows; - for (const auto& o : s.originals) rows.push_back({0, o.tile.handle}); - for (const auto& mode : modes) rows.push_back({0, s.mode_cards.at(mode.key()).tile.handle}); + const auto stock = stock_places(modes); + for (unsigned i = 0; i < 3; ++i) + rows.push_back({0, stock[i] ? s.mode_cards.at(stock[i]->key()).tile.handle : s.originals[i].tile.handle}); + for (const auto& mode : modes) + if (std::none_of(stock.begin(), stock.end(), [&](const auto* m) { return m == &mode; })) + rows.push_back({0, s.mode_cards.at(mode.key()).tile.handle}); const bool scale = !modes.empty(); if (scale != s.cards_scaled) { s.cards_scaled = scale; From ae05341bb8508683f8d9455d9073c77ff682e315 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Mon, 19 Oct 2026 19:04:26 -0500 Subject: [PATCH 2/9] Plugins: SmokeyDawg58's ReSkate_Throwdown plugin, his ten modes on the Throwdowns page His mod ships its assets and this plugin together. Each mode has its name, description, logo, flag, players, timer and setup rows as his fork had them; lobby events are said in chat. Their gameplay is ported mode by mode; until then Start says the mode is not playable yet. The mod's assets stay out of the repository. --- .gitignore | 5 +- .../plugin/main.lua | 40 ++++++++++ .../plugin/modes.lua | 73 +++++++++++++++++++ .../reskate-plugin.json | 6 ++ 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/main.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/modes.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/reskate-plugin.json diff --git a/.gitignore b/.gitignore index a31f8b1c..13abe262 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,8 @@ analysis/raw/**/*.res # Drone model sources (assets/models/fpv/CREDITS.md): large; only the prepared drone.glb is kept. /assets/models/fpv/dji_avata2.glb /assets/models/fpv/fpv_drone.glb -/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown +# SmokeyDawg58's ReSkate_Throwdown mod: its assets stay out (his to publish); its plugin is kept. +/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/* +!/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/ +!/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/reskate-plugin.json /contrib/plugins/*.fbmod diff --git a/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/main.lua b/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/main.lua new file mode 100644 index 00000000..a7ef5847 --- /dev/null +++ b/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/main.lua @@ -0,0 +1,40 @@ +-- SmokeyDawg58's ReSkate Throwdowns as a plugin mod: his modes on the game's Throwdowns page, with +-- the logos, flags, gates and HUD art this mod ships. A flag that is down is shown to the other +-- players in the session who have this mod, who join it with the game's own Join. The modes' +-- gameplay comes over from his fork one at a time as ReSkate gains what each needs; until a +-- mode's has, its Start says so. +local modes = require("modes") +local ported = {} -- mode id -> the module that runs it, once it is ported + +for _, mode in ipairs(modes) do + local ok, why = reskate.throwdowns.mode(mode) + if not ok then reskate.log("could not add " .. mode.name .. ": " .. tostring(why)) end +end + +local names = {} +for _, mode in ipairs(modes) do names[mode.id] = mode.name end + +local function player_name(id) + if not id then return "someone" end + for _, player in ipairs(reskate.session.players()) do + if tostring(player.id) == id then return player.name end + end + return "a player" +end + +reskate.on("throwdown_mode", function(e) + local runner = ported[e.mode] + if runner then return runner.event(e) end + local mode = names[e.mode] or e.mode + if e.action == "joined" then + reskate.chat.notice(e.host and (player_name(e.player) .. " joined your " .. mode .. ".") + or ("You joined " .. player_name(e.leader) .. "'s " .. mode .. ".")) + elseif e.action == "left" and e.host then + reskate.chat.notice(player_name(e.player) .. " left your " .. mode .. ".") + elseif e.action == "closed" then + reskate.chat.notice(player_name(e.leader) .. " took their " .. mode .. " flag down.") + elseif e.action == "start" then + if e.host then reskate.throwdowns.release_flag() end + reskate.chat.notice(mode .. " is not playable in this version yet (" .. #(e.players or {}) .. " player(s) were ready).") + end +end) diff --git a/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/modes.lua b/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/modes.lua new file mode 100644 index 00000000..9d9f5d9b --- /dev/null +++ b/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/modes.lua @@ -0,0 +1,73 @@ +-- SmokeyDawg58's throwdown modes, as his ReSkate fork set them up (reskate-custom-mods, +-- Extension/Modes/mode_rules.cpp and the native setup page in one_up_menu.cpp). Each has its logo and +-- its flag in this mod's assets; the setup page shows its players, timer and rows. +local logos = "UI/ReSkate/Throwdowns/" +local function area(text) return {id = "area", kind = "button", caption = "Area", text = text or "Set optional area"} end +local function duration(caption, default) + return {caption = caption or "Duration", min = 30, max = 3600, step = 10, default = default or 300} +end +local players = {min = 1, max = 6, default = 4} + +return { + { + id = "skate", name = "R.E.S.K.A.T.E.", flag = "skate", replaces = "skate", + art = logos .. "img_skate_Reskate_1024", + description = "Set a trick, everyone copies it or takes a letter. Spell R.E.S.K.A.T.E. and you're out; last one standing wins.", + players = players, + timer = {caption = "Turn Timer", min = 10, max = 120, step = 5, default = 30}, + rows = { + {id = "letters", caption = "Letters", min = 1, max = 7, default = 7}, + {id = "tricks", kind = "choice", caption = "Tricks", options = {"All tricks", "Flips only", "Grinds only", "Grabs only"}}, + {id = "zone", kind = "choice", caption = "Trick Zone", options = {"Anywhere", "Small", "Medium", "Large", "Huge", "Custom"}}, + }, + }, + { + id = "oneup", name = "1-UP", flag = "oneup", art = "UI/ReSkate/OneUp/img_OneUp_Blue_1024", + description = "Beat the previous turn total. Three penalties and you're out. Last skater wins!", + players = players, + timer = {caption = "Turn", min = 10, max = 120, step = 10, default = 20}, + }, + { + id = "race", name = "Deathrace", flag = "race", art = logos .. "img_race_Placeholder", + description = "Cross required gates in order; gray guides are optional. Reach the red finish. Countdown only in the final 30 seconds.", + players = players, + timer = {caption = "Finish deadline", min = 30, max = 3600, step = 30, default = 180}, + }, + { + id = "jam", name = "Spot Jam", flag = "jam", art = logos .. "img_jam_Placeholder", + description = "Land lines inside the area. Every line adds to your score; highest total wins.", + players = players, timer = duration(), rows = {area()}, + }, + { + id = "meat", name = "Hall of Meat", flag = "meat", art = logos .. "img_meat_Placeholder", + description = "Bail as hard as you can. Speed, drops and tumbling score; most meat wins.", + players = players, timer = duration(), rows = {area()}, + }, + { + id = "domination", name = "Domination", flag = "domination", art = logos .. "img_domination_Placeholder", + description = "Take spots with your best line there. Every second you hold one scores.", + players = players, timer = duration(), + rows = {{id = "spots", kind = "button", caption = "Capture Spots", text = "Place spots"}}, + }, + { + id = "graffiti", name = "Graffiti", flag = "graffiti", art = logos .. "img_graffiti_Placeholder", + description = "Grind it, manual it, gap it: what you skate takes your colour. A bigger line steals it. Most tags wins.", + players = players, timer = duration(), rows = {area()}, + }, + { + id = "tag", name = "Skate Tag", flag = "tag", art = logos .. "img_tag_Placeholder", + description = "One player is it: get close to tag someone else. No tag-backs. Least time spent it wins.", + players = players, timer = duration(), rows = {area()}, + }, + { + id = "infection", name = "Infection", flag = "infection", art = logos .. "img_infection_Placeholder", + description = "One player starts infected. Everyone they reach is infected too and joins the hunt. Survive longest to win.", + players = players, timer = duration(), rows = {area()}, + }, + { + id = "hide", name = "Hide & Seek", flag = "hide", art = logos .. "img_hide_Placeholder", + description = "Hide while the seeker can't see. Then they hunt: whoever is found seeks too. Stay hidden longest to win.", + players = players, timer = duration(), + rows = {{id = "hide_time", caption = "Hide Timer", min = 10, max = 120, step = 5, default = 30}, area()}, + }, +} diff --git a/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/reskate-plugin.json b/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/reskate-plugin.json new file mode 100644 index 00000000..f18e7b6d --- /dev/null +++ b/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/reskate-plugin.json @@ -0,0 +1,6 @@ +{ + "api": 1, + "entry": "plugin/main.lua", + "side": "client", + "permissions": ["throwdowns", "chat.local", "console", "session.read"] +} From e14064250ce2a7404bb2629bee355d9465da5999 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Tue, 20 Oct 2026 01:04:37 -0500 Subject: [PATCH 3/9] Merge: a plugin mod's resident assets stay when a map replaces their bundle, and the merge never writes into a mod's own folder reskate-plugin.json's "resident" list names the assets a mod's bundles keep (world objects and HUD widgets the game picks at run time, and their shader block depots), so they survive another mod replacing that bundle. Scanning now uses lexically_relative, so a mod folder that is a junction no longer yields ../ paths, and every output path is checked to be inside the merge output. Chunk metadata audit failures are fixed; the full merge audit passes. --- .../Vfs/Test/chunk_metadata_merge_tests.cpp | 9 +- .../Vfs/Test/mod_merge_added_assets_tests.cpp | 222 +++++++++++++++++- Engine/Vfs/mod_list.cpp | 83 ++++++- Engine/Vfs/mod_list.h | 27 +++ Engine/Vfs/mod_merge.cpp | 25 +- Engine/Vfs/mod_merge_cas_store.cpp | 2 +- Engine/Vfs/mod_merge_combine.cpp | 102 +++++++- Engine/Vfs/mod_merge_files.cpp | 14 +- Engine/Vfs/mod_merge_internal.h | 21 +- Engine/Vfs/mod_merge_overrides.cpp | 105 ++++++++- Engine/Vfs/mod_merge_stamp.cpp | 4 +- 11 files changed, 576 insertions(+), 38 deletions(-) diff --git a/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp b/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp index 67aab2d4..9bed834a 100644 --- a/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp +++ b/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp @@ -143,9 +143,14 @@ int main() { // One mod's copy alone is still written in the game's form: its additions go where their guids put them. const std::vector oneMod{chunk(0x50, 1, 0, 0), chunk(0x30, 1, 1, 1), chunk(0x70, 1, 2, 2), chunk(0x10, 1, 3, 3), chunk(0x60, 1, 4, 4), chunk(0x20, 1, 5), chunk(0x80, 1, 6)}; - check(read_list(merge_chunk_metadata(lists, oneMod, gameList, shipped)) == + std::vector rows; + check(read_list(merge_chunk_metadata(lists, oneMod, gameList, shipped, &rows)) == std::vector{mesh, firstTexture, textureA, other, textureB, mesh, firstMesh}, "one mod's additions are put in guid order with the game's"); + // Where each chunk's record went, so a copy whose list this is reads its chunks' records there. + check(rows == std::vector{3, 2, 5, 0, 4, 1, 6}, "each chunk's record is said to be at its place by guid"); + (void)merge_chunk_metadata(lists, untouched, gameList, shipped, &rows); + check(rows == std::vector{2, 1, 4, 0, 3}, "and so is each in the game's own list, which comes back as it is"); // A newer tool writes the replaced texture's own hash with its first mip; the first mip is what counts. auto newer = game; newer[4] = Record{textureB.owner, 0}; @@ -172,6 +177,8 @@ int main() { const std::vector modsOnly{list_of({firstTexture, firstMesh}), list_of({firstTexture, firstMesh, secondTexture})}; const std::vector theirs{chunk(0x90, 1, 0), chunk(0x20, 1, 1), chunk(0x40, 1, 2)}; check(merge_chunk_metadata(modsOnly, theirs) == modsOnly[1], "a bundle that is one mod's copy alone keeps that copy's list"); + (void)merge_chunk_metadata(modsOnly, theirs, ChunkRecord::none, {}, &rows); + check(rows == std::vector{0, 1, 2}, "and has each record where its chunk is listed"); const std::vector both{chunk(0x90, 0, 0), chunk(0x20, 0, 1), chunk(0x40, 1, 2)}; check(read_list(merge_chunk_metadata(modsOnly, both)) == std::vector{firstMesh, secondTexture, firstTexture}, "and otherwise each chunk's record from the copy it came in, by guid like any other"); diff --git a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp index f52c1ec3..310e531e 100644 --- a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp +++ b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp @@ -26,8 +26,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -164,7 +166,61 @@ fb::Sha1 sha(const std::string& name, unsigned version) { struct Ebx { std::string name; unsigned version; Bytes payload; }; struct Resource { std::string name; Bytes payload; }; -fb::BinaryBundle manifest_of(const std::vector& assets, const std::vector& resources) { +// ---- Chunk metadata: one record a chunk, whose it is and a texture's first mip --------------------- +struct Record { + std::uint64_t owner{}; + std::uint32_t firstMip{}; + bool operator==(const Record&) const = default; +}; +struct Chunk { fb::Guid guid; Bytes payload; Record record; }; +db::Node number(unsigned type, const char* name, std::uint64_t value, std::size_t size) { + db::Node node; + node.type = type; + node.named = true; + node.name = name; + for (std::size_t byte = 0; byte < size; ++byte) node.owned.push_back(static_cast(value >> (8 * byte))); + return node; +} +// The list as the game writes it: the chunks' records in the order of their guids. +Bytes chunk_list(std::vector chunks) { + std::ranges::sort(chunks, {}, &Chunk::guid); + db::Node list; + list.type = 1; + list.named = true; + list.name = "chunkMeta"; + list.terminated = true; + for (const auto& chunk : chunks) { + db::Node meta; + meta.type = 2; + meta.named = true; + meta.name = "meta"; + meta.terminated = true; + meta.children.push_back(number(8, "firstMip", chunk.record.firstMip, 4)); + db::Node row; + row.type = 2; + row.terminated = true; + row.children.push_back(number(9, "h64", chunk.record.owner, 8)); + row.children.push_back(std::move(meta)); + list.children.push_back(std::move(row)); + } + const auto written = db::write(list); + const auto* bytes = reinterpret_cast(written.data()); + return {bytes, bytes + written.size()}; +} +std::vector records_in(std::span list) { + const std::span bytes(reinterpret_cast(list.data()), list.size()); + std::vector records; + for (const auto& row : db::read(bytes, "chunk metadata", nullptr, {.unique_fields = false, .max_entries = bytes.size()}).children) { + Record record; + if (const auto* owner = row.field("h64"); owner && owner->payload().size() == 8) std::memcpy(&record.owner, owner->payload().data(), 8); + if (const auto* meta = row.field("meta")) + if (const auto* mip = meta->field("firstMip"); mip && mip->payload().size() == 4) std::memcpy(&record.firstMip, mip->payload().data(), 4); + records.push_back(record); + } + return records; +} + +fb::BinaryBundle manifest_of(const std::vector& assets, const std::vector& resources, const std::vector& chunks = {}) { fb::BinaryBundle manifest; for (const auto& asset : assets) { fb::BundleAsset entry; @@ -185,6 +241,16 @@ fb::BinaryBundle manifest_of(const std::vector& assets, const std::vector(chunk.payload.size()); + manifest.chunks.push_back(std::move(entry)); + } + if (!chunks.empty()) manifest.chunkMetadata = chunk_list(chunks); return manifest; } @@ -229,10 +295,28 @@ struct Fixture { fs::remove_all(root, ignored); } + // The game's copy of the bundle in another of its TOCs (a level's), in the game's archive. + void game(const std::string& toc, const std::vector& assets, const std::vector& chunks) { + const auto path = catalog.data_root / "Data" / "Win32" / "pkg" / "cas_01.cas"; + auto archive = read(path); + std::vector files; + const auto store = [&](const Bytes& bytes) { + files.push_back({{false, package, 1}, static_cast(archive.size()), static_cast(bytes.size())}); + archive.insert(archive.end(), bytes.begin(), bytes.end()); + }; + store(fb::write_binary_bundle(manifest_of(assets, {}, chunks))); + for (const auto& asset : assets) store(encoded(asset.payload)); + for (const auto& chunk : chunks) store(encoded(chunk.payload)); + write(path, archive); + const std::vector bundles{{bundle_name, fb::write_bundle_region(files), 1}}; + write(catalog.data_root / "Data" / "Win32" / fs::path(toc), fb::write_patch_toc(bundles)); + } + // A mod with its own copy of a bundle: the manifest first (raw), then every payload. void add(const std::string& name, bool levels, const std::string& toc, const std::vector& superbundles, - const std::vector& assets, const std::vector& resources = {}, const char* bundle = bundle_name) { - const auto listing = fb::write_binary_bundle(manifest_of(assets, resources)); + const std::vector& assets, const std::vector& resources = {}, const char* bundle = bundle_name, + const std::vector& chunks = {}) { + const auto listing = fb::write_binary_bundle(manifest_of(assets, resources, chunks)); Bytes archive(listing); std::vector files{{{true, package, 1}, 0, static_cast(listing.size())}}; const auto store = [&](const Bytes& payload) { @@ -242,6 +326,7 @@ struct Fixture { }; for (const auto& asset : assets) store(asset.payload); for (const auto& resource : resources) store(resource.payload); + for (const auto& chunk : chunks) store(chunk.payload); const std::vector bundles{{bundle, fb::write_bundle_region(files), 1}}; const auto directory = catalog.root / name; write(directory / "layout.toc", layout_toc(superbundles)); @@ -327,6 +412,94 @@ void carried_into_the_maps_copy(bool map_first, int baseline) { std::to_string(files) + " files, " + std::to_string(baseline) + " without the music mod)"); } +// A mod's resident list (reskate-plugin.json, read from its folder as the launcher reads it) keeps +// an asset nothing names, one its plugin picks at run time, with its additions: the map's copy of the +// bundle gets it too. Without the list it stays out (carried_into_the_maps_copy). +void resident_assets_go_with_the_additions(int baseline) { + Fixture fixture("resident"); + fixture.add("map", true, map_toc, {map_superbundle}, game_copy()); + fixture.add("music", false, shared_toc, {}, music_assets(), music_resources()); + const std::string manifest = std::string(R"({"api": 1, "entry": "plugin/main.lua", "resident": [{"bundle": ")") + bundle_name + + R"(", "roots": ["Test/Unrelated", "ui/menus/"]}]})"; + const auto bytes = std::as_bytes(std::span(manifest)); + write(fixture.catalog.root / "music" / mods::plugin_manifest_file, bytes); + std::vector notes; + const auto described = mods::describe_mod(fixture.catalog.root / "music", "music", notes); + expect(notes.empty() && described.resident.size() == 1 && mods::resident_root(described, "WIN32/test/shared", "test/unrelated") && + mods::resident_root(described, bundle_name, "ui/menus/a/b") && !mods::resident_root(described, bundle_name, "test/other") && + !mods::resident_root(described, second_bundle, "test/unrelated"), + "resident: the list is read, by bundle, a root ending in / a prefix"); + const std::string broken = R"({"api": 1, "entry": "plugin/main.lua", "resident": [{"roots": []}]})"; + write(fixture.catalog.root / "music" / mods::plugin_manifest_file, std::as_bytes(std::span(broken))); + notes.clear(); + expect(mods::describe_mod(fixture.catalog.root / "music", "music", notes).resident.empty() && notes.size() == 1, + "resident: a list without its bundle is a note, and keeps nothing"); + write(fixture.catalog.root / "music" / mods::plugin_manifest_file, bytes); + fixture.catalog.mods.back().resident = described.resident; + const auto report = mods::merge_mods(fixture.catalog); + expect(report.issue.empty() && report.built, "resident: the merge builds the patch\n" + describe(report)); + const auto files = fixture.merged_files(map_toc); + expect(files == baseline + 4, "resident: the map's copy gained the three named files and the resident one (" + std::to_string(files) + + " files, " + std::to_string(baseline) + " without the music mod)\n" + describe(report)); +} + +// The files under a folder, by path inside it, with their bytes. +std::map snapshot(const fs::path& folder) { + std::map files; + for (const auto& entry : fs::recursive_directory_iterator(folder)) + if (entry.is_regular_file()) files[entry.path().lexically_relative(folder).generic_string()] = read(entry.path()); + return files; +} + +// The music mod with a resident list, its Win32 folder a junction to a store elsewhere (a test +// root linking a mod's folders to an install's did this). Its files are where the folder shows +// them, Win32/...: the paths fs::relative gives through the link (../../../store/...) made the +// merge take the mod's own TOC for the game's and write the merged TOCs and archive links over +// the mod's real files, so the next merge read its bundles from the wrong archives and left the +// whole mod out ("Raw CAS block size does not match its header", 2026-10-20). +void linked_win32_stays_in_the_patch(int baseline) { + Fixture fixture("linked"); + fixture.add("map", true, map_toc, {map_superbundle}, game_copy()); + fixture.add("music", false, shared_toc, {}, music_assets(), music_resources()); + const auto directory = fixture.catalog.root / "music"; + const auto store = fixture.root / "store" / "music-Win32"; + fs::create_directories(store.parent_path()); + fs::rename(directory / "Win32", store); + const auto link = directory / "Win32"; + const bool linked = _wsystem((L"mklink /J \"" + link.wstring() + L"\" \"" + store.wstring() + L"\" >nul").c_str()) == 0; + expect(linked, "linked: a junction could be made for the check"); + if (!linked) return; + const std::string manifest = std::string(R"({"api": 1, "entry": "plugin/main.lua", "resident": [{"bundle": ")") + bundle_name + + R"(", "roots": ["test/unrelated"]}]})"; + write(directory / mods::plugin_manifest_file, std::as_bytes(std::span(manifest))); + std::vector notes; + fixture.catalog.mods.back().resident = mods::describe_mod(directory, "music", notes).resident; + const auto files = mods::detail::scan(directory); + expect(files.tocs == std::vector{"Win32/test_shared.toc"} && + files.archives == std::vector{"Win32/pkg/cas_01.cas"}, + "linked: the mod's files are found as Win32/..., not through the link (" + + (files.tocs.empty() ? std::string("none") : files.tocs.front()) + ")"); + const auto before = snapshot(store); + for (const auto pass : {"first", "second"}) { + const auto report = mods::merge_mods(fixture.catalog); + expect(report.issue.empty() && report.built && report.problems.empty(), + std::string("linked: the ") + pass + " merge builds the patch with no problem\n" + describe(report)); + expect(fixture.merged_files(map_toc) == baseline + 4, + std::string("linked: after the ") + pass + " merge the map's copy has the resident asset too\n" + describe(report)); + expect(snapshot(store) == before, std::string("linked: the ") + pass + " merge leaves the mod's own files as they were"); + } + RemoveDirectoryW(link.c_str()); + // Whatever names a file, nothing the merge writes leaves its output folder. + const auto refused = [](const fs::path& relative) { + try { mods::detail::inside("C:/patch", relative); } catch (const std::exception&) { return true; } + return false; + }; + expect(mods::detail::inside("C:/patch", "Win32/pkg/./cas_02.cas") == fs::path("C:/patch/Win32/pkg/cas_02.cas") && + refused("../../../reskate/mods/x/win32/items.toc") && refused("Win32/../../x.toc") && refused("C:/x.toc") && + refused("/x.toc") && refused("D:x.toc") && refused(""), + "linked: a path outside the output folder is refused"); +} + // Another asset mod ships the game's copy of the bundle in the TOC the music mod ships, the way // cosmetic mods share one. That copy merges with the music mod's bundle, which already has the // song, so nothing is carried into it (no "added by other mods" note), and the merged bundle is @@ -635,6 +808,46 @@ void a_mod_archive_numbered_like_a_placeholder() { }); } +// The game keeps the bundle in a level's TOC too, its chunks listed in an order of their own and +// their records by guid. Two map mods ship that level's copy (as SmokeyDawg58's mod and the +// built-in throwdown_menu pack both ship the game's level root bundles), made as Studio makes +// one: the chunks listed by guid, their records in the same order. The music mod's song names a +// texture chunk of its own, which is carried into the maps' copies after their chunks, and the +// list written for those copies has its record at its place by guid. Read back by where each +// chunk is listed, every chunk after it by guid had its neighbour's record once the level was +// merged with the game's copy: the carried texture another chunk's, and the game's own textures +// the first mip of the one before (23 textures in each level root bundle, 2026-10-20). +void carried_chunk_keeps_the_records_in_step() { + Fixture fixture("carried-chunk-records"); + const Chunk low{guid(0x10), {std::byte{1}}, {0x1010, 1}}, mid{guid(0x30), {std::byte{3}}, {0x3030, 2}}, + high{guid(0x50), {std::byte{5}}, {0x5050, 3}}; + const Chunk texture{guid(0x20), {std::byte{2}, std::byte{2}}, {0x2020, 0}}; + fixture.game(map_toc, game_copy(), {high, low, mid}); + fixture.add("map", true, map_toc, {map_superbundle}, game_copy(), {}, bundle_name, {low, mid, high}); + fixture.add("map2", true, map_toc, {map_superbundle}, game_copy(), {}, bundle_name, {low, mid, high}); + auto assets = music_assets(); + assets[2].payload = ebx_document(guid(10), {guid(11), texture.guid}); // the song names the texture's chunk + fixture.add("music", false, shared_toc, {}, assets, music_resources(), bundle_name, {texture}); + const auto report = mods::merge_mods(fixture.catalog); + expect(report.issue.empty() && report.built, "chunk records: the merge builds the patch\n" + describe(report)); + if (!report.built) return; + Patch patch(fixture); + std::optional listing; + for (const auto& bundle : fb::read_toc(read(patch.root / "Win32" / fs::path(map_toc))).bundles) + if (bundle.name == bundle_name) listing = mods::detail::list_bundle(patch.store, patch.root, patch.base, bundle, patch.game); + expect(listing.has_value(), "chunk records: the map's copy of the bundle can be read"); + if (!listing) return; + const auto& chunks = listing->manifest.chunks; + expect(std::ranges::any_of(chunks, [&](const fb::BundleAsset& chunk) { return chunk.guid == texture.guid; }), + "chunk records: the texture's chunk is carried into the map's copy\n" + describe(report)); + const std::vector expected{low.record, texture.record, mid.record, high.record}; + const auto records = records_in(listing->manifest.chunkMetadata); + std::string got; + for (const auto& record : records) got += " " + std::to_string(record.owner) + "/" + std::to_string(record.firstMip); + expect(chunks.size() == expected.size() && records == expected, + "chunk records: each chunk has its own record, with its own first mip, at its place by guid (got" + got + ")"); +} + // A merge built again from the same mods writes the same bytes. void two_merges_write_the_same_bytes() { Fixture fixture("same-bytes"); @@ -660,6 +873,8 @@ int main() try { expect(baseline > 0, "map only: the map's copy exists (" + std::to_string(baseline) + ")"); carried_into_the_maps_copy(true, baseline); carried_into_the_maps_copy(false, baseline); + resident_assets_go_with_the_additions(baseline); + linked_win32_stays_in_the_patch(baseline); copy_in_the_adders_toc_is_left_alone(); same_name_from_two_mods(true); same_name_from_two_mods(false); @@ -671,6 +886,7 @@ int main() try { every_placement_lands_in_an_archive(); a_mod_archive_numbered_like_a_placeholder(); two_merges_write_the_same_bytes(); + carried_chunk_keeps_the_records_in_step(); if (failures) { std::cerr << failures << " failure(s)\n"; return 1; diff --git a/Engine/Vfs/mod_list.cpp b/Engine/Vfs/mod_list.cpp index 128be11d..cd47921c 100644 --- a/Engine/Vfs/mod_list.cpp +++ b/Engine/Vfs/mod_list.cpp @@ -29,6 +29,7 @@ constexpr std::size_t maximum_order_bytes = 4 * 1024 * 1024; constexpr std::size_t maximum_build_bytes = 16 * 1024; constexpr std::size_t maximum_info_bytes = 32 * 1024; constexpr std::size_t maximum_levels_bytes = 64 * 1024; +constexpr std::size_t maximum_plugin_bytes = 64 * 1024; std::string lower(std::string_view text) { std::string result(text); @@ -139,6 +140,54 @@ void read_levels(Mod& mod) { } } +// reskate-plugin.json's "resident" list (ResidentAssets). A malformed list is a note, and the mod +// keeps nothing resident; the plugin host reports the rest of the file. +void read_resident(Mod& mod, std::vector& notes) { + const auto path = mod.directory / plugin_manifest_file; + std::error_code error; + if (!fs::is_regular_file(path, error) || error) return; + try { + const auto root = parse(path, maximum_plugin_bytes); + if (!root.is_object() || !root.contains("resident")) return; + const auto name_of = [](const Json& value) { + if (!value.is_string() || value.string().empty() || value.string().size() > 260) throw std::runtime_error("a name is 1 to 260 characters"); + for (const auto c : value.string()) + if (static_cast(c) < 0x20 || static_cast(c) > 0x7e || c == '\\') + throw std::runtime_error("names are printable ASCII with / between folders"); + return lower(value.string()); + }; + const auto names_of = [&](const Json& list, std::size_t most) { + if (!list.is_array() || list.size() > most) throw std::runtime_error("roots is a list of at most " + std::to_string(most) + " names"); + std::vector out; + for (const auto& item : list) out.push_back(name_of(item)); + return out; + }; + const auto& list = root.at("resident"); + if (!list.is_array() || list.size() > 16) throw std::runtime_error("resident is a list of at most 16 bundles"); + std::vector result; + for (const auto& row : list) { + if (!row.is_object() || !row.contains("bundle")) throw std::runtime_error("each resident entry names its bundle"); + ResidentAssets entry; + entry.bundle = name_of(row.at("bundle")); + if (row.contains("roots")) entry.roots = names_of(row.at("roots"), 512); + if (row.contains("shaders")) { + const auto& shaders = row.at("shaders"); + if (!shaders.is_array() || shaders.size() > 32) throw std::runtime_error("shaders is a list of at most 32 depots"); + for (const auto& shader : shaders) { + if (!shader.is_object() || !shader.contains("name") || !shader.contains("roots")) + throw std::runtime_error("each shader names its depot and its roots"); + entry.shaders.push_back({name_of(shader.at("name")), names_of(shader.at("roots"), 64)}); + if (entry.shaders.back().roots.empty()) throw std::runtime_error("a shader needs its roots"); + } + } + result.push_back(std::move(entry)); + } + mod.resident = std::move(result); + } catch (const std::exception& failure) { + notes.push_back("Mod " + mod.name + ": " + plugin_manifest_file + "'s resident list could not be read (" + failure.what() + ")"); + } +} + // .reskate-studio-patch: the stamp line, then key=value lines. A mod that ships game data must // record the Skate.exe it was built for, and it must be this one (supported_build). void read_studio_marker(Mod& mod) { @@ -183,6 +232,14 @@ std::vector folders_on_disk(const fs::path& root) { if (error) break; if (!entry.is_directory(error) || error) { error.clear(); continue; } const auto name = ascii_path(entry.path().filename()); + // A folder with no file of its own and no parks (only other folders, such as saved data a + // tool keeps beside the mods) holds no mod: every other mod has its manifest or layout at its + // top, and a park editor mod its parks folder. + bool content{}; + for (fs::directory_iterator inner(entry.path(), error), end; !error && inner != end && !content; inner.increment(error)) + content = inner->is_regular_file(error) || (inner->is_directory(error) && lower(ascii_path(inner->path().filename())) == "parks"); + error.clear(); + if (!content) continue; if (valid_mod_name(name)) names.push_back(name); } std::sort(names.begin(), names.end(), [](const std::string& a, const std::string& b) { @@ -229,7 +286,7 @@ std::string mod_fingerprint(const std::filesystem::path& directory) { const auto size = it->file_size(error); const auto written = it->last_write_time(error).time_since_epoch().count(); // Wide, so any file name counts; ASCII names give the same text as before. - const auto relative = fs::relative(it->path(), directory, error).generic_wstring(); + const auto relative = it->path().lexically_relative(directory).generic_wstring(); // as laid out, not through links std::string name; for (const auto ch : relative) { if (ch < 0x80) name.push_back(static_cast(ch)); @@ -347,10 +404,34 @@ Mod describe_mod(const std::filesystem::path& directory, std::string name, std:: read_build_info(mod, notes); read_mod_info(mod, notes); read_levels(mod); + read_resident(mod, notes); read_studio_marker(mod); return mod; } +bool resident_root(const Mod& mod, std::string_view bundle, std::string_view name) noexcept { + try { + const auto b = lower(bundle), n = lower(name); + for (const auto& entry : mod.resident) { + if (entry.bundle != b) continue; + for (const auto& root : entry.roots) + if (root.ends_with('/') ? n.starts_with(root) : n == root) return true; + } + } catch (...) {} + return false; +} +const ResidentAssets::Shader* resident_shader(const Mod& mod, std::string_view bundle, std::string_view name) noexcept { + try { + const auto b = lower(bundle), n = lower(name); + for (const auto& entry : mod.resident) { + if (entry.bundle != b) continue; + for (const auto& shader : entry.shaders) + if (shader.name == n) return &shader; + } + } catch (...) {} + return nullptr; +} + ModList scan_mods(const std::filesystem::path& data_root) noexcept { ModList result; try { diff --git a/Engine/Vfs/mod_list.h b/Engine/Vfs/mod_list.h index ffa9121f..9132e0f2 100644 --- a/Engine/Vfs/mod_list.h +++ b/Engine/Vfs/mod_list.h @@ -27,11 +27,31 @@ inline constexpr char manifest_file[] = "manifest.json"; // The older name for the same information ("version" instead of // "version_number"), still read when a mod has no manifest.json. inline constexpr char info_file[] = "reskate-mod.json"; +// A plugin mod's manifest (Extension/Plugins/plugin_manifest.h). Besides what the plugin host reads, +// the merge reads its "resident" list (ResidentAssets) from here. +inline constexpr char plugin_manifest_file[] = "reskate-plugin.json"; // ReSkate Studio's stamp in every mod it builds: "ReSkate Studio native Patch v1", then // key=value lines, of which skate_sha256= decides whether the // mod loads: game data built for another Skate.exe points into that build's archives. inline constexpr char studio_marker_file[] = ".reskate-studio-patch"; +// Assets of one of the mod's bundles that the game selects at run time without an import naming them +// (a plugin's world objects, the HUD widgets it picks by size): kept with the mod's additions when +// another mod (a map) replaces that bundle, as imported additions are. reskate-plugin.json: +// "resident": [{"bundle": "win32/levels/...", "roots": ["world/...", "ui/.../"], +// "shaders": [{"name": "world/.../shaderblockdepot_...", "roots": ["world/...", ...]}]}] +// Names are lower-case asset names; a root ending in '/' keeps every asset under it. A shader block +// depot (a resource no EBX imports) goes with its roots when all of them come from the mod. +struct ResidentAssets { + std::string bundle; + std::vector roots; + struct Shader { + std::string name; + std::vector roots; + }; + std::vector shaders; +}; + struct Mod { std::string name; // folder name directly under Mods/ std::filesystem::path directory; @@ -50,6 +70,8 @@ struct Mod { std::vector levels; // What the merge could not use from this mod. Empty once it merged cleanly. std::vector problems; + // From reskate-plugin.json: the assets its bundles keep resident (ResidentAssets). + std::vector resident; // From .reskate-studio-patch: whether the mod has one, and the Skate.exe SHA-256 it records. bool studio_marker{}; std::string skate_sha256; @@ -101,6 +123,11 @@ struct ModList { std::vector notes; }; +// Whether `name` (an asset of `bundle`, either lower-case) is one the mod keeps resident, and the +// shader block depot entry `name` is in that bundle, or null. +bool resident_root(const Mod& mod, std::string_view bundle, std::string_view name) noexcept; +const ResidentAssets::Shader* resident_shader(const Mod& mod, std::string_view bundle, std::string_view name) noexcept; + // Folder names travel into engine paths, so only plain ASCII is accepted: // letters, digits, space, '_', '-' and '.', not starting with '.'. bool valid_mod_name(std::string_view name); diff --git a/Engine/Vfs/mod_merge.cpp b/Engine/Vfs/mod_merge.cpp index 6899fc86..13950f65 100644 --- a/Engine/Vfs/mod_merge.cpp +++ b/Engine/Vfs/mod_merge.cpp @@ -149,8 +149,9 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con const auto before = options.live ? read_item_sources(output / item_sources_file) : std::vector{}; itemSources.emplace(); for (const auto* mod : mods) { - std::error_code outside; - const auto folder = fs::relative(mod->directory, catalog.root, outside); + // As the Mods folder lays it out: fs::relative resolves a linked mod folder. + const auto folder = mod->directory.lexically_relative(catalog.root); + const bool outside = folder.empty() || *folder.begin() == L".."; ItemSource source{mod->name, mod->title, mod->author, mod->version, outside ? std::string() : path_utf8(folder.generic_wstring()), {}}; const auto read = std::ranges::find(*itemsRead, mod->name, &StoreCopies::Added::mod); @@ -292,7 +293,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con if (!number) throw std::runtime_error("Unexpected archive name: " + relative); auto directory = lower(path.parent_path().generic_string()); if (directory.starts_with("win32/")) directory.erase(0, 6); - const auto target = output / path.parent_path() / archive_file(manifestArchive); + const auto target = inside(output, path.parent_path() / archive_file(manifestArchive)); if (!fs::exists(target, error)) throw std::runtime_error(mod->name + " needs a restart: the patch has no archive 1 in " + directory); // Checked before anything is appended: past 4 GB the mod's @@ -341,18 +342,18 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con if (number == manifestArchive) { if (const auto free = claim(directory); free) { spot.archive = *free; - link_or_copy(mod->directory / path, output / path.parent_path() / archive_file(*free)); + link_or_copy(mod->directory / path, inside(output, path.parent_path() / archive_file(*free))); } else { - spot.offset = append_file(mod->directory / path, output / path); + spot.offset = append_file(mod->directory / path, inside(output, path)); } } else if (claimed.insert(number).second) { - link_or_copy(mod->directory / path, output / path); + link_or_copy(mod->directory / path, inside(output, path)); } else if (const auto free = claim(directory); free) { spot.archive = *free; link_or_copy(mod->directory / path, - output / path.parent_path() / archive_file(*free)); + inside(output, path.parent_path() / archive_file(*free))); } else { - spot.offset = append_file(mod->directory / path, output / path); + spot.offset = append_file(mod->directory / path, inside(output, path)); } placements[mod].at.emplace(std::pair{directory, number}, spot); // A disabled mod's archives are read by no TOC yet, so nothing @@ -380,7 +381,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con if (!options.live) for (const auto& [directory, declared] : declaredIn) { if (!declared.contains(manifestArchive)) continue; - const auto path = output / L"Win32" / fs::path(directory) / archive_file(manifestArchive); + const auto path = inside(output, fs::path(L"Win32") / fs::path(directory) / archive_file(manifestArchive)); if (fs::exists(path, error)) continue; fs::create_directories(path.parent_path(), error); write_file(path, {}); @@ -591,7 +592,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con if (check.bundles.size() != job.merged.bundles.size() || check.chunks.size() != job.merged.chunks.size()) throw std::runtime_error("Merged TOC did not round-trip: " + relative); - write_file(output / fs::path(relative), bytes); + write_file(inside(output, fs::path(relative)), bytes); ++report.superbundles; // Done with: a big merge would otherwise hold every superbundle's until the end. job.merged = {}; @@ -606,7 +607,9 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con for (const auto& relative : superbundles) written.insert(lower(relative)); if (options.live) for (const auto& previous : record.tocs) - if (!written.contains(lower(previous)) && fs::remove(output / fs::path(previous), error)) + if (!written.contains(lower(previous)) && [&] { + try { return fs::remove(inside(output, fs::path(previous)), error); } catch (const std::exception&) { return false; } + }()) report.removed_tocs.push_back(previous); record.tocs = superbundles; write_placements(placementsPath, record); diff --git a/Engine/Vfs/mod_merge_cas_store.cpp b/Engine/Vfs/mod_merge_cas_store.cpp index bb10d7e1..b0c8e605 100644 --- a/Engine/Vfs/mod_merge_cas_store.cpp +++ b/Engine/Vfs/mod_merge_cas_store.cpp @@ -29,7 +29,7 @@ CasStore::CasStore(fs::path baseRoot, fs::path output, const native_db::Node& la } fs::path CasStore::archive_path(std::uint32_t installChunk, std::uint16_t archive) const { - return output_ / L"Win32" / fs::path(directory(installChunk)) / fs::path(archive_file(archive)); + return inside(output_, fs::path(L"Win32") / fs::path(directory(installChunk)) / fs::path(archive_file(archive))); } std::uint64_t CasStore::append(const fs::path& path, std::span encoded) { diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index 319f401b..a9a5dcbf 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -46,6 +46,7 @@ struct Asset { // For a chunk: where its record is in the chunk metadata of the copy this version // of it came in, and where the game's copy lists the chunk (merge_chunk_metadata). ChunkRecord record; + const AssetAddition* carried_from{}; }; // Where one contributor's copy of an asset lives, so conflicting copies can be @@ -241,7 +242,12 @@ void conflict(MergeReport& report, const std::string& kept, const std::string& l std::vector merge_chunk_metadata(std::span> lists, std::span chunks, std::size_t game, - std::span shipped) { + std::span shipped, std::vector* rows) { + // Unless the records are put in another order below, each chunk's is where it is listed. + if (rows) { + rows->resize(chunks.size()); + for (std::size_t index = 0; index < chunks.size(); ++index) (*rows)[index] = index; + } // A bundle with no chunks has nothing to describe; what the first copy has stays. if (chunks.empty()) return lists.empty() ? std::vector{} : lists.front(); const bool shippedList = game < lists.size() && !lists[game].empty(); @@ -254,7 +260,22 @@ std::vector merge_chunk_metadata(std::span order(chunks.size()); + for (std::size_t index = 0; index < order.size(); ++index) order[index] = index; + std::ranges::stable_sort(order, [&](std::size_t left, std::size_t right) { return chunks[left].guid < chunks[right].guid; }); + for (std::size_t at = 0; at < order.size(); ++at) (*rows)[order[at]] = at; + } + return lists[first]; + } std::vector> read(lists.size()); const auto list = [&](std::size_t copy) -> const native_db::Node* { @@ -337,6 +358,8 @@ std::vector merge_chunk_metadata(std::span(bytes.data()); return {data, data + bytes.size()}; @@ -387,12 +410,17 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, std::multimap> addedByGuid; // The resource a mod added to a bundle under a name, by bundle, mod and name. std::map, const AssetAddition*> companions; + std::map additionBundles; + std::map,const AssetAddition*> bundleChunks; for (const auto& [bundleName, list] : overrides.added) for (const auto& addition : list) { + additionBundles.emplace(&addition,&bundleName); if (addition.asset.kind == fb::AssetKind::ebx) addedByGuid.emplace(addition.file, std::pair{&bundleName, &addition}); else if (addition.asset.kind == fb::AssetKind::resource) companions.emplace(std::tuple{bundleName, addition.mod, lower(addition.asset.name)}, &addition); + else if (addition.asset.kind == fb::AssetKind::chunk) + bundleChunks.emplace(std::tuple{bundleName,addition.mod,addition.asset.guid},&addition); } // The map meshes already counted (reskate-map-meshes.json), by where they lie in a mod's folder. std::set> countedMeshes; @@ -735,10 +763,20 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, take.push_back(companion->second); } } + // Expand only the selected EBX/RES dependencies, including imports + // carried from another source bundle. Unrelated geometry stays out. + for (std::size_t index=0;indexchunk_names) + if (const auto found=bundleChunks.find({*from,asset->mod,id});found!=bundleChunks.end() && + taking.insert(found->second).second) take.push_back(found->second); + } if (!take.empty()) { std::set> present; for (const auto& asset : casManifest.ebx) present.insert(asset_key(asset)); for (const auto& asset : casManifest.resources) present.insert(asset_key(asset)); + for (const auto& asset : casManifest.chunks) present.insert(asset_key(asset)); const auto installChunk = region.files.front().location.installChunk; for (const auto* addition : take) { if (!present.insert(asset_key(addition->asset)).second) continue; @@ -748,6 +786,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, placed = carriedAt.emplace(std::pair{installChunk, addition}, store.carry(installChunk, manifestArchive, addition->encoded)).first; additions.push_back({addition->asset, placed->second}); + additions.back().carried_from=addition; addedFrom.insert(addition->mod); } catch (const std::exception& error) { report.notes.push_back(modName + ": " + bundle.name + ": " + addition->asset.name + @@ -782,14 +821,57 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, // Added EBX go after this copy's own EBX and added resources after its own // resources, ahead of its chunks; after the indexed moves above so their // positions still hold. + // A map's replacement bundle can retain the game's GUID-ordered rows + // and append its own rows. Use the actual game's baseline to interpret + // those records; the map's whole table is not a new stock baseline. + const auto defaults=overrides.bundle_chunk_defaults.find(key); + const bool hasDefaults=defaults!=overrides.bundle_chunk_defaults.end(); + std::vector> chunkLists; + if (hasDefaults) chunkLists.push_back(defaults->second.metadata); + const auto ownList=chunkLists.size(); + chunkLists.push_back(casManifest.chunkMetadata); + std::vector chunkRecords; + std::vector originalChunks; + if (hasDefaults) originalChunks=defaults->second.chunks; + std::map stockChunks; + for (std::size_t index=0;indexsecond}); + } + bool chunksAdded{}; for (auto& addition : additions) { - const auto ebx = addition.asset.kind == fb::AssetKind::ebx; - const auto at = static_cast(1 + casManifest.ebx.size() + (ebx ? 0 : casManifest.resources.size())); - (ebx ? casManifest.ebx : casManifest.resources).push_back(std::move(addition.asset)); - region.files.insert(region.files.begin() + at, addition.file); - inPatch.insert(inPatch.begin() + at, true); + auto at=1+casManifest.ebx.size(); + if (addition.asset.kind==fb::AssetKind::ebx) casManifest.ebx.push_back(std::move(addition.asset)); + else if (addition.asset.kind==fb::AssetKind::resource) { + at+=casManifest.resources.size();casManifest.resources.push_back(std::move(addition.asset)); + } else { + at+=casManifest.resources.size()+casManifest.chunks.size(); + const auto* source=addition.carried_from; + if (!source) throw std::runtime_error("Carried bundle chunk has no source record"); + auto copy=ChunkRecord::none; + if (source->chunk_metadata && !source->chunk_metadata->empty()) { + auto found=std::find(chunkLists.begin(),chunkLists.end(),*source->chunk_metadata); + if (found==chunkLists.end()) found=chunkLists.insert(chunkLists.end(),*source->chunk_metadata); + copy=static_cast(found-chunkLists.begin()); + } + const auto shipped=stockChunks.find(addition.asset.guid); + chunkRecords.push_back({addition.asset.guid,copy,source->chunk_index, + shipped==stockChunks.end()?ChunkRecord::none:shipped->second}); + casManifest.chunks.push_back(std::move(addition.asset)); + chunksAdded=true; + } + region.files.insert(region.files.begin() + static_cast(at), addition.file); + inPatch.insert(inPatch.begin() + static_cast(at), true); used.emplace(addition.file.location.installChunk, addition.file.location.archive); } + // The list written for this copy has its records by the chunks' guids, while the carried + // chunks are listed after its own: where each chunk's record went, for the records below. + std::vector chunkRows; + if (chunksAdded) casManifest.chunkMetadata=merge_chunk_metadata(chunkLists,chunkRecords, + hasDefaults?0:ChunkRecord::none,originalChunks,&chunkRows); const auto fresh = !states.contains(key); if (fresh) { order.push_back(bundle.name); states.emplace(key, BundleState{}); } auto& state = states.at(key); @@ -859,7 +941,11 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, auto& record = flat[firstChunk + index].record; record.guid = manifest.chunks[index].guid; record.copy = copy; - record.index = index; + // Where this copy's list has the chunk's record: where the chunk is listed, unless + // other mods' chunks were carried in and the list was written again by guid. Read + // by where the chunk is listed, every chunk after the first carried one by guid + // took its neighbour's record, and the first mip in it. + record.index = index < chunkRows.size() ? chunkRows[index] : index; if (isBase) record.shipped = index; } } diff --git a/Engine/Vfs/mod_merge_files.cpp b/Engine/Vfs/mod_merge_files.cpp index 0f20d113..8e0f6816 100644 --- a/Engine/Vfs/mod_merge_files.cpp +++ b/Engine/Vfs/mod_merge_files.cpp @@ -100,6 +100,13 @@ std::uint64_t append_file(const fs::path& from, const fs::path& to) { return start; } +fs::path inside(const fs::path& output, const fs::path& relative) { + const auto normal = relative.lexically_normal(); + if (normal.empty() || normal.has_root_name() || normal.has_root_directory() || *normal.begin() == L"..") + throw std::runtime_error("Refused to write " + path_utf8(relative) + ": it is outside the merged patch's folder"); + return output / normal; +} + // Hard link when the volume allows it, so a 400 MB archive costs nothing. void link_or_copy(const fs::path& from, const fs::path& to) { fs::create_directories(to.parent_path()); @@ -118,8 +125,11 @@ RelativeFiles scan(const fs::path& directory) { for (fs::recursive_directory_iterator it(root, error), end; it != end && !error; it.increment(error)) { if (!it->is_regular_file(error) || error) { error.clear(); continue; } // The game only names archives in ASCII: anything else is not one of its files. - const auto relative = ascii_path(fs::relative(it->path(), directory, error)); - if (error || relative.empty()) { error.clear(); continue; } + // Lexically, as the folder is laid out: fs::relative resolves links, and a Win32 (or a + // folder in it) that is a junction elsewhere would come out as ../../.., a path the merge + // then reads the game's TOC from and writes its own copies to outside the patch folder. + const auto relative = ascii_path(it->path().lexically_relative(directory)); + if (relative.empty() || relative.starts_with("../")) continue; const auto extension = lower(ascii_path(it->path().extension())); if (extension == ".toc") result.tocs.push_back(relative); else if (extension == ".cas") result.archives.push_back(relative); diff --git a/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index df007ba1..bacbde35 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,10 @@ using vfs::archive_file; void unshare(const fs::path& path); std::uint64_t append_file(const fs::path& from, const fs::path& to); void link_or_copy(const fs::path& from, const fs::path& to); +// `relative` (a mod's file, a superbundle a layout names) under the merge's output folder. One +// that would land outside it (absolute, or climbing out with ..) throws: nothing the merge writes +// may reach a mod's own folder or the game's. +fs::path inside(const fs::path& output, const fs::path& relative); struct RelativeFiles { std::vector tocs; // Win32/... .toc, generic separators @@ -334,8 +339,14 @@ struct AssetAddition { // guids of the documents it refers to in turn (a song names its wave). fb::Guid file; std::vector names; + // Bundled mesh/texture data follows the EBX/RES that references it. + std::vector chunk_names; + std::shared_ptr> chunk_metadata; + std::size_t chunk_index{}; }; struct AssetOverrides { + struct ChunkDefaults { std::vector metadata; std::vector chunks; }; + std::map> bundle_chunk_defaults; // By lower-case asset name, then the game's sha1 the change replaces. std::map, std::less<>> changed; // Separate from EBX: a script's EBX wrapper can have the same asset name. @@ -377,7 +388,7 @@ struct ChunkRecord { static constexpr std::size_t none = static_cast(-1); fb::Guid guid; std::size_t copy{none}; // the list of the copy this version of the chunk came in; none when that copy has no list - std::size_t index{}; // where that copy lists the chunk + std::size_t index{}; // where that copy's list has its record: where the copy lists it, unless the list was written again (merge_chunk_metadata's rows) std::size_t shipped{none}; // where the game's copy lists it; none for a chunk mods add }; // The list for a merged bundle, written as the game writes one: a record for each @@ -390,12 +401,16 @@ struct ChunkRecord { // does not ship, or ships without a list, every chunk is one a mod adds. When // every chunk is the game's own where the game lists it, the game's list comes // back as it is; so does one mod's, for a bundle without a list in the game that -// is that mod's copy alone. +// is that mod's copy alone. `rows`, when given, gets where each chunk's record is in +// the list that comes back: its place by guid in a list in the game's form, else +// where the chunk is listed. A copy given that list as its own reads its chunks' +// records there (ChunkRecord::index). // Throws when a list that is needed cannot be read. [[nodiscard]] std::vector merge_chunk_metadata(std::span> lists, std::span chunks, std::size_t game = ChunkRecord::none, - std::span shipped = {}); + std::span shipped = {}, + std::vector* rows = nullptr); fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, std::vector& sources, MergeReport& report, diff --git a/Engine/Vfs/mod_merge_overrides.cpp b/Engine/Vfs/mod_merge_overrides.cpp index ec5412fd..f21ba8e6 100644 --- a/Engine/Vfs/mod_merge_overrides.cpp +++ b/Engine/Vfs/mod_merge_overrides.cpp @@ -86,6 +86,9 @@ struct ModRead { // own, it could arrive in another mod's bundle without the resources // and chunks it was added with. std::vector> candidates, companions; + struct BundleChunk { std::string bundle; AssetAddition addition; fb::BundleFileInfo file; }; + std::vector bundleChunks; + std::map> chunkDefaults; std::vector unreadable; // per candidate: never follows std::map, std::less<>> addedIn; // the documents it added to each bundle, by guid std::vector newChunks; // TOC chunks the game's TOCs do not have @@ -99,7 +102,7 @@ struct ModRead { ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& store, const fs::path& baseRoot, const fs::path& gameRoot, Shared& gameTocs, Shared& gameBundles) { ModRead read; - auto& [candidates, companions, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read; + auto& [candidates, companions, bundleChunks, chunkDefaults, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read; const auto decoded = [&](std::span encoded) { return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot})); }; @@ -137,6 +140,12 @@ ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& sto } }); if (!modListing || !known.listing) continue; + if (!known.listing->manifest.chunkMetadata.empty() && !chunkDefaults.contains(bundleName)) { + AssetOverrides::ChunkDefaults defaults; + defaults.metadata=known.listing->manifest.chunkMetadata; + for (const auto& entry:known.listing->manifest.chunks) defaults.chunks.push_back(entry.guid); + chunkDefaults.emplace(bundleName,std::move(defaults)); + } const auto& gameListing = known.listing; const auto& original = known.original; for (std::size_t index = 0; index < modListing->manifest.ebx.size(); ++index) { @@ -201,6 +210,21 @@ ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& sto scripts.push_back({name, originalScript->second->sha1, std::nullopt, notes.size() - 1}); } } + // Keep chunk locations, not their potentially large payloads. Only + // data named by a retained mesh/texture is read and carried later. + const auto& chunks = modListing->manifest.chunks; + const auto chunkMetadata = chunks.empty() ? nullptr : + std::make_shared>(modListing->manifest.chunkMetadata); + for (std::size_t index = 0; index < chunks.size(); ++index) { + const auto at = modListing->first + modListing->manifest.ebx.size() + + modListing->manifest.resources.size() + index; + if (at >= modListing->files.size()) continue; + AssetAddition addition{mod.name, chunks[index], {}, tocKey}; + addition.asset.name = addition.asset.guid.string(); + addition.chunk_metadata = chunkMetadata; + addition.chunk_index = index; + bundleChunks.push_back({bundleName, std::move(addition), modListing->files[at]}); + } // Resources the mod adds, kept to go with an added EBX of the same name // (a wave's sound-bank resource registers the wave with the audio system). for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) { @@ -226,6 +250,10 @@ ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& sto addition.file = document.fileGuid; for (const auto& reference : document.imports) addition.names.push_back(reference.fileGuid); addedIn[candidates[index].first].insert(addition.file); + // Assets the mod's plugin selects at run time (its world objects, HUD variants picked + // by size) have no import naming them: its resident list keeps them, as imported + // additions are kept, when a map replaces their bundle. + if (resident_root(mod, candidates[index].first, addition.asset.name)) imported.insert(addition.file); } catch (const std::exception&) { unreadable[index] = true; } } return read; @@ -287,7 +315,8 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, const auto* mod = asked[slot].first; auto& read = reads[slot]; if (read.failure) std::rethrow_exception(read.failure); - auto& [candidates, companions, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read; + auto& [candidates, companions, bundleChunks, chunkDefaults, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read; + for (auto& [bundle,defaults]:chunkDefaults) out.bundle_chunk_defaults.try_emplace(bundle,std::move(defaults)); std::size_t changed{}; // A higher mod's version of an asset, which this mod's change is combined with // below, may name what this mod adds. @@ -354,7 +383,69 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, // One addition per kind and name in a bundle (keep): this mod's, or not. const auto& index = addedAt[bundle]; const auto held = index.find({fb::AssetKind::ebx, lower(companion.asset.name)}); - if (held != index.end() && found->second[held->second].mod == mod->name) keep(bundle, std::move(companion)); + // Native mesh material keys do not import their shader depot by EBX GUID/name. A depot + // the mod's resident list names goes with its own runtime roots, once all of them come + // from this mod (on a replacement map's copy of the bundle too). + constexpr std::uint32_t shader_block_depot = 0x73312045; + const auto* shader = companion.asset.resourceType == shader_block_depot ? resident_shader(*mod, bundle, companion.asset.name) : nullptr; + const bool runtimeShader = shader && + std::all_of(shader->roots.begin(),shader->roots.end(),[&](const auto& root) { + return std::any_of(found->second.begin(),found->second.end(),[&](const auto& addition) { + return addition.mod==mod->name && addition.asset.kind==fb::AssetKind::ebx && + lower(addition.asset.name)==root; + }); + }); + if ((held != index.end() && found->second[held->second].mod == mod->name) || runtimeShader) + keep(bundle, std::move(companion)); + } + // A native mesh RES can refer to geometry in its bundle, rather than + // the superbundle TOC. Carrying only EBX/RES leaves the engine probing + // its chunk hash table forever on replacement-map bundles. + if (!bundleChunks.empty()) { + std::map aliases; + std::vector starts(0x10000); + const auto add_alias = [&](const fb::Guid& bytes, const fb::Guid& id) { + aliases.emplace(bytes,id); + starts[std::to_integer(bytes.bytes[0])*256 + + std::to_integer(bytes.bytes[1])] = true; + }; + for (const auto& chunk : bundleChunks) { + const auto& id=chunk.addition.asset.guid; + add_alias(id,id); + auto swapped=id; + std::reverse(swapped.bytes.begin(),swapped.bytes.begin()+4); + std::reverse(swapped.bytes.begin()+4,swapped.bytes.begin()+6); + std::reverse(swapped.bytes.begin()+6,swapped.bytes.begin()+8); + add_alias(swapped,id); + } + std::map,std::less<>> referenced; + for (auto& [bundle,list] : out.added) + for (auto& addition : list) { + if (addition.mod!=mod->name || addition.asset.kind==fb::AssetKind::chunk) continue; + std::vector bytes; + try { bytes=fb::decode_cas(addition.encoded,{gameRoot}); } + catch (const std::exception&) { continue; } + std::set named; + for (std::size_t at=0;at+16<=bytes.size();++at) { + if (!starts[std::to_integer(bytes[at])*256+ + std::to_integer(bytes[at+1])]) continue; + fb::Guid key;std::memcpy(key.bytes.data(),bytes.data()+at,16); + if (const auto found=aliases.find(key);found!=aliases.end()) named.insert(found->second); + } + addition.chunk_names.assign(named.begin(),named.end()); + referenced[bundle].insert(named.begin(),named.end()); + } + std::size_t carried{}; + for (auto& chunk : bundleChunks) { + const auto found=referenced.find(chunk.bundle); + if (found==referenced.end() || !found->second.contains(chunk.addition.asset.guid)) continue; + const auto& file=chunk.file; + chunk.addition.encoded=store.read(file.location.patch?mod->directory:baseRoot, + file.location,file.offset,file.size); + if (keep(chunk.bundle,std::move(chunk.addition))) ++carried; + } + if (carried) report.notes.push_back(mod->name+": "+std::to_string(carried)+ + " referenced bundle chunk(s) accompany mesh/texture additions into map copies"); } for (auto& change : changes) { // What the change names of the assets the mod added beside it. The mod @@ -419,13 +510,15 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, report.notes.push_back(mod->name + ": " + std::to_string(clash.first) + " added asset(s) share a name with ones " + other + " adds, e.g. " + clash.second + "; other mods' copies of the bundle get " + other + "'s"); - // The new TOC chunks this mod's carried EBX name (as raw GUID bytes, the way an - // EBX stores a ChunkId), so they can follow those assets into other superbundles. + // The new TOC chunks this mod's carried EBX or companion RES name, as raw + // GUID bytes. Texture pixel ChunkIds live in RES headers, not their EBX. + // Scan only retained dependencies so unrelated chunks do not follow them. if (!newChunks.empty()) { std::vector> carried; for (const auto& [bundle, list] : out.added) for (const auto& addition : list) - if (addition.mod == mod->name && addition.asset.kind == fb::AssetKind::ebx) try { + if (addition.mod == mod->name && (addition.asset.kind == fb::AssetKind::ebx || + addition.asset.kind == fb::AssetKind::resource)) try { carried.push_back(fb::decode_cas(addition.encoded, {gameRoot})); } catch (const std::exception&) {} // Each carried asset is gone through once, for all the chunks together: looking for diff --git a/Engine/Vfs/mod_merge_stamp.cpp b/Engine/Vfs/mod_merge_stamp.cpp index 247a1a51..e8f46da8 100644 --- a/Engine/Vfs/mod_merge_stamp.cpp +++ b/Engine/Vfs/mod_merge_stamp.cpp @@ -83,7 +83,7 @@ std::string merge_fingerprint(const Catalog& catalog, const std::vectordirectory)); std::ranges::sort(files); for (const auto& file : files) - describe(utf8(fs::relative(file, mod->directory, error)), file); + describe(utf8(file.lexically_relative(mod->directory)), file); for (const auto& toc : modFiles.at(mod).tocs) tocs.insert(lower(toc)); } for (const auto& toc : tocs) { @@ -163,7 +163,7 @@ void write_stamp(const fs::path& output, const std::string& fingerprint, const M if (!it->is_regular_file(error) || error) { error.clear(); continue; } const auto size = fs::file_size(it->path(), error); if (error) throw std::runtime_error("Cannot size " + path_utf8(it->path())); - text += "file " + std::to_string(size) + ' ' + utf8(fs::relative(it->path(), output, error)) + '\n'; + text += "file " + std::to_string(size) + ' ' + utf8(it->path().lexically_relative(output)) + '\n'; } if (error) throw std::runtime_error("Cannot list " + path_utf8(output)); for (auto note : report.notes) { From 844455340247aaffd07509ecdd16d534aa2c9d63 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Tue, 20 Oct 2026 01:04:54 -0500 Subject: [PATCH 4/9] Throwdowns: the throwdown kit, so a mode is a plugin and the SDK knows no particular mode Plugins register modes (card, replaces, order, flag, players) and publish documents through reskate.throwdowns.ui: the setup page, the waiting card, HUD slots themed on the game's HUD__* widgets, the match panel, flags and match restrictions. SmokeyDawg58's native plumbing (throwdown_menu.cpp and its .inl files) renders them and posts throwdown_ui events back. kit::flag is the one native flag registration. General services added for it: reskate.files, reskate.ui.dialog, reskate.skater push/hold/bone_cam, reskate.camera.pad_flight, reskate.physics competition, reskate.session.world, reskate.tricks feed/gesture, and world entities. His ten modes are now Lua in his plugin: the rules, referee and wire (td/rules, referee, wire), and every page and HUD document (td/ui*.lua). Extension/Modes and reskate.modes are gone, along with every race and 1-UP branch in the SDK. The plugin test drives each mode through kit events. A native setup page is never written to while it closes into flag placement: its rows are not republished, the stock page is put back once the exit is over, and the stock Players slider is left alone while a plugin flag is out. This fixes the crashes on the Deathrace button and on 1-UP's Confirm. --- .../Game/Build/20260929/guid_table_lookup.h | 20 + .../Game/Build/20260929/native_throwdowns.h | 5 + Engine/Game/Build/20260929/no_bail.h | 9 +- Engine/Game/Build/20260929/skater_entities.h | 9 + Engine/Game/Input/owned_menu_input.h | 15 + Engine/Game/Multiplayer/session_competition.h | 14 + Engine/Game/Multiplayer/session_physics.h | 3 +- Engine/Game/Multiplayer/session_tools.h | 7 +- Engine/Game/World/load_completion.h | 13 + .../local_player_card_runtime.cpp | 9 +- Extension/Multiplayer/Hud/native_party.cpp | 9 + Extension/Multiplayer/Hud/native_party.h | 2 + .../Multiplayer/Hud/native_party_hooks.cpp | 9 + .../Multiplayer/Remote/native_skater.cpp | 2 + Extension/Multiplayer/Session/session.cpp | 50 +- .../Multiplayer/Session/session_commands.cpp | 5 + .../Multiplayer/Session/session_send.cpp | 5 + Extension/Multiplayer/Steam/steam_social.cpp | 35 +- Extension/Multiplayer/Test/protocol_tests.cpp | 8 +- Extension/Multiplayer/console_commands.cpp | 4 +- .../ParkEditor/park_editor_runtime.cpp | 81 + .../Objects/ParkEditor/park_editor_runtime.h | 13 + .../Objects/local_placements_runtime.cpp | 16 +- Extension/Objects/local_placements_runtime.h | 3 +- Extension/Plugins/Test/plugin_tests.cpp | 646 ++- Extension/Plugins/plugin_api.cpp | 13 +- Extension/Plugins/plugin_api.h | 7 + Extension/Plugins/plugin_api_services.cpp | 309 ++ Extension/Plugins/plugin_api_throwdowns.cpp | 402 ++ Extension/Plugins/plugin_api_ui.cpp | 2 +- Extension/Plugins/plugin_api_world.cpp | 214 +- Extension/Plugins/plugin_camera.h | 3 +- Extension/Plugins/plugin_game.h | 176 +- Extension/Plugins/plugin_game_runtime.cpp | 308 +- Extension/Plugins/plugin_host.cpp | 47 +- Extension/Plugins/plugin_manifest.cpp | 13 +- Extension/Plugins/plugin_runtime.cpp | 9 +- Extension/Plugins/plugin_vm.h | 4 + Extension/Plugins/plugin_wire.h | 2 +- Extension/Profile/local_profile_objects.cpp | 31 +- Extension/Profile/local_profile_runtime.cpp | 5 +- Extension/Profile/local_profile_runtime.h | 3 + .../local_entitlement_trigger_runtime.cpp | 7 +- .../Settings/local_gameplay_settings.cpp | 57 +- Extension/Settings/local_gameplay_settings.h | 3 + Extension/Settings/named_settings.cpp | 1 + Extension/Skater/Test/trick_watch_tests.cpp | 3 +- Extension/Skater/bone_cam.cpp | 724 ++++ Extension/Skater/bone_cam.h | 85 + Extension/Skater/bone_sprites.h | 37 + Extension/Skater/client_camera_activation.h | 52 + Extension/Skater/client_debug.cpp | 42 +- Extension/Skater/client_noclip.cpp | 73 + Extension/Skater/client_source_spawn.h | 7 + Extension/Skater/no_bail.cpp | 54 +- Extension/Skater/no_bail.h | 6 +- Extension/Skater/pad_flight.h | 10 + Extension/Skater/score_hud.h | 2 +- Extension/Skater/skater_push.cpp | 63 + Extension/Skater/skater_push.h | 27 + Extension/Skater/skater_skeleton.h | 39 + Extension/Skater/trick_feed.cpp | 126 + Extension/Skater/trick_feed.h | 22 + Extension/Skater/trick_gestures.cpp | 166 + Extension/Skater/trick_gestures.h | 23 + Extension/Skater/trick_watch.h | 4 +- .../Test/custom_throwdown_tests.cpp | 87 +- .../Throwdowns/Test/throwdown_relay_tests.cpp | 261 +- Extension/Throwdowns/challenge_registry.cpp | 99 + Extension/Throwdowns/challenge_registry.h | 53 + Extension/Throwdowns/custom_modes.cpp | 96 +- Extension/Throwdowns/custom_modes.h | 66 +- Extension/Throwdowns/custom_placement.cpp | 322 -- Extension/Throwdowns/custom_placement.h | 59 - Extension/Throwdowns/native_flag_bubble.h | 111 + .../Throwdowns/native_flag_cursor_height.h | 25 + Extension/Throwdowns/native_flag_preview.h | 50 + Extension/Throwdowns/native_flag_session.cpp | 507 +++ Extension/Throwdowns/native_flag_session.h | 61 + Extension/Throwdowns/native_flag_spawn.h | 60 + .../Throwdowns/native_throwdown_lifetime.h | 151 + Extension/Throwdowns/native_throwdowns.cpp | 505 ++- Extension/Throwdowns/native_throwdowns.h | 32 +- Extension/Throwdowns/session_marker_actions.h | 21 + Extension/Throwdowns/throwdown_kit.cpp | 560 +++ Extension/Throwdowns/throwdown_kit.h | 268 ++ Extension/Throwdowns/throwdown_lab.cpp | 473 ++- Extension/Throwdowns/throwdown_lab.h | 28 +- Extension/Throwdowns/throwdown_relay.cpp | 330 +- Extension/Throwdowns/throwdown_relay.h | 39 +- Extension/Throwdowns/throwdown_wire.cpp | 80 +- Extension/Throwdowns/throwdown_wire.h | 28 +- Extension/Trainer/trainer.cpp | 42 +- Extension/Trainer/trainer_motion.h | 9 + .../UI/NativeMenu/native_flag_interaction.h | 49 + Extension/UI/NativeMenu/native_hub.cpp | 8 +- .../UI/NativeMenu/native_hud_selection.h | 168 + Extension/UI/NativeMenu/native_hud_values.h | 150 + Extension/UI/NativeMenu/native_join_context.h | 37 + Extension/UI/NativeMenu/native_menu.cpp | 64 +- Extension/UI/NativeMenu/native_menu_data.cpp | 26 +- Extension/UI/NativeMenu/native_menu_data.h | 15 + .../UI/NativeMenu/native_menu_lifetime.h | 99 +- .../UI/NativeMenu/native_menu_ownership.h | 255 ++ Extension/UI/NativeMenu/native_setup_roles.h | 23 + Extension/UI/NativeMenu/throwdown_kit_hud.inl | 1239 ++++++ .../UI/NativeMenu/throwdown_map_identity.inl | 328 ++ .../UI/NativeMenu/throwdown_match_panel.inl | 1015 +++++ Extension/UI/NativeMenu/throwdown_menu.cpp | 3611 ++++++++++------- Extension/UI/NativeMenu/throwdown_menu.h | 19 +- .../UI/NativeMenu/throwdown_modes_ui.inl | 264 ++ Extension/UI/NativeMenu/ui_sound.cpp | 6 +- Extension/UI/NativeMenu/ui_sound.h | 4 +- Extension/UI/Overlay/input_capture.cpp | 104 +- Extension/UI/Overlay/input_capture.h | 2 + Extension/UI/Overlay/overlay.cpp | 2 +- Extension/UI/Overlay/overlay.h | 27 + Extension/UI/Overlay/overlay_input.cpp | 9 +- Extension/UI/Overlay/overlay_internal.h | 1 + Extension/UI/Overlay/overlay_render.cpp | 4 +- Extension/UI/Overlay/plugin_dialog.cpp | 256 ++ Extension/UI/Overlay/skate_hud_overlay.cpp | 44 +- Extension/UI/Overlay/skate_menu.cpp | 2 + Extension/UI/Overlay/skate_menu.h | 1 + Extension/UI/hud_corner.cpp | 8 +- Extension/UI/own_ui_writes.h | 12 + Extension/World/guid_table_guard.cpp | 84 + Extension/World/guid_table_guard.h | 10 + Extension/World/guid_table_probe.h | 33 + Extension/World/world_entities.cpp | 351 ++ Extension/World/world_entities.h | 33 + Extension/World/world_entities_contract.h | 99 + Runtime/client_tick.cpp | 34 +- Runtime/runtime_init.cpp | 7 + cmake/Core.cmake | 7 + cmake/Runtime.cmake | 9 +- cmake/UI.cmake | 1 + .../plugin/gates.lua | 134 + .../plugin/main.lua | 147 +- .../plugin/modes.lua | 109 +- .../plugin/sounds/cue-checkpoint.wav | Bin 0 -> 33118 bytes .../plugin/sounds/cue-finish.wav | Bin 0 -> 58696 bytes .../plugin/sounds/cue-invite.wav | Bin 0 -> 46348 bytes .../plugin/sounds/cue-missed.wav | Bin 0 -> 43702 bytes .../plugin/sounds/race-finish.wav | Bin 0 -> 363040 bytes .../plugin/sounds/race-gate.wav | Bin 0 -> 131568 bytes .../plugin/sounds/race-recovery.wav | Bin 0 -> 53932 bytes .../plugin/td/command.lua | 725 ++++ .../plugin/td/core.lua | 738 ++++ .../plugin/td/h/mode_announcements.lua | 58 + .../plugin/td/h/mode_lobby.lua | 41 + .../plugin/td/h/mode_participation.lua | 20 + .../plugin/td/h/placement_camera.lua | 51 + .../plugin/td/h/race_arrow_placement.lua | 176 + .../plugin/td/h/race_asset_contract.lua | 39 + .../plugin/td/h/race_editor.lua | 445 ++ .../plugin/td/h/race_group_reconciliation.lua | 27 + .../plugin/td/h/race_momentum.lua | 211 + .../plugin/td/h/race_navigation.lua | 38 + .../plugin/td/h/race_route.lua | 592 +++ .../plugin/td/h/race_sound.lua | 65 + .../plugin/td/hud.lua | 854 ++++ .../plugin/td/lobby.lua | 453 +++ .../plugin/td/oneup/match.lua | 312 ++ .../plugin/td/oneup/native.lua | 175 + .../plugin/td/oneup/runtime.lua | 595 +++ .../plugin/td/oneup/spawn.lua | 58 + .../plugin/td/oneup/wire.lua | 282 ++ .../plugin/td/placing.lua | 637 +++ .../plugin/td/play.lua | 674 +++ .../plugin/td/referee.lua | 811 ++++ .../plugin/td/routes.lua | 317 ++ .../plugin/td/rules.lua | 564 +++ .../plugin/td/rules_tests.lua | 761 ++++ .../plugin/td/skatepedia.lua | 338 ++ .../plugin/td/skater.lua | 426 ++ .../plugin/td/tick.lua | 301 ++ .../plugin/td/ui.lua | 268 ++ .../plugin/td/ui_events.lua | 335 ++ .../plugin/td/ui_hud.lua | 345 ++ .../plugin/td/ui_oneup.lua | 279 ++ .../plugin/td/wire.lua | 550 +++ .../plugin/td/world.lua | 535 +++ .../reskate-plugin.json | 25 +- contrib/plugins/hall-of-meat/plugin/main.lua | 49 +- .../throwdown-modes-test/plugin/main.lua | 86 +- 186 files changed, 28015 insertions(+), 2747 deletions(-) create mode 100644 Engine/Game/Build/20260929/guid_table_lookup.h create mode 100644 Engine/Game/Input/owned_menu_input.h create mode 100644 Engine/Game/Multiplayer/session_competition.h create mode 100644 Engine/Game/World/load_completion.h create mode 100644 Extension/Plugins/plugin_api_services.cpp create mode 100644 Extension/Plugins/plugin_api_throwdowns.cpp create mode 100644 Extension/Skater/bone_cam.cpp create mode 100644 Extension/Skater/bone_cam.h create mode 100644 Extension/Skater/bone_sprites.h create mode 100644 Extension/Skater/client_camera_activation.h create mode 100644 Extension/Skater/pad_flight.h create mode 100644 Extension/Skater/skater_push.cpp create mode 100644 Extension/Skater/skater_push.h create mode 100644 Extension/Skater/skater_skeleton.h create mode 100644 Extension/Skater/trick_feed.cpp create mode 100644 Extension/Skater/trick_feed.h create mode 100644 Extension/Skater/trick_gestures.cpp create mode 100644 Extension/Skater/trick_gestures.h create mode 100644 Extension/Throwdowns/challenge_registry.cpp create mode 100644 Extension/Throwdowns/challenge_registry.h delete mode 100644 Extension/Throwdowns/custom_placement.cpp delete mode 100644 Extension/Throwdowns/custom_placement.h create mode 100644 Extension/Throwdowns/native_flag_bubble.h create mode 100644 Extension/Throwdowns/native_flag_cursor_height.h create mode 100644 Extension/Throwdowns/native_flag_preview.h create mode 100644 Extension/Throwdowns/native_flag_session.cpp create mode 100644 Extension/Throwdowns/native_flag_session.h create mode 100644 Extension/Throwdowns/native_flag_spawn.h create mode 100644 Extension/Throwdowns/native_throwdown_lifetime.h create mode 100644 Extension/Throwdowns/session_marker_actions.h create mode 100644 Extension/Throwdowns/throwdown_kit.cpp create mode 100644 Extension/Throwdowns/throwdown_kit.h create mode 100644 Extension/Trainer/trainer_motion.h create mode 100644 Extension/UI/NativeMenu/native_flag_interaction.h create mode 100644 Extension/UI/NativeMenu/native_hud_selection.h create mode 100644 Extension/UI/NativeMenu/native_hud_values.h create mode 100644 Extension/UI/NativeMenu/native_join_context.h create mode 100644 Extension/UI/NativeMenu/native_menu_ownership.h create mode 100644 Extension/UI/NativeMenu/native_setup_roles.h create mode 100644 Extension/UI/NativeMenu/throwdown_kit_hud.inl create mode 100644 Extension/UI/NativeMenu/throwdown_map_identity.inl create mode 100644 Extension/UI/NativeMenu/throwdown_match_panel.inl create mode 100644 Extension/UI/NativeMenu/throwdown_modes_ui.inl create mode 100644 Extension/UI/Overlay/plugin_dialog.cpp create mode 100644 Extension/UI/own_ui_writes.h create mode 100644 Extension/World/guid_table_guard.cpp create mode 100644 Extension/World/guid_table_guard.h create mode 100644 Extension/World/guid_table_probe.h create mode 100644 Extension/World/world_entities.cpp create mode 100644 Extension/World/world_entities.h create mode 100644 Extension/World/world_entities_contract.h create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/gates.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/cue-checkpoint.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/cue-finish.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/cue-invite.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/cue-missed.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/race-finish.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/race-gate.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/race-recovery.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/command.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/core.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/mode_announcements.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/mode_lobby.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/mode_participation.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/placement_camera.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_arrow_placement.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_asset_contract.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_editor.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_group_reconciliation.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_momentum.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_navigation.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_route.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_sound.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/hud.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/lobby.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/oneup/match.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/oneup/native.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/oneup/runtime.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/oneup/spawn.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/oneup/wire.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/placing.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/play.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/referee.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/routes.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/rules.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/rules_tests.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/skatepedia.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/skater.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/tick.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/ui.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/ui_events.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/ui_hud.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/ui_oneup.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/wire.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/world.lua diff --git a/Engine/Game/Build/20260929/guid_table_lookup.h b/Engine/Game/Build/20260929/guid_table_lookup.h new file mode 100644 index 00000000..8d8bbc1b --- /dev/null +++ b/Engine/Game/Build/20260929/guid_table_lookup.h @@ -0,0 +1,20 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include +// find(table, key, a, b): an open-addressed table of u16 record indices (+0x958, sized from the +// record count at +0x7c, at least 16 and 1.5 times it, rounded up to a power of two), probed from +// the key's first dword by (key & 0x3e) | 1. Each index is turned into its record's 16-byte key +// by a virtual call (+0xc0) on the source at +0x18. The probe stops only on a matching key: a key +// that is not there loops forever, the empty slots (0xffff) included. That froze the game joining +// custom-map servers with the Throwdown asset pack installed (key 53c67866-6592-9274-105c- +// eb835e72f8c9, in two hang dumps, 2026-10-17). On a match it goes on to record(table, index, a, b). +namespace dingosdk::game::build::v20260929::guid_table_lookup { +inline constexpr Fingerprint find{0x41d3a50, { + 0x40,0x53,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x83,0xec, + 0x38,0x44,0x8b,0x59,0x7c,0xb8,0x10,0x00,0x00,0x00,0x41,0xd1,0xeb,0x4d,0x8b,0xe8}}; +inline constexpr Fingerprint record_contract{0x41c7df0, { + 0x48,0x89,0x5c,0x24,0x20,0x4c,0x89,0x44,0x24,0x18,0x66,0x89,0x54,0x24,0x10,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x30,0x48,0x8b}}; +inline constexpr std::uintptr_t record = record_contract.rva; +inline constexpr std::uintptr_t count = 0x7c, slots = 0x958, source = 0x18, key_of = 0xc0; +inline constexpr std::uint16_t empty = 0xffff; +} diff --git a/Engine/Game/Build/20260929/native_throwdowns.h b/Engine/Game/Build/20260929/native_throwdowns.h index 5151b2f1..84328d07 100644 --- a/Engine/Game/Build/20260929/native_throwdowns.h +++ b/Engine/Game/Build/20260929/native_throwdowns.h @@ -4,6 +4,11 @@ namespace dingosdk::game::build::v20260929::native_throwdowns { // Hooked functions. +// GetPlayerIsInActivity (6dae8d15). The typed fast thunk calls this same +// implementation, including calls inlined into the activity pause menu. +inline constexpr std::uintptr_t player_in_activity = 0x834520; +inline constexpr std::array player_in_activity_prefix{ + 0x40,0x55,0x48,0x83,0xec,0x20,0x8b,0xe9,0xba,0x89,0x97,0x0f,0xbf,0x48,0x8d,0x4c,0x24,0x38,0xe8}; // Reports whether a throwdown mode exists in the backend catalogue. inline constexpr std::uintptr_t mode_available = 0x807130; inline constexpr std::array mode_available_prefix{ diff --git a/Engine/Game/Build/20260929/no_bail.h b/Engine/Game/Build/20260929/no_bail.h index 1667c049..c8625bd8 100644 --- a/Engine/Game/Build/20260929/no_bail.h +++ b/Engine/Game/Build/20260929/no_bail.h @@ -47,7 +47,7 @@ inline constexpr Fingerprint bail_publish_contract{0x47da450, { // Native consumers: skeleton caller's request test and ABI, animation's // +87 explicit request export, and +8d independent request export. No Bail // leaves the separate native recover/runout output at +9e alone. -inline constexpr std::array bail_consumer_contracts{{ +inline constexpr std::array bail_consumer_contracts{{ {0x47da40b, { 0x48,0x8b,0x8b,0xc0,0x03,0x00,0x00,0xf7,0x81,0xd4,0x13,0x00,0x00,0x00,0x00,0x00, 0x08,0x74,0x05,0x41,0xb0,0x01,0xeb,0x03,0x45,0x32,0xc0,0x48,0x8b,0x83,0xc0,0x03}}, @@ -60,6 +60,11 @@ inline constexpr std::array bail_consumer_contracts{{ {0x47da965, { 0x48,0x8b,0x87,0xc0,0x03,0x00,0x00,0xf7,0x80,0xd4,0x13,0x00,0x00,0x00,0x00,0x00, 0x08,0x74,0x04,0xb0,0x01,0xeb,0x02,0x32,0xc0,0x88,0x83,0x8d,0x00,0x00,0x00,0x48}}, + // The independent failed-landing/runout predicate is published to the + // trick state's animation output (+38) at +9e, including non-ragdoll saves. + {0x47da984, { + 0x48,0x8b,0x87,0xb8,0x03,0x00,0x00,0x48,0x8b,0x48,0x38,0x40,0x88,0xb1,0x9e,0x00, + 0x00,0x00,0x48,0x8b,0x87,0xb8,0x03,0x00,0x00,0x48,0x8b,0x97,0xc0,0x03,0x00,0x00}}, }}; // The sensitive body-contact report bypasses the cause collector. Native // collision processing sets +0x10fe for configured body bones hitting a solid @@ -120,4 +125,6 @@ inline constexpr std::uintptr_t bail_core_vtable = 0x6176d30; // Native recovery/stumble predicate. Its result is also exported to animation // at +0x9e, so No Bail never forces it. Reference only. inline constexpr std::uintptr_t recovery_predicate = 0x47d1100; +inline constexpr std::uintptr_t recovery_animation_output = 0x38; // core+3b8 -> trick state +inline constexpr std::uintptr_t recovery_animation_flag = 0x9e; // animation output, byte } diff --git a/Engine/Game/Build/20260929/skater_entities.h b/Engine/Game/Build/20260929/skater_entities.h index 630a2ad1..1843ed74 100644 --- a/Engine/Game/Build/20260929/skater_entities.h +++ b/Engine/Game/Build/20260929/skater_entities.h @@ -7,6 +7,9 @@ namespace dingosdk::game::build::v20260929::skater_entities { // Blueprint asset TypeInfo. Named character blueprints derive from it. inline constexpr std::uintptr_t blueprint_type = 0x7586ce8; +// ObjectBlueprint ClassInfo: a world object's blueprint (props, checkpoints), a different branch +// of the type hierarchy from the skater Blueprint above. +inline constexpr std::uintptr_t object_blueprint_type = 0x75043a8; // Secondary vtables of the skater animation component (engine::skater_component_vtable) at +0x38 and +0x40. inline constexpr std::array skater_component_base_vtables{0x61705e8, 0x6170650}; // ClientSkaterSource::applyAppearance. It enables the customization component and requests @@ -42,6 +45,12 @@ inline constexpr std::array release_reference_prefix{ inline constexpr std::uintptr_t place_entity = 0x53ed70; inline constexpr std::array place_entity_prefix{ 0x41,0xb1,0x01,0x45,0x0f,0xb6,0xc1,0xe9,0x44,0xfe,0xa3,0x02}; +// Generic SpatialEntity setter used by place_entity. Copies both matrix halves +// into the owned transform collection, then notifies components via +0x108. +inline constexpr std::uintptr_t move_spatial_entity = 0x2f7ebc0; +inline constexpr std::array move_spatial_entity_prefix{ + 0x48,0x83,0xec,0x48,0x4c,0x8b,0x59,0x70,0xc5,0xfc,0x10,0x02, + 0xc5,0xfc,0x10,0x4a,0x20,0x41,0x0f,0xb6,0x53,0x0a}; inline constexpr std::uintptr_t initialize_placement = 0x2f7d670; inline constexpr std::array initialize_placement_prefix{ 0x4c,0x8b,0xdc,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x68,0xf6,0x41,0x28,0x08}; diff --git a/Engine/Game/Input/owned_menu_input.h b/Engine/Game/Input/owned_menu_input.h new file mode 100644 index 00000000..25f5e40f --- /dev/null +++ b/Engine/Game/Input/owned_menu_input.h @@ -0,0 +1,15 @@ +#pragma once +#include +namespace dingosdk::game { +// Keep an opening press hidden until its physical release. Otherwise handing +// input to the newly opened native page replays that same Start and closes it. +constexpr bool owned_menu_release_latch(bool latched,bool capturing,bool down) noexcept { + return down && (capturing || latched); +} +constexpr std::uint16_t owned_menu_buttons(std::uint16_t buttons,bool suppress) noexcept { + return suppress?static_cast(buttons & ~0x0010u):buttons; +} +constexpr bool owned_menu_countdown_capture(bool countdown,bool native_panel) noexcept { + return countdown && !native_panel; +} +} diff --git a/Engine/Game/Multiplayer/session_competition.h b/Engine/Game/Multiplayer/session_competition.h new file mode 100644 index 00000000..7e848587 --- /dev/null +++ b/Engine/Game/Multiplayer/session_competition.h @@ -0,0 +1,14 @@ +#pragma once +#include + +namespace dingosdk { +namespace session_competition_detail { inline std::atomic stock{}; } +// Local participation, including hosts/admins, overrides session permissions. +// Their settings are retained for after Leave. +inline bool competition_stock_physics() noexcept { + return session_competition_detail::stock.load(std::memory_order_acquire); +} +inline void set_competition_stock_physics(bool locked) noexcept { + session_competition_detail::stock.store(locked,std::memory_order_release); +} +} diff --git a/Engine/Game/Multiplayer/session_physics.h b/Engine/Game/Multiplayer/session_physics.h index 18eb4cde..2fad7695 100644 --- a/Engine/Game/Multiplayer/session_physics.h +++ b/Engine/Game/Multiplayer/session_physics.h @@ -9,6 +9,7 @@ #include #include #include +#include "session_competition.h" namespace dingosdk { // Physics a player changes outside Gameplay/SkatePhysicsTuning: the trainer's tuning-class @@ -44,7 +45,7 @@ inline bool take(Box &box, std::uint64_t &seen, std::vector &bytes // physics (the roster's enforce_tuning): their own physics edits stand down, and what the host // shares is theirs. False outside a session and for a host. Set by the session every tick. inline bool session_tuning_enforced() noexcept { - return session_physics_detail::enforced.load(std::memory_order_acquire); + return competition_stock_physics() || session_physics_detail::enforced.load(std::memory_order_acquire); } inline void set_session_tuning_enforced(bool enforced) noexcept { session_physics_detail::enforced.store(enforced, std::memory_order_release); diff --git a/Engine/Game/Multiplayer/session_tools.h b/Engine/Game/Multiplayer/session_tools.h index 3363ff62..eba8fa47 100644 --- a/Engine/Game/Multiplayer/session_tools.h +++ b/Engine/Game/Multiplayer/session_tools.h @@ -1,5 +1,6 @@ #pragma once #include +#include "session_competition.h" namespace dingosdk { // Noclip (and teleporting), No Bail and the forward / up boosts as the session's host lets @@ -9,9 +10,9 @@ namespace session_tools_detail { inline std::atomic noclip{true}, no_bail{true}, boosts{true}; inline std::atomic session{}; } -inline bool session_noclip_allowed() noexcept { return session_tools_detail::noclip.load(std::memory_order_acquire); } -inline bool session_no_bail_allowed() noexcept { return session_tools_detail::no_bail.load(std::memory_order_acquire); } -inline bool session_boosts_allowed() noexcept { return session_tools_detail::boosts.load(std::memory_order_acquire); } +inline bool session_noclip_allowed() noexcept { return !competition_stock_physics() && session_tools_detail::noclip.load(std::memory_order_acquire); } +inline bool session_no_bail_allowed() noexcept { return !competition_stock_physics() && session_tools_detail::no_bail.load(std::memory_order_acquire); } +inline bool session_boosts_allowed() noexcept { return !competition_stock_physics() && session_tools_detail::boosts.load(std::memory_order_acquire); } // Hosting or joined to a multiplayer session (set by the session every tick). Any thread. inline bool multiplayer_session_active() noexcept { return session_tools_detail::session.load(std::memory_order_acquire); } inline void set_multiplayer_session_active(bool active) noexcept { diff --git a/Engine/Game/World/load_completion.h b/Engine/Game/World/load_completion.h new file mode 100644 index 00000000..11294820 --- /dev/null +++ b/Engine/Game/World/load_completion.h @@ -0,0 +1,13 @@ +#pragma once +namespace dingosdk { +// Some stock worlds do not appear in the supplemental sublevel registries. +// An exact destination plus an initialized, controllable local world is an +// independent completion signal. Merely reaching state 13 (the splash also +// uses it), or retaining an actor from a different map, is insufficient. +inline constexpr bool playable_load_complete(bool transitioned, unsigned state, + bool exact_destination, bool context_ready, bool players_available, + unsigned local_players, unsigned controllables) noexcept { + return transitioned && (state == 13 || state == 21) && exact_destination && + context_ready && players_available && local_players == 1 && controllables == 1; +} +} diff --git a/Extension/Customization/local_player_card_runtime.cpp b/Extension/Customization/local_player_card_runtime.cpp index 56a7ac8d..9c22ad29 100644 --- a/Extension/Customization/local_player_card_runtime.cpp +++ b/Extension/Customization/local_player_card_runtime.cpp @@ -6,6 +6,8 @@ #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Build/20260929/local_player_card.h" #include "Extension/Throwdowns/native_throwdowns.h" +#include "Extension/Throwdowns/native_flag_session.h" +#include "Extension/Throwdowns/throwdown_kit.h" #include "Extension/Multiplayer/Steam/steam_social.h" namespace dingosdk::profile_runtime { @@ -211,8 +213,11 @@ bool publish_player_card(const profile::CosmeticLoadout& value) { // In-throwdown / throwdown-host flags. The throwdown Quit action does // nothing unless the first is set; the second is only for the local // player's own drop (a joined one is left, not cancelled). - const bool throwdown = multiplayer::local_throwdown_active(); - const bool host = multiplayer::local_throwdown_host(); + // A plugin match counts when it asks to be an activity (kit::match().in_activity); its + // host is whoever placed its flag. + const bool plugin_match = throwdowns::kit::match().in_activity; + const bool throwdown = multiplayer::local_throwdown_active() || plugin_match; + const bool host = multiplayer::local_throwdown_host() || (plugin_match && multiplayer::native_flag::hosting()); static bool logged_state{}; if (const auto field = n.field(model, handle, 11, UINT32_MAX, false)) n.publish(model, field, s.base + addr::engine::bool_type, &throwdown); diff --git a/Extension/Multiplayer/Hud/native_party.cpp b/Extension/Multiplayer/Hud/native_party.cpp index 6a175c17..fb9e73a0 100644 --- a/Extension/Multiplayer/Hud/native_party.cpp +++ b/Extension/Multiplayer/Hud/native_party.cpp @@ -512,6 +512,15 @@ std::string native_party_status() { auto &s = state(); std::lock_guard lock(s.status_mutex); return s.status + " Map party markers: " + std::to_string(s.map_count.load()) + "."; } +std::uint64_t native_party_player_info_by_id(std::uintptr_t manager, std::uint64_t id) noexcept { + try { + const auto published=state().published.load(std::memory_order_acquire); + if(!published || published->manager!=manager)return 0; + const auto record=published->record(id); + if(record && game::native_data().models.value(manager,record->handle,0,0))return record->handle; + } catch(...) {} + return 0; +} void set_native_party_map_markers(bool enabled) noexcept { state().map_markers.store(enabled, std::memory_order_release); } bool native_party_map_markers() noexcept { return state().map_markers.load(std::memory_order_acquire); } bool native_party_map_icon_visible(std::size_t slot) noexcept { diff --git a/Extension/Multiplayer/Hud/native_party.h b/Extension/Multiplayer/Hud/native_party.h index 7fbd0f17..064ad1d2 100644 --- a/Extension/Multiplayer/Hud/native_party.h +++ b/Extension/Multiplayer/Hud/native_party.h @@ -42,6 +42,8 @@ void spectate_party_member(std::uint64_t id) noexcept; bool party_spectating() noexcept; // Returns only a record owned by this adapter, validated against this manager. std::uint64_t native_party_player_info(std::uintptr_t manager, std::size_t slot) noexcept; +// Lookup by session identity for native activity leaderboard rows. +std::uint64_t native_party_player_info_by_id(std::uintptr_t manager, std::uint64_t id) noexcept; std::string native_party_status(); // Whether players can form parties: in a session, a lobby or a dedicated server. Outside one // the game's party buttons are refused. diff --git a/Extension/Multiplayer/Hud/native_party_hooks.cpp b/Extension/Multiplayer/Hud/native_party_hooks.cpp index 3c3e77a9..d47fdfa4 100644 --- a/Extension/Multiplayer/Hud/native_party_hooks.cpp +++ b/Extension/Multiplayer/Hud/native_party_hooks.cpp @@ -4,6 +4,7 @@ #include "Extension/Skater/client_source_spawn.h" #include "Extension/Throwdowns/native_type_scan.h" #include "Extension/Throwdowns/throwdown_relay.h" +#include "Extension/Throwdowns/throwdown_kit.h" #include "Engine/Core/Platform/memory.h" #include "follow_camera.h" #include "Extension/Customization/local_player_card_runtime.h" @@ -557,6 +558,14 @@ std::uint32_t teleport_hook(Address reason, const void *matrix, Address destinat // lip mount. While the local player waits for their S.K.A.T.E. turn, the turn start's // teleport of the local skater (id 0) lands it on foot. Kept in a static: the native // copies the options into its components, but must never see a dead buffer. + // A plugin match's countdown (kit::match().input_lock) lands the skater on the board. + if(!id && stance && dingosdk::throwdowns::kit::match().input_lock) { + static thread_local std::array on_board{}; + if(memory::peek_bytes(stance,on_board.data(),on_board.size())) { + on_board[0]=1; + return state().teleport(reason,matrix,destination,id,mode,reinterpret_cast
(on_board.data()),transition); + } + } if (!id && stance && throwdown_relay_waits_offboard()) { static thread_local std::array on_foot{}; if (memory::peek_bytes(stance, on_foot.data(), on_foot.size()) && on_foot[0]) { diff --git a/Extension/Multiplayer/Remote/native_skater.cpp b/Extension/Multiplayer/Remote/native_skater.cpp index 1e072604..5f298dee 100644 --- a/Extension/Multiplayer/Remote/native_skater.cpp +++ b/Extension/Multiplayer/Remote/native_skater.cpp @@ -1,6 +1,7 @@ #include "native_skater_internal.h" #include "native_pose_layout.h" #include "puppet_cost.h" +#include "Extension/World/world_entities.h" #include "Extension/Multiplayer/Session/monotonic_clock.h" #include "Extension/Multiplayer/Session/peer_slots.h" #include "Extension/Skater/board_pose.h" @@ -392,6 +393,7 @@ void animation_hook(std::uintptr_t component, std::uintptr_t update) { listener(component); } void destroy_hook(std::uintptr_t entity, std::uintptr_t owner) { + world_entities::entity_destroyed(entity); if (const auto listener = shared().destroyed_listener.load(std::memory_order_acquire)) listener(entity); // The first slot watching it as either skater or board. diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index a85d8136..54558cab 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -1,4 +1,7 @@ #include "session_internal.h" +#include "Extension/Throwdowns/throwdown_kit.h" +#include "Extension/Skater/skater_push.h" +#include "Extension/Throwdowns/throwdown_lab.h" #include "Engine/Core/Hooks/hooks.h" #include "Extension/Plugins/plugin_runtime.h" #include "Extension/Plugins/plugin_wire.h" @@ -358,7 +361,7 @@ void publish_party(Session &s) { // every tick, with no session too, so linked drops are removed when it ends. The input // persists: its player list is rebuilt when the admitted players change, and names // (Steam lookups) are refreshed once a second. -void relay_throwdowns(Session &s, bool in_world) { +void relay_throwdowns(Session &s, bool in_world, const NativeFrame* /*offline_skater*/ = nullptr) { auto &input = s.throwdown_input; const bool in_session = s.mode == Mode::host || s.mode == Mode::join; const auto local = in_session ? s.transport.status().local_id : 0; @@ -406,11 +409,17 @@ void relay_throwdowns(Session &s, bool in_world) { d2 = (other->position[0] - me->position[0]) * (other->position[0] - me->position[0]) + (other->position[1] - me->position[1]) * (other->position[1] - me->position[1]) + (other->position[2] - me->position[2]) * (other->position[2] - me->position[2]); + peer.position = other ? std::optional(other->position) : std::nullopt; peer.nearby = d2 < 60.f * 60.f; peer.party = party_member(s, peer.id); } - if (names) + if (names) { input.local_name = in_session ? s.transport.name(local) : std::string(steam_social_snapshot()->local.name); + if(!in_session) { + const auto card=local_profile_player_card(); + if(!card.custom_name.empty())input.local_name=card.custom_name; + } + } // Plugin messages ride in throwdown packets too, told apart by their first byte // (Extension/Plugins/plugin_wire.h); they go to the plugins, never to the relay. for (const auto &[sender, message] : s.throwdown_inbox) { @@ -619,8 +628,9 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board, mark_styles(p)); present_audio(p); - update_party_position(&p.render_pose); - if (labels) label(p); + update_party_position(throwdowns::kit::hidden(p.member.id)?nullptr:&p.render_pose); + if(throwdowns::kit::hidden(p.member.id)) {update_player_ui(s.base,nullptr,{});p.ui_visible=false;} + if (labels && !throwdowns::kit::hidden(p.member.id)) label(p); return; } p.visible = false; @@ -645,7 +655,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui native_pass = true; p.applied_cosmetics = 0; // the new actor wears no recipe yet } - const bool hidden = throwdown_relay_hides(p.member.id); + const bool hidden = throwdown_relay_hides(p.member.id) || throwdowns::kit::hidden(p.member.id); const auto spot = hidden ? std::nullopt : throwdown_relay_celebration_offset(p.member.id, p.render_pose.root.position); p.visible = show_remote(s.base, client, local, @@ -680,9 +690,11 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui apply_cosmetics(p, spawning); // The spectate camera follows this every frame; the map and // indicators below only need network-rate updates. - update_party_position(&p.render_pose); - if (labels) label(p); - if (!p.ui_visible || now >= p.next_ui_update) { + update_party_position(throwdowns::kit::hidden(p.member.id)?nullptr:&p.render_pose); + if (labels && !throwdowns::kit::hidden(p.member.id)) label(p); + if(throwdowns::kit::hidden(p.member.id)) { + update_player_ui(s.base,nullptr,{});p.ui_visible=false; + } else if (!p.ui_visible || now >= p.next_ui_update) { update_player_ui(s.base, &p.render_pose, p.member.name); p.ui_visible = true; p.next_ui_update = now + network_tick_us; @@ -970,9 +982,19 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_vi erase_password(s.lobby_password); expire_remote_collision(base, now_us()); if (s.mode == Mode::off) { - physics_tuning::release(base); // the player's own tuning again after a session + if (!competition_stock_physics()) physics_tuning::release(base); set_session_tuning_enforced(false); - relay_throwdowns(s, ready); + NativeFrame solo; + if(ready && !map_name.empty()) { + try {solo=capture_local(base,client,false);if(solo.ready)note_local_skater(solo.pose.root);} + catch(...) {} + } + relay_throwdowns(s, ready, &solo); + if (competition_stock_physics() && solo.ready) { + physics_tuning::prepare();physics_tuning::enforce(base,solo.entity,{}); + } else if (!competition_stock_physics()) physics_tuning::release(base); + if (solo.ready && (throwdowns::kit::match().input_lock || skater_push::holding())) + prepare_on_board_teleport(client, solo.entity); // Without a session the UI model follows at the same 10 Hz as in one; // commands still publish at once. if (const auto now = now_us(); now >= s.next_publish) { @@ -1071,9 +1093,13 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_vi networking(s, local, receive_at); for (const auto ¬e : s.transport.take_direct_notes()) logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: {}", note); - relay_throwdowns(s, world_playing(s, local)); + // A menu or respawn can suspend the pose capture while this world and + // its Throwdown queue remain loaded. Do not cancel their coordinators. + relay_throwdowns(s, s.gameplay_ready && !s.travelling && !s.awaiting_map && s.host_world_ready); // A S.K.A.T.E. player waiting for their turn stays off the board. - update_board_lock(client, local.entity, local.ready && throwdown_relay_waits_offboard()); + update_board_lock(client, local.entity, local.ready && (throwdown_relay_waits_offboard() || throwdowns::kit::match().offboard)); + if (local.ready && (throwdowns::kit::match().input_lock || skater_push::holding())) + prepare_on_board_teleport(client, local.entity); update_physics_tuning(s, local, now); const auto send_at = now_us(); s.client_timing.record(ClientTiming::network, receive_at, send_at); diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index 2ae2f3f9..b3a535bb 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -430,6 +430,10 @@ std::string edit_player_distance(Session &s, std::string_view argument) { : "Players within " + std::to_string(static_cast(value)) + " m are shown as skaters."; } std::string start_pose_dump(Session &s, std::string_view argument) { +#if defined(DINGOSDK_LEAN_PLAYTEST) + (void)s; (void)argument; + return "Pose recording is not included in this playtest build."; +#else int seconds{}; const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), seconds); if (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || seconds < 1 || seconds > 600) @@ -446,6 +450,7 @@ std::string start_pose_dump(Session &s, std::string_view argument) { s.pose_dump_until = now_us() + static_cast(seconds) * 1000000; s.pose_dump_count = 0; return "Recording your poses for " + std::to_string(seconds) + " s to " + path_utf8(file) + ". Skate as you normally would."; +#endif } std::string edit_direct_connections(Session &s, std::string_view argument) { const auto enabled = parse_switch(argument, s.prefer_direct); diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 4c2833cb..5ea156d1 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -575,6 +575,11 @@ void update_physics_tuning(Session &s, const NativeFrame &local, std::uint64_t n set_session_tuning_enforced(enforced); if (enforced && s.host_extras && !dedicated_host(s)) set_host_physics_extras(*s.host_extras); else set_host_physics_extras({}); + if (competition_stock_physics()) { + physics_tuning::prepare(); + if (world_playing(s,local)) physics_tuning::enforce(s.base,local.entity,{}); + return; + } if (s.mode == Mode::host) { physics_tuning::release(s.base); if (!s.enforce_tuning) { diff --git a/Extension/Multiplayer/Steam/steam_social.cpp b/Extension/Multiplayer/Steam/steam_social.cpp index 04286ddc..99d310bc 100644 --- a/Extension/Multiplayer/Steam/steam_social.cpp +++ b/Extension/Multiplayer/Steam/steam_social.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -24,6 +25,35 @@ std::string name(const char *value) { if (static_cast(value[i]) >= 32 && value[i] != 127) result += value[i]; return result; } +// Offline still has a Steam account identity. Resolve that account's cached +// persona, never a different account's login name or a hard-coded player name. +std::string offline_persona_name() { + const auto incoming_error=GetLastError(); + struct RestoreError { DWORD value; ~RestoreError(){SetLastError(value);} } restore{incoming_error}; + try { + wchar_t path[32768]{}; DWORD bytes=sizeof(path); + if(RegGetValueW(HKEY_CURRENT_USER,L"Software\\Valve\\Steam",L"SteamPath",RRF_RT_REG_SZ,nullptr,path,&bytes)!=ERROR_SUCCESS)return {}; + std::ifstream file(std::filesystem::path(path)/L"config"/L"loginusers.vdf"); + const auto wanted=std::to_string(launcher::offline_steam_id()); + bool account{}; + for(std::string line;std::getline(file,line);) { + std::vector tokens; + for(std::size_t i=0;i steam_social_snapshot() { next = now + 10000; SteamSocialSnapshot result; if (launcher::offline_mode()) { - result.local = {launcher::offline_steam_id(), launcher::offline_player_name, false, true}; + auto persona=offline_persona_name(); + if(persona.empty() && current->local.id==launcher::offline_steam_id() && current->local.name!=launcher::offline_player_name) + persona=current->local.name; + result.local = {launcher::offline_steam_id(), persona.empty()?launcher::offline_player_name:persona, false, true}; if (result.local != current->local) { result.revision = current->revision + 1; current = std::make_shared(std::move(result)); diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index f524b548..a315fd0e 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -725,18 +725,18 @@ void throwdown_codec() { ThrowdownMessage m; m.kind = kind; m.leader = leader; m.id = 5; check(decode_throwdown(encode_throwdown(m)) == m, "Throwdown control message failed to round-trip"); } - // A plugin mode's flag. + // An older client's plugin mode tag (still decoded, never sent). ThrowdownMessage mode; mode.kind = ThrowdownMessage::Kind::mode; mode.leader = leader; mode.id = 0x7c8bf0b2; - mode.challenge = "SmokeyDawg58-ReSkate_Throwdown/oneup"; mode.series = "oneup"; mode.value = 6; + mode.challenge = "demo/duel"; mode.series = "duel"; mode.value = 6; check(decode_throwdown(encode_throwdown(mode)) == mode, "Throwdown mode failed to round-trip"); auto stock_flag = mode; stock_flag.series.clear(); check(decode_throwdown(encode_throwdown(stock_flag)) == stock_flag, "A mode on Spot Battle's flag failed to round-trip"); - auto no_plugin = mode; no_plugin.challenge = "oneup"; + auto no_plugin = mode; no_plugin.challenge = "duel"; check(!valid_throwdown(no_plugin), "A mode without its plugin was valid"); auto crowded = mode; crowded.value = 11; check(!valid_throwdown(crowded), "A mode for 11 players was valid"); - auto spaced_flag = mode; spaced_flag.series = "one up"; + auto spaced_flag = mode; spaced_flag.series = "du el"; check(!valid_throwdown(spaced_flag), "A flag name with a space was valid"); // Coop challenges. ThrowdownMessage challenge; diff --git a/Extension/Multiplayer/console_commands.cpp b/Extension/Multiplayer/console_commands.cpp index 96c7e4f0..405415d6 100644 --- a/Extension/Multiplayer/console_commands.cpp +++ b/Extension/Multiplayer/console_commands.cpp @@ -9,6 +9,7 @@ #include "Extension/Throwdowns/throwdown_relay.h" #include "Extension/UI/NativeMenu/native_menu_dump.h" #include "Extension/Assets/live_mods.h" +#include "Engine/Game/Multiplayer/session_tools.h" #include #include @@ -222,10 +223,11 @@ void register_multiplayer_commands(Commands ®istry) { auto lab_args = argument("verb_and_arguments", Type::text, true); lab_args.rest = true; lab_args.complete = [](const Model &, auto) { - return std::vector{"status", "ai", "spawn", "join", "start", "destroy", "mmid", "trace", "endturn", "score", "mirror", "attempt", "relay"}; + return std::vector{"status", "ai", "spawn", "join", "start", "solo", "destroy", "mmid", "trace", "endturn", "score", "mirror", "attempt", "relay"}; }; auto lab = action("throwdown", "Throwdown lab: add virtual participants, spawn/start/destroy queues (research aid)", Group::console, {std::move(lab_args)}); + lab.aliases={"dingo throwdown"}; lab.run = [](const Model &model, const Values &values, const Output &out) { std::optional> skater; if (model.debug.skater_position_valid) skater = model.debug.skater_position; diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.cpp b/Extension/Objects/ParkEditor/park_editor_runtime.cpp index f76042b6..a35986a3 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.cpp +++ b/Extension/Objects/ParkEditor/park_editor_runtime.cpp @@ -256,6 +256,47 @@ void move_existing(std::uint64_t entity, const profile::PlacedObject &object) { } e.transform_move(query.data(), pose.data(), source); } +struct WorldRays { + struct Ray { + std::array origin{}, end{}; + }; + std::mutex mutex; + std::map pending; + std::map answers; +}; +WorldRays &world_rays() { + static auto *value = new WorldRays; + return *value; +} +// Casts the queued outside rays on the client update, where the client physics context resolves. +void cast_world_rays() { + auto &w = world_rays(); + std::map rays; + { + std::lock_guard lock(w.mutex); + rays.swap(w.pending); + } + if (rays.empty()) + return; + auto &e = editor_state(); + auto &r = placements_runtime(); + std::array context{}; + if (r.context) + r.context(context.data()); + const auto world = e.surface_api.ready && context[0] ? e.surface_api.world(context[0]) : 0; + const auto now = GetTickCount64(); + for (const auto &[id, ray] : rays) { + WorldRayHit answer{true, false, {}, now}; + answer.available = world != 0; + if (world) + if (const auto hit = editor::cast_surface(e.surface_api, world, ray.origin, ray.end)) { + answer.hit = true; + answer.at = *hit; + } + std::lock_guard lock(w.mutex); + w.answers[id] = answer; + } +} } // namespace void initialize_park_editor(const std::filesystem::path &directory) { auto &e = editor_state(); @@ -289,6 +330,12 @@ void set_park_mods_root(const std::filesystem::path &data_root) { } void reset_park_editor() { auto &e = editor_state(); + { + auto &w = world_rays(); + std::lock_guard lock(w.mutex); + w.pending.clear(); + w.answers.clear(); + } ++e.generation; ++e.revision; e.transaction.reset(); @@ -634,12 +681,46 @@ void update_park_editor() { } // namespace dingosdk::profile_runtime namespace dingosdk { using namespace profile_runtime; +void queue_world_ray(std::uint32_t id, const std::array &origin, const std::array &direction, float length) { + for (int i = 0; i < 3; ++i) + if (!std::isfinite(origin[i]) || !std::isfinite(direction[i]) || std::abs(origin[i]) > 100000) + return; + const float size = std::sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]); + if (size < 0.5f || !(length > 0) || length > 2000) + return; + std::array end{}; + for (int i = 0; i < 3; ++i) + end[i] = origin[i] + direction[i] / size * length; + auto &w = world_rays(); + std::lock_guard lock(w.mutex); + w.pending[id] = {origin, end}; +} +WorldRayHit world_ray(std::uint32_t id) { + auto &w = world_rays(); + std::lock_guard lock(w.mutex); + const auto it = w.answers.find(id); + return it == w.answers.end() ? WorldRayHit{} : it->second; +} +void tick_local_world_rays() noexcept { + PreserveError preserve; + try { + std::lock_guard lock(local_runtime().native_mutex); + if (!local_runtime().active || cosmetic_runtime().update_thread != GetCurrentThreadId()) + return; + cast_world_rays(); + } catch (...) { + // A mode's optional aim query must not fail or disable park editing. + } +} void tick_local_park_editor() noexcept { PreserveError preserve; try { std::lock_guard lock(local_runtime().native_mutex); if (!local_runtime().active || cosmetic_runtime().update_thread != GetCurrentThreadId()) return; + // Mode placement uses the client physics world even before the park + // placement store/server manager becomes ready. + cast_world_rays(); update_placement_restore(); } catch (...) { std::lock_guard lock(local_runtime().native_mutex); diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.h b/Extension/Objects/ParkEditor/park_editor_runtime.h index 6facc2c2..092313ea 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.h +++ b/Extension/Objects/ParkEditor/park_editor_runtime.h @@ -32,6 +32,19 @@ bool queue_local_park_paste(const EditorPasteRequest &); bool queue_local_park_object_set(const EditorObjectSetRequest &); bool queue_local_park_delete(const EditorDeleteRequest &); void tick_local_park_editor() noexcept; +struct WorldRayHit { + bool cast{}, hit{}; + std::array at{}; + std::uint64_t when{}; // GetTickCount64 of the cast + bool available{}; // Native client collision API and world were ready. +}; +void queue_world_ray(std::uint32_t id, const std::array &origin, const std::array &direction, float length); +WorldRayHit world_ray(std::uint32_t id); +// Client collision queries used by mode placement do not require the park +// editor, its placement store, or the server object manager to be enabled. +// Runs only on the verified local client update thread. +void tick_local_world_rays() noexcept; + // Invoked through the console's verified game-thread dispatcher. `texts` // carries a park mod's name, author, version and description (mod-create: // all four; mod-details: the same after the folder in `argument`). diff --git a/Extension/Objects/local_placements_runtime.cpp b/Extension/Objects/local_placements_runtime.cpp index 7fd21167..2935db76 100644 --- a/Extension/Objects/local_placements_runtime.cpp +++ b/Extension/Objects/local_placements_runtime.cpp @@ -630,18 +630,18 @@ void update_position_teleport() { if (!r.teleport || !r.transition_ctor || !r.transition_destroy || !placement_client_channel() || !read(local_runtime().base + addr::local_placements::teleport_manager, manager) || !manager || !read(manager, state) || state != 0) return; - const auto at = *r.position_teleport; - const auto heading = r.position_teleport_heading; // set only by teleport_local_skater_heading + alignas(16) const auto transform = *r.position_teleport; + const std::array at{transform[12],transform[13],transform[14]}; r.position_teleport.reset(); - r.position_teleport_heading.reset(); - // Upright at the spot. The native teleport manager handles streaming, ground checks and physics reset. - // With a heading h the root's rows are right (cos h, 0, -sin h), up, forward (sin h, 0, cos h): - // h = 0 is the identity, facing +Z (measured: the skater faces h within 0.002 degrees). - const float sh = heading ? std::sin(*heading) : 0.0f, ch = heading ? std::cos(*heading) : 1.0f; - alignas(16) const std::array transform{ch,0,-sh,0, 0,1,0,0, sh,0,ch,0, at[0], at[1], at[2], 1}; + // Preserve the native flag's complete facing basis. The teleport manager + // still handles streaming, ground checks and physics reset. alignas(16) std::array transition{}; r.transition_ctor(transition.data()); struct Destroy { decltype(r.transition_destroy) call; void* value; ~Destroy() { call(value); } } destroy{r.transition_destroy, transition.data()}; + // The local teleport manager works in offline play. RequestTeleport's + // social/player lookup can reject the same local skater with UINT32_MAX. + // The session tick prepares the validated skater's native board option + // before this queued move, so this path also starts a plugin's match on the board. const auto request = r.teleport(manager, transform.data(), 1, transition.data()); dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{{"event", "position_teleport_requested"}, {"x", at[0]}, {"y", at[1]}, {"z", at[2]}, {"request", request}}.dump().c_str()); diff --git a/Extension/Objects/local_placements_runtime.h b/Extension/Objects/local_placements_runtime.h index 197c8faa..79820b94 100644 --- a/Extension/Objects/local_placements_runtime.h +++ b/Extension/Objects/local_placements_runtime.h @@ -76,11 +76,10 @@ struct PlacementsRuntime { std::vector rows; std::uint64_t next_token{1}, teleport_token{}, teleport_queued_at{}; // A teleport of the local skater to a position (console tp, the host's tpall). - std::optional> position_teleport; + std::optional> position_teleport; std::uint64_t position_teleport_at{}; // The heading the pending position teleport faces (teleport_local_skater_heading), radians about // world up; unset means the identity rotation the other teleports use (facing +Z). - std::optional position_teleport_heading; std::optional inflight; std::uint64_t sent_at{}, next_poll{}, map_since{}, map_generation{}, next_id{1}; bool failed{}, restore_failed{}; diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 91b73778..1df13956 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -4,6 +4,9 @@ #include "Extension/Plugins/plugin_host.h" #include "Extension/Plugins/plugin_manifest.h" #include "Extension/Plugins/plugin_wire.h" +#include "Extension/Throwdowns/challenge_registry.h" +#include "Extension/Throwdowns/custom_modes.h" +#include "Extension/Throwdowns/throwdown_kit.h" #include "Engine/Vfs/mod_list.h" #include #include @@ -58,6 +61,30 @@ struct FakeGame final : Game { } std::string board_hide(std::string_view plugin, int bone) override { calls.push_back(std::format("board hide {} {}", plugin, bone)); return {}; } void board_park(std::string_view plugin, std::vector bones) override { calls.push_back(std::format("board park {} {}", plugin, bones.size())); } + // The general services a game layer in Lua reads and drives (plugin_api_services.cpp). + std::optional telemetry; + std::optional skater_telemetry() override { return telemetry; } + std::uint64_t world_id() override { return 77; } + std::string world_map() override { return "test_map"; } + std::string pad_flight(std::string_view plugin, bool on) override { + calls.push_back(std::format("pad flight {} {}", plugin, on)); + return {}; + } + void competition(std::string_view plugin, bool on) override { calls.push_back(std::format("competition {} {}", plugin, on)); } + bool stock_physics_ready() override { return true; } + void prepare_physics() override { calls.push_back("prepare physics"); } + void trick_feed(std::string_view plugin, bool on) override { calls.push_back(std::format("trick feed {} {}", plugin, on)); } + std::vector> trick_gesture(std::string_view trick) override { + if (trick == "Kickflip") return {{0, 0}, {0.5f, 1}}; + return {}; + } + std::string bone_cam(std::string_view plugin, const std::vector& words) override { + std::string line = "bone cam " + std::string(plugin); + for (const auto& w : words) line += " " + w; + calls.push_back(line); + if (words.empty()) return "game"; + return words.front() == "ringing" ? "1" : ""; + } std::string board_toss(std::string_view plugin, std::vector bones, const Vec3& velocity, const Vec3&, float ground) override { calls.push_back(std::format("board toss {} {} {:.0f} {:.0f}", plugin, bones.size(), velocity[1], ground)); return {}; @@ -102,6 +129,24 @@ struct FakeGame final : Game { calls.push_back(std::format("{} {} {} {:.1f}", op, o.id, o.item, o.position[0])); return {}; } + // 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 { + 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" : "")); + return {}; + } + void entity_remove(std::string_view plugin, std::uint32_t id) override { entities.erase({std::string(plugin), id}); } + void entity_clear(std::string_view plugin) override { + std::erase_if(entities, [&](const auto& e) { return e.first.first == plugin; }); + calls.push_back("entities clear " + std::string(plugin)); + } + int entity_status(std::string_view plugin, std::uint32_t id) override { + const auto found = entities.find({std::string(plugin), id}); + return found == entities.end() ? 0 : found->second.first.ends_with(" hidden") ? 5 : 4; + } + bool entities_ready(std::string_view) override { return true; } std::string set_environment(std::string_view kind, std::string_view key, std::string_view value) override { calls.push_back(std::format("env {} {} {}", kind, key, value)); return key == "never" ? "refused" : ""; @@ -181,16 +226,85 @@ struct FakeGame final : Game { } std::string throwdown_status(std::string_view) override { return "open"; } std::vector modes; - std::vector mode_news; std::string set_throwdown_mode(std::string_view plugin, const ThrowdownMode& mode) override { - calls.push_back(std::format("mode {} {} {} rows {}{}", plugin, mode.id, mode.name, mode.rows.size(), mode.timer ? " timer" : "")); + calls.push_back(std::format("mode {} {} {}", plugin, mode.id, mode.name)); std::erase_if(modes, [&](const auto& m) { return m.id == mode.id; }); modes.push_back(mode); return {}; } void remove_throwdown_mode(std::string_view plugin, std::string_view id) override { calls.push_back(std::format("remove mode {} {}", plugin, id)); } - void release_throwdown_flag(std::string_view plugin) override { calls.push_back(std::format("release flag {}", plugin)); } - std::vector take_throwdown_news(std::string_view) override { return std::exchange(mode_news, {}); } + // The throwdown kit: documents go to the real store (so the tests read them parsed); the flag is + // the fake's own (kit::flag is the game's). + int kit_documents{}; + std::string publish_throwdown_ui(std::string_view plugin, const dingosdk::Json& document) override { + ++kit_documents; + return dingosdk::throwdowns::kit::publish(plugin, document); + } + dingosdk::throwdowns::kit::flag::State flag; + bool throwdown_flag_begin(std::string_view mode, std::uint32_t game, const dingosdk::throwdowns::kit::flag::Options& o) override { + calls.push_back(std::format("flag begin {} {} {} {} {} {:.1f}", mode, game, o.players, o.seconds, o.setup_page, o.cursor_height)); + if (flag.busy) return false; + flag = {true, false, true, true, std::string(mode), game}; + return true; + } + dingosdk::throwdowns::kit::flag::State throwdown_flag_state() override { return flag; } + void throwdown_flag_leave(std::string_view plugin) override { + calls.push_back(std::format("flag leave {}", plugin)); + if (flag.mode.starts_with(std::string(plugin) + "/")) flag = {}; + } + // A plugin's data folder, as a map by "/". + std::map files; + std::string files_list(std::string_view plugin, std::string_view folder, std::vector& names) override { + const auto prefix = std::string(plugin) + "/" + (folder.empty() ? "" : std::string(folder) + "/"); + for (const auto& [path, text] : files) { + if (!path.starts_with(prefix)) continue; + auto rest = path.substr(prefix.size()); + if (const auto slash = rest.find('/'); slash != std::string::npos) rest = rest.substr(0, slash + 1); + if (std::find(names.begin(), names.end(), rest) == names.end()) names.push_back(rest); + } + return {}; + } + std::string files_read(std::string_view plugin, std::string_view path, std::string& text) override { + const auto found = files.find(std::string(plugin) + "/" + std::string(path)); + if (found == files.end()) return "not found"; + text = found->second; + return {}; + } + std::string files_write(std::string_view plugin, std::string_view path, std::string_view text) override { + files[std::string(plugin) + "/" + std::string(path)] = std::string(text); + return {}; + } + std::string files_remove(std::string_view plugin, std::string_view path) override { + return files.erase(std::string(plugin) + "/" + std::string(path)) ? std::string{} : std::string("not found"); + } + std::optional dialog; + std::string dialog_open(std::string_view plugin, const DialogSpec& spec) override { + calls.push_back(std::format("dialog {} {} rows {} buttons {}", plugin, spec.id, spec.rows.size(), spec.buttons.size())); + dialog = spec; + return {}; + } + // SmokeyDawg58's editor reads the pad and the window's focus. + std::optional pad_bits_now; + InputFocus focus_now{}; + std::optional pad_bits(bool) override { return pad_bits_now; } + InputFocus input_focus() override { return focus_now; } + void dialog_close(std::string_view plugin, std::string_view id) override { calls.push_back(std::format("dialog close {} {}", plugin, id)); } + std::string skater_push(std::string_view plugin, const Vec3& v) override { + calls.push_back(std::format("push {} {:.0f} {:.0f} {:.0f}", plugin, v[0], v[1], v[2])); + return {}; + } + std::string skater_push_wait(std::string_view plugin) override { + calls.push_back(std::format("push wait {}", plugin)); + return {}; + } + std::string skater_push_reset(std::string_view plugin) override { + calls.push_back(std::format("push reset {}", plugin)); + return {}; + } + std::string skater_hold(std::string_view plugin, bool on) override { + calls.push_back(std::format("hold {} {}", plugin, on)); + return {}; + } std::string kick(std::uint64_t player, std::string_view why) override { calls.push_back(std::format("kick {} {}", player, why)); return {}; } std::string announce(std::string_view plugin, std::string_view text) override { calls.push_back(std::format("announce {} {}", plugin, text)); @@ -217,7 +331,7 @@ struct FakeGame final : Game { return {}; } std::vector> take_presses() override { return std::exchange(presses, {}); } - void release(std::string_view plugin) override { released.emplace_back(plugin); } + void release(std::string_view plugin) override { released.emplace_back(plugin); entity_clear(plugin); } bool called(std::string_view part) const { return std::any_of(calls.begin(), calls.end(), [&](const auto& c) { return c.find(part) != std::string::npos; }); } @@ -784,6 +898,42 @@ void plugins_draw_pictures() { "and in the world, so many metres tall, a part of it"); } +void plugins_place_world_entities() { + TestEnvironment environment; + auto& game = environment.fake; + Host host(environment, Side::client); + const std::string main = R"( + local gate = "World/Props/Gate_Arch" + assert(reskate.entities.set(1, gate, {position = vector.create(1, 2, 3), yaw = 90, scale = 2})) + assert(reskate.entities.set(2, gate, {position = vector.create(0, 0, 0), right = vector.create(1, 0, 0), + up = vector.create(0, 1, 0), forward = vector.create(0, 0, 3)})) + assert(reskate.entities.set(3, gate, {matrix = {1,0,0,0, 0,1,0,0, 0,0,1,0, 7,8,9,1}}, true)) + assert(reskate.entities.status(1) == "ready" and reskate.entities.status(3) == "hidden" and reskate.entities.status(9) == nil) + 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") + reskate.entities.remove(2) + assert(reskate.entities.status(2) == nil and reskate.entities.ready()) + reskate.log("placed") + )"; + host.apply({package("props", {{manifest_file, manifest(R"("world.entities")")}, {"plugin/main.lua", main}})}); + ticks(host, environment, 1); + running(host, "props", environment); + check(environment.logged("[props] placed"), "A plugin places world objects\n" + environment.all()); + // Yaw 90: forward is +x (sin, 0, cos), scaled 2; the axes and the matrix come as given. + 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"); + host.apply({}); + check(game.called("entities clear props"), "A plugin's objects go with it"); + // Without the permission, refused. + host.apply({package("sneaky", {{manifest_file, manifest()}, {"plugin/main.lua", R"( + reskate.log(tostring(pcall(reskate.entities.set, 1, "x", {position = vector.create(0, 0, 0)}))) + )"}})}); + ticks(host, environment, 1); + check(environment.logged("[sneaky] false"), "world.entities is needed\n" + environment.all()); +} + void plugins_draw_on_the_overlay() { TestEnvironment environment; Host host(environment, Side::client); @@ -1746,47 +1896,463 @@ void plugins_add_throwdown_modes() { auto& game = environment.fake; Host host(environment, Side::client); host.apply({script("modes", R"( - assert(reskate.throwdowns.mode{id = "oneup", name = "1-UP", description = "Beat the last total.", art = "OneUp", flag = "oneup", replaces = "skate", - players = {min = 1, max = 6, default = 2}, timer = {caption = "Turn", min = 10, max = 120, step = 10, default = 20}, - rows = {{id = "letters", caption = "Letters", min = 1, max = 7, default = 3}, - {id = "tricks", kind = "choice", caption = "Tricks", options = {"All", "Flips"}, default = 2}, - {id = "area", kind = "button", caption = "Area", text = "Set area"}}}) - assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", rows = {{id = "r", kind = "dial"}}}), "a row kind") + assert(reskate.throwdowns.mode{id = "duel", name = "Duel", description = "Beat the last total.", art = "Duel", flag = "duel", replaces = "skate", + players = {min = 1, max = 6, default = 2}, cursor_height = 99999}) assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", flag = "two words"}), "a flag name") assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", replaces = "bowling"}), "a stock throwdown") - reskate.on("throwdown_mode", function(e) - reskate.log(e.mode .. " " .. e.action .. " " .. tostring(e.settings.players) .. " " .. tostring(e.settings.tricks) - .. (e.position and string.format(" at %.0f %.0f %.0f facing %.0f", e.position[1], e.position[2], e.position[3], e.heading) or "")) - if e.players then reskate.log(e.mode .. " players " .. table.concat(e.players, ",") .. " led by " .. e.leader .. (e.host and " here" or " elsewhere")) end - if e.action == "start" then reskate.throwdowns.release_flag() end - end) - reskate.console.command("tdgone", "Removes the mode", function() reskate.throwdowns.remove_mode("oneup") end) + assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", cursor_height = 200000}), "a cursor height") + assert(reskate.throwdowns.release_flag == nil and reskate.throwdowns.view == nil and reskate.throwdowns.one_up_view == nil, + "the old flag and view calls are gone") + assert(not pcall(reskate.on, "throwdown_mode", function() end), "and the old news event") + reskate.console.command("tdgone", "Removes the mode", function() reskate.throwdowns.remove_mode("duel") end) )", R"("throwdowns", "console")")}); running(host, "modes", environment); - check(game.called("mode modes oneup 1-UP rows 3 timer") && game.modes.size() == 1, "The mode reaches the game\n" + environment.all()); + check(game.called("mode modes duel Duel") && game.modes.size() == 1, "The mode reaches the game\n" + environment.all()); const auto& mode = game.modes.front(); - check(mode.players == 2 && mode.min_players == 1 && mode.max_players == 6 && mode.flag == "oneup" && mode.art == "OneUp" && mode.replaces == "skate", - "with its players, flag and art"); - check(mode.timer && mode.timer->caption == "Turn" && mode.timer->step == 10 && mode.timer->value == 20, "its timer"); - check(mode.rows[0].kind == "slider" && mode.rows[0].maximum == 7 && mode.rows[0].value == 3, "a slider row"); - check(mode.rows[1].kind == "choice" && mode.rows[1].options.size() == 2 && mode.rows[1].value == 1, "a choice, counted from 0"); - check(mode.rows[2].kind == "button" && mode.rows[2].text == "Set area", "a button"); - game.mode_news.push_back({"oneup", "placed", "", 0, {{"players", 2}, {"tricks", 1}}, Vec3{1, 2, 3}, 90}); - ThrowdownModeNews start{"oneup", "start", "", 0, {{"players", 2}, {"tricks", 1}}}; - start.host = false; - start.leader = 76561198000000001ULL; - start.players = {76561198000000001ULL, 76561198000000002ULL}; - game.mode_news.push_back(start); - ticks(host, environment, 1); - check(environment.logged("[modes] oneup placed 2 1 at 1 2 3 facing 90"), "The flag's place reaches the plugin\n" + environment.all()); - check(environment.logged("[modes] oneup start 2 1") && game.called("release flag modes"), "and its Start, and it takes the flag down"); - check(environment.logged("[modes] oneup players 76561198000000001,76561198000000002 led by 76561198000000001 elsewhere"), - "A start in another player's flag says who plays"); - check(host.run_console("tdgone") && game.called("remove mode modes oneup"), "A mode can go"); + check(mode.players == 2 && mode.min_players == 1 && mode.max_players == 6 && mode.flag == "duel" && mode.art == "Duel" && mode.replaces == "skate" && + mode.cursor_height == 99999, + "with its players, flag, art and cursor height"); + check(host.run_console("tdgone") && game.called("remove mode modes duel"), "A mode can go"); host.apply({contributed("throwdown-modes-test")}); running(host, "throwdown-modes-test", environment); - check(game.called("mode throwdown-modes-test oneup 1-UP rows 0 timer") && game.called("mode throwdown-modes-test race Deathrace rows 3 timer"), + check(game.called("mode throwdown-modes-test duel Duel") && game.called("mode throwdown-modes-test gates Gates"), "The test plugin's two modes register\n" + environment.all()); + // Its card clicked: it publishes the setup page, then its waiting card once the flag is down. + namespace kit = dingosdk::throwdowns::kit; + kit::post("throwdown-modes-test/gates", "open", {"9"}); + ticks(host, environment, 1); + const auto setup = kit::documents_for("throwdown-modes-test/gates"); + check(setup && setup->setup && setup->setup->rows.size() == 3 && setup->setup->timer.present, "Its setup page\n" + environment.all()); + kit::post("throwdown-modes-test/gates", "flag_placed", {"9", "1", "2", "3", "0"}); + ticks(host, environment, 1); + const auto waiting = kit::documents_for("throwdown-modes-test/gates"); + check(waiting && !waiting->setup && waiting->waiting && waiting->waiting->can_start, "Its waiting card\n" + environment.all()); + kit::post("throwdown-modes-test/gates", "start", {"9"}); + ticks(host, environment, 1); + check(game.called("flag leave throwdown-modes-test"), "Start lets the flag go"); +} + +// The throwdown kit: a plugin registers a mode, publishes its documents (parsed into the store the +// native UI reads), hears what the native UI posts, holds the native flag, keeps data files, opens a +// dialog; and everything goes when it stops. +void plugins_drive_the_throwdown_kit() { + namespace kit = dingosdk::throwdowns::kit; + TestEnvironment environment; + auto& game = environment.fake; + Host host(environment, Side::client); + host.apply({script("kit", R"( + assert(reskate.throwdowns.mode{id = "lap", name = "Lap", description = "Through the gates.", card = "spot", order = 2, + flag = "World/ReSkate/Throwdowns/Flags/lap_Placement", color = 0xff3434ff, players = {min = 1, max = 6, default = 4}}) + assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", card = "bowling"}), "a stock card") + assert(reskate.throwdowns.ui{ + setup = {mode = "lap", title = "LAP", players = {caption = "Players", min = 1, max = 6, step = 1, value = 4}, + timer = {caption = "Finish deadline", min = 30, max = 600, step = 30, value = 180}, + rows = {{id = "laps", kind = "choice", caption = "Laps", options = {"1", "2", "3"}, value = 2}, + {id = "area", kind = "button", caption = "Area", label = "Set optional area"}}}, + waiting = {mode = "lap", title = "LAP", status = "Waiting for host", enrolled = 1, capacity = 6, can_start = true}, + hud = {theme = "lap", layout = "side", counters = {{text = "LEADER 1,200"}}, + clock = {remaining_ms = 60000, total_ms = 90000, sampled_ms = 5, ticking = true}, + board = {title = "LEADERBOARD", columns = {"POS", "NAME", "SCORE"}, + rows = {{rank = 1, name = "Me", value = 1200, self = true, marks = {{text = "S", on = true}}}}}, + timers = {{caption = "LAP", text = {base_ms = 1500, sampled_ms = 5, running = true, cap_ms = 600000}}}, + markers = {{at = vector.create(1, 2, 3), text = "1", distance = true}}}, + panel = {title = "LAP", settings = {{icon = "players", caption = "Players", value = "4"}}, + prompts = {{input = "UI_ShoulderL", caption = "Area", command = "area"}}, players = {{id = "76561198000000001", name = "Me", self = true}}}, + flags = {{id = "f1", mode = "lap", leader = "76561198000000001", game = 7, at = vector.create(1, 2, 3), yaw = 90, + stats = {{title = "LAPS", value = "2"}}}}, + match = {teleports = true, hidden = {"76561198000000002"}}, + }) + assert(not pcall(reskate.throwdowns.ui, "text"), "a document is a table") + reskate.on("throwdown_ui", function(e) + reskate.log("ui " .. e.action .. " " .. e.mode .. " " .. table.concat(e.args, ",")) + if e.action == "open" then + assert(reskate.throwdowns.flag.begin("lap", tonumber(e.args[1]), {players = 4, seconds = 90})) + local state = reskate.throwdowns.flag.state() + assert(state.mine and state.mode == "lap" and state.game == tonumber(e.args[1]) and state.placing, "the flag is the plugin's") + elseif e.action == "leave" then + reskate.throwdowns.flag.leave() + end + end) + assert(reskate.files.write("routes/a.json", "{\"gates\": 3}")) + assert(reskate.files.read("routes/a.json") == "{\"gates\": 3}") + local names = reskate.files.list("routes") + assert(#names == 1 and names[1] == "a.json", "the folder lists its file") + assert(reskate.files.list()[1] == "routes/", "and the top its folder") + assert(not pcall(reskate.files.read, "../escape.json"), "no way out") + assert(not pcall(reskate.files.write, "C:/escape.json", ""), "no drive") + assert(not pcall(reskate.files.write, "routes/con.json", ""), "no device") + assert(reskate.files.write("old.json", "x") and reskate.files.remove("old.json")) + local missing, why = reskate.files.read("old.json") + assert(missing == nil and why == "not found", "a removed file is gone") + assert(reskate.ui.dialog("save", {title = "SAVE ROUTE", field = {text = "My route", max = 32}, + list = {rows = {{text = "Route A", sub = "3 gates", buttons = {"rename"}}}, empty = "No routes"}, + buttons = {"Save", {id = "cancel", label = "Cancel"}}, file = {label = "Load .json", filter = "*.json"}})) + assert(not pcall(reskate.ui.dialog, "x", {buttons = {"1", "2", "3", "4", "5"}}), "at most 4 buttons") + assert(reskate.skater.push(vector.create(1, 0, 2))) + assert(reskate.skater.push_wait() and reskate.skater.push_reset()) + reskate.skater.hold(true) + assert(reskate.modes == nil, "reskate.modes is gone") + -- The general services: physics, the session's world, the trick feed and flick diagrams, + -- the pad flight and the Bone Cam. + reskate.physics.competition(true) + assert(reskate.physics.stock_ready()) + reskate.physics.prepare() + local world = reskate.session.world() + assert(world.world == "77" and world.map == "test_map" and world.barred == false, "the session's world") + reskate.tricks.feed(true) + assert(#reskate.tricks.gesture("Kickflip") == 2 and #reskate.tricks.gesture("Boardslide") == 0, "flick diagrams") + assert(reskate.camera.pad_flight(true)) + assert(reskate.skater.bone_cam() == "game" and reskate.skater.bone_cam("ringing") == "1") + reskate.skater.bone_cam("game", "on") + )", R"("throwdowns", "files", "ui.screen", "skater.control", "session.read", "events.tricks", "camera")")}); + running(host, "kit", environment); + check(game.called("mode kit lap Lap") && game.modes.size() == 1, "The mode reaches the game\n" + environment.all()); + const auto& mode = game.modes.front(); + check(mode.card == "spot" && mode.order == 2 && mode.flag == "World/ReSkate/Throwdowns/Flags/lap_Placement" && mode.color == 0xff3434ffU, + "with its card, order, flag prefab and colour"); + const auto documents = kit::documents_for("kit/lap"); + check(documents && documents->plugin == "kit" && game.kit_documents == 1, "Its documents are in the kit's store"); + const auto& setup = *documents->setup; + check(setup.mode == "kit/lap" && setup.title == "LAP" && setup.players.present && setup.players.value == 4 && setup.timer.present && + setup.timer.value == 180 && setup.hide_native == std::vector{"rounds"}, + "Its setup page, its mode the SDK's id"); + check(setup.rows.size() == 2 && setup.rows[0].kind == "choice" && setup.rows[0].selected == 1 && setup.rows[1].kind == "button" && + setup.rows[1].label == "Set optional area", + "Its setup rows, a choice counted from 0"); + check(documents->waiting && documents->waiting->mode == "kit/lap" && documents->waiting->can_start && documents->waiting->capacity == 6, "Its waiting card"); + const auto& hud = documents->hud; + check(hud.active && hud.theme == "lap" && hud.counters == std::vector{"LEADER 1,200"} && hud.clock.present && hud.clock.remaining_ms == 60000, + "Its HUD"); + check(hud.board.present && hud.board.rows.size() == 1 && hud.board.rows[0].rank == "1" && hud.board.rows[0].value == "1200" && hud.board.rows[0].self && + hud.board.rows[0].marks.size() == 1 && hud.board.rows[0].marks[0].on && hud.board.columns.size() == 3, + "Its board, numbers as labels"); + check(hud.timers.size() == 1 && hud.timers[0].clock.present && hud.timers[0].clock.up && hud.timers[0].clock.remaining_ms == 1500 && + hud.timers[0].clock.total_ms == 600000 && hud.markers.size() == 1 && hud.markers[0].at[2] == 3, + "A stopwatch timer and a marker"); + check(documents->panel.present && documents->panel.prompts.size() == 1 && documents->panel.prompts[0].glyph == "area" && + documents->panel.players.size() == 1 && documents->panel.players[0].id == 76561198000000001ULL, + "Its match panel"); + check(documents->flags.size() == 1 && documents->flags[0].mode == "kit/lap" && documents->flags[0].leader == 76561198000000001ULL && + documents->flags[0].game == 7 && documents->flags[0].at[0] == 1 && documents->flags[0].stats.size() == 1, + "Its flags"); + check(kit::match().teleports && kit::hidden(76561198000000002ULL) && !kit::hidden(76561198000000001ULL) && kit::active() && kit::active()->plugin == "kit", + "What its match asks of the game"); + check(game.files.contains("kit/routes/a.json") && !game.files.contains("kit/old.json"), "Its files are in its own folder"); + check(game.called("dialog kit save rows 1 buttons 2") && game.dialog && game.dialog->field.text == "My route" && game.dialog->file.filter == "*.json" && + game.dialog->buttons[1].id == "cancel" && game.dialog->rows[0].buttons[0] == "rename", + "Its dialog"); + check(game.called("push kit 1 0 2") && game.called("push wait kit") && game.called("push reset kit") && game.called("hold kit true"), + "Push, its wait and reset, and hold reach the game"); + check(game.called("competition kit true") && game.called("prepare physics") && game.called("trick feed kit true") && + game.called("pad flight kit true") && game.called("bone cam kit game on"), + "The general services reach the game\n" + environment.all()); + // The native UI: a card clicked, a dialog answered. + kit::post("kit/lap", "open", {"7"}); + kit::post_to("kit", "dialog", "", {"save", "Save", "My route", "0", ""}); + kit::post("other/lap", "open", {"8"}); + ticks(host, environment, 1); + check(environment.logged("[kit] ui open lap 7") && game.called("flag begin kit/lap 7 4 90 true 0.0"), + "A card clicked reaches the plugin by its own id, which takes the flag\n" + environment.all()); + check(environment.logged("[kit] ui dialog save,Save,My route,0,"), "A dialog's answer reaches it\n" + environment.all()); + check(!environment.logged("ui open lap 8") && kit::take("other").size() == 1, "Another plugin's events stay its own"); + kit::post("kit/lap", "leave"); + ticks(host, environment, 1); + check(game.called("flag leave kit") && !game.flag.busy, "Leave lets the flag go"); + host.switch_off("kit"); + check(!kit::documents_for("kit/lap") && !kit::match().teleports && !kit::hidden(76561198000000002ULL), "Its documents go when it stops"); +} + +// The Throwdowns page's cards: the stock three (one replaced) and the plugin modes by order. +void the_throwdowns_page_lists_the_stock_cards_and_plugin_modes() { + namespace custom = dingosdk::multiplayer::custom_throwdowns; + using dingosdk::throwdowns::Adapter; + custom::Mode skate; + skate.plugin = "p"; + skate.id = "skate"; + skate.name = "Letters"; + skate.card = "skate"; + skate.replaces = "skate"; + custom::Mode late = skate; + late.id = "late"; + late.name = "Late"; + late.card = "jam"; + late.replaces.clear(); + late.order = 20; + late.color = 0xff00ff00U; + custom::Mode early = late; + early.id = "early"; + early.name = "Early"; + early.card = "spot"; + early.order = 10; + early.color = 0; + check(custom::set_mode(skate).empty() && custom::set_mode(late).empty() && custom::set_mode(early).empty(), "The modes register"); + check(!custom::set_mode([&] { auto bad = early; bad.card = "bowling"; return bad; }()).empty(), "A card is a stock one"); + const auto& registry = dingosdk::throwdowns::registry(); + std::vector ids; + for (const auto& d : registry.definitions()) ids.push_back(d.id); + check(ids == std::vector{"p/skate", "native.spot", "native.jam", "p/early", "p/late"}, "In the grid's order: " + [&] { + std::string all; + for (const auto& id : ids) all += id + " "; + return all; + }()); + const auto* late_card = registry.find("p/late"); + check(late_card && late_card->adapter == Adapter::plugin && late_card->donor == 2 && late_card->palette.primary == 0xff00ff00U && late_card->plugin == "p", + "A plugin card: its donor and colour"); + check(registry.find("p/early")->palette.primary == dingosdk::throwdowns::palette_for("p/early").primary, "a colour picked from its id"); + check(registry.find("native.spot")->donor == 1 && registry.find("native.jam")->adapter == Adapter::native_jam, "The stock cards"); + const auto before = registry.revision(); + custom::remove_plugin("p"); + const auto& again = dingosdk::throwdowns::registry(); + check(again.revision() > before && again.definitions().size() == 3 && again.definitions()[0].id == "native.skate", "S.K.A.T.E. is back when it goes"); +} + +void smokey_throwdowns_register_and_play_one_up() { + namespace kit = dingosdk::throwdowns::kit; + TestEnvironment environment; + auto& game = environment.fake; + const std::string plugin = "SmokeyDawg58-ReSkate_Throwdown"; + Host host(environment, Side::client); + host.apply({contributed(plugin)}); + running(host, plugin, environment); + // His challenge registry on the Throwdowns page: R.E.S.K.A.T.E. in S.K.A.T.E.'s place, 1-UP and his + // game modes after the stock cards, each with his donor card, colour, order and flag prefab. + check(game.modes.size() == 10, "His ten modes are on the Throwdowns page\n" + environment.all()); + check(game.modes.front().id == "skate" && game.modes.front().replaces == "skate" && game.modes.front().card == "skate", + "R.E.S.K.A.T.E. takes S.K.A.T.E.'s place"); + const auto registered = [&](const std::string& id) -> const ThrowdownMode& { + for (const auto& m : game.modes) if (m.id == id) return m; + throw std::runtime_error("no mode " + id); + }; + check(registered("oneup").card == "spot" && registered("oneup").order == 1 && registered("oneup").name == "1-UP" && + registered("oneup").flag == "World/ReSkate/Throwdowns/Flags/oneup_Placement", + "1-UP is one of his modes now"); + check(registered("race").card == "spot" && registered("race").color == 0xff3434ffU && registered("race").order == 4 && + registered("race").flag == "World/ReSkate/Throwdowns/Flags/race_Placement" && registered("race").cursor_height == 99999, + "Deathrace: his donor, colour, order, flag and a start flag that may stand on anything"); + check(registered("tag").card == "skate" && registered("hide").card == "skate" && registered("jam").card == "jam" && registered("meat").card == "spot", + "His donor rule: tag-like modes S.K.A.T.E.'s card, Spot Jam Skate Jam's, the rest Spot Battle's"); + SkaterTelemetry t; + t.skater = true; + t.position = {1, 2, 3}; + t.physics_state = 100; + game.telemetry = t; + environment.local_skater = SkaterState{{1, 2, 3}, 0, 0, 0, false, 0}; + environment.score_now = ScoreState{0, 1, false, {}, {}, false}; + host.set_level("sanvan"); + ticks(host, environment, 50); // its game layer loads a part a tick + running(host, plugin, environment); + check(game.kit_documents >= 5, "It publishes the kit's documents\n" + environment.all()); + { + const auto idle = kit::documents_for(plugin + "/jam"); + check(idle && !idle->hud.active && !idle->setup && !idle->waiting && !idle->panel.present, "Idle, no HUD and no pages"); + } + check(!environment.logged(".lua:") && !environment.logged("tick failed"), "Idle ticks run clean\n" + environment.all()); + // His rule checks (tdm test, his mode_rules_tests.cpp). + environment.lines.clear(); + check(host.run_console("tdm test"), "tdm test"); + check(environment.printed(" 0 failed") && environment.printed(" passed"), "His rule checks pass:\n" + printed_lines(environment)); + + const auto docs_of = [&](const std::string& key) { + const auto docs = kit::documents_for(key); + check(docs.has_value(), key + ": its documents are in the kit's store"); + return *docs; + }; + const auto flag_waiting = [&](const std::string& key, std::uint32_t id) { game.flag = {true, true, false, true, key, id}; }; + struct Expect { + const char* mode; + const char* title; + const char* timer; + std::vector rows; + }; + const std::vector expected{ + {"jam", "Spot Jam", "Timer", {"area"}}, {"meat", "Hall of Meat", "Timer", {"area"}}, + {"domination", "Domination", "Timer", {"capture-spots"}}, {"graffiti", "Graffiti", "Timer", {"area"}}, + {"tag", "Skate Tag", "Timer", {"area"}}, {"infection", "Infection", "Timer", {"area"}}, + {"hide", "Hide & Seek", "Duration", {"hide_time", "area"}}, {"skate", "R.E.S.K.A.T.E.", "Turn Timer", {"letters", "tricks", "zone"}}, + {"race", "Deathrace", "Finish deadline", {}}}; + std::uint32_t id = 101; + for (const auto& e : expected) { + const std::string mode = e.mode, key = plugin + "/" + mode; + id += 2; + // A card click: the kit begins the native flag with the stock setup page open, then says `open`. + check(game.throwdown_flag_begin(key, id, {6, 120, true, 0}), mode + ": the flag begins"); + game.flag.placing = false; + kit::post(key, "open", {std::to_string(id)}); + ticks(host, environment, 2); + auto docs = docs_of(key); + check(docs.setup && docs.setup->mode == key && docs.setup->title == e.title && docs.setup->timer.present && + docs.setup->timer.caption == e.timer && docs.setup->players.present && docs.setup->players.max == 6 && docs.setup->back == "BACK", + mode + ": his setup page\n" + environment.all()); + std::vector rows; + for (const auto& row : docs.setup->rows) rows.push_back(row.id); + check(rows == e.rows, mode + ": his setup rows (" + std::to_string(rows.size()) + ")"); + if (mode == "jam") check(docs.setup->rows[0].kind == "button" && docs.setup->rows[0].label == "Set optional area", "Spot Jam's Area button"); + if (mode == "skate") + check(docs.setup->rows[1].options.size() == 4 && docs.setup->rows[2].options.size() == 6 && docs.setup->rows[0].max == 7, + "R.E.S.K.A.T.E.'s Letters, Tricks and Trick Zone"); + kit::post(key, "setup_row", {"players", "3"}); + ticks(host, environment, 2); + check(docs_of(key).setup->players.value == 3, mode + ": the Players row sets his capacity"); + // Confirm, then the native flag placed. + kit::post(key, "setup_confirm", {"3", mode == "race" ? "120" : "300"}); + game.flag.placing = true; + ticks(host, environment, 1); + flag_waiting(key, id); + kit::post(key, "flag_placed", {std::to_string(id), "1", "2", "3", "90"}); + ticks(host, environment, 3); + docs = docs_of(key); + // Deathrace's route editor opens over its placed start flag: no waiting card until it closes. + check(mode == "race" ? !docs.waiting + : docs.waiting && docs.waiting->mode == key && docs.waiting->title == e.title && docs.waiting->capacity == 3, + mode + ": his waiting card\n" + environment.all()); + if (mode == "race") { + check(docs.hud.active && docs.hud.theme == "race" && docs.hud.editor.present && docs.hud.editor.title == "Deathrace builder" && + docs.hud.editor.prompts.size() == 11 && docs.hud.editor.info_title == "ROUTE INFO" && docs.hud.header.title == "DEATHRACE" && + docs.hud.header.frame_width == 704, + "Deathrace's start gate opens his route editor\n" + environment.all()); + // His route library: a route saved on this map, the load dialog (hold Start), a row picked. + game.files[plugin + "/routes/test_map/Loop.json"] = + R"({"format": "ReSkate Deathrace route", "version": 1, "id": 7, "name": "Loop", "map": "test_map", "settings": {)" + R"("duration_seconds": 120, "turn_seconds": 30, "strikes": 3, "radius": 6, "area_radius": 0, "corners": [],)" + R"("points": [[1, 2, 3], [1, 2, 23], [1, 2, 43]], "yaw_degrees": [0, 0, 0], "half_widths": [6, 6, 6],)" + R"("gate_types": ["required", "required", "required"], "arrow_roll_degrees": [], "pitch_degrees": [], "spawn": [1, 2, 3],)" + R"("has_spawn": true, "finish_gate": true, "trick_kinds": 15}})"; + game.focus_now = {true, false, true}; + game.pad_bits_now = 0u; + ticks(host, environment, 3); + game.pad_bits_now = 0x10u; + ticks(host, environment, 100); + check(game.dialog && game.dialog->id == "route-load" && game.dialog->title == "LOAD DEATHRACE ROUTE" && game.dialog->rows.size() == 1 && + game.dialog->rows[0].text == "Loop" && game.dialog->file.present && game.dialog->buttons[0].label == "Back to builder", + "Hold Start: his route library\n" + environment.all()); + game.pad_bits_now = 0u; + ticks(host, environment, 2); + kit::post_to(plugin, "dialog", {}, {"route-load", "row", "", "1", ""}); + ticks(host, environment, 3); + { + const auto info = docs_of(key).hud.editor.info; + check(!info.empty() && info[0].first == "Required gates: 1" && info[1].first == "Finish gate: Placed", "The route is loaded\n" + environment.all()); + } + // Tap Start: name and save it, then players can enroll. + game.pad_bits_now = 0x10u; + ticks(host, environment, 2); + game.pad_bits_now = 0u; + ticks(host, environment, 3); + check(game.dialog && game.dialog->id == "route-save" && game.dialog->title == "SAVE DEATHRACE ROUTE" && game.dialog->field.text == "Loop", + "Tap Start: his save dialog\n" + environment.all()); + kit::post_to(plugin, "dialog", {}, {"route-save", "save", "Loop Two", "0", ""}); + ticks(host, environment, 3); + check(game.files.contains(plugin + "/routes/test_map/Loop Two.json") && game.called("dialog close " + plugin + " route-save"), + "The route is saved as his JSON\n" + environment.all()); + check(game.files[plugin + "/routes/test_map/Loop Two.json"].find("\"format\": \"ReSkate Deathrace route\"") != std::string::npos, + "in his format"); + { + const auto saved = docs_of(key); + check(saved.waiting && saved.waiting->title == e.title && saved.waiting->capacity == 3 && saved.waiting->can_start && + !saved.hud.editor.present, + "A saved route closes the editor: his waiting card, and it can start\n" + environment.all()); + } + game.focus_now = {}; + game.pad_bits_now.reset(); + } else { + if (mode == "domination") check(host.run_console("tdm point"), "his Domination needs a spot: `mode point` where the skater is"); + ticks(host, environment, 2); + check(docs_of(key).waiting->can_start, mode + ": the host can start\n" + environment.all()); + kit::post(key, "start", {std::to_string(id)}); + ticks(host, environment, 400); + const auto ms = status_of(host, plugin).tick_ms; + std::cout << "SmokeyDawg58 " << mode << ": " << ms << " ms a tick playing (its budget is 2 ms)\n"; + check(ms < 2, mode + ": within its time"); + docs = docs_of(key); + const auto& hud = docs.hud; + check(hud.active && hud.theme == mode && !docs.waiting, mode + ": Start plays it alone\n" + environment.all()); + check(mode == "skate" ? hud.header.title.empty() && hud.card.present && hud.layout == "bottom" + : hud.header.title == e.title && hud.counters.size() == 2 && hud.counters[0].starts_with("LEADER"), + mode + ": his HUD's title and score block"); + check(hud.board.present && !hud.results.present + ? hud.board.title == "LEADERBOARD" && hud.board.rows.size() == 1 && hud.board.rows[0].self + : hud.results.present && hud.results.title == std::string(e.title) + " RESULTS", + mode + ": his leaderboard\n" + environment.all()); + if (!hud.results.present) + check(docs.panel.present && docs.panel.title == e.title && docs.panel.players.size() == 1 && docs.panel.settings.size() == 3, + mode + ": his Throwdown Panel"); + check(kit::match().markers || hud.results.present, mode + ": the match keeps the session markers"); + } + running(host, plugin, environment); + check(!environment.logged(".lua:") && !environment.logged("tick failed") && !environment.logged("failed:"), + mode + " runs without errors\n" + environment.all()); + kit::post(key, "quit", {}); + ticks(host, environment, 3); + game.flag = {}; + ticks(host, environment, 2); + docs = docs_of(key); + check(!docs.hud.active && !docs.waiting, mode + ": Quit ends it\n" + environment.all()); + } + // 1-UP: a practice match, alone at its flag. + const std::string oneup = plugin + "/oneup"; + check(game.throwdown_flag_begin(oneup, 501, {4, 120, true, 0}), "1-UP's flag begins"); + game.flag.placing = false; + kit::post(oneup, "open", {"501"}); + ticks(host, environment, 2); + auto docs = docs_of(oneup); + check(docs.setup && docs.setup->title == "1-UP" && docs.setup->timer.value == 20 && docs.setup->timer.max == 120, "1-UP's setup page\n" + environment.all()); + kit::post(oneup, "setup_row", {"players", "1"}); + kit::post(oneup, "setup_confirm", {"1", "20"}); + game.flag.placing = true; + ticks(host, environment, 1); + flag_waiting(oneup, 501); + kit::post(oneup, "flag_placed", {"501", "1", "2", "3", "0"}); + ticks(host, environment, 5); + docs = docs_of(oneup); + check(docs.waiting && docs.waiting->title == "1-UP" && docs.waiting->can_start && docs.waiting->capacity == 1, "1-UP's waiting card\n" + environment.all()); + kit::post(oneup, "start", {"501"}); + bool played = false; + for (int i = 0; i < 600 && !played; ++i) { + ticks(host, environment, 1); + docs = docs_of(oneup); + played = docs.hud.active && docs.hud.clock.present && docs.hud.clock.ticking; + } + const auto& hud = docs.hud; + check(played && hud.theme == "oneup" && hud.header.title == "1-UP" && hud.counters.size() == 2 && hud.counters[0].starts_with("TARGET") && + hud.board.present && hud.board.variant == "stamps" && hud.board.rows.size() == 1 && hud.board.rows[0].marks.size() == 3 && + hud.board.title.ends_with("'S TURN"), + "1-UP's practice plays, his score HUD up\n" + environment.all()); + check(docs.panel.present && docs.panel.title == "1-UP" && kit::match().markers, "1-UP's panel and markers"); + std::cout << "SmokeyDawg58 1-UP: " << status_of(host, plugin).tick_ms << " ms a tick playing\n"; + check(status_of(host, plugin).tick_ms < 2, "1-UP within its time"); + kit::post(oneup, "quit", {}); + ticks(host, environment, 3); + game.flag = {}; + ticks(host, environment, 2); + check(!docs_of(oneup).hud.active, "1-UP's Quit ends it\n" + environment.all()); + running(host, plugin, environment); + check(!environment.logged(".lua:") && !environment.logged("tick failed"), "1-UP runs without errors\n" + environment.all()); + // His ReSkate-menu 1-UP card, as the plugin's page: Place 1-Up here, then its lobby, then Exit. + environment.panels_open = true; + environment.shown = plugin; + ticks(host, environment, 5); + using K = plugin_ui::Widget::Kind; + check(!environment.panels.empty() && environment.panels[0].page && environment.panels[0].title == "1-UP" && + environment.panels[0].widgets[0].kind == K::card && environment.panels[0].widgets[0].text == "1-UP", + "His 1-UP card on the plugin's page\n" + environment.all()); + environment.actions = {plugin_ui::Action{plugin, "1-UP", "place", false}}; + ticks(host, environment, 30); + const auto heading = [&] { + for (const auto& w : environment.panels[0].widgets) if (w.kind == K::heading) return w.text; + return std::string{}; + }; + { + std::string widgets; + for (const auto& w : environment.panels[0].widgets) widgets += w.text + " | " + w.help + "\n"; + check(heading() == "READY UP", "Place 1-Up here opens its lobby: " + heading() + "\n" + widgets + environment.all()); + } + environment.actions = {plugin_ui::Action{plugin, "1-UP", "exit", false}}; + ticks(host, environment, 30); + check(heading().empty(), "and Exit 1-Up leaves it"); + environment.panels_open = false; + // His console verbs, under tdm. + check(host.run_console("tdm help"), "tdm help"); + check(!environment.logged(".lua:"), "tdm help answers\n" + environment.all()); } void grind_exits_pick_the_side() { @@ -1961,6 +2527,7 @@ int main() { tricks_reach_plugins_that_ask_for_them(); plugins_draw_on_the_overlay(); plugins_draw_pictures(); + plugins_place_world_entities(); plugins_server_copies_are_the_authority(); plugins_animate_the_skater(); plugins_play_their_own_sounds(); @@ -1982,6 +2549,9 @@ int main() { fpv_drone_flies_and_follows(); hall_of_meat_scores_a_bail(); grind_exits_pick_the_side(); + smokey_throwdowns_register_and_play_one_up(); + plugins_drive_the_throwdown_kit(); + the_throwdowns_page_lists_the_stock_cards_and_plugin_modes(); plugins_add_throwdown_modes(); reskate_hostile_is_stopped_or_refused_every_time(); std::filesystem::remove_all(scratch); diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index 242811c3..b320168f 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -17,14 +17,15 @@ namespace { struct Event { std::string_view name, permission; }; -constexpr std::array events{{ +constexpr std::array events{{ {"load", {}}, {"unload", {}}, {"tick", {}}, {"level_loaded", {}}, {"level_unloading", {}}, {"trick_started", "events.tricks"}, {"trick_landed", "events.tricks"}, {"line_broken", "events.tricks"}, {"draw", "overlay"}, {"player_joined", "session.read"}, {"player_left", "session.read"}, {"message", "net.session"}, {"skater_state", "events.skater"}, {"bail", "events.skater"}, {"chat", "chat.read"}, {"track_changed", "music"}, {"throwdown", "throwdowns"}, {"action", "input"}, {"screen_closed", "ui.screen"}, {"board_landed", "events.skater"}, {"body_hit", "events.skater"}, {"bail_end", "events.skater"}, - {"physics_step", "events.physics"}, {"grind_exit", "events.skater"}, {"throwdown_mode", "throwdowns"}, + {"physics_step", "events.physics"}, {"grind_exit", "events.skater"}, {"trick_notes", "events.tricks"}, + {"throwdown_ui", "throwdowns"}, }}; constexpr std::size_t maximum_key = 128; constexpr std::size_t maximum_command = 48; @@ -82,6 +83,12 @@ Json to_json(lua_State* L, int index, int depth, Budget& budget) { budget.spend(0, length); return std::string(text, length); } + case LUA_TVECTOR: { // a position: [x, y, z] + const float* v = lua_tovector(L, index); + if (!std::isfinite(v[0]) || !std::isfinite(v[1]) || !std::isfinite(v[2])) throw std::runtime_error("JSON has no NaN or infinity"); + budget.spend(3, 0); + return Json::array({static_cast(v[0]), static_cast(v[1]), static_cast(v[2])}); + } case LUA_TTABLE: { luaL_checkstack(L, 4, "tables nested too deep"); const int length = lua_objlen(L, index); @@ -676,6 +683,8 @@ void Api::install(Plugin& plugin, lua_State* L) { install_ui_api(plugin, L); install_more_api(plugin, L); install_world_api(plugin, L); + install_services_api(plugin, L); + install_kit_api(plugin, L); readonly(); lua_setglobal(L, "reskate"); lua_pushcfunction(L, ApiFunctions::print, "print"); diff --git a/Extension/Plugins/plugin_api.h b/Extension/Plugins/plugin_api.h index 127a557e..92c1a606 100644 --- a/Extension/Plugins/plugin_api.h +++ b/Extension/Plugins/plugin_api.h @@ -14,6 +14,13 @@ void install_ui_api(Plugin& plugin, lua_State* L); void install_more_api(Plugin& plugin, lua_State* L); // The wider set's game parts (plugin_api_world.cpp), into the same table. void install_world_api(Plugin& plugin, lua_State* L); +// General services for a game layer: the skater's telemetry and Bone Cam, the pad and keys, the +// free and pad-flown cameras, the session's world, competition physics and the trick feed +// (plugin_api_services.cpp). +void install_services_api(Plugin& plugin, lua_State* L); +// The throwdown kit's documents and native flag, the plugin's data files, overlay dialogs and the +// skater's push (with its wait and reset) and hold (plugin_api_throwdowns.cpp). +void install_kit_api(Plugin& plugin, lua_State* L); // A player as plugins get one: {id = "", name, me, host}. void push_player(lua_State* L, const SessionPlayer& player); // A file's bytes when it is a regular file no larger than `most`; otherwise empty, and `error` diff --git a/Extension/Plugins/plugin_api_services.cpp b/Extension/Plugins/plugin_api_services.cpp new file mode 100644 index 00000000..395328ce --- /dev/null +++ b/Extension/Plugins/plugin_api_services.cpp @@ -0,0 +1,309 @@ +#include "plugin_api.h" +#include "plugin_game.h" +#include "lua.h" +#include "lualib.h" +#include +#include +#include +#include + +// General services a game layer in Lua reads and drives each tick: the skater's telemetry, stance +// and Bone Cam, the pad and keys, the free and pad-flown cameras, spectating, the session's world, +// the competition's stock physics, and skate.'s trick list and flick diagrams. None of them knows a +// throwdown mode; each checks its permission and the game (plugin_game.h) does the rest. +namespace dingosdk::plugins { +struct ServicesApi { + static Game& game(Plugin& plugin) { return plugin.services_.game(); } +}; +namespace { +Game& game_of(Plugin& plugin) { return ServicesApi::game(plugin); } +Plugin& plugin_of(lua_State* L) { + auto& plugin = *static_cast(lua_callbacks(L)->userdata); + if (plugin.halted()) luaL_error(L, "the plugin was stopped"); + return plugin; +} +Plugin& allowed(lua_State* L, const char* permission, const char* what) { + auto& plugin = plugin_of(L); + if (!plugin.has(permission)) luaL_error(L, "%s needs the \"%s\" permission in reskate-plugin.json", what, permission); + return plugin; +} +std::uint64_t id_at(lua_State* L, int index) { + if (lua_isnumber(L, index)) return static_cast(lua_tonumber(L, index)); + // Steam IDs come as strings: a Lua number cannot hold one exactly. + const std::string text = luaL_checkstring(L, index); + std::uint64_t out{}; + for (const char c : text) { + if (c < '0' || c > '9' || out > (~0ULL - 9) / 10) luaL_argerror(L, index, "a Steam ID"); + out = out * 10 + static_cast(c - '0'); + } + return out; +} +int result(lua_State* L, const std::string& why) { + lua_pushboolean(L, why.empty()); + if (why.empty()) return 1; + lua_pushlstring(L, why.data(), why.size()); + return 2; +} +void field_number(lua_State* L, const char* name, double value) { + lua_pushnumber(L, value); + lua_setfield(L, -2, name); +} +void field_bool(lua_State* L, const char* name, bool value) { + lua_pushboolean(L, value); + lua_setfield(L, -2, name); +} +void field_vec(lua_State* L, const char* name, const Vec3& v) { + lua_pushvector(L, v[0], v[1], v[2]); + lua_setfield(L, -2, name); +} + +// ---- skater -------------------------------------------------------------------------------------- +// skater.telemetry(): the trainer's telemetry, its last jump, the physics watch and the bail's body. +int telemetry(lua_State* L) { + auto& plugin = allowed(L, "skater.read", "reskate.skater.telemetry"); + const auto t = game_of(plugin).skater_telemetry(); + if (!t) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 20); + field_bool(L, "skater", t->skater); + field_bool(L, "airborne", t->airborne); + field_vec(L, "position", t->position); + field_number(L, "heading", t->heading); + field_number(L, "speed", t->speed); + field_number(L, "vertical", t->vertical); + field_number(L, "air_time", t->air_time); + field_number(L, "height", t->height); + field_number(L, "top_speed", t->top_speed); + field_number(L, "physics_state", t->physics_state); + lua_createtable(L, 0, 16); + const auto& j = t->last; + field_number(L, "serial", static_cast(j.serial)); + field_number(L, "takeoff_speed", j.takeoff_speed); + field_number(L, "takeoff_angle", j.takeoff_angle); + field_number(L, "air_time", j.air_time); + field_number(L, "height", j.height); + field_number(L, "distance", j.distance); + field_number(L, "drop", j.drop); + field_number(L, "landing_speed", j.landing_speed); + field_number(L, "spin", j.spin); + field_number(L, "spin_rate", j.spin_rate); + field_number(L, "flip", j.flip); + field_number(L, "board_turn", j.board_turn); + field_number(L, "state", j.state); + field_vec(L, "takeoff", j.takeoff); + field_vec(L, "landing", j.landing); + lua_setfield(L, -2, "last"); + lua_createtable(L, 0, 9); + const auto& w = t->watch; + field_bool(L, "valid", w.valid); + field_bool(L, "ragdoll_known", w.ragdoll_known); + field_bool(L, "ragdoll", w.ragdoll); + field_number(L, "state", w.state); + field_number(L, "previous", w.previous); + field_number(L, "previous_seconds", w.previous_seconds); + field_number(L, "changes", static_cast(w.changes)); + field_number(L, "wipeouts", static_cast(w.wipeouts)); + field_number(L, "runouts", static_cast(w.runouts)); + lua_setfield(L, -2, "watch"); + field_bool(L, "teleport_pending", t->teleport_pending); + field_bool(L, "slam", t->slam); + field_bool(L, "struck", t->struck); + if (t->body) field_vec(L, "body", *t->body); + return 1; +} +int goofy(lua_State* L) { + auto& plugin = allowed(L, "skater.read", "reskate.skater.goofy"); + if (const auto g = game_of(plugin).stance_goofy()) lua_pushboolean(L, *g); + else lua_pushnil(L); + return 1; +} +// skater.bone_cam(word, ...): no words for its setting ("game", "on" or "off"), "status" its full +// status line, "bail" a bail to show, "ringing" "1" or "0", "game" "on"/"off" (a plugin's game +// wants it), or the `bonecam` console words. +int bone_cam(lua_State* L) { + auto& plugin = allowed(L, "camera", "reskate.skater.bone_cam"); + std::vector words; + for (int i = 1, n = std::min(lua_gettop(L), 8); i <= n; ++i) { + std::size_t length = 0; + const char* word = luaL_checklstring(L, i, &length); + words.emplace_back(word, std::min(length, 32)); + } + const auto text = game_of(plugin).bone_cam(plugin.id(), words); + lua_pushlstring(L, text.data(), text.size()); + return 1; +} + +// ---- input --------------------------------------------------------------------------------------- +// input.pad_bits(menu): XInput's button bits with LT 0x10000 and RT 0x20000, or nil without a pad; +// with `menu`, read while ReSkate's menu is open too. +int pad_bits(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.pad_bits"); + if (const auto bits = game_of(plugin).pad_bits(lua_toboolean(L, 1) != 0)) lua_pushnumber(L, *bits); + else lua_pushnil(L); + return 1; +} +int key_code(lua_State* L, int index) { + if (lua_isnumber(L, index)) { + const int vk = static_cast(lua_tonumber(L, index)); + if (vk < 1 || vk > 254) luaL_argerror(L, index, "a virtual key from 1 to 254"); + return vk; + } + const std::string name = luaL_checkstring(L, index); + static const std::pair names[] = { + {"Enter", 13}, {"Backspace", 8}, {"Escape", 27}, {"End", 35}, {"Home", 36}, {"PageUp", 33}, {"PageDown", 34}, {"Space", 32}, + {"Shift", 16}, {"Ctrl", 17}, {"Alt", 18}, {"Tab", 9}, {"Left", 37}, {"Up", 38}, {"Right", 39}, {"Down", 40}, + {"[", 0xDB}, {"]", 0xDD}, {"LeftMouse", 1}, {"RightMouse", 2}, {"MiddleMouse", 4}}; + for (const auto& [n, vk] : names) + if (name == n) return vk; + if (name.size() == 1 && ((name[0] >= 'A' && name[0] <= 'Z') || (name[0] >= '0' && name[0] <= '9'))) return name[0]; + luaL_argerror(L, index, "a key name (A-Z, 0-9, Enter, Backspace, Escape, End, PageUp, PageDown, [, ], ...) or a virtual key"); + return 0; +} +int key(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.key"); + lua_pushboolean(L, game_of(plugin).key_down(key_code(L, 1))); + return 1; +} +int wheel(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.wheel"); + lua_pushnumber(L, game_of(plugin).mouse_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) { + auto& plugin = allowed(L, "input", "reskate.input.game_buttons"); + const auto mask = [&](int index) { + const double v = luaL_optnumber(L, index, 0); + if (!(v >= 0 && v <= 0xffff)) luaL_argerror(L, index, "a 16-bit button mask"); + return static_cast(v); + }; + game_of(plugin).game_buttons(plugin.id(), mask(1), mask(2), lua_toboolean(L, 3) != 0); + return 0; +} +int input_allowed(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.allowed"); + lua_pushboolean(L, game_of(plugin).input_allowed()); + return 1; +} +int focus(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.focus"); + const auto f = game_of(plugin).input_focus(); + lua_createtable(L, 0, 3); + field_bool(L, "focused", f.focused); + field_bool(L, "interface_open", f.interface_open); + field_bool(L, "gameplay", f.gameplay); + return 1; +} + +// ---- camera -------------------------------------------------------------------------------------- +int free_camera(lua_State* L) { + auto& plugin = allowed(L, "camera", "reskate.camera.free"); + return result(L, game_of(plugin).free_camera(plugin.id(), lua_toboolean(L, 1) != 0)); +} +// camera.pad_flight(on): the free camera flown from the pad while placing things from the air (the +// bumpers or Q/E its height; the triggers stay the plugin's). Held by one plugin; released with it. +int pad_flight(lua_State* L) { + auto& plugin = allowed(L, "camera", "reskate.camera.pad_flight"); + return result(L, game_of(plugin).pad_flight(plugin.id(), lua_toboolean(L, 1) != 0)); +} + +// ---- session ------------------------------------------------------------------------------------- +int spectate(lua_State* L) { + auto& plugin = allowed(L, "session.read", "reskate.session.spectate"); + game_of(plugin).spectate(lua_isnoneornil(L, 1) ? 0 : id_at(L, 1)); + return 0; +} +// session.world(): {world = id (a string; it changes with the map or session), barred = the local +// player's mods change scoring, map = the map's name}. +int world(lua_State* L) { + auto& plugin = allowed(L, "session.read", "reskate.session.world"); + auto& g = game_of(plugin); + lua_createtable(L, 0, 3); + const auto id = std::to_string(g.world_id()); + lua_pushlstring(L, id.data(), id.size()); + lua_setfield(L, -2, "world"); + field_bool(L, "barred", g.world_barred()); + const auto map = g.world_map(); + lua_pushlstring(L, map.data(), map.size()); + lua_setfield(L, -2, "map"); + return 1; +} + +// ---- physics ------------------------------------------------------------------------------------- +// physics.competition(on): the game's stock physics while the local player competes (the player's +// own tuning comes back after); stock_ready(): the stock tuning has been read; prepare(): read it +// now, switching nothing. +int competition(lua_State* L) { + auto& plugin = allowed(L, "skater.control", "reskate.physics.competition"); + game_of(plugin).competition(plugin.id(), lua_toboolean(L, 1) != 0); + return 0; +} +int stock_ready(lua_State* L) { + auto& plugin = allowed(L, "skater.control", "reskate.physics.stock_ready"); + lua_pushboolean(L, game_of(plugin).stock_physics_ready()); + return 1; +} +int prepare(lua_State* L) { + auto& plugin = allowed(L, "skater.control", "reskate.physics.prepare"); + game_of(plugin).prepare_physics(); + return 0; +} + +// ---- tricks -------------------------------------------------------------------------------------- +// tricks.feed(on): skate.'s trick list, entry by entry, as trick_notes events while on. +int trick_feed(lua_State* L) { + auto& plugin = allowed(L, "events.tricks", "reskate.tricks.feed"); + game_of(plugin).trick_feed(plugin.id(), lua_toboolean(L, 1) != 0); + return 0; +} +// tricks.gesture(name): the flick diagram of a flip trick as skate. names it ({{x, y}, ...}: x to the +// left, y pulled back, the stick's gate the unit circle, a regular stance); empty until the game's +// gestures are read (the first call starts that) or for a trick without one. +int gesture(lua_State* L) { + auto& plugin = allowed(L, "events.tricks", "reskate.tricks.gesture"); + std::size_t length = 0; + const char* name = luaL_checklstring(L, 1, &length); + const auto path = game_of(plugin).trick_gesture(std::string_view(name, std::min(length, 96))); + lua_createtable(L, static_cast(path.size()), 0); + for (std::size_t i = 0; i < path.size(); ++i) { + lua_createtable(L, 2, 0); + lua_pushnumber(L, path[i][0]); + lua_rawseti(L, -2, 1); + lua_pushnumber(L, path[i][1]); + lua_rawseti(L, -2, 2); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; +} + +// Adds the functions to reskate. (on top of the stack), making the table when there is none. +void into_table(lua_State* L, const char* table, std::initializer_list> functions) { + lua_getfield(L, -1, table); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + lua_createtable(L, 0, static_cast(functions.size())); + lua_pushvalue(L, -1); + lua_setfield(L, -3, table); + } + lua_setreadonly(L, -1, false); + for (const auto& [name, f] : functions) { + lua_pushcfunction(L, f, name); + lua_setfield(L, -2, name); + } + lua_setreadonly(L, -1, true); + lua_pop(L, 1); +} +} // namespace + +void install_services_api(Plugin&, lua_State* L) { + into_table(L, "skater", {{"telemetry", telemetry}, {"goofy", goofy}, {"bone_cam", bone_cam}}); + into_table(L, "input", {{"pad_bits", pad_bits}, {"key", key}, {"wheel", wheel}, {"game_buttons", game_buttons}, {"allowed", input_allowed}, {"focus", focus}}); + into_table(L, "session", {{"spectate", spectate}, {"world", world}}); + into_table(L, "camera", {{"free", free_camera}, {"pad_flight", pad_flight}}); + into_table(L, "physics", {{"competition", competition}, {"stock_ready", stock_ready}, {"prepare", prepare}}); + into_table(L, "tricks", {{"feed", trick_feed}, {"gesture", gesture}}); +} +} // namespace dingosdk::plugins diff --git a/Extension/Plugins/plugin_api_throwdowns.cpp b/Extension/Plugins/plugin_api_throwdowns.cpp new file mode 100644 index 00000000..dbc8f9ae --- /dev/null +++ b/Extension/Plugins/plugin_api_throwdowns.cpp @@ -0,0 +1,402 @@ +#include "plugin_api.h" +#include "plugin_game.h" +#include "lua.h" +#include "lualib.h" +#include +#include +#include +#include + +// The throwdown kit's side of the reskate API (throwdown_kit.h): a plugin's documents for the +// native Throwdowns UI and the native flag registration, plus the general services its modes use: +// the plugin's own data files, overlay dialogs, and the skater's push and hold. Each checks its +// permission and its arguments; the game (plugin_game.h) does the rest. +namespace dingosdk::plugins { +namespace { +Plugin& plugin_of(lua_State* L) { + auto& plugin = *static_cast(lua_callbacks(L)->userdata); + if (plugin.halted()) luaL_error(L, "the plugin was stopped"); + return plugin; +} +void need(lua_State* L, Plugin& plugin, const char* permission, const char* what) { + if (!plugin.has(permission)) luaL_error(L, "%s needs the \"%s\" permission in reskate-plugin.json", what, permission); +} +int result(lua_State* L, const std::string& why) { + lua_pushboolean(L, why.empty()); + if (why.empty()) return 1; + lua_pushlstring(L, why.data(), why.size()); + return 2; +} +// nil, why: what a call that hands back a value returns when it fails. +int failed(lua_State* L, const std::string& why) { + lua_pushnil(L); + lua_pushlstring(L, why.data(), why.size()); + return 2; +} +bool within(double now, double& second, int& count, int most) { + if (now - second >= 1000) { + second = now; + count = 0; + } + return ++count <= most; +} +std::string checked_text(lua_State* L, int index, std::size_t most, const char* what) { + std::size_t length = 0; + const char* text = luaL_checklstring(L, index, &length); + if (length > most) luaL_error(L, "%s is longer than %d characters", what, static_cast(most)); + return std::string(text, length); +} +// A string field of the table at `table` (nil: empty), at most `most` long. +std::string text_field(lua_State* L, int table, const char* name, std::size_t most) { + lua_getfield(L, table, name); + std::string value; + if (!lua_isnil(L, -1)) { + if (!lua_isstring(L, -1)) luaL_error(L, "%s is a string", name); + std::size_t length = 0; + const char* text = lua_tolstring(L, -1, &length); + if (length > most) luaL_error(L, "%s is longer than %d characters", name, static_cast(most)); + value.assign(text, length); + } + lua_pop(L, 1); + return value; +} +double number_field(lua_State* L, int table, const char* name, double fallback, double low, double high) { + lua_getfield(L, table, name); + double value = fallback; + if (!lua_isnil(L, -1)) { + if (!lua_isnumber(L, -1)) luaL_error(L, "%s is a number", name); + value = lua_tonumber(L, -1); + if (!(value >= low && value <= high)) luaL_error(L, "%s is %g to %g", name, low, high); + } + lua_pop(L, 1); + return value; +} +bool bool_field(lua_State* L, int table, const char* name, bool fallback) { + lua_getfield(L, table, name); + const bool value = lua_isnil(L, -1) ? fallback : lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + return value; +} +// A mode id of the plugin's own ("duel"): the SDK's is "/". +std::string own_mode(lua_State* L, int index) { + auto id = checked_text(L, index, 32, "a mode id"); + if (id.empty() || id.find('/') != std::string::npos) luaL_argerror(L, index, "a mode id of 1 to 32 characters, without /"); + return id; +} +// A path in the plugin's data folder: relative, inside it, and no name Windows keeps for a device. +std::string data_path(lua_State* L, int index, bool folder) { + if (folder && lua_isnoneornil(L, index)) return {}; + auto path = checked_text(L, index, 200, "a data path"); + while (folder && path.ends_with('/')) path.pop_back(); + if (folder && path.empty()) return {}; + if (safe_relative_path(path).empty()) luaL_argerror(L, index, "a path inside the plugin's data folder (no .., no drive, / between folders)"); + std::size_t start = 0; + while (start <= path.size()) { + const auto end = std::min(path.find('/', start), path.size()); + std::string base(path.substr(start, std::min(path.find('.', start), end) - start)); + for (auto& c : base) c = static_cast(std::tolower(static_cast(c))); + static constexpr std::array devices{"con", "prn", "aux", "nul"}; + const bool numbered = base.size() == 4 && (base.starts_with("com") || base.starts_with("lpt")) && base[3] >= '0' && base[3] <= '9'; + if (numbered || std::find(devices.begin(), devices.end(), base) != devices.end()) luaL_argerror(L, index, "a name Windows keeps for a device"); + start = end + 1; + } + return path; +} +} // namespace + +struct KitApi { + static Game& game(Plugin& plugin) { return plugin.services_.game(); } + + // ---- throwdowns: the kit --------------------------------------------------------------------- + // ui(doc): the plugin's documents (setup, waiting, hud, panel, flags, match) for the native UI. + // Every tick is fine: an unchanged document costs a comparison. + static int ui(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.ui"); + luaL_checktype(L, 1, LUA_TTABLE); + Json document; + try { + document = lua_to_json(L, 1); + } catch (const std::exception& e) { + luaL_error(L, "%s", e.what()); + } + return result(L, game(plugin).publish_throwdown_ui(plugin.id(), document)); + } + // flag.begin(mode, game, {players, seconds, setup_page, cursor_height}): the native flag for + // one of the plugin's modes; false while another registration is busy. + static int flag_begin(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.flag.begin"); + const auto mode = own_mode(L, 1); + const double number = luaL_checknumber(L, 2); + if (!(number >= 1 && number <= 4294967295.0) || number != std::floor(number)) luaL_argerror(L, 2, "a game number"); + throwdowns::kit::flag::Options options; + if (!lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + options.players = static_cast(number_field(L, 3, "players", options.players, 1, 10)); + options.seconds = static_cast(number_field(L, 3, "seconds", options.seconds, 0, 3600)); + options.setup_page = bool_field(L, 3, "setup_page", options.setup_page); + options.cursor_height = static_cast(number_field(L, 3, "cursor_height", options.cursor_height, 0, 100000)); + } + lua_pushboolean(L, game(plugin).throwdown_flag_begin(plugin.id() + "/" + mode, static_cast(number), options)); + return 1; + } + // flag.state(): {owned, waiting, placing, busy, mine, mode, game}; mode (the plugin's own id) and + // game only when the registration is one of its modes. + static int flag_state(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.flag.state"); + const auto state = game(plugin).throwdown_flag_state(); + const auto prefix = plugin.id() + "/"; + const bool mine = state.mode.starts_with(prefix); + lua_createtable(L, 0, 7); + lua_pushboolean(L, state.owned); + lua_setfield(L, -2, "owned"); + lua_pushboolean(L, state.waiting); + lua_setfield(L, -2, "waiting"); + lua_pushboolean(L, state.placing); + lua_setfield(L, -2, "placing"); + lua_pushboolean(L, state.busy); + lua_setfield(L, -2, "busy"); + lua_pushboolean(L, mine); + lua_setfield(L, -2, "mine"); + if (mine) { + const auto own = state.mode.substr(prefix.size()); + lua_pushlstring(L, own.data(), own.size()); + lua_setfield(L, -2, "mode"); + lua_pushnumber(L, state.game); + lua_setfield(L, -2, "game"); + } + return 1; + } + static int flag_leave(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.flag.leave"); + game(plugin).throwdown_flag_leave(plugin.id()); + return 0; + } + + // ---- files: the plugin's own data folder ---------------------------------------------------------- + static Plugin& files_call(lua_State* L, const char* what) { + auto& plugin = plugin_of(L); + need(L, plugin, "files", what); + if (!within(plugin.services_.now_ms(), plugin.files_second_, plugin.file_calls_, 64)) luaL_error(L, "more than 64 file calls in a second"); + return plugin; + } + // list(folder?): the names in it, folders ending in "/"; or nil, why. + static int files_list(lua_State* L) { + auto& plugin = files_call(L, "reskate.files.list"); + const auto folder = data_path(L, 1, true); + std::vector names; + if (const auto why = game(plugin).files_list(plugin.id(), folder, names); !why.empty()) return failed(L, why); + lua_createtable(L, static_cast(names.size()), 0); + for (std::size_t i = 0; i < names.size(); ++i) { + lua_pushlstring(L, names[i].data(), names[i].size()); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; + } + // read(path): the file's text, or nil, why ("not found" for none). + static int files_read(lua_State* L) { + auto& plugin = files_call(L, "reskate.files.read"); + const auto path = data_path(L, 1, false); + std::string text; + if (const auto why = game(plugin).files_read(plugin.id(), path, text); !why.empty()) return failed(L, why); + lua_pushlstring(L, text.data(), text.size()); + return 1; + } + // write(path, text): at most 1 MB a file and 1000 files; the folders it names are made. + static int files_write(lua_State* L) { + auto& plugin = files_call(L, "reskate.files.write"); + const auto path = data_path(L, 1, false); + std::size_t length = 0; + const char* text = luaL_checklstring(L, 2, &length); + if (length > files_most_bytes) return result(L, "a file is at most 1 MB"); + return result(L, game(plugin).files_write(plugin.id(), path, std::string_view(text, length))); + } + // remove(path): a file, or a folder with nothing in it. + static int files_remove(lua_State* L) { + auto& plugin = files_call(L, "reskate.files.remove"); + return result(L, game(plugin).files_remove(plugin.id(), data_path(L, 1, false))); + } + + // ---- ui.dialog ---------------------------------------------------------------------------------- + // dialog(id, {title, message, field = {text, max}, list = {rows = {{text, sub, buttons = {"rename"}}}, + // empty}, buttons = {"OK", {id = "save", label = "Save"}}, file = {label, filter = "*.json"}}). + // Answers come as throwdown_ui events: action "dialog", args {id, button, text, row, file_text}. + // Esc (or B) answers "cancel" and closes it. + static int dialog(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "ui.screen", "reskate.ui.dialog"); + need(L, plugin, "throwdowns", "reskate.ui.dialog (its answers are throwdown_ui events)"); + if (!within(plugin.services_.now_ms(), plugin.dialog_second_, plugin.dialog_calls_, 120)) luaL_error(L, "more than 120 dialog calls in a second"); + DialogSpec spec; + spec.id = checked_text(L, 1, 32, "a dialog id"); + if (spec.id.empty()) luaL_argerror(L, 1, "a dialog id of 1 to 32 characters"); + luaL_checktype(L, 2, LUA_TTABLE); + spec.title = text_field(L, 2, "title", 64); + spec.message = text_field(L, 2, "message", 1024); + lua_getfield(L, 2, "field"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + spec.field.present = true; + spec.field.max = static_cast(number_field(L, at, "max", 64, 1, 256)); + spec.field.text = text_field(L, at, "text", spec.field.max); + } + lua_pop(L, 1); + lua_getfield(L, 2, "list"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + spec.list = true; + spec.empty = text_field(L, at, "empty", 256); + lua_getfield(L, at, "rows"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int rows = lua_gettop(L); + const int count = lua_objlen(L, rows); + if (count > 64) luaL_error(L, "a dialog's list has at most 64 rows"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, rows, i); + luaL_checktype(L, -1, LUA_TTABLE); + const int row = lua_gettop(L); + DialogSpec::Row r{text_field(L, row, "text", 128), text_field(L, row, "sub", 128), {}}; + lua_getfield(L, row, "buttons"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int buttons = lua_objlen(L, -1); + if (buttons > 4) luaL_error(L, "a row has at most 4 buttons"); + for (int b = 1; b <= buttons; ++b) { + lua_rawgeti(L, -1, b); + std::size_t length = 0; + const char* name = lua_isstring(L, -1) ? lua_tolstring(L, -1, &length) : nullptr; + if (!name || !length || length > 24) luaL_error(L, "a row's buttons are names of 1 to 24 characters"); + r.buttons.emplace_back(name, length); + lua_pop(L, 1); + } + } + lua_pop(L, 2); + spec.rows.push_back(std::move(r)); + } + } + lua_pop(L, 1); + } + lua_pop(L, 1); + lua_getfield(L, 2, "buttons"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + const int count = lua_objlen(L, at); + if (count > 4) luaL_error(L, "a dialog has at most 4 buttons"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, at, i); + DialogSpec::Button button; + if (lua_istable(L, -1)) { + button.label = text_field(L, lua_gettop(L), "label", 32); + button.id = text_field(L, lua_gettop(L), "id", 24); + } else if (lua_isstring(L, -1)) { + std::size_t length = 0; + const char* label = lua_tolstring(L, -1, &length); + if (length > 32) luaL_error(L, "a button's label is at most 32 characters"); + button.label.assign(label, length); + } else luaL_error(L, "a dialog's buttons are labels or {id, label}"); + if (button.label.empty()) luaL_error(L, "a button needs a label"); + if (button.id.empty()) button.id = button.label.substr(0, 24); + lua_pop(L, 1); + spec.buttons.push_back(std::move(button)); + } + } + lua_pop(L, 1); + lua_getfield(L, 2, "file"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + spec.file.present = true; + spec.file.label = text_field(L, at, "label", 32); + if (spec.file.label.empty()) spec.file.label = "Open a file"; + spec.file.filter = text_field(L, at, "filter", 64); + if (spec.file.filter.empty()) spec.file.filter = "*.*"; + for (const char c : spec.file.filter) + if (!std::isalnum(static_cast(c)) && c != '*' && c != '.' && c != ';' && c != '_' && c != '-') + luaL_error(L, "a file filter is patterns like *.json, split by ;"); + } + lua_pop(L, 1); + return result(L, game(plugin).dialog_open(plugin.id(), spec)); + } + static int close_dialog(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "ui.screen", "reskate.ui.close_dialog"); + const auto id = lua_isnoneornil(L, 1) ? std::string{} : checked_text(L, 1, 32, "a dialog id"); + game(plugin).dialog_close(plugin.id(), id); + return 0; + } + + // ---- skater.push / hold ------------------------------------------------------------------------- + // push(velocity): the skater's velocity (m/s, world) once it is on its board again. + static int push(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.push"); + const float* v = luaL_checkvector(L, 1); + for (int i = 0; i < 3; ++i) + if (!std::isfinite(v[i]) || std::fabs(v[i]) > 100) luaL_argerror(L, 1, "a velocity within 100 m/s on each axis"); + return result(L, game(plugin).skater_push(plugin.id(), Vec3{v[0], v[1], v[2]})); + } + // push_wait(): the skater is still getting back on its board, so a pending push waits two seconds + // more; push_reset(): a pending push is dropped. + static int push_wait(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.push_wait"); + return result(L, game(plugin).skater_push_wait(plugin.id())); + } + static int push_reset(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.push_reset"); + return result(L, game(plugin).skater_push_reset(plugin.id())); + } + static int hold(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.hold"); + return result(L, game(plugin).skater_hold(plugin.id(), lua_toboolean(L, 1) != 0)); + } + + static constexpr std::size_t files_most_bytes = 1u << 20; + static void install(Plugin& plugin, lua_State* L); +}; + +void install_kit_api(Plugin& plugin, lua_State* L) { KitApi::install(plugin, L); } + +namespace { +// Adds the functions to the table at `field` of the table on top of the stack, making it when +// there is none yet (a table made read-only earlier in the install is opened for this). +void into(lua_State* L, const char* field, std::initializer_list> functions) { + lua_getfield(L, -1, field); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_createtable(L, 0, static_cast(functions.size())); + lua_pushvalue(L, -1); + const bool locked = lua_getreadonly(L, -3) != 0; + lua_setreadonly(L, -3, false); + lua_setfield(L, -3, field); + lua_setreadonly(L, -2, locked); + } + lua_setreadonly(L, -1, false); + for (const auto& [name, function] : functions) { + lua_pushcfunction(L, function, name); + lua_setfield(L, -2, name); + } + lua_setreadonly(L, -1, true); + lua_pop(L, 1); +} +} // namespace + +void KitApi::install(Plugin&, lua_State* L) { + into(L, "throwdowns", {{"ui", ui}}); + lua_getfield(L, -1, "throwdowns"); + into(L, "flag", {{"begin", flag_begin}, {"state", flag_state}, {"leave", flag_leave}}); + lua_pop(L, 1); + into(L, "files", {{"list", files_list}, {"read", files_read}, {"write", files_write}, {"remove", files_remove}}); + into(L, "ui", {{"dialog", dialog}, {"close_dialog", close_dialog}}); + into(L, "skater", {{"push", push}, {"push_wait", push_wait}, {"push_reset", push_reset}, {"hold", hold}}); +} +} diff --git a/Extension/Plugins/plugin_api_ui.cpp b/Extension/Plugins/plugin_api_ui.cpp index d8aa489e..b4af440b 100644 --- a/Extension/Plugins/plugin_api_ui.cpp +++ b/Extension/Plugins/plugin_api_ui.cpp @@ -520,7 +520,7 @@ struct UiApi { return 1; } if (!plugin.services_.ui_sound(name)) - luaL_error(L, "there is no menu sound \"%s\": select, cancel, navigate, toggle or tab", name.c_str()); + luaL_error(L, "there is no menu sound \"%s\": select, cancel, navigate, toggle, tab, countdown or go", name.c_str()); lua_pushboolean(L, 1); return 1; } diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 5e058784..19d72fe6 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -254,8 +254,14 @@ struct WorldApi { lua_setfield(L, -2, "position"); push_position(L, view->forward); lua_setfield(L, -2, "forward"); + push_position(L, view->right); + lua_setfield(L, -2, "right"); + push_position(L, view->up); + lua_setfield(L, -2, "up"); lua_pushnumber(L, view->fov); lua_setfield(L, -2, "fov"); + lua_pushboolean(L, view->free_camera); + lua_setfield(L, -2, "free"); return 1; } // ---- trainer: one save point of the plugin's own ------------------------------------------- @@ -374,6 +380,119 @@ struct WorldApi { if (!within(plugin.services_.now_ms(), plugin.edit_second_, plugin.edits_, 4)) return result(L, "more than 4 edits in a second"); return result(L, game(plugin).edit_object(operation, object)); } + // ---- world.entities ---------------------------------------------------------------------------- + // set(id, blueprint, transform, hidden?): the plugin's object `id` (1 to 2^31) is an entity of the + // world object blueprint at the transform, made, moved or taken away by the game a few a frame. + // A transform is {position = vector, right = vector, up = vector, forward = vector} (axes carry + // their scale), {position, yaw, pitch, roll (degrees), scale (number or vector)}, or + // {matrix = {16 numbers}} (rows right, up, forward, position). + static std::uint32_t entity_id(lua_State* L, int index) { + const double id = luaL_checknumber(L, index); + if (!(id >= 1 && id <= 2147483647.0) || id != std::floor(id)) luaL_argerror(L, index, "an object's id is a whole number from 1"); + return static_cast(id); + } + static std::array axis_of(lua_State* L, int table, const char* field) { + lua_getfield(L, table, field); + const float* v = luaL_checkvector(L, -1); + std::array result{v[0], v[1], v[2]}; + lua_pop(L, 1); + return result; + } + static std::array transform_of(lua_State* L, int table) { + luaL_checktype(L, table, LUA_TTABLE); + std::array m{}; + lua_getfield(L, table, "matrix"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + if (lua_objlen(L, -1) != 16) luaL_error(L, "a matrix is 16 numbers"); + for (int i = 0; i < 16; ++i) { + lua_rawgeti(L, -1, i + 1); + m[i] = static_cast(luaL_checknumber(L, -1)); + lua_pop(L, 1); + } + lua_pop(L, 1); + return m; + } + lua_pop(L, 1); + lua_getfield(L, table, "position"); + const auto at = position_of(L, -1, "an object's position"); + lua_pop(L, 1); + lua_getfield(L, table, "right"); + const bool axes = !lua_isnil(L, -1); + lua_pop(L, 1); + std::array right{1, 0, 0}, up{0, 1, 0}, forward{0, 0, 1}; + if (axes) { + right = axis_of(L, table, "right"); + up = axis_of(L, table, "up"); + forward = axis_of(L, table, "forward"); + } else { + const auto degrees = [&](const char* field) { + lua_getfield(L, table, field); + const double value = luaL_optnumber(L, -1, 0); + lua_pop(L, 1); + if (!std::isfinite(value)) luaL_error(L, "%s is a finite number of degrees", field); + return value * 3.14159265358979323846 / 180; + }; + const double yaw = degrees("yaw"), pitch = degrees("pitch"), roll = degrees("roll"); + // Yaw about up (forward = sin, 0, cos at zero pitch), then pitch about right, then roll + // about forward, as the game lays out a world object's axes. + const double cy = std::cos(yaw), sy = std::sin(yaw), cp = std::cos(pitch), sp = std::sin(pitch), cr = std::cos(roll), sr = std::sin(roll); + const std::array f{sy * cp, sp, cy * cp}, r0{cy, 0, -sy}; + const std::array u0{f[1] * r0[2] - f[2] * r0[1], f[2] * r0[0] - f[0] * r0[2], f[0] * r0[1] - f[1] * r0[0]}; + for (int i = 0; i < 3; ++i) { + right[i] = static_cast(r0[i] * cr + u0[i] * sr); + up[i] = static_cast(u0[i] * cr - r0[i] * sr); + forward[i] = static_cast(f[i]); + } + lua_getfield(L, table, "scale"); + std::array scale{1, 1, 1}; + if (lua_isnumber(L, -1)) scale.fill(static_cast(lua_tonumber(L, -1))); + else if (lua_isvector(L, -1)) { + const float* v = lua_tovector(L, -1); + scale = {v[0], v[1], v[2]}; + } else if (!lua_isnil(L, -1)) luaL_error(L, "a scale is a number or a vector"); + lua_pop(L, 1); + for (int i = 0; i < 3; ++i) right[i] *= scale[0], up[i] *= scale[1], forward[i] *= scale[2]; + } + for (int i = 0; i < 3; ++i) m[i] = right[i], m[4 + i] = up[i], m[8 + i] = forward[i], m[12 + i] = at[i]; + m[15] = 1; + return m; + } + static int entities_set(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.set"); + 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)); + } + static int entities_remove(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.remove"); + game(plugin).entity_remove(plugin.id(), entity_id(L, 1)); + return 0; + } + static int entities_clear(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.clear"); + game(plugin).entity_clear(plugin.id()); + return 0; + } + static int entities_status(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.status"); + static constexpr const char* names[] = {nullptr, "waiting", "loading", "missing", "ready", "hidden"}; + const auto status = game(plugin).entity_status(plugin.id(), entity_id(L, 1)); + if (status <= 0 || status > 5) lua_pushnil(L); + else lua_pushstring(L, names[status]); + return 1; + } + static int entities_ready(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.ready"); + lua_pushboolean(L, game(plugin).entities_ready(plugin.id())); + return 1; + } static int place(lua_State* L) { need(L, plugin_of(L), "world.objects", "reskate.objects.place"); auto object = pose(L, 2, 3, 4); @@ -1054,9 +1173,11 @@ struct WorldApi { else lua_pushlstring(L, status.data(), status.size()); return 1; } - // mode{id, name, description, art, flag, players = {min, max, default}, - // timer = {caption, min, max, step, default}, rows = {{id, kind, caption, ...}}}: the mode's card on - // the Throwdowns page, its setup page and its start flag. Again with the same id updates it. + // mode{id, name, description, art, color, card, replaces, order, flag, cursor_height, + // players = {min, max, default}}: the mode's card on the Throwdowns page (cloned from the stock + // `card`: "skate", "spot" or "jam") and its native flag's preview prefab; cursor_height (metres) + // lets its flag's placement cursor rise onto anything that high. Its setup page is the plugin's + // `setup` document (throwdowns.ui). Again with the same id updates it. static int int_field(lua_State* L, int table, const char* name, int fallback) { lua_getfield(L, table, name); const int value = lua_isnil(L, -1) ? fallback : static_cast(luaL_checkinteger(L, -1)); @@ -1073,35 +1194,6 @@ struct WorldApi { lua_pop(L, 1); return value; } - static ThrowdownRow row_of(lua_State* L, int table, const char* fallback_kind) { - ThrowdownRow row; - row.id = text_field(L, table, "id"); - row.kind = text_field(L, table, "kind"); - if (row.kind.empty()) row.kind = fallback_kind; - if (row.kind != "slider" && row.kind != "choice" && row.kind != "button") - luaL_error(L, "a row's kind is \"slider\", \"choice\" or \"button\""); - row.caption = text_field(L, table, "caption"); - row.text = text_field(L, table, "text"); - row.minimum = int_field(L, table, "min", 0); - row.maximum = int_field(L, table, "max", 0); - row.step = int_field(L, table, "step", 1); - row.value = int_field(L, table, "default", row.minimum); - lua_getfield(L, table, "options"); - if (!lua_isnil(L, -1)) { - luaL_checktype(L, -1, LUA_TTABLE); - const int count = lua_objlen(L, -1); - for (int i = 1; i <= count && i <= 16; ++i) { - lua_rawgeti(L, -1, i); - if (!lua_isstring(L, -1)) luaL_error(L, "a choice's options are strings"); - row.options.emplace_back(lua_tostring(L, -1)); - lua_pop(L, 1); - } - } - lua_pop(L, 1); - // A choice's default is its option's number, from 1 as Lua counts. - if (row.kind == "choice") row.value = int_field(L, table, "default", 1) - 1; - return row; - } static int throwdown_mode(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "throwdowns", "reskate.throwdowns.mode"); @@ -1113,10 +1205,31 @@ struct WorldApi { mode.art = text_field(L, 1, "art"); mode.flag = text_field(L, 1, "flag"); mode.replaces = text_field(L, 1, "replaces"); - if (!mode.replaces.empty() && mode.replaces != "skate" && mode.replaces != "spot_battle" && mode.replaces != "jam") - luaL_argerror(L, 1, "replaces is \"skate\", \"spot_battle\" or \"jam\""); + mode.card = text_field(L, 1, "card"); + if (mode.card.empty()) mode.card = "spot"; + mode.order = int_field(L, 1, "order", 0); + lua_getfield(L, 1, "color"); + if (lua_isnumber(L, -1)) { + const double c = lua_tonumber(L, -1); + if (!(c >= 0 && c <= 4294967295.0)) luaL_argerror(L, 1, "color is 0xAABBGGRR"); + mode.color = static_cast(c); + } + lua_pop(L, 1); + lua_getfield(L, 1, "cursor_height"); + if (!lua_isnil(L, -1)) { + const double h = luaL_checknumber(L, -1); + if (!(h >= 0 && h <= 100000)) luaL_argerror(L, 1, "cursor_height is 0 to 100000 metres"); + mode.cursor_height = static_cast(h); + } + lua_pop(L, 1); + const auto stock = [](const std::string& tile) { return tile == "skate" || tile == "spot" || tile == "spot_battle" || tile == "jam"; }; + if (!mode.replaces.empty() && !stock(mode.replaces)) luaL_argerror(L, 1, "replaces is \"skate\", \"spot\" or \"jam\""); + if (!stock(mode.card)) luaL_argerror(L, 1, "card is \"skate\", \"spot\" or \"jam\""); + if (mode.flag.size() > 256 || mode.art.size() > 256) luaL_argerror(L, 1, "art and flag are at most 256 characters"); + // An asset path: letters, digits, _ - . and /. for (const char c : mode.flag) - if (!std::isalnum(static_cast(c)) && c != '_') luaL_argerror(L, 1, "flag is letters, digits and _"); + if (!std::isalnum(static_cast(c)) && c != '_' && c != '/' && c != '-' && c != '.') + luaL_argerror(L, 1, "flag is an asset path: letters, digits, _, -, . and /"); lua_getfield(L, 1, "players"); if (!lua_isnil(L, -1)) { luaL_checktype(L, -1, LUA_TTABLE); @@ -1126,27 +1239,6 @@ struct WorldApi { mode.players = int_field(L, at, "default", std::min(4, mode.max_players)); } lua_pop(L, 1); - lua_getfield(L, 1, "timer"); - if (!lua_isnil(L, -1)) { - luaL_checktype(L, -1, LUA_TTABLE); - auto timer = row_of(L, lua_gettop(L), "slider"); - if (timer.caption.empty()) timer.caption = "Timer"; - timer.id = "timer"; - mode.timer = std::move(timer); - } - lua_pop(L, 1); - lua_getfield(L, 1, "rows"); - if (!lua_isnil(L, -1)) { - luaL_checktype(L, -1, LUA_TTABLE); - const int count = lua_objlen(L, -1); - for (int i = 1; i <= count && i <= 16; ++i) { - lua_rawgeti(L, -1, i); - luaL_checktype(L, -1, LUA_TTABLE); - mode.rows.push_back(row_of(L, lua_gettop(L), "slider")); - lua_pop(L, 1); - } - } - lua_pop(L, 1); return result(L, game(plugin).set_throwdown_mode(plugin.id(), mode)); } static int throwdown_remove_mode(lua_State* L) { @@ -1155,12 +1247,6 @@ struct WorldApi { game(plugin).remove_throwdown_mode(plugin.id(), luaL_checkstring(L, 1)); return 0; } - static int throwdown_release_flag(lua_State* L) { - auto& plugin = plugin_of(L); - need(L, plugin, "throwdowns", "reskate.throwdowns.release_flag"); - game(plugin).release_throwdown_flag(plugin.id()); - return 0; - } static int throwdown_state(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "throwdowns", "reskate.throwdowns.state"); @@ -1490,6 +1576,8 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { into(L, "hud", {{"hide", hide_hud}, {"corner", hud_corner}}); into(L, "time", {{"slow", slow}, {"speed", speed}}); into(L, "objects", {{"catalog", catalog}, {"list", objects}, {"place", place}, {"move", move}, {"remove", remove}}); + into(L, "entities", {{"set", entities_set}, {"remove", entities_remove}, {"clear", entities_clear}, {"status", entities_status}, + {"ready", entities_ready}}); into(L, "world", {{"set_time", set_time}, {"set_control", set_control}, {"set_park", set_park}}); into(L, "travel", {{"locations", locations}, {"levels", levels}, {"go", go}, {"load", load}}); into(L, "ai", {{"spawn", ai_spawn}, {"clear", ai_clear}, {"count", ai_count}}); @@ -1502,7 +1590,7 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { into(L, "audio", {{"play", audio_play}, {"set", audio_set}, {"stop", audio_stop}, {"playing", audio_playing}}); into(L, "server", {{"kick", server_kick}, {"say", server_say}, {"tell", server_tell}, {"move", server_move}}); into(L, "throwdowns", {{"state", throwdown_state}, {"host", throwdown_host}, {"cancel", throwdown_cancel}, {"hosting", throwdown_hosting}, - {"mode", throwdown_mode}, {"remove_mode", throwdown_remove_mode}, {"release_flag", throwdown_release_flag}}); + {"mode", throwdown_mode}, {"remove_mode", throwdown_remove_mode}}); into(L, "music", {{"current", current}, {"play", play}, {"pause", pause}, {"next", next}, {"previous", previous}, {"game", game_current}, {"game_next", game_next}}); into(L, "progression", {{"get", progress}}); diff --git a/Extension/Plugins/plugin_camera.h b/Extension/Plugins/plugin_camera.h index bb01df12..7fc4ce14 100644 --- a/Extension/Plugins/plugin_camera.h +++ b/Extension/Plugins/plugin_camera.h @@ -10,8 +10,7 @@ #include #include -// World positions on screen, through the camera as it is now (as the nametags and Domination's -// overlay read it). The plugin host projects with it on the client thread (world_to_screen) and the +// World positions on screen, through the camera as it is now (as the nametags read it). The plugin host projects with it on the client thread (world_to_screen) and the // overlay on the render thread (world shapes), each with the camera of its own moment. namespace dingosdk::plugin_camera { inline std::atomic& image_base() { diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index 8cbbebc8..bd3c719a 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -1,6 +1,8 @@ #pragma once #include "plugin_wave.h" +#include "Engine/Core/Json/json.h" #include "Extension/AnimationStudio/anim_core.h" +#include "Extension/Throwdowns/throwdown_kit.h" #include #include #include @@ -25,6 +27,38 @@ inline constexpr char unavailable[] = "unavailable here"; struct CameraView { Vec3 position{}, forward{}; float fov{}; + Vec3 right{}, up{}; + bool free_camera{}; // the view is Freecam's, verified selected (not just asked for) +}; +// What a game layer reads of the local skater each tick (reskate.skater.telemetry): the trainer's +// telemetry and its last measured jump, the physics state the skater's selector chose (with No +// Bail's wipeout and runout counts), the Bone Cam's slams and where the body is during a bail. +struct SkaterTelemetry { + bool skater{}, airborne{}; + Vec3 position{}; + float heading{}; // the trainer's degrees: 0 faces +Z, forward (sin, 0, cos) + float speed{}, vertical{}, air_time{}, height{}, top_speed{}; + std::uint32_t physics_state{}; + struct Jump { + std::uint64_t serial{}; + float takeoff_speed{}, takeoff_angle{}, air_time{}, height{}, distance{}, drop{}, landing_speed{}; + float spin{}, spin_rate{}, flip{}, board_turn{}; + std::uint32_t state{}; + Vec3 takeoff{}, landing{}; + } last; + struct Watch { + bool valid{}, ragdoll_known{}, ragdoll{}; + std::uint32_t state{}, previous{}; + float previous_seconds{}; + std::uint64_t changes{}, wipeouts{}, runouts{}; + } watch; + bool teleport_pending{}, slam{}, struck{}; + std::optional body; // the hips, during a bail (Bone Cam) +}; +struct TrickNote { + std::uint64_t entry{}; + char kind{}; // 'n' name, 's' side, 'l' landing, 'b' body spin, 'r' board spin, 'k' stance, 'm' modifier + std::string text; }; // Input for a plugin that flies something: the flight keys and mouse look (as Freecam reads them), // a gamepad's sticks, triggers and buttons (XInput's bits), a joystick or radio's axes and buttons. @@ -156,36 +190,43 @@ struct ThrowdownState { }; std::vector skate; // S.K.A.T.E.'s players and letters, while one is shown }; -// A throwdown mode on the game's Throwdowns page: a card on its picker, the game's own setup -// page with these rows, and the game's start flag. The plugin runs the mode itself. -struct ThrowdownRow { - std::string id, kind, caption, text; // kind "slider", "choice" or "button"; text: a button's label - int minimum{}, maximum{}, step{1}, value{}; - std::vector options; // a choice's; its value is the option's index (from 0) -}; +// A throwdown mode on the game's Throwdowns page: a card on its picker, the game's own setup page +// (relabelled from the plugin's setup document) and the game's start flag. The plugin runs it. struct ThrowdownMode { std::string id, name, description; - std::string art; // the logo: a texture or image asset the plugin's mod adds, by path; empty: Spot Battle's - std::string flag; // the mod's own flag (InstanceId ReSkate.); empty: Spot Battle's - std::string replaces; // the stock throwdown whose place it takes: "skate", "spot_battle", "jam" or empty + std::string art; // the logo: a texture or image asset the plugin's mod adds, by path; empty: the card's own + std::string flag; // the placement-preview prefab its native flag shows, by path; empty: Spot Battle's + std::string card = "spot"; // the stock card it is cloned from: "skate", "spot" or "jam" + std::string replaces; // the stock throwdown whose place it takes: "skate", "spot", "jam" or empty + std::uint32_t color{}; + int order{}; // among the plugin modes on the picker int min_players = 1, max_players = 6, players = 4; - std::optional timer; - std::vector rows; + float cursor_height{}; // > 0: its flag's placement cursor may rise this far (metres) }; -// "opened", "setting", "button", "placed", "start", "leave" or "cancelled" for one of the -// plugin's modes, with every setting (players, timer and the rows by id). -// In a session the flag is offered to the other players with the same plugin mode: the host hears -// "joined" and "left" (`player`); a player in another's flag hears "joined" and "left" (themselves), -// "closed" (taken down) and "start", whose `players` (the leader first) also come to the host's. -struct ThrowdownModeNews { - std::string mode, action, row; - int value{}; - std::map settings; - std::optional at; - float heading{}; // degrees - bool host = true; - std::uint64_t leader{}, player{}; - std::vector players; +// reskate.ui.dialog: an overlay dialog over the game (plugin_dialog.cpp). Its answers come back to +// the plugin as throwdown_ui events with action "dialog" (throwdown_kit.h). +struct DialogSpec { + std::string id, title, message; + struct Field { + bool present{}; + std::string text; + std::size_t max{64}; + } field; + struct Row { + std::string text, sub; + std::vector buttons; // each answers with its name and the row + }; + bool list{}; + std::vector rows; + std::string empty; // shown in a list with no rows + struct Button { + std::string id, label; + }; + std::vector