diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..05974b3c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,70 @@ +name: Bug report +description: Something in ReSkate doesn't work, or the game crashes. +body: + - type: markdown + attributes: + value: | + Antivirus warnings, the menu not opening and crashes from mods are answered in the README's + [Troubleshooting](https://github.com/Dingo-Shenanigans/ReSkate#troubleshooting) section. Please check it first. + - type: input + id: version + attributes: + label: ReSkate version + description: Shown in the launcher, and in the release's file name (ReSkate-.zip). + placeholder: "2.1.1" + validations: + required: true + - type: dropdown + id: system + attributes: + label: System + options: + - Windows 11 + - Windows 10 + - Linux (Proton) + - Steam Deck + - macOS (CrossOver or another Wine tool) + validations: + required: true + - type: input + id: system-detail + attributes: + label: Proton or Wine version + description: Only on Linux, Steam Deck or macOS. + placeholder: "Proton Experimental" + - type: dropdown + id: started + attributes: + label: How was the game started? + options: + - PLAY in the ReSkate launcher + - Play in Steam, with the launch option from the README + - Play in Steam, without a launch option + - Something else (say below) + validations: + required: true + - type: dropdown + id: mods + attributes: + label: Mods + description: Does it still happen with every mod turned off? + options: + - It happens with every mod off + - It only happens with mods on + - I haven't tried with mods off + validations: + required: true + - type: textarea + id: what + attributes: + label: What happened + description: What you did, what you expected and what happened instead. For a crash, say when it happened (the date and time, or as near as you know), so it can be found among the crash reports. + validations: + required: true + - type: textarea + id: log + attributes: + label: Log + description: | + Drag `logs\ReSkate.log` (beside `Skate.exe` in your ReSkate folder) into this box. + Its file paths can show your Windows user name. diff --git a/CMakeLists.txt b/CMakeLists.txt index 0479c577..693fe94a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -442,16 +442,23 @@ if(DINGOSDK_BUILD_CAPTURE_TESTS AND TARGET dingosdk_capture) add_test(NAME capture COMMAND dingosdk_capture_tests "${PROJECT_SOURCE_DIR}/External/fonts") endif() option(DINGOSDK_BUILD_PLUGIN_TESTS "Build plugin host regression tests" OFF) -if(DINGOSDK_BUILD_PLUGIN_TESTS AND WIN32) +if(DINGOSDK_BUILD_PLUGIN_TESTS) enable_testing() # The plugin host apart from the game: manifests, the reskate API, storage, modules, hot reload, - # and the sandbox against plugins written to break it. + # and the sandbox against plugins written to break it. These build on Linux too, so they can be + # run without Windows. add_executable(dingosdk_plugin_tests Extension/Plugins/Test/plugin_tests.cpp) target_include_directories(dingosdk_plugin_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_plugin_tests PRIVATE dingosdk_plugins) + if(NOT WIN32) + # The Linux plugins library leaves out finding plugins among the mods; the tests need it. + target_sources(dingosdk_plugin_tests PRIVATE Extension/Plugins/plugin_discovery.cpp Engine/Vfs/mod_list.cpp) + endif() # It also runs the test plugins in contrib/plugins. target_compile_definitions(dingosdk_plugin_tests PRIVATE "RESKATE_SOURCE_DIR=\"${PROJECT_SOURCE_DIR}\"") add_test(NAME plugins COMMAND dingosdk_plugin_tests) +endif() +if(DINGOSDK_BUILD_PLUGIN_TESTS AND WIN32) # Tricks read from the score HUD's values (Extension/Skater/trick_watch.h), what plugins hear. add_executable(dingosdk_trick_watch_tests Extension/Skater/Test/trick_watch_tests.cpp Extension/Skater/trick_watch.cpp) target_include_directories(dingosdk_trick_watch_tests PRIVATE "${PROJECT_SOURCE_DIR}") diff --git a/Engine/Game/Build/20260929/physics_wake.h b/Engine/Game/Build/20260929/physics_wake.h index 66969ede..41b0acbe 100644 --- a/Engine/Game/Build/20260929/physics_wake.h +++ b/Engine/Game/Build/20260929/physics_wake.h @@ -18,6 +18,9 @@ // later free crashes in the allocator (Skate.exe+0x3d99e2f, +0x3d9ee43, +0x3d9ee47; indices // 20199, 20203 and 20215 in three dumps, the next block's size off by exactly that bit; // 2026-10-21). +// The owner count is past 20000 in every session because ReSkate raises the owner pool to 160000 +// (Extension/World/physics_world_size.cpp) and the generation list is sized to the pool; the setting +// is written only by the system's init (0x3d2fe20) and read only by the wake pass (2026-10-24). namespace dingosdk::game::build::v20260929::physics_wake { inline constexpr Fingerprint wake{0x3d2f780, { 0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x7c,0x24,0x10,0x55,0x48,0x8d,0x6c,0x24,0xa9, diff --git a/Engine/Game/Build/20260929/texture_handles.h b/Engine/Game/Build/20260929/texture_handles.h index f07aaed2..25af837c 100644 --- a/Engine/Game/Build/20260929/texture_handles.h +++ b/Engine/Game/Build/20260929/texture_handles.h @@ -1,5 +1,6 @@ #pragma once #include "Engine/Game/Build/fingerprint.h" +#include #include // The renderer's texture handles (Extension/World/texture_handle_guard.cpp). @@ -16,10 +17,34 @@ // that: an entry with no texture and no redirect. Both callers then read through null // (Skate.exe+0x3e897fb, a third of 2.1.1's crash reports, every one with TheVlixOne-Nike_Pack // 2.3.x installed; 2026-10-21). +// +// The "default for the id" (0x3e89740) is the entry itself, so resolve then answers the entry's +// own texture at + 0, which for a dead entry is null too: with an empty holder from redirect, +// resolve gives null instead of crashing. Its caller for a material's texture parameter (0x3f86270, +// slot 17 of the table at 0x64ff6e8) passes that through 0x3e61260 to the texture view cache +// (0x3e6dbb0, keyed by the texture at DAT 0x76e9548 + 0xb10). On a miss the cache builds the view +// state without the texture (0x469a000 checks it for null and makes the same empty view the game +// makes for an empty slot), then, under its write lock from 0x3e6dcf1, asks the texture for its key +// (texture vtbl + 0xf8) to keep the state: through null (Skate.exe+0x3e6ddc1, every report with the +// guard's line just before it and TheVlixOne-Nike_Pack installed; 2026-10-24). For a null texture +// the cache insert is skipped (`view_cache_insert`): no lock is held there, rsi is saved on every +// path to it, and no branch lands inside its two instructions. namespace dingosdk::game::build::v20260929::texture_handles { inline constexpr std::uintptr_t textures = 0x76e0010; // redirect(pool, id) -> the texture holder of the entry `id` points at, null when it points at none. inline constexpr Fingerprint redirect{0x3e9b770, { 0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x44, 0x0f,0xb7,0xc2,0x4c,0x8d,0x89,0xb8,0x00,0x00,0x00,0x41,0x83,0xe8,0x02,0x48,0x8b}}; +// In the view cache (0x3e6dbb0), the texture in r15, the built state in r14: +// mov rbx, [rip + 0x0387b850] ; DAT 0x76e9548, the cache +// add rbx, 0xae8 ; its write lock +// the two complete instructions moved into the stub, which goes on at `view_cache_resume`, or for a +// null texture to `view_cache_return` (mov rsi, [rsp + 0xe8]; mov rax, r14; the cookie check; ret). +inline constexpr std::uintptr_t view_cache_insert_rva = 0x3e6dcf1; +inline constexpr std::array view_cache_insert{ + 0x48,0x8b,0x1d,0x50,0xb8,0x87,0x03,0x48,0x81,0xc3,0xe8,0x0a,0x00,0x00}; +inline constexpr std::uintptr_t view_cache_resume_rva = 0x3e6dcff; +inline constexpr std::uintptr_t view_cache_return_rva = 0x3e6df03; +inline constexpr std::uintptr_t view_cache = 0x76e9548; +inline constexpr std::uint32_t view_cache_lock_offset = 0xae8; } diff --git a/Engine/Vfs/Test/thunderstore_package_tests.cpp b/Engine/Vfs/Test/thunderstore_package_tests.cpp index c5081d46..9ef32999 100644 --- a/Engine/Vfs/Test/thunderstore_package_tests.cpp +++ b/Engine/Vfs/Test/thunderstore_package_tests.cpp @@ -78,6 +78,18 @@ int main() { refused(marked, "a package marked NSFW is refused"); } refused("{\"namespace\":\"Sandos\",\"name\":\"Vancouver_Plaza\"}", "an answer with no listing is refused"); + // A dedicated server's required plugin: taken only when it is listed as a plugin. + { + const auto plugin = answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Plugins", "unreviewed"); + check(package::choose(plugin, newest, "reskate", package::Kind::plugin).ok, "a plugin listed as one is refused"); + check(!package::choose(plugin, newest, "reskate").ok, "a plugin is taken as a map"); + const auto cosmetic = package::choose(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Cosmetics", "unreviewed"), + newest, "reskate", package::Kind::plugin); + check(!cosmetic.ok && cosmetic.reason.find("plugin") != std::string::npos, "a cosmetic is taken as a plugin"); + check(!package::choose(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Maps", "unreviewed"), newest, "reskate", + package::Kind::plugin).ok, + "a map is taken as a plugin"); + } refused("not json", "an answer that is not JSON is refused"); refused("[]", "an answer that is not an object is refused"); } diff --git a/Engine/Vfs/content_catalogs.cpp b/Engine/Vfs/content_catalogs.cpp index 6623b50e..eb57e8f5 100644 --- a/Engine/Vfs/content_catalogs.cpp +++ b/Engine/Vfs/content_catalogs.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace dingosdk::content_cache { namespace { @@ -302,8 +303,112 @@ bool Catalogs::reserved(const std::string& key) const { return !key.starts_with("dev_") && key.find("_dev_") == std::string::npos && !key.ends_with("_dev"); } -const Catalogs& catalogs() { - static const Catalogs value = [] { +namespace { +// reskate-objects.json text: at most 255 bytes, no control characters. Anything else is absent. +std::optional mod_text(const Json& entry, std::string_view field) { + if (!entry.contains(field) || !entry.at(field).is_string()) return std::nullopt; + const auto& value = entry.at(field).string(); + if (value.empty() || value.size() > 255 || std::any_of(value.begin(), value.end(), [](char c) { + const auto byte = static_cast(c); + return byte < 0x20 || byte == 0x7f; + })) return std::nullopt; + return value; +} +// The entry's priority, or `after` (one past the highest already there) when it gives none. +std::uint32_t mod_priority(const Json& entry, std::uint32_t after) { + if (entry.contains("priority") && entry.at("priority").is_number_integer()) { + const auto value = entry.at("priority").get(); + if (value >= 0 && value <= 0xffffffffLL) return static_cast(value); + } + return after; +} +const Json& mod_list(const Json& root, std::string_view name) { + static const Json none = Json::array(); + if (!root.contains(name)) return none; + if (!root.at(name).is_array()) throw std::runtime_error(std::string(name) + " is not a list"); + return root.at(name); +} +ObjectCategory* find_category(Catalogs& out, std::string_view id) { + for (auto& category : out.object_categories) + if (category.id == id) return &category; + return nullptr; +} +bool family_exists(const Catalogs& out, std::string_view id) { + return std::ranges::any_of(out.object_categories, [&](const ObjectCategory& category) { + return std::ranges::any_of(category.groups, [&](const ObjectGroup& group) { return group.id == id; }); + }); +} +} + +std::vector add_mod_objects(Catalogs& out, const std::string& json) { + const auto root = Json::parse(json); + if (!root.is_object() || !root.contains("schema") || !root.at("schema").is_number_integer() || + root.at("schema").get() != 1) + throw std::runtime_error("expected schema 1"); + // Checked before anything is added, so a file that is not this shape adds nothing. + const auto& categories = mod_list(root, "categories"); + const auto& families = mod_list(root, "families"); + const auto& objects = mod_list(root, "objects"); + std::vector refused; + for (std::size_t i = 0; i < categories.size() && i < 64; ++i) { + const auto& entry = categories.at(i); + const auto id = mod_text(entry, "id"); + if (!id) { refused.push_back("a category without an id was left out"); continue; } + if (find_category(out, *id)) { refused.push_back("category " + *id + " was left out: the id is taken"); continue; } + std::uint32_t last{}; + for (const auto& category : out.object_categories) last = std::max(last, category.priority); + // In the Object Browser, as the game's categories tagged "qdbuildkit" are. No icon: the + // browser draws its first tile's thumbnail. + out.object_categories.push_back({*id, mod_text(entry, "title").value_or(*id), {}, + mod_priority(entry, last + 1), true, {}}); + } + for (std::size_t i = 0; i < families.size() && i < 1024; ++i) { + const auto& entry = families.at(i); + const auto id = mod_text(entry, "id"); + const auto category_id = mod_text(entry, "category"); + if (!id || !category_id) { refused.push_back("a family without an id and a category was left out"); continue; } + if (family_exists(out, *id)) { refused.push_back("family " + *id + " was left out: the id is taken"); continue; } + auto* category = find_category(out, *category_id); + if (!category) { + refused.push_back("family " + *id + " was left out: there is no category " + *category_id); + continue; + } + std::uint32_t last{}; + for (const auto& group : category->groups) last = std::max(last, group.priority); + category->groups.push_back({*id, mod_text(entry, "title").value_or(*id), mod_priority(entry, last + 1)}); + } + for (std::size_t i = 0; i < objects.size() && i < 8192; ++i) { + const auto& entry = objects.at(i); + const auto key = mod_text(entry, "key"); + if (!key || lower(key->substr(0, 4)) != "own_") { + refused.push_back("an object without an own_ key was left out"); + continue; + } + const auto id = lower(*key); + // The game's own items keep their names and families, and the first mod to name an object wins. + if (out.items.contains(id)) { refused.push_back("object " + id + " was left out: it is the game's"); continue; } + if (out.mod_objects.contains(id)) { refused.push_back("object " + id + " was left out: it is already named"); continue; } + Json object = Json::object(); + if (const auto family = mod_text(entry, "family")) { + if (!family_exists(out, *family)) { + refused.push_back("object " + id + " was left out: there is no family " + *family); + continue; + } + object["group"] = *family; + // What the game's own Quick Drop records give as their BuildKitObjectType. + object["object_type"] = std::string("QuickDrop"); + } + if (const auto title = mod_text(entry, "title")) object["title"] = *title; + if (const auto description = mod_text(entry, "description")) object["description"] = *description; + if (!object.size()) { refused.push_back("object " + id + " was left out: it has no title or family"); continue; } + out.mod_objects[id] = std::move(object); + } + return refused; +} + +namespace { +Catalogs& process_catalogs() { + static Catalogs value = [] { try { if (installed()) return read_catalogs(directory()); } catch (...) {} @@ -312,3 +417,27 @@ const Catalogs& catalogs() { return value; } } + +const Catalogs& catalogs() { return process_catalogs(); } + +ModObjectsReport install_mod_objects(const std::vector>& files) { + ModObjectsReport report; + auto& out = process_catalogs(); + for (const auto& [mod, path] : files) { + std::error_code error; + if (!std::filesystem::is_regular_file(path, error)) continue; + try { + if (std::filesystem::file_size(path) > JsonLimits{}.bytes) throw std::runtime_error("it is too large"); + const auto bytes = read_file(path); + const auto before = out.mod_objects.size(); + for (auto& note : add_mod_objects(out, std::string(bytes.begin(), bytes.end()))) + report.notes.push_back(mod + ": " + note); + report.objects += out.mod_objects.size() - before; + ++report.mods; + } catch (const std::exception& failure) { + report.notes.push_back(mod + ": " + mod_objects_file + " was skipped: " + failure.what()); + } + } + return report; +} +} diff --git a/Engine/Vfs/content_catalogs.h b/Engine/Vfs/content_catalogs.h index c40afb57..8f065628 100644 --- a/Engine/Vfs/content_catalogs.h +++ b/Engine/Vfs/content_catalogs.h @@ -55,9 +55,36 @@ struct Catalogs { // Song ("Artist - Title") -> its cdn:/ cover art: a song record carries a field-10 message // (10.10 artist, 10.11 title, 10.12 artwork). std::map> music_song_artwork; + // Objects mods add, as their reskate-objects.json describes them (add_mod_objects): lower-case + // owned asset id -> {"title", "description", "group", "object_type"}, read like `items`. Kept + // apart from `items`, which stays the game's own catalogue (store checks, the mod badge). + Json mod_objects = Json::object(); bool reserved(const std::string& key) const; }; // Read once, on first use. Empty (available == false) when no pack is installed. const Catalogs& catalogs(); Catalogs read_catalogs(const std::filesystem::path& folder); + +// A mod's names and Quick Drop families for the objects it adds, in its folder: +// {"schema": 1, +// "categories": [{"id": "category_mine", "title": "My Objects", "priority": 900}], +// "families": [{"id": "qdparent_mine_box", "title": "Box", "category": "category_mine"}], +// "objects": [{"key": "own_bkprops_mine_box_00001", "title": "Box, Red", "family": "qdparent_mine_box"}]} +// A family is one Object Browser tile: its objects are the choices X / Square offers there, as with +// the game's own families. A family may go in one of the game's categories, and an object in one of +// the game's families. Every list and field but the ids and keys is optional; an entry without a +// priority goes after the ones already there. +inline constexpr char mod_objects_file[] = "reskate-objects.json"; +// Adds one mod's reskate-objects.json to `out`. Only adds: a category or family id that is already +// taken, or an object the game's catalogue (or a mod read earlier) already has, is refused, so a +// mod can name and group its own objects but never the game's. Returns what was refused, a line +// each. Throws std::exception on a document that is not this shape, so the caller can skip it. +std::vector add_mod_objects(Catalogs& out, const std::string& json); +// The process-wide catalogues plus each mod's file (mod name, path), highest priority first. +// Called once while the runtime starts, before the game runs. Missing files are skipped. +struct ModObjectsReport { + std::size_t mods{}, objects{}; + std::vector notes; // a line each for every file or entry left out +}; +ModObjectsReport install_mod_objects(const std::vector>& files); } diff --git a/Engine/Vfs/mod_list.cpp b/Engine/Vfs/mod_list.cpp index 57135953..1c103ed9 100644 --- a/Engine/Vfs/mod_list.cpp +++ b/Engine/Vfs/mod_list.cpp @@ -4,7 +4,9 @@ #include "Engine/Core/Json/json.h" #include "Engine/Core/Platform/path_text.h" +#ifdef _WIN32 #include +#endif #include #include @@ -20,6 +22,8 @@ namespace fs = std::filesystem; constexpr char layout_name[] = "layout.toc"; constexpr char levels_name[] = "reskate-levels.json"; +constexpr char objects_name[] = "reskate-objects.json"; // content_catalogs.h's mod_objects_file +constexpr char music_name[] = "reskate-music.json"; // mod_music.h // Written by the tool that built the mod (Studio, since 2026-09-24): who made // it and when. Purely informational, so it is read loosely and never rejected. constexpr char build_name[] = "reskate-build.json"; @@ -228,6 +232,19 @@ void read_variants(Mod& mod, std::vector& notes) { } } +// Whether reskate-plugin.json names code to run. A file with only "resident" or "variants" serves +// the mod's game data and is no plugin. The plugin host reports what else is wrong with it. +void read_plugin_entry(Mod& mod) { + const auto path = mod.directory / plugin_manifest_file; + std::error_code error; + if (!fs::is_regular_file(path, error) || error) return; + try { + const auto root = parse(path, maximum_plugin_bytes); + mod.provides_plugin = root.is_object() && root.contains("entry") && root.at("entry").is_string(); + } catch (...) { + } +} + // .reskate-studio-patch: the stamp line, then key=value lines. A mod that ships game data must // record the Skate.exe it was built for, and it must be this one (supported_build). void read_studio_marker(Mod& mod) { @@ -429,7 +446,13 @@ void write_exclusions(const std::filesystem::path& mods_root, output.write(text.data(), static_cast(text.size())); if (!output.flush()) return; } +#ifdef _WIN32 MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH); +#else + // rename replaces the old file in one step on POSIX, as MoveFileExW does on Windows. + std::error_code ignored; + fs::rename(temporary, path, ignored); +#endif } catch (...) {} } @@ -461,6 +484,8 @@ Mod describe_mod(const std::filesystem::path& directory, std::string name, std:: mod.directory = directory; mod.provides_layout = fs::is_regular_file(mod.directory / layout_name, error); mod.provides_levels = fs::is_regular_file(mod.directory / levels_name, error); + mod.provides_objects = fs::is_regular_file(mod.directory / objects_name, error); + mod.provides_music = fs::is_regular_file(mod.directory / music_name, error); for (fs::directory_iterator it(mod.directory / "parks", error), end; !error && it != end; it.increment(error)) { auto file = ascii_path(it->path().filename()); if (!file.ends_with(".park.json") || mod.park_maps.size() >= 16) continue; @@ -473,10 +498,24 @@ Mod describe_mod(const std::filesystem::path& directory, std::string name, std:: read_levels(mod); read_resident(mod, notes); read_variants(mod, notes); + read_plugin_entry(mod); read_studio_marker(mod); return mod; } +std::vector mod_contents(const Mod& mod) { + std::vector parts; + if (mod.provides_levels) + parts.push_back(mod.levels.size() > 1 ? std::to_string(mod.levels.size()) + " maps" : "Map"); + if (!mod.park_maps.empty()) parts.push_back(mod.park_maps.size() == 1 ? "Park" : "Parks"); + if (mod.provides_plugin) parts.push_back("Plugin"); + // A map's own game data is part of the map. + if (mod.provides_layout && !mod.provides_levels) parts.push_back("Game data"); + if (mod.provides_objects) parts.push_back("Objects"); + if (mod.provides_music) parts.push_back("Music"); + return parts; +} + bool resident_root(const Mod& mod, std::string_view bundle, std::string_view name) noexcept { try { const auto b = lower(bundle), n = lower(name); @@ -615,12 +654,23 @@ void save_mod_order(const std::filesystem::path& mods_root, const std::vector(text.size())) || !output.flush()) throw std::runtime_error("Cannot write " + path_utf8(temporary)); } +#ifdef _WIN32 if (!MoveFileExW(temporary.c_str(), target.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { std::error_code ignored; fs::remove(temporary, ignored); throw std::runtime_error("Cannot replace " + path_utf8(target) + " (Windows error " + std::to_string(GetLastError()) + ")"); } +#else + // Elsewhere (the plugin tests on Linux) rename replaces the old file in one step. + std::error_code renamed; + fs::rename(temporary, target, renamed); + if (renamed) { + std::error_code ignored; + fs::remove(temporary, ignored); + throw std::runtime_error("Cannot replace " + path_utf8(target) + " (" + renamed.message() + ")"); + } +#endif } } // namespace dingosdk::mods diff --git a/Engine/Vfs/mod_list.h b/Engine/Vfs/mod_list.h index d8c40459..449e02bd 100644 --- a/Engine/Vfs/mod_list.h +++ b/Engine/Vfs/mod_list.h @@ -76,6 +76,9 @@ struct Mod { std::filesystem::path directory; bool provides_layout{}; // has layout.toc, so it becomes a layout layer bool provides_levels{}; // has reskate-levels.json + bool provides_plugin{}; // its reskate-plugin.json names Luau code to run (an "entry") + bool provides_objects{}; // has reskate-objects.json (Quick Drop object names and families) + bool provides_music{}; // has reskate-music.json (playlists for its songs) // Base maps it ships a park for: parks/.park.json (park editor mods). std::vector park_maps; // Presentation, from manifest.json (or reskate-mod.json). `title` falls back to the folder @@ -154,6 +157,11 @@ struct ModList { bool resident_root(const Mod& mod, std::string_view bundle, std::string_view name) noexcept; const ResidentAssets::Shader* resident_shader(const Mod& mod, std::string_view bundle, std::string_view name) noexcept; +// What a mod holds, for a player, what says most about it first: "Map" or "3 maps", "Park" or +// "Parks", "Plugin", "Game data", "Objects", "Music". Empty when it holds none of these. A plugin +// that also ships game data reads as a plugin first, since its code is what it is for. +std::vector mod_contents(const Mod& mod); + // Folder names travel into engine paths, so only plain ASCII is accepted: // letters, digits, space, '_', '-' and '.', not starting with '.'. bool valid_mod_name(std::string_view name); diff --git a/Engine/Vfs/thunderstore_package.cpp b/Engine/Vfs/thunderstore_package.cpp index e7adc20a..ae37d203 100644 --- a/Engine/Vfs/thunderstore_package.cpp +++ b/Engine/Vfs/thunderstore_package.cpp @@ -61,7 +61,7 @@ std::wstring download_url(const Name& name, std::string_view wanted) { return wide(std::string(site) + "/package/download/" + name.owner + '/' + name.name + '/' + std::string(wanted) + '/'); } -Choice choose(std::string_view json, const Name& wanted, std::string_view community) { +Choice choose(std::string_view json, const Name& wanted, std::string_view community, Kind kind) { Choice choice; try { const auto root = Json::parse(json); @@ -73,7 +73,8 @@ Choice choose(std::string_view json, const Name& wanted, std::string_view commun choice.reason = "Its package on Thunderstore is marked deprecated."; return choice; } - bool listed{}, map{}, rejected{}, nsfw{}; + const std::string_view category_wanted = kind == Kind::plugin ? "Plugins" : "Maps"; + bool listed{}, fits{}, rejected{}, nsfw{}; if (root.contains("community_listings") && root.at("community_listings").is_array()) for (const auto& listing : root.at("community_listings")) { if (!same(text_of(listing, "community"), community)) continue; @@ -83,14 +84,18 @@ Choice choose(std::string_view json, const Name& wanted, std::string_view commun if (listing.contains("categories") && listing.at("categories").is_array()) for (const auto& category : listing.at("categories")) { - map = map || (category.is_string() && same(category.string(), "Maps")); + fits = fits || (category.is_string() && same(category.string(), category_wanted)); nsfw = nsfw || (category.is_string() && same(category.string(), "NSFW")); } } if (!listed) { choice.reason = "Its package is not listed for ReSkate on Thunderstore."; return choice; } if (rejected) { choice.reason = "Its package was rejected by Thunderstore's moderators."; return choice; } if (nsfw) { choice.reason = "Its package is marked NSFW on Thunderstore."; return choice; } - if (!map) { choice.reason = "Its package is not listed as a map on Thunderstore."; return choice; } + if (!fits) { + choice.reason = kind == Kind::plugin ? "Its package is not listed as a plugin on Thunderstore." + : "Its package is not listed as a map on Thunderstore."; + return choice; + } const auto newest = root.contains("latest") ? text_of(root.at("latest"), "version_number") : std::string(); if (!version(newest)) { choice.reason = "Thunderstore gave no version of that package."; return choice; } choice.newest = newest; diff --git a/Engine/Vfs/thunderstore_package.h b/Engine/Vfs/thunderstore_package.h index 63fbeb38..3af20cf5 100644 --- a/Engine/Vfs/thunderstore_package.h +++ b/Engine/Vfs/thunderstore_package.h @@ -5,7 +5,8 @@ #include // One Thunderstore package looked up by name, for the game: the map a multiplayer host is on -// and this player does not have (Extension/Assets/map_download.h). No network here; the caller +// and this player does not have, and the plugins a dedicated server needs +// (Extension/Assets/map_download.h). No network here; the caller // fetches details_url() and hands the answer to choose(). namespace dingosdk::thunderstore_package { @@ -35,9 +36,11 @@ struct Choice { std::string icon; std::string reason; // why not, for the player }; +// What the package has to be listed as: a map (the "Maps" category) or a plugin ("Plugins"). +enum class Kind { map, plugin }; // Reads that answer. The package is taken only when it is the one asked for, is listed in -// `community` as a map (the "Maps" category), is not deprecated or marked NSFW there and was -// not rejected by the community's moderators. -Choice choose(std::string_view json, const Name& wanted, std::string_view community); +// `community` as `kind`, is not deprecated or marked NSFW there and was not rejected by the +// community's moderators. +Choice choose(std::string_view json, const Name& wanted, std::string_view community, Kind kind = Kind::map); } // namespace dingosdk::thunderstore_package diff --git a/Extension/Assets/Test/map_download_timeout_tests.cpp b/Extension/Assets/Test/map_download_timeout_tests.cpp index c3856e64..02383471 100644 --- a/Extension/Assets/Test/map_download_timeout_tests.cpp +++ b/Extension/Assets/Test/map_download_timeout_tests.cpp @@ -29,7 +29,7 @@ constexpr auto use_download_deadline = static_cast(-1); std::atomic active_deadline{use_download_deadline}; std::atomic downloading{}, installing{}, release_download{}, release_install{}, merging{}; std::atomic fetching{true}, held{}, observed_cancel{}, installed_cancel{}; -std::atomic downloads{}, installs{}, applies{}, stops{}, rejoins{}; +std::atomic downloads{}, installs{}, applies{}, stops{}, rejoins{}, removals{}; std::atomic queued_stop_deadline{}; bool block_download{}, block_install{}, slow{}, transfer_error{}, fallback{}; dingosdk::MultiplayerModel session; @@ -81,11 +81,17 @@ namespace dingosdk::https { std::optional content_length(std::wstring_view, std::uint32_t, const wchar_t*, Watch*) { return std::nullopt; } -std::optional get_text(std::wstring_view, std::uint64_t, std::uint32_t, +std::optional get_text(std::wstring_view url, std::uint64_t, std::uint32_t, const wchar_t*, Download* result, Watch* watch) { fixture::require(watch && watch->deadline_us.load() == map_download::deadline.load(), "metadata shares the session deadline"); if (result) *result = {true, 200, 0}; + for (const auto* name : {"Plug", "Fake", "Cosmetic"}) + if (url.find(L"/" + std::wstring(name, name + std::strlen(name)) + L"/") != std::wstring_view::npos) { + const std::string narrow(name); + return R"({"namespace":"Team","name":")" + narrow + R"(","community_listings":[{"community":"reskate","categories":[")" + + (narrow == "Cosmetic" ? "Cosmetics" : "Plugins") + R"("]}],"latest":{"version_number":"1.2.0","description":"Test plugin"}})"; + } return R"({"namespace":"Team","name":"Map","community_listings":[{"community":"reskate","categories":["Maps"]}], "latest":{"version_number":"2.0.0","description":"Test map"}})"; } @@ -125,6 +131,12 @@ Download get(std::wstring_view, const std::filesystem::path& destination, namespace dingosdk::mods { std::filesystem::path engine_data_root() noexcept { return fixture::root; } ModList scan_mods(const std::filesystem::path&) noexcept { return {}; } +Mod describe_mod(const std::filesystem::path& directory, std::string name, std::vector&) { + Mod mod; + mod.name = std::move(name); + mod.provides_plugin = std::filesystem::exists(directory / "reskate-plugin.json"); + return mod; +} void save_mod_order(const std::filesystem::path&, const std::vector&) {} } namespace dingosdk::launcher_mods { @@ -142,10 +154,14 @@ std::string install(const std::filesystem::path& root, const std::filesystem::pa installed_cancel.store(cancel.load()); if (cancel.load()) throw std::runtime_error("cancelled"); fs::create_directories(root / options.folder); - std::ofstream(root / options.folder / "reskate-levels.json") << "{}"; + if (options.folder == "Team-Plug") std::ofstream(root / options.folder / "reskate-plugin.json") << R"({"entry":"main.lua"})"; + else if (options.folder != "Team-Fake") std::ofstream(root / options.folder / "reskate-levels.json") << "{}"; return options.folder; } -void remove(const std::filesystem::path&, const std::string&) {} +void remove(const std::filesystem::path& root, const std::string& folder) { + ++fixture::removals; + std::filesystem::remove_all(root / folder); +} } namespace dingosdk::live_mods { bool only_maps(const std::filesystem::path&) { return true; } @@ -169,7 +185,7 @@ std::uint64_t map_fetch_deadline() noexcept { } void hold_map_load(bool hold, std::string_view package) noexcept { fixture::held.store(hold); - if (hold && package != "Team-Map-1.0.0") std::abort(); + if (hold && !package.empty() && package != "Team-Map-1.0.0") std::abort(); } bool queue_command(std::string_view action, std::string_view argument, std::string_view password) { if (action == "stop") ++fixture::stops; @@ -228,6 +244,7 @@ void reset() { downloads.store(0); installs.store(0); applies.store(0); + removals.store(0); stops.store(0); queued_stop_deadline.store(0); rejoins.store(0); @@ -624,6 +641,56 @@ void tests() { rejoins.load() == 0 && !noticed("timed out"), "join completed within session budget retires successfully even when next tick is after deadline"); + // A dedicated server's required plugins alone: one card, fetched without a live merge (the + // plugin host takes them from Mods), and the session goes on. + reset(); + download::offer({"", "Test map", true, false, "", 0, {"Team-Plug-1.0.0"}}); + wait([] { return download::asking(); }, "plugin need did not reach confirmation"); + drain(); + { + const auto shown = download::view(); + require(shown.items.size() == 1 && shown.items[0].plugin && shown.items[0].name == "Plug" && shown.items[0].version == "1.2.0" && + shown.map.empty(), "the card lists the plugin, at Thunderstore's newest"); + } + download::answer(true); + wait([] { return download::view().stage == Stage::joining; }, "plugin install did not reach joining"); + drain(); + require(downloads.load() == 1 && installs.load() == 1 && applies.load() == 0, "a plugin alone needs no live merge"); + fetching.store(false); + session.connected = true; + clock.fetch_add(2500000); + download::tick(); + require(download::view().stage == Stage::idle && stops.load() == 0, "the session goes on once the plugin is in"); + + // The map and a plugin: asked once, on one card, both fetched, the map applied. + reset(); + download::offer({"Team-Map-1.0.0", "Test map", true, false, "", 0, {"Team-Plug"}}); + wait([] { return download::asking(); }, "map and plugin need did not reach confirmation"); + drain(); + require(download::view().items.size() == 2 && !download::view().items[0].plugin && download::view().items[1].plugin, + "the card lists the map first, then the plugin"); + applying(); + require(downloads.load() == 2 && installs.load() == 2, "both are downloaded and installed"); + joined(); + + // A package that is not a plugin on Thunderstore (a cosmetic) is never fetched for a server. + reset(); + download::offer({"", "Test map", true, false, "", 0, {"Team-Cosmetic"}}); + wait([] { return download::view().stage == Stage::idle; }, "a cosmetic was not refused"); + drain(); + require(downloads.load() == 0 && noticed("not listed as a plugin") && stops.load() == 1, "a cosmetic is refused before any download"); + + // Listed as a plugin, but what it installs is none: taken out again. + reset(); + download::offer({"", "Test map", true, false, "", 0, {"Team-Fake"}}); + wait([] { return download::asking(); }, "fake plugin did not reach confirmation"); + drain(); + download::answer(true); + wait([] { return download::view().stage == Stage::idle; }, "a package that is not a plugin was kept"); + drain(); + require(removals.load() == 1 && !fs::exists(root / "Mods" / "Team-Fake") && noticed("is not a plugin"), + "a package that installs as no plugin is removed"); + reset(); download::tick(); require(!held.load() && downloads.load() == 0 && stops.load() == 0 && rejoins.load() == 0, diff --git a/Extension/Assets/map_download.cpp b/Extension/Assets/map_download.cpp index e1db384a..ba3df0ab 100644 --- a/Extension/Assets/map_download.cpp +++ b/Extension/Assets/map_download.cpp @@ -32,6 +32,19 @@ constexpr std::uint64_t largest_download = 8ull << 30; constexpr std::uint32_t download_seconds = 4 * 60 * 60; namespace policy = multiplayer::map_fetch_policy; +// One package the session needs: the map, or one of the plugins. +struct Item { + package::Name name; // the map's with the host's version; a plugin's without one + bool plugin{}; + std::string at_least; // a plugin: the version the server named, the least it takes (empty: any) + std::string have; // the version here, when the one fetched replaces it + std::string folder; // its folder in Mods: an installed one's when it is here, else the package's + std::string version, newest, description; + std::uint64_t bytes{}; // the zip's Content-Length (0: not known) + bool off{}; // here already, switched off: nothing is downloaded + bool merge{}; // once it is in: it has game data, which the live merge applies +}; + std::mutex mutex; std::atomic stage{Stage::idle}; std::atomic workers{}; @@ -42,13 +55,9 @@ std::atomic deadline{}, ask_until{}; std::atomic detached{}; multiplayer::MapNeed need; // under mutex, while not idle std::optional pending; // a fresh attempt waiting for cancelled work to drain -package::Name wanted; // under mutex -std::string version; // under mutex: the one to download -std::string newest; // under mutex -std::string description; // under mutex: Thunderstore's, or the installed mod's own -std::uint64_t package_bytes{}; // under mutex: the zip's Content-Length -bool installed_off{}; // under mutex: here already, switched off -std::string last_failed; // under mutex: a package not to offer again this run +std::vector items; // under mutex: the map first when it is needed, then the plugins +std::atomic current{}; // the item being fetched +std::string last_failed; // under mutex: a need (need_key) not to offer again this run https::Watch watch; std::atomic stop_install{}; std::atomic apply_wanted{}, apply_started{}; @@ -67,6 +76,25 @@ std::string read_bytes(const fs::path& path, std::uintmax_t most) { } std::string host_word(bool server) { return server ? "server" : "host"; } +// What a need is, to know it again: its map and its plugins. +std::string need_key(const multiplayer::MapNeed& of) { + auto key = of.package; + for (const auto& plugin : of.plugins) key += '|' + plugin; + return key; +} +// A plugin's name for a player: its package's name with spaces ("FPV Drone"). +std::string plugin_label(std::string_view package) { + const auto name = package::parse_name(package); + auto label = name ? name->name : std::string(package); + std::replace(label.begin(), label.end(), '_', ' '); + return label; +} +std::string plugin_list(const std::vector& packages) { + std::string out; + for (std::size_t at = 0; at < packages.size(); ++at) + out += (at == 0 ? "" : at + 1 == packages.size() ? " and " : ", ") + plugin_label(packages[at]); + return out; +} std::string need_package() { std::lock_guard lock(mutex); return need.package; @@ -76,17 +104,36 @@ bool need_server() { return need.server; } -// The session that is waiting on the map is left: there is no map to wait for. +// The session that is waiting is left: what it waits for will not come. void leave_session(std::uint64_t expected = 0) { const auto owned = expected ? expected : multiplayer::map_fetch_deadline(); if (multiplayer::fetching_map() && owned) multiplayer::queue_command("stop-map-fetch", std::to_string(owned), ""); } +// What the session needs and this PC does not have, said for a player: "The server is on +// Vancouver Plaza, which is not installed on this PC." Under mutex. +std::string needed_text(const multiplayer::MapNeed& of) { + const auto who = host_word(of.server); + std::string text; + if (!of.package.empty()) + text = "The " + who + (of.moved ? " changed to " : " is on ") + of.map + + (!of.have.empty() ? ", at another version (" + std::string(multiplayer::map_package_version(of.package)) + + ") than the one installed on this PC (" + of.have + ")" + : ", which is not installed on this PC"); + if (!of.plugins.empty()) + text += (text.empty() ? "The " + who + " needs " : ", and needs ") + std::string(of.plugins.size() == 1 ? "the plugin " : "the plugins ") + + plugin_list(of.plugins) + ", which this PC does not have ready"; + return text + ". "; +} +std::string needed_title(const multiplayer::MapNeed& of) { + if (of.package.empty()) return "Plugins needed"; + return of.have.empty() ? "Map not installed" : "Map version differs"; +} + // Back to idle with the reason said, as the plain "map not installed" notice is. void fail(std::string reason, bool retry = false) { - std::string text; - bool update{}; + std::string text, title; std::uint64_t failed_deadline{}; { std::lock_guard lock(mutex); @@ -95,15 +142,13 @@ void fail(std::string reason, bool retry = false) { watch.cancel.store(true); stop_install.store(true); apply_wanted.store(false); - update = !need.have.empty(); - text = "The " + host_word(need.server) + (need.moved ? " changed to " : " is on ") + need.map + - (update ? ", at another version (" + std::string(multiplayer::map_package_version(need.package)) + ") than the one installed on this PC (" + need.have + "). " - : ", which is not installed on this PC. ") + reason; - if (!retry) last_failed = need.package; + text = needed_text(need) + reason; + title = needed_title(need); + if (!retry) last_failed = need_key(need); } if (multiplayer::map_fetch_deadline() == failed_deadline) leave_session(failed_deadline); logging::log(logging::Level::warning, logging::Channel::assets, "Map download: {}", text); - overlay::notify(overlay::NoticeLevel::warning, update ? "Map version differs" : "Map not installed", std::move(text)); + overlay::notify(overlay::NoticeLevel::warning, std::move(title), std::move(text)); } // Whether the download has begun (or is done): from then on it goes to the end on its own time. @@ -117,7 +162,7 @@ bool continuing() { // The session's deadline ends a check or a question it outlived; a download under way is // not thrown away with it (a large map on a slow line would never come at all). if (!under_way(now) && policy::expired(deadline.load(), multiplayer::now_us())) { - fail("Map download timed out. Please try joining the server again.", true); + fail("The download timed out. Please try joining the server again.", true); return false; } return !watch.cancel.load(); @@ -132,28 +177,38 @@ void ask() { ask_until.store(std::min(deadline.load(), multiplayer::now_us() + policy::confirmation_us)); advance(Stage::checking, Stage::asking); } -// The packages of the maps installed here, by level (lower case), read from the Mods folder in -// one pass (package_of). Forgotten when a map is installed; read again in the background two -// minutes after it was read. It was read for each level asked, on the client thread, with every -// map offer a host sends (a guest joining, a map change): tens of milliseconds, a second and more -// with many mods (2026-10-15). -struct KnownPackages { +// The packages of the maps installed here, by level (lower case), and every plugin here, read +// from the Mods folder in one pass (package_of, has_level, missing_plugins). Forgotten when +// anything is installed; read again in the background two minutes after it was read. It was +// read for each level asked, on the client thread, with every map offer a host sends (a guest +// joining, a map change): tens of milliseconds, a second and more with many mods (2026-10-15). +struct KnownPlugin { + std::string folder, title, author, version; + bool enabled{}; +}; +struct KnownTable { std::map> by_level; + std::vector plugins; +}; +struct KnownPackages { + KnownTable table; std::uint64_t read_at{}; bool valid{}, reading{}; }; std::mutex known_mutex; KnownPackages known_packages; -std::map> read_known_packages() { - std::map> table; +KnownTable read_known_packages() { + KnownTable table; const auto list = mods::scan_mods(mods::engine_data_root()); for (const auto& entry : list.entries) { + if (entry.mod.provides_plugin) + table.plugins.push_back({entry.mod.name, entry.mod.title, entry.mod.author, entry.mod.version, entry.enabled}); if (!entry.enabled || !entry.mod.provides_levels) continue; const auto package = multiplayer::map_package_name(entry.mod.name, entry.mod.version); for (auto level : entry.mod.levels) { std::transform(level.begin(), level.end(), level.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); - table.emplace(std::move(level), package); // (the first mod that has a level is the one) + table.by_level.emplace(std::move(level), package); // (the first mod that has a level is the one) } } return table; @@ -165,12 +220,12 @@ void refresh_known_packages(std::uint64_t now) { known_packages.reading = true; try { std::thread([] { - std::map> table; + KnownTable table; bool ok{}; try { table = read_known_packages(); ok = true; } catch (...) {} std::lock_guard lock(known_mutex); if (ok) { - known_packages.by_level = std::move(table); + known_packages.table = std::move(table); known_packages.read_at = GetTickCount64(); known_packages.valid = true; } @@ -178,169 +233,327 @@ void refresh_known_packages(std::uint64_t now) { }).detach(); } catch (...) { known_packages.reading = false; } } - -// The mod's row in mods.json when its folder is here: whether it is switched on, and what it -// says of itself. -std::optional installed(const std::string& folder, std::string& about) { - const auto list = mods::scan_mods(mods::engine_data_root()); - for (const auto& entry : list.entries) - if (same(entry.mod.name, folder)) { - about = entry.mod.description; - return entry.enabled; +// The table, read here when there is none (only just after something was installed). Takes +// known_mutex; `use` runs under it. +template +auto with_known(Use use) { + const auto now = GetTickCount64(); + std::unique_lock lock(known_mutex); + if (!known_packages.valid) { + lock.unlock(); + auto table = read_known_packages(); + lock.lock(); + if (!known_packages.valid) { + known_packages.table = std::move(table); + known_packages.read_at = now; + known_packages.valid = true; } - return std::nullopt; + } + refresh_known_packages(now); + return use(known_packages.table); +} +void forget_known() { + std::lock_guard known(known_mutex); + known_packages.valid = false; +} +// Whether a plugin here is the package: in the package's folder, or with its name and author. +bool is_package(const package::Name& name, std::string_view folder, std::string_view title, std::string_view author) { + return same(folder, name.folder()) || (same(title, name.name) && same(author, name.owner)); +} +// Whether `have` is at least `at_least` (any version is, when none is asked). +bool new_enough(std::string_view have, std::string_view at_least) { + return at_least.empty() || (!have.empty() && !multiplayer::map_version_newer(at_least, have)); } -// Stage::checking: is it here but off, or what does Thunderstore say of it. +// The mod's row in mods.json when it is here: the map's by its package folder, a plugin's by +// is_package (an enabled one first). Its entry, and whether it was found. +std::optional installed(const Item& item) { + const auto list = mods::scan_mods(mods::engine_data_root()); + std::optional found; + for (const auto& entry : list.entries) { + const bool match = item.plugin ? is_package(item.name, entry.mod.name, entry.mod.title, entry.mod.author) + : same(entry.mod.name, item.name.folder()); + if (match && (!found || (entry.enabled && !found->enabled))) found = entry; + } + return found; +} + +// Stage::checking for the map: is it here but off, or what does Thunderstore say of it. False +// when it failed (and fail() has said why). +bool check_map(Item& item, bool first) { + const bool update = !item.have.empty(); + // (At another version than the host's, it is fetched whether it is on or off.) + if (const auto here = update ? std::nullopt : installed(item)) { + if (here->enabled) { + fail("Its mod is installed, but the map did not load: update or reinstall it from the launcher."); + return false; + } + item.off = true; + item.description = here->mod.description.substr(0, 240); + // Its own icon, from its folder. + if (first) + try { + overlay::set_map_download_icon(read_bytes(mods_root() / item.folder / L"icon.png", 4 * 1024 * 1024)); + } catch (const std::exception&) {} + return true; + } + https::Download result; + const auto answer = https::get_text(package::details_url(item.name), 512 * 1024, 20, agent, &result, &watch); + if (!continuing()) return false; + if (!answer) { + fail(result.http_status == 404 ? "Its mod is not on Thunderstore. Install its map mod yourself and join again." + : "Thunderstore could not be reached to look for it."); + return false; + } + const auto choice = package::choose(*answer, item.name, community, package::Kind::map); + if (!choice.ok) { + fail(choice.reason + " Install its map mod yourself and join again."); + return false; + } + // Another version is only worth fetching when it is the host's: any other leaves the two apart still. + if (update && choice.version != multiplayer::map_package_version(need_package())) { + fail("That version is not on Thunderstore any more. Ask the " + host_word(need_server()) + " to update the map, or install its version yourself."); + return false; + } + // The package page names the version and not its size. The download's Content-Length is + // what the card shows; the body is not fetched until the player says yes. + const auto length = https::content_length(package::download_url(item.name, choice.version), 8, agent, &watch); + item.version = choice.version; + item.newest = choice.newest; + item.description = choice.description; + item.bytes = length.value_or(0); + // Its icon for the card: small, and not worth holding the question up for long. + if (first && !choice.icon.empty()) + if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent, nullptr, &watch)) + overlay::set_map_download_icon(*png); + return true; +} + +// Stage::checking for a plugin. False when it failed; `ready` when it turns out to be here +// after all (switched on since the session looked). +bool check_plugin(Item& item, bool first, bool& ready) { + const auto label = plugin_label(item.name.folder()); + if (const auto here = installed(item)) { + item.folder = here->mod.name; + if (here->mod.provides_plugin && new_enough(here->mod.version, item.at_least)) { + if (here->enabled) { + ready = true; + return true; + } + item.off = true; + item.version = here->mod.version; + item.description = here->mod.description.substr(0, 240); + if (first) + try { + overlay::set_map_download_icon(read_bytes(mods_root() / item.folder / L"icon.png", 4 * 1024 * 1024)); + } catch (const std::exception&) {} + return true; + } + // Here at an older version (or not as a plugin): the new one goes in its place. + item.have = here->mod.version.empty() ? std::string("an older version") : here->mod.version; + } + https::Download result; + const auto answer = https::get_text(package::details_url(item.name), 512 * 1024, 20, agent, &result, &watch); + if (!continuing()) return false; + if (!answer) { + fail(result.http_status == 404 ? label + " is not on Thunderstore. Install it yourself and join again." + : "Thunderstore could not be reached to look for " + label + "."); + return false; + } + const auto choice = package::choose(*answer, item.name, community, package::Kind::plugin); + if (!choice.ok) { + fail(label + ": " + choice.reason + " It is not installed from here."); + return false; + } + // (The server names the least it takes; the newest is fetched, which is at least that.) + if (!new_enough(choice.newest, item.at_least)) { + fail("Thunderstore has version " + choice.newest + " of " + label + ", older than the " + item.at_least + " the " + + host_word(need_server()) + " needs."); + return false; + } + const auto length = https::content_length(package::download_url(item.name, choice.newest), 8, agent, &watch); + item.version = choice.newest; + item.newest = choice.newest; + item.description = choice.description; + item.bytes = length.value_or(0); + if (first && !choice.icon.empty()) + if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent, nullptr, &watch)) + overlay::set_map_download_icon(*png); + return true; +} + +// Stage::checking: what each package is and where it comes from. void check() { - package::Name name; - bool update{}; + std::vector list; { std::lock_guard lock(mutex); - name = wanted; - update = !need.have.empty(); + list = items; } try { - std::string about; - // (At another version than the host's, it is fetched whether it is on or off.) - if (const auto on = update ? std::optional{} : installed(name.folder(), about)) { - if (*on) return fail("Its mod is installed, but the map did not load: update or reinstall it from the launcher."); - { - std::lock_guard lock(mutex); - installed_off = true; - description = about.substr(0, 240); - } - // Its own icon, from its folder. - try { - const auto bytes = read_bytes(mods_root() / name.folder() / L"icon.png", 4 * 1024 * 1024); - overlay::set_map_download_icon(bytes); - } catch (const std::exception&) {} - ask(); + std::vector wanted; + for (auto& item : list) { + if (!continuing()) return; + const bool first = wanted.empty(); + bool ready{}; + if (!(item.plugin ? check_plugin(item, first, ready) : check_map(item, first))) return; + if (!ready) wanted.push_back(std::move(item)); + } + if (wanted.empty()) { + // (Everything turned out to be here: the session sees so itself and goes on.) + auto from = Stage::checking; + stage.compare_exchange_strong(from, Stage::idle); return; } - https::Download result; - const auto answer = https::get_text(package::details_url(name), 512 * 1024, 20, agent, &result, &watch); - if (!continuing()) return; - if (!answer) - return fail(result.http_status == 404 ? "Its mod is not on Thunderstore. Install its map mod yourself and join again." - : "Thunderstore could not be reached to look for it."); - const auto choice = package::choose(*answer, name, community); - if (!choice.ok) return fail(choice.reason + " Install its map mod yourself and join again."); - // Another version is only worth fetching when it is the host's: any other leaves the two apart still. - if (update && choice.version != multiplayer::map_package_version(need_package())) - return fail("That version is not on Thunderstore any more. Ask the " + host_word(need_server()) + " to update the map, or install its version yourself."); - // The package page names the version and not its size. The download's Content-Length is - // what the card shows; the body is not fetched until the player says yes. - const auto length = https::content_length(package::download_url(name, choice.version), 8, agent, &watch); { std::lock_guard lock(mutex); - version = choice.version; - newest = choice.newest; - description = choice.description; - package_bytes = length.value_or(0); + items = std::move(wanted); } - // Its icon for the card: small, and not worth holding the question up for long. - if (!choice.icon.empty()) - if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent, nullptr, &watch)) - overlay::set_map_download_icon(*png); ask(); } catch (const std::exception& failure) { fail(std::string("Looking for it failed: ") + failure.what()); } } -// Stage::downloading and Stage::installing, then Stage::applying is the client thread's. +// Downloads one package into its folder in Mods (Stage::downloading, then Stage::installing). +// False when it failed or was stopped. +bool fetch_one(const Item& item) { + const auto root = mods_root(); + const bool update = !item.have.empty(); + wchar_t temp[MAX_PATH + 1]{}; + const auto length = GetTempPathW(MAX_PATH, temp); + const auto archive = fs::path(length ? std::wstring(temp, length) : L".") / + (L"ReSkate-map-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::wstring(item.folder.begin(), item.folder.end()) + L".zip"); + std::error_code error; + fs::remove(archive, error); + watch.received.store(0); + watch.total.store(0); + // (The session's deadline is not the download's: only its own time limit stops it.) + const auto download = [&](const std::string& selected) { + watch.deadline_us.store(0); + return https::get(package::download_url(item.name, selected), archive, largest_download, download_seconds, agent, &watch); + }; + auto result = download(item.version); + // The host's version of a map can be gone from Thunderstore: the newest is the same map. + if (!item.plugin && !update && !result.ok && result.http_status == 404 && item.version != item.newest && !watch.cancel.load()) { + fs::remove(archive, error); + logging::log(logging::Level::info, logging::Channel::assets, + "Map download: {} {} is not on Thunderstore any more; getting {}.", item.folder, item.version, item.newest); + result = download(item.newest); + } + if (!result.ok) { + fs::remove(archive, error); + if (!continuing()) return false; + if (result.error == ERROR_TIMEOUT || result.error == ERROR_WINHTTP_TIMEOUT) { + fail("The download timed out. Please try joining the server again.", true); + return false; + } + fail(result.http_status && result.http_status != 200 + ? "The download failed (Thunderstore answered " + std::to_string(result.http_status) + ")." + : "The download failed (Windows error " + std::to_string(result.error) + ")."); + return false; + } + if (!advance(Stage::downloading, Stage::installing)) { + fs::remove(archive, error); + return false; + } + try { + // (In the installed one's place when it is the same package at another version. The + // game may hold the old one's files open when it was used this run: then it cannot be + // replaced until the game is closed.) + launcher_mods::install(root, archive, update, [](float) { continuing(); }, stop_install, {item.folder, item.name.owner, true}); + } catch (const std::exception& failure) { + fs::remove(archive, error); + if (!continuing()) return false; + fail(update ? std::string("It could not be replaced while the game runs (") + failure.what() + + "). Close the game, update it from the launcher and join again." + : std::string("It could not be installed: ") + failure.what()); + return false; + } + fs::remove(archive, error); + return continuing(); +} + +// Whether what is in the folder is what it was let in as: a map and nothing else, or a plugin +// (with what game data it brings, which is then merged). False when it is not, and it is taken +// out again unless it was here before. +bool vetted(Item& item) { + const auto root = mods_root(); + const auto folder = root / item.folder; + std::error_code error; + if (!item.plugin) { + if (fs::exists(folder / L"reskate-levels.json", error) && live_mods::only_maps(folder)) { + item.merge = true; + return true; + } + if (!item.off) + try { launcher_mods::remove(root, item.folder); } catch (const std::exception&) {} + fail("Its package holds more than a map, so it is not installed from here. Install it from the launcher if you trust it."); + return false; + } + std::vector notes; + mods::Mod mod; + try { mod = mods::describe_mod(folder, item.folder, notes); } catch (const std::exception&) {} + if (mod.provides_plugin) { + item.merge = mod.provides_layout || mod.provides_levels; + return true; + } + if (!item.off && item.have.empty()) + try { launcher_mods::remove(root, item.folder); } catch (const std::exception&) {} + fail(plugin_label(item.name.folder()) + " is not a plugin, so it is not installed from here. Install it from the launcher if you trust it."); + return false; +} + +// Stage::downloading through Stage::installing for every package in turn, then Stage::applying +// (the client thread's) when any has game data, else Stage::joining. void fetch() { - package::Name name; - std::string get, fallback; - bool off{}, update{}; + std::vector list; { std::lock_guard lock(mutex); - name = wanted; - get = version; - fallback = newest; - off = installed_off; - update = !need.have.empty(); + list = items; } - const auto root = mods_root(); - const auto folder = name.folder(); try { - if (!continuing()) return; - if (!off) { - wchar_t temp[MAX_PATH + 1]{}; - const auto length = GetTempPathW(MAX_PATH, temp); - const auto archive = fs::path(length ? std::wstring(temp, length) : L".") / - (L"ReSkate-map-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::wstring(folder.begin(), folder.end()) + L".zip"); - std::error_code error; - fs::remove(archive, error); - watch.received.store(0); - watch.total.store(0); - // (The session's deadline is not the download's: only its own time limit stops it.) - const auto download = [&](const std::string& selected) { - watch.deadline_us.store(0); - return https::get(package::download_url(name, selected), archive, largest_download, download_seconds, agent, &watch); - }; - auto result = download(get); - // The host's version can be gone from Thunderstore: the newest is the same map. - if (!update && !result.ok && result.http_status == 404 && get != fallback && !watch.cancel.load()) { - fs::remove(archive, error); - logging::log(logging::Level::info, logging::Channel::assets, - "Map download: {} {} is not on Thunderstore any more; getting {}.", folder, get, fallback); - result = download(fallback); - } - if (!result.ok) { - fs::remove(archive, error); - if (!continuing()) return; - if (result.error == ERROR_TIMEOUT || result.error == ERROR_WINHTTP_TIMEOUT) - return fail("The download timed out. Please try joining the server again.", true); - return fail(result.http_status && result.http_status != 200 - ? "The download failed (Thunderstore answered " + std::to_string(result.http_status) + ")." - : "The download failed (Windows error " + std::to_string(result.error) + ")."); - } - if (!advance(Stage::downloading, Stage::installing)) { - fs::remove(archive, error); + bool any_merge{}, any_switch{}; + for (std::size_t at = 0; at < list.size(); ++at) { + auto& item = list[at]; + current.store(at); + if (!continuing()) return; + // (Each after the first is downloaded as the first was.) + if (at && !advance(Stage::installing, Stage::downloading)) return; + if (item.off) { + if (!advance(Stage::downloading, Stage::installing)) return; + } else if (!fetch_one(item)) { return; } - try { - // (In the installed one's place when it is the same map at another version. The - // game may hold the old one's files open when it was skated this run: then it - // cannot be replaced until the game is closed.) - launcher_mods::install(root, archive, update, [](float) { continuing(); }, stop_install, {folder, name.owner, true}); - } catch (const std::exception& failure) { - fs::remove(archive, error); - if (!continuing()) return; - return fail(update ? std::string("It could not be replaced while the game runs (") + failure.what() + - "). Close the game, update it from the launcher and join again." - : std::string("It could not be installed: ") + failure.what()); - } - fs::remove(archive, error); if (!continuing()) return; - { - std::lock_guard known(known_mutex); - known_packages.valid = false; - } - } else { - if (!advance(Stage::downloading, Stage::installing)) return; + if (!vetted(item)) return; + any_merge |= item.merge; + any_switch |= item.off || !item.have.empty(); + } + forget_known(); + { + std::lock_guard lock(mutex); + items = list; + } + // One that was here switched off, or at another version, is wanted on now. + if (any_switch) { + if (!continuing()) return; + auto mods_list = mods::scan_mods(mods::engine_data_root()); + if (!mods_list.issue.empty()) return fail("mods.json could not be read: " + mods_list.issue); + for (auto& entry : mods_list.entries) + for (const auto& item : list) + if ((item.off || !item.have.empty()) && same(entry.mod.name, item.folder)) entry.enabled = true; + mods::save_mod_order(mods_list.root, mods_list.entries); + forget_known(); } if (!continuing()) return; - // A map and nothing else: this way in is for maps. - std::error_code error; - if (!fs::exists(root / folder / L"reskate-levels.json", error) || !live_mods::only_maps(root / folder)) { - if (!off) { - try { launcher_mods::remove(root, folder); } catch (const std::exception&) {} - } - return fail("Its package holds more than a map, so it is not installed from here. Install it from the launcher if you trust it."); + if (!any_merge) { + // Plugins alone: the plugin host takes them from the Mods folder itself. + logging::log(logging::Level::info, logging::Channel::assets, "Map download: the plugins are in Mods."); + join_at.store(multiplayer::now_us()); + advance(Stage::installing, Stage::joining); + return; } - if (off || update) { // (a map at another version may have been switched off: it is wanted now) - if (!continuing()) return; - auto list = mods::scan_mods(mods::engine_data_root()); - if (!list.issue.empty()) return fail("mods.json could not be read: " + list.issue); - for (auto& entry : list.entries) - if (same(entry.mod.name, folder)) entry.enabled = true; - mods::save_mod_order(list.root, list.entries); - } - if (!continuing()) return; - logging::log(logging::Level::info, logging::Channel::assets, "Map download: {} is in Mods; applying it.", folder); + logging::log(logging::Level::info, logging::Channel::assets, "Map download: {} is in Mods; applying it.", list.front().folder); apply_started.store(false); apply_wanted.store(true); if (!advance(Stage::installing, Stage::applying)) apply_wanted.store(false); @@ -371,7 +584,7 @@ void offer(const multiplayer::MapNeed& asked) { // on it already: then it finishes, and the new need is turned away below.) if (stage.load() != Stage::idle && !detached.load() && asked.deadline_us && asked.deadline_us != deadline.load()) { lock.unlock(); - fail("Map download cancelled.", true); + fail("Download cancelled.", true); lock.lock(); } // A cancelled worker/atomic live merge must drain before its globals or files are reused. @@ -383,14 +596,11 @@ void offer(const multiplayer::MapNeed& asked) { } pending.reset(); auto idle = Stage::idle; - const auto name = package::parse_name(asked.package); // (Taken before anything is set up, so a second need while this one is in hand is dropped.) if (!stage.compare_exchange_strong(idle, Stage::checking)) { - // Another map is in hand (the session moved on while it came). One at a time: the one + // Another need is in hand (the session moved on while it came). One at a time: the one // being fetched is finished, and the session, which now waits on this other one, is left. - bool other{}; - other = asked.package != need.package; - if (other) { + if (need_key(asked) != need_key(need)) { overlay::notify(overlay::NoticeLevel::warning, "Map changed again", "The " + host_word(asked.server) + " moved on to " + asked.map + " while another map was downloading. Join again when it is done."); leave_session(); @@ -399,21 +609,42 @@ void offer(const multiplayer::MapNeed& asked) { } { need = asked; - if (!name || asked.package == last_failed) { + std::vector list; + bool fetchable = !asked.package.empty() || !asked.plugins.empty(); + if (!asked.package.empty()) { + if (const auto name = package::parse_name(asked.package)) { + Item map; + map.name = *name; + map.have = asked.have; + map.folder = name->folder(); + list.push_back(std::move(map)); + } else { + fetchable = false; + } + } + for (const auto& plugin : asked.plugins) { + auto name = package::parse_name(plugin); + if (!name) { + fetchable = false; + continue; + } + Item item; + item.plugin = true; + item.at_least = std::exchange(name->version, {}); + item.name = std::move(*name); + item.folder = item.name.folder(); + list.push_back(std::move(item)); + } + if (!fetchable || need_key(asked) == last_failed) { // (Said once already, or nothing this can fetch: the plain notice.) - overlay::notify(overlay::NoticeLevel::warning, "Map not installed", - "The " + host_word(asked.server) + (asked.moved ? " changed to " : " is on ") + asked.map + - ", which is not installed on this PC. Install its map mod and join again."); + overlay::notify(overlay::NoticeLevel::warning, needed_title(asked), + needed_text(asked) + (asked.package.empty() ? "Install them and join again." : "Install its map mod and join again.")); stage.store(Stage::idle); leave_session(); return; } - wanted = *name; - version.clear(); - newest.clear(); - description.clear(); - package_bytes = 0; - installed_off = false; + items = std::move(list); + current.store(0); } overlay::set_map_download_icon({}); watch.cancel.store(false); @@ -430,7 +661,7 @@ void offer(const multiplayer::MapNeed& asked) { start(check); } catch (...) { lock.unlock(); - fail("The map download could not be started. Please try joining again.", true); + fail("The download could not be started. Please try joining again.", true); } } @@ -440,12 +671,12 @@ void answer(bool download) noexcept { if (!continuing()) return; const auto now = multiplayer::now_us(); if (stage.load() == Stage::asking && policy::expired(ask_until.load(), now)) { - fail("The map download was not confirmed within two minutes. Please try joining again.", true); + fail("The download was not confirmed within two minutes. Please try joining again.", true); return; } if (!download) { if (stage.load() == Stage::asking || stage.load() == Stage::downloading) - fail("Map download cancelled.", true); + fail("Download cancelled.", true); } else { // Publish the stage and claim its worker together. Cancellation cannot allow // a fresh offer to reuse the globals before this thread starts its worker. @@ -454,7 +685,7 @@ void answer(bool download) noexcept { if (!watch.cancel.load() && stage.compare_exchange_strong(asked, Stage::downloading)) start(fetch); } } catch (...) { - fail("The map download could not be started. Please try joining again.", true); + fail("The download could not be started. Please try joining again.", true); } } @@ -472,12 +703,31 @@ View view() { out.stage = stage.load(); if (out.stage == Stage::idle || out.stage == Stage::checking) return out; std::lock_guard lock(mutex); - out.map = need.map; - out.package = wanted.folder() + (version.empty() ? std::string() : " " + version); - out.author = wanted.owner; - out.version = version; - out.description = description; - out.bytes = package_bytes; + for (const auto& item : items) { + View::Item shown; + shown.name = item.plugin ? plugin_label(item.name.folder()) : need.map; + shown.author = item.name.owner; + shown.version = item.version; + shown.description = item.description; + shown.have = item.have; + shown.plugin = item.plugin; + shown.installed = item.off; + shown.bytes = item.off ? 0 : item.bytes; + out.bytes += shown.bytes; + out.items.push_back(std::move(shown)); + } + out.current = std::min(current.load(), out.items.empty() ? std::size_t{} : out.items.size() - 1); + // The map's own facts, for the card that asks for a map alone. + if (!items.empty() && !items.front().plugin) { + const auto& map = items.front(); + out.map = need.map; + out.package = map.name.folder() + (map.version.empty() ? std::string() : " " + map.version); + out.author = map.name.owner; + out.version = map.version; + out.description = map.description; + out.installed = map.off; + out.have = need.have; + } if (out.stage == Stage::applying) { const auto now = live_mods::progress(); out.step = now.done; @@ -486,8 +736,6 @@ View view() { } out.server = need.server; out.moved = need.moved; - out.installed = installed_off; - out.have = need.have; out.choice = pick_download.load(std::memory_order_relaxed); out.received = watch.received.load(); out.total = watch.total.load(); @@ -522,16 +770,16 @@ void tick() { session.status.find("stopped waiting for the map download") != std::string::npos; // The session stopped waiting while the download was under way: it finishes all the same. if (!session.active && under_way(now) && !detached.exchange(true) && gave_up) - overlay::notify(overlay::NoticeLevel::warning, "Map still downloading", + overlay::notify(overlay::NoticeLevel::warning, need_package().empty() ? "Plugins still downloading" : "Map still downloading", "The " + [] { std::lock_guard lock(mutex); return host_word(need.server); }() + - " stopped waiting for the map. It goes on downloading and installs when it is done; join again then."); + " stopped waiting for the download. It goes on and installs when it is done; join again then."); if (session.active && !detached.load() && now != Stage::joining && multiplayer::map_fetch_deadline() != deadline.load()) - fail("Map download cancelled.", true); + fail("Download cancelled.", true); if (!session.active && !under_way(now) && gave_up) - fail("The server stopped waiting for the map download.", true); + fail("The server stopped waiting for the download.", true); if (continuing() && now == Stage::asking && policy::expired(ask_until.load(), multiplayer::now_us())) - fail("The map download was not confirmed within two minutes. Please try joining again.", true); + fail("The download was not confirmed within two minutes. Please try joining again.", true); now = stage.load(); } // The session leaves the map alone until it has been applied (an update's level is one the @@ -550,15 +798,17 @@ void tick() { } if (!apply_started.load() || live_mods::busy()) return; apply_started.store(false); - std::string folder; + std::vector merged; { std::lock_guard lock(mutex); - folder = wanted.folder(); + for (const auto& item : items) + if (item.merge) merged.push_back(item.folder); } const auto applied = live_mods::applied_mods(); - if (std::none_of(applied.begin(), applied.end(), [&](const std::string& name) { return same(name, folder); })) - return fail("It is installed, but was left out when the mods were applied (" + live_mods::status() + ")."); - // The level list takes the new map on the next ticks, and the session, which has been + for (const auto& folder : merged) + if (std::none_of(applied.begin(), applied.end(), [&](const std::string& name) { return same(name, folder); })) + return fail(folder + " is installed, but was left out when the mods were applied (" + live_mods::status() + ")."); + // The level list takes a new map on the next ticks, and the session, which has been // trying the map all along, loads it then. join_at.store(multiplayer::now_us()); advance(Stage::applying, Stage::joining); @@ -567,10 +817,12 @@ void tick() { if (now == Stage::joining) { const auto waited = multiplayer::now_us() - join_at.load(); if (waited < 2500000) return; - // Still waiting on it: the game has the mod and cannot find the map in it. + // Still waiting on it: the game has the mods and the session still cannot go on. const auto session = multiplayer::model(); if (multiplayer::fetching_map() || (session.active && !session.connected)) { - if (waited >= policy::installed_load_us) fail("It is installed, but the game still cannot load the map. Restart the game and join again."); + if (waited >= policy::installed_load_us) + fail(need_package().empty() ? "They are installed, but the session still does not see them. Restart the game and join again." + : "It is installed, but the game still cannot load the map. Restart the game and join again."); return; } { @@ -579,11 +831,12 @@ void tick() { last_failed.clear(); } if (!continuing() || !stage.compare_exchange_strong(now, Stage::idle)) return; - // The session ended while the map came (the host left, the connection dropped): back to it, + // The session ended while it came (the host left, the connection dropped): back to it, // while it would still have the player. Past its deadline it would not. if (!multiplayer::model().active && (policy::expired(deadline.load(), multiplayer::now_us()) || !multiplayer::queue_command("rejoin", "", ""))) - overlay::notify(overlay::NoticeLevel::info, "Map installed", "Join the session again from the server browser."); + overlay::notify(overlay::NoticeLevel::info, need_package().empty() ? "Plugins installed" : "Map installed", + "Join the session again from the server browser."); } } @@ -598,21 +851,30 @@ std::string package_of(std::string_view asset) { if (asset.empty()) return {}; std::string key(asset); std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); - const auto now = GetTickCount64(); - std::unique_lock lock(known_mutex); - // (Only with no table at all, just after a map was installed, is the Mods folder read here.) - if (!known_packages.valid) { - lock.unlock(); - auto table = read_known_packages(); - lock.lock(); - if (!known_packages.valid) { - known_packages.by_level = std::move(table); - known_packages.read_at = now; - known_packages.valid = true; + return with_known([&](const KnownTable& table) { + const auto found = table.by_level.find(key); + return found != table.by_level.end() ? found->second : std::string{}; + }); +} + +bool has_level(std::string_view asset) { + if (asset.empty()) return false; + std::string key(asset); + std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return with_known([&](const KnownTable& table) { return table.by_level.contains(key); }); +} + +std::vector missing_plugins(const std::vector& required) { + return with_known([&](const KnownTable& table) { + std::vector missing; + for (const auto& wanted : required) { + const auto name = package::parse_name(wanted); + const bool ready = name && std::any_of(table.plugins.begin(), table.plugins.end(), [&](const KnownPlugin& plugin) { + return plugin.enabled && is_package(*name, plugin.folder, plugin.title, plugin.author) && new_enough(plugin.version, name->version); + }); + if (!ready) missing.push_back(wanted); } - } - refresh_known_packages(now); - const auto found = known_packages.by_level.find(key); - return found != known_packages.by_level.end() ? found->second : std::string{}; + return missing; + }); } } diff --git a/Extension/Assets/map_download.h b/Extension/Assets/map_download.h index 6f60a7a0..e5b3f397 100644 --- a/Extension/Assets/map_download.h +++ b/Extension/Assets/map_download.h @@ -3,36 +3,40 @@ #include #include #include +#include -// A map a multiplayer session needs and this PC does not have, fetched from Thunderstore -// while the game runs. The host says which package its map is from (session.h: MapNeed). The -// player is asked; on yes the package is downloaded, installed into Mods and applied as a live -// mod (live_mods.h). The session stays connected meanwhile and loads the map once it is there; -// on no, or when the map cannot be had, the session is left. +// What a multiplayer session needs and this PC does not have, fetched from Thunderstore while +// the game runs: the map, when the host says which package its map is from (session.h: +// MapNeed), and the plugins a dedicated server requires. The player is asked once, on one card, +// for all of it; on yes each package is downloaded and installed into Mods, and those with game +// data are applied as a live mod (live_mods.h). The session stays connected meanwhile and goes +// on once everything is there; on no, or when anything cannot be had, the session is left. // The session waits 30 minutes on its local monotonic clock for the whole of it (checking, // confirmation, download, installation, applying and joining); confirmation gets two of them. // A download that has begun is not cut short when the session stops waiting: it finishes, // installs and is applied, and the player is told to join again. A cancelled worker drains // before another attempt can begin. // -// Only maps come in this way. The package has to be listed as a map in ReSkate's Thunderstore -// community, not deprecated and not rejected by its moderators; what is installed has to be a -// map and nothing else (level bundles and their registration: no cosmetics, no settings, no -// scripts), or it is taken out again. Everything is downloaded from thunderstore.io, whatever -// a host says. +// Only maps and plugins come in this way, whatever a host says. A map has to be listed as a map +// in ReSkate's Thunderstore community and install as a map and nothing else (level bundles and +// their registration: no cosmetics, no settings, no scripts). A plugin has to be listed as a +// plugin there and install as one (a reskate-plugin.json that names Luau code to run); it may +// bring the game data it uses, which the live merge checks as it checks any mod's. A package +// that is deprecated or was rejected by the community's moderators is not fetched, and one that +// installs as anything else is taken out again. Everything is downloaded from thunderstore.io. namespace dingosdk::map_download { enum class Stage { idle, - checking, // asking Thunderstore about the package; nothing is shown yet + checking, // asking Thunderstore about the packages; nothing is shown yet asking, // the card asks: download, or not downloading, installing, applying, // the live merge - joining, // the map is in the game: the session is loading it (or is joined again) + joining, // everything is in the game: the session is going on (or is joined again) }; struct View { Stage stage{}; - std::string map, package; // "Vancouver Plaza", "Sandos-Vancouver_Plaza 2.0.0" + std::string map, package; // "Vancouver Plaza", "Sandos-Vancouver_Plaza 2.0.0"; empty when only plugins are needed std::string author, version, description; // "Sandos", "2.0.0", and what its Thunderstore page says of it // applying: the live merge's steps done of all, and the one in hand. std::size_t step{}, steps{}; @@ -42,11 +46,21 @@ struct View { bool installed{}; // asking: the mod is here but switched off, so nothing is downloaded std::string have; // the version installed here, when the host's is another and replaces it bool choice{true}; // asking: the answer a controller has picked (true: download) - std::uint64_t bytes{}; // asking: the zip's Content-Length; 0 when the server did not say - std::uint64_t received{}, total{}; // downloading; total 0 when the size is not known + std::uint64_t bytes{}; // asking: what all of it downloads (Content-Length); 0 when no server said + std::uint64_t received{}, total{}; // downloading the one in hand; total 0 when the size is not known std::uint32_t yes_bind{}, no_bind{}; // the vote binds, which answer the card + // Everything asked for, the map first when it is needed: what the card lists. + struct Item { + std::string name; // the map's name, or the plugin's ("FPV Drone") + std::string author, version, description; + std::string have; // the version installed here, when it is replaced + bool plugin{}, installed{}; + std::uint64_t bytes{}; // 0 when not known, or nothing is downloaded + }; + std::vector items; + std::size_t current{}; // downloading, installing: the one in hand, an index into items }; -// Client thread: a session just ended over this map. Ignored while another is in hand. +// Client thread: a session is waiting on what it needs. Ignored while another is in hand. void offer(const multiplayer::MapNeed& need); // Any thread. Yes downloads (when asking); no declines, or stops a download under way. void answer(bool download) noexcept; @@ -61,6 +75,13 @@ void tick(); // The Thunderstore package an enabled mod's level comes from (its folder and manifest), for a // host to tell its guests; empty when the level is the game's own or the mod is not a package. std::string package_of(std::string_view asset); +// Whether an enabled mod here has the level, from a package or not (session.h: LevelCheck). +bool has_level(std::string_view asset); +// Which of a dedicated server's required plugins are not ready here: not installed, switched +// off, not a plugin, or older than the version named (session.h: PluginCheck). A plugin counts +// as the package when its folder is the package's (Owner-Name), or its manifest's name and +// author are the package's. +std::vector missing_plugins(const std::vector& required); // Reads the table package_of answers from, on a thread of its own, if it has not been (so the // first map offer does not wait on the Mods folder). Cheap to call each frame; tick() does. void warm_packages() noexcept; diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index 53fa0532..9cfe5fe1 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -1,6 +1,8 @@ #include "commands.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/path_text.h" +#include "Engine/Game/Input/controller_bindings.h" +#include #include namespace dingosdk::console { @@ -231,6 +233,21 @@ const Commands &game_commands() { register_trainer_commands(*result); register_replay_keyframes_commands(*result); register_plugin_commands(*result); + // Each switch a player binds runs a command (controller_bindings.h action_binds): one whose + // command is gone reads "Unknown command" at every press, as "menu" once did. + for (const auto &bind : action_binds) { + bool found{}; + std::string name; + for (std::size_t at = 0; !found && at <= bind.command.size();) { + const auto end = std::min(bind.command.find(' ', at), bind.command.size()); + name = std::string(bind.command.substr(0, end)); + found = result->find(name) != nullptr; + at = end + 1; + } + if (!found) + logging::log(logging::Level::warning, logging::Channel::command, "The {} bind runs \"{}\", which is not a command.", + bind.name, bind.command); + } return result; }(); return *registry; diff --git a/Extension/Customization/local_customization.cpp b/Extension/Customization/local_customization.cpp index 01fefb14..32440dc7 100644 --- a/Extension/Customization/local_customization.cpp +++ b/Extension/Customization/local_customization.cpp @@ -115,11 +115,12 @@ void validate_player_card(const CosmeticLoadout& value) { "Invalid RIP Card slot"); } dingosdk::Json item_display_metadata(const std::string& asset, const dingosdk::Json& overrides) { - // Titles, descriptions and rarities come from the installed content cache. - const auto& items = content_cache::catalogs().items; + // Titles, descriptions and rarities come from the installed content cache. A mod's own + // object takes the title its reskate-objects.json gives it (content_catalogs.h). + const auto& catalogs = content_cache::catalogs(); std::string key = asset; for (auto& c : key) if (c >= 'A' && c <= 'Z') c = static_cast(c + ('a' - 'A')); - Json result = items.value(key, Json::object()); + Json result = catalogs.items.contains(key) ? catalogs.items.at(key) : catalogs.mod_objects.value(key, Json::object()); // User metadata takes precedence field by field, including explicit empty // descriptions. Do not copy unrelated saved inventory data into the catalog. for (const auto* field : {"title", "description", "rarity_id"}) diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 274fe8a2..26873e9b 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -426,6 +426,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 throw std::invalid_argument("Invalid chat message"); if (p.kind == PacketKind::typing && !p.source) throw std::invalid_argument("Invalid typing notice"); + if (p.kind == PacketKind::present && !p.source) + throw std::invalid_argument("Invalid presence notice"); if (p.kind == PacketKind::admin && (!p.source || !valid_admin_text(p.text))) throw std::invalid_argument("Invalid admin message"); if (p.kind == PacketKind::throwdown && (!p.source || p.throwdown.empty() || p.throwdown.size() > max_throwdown_message)) @@ -478,6 +480,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 throw std::invalid_argument("Invalid map name"); if ((p.kind == PacketKind::map_offer || p.kind == PacketKind::world_state) && !valid_map_package(p.map_package)) throw std::invalid_argument("Invalid map package"); + if (!p.required_plugins.empty() && (p.kind != PacketKind::map_offer || !valid_required_plugins(p.required_plugins))) + throw std::invalid_argument("Invalid required plugins"); if (p.map_fetching && (p.world_ready || (p.kind != PacketKind::map_request && p.kind != PacketKind::world_ready))) throw std::invalid_argument("Invalid map fetch notice"); if (p.kind == PacketKind::world_state && @@ -497,7 +501,7 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 p.kind != PacketKind::throwdown && p.kind != PacketKind::teleport && p.kind != PacketKind::physics_tuning && p.kind != PacketKind::party && p.kind != PacketKind::scoring && p.kind != PacketKind::physics_extras && p.kind != PacketKind::effects && p.kind != PacketKind::permissions && p.kind != PacketKind::words && - p.kind != PacketKind::next_maps && p.kind != PacketKind::typing) + p.kind != PacketKind::next_maps && p.kind != PacketKind::typing && p.kind != PacketKind::present) throw std::invalid_argument("Unknown packet kind"); const auto payload = greeting ? 72 : p.kind == PacketKind::away @@ -528,6 +532,11 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end()); w.integer(p.map_package.size(), 1); w.bytes.insert(w.bytes.end(), p.map_package.begin(), p.map_package.end()); + w.integer(p.required_plugins.size(), 1); + for (const auto &plugin : p.required_plugins) { + w.integer(plugin.size(), 1); + w.bytes.insert(w.bytes.end(), plugin.begin(), plugin.end()); + } } if (p.kind == PacketKind::map_request) w.integer(p.map_fetching ? 1 : 0, 1); if (p.kind == PacketKind::hello) { @@ -872,6 +881,14 @@ std::optional decode(std::span bytes) noexcept { r.at += static_cast(length); if (!valid_map_destination(p.destination) || p.map != map_hash(p.destination) || !read_map_label(r, bytes, p)) return {}; + const auto plugins = r.integer(1); + if (plugins > max_required_plugins) return {}; + for (std::uint64_t i = 0; i < plugins; ++i) { + auto plugin = read_text(r, bytes, 1, max_map_package); + if (!plugin) return {}; + p.required_plugins.push_back(std::move(*plugin)); + } + if (!valid_required_plugins(p.required_plugins)) return {}; } if (p.kind == PacketKind::map_request) { const auto fetching = r.integer(1); @@ -1287,6 +1304,8 @@ std::optional decode(std::span bytes) noexcept { } for (std::uint64_t i = 0; i < removed; ++i) chunk.removed.push_back(r.integer(8)); if (!valid_object_chunk(chunk)) return {}; + } else if (p.kind == PacketKind::present) { + if (!p.source) return {}; } else if (p.kind != PacketKind::away) return {}; if (r.at != bytes.size()) @@ -1418,6 +1437,22 @@ bool valid_map_package(std::string_view package) noexcept { if (!package_word(package.substr(0, first)) || !package_word(rest.substr(0, second))) return false; return second == std::string_view::npos || package_version(rest.substr(second + 1)); } +bool valid_required_plugins(const std::vector &packages) noexcept { + if (packages.size() > max_required_plugins) return false; + for (std::size_t i = 0; i < packages.size(); ++i) { + if (packages[i].empty() || !valid_map_package(packages[i])) return false; + // (Twice is twice whatever the version or the case: "A-B" and "a-b-1.0.0" are one package.) + const auto name = packages[i].substr(0, packages[i].size() - map_package_version(packages[i]).size()); + for (std::size_t k = 0; k < i; ++k) { + const auto other = packages[k].substr(0, packages[k].size() - map_package_version(packages[k]).size()); + const auto bare = [](std::string_view text) { return text.ends_with('-') ? text.substr(0, text.size() - 1) : text; }; + const auto a = bare(name), b = bare(other); + if (a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { return (x | 0x20) == (y | 0x20); })) + return false; + } + } + return true; +} std::string map_package_name(std::string_view folder, std::string_view version) { const auto dash = folder.find('-'); if (dash == std::string_view::npos || !package_word(folder.substr(0, dash))) return {}; diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index a9581f7d..e3d9572d 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -27,7 +27,7 @@ namespace dingosdk::multiplayer { constexpr std::size_t max_skater_bones = 512, max_board_bones = 64; constexpr std::size_t max_packet = 24576; constexpr std::size_t packet_header_size = 64; -constexpr std::uint16_t protocol_version = 53; +constexpr std::uint16_t protocol_version = 55; // How long a receiver keeps typing dots after the last "still typing" notice. The sender // clears them after three seconds without input; this only covers a lost "stopped" packet. constexpr std::uint64_t typing_hold_us = 4000000; @@ -152,7 +152,14 @@ enum class PacketKind : std::uint16_t { // Relayed like chat. Not a chat line: receivers draw grey dots above that skater. typing = 35, // 36 was the FPV drone's marker, before the drone became a plugin: not used again. + // A player telling a dedicated server they are at their game: they worked its controls + // lately, though their skater may stand still (flying a plugin's drone). The server counts + // it as activity for its away limit. Nothing but the header; never relayed. + present = 37, }; +// How often a game says it is present (PacketKind::present) while its player works the controls, +// and the least time a server lets pass between two it counts. +constexpr std::uint64_t present_interval_us = 20000000, present_least_us = 10000000; // Packet::party_action. Requests go from a player to whoever hosts, a dedicated server or a // lobby's host (party_player = the other player involved, 0 for leave/open/close); invited // and withdrawn go from the host to the invitee (party_player = the inviter). The host @@ -217,6 +224,12 @@ inline constexpr std::uint64_t map_fetch_limit_us = map_fetch_policy::session_us // maps and for a map mod that is not from Thunderstore. inline constexpr std::size_t max_map_package = 96; bool valid_map_package(std::string_view package) noexcept; +// The plugins a dedicated server needs its players to have, named as map packages are +// ("Owner-Name", or "Owner-Name-1.2.3" for at least that version): each a valid_map_package, +// none empty, none twice. A player's game fetches only what Thunderstore lists as a plugin and +// what runs Luau code, whatever a server names (Extension/Assets/map_download.h). +inline constexpr std::size_t max_required_plugins = 16; +bool valid_required_plugins(const std::vector &packages) noexcept; // That name for a mod folder and the version its manifest gives; empty when the folder is not // named as a package: "Owner-Name", as the launcher installs one, or "Owner-Name-1.2.3", as its // zip unpacks by hand. The manifest's version comes before the folder's; one that is not three @@ -366,6 +379,7 @@ struct Packet { std::uint16_t map_rotation{}; // maps: minutes per map (0: off) std::string map_label; // map_offer, world_state: the map's name for people (may be empty) std::string map_package; // map_offer, world_state: valid_map_package (may be empty) + std::vector required_plugins; // map_offer: valid_required_plugins (a player's lobby: none) std::vector throwdown; // throwdown: one encoded message (1..max_throwdown_message bytes) std::array teleport{}; // teleport: where the receiver goes (world position) std::vector tuning; // physics_tuning: 0..max_physics_tuning bytes diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 93300b1c..51cf868f 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -80,6 +80,7 @@ void reset_peer(Session &s, std::size_t slot) { } void stop(Session &s, std::string reason) { s.local_typing_until = 0; + s.next_presence = s.presence_for = 0; // A join that ends before the host's map was ready never got in. if (s.mode == Mode::join && s.awaiting_map && !s.travelling && s.join_started) trace::record("multiplayer.join", "app.join", std::chrono::milliseconds((now_us() - s.join_started) / 1000), false); @@ -163,6 +164,8 @@ void stop(Session &s, std::string reason) { s.join_destination.clear(); s.map_label.clear(); s.map_package.clear(); + s.required_plugins.clear(); + s.plugins_ready = s.fetching_with_map = false; s.fetching_since = 0; s.map_fetch_deadline = 0; map_fetch_deadline_flag.store(0); @@ -826,6 +829,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui s.nametag_dots); } } // namespace +void note_player_input() noexcept { note_input(); } MultiplayerModel model() { auto &s = session(); std::lock_guard lock(s.mutex); @@ -835,6 +839,10 @@ std::string take_leave_notice() { return std::exchange(session().leave_notice, { std::string take_removed_notice() { return std::exchange(session().removed_notice, {}); } std::optional take_map_need() { return std::exchange(session().map_need, std::nullopt); } void set_map_package_lookup(MapPackageLookup lookup) noexcept { map_package_lookup.store(lookup, std::memory_order_relaxed); } +void set_plugin_check(PluginCheck plugins, LevelCheck levels) noexcept { + plugin_check.store(plugins, std::memory_order_relaxed); + level_check.store(levels, std::memory_order_relaxed); +} bool fetching_map() noexcept { return fetching_map_flag.load(std::memory_order_relaxed); } std::uint64_t map_fetch_deadline() noexcept { return map_fetch_deadline_flag.load(); } void hold_map_load(bool hold, std::string_view package) noexcept { @@ -955,6 +963,45 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade // fetched at the host's version as a missing one is (the downloader puts it in the // installed one's place), and loads when the two agree. const auto have = std::string(other_map_version(s.map_package, host_map_package(s.join_destination))); + // A dedicated server's required plugins come before its map, which does not load until + // they are here. The ones missing are fetched as a missing map is, on one card with the + // map when that is missing too, so the player is asked once. + if (!s.plugins_ready && !s.required_plugins.empty()) { + std::vector missing = s.required_plugins; + try { + if (const auto check = plugin_check.load(std::memory_order_relaxed)) missing = check(s.required_plugins); + } catch (...) {} + if (missing.empty()) { + s.plugins_ready = true; + // (A fetch of plugins alone is over: the map, which is here, loads as usual.) + if (s.fetching_since && !s.fetching_with_map) { + s.fetching_since = 0; + fetching_map_flag.store(false, std::memory_order_relaxed); + } + } else { + if (!s.fetching_since) { + // The map goes on the card too when it is a package that no enabled mod here has. + const auto levels = level_check.load(std::memory_order_relaxed); + bool map_here = s.map_package.empty(); + try { + map_here = map_here || (have.empty() && levels && levels(world_destination_asset(s.join_destination))); + } catch (...) {} + const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label; + s.fetching_since = now; + s.fetching_with_map = !map_here; + if (!s.map_fetch_deadline) s.map_fetch_deadline = now + map_fetch_limit_us; + map_fetch_deadline_flag.store(s.map_fetch_deadline); + fetching_map_flag.store(true, std::memory_order_relaxed); + s.map_need = MapNeed{map_here ? std::string() : s.map_package, name, dedicated_host(s), s.travelling, + map_here ? std::string() : have, s.map_fetch_deadline, std::move(missing)}; + s.rejoin = Session::Rejoin{{s.host_id, s.secret}, s.password, s.map_fetch_deadline}; + s.last_map_request = s.last_world_ready = 0; // say so at once + } + s.join_started = now; + s.status = "Waiting for the server's plugins to download..."; + return false; + } + } // (Nor while its files are being put in place and applied: hold_map_load.) const bool held = !s.map_package.empty() && map_load_held.load() == map_hash(s.map_package); const auto result = have.empty() && !held ? loader(s.join_destination, s.map_load_submitted, detail) : MapLoadResult::missing; diff --git a/Extension/Multiplayer/Session/session.h b/Extension/Multiplayer/Session/session.h index aa071694..e36565d0 100644 --- a/Extension/Multiplayer/Session/session.h +++ b/Extension/Multiplayer/Session/session.h @@ -25,6 +25,9 @@ using MapLoader = MapLoadResult (*)(std::string_view map, bool submitted, std::s void tick(std::uintptr_t base, std::uintptr_t client, bool gameplay_ready, std::string_view map, MapLoader loader = nullptr); MultiplayerModel model(); +// The player worked the game's controls just now (overlay.h controls_touched_at). While they do, a +// dedicated server is told they are at their game (PacketKind::present). Any thread. +void note_player_input() noexcept; std::string take_leave_notice(); // why the session just ended over a missing map, once; client thread // What the host or server said as it put the player out of the session (kicked, banned, away too // long, a mod or a cheat it does not allow), or refused to let them in for it; once. Client thread. @@ -38,7 +41,7 @@ std::string take_removed_notice(); // cannot be had. Should the session end meanwhile for another reason, // queue_command("rejoin", "", "") goes back to it, with the password it was joined with. struct MapNeed { - std::string package; // protocol.h: valid_map_package, never empty + std::string package; // protocol.h: valid_map_package; empty when only plugins are needed std::string map; // its name for people bool server{}; // a dedicated server's map, not a player's lobby bool moved{}; // the session changed to this map with the player in it (else: joining) @@ -47,6 +50,9 @@ struct MapNeed { std::string have; // Local steady-clock deadline, shared with the downloader, never transmitted. std::uint64_t deadline_us{}; + // The plugins a dedicated server needs that this PC does not have ready (protocol.h: + // valid_required_plugins), asked for on the same card as the map; empty when only the map is. + std::vector plugins; }; std::optional take_map_need(); // Whether a session is waiting on a map being fetched right now. Thread-safe. @@ -70,6 +76,14 @@ struct NextMapWanted { std::vector next_maps(); unsigned next_maps_serial() noexcept; void set_map_package_lookup(MapPackageLookup lookup) noexcept; +// A dedicated server's required plugins (Packet::required_plugins) are checked before its map +// loads. PluginCheck answers which of them this PC does not have ready: not installed, switched +// off, or older than the version named. LevelCheck answers whether an enabled mod here has a +// level (its asset), from a package or not, so a missing map goes on the same card. Both are +// called on the client thread every half second while joining; they should be quick. +using PluginCheck = std::vector (*)(const std::vector &required); +using LevelCheck = bool (*)(std::string_view asset); +void set_plugin_check(PluginCheck plugins, LevelCheck levels) noexcept; // The session's chat lines for the overlay. Thread-safe and cheap: callable every frame. MultiplayerChat chat(); // A chat line only this player sees, from a plugin: shown under the plugin's name with a diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 7d18344e..50bc7fa6 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -262,6 +262,10 @@ struct Session { unsigned server_map_rotation{}; // minutes per map (0: off) std::string map_label; // the host's name for join_destination (may be empty) std::string map_package; // the Thunderstore package it is from (may be empty) + // A dedicated server's required plugins (map offer), and whether this PC has them all ready. + std::vector required_plugins; + bool plugins_ready{}; + bool fetching_with_map{}; // the fetch under way brings the map too // The map is not installed and is being fetched (take_map_need): since when, 0 for not. // The host is told with what is sent while waiting (Packet::map_fetching). std::uint64_t fetching_since{}; @@ -320,6 +324,8 @@ struct Session { // Grey dots above this skater until this time. The overlay sets it while the chat // input is changing, and clears it after three seconds idle or when the box closes. std::uint64_t local_typing_until{}; + // The next presence notice may go then, for input after `presence_for` (update_presence). + std::uint64_t next_presence{}, presence_for{}; float chat_bubbles_distance = 40.f, chat_bubbles_duration = 5.f; int chat_bubbles_history = 3; bool game_menu{}; // a game menu is up or the game's UI is hidden: no chat on screen @@ -479,6 +485,8 @@ std::string answer_server_poll(Session &s, std::size_t answer); // Whether a vote the local player may answer is running: read by the game thread for the binds. inline std::atomic server_vote_open_flag{}; inline std::atomic map_package_lookup{}; +inline std::atomic plugin_check{}; +inline std::atomic level_check{}; inline std::atomic fetching_map_flag{}; inline std::atomic map_fetch_deadline_flag{}, map_load_held{}; // A guest without the map is downloading it: the clock they are loading against starts over @@ -546,6 +554,10 @@ void update_physics_tuning(Session &s, const NativeFrame &local, std::uint64_t n // How the local mods change trick scoring (Engine/Vfs/mod_scoring.h). Guest: reported to the host // or dedicated server once known and whenever it changes. Host: judged like a guest's. void update_scoring(Session &s, std::uint64_t now); +// Guest of a dedicated server: tells it the player is at their game while they work the controls +// (note_player_input), every present_interval_us at most. +void update_presence(Session &s, std::uint64_t now); +void note_input() noexcept; // (note_player_input) // Host: flags or clears a guest from its report and the host's choice. void judge_scoring(Session &s, Peer &peer); // The chat line for the local player's own mods changing scoring or physics. diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index b776143e..04e04e56 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -774,6 +774,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { s.join_destination = p.destination; s.map_label = p.map_label; s.map_package = p.map_package; + // (Only a dedicated server needs plugins of its players.) + if (dedicated_host(s)) s.required_plugins = p.required_plugins; s.world = p.world; s.join_map_authorized |= p.map_authorized; link->last_packet = now; @@ -1219,6 +1221,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { } } update_scoring(s, now); // before the roster, so a host's own flag goes out with it + update_presence(s, now); tick_host_parties(s, now); // and the lobby's parties, which the roster carries if (s.mode == Mode::host && world_playing(s, local) && (s.roster_dirty || now - s.last_roster > 2000000)) send_roster(s, now); diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 56fe1075..f5cb9ef9 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -11,6 +11,7 @@ #include "Engine/Vfs/mod_scoring.h" #include "Extension/Multiplayer/Remote/remote_collision.h" #include +#include #include #include @@ -313,6 +314,23 @@ void send_typing(Session &s, bool on) { message.typing = on; broadcast(s, message, true, false, now); } +namespace { +std::atomic player_input_at{}; +} +void note_input() noexcept { player_input_at.store(now_us(), std::memory_order_relaxed); } +// A dedicated server counts a player away while nothing they do reaches it: their skater's moves, +// chat, voice or objects. One whose skater stands still while they play (a plugin's drone or +// camera) says so here. +void update_presence(Session &s, std::uint64_t now) { + if (!dedicated_host(s) || now < s.next_presence) return; + const auto input = player_input_at.load(std::memory_order_relaxed); + if (!input || input <= s.presence_for) return; // nothing worked since the last notice + const auto *host = find_peer(s, s.host_id); + if (!host || !host->handshaken) return; + if (!send_packet(s, s.host_id, packet(s, PacketKind::present, now), false, true)) return; + s.next_presence = now + present_interval_us; + s.presence_for = input; +} void send_throwdown(Session &s, std::vector message) { if ((s.mode != Mode::host && s.mode != Mode::join) || message.empty() || message.size() > max_throwdown_message) return; const auto now = now_us(); diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 02add6ff..f593d6dc 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -692,6 +692,22 @@ void chat_codec() { auto bad = encode(typing); bad.back() = 2; // the on/off byte; anything but 0 or 1 is refused check(!decode(bad), "A typing notice with a bad flag decoded"); + // At the game: nothing but the header, from a sender. + Packet present; + present.kind = PacketKind::present; present.session = 9; present.epoch = 10; present.map = 11; + present.source = 76561198000000001ULL; + const auto bytes_present = encode(present); + const auto here = decode(bytes_present); + check(here && here->kind == PacketKind::present && here->source == present.source && bytes_present.size() == packet_header_size, + "A presence notice failed to round-trip, or carried more than its header"); + check(decode_wire(encode_wire(present)) && decode_wire(encode_wire(present))->kind == PacketKind::present, + "A presence notice failed the wire codec"); + auto longer = bytes_present; + longer.push_back(0); + longer[8] = 1; // its payload length + check(!decode(longer), "A presence notice with a payload decoded"); + present.source = 0; + check(reject(present), "A presence notice without a sender was encoded"); } // Linked throwdowns: the packet carries one opaque message; the message codec is strict. void throwdown_codec() { @@ -1169,6 +1185,28 @@ void dedicated_server_codec() { const auto offer_arrived = decode_wire(encode_wire(offered)); check(offer_arrived && offer_arrived->map_package == "Sandos-Vancouver_Plaza" && offer_arrived->destination == offered.destination, "The map's package lost from a map offer"); + // A dedicated server's required plugins ride on its map offer. + check(offer_arrived && offer_arrived->required_plugins.empty(), "A map offer without plugins arrived with some"); + auto requiring = offered; + requiring.required_plugins = {"neekgate-FPV_Drone", "Team-Filmer-1.2.0"}; + const auto required_arrived = decode_wire(encode_wire(requiring)); + check(required_arrived && required_arrived->required_plugins == requiring.required_plugins && + required_arrived->map_package == "Sandos-Vancouver_Plaza", + "The required plugins lost from a map offer"); + for (const auto &bad : std::vector>{{""}, {"Filmer"}, {"Team-Filmer", "team-filmer-1.0.0"}, + {"Team-A/B"}, std::vector(max_required_plugins + 1, "Team-X")}) { + auto wrong = offered; + wrong.required_plugins = bad; + check(reject(wrong) && !valid_required_plugins(bad), "A required plugin list that is not one encoded"); + } + { + std::vector sixteen; + for (std::size_t i = 0; i < max_required_plugins; ++i) sixteen.push_back("Team-Plugin_" + std::to_string(i)); + check(valid_required_plugins(sixteen) && valid_required_plugins({}), "A full required plugin list refused"); + } + auto wrong_kind = packaged; + wrong_kind.required_plugins = {"Team-Filmer"}; + check(reject(wrong_kind), "Required plugins encoded on something other than a map offer"); for (const char *bad : {"Sandos", "Sandos-", "-Plaza", "San dos-Plaza", "Sandos-Plaza-2.0", "Sandos-Plaza-2.0.0.1", "../x-y", "Sandos-Pla/za", "Sandos-Plaza-1.2.3-4", "https://thunderstore.io/x-y"}) { auto wrong = packaged; diff --git a/Extension/Objects/Test/object_categories_tests.cpp b/Extension/Objects/Test/object_categories_tests.cpp index aae0a887..7d6d8162 100644 --- a/Extension/Objects/Test/object_categories_tests.cpp +++ b/Extension/Objects/Test/object_categories_tests.cpp @@ -56,6 +56,86 @@ int main() { check(objects::browser_tile("own_bkprops_unknown_00001", catalogs, groups) == "own_bkprops_unknown_00001", "unplaced tile is the object itself"); + // A mod's reskate-objects.json: a category and families of its own, a family in the game's Rail + // category and an object in the game's A Frame family. It cannot rename or regroup the game's items. + { + auto modded = catalogs; + const auto refused = content_cache::add_mod_objects(modded, R"({"schema": 1, + "categories": [{"id": "category_shoebox", "title": "Shoebox Pads"}, {"id": "category_ramp"}], + "families": [ + {"id": "qdparent_shoebox_dc", "title": "DC Shoebox", "category": "category_shoebox"}, + {"id": "qdparent_shoebox_etnies", "title": "Etnies Shoebox", "category": "category_shoebox", "priority": 0}, + {"id": "qdparent_mod_rail", "title": "Mod Rail", "category": "category_rail"}, + {"id": "qdparent_aframe", "category": "category_shoebox"}, + {"id": "qdparent_orphan", "category": "category_missing"}], + "objects": [ + {"key": "own_bkrails_shoebox_dc_12m", "title": "DC Shoebox 12m", "family": "qdparent_shoebox_dc"}, + {"key": "OWN_BKRAILS_SHOEBOX_DC_9M_RED", "title": "DC Shoebox 9m, Red", "family": "qdparent_shoebox_dc"}, + {"key": "own_bkrails_shoebox_dc_6m_blue", "family": "qdparent_shoebox_dc"}, + {"key": "own_bkrails_shoebox_etnies_12m", "title": "Etnies Shoebox 12m", "family": "qdparent_shoebox_etnies"}, + {"key": "own_bkramps_mod_aframe_00001", "title": "A Frame, Mod", "family": "qdparent_aframe"}, + {"key": "own_bkprops_named_00001", "title": "Named Prop"}, + {"key": "own_bkramps_aframe_long_00001", "title": "Renamed", "family": "qdparent_shoebox_dc"}, + {"key": "own_bkrails_shoebox_dc_12m", "title": "Twice"}, + {"key": "own_bkprops_lost_00001", "family": "qdparent_missing"}, + {"key": "bkprops_no_prefix", "title": "No prefix"}]})"); + check(refused.size() == 7, "taken ids, the game's item, a repeat, a missing family and a bad key refused"); + const auto* shoebox = &modded.object_categories.back(); + check(shoebox->id == "category_shoebox" && shoebox->title == "Shoebox Pads" && shoebox->quick_drop && + shoebox->priority == 3 && shoebox->groups.size() == 2, "mod category after the game's, in the browser"); + check(shoebox->groups[0].priority == 1 && shoebox->groups[1].priority == 0, "family priorities"); + const auto& rail = modded.object_categories[1]; + check(rail.groups.size() == 2 && rail.groups[1].id == "qdparent_mod_rail" && rail.groups[1].priority == 1, + "mod family in the game's category"); + check(modded.items.at("own_bkramps_aframe_long_00001").value("title", "") == "A Frame, Long" && + !modded.mod_objects.contains("own_bkramps_aframe_long_00001"), "the game's item keeps its name and family"); + check(modded.mod_objects.at("own_bkrails_shoebox_dc_12m").value("title", "") == "DC Shoebox 12m" && + modded.mod_objects.at("own_bkrails_shoebox_dc_9m_red").value("group", "") == "qdparent_shoebox_dc" && + modded.mod_objects.at("own_bkrails_shoebox_dc_9m_red").value("object_type", "") == "QuickDrop", + "first name wins, keys are lower case, family objects are Quick Drop objects"); + check(!modded.mod_objects.at("own_bkprops_named_00001").contains("group"), "a title alone places nothing"); + check(!modded.reserved("own_bkrails_shoebox_dc_12m"), "mod objects are never store items"); + + const std::vector installed{ + {"own_bkramps_aframe_long_00001", "bkramps", "A Frame, Long"}, + {"own_bkramps_aframe_short_00001", "bkramps", "A Frame, Short"}, + {"own_bkrails_flat_00001", "bkrails", "Flat"}, + {"own_bkvertbasesmall_00001", "bkvertbasesmall", "Bowl"}, + {"own_bkrails_shoebox_dc_12m", "bkrails", "DC Shoebox 12m"}, + {"own_bkrails_shoebox_dc_6m_blue", "bkrails", "own bkrails shoebox dc 6m blue"}, + {"own_bkrails_shoebox_dc_9m_red", "bkrails", "DC Shoebox 9m, Red"}, + {"own_bkrails_shoebox_etnies_12m", "bkrails", "Etnies Shoebox 12m"}, + {"own_bkramps_mod_aframe_00001", "bkramps", "A Frame, Mod"}, + {"own_bkprops_named_00001", "bkprops", "Named Prop"}, + }; + const auto mod_layout = objects::browser_layout(installed, modded); + check(mod_layout.size() == 5 && mod_layout[2].id == "category_shoebox" && mod_layout[4].id == "bkprops", + "mod category among the browser categories, by priority"); + if (const auto* pads = find(mod_layout, "category_shoebox")) { + check(pads->tiles.size() == 2 && pads->tiles[0].id == "qdparent_shoebox_etnies" && + pads->tiles[1].id == "qdparent_shoebox_dc", "one tile per mod family"); + check(pads->tiles.size() == 2 && pads->tiles[1].items.size() == 3 && + pads->tiles[1].items[0] == "own_bkrails_shoebox_dc_12m", "a family's objects share its tile"); + } + if (const auto* ramp = find(mod_layout, "category_ramp")) + check(ramp->tiles.size() == 1 && ramp->tiles[0].items.size() == 3, "mod object in the game's family"); + const auto mod_groups = objects::object_groups(modded); + check(objects::browser_tile("OWN_BKRAILS_SHOEBOX_DC_9M_RED", modded, mod_groups) == "qdparent_shoebox_dc", + "mod object tile lookup ignores case"); + + const auto unchanged = [&](const char* json) { + auto copy = catalogs; + try { content_cache::add_mod_objects(copy, json); } catch (const std::exception&) { + return copy.mod_objects.size() == 0 && copy.object_categories.size() == catalogs.object_categories.size(); + } + return false; + }; + check(unchanged(R"({"schema": 2, "objects": []})"), "another schema is refused"); + check(unchanged(R"({"schema": 1, "categories": [{"id": "category_new"}], "objects": {}})"), + "a file that is not this shape adds nothing"); + check(unchanged("not json"), "a file that is not JSON is refused"); + } + if (content_cache::installed()) { const auto& cache = content_cache::catalogs(); std::vector browser; diff --git a/Extension/Objects/object_categories.cpp b/Extension/Objects/object_categories.cpp index 1a5fbf28..8157db37 100644 --- a/Extension/Objects/object_categories.cpp +++ b/Extension/Objects/object_categories.cpp @@ -23,8 +23,10 @@ std::map> object_groups(const content_cache: Placement object_placement(std::string_view key, const content_cache::Catalogs& catalogs, const std::map>& groups) { const auto id = lower(key); - if (!catalogs.items.is_object() || !catalogs.items.contains(id)) return {}; - const auto& item = catalogs.items.at(id); + // The game's own record, or the family a mod gives its object (reskate-objects.json). + const auto& records = catalogs.items.contains(id) ? catalogs.items : catalogs.mod_objects; + if (!records.contains(id)) return {}; + const auto& item = records.at(id); if (!item.is_object() || !item.contains("group") || !item.at("group").is_string()) return {}; const auto found = groups.find(item.at("group").string()); return found == groups.end() ? Placement{} : found->second; diff --git a/Extension/Objects/object_categories.h b/Extension/Objects/object_categories.h index c92ca529..7f8f7f62 100644 --- a/Extension/Objects/object_categories.h +++ b/Extension/Objects/object_categories.h @@ -6,7 +6,8 @@ #include // The Object Browser's category tree, taken from the game's category service -// records in the content cache. Objects the cache does not place fall back to +// records in the content cache and the families mods declare for their own +// objects (content_cache::add_mod_objects). Objects neither places fall back to // one tile each under their installed family. namespace dingosdk::objects { struct InstalledObject { diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 0391a85e..be2901b2 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -1,5 +1,6 @@ // The plugin host apart from the game: manifests, the reskate API, storage, modules, hot reload, // and the sandbox against plugins written to break it (loops, memory, error floods, escapes). +#include "Extension/Plugins/plugin_content.h" #include "Extension/Plugins/plugin_discovery.h" #include "Extension/Plugins/plugin_host.h" #include "Extension/Plugins/plugin_manifest.h" @@ -546,6 +547,10 @@ struct FakeGame final : Game { calls.push_back(std::format("announce {} {}", plugin, text)); return {}; } + std::optional role(std::uint64_t player) override { + if (player == 1) return Role{"moderator", 50, true}; + return std::nullopt; + } std::vector server_parks() override { return {{"street", "San Vansterdam", true, true, 12, 5}, {"plaza", "Isle of Grom", false, false, 0, 0}}; } std::string spawn_park(std::string_view plugin, std::string_view name, unsigned minutes) override { calls.push_back(std::format("park spawn {} {} {}", plugin, name, minutes)); @@ -555,6 +560,40 @@ struct FakeGame final : Game { calls.push_back(std::format("park remove {} {}", plugin, name)); return {}; } + std::string server_hide(std::string_view plugin, std::uint64_t target, const std::vector& viewers, const Hiding& what) override { + calls.push_back(std::format("hide {} {} from {} skater {} voice {} sound {}", plugin, target, players_text(viewers), what.skater, what.voice, + what.sound)); + return target == 9 ? "that player is not on the server" : std::string{}; + } + std::string server_unhide(std::string_view plugin, std::uint64_t target, const std::vector& viewers) override { + calls.push_back(std::format("unhide {} {} from {}", plugin, target, players_text(viewers))); + return {}; + } + static std::string players_text(const std::vector& players) { + std::string text; + for (const auto id : players) text += (text.empty() ? "" : ",") + std::to_string(id); + return text.empty() ? "all" : text; + } + std::string server_card(std::string_view plugin, const std::vector& players, std::string_view text, unsigned seconds) override { + calls.push_back(std::format("card {} {} {} {}", plugin, players_text(players), text, seconds)); + return {}; + } + std::uint32_t next_poll = 41; + std::string server_poll(std::string_view plugin, const std::vector& players, std::string_view question, + const std::vector& answers, unsigned seconds, std::uint32_t& id) override { + std::string listed; + for (const auto& answer : answers) listed += (listed.empty() ? "" : "|") + answer; + calls.push_back(std::format("poll {} {} {} {} {}", plugin, players_text(players), question, listed, seconds)); + if (players == std::vector{9}) return "that player is not on the server"; + id = next_poll++; + return {}; + } + std::string server_poll_close(std::string_view plugin, std::uint32_t id) override { + calls.push_back(std::format("poll close {} {}", plugin, id)); + return {}; + } + std::vector poll_news; + std::vector take_poll_news() override { return std::exchange(poll_news, {}); } std::optional camera_view() override { return CameraView{{1, 2, 3}, {0, 0, 1}, 60}; } std::string set_camera(std::string_view plugin, const std::array& pose, float fov) override { calls.push_back(std::format("camera {} {:.1f} {:.1f} {:.1f} back {:.2f} {:.2f} {:.2f} fov {:.0f}", plugin, pose[12], pose[13], pose[14], @@ -1741,11 +1780,17 @@ void plugins_draw_on_the_overlay() { local on = d:world_to_screen(vector.create(1, 0, -10)) assert(on and math.abs(on.x - 1060) < 0.01 and math.abs(on.z - 10) < 0.01, "world_to_screen") assert(d:world_to_screen(vector.create(0, 0, 5)) == nil, "behind the camera") + -- Just in front of the camera and off to the side the point is millions of pixels + -- out, which d:poly would refuse. + assert(d:world_to_screen(vector.create(1000, 0, -0.06)) == nil, "far off the screen") + assert(d:world_to_screen(vector.create(0, 1000, -0.06)) == nil, "far above the screen") + assert(d:world_to_screen(vector.create(1, 0, -0.06)), "off the screen but drawable") assert(d:size().x == 1920 and d:size().y == 1080) assert(not pcall(d.text, d, vector.create(0, 0, 0), "x", {colour = "red"}), "a colour by name") assert(not pcall(d.text, d, vector.create(0 / 0, 0, 0), "x"), "NaN") assert(not pcall(d.poly, d, {vector.create(0, 0, 0)}), "a polygon of one point") assert(not pcall(d.text, d, vector.create(0, 0, 0), string.rep("x", 600)), "a long text") + reskate.log("drawn") end) reskate.console.command("outside", "x", function() local ok, err = pcall(saved.text, saved, vector.create(0, 0, 0), "x") @@ -1769,6 +1814,7 @@ void plugins_draw_on_the_overlay() { running(host, "painter", environment); check(environment.layers.size() == 1 && environment.layers[0].plugin == "painter" && environment.layers[0].shapes.size() == 8, "Its shapes are handed to the overlay each tick\n" + environment.all()); + check(environment.logged("drawn"), "and the draw handler ran to its end\n" + environment.all()); const auto& first = environment.layers[0].shapes[0]; check(first.kind == plugin_ui::Shape::Kind::text && first.text == "hello" && first.style.colour == 0xff0080ff && first.style.size == 24 && first.style.align == 1, "with their style (colours as ImGui keeps them)"); @@ -2070,6 +2116,53 @@ void plugins_server_copies_are_the_authority() { // A dedicated server's park mods, for a server copy with server.parks: listed, spawned and removed // as the server's park-mod does. Not for a copy without the permission, nor for a player's copy. +// One player hidden from others, for a server copy with "server": wholly (skater, name, voice, +// sound) or only their voice or sound, from a player, a list of them or "all", and shown again. +// Not for a copy without the permission, nor for a player's copy. +void plugins_server_copies_hide_players() { + TestEnvironment server, player, blind; + const std::string main = R"( + reskate.console.command("hide", "x", function() + assert(reskate.server.hide("7", "all")) + assert(reskate.server.hide({id = "7"}, {"8", {id = "5"}})) + assert(reskate.server.hide("7", "8", {skater = false, voice = true, sound = false})) + assert(reskate.server.hide("7", "8", {skater = false, sound = true})) + assert(reskate.server.hide("7", "8", {skater = true, name = true})) + local ok, why = reskate.server.hide("9", "8") + assert(not ok and why == "that player is not on the server", tostring(why)) + assert(reskate.server.unhide("7", "8")) + assert(reskate.server.unhide("7")) + assert(not pcall(reskate.server.hide, "7"), "who it is hidden from") + assert(not pcall(reskate.server.hide, "7", {}), "at least one viewer") + assert(not pcall(reskate.server.hide, "7", "8", {skater = false, voice = false, sound = false}), "it hides something") + assert(not pcall(reskate.server.hide, "7", "8", {skater = true, voice = false}), "a hidden skater's voice goes too") + assert(not pcall(reskate.server.hide, "7", "8", {skater = false, name = true}), "the name goes with the skater") + assert(not pcall(reskate.server.hide, "7", "8", {colour = true}), "only skater, name, voice and sound") + assert(not pcall(reskate.server.hide, "7", "8", {skater = "yes"}), "true or false") + assert(not pcall(reskate.server.unhide, "nobody"), "a player") + reskate.log("hide done") + end) + )"; + const auto hider = [&](const char* id, const char* permissions, const char* side) { + return package(id, {{manifest_file, manifest(permissions, side)}, {"plugin/main.lua", main}}); + }; + Host server_host(server, Side::server), player_host(player, Side::client), blind_host(blind, Side::server); + server_host.apply({hider("hide-s", R"("console", "server")", "server")}); + player_host.apply({hider("hide-p", R"("console", "server")", "client")}); + blind_host.apply({hider("hide-b", R"("console")", "server")}); + for (auto* host : {&server_host, &player_host, &blind_host}) host->run_console("hide"); + check(server.logged("[hide-s] hide done"), "A server copy could not hide players\n" + server.all()); + for (const auto* call : {"hide hide-s 7 from all skater true voice true sound true", "hide hide-s 7 from 8,5 skater true voice true sound true", + "hide hide-s 7 from 8 skater false voice true sound false", "hide hide-s 7 from 8 skater false voice true sound true", + "hide hide-s 9 from 8 skater true voice true sound true", "unhide hide-s 7 from 8", "unhide hide-s 7 from all"}) + check(server.fake.called(call), std::string("The server was asked: ") + call + "\n" + server.all()); + check(!server.fake.called("hide hide-s 7 from 8 skater false voice false sound false") && !server.fake.called("unhide hide-s 0"), + "A refused call reached the server"); + check(!player.logged("hide done") && player.all().find("server copy") != std::string::npos, "A player's copy hid players\n" + player.all()); + check(!blind.logged("hide done") && blind.all().find("\"server\"") != std::string::npos, "A plugin without server hid players\n" + blind.all()); + running(server_host, "hide-s", server); +} + void plugins_server_copies_spawn_park_mods() { TestEnvironment server, player, blind; const std::string main = R"( @@ -2108,6 +2201,103 @@ void plugins_server_copies_spawn_park_mods() { "A plugin without server.parks used the park mods\n" + blind.all()); } +// A player's role, for a server copy with server: read-only, nil for one with none. Not for a copy +// without the permission, nor for a player's copy. +void plugins_server_copies_see_roles() { + TestEnvironment server, player, blind; + for (auto* environment : {&server, &player, &blind}) environment->session_now = {true, false, {{1, "Ann", false, false}, {2, "Bob", false, false}}}; + const std::string main = R"( + reskate.console.command("roles", "x", function() + local ann, bob = reskate.session.players()[1], reskate.session.players()[2] + local role = ann.role + assert(role and role.name == "moderator" and role.rank == 50 and role.staff == true) + assert(not pcall(function() role.rank = 1000 end), "read-only") + assert(bob.role == nil, "nil with no role") + reskate.log("roles done") + end) + )"; + const auto roles = [&](const char* id, const char* permissions, const char* side) { + return package(id, {{manifest_file, manifest(permissions, side)}, {"plugin/main.lua", main}}); + }; + Host server_host(server, Side::server), player_host(player, Side::client), blind_host(blind, Side::server); + server_host.apply({roles("roles-s", R"("console", "session.read", "server")", "server")}); + player_host.apply({roles("roles-p", R"("console", "session.read", "server")", "client")}); + blind_host.apply({roles("roles-b", R"("console", "session.read")", "server")}); + // a tick first: the host takes the session's players then (Host::follow_session), so they are there to ask + ticks(server_host, server, 1); + ticks(player_host, player, 1); + ticks(blind_host, blind, 1); + for (auto* host : {&server_host, &player_host, &blind_host}) host->run_console("roles"); + check(server.logged("[roles-s] roles done"), "A server copy could not read the players' roles\n" + server.all()); + check(!player.logged("roles done") && player.all().find("server copy") != std::string::npos, + "A player's copy read the players' roles\n" + player.all()); + check(!blind.logged("roles done") && blind.all().find("\"server\"") != std::string::npos, + "A plugin without server read the players' roles\n" + blind.all()); +} + +// Cards and polls on players' screens, for a server copy with "server": for a player, a list of +// them or "all", with checked text, and each answer or close coming back as an event to the plugin +// that asked. Not for a copy without the permission, nor for a player's copy. +void plugins_server_copies_show_cards_and_polls() { + TestEnvironment server, player, blind; + const std::string main = R"( + local heard = pcall(reskate.on, "poll_answer", function(e) + reskate.log(string.format("answer %d %s %d %s", e.id, e.player, e.answer, e.text)) + end) + pcall(reskate.on, "poll_closed", function(e) reskate.log(string.format("closed %d %s %s", e.id, e.player, e.why)) end) + if not heard then reskate.log("no poll events") end + reskate.console.command("cards", "x", function() + assert(reskate.server.card("all", "Race in 30 s")) + assert(reskate.server.card({"7", {id = "8"}}, "Ready?", 5)) + assert(reskate.server.card({id = "7"}, "Go")) + local id = assert(reskate.server.poll("all", "Next track?", {"Docks", "Hill"}, {seconds = 20})) + assert(id == 41, tostring(id)) + assert(reskate.server.poll("7", "Again?", {"Yes", "No", "Later"}) == 42) + local none, why = reskate.server.poll("9", "Anyone?", {"a", "b"}) + assert(none == nil and why == "that player is not on the server", tostring(why)) + assert(reskate.server.poll_close(41)) + assert(not pcall(reskate.server.card, "all", ""), "a card says something") + assert(not pcall(reskate.server.card, "all", string.rep("x", 121)), "120 characters at most") + assert(not pcall(reskate.server.card, "all", "x", 0), "1 to 60 s") + assert(not pcall(reskate.server.card, "all", "x", 61), "1 to 60 s") + assert(not pcall(reskate.server.card, "everyone", "x"), "a player, a list of them or all") + assert(not pcall(reskate.server.card, {}, "x"), "at least one player") + assert(not pcall(reskate.server.poll, "all", "", {"a", "b"}), "a question") + assert(not pcall(reskate.server.poll, "all", "q?", {"only one"}), "2 to 6 answers") + assert(not pcall(reskate.server.poll, "all", "q?", {"1", "2", "3", "4", "5", "6", "7"}), "2 to 6 answers") + assert(not pcall(reskate.server.poll, "all", "q?", {"a", string.rep("x", 49)}), "48 characters an answer at most") + assert(not pcall(reskate.server.poll, "all", "q?", {"a", ""}), "an answer says something") + assert(not pcall(reskate.server.poll, "all", "q?", {"a", "b"}, {seconds = 4}), "5 to 300 s") + assert(not pcall(reskate.server.poll_close, "x"), "a poll's id") + reskate.log("cards done") + end) + )"; + const auto cards = [&](const char* id, const char* permissions, const char* side) { + return package(id, {{manifest_file, manifest(permissions, side)}, {"plugin/main.lua", main}}); + }; + Host server_host(server, Side::server), player_host(player, Side::client), blind_host(blind, Side::server); + server_host.apply({cards("cards-s", R"("console", "server")", "server")}); + player_host.apply({cards("cards-p", R"("console", "server")", "client")}); + blind_host.apply({cards("cards-b", R"("console")", "server")}); + for (auto* host : {&server_host, &player_host, &blind_host}) host->run_console("cards"); + check(server.logged("[cards-s] cards done"), "A server copy could not show cards and polls\n" + server.all()); + for (const auto* call : {"card cards-s all Race in 30 s 0", "card cards-s 7,8 Ready? 5", "card cards-s 7 Go 0", + "poll cards-s all Next track? Docks|Hill 20", "poll cards-s 7 Again? Yes|No|Later 0", "poll close cards-s 41"}) + check(server.fake.called(call), std::string("The server was asked: ") + call + "\n" + server.all()); + check(!server.fake.called("card cards-s all 0") && !server.fake.called("poll cards-s all q? only one 0"), "A refused call reached the server"); + // Answers and closes, each to the plugin that asked. + server.fake.poll_news = {{"cards-s", 41, 7, 2, "Hill", ""}, {"cards-s", 42, 7, 0, "", "timeout"}, {"someone", 50, 8, 1, "Docks", ""}}; + ticks(server_host, server, 1); + check(server.logged("[cards-s] answer 41 7 2 Hill") && server.logged("[cards-s] closed 42 7 timeout"), + "The plugin hears its polls answered and closed\n" + server.all()); + check(!server.logged("answer 50"), "and only its own"); + check(!player.logged("cards done") && player.all().find("server copy") != std::string::npos, + "A player's copy showed cards\n" + player.all()); + check(!blind.logged("cards done") && blind.all().find("\"server\"") != std::string::npos && blind.logged("no poll events"), + "A plugin without server showed cards or heard polls\n" + blind.all()); + running(server_host, "cards-s", server); +} + // Clips on the skater's body: from the package or recorded, played, replacing a trick. void plugins_animate_the_skater() { TestEnvironment environment; @@ -2886,6 +3076,54 @@ void a_plugin_reads_the_content_folder_it_names() { check(environment.printed("content ok") && game.called("content list parks"), "The plugin reads its content folder\n" + environment.all()); } +// What the game and a dedicated server read for mods.content (plugin_content.h), from real folders: +// every mod folder in a Mods folder by name, each one's files under the folder asked for, and the +// limits a plugin cannot get past. +void content_is_read_from_the_mod_folders() { + namespace content = dingosdk::plugins::content; + const auto mods = scratch / "content mods"; + const auto write = [](const std::filesystem::path& file, const std::string& text) { + std::filesystem::create_directories(file.parent_path()); + std::ofstream(file, std::ios::binary) << text; + }; + write(mods / "b_track" / "parks" / "start.json", "{}"); + write(mods / "b_track" / "parks" / "deep" / "a" / "b" / "c" / "level4.json", "{}"); + write(mods / "b_track" / "parks" / "deep" / "a" / "b" / "c" / "d" / "level5.json", "{}"); + write(mods / "b_track" / "parks" / "half.tmp", ""); + write(mods / "b_track" / "parks" / "big.json", std::string((1u << 20) + 1, ' ')); + write(mods / "b_track" / "manifest.json", R"({"name": "B Track", "version_number": "1.0.0"})"); + write(mods / "A_Park" / "parks" / "z.json", "zz"); + write(mods / "A_Park" / "reskate-levels.json", "{}"); + write(mods / "no parks" / "manifest.json", "not JSON"); + write(mods / ".reskate_plugin_data" / "parks" / "secret.json", "{}"); + write(mods / "loose.json", "{}"); + + const auto found = content::mod_folders(mods); + check(found.size() == 3 && found[0].name == "A_Park" && found[1].name == "b_track" && found[2].name == "no parks", + "Every mod folder, by name, none hidden"); + check(found[0].title == "A_Park" && found[1].title == "B Track" && found[2].title == "no parks", "titled as their manifest names them"); + check(content::mod_folders(scratch / "no such folder").empty(), "No Mods folder, no mods"); + + std::vector files; + content::list(found, "parks", files); + std::string listed; + for (const auto& file : files) listed += std::format("{}:{}:{} ", file.mod, file.path, file.size); + check(listed == std::format("A_Park:z.json:2 b_track:big.json:{} b_track:deep/a/b/c/level4.json:2 b_track:start.json:2 ", (1u << 20) + 1), + "Mod by mod, sorted, four folders deep at most, no .tmp: " + listed); + + std::string text; + check(content::read(found, "B_TRACK", "parks", "start.json", text).empty() && text == "{}", "A file is read, the mod named in any case"); + check(content::read(found, "b_track", "parks", "missing.json", text) == "not found", "A missing file"); + check(content::read(found, "b_track", "parks", "big.json", text) == "the file is larger than 1 MB", "A file over 1 MB"); + check(content::read(found, "nobody", "parks", "start.json", text) == "no such mod is on", "A mod that is not there"); + check(content::read(found, ".reskate_plugin_data", "parks", "secret.json", text) == "no such mod is on", "A hidden folder is no mod"); + + for (int i = 0; i < 1005; ++i) write(mods / "c_many" / "parks" / std::format("{:04}.json", i), ""); + files.clear(); + content::list(content::mod_folders(mods), "parks", files); + check(files.size() == content::most_files, std::format("At most {} files, {} listed", content::most_files, files.size())); +} + void board_break_snaps_the_board_on_a_slam() { TestEnvironment environment; std::filesystem::remove_all(scratch / "storage" / "board-break"); @@ -3870,6 +4108,9 @@ int main() { run(animator_studio_records_and_saves); run(plugins_server_copies_are_the_authority); run(plugins_server_copies_spawn_park_mods); + run(plugins_server_copies_hide_players); + run(plugins_server_copies_see_roles); + run(plugins_server_copies_show_cards_and_polls); run(plugins_animate_the_skater); run(plugins_play_their_own_sounds); run(plugins_have_menu_panels); @@ -3889,6 +4130,7 @@ int main() { run(dual_stick_edits_the_companions_settings); run(rock_to_fakie_relabels_and_records_the_stall); run(a_plugin_reads_the_content_folder_it_names); + run(content_is_read_from_the_mod_folders); run(board_break_snaps_the_board_on_a_slam); run(fpv_drone_flies_and_follows); run(hall_of_meat_scores_a_bail); diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index b2d618ab..edbd05d8 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -17,7 +17,7 @@ namespace { struct Event { std::string_view name, permission; }; -constexpr std::array events{{ +constexpr std::array events{{ {"load", {}}, {"unload", {}}, {"tick", {}}, {"level_loaded", {}}, {"level_unloading", {}}, {"trick_started", "events.tricks"}, {"trick_landed", "events.tricks"}, {"line_broken", "events.tricks"}, {"draw", "overlay"}, {"player_joined", "session.read"}, {"player_left", "session.read"}, {"message", "net.session"}, @@ -26,6 +26,7 @@ constexpr std::array events{{ {"board_landed", "events.skater"}, {"body_hit", "events.skater"}, {"bail_end", "events.skater"}, {"physics_step", "events.physics"}, {"launch", "events.skater"}, {"trick_notes", "events.tricks"}, {"throwdown_ui", "throwdowns"}, {"touch", "session.skaters"}, {"touch_end", "session.skaters"}, {"camera_cycle", "camera"}, + {"poll_answer", "server"}, {"poll_closed", "server"}, }}; constexpr std::size_t maximum_key = 128; constexpr std::size_t maximum_command = 48; diff --git a/Extension/Plugins/plugin_api_ui.cpp b/Extension/Plugins/plugin_api_ui.cpp index 3935a9e9..1b6fb99a 100644 --- a/Extension/Plugins/plugin_api_ui.cpp +++ b/Extension/Plugins/plugin_api_ui.cpp @@ -282,7 +282,11 @@ struct UiApi { auto& plugin = plugin_of(L); need(L, plugin, "overlay", "world_to_screen"); const auto on = plugin.services_.world_to_screen(point(L, 2, "the world position")); - if (on) lua_pushvector(L, (*on)[0], (*on)[1], (*on)[2]); + // A point just in front of the camera and off to the side lands millions of pixels out, + // where the drawing functions refuse it; nil here, as for a point behind the camera, so + // a plugin that draws what it gets back keeps running. + const auto drawable = [](float v) { return std::isfinite(v) && std::fabs(v) <= 1e6f; }; + if (on && drawable((*on)[0]) && drawable((*on)[1]) && drawable((*on)[2])) lua_pushvector(L, (*on)[0], (*on)[1], (*on)[2]); else lua_pushnil(L); return 1; } diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 40f72379..05a15db3 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include // The wider set's game parts of the reskate API: the skater's state, control and save points, the @@ -2063,6 +2064,121 @@ struct WorldApi { auto& plugin = authority(L, "reskate.server.unban"); return result(L, game(plugin).unban(player_of(L, 1))); } + // ---- cards and polls on players' screens --------------------------------------------------------- + // Who a card or poll is for: "all" (empty: everyone on the server), one player (an id or a player + // table) or a list of them, 1 to 128. + static std::vector players_of(lua_State* L, int index) { + if (lua_isstring(L, index) && std::string_view(lua_tostring(L, index)) == "all") return {}; + if (!lua_istable(L, index)) return {player_of(L, index)}; + lua_getfield(L, index, "id"); + const bool one = !lua_isnil(L, -1); + lua_pop(L, 1); + if (one) return {player_of(L, index)}; + const int count = lua_objlen(L, index); + if (count < 1 || count > 128) luaL_argerror(L, index, "a player, a list of 1 to 128 players, or \"all\""); + std::vector players; + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, index, i); + const auto id = player_of(L, lua_gettop(L)); + if (std::find(players.begin(), players.end(), id) == players.end()) players.push_back(id); + lua_pop(L, 1); + } + return players; + } + // As the roster carries an announcement and a vote (Extension/Multiplayer/Net/protocol.h: + // max_vote_label, max_vote_answers, max_vote_answer). + static constexpr std::size_t card_text = 120, poll_answers = 6, poll_answer = 48; + static std::string line_of(lua_State* L, int index, std::size_t most, const char* what) { + auto text = checked_text(L, index, most, what); + if (text.empty()) luaL_error(L, "%s is empty", what); + return text; + } + // server.card(players, text, seconds?): up for 1 to 60 s (none: long enough to read). + static int server_card(lua_State* L) { + auto& plugin = authority(L, "reskate.server.card"); + const auto players = players_of(L, 1); + const auto text = line_of(L, 2, card_text, "a card's text"); + const auto seconds = lua_isnoneornil(L, 3) ? 0 : luaL_checkinteger(L, 3); + if (!lua_isnoneornil(L, 3) && (seconds < 1 || seconds > 60)) luaL_argerror(L, 3, "seconds, 1 to 60 (none: long enough to read)"); + return result(L, game(plugin).server_card(plugin.id(), players, text, static_cast(seconds))); + } + // server.poll(players, question, answers, {seconds}?): the poll's id, or nil and why. 2 to 6 + // answers; it runs 5 to 300 s (none: the server's poll time). + static int server_poll(lua_State* L) { + auto& plugin = authority(L, "reskate.server.poll"); + const auto players = players_of(L, 1); + const auto question = line_of(L, 2, card_text, "a poll's question"); + luaL_checktype(L, 3, LUA_TTABLE); + const int count = lua_objlen(L, 3); + if (count < 2 || count > static_cast(poll_answers)) luaL_argerror(L, 3, "2 to 6 answers"); + std::vector answers; + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, 3, i); + answers.push_back(line_of(L, lua_gettop(L), poll_answer, "a poll's answer")); + lua_pop(L, 1); + } + lua_Integer seconds = 0; + if (!lua_isnoneornil(L, 4)) { + luaL_checktype(L, 4, LUA_TTABLE); + if (lua_getfield(L, 4, "seconds") != LUA_TNIL) { + seconds = luaL_checkinteger(L, -1); + if (seconds < 5 || seconds > 300) luaL_argerror(L, 4, "seconds, 5 to 300 (none: the server's poll time)"); + } + lua_pop(L, 1); + } + std::uint32_t id = 0; + if (const auto why = game(plugin).server_poll(plugin.id(), players, question, answers, static_cast(seconds), id); !why.empty()) { + lua_pushnil(L); + lua_pushlstring(L, why.data(), why.size()); + return 2; + } + lua_pushnumber(L, static_cast(id)); + return 1; + } + static int server_poll_close(lua_State* L) { + auto& plugin = authority(L, "reskate.server.poll_close"); + const double id = luaL_checknumber(L, 1); + if (!(id >= 1 && id <= 4294967295.0) || id != std::floor(id)) luaL_argerror(L, 1, "a poll's id"); + return result(L, game(plugin).server_poll_close(plugin.id(), static_cast(id))); + } + // ---- hiding a player from others ---------------------------------------------------------------- + // server.hide(target, viewers, {skater, name, voice, sound}?): each true unless said. The name + // goes with the skater (a game has no skater without its player); without them, only the voice + // and/or the sound is held back. + static int server_hide(lua_State* L) { + auto& plugin = authority(L, "reskate.server.hide"); + const auto target = player_of(L, 1); + if (lua_isnoneornil(L, 2)) luaL_argerror(L, 2, "who they are hidden from: a player, a list of players, or \"all\""); + const auto viewers = players_of(L, 2); + Game::Hiding what; + if (!lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + std::optional skater, name, voice, sound; + lua_pushnil(L); + while (lua_next(L, 3)) { + const std::string_view key = lua_type(L, -2) == LUA_TSTRING ? lua_tostring(L, -2) : ""; + auto* field = key == "skater" ? &skater : key == "name" ? &name : key == "voice" ? &voice : key == "sound" ? &sound : nullptr; + if (!field) luaL_argerror(L, 3, "only skater, name, voice and sound"); + if (!lua_isboolean(L, -1)) luaL_argerror(L, 3, "skater, name, voice and sound are true or false"); + *field = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + } + if (skater && name && *skater != *name) luaL_argerror(L, 3, "the name goes with the skater: both true or both false"); + what.skater = skater.value_or(name.value_or(true)); + what.voice = voice.value_or(true); + what.sound = sound.value_or(true); + if (what.skater && (!what.voice || !what.sound)) luaL_argerror(L, 3, "a hidden skater's voice and sound are hidden too"); + if (!what.skater && !what.voice && !what.sound) luaL_argerror(L, 3, "nothing to hide: the skater, the voice or the sound"); + } + return result(L, game(plugin).server_hide(plugin.id(), target, viewers, what)); + } + // server.unhide(target, viewers?): shown again to them (none: to everyone this plugin hid them from). + static int server_unhide(lua_State* L) { + auto& plugin = authority(L, "reskate.server.unhide"); + const auto target = player_of(L, 1); + const auto viewers = lua_isnoneornil(L, 2) ? std::vector{} : players_of(L, 2); + return result(L, game(plugin).server_unhide(plugin.id(), target, viewers)); + } // ---- server.parks: a dedicated server's park mods ----------------------------------------------- // parks(): {name, map, here (fits the map running now), spawned, objects, minutes_left}[]; // park_spawn(name, minutes?): for that long (1-1440), or until removed; park_remove(name). @@ -2152,14 +2268,30 @@ struct WorldApi { const auto player = player_of(L, 1); return result(L, game(plugin).apply_permissions(plugin.id(), player, permissions_of(L, 2))); } - // A player table's own names: permissions (read when asked, so always the server's now) and - // apply_permissions. Anything else a player table does not have is nil. + // player.role: {name, rank, staff}, read-only, or nil for a player with no role. + static int player_role(lua_State* L) { + auto& plugin = authority(L, "player.role"); + const auto role = game(plugin).role(player_of(L, 1)); + if (!role) return 0; + lua_createtable(L, 0, 3); + lua_pushlstring(L, role->name.data(), role->name.size()); + lua_setfield(L, -2, "name"); + lua_pushinteger(L, role->rank); + lua_setfield(L, -2, "rank"); + lua_pushboolean(L, role->staff); + lua_setfield(L, -2, "staff"); + lua_setreadonly(L, -1, true); + return 1; + } + // A player table's own names: permissions and role (read when asked, so always the server's + // now) and apply_permissions. Anything else a player table does not have is nil. static int player_index(lua_State* L) { const char* key = lua_type(L, 2) == LUA_TSTRING ? lua_tostring(L, 2) : ""; if (std::string_view(key) == "apply_permissions") { lua_pushcfunction(L, player_apply_permissions, "apply_permissions"); return 1; } + if (std::string_view(key) == "role") return player_role(L); if (std::string_view(key) != "permissions") return 0; auto& plugin = authority(L, "player.permissions"); const auto permissions = game(plugin).permissions(player_of(L, 1)); @@ -2799,7 +2931,8 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { add_functions(L, "audio", {{"play", audio_play}, {"set", audio_set}, {"stop", audio_stop}, {"playing", audio_playing}}); add_functions(L, "server", {{"kick", server_kick}, {"say", server_say}, {"tell", server_tell}, {"move", server_move}, {"ban", server_ban}, {"unban", server_unban}, {"parks", server_parks}, {"park_spawn", server_park_spawn}, - {"park_remove", server_park_remove}}); + {"park_remove", server_park_remove}, {"card", server_card}, {"poll", server_poll}, {"poll_close", server_poll_close}, + {"hide", server_hide}, {"unhide", server_unhide}}); add_functions(L, "throwdowns", {{"state", throwdown_state}, {"host", throwdown_host}, {"cancel", throwdown_cancel}, {"hosting", throwdown_hosting}, {"mode", throwdown_mode}, {"remove_mode", throwdown_remove_mode}}); add_functions(L, "music", {{"current", current}, {"play", play}, {"pause", pause}, {"next", next}, {"previous", previous}, diff --git a/Extension/Plugins/plugin_content.cpp b/Extension/Plugins/plugin_content.cpp new file mode 100644 index 00000000..a2db86c5 --- /dev/null +++ b/Extension/Plugins/plugin_content.cpp @@ -0,0 +1,104 @@ +#include "plugin_content.h" +#include "Engine/Core/Json/json.h" +#include +#include +#include + +namespace dingosdk::plugins::content { +namespace { +// The names mod_list.h's scan_mods takes for a mod folder, as long as read_content takes. +bool mod_name(std::string_view name) { + if (name.empty() || name.size() > 64 || name.front() == '.') return false; + return std::all_of(name.begin(), name.end(), [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.' || c == ' '; + }); +} +bool same_name(std::string_view a, std::string_view b) { + return std::equal(a.begin(), a.end(), b.begin(), b.end(), [](char x, char y) { + const auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? static_cast(c + 32) : c; }; + return lower(x) == lower(y); + }); +} +// manifest.json's "name", 1 to 64 characters, or empty when there is none to read. +std::string manifest_name(const std::filesystem::path& directory) { + const auto file = directory / "manifest.json"; + std::error_code error; + if (!std::filesystem::is_regular_file(file, error) || std::filesystem::file_size(file, error) > 32 * 1024 || error) return {}; + try { + std::ifstream in(file, std::ios::binary); + std::stringstream text; + text << in.rdbuf(); + const auto root = Json::parse(text.str()); + if (!root.is_object() || !root.contains("name") || !root.at("name").is_string()) return {}; + const auto& name = root.at("name").string(); + return name.size() <= 64 ? name : std::string{}; + } catch (const std::exception&) { + return {}; + } +} +} // namespace + +std::vector mod_folders(const std::filesystem::path& mods_root) { + std::vector mods; + std::error_code error; + for (std::filesystem::directory_iterator it(mods_root, error), end; !error && it != end; it.increment(error)) { + std::error_code entry; + if (!it->is_directory(entry)) continue; + const auto utf8 = it->path().filename().u8string(); + std::string name(utf8.begin(), utf8.end()); + if (!mod_name(name)) continue; + auto title = manifest_name(it->path()); + mods.push_back({name, title.empty() ? name : std::move(title), it->path()}); + } + std::sort(mods.begin(), mods.end(), [](const Mod& a, const Mod& b) { return a.name < b.name; }); + return mods; +} + +void list(const std::vector& mods, std::string_view folder, std::vector& files) { + for (const auto& mod : mods) { + const auto root = mod.directory / std::filesystem::path(std::string(folder)); + std::error_code error; + if (!std::filesystem::is_directory(root, error)) continue; + std::vector found; + for (std::filesystem::recursive_directory_iterator it(root, std::filesystem::directory_options::none, error), end; + !error && it != end; it.increment(error)) { + if (it.depth() >= 4) it.disable_recursion_pending(); + if (!it->is_regular_file(error)) continue; + const auto relative = it->path().lexically_relative(root).generic_wstring(); + std::string path; + bool plain = true; + for (const auto c : relative) { + if (c < 0x20 || c > 0x7e) plain = false; + path.push_back(static_cast(c)); + } + if (!plain || path.ends_with(".tmp")) continue; // a name a plugin could not ask for again + found.push_back({mod.name, mod.title, std::move(path), static_cast(it->file_size(error))}); + if (files.size() + found.size() >= most_files) break; + } + std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.path < b.path; }); + for (auto& file : found) files.push_back(std::move(file)); + if (files.size() >= most_files) break; + } +} + +std::string read(const std::vector& mods, std::string_view mod, std::string_view folder, std::string_view path, std::string& text) { + const auto found = std::find_if(mods.begin(), mods.end(), [&](const Mod& each) { return same_name(each.name, mod); }); + if (found == mods.end()) return "no such mod is on"; + const auto root = found->directory / std::filesystem::path(std::string(folder)); + const auto file = root / std::filesystem::path(std::string(path)); + std::error_code error; + const auto real_root = std::filesystem::weakly_canonical(root, error); + const auto real_file = error ? std::filesystem::path{} : std::filesystem::weakly_canonical(file, error); + if (error) return "not found"; + const auto relative = real_file.lexically_relative(real_root); + if (relative.empty() || *relative.begin() == "..") return "outside the mod's folder"; + if (!std::filesystem::is_regular_file(file, error)) return "not found"; + const auto size = std::filesystem::file_size(file, error); + if (error) return "the file cannot be read"; + if (size > most_bytes) return "the file is larger than 1 MB"; + std::ifstream in(file, std::ios::binary); + text.resize(static_cast(size)); + if (!in || !in.read(text.data(), static_cast(size))) return "the file cannot be read"; + return {}; +} +} // namespace dingosdk::plugins::content diff --git a/Extension/Plugins/plugin_content.h b/Extension/Plugins/plugin_content.h new file mode 100644 index 00000000..7ead29e9 --- /dev/null +++ b/Extension/Plugins/plugin_content.h @@ -0,0 +1,28 @@ +#pragma once +#include "plugin_game.h" +#include +#include +#include +#include +#include + +// mods.content and read_content, from the mods' folders on disk: the game reads its enabled mods +// (plugin_game_runtime.cpp), a dedicated server every mod folder in its Mods (server_plugins.cpp). +// Both keep the same limits, so a plugin reads the same files on either. +namespace dingosdk::plugins::content { +struct Mod { + std::string name, title; // the folder's name and the mod's own name + std::filesystem::path directory; +}; +inline constexpr std::size_t most_files = 1000, most_bytes = 1u << 20; + +// Every folder in `mods_root` a mod can be, by name: not hidden (".reskate_plugin_data"), and a +// name a plugin can ask for again. Titled by its manifest.json's "name", else its folder's name. +std::vector mod_folders(const std::filesystem::path& mods_root); +// The files under // of each of `mods` in turn, sorted within a mod, four folders deep +// at most and most_files in all; `folder` is checked against the manifest already. +void list(const std::vector& mods, std::string_view folder, std::vector& files); +// One of them, at most most_bytes; `path` is checked already (safe_relative_path). Empty when read, +// else why not. +std::string read(const std::vector& mods, std::string_view mod, std::string_view folder, std::string_view path, std::string& text); +} // namespace dingosdk::plugins::content diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index 6ea2fa9f..f2076e0e 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -596,6 +596,15 @@ public: }; virtual std::optional permissions(std::uint64_t) { return std::nullopt; } virtual std::string apply_permissions(std::string_view /*plugin*/, std::uint64_t, const Permissions&) { return unavailable; } + // A player's role on a dedicated server (data/roles.json): its name, its rank (0 to 1000), and + // whether it gives any of the server's commands. Nothing for a player with no role, nor where + // there are no roles (a lobby's host). + struct Role { + std::string name; + int rank{}; + bool staff{}; + }; + virtual std::optional role(std::uint64_t) { return std::nullopt; } // A dedicated server's park mods (server.parks): those it has set up, whether each fits the map // running now and is spawned (its objects, and minutes left; 0 until removed). Spawning one (for // `minutes`, 0 until removed) or removing it goes as the server's own park-mod does. @@ -608,6 +617,48 @@ public: virtual std::vector server_parks() { return {}; } virtual std::string spawn_park(std::string_view /*plugin*/, std::string_view /*name*/, unsigned /*minutes*/) { return unavailable; } virtual std::string remove_park(std::string_view /*plugin*/, std::string_view /*name*/) { return unavailable; } + // Cards and polls on players' screens (a dedicated server's only): each player's own, shown over + // the server's announcement or vote until it ends, then the server's again. `players` empty is + // everyone on the server now. A card is up for `seconds` (0: long enough to read). A poll has + // 2 to max_vote_answers answers and runs `seconds` (0: the server's poll time); it is one card + // with one `id` for all its players, and closes for each when they answer. A newer poll for a + // player replaces theirs. What each player did comes back from take_poll_news. + virtual std::string server_card(std::string_view /*plugin*/, const std::vector& /*players*/, std::string_view /*text*/, + unsigned /*seconds*/) { + return unavailable; + } + virtual std::string server_poll(std::string_view /*plugin*/, const std::vector& /*players*/, std::string_view /*question*/, + const std::vector& /*answers*/, unsigned /*seconds*/, std::uint32_t& /*id*/) { + return unavailable; + } + // Closes a poll of the plugin's for everyone it is still open for. + virtual std::string server_poll_close(std::string_view /*plugin*/, std::uint32_t /*id*/) { return unavailable; } + // A player answered a plugin's poll (`answer` from 1, its text), or it closed for them unanswered + // (`answer` 0, and why: "timeout", "left", "replaced" or "closed"). + struct PollNews { + std::string plugin; + std::uint32_t id{}; + std::uint64_t player{}; + std::size_t answer{}; + std::string text, why; + }; + virtual std::vector take_poll_news() { return {}; } + // One player hidden from others (a dedicated server's only): `skater` takes them out of those + // players' games altogether (skater, name, objects, voice and sound, as if they had left); + // without it, only their voice and/or sound is held back. `viewers` empty is everyone on now + // but the target. Refused while the target shares a party or a throwdown with one of them. + // Showing them again (`viewers` empty: everyone the plugin hid them from) sends their outfit, + // poses and objects anew. + struct Hiding { + bool skater = true, voice = true, sound = true; + }; + virtual std::string server_hide(std::string_view /*plugin*/, std::uint64_t /*target*/, const std::vector& /*viewers*/, + const Hiding&) { + return unavailable; + } + virtual std::string server_unhide(std::string_view /*plugin*/, std::uint64_t /*target*/, const std::vector& /*viewers*/) { + return unavailable; + } // ---- skater.animation: clips on the local skater's body (Extension/Skater/skater_animation.h) ------ // One plugin plays at a time; none while a throwdown runs. A replacement plays when the game does // `trick` ("kickflip") with that pop ("" any, "low", "mid", "high"); a null clip takes it away. diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 70409d7c..d51114b9 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -1,5 +1,6 @@ #include "plugin_game_runtime.h" #include "plugin_session_feed.h" +#include "plugin_content.h" #include "Runtime/runtime_internal.h" #include "plugin_ui.h" #include "Engine/Game/World/park_rotation.h" @@ -902,6 +903,7 @@ public: if (!joystick::game_in_foreground()) return std::nullopt; const auto sample = joystick::poll(); if (!sample.valid) return std::nullopt; + overlay::note_joystick(sample.buttons, sample.axes); return StickInput{sample.axes, sample.buttons, sample.name}; } std::string capture_input(std::string_view plugin, bool on) override { @@ -1442,13 +1444,9 @@ public: tricks::prepare_trick_gestures(); return tricks::trick_gesture(trick); } - // ---- content: // of every enabled mod --------------------------------------------- + // ---- content: // of every enabled mod (plugin_content.h) -------------------------- // The enabled mods, read again at most every 2 s: a page may list them every frame. - struct EnabledMod { - std::string name, title; - std::filesystem::path directory; - }; - std::vector enabled_mods() { + std::vector enabled_mods() { const auto now = GetTickCount64(); if (enabled_read_at_ && now - enabled_read_at_ < 2000) return enabled_mods_; enabled_read_at_ = now; @@ -1459,56 +1457,12 @@ public: if (entry.enabled) enabled_mods_.push_back({entry.mod.name, entry.mod.title.empty() ? entry.mod.name : entry.mod.title, entry.mod.directory}); return enabled_mods_; } - static constexpr std::size_t most_content_files = 1000, most_content_bytes = 1u << 20; std::string content_list(std::string_view folder, std::vector& files) override { - for (const auto& mod : enabled_mods()) { - const auto root = mod.directory / std::filesystem::path(std::string(folder)); - std::error_code error; - if (!std::filesystem::is_directory(root, error)) continue; - std::vector found; - for (std::filesystem::recursive_directory_iterator it(root, std::filesystem::directory_options::none, error), end; - !error && it != end; it.increment(error)) { - if (it.depth() >= 4) it.disable_recursion_pending(); - if (!it->is_regular_file(error)) continue; - const auto relative = it->path().lexically_relative(root).generic_wstring(); - std::string path; - bool plain = true; - for (const auto c : relative) { - if (c < 0x20 || c > 0x7e) plain = false; - path.push_back(static_cast(c)); - } - if (!plain || path.ends_with(".tmp")) continue; // a name a plugin could not ask for again - found.push_back({mod.name, mod.title, std::move(path), static_cast(it->file_size(error))}); - if (files.size() + found.size() >= most_content_files) break; - } - std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.path < b.path; }); - for (auto& file : found) files.push_back(std::move(file)); - if (files.size() >= most_content_files) break; - } + content::list(enabled_mods(), folder, files); return {}; } std::string content_read(std::string_view mod, std::string_view folder, std::string_view path, std::string& text) override { - const auto mods_now = enabled_mods(); - const auto found = std::find_if(mods_now.begin(), mods_now.end(), [&](const EnabledMod& each) { - return each.name.size() == mod.size() && _strnicmp(each.name.c_str(), mod.data(), mod.size()) == 0; - }); - if (found == mods_now.end()) return "no such mod is on"; - const auto root = found->directory / std::filesystem::path(std::string(folder)); - const auto file = root / std::filesystem::path(std::string(path)); - std::error_code error; - const auto real_root = std::filesystem::weakly_canonical(root, error); - const auto real_file = error ? std::filesystem::path{} : std::filesystem::weakly_canonical(file, error); - if (error) return "not found"; - const auto relative = real_file.lexically_relative(real_root); - if (relative.empty() || *relative.begin() == "..") return "outside the mod's folder"; - if (!std::filesystem::is_regular_file(file, error)) return "not found"; - const auto size = std::filesystem::file_size(file, error); - if (error) return "the file cannot be read"; - if (size > most_content_bytes) return "the file is larger than 1 MB"; - std::ifstream in(file, std::ios::binary); - text.resize(static_cast(size)); - if (!in || !in.read(text.data(), static_cast(size))) return "the file cannot be read"; - return {}; + return content::read(enabled_mods(), mod, folder, path, text); } std::string trick_label(std::string_view plugin, std::string_view from, std::string_view to) override { return trick_rewrite::label(plugin, from, to); @@ -1741,7 +1695,7 @@ private: std::vector installed; }; std::map> variant_reads_; - std::vector enabled_mods_; + std::vector enabled_mods_; std::string reacher_; // the plugin holding the skater's arm (skater_reach) std::string poser_; // the plugin turning the skater's joints (skater_pose) std::string carrier_; // the plugin having the skater carry something (skater_carry) diff --git a/Extension/Plugins/plugin_host.cpp b/Extension/Plugins/plugin_host.cpp index 4a0cdf2e..3a45e633 100644 --- a/Extension/Plugins/plugin_host.cpp +++ b/Extension/Plugins/plugin_host.cpp @@ -383,6 +383,20 @@ void Host::follow_game() { each([&](Plugin& plugin, const std::string&) { plugin.throwdown_phase(active ? "started" : "ended"); }); } } + // Answers to a server copy's polls (server.poll), and polls that closed unanswered, to the + // plugin that asked. + for (const auto& news : game.take_poll_news()) { + auto data = Json::object(); + data["id"] = static_cast(news.id); + data["player"] = std::to_string(news.player); + if (news.answer) { + data["answer"] = static_cast(news.answer); + data["text"] = news.text; + } else data["why"] = news.why; + each([&](Plugin& plugin, const std::string&) { + if (plugin.id() == news.plugin) plugin.game_event(news.answer ? "poll_answer" : "poll_closed", "server", data); + }); + } for (const auto& [plugin, action] : game.take_presses()) if (auto* entry = find(plugin); entry && entry->plugin) { entry->plugin->action(action); @@ -501,6 +515,11 @@ bool Host::wants_tricks() const { [](const auto& item) { return item.second.plugin && item.second.plugin->has("events.tricks"); }); } +bool Host::wants_chat() const { + return std::any_of(entries_.begin(), entries_.end(), + [](const auto& item) { return item.second.plugin && item.second.plugin->has("chat.read"); }); +} + bool Host::run_console(std::string_view text) { if (commands_.empty()) return false; const auto parsed = parse_console_command(text); diff --git a/Extension/Plugins/plugin_host.h b/Extension/Plugins/plugin_host.h index b301b3ee..49bfe767 100644 --- a/Extension/Plugins/plugin_host.h +++ b/Extension/Plugins/plugin_host.h @@ -95,6 +95,9 @@ public: void trick(const TrickNews& news); // Whether a running plugin has events.tricks: the score HUD is read only then. bool wants_tricks() const; + // Whether a running plugin has chat.read: a dedicated server then hands it the /commands it + // does not know itself. + bool wants_chat() const; // Runs a plugin's console command when the text starts with one; false when none matched. bool run_console(std::string_view text); std::vector status() const; diff --git a/Extension/Skater/console_commands.cpp b/Extension/Skater/console_commands.cpp index b3d21dd9..f7a724ad 100644 --- a/Extension/Skater/console_commands.cpp +++ b/Extension/Skater/console_commands.cpp @@ -295,6 +295,14 @@ void register_movement_commands(Commands ®istry) { }; registry.add(std::move(entry)); } + // What "bind reskatemenu" runs, for a player who cannot press the menu's key (no Insert key). + auto open_menu = action("menu", "Open the ReSkate menu (bind it to a controller combo or key: bind reskatemenu)", Group::console); + open_menu.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; + open_menu.run = [](const Model &, const Values &, const Output &out) { + overlay::menu_open_flag().store(true); + out("Opening the ReSkate menu."); + }; + registry.add(std::move(open_menu)); } void register_ai_commands(Commands ®istry) { const auto ready = [](const Model &m) { diff --git a/Extension/Throwdowns/native_type_scan.cpp b/Extension/Throwdowns/native_type_scan.cpp index 7a10b92c..628cf6d2 100644 --- a/Extension/Throwdowns/native_type_scan.cpp +++ b/Extension/Throwdowns/native_type_scan.cpp @@ -53,6 +53,17 @@ std::size_t scan_region(std::uintptr_t begin, std::size_t size, NativeTypeQuery* } __except (EXCEPTION_EXECUTE_HANDLER) {} return found; } +// Whether QueryWorkingSetEx reports resident pages at all. Under Wine on macOS +// (CrossOver), NtQueryVirtualMemory(MemoryWorkingSetExInformation) has no +// /proc/self/pagemap to read and succeeds with Valid = 0 for every page, even +// for the stack page just written here. +bool working_set_supported() noexcept { + static const bool supported = []() noexcept { + PSAPI_WORKING_SET_EX_INFORMATION page{&page}; + return QueryWorkingSetEx(GetCurrentProcess(), &page, sizeof(page)) && page.VirtualAttributes.Valid; + }(); + return supported; +} // Scans only the pages of a region that are in the working set. Reading any other // committed page makes the kernel fault it in: a zero page never touched, or one // paged out to disk. Type records are written when registered, so they are @@ -89,14 +100,15 @@ std::size_t find_native_types(std::span wanted, bool background // CPU, I/O and memory priority, yielding between regions, it competes as // little as possible with the game's threads for cores and memory bandwidth. const bool lowered = background && SetThreadPriority(GetCurrentThread(), THREAD_MODE_BACKGROUND_BEGIN); + const bool resident_only = background && working_set_supported(); std::uintptr_t at = 0x10000; MEMORY_BASIC_INFORMATION info{}; while (missing && at < 0x00007fffffff0000ULL && VirtualQuery(reinterpret_cast(at), &info, sizeof(info))) { const auto base = reinterpret_cast(info.BaseAddress); // Type records are heap data: private, writable, and never in huge blocks. if (readable(info) && info.Type == MEM_PRIVATE && (info.Protect & PAGE_READWRITE) && info.RegionSize <= (1ULL << 30)) { - missing -= background ? scan_resident(base, info.RegionSize, wanted.data(), wanted.size()) - : scan_region(base, info.RegionSize, wanted.data(), wanted.size()); + missing -= resident_only ? scan_resident(base, info.RegionSize, wanted.data(), wanted.size()) + : scan_region(base, info.RegionSize, wanted.data(), wanted.size()); if (background) SwitchToThread(); } at = base + info.RegionSize; diff --git a/Extension/Trainer/trainer_gamestate.cpp b/Extension/Trainer/trainer_gamestate.cpp index ec6743cb..579e9aa9 100644 --- a/Extension/Trainer/trainer_gamestate.cpp +++ b/Extension/Trainer/trainer_gamestate.cpp @@ -297,19 +297,26 @@ void find_state_objects(std::uintptr_t start, std::size_t size) noexcept { } __except (1) {} } int scale_underflip_bands(float low_factor, float high_factor) noexcept { - band_factor.store(low_factor, std::memory_order_relaxed); - band_high_factor.store(high_factor, std::memory_order_relaxed); + const float was_low = band_factor.exchange(low_factor, std::memory_order_relaxed), + was_high = band_high_factor.exchange(high_factor, std::memory_order_relaxed); int found = 0; for (auto &band : underflip_bands) for (auto &slot : band.at) { const auto at = slot.load(std::memory_order_relaxed); if (!at) continue; + // A band's memory goes back to the game's heap with its level and stays readable, so only a copy that still + // is that band and still holds the game's band, or the last one written, is written. Writing a freed copy + // put 1.0 and 0.6 into the upper half of whatever the game made there next (a third of the crash reports + // of 55a7342: Skate.exe+0x157f930, +0x12a1549, +0x10084de, +0x11ef49e, +0x3db81ea, +0x12c21ca; 2026-10-24). float low{}, high{}; - if (!peek(at + rate_min_scale, low) || !peek(at + rate_max_scale, high)) { - slot.store(0, std::memory_order_relaxed); // freed with its level + const float want_low = band.stock_min * low_factor, want_high = band.stock_max * high_factor; + if (!is_named(band.id, band.name, at - object_after_id) || !peek(at + rate_min_scale, low) || + !peek(at + rate_max_scale, high) || + !((low == band.stock_min && high == band.stock_max) || (low == band.stock_min * was_low && high == band.stock_max * was_high) || + (low == want_low && high == want_high))) { + slot.store(0, std::memory_order_relaxed); // freed with its level, or reused continue; } - const float want_low = band.stock_min * low_factor, want_high = band.stock_max * high_factor; if (low != want_low) (void)put(at + rate_min_scale, want_low); if (high != want_high) (void)put(at + rate_max_scale, want_high); ++found; diff --git a/Extension/UI/NativeMenu/native_menu.cpp b/Extension/UI/NativeMenu/native_menu.cpp index c088e11f..15ab9fb1 100644 --- a/Extension/UI/NativeMenu/native_menu.cpp +++ b/Extension/UI/NativeMenu/native_menu.cpp @@ -657,7 +657,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 (!release_native_throwdown_menu(base, true)) return false; if (std::all_of(page_slots.begin(), page_slots.end(), [](unsigned slot) { return page_state(slot).owned_models.empty(); })) return true; try { diff --git a/Extension/UI/NativeMenu/throwdown_menu.cpp b/Extension/UI/NativeMenu/throwdown_menu.cpp index a67efa6d..48f4b230 100644 --- a/Extension/UI/NativeMenu/throwdown_menu.cpp +++ b/Extension/UI/NativeMenu/throwdown_menu.cpp @@ -2276,14 +2276,28 @@ void prepare_native_throwdown_match_panel() noexcept { "Throwdowns: native Details presentation preparation deferred: {}",e.what()); } } -bool release_native_throwdown_menu(std::uintptr_t base) noexcept { +bool release_native_throwdown_menu(std::uintptr_t base,bool before_unload) noexcept { overlay::capture_challenge_menu_input(false); sync_custom_activity_ui_presence(false); - auto& s=state(); if(s.owned.empty()) { s.owner.store(0); s.setup_open.store(false); return true; } + auto& s=state(); if(s.owned.empty() && (!before_unload || s.doomed.empty())) { s.owner.store(0); s.setup_open.store(false); return true; } try { const auto ui=read
(base+build::engine::ui_manager); const auto manager=ui?read
(ui+0x140):0; require(manager!=0,"Throwdowns UI 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; + const Context c(base,manager); game::ModelWriteLock lock(manager); + if(!s.owned.empty()) { release(c); s.generations.clear(); } + else { s.owner.store(0); s.setup_open.store(false); } + // Before a level unload the released models go now, not 3 s later: the tick does not run while + // the level loads, and the game's own flush of the unloaded level's types destroyed them after + // the assets they name were freed (Skate.exe+0x3dc5eaa, the most reported crash of 55a7342; + // 2026-10-24). The native menu's own models go the same way (native_menu.cpp). + if(before_unload && !s.doomed.empty()) { + logging::log(logging::Level::info,logging::Channel::ui, + "Throwdowns: {} released native model(s) destroyed before the level unload.",s.doomed.size()); + for(const auto& entry:s.doomed) + if(c.type_of(entry.first.handle)==entry.first.type)c.destroy(entry.first); + s.doomed.clear(); + } + return true; } catch(const std::exception& e) { logging::log(logging::Level::warning,logging::Channel::ui,"Throwdowns native menu cleanup: {}",e.what()); return false; } } // A document prompt's input pressed while its prompt shows: the plugin hears panel_command. @@ -2410,7 +2424,7 @@ void tick_native_throwdown_menu(std::uintptr_t base,bool loading,bool in_menu) n gameplay_hud(c,now); } catch(const std::exception& e) { logging::log(logging::Level::warning,logging::Channel::ui,"Throwdowns native menu: {}",e.what()); - s.next_tick=now+5000; release_native_throwdown_menu(base); + s.next_tick=now+5000; release_native_throwdown_menu(base,false); } } } // namespace dingosdk::multiplayer diff --git a/Extension/UI/NativeMenu/throwdown_menu.h b/Extension/UI/NativeMenu/throwdown_menu.h index 3d111f68..c8925800 100644 --- a/Extension/UI/NativeMenu/throwdown_menu.h +++ b/Extension/UI/NativeMenu/throwdown_menu.h @@ -2,7 +2,7 @@ #include namespace dingosdk::multiplayer { void tick_native_throwdown_menu(std::uintptr_t base, bool loading, bool in_menu) noexcept; -bool release_native_throwdown_menu(std::uintptr_t base) noexcept; +bool release_native_throwdown_menu(std::uintptr_t base, bool before_unload) noexcept; bool native_throwdown_setup_open() noexcept; bool native_throwdown_hud_ready() noexcept; bool native_throwdown_match_panel_open() noexcept; diff --git a/Extension/UI/Overlay/input_capture.cpp b/Extension/UI/Overlay/input_capture.cpp index 5871e251..b56f0045 100644 --- a/Extension/UI/Overlay/input_capture.cpp +++ b/Extension/UI/Overlay/input_capture.cpp @@ -8,6 +8,7 @@ #include "Engine/Game/Input/owned_menu_input.h" #include #include +#include #include #include #include @@ -413,6 +414,7 @@ void filter_pad(bool outer, DWORD user, XINPUT_STATE* output) noexcept { // holds the game's input does not hold its controller. That hook's own function is hooked // then, so the capture is in front of it again. void game_pad_read(DWORD user, XINPUT_STATE* output) noexcept; +void note_xinput(DWORD user, const XINPUT_STATE& state) noexcept; template DWORD WINAPI captured_front(DWORD user, XINPUT_STATE* output) { const bool capture = block_polled_input(); @@ -424,6 +426,7 @@ DWORD WINAPI captured_front(DWORD user, XINPUT_STATE* output) { OverlayInputAccess access; result = original(input_hooks().xinput_front[I])(user, output); } + if (result == ERROR_SUCCESS && output) note_xinput(user, *output); // Only the game gets a scripted pad (the character room's walk): ReSkate's own reads get the real one. if (outer && output && scripted_pad(user, *output)) return ERROR_SUCCESS; if (result == ERROR_SUCCESS && output && capture) { @@ -527,9 +530,41 @@ struct LastInput { std::atomic message{}, message_key{}; // the game window's last input message }; LastInput& last_input() { static auto* value = new LastInput; return *value; } +std::atomic controls_at{}; +namespace { +struct PadSeen { + bool known{}; + std::uint32_t buttons{}; + std::array axes{}; +}; +} +void note_controls() noexcept { controls_at.store(GetTickCount64(), std::memory_order_relaxed); } +// A controller counts as worked when a button changes, or a stick or trigger moves an eighth of +// its travel from where it was last worked: one left resting off centre, or a little noise, does not. +void note_pad(std::size_t slot, std::uint32_t buttons, const std::array& axes) noexcept { + static std::mutex mutex; + static std::array seen{}; + if (slot >= seen.size()) return; + bool moved{}; + { + std::lock_guard lock(mutex); + auto& was = seen[slot]; + moved = was.known && was.buttons != buttons; + for (std::size_t i = 0; i < axes.size() && was.known; ++i) moved = moved || std::abs(axes[i] - was.axes[i]) > 0.125f; + if (!was.known || moved) was = {true, buttons, axes}; + } + if (moved) note_controls(); +} +void note_xinput(DWORD user, const XINPUT_STATE& state) noexcept { + const auto& g = state.Gamepad; + const auto stick = [](SHORT v) { return static_cast(v) / 32768.0f; }; + note_pad(user, g.wButtons, {stick(g.sThumbLX), stick(g.sThumbLY), stick(g.sThumbRX), stick(g.sThumbRY), + g.bLeftTrigger / 255.0f, g.bRightTrigger / 255.0f}); +} void note_input_record(const RAWINPUT& record) noexcept { auto& last = last_input(); if (record.header.dwType == RIM_TYPEKEYBOARD) { + note_controls(); last.key.store(record.data.keyboard.VKey | (std::uint32_t{record.data.keyboard.MakeCode} << 16), std::memory_order_relaxed); last.key_device.store(record.header.hDevice, std::memory_order_relaxed); @@ -541,6 +576,7 @@ void note_input_record(const RAWINPUT& record) noexcept { last.mouse_buttons.store(record.data.mouse.usButtonFlags, std::memory_order_relaxed); last.mouse_device.store(record.header.hDevice, std::memory_order_relaxed); last.mouse_at.store(GetTickCount64(), std::memory_order_relaxed); + note_controls(); if ((record.data.mouse.usButtonFlags & RI_MOUSE_WHEEL) && GetTickCount64() < mode_input_paused_until.load(std::memory_order_relaxed)) mode_mouse_wheel.fetch_add(static_cast(record.data.mouse.usButtonData) / WHEEL_DELTA, std::memory_order_relaxed); @@ -560,6 +596,8 @@ void note_input_message(UINT message, WPARAM wp) noexcept { last.message.store(message, std::memory_order_relaxed); last.message_key.store(static_cast(wp), std::memory_order_relaxed); last.message_at.store(GetTickCount64(), std::memory_order_relaxed); + // (Windows sends a mouse move message without the mouse moving, as a window comes or goes under it.) + if (message != WM_MOUSEMOVE) note_controls(); } // What the game last read from the keyboard and mouse, within the 3 s before now. std::string recent_input() { @@ -856,6 +894,7 @@ DWORD WINAPI captured_xinput(DWORD user, XINPUT_STATE* output) { OverlayInputAccess access; result = original(hook)(user, output); } + if (result == ERROR_SUCCESS && output) note_xinput(user, *output); // Only the game gets a scripted pad (the character room's walk): ReSkate's own reads get the real one. if (game_poll && output && scripted_pad(user, *output)) return ERROR_SUCCESS; if (result == ERROR_SUCCESS && output && capture) { diff --git a/Extension/UI/Overlay/input_capture.h b/Extension/UI/Overlay/input_capture.h index 18d91aa4..2412e6ca 100644 --- a/Extension/UI/Overlay/input_capture.h +++ b/Extension/UI/Overlay/input_capture.h @@ -1,5 +1,9 @@ #pragma once #include +#include +#include +#include +#include #include namespace dingosdk::overlay::detail { extern thread_local unsigned overlay_input_access; @@ -44,4 +48,10 @@ void note_raw_mouse_packet(const RAWMOUSE& mouse); void note_raw_mouse_packet(const RAWMOUSE& mouse, bool overlay_owns_pointer); // The game window's input messages (keys, mouse), for the input-mode log (input_capture.cpp). void note_input_message(UINT message, WPARAM wp) noexcept; +// The player worked the controls just now (overlay.h controls_touched_at). +void note_controls() noexcept; +// A controller's state as it was read: `slot` 0 to 3 the XInput users, 4 ReSkate's own reads +// (read_gamepad), 5 a joystick a plugin reads; `axes` the sticks (-1 to 1) and triggers (0 to 1). +void note_pad(std::size_t slot, std::uint32_t buttons, const std::array& axes) noexcept; +extern std::atomic controls_at; } diff --git a/Extension/UI/Overlay/map_download_card.cpp b/Extension/UI/Overlay/map_download_card.cpp index 05743772..12bf7adb 100644 --- a/Extension/UI/Overlay/map_download_card.cpp +++ b/Extension/UI/Overlay/map_download_card.cpp @@ -6,7 +6,9 @@ // The card for a map a multiplayer session needs and this PC does not have // (Extension/Assets/map_download.h), laid out as the mod's page is: its icon, name, author, -// version, size and description, then what is being asked or done. It asks whether to download it, +// version, size and description, then what is being asked or done. When a dedicated server +// needs plugins too, or plugins alone, one card lists everything instead (draw_asked_list), so +// the player is asked once. It asks whether to download it, // in the middle of the screen with the controls under it as the game's own menus have them. // Once the player has said yes it becomes a small card at the right edge, with the download, // the install and the apply, so they can skate on while they wait; it goes when the map is in @@ -119,7 +121,12 @@ ImU32 faded(ImU32 colour, float alpha) { } std::string megabytes(std::uint64_t bytes) { const auto mb = static_cast(bytes) / (1024.0 * 1024.0); - return mb >= 1024.0 ? std::format("{:.2f} GB", mb / 1024.0) : std::format("{:.0f} MB", mb); + return mb >= 1024.0 ? std::format("{:.2f} GB", mb / 1024.0) : bytes && mb < 1.0 ? std::string("1 MB") : std::format("{:.0f} MB", mb); +} +// Whether the card lists what is asked for: a map with plugins, or plugins alone. A map alone +// has its own card, as it always had. +bool listed(const MapDownloadCard& of) { + return of.items.size() > 1 || (of.items.size() == 1 && of.items.front().plugin); } } @@ -143,7 +150,14 @@ void draw_progress(State& s) { const auto display = ImGui::GetIO().DisplaySize; const float u = std::min(display.x / 3840.0f, display.y / 2160.0f); const bool downloading = card.stage == 2, applying = card.stage == 4; - const char* title = downloading ? "DOWNLOADING MAP" : card.stage == 3 ? "INSTALLING MAP" : "APPLYING MAP"; + // Several packages: which of them is in hand, and its name. + const bool several = listed(card); + const auto count = several && !applying ? std::format(" {} OF {}", card.current + 1, card.items.size()) : std::string(); + const std::string title = downloading ? (several ? "DOWNLOADING" + count : "DOWNLOADING MAP") + : card.stage == 3 ? (several ? "INSTALLING" + count : "INSTALLING MAP") + : several ? "APPLYING MODS" : "APPLYING MAP"; + const std::string name = several && !applying && card.current < card.items.size() ? card.items[card.current].name + : card.map.empty() && !card.items.empty() ? card.items.front().name : card.map; const std::string status = downloading ? (card.total ? megabytes(card.received) + " of " + megabytes(card.total) : megabytes(card.received)) : card.stage == 3 ? std::string("Putting it in your Mods folder...") : card.step_name.empty() ? std::string("Adding it to the game...") : card.step_name + "..."; @@ -161,10 +175,10 @@ void draw_progress(State& s) { else skate_theme::rough_rect(draw, icon_min, icon_max, skate_theme::tile_light, 0x1c1, u * 2); const float x = left + icon + between, right = max.x - inset; float y = top; - draw->AddText(bold, label_size, ImVec2(x, y), theme::blue, title); + draw->AddText(bold, label_size, ImVec2(x, y), theme::blue, title.c_str()); y += label_size + 12 * u; draw->PushClipRect(ImVec2(x, y - name_size), ImVec2(right, y + name_size * 2), true); - draw->AddText(bold, name_size, ImVec2(x, y), theme::paper, card.map.c_str()); + draw->AddText(bold, name_size, ImVec2(x, y), theme::paper, name.c_str()); draw->PopClipRect(); y += name_size + 18 * u; const float share = downloading ? (card.total ? static_cast(card.received) / static_cast(card.total) : -1.0f) @@ -185,14 +199,139 @@ void draw_progress(State& s) { } void draw_asked_map(State& s); +void draw_asked_list(State& s); void draw_map_download() { if (card.stage) { auto& s = state(); if (card.stage != 1) draw_progress(s); + else if (listed(card)) draw_asked_list(s); else draw_asked_map(s); } draw_message(); // (over whatever else is up) } + +// A dedicated server's plugins, with its map when that is missing too: one card that lists all +// of it, asked once. Laid out as the map's card is (the pause menu's look), a row for each +// package: what it is, its name and author, and what getting it takes. Answered the same ways. +void draw_asked_list(State& s) { + auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont(); + auto* body = s.menu.body ? s.menu.body : ImGui::GetFont(); + auto* heading = s.menu.heading ? s.menu.heading : bold; + const auto display = ImGui::GetIO().DisplaySize; + const float u = std::min(display.x / 3840.0f, display.y / 2160.0f); + constexpr ImU32 sticky = IM_COL32(236, 215, 108, 255), sticky_dim = IM_COL32(150, 140, 86, 255); + const char* host = card.server ? "server" : "host"; + const auto width_of = [](ImFont* font, float size, const std::string& text) { return font->CalcTextSizeA(size, FLT_MAX, 0, text.c_str()).x; }; + const bool with_map = !card.items.front().plugin; + const bool all_here = std::all_of(card.items.begin(), card.items.end(), [](const MapDownloadCard::Item& item) { return item.installed; }); + + const char* title = with_map ? (card.moved ? "MAP CHANGED" : "MAP AND PLUGINS NEEDED") : "PLUGINS NEEDED"; + const auto plugins = static_cast(std::count_if(card.items.begin(), card.items.end(), + [](const MapDownloadCard::Item& item) { return item.plugin; })); + const std::string headline = with_map ? std::format("{} and {} {}", card.items.front().name, plugins, plugins == 1 ? "plugin" : "plugins") + : std::format("{} {}", plugins, plugins == 1 ? "plugin" : "plugins"); + const std::string why = std::format("The {} needs {} to join. Plugins and maps are the only mods a {} can ask for, and they come from Thunderstore.", + host, card.items.size() == 1 ? "this" : "these", host); + const std::string total = card.bytes ? "Downloads " + megabytes(card.bytes) + " in all" : std::string(); + + dingosdk::ControllerInput pad; + DingoSDKOverlayReadControllerInput(&pad, true); + struct Hint { std::string key; const char* what; }; + const std::vector hints{ + {pad.available ? std::string("D-pad") : std::string("< >"), "Pick"}, + {pad.available ? dingosdk::controller_combo_label(0x1000, pad.style) : std::string("Enter"), "Select"}, + {pad.available ? dingosdk::controller_combo_label(0x2000, pad.style) : std::string("Esc"), "Cancel"}}; + const float key_scale = u * 2, hint_gap = 36 * u; + float hints_width{}; + for (const auto& hint : hints) + hints_width += width_of(bold, 12 * key_scale, hint.key) + 20 * key_scale + width_of(bold, 14 * key_scale, hint.what); + hints_width += hint_gap * static_cast(hints.size() - 1); + + const float width = 1500 * u, inset = 44 * u, tall = 110 * u, row = 104 * u, row_gap = 12 * u; + const float inner = width - inset * 2; + const float label_size = 28 * u, name_size = 64 * u, row_name = 36 * u, row_text = 28 * u, text_size = 32 * u; + const auto why_extent = body->CalcTextSizeA(text_size, FLT_MAX, inner, why.c_str()); + // (A long list is cut short to what fits the screen: the rest are said in a line.) + const std::size_t most = std::max(3, static_cast((display.y * 0.45f) / (row + row_gap))); + const std::size_t shown = std::min(card.items.size(), most); + float height = inset + label_size + 20 * u + name_size + 24 * u + why_extent.y + 30 * u + + static_cast(shown) * (row + row_gap) + inset + tall + inset; + if (shown < card.items.size()) height += row_text + 12 * u; + if (!total.empty()) height += row_text + 18 * u; + const float under = 34 * u + 20 * key_scale; + const ImVec2 min(std::floor((display.x - width) * 0.5f), std::floor((display.y - height - under) * 0.5f)), max(min.x + width, min.y + height); + auto* draw = ImGui::GetForegroundDrawList(); + draw->AddRectFilled(ImVec2(0, 0), display, IM_COL32(0, 0, 0, s.hub_pointer ? 170 : 110)); + skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 250), 0x4d4, u * 2); + const float left = min.x + inset, right = max.x - inset; + float y = min.y + inset; + draw->AddText(bold, label_size, ImVec2(left, y), theme::blue, title); + y += label_size + 20 * u; + draw->PushClipRect(ImVec2(left, y - name_size), ImVec2(right, y + name_size * 2), true); + draw->AddText(heading, name_size, ImVec2(left, y), theme::paper, headline.c_str()); + draw->PopClipRect(); + y += name_size + 24 * u; + draw->AddText(body, text_size, ImVec2(left, y), theme::muted, why.c_str(), nullptr, inner); + y += why_extent.y + 30 * u; + + // A row for each: MAP or PLUGIN, its name and who made it, and on the right what it takes. + for (std::size_t at = 0; at < shown; ++at) { + const auto& item = card.items[at]; + const ImVec2 a(left, y), b(right, y + row); + skate_theme::rough_rect(draw, a, b, skate_theme::tile, 0x700 + static_cast(at), u * 2); + const std::string kind = item.plugin ? "PLUGIN" : "MAP"; + const float tag_size = 24 * u, tag_pad = 14 * u, tag_width = 150 * u; + const ImVec2 tag_min(a.x + 24 * u, a.y + (row - tag_size - tag_pad * 2) * 0.5f), + tag_max(tag_min.x + tag_width, tag_min.y + tag_size + tag_pad * 2); + skate_theme::rough_rect(draw, tag_min, tag_max, item.plugin ? theme::blue : sticky, 0x720 + static_cast(at), u * 2); + draw->AddText(bold, tag_size, ImVec2(tag_min.x + (tag_width - width_of(bold, tag_size, kind)) * 0.5f, tag_min.y + tag_pad), + skate_theme::black, kind.c_str()); + const std::string what = item.installed ? "Here, switched off" + : !item.have.empty() ? "Update " + item.have + " \xe2\x86\x92 " + item.version + : item.bytes ? megabytes(item.bytes) : std::string("Download"); + const float what_width = width_of(bold, row_text, what); + const float text_x = tag_max.x + 28 * u, text_right = b.x - 24 * u - what_width - 28 * u; + draw->PushClipRect(ImVec2(text_x, a.y), ImVec2(text_right, b.y), true); + draw->AddText(bold, row_name, ImVec2(text_x, a.y + 14 * u), theme::paper, item.name.c_str()); + const auto by = "by " + item.author + (item.version.empty() || !item.have.empty() ? std::string() : " v" + item.version); + draw->AddText(body, row_text, ImVec2(text_x, a.y + 14 * u + row_name + 8 * u), theme::muted, by.c_str()); + draw->PopClipRect(); + draw->AddText(bold, row_text, ImVec2(b.x - 24 * u - what_width, a.y + (row - row_text) * 0.5f), + item.installed || item.have.empty() ? theme::paper : theme::blue, what.c_str()); + y += row + row_gap; + } + if (shown < card.items.size()) { + const auto more = std::format("and {} more", card.items.size() - shown); + draw->AddText(body, row_text, ImVec2(left, y), theme::muted, more.c_str()); + y += row_text + 12 * u; + } + if (!total.empty()) { + draw->AddText(body, row_text, ImVec2(left, y + 6 * u), theme::muted, total.c_str()); + y += row_text + 18 * u; + } + y += inset - row_gap; + const float button_width = (inner - 24 * u) * 0.5f; + const auto button = [&](float at, bool yes, unsigned seed) { + const ImVec2 a(at, y), b(at + button_width, y + tall); + const bool picked = card.choice == yes; + const bool hovered = ImGui::IsMouseHoveringRect(a, b, false); + const bool lit = picked || hovered; + const ImU32 fill = yes ? (lit ? sticky : sticky_dim) : (lit ? theme::blue : skate_theme::tile_light); + skate_theme::rough_rect(draw, a, b, fill, seed, u * 2); + if (yes && picked) draw->AddRect(a, b, theme::blue, 0, 0, 6 * u); + const std::string label = yes ? (all_here ? "SWITCH ON" : card.items.size() == 1 ? "DOWNLOAD" : "GET ALL") : "CANCEL"; + const float size = 40 * u; + draw->AddText(bold, size, ImVec2(a.x + (button_width - width_of(bold, size, label)) * 0.5f, a.y + (tall - size) * 0.5f), + yes || lit ? skate_theme::black : theme::paper, label.c_str()); + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) + if (const auto reply = answer.load()) reply(yes); + }; + button(left, true, 0x53); + button(left + button_width + 24 * u, false, 0x54); + float x = min.x + (width - hints_width) * 0.5f; + const float hint_y = max.y + 34 * u; + for (const auto& hint : hints) x += theme::keycap(draw, bold, key_scale, ImVec2(x, hint_y), hint.key.c_str(), hint.what) + hint_gap; +} void draw_asked_map(State& s) { // Drawn as the pages of the game's pause menu are (hub_page.cpp): the same unit (one of // the game's 3840 by 2160), brushed heading, hand-cut tiles, the yellow note for what to diff --git a/Extension/UI/Overlay/modding_menu.cpp b/Extension/UI/Overlay/modding_menu.cpp index 78a9055e..1b5fd0bd 100644 --- a/Extension/UI/Overlay/modding_menu.cpp +++ b/Extension/UI/Overlay/modding_menu.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -69,10 +70,12 @@ std::filesystem::path game_directory() { return std::filesystem::path(std::wstring(executable.data(), length)).parent_path(); } // What a mod brings, as the native Mods page words it. -std::string mod_contents(const mods::Mod& mod) { - std::string text = mod.provides_layout && mod.provides_levels ? "Maps + game data" - : mod.provides_levels ? "Maps" : mod.provides_layout ? "Game data" : ""; - if (!mod.park_maps.empty()) text += text.empty() ? "Parks" : " + parks"; +std::string contents_text(const mods::Mod& mod) { + std::string text; + for (auto part : mods::mod_contents(mod)) { + if (!text.empty()) part[0] = static_cast(std::tolower(static_cast(part[0]))); + text += (text.empty() ? "" : " + ") + part; + } return text.empty() ? "Nothing to load" : text; } // Every mod folder and what mods.json says about it, re-read every 1.5 s while @@ -250,7 +253,7 @@ void draw_installed_mods(SkateMenu& menu) { status = "APPLY TO UNLOAD"; colour = skate_theme::danger; } - if (tile(index++, mod.title.empty() ? mod.name : mod.title, byline(mod), status, colour, mod_contents(mod), hover, + if (tile(index++, mod.title.empty() ? mod.name : mod.title, byline(mod), status, colour, contents_text(mod), hover, entry.enabled ? "Disable" : "Enable")) toggle = i; } diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 69779f5d..0f8c1b3f 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -398,7 +398,7 @@ struct CallbacksV2 { using MultiplayerQueue = bool (*)(const char *action, const char *argument, const char *password, char *result, std::size_t size); void set_multiplayer_queue(MultiplayerQueue) noexcept; -// A map the session needs being fetched (Extension/Assets/map_download.h), for its card. +// What the session needs being fetched, a map and plugins (Extension/Assets/map_download.h), for its card. struct MapDownloadCard { int stage{}; // 0 none, 1 asking, 2 downloading, 3 installing, 4 applying, 5 joining std::string map, package, author, version, description; @@ -409,6 +409,15 @@ struct MapDownloadCard { std::uint64_t bytes{}; // asking: the package's size; 0 when it is not known std::uint64_t received{}, total{}; std::uint32_t yes_bind{}, no_bind{}; + // Everything asked for, the map first when it is needed, then a dedicated server's plugins. + // One map alone has the map's own card; anything more is listed. + struct Item { + std::string name, author, version, have; + bool plugin{}, installed{}; + std::uint64_t bytes{}; + }; + std::vector items; + std::size_t current{}; // downloading, installing: the one in hand }; using MapDownloadFeed = MapDownloadCard (*)(); void set_map_download_feed(MapDownloadFeed) noexcept; @@ -693,6 +702,13 @@ inline std::atomic& menu_open_flag() { // Plugins read controllers this way (Extension/Plugins/plugin_joystick.h). void begin_input_access() noexcept; void end_input_access() noexcept; +// When the player last worked the controls (GetTickCount64 ms; 0: not since the game started): a +// key or a mouse button or move the game read, a gamepad's buttons, sticks or triggers, or a +// joystick a plugin reads. A multiplayer server is told while they do (PacketKind::present), so a +// player whose skater stands still (flying a plugin's drone) is not taken for away. Any thread. +std::uint64_t controls_touched_at() noexcept; +// A joystick a plugin read (Extension/Plugins/plugin_joystick.h): its buttons and six axes. +void note_joystick(std::uint32_t buttons, const std::array& axes) noexcept; } // Nonblocking. A later Present releases GPU resources after its fence completes. diff --git a/Extension/UI/Overlay/overlay_input.cpp b/Extension/UI/Overlay/overlay_input.cpp index 9562f987..7c836ef1 100644 --- a/Extension/UI/Overlay/overlay_input.cpp +++ b/Extension/UI/Overlay/overlay_input.cpp @@ -459,6 +459,8 @@ dingosdk::overlay::FlightInput read_player_flight_controller() { namespace dingosdk::overlay { void begin_input_access() noexcept { ++detail::overlay_input_access; } void end_input_access() noexcept { --detail::overlay_input_access; } +std::uint64_t controls_touched_at() noexcept { return detail::controls_at.load(std::memory_order_relaxed); } +void note_joystick(std::uint32_t buttons, const std::array& axes) noexcept { detail::note_pad(5, buttons, axes); } Gamepad read_gamepad() { const auto sample = read_controller_sample(); Gamepad out; @@ -471,6 +473,8 @@ Gamepad read_gamepad() { out.left_trigger = static_cast(sample.pad.bLeftTrigger) / 255.0f; out.right_trigger = static_cast(sample.pad.bRightTrigger) / 255.0f; out.buttons = sample.pad.wButtons; + // (A PlayStation controller the game reads itself is seen here, when ReSkate reads it.) + detail::note_pad(4, out.buttons, {out.left_x, out.left_y, out.right_x, out.right_y, out.left_trigger, out.right_trigger}); return out; } } // namespace dingosdk::overlay diff --git a/Extension/World/physics_wake_guard.cpp b/Extension/World/physics_wake_guard.cpp index a2fefde8..3d45672f 100644 --- a/Extension/World/physics_wake_guard.cpp +++ b/Extension/World/physics_wake_guard.cpp @@ -6,7 +6,6 @@ #include "Engine/Game/Build/20260929/physics_wake.h" #include #include -#include namespace dingosdk::physics_wake_guard { namespace { @@ -17,14 +16,15 @@ std::atomic installed{}; std::atomic most{}; std::atomic widened{}; -// Said the first few times, then each power of two: a session past the limit stays past it. +// Said once: with the owner pool raised (Extension/World/physics_world_size.cpp) every pass of every +// session is widened, so the line says how the pass is sized, not a crash each time (55a7342's logs +// had it thousands of times per session; 2026-10-24). __declspec(noinline) void note(std::uint32_t owners, std::uint32_t limit) noexcept { - const auto count = widened.fetch_add(1, std::memory_order_relaxed) + 1; - if (count > 4 && !std::has_single_bit(count)) return; + if (widened.fetch_add(1, std::memory_order_relaxed) != 0) return; try { logging::log(logging::Level::info, logging::Channel::runtime, - "Physics: the world has {} physics owners, more than the {} the game's wake pass makes room for, where the game " - "would have crashed; the pass makes room for all of them ({} pass(es) since the game started).", owners, limit, count); + "Physics: the wake pass makes room for the world's {} physics owners (the game's own size is {}, below the " + "raised owner pool).", owners, limit); } catch (...) {} } diff --git a/Extension/World/texture_handle_guard.cpp b/Extension/World/texture_handle_guard.cpp index cfc58cfb..3cc4bba0 100644 --- a/Extension/World/texture_handle_guard.cpp +++ b/Extension/World/texture_handle_guard.cpp @@ -3,10 +3,14 @@ #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/memory.h" #include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/supported_build.h" #include "Engine/Game/Build/20260929/texture_handles.h" +#include #include #include #include +#include +#include namespace dingosdk::texture_handle_guard { namespace { @@ -16,8 +20,9 @@ Redirect original{}; std::atomic installed{}; std::atomic misses{}; // What both callers read when there is nothing: a holder with no texture in it, which sends them -// to the pool's default for the id. +// to the entry's own texture, null for a dead entry (texture_handles.h). std::uintptr_t no_texture{}; +constexpr std::size_t stub_page_bytes = 0x1000; // Said for the first few, then each power of two: a stale handle is resolved on every frame it // is drawn. @@ -26,8 +31,8 @@ __declspec(noinline) void note(std::uint16_t id) noexcept { if (count > 4 && !std::has_single_bit(count)) return; try { logging::log(logging::Level::warning, logging::Channel::runtime, - "Textures: a texture handle ({:#x}) names a texture that is gone, where the game would have crashed; the default " - "texture is drawn for it ({} time(s) since the game started). A mod's material that keeps a texture another " + "Textures: a texture handle ({:#x}) names a texture that is gone, where the game would have crashed; nothing is " + "drawn for it ({} time(s) since the game started). A mod's material that keeps a texture another " "bundle unloaded does this.", id, count); } catch (...) {} } @@ -37,6 +42,64 @@ std::uintptr_t* guarded(std::uintptr_t pool, std::uint16_t id) { note(id); return &no_texture; } + +bool rel32(std::uintptr_t next_instruction, std::uintptr_t target, std::int32_t& displacement) noexcept { + const auto delta = static_cast(target) - static_cast(next_instruction); + if (delta < std::numeric_limits::min() || delta > std::numeric_limits::max()) return false; + displacement = static_cast(delta); + return true; +} + +bool write_code(std::uintptr_t address, const void* bytes, std::size_t size) noexcept { + auto* const destination = reinterpret_cast(address); + DWORD previous{}; + if (!VirtualProtect(destination, size, PAGE_EXECUTE_READWRITE, &previous)) return false; + std::memcpy(destination, bytes, size); + const bool flushed = FlushInstructionCache(GetCurrentProcess(), destination, size) != FALSE; + DWORD discarded{}; + return VirtualProtect(destination, size, previous, &discarded) != FALSE && flushed; +} + +// The null the guard leads resolve to reaches the texture view cache, which asks the texture for its +// key before keeping the view it built (texture_handles.h). The stub skips the keeping for a null +// texture, so the cache hands back the empty view: +// test r15, r15 / jz return / mov rbx, [cache] / add rbx, lock / jmp resume +bool skip_null_view_insert(std::uintptr_t base) noexcept { + std::array actual{}; + if (!memory::peek(base + th::view_cache_insert_rva, actual) || actual != th::view_cache_insert) return false; + auto* const stub = static_cast(memory::allocate_near_image(base, supported_build::game_image_size, stub_page_bytes)); + if (!stub) return false; + const auto at = reinterpret_cast(stub); + std::array code{0x4d, 0x85, 0xff, 0x0f, 0x84, 0, 0, 0, 0, 0x48, 0x8b, 0x1d, 0, 0, 0, 0, + 0x48, 0x81, 0xc3, 0, 0, 0, 0, 0xe9, 0, 0, 0, 0}; + std::memcpy(code.data() + 19, &th::view_cache_lock_offset, 4); + std::int32_t leave{}, cache{}, resume{}, into{}; + if (!rel32(at + 9, base + th::view_cache_return_rva, leave) || !rel32(at + 16, base + th::view_cache, cache) || + !rel32(at + 28, base + th::view_cache_resume_rva, resume) || !rel32(base + th::view_cache_insert_rva + 5, at, into)) { + VirtualFree(stub, 0, MEM_RELEASE); + return false; + } + std::memcpy(code.data() + 5, &leave, 4); + std::memcpy(code.data() + 12, &cache, 4); + std::memcpy(code.data() + 24, &resume, 4); + std::memset(stub, 0xcc, stub_page_bytes); + std::memcpy(stub, code.data(), code.size()); + DWORD previous{}; + if (!VirtualProtect(stub, stub_page_bytes, PAGE_EXECUTE_READ, &previous) || + !FlushInstructionCache(GetCurrentProcess(), stub, stub_page_bytes)) { + VirtualFree(stub, 0, MEM_RELEASE); + return false; + } + std::array jump{0xe9}; + std::memcpy(jump.data() + 1, &into, 4); + std::memset(jump.data() + 5, 0x90, jump.size() - 5); + if (!write_code(base + th::view_cache_insert_rva, jump.data(), jump.size())) { + (void)write_code(base + th::view_cache_insert_rva, th::view_cache_insert.data(), th::view_cache_insert.size()); + VirtualFree(stub, 0, MEM_RELEASE); + return false; + } + return true; +} } // namespace bool install(std::uintptr_t base) noexcept { @@ -48,6 +111,12 @@ bool install(std::uintptr_t base) noexcept { "Texture handle guard: this build's texture code differs; it is left as it is."); return false; } + // Without it the null only crashes a step later (Skate.exe+0x3e6ddc1), so the guard needs both. + if (!skip_null_view_insert(base)) { + logging::log(logging::Level::warning, logging::Channel::runtime, + "Texture handle guard: the game's texture view cache differs or could not be patched; it is left as it is."); + return false; + } const auto target = reinterpret_cast(base + th::redirect.rva); if (hook_prepare(target, reinterpret_cast(&guarded), reinterpret_cast(&original)) != HookOk || hook_enable(target) != HookOk) { diff --git a/Extension/World/texture_handle_guard.h b/Extension/World/texture_handle_guard.h index 042faa08..55f1c59b 100644 --- a/Extension/World/texture_handle_guard.h +++ b/Extension/World/texture_handle_guard.h @@ -5,9 +5,10 @@ // report): a render job resolves a texture handle a material parameter still holds after the // texture went, and the game reads through the null its redirect lookup gives back // (Engine/Game/Build//texture_handles.h). The guard gives that lookup an empty holder -// instead, so the job takes the game's own default texture for the id, the way it does for an id -// whose texture is simply gone; what it draws may show the default texture where the game would -// have closed. +// instead, so resolve answers no texture, and has the texture view cache hand back the empty view +// it builds for no texture rather than ask the missing texture for its key (Skate.exe+0x3e6ddc1, +// where 2.1.2's first guard only moved the crash). The material draws without that texture where +// the game would have closed. namespace dingosdk::texture_handle_guard { // Checks this build's function and hooks it; once. False when the build differs (no guard). bool install(std::uintptr_t base) noexcept; diff --git a/Launcher/Test/mod_manager_tests.cpp b/Launcher/Test/mod_manager_tests.cpp index dee0085d..7c1cb4f3 100644 --- a/Launcher/Test/mod_manager_tests.cpp +++ b/Launcher/Test/mod_manager_tests.cpp @@ -261,6 +261,24 @@ int main() { for (const auto& entry : mods::scan_mods(game).entries) if (entry.mod.name == "Deep_Mod") check(entry.mod.park_maps == std::vector{"bam", "grom"}, "Park maps are listed"); + + // What a mod holds, for the Type column: a plugin is one only when its manifest names code. + write(mods / L"Deep_Mod" / L"reskate-plugin.json", R"({"variants":{"folder":"decks"}})"); + for (const auto& entry : mods::scan_mods(game).entries) + if (entry.mod.name == "Deep_Mod") + check(!entry.mod.provides_plugin && mods::mod_contents(entry.mod) == std::vector{"Parks", "Game data"}, + "A reskate-plugin.json without an entry is no plugin"); + write(mods / L"Deep_Mod" / L"reskate-plugin.json", R"({"entry":"plugin/main.lua","permissions":[]})"); + write(mods / L"Deep_Mod" / L"reskate-music.json", "{}"); + write(mods / L"Deep_Mod" / L"reskate-objects.json", "{}"); + for (const auto& entry : mods::scan_mods(game).entries) + if (entry.mod.name == "Deep_Mod") + check(entry.mod.provides_plugin && + mods::mod_contents(entry.mod) == std::vector{"Parks", "Plugin", "Game data", "Objects", "Music"}, + "Plugins, objects and music are listed with what else the mod holds"); + std::filesystem::remove(mods / L"Deep_Mod" / L"reskate-plugin.json"); + std::filesystem::remove(mods / L"Deep_Mod" / L"reskate-music.json"); + std::filesystem::remove(mods / L"Deep_Mod" / L"reskate-objects.json"); make_zip(sources / L"parkmod.zip", {{"My Park/manifest.json", R"({"name":"My Park"})"}, {"My Park/parks/bam.park.json", "{}"}}); check(install(mods, sources / L"parkmod.zip") == "My Park", "A park mod (manifest.json only) installs"); @@ -402,14 +420,46 @@ int main() { .find("nothing for ReSkate to load") != std::string::npos && !fs::exists(store / L"Team-Empty"), "Thunderstore installs refuse a package with nothing to load"); - // A mod pack: nothing to load of its own, its manifest lists what it brings. - make_zip(sources / L"modpack.zip", {{"manifest.json", R"({"name":"Pack","version_number":"1.0.0","dependencies":["Team-Mod-1.0.0"]})"}, + check(install(store, sources / L"empty.zip") == "empty", "A plain install still takes a description-only mod"); + make_zip(sources / L"empty-list.zip", {{"manifest.json", R"({"name":"Nothing","dependencies":[]})"}}); + options.folder = "Team-Empty_List"; + check(error_of([&] { launcher_mods::install(store, sources / L"empty-list.zip", true, {}, cancel, options); }) + .find("plugins and mod packs") != std::string::npos && + !fs::exists(store / L"Team-Empty_List"), + "An empty dependencies list is not a mod pack, and the refusal names plugins and packs"); + + // A plugin-only package: reskate-plugin.json is something to load, at the top or one folder down. + make_zip(sources / L"plugin.zip", {{"manifest.json", manifest}, {"icon.png", "png"}, {"README.md", "readme"}, + {"reskate-plugin.json", R"({"api":1})"}, {"main.luau", "-- plugin"}}); + options.folder = "Team-Plugin"; + check(launcher_mods::install(store, sources / L"plugin.zip", true, {}, cancel, options) == "Team-Plugin" && + read(store / L"Team-Plugin" / L"main.luau") == "-- plugin" && + read(store / L"Team-Plugin" / L"icon.png") == "png", + "Thunderstore installs take a plugin-only package"); + make_zip(sources / L"nested-plugin.zip", {{"manifest.json", manifest}, {"icon.png", "png"}, {"README.md", "readme"}, + {"Plugin/reskate-plugin.json", R"({"api":1})"}, {"Plugin/main.luau", "-- nested"}}); + options.folder = "Team-Nested_Plugin"; + check(launcher_mods::install(store, sources / L"nested-plugin.zip", true, {}, cancel, options) == "Team-Nested_Plugin" && + read(store / L"Team-Nested_Plugin" / L"reskate-plugin.json") == R"({"api":1})" && + read(store / L"Team-Nested_Plugin" / L"main.luau") == "-- nested" && + read(store / L"Team-Nested_Plugin" / L"icon.png") == "png" && + read(store / L"Team-Nested_Plugin" / L"README.md") == "readme" && + !fs::exists(store / L"Team-Nested_Plugin" / L"Plugin"), + "A plugin one folder down is the mod, and the package files follow it in"); + const auto plugin_manifest = read(store / L"Team-Nested_Plugin" / L"manifest.json"); + check(plugin_manifest.find("Desert_Springs") != std::string::npos && plugin_manifest.find(R"("Zee")") != std::string::npos, + "The carried manifest.json gets the team as author"); + + // A mod pack: nothing of its own to load, only dependencies the launcher installs first. + make_zip(sources / L"modpack.zip", { + {"manifest.json", R"({"name":"Pack","version_number":"1.0.0","dependencies":["Zee-Desert_Springs-1.2.3"]})"}, {"icon.png", "png"}, {"README.md", "readme"}}); options.folder = "Team-Pack"; check(launcher_mods::install(store, sources / L"modpack.zip", true, {}, cancel, options) == "Team-Pack" && - fs::exists(store / L"Team-Pack" / L"manifest.json"), - "Thunderstore installs take a mod pack, whose manifest lists dependencies"); - check(install(store, sources / L"empty.zip") == "empty", "A plain install still takes a description-only mod"); + read(store / L"Team-Pack" / L"icon.png") == "png", + "Thunderstore installs take a mod pack that only lists dependencies"); + const auto pack = installed("Team-Pack"); + check(pack && pack->author == "Zee" && pack->version == "1.0.0", "A mod pack is listed with its team as author"); } // A built-in pack in ReSkate's own format unpacks to exactly its files (a name with spaces diff --git a/Launcher/Test/mod_profiles_tests.cpp b/Launcher/Test/mod_profiles_tests.cpp index 9d8f6dd6..755a81c4 100644 --- a/Launcher/Test/mod_profiles_tests.cpp +++ b/Launcher/Test/mod_profiles_tests.cpp @@ -79,6 +79,21 @@ void applying() { check(profiles::in_use(have, after) && !profiles::in_use(have, list), "In use does not follow which mods are on"); } +// "Keep my other mods on": a profile adds its mods to the player's own, and is in use when its own mods match. +void keeping_others() { + const auto list = installed({{"Team-A", "1.0.0", true}, {"Team-B", "2.1.0", false}, {"Local Mod", "wip", true}}); + const profiles::Profile pack{"Pack", {{"Team-B", "2.1.0", true}}}; + const auto applied = profiles::apply(pack, list, true); + check(order(applied.entries) == std::vector{"Team-B+", "Team-A+", "Local Mod+"}, + "Keeping other mods switched a mod the profile does not name off"); + check(order(profiles::apply(pack, list).entries) == std::vector{"Team-B+", "Team-A-", "Local Mod-"}, + "Without keeping other mods, the mods outside the profile were not switched off"); + auto after = list; + after.entries = applied.entries; + check(profiles::in_use(pack, after, true) && !profiles::in_use(pack, after), "In use with kept mods does not follow the profile's own mods"); + check(!profiles::in_use(pack, list, true), "A profile whose mod is off counts as in use"); +} + void storage(const std::filesystem::path& root) { std::filesystem::remove_all(root); std::filesystem::create_directories(root); @@ -86,9 +101,11 @@ void storage(const std::filesystem::path& root) { profiles::Profiles set; set.list = {{"Street", {{"Team-A", "1.0.0", true}, {"Team-B", "2.0.0", false}}}, {"Empty", {}}}; set.active = "Street"; + set.keep_others = true; profiles::save(root, set); const auto read = profiles::load(root); check(read.active == "Street" && read.list == set.list, "Profiles did not survive saving and loading"); + check(read.keep_others, "Keeping other mods did not survive saving and loading"); std::ofstream(root / profiles::profiles_file) << R"({"active":"Gone","profiles":[{"name":"ok","mods":[{"name":"../x"},{"name":"Fine"}]},)" R"({"name":"bad/name"},{"name":"ok"},7]})"; const auto loose = profiles::load(root); @@ -149,6 +166,7 @@ int wmain(int argc, wchar_t** argv) { } names(); applying(); + keeping_others(); storage(std::filesystem::path(argv[1]) / L"profiles"); exporting(std::filesystem::path(argv[1]) / L"profiles"); codes(); diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 5f258add..7db4a47c 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -391,12 +391,20 @@ std::string pair_reason(const ConflictPair& pair) { std::format(" ({} {})", pair.row->assets, pair.row->assets == 1 ? "file" : "files"); } -// What a mod brings, for the list's Type column. -std::string kind_of(const mods::Mod& mod) { - if (!mod.levels.empty()) return mod.levels.size() == 1 ? "Map" : std::format("{} maps", mod.levels.size()); - if (!mod.park_maps.empty()) return "Park"; - if (mod.provides_layout) return "Game data"; - return "Nothing"; +// What a mod brings, for the list's Type column: what says most about it, and on hover all of it. +struct Kind { + std::string label, tip; +}; +Kind kind_of(const mods::Mod& mod) { + const auto parts = mods::mod_contents(mod); + if (parts.empty()) return {"Nothing", "Holds nothing ReSkate loads"}; + std::string all; + for (std::size_t at = 0; at < parts.size(); ++at) { + auto part = parts[at]; + if (at) part[0] = static_cast(std::tolower(static_cast(part[0]))); + all += (at == 0 ? "" : at + 1 == parts.size() ? " and " : ", ") + part; + } + return {parts.size() == 1 ? parts.front() : parts.front() + " +", "Holds: " + all}; } // The rows the list shows, as places in the load order: what the search and the @@ -632,7 +640,7 @@ struct Columns { }; // Where the right-hand columns of a row `width` wide start, from its left edge. struct RightColumns { - float more, status, type, update; + float more, status, type; }; RightColumns right_columns(float width) { const Columns c; @@ -640,7 +648,6 @@ RightColumns right_columns(float width) { r.more = width - S(10) - c.more; r.status = r.more - S(12) - c.status; r.type = r.status - S(12) - c.type; - r.update = r.type - S(10) - c.update; return r; } @@ -767,23 +774,17 @@ void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Ins // The same icon the store shows, so a mod looks like itself on both pages. mod_icon(panel, package, ImVec2(start.x + c.icon, middle - S(15)), S(30)); - // Right to left: the menu, how it stands, what it is, and an update waiting for it. + // Right to left: the menu, how it stands, and what it is. ImGui::SetCursorScreenPos(ImVec2(start.x + r.more, middle - c.more * 0.5f)); if (more_button("##more", c.more)) menu = true; if (ImGui::IsItemHovered()) ImGui::SetTooltip("More"); - float name_end = start.x + r.type - S(12); - if (update) { - ImGui::SetCursorScreenPos(ImVec2(start.x + r.update, middle - S(14))); - push_primary_button(); - if (ImGui::Button("UPDATE", ImVec2(c.update, S(28)))) request = {Request::Kind::update, {mod.name}}; - pop_primary_button(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Thunderstore has v%s", package->latest().number.c_str()); - name_end = start.x + r.update - S(12); - } const auto kind = kind_of(mod); draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(start.x + r.type, middle - fonts.body->FontSize * 0.5f), - color::muted, kind.c_str()); + color::muted, kind.label.c_str()); + if (ImGui::IsWindowHovered() && + ImGui::IsMouseHoveringRect(ImVec2(start.x + r.type, start.y), ImVec2(start.x + r.status, start.y + tall))) + ImGui::SetTooltip("%s", kind.tip.c_str()); // How it stands: a pill, and on hover why. { @@ -817,24 +818,42 @@ void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Ins ImGui::SetTooltip("%s", tip.c_str()); } - // The name, who made it, and its version: with an update waiting, old -> new in blue. + // The name, who made it, and its version: with an update waiting, old -> new in blue and the + // UPDATE button right after it, so the button sits with the change it makes. A name too long + // for the row is cut short before the button rather than pushing it into the Type column. { - const float x = start.x + c.name; + const float x = start.x + c.name, limit = start.x + r.type - S(12); + const auto to = update ? (mod.version.empty() ? std::string() : "v" + mod.version + " ") + "\xe2\x86\x92 v" + + package->latest().number + : std::string(); + const auto by = mod.author.empty() ? std::string() + : "by " + mod.author + (mod.version.empty() || update ? std::string() : " v" + mod.version); + const auto wide = [&](ImFont* font, const std::string& text) { + return font->CalcTextSizeA(font->FontSize, FLT_MAX, 0, text.c_str()).x; + }; + float name_end = limit; + if (update) { + const float text = wide(fonts.bold, mod.title) + S(10) + (by.empty() ? 0.0f : wide(fonts.body, by) + S(10)) + + wide(fonts.body, to); + const float button_x = std::min(x + text + S(12), limit - c.update); + ImGui::SetCursorScreenPos(ImVec2(button_x, middle - S(13))); + push_primary_button(); + if (ImGui::Button("UPDATE", ImVec2(c.update, S(26)))) request = {Request::Kind::update, {mod.name}}; + pop_primary_button(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Thunderstore has v%s", package->latest().number.c_str()); + name_end = button_x - S(12); + } const ImVec4 clip(x, start.y, name_end, start.y + tall); const float y = middle - fonts.bold->FontSize * 0.5f, body_y = middle - fonts.body->FontSize * 0.5f; draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(x, y), entry.enabled ? color::text : color::muted, mod.title.c_str(), nullptr, 0, &clip); float after = x + fonts.bold->CalcTextSizeA(fonts.bold->FontSize, FLT_MAX, 0, mod.title.c_str()).x + S(10); - if (!mod.author.empty()) { - const auto by = "by " + mod.author + (mod.version.empty() || update ? std::string() : " v" + mod.version); + if (!by.empty()) { draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(after, body_y), color::muted, by.c_str(), nullptr, 0, &clip); - after += fonts.body->CalcTextSizeA(fonts.body->FontSize, FLT_MAX, 0, by.c_str()).x + S(10); + after += wide(fonts.body, by) + S(10); } - if (update) { - const auto to = (mod.version.empty() ? std::string() : "v" + mod.version + " ") + "\xe2\x86\x92 v" + - package->latest().number; + if (update) draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(after, body_y), color::blue, to.c_str(), nullptr, 0, &clip); - } } end_row(); diff --git a/Launcher/gui_mods_profiles.cpp b/Launcher/gui_mods_profiles.cpp index 607556e9..cfb6b441 100644 --- a/Launcher/gui_mods_profiles.cpp +++ b/Launcher/gui_mods_profiles.cpp @@ -68,7 +68,7 @@ std::vector unobtainable(const ModsPanel& panel, const std::vector< // Writes mods.json as the profile has it. False, with the reason shown, when that failed. bool write_order(ModsPanel& panel, const profiles::Profile& profile, std::vector& missing) { - auto applied = profiles::apply(profile, panel.list); + auto applied = profiles::apply(profile, panel.list, panel.profiles.keep_others); try { mods::save_mod_order(panel.root, applied.entries); } catch (const std::exception& failure) { @@ -122,7 +122,7 @@ void add_profile(ModsPanel& panel, profiles::Profile profile, std::string_view v profile.name = panel.profiles.free_name(profiles::clean_profile_name(profile.name)); const auto name = profile.name; const auto count = profile.mods.size(); - const auto missing = profiles::apply(profile, panel.list).missing.size(); + const auto missing = profiles::apply(profile, panel.list, panel.profiles.keep_others).missing.size(); panel.profiles.list.push_back(std::move(profile)); if (!store(panel)) { panel.profiles.list.pop_back(); @@ -319,10 +319,14 @@ void profiles_page(const Fonts& fonts, ModsPanel& panel, HWND window, float heig const profiles::Profile* using_now = nullptr; for (const auto& profile : panel.profiles.list) - if (!using_now && profiles::in_use(profile, panel.list)) using_now = &profile; + if (!using_now && profiles::in_use(profile, panel.list, panel.profiles.keep_others)) using_now = &profile; page_banner(fonts, "PROFILES", nullptr, - "A profile remembers which of your mods are on and in what order. Every mod stays installed once; loading a " - "profile switches the rest off. Share one as a code or a file, with ReSkate or r2modman.", + panel.profiles.keep_others + ? "A profile remembers which of your mods are on and in what order. Every mod stays installed once; loading a " + "profile switches its mods on and leaves your other mods as they are. Share one as a code or a file, with " + "ReSkate or r2modman." + : "A profile remembers which of your mods are on and in what order. Every mod stays installed once; loading a " + "profile switches the rest off. Share one as a code or a file, with ReSkate or r2modman.", using_now ? "IN USE: " + using_now->name : "NO PROFILE IN USE", using_now ? color::good : color::tile_grey, std::format("{} of {} saved", panel.profiles.list.size(), profiles::max_profiles)); @@ -390,7 +394,16 @@ void profiles_page(const Fonts& fonts, ModsPanel& panel, HWND window, float heig ImGui::EndDisabled(); ImGui::SetCursorScreenPos(ImVec2(start.x, start.y + tall + S(12))); - ImGui::Dummy(ImVec2(0, 0)); + ImGui::BeginDisabled(busy); + // The label as far from the box as the settings page has it (an item's spacing, not the inner one). + ImGui::PushStyleVar(ImGuiStyleVar_ItemInnerSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, ImGui::GetStyle().ItemInnerSpacing.y)); + if (ImGui::Checkbox("Keep my other mods on when loading a profile", &panel.profiles.keep_others)) store(panel); + ImGui::PopStyleVar(); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("On: a profile switches its own mods on and leaves the rest as they are.\n" + "Off: a profile switches every mod it does not list off."); + ImGui::Dummy(ImVec2(0, S(4))); } // ------------------------------------------------ the profiles, a card each @@ -408,8 +421,8 @@ void profiles_page(const Fonts& fonts, ModsPanel& panel, HWND window, float heig const auto& profile = panel.profiles.list[index]; ImGui::PushID(static_cast(index)); const auto on = std::count_if(profile.mods.begin(), profile.mods.end(), [](const auto& mod) { return mod.enabled; }); - const auto missing = profiles::apply(profile, panel.list).missing.size(); - const bool current = profiles::in_use(profile, panel.list); + const auto missing = profiles::apply(profile, panel.list, panel.profiles.keep_others).missing.size(); + const bool current = profiles::in_use(profile, panel.list, panel.profiles.keep_others); const bool last = !current && profile.name == panel.profiles.active; std::vector names; for (const auto& mod : profile.mods) diff --git a/Launcher/mod_manager.cpp b/Launcher/mod_manager.cpp index 1c73e370..f1aae088 100644 --- a/Launcher/mod_manager.cpp +++ b/Launcher/mod_manager.cpp @@ -23,13 +23,15 @@ namespace fs = std::filesystem; namespace dingosdk::launcher_mods { namespace { -// What the game loads from a mod, and the files that only describe one. -constexpr std::array content_markers{"layout.toc", "reskate-levels.json"}; +// What the game loads from a mod (a plugin's reskate-plugin.json sits at its +// mod's top), and the files that only describe one. +constexpr std::array content_markers{"layout.toc", "reskate-levels.json", "reskate-plugin.json"}; constexpr std::array info_markers{"manifest.json", "reskate-mod.json"}; // Thunderstore requires these at the top of a package (lower case here); they // follow the mod in when it is packed one folder deeper. constexpr std::array package_files{"manifest.json", "icon.png", "readme.md", "changelog.md"}; -constexpr char no_mod[] = " does not contain a ReSkate mod (no layout.toc, reskate-levels.json or manifest.json)."; +constexpr char no_mod[] = + " does not contain a ReSkate mod (no layout.toc, reskate-levels.json, reskate-plugin.json or manifest.json)."; // Staging lives inside Mods so the final move is a rename on one volume; the // leading dot keeps the runtime from ever treating it as a mod. constexpr wchar_t staging_folder[] = L".reskate-install"; @@ -84,8 +86,9 @@ struct Root { bool content{}; // holds something the game loads }; -// The shallowest folder holding game content (layout.toc, reskate-levels.json -// or parks/.park.json), else the shallowest holding only a description. +// The shallowest folder holding game content (layout.toc, reskate-levels.json, +// reskate-plugin.json or parks/.park.json), else the shallowest holding +// only a description. // Content wins so a package's own manifest.json at the top cannot hide a mod // packed one folder down. std::optional mod_root(const std::vector& files) { @@ -146,30 +149,11 @@ std::string mod_name(const Root& root, const std::string& source_name, const Ins return folder_name(root.folder.empty() ? without_version(source_name) : last_folder(root.folder)); } -// Whether the manifest.json in `folder` lists dependencies: a mod pack, which is nothing else. -bool lists_dependencies(const fs::path& folder) { - try { - std::ifstream input(folder / mods::manifest_file, std::ios::binary); - if (!input) return false; - std::string text{std::istreambuf_iterator(input), {}}; - if (text.starts_with("\xef\xbb\xbf")) text.erase(0, 3); - const auto json = Json::parse(text, JsonLimits{1024 * 1024, 16, 65536}); - return json.is_object() && json.contains("dependencies") && json.at("dependencies").is_array() && - json.at("dependencies").size() > 0; - } catch (...) { - return false; - } -} - -// Last steps in staging: a source that gives the game nothing to load is -// refused when asked, unless it is a mod pack, and the author goes into manifest.json. -void finish(const fs::path& staging, const Root& root, const std::string& source_name, const InstallOptions& options) { - if (options.require_content && !root.content && !lists_dependencies(staging)) - fail(source_name + " has nothing for ReSkate to load: no layout.toc, reskate-levels.json or parks folder. " - "Only mods built with ReSkate Studio, and park mods, can be installed."); - const auto manifest = staging / mods::manifest_file; +// The mod's manifest.json as an object, if it has a readable one. An unreadable +// manifest is the mod's own business; the game reports it. +std::optional read_manifest(const fs::path& manifest) { std::error_code error; - if (options.author.empty() || !fs::is_regular_file(manifest, error)) return; + if (!fs::is_regular_file(manifest, error)) return std::nullopt; try { std::string text; { @@ -178,13 +162,28 @@ void finish(const fs::path& staging, const Root& root, const std::string& source } if (text.starts_with("\xef\xbb\xbf")) text.erase(0, 3); auto json = Json::parse(text, JsonLimits{1024 * 1024, 16, 65536}); - if (!json.is_object()) return; - if (json.contains("author") && json.at("author").is_string() && !json.at("author").string().empty()) return; - json.items()["author"] = Json(options.author); - std::ofstream(manifest, std::ios::binary | std::ios::trunc) << json.dump(4) << '\n'; + if (json.is_object()) return json; } catch (...) { - // An unreadable manifest is the mod's own business; the game reports it. } + return std::nullopt; +} + +// Last steps in staging: a source that gives the game nothing to load is +// refused when asked, unless its manifest.json lists dependencies (a mod pack: +// what it brings is those), and the author goes into manifest.json. +void finish(const fs::path& staging, const Root& root, const std::string& source_name, const InstallOptions& options) { + const auto manifest = staging / mods::manifest_file; + auto json = read_manifest(manifest); + const bool pack = json && json->contains("dependencies") && json->at("dependencies").is_array() && + !json->at("dependencies").empty(); + if (options.require_content && !root.content && !pack) + fail(source_name + " has nothing for ReSkate to load: no layout.toc, reskate-levels.json, reskate-plugin.json " + "or parks folder, and no dependencies. Only mods built with ReSkate Studio, park mods, plugins and mod " + "packs can be installed."); + if (options.author.empty() || !json) return; + if (json->contains("author") && json->at("author").is_string() && !json->at("author").string().empty()) return; + json->items()["author"] = Json(options.author); + std::ofstream(manifest, std::ios::binary | std::ios::trunc) << json->dump(4) << '\n'; } // Entry names from an archive are untrusted: no absolute paths, drive diff --git a/Launcher/mod_manager.h b/Launcher/mod_manager.h index b70acb67..891d9857 100644 --- a/Launcher/mod_manager.h +++ b/Launcher/mod_manager.h @@ -40,8 +40,8 @@ struct InstallOptions { }; // Installs `source` (a .zip, or a folder) as Mods/ and returns . -// The mod is the shallowest folder holding layout.toc, reskate-levels.json or -// parks/.park.json, else the shallowest holding manifest.json or +// The mod is the shallowest folder holding layout.toc, reskate-levels.json, +// reskate-plugin.json or parks/.park.json, else the shallowest holding manifest.json or // reskate-mod.json. Thunderstore's manifest.json, icon.png, README.md and // CHANGELOG.md at the top of a package are copied in when the mod sits one // folder deeper. A top-level mod takes the archive's name without a trailing diff --git a/Launcher/mod_profiles.cpp b/Launcher/mod_profiles.cpp index 81dec324..d1b3749b 100644 --- a/Launcher/mod_profiles.cpp +++ b/Launcher/mod_profiles.cpp @@ -228,6 +228,7 @@ Profiles load(const fs::path& mods_root) noexcept { } profiles.active = root.value("active", ""); if (!profiles.find(profiles.active)) profiles.active.clear(); + profiles.keep_others = root.value("keep_others", false); } catch (...) { profiles = {}; } @@ -252,6 +253,7 @@ void save(const fs::path& mods_root, const Profiles& profiles) { } Json root = Json::object(); root["active"] = profiles.active; + root["keep_others"] = profiles.keep_others; root["profiles"] = std::move(list); const auto target = mods_root / profiles_file; auto temporary = target; @@ -280,7 +282,7 @@ Profile capture(std::string name, const mods::ModList& list) { return profile; } -Applied apply(const Profile& profile, const mods::ModList& list) { +Applied apply(const Profile& profile, const mods::ModList& list, bool keep_others) { Applied result; std::map installed; for (const auto& entry : list.entries) installed.emplace(folded(entry.mod.name), &entry); @@ -299,13 +301,13 @@ Applied apply(const Profile& profile, const mods::ModList& list) { for (const auto& entry : list.entries) if (!used.contains(folded(entry.mod.name))) { result.entries.push_back(entry); - result.entries.back().enabled = false; + if (!keep_others) result.entries.back().enabled = false; } return result; } -bool in_use(const Profile& profile, const mods::ModList& list) { - const auto applied = apply(profile, list); +bool in_use(const Profile& profile, const mods::ModList& list, bool keep_others) { + const auto applied = apply(profile, list, keep_others); if (!applied.missing.empty()) return false; const auto enabled = [](const std::vector& entries) { std::vector names; diff --git a/Launcher/mod_profiles.h b/Launcher/mod_profiles.h index dea21e9e..85dfe9de 100644 --- a/Launcher/mod_profiles.h +++ b/Launcher/mod_profiles.h @@ -34,6 +34,9 @@ struct Profile { struct Profiles { std::string active; // the profile last loaded or saved; empty for none std::vector list; + // Loading a profile leaves the mods it does not name as they are, instead of switching them off: + // a profile then adds its mods to the player's own. + bool keep_others{}; Profile* find(std::string_view name); // `wanted`, or `wanted (2)`, `wanted (3)`... when a profile already has that name. @@ -59,14 +62,16 @@ Profile capture(std::string name, const mods::ModList& list); struct Applied { // What mods.json should hold: the profile's installed mods in its order with its - // switches, then every installed mod it does not name, switched off. + // switches, then every installed mod it does not name, switched off (or left as it is + // with keep_others). std::vector entries; // The mods it switches on that are not installed, to be downloaded. std::vector missing; }; -Applied apply(const Profile& profile, const mods::ModList& list); +Applied apply(const Profile& profile, const mods::ModList& list, bool keep_others = false); // True when loading `profile` would change nothing: the same mods on, in the same order. -bool in_use(const Profile& profile, const mods::ModList& list); +// With keep_others only the profile's own mods count. +bool in_use(const Profile& profile, const mods::ModList& list, bool keep_others = false); // export.r2x. parse_export throws a readable message for text that is not one. std::string export_text(const Profile& profile); diff --git a/MACOS-SETUP.md b/MACOS-SETUP.md deleted file mode 100644 index b0873997..00000000 --- a/MACOS-SETUP.md +++ /dev/null @@ -1,44 +0,0 @@ -# ReSkate on macOS (Steam for Mac + NotProton / CrossOver) - -ReSkate has no macOS build. It runs through a Wine-based Steam compatibility tool on top of the -Steam for Mac client, set up much like Linux (see the README). Found and first tested by codejunky -on an M1 Pro with macOS 26 and NotProton on CrossOver 26.3: the launcher showed *Welcome, * and the game reached the Hub. - -## Setup - -1. Install skate. with Steam for Mac. -2. Install the compatibility tool, then in Steam → skate. → Properties → Compatibility, force it. -3. Put `ReSkateLauncher.exe` and `ReSkate.dll` in a new, empty folder of their own, for example - `~/Games/ReSkate` (not Steam's skate. folder: the launcher refuses to run from there). -4. In Steam → skate. → Properties → Launch Options, enter this with your own folder's path: - - ``` - /bin/bash -c 'export ROSETTA_ADVERTISE_AVX=1; exec "${@/*EAAntiCheat.GameServiceLauncher.exe//Users/you/Games/ReSkate/ReSkateLauncher.exe}"' -- %command% - ``` - -5. Press **Play** on skate. in your Steam library. Start it from Steam rather than running the - launcher yourself: the launcher finds your Steam account through what Steam gives the - compatibility tool. The first time, it installs its own copy of the game into that folder. - -### Why this launch option - -- **`/bin/bash`, not `bash`.** Steam for Mac runs a launch option directly, without a shell, so - `bash` alone fails to start (*Failed to spawn process*, OS error 260); the full path works. -- **`ROSETTA_ADVERTISE_AVX=1`.** Without it the game stops at a *Below Min Spec* dialog: it checks - that the CPU reports F16C and OSXSAVE, which Rosetta 2 runs but does not report unless told to. - It has to be set before Wine starts, so it goes in the launch option. -- **The `${@/…/…}` swap** is the Linux one: Steam would start EA's anti-cheat launcher, which does - not run under Wine, and this starts ReSkate's launcher in its place. - -The setup above, with the launcher in a folder of its own, has not been tested on a Mac yet (it was -first tested with the launcher beside `Skate.exe`, the setup ReSkate used then). - -## If the launcher says Steam is not running - -The launcher reads the signed-in account from Steam's `config/loginusers.vdf`. Under a Wine-based -tool on macOS, Steam points the tool inside its app bundle rather than at that folder, and the tool -keeps no record of the running Steam client where Windows has one; the launcher looks up from the -bundle for `config/loginusers.vdf` and, started by Steam this way, takes the account it marks as -most recent. If it still says Steam is not running, check that skate. was started from the Steam -library with the launch option above, and that Steam is signed in. diff --git a/MUSIC-SETUP.md b/MUSIC-SETUP.md deleted file mode 100644 index 79c81327..00000000 --- a/MUSIC-SETUP.md +++ /dev/null @@ -1,203 +0,0 @@ -# ReSkate music sources: setup - -ReSkate can play **Spotify**, **YouTube Music**, **your own music files**, **internet radio** and -**any app on your PC** through the in-game music player. Each one shows up as its own row in the game's music screen, with tiles you pick -like any other playlist. Pause, next, track name and cover art all work from the game. - -Everything is set up from one page in your browser. Each source has numbered steps on the page -that tick off as you do them, so you can also just follow the page. - -## Open the setup page - -1. Start the game. -2. Press **~** to open the console. -3. Type `music setup` and press Enter. (The menu's **MUSIC** page has a button for it too, under **SOURCES**.) - -Your browser opens `http://127.0.0.1:8421/`. The page only works on the PC the game is running -on, and only while the game is running. - -> A new row or tile can take a game restart to show up. If you set something up and don't see -> it in the music screen, restart the game once. - ---- - -## Spotify - -**You need:** Spotify Premium. A free account cannot play to the game: if you pick **ReSkate** -with one, the game tells you so on the setup page and forgets it. - -### Play Spotify through the game - -1. Start the game. Windows may ask whether to allow the game through the firewall: allow it on - private networks. Spotify uses this to find the game. -2. Open Spotify on your phone or PC, on the same network as the game. -3. Open Spotify's device list and pick **ReSkate**. - -That's it. You only do this once; after that the game signs in by itself. - -In the game's music screen, open the **Spotify** row and pick the **Spotify** tile. Whatever -Spotify was playing carries on through the game. If nothing was playing, it starts your Liked -Songs on shuffle. - -### Show your own playlists (optional) - -To get your playlists and Liked Songs as tiles, with their tracks listed, ReSkate needs a -Spotify developer app. This is free and takes a couple of minutes. - -1. Go to and sign in. -2. Create an app. Any name and description will do. -3. Add this redirect URI exactly: `http://127.0.0.1:47824/callback` -4. Copy the app's **Client ID**. -5. On the setup page, under **Spotify → Show your own playlists**, paste the Client ID and press **Save**. -6. Press **Sign in with Spotify** and approve it in the browser tab that opens. - -Your first 50 playlists appear, each with its first 50 tracks. Liked Songs shows your 50 most -recent. Pressing a track in the game starts Spotify at that track. - ---- - -## YouTube Music - -**You need:** nothing installed beforehand. ReSkate downloads one small tool for you. - -> Playing YouTube this way is against YouTube's terms of service. Whether to use it is up to you. - -1. On the setup page, under **YouTube Music**, press **Download and set up**. This downloads - `yt-dlp` (about 18 MB) from its official GitHub releases. -2. Paste a link to a YouTube or YouTube Music **video or playlist** and press **Add**. - Up to 100 songs per playlist, 12 playlists. -3. In the game's music screen, open the **YouTube Music** row and pick your playlist, or press - a song in it. - -Each song takes about two seconds to start. - -### Songs that get skipped - -Private and age-restricted songs only play if YouTube can see you're signed in. If songs are -being skipped: - -1. In your browser, signed in to YouTube, install the extension - **Get cookies.txt LOCALLY**. -2. On youtube.com, use it to export a `cookies.txt`. -3. On the setup page, under **YouTube Music → Songs being skipped?**, choose that file. - -ReSkate downloads one more small tool (QuickJS, about 2 MB) the first time you add a sign-in; -there is nothing else to install. With a sign-in set, each song takes about seven seconds to -start instead of two. If you have **Node.js** installed, ReSkate uses that instead and it is -about twice as fast. - -Treat `cookies.txt` like a password: it is your YouTube sign-in. It stays on your PC, and the -**Remove cookies.txt** button deletes it. Cookies expire, so if songs start skipping again -weeks later, export a fresh file. - ---- - -## Local files - -Plays music saved on your PC: MP3, M4A, AAC, Ogg, FLAC and WAV. - -1. Open the folder with your music in File Explorer, click the address bar at the top and copy - what it shows. -2. On the setup page, under **Local files**, paste it and press **Add**. Or press - **Or use my Music folder**. -3. In the game's music screen, open the **Local Files** row and pick the folder, or press a - song in it. - -Each folder is a tile with up to 200 songs, including the folders inside it. Song names and -covers come from the files' tags; a file with no tags is named after its file name -(`Artist - Title.mp3` works best). Up to 12 folders. - -Added new files to a folder? Press **Rescan** next to it on the setup page. - ---- - -## Online radio - -Plays live internet radio stations. Each station you add is its own tile. - -1. On the setup page, under **Online radio**, type part of a station's name and press - **Search**. This searches [radio-browser.info](https://www.radio-browser.info), a free public - directory. -2. Press **Add** next to the station you want. Up to 24 stations. -3. In the game's music screen, open the **Online Radio** row and pick the station. - -The track name updates as the station changes song, when the station sends it. Radio is live, -so pausing leaves the station and playing tunes back in; there is no next or previous. - -If you have a stream's address instead, open **Have a stream's address instead?** and paste it. -MP3 and AAC streams work, and `.pls` / `.m3u` links to them. Addresses ending in `.m3u8` (HLS) -are not supported. - ---- - -## External audio - -Plays the sound of any app on your PC through the game: a browser, a media player, another -music service. - -1. Start the app and get it **playing something**. Only apps making sound are listed. -2. On the setup page, under **External audio**, pick the app and press **Use this app**. -3. In the game's music screen, open the **External Audio** row and pick its tile. - -While it plays through the game, the app's own volume is turned right down so you don't hear -it twice. It's put back when you leave the tile or close the game. - -Track name, cover, pause and next work for apps that support Windows media controls (the same -ones your keyboard's play/pause key uses). Browsers and most media players do. An app that -doesn't will still be heard through the game, but can't be paused or skipped from it. - ---- - -## Sound settings - -In the **Sound** section of the setup page: - -- **Loudness:** how loud the music is through the game. -- **Keep every song at the same loudness:** turns quiet songs up and loud ones down. -- **Equaliser:** five sliders, from bass to treble, with a few presets. Up is more, down is - less. It only changes the music you play through ReSkate, not the game's own. -- **Speakers in the world play Spotify too:** when on, speakers in the world play what you're - playing, and grabbing music from one keeps you on your own music. - -These settings are remembered between game sessions. - ---- - -## Queue - -The **Queue** section of the setup page lists the songs of the YouTube Music or Local Files -playlist you are playing, with the current one marked. Press a song to jump to it, or type in -the box to find one. Spotify keeps its own queue, in the Spotify app. - ---- - -## Troubleshooting - -| Problem | Try this | -| --- | --- | -| "ReSkate" isn't in Spotify's device list | Make sure the game is running, both are on the same network, and the game was allowed through the firewall. | -| Spotify tile is silent | Type `music` in the console. It shows whether a Spotify account is on the device. If not, pick **ReSkate** in Spotify's device list again. | -| New playlist or row isn't in the music screen | Restart the game. | -| A newly added playlist plays the wrong music | Press a song in it instead of the tile, or restart the game. | -| YouTube songs keep getting skipped | See "Songs that get skipped" above. | -| YouTube stopped working altogether | YouTube changes things often. Close the game, delete `yt-dlp.exe` from `%LOCALAPPDATA%\ReSkate\bin`, start the game and press **Download and set up** again to get the latest version. | -| A local file gets skipped | It is a kind ReSkate can't play (for example Apple Lossless or WMA), or the file has moved. Press **Rescan**. | -| A radio station won't play | Some stations only send HLS, which isn't supported. Try another entry for the same station in the search results. | -| The setup page says the game isn't answering | The game has been closed. The page only works while it's running. | - -## Where things are kept - -Everything is on your PC, in `%LOCALAPPDATA%\ReSkate`: - -| File or folder | What it is | -| --- | --- | -| `spotify-device\` | the remembered Spotify sign-in for the in-game device | -| `spotify.json` | your Client ID and playlist sign-in (encrypted for your Windows account) | -| `youtube-music.json` | your saved YouTube playlists, music folders and radio stations | -| `music-settings.json` | loudness, equaliser and the other sound settings | -| `youtube-cookies.txt` | your YouTube sign-in, if you added one | -| `external-audio.json` | which app External Audio uses | -| `bin\yt-dlp.exe` | the YouTube tool | -| `bin\qjs.exe` | QuickJS, which the YouTube tool needs once a sign-in is set | - -Deleting any of these resets that part. diff --git a/README.md b/README.md index 78083c54..087521c6 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ custom map and then join a server on another map, the screen can stay black ### macOS (NotProton / CrossOver) Steam for Mac with a Wine-based compatibility tool works much the same way; see -[MACOS-SETUP.md](MACOS-SETUP.md) for its launch option. +[macOS](https://docs.reskate.dev/macos) in the docs for its launch option. ### Multiplayer map downloads @@ -111,6 +111,10 @@ being forcibly interrupted. Retry after that work has finished, without restarti The menu and console keys can be changed in the launcher's Settings. +The music player can play Spotify, YouTube Music, your own music files, internet radio and any app +on your PC: type `music setup` in the console, and see +[Music sources](https://docs.reskate.dev/music-sources) in the docs. + In **MAP → ROTATING PARKS** and the controller-friendly **MOD OPTIONS → PARKS** screen, **Load Random Parks** chooses and loads one random layout for each of San Vansterdam's three slots. Each supported layout has an equal chance; empty lots are excluded and @@ -135,6 +139,29 @@ accepts `park random` from its console or an admin. | The game's own settings and saves | `%LOCALAPPDATA%\ReSkate\Game\` (kept apart from the normal game) | | Launcher settings | `ReSkateLauncher.settings.json` beside the launcher | +### Troubleshooting + +**My antivirus says ReSkate is a virus.** It is a false alarm. ReSkate has to load itself into the game as +it starts, and antivirus programs distrust anything that does that, most of all new programs they have not +seen much of yet. Only download ReSkate from this page's +[Releases](https://github.com/Dingo-Shenanigans/ReSkate/releases). If your antivirus removed a file, you can +bring it back: Windows Security → Protection history → find ReSkate → Restore. An error about SHA-256 right +after an update usually means the antivirus took the new file. + +**The launcher says it cannot run from Steam's skate. folder.** Move `ReSkateLauncher.exe` and `ReSkate.dll` +into a new, empty folder of their own and run the launcher from there (see [Getting started](#getting-started)). + +**The ReSkate menu (Insert) doesn't open.** ReSkate isn't running. On Windows, always start the game with +**PLAY** in the ReSkate launcher: pressing Play on skate. in Steam starts the normal game, without ReSkate. +On Linux and macOS, use the launch option in [Linux (Proton)](#linux-proton) or +[macOS](https://docs.reskate.dev/macos) in the docs. + +**The game crashes.** Turn your mods off and try again first: many crashes come from mods. If it still +crashes, [open an issue](https://github.com/Dingo-Shenanigans/ReSkate/issues/new/choose) with your ReSkate +version, when it happened and what you were doing, and attach `logs\ReSkate.log`. With **Send crash reports** +on (it is by default), the crash details have already been sent; see +[Crash reports and privacy](#crash-reports-and-privacy) for what is sent and how to turn it off. + ## Mods - Install mods from the launcher's **MODS** page: browse Thunderstore, or drag a `.zip` or folder onto the @@ -172,6 +199,27 @@ accepts `park random` from its console or an admin. ReSkate serves registered image bytes through a process-local HTTP endpoint bound only to `127.0.0.1` on an automatically selected port; no internet hosting is needed. The endpoint exposes no filesystem routes and retains at most 64 MiB of artwork. Existing content-cache covers keep their priority. +- A mod that adds Quick Drop objects can name them and group them into families with a + `reskate-objects.json` in its folder. A family is one tile in the Object Browser, and X / Square on it + picks between its objects, the way the game's own sizes and colours work: + + ```json + { + "schema": 1, + "categories": [{"id": "category_shoebox", "title": "Shoebox Pads"}], + "families": [{"id": "qdparent_shoebox_dc", "title": "DC Shoebox", "category": "category_shoebox"}], + "objects": [ + {"key": "own_bkrails_shoebox_dc_12m", "title": "DC Shoebox 12m", "family": "qdparent_shoebox_dc"}, + {"key": "own_bkrails_shoebox_dc_6m_red", "title": "DC Shoebox 6m, Red", "family": "qdparent_shoebox_dc"} + ] + } + ``` + + `key` is the object's owned asset key. A family can also go in one of the game's categories + (`category_ramp`, `category_rail`, ...), and an object in one of the game's families. Titles, a + `description` and `priority` (lower first) are optional. The file only adds: ids that are already taken + and the game's own items are left out and logged, so a mod cannot rename or regroup the game's objects. + The file is read when the game starts. Only install mods you trust. Mods change game data. Custom Lua scripts (`scripts/Custom`) are switched off in this version: nothing in that folder is read or run. diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index ebdf0988..11b935ab 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -1548,6 +1548,12 @@ void tick(std::uintptr_t client, std::uintptr_t update) { context_ready(); { DINGO_PROFILE_ZONE("tick/multiplayer"); + // The player at the controls, whatever the controls drive (the skater, or a plugin's drone). + static std::uint64_t controls_worked{}; + if (const auto at = dingosdk::overlay::controls_touched_at(); at != controls_worked) { + controls_worked = at; + dingosdk::multiplayer::note_player_input(); + } dingosdk::multiplayer::tick(r.base,client,multiplayer_ready,r.multiplayer_map,load_multiplayer_map); if (auto notice = dingosdk::multiplayer::take_leave_notice(); !notice.empty()) dingosdk::overlay::notify(dingosdk::overlay::NoticeLevel::warning, "Map not installed", std::move(notice)); diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index bfa51f62..d0a0e760 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -203,6 +203,19 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { if (r.custom_levels.present) dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::level, "Custom-level destinations after merging: {}.", r.custom_levels.levels.size()); + // Names and Quick Drop families mods give their own objects, before anything lists objects. + { + std::vector> object_files; + for (const auto& mod : mod_catalog.mods) + object_files.emplace_back(mod.name, mod.directory / dingosdk::content_cache::mod_objects_file); + const auto declared = dingosdk::content_cache::install_mod_objects(object_files); + for (const auto& note : declared.notes) + dingosdk::logging::log(dingosdk::logging::Level::warning, dingosdk::logging::Channel::objects, + "Mod objects: {}.", note); + if (declared.mods) + dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::objects, + "Mod objects: {} named by {} mod(s).", declared.objects, declared.mods); + } auto interposer = GetModuleHandleW(L"sl.interposer.dll"); if (!interposer) interposer = LoadLibraryExW(interposer_path.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32); @@ -236,6 +249,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { // A map a session needs: hosts say which Thunderstore package theirs is from, and a // guest without it is offered it on a card (Extension/Assets/map_download.h). dingosdk::multiplayer::set_map_package_lookup(dingosdk::map_download::package_of); + // And a dedicated server's required plugins, offered on the same card. + dingosdk::multiplayer::set_plugin_check(dingosdk::map_download::missing_plugins, dingosdk::map_download::has_level); dingosdk::overlay::set_map_download_feed([]() { const auto view = dingosdk::map_download::view(); using Stage = dingosdk::map_download::Stage; @@ -260,6 +275,9 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { card.total = view.total; card.yes_bind = view.yes_bind; card.no_bind = view.no_bind; + for (const auto& item : view.items) + card.items.push_back({item.name, item.author, item.version, item.have, item.plugin, item.installed, item.bytes}); + card.current = view.current; return card; }); dingosdk::overlay::set_map_download_answer(dingosdk::map_download::answer); diff --git a/STRUCTURE.md b/STRUCTURE.md index 50089afa..877b4fe1 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -56,7 +56,7 @@ ReSkate/ ├── assets/ files embedded into the binaries ├── config/defaults/ default profile/settings JSON embedded into ReSkate.dll ├── contrib/ server hosting files, design notes, release script -├── README.md, MACOS-SETUP.md, MUSIC-SETUP.md, LICENSE +├── README.md, LICENSE └── .clangd, .gitattributes, .gitignore ``` diff --git a/Server/README-roles.txt b/Server/README-roles.txt index 8223ce68..00bf3acd 100644 --- a/Server/README-roles.txt +++ b/Server/README-roles.txt @@ -185,7 +185,6 @@ What players may do object-scaling object-scaling on|off: whether players may resize objects. clear-objects Delete every placed object. effects effects on|off: whether players see each other's effects. - drones drones on|off: whether players see each other's FPV drones. noclip noclip on|off. (The menu calls it noclip-allow.) nobail nobail on|off. (The menu calls it nobail-allow.) boosts boosts on|off. (The menu calls it boosts-allow.) diff --git a/Server/README.txt b/Server/README.txt index 4c6524d1..3d014a19 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -192,6 +192,17 @@ park_mods Parks built in the game's Park Editor and shared as mods, park_limits How much may be spawned at once (default {"parks": 3, "objects": 512}): parks 1-16, their objects in all 1-1024. +"plugins" - What players must have to join. +required Thunderstore plugins every player must have (default []), + such as "neekgate-FPV_Drone", or "neekgate-FPV_Drone-1.0.0" + for at least that version; at most 16. A player without + them is asked once, on the card that offers a missing map, + and their game downloads, installs and applies them and + joins. Only plugins: the server checks each on Thunderstore + as it starts and leaves out one not listed as a plugin, and + players' games fetch nothing that is not a plugin, whatever + a server asks for. + "players" - What players may do. allow_noclip, allow_no_bail, allow_boosts Let players use noclip (and tp) / No Bail / the forward and up @@ -204,7 +215,9 @@ allow_parties Let players form parties (default true): invite each other party_size Most players in one party, 2-8 (default 8). afk_kick_minutes Remove a player who has been away this many minutes, 1-1440 (default 0: never). Away is not moving, speaking, typing - in chat or changing their objects. They are warned in chat + in chat, changing their objects or using the game's + controls (a player flying a plugin's drone is not away, + though their skater stands still). They are warned in chat a minute before and can join again at once. Admins are never removed for it. Console: afk-kick |off. kick_minutes How long a player kicked by an admin is kept out: 1-1440 @@ -250,11 +263,6 @@ sync_effects Let players see each other's skater effects (default true): relays none and players' games show each other without them, which saves a little traffic and drawing on a busy server. Console: effects on|off. -sync_drones Let players see each other's FPV drones (default true): the - marker where a player flying ReSkate's drone mode has - their drone. false relays none, and players' games - neither send their drone nor show anyone else's. - Console: drones on|off. announce_throwdowns Tell everyone in chat when a throwdown drop is placed (default true). throwdown_locks Tools locked for the players of a throwdown while it runs, @@ -605,7 +613,6 @@ or Travel, and voice, distances, placement and kicks from the Multiplayer menu. objects |off How many objects each player may have placed. object-scaling on|off Whether players may resize the objects they place. effects on|off Whether players see each other's skater effects. - drones on|off Whether players see each other's FPV drones. noclip on|off nobail on|off boosts on|off What players may use (admins always can). tuning on|off Everyone on the game's own physics tuning. diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 4df8a2d0..a29ba72d 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -226,6 +226,23 @@ int run() { "A config in sections did not read back as it was written"); std::filesystem::remove_all(folder / "data"); } + // Required plugins: none unless set, kept as written, and only Thunderstore package names. + { + ServerConfig plugins; + check(plugins.required_plugins.empty() && config_error(plugins).find("plugins.required") == std::string::npos, + "Required plugins are not off by default"); + plugins.file = folder / "plugins.json"; + plugins.required_plugins = {"neekgate-FPV_Drone", "Team-Filmer-1.2.0"}; + save_config(plugins); + check(load_config(plugins.file).required_plugins == plugins.required_plugins && + text(plugins.file).find("\"required\"") != std::string::npos, + "Required plugins were not kept under plugins.required"); + for (const auto &bad : std::vector>{{"FPV_Drone"}, {""}, {"neekgate-FPV_Drone", "NEEKGATE-fpv_drone-1.0.0"}, + {"neekgate-FPV Drone"}, std::vector(17, "Team-X")}) { + plugins.required_plugins = bad; + check(config_error(plugins).find("plugins.required") != std::string::npos, "A required plugin that is not a package was accepted"); + } + } // The away timer: off unless set, and no more than a day. { ServerConfig away; diff --git a/Server/Test/server_park_tests.cpp b/Server/Test/server_park_tests.cpp index f1be9c11..b1f9c6f1 100644 --- a/Server/Test/server_park_tests.cpp +++ b/Server/Test/server_park_tests.cpp @@ -606,6 +606,39 @@ int run(const std::filesystem::path &fixtures) { command("rotation off"); command("rotation vote off"); } + + // ---- Away -------------------------------------------------------------------------------- + // A player whose skater stands still while their game says they are at it (flying a plugin's + // drone) stays; one whose game says nothing is removed when the away limit runs out. + { + command("afk-kick 2"); + auto &pilot = add_game(76561198000000071ULL, "Pilot"); + auto &asleep = add_game(76561198000000072ULL, "Asleep"); + pilot.map = asleep.map = a.map; // the map and world the server is on now + pilot.world = asleep.world = a.world; + pilot.hello(now); + asleep.hello(now); + run_for(2000000); + check(pilot.joined && asleep.joined, "The away test's players did not join"); + // Their games load the map, as a game does once let in, and are in: the time away starts. + for (auto *game : {&pilot, &asleep}) { + auto loading = game->header(PacketKind::world_ready, now); + loading.world_ready = false; + game->send(loading); + } + run_for(2000000); + for (int i = 0; i < 9; ++i) { + pilot.send(pilot.header(PacketKind::present, now)); + // A flood of them counts no more than one. + pilot.send(pilot.header(PacketKind::present, now)); + run_for(present_interval_us); + } + check(wire().closed.contains(asleep.id) && logged("[afk] Asleep was removed after 2 min away."), + "A player who did nothing was not removed for being away"); + check(!wire().closed.contains(pilot.id), "A player whose game said they were at it was removed for being away: " + + (wire().closed.contains(pilot.id) ? wire().closed[pilot.id] : std::string{})); + command("afk-kick off"); + } check(!wire().closed.contains(admin_id), "The player was disconnected: " + (wire().closed.contains(admin_id) ? wire().closed[admin_id] : std::string{})); return failures; } diff --git a/Server/Test/server_plugin_tests.cpp b/Server/Test/server_plugin_tests.cpp index 4a682735..419f7b22 100644 --- a/Server/Test/server_plugin_tests.cpp +++ b/Server/Test/server_plugin_tests.cpp @@ -1,5 +1,6 @@ // The dedicated server's plugins (server_plugins.cpp) with the server's host, over a transport -// that is not Steam's and simulated players: what a server plugin sees of the players' skaters. +// that is not Steam's and simulated players: what a server plugin sees of the players' skaters +// and roles. // // dingosdk_server_plugin_tests #include "Server/server_host.h" @@ -7,6 +8,7 @@ #include "Extension/Multiplayer/Net/pose_codec.h" #include "Extension/Multiplayer/Net/wire_codec.h" #include "Extension/Multiplayer/Steam/steam_transport.h" +#include "Extension/Throwdowns/throwdown_wire.h" #include "Engine/Game/Build/supported_build.h" #include "Engine/Game/World/world_names.h" @@ -137,9 +139,46 @@ struct Player { p.world_ready = true; wire().inbound.push_back({id, encode_wire(p), now}); } + void chat(std::uint64_t now, const std::string &text) { + auto p = header(PacketKind::chat, now); + p.text = text; + wire().inbound.push_back({id, encode_wire(p), now}); + } + void throwdown(std::uint64_t now, const ThrowdownMessage &message) { + auto p = header(PacketKind::throwdown, now); + p.throwdown = encode_throwdown(message); + wire().inbound.push_back({id, encode_wire(p), now}); + } + // A moment of voice chat, heard everywhere (no hearing distance). + void speak(std::uint64_t now) { + auto p = header(PacketKind::voice, now); + p.voice.distance = 0.f; + p.voice.bytes = {1, 2, 3, 4}; + wire().inbound.push_back({id, encode_wire(p), now}); + } + // What the server sent this player since the last look: the latest roster, how many skaters' + // poses (entries of its pose messages) and how much voice. Acks for their own poses are taken. + struct Seen { + std::optional roster; + std::size_t poses{}, voices{}; + }; + Seen seen; + void read_sent() { + for (const auto &bytes : std::exchange(wire().sent[id], {})) { + if (const auto ack = pose_batch::Ack::read(bytes)) upload.ack(*ack); + else if (const auto batch = pose_batch::read(bytes)) seen.poses += batch->entries.size(); + else if (auto p = decode_wire(bytes); p && p->kind == PacketKind::roster) seen.roster = std::move(p); + else if (p && p->kind == PacketKind::voice) ++seen.voices; + } + } + Seen look() { + read_sent(); + return std::exchange(seen, {}); + } + // The latest roster the server sent this player (and forgets what else it sent). + std::optional roster() { return look().roster; } void pose(std::uint64_t now) { - for (const auto &bytes : std::exchange(wire().sent[id], {})) - if (const auto read = pose_batch::Ack::read(bytes)) upload.ack(*read); + read_sent(); auto p = header(PacketKind::pose, now); p.pose.root.position = at; p.pose.root.rotation = {0.f, std::sin(heading * 0.5f), 0.f, std::cos(heading * 0.5f)}; @@ -177,6 +216,7 @@ int run(const std::filesystem::path &fixtures) { config.auto_update = false; config.activity_log = false; config.roles = default_roles(); + config.roles.players[76561198000000001ULL] = "moderator"; // the second player has the default role config.park_mods = {{"street", "popular skate 2 street park", "San Vansterdam", "bam"}, {"plaza", "mixed park", "Isle of Grom", "grom"}}; check(config_error(config).empty(), "The test's config was refused: " + config_error(config)); @@ -211,6 +251,10 @@ int run(const std::filesystem::path &fixtures) { local ok, why = pcall(reskate.server.parks) reskate.console.print(ok and "blind listed parks" or "blind parks refused: " .. tostring(why)) end) + reskate.console.command("blind content", "mod content without mods.read", function() + local ok, why = pcall(reskate.mods.content, "parks") + reskate.console.print(ok and "blind listed content" or "blind content refused: " .. tostring(why)) + end) )"); // One that says what the level is, and when it changes. @@ -251,6 +295,110 @@ int run(const std::filesystem::path &fixtures) { end) )"); + // One that says each player's role, and one without server that may not know them. + write(root / "Plugins" / "ranks" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "session.read", "server"]})"); + write(root / "Plugins" / "ranks" / "plugin" / "main.lua", R"( + reskate.console.command("ranks", "players' roles, for the test", function() + for _, p in ipairs(reskate.session.players()) do + local role = p.role + if role then + reskate.console.print(string.format("role %s %s rank %d staff %s", p.id, role.name, role.rank, tostring(role.staff))) + reskate.console.print(pcall(function() role.rank = 1000 end) and "role changed" or "role read-only") + elseif not p.server then + reskate.console.print("role " .. p.id .. " none") + end + end + end) + )"); + write(root / "Plugins" / "rankless" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "session.read"]})"); + write(root / "Plugins" / "rankless" / "plugin" / "main.lua", R"( + reskate.console.command("rankless", "roles without server", function() + local p = reskate.session.players()[1] + local ok, why = pcall(function() return p.role end) + reskate.console.print(ok and "rankless saw a role" or "rankless refused: " .. tostring(why)) + end) + )"); + + // One that reads the parks folder of the mods the server has, as a game's plugin reads its + // enabled mods'. + write(root / "Plugins" / "reader" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "mods.read"], "content": ["parks"]})"); + write(root / "Plugins" / "reader" / "plugin" / "main.lua", R"( + reskate.console.command("reader", "mod content, for the test", function() + for _, f in ipairs(assert(reskate.mods.content("parks"))) do + reskate.console.print(string.format("content %s (%s) %s %d", f.mod, f.title, f.path, f.size)) + end + local text = assert(reskate.mods.read_content("mixed park", "parks", "grom.park.json")) + reskate.console.print("read " .. #text .. " " .. tostring(text:find('"reskate-park"', 1, true) ~= nil)) + local missing, why = reskate.mods.read_content("mixed park", "parks", "nothing.json") + reskate.console.print("missing " .. tostring(missing) .. " " .. why) + end) + )"); + + // One that asks players and shows them cards, and says what came back. + write(root / "Plugins" / "asker" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "server"]})"); + write(root / "Plugins" / "asker" / "plugin" / "main.lua", R"( + reskate.on("poll_answer", function(e) reskate.console.print(string.format("answer %s %d %s", e.player, e.answer, e.text)) end) + reskate.on("poll_closed", function(e) reskate.console.print(string.format("closed %s %s", e.player, e.why)) end) + reskate.console.command("asker", "cards and polls, for the test", function(args) + local who = args[2] == "all" and "all" or args[2] + if args[1] == "poll" then + local id, why = reskate.server.poll(who, "Next track?", {"Docks", "Hill"}, {seconds = tonumber(args[3]) or 30}) + reskate.console.print("poll " .. tostring(id or why)) + elseif args[1] == "card" then + local ok, why = reskate.server.card(who, "Race in 30 s", 5) + reskate.console.print("card " .. tostring(ok or why)) + end + end) + )"); + + // One that hears chat and the /commands the server does not know, reads the map, and changes + // the map and the time of day for everyone. + write(root / "Plugins" / "talker" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", + "permissions": ["console", "chat.read", "session.read", "travel", "world.environment"]})"); + write(root / "Plugins" / "talker" / "plugin" / "main.lua", R"lua( + reskate.on("chat", function(line) + reskate.console.print(string.format("heard %s %s: %s%s", line.sender, line.name, line.text, line.server and " (server)" or "")) + end) + reskate.console.command("talker", "chat, the map and the time, for the test", function(args) + if args[1] == "world" then + local w = reskate.session.world() + reskate.console.print("world " .. w.world .. " map " .. w.map) + elseif args[1] == "levels" then + local n, named = 0, false + for _, level in reskate.travel.levels() do + n += 1 + if level.name == "Isle of Grom" and level.can_load then named = true end + end + reskate.console.print("levels " .. n .. " " .. tostring(named)) + elseif args[1] == "load" then + local ok, why = reskate.travel.load(table.concat(args, " ", 2)) + reskate.console.print("load " .. tostring(ok or why)) + elseif args[1] == "tod" then + local ok, why = reskate.world.set_time(args[2]) + reskate.console.print("tod " .. tostring(ok or why)) + end + end) + )lua"); + + // One that hides a player from another, wholly or only their voice and sound, and shows them again. + write(root / "Plugins" / "hider" / "reskate-plugin.json", + R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "server"]})"); + write(root / "Plugins" / "hider" / "plugin" / "main.lua", R"( + reskate.console.command("hider", "hiding, for the test", function(args) + local verb, target, viewer = args[1], args[2], args[3] + local ok, why + if verb == "hide" then ok, why = reskate.server.hide(target, viewer) + elseif verb == "mute" then ok, why = reskate.server.hide(target, viewer, {skater = false}) + elseif verb == "unhide" then ok, why = reskate.server.unhide(target, viewer) end + reskate.console.print(verb .. " " .. tostring(ok or why)) + end) + )"); + std::vector log; SteamTransport transport; Host host(config, transport, [&](const std::string &line) { log.push_back(line); }); @@ -278,7 +426,7 @@ int run(const std::filesystem::path &fixtures) { players[1].at = {-4.f, 7.5f, 12.f}; std::uint64_t now = 1000000000ULL, next_ready = now; - bool posing{}; + bool posing{}, speaking{}; const auto run_for = [&](std::uint64_t microseconds) { for (const auto end = now + microseconds; now < end; now += 50000) { if (now >= next_ready) { @@ -287,6 +435,7 @@ int run(const std::filesystem::path &fixtures) { } if (posing) for (auto &p : players) p.pose(now); + if (speaking && (now / 50000) % 4 == 0) players[0].speak(now); host.tick(now); } }; @@ -320,12 +469,24 @@ int run(const std::filesystem::path &fixtures) { run_for(1100000); check(has(log, "skater 76561198000000001 at 10.0 2.0 -30.0 facing 1.00, stale"), "A skater's age did not grow without poses"); - // Park mods: listed with whether they fit this map; spawned and removed through park-mod's own - // checks, logged by the plugin's name. + // Roles: the one given (moderator), the server's default (player) for the other, read-only; + // and nothing for a plugin without server. const auto plugin_command = [&](std::string_view line) { log.clear(); check(host.command(line) == "Ran by a plugin.", std::string(line) + " did not reach the plugin"); }; + plugin_command("ranks"); + check(has(log, "role 76561198000000001 moderator rank 50 staff true"), "The moderator's role is not what the server gave them"); + check(has(log, "role 76561198000000002 player rank 0 staff false"), "A player given no role does not have the default role"); + check(has(log, "role read-only") && !has(log, "role changed"), "A plugin changed a player's role table"); + host.command("role 76561198000000002 admin"); + plugin_command("ranks"); + check(has(log, "role 76561198000000002 admin rank 100 staff true"), "A role given while they are on was not seen"); + plugin_command("rankless"); + check(has(log, "rankless refused: ") && has(log, "server"), "A plugin without server saw a role"); + + // Park mods: listed with whether they fit this map; spawned and removed through park-mod's own + // checks, logged by the plugin's name. plugin_command("parker list"); check(has(log, "park street on San Vansterdam, here true, spawned false, 0 objects, 0 min left"), "street is not listed as it is"); check(has(log, "park plaza on Isle of Grom, here false, spawned false"), "plaza is not listed as on another map"); @@ -350,6 +511,185 @@ int run(const std::filesystem::path &fixtures) { plugin_command("blind parks"); check(has(log, "blind parks refused: ") && has(log, "server.parks"), "A plugin without server.parks listed the parks"); + // The mods the server has, read through mods.content as a game reads its enabled mods. + const auto size_of = [&](const char *mod, const char *file) { + return std::to_string(std::filesystem::file_size(root / "Mods" / mod / "parks" / file)); + }; + plugin_command("reader"); + check(has(log, "content mixed park (mixed park) grom.park.json " + size_of("mixed park", "grom.park.json")) && + has(log, "content popular skate 2 street park (popular skate 2 street park) bam.park.json " + + size_of("popular skate 2 street park", "bam.park.json")), + "The server's mods' parks folders were not listed"); + check(has(log, "read " + size_of("mixed park", "grom.park.json") + " true"), "A park mod's file was not read"); + check(has(log, "missing nil not found"), "A missing file was not \"not found\""); + plugin_command("blind content"); + check(has(log, "blind content refused: ") && has(log, "mods.read"), "A plugin without mods.read listed the mods' content"); + + // Cards and polls: each player's own roster carries them, over the server's; a player's answer + // (as the game sends it, "/2") comes back to the plugin, and so does a poll that ran out. + const auto first_id = std::to_string(players[0].id), second_id = std::to_string(players[1].id); + for (auto &p : players) p.roster(); + plugin_command("asker card " + first_id); + check(has(log, "card true"), "The plugin could not show a card"); + plugin_command("asker poll all 10"); + check(has(log, "poll ") && !has(log, "poll nil"), "The plugin could not ask everyone"); + run_for(200000); + auto first_roster = players[0].roster(), second_roster = players[1].roster(); + check(first_roster && first_roster->announcement.text == "Race in 30 s" && first_roster->announcement.seconds == 5, + "The first player's roster has no card"); + check(second_roster && second_roster->announcement.text.empty(), "The second player was shown the first player's card"); + for (auto *r : {&first_roster, &second_roster}) + check(*r && (*r)->vote.label == "Next track?" && (*r)->vote.answers == std::vector{"Docks", "Hill"} && + (*r)->vote.kind == server_vote_poll && (*r)->vote.outcome == vote_running && (*r)->vote.seconds == 10, + "A player's roster has no poll"); + const auto poll_id = first_roster ? first_roster->vote.id : 0; + check(poll_id && second_roster && second_roster->vote.id == poll_id, "The poll is not one for everyone"); + log.clear(); + players[0].chat(now, "/2"); + run_for(200000); + check(has(log, "answer " + first_id + " 2 Hill"), "The plugin did not hear the answer"); + first_roster = players[0].roster(); + check(first_roster && first_roster->vote.id != poll_id, "The answered poll stayed on the player's screen"); + players[0].chat(now, "/1"); + run_for(200000); + check(!has(log, "answer " + first_id + " 1"), "A poll was answered twice"); + log.clear(); + run_for(10000000); + check(has(log, "closed " + second_id + " timeout") && !has(log, "closed " + first_id), "The unanswered poll did not run out"); + second_roster = players[1].roster(); + check(second_roster && second_roster->vote.id != poll_id && second_roster->announcement.text.empty(), "The poll stayed after it ran out"); + plugin_command("asker poll " + second_id); + plugin_command("asker poll " + second_id); + run_for(200000); + check(has(log, "closed " + second_id + " replaced"), "A new poll did not replace the player's earlier one"); + plugin_command("asker poll 76561198000000099"); + check(has(log, "poll that player is not on the server"), "A poll for nobody here was asked"); + + // Chat: a player's line as it is passed on, the server's own, and a /command the server does + // not know (no "Unknown command" for it: the plugin answers); the server's own commands stay its. + log.clear(); + players[1].chat(now, "hello everyone"); + run_for(200000); + check(has(log, "heard 76561198000000002 Player 2: hello everyone"), "The plugin did not hear a player's chat line"); + log.clear(); + host.command("say the server speaks"); + run_for(200000); + check(has(log, "heard 0 Server: the server speaks (server)"), "The plugin did not hear the server's own line"); + log.clear(); + wire().sent[players[1].id].clear(); + players[1].chat(now, "/race join"); + run_for(200000); + check(has(log, "heard 76561198000000002 Player 2: /race join"), "A /command the server does not know did not reach the plugin"); + bool unknown{}; + for (const auto &bytes : std::exchange(wire().sent[players[1].id], {})) + if (auto p = decode_wire(bytes); p && p->kind == PacketKind::chat && p->text.find("Unknown command") != std::string::npos) unknown = true; + check(!unknown, "The player was told the plugin's /command is unknown"); + log.clear(); + players[1].chat(now, "/help"); + run_for(200000); + check(!has(log, "heard 76561198000000002 Player 2: /help"), "One of the server's own commands went to the plugin"); + + // Hiding: the first player hidden from the second leaves the second's roster and poses, and + // only theirs; the first still sees the second. Each window is longer than the roster's 2 s. + const auto in_roster = [](const std::optional &roster, std::uint64_t id) { + return roster && std::any_of(roster->members.begin(), roster->members.end(), [&](const Member &m) { return m.id == id; }); + }; + const auto settle = [&] { + run_for(300000); // what was on its way before + for (auto &p : players) p.look(); + run_for(2500000); + }; + posing = speaking = true; + settle(); + { + auto viewer = players[1].look(); + check(viewer.poses > 0 && viewer.voices > 0 && in_roster(viewer.roster, players[0].id), + "The test needs the second player to see and hear the first"); + } + plugin_command("hider hide " + first_id + " " + second_id); + check(has(log, "hide true"), "The plugin could not hide a player"); + settle(); + { + auto viewer = players[1].look(), target = players[0].look(); + check(viewer.poses == 0 && viewer.voices == 0, "The viewer was still sent the hidden player's poses or voice"); + check(viewer.roster && !in_roster(viewer.roster, players[0].id) && in_roster(viewer.roster, players[1].id), + "The hidden player was still in the viewer's roster"); + check(target.poses > 0 && in_roster(target.roster, players[1].id), "The hidden player stopped seeing the viewer"); + } + // Shown again: back in the roster, and their poses follow. + plugin_command("hider unhide " + first_id + " " + second_id); + check(has(log, "unhide true"), "The plugin could not show a player again"); + settle(); + { + auto viewer = players[1].look(); + check(viewer.poses > 0 && viewer.voices > 0 && in_roster(viewer.roster, players[0].id), "A player shown again did not come back"); + } + // Only their voice and sound: they stay in the roster and their poses go on. + plugin_command("hider mute " + first_id + " " + second_id); + check(has(log, "mute true"), "The plugin could not mute a player"); + settle(); + { + auto viewer = players[1].look(); + check(viewer.poses > 0 && in_roster(viewer.roster, players[0].id), "A muted player was hidden"); + check(viewer.voices == 0, "A muted player's voice was still sent"); + } + // A plugin that stops shows everyone it hid. + plugin_command("hider hide " + first_id + " " + second_id); + check(host.command("plugin off hider").find("hider") != std::string::npos, "The plugin could not be switched off"); + settle(); + { + auto viewer = players[1].look(); + check(viewer.poses > 0 && viewer.voices > 0 && in_roster(viewer.roster, players[0].id), "A plugin that stopped left a player hidden"); + } + host.command("plugin on hider"); + run_for(2500000); // it loads again on the next scan + // Not while they share a party, nor a throwdown. + players[0].chat(now, "/party invite Player 2"); + run_for(200000); + players[1].chat(now, "/party accept Player 1"); + run_for(200000); + plugin_command("hider hide " + first_id + " " + second_id); + check(has(log, "hide they share a party"), "A player was hidden from someone in their party"); + players[1].chat(now, "/party leave"); + run_for(200000); + ThrowdownMessage offer; + offer.kind = ThrowdownMessage::Kind::offer; + offer.leader = players[0].id; + offer.id = 1; + offer.series = "skate"; + offer.placement = {1, 2, 3, 4}; // opaque to the server + players[0].throwdown(now, offer); + auto join = offer; + join.kind = ThrowdownMessage::Kind::join; + players[1].throwdown(now, join); + run_for(200000); + plugin_command("hider hide " + first_id + " " + second_id); + check(has(log, "hide they are in a throwdown together"), "A player was hidden from someone in their throwdown"); + auto close = offer; + close.kind = ThrowdownMessage::Kind::close; + players[0].throwdown(now, close); + run_for(200000); + plugin_command("hider hide " + first_id + " " + second_id); + check(has(log, "hide true"), "A player could not be hidden once the party and the throwdown were over"); + // Hidden, they cannot join the viewer's party either. + log.clear(); + players[0].chat(now, "/party invite Player 2"); + run_for(200000); + check(!has(log, "[party] Player 1 invited Player 2"), "A hidden player invited the viewer to their party"); + plugin_command("hider unhide " + first_id); + check(has(log, "unhide true"), "The plugin could not show a player to everyone"); + posing = speaking = false; + + // The map and the world as the session's, and the maps the server knows. + plugin_command("talker world"); + check(has(log, "world 1 map " + host.map_name()), "session.world() is not the server's map and world"); + plugin_command("talker levels"); + check(has(log, "levels " + std::to_string(levels().size()) + " true"), "travel.levels() is not the server's maps"); + // The time of day for everyone needs the server's world layers and layer-sync. + plugin_command("talker tod night"); + check(has(log, "tod the server has no world layers") || has(log, "tod the server lets players keep their own world layers"), + "A time of day was set without the server's world layers"); + // The level, as players' games name it, and its change heard when the server changes it. const auto *first = find_level(config.map); const auto other = std::find_if(levels().begin(), levels().end(), [&](const auto &l) { return l.asset != first->asset; }); @@ -363,6 +703,26 @@ int run(const std::filesystem::path &fixtures) { check(has(log, "unloading " + first->asset) && has(log, "loaded " + other->asset), "The plugin did not hear the map change"); check(has(log, "level " + other->asset) && !has(log, "level " + first->asset), "level.current() did not follow the map change"); check(!has(log, "skaters 2"), "Skaters stayed on the old map's places after the map changed"); + // A plugin changes the map back, as the console's map does, with its checks. + plugin_command("talker load " + first->name); + { + std::string said; + for (const auto &line : log) said += "\n " + line; + check(has(log, "load true") && has(log, "[map] A plugin changed the map to "), "The plugin could not change the map:" + said); + } + run_for(1100000); + check(host.map_level() == first->asset, "The plugin's map change did not happen"); + check(host.plugin_map(first->name) == "the server is already on that map", "A change to the map in play was not refused"); + const auto nowhere = host.plugin_map("Nowhere At All"); + check(nowhere.starts_with("no single map is called") || nowhere == "this server does not have that map", + "A map the server does not have was loaded: " + nowhere); + plugin_command("talker load " + other->name); + check(has(log, "load one trip every 5 s"), "travel.load was not held to one trip every 5 s"); + // A player who leaves with a poll open: the plugin hears it closed. + log.clear(); + host.drop(players[1].id, "testing"); + run_for(200000); + check(has(log, "closed " + second_id + " left"), "A poll stayed open for a player who left"); if (failures) for (const auto &line : log) std::fprintf(stderr, "log: %s\n", line.c_str()); return failures ? 1 : 0; diff --git a/Server/main.cpp b/Server/main.cpp index 13e78ae6..2b7c10a4 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -528,6 +528,7 @@ int run(int argc, char **argv, bool skip_update) { auto next_ban_check = next_advertise; bool bans_unread{}; MapUpdater map_updater; // "maps.latest_versions" and "maps update" (server_map_update.h) + RequiredPluginCheck plugin_check; // "plugins.required" (server_map_update.h) if (!config.global_bans) write_log("Global bans are off (\"use_global_bans\": false): only this server's own bans apply."); write_log(host.command("words")); // reads this server's own chat words // Signed in to Steam, as last logged, and when it is looked at again. Players already on stay @@ -605,6 +606,10 @@ int run(int argc, char **argv, bool skip_update) { for (const auto &line : maps.lines) write_log(line); if (maps.changed) write_log(host.reload_maps()); } + if (auto plugins = plugin_check.tick(config.required_plugins); plugins.finished) { + for (const auto &line : plugins.lines) write_log(line); + host.refuse_plugins(std::move(plugins.refused)); + } // (The chat word lists come in the same answer, and hold on every server: it is read // for them whether or not the bans are used.) if (!ban_check.valid() && now_time >= next_ban_check) diff --git a/Server/server_activity.cpp b/Server/server_activity.cpp index 1297da82..7ca64a0a 100644 --- a/Server/server_activity.cpp +++ b/Server/server_activity.cpp @@ -210,6 +210,17 @@ std::set ActivityLog::playing() const { return players; } +bool ActivityLog::together(std::uint64_t a, std::uint64_t b) const { + for (const auto &[key, t] : throwdowns_) { + const auto in = [&](std::uint64_t player) { + return !t.quit.contains(player) && (key.first == player || std::ranges::find(t.queue, player) != t.queue.end() || + std::ranges::find(t.players, player) != t.players.end()); + }; + if (in(a) && in(b)) return true; + } + return false; +} + void ActivityLog::left(std::uint64_t player) { name(player); // keep the name for results after they left for (auto it = throwdowns_.begin(); it != throwdowns_.end();) { diff --git a/Server/server_activity.h b/Server/server_activity.h index 91670fae..69785407 100644 --- a/Server/server_activity.h +++ b/Server/server_activity.h @@ -37,6 +37,9 @@ class ActivityLog { void tick(std::uint64_t now); // The players of the throwdowns running now, but those who quit theirs. std::set playing() const; + // Whether two players are in one throwdown now: its leader, its queue or its players, but + // those who quit. + bool together(std::uint64_t a, std::uint64_t b) const; private: struct Throwdown { diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 15280049..50f4568b 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -827,25 +827,8 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { (config_.world_layer_sync ? "." : ". Turn on layer-sync to apply them to everyone.")); } if (name == "tod") { - // Every map's seven time layers ("_tod__"): one on and the rest off, or - // all back to the level's own. Set for every map, so it holds across map changes. - static constexpr std::array times{"default", "morning", "noon", "afternoon", - "evening", "night", "weatherday", "weathernight"}; - const auto wanted = lower(argument); - const auto found = std::find(times.begin(), times.end(), wanted); - if (found == times.end()) return "tod default|morning|noon|afternoon|evening|night|weatherday|weathernight"; - const auto slot = static_cast('0' + (found - times.begin())); - unsigned count{}; - for (const auto &layer : world_layers()) { - const auto at = layer.key.find("_tod_"); - if (at == std::string::npos || at + 5 >= layer.key.size()) continue; - if (slot == '0') config_.layers.erase(layer.key); - else config_.layers[layer.key] = layer.key[at + 5] == slot ? "on" : "off"; - ++count; - } - if (!count) return "world-layers.json has no time-of-day layers."; - apply_layers(); - return changed("Time of day set to " + std::string(*found) + + if (auto why = set_time_of_day(argument); !why.empty()) return why; + return changed("Time of day set to " + lower(argument) + (config_.world_layer_sync ? " for everyone." : ". Turn on layer-sync to apply it to everyone.")); } if (name == "layer") { @@ -1020,6 +1003,27 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { if (auto answer = plugins_->command(line)) return *answer; return "Unknown command \"" + std::string(action) + "\". Type help."; } +std::string Host::set_time_of_day(std::string_view time) { + // Every map's seven time layers ("_tod__"): one on and the rest off, or + // all back to the level's own. Set for every map, so it holds across map changes. + static constexpr std::array times{"default", "morning", "noon", "afternoon", + "evening", "night", "weatherday", "weathernight"}; + const auto wanted = lower(time); + const auto found = std::find(times.begin(), times.end(), wanted); + if (found == times.end()) return "tod default|morning|noon|afternoon|evening|night|weatherday|weathernight"; + const auto slot = static_cast('0' + (found - times.begin())); + unsigned count{}; + for (const auto &layer : world_layers()) { + const auto at = layer.key.find("_tod_"); + if (at == std::string::npos || at + 5 >= layer.key.size()) continue; + if (slot == '0') config_.layers.erase(layer.key); + else config_.layers[layer.key] = layer.key[at + 5] == slot ? "on" : "off"; + ++count; + } + if (!count) return "world-layers.json has no time-of-day layers."; + apply_layers(); + return {}; +} std::string Host::lock_name(std::uint64_t id, const Guest *guest) const { if (guest) return guest_name(*guest); if (const auto seen = seen_names_.find(id); seen != seen_names_.end()) return seen->second; diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 8a432c69..ebea71fb 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -114,6 +114,11 @@ Layout layout(const ServerConfig &c) { park_limits["objects"] = c.park_limits.objects; maps.set("park_limits", std::move(park_limits)); + auto &plugins = root.section("plugins"); + auto required = Json::array(); + for (const auto &plugin : c.required_plugins) required.push_back(plugin); + plugins.set("required", std::move(required)); + auto &players = root.section("players"); players.set("allow_boosts", c.boosts); players.set("allow_no_bail", c.no_bail); @@ -346,6 +351,9 @@ ServerConfig load_config(const std::filesystem::path &file, std::vectoris_array()) + for (const auto &plugin : *required) + if (plugin.is_string()) c.required_plugins.push_back(plugin.string()); if (const auto *parks = find("maps", "parks"); parks && parks->is_object()) for (unsigned lot = 0; lot < park_lots.size(); ++lot) c.parks[lot] = parks->value(park_lots[lot].key, c.parks[lot]); c.world_layer_sync = get("maps", "world_layer_sync", c.world_layer_sync); @@ -806,7 +814,8 @@ bool custom_command_name_free(std::string_view name) noexcept { "object-placement", "object-scaling", "objects", "park", "park-mod", "parties", "party-size", "password", "placement", "players", "rate", "reserved", "rotation", "say", "score-allow", "score-check", "speed-check", "status", "teleport", "tod", "tpall", "tphere", "tps", "tuning", "tuning-enforce", "unban", "voice", "voice-allow", "voice-range", - "vote-cancel", "votes", "welcome", "world-layer-sync", "quit", "exit", "stop", "update"}) + "vote-cancel", "votes", "welcome", "world-layer-sync", "quit", "exit", "stop", "update", + "bone-reach", "lock", "locks", "unlock", "throwdown-locks", "words", "poll-run", "plugin"}) if (name == taken) return false; // A number is an answer to a poll ("/2"). return !std::all_of(name.begin(), name.end(), [](char c) { return c >= '0' && c <= '9'; }); @@ -872,6 +881,12 @@ std::string config_error(const ServerConfig &c) { if (c.kick_minutes > 1440) return "kick_minutes must be 0 (until the server restarts) to 1440."; if (c.votes.kick_minutes > 1440) return "votes.kick_minutes must be 0 (until the server restarts) to 1440."; if (c.word_warnings > 10) return "word_warnings must be 0 (words are not checked) to 10."; + if (c.required_plugins.size() > max_required_plugins) + return "plugins.required holds at most " + std::to_string(max_required_plugins) + " plugins."; + for (const auto &plugin : c.required_plugins) + if (plugin.empty() || !valid_map_package(plugin)) + return "plugins.required: \"" + plugin + "\" is not a Thunderstore package. Write it as Owner-Name, or Owner-Name-1.2.3 for at least that version."; + if (!valid_required_plugins(c.required_plugins)) return "plugins.required names a plugin twice."; if (!parse_colour(c.chat_text_color)) return "chat_text_color must be a colour like #D9C8FF."; if (!valid_server_name(c.name)) return std::string("name must be ") + server_name_rule + "."; for (const auto id : c.reserved) diff --git a/Server/server_config.h b/Server/server_config.h index 65e47662..039c70c2 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -113,6 +113,12 @@ struct ServerConfig { // version (server_map_update.h), looked up at start and every so many minutes. bool latest_versions = false; unsigned latest_check_minutes = 60; + // Thunderstore packages every player must have to join ("plugins.required"): "Owner-Name", + // or "Owner-Name-1.2.3" for at least that version. A player without them is offered them on + // the card that offers a missing map, and fetches only what Thunderstore lists as a plugin + // and what runs Luau code. The server checks each on Thunderstore as it starts and leaves + // out one that is not a plugin there (RequiredPluginCheck, server_map_update.h). + std::vector required_plugins; unsigned max_players = 16; // players; the server itself is not one // The most poses a second one player is sent (crowd_limits); 0: no limit. unsigned crowd_budget = crowd_pose_budget; diff --git a/Server/server_engine.cpp b/Server/server_engine.cpp index a95fc4c0..1ed9bc1a 100644 --- a/Server/server_engine.cpp +++ b/Server/server_engine.cpp @@ -406,6 +406,7 @@ void ServerEngine::worker_loop() { auto next_ban_check = std::chrono::steady_clock::now(); std::future ban_check; MapUpdater map_updater; // "maps.latest_versions" and "maps update" (server_map_update.h) + RequiredPluginCheck plugin_check; // "plugins.required" (server_map_update.h) bool bans_unread = true; std::optional name_allowed; const auto target_tick_interval = std::chrono::microseconds(1000000 / std::max(10U, config_.tps)); @@ -433,6 +434,10 @@ void ServerEngine::worker_loop() { for (const auto &line : maps.lines) log(line); if (maps.changed) log(host_->reload_maps()); } + if (auto plugins = plugin_check.tick(config_.required_plugins); plugins.finished) { + for (const auto &line : plugins.lines) log(line); + host_->refuse_plugins(std::move(plugins.refused)); + } if (!ban_check.valid() && now_time >= next_ban_check) { ban_check = std::async(std::launch::async, read_global_bans); } diff --git a/Server/server_host.cpp b/Server/server_host.cpp index b4cc006b..9170a2cd 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -207,6 +207,89 @@ void Host::send_plugin_message(std::uint64_t to, const std::vector if (auto *guest = find(to); guest && guest->handshaken) send_packet(*guest, p, true, false); } +// ---- Players the server's plugins hide from others ------------------------------------------------ +std::string Host::plugin_hide(std::string_view plugin, std::uint64_t target, const std::vector &viewers, bool skater, bool voice, + bool sound) { + const auto *hidden = find(target); + if (!hidden || !hidden->handshaken) return "that player is not on the server"; + std::vector from; + for (auto &[id, guest] : guests_) + if (guest->handshaken && id != target && (viewers.empty() || std::find(viewers.begin(), viewers.end(), id) != viewers.end())) + from.push_back(guest.get()); + if (from.empty()) return viewers.empty() ? "nobody else is on the server" : "none of those players is on the server"; + // Their games would take a party member or a throwdown's player away mid-way. + const auto party = parties_.party_of(target); + for (const auto *viewer : from) { + if (party && parties_.party_of(viewer->member.id) == party) return "they share a party"; + if (activity_.together(target, viewer->member.id)) return "they are in a throwdown together"; + } + std::vector names; + for (auto *viewer : from) { + auto &entry = hidden_[viewer->member.id][target]; + // Wholly from now: not introduced again until shown (introduce), and sent afresh then. + if (skater && !entry.skater) { + viewer->unmet.insert(target); + viewer->pose_sender.forget(target); + viewer->sound_sender.forget(target); + viewer->object_delivery.sent.erase(target); + if (viewer->object_delivery.source == target) viewer->object_delivery.chunks.clear(); + } + entry = {std::string(plugin), skater, skater || voice, skater || sound}; + names.push_back(guest_name(*viewer)); + } + log_("[hide] " + guest_name(*hidden) + (skater ? " hidden from " : std::string(voice && sound ? " muted for " : voice ? " voice muted for " : " sound muted for ")) + + (viewers.empty() ? std::string("everyone") : std::to_string(names.size()) + " player(s)") + " by plugin " + std::string(plugin)); + roster_dirty_ = true; + return {}; +} + +std::string Host::plugin_unhide(std::string_view plugin, std::uint64_t target, const std::vector &viewers) { + bool shown{}; + for (auto viewer = hidden_.begin(); viewer != hidden_.end();) { + if (viewers.empty() || std::find(viewers.begin(), viewers.end(), viewer->first) != viewers.end()) + if (const auto found = viewer->second.find(target); found != viewer->second.end() && found->second.plugin == plugin) { + viewer->second.erase(found); + shown = true; + } + viewer = viewer->second.empty() ? hidden_.erase(viewer) : std::next(viewer); + } + if (!shown) return "the plugin hides that player from none of them"; + const auto *guest = find(target); + log_("[hide] " + (guest ? guest_name(*guest) : std::to_string(target)) + " shown again by plugin " + std::string(plugin)); + roster_dirty_ = true; + return {}; +} + +void Host::plugin_hides_release(std::string_view plugin) { + bool shown{}; + for (auto viewer = hidden_.begin(); viewer != hidden_.end();) { + shown |= std::erase_if(viewer->second, [&](const auto &entry) { return entry.second.plugin == plugin; }) > 0; + viewer = viewer->second.empty() ? hidden_.erase(viewer) : std::next(viewer); + } + if (shown) roster_dirty_ = true; +} + +const Host::Hidden *Host::hidden_from(std::uint64_t viewer, std::uint64_t target) const { + if (hidden_.empty()) return nullptr; + const auto found = hidden_.find(viewer); + if (found == hidden_.end()) return nullptr; + const auto entry = found->second.find(target); + return entry == found->second.end() ? nullptr : &entry->second; +} + +bool Host::hidden_between(std::uint64_t a, std::uint64_t b) const { + const auto *one = hidden_from(a, b), *other = hidden_from(b, a); + return (one && one->skater) || (other && other->skater); +} + +void Host::hidden_left(std::uint64_t player) { + hidden_.erase(player); + for (auto viewer = hidden_.begin(); viewer != hidden_.end();) { + viewer->second.erase(player); + viewer = viewer->second.empty() ? hidden_.erase(viewer) : std::next(viewer); + } +} + std::optional> Host::player_position(std::uint64_t steam_id) const { auto it = guests_.find(steam_id); if (it != guests_.end() && it->second && it->second->latest_root) { @@ -239,6 +322,14 @@ void Host::fetching(Guest &guest) { else if (guest.travel_since) guest.travel_since = now_; } std::string Host::invite() const { return format_invite({id_, secret_}); } +std::vector Host::required_plugins() const { + std::vector out; + for (const auto &plugin : config_.required_plugins) + if (!refused_plugins_.contains(plugin)) out.push_back(plugin); + // (The config is checked as it is read; one edited in since is sent only when it is valid.) + return multiplayer::valid_required_plugins(out) ? out : std::vector{}; +} + std::string Host::map_name() const { return map_label(config_.map); } std::string Host::map_level() const { const auto destination = map_destination(config_.map); @@ -554,6 +645,10 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 auto &p = *guest; if (!p.handshaken || id == except || (gameplay && !p.world_ready)) continue; if ((only && !only->contains(id)) || (skip && skip->contains(id))) continue; + // A player a plugin hides from this one (plugin_hide): nothing of them, or not their voice or sound. + if (const auto *hidden = hidden_from(id, packet.source); + hidden && (hidden->skater ? gameplay : (packet.kind == PacketKind::voice && hidden->voice) || (packet.kind == PacketKind::audio && hidden->sound))) + continue; if (packet.kind == PacketKind::voice) { if (!voice_policy_.accepts(packet.voice)) continue; // Each listener fades voices by their own hearing distance; the server @@ -739,6 +834,8 @@ void Host::introduce() { continue; } ++it; + // Hidden from them by a plugin: introduced once shown again (plugin_unhide). + if (const auto *hidden = hidden_from(id, other->member.id); hidden && hidden->skater) continue; float distance = std::numeric_limits::max(); if (g.latest_root && other->latest_root) { distance = 0; @@ -823,11 +920,15 @@ void Host::send_roster() { if (m.party && std::none_of(p.members.begin(), p.members.end(), [&](const Member &o) { return o.party == m.party && o.party_leader; })) m.party_leader = true; // the first listed member of a party missing its leader - // An announcement for one player: everyone else's roster goes without it. And a player with - // locked tools is told those are off for them. Everyone else gets the same roster; players - // whose roster differs in the same way share one. + // An announcement for one player: everyone else's roster goes without it. A player with + // locked tools is told those are off for them, and one with a plugin's card or poll is shown + // it (by its id) over the server's; one a plugin hides players from (plugin_hide) is sent a + // roster without them, so their game takes those skaters away. Everyone else gets the same + // roster; players whose roster differs in the same way share one. struct Variant { bool noclip{}, no_bail{}, boosts{}, ollies{}, enforce_tuning{}, announcement{}; + std::uint32_t plugin_card{}, plugin_poll{}; + std::vector hidden; // in order auto operator<=>(const Variant &) const = default; }; const Variant common{config_.noclip, config_.no_bail, config_.boosts, true, config_.enforce_tuning, !announcement_for_}; @@ -837,6 +938,11 @@ void Host::send_roster() { if (!guest->handshaken) continue; auto variant = common; variant.announcement = !announcement_for_ || id == announcement_for_; + if (const auto card = plugin_cards_.find(id); card != plugin_cards_.end()) variant.plugin_card = card->second.card.id; + if (const auto poll = plugin_polls_.find(id); poll != plugin_polls_.end()) variant.plugin_poll = poll->second.id; + if (const auto from = hidden_.find(id); from != hidden_.end()) + for (const auto &[target, what] : from->second) + if (what.skater) variant.hidden.push_back(target); if (const auto locks = locks_of(id)) { // Without "movement" (send_permissions), the tools their role gives are said here instead. const bool movement = (locks & lock_movement) && has(id, multiplayer::role_movement); @@ -853,15 +959,22 @@ void Host::send_roster() { others.insert(id); } const auto shown = p.announcement; + const auto vote = p.vote; + const auto members = p.members; if (announcement_for_) p.announcement = {}; broadcast(p, true, false, 0, nullptr, others.empty() ? nullptr : &others); for (const auto &[variant, players] : variants) { + p.members = members; + std::erase_if(p.members, [&](const Member &m) { return std::binary_search(variant.hidden.begin(), variant.hidden.end(), m.id); }); p.guest_noclip = variant.noclip; p.guest_no_bail = variant.no_bail; p.guest_boosts = variant.boosts; p.guest_ollies = variant.ollies; p.enforce_tuning = variant.enforce_tuning; p.announcement = variant.announcement ? shown : multiplayer::ServerAnnouncement{}; + p.vote = vote; + // The same card and poll ids: the same card and poll, so any of these players' is theirs. + if (variant.plugin_card || variant.plugin_poll) plugin_screens_roster(*players.begin(), p.announcement, p.vote); broadcast(p, true, false, 0, &players); } roster_dirty_ = false; @@ -883,7 +996,46 @@ void Host::send_chat(std::string_view text, Guest *only) { message.text = clean_chat_text(text); if (message.text.empty()) return; if (only) send_packet(*only, message, true, false); - else broadcast(message, true, false); + else { + broadcast(message, true, false); + record_chat(0, "Server", message.text, true); + } +} +void Host::record_chat(std::uint64_t sender, std::string name, std::string text, bool server) { + chat_log_.push_back({++chat_sequence_, sender, std::move(name), std::move(text), server}); + while (chat_log_.size() > 64) chat_log_.pop_front(); +} +std::vector Host::chat_since(std::uint64_t sequence) const { + std::vector out; + for (const auto &line : chat_log_) + if (line.sequence > sequence) out.push_back(line); + return out; +} +bool Host::chat_to_plugins(Guest &guest, std::string_view line) { + if (!plugins_ || !plugins_->hears_chat()) return false; + record_chat(guest.member.id, guest_name(guest), "/" + std::string(line), false); + return true; +} +std::string Host::plugin_map(std::string_view map) { + // As the console's map: a destination the server has, saved so it starts on it again. + if (map.empty() || !valid_map_destination(map_destination(map)) || !valid_map_destination(map_destination(map_setting(map)))) + return "no single map is called \"" + std::string(map) + "\" (travel.levels lists them)"; + if (!installed_map(map)) return "this server does not have that map"; + if (map_hash(map_destination(map)) == map_) return "the server is already on that map"; + change_map(map); + save(); + roster_dirty_ = true; + log_("[map] A plugin changed the map to " + map_name() + "."); + return {}; +} +std::string Host::plugin_time_of_day(std::string_view time) { + if (world_layers().empty()) return "the server has no world layers (world-layers.json beside it)"; + if (!config_.world_layer_sync) return "the server lets players keep their own world layers (layer-sync is off)"; + if (auto why = set_time_of_day(time); !why.empty()) return why; + save(); + roster_dirty_ = true; + log_("[world] A plugin set the time of day to " + std::string(time) + "."); + return {}; } bool Host::allowed_words(Guest &guest, std::string_view text) { if (!text::contains_forbidden_words(text)) return true; @@ -1031,6 +1183,8 @@ void Host::drop(std::uint64_t id, const std::string &reason, const std::string & guest->sound_sender.forget(id); } party_left(id, name); + plugin_screens_left(id); + hidden_left(id); // Their vote cooldown stays (tick() drops it once it has run out): leaving and coming // back does not let a player start another vote sooner. // One voter fewer, or the player a kick vote was about. Counted in tick(): a passing kick @@ -1230,6 +1384,7 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: offer.destination = map_destination(config_.map); offer.map_label = wire_map_label(); offer.map_package = map_package(config_.map); + offer.required_plugins = required_plugins(); offer.challenge = link->password_challenge; offer.map_authorized = authorized; send_required(*link, encode_wire(offer)); @@ -1350,6 +1505,7 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: } log_("[chat] " + guest_name(*link) + ": " + p.text); broadcast(p, true, false, p.source); + record_chat(peer, guest_name(*link), p.text, false); return; } // Grey dots above the sender. Not a chat line, and not logged. A player who may not @@ -1431,6 +1587,15 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: if (!link->world_ready) return; if (!routed_source(p, link->member, peer, true, id_)) return; if (p.kind == PacketKind::away) return drop(peer, "A player ended their session."); + // At their game though their skater may stand still (a plugin's drone): their time away starts + // again. Counted once in present_least_us, so a flood of them does nothing more. + if (p.kind == PacketKind::present) { + if (link->present_at && now_ < link->present_at + multiplayer::present_least_us) return; + link->present_at = now_; + link->last_packet = now_; + active(*link); + return; + } if (p.kind == PacketKind::objects) { const auto before = link->objects.revision(); if (link->objects.receive(p.objects) == ObjectState::Result::invalid) @@ -1512,8 +1677,9 @@ void Host::receive_cosmetics() { // Players who have been away longer than the server allows ("afk_kick_minutes") are removed, // with a warning a minute before. Away is doing nothing a player at their game does: not -// moving, speaking, typing in chat or changing their objects. Admins stay, and so does anyone -// whose game is still loading the map. +// moving, speaking, typing in chat, changing their objects or working the controls (their game's +// presence notice, PacketKind::present). Admins stay, and so does anyone whose game is still +// loading the map. void Host::remove_away() { if (!config_.afk_kick) return; const auto limit = static_cast(config_.afk_kick) * 60000000; @@ -1608,6 +1774,7 @@ void Host::sync_objects() { for (std::size_t attempt = 0; attempt < sources.size(); ++attempt) { const auto &source = sources[delivery.cursor++ % sources.size()]; if (source.id == id || !source.state->revision()) continue; + if (const auto *hidden = hidden_from(id, source.id); hidden && hidden->skater) continue; // plugin_hide const auto previous = delivery.sent.find(source.id); const auto since = previous != delivery.sent.end() && previous->second.first == source.epoch ? previous->second.second : 0; diff --git a/Server/server_host.h b/Server/server_host.h index 2ad1843c..c94b3bf0 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -14,6 +14,7 @@ #include "Extension/Multiplayer/Session/room.h" #include "Extension/Multiplayer/Steam/steam_transport.h" #include "Engine/Game/World/world_layers.h" +#include #include #include #include @@ -23,6 +24,7 @@ #include #include #include +#include #include #include @@ -56,6 +58,10 @@ class Host { // nothing; and whether "maps update" was typed since this was last asked. std::string map_folder_in_play() const; bool take_map_update_request() { return std::exchange(map_update_requested_, false); } + // The plugins players are told to have ("plugins.required"): the config's, less those a + // check on Thunderstore found are not plugins (refuse_plugins). + std::vector required_plugins() const; + void refuse_plugins(std::set refused) { refused_plugins_ = std::move(refused); } unsigned players() const; std::uint64_t secret() const { return secret_; } // The UDP port players may connect straight to, or 0 (config connection). @@ -85,6 +91,8 @@ class Host { std::uint8_t player_locks(std::uint64_t id) const { return locks_of(id); } std::uint8_t set_locks(std::uint64_t id, std::uint8_t locks, const std::string &source); std::optional> player_position(std::uint64_t steam_id) const; + // The role a player has (data/roles.json: given them, or the default), here or not; or none. + const Role *player_role(std::uint64_t id) const { return role_of(id); } // Where each player in the world last said their skater was (their latest pose's root), and // how long ago that arrived: what the server's plugins see of the skaters. struct SkaterPose { @@ -95,6 +103,20 @@ class Host { std::vector skater_poses() const; void drop(std::uint64_t id, const std::string &reason, const std::string &detail = {}); void change_map(std::string_view map); // a level name, level path or destination + std::uint64_t world_id() const { return world_; } + // A server plugin changes the map, or the time of day for everyone, with the checks of the + // console's map and tod (and saved as they save): "" when done, else why not. + std::string plugin_map(std::string_view map); + std::string plugin_time_of_day(std::string_view time); + // The last chat lines, for the server's plugins (chat.read): each player's line as it is passed + // on, the server's own lines to everyone (sender 0), and a player's /command the server does + // not know itself, while a plugin hears chat. Sequences count from 1. + struct ChatRecord { + std::uint64_t sequence{}, sender{}; + std::string name, text; + bool server{}; + }; + std::vector chat_since(std::uint64_t sequence) const; // The park mods set up (maps.park_mods), for the server's plugins: which fit the map running // now, and which are spawned, with how many objects and minutes left (0: until removed). struct ParkStatus { @@ -110,6 +132,37 @@ class Host { std::string plugin_park(std::string_view plugin, bool spawn, std::string_view name, unsigned minutes); // A plugin message from the server's plugins (server_plugins.h) to one player, or everyone (0). void send_plugin_message(std::uint64_t to, const std::vector &bytes); + // Cards and polls for the server's plugins (server_votes.cpp), on the players' screens: each + // player's own, shown over the server's announcement and vote until it ends, then those again. + // `players` empty is everyone on now. A card is up `seconds` (0: as long as an announcement); + // a poll runs `seconds` (0: votes.poll_seconds), one id for all its players, closed for each + // as they answer ("/2"); a player's newer poll replaces theirs. "" when shown, else why not. + std::string plugin_card(std::string_view plugin, const std::vector &players, std::string_view text, unsigned seconds); + std::string plugin_poll(std::string_view plugin, const std::vector &players, std::string_view question, + const std::vector &answers, unsigned seconds, std::uint32_t &id); + std::string plugin_poll_close(std::string_view plugin, std::uint32_t id); + void plugin_screens_release(std::string_view plugin); // a plugin that stopped: its cards and polls go + // A player's answer (from 1) to a plugin's poll, or (answer 0) the poll closed for them and why: + // "timeout", "left", "replaced" or "closed". + struct PollNews { + std::string plugin; + std::uint32_t id{}; + std::uint64_t player{}; + std::size_t answer{}; + std::string text, why; + }; + std::vector take_poll_news() { return std::exchange(poll_news_, {}); } + // One player hidden from others for the server's plugins (server_host.cpp). Wholly (`skater`): + // they leave those players' rosters, and nothing of them (poses, sound, voice, outfit, effects, + // objects) is sent to them, so their games take the skater away as for a player who left. + // Otherwise only their voice and/or sound is held back. `viewers` empty is everyone on now but + // the target. Refused while the target shares a party or a throwdown with one of them. Shown + // again (`viewers` empty: everyone this plugin hid them from), they are introduced anew. + // "" when done, else why not. + std::string plugin_hide(std::string_view plugin, std::uint64_t target, const std::vector &viewers, bool skater, bool voice, + bool sound); + std::string plugin_unhide(std::string_view plugin, std::uint64_t target, const std::vector &viewers); + void plugin_hides_release(std::string_view plugin); // a plugin that stopped: everyone it hid is shown again enum class VoteKind { map, kick, time, custom, poll, extend }; @@ -203,6 +256,7 @@ class Host { // been told they are about to be removed for being away. std::uint64_t active_at{}; bool away_warned{}; + std::uint64_t present_at{}; // their game's last presence notice counted (PacketKind::present) std::uint64_t pose_arrival{}; std::array pose_delivery; CrowdLimits crowd; // how far the full and half rates reach for them in a crowd @@ -265,6 +319,7 @@ class Host { SteamTransport &transport_; Log log_; bool map_update_requested_{}; + std::set refused_plugins_; ActivityLog activity_; // what players do, for the console (config_.activity_log) // The running player vote (server_votes.cpp), and when each player may start another. // A poll is one too: it has answers instead of yes and no, and runs nothing (but the console's poll-run). @@ -293,6 +348,29 @@ class Host { std::uint64_t announcement_until_{}; std::uint64_t announcement_for_{}; // the one player shown it, or 0: everyone std::uint32_t announcement_ids_{}; + // The plugins' cards and polls, by the player they are shown to (plugin_card, plugin_poll). + struct PluginCard { + std::string plugin; + multiplayer::ServerAnnouncement card; + std::uint64_t until{}; + }; + struct PluginPoll { + std::string plugin; + std::uint32_t id{}; + std::string question; + std::vector answers; + std::uint64_t ends{}; + }; + std::map plugin_cards_; + std::map plugin_polls_; + std::vector poll_news_; + // The players the server's plugins hide (plugin_hide), by who they are hidden from: each + // target's plugin, and what of them is held back. + struct Hidden { + std::string plugin; + bool skater{}, voice{}, sound{}; + }; + std::map