Files
ReSkate/Extension/Plugins/plugin_host.cpp
T
AyeZeeBB d884c1c161 Plugins: the rest of the core set (overlay, menu panels, sounds, mods, session and messages)
- overlay: reskate.on("draw", function(d) ...) each tick, with text, rect, line, circle, poly
  and world_text, world_line, world_circle (projected through the camera as the frame draws),
  size and world_to_screen; 4000 shapes a frame. reskate.ui.toast through ReSkate's notices, three
  in 5 s. Images are left for later: they need texture slots in the overlay's renderer.
- menu: reskate.menu.panel(title, build) on a new PLUGINS tab of the ADDONS page, with heading,
  label, separator, button, toggle, slider and dropdown, built ten times a second while it shows.
- audio.ui: the game's menu sounds by name, ten a second. mods.read: installed(name) and list(),
  from the discovery thread's scan.
- session.read: active, is_dedicated, players, me, host, and player_joined / player_left.
  net.session: send(to, message) to the same plugin on other games (by its manifest.json name) in
  the session's throwdown packets, tagged 0xD1 (plugin_wire.h); 4 KB each, 16 KB a second.
- The overlay and the host share plugin_ui.h (shapes, panels, actions) and plugin_camera.h.
- Fixed: the overlay asked ImGui for the window's size before its context existed, which crashed
  on the first frames; it now reads the size from the context.
- reskate-test has overlay (ptest hud), menu, audio, mods and session (ptest ping) modules;
  reskate-hostile has draw-flood, toast-spam, sound-spam, message-flood and panel-loop.

Checked in game: ptest all 62 passed, 0 failed; the test HUD, the PLUGINS tab and its panel; every
hostile attack stopped or refused, trick-loop and panel-loop included. Not yet: ptest ping with a
second player.
2026-10-19 13:20:43 -05:00

334 lines
13 KiB
C++

