mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-23 23:26:06 +02:00
- 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.
334 lines
13 KiB
C++
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 {};
|
|
}
|
|
}
|