Files
ReSkate/Extension/Plugins/plugin_api_more.cpp
T
AyeZeeBB 5c0993afea Plugins: the wider set (phase 3), behind one Game interface the tests fake
- Skater: skater.state() and the skater_state and bail events (events.skater); noclip and boosts
  through the console's own queue, under the host's rules (skater.control); a save point of the
  plugin's own (trainer); the game's HUD hidden while any plugin asks (hud).
- World: park editor objects listed, placed, moved and removed through the editor's own edits and
  the session's object rules (world.objects); time of day, world controls and park rotation as
  the World menu sets them, refused where the host chooses (world.environment); fast travel and
  levels, refused for guests (travel); AI skaters, outside sessions only (world.ai).
- Session: other players' skaters as last heard (session.skaters, a feed the session publishes
  each frame, plugin_session_feed.h); a plugin's tag above a player's nametag (session.nametags);
  voice volume (voice.control); chat lines as events (chat.read) and sending one, one every 3 s
  (chat.send); throwdown state and events (throwdowns).
- Music from outside players (music), progress (progression.read) and game settings
  (settings.read), read only; named actions on keys or pad combos, pressed while the game has the
  input (input); full-screen screens with the input, closed by Esc (ui.screen).
- Libraries and events between plugins: expose, require_plugin, emit, on_plugin; plain data is
  copied across VMs and a plugin in a call cannot be called back into.
- net.http: HTTPS to the hosts reskate-plugin.json's new "domains" lists; no redirects, cookies
  or Windows sign-in; private and local addresses refused; 10 a minute, 4 waiting, 1 MB back.
- Left for engine work (listed in the design doc): respawn and freeze, manuals, the camera, trick
  animations, replays, starting throwdowns, the game's own soundtrack, overlay images, rebinding
  plugin keys in the Binds page, local-only chat lines.
- reskate-test has a wider module (ptest noclip, boost, hidehud, object, night, day, ai, screen,
  bind, music, chat, web by hand); reskate-hostile has chat-spam, edit-spam, travel-spam,
  web-elsewhere, bind-spam and screen-loop.

All host tests pass; not yet tried in game.
2026-10-19 13:49:29 -05:00

210 lines
11 KiB
C++

