mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-27 19:47:08 +01:00
Merge remote-tracking branch 'origin/main' into uninstall-pack-dependencies
# Conflicts: # Launcher/mod_manager.cpp
This commit is contained in:
commit
f0bc4c92c1
89 files changed
+3547
-745
No files matched your search
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -295,6 +295,14 @@ void register_movement_commands(Commands ®istry) {
|
||||
};
|
||||
registry.add(std::move(entry));
|
||||
}
|
||||
// What "bind reskatemenu" runs, for a player who cannot press the menu's key (no Insert key).
|
||||
auto open_menu = action("menu", "Open the ReSkate menu (bind it to a controller combo or key: bind reskatemenu)", Group::console);
|
||||
open_menu.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; };
|
||||
open_menu.run = [](const Model &, const Values &, const Output &out) {
|
||||
overlay::menu_open_flag().store(true);
|
||||
out("Opening the ReSkate menu.");
|
||||
};
|
||||
registry.add(std::move(open_menu));
|
||||
}
|
||||
void register_ai_commands(Commands ®istry) {
|
||||
const auto ready = [](const Model &m) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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 (...) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
@@ -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
|
||||
|
||||
Reference in new issue
Block a user