Plugins: mods.content and read_content read the server's Mods on a dedicated server

A server plugin with mods.read could call reskate.mods.content, but the
server's game had no content of its own, so it got nil and "not
available". A mode that keeps its data in mods (a race reading start
and finish from a park mod) could only read it in the players' games.

The server now answers from every mod folder in its Mods, by name, as it
finds its maps there; it has no load order or mods.json. A hidden folder
(.reskate_plugin_data) and names a plugin could not ask for are left out,
and a mod's title is its manifest.json's "name".

The listing and reading moved from plugin_game_runtime.cpp into
plugin_content.cpp, which the game and the server share, so both keep
the same limits: the folders the manifest's "content" names, 1000 files,
four folders deep, 1 MB a file, nothing outside the mod's folder. The
game's side is unchanged apart from calling it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYabE7FCPgj3SaWeF92EML
This commit is contained in:
Robin MoellerandClaude Opus 5.5 committed 2026-10-23 15:50:55 +02:00
1 parent f763792732
commit 0706730e79
8 files changed
+246 -54

No files matched your search

+50
View File
@@ -1,5 +1,6 @@
// The plugin host apart from the game: manifests, the reskate API, storage, modules, hot reload,
// and the sandbox against plugins written to break it (loops, memory, error floods, escapes).
#include "Extension/Plugins/plugin_content.h"
#include "Extension/Plugins/plugin_discovery.h"
#include "Extension/Plugins/plugin_host.h"
#include "Extension/Plugins/plugin_manifest.h"
@@ -2893,6 +2894,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");
@@ -3896,6 +3945,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);
+104
View File
@@ -0,0 +1,104 @@
#include "plugin_content.h"
#include "Engine/Core/Json/json.h"
#include <algorithm>
#include <fstream>
#include <sstream>
namespace dingosdk::plugins::content {
namespace {
// The names mod_list.h's scan_mods takes for a mod folder, as long as read_content takes.
bool mod_name(std::string_view name) {
if (name.empty() || name.size() > 64 || name.front() == '.') return false;
return std::all_of(name.begin(), name.end(), [](char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.' || c == ' ';
});
}
bool same_name(std::string_view a, std::string_view b) {
return std::equal(a.begin(), a.end(), b.begin(), b.end(), [](char x, char y) {
const auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? static_cast<char>(c + 32) : c; };
return lower(x) == lower(y);
});
}
// manifest.json's "name", 1 to 64 characters, or empty when there is none to read.
std::string manifest_name(const std::filesystem::path& directory) {
const auto file = directory / "manifest.json";
std::error_code error;
if (!std::filesystem::is_regular_file(file, error) || std::filesystem::file_size(file, error) > 32 * 1024 || error) return {};
try {
std::ifstream in(file, std::ios::binary);
std::stringstream text;
text << in.rdbuf();
const auto root = Json::parse(text.str());
if (!root.is_object() || !root.contains("name") || !root.at("name").is_string()) return {};
const auto& name = root.at("name").string();
return name.size() <= 64 ? name : std::string{};
} catch (const std::exception&) {
return {};
}
}
} // namespace
std::vector<Mod> mod_folders(const std::filesystem::path& mods_root) {
std::vector<Mod> mods;
std::error_code error;
for (std::filesystem::directory_iterator it(mods_root, error), end; !error && it != end; it.increment(error)) {
std::error_code entry;
if (!it->is_directory(entry)) continue;
const auto utf8 = it->path().filename().u8string();
std::string name(utf8.begin(), utf8.end());
if (!mod_name(name)) continue;
auto title = manifest_name(it->path());
mods.push_back({name, title.empty() ? name : std::move(title), it->path()});
}
std::sort(mods.begin(), mods.end(), [](const Mod& a, const Mod& b) { return a.name < b.name; });
return mods;
}
void list(const std::vector<Mod>& mods, std::string_view folder, std::vector<Game::ContentFile>& files) {
for (const auto& mod : mods) {
const auto root = mod.directory / std::filesystem::path(std::string(folder));
std::error_code error;
if (!std::filesystem::is_directory(root, error)) continue;
std::vector<Game::ContentFile> found;
for (std::filesystem::recursive_directory_iterator it(root, std::filesystem::directory_options::none, error), end;
!error && it != end; it.increment(error)) {
if (it.depth() >= 4) it.disable_recursion_pending();
if (!it->is_regular_file(error)) continue;
const auto relative = it->path().lexically_relative(root).generic_wstring();
std::string path;
bool plain = true;
for (const auto c : relative) {
if (c < 0x20 || c > 0x7e) plain = false;
path.push_back(static_cast<char>(c));
}
if (!plain || path.ends_with(".tmp")) continue; // a name a plugin could not ask for again
found.push_back({mod.name, mod.title, std::move(path), static_cast<std::uint64_t>(it->file_size(error))});
if (files.size() + found.size() >= most_files) break;
}
std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.path < b.path; });
for (auto& file : found) files.push_back(std::move(file));
if (files.size() >= most_files) break;
}
}
std::string read(const std::vector<Mod>& mods, std::string_view mod, std::string_view folder, std::string_view path, std::string& text) {
const auto found = std::find_if(mods.begin(), mods.end(), [&](const Mod& each) { return same_name(each.name, mod); });
if (found == mods.end()) return "no such mod is on";
const auto root = found->directory / std::filesystem::path(std::string(folder));
const auto file = root / std::filesystem::path(std::string(path));
std::error_code error;
const auto real_root = std::filesystem::weakly_canonical(root, error);
const auto real_file = error ? std::filesystem::path{} : std::filesystem::weakly_canonical(file, error);
if (error) return "not found";
const auto relative = real_file.lexically_relative(real_root);
if (relative.empty() || *relative.begin() == "..") return "outside the mod's folder";
if (!std::filesystem::is_regular_file(file, error)) return "not found";
const auto size = std::filesystem::file_size(file, error);
if (error) return "the file cannot be read";
if (size > most_bytes) return "the file is larger than 1 MB";
std::ifstream in(file, std::ios::binary);
text.resize(static_cast<std::size_t>(size));
if (!in || !in.read(text.data(), static_cast<std::streamsize>(size))) return "the file cannot be read";
return {};
}
} // namespace dingosdk::plugins::content
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "plugin_game.h"
#include <cstddef>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
// mods.content and read_content, from the mods' folders on disk: the game reads its enabled mods
// (plugin_game_runtime.cpp), a dedicated server every mod folder in its Mods (server_plugins.cpp).
// Both keep the same limits, so a plugin reads the same files on either.
namespace dingosdk::plugins::content {
struct Mod {
std::string name, title; // the folder's name and the mod's own name
std::filesystem::path directory;
};
inline constexpr std::size_t most_files = 1000, most_bytes = 1u << 20;
// Every folder in `mods_root` a mod can be, by name: not hidden (".reskate_plugin_data"), and a
// name a plugin can ask for again. Titled by its manifest.json's "name", else its folder's name.
std::vector<Mod> mod_folders(const std::filesystem::path& mods_root);
// The files under <mod>/<folder>/ of each of `mods` in turn, sorted within a mod, four folders deep
// at most and most_files in all; `folder` is checked against the manifest already.
void list(const std::vector<Mod>& mods, std::string_view folder, std::vector<Game::ContentFile>& files);
// One of them, at most most_bytes; `path` is checked already (safe_relative_path). Empty when read,
// else why not.
std::string read(const std::vector<Mod>& mods, std::string_view mod, std::string_view folder, std::string_view path, std::string& text);
} // namespace dingosdk::plugins::content
+6 -53
View File
@@ -1,5 +1,6 @@
#include "plugin_game_runtime.h"
#include "plugin_session_feed.h"
#include "plugin_content.h"
#include "Runtime/runtime_internal.h"
#include "plugin_ui.h"
#include "Engine/Game/World/park_rotation.h"
@@ -1442,13 +1443,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 +1456,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 +1694,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)
+34
View File
@@ -211,6 +211,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 +255,22 @@ int run(const std::filesystem::path &fixtures) {
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)
)");
std::vector<std::string> log;
SteamTransport transport;
Host host(config, transport, [&](const std::string &line) { log.push_back(line); });
@@ -350,6 +370,20 @@ 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");
// 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; });
+21
View File
@@ -1,5 +1,6 @@
#include "server_plugins.h"
#include "server_host.h"
#include "Extension/Plugins/plugin_content.h"
#include "Extension/Plugins/plugin_host.h"
#include "Extension/Plugins/plugin_manifest.h"
#include "Extension/Plugins/plugin_wire.h"
@@ -101,6 +102,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));
@@ -116,6 +126,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 +142,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 {
+2 -1
View File
@@ -12,7 +12,8 @@
// 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, the server API (kick, say, tell, move), 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;
+1
View File
@@ -147,6 +147,7 @@ add_library(dingosdk_plugins STATIC
Extension/Plugins/plugin_api_services.cpp
Extension/Plugins/plugin_api_throwdowns.cpp
Extension/Plugins/plugin_api_capture.cpp
Extension/Plugins/plugin_content.cpp
# The throwdown kit's store and the plugins' modes, which the plugin host and the game's
# native UI share (kit::flag is the game's: Extension/Throwdowns/native_flag_session.cpp).
Extension/Throwdowns/throwdown_kit.cpp