Merge remote-tracking branch 'origin/main' into uninstall-pack-dependencies

# Conflicts:
#	Launcher/mod_manager.cpp
This commit is contained in:
nxroot committed 2026-10-25 01:34:52 +01:00
commit f0bc4c92c1
89 files changed
+3547 -745

No files matched your search

+70
View File
@@ -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-<version>.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.
+9 -2
View File
@@ -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}")
@@ -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,
@@ -1,5 +1,6 @@
#pragma once
#include "Engine/Game/Build/fingerprint.h"
#include <array>
#include <cstdint>
// 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<unsigned char, 14> 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;
}
@@ -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");
}
+131 -2
View File
@@ -5,6 +5,7 @@
#include <fstream>
#include <map>
#include <set>
#include <stdexcept>
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<std::string> 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<unsigned char>(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<std::int64_t>();
if (value >= 0 && value <= 0xffffffffLL) return static_cast<std::uint32_t>(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<std::string> 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<int>() != 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<std::string> 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<std::pair<std::string, std::filesystem::path>>& 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;
}
}
+27
View File
@@ -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<std::string, std::string, std::less<>> 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<std::string> 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<std::string> notes; // a line each for every file or entry left out
};
ModObjectsReport install_mod_objects(const std::vector<std::pair<std::string, std::filesystem::path>>& files);
}
+50
View File
@@ -4,7 +4,9 @@
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Platform/path_text.h"
#ifdef _WIN32
#include <Windows.h>
#endif
#include <algorithm>
#include <cstdint>
@@ -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<std::string>& 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<std::streamsize>(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<std::string> mod_contents(const Mod& mod) {
std::vector<std::string> 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<Mo
if (!output || !output.write(text.data(), static_cast<std::streamsize>(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
+8
View File
@@ -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/<map>.park.json (park editor mods).
std::vector<std::string> 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<std::string> 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);
+9 -4
View File
@@ -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;
+7 -4
View File
@@ -5,7 +5,8 @@
#include <string_view>
// 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
@@ -29,7 +29,7 @@ constexpr auto use_download_deadline = static_cast<std::uint64_t>(-1);
std::atomic<std::uint64_t> active_deadline{use_download_deadline};
std::atomic<bool> downloading{}, installing{}, release_download{}, release_install{}, merging{};
std::atomic<bool> fetching{true}, held{}, observed_cancel{}, installed_cancel{};
std::atomic<unsigned> downloads{}, installs{}, applies{}, stops{}, rejoins{};
std::atomic<unsigned> downloads{}, installs{}, applies{}, stops{}, rejoins{}, removals{};
std::atomic<std::uint64_t> queued_stop_deadline{};
bool block_download{}, block_install{}, slow{}, transfer_error{}, fallback{};
dingosdk::MultiplayerModel session;
@@ -81,11 +81,17 @@ namespace dingosdk::https {
std::optional<std::uint64_t> content_length(std::wstring_view, std::uint32_t, const wchar_t*, Watch*) {
return std::nullopt;
}
std::optional<std::string> get_text(std::wstring_view, std::uint64_t, std::uint32_t,
std::optional<std::string> 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<std::string>&) {
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<ModEntry>&) {}
}
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,
+480 -218
View File
@@ -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{Stage::idle};
std::atomic<unsigned> workers{};
@@ -42,13 +55,9 @@ std::atomic<std::uint64_t> deadline{}, ask_until{};
std::atomic<bool> detached{};
multiplayer::MapNeed need; // under mutex, while not idle
std::optional<multiplayer::MapNeed> 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<Item> items; // under mutex: the map first when it is needed, then the plugins
std::atomic<std::size_t> 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<bool> stop_install{};
std::atomic<bool> 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<std::string>& 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<std::string, std::string, std::less<>> by_level;
std::vector<KnownPlugin> plugins;
};
struct KnownPackages {
KnownTable table;
std::uint64_t read_at{};
bool valid{}, reading{};
};
std::mutex known_mutex;
KnownPackages known_packages;
std::map<std::string, std::string, std::less<>> read_known_packages() {
std::map<std::string, std::string, std::less<>> 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<char>(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<std::string, std::string, std::less<>> 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<bool> 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<class Use>
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<mods::ModEntry> installed(const Item& item) {
const auto list = mods::scan_mods(mods::engine_data_root());
std::optional<mods::ModEntry> 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<Item> 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<bool>{} : 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<Item> 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<std::string> 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<Item> 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<Item> 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<std::string> 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<char>(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<char>(std::tolower(c)); });
return with_known([&](const KnownTable& table) { return table.by_level.contains(key); });
}
std::vector<std::string> missing_plugins(const std::vector<std::string>& required) {
return with_known([&](const KnownTable& table) {
std::vector<std::string> 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;
});
}
}
+37 -16
View File
@@ -3,36 +3,40 @@
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
// 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<Item> 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<std::string> missing_plugins(const std::vector<std::string>& 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;
+17
View File
@@ -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 <algorithm>
#include <memory>
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;
@@ -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<char>(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"})
+36 -1
View File
@@ -426,6 +426,8 @@ std::vector<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
r.at += static_cast<std::size_t>(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<Packet> decode(std::span<const std::uint8_t> 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<std::string> &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 {};
+15 -1
View File
@@ -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<std::string> &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<std::string> required_plugins; // map_offer: valid_required_plugins (a player's lobby: none)
std::vector<std::uint8_t> throwdown; // throwdown: one encoded message (1..max_throwdown_message bytes)
std::array<float, 3> teleport{}; // teleport: where the receiver goes (world position)
std::vector<std::uint8_t> tuning; // physics_tuning: 0..max_physics_tuning bytes
+47
View File
@@ -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<MapNeed> 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<std::string> 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;
+15 -1
View File
@@ -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<std::string> plugins;
};
std::optional<MapNeed> 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<NextMapWanted> 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<std::string> (*)(const std::vector<std::string> &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
@@ -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<std::string> 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<bool> server_vote_open_flag{};
inline std::atomic<MapPackageLookup> map_package_lookup{};
inline std::atomic<PluginCheck> plugin_check{};
inline std::atomic<LevelCheck> level_check{};
inline std::atomic<bool> fetching_map_flag{};
inline std::atomic<std::uint64_t> 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.
@@ -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);
@@ -11,6 +11,7 @@
#include "Engine/Vfs/mod_scoring.h"
#include "Extension/Multiplayer/Remote/remote_collision.h"
#include <algorithm>
#include <atomic>
#include <stdexcept>
#include <utility>
@@ -313,6 +314,23 @@ void send_typing(Session &s, bool on) {
message.typing = on;
broadcast(s, message, true, false, now);
}
namespace {
std::atomic<std::uint64_t> 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<std::uint8_t> message) {
if ((s.mode != Mode::host && s.mode != Mode::join) || message.empty() || message.size() > max_throwdown_message) return;
const auto now = now_us();
@@ -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<std::vector<std::string>>{{""}, {"Filmer"}, {"Team-Filmer", "team-filmer-1.0.0"},
{"Team-A/B"}, std::vector<std::string>(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<std::string> 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;
@@ -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<objects::InstalledObject> 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<std::string> browser;
+4 -2
View File
@@ -23,8 +23,10 @@ std::map<std::string, Placement, std::less<>> object_groups(const content_cache:
Placement object_placement(std::string_view key, const content_cache::Catalogs& catalogs,
const std::map<std::string, Placement, std::less<>>& 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;
+2 -1
View File
@@ -6,7 +6,8 @@
#include <vector>
// 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 {
+242
View File
@@ -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> role(std::uint64_t player) override {
if (player == 1) return Role{"moderator", 50, true};
return std::nullopt;
}
std::vector<ServerPark> 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<std::uint64_t>& 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<std::uint64_t>& viewers) override {
calls.push_back(std::format("unhide {} {} from {}", plugin, target, players_text(viewers)));
return {};
}
static std::string players_text(const std::vector<std::uint64_t>& 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<std::uint64_t>& 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<std::uint64_t>& players, std::string_view question,
const std::vector<std::string>& 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<std::uint64_t>{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<PollNews> poll_news;
std::vector<PollNews> take_poll_news() override { return std::exchange(poll_news, {}); }
std::optional<CameraView> camera_view() override { return CameraView{{1, 2, 3}, {0, 0, 1}, 60}; }
std::string set_camera(std::string_view plugin, const std::array<float, 16>& 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<Game::ContentFile> 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);
+2 -1
View File
@@ -17,7 +17,7 @@ namespace {
struct Event {
std::string_view name, permission;
};
constexpr std::array<Event, 29> events{{
constexpr std::array<Event, 31> 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<Event, 29> 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;
+5 -1
View File
@@ -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;
}
+136 -3
View File
@@ -8,6 +8,7 @@
#include <charconv>
#include <cmath>
#include <cstring>
#include <optional>
#include <span>
// 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<std::uint64_t> 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<std::uint64_t> 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<unsigned>(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<int>(poll_answers)) luaL_argerror(L, 3, "2 to 6 answers");
std::vector<std::string> 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<unsigned>(seconds), id); !why.empty()) {
lua_pushnil(L);
lua_pushlstring(L, why.data(), why.size());
return 2;
}
lua_pushnumber(L, static_cast<double>(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<std::uint32_t>(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<bool> 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<std::uint64_t>{} : 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},
+104
View File
@@ -0,0 +1,104 @@
#include "plugin_content.h"
#include "Engine/Core/Json/json.h"
#include <algorithm>
#include <fstream>
#include <sstream>
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<char>(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> mod_folders(const std::filesystem::path& mods_root) {
std::vector<Mod> 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<Mod>& mods, std::string_view folder, std::vector<Game::ContentFile>& 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<Game::ContentFile> 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<char>(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<std::uint64_t>(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<Mod>& 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<std::size_t>(size));
if (!in || !in.read(text.data(), static_cast<std::streamsize>(size))) return "the file cannot be read";
return {};
}
} // namespace dingosdk::plugins::content
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "plugin_game.h"
#include <cstddef>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
// 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> mod_folders(const std::filesystem::path& mods_root);
// The files under <mod>/<folder>/ 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<Mod>& mods, std::string_view folder, std::vector<Game::ContentFile>& 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<Mod>& mods, std::string_view mod, std::string_view folder, std::string_view path, std::string& text);
} // namespace dingosdk::plugins::content
+51
View File
@@ -596,6 +596,15 @@ public:
};
virtual std::optional<Permissions> 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> 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<ServerPark> 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<std::uint64_t>& /*players*/, std::string_view /*text*/,
unsigned /*seconds*/) {
return unavailable;
}
virtual std::string server_poll(std::string_view /*plugin*/, const std::vector<std::uint64_t>& /*players*/, std::string_view /*question*/,
const std::vector<std::string>& /*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<PollNews> 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<std::uint64_t>& /*viewers*/,
const Hiding&) {
return unavailable;
}
virtual std::string server_unhide(std::string_view /*plugin*/, std::uint64_t /*target*/, const std::vector<std::uint64_t>& /*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.
+7 -53
View File
@@ -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: <mod>/<folder>/ of every enabled mod ---------------------------------------------
// ---- content: <mod>/<folder>/ 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<EnabledMod> enabled_mods() {
std::vector<content::Mod> 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<ContentFile>& 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<ContentFile> 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<char>(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<std::uint64_t>(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<std::size_t>(size));
if (!in || !in.read(text.data(), static_cast<std::streamsize>(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<std::string> installed;
};
std::map<std::string, VariantRead, std::less<>> variant_reads_;
std::vector<EnabledMod> enabled_mods_;
std::vector<content::Mod> 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)
+19
View File
@@ -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<double>(news.id);
data["player"] = std::to_string(news.player);
if (news.answer) {
data["answer"] = static_cast<double>(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);
+3
View File
@@ -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> status() const;
+8
View File
@@ -295,6 +295,14 @@ void register_movement_commands(Commands &registry) {
};
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 &registry) {
const auto ready = [](const Model &m) {
+14 -2
View File
@@ -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<NativeTypeQuery> 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<void*>(at), &info, sizeof(info))) {
const auto base = reinterpret_cast<std::uintptr_t>(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;
+12 -5
View File
@@ -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;
+1 -1
View File
@@ -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 {
+18 -4
View File
@@ -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<Address>(base+build::engine::ui_manager); const auto manager=ui?read<Address>(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
+1 -1
View File
@@ -2,7 +2,7 @@
#include <cstdint>
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;
+39
View File
@@ -8,6 +8,7 @@
#include "Engine/Game/Input/owned_menu_input.h"
#include <hidsdi.h>
#include <atomic>
#include <cmath>
#include <format>
#include <array>
#include <cstring>
@@ -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<std::size_t I>
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<DWORD(WINAPI*)(DWORD, XINPUT_STATE*)>(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<std::uint32_t> message{}, message_key{}; // the game window's last input message
};
LastInput& last_input() { static auto* value = new LastInput; return *value; }
std::atomic<ULONGLONG> controls_at{};
namespace {
struct PadSeen {
bool known{};
std::uint32_t buttons{};
std::array<float, 6> 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<float, 6>& axes) noexcept {
static std::mutex mutex;
static std::array<PadSeen, 6> 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<float>(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<SHORT>(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<std::uint32_t>(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<DWORD(WINAPI*)(DWORD, XINPUT_STATE*)>(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) {
+10
View File
@@ -1,5 +1,9 @@
#pragma once
#include <Windows.h>
#include <array>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <string>
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<float, 6>& axes) noexcept;
extern std::atomic<ULONGLONG> controls_at;
}
+144 -5
View File
@@ -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<double>(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<float>(card.received) / static_cast<float>(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::size_t>(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<Hint> 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<float>(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<std::size_t>(3, static_cast<std::size_t>((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<float>(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<unsigned>(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<unsigned>(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
+8 -5
View File
@@ -12,6 +12,7 @@
#include <Windows.h>
#include <algorithm>
#include <atomic>
#include <cctype>
#include <filesystem>
#include <memory>
#include <mutex>
@@ -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<char>(std::tolower(static_cast<unsigned char>(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;
}
+17 -1
View File
@@ -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<Item> 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<bool>& 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<float, 6>& axes) noexcept;
}
// Nonblocking. A later Present releases GPU resources after its fence completes.
+4
View File
@@ -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<float, 6>& 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<float>(sample.pad.bLeftTrigger) / 255.0f;
out.right_trigger = static_cast<float>(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
+6 -6
View File
@@ -6,7 +6,6 @@
#include "Engine/Game/Build/20260929/physics_wake.h"
#include <array>
#include <atomic>
#include <bit>
namespace dingosdk::physics_wake_guard {
namespace {
@@ -17,14 +16,15 @@ std::atomic<bool> installed{};
std::atomic<std::uint32_t> most{};
std::atomic<std::uint64_t> 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 (...) {}
}
+72 -3
View File
@@ -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 <Windows.h>
#include <array>
#include <atomic>
#include <bit>
#include <cstring>
#include <limits>
namespace dingosdk::texture_handle_guard {
namespace {
@@ -16,8 +20,9 @@ Redirect original{};
std::atomic<bool> installed{};
std::atomic<std::uint64_t> 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<std::int64_t>(target) - static_cast<std::int64_t>(next_instruction);
if (delta < std::numeric_limits<std::int32_t>::min() || delta > std::numeric_limits<std::int32_t>::max()) return false;
displacement = static_cast<std::int32_t>(delta);
return true;
}
bool write_code(std::uintptr_t address, const void* bytes, std::size_t size) noexcept {
auto* const destination = reinterpret_cast<void*>(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<unsigned char, th::view_cache_insert.size()> actual{};
if (!memory::peek(base + th::view_cache_insert_rva, actual) || actual != th::view_cache_insert) return false;
auto* const stub = static_cast<std::uint8_t*>(memory::allocate_near_image(base, supported_build::game_image_size, stub_page_bytes));
if (!stub) return false;
const auto at = reinterpret_cast<std::uintptr_t>(stub);
std::array<std::uint8_t, 28> 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<std::uint8_t, th::view_cache_insert.size()> 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<void*>(base + th::redirect.rva);
if (hook_prepare(target, reinterpret_cast<void*>(&guarded), reinterpret_cast<void**>(&original)) != HookOk ||
hook_enable(target) != HookOk) {
+4 -3
View File
@@ -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/<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;
+55 -5
View File
@@ -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<std::string>{"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<std::string>{"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<std::string>{"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
+18
View File
@@ -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<std::string>{"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<std::string>{"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();
+47 -28
View File
@@ -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<char>(std::tolower(static_cast<unsigned char>(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();
+21 -8
View File
@@ -68,7 +68,7 @@ std::vector<std::string> 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<profiles::ProfileMod>& 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<int>(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<std::string> names;
for (const auto& mod : profile.mods)
+32 -33
View File
@@ -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<std::string_view, 2> 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<std::string_view, 3> content_markers{"layout.toc", "reskate-levels.json", "reskate-plugin.json"};
constexpr std::array<std::string_view, 2> 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<std::string_view, 4> 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/<map>.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/<map>.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<Root> mod_root(const std::vector<std::string>& 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<char>(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<Json> 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
+2 -2
View File
@@ -40,8 +40,8 @@ struct InstallOptions {
};
// Installs `source` (a .zip, or a folder) as Mods/<name> and returns <name>.
// The mod is the shallowest folder holding layout.toc, reskate-levels.json or
// parks/<map>.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/<map>.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
+6 -4
View File
@@ -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<std::string, const mods::ModEntry*> 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<mods::ModEntry>& entries) {
std::vector<std::string> names;
+8 -3
View File
@@ -34,6 +34,9 @@ struct Profile {
struct Profiles {
std::string active; // the profile last loaded or saved; empty for none
std::vector<Profile> 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<mods::ModEntry> entries;
// The mods it switches on that are not installed, to be downloaded.
std::vector<ProfileMod> 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);
-44
View File
@@ -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, <your
name>* 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.
-203
View File
@@ -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 <https://developer.spotify.com/dashboard> 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.
+49 -1
View File
@@ -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.
+6
View File
@@ -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));
+18
View File
@@ -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<std::pair<std::string, std::filesystem::path>> 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);
+1 -1
View File
@@ -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
```
-1
View File
@@ -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.)
+14 -7
View File
@@ -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 <minutes>|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 <number>|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.
+17
View File
@@ -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<std::vector<std::string>>{{"FPV_Drone"}, {""}, {"neekgate-FPV_Drone", "NEEKGATE-fpv_drone-1.0.0"},
{"neekgate-FPV Drone"}, std::vector<std::string>(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;
+33
View File
@@ -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;
}
+366 -6
View File
@@ -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 <Server/Test/fixtures/park_mods>
#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<Packet> 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<Packet> 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<std::string> 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<std::string>{"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<Packet> &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;
+5
View File
@@ -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)
+11
View File
@@ -210,6 +210,17 @@ std::set<std::uint64_t> 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();) {
+3
View File
@@ -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<std::uint64_t> 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 {
+23 -19
View File
@@ -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 ("<map>_tod_<n>_<name>"): 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<std::string_view, 8> 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<char>('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 ("<map>_tod_<n>_<name>"): 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<std::string_view, 8> 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<char>('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;
+16 -1
View File
@@ -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::vector<std::str
c.rotation_announce = get("maps", "rotation_announce", c.rotation_announce);
c.latest_versions = get("maps", "latest_versions", c.latest_versions);
c.latest_check_minutes = std::clamp(get("maps", "latest_check_minutes", c.latest_check_minutes), 5U, 1440U);
if (const auto *required = find("plugins", "required"); required && required->is_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)
+6
View File
@@ -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<std::string> 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;
+5
View File
@@ -406,6 +406,7 @@ void ServerEngine::worker_loop() {
auto next_ban_check = std::chrono::steady_clock::now();
std::future<BanListCheck> 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<bool> 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);
}
+173 -6
View File
@@ -207,6 +207,89 @@ void Host::send_plugin_message(std::uint64_t to, const std::vector<std::uint8_t>
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<std::uint64_t> &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<Guest *> 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<std::string> 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<std::uint64_t> &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<std::array<float, 3>> 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<std::string> Host::required_plugins() const {
std::vector<std::string> 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>{};
}
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<float>::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<std::uint64_t> 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::ChatRecord> Host::chat_since(std::uint64_t sequence) const {
std::vector<ChatRecord> 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<const std::uint8_t> 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<const std::uint8_t> 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<const std::uint8_t> 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<std::uint64_t>(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;
+101
View File
@@ -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 <deque>
#include <mutex>
#include <unordered_map>
#include <functional>
@@ -23,6 +24,7 @@
#include <random>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include <array>
@@ -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<std::string> required_plugins() const;
void refuse_plugins(std::set<std::string> 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<std::array<float, 3>> 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<SkaterPose> 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<ChatRecord> 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<std::uint8_t> &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<std::uint64_t> &players, std::string_view text, unsigned seconds);
std::string plugin_poll(std::string_view plugin, const std::vector<std::uint64_t> &players, std::string_view question,
const std::vector<std::string> &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<PollNews> 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<std::uint64_t> &viewers, bool skater, bool voice,
bool sound);
std::string plugin_unhide(std::string_view plugin, std::uint64_t target, const std::vector<std::uint64_t> &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<PoseDelivery, max_players> 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<std::string> 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<std::string> answers;
std::uint64_t ends{};
};
std::map<std::uint64_t, PluginCard> plugin_cards_;
std::map<std::uint64_t, PluginPoll> plugin_polls_;
std::vector<PollNews> 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<std::uint64_t, std::map<std::uint64_t, Hidden>> hidden_;
std::uint64_t announced_at_{}; // the last timed announcement, or while nobody is on
std::size_t next_announcement_{};
void active(Guest &guest) {
@@ -475,6 +553,13 @@ class Host {
void send_roster();
void send_world_state();
void send_chat(std::string_view text, Guest *only = nullptr);
std::deque<ChatRecord> chat_log_;
std::uint64_t chat_sequence_{};
void record_chat(std::uint64_t sender, std::string name, std::string text, bool server);
// A /command the server does not know, for the plugins when one hears chat: true when taken.
bool chat_to_plugins(Guest &guest, std::string_view line);
// The time of day on every map (tod): "" when set, else why not.
std::string set_time_of_day(std::string_view time);
void send_bans(Guest &admin);
void send_maps(Guest &admin);
std::string wire_map_label() const;
@@ -517,6 +602,22 @@ class Host {
// The announcement card on every player's screen, or only on player `to`'s (not in chat).
void announce(std::string_view text, std::uint64_t to = 0);
void tick_announcements(); // the owner's messages in turn, on their timer
// The plugins' cards and polls (plugin_card, plugin_poll): a poll closed for one player, with
// why; a number they typed, true when it was about their plugin poll (answered or refused);
// what ran out; a player who left; and the card and vote player `id`'s roster carries instead
// of `card` and `vote`.
// The players a plugin named who are on now (everyone for none); empty, and why, when none is.
std::vector<Guest *> plugin_screen_players(const std::vector<std::uint64_t> &players, std::string &why);
void close_plugin_poll(std::uint64_t player, std::string_view why);
bool answer_plugin_poll(Guest &, std::size_t answer);
void tick_plugin_screens();
void plugin_screens_left(std::uint64_t player);
void plugin_screens_roster(std::uint64_t id, multiplayer::ServerAnnouncement &card, multiplayer::ServerVote &vote) const;
// What of `target` a plugin holds back from `viewer`, or null; whether either of two players is
// wholly hidden from the other; and a player who left, taken out of every list.
const Hidden *hidden_from(std::uint64_t viewer, std::uint64_t target) const;
bool hidden_between(std::uint64_t a, std::uint64_t b) const;
void hidden_left(std::uint64_t player);
void reply(Guest &, std::string_view text, unsigned max_lines = 12);
Guest *match_player(std::string_view text);
// Sends each mover to a ring of `radius` around `at`, 1 m up, so nobody lands inside anyone; how many were sent.
+54
View File
@@ -1,6 +1,7 @@
#include "server_map_update.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Vfs/thunderstore_package.h"
#include "Extension/Multiplayer/Net/protocol.h"
#ifdef _WIN32
#include "Engine/Vfs/https_download.h"
#else
@@ -102,6 +103,59 @@ void set_map_version(const fs::path &mod, const std::string &version) {
}
}
std::string required_plugin_problem(const std::string &package_name, bool &reached) {
reached = false;
const auto name = package::parse_name(package_name);
if (!name) return "it is not a Thunderstore package (Owner-Name, or Owner-Name-1.2.3)";
auto bare = *name;
bare.version.clear();
std::string answer;
try {
answer = get_text(package::details_url(bare));
} catch (const std::exception &failure) {
return std::string("Thunderstore could not be asked about it (") + failure.what() + ")";
}
reached = true;
const auto choice = package::choose(answer, bare, community, package::Kind::plugin);
if (!choice.ok) return choice.reason.empty() ? "Thunderstore does not list it as a plugin" : choice.reason;
if (!name->version.empty() && multiplayer::map_version_newer(name->version, choice.newest))
return "Thunderstore's newest version is " + choice.newest + ", older than " + name->version;
return {};
}
RequiredPluginCheck::Done RequiredPluginCheck::tick(const std::vector<std::string> &required) {
Done done;
if (work_.valid()) {
if (work_.wait_for(std::chrono::seconds(0)) != std::future_status::ready) return done;
done = work_.get();
done.finished = true;
}
if (!work_.valid() && (!started_ || required != checked_)) {
started_ = true;
checked_ = required;
if (required.empty()) return done;
work_ = std::async(std::launch::async, [required] {
Done result;
for (const auto &plugin : required) {
bool reached{};
auto problem = required_plugin_problem(plugin, reached);
if (problem.empty()) continue;
if (problem.ends_with('.')) problem.pop_back();
if (reached) {
result.refused.insert(plugin);
result.lines.push_back("Required plugin " + plugin + " is left out: " + problem + ". Players are only asked for plugins.");
} else {
result.lines.push_back("Required plugin " + plugin + " could not be checked: " + problem + ". Players' games check it themselves.");
}
}
if (result.lines.empty())
result.lines.push_back("Required plugins: " + std::to_string(required.size()) + " checked on Thunderstore; players without them are offered them as they join.");
return result;
});
}
return done;
}
std::vector<MapUpdate> update_maps(const fs::path &mods, const std::string &in_play) {
std::vector<MapUpdate> result;
std::error_code error;
+23
View File
@@ -32,6 +32,29 @@ std::vector<MapUpdate> update_maps(const std::filesystem::path &mods, const std:
std::string map_version(const std::filesystem::path &mod);
void set_map_version(const std::filesystem::path &mod, const std::string &version);
// Whether a required plugin ("plugins.required") is one players can be asked for: listed as a
// plugin in ReSkate's Thunderstore community, not deprecated, not rejected. Empty when it is,
// else why not. `reached` is false when Thunderstore could not be asked (it is then kept: the
// players' games check it again themselves). Asks Thunderstore; for a worker thread.
std::string required_plugin_problem(const std::string &package, bool &reached);
// The run loop's side of that: the list checked once it changes (and at start), on a worker,
// and the packages to leave out of what players are told.
class RequiredPluginCheck {
public:
struct Done {
std::vector<std::string> lines; // for the log
std::set<std::string> refused; // not plugins on Thunderstore: not asked of players
bool finished{};
};
Done tick(const std::vector<std::string> &required);
private:
std::future<Done> work_;
std::vector<std::string> checked_;
bool started_{};
};
// The run loop's side: a check when one is due or asked for, on a worker, and what it found.
class MapUpdater {
public:
+12
View File
@@ -73,6 +73,18 @@ void Host::party_request(Guest &guest, PartyAction action, std::uint64_t player)
if (!other || !other->handshaken) return reply(guest, "That player is not on the server.");
}
const auto their_name = other ? guest_name(*other) : std::string{};
// Not into one party with a player a plugin hides from them, or from whom they are hidden
// (plugin_hide): their games would have a party member they cannot see.
if (other && (action == PartyAction::invite || action == PartyAction::accept || action == PartyAction::join) && !hidden_.empty()) {
const auto with = [&](std::uint64_t who) {
std::vector<std::uint64_t> group{who};
if (const auto *details = parties_.party(parties_.party_of(who))) group.assign(details->members.begin(), details->members.end());
return group;
};
for (const auto a : with(me))
for (const auto b : with(player))
if (hidden_between(a, b)) return reply(guest, "That party is not open to you right now.");
}
using R = PartyBook::Result;
auto result = R::ok;
switch (action) {
+79
View File
@@ -1,5 +1,6 @@
#include "server_plugins.h"
#include "server_host.h"
#include "Extension/Plugins/plugin_content.h"
#include "Extension/Plugins/plugin_host.h"
#include "Extension/Plugins/plugin_manifest.h"
#include "Extension/Plugins/plugin_wire.h"
@@ -80,6 +81,13 @@ class ServerGame final : public Game {
host_.set_locks(player, locks, "plugin " + std::string(plugin));
return {};
}
// The role they have (the one given them, or the server's default role), here or not.
std::optional<Role> role(std::uint64_t player) override {
if (!multiplayer::individual_steam_id(player)) return std::nullopt; // the server itself has none
const auto *held = host_.player_role(player);
if (!held) return std::nullopt;
return Role{held->name, held->rank, server::staff(held)};
}
std::vector<ServerPark> server_parks() override {
std::vector<ServerPark> result;
for (auto &p : host_.park_status()) result.push_back({std::move(p.name), std::move(p.map), p.here, p.spawned, p.objects, p.minutes_left});
@@ -89,6 +97,31 @@ class ServerGame final : public Game {
return host_.plugin_park(plugin, true, name, minutes);
}
std::string remove_park(std::string_view plugin, std::string_view name) override { return host_.plugin_park(plugin, false, name, 0); }
// Cards and polls on the players' screens, as the roster carries the server's own.
std::string server_card(std::string_view plugin, const std::vector<std::uint64_t> &players, std::string_view text, unsigned seconds) override {
return host_.plugin_card(plugin, players, text, seconds);
}
std::string server_poll(std::string_view plugin, const std::vector<std::uint64_t> &players, std::string_view question,
const std::vector<std::string> &answers, unsigned seconds, std::uint32_t &id) override {
return host_.plugin_poll(plugin, players, question, answers, seconds, id);
}
std::string server_poll_close(std::string_view plugin, std::uint32_t id) override { return host_.plugin_poll_close(plugin, id); }
std::vector<PollNews> take_poll_news() override {
std::vector<PollNews> news;
for (auto &n : host_.take_poll_news()) news.push_back({std::move(n.plugin), n.id, n.player, n.answer, std::move(n.text), std::move(n.why)});
return news;
}
// A player hidden from others, wholly or only their voice and sound (the roster and relay).
std::string server_hide(std::string_view plugin, std::uint64_t target, const std::vector<std::uint64_t> &viewers, const Hiding &what) override {
return host_.plugin_hide(plugin, target, viewers, what.skater, what.voice, what.sound);
}
std::string server_unhide(std::string_view plugin, std::uint64_t target, const std::vector<std::uint64_t> &viewers) override {
return host_.plugin_unhide(plugin, target, viewers);
}
void release(std::string_view plugin) override {
host_.plugin_screens_release(plugin);
host_.plugin_hides_release(plugin);
}
// The skaters of the players in the world, as their latest poses put them. The server has
// no skater of its own, so this is every player's.
std::vector<PeerSkater> peer_skaters() override {
@@ -101,6 +134,15 @@ class ServerGame final : public Game {
}
return result;
}
// mods.content: every mod folder in the server's Mods, as a game reads its enabled mods. The
// server has no load order or mods.json of its own; the folders come by name, as for its maps.
std::string content_list(std::string_view folder, std::vector<ContentFile> &files) override {
content::list(mod_folders(), folder, files);
return {};
}
std::string content_read(std::string_view mod, std::string_view folder, std::string_view path, std::string &text) override {
return content::read(mod_folders(), mod, folder, path, text);
}
// Through the console's own ban and unban, so they keep its checks and save the bans file.
std::string ban(std::uint64_t player) override {
const auto answer = host_.command("ban " + std::to_string(player));
@@ -108,6 +150,30 @@ class ServerGame final : public Game {
log_("[Plugins] " + answer); // the console's ban leaves logging to the console
return {};
}
// Chat as the server passes it on: players' lines, the server's own to everyone, and the
// /commands it does not know itself (Host::chat_since).
std::vector<ChatLine> chat_since(std::uint64_t sequence) override {
std::vector<ChatLine> out;
for (auto &line : host_.chat_since(sequence)) out.push_back({line.sequence, line.sender, std::move(line.name), std::move(line.text), false, line.server});
return out;
}
std::string world_map() override { return host_.map_name(); }
std::uint64_t world_id() override { return host_.world_id(); }
// The maps the server knows, which travel.load switches to (as the console's map does).
std::vector<LevelInfo> levels() override {
std::vector<LevelInfo> out;
for (const auto &level : server::levels()) out.push_back({level.asset, level.name, true});
return out;
}
std::string travel(std::string_view kind, std::string_view target) override {
if (kind != "level") return "a dedicated server has no fast travel: load one of its maps (travel.load)";
return host_.plugin_map(target);
}
// The time of day for everyone, as the console's tod sets it; the server has no other world.
std::string set_environment(std::string_view kind, std::string_view key, std::string_view) override {
if (kind != "tod") return unavailable;
return host_.plugin_time_of_day(key);
}
std::string unban(std::uint64_t player) override {
const auto answer = host_.command("unban " + std::to_string(player));
if (host_.is_banned(player)) return answer;
@@ -116,6 +182,15 @@ class ServerGame final : public Game {
}
private:
// Read again at most every 2 s: a plugin may list them every tick.
const std::vector<content::Mod> &mod_folders() {
const auto now = std::chrono::steady_clock::now();
if (!mods_read_ || now - *mods_read_ >= std::chrono::seconds(2)) {
mods_read_ = now;
mods_ = content::mod_folders(host_.config().mods);
}
return mods_;
}
bool present(std::uint64_t player) const {
for (const auto &p : host_.player_list())
if (p.id == player) return true;
@@ -123,6 +198,8 @@ class ServerGame final : public Game {
}
Host &host_;
ServerPlugins::Log log_;
std::vector<content::Mod> mods_;
std::optional<std::chrono::steady_clock::time_point> mods_read_;
};
class ServerEnvironment final : public Environment {
@@ -229,6 +306,8 @@ void ServerPlugins::tick(std::uint64_t now_us) {
}
}
bool ServerPlugins::hears_chat() const { return state_->plugins.wants_chat(); }
void ServerPlugins::receive(std::uint64_t from, const std::vector<std::uint8_t> &bytes) {
auto &inbox = state_->environment.inbox;
const auto to = wire::to_of(bytes);
+5 -1
View File
@@ -12,7 +12,9 @@
// a game mode's rules run. The operator installs them in Plugins beside the server, a folder a
// plugin (with its reskate-plugin.json, "side" "server" or "both"); they load, reload and unload as
// the folder changes, checked every 2 s. The server has no game: what a plugin may do here is the
// session's messages, its players, the server API (kick, say, tell, move) and its storage.
// session's messages, its players and their chat, the server API (kick, say, tell, move), the map
// (session.world, travel.levels and travel.load) and the time of day for everyone (world.set_time),
// its storage and reading the content folders of the mods in Mods (mods.content).
// Everything here runs on the server's tick thread.
namespace dingosdk::server {
class Host;
@@ -31,6 +33,8 @@ class ServerPlugins {
// A console line for the plugins: "plugin list|reload|off|on" or a plugin's own command.
// Empty when it is neither.
std::optional<std::string> command(std::string_view line);
// Whether a running plugin hears chat (chat.read).
bool hears_chat() const;
private:
struct State;
+151 -1
View File
@@ -155,6 +155,12 @@ void Host::chat_command(Guest &guest, std::string_view line) {
const std::string whisper = "/w <player> <message>: send a private message";
return reply(guest, (text.empty() ? "This server has no player votes. Type /tp <player> to teleport.\n" : text) + whisper);
}
// A number from a player with a plugin's poll open answers that one, not the server's: it is the
// plugin's question, so their role's "votes" is not asked.
if (plugin_polls_.contains(guest.member.id)) {
const auto word = verb == "vote" ? lower(split(rest).first) : verb;
if (word.size() == 1 && word[0] >= '1' && word[0] <= '9' && answer_plugin_poll(guest, static_cast<std::size_t>(word[0] - '1'))) return;
}
if ((verb == "party" || verb == "p") && !may(guest.member.id, multiplayer::role_parties))
return reply(guest, role_refusal(guest.member.id, "use parties"));
if ((verb == "w" || verb == "whisper" || verb == "tell") && !may(guest.member.id, multiplayer::role_whisper))
@@ -222,6 +228,9 @@ void Host::chat_command(Guest &guest, std::string_view line) {
return run_custom_command(guest, custom_command, trim(rest));
// Admins run any server command from chat, as they do with "mp server". The caller
// has already logged the line with any password hidden, so it is not logged again here.
// A word that is not one of the server's commands is the plugins' while one hears chat: a game
// mode's "/race join". They answer the player themselves (server.tell).
if (custom_command_name_free(verb) && chat_to_plugins(guest, line)) return;
if (is_staff(guest.member.id)) {
const auto id = guest.member.id;
const auto answer_text = command(line, id);
@@ -405,11 +414,14 @@ void Host::answer_poll(Guest &guest, std::size_t answer) {
}
bool Host::answers_vote(const Guest &guest, std::string_view line) const {
if (!vote_) return false;
// "/2", "/yes", or the same after "/vote", as chat_command takes them.
const auto [first, rest] = split(line);
auto verb = lower(first);
if (verb == "vote") verb = lower(split(rest).first);
if (const auto poll = plugin_polls_.find(guest.member.id);
poll != plugin_polls_.end() && verb.size() == 1 && verb[0] >= '1' && static_cast<std::size_t>(verb[0] - '0') <= poll->second.answers.size())
return true;
if (!vote_) return false;
if (vote_->kind == VoteKind::poll)
return verb.size() == 1 && verb[0] >= '1' && static_cast<std::size_t>(verb[0] - '0') <= vote_->answers.size();
return guest.member.id != vote_->target && (verb == "yes" || verb == "y" || verb == "no" || verb == "n");
@@ -684,6 +696,7 @@ void Host::announce(std::string_view text, std::uint64_t to) {
roster_dirty_ = true;
}
void Host::tick_announcements() { // the timer waits while nobody is on
tick_plugin_screens();
if (announcement_.id && now_ >= announcement_until_) {
announcement_ = {};
announcement_for_ = 0;
@@ -699,6 +712,143 @@ void Host::tick_announcements() { // the timer waits while nobody is on
next_announcement_ %= a.messages.size();
announce(a.messages[next_announcement_++]);
}
// ---- The plugins' cards and polls ------------------------------------------------------------------
std::vector<Host::Guest *> Host::plugin_screen_players(const std::vector<std::uint64_t> &players, std::string &why) {
std::vector<Guest *> found;
for (auto &[id, guest] : guests_)
if (guest->handshaken && (players.empty() || std::find(players.begin(), players.end(), id) != players.end())) found.push_back(guest.get());
if (found.empty())
why = players.empty() ? "nobody is on the server" : players.size() == 1 ? "that player is not on the server" : "none of those players is on the server";
return found;
}
std::string Host::plugin_card(std::string_view plugin, const std::vector<std::uint64_t> &players, std::string_view text, unsigned seconds) {
const auto line = clean_chat_text(text);
if (line.empty()) return "the card has nothing to show";
std::string why;
const auto shown = plugin_screen_players(players, why);
if (shown.empty()) return why;
// As long as an announcement of the same text, unless the plugin says.
if (!seconds) seconds = static_cast<unsigned>(std::clamp<std::size_t>(5 + line.size() / 20, 6, 15));
if (!++announcement_ids_) ++announcement_ids_;
std::vector<std::string> names;
for (const auto *guest : shown) {
plugin_cards_[guest->member.id] = {std::string(plugin), {announcement_ids_, static_cast<std::uint16_t>(seconds), line}, now_ + microseconds(seconds)};
names.push_back(guest_name(*guest));
}
log_("[card by plugin " + std::string(plugin) + " -> " + (players.empty() ? std::string("everyone") : joined(names, ", ")) + "] " + line);
roster_dirty_ = true;
return {};
}
std::string Host::plugin_poll(std::string_view plugin, const std::vector<std::uint64_t> &players, std::string_view question,
const std::vector<std::string> &answers, unsigned seconds, std::uint32_t &id) {
auto label = clean_chat_text(question);
cut_text(label, multiplayer::max_vote_label);
std::vector<std::string> choices;
for (const auto &answer : answers) {
auto choice = clean_chat_text(answer);
cut_text(choice, multiplayer::max_vote_answer);
if (choice.empty()) return "an answer has nothing to show";
choices.push_back(std::move(choice));
}
if (label.empty()) return "the question has nothing to show";
if (choices.size() < 2 || choices.size() > multiplayer::max_vote_answers)
return "a poll has 2 to " + std::to_string(multiplayer::max_vote_answers) + " answers";
std::string why;
const auto shown = plugin_screen_players(players, why);
if (shown.empty()) return why;
// From the server's own count of votes: no game takes one poll for another.
if (!++vote_ids_) ++vote_ids_;
id = vote_ids_;
const auto ends = now_ + microseconds(seconds ? seconds : config_.votes.poll_seconds);
std::vector<std::string> names;
for (const auto *guest : shown) {
if (plugin_polls_.contains(guest->member.id)) close_plugin_poll(guest->member.id, "replaced");
plugin_polls_[guest->member.id] = {std::string(plugin), id, label, choices, ends};
names.push_back(guest_name(*guest));
}
log_("[poll by plugin " + std::string(plugin) + " #" + std::to_string(id) + " -> " +
(players.empty() ? std::string("everyone") : joined(names, ", ")) + "] " + label + " | " + joined(choices, " | "));
roster_dirty_ = true;
return {};
}
std::string Host::plugin_poll_close(std::string_view plugin, std::uint32_t id) {
std::vector<std::uint64_t> open;
for (const auto &[player, poll] : plugin_polls_)
if (poll.id == id && poll.plugin == plugin) open.push_back(player);
if (open.empty()) return "no poll of the plugin's with that id is open";
for (const auto player : open) close_plugin_poll(player, "closed");
return {};
}
void Host::plugin_screens_release(std::string_view plugin) {
const auto before = plugin_cards_.size() + plugin_polls_.size();
std::erase_if(plugin_cards_, [&](const auto &entry) { return entry.second.plugin == plugin; });
std::erase_if(plugin_polls_, [&](const auto &entry) { return entry.second.plugin == plugin; });
if (plugin_cards_.size() + plugin_polls_.size() != before) roster_dirty_ = true;
}
void Host::close_plugin_poll(std::uint64_t player, std::string_view why) {
const auto found = plugin_polls_.find(player);
if (found == plugin_polls_.end()) return;
if (poll_news_.size() < 4096) poll_news_.push_back({found->second.plugin, found->second.id, player, 0, {}, std::string(why)});
plugin_polls_.erase(found);
roster_dirty_ = true;
}
bool Host::answer_plugin_poll(Guest &guest, std::size_t answer) {
const auto found = plugin_polls_.find(guest.member.id);
if (found == plugin_polls_.end()) return false;
const auto &poll = found->second;
if (answer >= poll.answers.size()) {
reply(guest, "Answer with /1 to /" + std::to_string(poll.answers.size()) + ".");
return true;
}
// One answer closes it for them: the card goes, and the server's vote or poll shows again if one runs.
log_("[poll by plugin " + poll.plugin + " #" + std::to_string(poll.id) + "] " + guest_name(guest) + " answered " +
std::to_string(answer + 1) + " " + poll.answers[answer]);
if (poll_news_.size() < 4096) poll_news_.push_back({poll.plugin, poll.id, guest.member.id, answer + 1, poll.answers[answer], {}});
plugin_polls_.erase(found);
roster_dirty_ = true;
return true;
}
void Host::tick_plugin_screens() {
if (std::erase_if(plugin_cards_, [&](const auto &entry) { return now_ >= entry.second.until; })) roster_dirty_ = true;
std::vector<std::uint64_t> ended;
for (const auto &[player, poll] : plugin_polls_)
if (now_ >= poll.ends) ended.push_back(player);
for (const auto player : ended) close_plugin_poll(player, "timeout");
}
void Host::plugin_screens_left(std::uint64_t player) {
plugin_cards_.erase(player);
close_plugin_poll(player, "left");
}
void Host::plugin_screens_roster(std::uint64_t id, multiplayer::ServerAnnouncement &card, multiplayer::ServerVote &vote) const {
const auto left = [&](std::uint64_t until) {
return static_cast<std::uint16_t>(std::min<std::uint64_t>(until > now_ ? (until - now_ + 999999) / 1000000 : 0, 65535));
};
if (const auto found = plugin_cards_.find(id); found != plugin_cards_.end()) {
card = found->second.card;
card.seconds = left(found->second.until);
}
if (const auto found = plugin_polls_.find(id); found != plugin_polls_.end()) {
const auto &poll = found->second;
vote = {};
vote.id = poll.id;
vote.kind = multiplayer::server_vote_poll;
vote.outcome = multiplayer::vote_running;
vote.label = poll.question;
vote.answers = poll.answers;
vote.counts.assign(poll.answers.size(), 0);
vote.seconds = left(poll.ends);
}
}
std::pair<std::string, bool> Host::announcements_command(std::string_view argument) {
auto &a = config_.announcements;
const auto [what_text, rest] = split(argument);
-17
View File
@@ -1,17 +0,0 @@
# FPV drone models
Both are licensed CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/), which allows them to
be shipped, changed and shared as long as the author is credited and changes are noted.
| File | Title | Author | Source |
| --- | --- | --- | --- |
| `fpv_drone.glb` | FPV Drone | PinaPipe (https://sketchfab.com/PinaPipe) | https://sketchfab.com/3d-models/fpv-drone-92152c31ec9a4a87b2a0478bd114d293 |
| `dji_avata2.glb` | DJI Avata2 | raphael.harris.gaffga (https://sketchfab.com/raphael.harris.gaffga) | https://sketchfab.com/3d-models/dji-avata2-e27ed758e2174a89a48368e84027f8d9 |
`drone.glb` is the game model, made from `fpv_drone.glb` by `tools/models/prepare_drone.py`:
reduced from 27,946 to about 8,000 triangles, textures shrunk to 512 pixels, scaled to 0.29 m
across, centred and turned to face forward:
blender --background --python tools/models/prepare_drone.py -- assets/models/fpv/fpv_drone.glb assets/models/fpv/drone.glb --triangles 8000 --span 0.33 --forward 0.305,-0.952 --texture-size 512
In game the credit reads: "Drone model: FPV Drone by PinaPipe (CC BY 4.0)".
Binary file not shown.
+1
View File
@@ -147,6 +147,7 @@ add_library(dingosdk_plugins STATIC
Extension/Plugins/plugin_api_services.cpp
Extension/Plugins/plugin_api_throwdowns.cpp
Extension/Plugins/plugin_api_capture.cpp
Extension/Plugins/plugin_content.cpp
# The throwdown kit's store and the plugins' modes, which the plugin host and the game's
# native UI share (kit::flag is the game's: Extension/Throwdowns/native_flag_session.cpp).
Extension/Throwdowns/throwdown_kit.cpp