#include "plugin_api.h"
#include "lua.h"
#include "lualib.h"
#include <algorithm>
#include <cmath>
// Phase 3's parts of the reskate API (docs: ReSkate Plugin System Design, "the wider set"). As in
// plugin_api.cpp, every value is checked before it leaves the VM.
namespace dingosdk::plugins {
namespace {
Plugin& plugin_of(lua_State* L) {
auto& plugin = *static_cast<Plugin*>(lua_callbacks(L)->userdata);
if (plugin.halted()) luaL_error(L, "the plugin was stopped");
return plugin;
}
void set_function(lua_State* L, const char* name, lua_CFunction function) {
lua_pushcfunction(L, function, name);
lua_setfield(L, -2, name);
}
std::string checked_text(lua_State* L, int index, std::size_t most, const char* what) {
std::size_t length = 0;
const char* text = luaL_checklstring(L, index, &length);
if (length > most) luaL_error(L, "%s is longer than %d characters", what, static_cast<int>(most));
return std::string(text, length);
}
// A name another plugin calls or listens for: letters, digits and _ . -, 1 to 48 of them.
std::string identifier(lua_State* L, int index, const char* what) {
auto name = checked_text(L, index, 48, what);
const bool valid = !name.empty() && 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 == '-';
});
if (!valid) luaL_error(L, "%s must be 1 to 48 letters, digits, _ . or -", what);
return name;
}
}
struct MoreApi {
// ---- libraries: functions one plugin offers others --------------------------------------
// expose({name = function, ...}): what other plugins get from require_plugin. Replaces the last.
static int expose(lua_State* L) {
auto& plugin = plugin_of(L);
luaL_checktype(L, 1, LUA_TTABLE);
std::map<std::string, int, std::less<>> next;
lua_pushnil(L);
while (lua_next(L, 1)) {
if (lua_type(L, -2) != LUA_TSTRING || lua_type(L, -1) != LUA_TFUNCTION) {
for (const auto& item : next) lua_unref(L, item.second);
luaL_error(L, "expose takes a table of names and functions");
}
const std::string name = lua_tostring(L, -2);
if (next.size() >= plugin.limits_.exposed || name.size() > 48) {
for (const auto& item : next) lua_unref(L, item.second);
luaL_error(L, "at most %d functions, each name 48 characters", static_cast<int>(plugin.limits_.exposed));
}
lua_pushvalue(L, -1);
next[name] = lua_ref(L, -1);
lua_pop(L, 2);
}
for (const auto& item : plugin.exposed_) lua_unref(L, item.second);
plugin.exposed_ = std::move(next);
return 0;
}
// A proxy's call: its upvalues are the plugin and the function's name. Arguments and the result
// are copied as JSON: tables, strings, numbers and booleans cross; functions never do.
static int proxy(lua_State* L) {
auto& plugin = plugin_of(L);
const std::string target = lua_tostring(L, lua_upvalueindex(1));
const std::string function = lua_tostring(L, lua_upvalueindex(2));
auto arguments = Json::array();
const int count = lua_gettop(L);
for (int i = 1; i <= count; ++i) arguments.push_back(lua_to_json(L, i));
Json result;
const auto error = plugin.services_.call_plugin(plugin.id(), target, function, arguments, result);
if (!error.empty()) luaL_error(L, "%s.%s: %s", target.c_str(), function.c_str(), error.c_str());
push_json(L, result);
return 1;
}
// require_plugin(name): a table of the functions that plugin exposes, or nil and why.
static int require_plugin(lua_State* L) {
auto& plugin = plugin_of(L);
const auto target = identifier(L, 1, "a plugin's name");
std::vector<std::string> names;
if (const auto error = plugin.services_.exposed(target, names); !error.empty()) {
lua_pushnil(L);
lua_pushlstring(L, error.data(), error.size());
return 2;
}
lua_createtable(L, 0, static_cast<int>(names.size()));
for (const auto& name : names) {
lua_pushlstring(L, target.data(), target.size());
lua_pushlstring(L, name.data(), name.size());
lua_pushcclosurek(L, proxy, "proxy", 2, nullptr);
lua_setfield(L, -2, name.c_str());
}
lua_setreadonly(L, -1, true);
return 1;
}
// ---- events between plugins ----------------------------------------------------------------
// emit(event, data?): to every other plugin with an on_plugin handler for it, at the next tick.
static int emit(lua_State* L) {
auto& plugin = plugin_of(L);
const auto event = identifier(L, 1, "an event's name");
Json data = lua_isnoneornil(L, 2) ? Json{} : lua_to_json(L, 2);
if (data.dump().size() > plugin.limits_.event_bytes)
luaL_error(L, "an event's data is at most %d KB as JSON", static_cast<int>(plugin.limits_.event_bytes >> 10));
const double now = plugin.services_.now_ms();
if (now != plugin.emit_tick_) {
plugin.emit_tick_ = now;
plugin.emitted_ = 0;
}
if (++plugin.emitted_ > plugin.limits_.events_per_tick)
luaL_error(L, "more than %d events in one tick", static_cast<int>(plugin.limits_.events_per_tick));
lua_pushboolean(L, plugin.services_.emit(plugin.id(), event, std::move(data)));
return 1;
}
// on_plugin(source, event, fn(source, data)): source is a plugin's name, or "*" for any.
static int on_plugin(lua_State* L) {
auto& plugin = plugin_of(L);
const auto source = lua_isstring(L, 1) && std::string_view(lua_tostring(L, 1)) == "*" ? std::string("*")
: identifier(L, 1, "a plugin's name");
const auto event = identifier(L, 2, "an event's name");
luaL_checktype(L, 3, LUA_TFUNCTION);
if (plugin.listeners_.size() >= plugin.limits_.listeners)
luaL_error(L, "more than %d on_plugin handlers", static_cast<int>(plugin.limits_.listeners));
lua_pushvalue(L, 3);
plugin.listeners_.push_back({source, event, lua_ref(L, -1)});
lua_pop(L, 1);
return 0;
}
// ---- net.http ------------------------------------------------------------------------------
// request({url = "https://host/path", method = "GET" | "POST", body = "...", content_type = "..."},
// function(status, body, error)): to a host its manifest's "domains" lists, HTTPS only.
static int http_request(lua_State* L) {
auto& plugin = plugin_of(L);
if (!plugin.has("net.http")) luaL_error(L, "reskate.http.request needs the \"net.http\" permission in reskate-plugin.json");
luaL_checktype(L, 1, LUA_TTABLE);
luaL_checktype(L, 2, LUA_TFUNCTION);
lua_getfield(L, 1, "url");
const auto url = checked_text(L, -1, plugin.limits_.http_url_bytes, "a web request's url");
lua_pop(L, 1);
lua_getfield(L, 1, "method");
const std::string method = lua_isnil(L, -1) ? "GET" : luaL_checkstring(L, -1);
lua_pop(L, 1);
if (method != "GET" && method != "POST") luaL_error(L, "a web request's method is \"GET\" or \"POST\"");
lua_getfield(L, 1, "body");
const std::string body = lua_isnil(L, -1) ? std::string{} : checked_text(L, -1, plugin.limits_.http_body_bytes, "a web request's body");
lua_pop(L, 1);
lua_getfield(L, 1, "content_type");
const std::string type = lua_isnil(L, -1) ? std::string{} : checked_text(L, -1, 100, "a content type");
lua_pop(L, 1);
for (const char c : type)
if (c < 0x20 || c > 0x7e) luaL_error(L, "a content type is printable text");
// https://<host>[:443]<path>: nothing else, the host one the manifest lists.
constexpr std::string_view scheme = "https://";
if (!url.starts_with(scheme)) luaL_error(L, "a web request is HTTPS: its url starts with https://");
const auto rest = std::string_view(url).substr(scheme.size());
const auto slash = rest.find('/');
std::string host(rest.substr(0, slash));
std::string path = slash == std::string_view::npos ? std::string("/") : std::string(rest.substr(slash));
if (host.ends_with(":443")) host.resize(host.size() - 4);
for (auto& c : host) if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + 32);
for (const char c : url)
if (c <= 0x20 || c > 0x7e) luaL_error(L, "a url is printable text with no spaces");
if (host.find_first_of("@:[]") != std::string::npos) luaL_error(L, "a url's host is a name from \"domains\", with no user, password or other port");
const auto& domains = plugin.manifest().domains;
if (std::find(domains.begin(), domains.end(), host) == domains.end())
luaL_error(L, "%s is not in reskate-plugin.json's \"domains\"", host.c_str());
const double now = plugin.services_.now_ms();
std::erase_if(plugin.http_sent_, [&](double at) { return now - at >= 60000; });
if (static_cast<int>(plugin.http_sent_.size()) >= plugin.limits_.http_per_minute)
luaL_error(L, "more than %d web requests in a minute", plugin.limits_.http_per_minute);
if (static_cast<int>(plugin.http_pending_.size()) >= plugin.limits_.http_in_flight)
luaL_error(L, "more than %d web requests waiting for an answer", plugin.limits_.http_in_flight);
const auto id = plugin.next_http_++;
if (!plugin.services_.http({id, plugin.id(), method, host, path, body, type})) {
lua_pushnil(L);
lua_pushstring(L, "web requests cannot be made now");
return 2;
}
plugin.http_sent_.push_back(now);
lua_pushvalue(L, 2);
plugin.http_pending_[id] = lua_ref(L, -1);
lua_pop(L, 1);
lua_pushinteger(L, static_cast<int>(id));
return 1;
}
static void install(Plugin& plugin, lua_State* L);
};
void install_more_api(Plugin& plugin, lua_State* L) { MoreApi::install(plugin, L); }
void MoreApi::install(Plugin& plugin, lua_State* L) {
(void)plugin;
// The reskate table is on top of the stack.
set_function(L, "expose", MoreApi::expose);
set_function(L, "require_plugin", MoreApi::require_plugin);
set_function(L, "emit", MoreApi::emit);
set_function(L, "on_plugin", MoreApi::on_plugin);
lua_createtable(L, 0, 1);
lua_pushcfunction(L, MoreApi::http_request, "request");
lua_setfield(L, -2, "request");
lua_setreadonly(L, -1, true);
lua_setfield(L, -2, "http");
}
}