#include "plugin_host.h"
#include "plugin_api.h"
#include "plugin_wire.h"
#include "Engine/Core/Console/console_core.h"
#include <algorithm>
#include <format>
#include <utility>
namespace dingosdk::plugins {
namespace {
std::string lowercase(std::string text) {
for (auto& c : text) if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + 32);
return text;
}
}
Host::Host(Environment& environment, Side side, Limits limits) : environment_(environment), side_(side), limits_(limits) {}
Host::~Host() { shutdown(); }
Host::Entry* Host::find(std::string_view id) {
const auto found = entries_.find(id);
return found == entries_.end() ? nullptr : &found->second;
}
void Host::load(Entry& entry) {
entry.failure.clear();
entry.plugin.reset();
const auto& id = entry.candidate.id;
if (!entry.candidate.refusal.empty()) {
entry.failure = entry.candidate.refusal;
environment_.log(LogLevel::warn, std::format("Plugin {} was not loaded: {}", id, entry.failure));
return;
}
Manifest manifest;
try {
std::string error;
const auto text = read_package_file(entry.candidate.directory / manifest_file, maximum_manifest_bytes, error);
if (!error.empty()) throw std::runtime_error(std::string(manifest_file) + " " + error);
manifest = parse_manifest(text);
} catch (const std::exception& error) {
entry.failure = error.what();
environment_.log(LogLevel::warn, std::format("Plugin {} was not loaded: {}", id, entry.failure));
return;
}
const bool here = manifest.side == Side::both || manifest.side == side_;
if (!here) {
entry.failure = std::format("it is a {} plugin", side_name(manifest.side));
environment_.log(LogLevel::info, std::format("Plugin {} was not loaded here: {}", id, entry.failure));
return;
}
std::string permissions;
for (const auto& permission : manifest.permissions) permissions += (permissions.empty() ? "" : ", ") + permission;
Services& services = *this;
entry.plugin = std::make_unique<Plugin>(id, entry.candidate.directory, std::move(manifest), services, limits_);
// Its messages go under the name its manifest.json gives it, the same on every player's game
// whatever its folder is called; the folder's name when it has none.
std::string channel = id;
try {
std::string error;
const auto text = read_package_file(entry.candidate.directory / "manifest.json", maximum_manifest_bytes, error);
if (error.empty()) {
const auto json = Json::parse(text, {maximum_manifest_bytes, 8, 4096});
if (json.is_object() && json.contains("name") && json.at("name").is_string() && !json.at("name").string().empty())
channel = json.at("name").string();
}
} catch (const std::exception&) {}
entry.plugin->set_channel(channel);
if (!entry.plugin->load()) {
entry.failure = entry.plugin->reason();
entry.plugin.reset();
release_commands(id); // its own log says why it stopped
return;
}
environment_.log(LogLevel::info, std::format("Plugin {} loaded ({}).", id, permissions.empty() ? "no permissions" : permissions));
}
void Host::unload(Entry& entry) {
if (entry.plugin) {
entry.plugin->unload();
entry.plugin.reset();
}
release_commands(entry.candidate.id);
}
void Host::collect(Entry& entry) {
if (!entry.plugin || entry.plugin->state() != State::stopped) return;
entry.failure = entry.plugin->reason();
entry.plugin->close_if_stopped();
entry.plugin.reset();
release_commands(entry.candidate.id);
}
void Host::release_commands(std::string_view id) {
std::erase_if(commands_, [&](const auto& item) { return item.second == id; });
}
void Host::apply(const std::vector<Candidate>& found) {
std::map<std::string, const Candidate*, std::less<>> wanted;
for (const auto& candidate : found) wanted.emplace(candidate.id, &candidate);
for (auto it = entries_.begin(); it != entries_.end();) {
if (wanted.contains(it->first)) {
++it;
continue;
}
if (it->second.plugin) environment_.log(LogLevel::info, std::format("Plugin {} unloaded: its mod was switched off or removed.", it->first));
unload(it->second);
it = entries_.erase(it);
}
for (const auto& [id, candidate] : wanted) {
auto* entry = find(id);
if (!entry) {
entry = &entries_.emplace(id, Entry{*candidate, {}, {}, false}).first->second;
load(*entry);
continue;
}
if (entry->candidate == *candidate) continue;
entry->candidate = *candidate;
if (entry->held) continue;
environment_.log(LogLevel::info, std::format("Plugin {} reloading: its files changed.", id));
unload(*entry);
load(*entry);
}
}
void Host::tick(double dt_ms) {
const double now = environment_.now_ms();
const bool save = now >= next_save_;
if (save) next_save_ = now + 2000;
follow_session();
deliver_messages();
for (auto& [id, entry] : entries_) {
if (!entry.plugin) continue;
entry.plugin->tick(dt_ms);
collect(entry);
if (save && entry.plugin) entry.plugin->save_storage();
}
draw_and_build();
}
void Host::follow_session() {
auto now = environment_.session();
if (!now.active) now.players.clear();
std::vector<SessionPlayer> joined, left;
const auto has = [](const std::vector<SessionPlayer>& list, std::uint64_t id) {
return std::any_of(list.begin(), list.end(), [&](const auto& p) { return p.id == id; });
};
for (const auto& player : now.players)
if (!has(session_.players, player.id)) joined.push_back(player);
for (const auto& player : session_.players)
if (!has(now.players, player.id)) left.push_back(player);
session_ = std::move(now);
if (joined.empty() && left.empty()) return;
for (auto& [id, entry] : entries_) {
if (!entry.plugin) continue;
entry.plugin->session_changed(joined, left);
collect(entry);
}
}
void Host::deliver_messages() {
std::uint64_t me = 0;
for (const auto& player : session_.players)
if (player.me) me = player.id;
for (const auto& received : environment_.take_messages()) {
const auto message = wire::decode(received.bytes);
if (!message || (message->to && message->to != me)) continue;
SessionPlayer from{received.from};
for (const auto& player : session_.players)
if (player.id == received.from) from = player;
for (auto& [id, entry] : entries_) {
if (!entry.plugin || entry.plugin->channel() != message->channel) continue;
entry.plugin->receive(from, message->payload);
collect(entry);
}
}
}
bool Host::send(std::uint32_t channel, std::uint64_t to, std::string_view payload) {
if (!session_.active) return false;
return environment_.send_message(wire::encode(channel, to, payload));
}
void Host::draw_and_build() {
std::vector<plugin_ui::Layer> layers;
for (auto& [id, entry] : entries_) {
if (!entry.plugin) continue;
entry.plugin->draw();
collect(entry);
if (entry.plugin) {
auto shapes = entry.plugin->take_shapes();
if (!shapes.empty()) layers.push_back({id, std::move(shapes)});
}
}
environment_.publish_layers(std::move(layers));
for (auto& action : environment_.take_actions())
if (actions_.size() < 256) actions_.push_back(std::move(action));
// Panels are built ten times a second, and only while the PLUGINS tab shows them.
const double now = environment_.now_ms();
if (!environment_.panels_shown() || now < next_panels_) return;
next_panels_ = now + 100;
std::vector<plugin_ui::Panel> panels;
for (auto& [id, entry] : entries_) {
if (!entry.plugin) continue;
std::vector<plugin_ui::Action> mine;
for (const auto& action : actions_)
if (action.plugin == id) mine.push_back(action);
auto built = entry.plugin->build_panels(mine);
collect(entry);
for (auto& panel : built) panels.push_back(std::move(panel));
}
actions_.clear();
environment_.publish_panels(std::move(panels));
}
void Host::set_level(std::string level) {
if (level == level_) return;
const auto previous = std::exchange(level_, std::move(level));
for (auto& [id, entry] : entries_) {
if (!entry.plugin) continue;
entry.plugin->level_changed(previous, level_);
collect(entry);
}
}
void Host::trick(const TrickNews& news) {
for (auto& [id, entry] : entries_) {
if (!entry.plugin || !entry.plugin->has("events.tricks")) continue;
entry.plugin->trick(news);
collect(entry);
}
}
bool Host::wants_tricks() const {
return std::any_of(entries_.begin(), entries_.end(),
[](const auto& item) { return item.second.plugin && item.second.plugin->has("events.tricks"); });
}
bool Host::run_console(std::string_view text) {
if (commands_.empty()) return false;
const auto parsed = parse_console_command(text);
if (!parsed || parsed.arguments.empty()) return false;
const auto& words = parsed.arguments;
// The longest command name the text starts with.
for (std::size_t count = std::min<std::size_t>(3, words.size()); count > 0; --count) {
std::string name;
for (std::size_t i = 0; i < count; ++i) name += (i ? " " : "") + lowercase(words[i]);
const auto found = commands_.find(name);
if (found == commands_.end()) continue;
auto* entry = find(found->second);
if (!entry || !entry->plugin) return false;
entry->plugin->run_command(name, {words.begin() + static_cast<std::ptrdiff_t>(count), words.end()});
collect(*entry);
return true;
}
return false;
}
std::vector<Status> Host::status() const {
std::vector<Status> result;
for (const auto& [id, entry] : entries_) {
Status status{id};
if (entry.plugin) {
status.state = "running";
status.memory = entry.plugin->memory();
status.tick_ms = entry.plugin->tick_ms();
status.commands = entry.plugin->commands();
} else if (entry.held) {
status.state = "switched off";
status.reason = "plugin on " + id + " starts it again";
} else {
status.state = entry.failure.starts_with("it is a ") ? "not loaded" : "stopped";
status.reason = entry.failure;
}
result.push_back(std::move(status));
}
return result;
}
std::string Host::reload(std::string_view id) {
auto* entry = find(id);
if (!entry) return std::format("error: There is no plugin {}.", id);
entry->held = false;
unload(*entry);
load(*entry);
return entry->plugin ? std::format("Plugin {} reloaded.", id) : std::format("error: Plugin {} did not start: {}", id, entry->failure);
}
std::string Host::switch_off(std::string_view id) {
auto* entry = find(id);
if (!entry) return std::format("error: There is no plugin {}.", id);
entry->held = true;
unload(*entry);
return std::format("Plugin {} switched off until plugin on {}, or the next launch.", id, id);
}
std::string Host::switch_on(std::string_view id) {
auto* entry = find(id);
if (!entry) return std::format("error: There is no plugin {}.", id);
if (entry->plugin) return std::format("Plugin {} is running.", id);
entry->held = false;
load(*entry);
return entry->plugin ? std::format("Plugin {} started.", id) : std::format("error: Plugin {} did not start: {}", id, entry->failure);
}
void Host::shutdown() {
for (auto& [id, entry] : entries_) unload(entry);
entries_.clear();
}
void Host::log(std::string_view plugin, LogLevel level, std::string_view text) {
environment_.log(level, std::format("[{}] {}", plugin, text));
}
void Host::console(std::string_view text) { environment_.console(text); }
double Host::now_ms() { return environment_.now_ms(); }
std::string Host::level() { return level_; }
std::filesystem::path Host::storage_file(std::string_view plugin) {
return environment_.storage_root() / std::filesystem::path(std::string(plugin)) / "storage.json";
}
std::string Host::claim_command(std::string_view plugin, std::string_view name) {
const auto first = name.substr(0, name.find(' '));
if (first == "plugin" || first == "help" || environment_.builtin_command(first))
return std::format("ReSkate has a console command \"{}\": pick another name", first);
for (const auto& [taken, owner] : commands_) {
if (owner == plugin) continue;
const bool overlaps = taken == name || name.starts_with(taken + " ") || taken.starts_with(std::string(name) + " ");
if (overlaps) return std::format("the plugin {} has the console command \"{}\"", owner, taken);
}
commands_.emplace(std::string(name), std::string(plugin));
return {};
}
}