diff --git a/.gitignore b/.gitignore index 70500ef5..a31f8b1c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ 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 +/contrib/plugins/*.fbmod diff --git a/CMakeLists.txt b/CMakeLists.txt index 7553abbf..8033bb8c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -258,6 +258,10 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) 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) + add_executable(dingosdk_custom_throwdown_tests Extension/Throwdowns/Test/custom_throwdown_tests.cpp + Extension/Throwdowns/custom_modes.cpp) + target_include_directories(dingosdk_custom_throwdown_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME custom_throwdowns COMMAND dingosdk_custom_throwdown_tests) add_executable(dingosdk_multiplayer_native_tests Extension/Multiplayer/Test/native_layout_tests.cpp Extension/Multiplayer/Net/protocol.cpp) add_test(NAME multiplayer_native_layout COMMAND dingosdk_multiplayer_native_tests) diff --git a/Engine/Vfs/Test/ebx_pick.cpp b/Engine/Vfs/Test/ebx_pick.cpp new file mode 100644 index 00000000..b258cc10 --- /dev/null +++ b/Engine/Vfs/Test/ebx_pick.cpp @@ -0,0 +1,158 @@ +// A tool, not a test: one mod's edit of an asset list cut down to some of the records it adds. +// dingosdk_ebx_pick ... +// The base is the game's own asset, the edit a mod's copy of it (both decoded EBX). The result +// is the edit with its root list cut back to the base's entries and the named records it +// appended (by DebugName, whole or after its last /). Used to carry the Throwdowns logo +// grid's records without the modes' logos that ReSkate_Throwdown adds beside them +// (tools/builtin/build_throwdown_menu_pack.py). +// +// The edit's bytes are changed in place, nothing else: the kept entries move up to follow the +// base's, and the list's two counts shrink. Rewriting the document instead would lay out its +// boxed values again, which the records' defaults all are (ebx_merge.cpp carries none either). +// The entries left out stay where they were, past the list's end, with their records unlisted: +// nothing finds an asset list's record but through its list. +#include "Engine/Resource/ebx_document.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +namespace ebx = dingosdk::frostbite::ebx; +std::vector load(const char* path) { + std::ifstream input(path, std::ios::binary); + const std::vector raw{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + if (raw.empty()) throw std::runtime_error(std::string("cannot read ") + path); + std::vector bytes(raw.size()); + std::memcpy(bytes.data(), raw.data(), raw.size()); + return bytes; +} +std::string debug_name(const ebx::Document& document, const ebx::Value& entry) { + const auto* pointer = std::get_if(&entry.data); + if (!pointer || pointer->kind != ebx::PointerKind::internal || pointer->index < 0 || + static_cast(pointer->index) >= document.instances.size()) return {}; + const auto& object = document.instances[static_cast(pointer->index)].object; + const auto* field = object ? object->find("DebugName") : nullptr; + const auto* text = field ? std::get_if(&field->value.data) : nullptr; + return text ? *text : std::string{}; +} +template T get(const std::vector& bytes, std::size_t at) { + if (at + sizeof(T) > bytes.size()) throw std::runtime_error("read past the document's end"); + T value{}; + std::memcpy(&value, bytes.data() + at, sizeof(T)); + return value; +} +template void put(std::vector& bytes, std::size_t at, T value) { std::memcpy(bytes.data() + at, &value, sizeof(T)); } +} // namespace + +int main(int argc, char** argv) { + if (argc < 5) { + std::cerr << "usage: dingosdk_ebx_pick ...\n"; + return 2; + } + try { + const auto base = ebx::read_document(load(argv[1])); + auto bytes = load(argv[2]); + const auto edit = ebx::read_document(bytes); + const std::set wanted(argv + 4, argv + argc); + const auto* base_root = base.root(); + const auto* edit_root = edit.root(); + if (!base_root || !base_root->object || !edit_root || !edit_root->object) throw std::runtime_error("no root object"); + std::set found; + unsigned lists{}; + for (const auto& field : edit_root->object->fields) { + const auto* entries = std::get_if(&field.value.data); + const auto* before = base_root->object->find(field.name); + const auto* base_entries = before ? std::get_if(&before->value.data) : nullptr; + if (!entries || !base_entries || entries->size() <= base_entries->size()) continue; + // The list's storage: the one array record of its length whose 8-byte slots decode to + // exactly its entries. + const auto slot_kind = [&](std::size_t at) { return get(bytes, at); }; + std::optional record; + for (std::size_t r = 0; r < edit.arrays.size(); ++r) { + const auto& array = edit.arrays[r]; + if (array.count != entries->size()) continue; + const auto start = edit.dataStart + array.offset; + bool same = true; + for (std::size_t i = 0; i < entries->size() && same; ++i) { + const auto* pointer = std::get_if(&(*entries)[i].data); + const auto encoded = slot_kind(start + i * 8); + if (!pointer) same = false; + else if (pointer->kind == ebx::PointerKind::null) same = encoded == 0; + else if (pointer->kind == ebx::PointerKind::external) same = encoded == ((pointer->index << 1) | 1); + else same = (encoded & 1) == 0 && encoded != 0 && + static_cast(start + i * 8) + encoded - static_cast(edit.dataStart) == + edit.instances[static_cast(pointer->index)].dataOffset; + } + if (!same) continue; + if (record) throw std::runtime_error("two arrays hold " + field.name); + record = r; + } + if (!record) throw std::runtime_error("cannot find the storage of " + field.name); + const auto& array = edit.arrays[*record]; + const auto start = edit.dataStart + array.offset; + if (get(bytes, start - 4) != array.count) throw std::runtime_error(field.name + "'s stored count differs"); + std::vector order; + for (std::size_t i = 0; i < base_entries->size(); ++i) order.push_back(i); + for (auto at = base_entries->size(); at < entries->size(); ++at) { + const auto name = debug_name(edit, (*entries)[at]); + const auto leaf = name.substr(name.find_last_of('/') + 1); + if (wanted.contains(name) || wanted.contains(leaf)) { + order.push_back(at); + found.insert(leaf); + } else if (!name.empty()) std::cout << "left out " << name << '\n'; + } + const std::vector before_bytes(bytes.begin() + static_cast(start), + bytes.begin() + static_cast(start + array.count * 8)); + const std::set internal(edit.pointerOffsets.begin(), edit.pointerOffsets.end()); + const std::set external(edit.importOffsets.begin(), edit.importOffsets.end()); + for (std::size_t slot = 0; slot < order.size(); ++slot) { + const auto from = order[slot]; + if (from == slot) continue; + std::memcpy(bytes.data() + start + slot * 8, before_bytes.data() + from * 8, 8); + const auto encoded = get(before_bytes, from * 8); + const auto offset = static_cast(array.offset + slot * 8); + if (encoded == 0) continue; + if (encoded & 1) { + if (!external.contains(offset)) throw std::runtime_error("an import moved to a slot with no import fixup"); + continue; + } + // An internal pointer is relative to its own slot. + if (!internal.contains(offset)) throw std::runtime_error("a pointer moved to a slot with no pointer fixup"); + const auto target = static_cast(start + from * 8) + encoded; + put(bytes, start + slot * 8, static_cast(target - static_cast(start + slot * 8))); + } + // Both counts: before the list's storage, and its EBXX array record. + put(bytes, start - 4, static_cast(order.size())); + std::size_t extra{}; + for (std::size_t at = 0; at + 16 <= bytes.size(); ++at) + if (std::memcmp(bytes.data() + at, "EBXX", 4) == 0 && get(bytes, at + 8) == edit.arrays.size() && + get(bytes, at + 12) == edit.boxedValues.size()) { extra = at + 8; break; } + if (!extra) throw std::runtime_error("cannot find the EBXX array table"); + const auto entry = extra + 8 + *record * 16; + if (get(bytes, entry) != array.offset || get(bytes, entry + 4) != array.count) + throw std::runtime_error("the EBXX array record differs"); + put(bytes, entry + 4, static_cast(order.size())); + ++lists; + } + if (!lists) throw std::runtime_error("the edit appends to none of the base's lists"); + for (const auto& name : wanted) + if (!found.contains(name.substr(name.find_last_of('/') + 1))) throw std::runtime_error("the edit adds no record " + name); + // It must read back as the same document, with the lists it was meant to have. + const auto check = ebx::read_document(bytes); + if (check.instances.size() != edit.instances.size() || check.imports.size() != edit.imports.size()) + throw std::runtime_error("the edited copy does not read back"); + std::ofstream(argv[3], std::ios::binary | std::ios::trunc).write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + std::cout << "kept " << found.size() << " record(s) the edit adds, in place; " << bytes.size() << " bytes\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "dingosdk_ebx_pick: " << error.what() << '\n'; + return 1; + } +} diff --git a/Engine/Vfs/builtin_packs.cpp b/Engine/Vfs/builtin_packs.cpp index 800a9222..a32be4e4 100644 --- a/Engine/Vfs/builtin_packs.cpp +++ b/Engine/Vfs/builtin_packs.cpp @@ -24,7 +24,7 @@ struct Embedded { const wchar_t* resource; const char* name; }; -constexpr Embedded embedded[]{{L"BUILTIN_PACK_FPV_DRONE", "fpv_drone"}}; +constexpr Embedded embedded[]{{L"BUILTIN_PACK_THROWDOWN_MENU", "throwdown_menu"}}; // Retired mods (builtin_packs.h), in small letters. constexpr std::string_view retired[]{"fourth_studio-board_break"}; std::string lower_text(std::string_view text) { diff --git a/Engine/Vfs/builtin_packs.h b/Engine/Vfs/builtin_packs.h index 8c8de083..96e68c02 100644 --- a/Engine/Vfs/builtin_packs.h +++ b/Engine/Vfs/builtin_packs.h @@ -5,7 +5,7 @@ #include // Game content ReSkate carries itself: packs of game assets built by ReSkate Studio, zipped into -// ReSkate.dll (the FPV drone's model, tools/models/build_drone_pack.py). Nobody installs or sees +// ReSkate.dll (the Throwdowns logo grid, tools/builtin/build_throwdown_menu_pack.py). Nobody installs or sees // them. Official mods (below) are the other content ReSkate keeps for every player. Before a merge each is unpacked under Mods/.reskate-builtin/ (a dot folder, // which no mod list shows) when the one embedded differs from what is there, and the catalogue // merges it after every installed mod, always on (mod_catalog.cpp). diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index da8b2af2..4180fee5 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -180,6 +180,17 @@ struct FakeGame final : Game { return {}; } 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" : "")); + 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, {}); } 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)); @@ -1730,6 +1741,46 @@ void hall_of_meat_scores_a_bail() { } // Grind exits as a mod (contrib/plugins/grind-exits): its own checks pass, and RT held in a grind asks // for the heel side and keeps RT and the stick's lean from the game. +void plugins_add_throwdown_modes() { + TestEnvironment environment; + 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", + 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") + 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.action == "start" then reskate.throwdowns.release_flag() end + end) + reskate.console.command("tdgone", "Removes the mode", function() reskate.throwdowns.remove_mode("oneup") 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()); + const auto& mode = game.modes.front(); + check(mode.players == 2 && mode.min_players == 1 && mode.max_players == 6 && mode.flag == "oneup" && mode.art == "OneUp", + "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}}}); + 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(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); + check(game.called("mode throwdown-modes-test oneup 1-UP rows 0 timer") && game.called("mode throwdown-modes-test race Deathrace rows 3 timer"), + "The test plugin's two modes register\n" + environment.all()); +} + void grind_exits_pick_the_side() { TestEnvironment environment; std::filesystem::remove_all(scratch / "storage" / "grind-exits"); @@ -1923,6 +1974,7 @@ int main() { fpv_drone_flies_and_follows(); hall_of_meat_scores_a_bail(); grind_exits_pick_the_side(); + plugins_add_throwdown_modes(); reskate_hostile_is_stopped_or_refused_every_time(); std::filesystem::remove_all(scratch); std::cout << "plugin tests passed\n"; diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index eb54cbbb..242811c3 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -17,14 +17,14 @@ 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"}, + {"physics_step", "events.physics"}, {"grind_exit", "events.skater"}, {"throwdown_mode", "throwdowns"}, }}; constexpr std::size_t maximum_key = 128; constexpr std::size_t maximum_command = 48; diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 1fc34d57..d0cd307a 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -3,6 +3,7 @@ #include "lua.h" #include "lualib.h" #include +#include #include #include #include @@ -1053,6 +1054,110 @@ 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. + 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)); + lua_pop(L, 1); + return value; + } + static std::string text_field(lua_State* L, int table, const char* name) { + 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); + value = lua_tostring(L, -1); + } + 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"); + luaL_checktype(L, 1, LUA_TTABLE); + ThrowdownMode mode; + mode.id = text_field(L, 1, "id"); + mode.name = text_field(L, 1, "name"); + mode.description = text_field(L, 1, "description"); + mode.art = text_field(L, 1, "art"); + mode.flag = text_field(L, 1, "flag"); + 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"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + mode.min_players = int_field(L, at, "min", 1); + mode.max_players = int_field(L, at, "max", 6); + 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) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.remove_mode"); + 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"); @@ -1393,7 +1498,8 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { into(L, "outfit", {{"set", outfit_set}, {"clear", outfit_clear}, {"find", outfit_find}, {"slots", outfit_slots}, {"status", outfit_status}}); 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}}); + 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}}); 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_game.h b/Extension/Plugins/plugin_game.h index 04b11856..c01dcaf8 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -3,6 +3,7 @@ #include "Extension/AnimationStudio/anim_core.h" #include #include +#include #include #include #include @@ -155,6 +156,30 @@ 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) +}; +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 + 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). +struct ThrowdownModeNews { + std::string mode, action, row; + int value{}; + std::map settings; + std::optional at; + float heading{}; // degrees +}; struct Progress { bool available{}; struct District { @@ -223,6 +248,12 @@ public: } virtual void cancel_throwdown(std::string_view /*plugin*/) {} virtual std::string throwdown_status(std::string_view /*plugin*/) { return {}; } + // Adds the mode to the Throwdowns page, or updates it (by id). + virtual std::string set_throwdown_mode(std::string_view /*plugin*/, const ThrowdownMode&) { return unavailable; } + virtual void remove_throwdown_mode(std::string_view /*plugin*/, std::string_view /*id*/) {} + // Takes the plugin's start flag down: its lobby is over. + virtual void release_throwdown_flag(std::string_view /*plugin*/) {} + virtual std::vector take_throwdown_news(std::string_view /*plugin*/) { return {}; } // ---- the rest ---------------------------------------------------------------------------------- // The music an outside player (Spotify and the like) is playing, and "play", "pause", // "next" or "previous" for it. diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 5f702a4f..111e855e 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -21,6 +21,8 @@ #include "Extension/Skater/grind_exit.h" #include "Extension/Skater/xray.h" #include "Extension/UI/hud_corner.h" +#include "Extension/Throwdowns/custom_modes.h" +#include "Extension/Throwdowns/custom_placement.h" #include "plugin_camera.h" #include "Extension/Multiplayer/Session/session.h" #include "Extension/Music/external_player.h" @@ -389,6 +391,40 @@ public: } return grind_exit::wish(plugin, w); } + std::string set_throwdown_mode(std::string_view plugin, const ThrowdownMode& m) override { + using multiplayer::custom_throwdowns::Row; + const auto row_of = [](const ThrowdownRow& r) { + return Row{r.kind == "choice" ? Row::Kind::choice : r.kind == "button" ? Row::Kind::button : Row::Kind::slider, + r.id, r.caption, r.minimum, r.maximum, r.step, r.value, r.options, r.text}; + }; + multiplayer::custom_throwdowns::Mode mode; + mode.plugin = plugin; + mode.id = m.id; + mode.name = m.name; + mode.description = m.description; + mode.art = m.art; + mode.flag = m.flag; + mode.min_players = m.min_players; + mode.max_players = m.max_players; + mode.players = m.players; + if (m.timer) mode.timer = row_of(*m.timer); + for (const auto& r : m.rows) mode.rows.push_back(row_of(r)); + return multiplayer::custom_throwdowns::set_mode(std::move(mode)); + } + void remove_throwdown_mode(std::string_view plugin, std::string_view id) override { + multiplayer::custom_throwdowns::remove_mode(plugin, id); + } + void release_throwdown_flag(std::string_view plugin) override { multiplayer::custom_throwdowns::release(plugin); } + std::vector take_throwdown_news(std::string_view plugin) override { + std::vector out; + for (auto& n : multiplayer::custom_throwdowns::take(plugin)) { + 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; + out.push_back(std::move(news)); + } + return out; + } std::vector take_grind_pops(std::string_view plugin) override { std::vector out; for (auto& p : grind_exit::take_pops(plugin)) out.push_back({p.applied, std::move(p.reason), std::move(p.basis), p.toward, p.before, p.after}); @@ -624,6 +660,8 @@ public: board_pose::clear(plugin); board_pose::park(plugin, {}); cancel_throwdown(plugin); + multiplayer::custom_throwdowns::release(plugin); + multiplayer::custom_throwdowns::remove_plugin(plugin); if (capturer_ == plugin) capturer_.clear(); if (std::erase_if(replacements_, [&](const auto& r) { return r.second.first == plugin; })) publish_replacements(); if (recorder_ == plugin) recorder_.clear(); diff --git a/Extension/Plugins/plugin_host.cpp b/Extension/Plugins/plugin_host.cpp index b7522822..2d548ca5 100644 --- a/Extension/Plugins/plugin_host.cpp +++ b/Extension/Plugins/plugin_host.cpp @@ -348,6 +348,25 @@ void Host::follow_game() { } } if (wants("throwdowns")) { + each([&](Plugin& plugin, const std::string& id) { + for (const auto& news : game.take_throwdown_news(id)) { + auto data = Json::object(); + data["mode"] = news.mode; + data["action"] = news.action; + if (!news.row.empty()) { + data["row"] = news.row; + data["value"] = news.value; + } + auto settings = Json::object(); + for (const auto& [row, value] : news.settings) settings[row] = value; + data["settings"] = std::move(settings); + if (news.at) { + data["position"] = Json::array({(*news.at)[0], (*news.at)[1], (*news.at)[2]}); + data["heading"] = news.heading; + } + plugin.game_event("throwdown_mode", "throwdowns", data); + } + }); const bool active = game.throwdowns().active; if (active != throwdown_active_) { throwdown_active_ = active; diff --git a/Extension/Throwdowns/Test/custom_throwdown_tests.cpp b/Extension/Throwdowns/Test/custom_throwdown_tests.cpp new file mode 100644 index 00000000..07b181a3 --- /dev/null +++ b/Extension/Throwdowns/Test/custom_throwdown_tests.cpp @@ -0,0 +1,105 @@ +// Plugin throwdown modes without the game: the registry's checks and news, and the +// pure helpers the native setup page uses. +#include "Extension/Throwdowns/custom_modes.h" +#include "Extension/UI/NativeMenu/native_menu_presenters.h" +#include +#include +#include +#include +#include +#include + +namespace { +using namespace dingosdk::multiplayer; +void check(bool value, const std::string& message) { if (!value) throw std::runtime_error(message); } + +custom_throwdowns::Mode one_up() { + custom_throwdowns::Mode mode; + mode.plugin = "smokey"; + mode.id = "oneup"; + mode.name = "1-UP"; + mode.description = "Beat the last total."; + mode.players = 9; // more than max: clamped + mode.timer = custom_throwdowns::Row{custom_throwdowns::Row::Kind::slider, "timer", "Turn", 10, 120, 10, 20}; + mode.rows.push_back({custom_throwdowns::Row::Kind::choice, "tricks", "Tricks", 0, 0, 1, 5, {"All", "Flips"}}); + mode.rows.push_back({custom_throwdowns::Row::Kind::button, "area", "Area", 0, 0, 1, 0, {}, "Set area"}); + return mode; +} + +void modes_are_checked_and_listed() { + check(custom_throwdowns::set_mode(one_up()).empty(), "1-UP registers"); + auto bad = one_up(); + bad.id = "a/b"; + check(!custom_throwdowns::set_mode(bad).empty(), "an id with / is refused"); + bad = one_up(); + bad.rows.push_back({custom_throwdowns::Row::Kind::slider, "players", "Players", 1, 4}); + check(!custom_throwdowns::set_mode(bad).empty(), "a row may not be called players"); + 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"); + 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"); + check(settings.at("players") == 6 && settings.at("timer") == 20 && settings.at("tricks") == 1 && !settings.contains("area"), + "settings start at the defaults, without buttons"); +} + +void settings_and_news_reach_the_plugin() { + const auto before = custom_throwdowns::revision(); + custom_throwdowns::set_setting("smokey/oneup", "players", 3); + custom_throwdowns::set_setting("smokey/oneup", "players", 3); // unchanged: no news + custom_throwdowns::News placed; + placed.action = "placed"; + placed.at = std::array{1, 2, 3}; + placed.heading = 90; + custom_throwdowns::post("smokey/oneup", placed); + const auto news = custom_throwdowns::take("smokey"); + check(news.size() == 2, "one setting and the flag"); + check(news[0].mode == "oneup" && news[0].action == "setting" && news[0].row == "players" && news[0].value == 3, "the setting"); + check(news[1].action == "placed" && news[1].at && (*news[1].at)[2] == 3 && news[1].settings.at("players") == 3, + "the flag, with the settings chosen"); + check(custom_throwdowns::take("smokey").empty() && custom_throwdowns::take("other").empty(), "taken once, by its own plugin"); + // An update keeps what was chosen; a reset goes back to the defaults. + auto again = one_up(); + again.description = "New words."; + check(custom_throwdowns::set_mode(again).empty() && custom_throwdowns::revision() != before, "an update"); + check(custom_throwdowns::settings("smokey/oneup").at("players") == 3, "keeps the players chosen"); + custom_throwdowns::reset_settings("smokey/oneup"); + check(custom_throwdowns::settings("smokey/oneup").at("players") == 6, "a reset starts over"); + custom_throwdowns::remove_plugin("smokey"); + check(custom_throwdowns::modes().empty() && !custom_throwdowns::find("smokey/oneup"), "a plugin's modes go with it"); +} + +struct Presenter { std::uintptr_t blueprint{}; struct { std::uintptr_t record{}; std::uint64_t handle{}; } data; }; +void setup_page_helpers() { + using menu_data::native_slider_choice, menu_data::native_slider_position; + check(native_slider_choice(0.f, 10, 120, 10) == 10 && native_slider_choice(1.f, 10, 120, 10) == 120, "slider ends"); + check(native_slider_choice(native_slider_position(70, 10, 120), 10, 120, 10) == 70, "a choice survives the round trip"); + check(native_slider_choice(.52f, 1, 7, 1) == 4, "rounds to the nearest step"); + check(native_slider_choice(std::nanf(""), 1, 7, 1) == 1, "a broken value is the minimum"); + // Two 32-byte rows with a presenter at 8: the same presenters need no publish; a new one keeps the rest. + std::vector live(64), saved(64); + const Presenter a{1, {0, 10}}, b{2, {0, 20}}; + std::memcpy(live.data() + 8, &a, sizeof a); + std::memcpy(saved.data() + 8, &a, sizeof a); + live[0] = std::byte{7}; // native focus state + check(!menu_data::remount_presenters(live, saved, 32, 8), "same presenters: nothing to publish"); + std::memcpy(saved.data() + 40, &b, sizeof b); + const auto next = menu_data::remount_presenters(live, saved, 32, 8); + check(next && (*next)[0] == std::byte{7}, "a new presenter keeps the live focus bytes"); + check(menu_data::remount_presenters(live, std::span(saved).first(32), 32, 8)->size() == 32, "another count takes the saved rows"); +} +} // namespace + +int main() { + try { + modes_are_checked_and_listed(); + settings_and_news_reach_the_plugin(); + setup_page_helpers(); + std::cout << "custom throwdown checks passed\n"; + return 0; + } catch (const std::exception& e) { + std::cerr << "FAILED: " << e.what() << "\n"; + return 1; + } +} diff --git a/Extension/Throwdowns/custom_modes.cpp b/Extension/Throwdowns/custom_modes.cpp new file mode 100644 index 00000000..ec83e467 --- /dev/null +++ b/Extension/Throwdowns/custom_modes.cpp @@ -0,0 +1,140 @@ +#include "custom_modes.h" +#include +#include +#include + +namespace dingosdk::multiplayer::custom_throwdowns { +namespace { +struct Registry { + std::mutex mutex; + std::vector modes; + std::map, std::less<>> settings; + std::map, std::less<>> news; + std::atomic revision{1}; +}; +Registry& registry() { static auto* r = new Registry; return *r; } +std::map defaults(const Mode& mode) { + std::map result{{"players", mode.players}}; + if (mode.timer) result["timer"] = mode.timer->value; + for (const auto& row : mode.rows) + if (row.kind != Row::Kind::button) result[row.id] = row.value; + return result; +} +std::string plugin_of(std::string_view key) { + const auto at = key.find('/'); + return std::string(at == std::string_view::npos ? key : key.substr(0, at)); +} +} // namespace + +std::string set_mode(Mode mode) { + if (mode.plugin.empty() || mode.id.empty() || mode.id.size() > 32 || mode.id.find('/') != std::string::npos) + 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.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); + if (mode.rows.size() > 8) return "a mode has at most 8 rows"; + std::vector ids{"players", "timer"}; + for (auto& row : mode.rows) { + if (row.id.empty() || std::find(ids.begin(), ids.end(), row.id) != ids.end()) + return "each row needs its own id (not players or timer)"; + ids.push_back(row.id); + if (row.caption.empty() || row.caption.size() > 32) return "a row's caption is 1 to 32 characters"; + if (row.kind == Row::Kind::choice) { + if (row.options.empty() || row.options.size() > 12) return "a choice has 1 to 12 options"; + row.value = std::clamp(row.value, 0, static_cast(row.options.size()) - 1); + } + } + const auto slider = [](Row& row) { + if (row.step < 1 || row.minimum > row.maximum || (row.maximum - row.minimum) / row.step > 600) return false; + row.value = std::clamp(row.value, row.minimum, row.maximum); + return true; + }; + if (mode.timer && !slider(*mode.timer)) return "the timer runs min to max in at most 600 steps"; + for (auto& row : mode.rows) + if (row.kind == Row::Kind::slider && !slider(row)) return "a slider runs min to max in at most 600 steps"; + auto& r = registry(); + std::lock_guard lock(r.mutex); + const auto key = mode.key(); + const auto found = std::find_if(r.modes.begin(), r.modes.end(), [&](const Mode& m) { return m.key() == key; }); + if (found == r.modes.end() && r.modes.size() >= 32) return "32 modes are registered already"; + // An update keeps the values already chosen for rows it still has. + auto values = defaults(mode); + if (found != r.modes.end()) + for (auto& [row, value] : values) + if (const auto kept = r.settings[key].find(row); kept != r.settings[key].end()) value = kept->second; + r.settings[key] = std::move(values); + if (found != r.modes.end()) *found = std::move(mode); + else r.modes.push_back(std::move(mode)); + r.revision.fetch_add(1); + return {}; +} +void remove_mode(std::string_view plugin, std::string_view id) { + auto& r = registry(); + std::lock_guard lock(r.mutex); + if (std::erase_if(r.modes, [&](const Mode& m) { return m.plugin == plugin && m.id == id; })) r.revision.fetch_add(1); +} +void remove_plugin(std::string_view plugin) { + auto& r = registry(); + std::lock_guard lock(r.mutex); + if (std::erase_if(r.modes, [&](const Mode& m) { return m.plugin == plugin; })) r.revision.fetch_add(1); + if (const auto found = r.news.find(plugin); found != r.news.end()) r.news.erase(found); +} +std::vector modes() { + auto& r = registry(); + std::lock_guard lock(r.mutex); + return r.modes; +} +std::optional find(std::string_view key) { + auto& r = registry(); + std::lock_guard lock(r.mutex); + for (const auto& mode : r.modes) + if (mode.key() == key) return mode; + return std::nullopt; +} +std::uint64_t revision() noexcept { return registry().revision.load(); } + +std::map settings(std::string_view key) { + auto& r = registry(); + std::lock_guard lock(r.mutex); + const auto found = r.settings.find(key); + return found == r.settings.end() ? std::map{} : found->second; +} +void reset_settings(std::string_view key) { + auto& r = registry(); + std::lock_guard lock(r.mutex); + for (const auto& mode : r.modes) + if (mode.key() == key) r.settings[std::string(key)] = defaults(mode); +} +void set_setting(std::string_view key, std::string_view row, int value) { + auto& r = registry(); + std::lock_guard lock(r.mutex); + auto& values = r.settings[std::string(key)]; + const auto found = values.find(std::string(row)); + if (found != values.end() && found->second == value) return; + values[std::string(row)] = value; + const auto at = key.find('/'); + auto& queue = r.news[plugin_of(key)]; + if (queue.size() >= 64) queue.erase(queue.begin()); + queue.push_back({std::string(key.substr(at + 1)), "setting", std::string(row), value, values}); +} +void post(std::string_view key, News news) { + auto& r = registry(); + std::lock_guard lock(r.mutex); + const auto at = key.find('/'); + news.mode = std::string(at == std::string_view::npos ? key : key.substr(at + 1)); + if (const auto found = r.settings.find(key); found != r.settings.end() && news.settings.empty()) + news.settings = found->second; + auto& queue = r.news[plugin_of(key)]; + if (queue.size() >= 64) queue.erase(queue.begin()); + queue.push_back(std::move(news)); +} +std::vector take(std::string_view plugin) { + auto& r = registry(); + std::lock_guard lock(r.mutex); + const auto found = r.news.find(plugin); + if (found == r.news.end()) return {}; + return std::exchange(found->second, {}); +} +} // namespace dingosdk::multiplayer::custom_throwdowns diff --git a/Extension/Throwdowns/custom_modes.h b/Extension/Throwdowns/custom_modes.h new file mode 100644 index 00000000..a25e7e07 --- /dev/null +++ b/Extension/Throwdowns/custom_modes.h @@ -0,0 +1,66 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +// Throwdown modes that plugins add to the game's Throwdowns page. A mode is a +// card on the picker, the game's own Spot Battle setup page relabelled with the +// mode's rows, and its native start flag: the plugin is told where the flag +// went and runs the mode itself. Thread-safe; the menu reads it on the UI +// thread, plugins write it from theirs. +namespace dingosdk::multiplayer::custom_throwdowns { +struct Row { + enum class Kind { slider, choice, button }; + Kind kind{}; + std::string id, caption; + int minimum{}, maximum{}, step{1}, value{}; // slider; choice: value is the option index + std::vector options; // choice + std::string text; // button: its label +}; +struct Mode { + std::string plugin, id, name, description; + // The picker logo: a texture or image asset the plugin's mod adds, by path + // (a bare name is a Throwdown_ModeSelect_ContentResources record). Empty: + // the Spot Battle art. + std::string art; + // The mod's flag (InstanceId ReSkate.). Empty: Spot Battle's own. + std::string flag; + 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; + std::string key() const { return plugin + "/" + id; } +}; +// What happened to a plugin's mode: opened (its setup page), setting (a row +// 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. +struct News { + std::string mode, action, row; + int value{}; + std::map settings; + std::optional> at; + float heading{}; // degrees +}; + +// Empty when accepted, else why not. +std::string set_mode(Mode mode); +void remove_mode(std::string_view plugin, std::string_view id); +void remove_plugin(std::string_view plugin); +std::vector modes(); +std::optional find(std::string_view key); +// Changes whenever a mode is set or removed. +std::uint64_t revision() noexcept; + +// The setup page's values: players, timer and each row's value by its id. +// Reset to the mode's defaults when its setup opens. +std::map settings(std::string_view key); +void reset_settings(std::string_view key); +// Records a new value; a change is also news for the plugin. +void set_setting(std::string_view key, std::string_view row, int value); +void post(std::string_view key, News news); +std::vector take(std::string_view plugin); +} // namespace dingosdk::multiplayer::custom_throwdowns diff --git a/Extension/Throwdowns/custom_placement.cpp b/Extension/Throwdowns/custom_placement.cpp new file mode 100644 index 00000000..3c3bf629 --- /dev/null +++ b/Extension/Throwdowns/custom_placement.cpp @@ -0,0 +1,291 @@ +#include "custom_placement.h" +#include "custom_modes.h" +#include "throwdown_lab.h" +#include "Extension/UI/NativeMenu/native_menu_data.h" +#include "Engine/Core/Log/logging.h" +#include +#include +#include +#include +#include +#include + +// The generic path of SmokeyDawg58's 1-UP flag adapter (one_up_placement.cpp in +// reskate-custom-mods): native settings, then the start flag, then the waiting card. +namespace dingosdk::multiplayer::custom_throwdowns { +namespace { +enum class Step { idle, open, setup, flag, placing, closing, waiting, cleanup }; +struct Placement { + std::mutex mutex; + Step step{}; + std::string key; + std::uint64_t until{}, changed{}, sent{}; + std::uint32_t mmid{}; + std::vector queues; + bool created{}, setup_seen{}, flag_seen{}, exited{}, cancelling{}, destroy_queued{}, destroyed{}; + bool handed_off{}, show_setup{}, told{}; + std::optional> at; + float yaw{}; +}; +Placement& placement() { static auto* p = new Placement; return *p; } +void reset(Placement& p) { + p.step = Step::idle; p.key.clear(); p.at.reset(); p.yaw = 0; p.mmid = 0; p.sent = 0; + p.created = p.setup_seen = p.flag_seen = p.exited = p.cancelling = p.destroy_queued = p.destroyed = false; + p.handed_off = p.show_setup = p.told = false; +} +void cleanup(Placement& p, std::uint64_t now) { + if (p.step != Step::cleanup) p.sent = 0; + p.cancelling = true; p.step = Step::cleanup; p.until = now + 15000; +} +// News for the plugin, once per flag: cancelled before the flag was down. +void tell(Placement& p, std::string action) { + if (p.key.empty() || p.told) return; + p.told = action != "placed"; + custom_throwdowns::News news; + news.action = std::move(action); + if (p.at && news.action == "placed") { news.at = p.at; news.heading = p.yaw * 180.f / 3.14159265f; } + post(p.key, std::move(news)); +} +// QueueStateNavigation's live graph publishes these two fields, with metadata +// [-1] for a command without arguments. Do not change the Back keyword stack. +bool navigate(const menu_data::Context& c, std::string_view name) { + using namespace menu_data; + const auto roots = c.roots({0xcc4776b5}); + require(roots.size() == 1, "Native navigation model is unavailable."); + const auto current = c.field(roots.front().model, 0xf3ba9cf8); + const auto metadata = c.field(roots.front().model, 0xf437b255); + if (read(c.address(current))) return false; + c.set(current, game::native_name_hash(name)); + c.array(metadata, std::vector{-1}); + logging::log(logging::Level::info, logging::Channel::ui, "Custom throwdown: navigation {}.", name); + return true; +} +} // namespace + +bool open_setup(const std::string& key) { + if (!find(key)) return false; + auto& p = placement(); + std::lock_guard lock(p.mutex); + // A fresh card replaces a setup that never got as far as a flag. + if (p.step != Step::idle && !((p.step == Step::open || p.step == Step::setup) && !p.flag_seen && !p.mmid)) return false; + tell(p, "cancelled"); // the setup this card replaces, if any + reset(p); + p.step = Step::open; p.key = key; p.show_setup = true; + p.changed = GetTickCount64(); p.until = p.changed + 15000; + reset_settings(key); + custom_throwdowns::News news; + news.action = "opened"; + post(key, std::move(news)); + prepare_throwdown_injection(); + return true; +} +std::string setup_mode() { + auto& p = placement(); + std::lock_guard lock(p.mutex); + return p.show_setup && (p.step == Step::open || p.step == Step::setup) ? p.key : std::string{}; +} +bool placement_active() noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + return p.step != Step::idle && p.step != Step::waiting; +} +bool registration_owned() noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + // The plugin's match outlives the temporary registration. Once that is + // destroyed, stock throwdowns own their events again. + return p.step != Step::idle && (p.step != Step::waiting || p.mmid != 0); +} +bool owns_registration(std::uint32_t mmid) noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + return mmid && p.step != Step::idle && p.mmid == mmid; +} +bool registration_waiting() noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + return p.step == Step::waiting && p.handed_off && p.at && p.mmid; +} +bool flag_down() noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + return (p.step == Step::closing || p.step == Step::waiting) && p.at && p.mmid && !p.cancelling; +} +std::string flag_key() { + auto& p = placement(); + std::string key; + { + std::lock_guard lock(p.mutex); + if (p.step == Step::idle) return {}; + key = p.key; + } + const auto mode = find(key); + return mode ? mode->flag : std::string{}; +} +bool known_queue(std::uint32_t mmid) noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + return mmid && std::find(p.queues.begin(), p.queues.end(), mmid) != p.queues.end(); +} +void release(std::string_view plugin) noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + if (p.step == Step::idle || p.key.substr(0, p.key.find('/')) != plugin) return; + p.told = true; // the plugin asked: no news back + cleanup(p, GetTickCount64()); +} +void retire_unconfirmed() noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + if ((p.step != Step::open && p.step != Step::setup && p.step != Step::cleanup) || p.flag_seen || p.mmid || p.handed_off) return; + tell(p, "cancelled"); + reset(p); +} +void observe_graph(std::uint32_t graph) noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + if (p.step == Step::idle) return; + if (graph == 0x0f131312 && p.step == Step::setup) { // Spot Battle setup OnBegin + p.setup_seen = true; p.until = GetTickCount64() + 300000; + } + if (graph == 1867583346U && p.step == Step::setup && p.setup_seen) { // the same setup's OnEnd + // Back and Confirm both leave this state. Stop adapting its page now; + // a Confirm goes on to the flag update below. Ignore a previous + // entry's late OnEnd, which comes before this one's OnBegin. + p.show_setup = false; p.changed = GetTickCount64(); p.until = p.changed + 1500; + } + if (graph == 1867583346U && p.step == Step::cleanup && !p.flag_seen && !p.mmid) p.exited = true; + // Spot Battle's own placement update, after its validator and spawn set up. + if (graph == 0x9700be92 && p.created && p.setup_seen && (p.step == Step::setup || (p.step == Step::flag && !p.flag_seen))) { + p.show_setup = false; p.step = Step::flag; p.flag_seen = true; p.until = GetTickCount64() + 300000; + logging::write(logging::Level::info, logging::Channel::ui, "Custom throwdown: entered the native flag placement."); + } +} +void observe_created() noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + if (p.step == Step::setup) p.created = true; +} +void observe_queue(std::uint32_t mmid) noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + if (!mmid || p.step == Step::idle || std::find(p.queues.begin(), p.queues.end(), mmid) != p.queues.end()) return; + if (p.queues.size() == 64) p.queues.erase(p.queues.begin()); + p.queues.push_back(mmid); +} +void observe_position(std::uint32_t mmid, const std::array& at, float yaw) noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + if ((p.step != Step::placing && p.step != Step::flag) || !p.created || !p.flag_seen || !mmid || !std::isfinite(yaw) || + !std::all_of(at.begin(), at.end(), [](float n) { return std::isfinite(n) && std::abs(n) < 100000.f; })) return; + p.at = at; p.yaw = yaw; p.mmid = mmid; p.step = Step::closing; p.until = GetTickCount64() + 15000; + logging::log(logging::Level::info, logging::Channel::ui, + "Custom throwdown: flag at ({:.2f}, {:.2f}, {:.2f}), heading {:.1f} degrees, native queue {:#x}.", + at[0], at[1], at[2], yaw * 180.f / 3.14159265f, mmid); +} +void observe_exit(bool cancelling) noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + if (p.step == Step::idle) return; + p.exited = true; p.cancelling = cancelling || !p.at; + p.step = p.cancelling ? Step::cleanup : Step::waiting; + p.changed = GetTickCount64(); p.until = p.changed + 15000; + if (p.cancelling) tell(p, "cancelled"); +} +void observe_destroyed(std::uint32_t mmid) noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + if (p.step == Step::cleanup && p.mmid == mmid) { p.destroyed = true; p.changed = GetTickCount64(); } +} +void waiting_start() noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + // 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"); + // The flag has done its job: the plugin runs the match from here. + cleanup(p, GetTickCount64()); +} +void waiting_left() noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + if (p.step == Step::idle) return; + tell(p, p.handed_off ? "leave" : "cancelled"); + cleanup(p, GetTickCount64()); +} +void abandon() noexcept { + auto& p = placement(); + std::lock_guard lock(p.mutex); + // The level's own teardown removes its queues. Never destroy an old MMID + // in a new world, where the native coordinator can reuse it. + if (p.step != Step::idle) { + tell(p, p.handed_off ? "leave" : "cancelled"); + reset(p); + } + p.queues.clear(); +} +void tick(const menu_data::Context& c) { + const auto now = GetTickCount64(); + auto& p = placement(); + std::string command; + Step next{}; + std::uint32_t destroy{}; + { + std::lock_guard lock(p.mutex); + if (p.step == Step::idle) return; + if (!find(p.key) && p.step != Step::cleanup) { p.told = true; cleanup(p, now); } // its plugin went + if (p.step == Step::flag && p.flag_seen) { p.step = Step::placing; p.until = now + 300000; } + if (p.step == Step::waiting && !p.handed_off && p.at) { + // The registration stays: it owns the world flag, the waiting card + // and its Start and Leave prompts. + p.handed_off = true; + tell(p, "placed"); + } + if (p.step == Step::setup && p.setup_seen && !p.show_setup && now >= p.until) { + // Back already did the native navigation. Sending an exit could + // close the next stock challenge's setup. + tell(p, "cancelled"); + reset(p); + return; + } + if ((p.step == Step::open || p.step == Step::setup) && !p.created && !p.setup_seen && now >= p.until) { + logging::write(logging::Level::warning, logging::Channel::ui, "Custom throwdown: the setup page did not open."); + tell(p, "cancelled"); + reset(p); + return; // No native queue was created: never close another menu. + } + if (now >= p.until && p.step != Step::cleanup && p.step != Step::waiting) { + tell(p, "cancelled"); + p.at.reset(); + cleanup(p, now); + } + if (p.step == Step::open) { command = "SpotBattle"; next = Step::setup; } + else if (p.step == Step::cleanup) { + if (!p.exited && !p.sent) { command = "ThrowdownerExit"; next = Step::cleanup; } + if (p.mmid && !p.destroy_queued) destroy = p.mmid; + if ((p.exited && !p.mmid) || (p.destroyed && now >= p.changed + 500)) reset(p); + else if (now >= p.until) { + logging::write(logging::Level::warning, logging::Channel::ui, "Custom throwdown: the native flag could not be removed."); + reset(p); + } + } + } + if (destroy && prepare_throwdown_injection() && queue_throwdown_destroy(destroy)) { + std::lock_guard lock(p.mutex); + if (p.mmid == destroy) p.destroy_queued = true; + } + if (!command.empty()) { + // Publication can run expression callbacks: arm the next step before + // publishing, without holding this lock across native code. + Step previous; + { std::lock_guard lock(p.mutex); previous = p.step; p.step = next; } + bool sent{}; + try { sent = navigate(c, command); } + catch (...) { std::lock_guard lock(p.mutex); p.step = previous; throw; } + std::lock_guard lock(p.mutex); + if (!sent) p.step = previous; + else { p.sent = now; if (next != Step::cleanup) p.until = now + 15000; } + } +} +} // namespace dingosdk::multiplayer::custom_throwdowns diff --git a/Extension/Throwdowns/custom_placement.h b/Extension/Throwdowns/custom_placement.h new file mode 100644 index 00000000..15275b17 --- /dev/null +++ b/Extension/Throwdowns/custom_placement.h @@ -0,0 +1,52 @@ +#pragma once +#include +#include +#include +#include + +namespace dingosdk::multiplayer::menu_data { class Context; } +// A plugin mode's start flag, through the game's own Spot Battle flow: its +// setup page, flag placement and waiting card. The temporary native queue +// owns only the flag and the waiting card; it never starts a Spot Battle. The +// pose goes to the plugin (custom_modes.h news), as do the waiting card's +// Start and Leave. Ordinary Spot Battles never enter this adapter. +namespace dingosdk::multiplayer::custom_throwdowns { +// Opens the setup page for this mode (a picker card's action). False while +// another flag of ours is being placed or is up. +bool open_setup(const std::string& key); +// The mode whose setup page is open (the menu relabels it), else empty. +std::string setup_mode(); +// From the setup page opening until the flag is down or abandoned. +bool placement_active() noexcept; +// Placement, or a flag of ours that is up (its queue still registered). +bool registration_owned() noexcept; +bool owns_registration(std::uint32_t mmid) noexcept; +// Our flag is down and its waiting card up. +bool registration_waiting() noexcept; +// Our flag is down: from its accepted position until it is taken away. The +// waiting card's Start is offered from here on. +bool flag_down() noexcept; +// The InstanceId suffix of the owning mode's own flag, when it has one. +std::string flag_key(); +// 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). +void release(std::string_view plugin) noexcept; +// A fresh picker card supersedes a setup that never placed a flag. +void retire_unconfirmed() noexcept; +// UI thread, under the model write lock: navigation and handoff. +void tick(const menu_data::Context& context); + +// From the throwdown hooks (native_throwdowns.cpp, throwdown_lab.cpp). +void observe_graph(std::uint32_t graph) noexcept; +void observe_created() noexcept; +void observe_queue(std::uint32_t mmid) noexcept; +void observe_position(std::uint32_t mmid, const std::array& at, float yaw_radians) noexcept; +void observe_exit(bool cancelling) noexcept; +void observe_destroyed(std::uint32_t mmid) noexcept; +// The waiting card's Start (ForceStart) and Leave (Destroy) on our queue. +void waiting_start() noexcept; +void waiting_left() noexcept; +// The level is unloading: its queues go with it. +void abandon() noexcept; +} // namespace dingosdk::multiplayer::custom_throwdowns diff --git a/Extension/Throwdowns/native_throwdowns.cpp b/Extension/Throwdowns/native_throwdowns.cpp index fd87c439..3fff47fe 100644 --- a/Extension/Throwdowns/native_throwdowns.cpp +++ b/Extension/Throwdowns/native_throwdowns.cpp @@ -1,4 +1,5 @@ #include "native_throwdowns.h" +#include "custom_placement.h" #include "grouped_number.h" #include "throwdown_lab.h" #include "throwdown_relay.h" @@ -181,8 +182,10 @@ void send_event_hook(Address signature, Address output, const Address* arguments const auto name = throwdown_event(hash); const auto current_realm = realm(); if (current_realm == client_realm) observe_throwdown_send(hash, read
(arguments[0]), arguments[1]); - // CreateThrowdownQueue: the local player now hosts a throwdown. - if (hash == 0x7fac81bb && !state().throwdown_active.exchange(true)) + if (current_realm == client_realm && consume_custom_throwdown_send(hash, read
(arguments[0]), arguments[1])) return; + // CreateThrowdownQueue: the local player now hosts a throwdown + // (a plugin mode's flag is not one). + if (hash == 0x7fac81bb && !custom_throwdowns::registration_owned() && !state().throwdown_active.exchange(true)) logging::log(logging::Level::info, logging::Channel::progression, "Throwdown: local player is hosting one."); // AddParticipantToCommunityEvent (the Join button): in one someone else placed, // which the Quit action must also leave. RemoveParticipant: left its queue. @@ -263,7 +266,8 @@ void debug_variable(const char* const* name, const std::uint32_t* fallback, std: std::array value{}; if (!memory::peek(text, value) || value[wanted.size()] != 0 || std::string_view(value.data(), wanted.size()) != wanted) return; - *out = 1; + // A plugin mode's flag queue must never count down and start alone. + *out = custom_throwdowns::registration_owned() ? 0U : 1U; static std::atomic logged{}; if (!logged.exchange(true)) logging::write(logging::Level::info, logging::Channel::progression, @@ -412,6 +416,39 @@ bool local_throwdown_host() noexcept { return state().throwdown_active.load(std: void complete_native_throwdown_parameters(Address vm) noexcept { pump_throwdown_lab(vm); + if (custom_throwdowns::registration_owned()) { + profile_runtime::PreserveError preserve; + try { + const auto resource = vm ? read
(vm + 0x38) : 0; + const auto instance = vm ? read
(vm + 0x30) : 0; + const auto hash = resource ? read(resource + 0x10) : 0; + if (resource && realm() == client_realm && custom_throwdowns::placement_active()) + custom_throwdowns::observe_graph(hash); + // Authored predicate outputs (page 1 of the graph's registers): the + // waiting queue normally locks the Toolbox, and Start waits for a + // full queue. A plugin mode's flag is neither: its host starts it. + constexpr std::array disabled_layout{64, 104, 44, 177, 3, 0, 2, 14, 720896, 66560}; + constexpr std::array start_layout{144, 1360, 1565, 1445, 18, 0, 7, 2293833, 5832773, 17434624}; + if (instance && read
(instance) == resource && realm() == client_realm && + ((hash == 0xc3999b06 && read>(resource + 0x20) == disabled_layout) || + (hash == 0x1a0c8c31 && read>(resource + 0x20) == start_layout))) { + const auto frame = read(resource + 0x20); + const auto arguments = read
(instance + ((frame + 15U) & ~15U) + 8); + if (arguments) { + const bool result = hash == 0x1a0c8c31 && custom_throwdowns::flag_down(); + // Both return through opcode 0x32's borrowed output pointer. + const auto output = read
(arguments + (hash == 0xc3999b06 ? 8 : 96)); + SIZE_T written{}; + const bool wrote = output && WriteProcessMemory(GetCurrentProcess(), reinterpret_cast(output), &result, + sizeof(result), &written) && written == sizeof(result); + static std::atomic reports{}; + if (hash == 0x1a0c8c31 && reports.fetch_add(1) < 40) + logging::log(logging::Level::info, logging::Channel::progression, + "Custom throwdown: waiting card Start {} (written {}).", result ? "offered" : "held back", wrote); + } + } + } catch (...) {} + } // The throwdown is over once a client throwdown enters its end state or // leaves in progress. (Starting the round destroys the registration point // through MPThrowdownCoordinator.OnRequestThrowdownDestroy, so neither that diff --git a/Extension/Throwdowns/throwdown_lab.cpp b/Extension/Throwdowns/throwdown_lab.cpp index 14852d8f..0b6a48dc 100644 --- a/Extension/Throwdowns/throwdown_lab.cpp +++ b/Extension/Throwdowns/throwdown_lab.cpp @@ -1,4 +1,5 @@ #include "throwdown_lab.h" +#include "custom_placement.h" #include "native_type_scan.h" #include "Engine/Core/Hooks/hooks.h" #include "Engine/Core/Log/logging.h" @@ -1336,6 +1337,7 @@ std::string run_job(const Job& job) { Instance e(type_of(event_destroy)); e.set_u32(field_mmid, mmid); e.send(); + custom_throwdowns::observe_destroyed(mmid); return std::format("RequestThrowdownDestroyGlobal sent for MMID {:#x}", mmid); } } @@ -1561,6 +1563,8 @@ 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; } { std::lock_guard lock(l.mutex); action.series = l.series; action.mmid = l.hosted_mmid; @@ -1642,6 +1646,8 @@ void observe_throwdown_send(std::uint32_t hash, Address type, Address payload) n std::string series; std::uint32_t mmid{}; std::vector params; std::string note; bool placed{}; + std::optional> selected_spawn; + float selected_yaw{}; if (const auto f = find_field(type, field_mmid); f && f->size == 4) mmid = read(payload + f->offset); if (const auto f = find_field(type, field_series); f && f->kind == kind_string) series = read_string(payload + f->offset); if (const auto p = find_field(type, field_params); p && p->kind == kind_struct) { @@ -1650,7 +1656,16 @@ void observe_throwdown_send(std::uint32_t hash, Address type, Address payload) n if (const auto f = find_field(p->type, field_series); f && f->kind == kind_string) series = read_string(payload + p->offset + f->offset); std::array spawn{}; - if (const auto f = find_field(p->type, field_spawn); f && f->size >= 12) memory::read(payload + p->offset + f->offset, spawn); + if (const auto f = find_field(p->type, field_spawn); f && f->size >= 12 && memory::read(payload + p->offset + f->offset, spawn)) { + selected_spawn = spawn; + // CreateThrowdownQueueParams.SpawnPoint is XYZ and a yaw in radians. + std::array pose{}; + if (f->size == 16 && memory::read(payload + p->offset + f->offset, pose)) selected_yaw = pose[3]; + else if (const auto t = find_field(p->type, field_teleport); t && t->size == 64) { + std::array transform{}; + if (memory::read(payload + p->offset + t->offset, transform)) selected_yaw = std::atan2(transform[8], transform[10]); + } + } placed = spawn[0] != 0 || spawn[1] != 0 || spawn[2] != 0; } { @@ -1671,6 +1686,13 @@ void observe_throwdown_send(std::uint32_t hash, Address type, Address payload) n "Throwdown lab: local client sent {:#x}; series {} MMID {:#x}{}{}.", hash, l.series, l.mmid, note.empty() ? "" : "; ", note); } + if (custom_throwdowns::registration_owned()) { + // A plugin mode's temporary flag: its own news, never a drop to relay. + custom_throwdowns::observe_queue(mmid); + if (hash == event_create) custom_throwdowns::observe_created(); + if (hash == event_update && selected_spawn) custom_throwdowns::observe_position(mmid, *selected_spawn, selected_yaw); + return; + } using Kind = ThrowdownLocalAction::Kind; const auto kind = hash == event_create ? Kind::created : hash == event_add_participant ? Kind::joined : hash == event_force_start ? Kind::force_started : hash == event_destroy ? Kind::destroy_requested @@ -1939,6 +1961,7 @@ void throwdown_lab_before_level_transition(unsigned next) noexcept { // 14, 22 and 3 leave a level or sublevel, 24 shuts down: the level's bundles are unloaded, // and the events' types with them. if (next != 14 && next != 22 && next != 3 && next != 24) return; + custom_throwdowns::abandon(); try { auto& l = lab(); std::lock_guard lock(l.mutex); @@ -1947,6 +1970,48 @@ void throwdown_lab_before_level_transition(unsigned next) noexcept { } catch (...) {} } +bool consume_custom_throwdown_send(std::uint32_t hash, Address type, Address payload) noexcept { + if (injecting || !type || !payload || !custom_throwdowns::registration_owned()) return false; + try { + const auto mmid_field = find_field(type, field_mmid); + const auto mmid = mmid_field && mmid_field->size == 4 ? read(payload + mmid_field->offset) : 0; + const bool owned = hash == event_create || custom_throwdowns::owns_registration(mmid); + if (!owned) return false; + if (hash == event_create || hash == event_update || hash == event_host_set) { + const auto params = find_field(type, field_params); + if (params && params->kind == kind_struct) { + // The native registry picks RegistrationPrefab by Series and + // InstanceId: keep Spot Battle's protocol with the mode's flag. + const auto instance = find_field(params->type, field_instance); + const auto series = find_field(params->type, field_series); + const auto flag = custom_throwdowns::flag_key(); + if (!flag.empty() && instance && instance->kind == kind_string && series && series->kind == kind_string && + read_string(payload + params->offset + series->offset) == "SpotBattle") { + assign_string(payload + params->offset + instance->offset, "ReSkate." + flag); + if (hash == event_create) + logging::log(logging::Level::info, logging::Channel::ui, "Custom throwdown: the ReSkate.{} flag.", flag); + } + // The registration only carries the flag and the waiting card: + // never let its queue fill and start a Spot Battle. The mode's + // own player count stays with the plugin. + if (const auto limit = find_field(params->type, field_max_players); hash == event_host_set && limit && limit->size == 4) { + const std::uint32_t held = 10; + SIZE_T written{}; + WriteProcessMemory(GetCurrentProcess(), reinterpret_cast(payload + params->offset + limit->offset), + &held, sizeof(held), &written); + } + } + return false; + } + if (hash == event_force_start) { + custom_throwdowns::waiting_start(); // the host held Start: the plugin starts its mode + return true; + } + if (hash == event_destroy) custom_throwdowns::waiting_left(); + } catch (...) {} + return false; +} + bool throwdown_lab_player(std::uint32_t player_id) noexcept { if (!player_id) return false; for (const auto& slot : lab().players) if (slot.load(std::memory_order_relaxed) == player_id) return true; diff --git a/Extension/Throwdowns/throwdown_lab.h b/Extension/Throwdowns/throwdown_lab.h index 2bcc3532..60840c86 100644 --- a/Extension/Throwdowns/throwdown_lab.h +++ b/Extension/Throwdowns/throwdown_lab.h @@ -22,6 +22,9 @@ void pump_throwdown_lab(std::uintptr_t vm) noexcept; void throwdown_lab_before_level_transition(unsigned next) noexcept; // Called by the SendNetworkedEvent hook for every event the local client sends. void observe_throwdown_send(std::uint32_t hash, std::uintptr_t type, std::uintptr_t payload) noexcept; +// After observe_throwdown_send: true when the event belongs to a plugin mode's +// flag and must not be sent (its waiting card's Start goes to the plugin). +bool consume_custom_throwdown_send(std::uint32_t hash, std::uintptr_t type, std::uintptr_t payload) noexcept; // True for ids the lab put into a throwdown; the UI player lookups answer them with // the local player's record so rows and host lookups resolve. bool throwdown_lab_player(std::uint32_t player_id) noexcept; diff --git a/Extension/UI/NativeMenu/native_array_publication.h b/Extension/UI/NativeMenu/native_array_publication.h new file mode 100644 index 00000000..3e5cdb0a --- /dev/null +++ b/Extension/UI/NativeMenu/native_array_publication.h @@ -0,0 +1,25 @@ +#pragma once +#include +#include + +namespace dingosdk::multiplayer::menu_data { +// A byte snapshot of a native row still borrows its strings, delegates and +// nested arrays. Native array publication resizes/destroys destination rows +// before it copies replacements. Retain the complete source into an owned +// temporary first, and release that temporary only after publication returns. +template +void publish_retained_array(Acquire&& acquire, Stage&& stage, Publish&& publish, Release&& release) { + auto owner = std::invoke(acquire); + using Owner = decltype(owner); + static_assert(std::is_nothrow_invocable_v, "Native transient array release must not throw."); + struct Guard { + Owner& owner; + Release& release; + ~Guard() noexcept { std::invoke(release, owner); } + } guard{owner, release}; + // Acquisition does not resolve/copy fields: the guard must already exist + // if validation or a partially completed native typed copy fails. + std::invoke(stage, owner); + std::invoke(publish, owner); +} +} // namespace dingosdk::multiplayer::menu_data diff --git a/Extension/UI/NativeMenu/native_menu.cpp b/Extension/UI/NativeMenu/native_menu.cpp index 8c0dd7bb..3822af46 100644 --- a/Extension/UI/NativeMenu/native_menu.cpp +++ b/Extension/UI/NativeMenu/native_menu.cpp @@ -2,6 +2,7 @@ #include "native_menu_internal.h" #include "native_menu_dump.h" #include "ui_sound.h" +#include "throwdown_menu.h" #include "Extension/Multiplayer/Session/session.h" #include "Extension/Multiplayer/Hud/game_ui_state.h" #include "Engine/Core/Log/logging.h" @@ -626,6 +627,7 @@ void tick_page(std::uintptr_t base, bool loading) noexcept { s.next_render = 0; } bool prepare_native_menu_level_load(std::uintptr_t base) noexcept { + if (!release_native_throwdown_menu(base)) return false; if (std::all_of(page_slots.begin(), page_slots.end(), [](unsigned slot) { return page_state(slot).owned_models.empty(); })) return true; try { @@ -720,6 +722,7 @@ void tick_native_menu(std::uintptr_t base, bool loading) noexcept { // (Read here: it takes the UI model's lock, which a page's tick then holds.) game_in_menu.store(!loading && sample_game_ui_state(base).in_menu, std::memory_order_relaxed); if (lifetime.blocked()) return; + tick_native_throwdown_menu(base, loading); for (unsigned slot = 0; slot < page_count; ++slot) { current_state = &page_state(slot); current_state->slot = slot; diff --git a/Extension/UI/NativeMenu/native_menu_data.cpp b/Extension/UI/NativeMenu/native_menu_data.cpp index f7061432..7db155e8 100644 --- a/Extension/UI/NativeMenu/native_menu_data.cpp +++ b/Extension/UI/NativeMenu/native_menu_data.cpp @@ -1,9 +1,11 @@ #include "native_menu_data.h" +#include "native_array_publication.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Build/20260929/native_menu.h" #include "Engine/Game/Build/20260929/native_party.h" #include +#include #include #include @@ -140,7 +142,23 @@ Value Context::element(Value array, unsigned index) const { Value Context::create(Schema schema, std::uint64_t id) const { const auto t = type(schema); const auto h = game::native_data().models.create(manager, t, id, game::native_name_hash("ReSkate.NativeMultiplayerMenu"), false, 2); - Value result{h, t}; address(result); return result; + Value result{h, t}; address(result); initialize_text(result); return result; +} +void Context::initialize_text(Value value) const { + if (kind(value.type) == 7) { + if (!read
(address(value))) text(value, ""); + return; + } + if (kind(value.type) != 2) return; // Asset/DataRef pointers and counted arrays are not inline structures. + const auto meta = read
(value.type), fields = read
(meta + 0x60); + const auto count = read(meta + 0x2a); + require(count <= 128 && (!count || fields), "Native menu text field table differs."); + for (unsigned i = 0; i < count; ++i) { + const auto entry = fields + i * 24; + const auto child_type = read
(entry + 16); + if (kind(child_type) != 2 && kind(child_type) != 7) continue; + initialize_text(field(value, read(entry))); + } } void Context::destroy(Value value) const { // The engine may already have removed a previous pause-menu generation. @@ -162,6 +180,9 @@ void Context::copy(Value target, Address source) const { std::vector bytes(size(target.type)); require(memory::read_bytes(source, bytes.data(), bytes.size()), "Native menu template is unavailable."); publish(target, bytes.data()); + // Some authored records carry null optional CString defaults. Give the + // copy engine-owned empty strings before it is attached to a widget. + initialize_text(target); } void Context::text(Value target, const std::string& text) const { require(kind(target.type) == 7 && text.size() <= 2048, "Native menu text schema differs."); @@ -188,14 +209,35 @@ void Context::array(Value target, std::span bytes, unsigned cou require(kind(target.type) == 4 && count <= 1024, "Native menu list is invalid."); const auto stride = size(read
(read
(target.type) + 0x30)); require(bytes.size() == static_cast(count) * stride, "Native menu list element size differs."); - // The engine deep-copies strings, delegates, DataRefs and nested arrays - // synchronously. No pointer into this temporary storage survives publish. + address(target); std::vector storage((bytes.size() + 15) / 8); const std::array header{count, count}; std::memcpy(storage.data(), header.data(), 8); if (!bytes.empty()) std::memcpy(storage.data() + 1, bytes.data(), bytes.size()); const auto pointer = storage.data() + 1; - set(target, pointer); + const auto& functions = game::native_data().models; + if (!functions.create || !functions.destroy) { set(target, pointer); return; } + // Native array publication (0x19289d0) resizes and destroys the + // destination rows (0x19158c0) before it copies the source rows. Raw row + // bytes alone cannot protect a later live row's text/asset arrays when it + // moves to an earlier slot. Stage them in an owned root of the array's own + // type while every source owner is live, then publish that retained copy. + static std::atomic next_array{0x4e4d415252000000ULL}; + Value retained; + publish_retained_array( + [&] { + retained = {functions.create(manager, target.type, next_array.fetch_add(1, std::memory_order_relaxed) + 1, + game::native_name_hash("ReSkate.NativeArrayPublication"), false, 2), target.type}; + return retained; + }, + [&](Value owned) { set(owned, pointer); }, + [&](Value owned) { publish(target, reinterpret_cast(address(owned))); }, + [&](Value owned) noexcept { + if (!owned.handle || type_of(owned.handle) != owned.type) return; + std::array iterator{}; + functions.destroy(manager, iterator.data(), owned.handle, 0, false, false); + }); + require(!retained.handle || type_of(retained.handle) != retained.type, "Native menu list source could not be released."); } Address named_asset(const Context& context, Address anchor, const char* name) { const auto find = game::native_data().find_asset; @@ -456,6 +498,12 @@ bool publish_tab_icons(const Context& context, Value tab, std::string_view idle, context.set(context.field(tab, 0xbc92d1fa), assets[1]); // FocusIcon. }); } +bool publish_texture(const Context& context, std::span targets, std::string_view name) { + return with_texture_assets(context, std::array{name}, [&](const auto& assets) { + for (const auto target : targets) + if (read
(context.address(target)) != assets[0]) context.set(target, assets[0]); + }); +} bool publish_texture(const Context& context, Value target, std::string_view name) { return with_texture_assets(context, std::array{name}, [&](const auto& assets) { context.set(target, assets[0]); }); } diff --git a/Extension/UI/NativeMenu/native_menu_data.h b/Extension/UI/NativeMenu/native_menu_data.h index 1c73812a..8ae6a437 100644 --- a/Extension/UI/NativeMenu/native_menu_data.h +++ b/Extension/UI/NativeMenu/native_menu_data.h @@ -1,5 +1,6 @@ #pragma once #include "Engine/Game/Abi/native_data.h" +#include #include #include #include @@ -34,6 +35,9 @@ public: Value path(Value value, std::initializer_list hashes) const; Value element(Value array, unsigned index) const; Value create(Schema schema, std::uint64_t id) const; + // Gives every null CString in an owned copy an engine-owned empty string: + // the VM's typed read calls strlen even for empty optional text. + void initialize_text(Value value) const; void destroy(Value owned_root) const; void publish(Value target, const void* bytes) const; void copy(Value target, Address source) const; @@ -42,6 +46,13 @@ public: if (size(target.type) != sizeof(T)) throw std::runtime_error("Native menu value size differs."); publish(target, &value); } + template void set_if_changed(Value target, const T& value) const { + static_assert(std::is_trivially_copyable_v); + if (size(target.type) != sizeof(T)) throw std::runtime_error("Native menu value size differs."); + T current{}; + if (!memory::peek_bytes(address(target), ¤t, sizeof(T))) throw std::runtime_error("Native menu value is unavailable."); + if (std::memcmp(¤t, &value, sizeof(T))) publish(target, &value); + } void text(Value target, const std::string& text) const; std::string text(Value value, std::size_t maximum = 128) const; std::vector array(Value value, unsigned maximum, unsigned& count, unsigned& stride) const; @@ -72,4 +83,6 @@ Address named_asset(const Context& context, Address anchor, const char* name); bool publish_tab_icons(const Context& context, Value tab, std::string_view idle, std::string_view focused); // Publishes one loaded texture or image asset, by name, into an asset reference field. bool publish_texture(const Context& context, Value target, std::string_view name); +// The same texture into several fields, each written only when it differs. +bool publish_texture(const Context& context, std::span targets, std::string_view name); } // namespace dingosdk::multiplayer::menu_data diff --git a/Extension/UI/NativeMenu/native_menu_lifetime.h b/Extension/UI/NativeMenu/native_menu_lifetime.h index 2b4a3588..e8640cca 100644 --- a/Extension/UI/NativeMenu/native_menu_lifetime.h +++ b/Extension/UI/NativeMenu/native_menu_lifetime.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +37,7 @@ template class OwnedMenuModels { std::vector models_; public: bool empty() const { return models_.empty(); } + std::size_t checkpoint() const noexcept { return models_.size(); } void track(Model value) { if (std::none_of(models_.begin(), models_.end(), [&](const auto& existing) { return existing.handle == value.handle && existing.type == value.type; @@ -49,6 +51,16 @@ public: models_.pop_back(); // Keep unfinished cleanup available on failure. } } + // An unmounted build gives back only the roots made since `checkpoint`. + // The caller detaches any published references first. Each pops only once + // destroyed, so an interrupted rollback can be retried. + template void rollback(std::size_t checkpoint, Destroy&& destroy) { + if (checkpoint > models_.size()) throw std::out_of_range("Native menu model checkpoint is no longer valid."); + while (models_.size() > checkpoint) { + destroy(models_.back()); + models_.pop_back(); + } + } // Use only after the native manager itself has been replaced. void manager_replaced() { models_.clear(); } }; diff --git a/Extension/UI/NativeMenu/native_menu_presenters.h b/Extension/UI/NativeMenu/native_menu_presenters.h new file mode 100644 index 00000000..39c4e840 --- /dev/null +++ b/Extension/UI/NativeMenu/native_menu_presenters.h @@ -0,0 +1,72 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Pure helpers for adapting a live native page (throwdown_menu.cpp), after +// SmokeyDawg58's native_menu_ownership.h and native_hud_values.h. +namespace dingosdk::multiplayer::menu_data { +// Register the private identity before following references. Native menus can +// share mutable labels and refer back to a model already being copied. +template class UiCloneCache { + std::map copies_; +public: + template Model clone(const Key& key, Create&& create, Follow&& follow) { + if (const auto found = copies_.find(key); found != copies_.end()) return found->second; + const auto model = create(); + copies_.emplace(key, model); + follow(model); + return model; + } +}; + +// Native array copies have independent storage and native stack items animate +// between menu ticks. Only the Content presenters' identities prove a remount. +template bool matching_presenters(std::span live, std::span saved, + std::size_t stride, std::size_t offset) { + static_assert(std::is_trivially_copyable_v); + if (!stride || offset > stride || sizeof(Presenter) > stride - offset || live.size() % stride || saved.size() % stride) + throw std::invalid_argument("Native presenter array layout differs."); + if (live.size() != saved.size()) return false; + for (std::size_t i = 0; i < live.size(); i += stride) { + Presenter current{}, expected{}; + std::memcpy(¤t, live.data() + i + offset, sizeof(current)); + std::memcpy(&expected, saved.data() + i + offset, sizeof(expected)); + if (current.blueprint != expected.blueprint || current.data.record != expected.data.record || + current.data.handle != expected.data.handle) return false; + } + return true; +} +// The array to publish so `live` presents `saved`'s presenters, or none when +// it already does. With the same slots every live visibility, transition and +// focus byte is kept; a different number of slots needs the saved array. +template std::optional> remount_presenters( + std::span live, std::span saved, std::size_t stride, std::size_t offset) { + if (matching_presenters(live, saved, stride, offset)) return std::nullopt; + if (live.size() != saved.size()) return std::vector(saved.begin(), saved.end()); + std::vector next(live.begin(), live.end()); + for (std::size_t i = 0; i < next.size(); i += stride) + std::memcpy(next.data() + i + offset, saved.data() + i + offset, sizeof(Presenter)); + return next; +} + +// A native slider's normalized 0..1 value as a whole choice on its own steps, +// and back. +inline int native_slider_choice(float normalized, int minimum, int maximum, int step) { + if (!std::isfinite(normalized) || maximum <= minimum || step < 1) return minimum; + const auto steps = (maximum - minimum) / step; + const auto index = static_cast(std::lround(std::clamp(normalized, 0.f, 1.f) * static_cast(steps))); + return minimum + std::min(index, steps) * step; +} +inline float native_slider_position(int choice, int minimum, int maximum) { + if (maximum <= minimum) return 0.f; + return static_cast(std::clamp(choice, minimum, maximum) - minimum) / static_cast(maximum - minimum); +} +} // namespace dingosdk::multiplayer::menu_data diff --git a/Extension/UI/NativeMenu/throwdown_menu.cpp b/Extension/UI/NativeMenu/throwdown_menu.cpp new file mode 100644 index 00000000..ac38fbb9 --- /dev/null +++ b/Extension/UI/NativeMenu/throwdown_menu.cpp @@ -0,0 +1,1558 @@ +#include "throwdown_menu.h" +#include "native_menu_data.h" +#include "native_menu_lifetime.h" +#include "native_menu_presenters.h" +#include "Extension/Throwdowns/custom_modes.h" +#include "Extension/Throwdowns/custom_placement.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/engine.h" +#include "Engine/Game/Build/20260929/native_menu.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// The Throwdowns page's native picker and setup, after SmokeyDawg58's +// one_up_menu.cpp and throwdown_modes_ui.inl (reskate-custom-mods): every view is +// a native Rime model, built from the game's own records or the mode's mod. +// The page is his logo grid, whose widgets and records ReSkate carries (the +// throwdown_menu built-in pack, tools/builtin/build_throwdown_menu_pack.py). +// Should they not load, the modes' cards follow the three stock cards. +namespace dingosdk::multiplayer { +namespace { +using namespace menu_data; +namespace native = addr::native_menu; +namespace engine = addr::engine; +constexpr Schema tile_schema{0xdeb20e0f, 1200}, category_schema{0x628aa99c, 320}, anchor_schema{0x0e3be640, 192}, + label_schema{0xbfe23948, 104}, panel_schema{0xd29bbf63, 576}, header_schema{0x4efe5e19, 312}, + setting_schema{0x94803e85, 176}, selector_schema{0xf45cb4c0, 1344}, selector_item_schema{0x6d9d5106, 312}, + notice_schema{0x7290e514, 256}, texture_schema{0x370c9975, 144}, content_tile_schema{0xcbe47f59, 1184}; +constexpr std::uint32_t content = 0x716496c8, items = 0x61742cb4, widget = 0x214d4984, rows_field = 0x67223da7, + text_field = 0x4d8e01b9; +constexpr std::uint32_t slider_hash = 0xcdc749b2; +// Fields a private copy borrows rather than copies: styles, transitions and +// backend expression contexts are read-only. +constexpr std::array borrowed_fields{0x8cc042ef, 0xa1e84eb1, 0x25189231, 0xf94d8cc6, 0x8a3e2159, + 0x54c9abee, 0xd78d240b, 0x85b8c3e6, 655725074U}; +bool borrowed(std::uint32_t key) { return std::find(borrowed_fields.begin(), borrowed_fields.end(), key) != borrowed_fields.end(); } +using Action = MenuAction; +struct Descriptor { alignas(8) std::array bytes{}; Address info{}; Action action; }; +struct Original { + Value tile, category, description; + std::array tile_size{}, description_size{}; + float icon_width{}, icon_height{}; +}; +struct ModeCard { Value tile, category, description, label, logo; }; +struct SetupRows { + Value list; + std::vector records; + std::vector handles; + // A typed native owner for the original inline rows: raw saved bytes + // cannot keep their nested arrays once the live list changes count. + Value retained_rows; + bool stack{}, owned_tiles{}; + Value timer_row; + unsigned timer_index{}; + std::map extra; + struct Choice { Value selector; std::vector options; int shown = -1, pending = -1; std::uint64_t pending_at{}; }; + std::map choices; +}; +struct State { + Address manager{}, base{}, anchor_asset{}; + Value page, cards, source_label, original_body; + std::vector originals; + std::map mode_cards; + Value picker_grid, picker_anchor; + std::array picker_stock, picker_stock_logos; + bool grid_failed{}, cards_scaled{}; + std::map> official_originals; + std::set official_panels; + std::map official_headers, official_contents, official_sliders, official_badges; + std::map official_slot_panels; + std::map official_setup_rows; + // Each native slider presentation's row: players, timer or a row's id. + std::map slider_rows; + std::string official_mode; + std::map> assets; + std::set missing_art; + OwnedMenuModels owned; + std::set> generations; + std::array descriptors; + unsigned used{}; + std::mutex mutex; + std::vector pending; + std::atomic owner{}; + std::uint64_t next_id{}, next_tick{}, pass{}, entry_guard_until{}; +}; +State& state() { static auto* s = new State; return *s; } + +Value reference(const Context& c, Ref ref) { return {ref.handle, c.type_of(ref.handle)}; } +Value primary(const Context& c, Value tile) { + return c.path(tile, {widget, 0x0fb0d794, 0xa704272a, 0xc52416ef, 0xa704272a, 0xa2b71c93}); +} +std::optional mounted_body(const Context& c, Value p) { + if (!p.handle || c.type_of(p.handle) != p.type || !game::native_data().models.value(c.manager, p.handle, 0, 1)) return {}; + const auto children = c.path(p, {content, items}); + const auto pointer = read
(c.address(children)); + // Native page navigation empties the content array while the next view is + // being installed: an ordinary transition, not a failed build. + if (!pointer || !(read(pointer - 4) & 0x7fffffff)) return {}; + return c.field(c.element(children, 0), content); +} +Value make(const Context& c, Schema schema, Address authored_type = 0) { + auto& s = state(); + const auto id = 0x3155400000000000ULL + ++s.next_id; + Value value; + if (authored_type) { + require((kind(authored_type) == 2 || kind(authored_type) == 4) && size(authored_type) == schema.size && + read(read
(authored_type)) == schema.hash, + "Throwdown authored model schema differs."); + value = {game::native_data().models.create(c.manager, authored_type, id, game::native_name_hash("ReSkate.NativeMultiplayerMenu"), false, 2), + authored_type}; + c.address(value); + c.initialize_text(value); + } else value = c.create(schema, id); + s.owned.track(value); + return value; +} +Value make_like(const Context& c, Value like) { + return make(c, {read(read
(like.type)), static_cast(size(like.type))}, like.type); +} +template void activate() noexcept { + auto& s = state(); + try { + std::lock_guard lock(s.mutex); + const auto& action = s.descriptors[I].action; + if (I >= s.used || action.generation != s.owner.load() || s.pending.size() >= 8) return; + if (std::any_of(s.pending.begin(), s.pending.end(), + [&](const Action& a) { return a.command == action.command && a.argument == action.argument; })) return; + const auto now = GetTickCount64(); + if (action.command == "mode-open") { + if (now < s.entry_guard_until) return; + s.entry_guard_until = now + 350; + } + s.pending.push_back(action); + } catch (...) {} +} +template auto functions(std::index_sequence) { return std::array{&activate...}; } +Address callback(const Context& c, std::string command, std::string argument = {}) { + auto& s = state(); + std::lock_guard lock(s.mutex); + const auto slot = action_slot(s.used, s.descriptors.size(), [&](std::size_t i) -> const Action& { return s.descriptors[i].action; }, + command, argument, s.pass); + require(slot.has_value(), "Throwdown native action capacity reached."); + static const auto f = functions(std::make_index_sequence<128>{}); + auto& d = s.descriptors[*slot]; + if (*slot == s.used) { + const auto prototype = c.base + native::action_prototype; + require(read
(prototype + 0x40) == c.base + native::action_invoker && read(prototype + 0x68) == 0, + "Throwdown action ABI differs."); + require(memory::peek_bytes(prototype, d.bytes.data(), d.bytes.size()), "Throwdown action prototype unavailable."); + const auto invoker = reinterpret_cast
(f[*slot]); + for (const auto offset : {0x30, 0x40, 0x48}) std::memcpy(d.bytes.data() + offset, &invoker, 8); + d.info = reinterpret_cast
(d.bytes.data()); + ++s.used; + } + d.action = {s.owner.load(), std::move(command), std::move(argument), s.pass}; + return reinterpret_cast
(&d.info); +} +Address asset(const Context& c, const char* name) { + auto& s = state(); + if (const auto cached = s.assets.find(name); cached != s.assets.end()) return cached->second; + std::vector
anchors; + std::set domains; + const auto add_anchor = [&](Address anchor) { + // A previous map's raw blueprint is no asset-domain lease. Skip + // expired anchors so they cannot abort the resident-domain lookup. + try { + if (!anchor) return; + const auto type = read
(anchor + 8); + if (type != c.base + native::widget_blueprint_type && type != c.base + native::texture_asset_type && + type != c.base + native::image_asset_type) return; + const auto domain = read(anchor + 0x16); + if (domain < 0xbbf && read
(c.base + engine::domain_owners + domain * 8ULL) && domains.insert(domain).second) + anchors.push_back(anchor); + } catch (const std::exception&) {} + }; + add_anchor(s.anchor_asset); + for (const auto& root : c.roots({presenter.hash})) { + const auto blueprint = read(root.value).blueprint; + if (!blueprint || read
(blueprint + 8) != c.base + native::widget_blueprint_type) continue; + add_anchor(blueprint); + if (domains.size() >= 32) break; + } + const auto find_loaded = [&](const char* path) { + for (const auto anchor : anchors) + if (const auto found = named_asset(c, anchor, path)) return found; + // Shared widgets may belong to a sibling resident UI domain after a + // custom map switch: only the engine's loaded-domain lookup is used. + const auto find = game::native_data().find_asset; + if (find) + for (unsigned candidate = 0; candidate < 0xbbf; ++candidate) { + if (!read
(c.base + engine::domain_owners + candidate * 8ULL)) continue; + if (const auto found = find(static_cast(candidate), path)) return found; + } + return Address{}; + }; + if (const auto found = find_loaded(name)) { + s.assets.emplace(name, found); + return found; + } + // Exported data-model records are not indexed as ordinary assets: read + // their resource list, then take the exact exported name. + constexpr std::array containers{ + std::pair{"Activities_DetailsMenu_ContentResource/", "UI/Features/Activities/Resources/Activities_DetailsMenu_ContentResource"}, + std::pair{"Throwdown_DetailsMenu_ContentResource/", "UI/Features/Activities/Resources/Throwdown_DetailsMenu_ContentResource"}, + std::pair{"StackPanelStylesList/", "UI/Foundations/Components/StackVariations/StackPanel/StackPanelStylesList"}, + std::pair{"TD_HostParameters_ContentResource/", "UI/Features/Throwdowns/ContentResources/TD_HostParameters_ContentResource"}, + std::pair{"TD_Page_ContentResources/", "UI/Features/Throwdowns/TD_Page_ContentResources"}, + std::pair{"SettingWrapperStylesList/", "UI/Foundations/Components/Settings/Widgets/SettingWrapperStylesList"}, + std::pair{"TextStylesList/", "UI/Foundations/Styles/Lists/TextStylesList"}, + std::pair{"InteractivityColorStylesList/", "UI/Foundations/Styles/Lists/InteractivityColorStylesList"}, + std::pair{"ButtonStyleList/", "UI/Foundations/Styles/Lists/ButtonStyleList"}, + // The logo grid's records (the built-in pack) and each mode's logo (its mod). + std::pair{"Throwdown_ModeSelect_ContentResources/", "UI/Features/Throwdowns/ContentResources/Throwdown_ModeSelect_ContentResources"}}; + for (const auto& [prefix, path] : containers) + if (std::string_view(name).starts_with(prefix)) { + const Address list = find_loaded(path); + if (!list || read
(list + 8) != c.base + native::asset_list_type) continue; + const auto records = read
(list + 0x20); + const auto count = records ? read(records - 4) & 0x7fffffff : 0; + require(count <= 2048, "Throwdown native resource list exceeds its limit."); + for (unsigned i = 0; i < count; ++i) { + const auto record = read
(records + i * 8ULL); + if (!record || read
(record + 8) != c.base + native::asset_record_type) continue; + s.assets.emplace(string(read
(record + 0x28), 256), record); + } + if (const auto found = s.assets.find(name); found != s.assets.end()) return found->second; + } + // Foundation widgets can live outside the Throwdowns domain: the pause + // menu's core knows them. + for (const auto& root : c.roots({core.hash})) { + try { + const auto found = blueprints(c, root.model); + s.assets.insert(found.begin(), found.end()); + if (const auto loaded = s.assets.find(name); loaded != s.assets.end()) return loaded->second; + } catch (const std::exception&) {} + } + throw std::runtime_error(std::string("Throwdown menu asset unavailable: ") + name); +} +Value category(const Context& c, Value tile) { + const auto w = read(c.address(c.path(tile, {widget, widget}))); + const auto model = reference(c, w.data); + require(model.type && size(model.type) == category_schema.size && read(read
(model.type)) == category_schema.hash, + "Throwdown category schema differs."); + return model; +} +Value description(const Context& c, Value cat) { return reference(c, read(c.address(c.field(cat, widget))).data); } +Address native_record_type(const Context& c, Address record) { + // A DataRef may hold a native record or an already materialized handle. + // Never read an arbitrary asset's fields as record TypeInfo: the model + // factory assumes a valid one and cannot reject it safely. + require(record && read
(record + 8) == c.base + native::asset_record_type, + "Throwdown UI reference is not a native data-model record."); + const auto type = read
(record + 0x18); + require(type && read
(record + 0x20), "Throwdown UI record has no type or data."); + return type; +} +Value record_model(const Context& c, const char* name, Schema schema) { + const auto record = asset(c, name); + const auto type = native_record_type(c, record), data = read
(record + 0x20); + auto result = make(c, schema, type); + c.copy(result, data); + return result; +} +Value record_model(const Context& c, const char* name) { + const auto record = asset(c, name), type = native_record_type(c, record); + require(kind(type) == 2, "Throwdown record schema differs."); + return record_model(c, name, {read(read
(type)), static_cast(size(type))}); +} +Ref record_ref(const Context& c, const char* name) { + const auto record = asset(c, name); + native_record_type(c, record); + return {record, 0}; +} +// A typed native copy keeps its DataRefs; it does not clone the models they +// reference. Copy every mutable UI edge before mounting a private page. +struct PrivateUiGraph { + const Context& c; + UiCloneCache, Value> copies; + std::set private_roots, walked; + unsigned budget = 2400; + Value clone(Ref source, unsigned depth = 0) { + const auto type = source.handle ? c.type_of(source.handle) : (source.record ? native_record_type(c, source.record) : 0); + require(type && (kind(type) == 2 || kind(type) == 4), "Private Throwdown UI model is unavailable."); + if (source.handle && (private_roots.contains(source.handle) || walked.contains(source.handle))) return {source.handle, type}; + const auto key = std::pair{source.handle, source.handle ? type : source.record}; + return copies.clone(key, [&] { + require(depth < 96 && budget, "Private Throwdown UI graph exceeds its limit."); + --budget; + const auto data = source.handle ? c.address({source.handle, type}) : read
(source.record + 0x20); + const auto result = make(c, {read(read
(type)), static_cast(size(type))}, type); + c.copy(result, data); + private_roots.insert(result.handle); + return result; + }, [&](Value result) { walk(result, depth + 1); }); + } + void walk(Value value, unsigned depth) { + if (!walked.insert(value.handle).second) return; + require(depth < 96, "Private Throwdown UI graph depth differs."); + const auto hash = read(read
(value.type)); + if (hash == presenter.hash) { + auto w = read(c.address(value)); + if (w.data.handle || w.data.record) { w.data = {0, clone(w.data, depth).handle}; c.set(value, w); } + return; + } + if (hash == 0x62088281) { + const auto ref = read(c.address(value)); + if (ref.handle || ref.record) { + const auto type = ref.handle ? c.type_of(ref.handle) : native_record_type(c, ref.record); + // Scalar backend expressions stay borrowed; mutable widgets and + // lists are private, array-valued models included. + if (type && (kind(type) == 2 || kind(type) == 4)) c.set(value, Ref{0, clone(ref, depth).handle}); + } + return; + } + if (kind(value.type) == 4) { + unsigned count{}, stride{}; + auto bytes = c.array(value, 128, count, stride); + for (unsigned i = 0; i < count; ++i) { + const auto child = c.element(value, i); + if (kind(child.type) == 2 || kind(child.type) == 4) { + // Publishing an inline element can retire its siblings' + // storage: adapt detached roots, then replace the array once. + const auto copy = clone({0, child.handle}, depth + 1); + require(memory::peek_bytes(c.address(copy), bytes.data() + i * stride, stride), "Private UI array copy is unavailable."); + } + } + c.array(value, bytes, count); + } else if (kind(value.type) == 2) { + const auto meta = read
(value.type), fields = read
(meta + 0x60); + const auto count = read(meta + 0x2a); + require(count <= 128, "Private Throwdown field count differs."); + for (unsigned i = 0; i < count; ++i) { + const auto key = read(fields + i * 24); + if (borrowed(key)) continue; + const auto child = c.field(value, key); + if (key == 0xf2c90867 && kind(child.type) == 4) { + unsigned n{}, stride{}; + const auto bytes = c.array(child, 128, n, stride); + require(stride == sizeof(Handle), "Private UI handle array differs."); + std::vector handles(n); + if (n) std::memcpy(handles.data(), bytes.data(), bytes.size()); + for (auto& handle : handles) + if (handle) handle = clone({0, handle}, depth + 1).handle; + c.array(child, handles); + } else if (kind(child.type) == 2 || kind(child.type) == 4) walk(child, depth + 1); + } + } + } +}; +// Every model of type `wanted` under `root`, following presenters, refs and lists. +std::vector ui_models(const Context& c, Value root, std::uint32_t wanted) { + std::vector result; + std::set seen; + unsigned budget = 2400; + std::function visit = [&](Value value, unsigned depth) { + if (!value.handle || !value.type || !budget || depth > 96 || !seen.insert(value.handle).second) return; + --budget; + const auto hash = read(read
(value.type)); + if (hash == wanted) { result.push_back(value); return; } + if (hash == 0x62088281) { + const auto ref = read(c.address(value)); + if (ref.handle) visit(reference(c, ref), depth + 1); + return; + } + if (hash == presenter.hash) { + const auto w = read(c.address(value)); + if (w.data.handle) visit(reference(c, w.data), depth + 1); + return; + } + if (kind(value.type) == 4) { + unsigned count{}, stride{}; + c.array(value, 128, count, stride); + for (unsigned i = 0; i < count; ++i) visit(c.element(value, i), depth + 1); + } else if (kind(value.type) == 2) { + const auto meta = read
(value.type), fields = read
(meta + 0x60); + const auto count = read(meta + 0x2a); + require(count <= 128, "Throwdown UI field count differs."); + for (unsigned i = 0; i < count; ++i) { + const auto key = read(fields + i * 24); + if (borrowed(key)) continue; + const auto child = c.field(value, key); + if (key == 0xf2c90867 && kind(child.type) == 4) { + unsigned n{}, stride{}; + const auto bytes = c.array(child, 128, n, stride); + require(stride == sizeof(Handle), "UI handle list differs."); + for (unsigned j = 0; j < n; ++j) { + Handle handle{}; + std::memcpy(&handle, bytes.data() + j * stride, stride); + if (handle) visit({handle, c.type_of(handle)}, depth + 1); + } + } else if (kind(child.type) == 2 || kind(child.type) == 4) visit(child, depth + 1); + } + } + }; + visit(root, 0); + return result; +} +void clear_value(const Context& c, Value value) { + if (kind(value.type) == 7) { + if (!read
(c.address(value)) || !c.text(value).empty()) c.text(value, ""); + return; + } + // Native array publication reads the count before the pointer, even for + // an empty array: a null pointer is no empty counted array. + if (kind(value.type) == 4) { c.array(value, std::span{}, 0); return; } + if (kind(value.type) == 2) { + // Bindings can hold counted arrays and owned delegates: reset from a + // natively constructed value, never zeroed storage. + const auto defaults = make_like(c, value); + c.copy(value, c.address(defaults)); + return; + } + const std::vector empty(size(value.type)); + std::vector current(empty.size()); + require(memory::peek_bytes(c.address(value), current.data(), current.size()), "Native reset value is unavailable."); + if (current != empty) c.publish(value, empty.data()); +} +void plain_label(const Context& c, Value value, const std::string& text) { + const auto label_text = c.field(value, text_field); + if (!read
(c.address(label_text)) || c.text(label_text, 2048) != text) c.text(label_text, text); + const auto visible = c.field(value, 0x042924a4); + if (!read(c.address(visible))) c.set(visible, true); +} +void label_styles(const Context& c, Value label, const char* idle, const char* focused) { + c.set_if_changed(c.field(label, 0xf94d8cc6), record_ref(c, idle)); + c.set_if_changed(c.field(label, 0xa1e84eb1), record_ref(c, focused)); + c.set_if_changed(c.field(label, 0xfc23a999), record_ref(c, idle)); + c.set_if_changed(c.field(label, 0x25189231), record_ref(c, focused)); +} + +// ---- the picker ---------------------------------------------------------------------------------- +// The mode's logo: a texture or image asset of its mod, by path (as +// UI/ReSkate/Throwdowns/img_race_placeholder), found among the loaded assets +// whether or not it was drawn yet. A bare name is a category record in the +// game's Throwdown_ModeSelect_ContentResources, as ReSkate_Throwdown's logos +// were: every mod editing that list replaces the others' edits, so a mode's +// own texture is the way. Missing art keeps the donor's. +void mode_art(const Context& c, std::span fields, const std::string& art) { + auto& s = state(); + if (art.empty() || s.missing_art.contains(art)) return; + try { + if (art.find('/') != std::string::npos) { + Address image{}; + try { image = asset(c, art.c_str()); } catch (const std::exception&) {} + if (image && (read
(image + 8) == c.base + native::texture_asset_type || + read
(image + 8) == c.base + native::image_asset_type)) { + for (const auto field : fields) + if (read
(c.address(field)) != image) c.set(field, image); + return; + } + } else { + // Texture registration is lazy: the exported category record + // already holds the loaded image before its first draw. + const auto name = "Throwdown_ModeSelect_ContentResources/" + art; + const auto record = asset(c, name.c_str()); + const auto type = read
(record + 0x18), data = read
(record + 0x20); + require(read
(record + 8) == c.base + native::asset_record_type && type && data && + read(read
(type)) == category_schema.hash, + "Throwdown logo record schema differs."); + const auto image = read
(data + c.member(type, 0x189084da).offset); + require(image && (read
(image + 8) == c.base + native::texture_asset_type || + read
(image + 8) == c.base + native::image_asset_type), + "Throwdown logo record image is not loaded."); + for (const auto field : fields) + if (read
(c.address(field)) != image) c.set(field, image); + return; + } + require(publish_texture(c, fields, art), "no loaded texture or image has that name"); + } catch (const std::exception& error) { + // Not looked for again until the level or the page changes: a missing + // name searches every loaded asset domain. + s.missing_art.insert(art); + logging::log(logging::Level::warning, logging::Channel::ui, "Throwdown mode art {} is unavailable; keeping the stock art: {}", + art, error.what()); + } +} +// A card for each registered mode, copied from Spot Battle's. +void mode_cards(const Context& c, const std::vector& modes) { + auto& s = state(); + if (!s.cards.handle || s.originals.size() != 3) return; + for (const auto& mode : modes) { + auto [it, fresh] = s.mode_cards.try_emplace(mode.key()); + auto& card = it->second; + if (fresh) { + const auto& donor = s.originals.at(1); + card.tile = make(c, tile_schema, donor.tile.type); + c.copy(card.tile, c.address(donor.tile)); + card.category = make(c, category_schema, donor.category.type); + c.copy(card.category, c.address(donor.category)); + card.description = make(c, anchor_schema, donor.description.type); + c.copy(card.description, c.address(donor.description)); + card.label = make(c, label_schema, s.source_label.type); + c.copy(card.label, c.address(s.source_label)); + auto desc = read(c.address(c.field(card.description, widget))); + desc.data = {0, card.label.handle}; + c.set(c.field(card.description, widget), desc); + auto cat = read(c.address(c.field(card.category, widget))); + cat.data = {0, card.description.handle}; + c.set(c.field(card.category, widget), cat); + auto mounted = read(c.address(c.path(card.tile, {widget, widget}))); + mounted.data = {0, card.category.handle}; + c.set(c.path(card.tile, {widget, widget}), mounted); + logging::log(logging::Level::info, logging::Channel::ui, "Throwdowns: {} card for {}.", mode.name, mode.key()); + } + plain_label(c, card.label, mode.description); + plain_label(c, c.field(card.category, 0x77b8ed0b), mode.name); + // Ask for the callback each pass, so reopening never recycles a live + // card's delegate. + c.text(c.field(primary(c, card.tile), 0xb1e14cd6), ""); + c.set(c.field(primary(c, card.tile), 0x9c76b86c), callback(c, "mode-open", mode.key())); + mode_art(c, std::array{c.field(card.category, 0x189084da), c.field(card.category, 0xc2917efc)}, mode.art); + } +} +// A grid tile: the logo above its name, in the grid's own widgets. +Value picker_logo(const Context& c, Value tile, Value cat, bool stock, const std::string& name) { + // Private copies of the button style keep the native focus graph with + // every fill empty; only the round outline changes on hover and focus. + const auto button_style = record_model(c, "ButtonStyleList/TileButton.RoughPartial.NoDefaultBG"); + const auto empty = c.path(button_style, {0x2d393a3a, 0xb6c6aace}); + for (const auto key : {0x24996c65U, 0x69cf50caU, 0xe8f882b7U, 0x182004ffU, 0xb0db14beU, 0xfadedb6aU}) + c.copy(c.path(button_style, {0x2d393a3a, key}), c.address(empty)); + try { + const auto ring = asset(c, "UI/ReSkate/Throwdowns/LogoHoverRing_FillStyle"); + for (const auto key : {0xf622ea66U, 0x943abf2dU, 0x287f4815U}) c.set(c.path(button_style, {0x343abac3, key}), ring); + } catch (const std::exception& e) { + static bool reported{}; + if (!std::exchange(reported, true)) + logging::log(logging::Level::warning, logging::Channel::ui, "Throwdown hover ring unavailable: {}", e.what()); + } + for (const auto flag : {0xc02028cfU, 0xa45fc55dU, 0xb811842aU, 0xe69a9d99U}) c.set(c.field(button_style, flag), false); + c.set(c.path(tile, {widget, 0x0fb0d794, 0x8cc042ef}), Ref{0, button_style.handle}); + c.set(c.path(tile, {widget, 0xa04998ca}), false); + const auto colors = record_ref(c, "InteractivityColorStylesList/InteractiveColors.Core.AllWhite"); + c.set(c.field(button_style, 0xcd4a53c0), colors); + const auto logo = record_model(c, "Throwdown_ModeSelect_ContentResources/LogoTexture", texture_schema); + if (stock) c.copy(c.field(logo, 0x6e469d2a), c.address(c.field(cat, 0x189084da))); + c.set(c.field(logo, 0xf524c836), 320.f); + c.set(c.field(logo, 0x6fbd254e), 320.f); + for (const auto flag : {0x81c94d8aU, 0x190e039cU, 0xd1997d0cU}) c.set(c.field(logo, flag), false); + c.set(c.field(logo, 0xcd4a53c0), colors); + const auto caption = make(c, label_schema, state().source_label.type); + c.copy(caption, c.address(state().source_label)); + plain_label(c, caption, name); + label_styles(c, caption, "TextStylesList/H52-XBold_White_DropShadow75", "TextStylesList/H52-XBold_White_DropShadow75"); + const auto outlined = record_model(c, "TextStylesList/H52-XBold_White_DropShadow75"); + c.set(c.field(outlined, 0x51332fc1), asset(c, "UI/Foundations/Fonts/Styles/Dingo_2pxOutline_Aqua_FontEffect")); + for (const auto key : {0xa1e84eb1U, 0x25189231U}) c.set(c.field(caption, key), Ref{0, outlined.handle}); + c.set(c.field(caption, 0x87ad624c), 2); // centred vertically + c.set(c.field(caption, 0x3d8639d0), 1); + c.set(c.field(caption, 0xc13ba637), false); + c.set(c.field(caption, 0xfc53d427), false); + c.set(c.field(caption, 0xcd71a279), true); + c.set(c.field(caption, 0x3fa0b887), false); + const auto contents = record_model(c, "Throwdown_ModeSelect_ContentResources/LogoContents", {0x9ade4bc7, 208}); + c.set(c.field(contents, 0xa98391bd), Widget{asset(c, "UI/ReSkate/Throwdowns/LogoTexture_Widget"), {0, logo.handle}}); + c.set(c.field(contents, 0x759c54dd), Widget{asset(c, "UI/ReSkate/Throwdowns/LogoName_Widget"), {0, caption.handle}}); + // The private blueprint owns fixed regions (logo 52,16,320,320; name + // 52,341,360,56): neither moves the other as its font or texture loads. + // The tile keeps its native button, focus and action. + c.set(c.path(tile, {widget, widget}), Widget{asset(c, "UI/ReSkate/Throwdowns/LogoCaptionTile_Widget"), {0, contents.handle}}); + c.set(c.field(tile, 0x868043e1), std::array{424.f, 424.f}); + return logo; +} +// 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(); + if (!s.picker_grid.handle) { + const auto checkpoint = s.owned.checkpoint(); + try { + s.picker_grid = record_model(c, "Throwdown_ModeSelect_ContentResources/LogoGrid"); + require(read(read
(s.picker_grid.type)) == 1948428891U, "Throwdown native grid schema differs."); + c.set(c.field(s.picker_grid, 0x55ca89f4), asset(c, "UI/ReSkate/Throwdowns/LogoPickerListItem_Widget")); + c.set(c.field(s.picker_grid, 1913319895U), 424.f); // ColumnSize + c.set(c.field(s.picker_grid, 2171233779U), 424.f); // RowSize + c.set(c.field(s.picker_grid, 2454275348U), 32.f); // ColumnSpacing + c.set(c.field(s.picker_grid, 2923226299U), 32.f); // RowSpacing + c.set(c.field(s.picker_grid, 3625715088U), 4); // StaticSegmentItemCount + c.set(c.field(s.picker_grid, 3831727006U), 0); // a static segment count + c.set(c.field(s.picker_grid, 1287003308U), 1); // rows of four columns + c.set(c.field(s.picker_grid, 369153166U), 0); // DefaultIndex + c.set(c.field(s.picker_grid, 3523782405U), 0); // Inset + for (const auto flag : {1972029470U, 133293336U, 3885835458U, 3339695017U, 235921311U}) c.set(c.field(s.picker_grid, flag), false); + c.set(c.field(s.picker_grid, 1163163164U), true); // clip to the content area + c.set(c.field(s.picker_grid, 3275567233U), true); // remember the selection on Back + s.picker_anchor = record_model(c, "Throwdown_ModeSelect_ContentResources/Throwdown_ModeSelect_LinearList_AnchoredContent", anchor_schema); + c.set(c.field(s.picker_anchor, widget), Widget{asset(c, "UI/Foundations/Components/Lists/GridList/Widget/GridList_Widget"), {0, s.picker_grid.handle}}); + c.set(c.field(s.picker_anchor, 0x1cff7243), std::array{1792.f, 1336.f}); + c.set(c.field(s.picker_anchor, 0x6efc1a61), false); + c.set(c.field(s.picker_anchor, 0xbe5683d9), false); + const auto horizontal = c.field(s.picker_anchor, 0xde7d30c7); + c.set(c.field(horizontal, 0x99bbed5a), .5f); + c.set(c.field(horizontal, 0x49943bed), .5f); + c.set(c.field(horizontal, 0x40bfbff4), 0.f); + c.set(c.field(horizontal, 0x1d233266), 0.f); + c.set(c.field(horizontal, 0xfec5e0b1), .5f); + c.set(c.field(horizontal, 0xd01f4a21), 1.f); + constexpr std::array names{"S.K.A.T.E.", "Spot Battle", "Skate Jam"}; + for (unsigned i = 0; i < 3; ++i) { + s.picker_stock[i] = make(c, tile_schema, s.originals[i].tile.type); + c.copy(s.picker_stock[i], c.address(s.originals[i].tile)); + s.picker_stock_logos[i] = picker_logo(c, s.picker_stock[i], s.originals[i].category, true, names[i]); + } + } catch (...) { + s.picker_grid = s.picker_anchor = {}; + s.picker_stock = s.picker_stock_logos = {}; + s.owned.rollback(checkpoint, [&](Value value) { c.destroy(value); }); + throw; + } + } + // ContentPresenterTileListItem consumes the ContentPresenterTileViewModel, + // not its TileButtonListItemViewModel wrapper: the wrapper adds a field to + // the flattened table and shifts every cached input scope by one. Keep the + // owner alive and publish the typed content-tile field for each cell. + std::vector refs; + const auto append_tile = [&](Value tile) { + const auto inner = c.field(tile, widget); + require(read(read
(inner.type)) == content_tile_schema.hash && size(inner.type) == content_tile_schema.size, + "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) { + 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); + } + 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."); + if (previous != std::vector(std::as_bytes(std::span(refs)).begin(), std::as_bytes(std::span(refs)).end())) + c.array(c.field(s.picker_grid, 0xf2c90867), refs); + // Only the mode selector is replaced: native navigation may reuse this + // page for another view, or leave its content array empty for a moment. + const auto slot = mounted_body(c, s.page); + if (!slot) return; + const auto mounted = read(c.address(*slot)); + if (mounted.data.handle != s.picker_anchor.handle) + c.set(*slot, Widget{asset(c, "UI/Foundations/Templates/Layouts/Anchored/AnchoredContentPresenter_Widget"), {0, s.picker_anchor.handle}}); +} +// Without the logo grid: the modes' cards after the stock three, +// every card a little narrower. +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 bool scale = !modes.empty(); + if (scale != s.cards_scaled) { + s.cards_scaled = scale; + const float k = scale ? .74f : 1.f; + const auto narrow = [&](Value tile, Value cat, Value desc, const Original& o) { + c.set(c.field(tile, 0x868043e1), std::array{o.tile_size[0] * k, o.tile_size[1]}); + c.set(c.field(desc, 0x1cff7243), std::array{o.description_size[0] * k, o.description_size[1]}); + c.set(c.field(cat, 0x495ffd43), o.icon_width * k); + c.set(c.field(cat, 0x24fb5ca8), o.icon_height * k); + }; + for (const auto& o : s.originals) narrow(o.tile, o.category, o.description, o); + } + for (const auto& mode : modes) { + const auto& card = s.mode_cards.at(mode.key()); + const auto& o = s.originals.at(1); + c.set_if_changed(c.field(card.tile, 0x868043e1), std::array{o.tile_size[0] * .74f, o.tile_size[1]}); + c.set_if_changed(c.field(card.description, 0x1cff7243), std::array{o.description_size[0] * .74f, o.description_size[1]}); + c.set_if_changed(c.field(card.category, 0x495ffd43), o.icon_width * .74f); + c.set_if_changed(c.field(card.category, 0x24fb5ca8), o.icon_height * .74f); + } + unsigned count{}, stride{}; + const auto live = c.array(c.field(s.cards, rows_field), 64, count, stride); + require(stride == sizeof(Ref), "Throwdown card list schema differs."); + if (live != std::vector(std::as_bytes(std::span(rows)).begin(), std::as_bytes(std::span(rows)).end())) + c.array(c.field(s.cards, rows_field), rows); +} +// The stock page's three cards (S.K.A.T.E., Spot Battle, Skate Jam) list. +std::optional card_list(const Context& c, Value p) { + try { + const auto slot = mounted_body(c, p); + if (!slot) return {}; + auto w = read(c.address(*slot)); + // Our grid replaces the presenter, not the stock list: the list stays + // the generation's identity while the grid is mounted. + const auto& s = state(); + if (p.handle == s.page.handle && s.picker_anchor.handle && w.data.handle == s.picker_anchor.handle && + c.type_of(s.cards.handle) == s.cards.type) return s.cards; + for (unsigned depth = 0; depth < 4; ++depth) { + auto value = reference(c, w.data); + if (!value.type) return {}; + const auto hash = read(read
(value.type)); + if (hash == linear_list.hash && size(value.type) == linear_list.size) { + unsigned count{}, stride{}; + auto bytes = c.array(c.field(value, rows_field), 64, count, stride); + if (count < 3 || stride != sizeof(Ref)) return {}; + // The live Skate Jam card navigates through ThrowdownerActive. + const std::array expected{"ThrowdownSkate", "SpotBattle", "ThrowdownerActive"}; + for (unsigned i = 0; i < 3; ++i) { + Ref ref{}; + std::memcpy(&ref, bytes.data() + i * stride, stride); + auto t = reference(c, ref); + if (!t.type || size(t.type) != tile_schema.size || read(read
(t.type)) != tile_schema.hash || + c.text(c.field(primary(c, t), 0xb1e14cd6)) != expected[i]) return {}; + } + // Our appended cards are ours; any other fourth card is not this page. + if (count > 3 && (p.handle != s.page.handle || value.handle != s.cards.handle)) return {}; + return value; + } + if (hash != anchor_schema.hash || size(value.type) != anchor_schema.size) return {}; + w = read(c.address(c.field(value, widget))); + } + } catch (...) {} + return {}; +} + +// ---- the setup page ------------------------------------------------------------------------------ +enum class SetupRole { other, players, timer, rounds }; +// Native labels can already be localized when the page is ready. +SetupRole setup_role(std::string_view text) { + if (text == "ID_SETTINGS_PLAYERS" || text == "Players") return SetupRole::players; + if (text == "ID_LABEL_TURNTIMER" || text == "ID_LABEL_DURATION" || text == "Turn Timer" || text == "Timer" || text == "Duration") + return SetupRole::timer; + if (text == "ID_LABEL_ROUNDS" || text == "Rounds") return SetupRole::rounds; + return SetupRole::other; +} +std::string setting_caption(const Context& c, Value setting) { return c.text(c.path(setting, {0x58c9d354, text_field})); } +bool setup_body_content_matches(const Context& c, Value live, Value saved) { + unsigned live_count{}, live_stride{}, saved_count{}, saved_stride{}; + const auto live_items = c.array(c.field(live, items), 128, live_count, live_stride); + const auto saved_items = c.array(c.field(saved, items), 128, saved_count, saved_stride); + require(live_stride == stack_item.size && saved_stride == stack_item.size, "Native setup body item schema differs."); + return matching_presenters(live_items, saved_items, stack_item.size, c.member(c.type(stack_item), content).offset); +} +void mount_setup_body(const Context& c, Value live, Value saved) { + // Array publication makes a new allocation: compare presenter identity, + // never its address or the native transition and focus fields around it. + const auto live_array = c.field(live, items), saved_array = c.field(saved, items); + unsigned live_count{}, live_stride{}, saved_count{}, saved_stride{}; + const auto live_items = c.array(live_array, 128, live_count, live_stride); + const auto saved_items = c.array(saved_array, 128, saved_count, saved_stride); + require(live_stride == stack_item.size && saved_stride == stack_item.size, "Native setup body item schema differs."); + // Same authored slots: change just their presenters, without restarting + // the stack items' native visibility animation. + if (const auto next = remount_presenters(live_items, saved_items, stack_item.size, c.member(c.type(stack_item), content).offset)) + c.array(live_array, *next, saved_count); +} +void restore_official(const Context& c) { + auto& s = state(); + std::set owned_panels; + for (const auto& [panel, header] : s.official_headers) { + if (c.type_of(panel) != c.type(panel_schema) || !game::native_data().models.value(c.manager, panel, 0, 1)) continue; + if (read(c.address(c.field({panel, c.type(panel_schema)}, 0xc0f1b449))).data.handle == header.handle) owned_panels.insert(panel); + } + for (const auto& [id, pair] : s.official_originals) { + // A retired panel can keep a typed handle after its storage is gone: + // that must not block every later menu update. + if (c.type_of(id) != pair.first.type || !game::native_data().models.value(c.manager, id, 0, 1)) continue; + const auto panel = s.official_slot_panels.find(id); + if (panel == s.official_slot_panels.end()) continue; + if (read(read
(pair.first.type)) == 0xd71ee0a1) { + // The body is an inline native stack: restore it only while it + // still presents our rows, and leave its focus and transition alone. + const auto private_body = s.official_contents.find(id); + if (private_body != s.official_contents.end() && setup_body_content_matches(c, pair.first, private_body->second)) + mount_setup_body(c, pair.first, pair.second); + } else if (owned_panels.contains(panel->second)) c.copy(pair.first, c.address(pair.second)); + } + s.official_originals.clear(); + s.official_panels.clear(); + s.official_headers.clear(); + s.official_contents.clear(); + s.official_slot_panels.clear(); + s.official_sliders.clear(); + s.official_setup_rows.clear(); + s.official_badges.clear(); + s.slider_rows.clear(); + s.official_mode.clear(); +} +void remember_official(const Context& c, Value value) { + auto& originals = state().official_originals; + if (originals.contains(value.handle)) return; + const auto copy = make_like(c, value); + c.copy(copy, c.address(value)); + originals.emplace(value.handle, std::pair{value, copy}); +} +// Drops every setting row with one of these roles from the private copy. +void remove_rows(const Context& c, Value root, std::initializer_list roles) { + std::set seen; + std::function visit = [&](Value value, unsigned depth) -> bool { + require(depth < 96, "Throwdown settings graph depth differs."); + if (!value.handle) return false; + const auto hash = read(read
(value.type)); + if (hash == setting_schema.hash) { + const auto role = setup_role(setting_caption(c, value)); + return std::find(roles.begin(), roles.end(), role) != roles.end(); + } + if (!seen.insert(value.handle).second) return false; + struct Unvisit { std::set& seen; Handle handle; ~Unvisit() { seen.erase(handle); } } unvisit{seen, value.handle}; + if (hash == 0x62088281) { + const auto ref = read(c.address(value)); + return ref.handle && visit(reference(c, ref), depth + 1); + } + if (hash == presenter.hash) { + const auto w = read(c.address(value)); + return w.data.handle && visit(reference(c, w.data), depth + 1); + } + if (kind(value.type) == 4) { + unsigned n{}, stride{}; + const auto old = c.array(value, 128, n, stride); + std::vector kept; + for (unsigned i = 0; i < n; ++i) { + const auto child = c.element(value, i); + if (kind(child.type) == 2 || kind(child.type) == 4) { + const auto copy = make_like(c, child); + c.copy(copy, c.address(child)); + if (visit(copy, depth + 1)) continue; + const auto offset = kept.size(); + kept.resize(offset + stride); + require(memory::peek_bytes(c.address(copy), kept.data() + offset, stride), "Private settings row is unavailable."); + } else kept.insert(kept.end(), old.begin() + i * stride, old.begin() + (i + 1) * stride); + } + if (kept != old) c.array(value, kept, static_cast(kept.size() / stride)); + return false; // removed a row, not the list holding it + } + bool remove = false; + if (kind(value.type) == 2) { + const auto meta = read
(value.type), fields = read
(meta + 0x60); + const auto count = read(meta + 0x2a); + require(count <= 128, "Throwdown settings field count differs."); + for (unsigned i = 0; i < count; ++i) { + const auto key = read(fields + i * 24); + if (borrowed(key)) continue; + const auto child = c.field(value, key); + if (key == 0xf2c90867 && kind(child.type) == 4) { + unsigned n{}, stride{}; + const auto old = c.array(child, 128, n, stride); + require(stride == sizeof(Handle), "Throwdown setting handle list differs."); + std::vector kept; + for (unsigned j = 0; j < n; ++j) { + Handle handle{}; + std::memcpy(&handle, old.data() + j * stride, stride); + if (!handle || !visit({handle, c.type_of(handle)}, depth + 1)) kept.push_back(handle); + } + if (kept.size() != n) c.array(child, kept); + } else if (kind(child.type) == 2 || kind(child.type) == 4) remove = visit(child, depth + 1) || remove; + } + } + return remove; + }; + visit(root, 0); +} +// The host parameters list as the native page mounted it, with its Players +// and Timer rows (the Timer row is the template for the mode's own rows). +SetupRows setup_rows(const Context& c, Value body_value) { + // HostParameters materializes its settings into the LinearList's OwnedItems + // tile array; ListItems/ListItemHandles are the alternatives. + for (const auto list : ui_models(c, body_value, linear_list.hash)) { + SetupRows result; + result.list = list; + const auto owned = c.field(list, 0x345b75b1); + unsigned owned_count{}, owned_stride{}; + c.array(owned, 128, owned_count, owned_stride); + require(owned_stride == content_tile_schema.size, "Native setup OwnedItems tile schema differs."); + bool owned_players{}; + for (unsigned i = 0; i < owned_count; ++i) { + const auto row = c.element(owned, i); + for (const auto setting : ui_models(c, row, setting_schema.hash)) { + const auto role = setup_role(setting_caption(c, setting)); + owned_players = owned_players || role == SetupRole::players; + if (role == SetupRole::timer) { result.timer_row = row; result.timer_index = i; } + } + } + if (owned_players && result.timer_row.handle) { + result.retained_rows = make_like(c, owned); + c.copy(result.retained_rows, c.address(owned)); + result.timer_row = c.element(result.retained_rows, result.timer_index); + result.owned_tiles = true; + return result; + } + result.timer_row = {}; + unsigned records_count{}, records_stride{}, handles_count{}, handles_stride{}; + const auto records = c.array(c.field(list, rows_field), 128, records_count, records_stride); + const auto handles = c.array(c.field(list, 0xf2c90867), 128, handles_count, handles_stride); + require(records_stride == sizeof(Ref) && handles_stride == sizeof(Handle), "Native setup list item types differ."); + if (records_count && handles_count) continue; + result.records.resize(records_count); + result.handles.resize(handles_count); + if (records_count) std::memcpy(result.records.data(), records.data(), records.size()); + if (handles_count) std::memcpy(result.handles.data(), handles.data(), handles.size()); + bool players{}; + const auto inspect = [&](Value row) { + for (const auto setting : ui_models(c, row, setting_schema.hash)) { + const auto role = setup_role(setting_caption(c, setting)); + if (role == SetupRole::players) players = true; + if (role == SetupRole::timer) result.timer_row = row; + } + }; + for (const auto ref : result.records) + if (ref.handle) inspect(reference(c, ref)); + for (const auto handle : result.handles) + if (handle) inspect({handle, c.type_of(handle)}); + if (players && result.timer_row.handle) return result; + } + // StackPanel.Body is a StackViewModel (0xd71ee0a1): some generations mount + // each setting as a stack item instead of a nested list. + if (read(read
(body_value.type)) == 0xd71ee0a1) { + SetupRows result; + result.list = body_value; + result.stack = true; + const auto items_value = c.field(body_value, items); + unsigned count{}, stride{}; + c.array(items_value, 128, count, stride); + require(stride == stack_item.size, "Native HostParameters stack item schema differs."); + bool players{}; + for (unsigned i = 0; i < count; ++i) { + const auto row = c.element(items_value, i); + const auto settings = ui_models(c, row, setting_schema.hash); + if (settings.size() != 1) continue; + const auto role = setup_role(setting_caption(c, settings.front())); + if (role == SetupRole::players) players = true; + if (role == SetupRole::timer) { result.timer_row = row; result.timer_index = i; } + } + if (players && result.timer_row.handle) { + result.retained_rows = make_like(c, items_value); + c.copy(result.retained_rows, c.address(items_value)); + result.timer_row = c.element(result.retained_rows, result.timer_index); + return result; + } + } + throw std::runtime_error("Native HostParameters list with Players and Timer is not mounted yet."); +} +// A private copy of the Timer row, captioned for one of the mode's rows. +Value setup_extra(const Context& c, SetupRows& rows, const std::string& key, const std::string& caption) { + if (const auto found = rows.extra.find(key); found != rows.extra.end()) return found->second; + PrivateUiGraph graph{c}; + const auto row = graph.clone({0, rows.timer_row.handle}); + const auto settings = ui_models(c, row, setting_schema.hash); + require(settings.size() == 1, "Native setup template must contain one SettingWrapper."); + plain_label(c, c.field(settings.front(), 0x58c9d354), caption); + if (rows.stack) c.set(c.field(row, 0x1e95752c), static_cast(2000 + rows.extra.size())); + rows.extra.emplace(key, row); + return row; +} +// A row that is a button: its press is news for the plugin. +void setup_action_row(const Context& c, SetupRows& rows, const std::string& key, const std::string& caption, + const std::string& value, const std::string& command, const std::string& argument) { + const bool fresh = !rows.extra.contains(key); + const auto checkpoint = state().owned.checkpoint(); + try { + const auto row = setup_extra(c, rows, key, caption); + const auto setting = ui_models(c, row, setting_schema.hash).front(); + plain_label(c, c.field(setting, 0x58c9d354), caption); + auto slot = c.field(setting, content); + Value control; + if (fresh) { + control = make(c, button); + // The game's setting label's own font styles, with a fresh label + // button input model: no shared host parameter binding. + c.copy(c.field(control, 0x58c9d354), c.address(c.field(setting, 0x58c9d354))); + const auto donor = record_model(c, "Throwdown_DetailsMenu_ContentResource/ThrowdownDetails_HUDButton", button); + for (const auto field : {0x8cc042efU, 0xf9515817U, 0xf343d160U}) c.copy(c.field(control, field), c.address(c.field(donor, field))); + clear_value(c, c.field(control, 0x67212c85)); + c.set(c.path(control, {0x0fb0d794, 0x8cc042ef}), record_ref(c, "ButtonStyleList/TileButton.RoughPartial.Default")); + c.set(slot, Widget{asset(c, "UI/Foundations/Components/Buttons/LabelButton/LabelButton_Widget"), {0, control.handle}}); + } else control = reference(c, read(c.address(slot)).data); + plain_label(c, c.field(control, 0x58c9d354), value); + const auto set_action = [&](Value model) { + const auto input_root = c.path(model, {0x0fb0d794, 0xa704272a, 0xc52416ef}); + const auto interactable = c.field(input_root, 0xa704272a); + c.set(c.field(interactable, 0x4b3146e9), true); + c.set(c.field(interactable, 0x38f7355f), false); + clear_value(c, c.path(input_root, {0x66a997b9, 0x66a997b9, 0x4c09193f})); + const auto action = c.field(interactable, 0xa2b71c93); + c.set(c.field(action, 0x9c76b86c), callback(c, command, argument)); + c.text(c.field(action, 0xb1e14cd6), ""); + const auto primary_input = asset(c, "Configurations/Input/UI/UI_PrimaryAction"); + c.set(c.field(action, 0x3d00de27), primary_input); + c.set(c.field(action, 0x0169c2db), primary_input); + }; + set_action(control); + // HostParameters items select through a ContentPresenterTile: keep its + // template and give it this row's action. + for (const auto tile : ui_models(c, row, content_tile_schema.hash)) { + set_action(tile); + c.set(c.path(tile, {0x0fb0d794, 0xa704272a, 0xc52416ef, 0x0abf7c31}), Ref{0, control.handle}); + } + } catch (...) { + if (fresh) { + rows.extra.erase(key); + state().owned.rollback(checkpoint, [&](Value model) { c.destroy(model); }); + } + throw; + } +} +// A row picking one of several options with the game's left/right selector +// (as Allow Join looks). The option the player settled on when it differs +// from `selected`, else -1. +int setup_choice_row(const Context& c, SetupRows& rows, const std::string& key, const std::string& caption, + const std::vector& options, int selected) { + const bool fresh = !rows.extra.contains(key); + const auto checkpoint = state().owned.checkpoint(); + try { + const auto row = setup_extra(c, rows, key, caption); + const auto setting = ui_models(c, row, setting_schema.hash).front(); + plain_label(c, c.field(setting, 0x58c9d354), caption); + auto& choice = rows.choices[key]; + if (fresh) { + const auto control = record_model(c, "TD_HostParameters_ContentResource/TD_Timer_BasicSelector", selector_schema); + // The authored selector's two-way bindings belong to Skate Jam: own + // the options and only read this selector's choice. + for (const auto field : {0x4256fd65U, 0x85b8c3e6U, 0xe88c0f54U}) clear_value(c, c.field(control, field)); + c.set(c.field(control, 0xeb92bf1c), Ref{}); + for (const auto field : {0x41b10c27U, 0xd637e1f4U}) c.set(c.field(control, field), Address{}); + const auto list = c.field(control, 0x760fc12b); + c.set(c.field(list, 0x340cfb95), Address{}); + choice.options.clear(); + for (const auto& option : options) { + auto item = make(c, selector_item_schema), text = make(c, label_schema); + c.copy(text, c.address(c.field(setting, 0x58c9d354))); + label_styles(c, text, "TextStylesList/H64-XBold_White", "TextStylesList/H64-XBold_Black"); + plain_label(c, text, option); + c.text(c.field(item, 0x58c9d354), option); + c.set(c.field(item, 0x34c8bcc2), Ref{0, text.handle}); + choice.options.push_back(item.handle); + } + c.array(c.field(list, rows_field), std::vector{}); + c.array(c.field(list, 0xf2c90867), choice.options); + c.set(c.field(control, 0x369babfe), true); + c.set(c.field(setting, content), Widget{asset(c, "UI/Foundations/Components/Settings/Widgets/BasicSelector_Widget"), {0, control.handle}}); + choice.selector = control; + choice.shown = -1; + } + const auto list = c.field(choice.selector, 0x760fc12b); + // The choice is its current item's handle (0x783c4b49); the index + // field (0x55511280) does not follow left and right. + const auto current = read(c.address(c.field(list, 0x783c4b49))); + const auto found = std::find(choice.options.begin(), choice.options.end(), current); + const int index = found == choice.options.end() ? -1 : static_cast(found - choice.options.begin()); + if (choice.shown < 0 || (selected != choice.shown && index == choice.shown)) { + const auto at = std::clamp(selected, 0, static_cast(choice.options.size()) - 1); + c.set(c.field(list, 0x55511280), at); + c.set(c.field(list, 0x783c4b49), choice.options[static_cast(at)]); + choice.shown = at; + return -1; + } + // A choice counts once it has stood a quarter second with the page + // open. The game puts the selector back to its first item as the page + // closes on Confirm, so the first item counts only after 2 s. + const auto now = GetTickCount64(); + if (index < 0 || static_cast(index) >= choice.options.size() || index == choice.shown) { choice.pending = -1; return -1; } + if (index != choice.pending) { choice.pending = index; choice.pending_at = now; return -1; } + if (now - choice.pending_at < (index == 0 ? 2000u : 250u) || custom_throwdowns::setup_mode().empty()) return -1; + choice.shown = index; + choice.pending = -1; + return index != selected ? index : -1; + } catch (...) { + if (fresh) { + rows.extra.erase(key); + rows.choices.erase(key); + state().owned.rollback(checkpoint, [&](Value model) { c.destroy(model); }); + } + throw; + } +} +// The mode's own rows after Players (and Timer, when it has one), in the list's own representation. +void setup_capabilities(const Context& c, Value panel, Value body_value, const custom_throwdowns::Mode& mode) { + auto& s = state(); + auto [found, fresh] = s.official_setup_rows.try_emplace(panel.handle); + if (fresh) { + const auto checkpoint = s.owned.checkpoint(); + try { + found->second = setup_rows(c, body_value); + } catch (...) { + s.official_setup_rows.erase(found); + s.owned.rollback(checkpoint, [&](Value model) { c.destroy(model); }); + throw; + } + } + auto& rows = found->second; + const auto values = custom_throwdowns::settings(mode.key()); + const auto value_of = [&](const custom_throwdowns::Row& row) { + const auto v = values.find(row.id); + return v == values.end() ? row.value : v->second; + }; + std::vector extra; + for (const auto& row : mode.rows) { + const auto key = "row-" + row.id; + if (row.kind == custom_throwdowns::Row::Kind::slider) { + const auto made = setup_extra(c, rows, key, row.caption); + for (const auto setting : ui_models(c, made, setting_schema.hash)) { + plain_label(c, c.field(setting, 0x58c9d354), row.caption); + s.slider_rows[c.field(setting, content).handle] = row.id; + } + extra.push_back(made); + } else if (row.kind == custom_throwdowns::Row::Kind::choice) { + const auto picked = setup_choice_row(c, rows, key, row.caption, row.options, value_of(row)); + if (picked >= 0) custom_throwdowns::set_setting(mode.key(), row.id, picked); + extra.push_back(rows.extra.at(key)); + } else { + setup_action_row(c, rows, key, row.caption, row.text.empty() ? row.caption : row.text, "mode-button", mode.key() + "|" + row.id); + extra.push_back(rows.extra.at(key)); + } + } + // Keep the native list's layout, scroll and focus state and item + // template: publish its own row representation, minus the Timer row for a + // mode without one. + const bool keep_timer = mode.timer.has_value(); + if (rows.owned_tiles || rows.stack) { + const auto stride = rows.owned_tiles ? content_tile_schema.size : stack_item.size; + unsigned saved_count{}, saved_stride{}; + const auto saved = c.array(rows.retained_rows, 128, saved_count, saved_stride); + require(saved_stride == stride, "Retained native setup row schema differs."); + std::vector bytes; + for (unsigned i = 0; i < saved_count; ++i) + if (keep_timer || i != rows.timer_index) bytes.insert(bytes.end(), saved.begin() + i * stride, saved.begin() + (i + 1) * stride); + for (const auto row : extra) { + const auto at = bytes.size(); + bytes.resize(at + stride); + require(memory::peek_bytes(c.address(row), bytes.data() + at, stride), "Private native setup row is unavailable."); + } + const auto target = rows.owned_tiles ? c.field(rows.list, 0x345b75b1) : c.field(rows.list, items); + unsigned count{}, live_stride{}; + const auto live = c.array(target, 128, count, live_stride); + require(live_stride == stride, "Private native setup row schema differs."); + const auto offset = rows.owned_tiles ? c.member(c.type(content_tile_schema), widget).offset : c.member(c.type(stack_item), content).offset; + // The native list updates focus and interaction inside these rows: + // remount only their presenters. + if (const auto next = remount_presenters(live, bytes, stride, offset)) + c.array(target, *next, static_cast(bytes.size() / stride)); + } else if (!rows.records.empty()) { + std::vector records; + for (const auto ref : rows.records) + if (keep_timer || ref.handle != rows.timer_row.handle) records.push_back(ref); + for (const auto row : extra) records.push_back({0, row.handle}); + unsigned count{}, stride{}; + const auto live = c.array(c.field(rows.list, rows_field), 128, count, stride); + if (live != std::vector(std::as_bytes(std::span(records)).begin(), std::as_bytes(std::span(records)).end())) + c.array(c.field(rows.list, rows_field), records); + } else { + std::vector handles; + for (const auto handle : rows.handles) + if (keep_timer || handle != rows.timer_row.handle) handles.push_back(handle); + for (const auto row : extra) handles.push_back(row.handle); + unsigned count{}, stride{}; + const auto live = c.array(c.field(rows.list, 0xf2c90867), 128, count, stride); + if (live != std::vector(std::as_bytes(std::span(handles)).begin(), std::as_bytes(std::span(handles)).end())) + c.array(c.field(rows.list, 0xf2c90867), handles); + } +} +// The game's real Spot Battle setup, opened for a plugin mode: a private copy +// of its header, body and footer, relabelled with the mode's name, logo, +// description and rows. Its stack, transitions, Confirm and the native flag +// placement after it stay the game's own. +void official_setup(const Context& c) { + auto& s = state(); + const auto key = custom_throwdowns::setup_mode(); + const auto mode = key.empty() ? std::nullopt : custom_throwdowns::find(key); + if (!mode) { restore_official(c); return; } + if (s.official_mode != key) { + restore_official(c); + s.official_mode = key; + } + const auto values = custom_throwdowns::settings(key); + for (const auto& root : c.roots({panel_schema.hash})) { + try { + const auto header_slot = c.field(root.model, 0xc0f1b449); + auto header = reference(c, read(c.address(header_slot)).data); + if (!header.handle || read(read
(header.type)) != header_schema.hash) continue; + auto title = c.field(header, 0x00f3b15e); + const auto name = c.text(c.field(title, text_field)); + const bool fresh = !s.official_panels.contains(root.model.handle); + if (fresh && name != "Spot Battle" && name != "SPOT BATTLE" && name != "ID_ACTIVITY_SPOTBATTLE_TITLE") continue; + if (fresh) { + // The header and footer mount before the native body: never + // keep that empty body and then cover its populated rows. + bool has_players{}, has_timer{}; + for (const auto setting : ui_models(c, c.field(root.model, 0x43967ece), setting_schema.hash)) { + const auto role = setup_role(setting_caption(c, setting)); + has_players = has_players || role == SetupRole::players; + has_timer = has_timer || role == SetupRole::timer; + } + if (!has_players || !has_timer) continue; + // Finish the private graph before any live presenter changes: a + // missing native dependency leaves the stock page as it was. + PrivateUiGraph graph{c}; + std::vector> copies; + for (const auto slot_key : {0xc0f1b449U, 0x43967eceU, 0xbfc64535U}) { + const auto slot = c.field(root.model, slot_key); + const auto copy = graph.clone(slot_key == 0x43967eceU ? Ref{0, slot.handle} : read(c.address(slot)).data); + if (slot_key == 0x43967eceU) remove_rows(c, copy, {SetupRole::rounds}); + copies.emplace_back(slot, copy); + if (slot_key == 0xc0f1b449U) header = copy; + } + for (const auto& [slot, copy] : copies) remember_official(c, slot); + try { + for (const auto& [slot, copy] : copies) { + if (slot.type == copy.type) mount_setup_body(c, slot, copy); + else { + auto current = read(c.address(slot)); + current.data = {0, copy.handle}; + c.set(slot, current); + } + s.official_contents.emplace(slot.handle, copy); + s.official_slot_panels.emplace(slot.handle, root.model.handle); + } + s.official_headers.emplace(root.model.handle, header); + s.official_panels.insert(root.model.handle); + } catch (...) { + for (const auto& [slot, copy] : copies) { + try { + const auto saved = s.official_originals.at(slot.handle).second; + if (slot.type == copy.type) mount_setup_body(c, slot, saved); + else c.copy(slot, c.address(saved)); + } catch (...) {} + s.official_contents.erase(slot.handle); + s.official_slot_panels.erase(slot.handle); + s.official_originals.erase(slot.handle); + } + s.official_headers.erase(root.model.handle); + s.official_panels.erase(root.model.handle); + throw; + } + title = c.field(header, 0x00f3b15e); + logging::log(logging::Level::info, logging::Channel::ui, "Throwdowns: the native setup {:#x} is {}'s.", root.model.handle, mode->name); + } else if (const auto saved = s.official_headers.find(root.model.handle); + saved != s.official_headers.end() && header.handle != saved->second.handle) { + auto mounted = read(c.address(header_slot)); + mounted.data = {0, saved->second.handle}; + c.set(header_slot, mounted); + header = saved->second; + title = c.field(header, 0x00f3b15e); + } + // Reentering the page can republish Spot Battle's presenters: keep + // ours, with the live stack's animation and focus. + const auto body_slot = c.field(root.model, 0x43967ece); + const auto private_body = s.official_contents.at(body_slot.handle); + mount_setup_body(c, body_slot, private_body); + { + const auto slot = c.field(root.model, 0xbfc64535); + const auto copy = s.official_contents.at(slot.handle); + auto current = read(c.address(slot)); + if (current.data.handle != copy.handle) { current.data = {0, copy.handle}; c.set(slot, current); } + } + plain_label(c, title, mode->name); + // Optional art never blocks the title or the native controls. The + // logo is a texture; ActivityDisplay's framed icon wants an image, + // so it goes in the native Texture widget. + if (!mode->art.empty()) try { + const auto blueprint = asset(c, "UI/Foundations/Components/Media/Icons/Texture_Widget"); + auto& badge = s.official_badges[root.model.handle]; + if (!badge.handle) { + badge = make(c, texture_schema); + c.set(c.field(badge, 0xf524c836), 288.f); + c.set(c.field(badge, 0x6fbd254e), 288.f); + for (const auto flag : {0x81c94d8aU, 0xd1997d0cU, 0x190e039cU}) c.set(c.field(badge, flag), false); + } + mode_art(c, std::array{c.field(badge, 0x6e469d2a)}, mode->art); + const auto icon = c.field(header, content); + if (read(c.address(icon)).data.handle != badge.handle) c.set(icon, Widget{blueprint, {0, badge.handle}}); + } catch (const std::exception& e) { + if (fresh) logging::log(logging::Level::warning, logging::Channel::ui, "Throwdown setup badge: {}", e.what()); + } + setup_capabilities(c, root.model, private_body, *mode); + mount_setup_body(c, body_slot, private_body); + const auto settings = ui_models(c, c.field(root.model, 0x43967ece), setting_schema.hash); + for (const auto setting : settings) { + const auto label = c.field(setting, 0x58c9d354); + const auto presentation = c.field(setting, content); + auto row_id = [&]() -> std::string { + if (const auto known = s.slider_rows.find(presentation.handle); known != s.slider_rows.end()) return known->second; + const auto role = setup_role(c.text(c.field(label, text_field))); + return role == SetupRole::players ? "players" : role == SetupRole::timer ? "timer" : ""; + }(); + if (row_id.empty()) continue; + s.slider_rows[presentation.handle] = row_id; + int minimum{}, maximum{}, step{1}, fallback{}; + std::string caption; + if (row_id == "players") { + minimum = mode->min_players; maximum = mode->max_players; fallback = mode->players; caption = "Players"; + } else if (row_id == "timer") { + if (!mode->timer) continue; + minimum = mode->timer->minimum; maximum = mode->timer->maximum; step = mode->timer->step; + fallback = mode->timer->value; caption = mode->timer->caption; + } else { + const auto row = std::find_if(mode->rows.begin(), mode->rows.end(), [&](const auto& r) { return r.id == row_id; }); + if (row == mode->rows.end() || row->kind != custom_throwdowns::Row::Kind::slider) continue; + minimum = row->minimum; maximum = row->maximum; step = row->step; fallback = row->value; caption = row->caption; + } + plain_label(c, label, caption); + auto slider = reference(c, read(c.address(presentation)).data); + if (!slider.handle || read(read
(slider.type)) != slider_hash) continue; + const bool first = !s.official_sliders.contains(presentation.handle); + if (first) { + // The native widget still handles input, focus and drawing; + // the shared Spot Battle setting never sees the mode's values. + clear_value(c, c.field(slider, 2243478502U)); + clear_value(c, c.field(slider, 655725074U)); + s.official_sliders.emplace(presentation.handle, slider); + } else { + const auto saved = s.official_sliders.at(presentation.handle); + if (slider.handle != saved.handle) { + auto mounted = read(c.address(presentation)); + mounted.data = {0, saved.handle}; + c.set(presentation, mounted); + } + slider = saved; + } + const auto range = c.field(slider, 0x170bebf7), value = c.field(slider, 0x5a547e87), increment = c.field(slider, 0xb47e6cec); + c.set_if_changed(range, std::array{static_cast(minimum), static_cast(maximum)}); + c.set_if_changed(increment, static_cast(step)); + c.set_if_changed(c.field(slider, 264787447U), true); // TrimWholeValues + c.set_if_changed(c.field(slider, 3516482810U), true); // RoundToNearestWhole + const auto stored = values.find(row_id); + if (first) c.set(value, native_slider_position(stored == values.end() ? fallback : stored->second, minimum, maximum)); + const auto normalized = read(c.address(value)); + const auto selected = native_slider_choice(normalized, minimum, maximum, step); + const auto snapped = native_slider_position(selected, minimum, maximum); + if (!std::isfinite(normalized) || std::abs(normalized - snapped) > .00001f) c.set(value, snapped); + custom_throwdowns::set_setting(key, row_id, selected); + } + for (const auto notice : ui_models(c, c.field(root.model, 0x43967ece), notice_schema.hash)) + plain_label(c, c.field(notice, 0x94703efc), mode->description); + // Keep the native Confirm and its location initializer: placement + // takes over once it completes. Advanced Parameters is Back. + for (const auto b : ui_models(c, c.field(root.model, 0xbfc64535), button.hash)) { + const auto action = c.path(b, {0x0fb0d794, 0xa704272a, 0xc52416ef, 0xa704272a, 0xa2b71c93}); + if (c.text(c.field(action, 0xb1e14cd6)) != "AdvancedParameters") continue; + c.text(c.field(action, 0xb1e14cd6), "ThrowdownerExit"); + plain_label(c, c.field(b, 0x58c9d354), "BACK"); + } + } catch (const std::exception& e) { + static std::uint64_t report{}; + const auto now = GetTickCount64(); + if (now >= report) { + report = now + 10000; + logging::log(logging::Level::warning, logging::Channel::ui, "Throwdown setup {:#x}: {}", root.model.handle, e.what()); + } + } + } +} + +// ---- generations --------------------------------------------------------------------------------- +void initialize(const Context& c, Value p, Value list) { + auto& s = state(); + s.page = p; + s.cards = list; + s.owner.store(p.handle); + unsigned count{}, stride{}; + auto bytes = c.array(c.field(list, rows_field), 3, count, stride); + std::vector refs(count); + std::memcpy(refs.data(), bytes.data(), bytes.size()); + for (const auto& ref : refs) { + auto tile = reference(c, ref), cat = category(c, tile), desc = description(c, cat); + s.originals.push_back({tile, cat, desc, read>(c.address(c.field(tile, 0x868043e1))), + read>(c.address(c.field(desc, 0x1cff7243))), + read(c.address(c.field(cat, 0x495ffd43))), read(c.address(c.field(cat, 0x24fb5ca8)))}); + } + const auto& donor = s.originals[1]; + s.anchor_asset = read(c.address(c.path(donor.tile, {widget, widget}))).blueprint; + const auto donor_label = reference(c, read(c.address(c.field(donor.description, widget))).data); + require(donor_label.type && size(donor_label.type) == label_schema.size, "Throwdown description label unavailable."); + s.source_label = make(c, label_schema, donor_label.type); + c.copy(s.source_label, c.address(donor_label)); + s.original_body = make(c, presenter, mounted_body(c, p)->type); + c.copy(s.original_body, c.address(*mounted_body(c, p))); + s.generations.emplace(p.handle, list.handle); + logging::write(logging::Level::info, logging::Channel::ui, "Throwdowns: plugin modes registered on the native page."); +} +void restore_page(const Context& c) { + auto& s = state(); + if (const auto slot = mounted_body(c, s.page); slot && s.original_body.handle) { + const auto mounted = read(c.address(*slot)).data.handle; + if (mounted == s.picker_anchor.handle && mounted) c.copy(*slot, c.address(s.original_body)); + } + if (s.cards.handle && c.type_of(s.cards.handle) == s.cards.type) { + unsigned count{}, stride{}; + const auto bytes = c.array(c.field(s.cards, rows_field), 64, count, stride); + require(stride == sizeof(Ref), "Throwdown card list changed during cleanup."); + std::vector retained; + for (unsigned i = 0; i < count; ++i) { + Ref ref{}; + std::memcpy(&ref, bytes.data() + i * stride, stride); + if (std::none_of(s.mode_cards.begin(), s.mode_cards.end(), [&](const auto& entry) { return entry.second.tile.handle == ref.handle; })) + retained.push_back(ref); + } + if (retained.size() != count) c.array(c.field(s.cards, rows_field), retained); + } + for (const auto& o : s.originals) { + if (c.type_of(o.tile.handle) == o.tile.type) c.set_if_changed(c.field(o.tile, 0x868043e1), o.tile_size); + if (c.type_of(o.category.handle) == o.category.type) { + c.set_if_changed(c.field(o.category, 0x495ffd43), o.icon_width); + c.set_if_changed(c.field(o.category, 0x24fb5ca8), o.icon_height); + } + if (c.type_of(o.description.handle) == o.description.type) c.set_if_changed(c.field(o.description, 0x1cff7243), o.description_size); + } +} +void forget_generation() { + auto& s = state(); + s.page = s.cards = s.source_label = s.original_body = {}; + s.picker_grid = s.picker_anchor = {}; + s.picker_stock = s.picker_stock_logos = {}; + s.mode_cards.clear(); + s.originals.clear(); + s.grid_failed = s.cards_scaled = false; + s.anchor_asset = 0; + s.missing_art.clear(); +} +// A picker generation is not the models' lifetime: native cached pages can +// still reference them. Put the stock page back and keep every model until the +// level or the model manager goes (release). +void retire(const Context& c) { + auto& s = state(); + s.owner.store(0); + try { + if (s.page.handle && c.type_of(s.page.handle) == s.page.type) restore_page(c); + } catch (const std::exception& e) { + logging::log(logging::Level::warning, logging::Channel::ui, "Throwdown picker retirement: {}", e.what()); + } + forget_generation(); +} +void release(const Context& c) { + auto& s = state(); + s.owner.store(0); + { std::lock_guard lock(s.mutex); s.pending.clear(); } + s.owned.release([&] { + restore_official(c); + restore_page(c); + }, [&](Value value) { c.destroy(value); }); + forget_generation(); + restore_official(c); + s.assets.clear(); +} +void forget_manager() { + auto& s = state(); + s.owned.manager_replaced(); + forget_generation(); + s.official_originals.clear(); + s.official_panels.clear(); + s.official_headers.clear(); + s.official_contents.clear(); + s.official_slot_panels.clear(); + s.official_sliders.clear(); + s.official_setup_rows.clear(); + s.official_badges.clear(); + s.slider_rows.clear(); + s.official_mode.clear(); + s.assets.clear(); + s.generations.clear(); + s.owner.store(0); +} +void run_actions(const Context& c) { + auto& s = state(); + std::vector pending; + { + std::lock_guard lock(s.mutex); + pending = std::exchange(s.pending, {}); + } + for (const auto& a : pending) { + if (a.generation != s.owner.load()) continue; + if (a.command == "mode-open") { + // The card's own navigation is cleared: the placement tick sends + // Spot Battle's setup once any earlier flag of ours is gone. + custom_throwdowns::retire_unconfirmed(); + if (!custom_throwdowns::open_setup(a.argument)) + logging::log(logging::Level::info, logging::Channel::ui, "Throwdowns: {} cannot open while another mode's flag is up.", a.argument); + } else if (a.command == "mode-button") { + const auto bar = a.argument.find('|'); + if (bar == std::string::npos || custom_throwdowns::setup_mode() != a.argument.substr(0, bar)) continue; + custom_throwdowns::News news; + news.action = "button"; + news.row = a.argument.substr(bar + 1); + custom_throwdowns::post(a.argument.substr(0, bar), std::move(news)); + } + } + (void)c; +} +} // namespace + +bool release_native_throwdown_menu(std::uintptr_t base) noexcept { + auto& s = state(); + if (s.owned.empty()) { forget_generation(); s.generations.clear(); s.owner.store(0); return true; } + try { + const auto ui = read
(base + engine::ui_manager); + const auto manager = ui ? read
(ui + 0x140) : 0; + require(manager != 0, "Throwdown menu manager unavailable during cleanup."); + if (manager != s.manager) { forget_manager(); return true; } + const Context c(base, manager); + game::ModelWriteLock lock(manager); + release(c); + s.generations.clear(); + return true; + } catch (const std::exception& e) { + logging::log(logging::Level::warning, logging::Channel::ui, "Throwdown menu cleanup: {}", e.what()); + return false; + } +} + +void tick_native_throwdown_menu(std::uintptr_t base, bool loading) noexcept { + auto& s = state(); + if (loading || !base || !game::native_data().models.create) return; + const auto now = GetTickCount64(); + if (now < s.next_tick && !custom_throwdowns::placement_active()) return; + s.next_tick = now + 200; + try { + // The logo grid is the Throwdowns page's menu whether or not a plugin + // has a mode: every pass looks for a fresh page generation. + const auto modes = custom_throwdowns::modes(); + const auto ui = read
(base + engine::ui_manager); + const auto manager = ui ? read
(ui + 0x140) : 0; + if (!manager) return; + const Context c(base, manager); + game::ModelWriteLock lock(manager); + if (s.manager && s.manager != manager) forget_manager(); + s.manager = manager; + s.base = base; + custom_throwdowns::tick(c); + run_actions(c); + ++s.pass; + if (s.page.handle && c.type_of(s.page.handle) != s.page.type) retire(c); + // The game makes new page and list generations when Throwdowns + // reopens: find the fresh one rather than staying on a retired page. + for (const auto& p : c.roots({page.hash})) + if (const auto list = card_list(c, p.model)) { + if (s.page.handle && (p.model.handle != s.page.handle || list->handle != s.cards.handle) && + !s.generations.contains({p.model.handle, list->handle})) retire(c); + if (!s.page.handle && !s.generations.contains({p.model.handle, list->handle})) { + initialize(c, p.model, *list); + break; + } + } + if (s.page.handle && mounted_body(c, s.page)) { + mode_cards(c, modes); + bool grid = false; + if (!s.grid_failed) try { + logo_picker(c, modes); + grid = true; + } catch (const std::exception& e) { + s.grid_failed = true; + logging::log(logging::Level::warning, logging::Channel::ui, + "Throwdowns: the logo grid is unavailable ({}); the modes' cards follow the stock ones.", e.what()); + } + if (!grid) append_cards(c, modes); + } + official_setup(c); + } catch (const std::exception& e) { + logging::log(logging::Level::warning, logging::Channel::ui, "Throwdown menu: {}", e.what()); + s.next_tick = now + 5000; + release_native_throwdown_menu(base); + } +} +} // namespace dingosdk::multiplayer diff --git a/Extension/UI/NativeMenu/throwdown_menu.h b/Extension/UI/NativeMenu/throwdown_menu.h new file mode 100644 index 00000000..3288b730 --- /dev/null +++ b/Extension/UI/NativeMenu/throwdown_menu.h @@ -0,0 +1,13 @@ +#pragma once +#include + +// The game's own Throwdowns page as a logo grid (SmokeyDawg58's picker, whose +// assets ReSkate carries): the stock throwdowns, then the plugins' modes +// (custom_modes.h), and the Spot Battle setup page relabelled for the one +// being set up. +namespace dingosdk::multiplayer { +// Client UI thread, from the native menu tick. +void tick_native_throwdown_menu(std::uintptr_t base, bool loading) noexcept; +// Before a level unloads: false if the page could not be restored. +bool release_native_throwdown_menu(std::uintptr_t base) noexcept; +} diff --git a/assets/builtin/throwdown_menu.zip b/assets/builtin/throwdown_menu.zip new file mode 100644 index 00000000..5c0629f2 Binary files /dev/null and b/assets/builtin/throwdown_menu.zip differ diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index f77cf452..ea7988e6 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -140,6 +140,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) add_executable(dingosdk_mod_mesh_check Engine/Vfs/Test/mod_mesh_check.cpp) target_link_libraries(dingosdk_mod_mesh_check PRIVATE dingosdk_mods dingosdk_frostbite dingosdk_game_archives) target_include_directories(dingosdk_mod_mesh_check PRIVATE "${PROJECT_SOURCE_DIR}") + # A tool: an asset list edit cut down to some of its records (Engine/Vfs/Test/ebx_pick.cpp). + add_executable(dingosdk_ebx_pick Engine/Vfs/Test/ebx_pick.cpp) + target_link_libraries(dingosdk_ebx_pick PRIVATE dingosdk_frostbite) + target_include_directories(dingosdk_ebx_pick PRIVATE "${PROJECT_SOURCE_DIR}") endif() if(WIN32) diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index d57d10f7..f3e4c2bc 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -23,6 +23,7 @@ add_library(dingosdk_runtime SHARED Extension/Multiplayer/Session/session_party.cpp Extension/Multiplayer/Session/party_book.cpp Extension/UI/NativeMenu/native_menu.cpp + Extension/UI/NativeMenu/throwdown_menu.cpp Extension/UI/NativeMenu/native_menu_rows.cpp Extension/UI/NativeMenu/native_menu_multiplayer.cpp Extension/UI/NativeMenu/ui_sound.cpp @@ -47,6 +48,8 @@ add_library(dingosdk_runtime SHARED Extension/Throwdowns/native_throwdowns.cpp Extension/Throwdowns/native_type_scan.cpp Extension/Throwdowns/throwdown_lab.cpp + Extension/Throwdowns/custom_modes.cpp + Extension/Throwdowns/custom_placement.cpp Extension/Throwdowns/throwdown_relay.cpp Extension/Throwdowns/throwdown_debug_text.cpp Extension/Throwdowns/skate_trick_rule.cpp @@ -327,15 +330,15 @@ if(EXISTS "${dingosdk_emotes}/emotes.json" AND EXISTS "${dingosdk_emotes}/emotes target_sources(dingosdk_runtime PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated/emotes.rc") endif() # Game content ReSkate carries itself (Engine/Vfs/builtin_packs.h), one zip each in assets/builtin: -# the FPV drone's model (tools/models/build_drone_pack.py), a blueprint plugins may place. A missing -# zip is left out. (Mods that are content for ReSkate's features, such +# the Throwdowns page's logo grid (tools/builtin/build_throwdown_menu_pack.py). A missing zip is +# left out. (Mods that are content for ReSkate's features, such # as Board Break's decks, are official mods the launcher downloads: Launcher/official_mods.h.) # Each zip is made into a zstd pack by ReSkateBuiltinPacker as the build goes (again only when the # zip changes), which is what the DLL carries: compressed whole, a pack's repeated files cost # next to nothing. set(dingosdk_builtin_rc "") set(dingosdk_builtin_packs "") -foreach(pack IN ITEMS "FPV_DRONE fpv_drone") +foreach(pack IN ITEMS "THROWDOWN_MENU throwdown_menu") separate_arguments(pack) list(GET pack 0 pack_resource) list(GET pack 1 pack_file) diff --git a/contrib/plugins/throwdown-modes-test/manifest.json b/contrib/plugins/throwdown-modes-test/manifest.json new file mode 100644 index 00000000..376bf30d --- /dev/null +++ b/contrib/plugins/throwdown-modes-test/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Throwdown_Modes_Test", + "version_number": "0.1.0", + "website_url": "https://github.com/Dingo-Shenanigans/ReSkate", + "description": "Two test modes on the Throwdowns page: pick one, set it up, place its flag and press Start. Says in chat what the plugin hears.", + "dependencies": [] +} diff --git a/contrib/plugins/throwdown-modes-test/plugin/main.lua b/contrib/plugins/throwdown-modes-test/plugin/main.lua new file mode 100644 index 00000000..ddacf15c --- /dev/null +++ b/contrib/plugins/throwdown-modes-test/plugin/main.lua @@ -0,0 +1,64 @@ +-- Throwdown modes test: two modes on the game's Throwdowns page, through the whole native flow +-- (card, Spot Battle's setup page with the mode's rows, the start flag, the waiting card). It runs +-- no match: it says in chat what it hears, and takes the flag down when the host presses Start. +-- Their logos are textures from SmokeyDawg58's ReSkate_Throwdown assets, by path; without that mod +-- they show Spot Battle's art. Both use Spot Battle's own flag (a mode's `flag` names its mod's own, +-- which only works with that mod installed). +local function say(text) + reskate.chat.notice(text) + reskate.log(text) +end + +local function register() + assert(reskate.throwdowns.mode{ + id = "oneup", name = "1-UP", art = "UI/ReSkate/OneUp/img_OneUp_Blue_1024", + description = "Beat the previous turn total. Three penalties and you're out. Last skater wins!", + players = {min = 1, max = 6, default = 2}, + timer = {caption = "Turn", min = 10, max = 120, step = 10, default = 20}, + }) + assert(reskate.throwdowns.mode{ + id = "race", name = "Deathrace", art = "UI/ReSkate/Throwdowns/img_race_placeholder", + description = "Race through the gates. First to the finish wins.", + players = {min = 1, max = 8, default = 4}, + timer = {caption = "Finish deadline", min = 30, max = 600, step = 30, default = 180}, + rows = { + {id = "laps", kind = "choice", caption = "Laps", options = {"1", "2", "3"}, default = 1}, + {id = "boost", caption = "Boost", min = 0, max = 5, default = 2}, + {id = "route", kind = "button", caption = "Route", text = "Place gates"}, + }, + }) +end +register() + +local last = "nothing yet" +reskate.on("throwdown_mode", function(e) + local settings = {} + for row, value in pairs(e.settings) do settings[#settings + 1] = row .. "=" .. tostring(value) end + table.sort(settings) + local line = e.mode .. ": " .. e.action + if e.row then line = line .. " " .. e.row .. "=" .. tostring(e.value) end + if e.position then + line = line .. string.format(" at %.1f %.1f %.1f facing %.0f", e.position[1], e.position[2], e.position[3], e.heading) + end + line = line .. " (" .. table.concat(settings, ", ") .. ")" + last = line + say(line) + if e.action == "start" then + -- A real mode would start its match here. + reskate.throwdowns.release_flag() + say(e.mode .. ": the match would start now") + end +end) + +reskate.console.command("tdmodes", "Throwdown modes test: what it heard last (tdmodes off takes the modes away, tdmodes on puts them back)", function(args) + if args[1] == "off" then + reskate.throwdowns.remove_mode("oneup") + reskate.throwdowns.remove_mode("race") + reskate.console.print("tdmodes: removed") + elseif args[1] == "on" then + register() + reskate.console.print("tdmodes: registered") + else + reskate.console.print("tdmodes: " .. last) + end +end) diff --git a/contrib/plugins/throwdown-modes-test/reskate-plugin.json b/contrib/plugins/throwdown-modes-test/reskate-plugin.json new file mode 100644 index 00000000..c701e2a9 --- /dev/null +++ b/contrib/plugins/throwdown-modes-test/reskate-plugin.json @@ -0,0 +1,6 @@ +{ + "api": 1, + "entry": "plugin/main.lua", + "side": "client", + "permissions": ["throwdowns", "chat.local", "console"] +}