mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-24 00:35:59 +02:00
The SDK knew grind exits: reskate.skater.grind_exit took a toe/heel/left/right wish, and C++ worked
out the rail's direction, toe and heel from the stance and facing, the 1.8 m/s and the strength.
That logic is now the grind-exits mod's own Lua (plugin/exits.lua, kept on this machine). What has
to run on the physics thread is general:
- Extension/Skater/launches: every launch of the local skater (ollie, grind, no_comply, boneless),
classified by its call site and the state before it, with the board's direction of travel; the
`launch` event for plugins with events.skater; reskate.skater.shape_launch(from, {across, along,
up}) sets those speeds against the travel, one plugin a kind.
- reskate.skater.get() gains `right` (the root's right axis) and `switch` (the game's stance flag).
- Extension/Skater/pad_hide: reskate.input.hide's pad filter, out of the old grind exit file.
Removed: reskate.skater.grind_exit, the grind_exit event, GrindExitWish and GrindPop.
Physics state 701 is a grind too (the game checks it with 400 in about a dozen places; the
2026-10-11 measurement only reached the 400s): pops out of it were dropped, and skater_state called
it "other". Launches, skater_state ("grinding") and the trainer's grind pop all count it now.
Tests: the grind-exits plugin test drives the new path (11 checks of its own).
745 lines
34 KiB
C++
745 lines
34 KiB
C++
#include "plugin_api.h"
|
|
#include "lua.h"
|
|
#include "lualib.h"
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cmath>
|
|
#include <format>
|
|
#include <optional>
|
|
|
|
// The reskate API, v1 base set plus console and storage (docs: ReSkate Plugin System Design).
|
|
// Every function checks what it is given before acting on it, and no value a plugin passes
|
|
// reaches anything outside its own VM unchecked. Errors are raised the way Lua reports them:
|
|
// luaL_error, or a C++ exception, which Luau turns into a Lua error with its message.
|
|
namespace dingosdk::plugins {
|
|
namespace {
|
|
// Events and the permission each needs (none for the base set).
|
|
struct Event {
|
|
std::string_view name, permission;
|
|
};
|
|
constexpr std::array<Event, 28> 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"},
|
|
{"skater_state", "events.skater"}, {"bail", "events.skater"}, {"chat", "chat.read"}, {"track_changed", "music"},
|
|
{"throwdown", "throwdowns"}, {"action", "input"}, {"screen_closed", "ui.screen"},
|
|
{"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"},
|
|
}};
|
|
constexpr std::size_t maximum_key = 128;
|
|
constexpr std::size_t maximum_command = 48;
|
|
constexpr std::size_t maximum_listing = 1000;
|
|
|
|
// The table being filled is on top of the stack.
|
|
void set_functions(lua_State* L, std::initializer_list<ApiFunction> functions) {
|
|
for (const auto& [name, function] : functions) {
|
|
lua_pushcfunction(L, function, name);
|
|
lua_setfield(L, -2, name);
|
|
}
|
|
}
|
|
|
|
std::string text_of(lua_State* L, int index) {
|
|
std::size_t length = 0;
|
|
const char* text = luaL_tolstring(L, index, &length);
|
|
std::string result(text, length);
|
|
lua_pop(L, 1);
|
|
return result;
|
|
}
|
|
|
|
// What turning Lua into JSON may cost outside the VM's own heap.
|
|
struct Budget {
|
|
std::size_t values = 1u << 20;
|
|
std::size_t bytes = 16u << 20;
|
|
void spend(std::size_t count, std::size_t size) {
|
|
if (count > values || size > bytes) throw std::runtime_error("the value is too large to turn into JSON");
|
|
values -= count;
|
|
bytes -= size;
|
|
}
|
|
};
|
|
|
|
Json to_json(lua_State* L, int index, int depth, Budget& budget) {
|
|
if (depth > 32) throw std::runtime_error("tables nested deeper than 32 cannot be turned into JSON");
|
|
index = lua_absindex(L, index);
|
|
budget.spend(1, 0);
|
|
switch (lua_type(L, index)) {
|
|
case LUA_TNIL: return nullptr;
|
|
case LUA_TBOOLEAN: return lua_toboolean(L, index) != 0;
|
|
case LUA_TNUMBER: {
|
|
const double value = lua_tonumber(L, index);
|
|
if (!std::isfinite(value)) throw std::runtime_error("JSON has no NaN or infinity");
|
|
if (value == std::floor(value) && std::fabs(value) <= 9007199254740992.0) return static_cast<std::int64_t>(value);
|
|
return value;
|
|
}
|
|
case LUA_TSTRING: {
|
|
std::size_t length = 0;
|
|
const char* text = lua_tolstring(L, index, &length);
|
|
budget.spend(0, length);
|
|
return std::string(text, length);
|
|
}
|
|
case LUA_TVECTOR: { // a position: [x, y, z]
|
|
const float* v = lua_tovector(L, index);
|
|
if (!std::isfinite(v[0]) || !std::isfinite(v[1]) || !std::isfinite(v[2])) throw std::runtime_error("JSON has no NaN or infinity");
|
|
budget.spend(3, 0);
|
|
return Json::array({static_cast<double>(v[0]), static_cast<double>(v[1]), static_cast<double>(v[2])});
|
|
}
|
|
case LUA_TTABLE: {
|
|
luaL_checkstack(L, 4, "tables nested too deep");
|
|
const int length = lua_objlen(L, index);
|
|
bool list = length > 0;
|
|
int count = 0;
|
|
lua_pushnil(L);
|
|
while (lua_next(L, index)) {
|
|
++count;
|
|
if (lua_type(L, -2) != LUA_TNUMBER) list = false;
|
|
else {
|
|
const double key = lua_tonumber(L, -2);
|
|
if (key != std::floor(key) || key < 1 || key > length) list = false;
|
|
}
|
|
lua_pop(L, 1);
|
|
}
|
|
if (list && count == length) {
|
|
auto result = Json::array();
|
|
for (int i = 1; i <= length; ++i) {
|
|
lua_rawgeti(L, index, i);
|
|
result.push_back(to_json(L, -1, depth + 1, budget));
|
|
lua_pop(L, 1);
|
|
}
|
|
return result;
|
|
}
|
|
auto result = Json::object();
|
|
lua_pushnil(L);
|
|
while (lua_next(L, index)) {
|
|
if (lua_type(L, -2) != LUA_TSTRING)
|
|
throw std::runtime_error("only a list (keys 1 to n) or a table with string keys can be turned into JSON");
|
|
std::size_t size = 0;
|
|
const char* key = lua_tolstring(L, -2, &size);
|
|
budget.spend(0, size);
|
|
result[std::string_view(key, size)] = to_json(L, -1, depth + 1, budget);
|
|
lua_pop(L, 1);
|
|
}
|
|
return result;
|
|
}
|
|
default:
|
|
throw std::runtime_error(std::string("a ") + lua_typename(L, lua_type(L, index)) + " cannot be turned into JSON");
|
|
}
|
|
}
|
|
|
|
void push_json_value(lua_State* L, const Json& value, int depth) {
|
|
luaL_checkstack(L, 4, "JSON nested too deep");
|
|
if (depth > 64) throw std::runtime_error("JSON nested too deep");
|
|
if (value.is_null()) lua_pushnil(L);
|
|
else if (value.is_boolean()) lua_pushboolean(L, value.get<bool>());
|
|
else if (value.is_number()) lua_pushnumber(L, value.get<double>());
|
|
else if (value.is_string()) lua_pushlstring(L, value.string().data(), value.string().size());
|
|
else if (value.is_array()) {
|
|
lua_createtable(L, static_cast<int>(value.size()), 0);
|
|
int index = 0;
|
|
for (const auto& item : value) {
|
|
push_json_value(L, item, depth + 1);
|
|
lua_rawseti(L, -2, ++index);
|
|
}
|
|
} else {
|
|
lua_createtable(L, 0, static_cast<int>(value.size()));
|
|
for (const auto& [key, item] : value.items()) {
|
|
lua_pushlstring(L, key.data(), key.size());
|
|
push_json_value(L, item, depth + 1);
|
|
lua_rawset(L, -3);
|
|
}
|
|
}
|
|
}
|
|
|
|
std::string key_of(lua_State* L, int index) {
|
|
std::size_t length = 0;
|
|
const char* key = luaL_checklstring(L, index, &length);
|
|
if (length == 0 || length > maximum_key) luaL_error(L, "a storage key must be 1 to %d characters", static_cast<int>(maximum_key));
|
|
return std::string(key, length);
|
|
}
|
|
|
|
// A file or folder of the plugin's own package, never outside it: the path is checked as text,
|
|
// then once more after links and junctions are resolved.
|
|
std::filesystem::path package_path(lua_State* L, Plugin& plugin, std::string_view path, bool folder) {
|
|
std::string relative = folder && path.empty() ? std::string{} : safe_relative_path(path);
|
|
if (relative.empty() && !(folder && path.empty()))
|
|
luaL_error(L, "\"%s\" is not a path inside the plugin's package (use / between folders, no ..)", std::string(path).c_str());
|
|
const auto root = plugin.directory();
|
|
const auto full = relative.empty() ? root : root / std::filesystem::path(relative);
|
|
std::error_code code;
|
|
const auto real_root = std::filesystem::weakly_canonical(root, code);
|
|
const auto real = std::filesystem::weakly_canonical(full, code);
|
|
const auto inside = real.lexically_relative(real_root);
|
|
if (code || (!relative.empty() && (inside.empty() || *inside.begin() == ".."))) luaL_error(L, "that path leads outside the package");
|
|
return full;
|
|
}
|
|
}
|
|
|
|
void add_functions(lua_State* L, const char* field, std::initializer_list<ApiFunction> functions) {
|
|
lua_getfield(L, -1, field);
|
|
if (lua_isnil(L, -1)) {
|
|
lua_pop(L, 1);
|
|
lua_createtable(L, 0, static_cast<int>(functions.size()));
|
|
lua_pushvalue(L, -1);
|
|
// The parent may be a read-only table itself (reskate.throwdowns, for its flag).
|
|
const bool locked = lua_getreadonly(L, -3) != 0;
|
|
lua_setreadonly(L, -3, false);
|
|
lua_setfield(L, -3, field);
|
|
lua_setreadonly(L, -2, locked);
|
|
}
|
|
// A table made read-only earlier in the install is opened for this, and closed again.
|
|
lua_setreadonly(L, -1, false);
|
|
set_functions(L, functions);
|
|
lua_setreadonly(L, -1, true);
|
|
lua_pop(L, 1);
|
|
}
|
|
void add_functions(lua_State* L, std::initializer_list<ApiFunction> functions) { set_functions(L, functions); }
|
|
void push_functions(lua_State* L, std::initializer_list<ApiFunction> functions) {
|
|
lua_createtable(L, 0, static_cast<int>(functions.size()));
|
|
set_functions(L, functions);
|
|
lua_setreadonly(L, -1, true);
|
|
}
|
|
|
|
// A plugin that caught its own memory or time error in a pcall gets no further: nothing it asks
|
|
// for is done, and the next safe point stops it.
|
|
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 need(lua_State* L, Plugin& plugin, const char* permission, const char* what) {
|
|
if (!plugin.has(permission))
|
|
luaL_error(L, "%s needs the \"%s\" permission in reskate-plugin.json", what, permission);
|
|
}
|
|
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);
|
|
}
|
|
Vec3 position_of(lua_State* L, int index, const char* what) {
|
|
const float* v = luaL_checkvector(L, index);
|
|
for (int i = 0; i < 3; ++i)
|
|
if (!std::isfinite(v[i]) || std::fabs(v[i]) > 1e5f) luaL_error(L, "%s must be a finite position within 100 km", what);
|
|
return {v[0], v[1], v[2]};
|
|
}
|
|
void push_position(lua_State* L, const Vec3& v) { lua_pushvector(L, v[0], v[1], v[2]); }
|
|
|
|
std::filesystem::path package_file(lua_State* L, Plugin& plugin, std::string_view path) { return package_path(L, plugin, path, false); }
|
|
|
|
struct ApiFunctions {
|
|
static int log(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
const auto message = text_of(L, 1);
|
|
const std::string level = luaL_optstring(L, 2, "info");
|
|
if (level != "info" && level != "warn" && level != "error") luaL_argerror(L, 2, "the level must be \"info\", \"warn\" or \"error\"");
|
|
plugin.log(level == "error" ? LogLevel::error : level == "warn" ? LogLevel::warn : LogLevel::info, message);
|
|
return 0;
|
|
}
|
|
static int print(lua_State* L) {
|
|
std::string line;
|
|
const int count = lua_gettop(L);
|
|
for (int i = 1; i <= count; ++i) {
|
|
if (i > 1) line += '\t';
|
|
line += text_of(L, i);
|
|
}
|
|
plugin_of(L).log(LogLevel::info, line);
|
|
return 0;
|
|
}
|
|
static int on(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
const std::string event = luaL_checkstring(L, 1);
|
|
luaL_checktype(L, 2, LUA_TFUNCTION);
|
|
const auto known = std::find_if(events.begin(), events.end(), [&](const Event& e) { return e.name == event; });
|
|
if (known == events.end()) luaL_error(L, "there is no event \"%s\" in this ReSkate", event.c_str());
|
|
if (!known->permission.empty() && !plugin.has(known->permission))
|
|
luaL_error(L, "the %s event needs the \"%s\" permission in reskate-plugin.json", event.c_str(), std::string(known->permission).c_str());
|
|
auto& list = plugin.handlers_[event];
|
|
if (list.size() >= plugin.limits_.handlers) luaL_error(L, "more than %d handlers for one event", static_cast<int>(plugin.limits_.handlers));
|
|
lua_pushvalue(L, 2);
|
|
list.push_back(lua_ref(L, -1));
|
|
lua_pop(L, 1);
|
|
return 0;
|
|
}
|
|
static int now(lua_State* L) {
|
|
lua_pushnumber(L, plugin_of(L).services_.now_ms());
|
|
return 1;
|
|
}
|
|
static int timer(lua_State* L, bool repeat) {
|
|
auto& plugin = plugin_of(L);
|
|
const double ms = luaL_checknumber(L, 1);
|
|
luaL_checktype(L, 2, LUA_TFUNCTION);
|
|
if (!std::isfinite(ms) || ms < (repeat ? 1 : 0)) luaL_argerror(L, 1, repeat ? "must be 1 ms or more" : "must be 0 ms or more");
|
|
if (plugin.timers_.size() >= plugin.limits_.timers) luaL_error(L, "more than %d timers", static_cast<int>(plugin.limits_.timers));
|
|
lua_pushvalue(L, 2);
|
|
const int function = lua_ref(L, -1);
|
|
lua_pop(L, 1);
|
|
const int id = plugin.next_timer_++;
|
|
plugin.timers_[id] = {plugin.services_.now_ms() + ms, repeat ? ms : 0, function};
|
|
lua_pushinteger(L, id);
|
|
return 1;
|
|
}
|
|
static int after(lua_State* L) { return timer(L, false); }
|
|
static int every(lua_State* L) { return timer(L, true); }
|
|
static int cancel(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
const auto found = plugin.timers_.find(luaL_checkinteger(L, 1));
|
|
if (found != plugin.timers_.end()) {
|
|
lua_unref(L, found->second.function);
|
|
plugin.timers_.erase(found);
|
|
}
|
|
return 0;
|
|
}
|
|
static int level(lua_State* L) {
|
|
const auto name = plugin_of(L).services_.level();
|
|
if (name.empty()) {
|
|
lua_pushnil(L);
|
|
return 1;
|
|
}
|
|
lua_createtable(L, 0, 1);
|
|
lua_pushlstring(L, name.data(), name.size());
|
|
lua_setfield(L, -2, "name");
|
|
return 1;
|
|
}
|
|
static int read(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
const auto file = package_path(L, plugin, luaL_checkstring(L, 1), false);
|
|
std::string error;
|
|
const auto text = read_package_file(file, plugin.limits_.file_bytes, error);
|
|
if (error == "not found") {
|
|
lua_pushnil(L);
|
|
return 1;
|
|
}
|
|
if (!error.empty()) luaL_error(L, "%s %s", luaL_checkstring(L, 1), error.c_str());
|
|
lua_pushlstring(L, text.data(), text.size());
|
|
return 1;
|
|
}
|
|
static int read_json(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
const auto file = package_path(L, plugin, luaL_checkstring(L, 1), false);
|
|
std::string error;
|
|
const auto text = read_package_file(file, plugin.limits_.file_bytes, error);
|
|
if (error == "not found") {
|
|
lua_pushnil(L);
|
|
return 1;
|
|
}
|
|
if (!error.empty()) luaL_error(L, "%s %s", luaL_checkstring(L, 1), error.c_str());
|
|
push_json(L, Json::parse(text, {plugin.limits_.file_bytes, 64, 1u << 20}));
|
|
return 1;
|
|
}
|
|
// variants(): the variants the plugin's own mod holds (its reskate-plugin.json "variants",
|
|
// Engine/Vfs/mod_list.h): {list = {...}, chosen = name or nil, in_play = name or nil}, its "order"
|
|
// first; nil when it declares none.
|
|
static int variants(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
const auto state = plugin.services_.game().variants(plugin.id(), plugin.directory());
|
|
if (!state) {
|
|
lua_pushnil(L);
|
|
return 1;
|
|
}
|
|
lua_createtable(L, 0, 3);
|
|
lua_createtable(L, static_cast<int>(state->installed.size()), 0);
|
|
for (std::size_t i = 0; i < state->installed.size(); ++i) {
|
|
lua_pushlstring(L, state->installed[i].data(), state->installed[i].size());
|
|
lua_rawseti(L, -2, static_cast<int>(i + 1));
|
|
}
|
|
lua_setfield(L, -2, "list");
|
|
for (const auto& [field, value] : {std::pair{"chosen", &state->chosen}, std::pair{"in_play", &state->in_play}}) {
|
|
if (value->empty()) continue;
|
|
lua_pushlstring(L, value->data(), value->size());
|
|
lua_setfield(L, -2, field);
|
|
}
|
|
return 1;
|
|
}
|
|
// choose(name): that variant of the plugin's own mod in the game: kept as the choice, the mods
|
|
// applied again and the level loaded again with it. true, or false and why not.
|
|
static int choose(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
need(L, plugin, "variants", "reskate.package.choose");
|
|
const std::string name = luaL_checkstring(L, 1);
|
|
const auto why = plugin.services_.game().choose_variant(plugin.id(), plugin.directory(), name);
|
|
lua_pushboolean(L, why.empty());
|
|
if (why.empty()) return 1;
|
|
lua_pushlstring(L, why.data(), why.size());
|
|
return 2;
|
|
}
|
|
static int list(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
const auto folder = package_path(L, plugin, luaL_optstring(L, 1, ""), true);
|
|
std::vector<std::string> names;
|
|
std::error_code code;
|
|
for (std::filesystem::directory_iterator it(folder, code), end; !code && it != end; it.increment(code)) {
|
|
const auto name = it->path().filename().wstring();
|
|
if (!std::all_of(name.begin(), name.end(), [](wchar_t c) { return c >= 0x20 && c < 0x7f; })) continue;
|
|
std::string ascii;
|
|
for (const wchar_t c : name) ascii += static_cast<char>(c);
|
|
if (it->is_directory(code)) ascii += '/';
|
|
names.push_back(std::move(ascii));
|
|
if (names.size() > maximum_listing) luaL_error(L, "the folder holds more than %d entries", static_cast<int>(maximum_listing));
|
|
}
|
|
std::sort(names.begin(), names.end());
|
|
lua_createtable(L, static_cast<int>(names.size()), 0);
|
|
for (std::size_t i = 0; i < names.size(); ++i) {
|
|
lua_pushlstring(L, names[i].data(), names[i].size());
|
|
lua_rawseti(L, -2, static_cast<int>(i + 1));
|
|
}
|
|
return 1;
|
|
}
|
|
static int encode(lua_State* L) {
|
|
luaL_checkany(L, 1);
|
|
const auto text = lua_to_json(L, 1).dump(lua_toboolean(L, 2) ? 2 : -1);
|
|
lua_pushlstring(L, text.data(), text.size());
|
|
return 1;
|
|
}
|
|
static int decode(lua_State* L) {
|
|
std::size_t length = 0;
|
|
const char* text = luaL_checklstring(L, 1, &length);
|
|
push_json(L, Json::parse(std::string_view(text, length), {16u << 20, 64, 1u << 20}));
|
|
return 1;
|
|
}
|
|
static int command(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
need(L, plugin, "console", "reskate.console.command");
|
|
std::size_t length = 0;
|
|
const char* text = luaL_checklstring(L, 1, &length);
|
|
const std::string help = luaL_checkstring(L, 2);
|
|
luaL_checktype(L, 3, LUA_TFUNCTION);
|
|
const std::string name(text, length);
|
|
// Lowercase words of letters, digits, _ and -, one space between them, at most three.
|
|
bool valid = !name.empty() && name.size() <= maximum_command && name.front() != ' ' && name.back() != ' ' &&
|
|
name.find(" ") == std::string::npos && std::count(name.begin(), name.end(), ' ') < 3;
|
|
for (const char c : name) valid = valid && ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == ' ');
|
|
if (!valid) luaL_error(L, "\"%s\" is not a command name: 1 to 3 lowercase words of a-z, 0-9, _ and -", name.c_str());
|
|
if (plugin.commands_.size() >= plugin.limits_.commands) luaL_error(L, "more than %d commands", static_cast<int>(plugin.limits_.commands));
|
|
if (plugin.commands_.contains(name)) luaL_error(L, "the command \"%s\" is registered already", name.c_str());
|
|
if (const auto refusal = plugin.services_.claim_command(plugin.id(), name); !refusal.empty())
|
|
luaL_error(L, "%s", refusal.c_str());
|
|
lua_pushvalue(L, 3);
|
|
plugin.commands_[name] = {help.substr(0, 200), lua_ref(L, -1)};
|
|
lua_pop(L, 1);
|
|
return 0;
|
|
}
|
|
static int console_print(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
need(L, plugin, "console", "reskate.console.print");
|
|
plugin.print(text_of(L, 1));
|
|
return 0;
|
|
}
|
|
static int storage_get(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
need(L, plugin, "storage", "reskate.storage");
|
|
const auto key = key_of(L, 1);
|
|
const auto found = plugin.storage_.find(key);
|
|
if (found == plugin.storage_.end()) {
|
|
lua_pushnil(L);
|
|
return 1;
|
|
}
|
|
push_json(L, *found);
|
|
return 1;
|
|
}
|
|
// Sets a key and checks the whole store against the quota; the value it had comes back on failure.
|
|
static void store(lua_State* L, Plugin& plugin, const std::string& key, Json value) {
|
|
std::optional<Json> previous;
|
|
if (const auto found = plugin.storage_.find(key); found != plugin.storage_.end()) previous = *found;
|
|
if (value.is_null()) plugin.storage_.erase(key);
|
|
else plugin.storage_[key] = std::move(value);
|
|
if (const auto size = plugin.storage_.dump().size(); size > plugin.limits_.storage_bytes) {
|
|
if (previous) plugin.storage_[key] = std::move(*previous);
|
|
else plugin.storage_.erase(key);
|
|
luaL_error(L, "storage is full: that would make it %d KB, and a plugin has %d KB", static_cast<int>(size >> 10),
|
|
static_cast<int>(plugin.limits_.storage_bytes >> 10));
|
|
}
|
|
plugin.storage_dirty_ = true;
|
|
}
|
|
static int storage_set(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
need(L, plugin, "storage", "reskate.storage");
|
|
const auto key = key_of(L, 1);
|
|
store(L, plugin, key, lua_to_json(L, 2));
|
|
return 0;
|
|
}
|
|
static int storage_add(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
need(L, plugin, "storage", "reskate.storage");
|
|
const auto key = key_of(L, 1);
|
|
const double by = luaL_optnumber(L, 2, 1);
|
|
if (!std::isfinite(by)) luaL_argerror(L, 2, "must be a finite number");
|
|
double value = 0;
|
|
if (const auto found = plugin.storage_.find(key); found != plugin.storage_.end()) {
|
|
if (!found->is_number()) luaL_error(L, "storage key \"%s\" does not hold a number", key.c_str());
|
|
value = found->get<double>();
|
|
}
|
|
value += by;
|
|
store(L, plugin, key, value == std::floor(value) && std::fabs(value) <= 9007199254740992.0
|
|
? Json(static_cast<std::int64_t>(value)) : Json(value));
|
|
lua_pushnumber(L, value);
|
|
return 1;
|
|
}
|
|
static int storage_keys(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
need(L, plugin, "storage", "reskate.storage");
|
|
lua_createtable(L, static_cast<int>(plugin.storage_.size()), 0);
|
|
int index = 0;
|
|
for (const auto& item : plugin.storage_.items()) {
|
|
lua_pushlstring(L, item.first.data(), item.first.size());
|
|
lua_rawseti(L, -2, ++index);
|
|
}
|
|
return 1;
|
|
}
|
|
// skater.read: the local skater as the game has it this tick, or nil without one.
|
|
static std::optional<SkaterState> skater(lua_State* L, const char* what) {
|
|
auto& plugin = plugin_of(L);
|
|
need(L, plugin, "skater.read", what);
|
|
return plugin.services_.skater();
|
|
}
|
|
static int skater_get(lua_State* L) {
|
|
const auto s = skater(L, "reskate.skater.get");
|
|
if (!s) {
|
|
lua_pushnil(L);
|
|
return 1;
|
|
}
|
|
lua_createtable(L, 0, 8);
|
|
push_position(L, s->position);
|
|
lua_setfield(L, -2, "position");
|
|
lua_pushnumber(L, s->heading);
|
|
lua_setfield(L, -2, "heading");
|
|
lua_pushnumber(L, s->speed);
|
|
lua_setfield(L, -2, "speed");
|
|
lua_pushnumber(L, s->vertical);
|
|
lua_setfield(L, -2, "vertical_speed");
|
|
lua_pushboolean(L, s->airborne);
|
|
lua_setfield(L, -2, "airborne");
|
|
lua_pushnumber(L, s->air_time);
|
|
lua_setfield(L, -2, "air_time");
|
|
if (s->right) {
|
|
push_position(L, *s->right);
|
|
lua_setfield(L, -2, "right");
|
|
lua_pushboolean(L, s->switch_stance);
|
|
lua_setfield(L, -2, "switch");
|
|
}
|
|
return 1;
|
|
}
|
|
static int skater_position(lua_State* L) {
|
|
const auto s = skater(L, "reskate.skater.position");
|
|
if (s) push_position(L, s->position);
|
|
else lua_pushnil(L);
|
|
return 1;
|
|
}
|
|
static int skater_heading(lua_State* L) {
|
|
const auto s = skater(L, "reskate.skater.heading");
|
|
if (s) lua_pushnumber(L, s->heading);
|
|
else lua_pushnil(L);
|
|
return 1;
|
|
}
|
|
static int skater_speed(lua_State* L) {
|
|
const auto s = skater(L, "reskate.skater.speed");
|
|
if (s) lua_pushnumber(L, s->speed);
|
|
else lua_pushnil(L);
|
|
return 1;
|
|
}
|
|
static int skater_on_ground(lua_State* L) {
|
|
const auto s = skater(L, "reskate.skater.on_ground");
|
|
if (s) lua_pushboolean(L, !s->airborne);
|
|
else lua_pushnil(L);
|
|
return 1;
|
|
}
|
|
// skater.teleport: true when the teleport was sent (it happens within a few seconds); false
|
|
// and why when the host has turned teleporting off, it cannot be done now, or it is asked too often.
|
|
static int skater_teleport(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
need(L, plugin, "skater.teleport", "reskate.skater.teleport");
|
|
const auto at = position_of(L, 1, "the teleport's position");
|
|
float heading = 0;
|
|
if (lua_isnoneornil(L, 2)) {
|
|
if (const auto s = plugin.services_.skater()) heading = s->heading;
|
|
} else {
|
|
const double given = luaL_checknumber(L, 2);
|
|
if (!std::isfinite(given)) luaL_argerror(L, 2, "the heading must be a finite number of radians");
|
|
heading = static_cast<float>(std::remainder(given, 2 * 3.14159265358979323846));
|
|
}
|
|
const double now = plugin.services_.now_ms();
|
|
if (now - plugin.teleport_second_ >= 1000) {
|
|
plugin.teleport_second_ = now;
|
|
plugin.teleports_ = 0;
|
|
}
|
|
const char* refusal = nullptr;
|
|
if (++plugin.teleports_ > plugin.limits_.teleports_per_second) refusal = "asked more than twice in a second";
|
|
else switch (plugin.services_.teleport(plugin.id(), at, heading)) {
|
|
case Teleport::sent: break;
|
|
case Teleport::forbidden: refusal = "the host has turned teleporting off"; break;
|
|
case Teleport::unavailable: refusal = "teleporting is unavailable now (no skater, or a load)"; break;
|
|
}
|
|
lua_pushboolean(L, refusal == nullptr);
|
|
if (!refusal) return 1;
|
|
lua_pushstring(L, refusal);
|
|
return 2;
|
|
}
|
|
// world.raycast: where the line from `from` to `to` first meets the world's collision, or nil
|
|
// when it is clear. Collision is loaded only near the skater: far off, a ray meets nothing.
|
|
static int raycast(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
need(L, plugin, "world.raycast", "reskate.world.raycast");
|
|
const auto from = position_of(L, 1, "the ray's start");
|
|
const auto to = position_of(L, 2, "the ray's end");
|
|
const float dx = to[0] - from[0], dy = to[1] - from[1], dz = to[2] - from[2];
|
|
if (std::sqrt(dx * dx + dy * dy + dz * dz) > plugin.limits_.raycast_metres)
|
|
luaL_error(L, "a ray is at most %d m long", static_cast<int>(plugin.limits_.raycast_metres));
|
|
if (++plugin.raycasts_ > plugin.limits_.raycasts_per_tick)
|
|
luaL_error(L, "more than %d raycasts in one tick", plugin.limits_.raycasts_per_tick);
|
|
if (const auto hit = plugin.services_.raycast(from, to)) push_position(L, *hit);
|
|
else lua_pushnil(L);
|
|
return 1;
|
|
}
|
|
// events.tricks: the score HUD now (the line's score, its multiplier, a trick going on), or nil.
|
|
static int score(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
need(L, plugin, "events.tricks", "reskate.score.current");
|
|
const auto now = plugin.services_.score();
|
|
if (!now) {
|
|
lua_pushnil(L);
|
|
return 1;
|
|
}
|
|
lua_createtable(L, 0, 3);
|
|
lua_pushnumber(L, now->line);
|
|
lua_setfield(L, -2, "line");
|
|
lua_pushnumber(L, now->multiplier);
|
|
lua_setfield(L, -2, "multiplier");
|
|
lua_pushboolean(L, now->in_trick);
|
|
lua_setfield(L, -2, "in_trick");
|
|
lua_pushlstring(L, now->trick.data(), now->trick.size());
|
|
lua_setfield(L, -2, "trick");
|
|
lua_pushlstring(L, now->detail.data(), now->detail.size());
|
|
lua_setfield(L, -2, "detail");
|
|
lua_pushboolean(L, now->manual);
|
|
lua_setfield(L, -2, "manual");
|
|
return 1;
|
|
}
|
|
// Modules come from the plugin's own package, beside its entry: require("./rules") or
|
|
// require("rules") is <entry folder>/rules.luau or rules.lua. Each runs once.
|
|
static int require(lua_State* L) {
|
|
auto& plugin = plugin_of(L);
|
|
std::string name = luaL_checkstring(L, 1);
|
|
if (name.starts_with("./")) name.erase(0, 2);
|
|
const auto relative = safe_relative_path(name);
|
|
if (relative.empty()) luaL_error(L, "require(\"%s\"): a module is a path inside the plugin's package, with no ..", name.c_str());
|
|
const auto in_root = [&](const std::string& path) { return plugin.code_root_.empty() ? path : plugin.code_root_ + "/" + path; };
|
|
std::vector<std::string> candidates;
|
|
if (relative.ends_with(".lua") || relative.ends_with(".luau")) candidates.push_back(in_root(relative));
|
|
else {
|
|
candidates.push_back(in_root(relative + ".luau"));
|
|
candidates.push_back(in_root(relative + ".lua"));
|
|
}
|
|
std::string key;
|
|
std::filesystem::path file;
|
|
for (const auto& candidate : candidates) {
|
|
const auto path = package_path(L, plugin, candidate, false);
|
|
std::error_code code;
|
|
if (std::filesystem::is_regular_file(path, code)) {
|
|
key = candidate;
|
|
file = path;
|
|
break;
|
|
}
|
|
}
|
|
if (key.empty()) luaL_error(L, "require(\"%s\"): there is no %s", name.c_str(), candidates.front().c_str());
|
|
lua_getref(L, plugin.modules_);
|
|
lua_getfield(L, -1, key.c_str());
|
|
if (!lua_isnil(L, -1)) return 1;
|
|
lua_pop(L, 1);
|
|
if (plugin.loading_modules_.contains(key)) luaL_error(L, "require(\"%s\"): it requires itself, through another module", name.c_str());
|
|
plugin.loading_modules_.insert(key);
|
|
struct Done {
|
|
Plugin& plugin;
|
|
std::string key;
|
|
~Done() { plugin.loading_modules_.erase(key); }
|
|
} done{plugin, key};
|
|
if (!plugin.load_chunk(L, key, file)) lua_error(L);
|
|
lua_call(L, 0, 1);
|
|
if (lua_isnil(L, -1)) {
|
|
lua_pop(L, 1);
|
|
lua_pushboolean(L, 1);
|
|
}
|
|
lua_pushvalue(L, -1);
|
|
lua_setfield(L, -3, key.c_str());
|
|
return 1;
|
|
}
|
|
};
|
|
|
|
Json lua_to_json(lua_State* L, int index) {
|
|
Budget budget;
|
|
return to_json(L, index, 0, budget);
|
|
}
|
|
|
|
void push_json(lua_State* L, const Json& value) { push_json_value(L, value, 0); }
|
|
|
|
void Api::install(Plugin& plugin, lua_State* L) {
|
|
const auto readonly = [&] { lua_setreadonly(L, -1, true); };
|
|
lua_createtable(L, 0, 16);
|
|
lua_pushinteger(L, api_version);
|
|
lua_setfield(L, -2, "api_version");
|
|
lua_pushlstring(L, plugin.id().data(), plugin.id().size());
|
|
lua_setfield(L, -2, "id");
|
|
// "client" for the copy on a player's game, "server" for the session authority's copy.
|
|
lua_pushstring(L, plugin.services_.authority() ? "server" : "client");
|
|
lua_setfield(L, -2, "side");
|
|
// Named constants, read-only and built from the C++ lists so they cannot drift:
|
|
// reskate.Enums.Side.server is "server", reskate.Enums.Event.tick is "tick". Plain strings still work.
|
|
lua_createtable(L, 0, 2);
|
|
lua_createtable(L, 0, 3);
|
|
for (const auto side : {Side::client, Side::server, Side::both}) {
|
|
lua_pushstring(L, side_name(side));
|
|
lua_setfield(L, -2, side_name(side));
|
|
}
|
|
readonly();
|
|
lua_setfield(L, -2, "Side");
|
|
lua_createtable(L, 0, static_cast<int>(events.size()));
|
|
for (const auto& event : events) {
|
|
lua_pushlstring(L, event.name.data(), event.name.size());
|
|
lua_setfield(L, -2, std::string(event.name).c_str());
|
|
}
|
|
readonly();
|
|
lua_setfield(L, -2, "Event");
|
|
readonly();
|
|
lua_setfield(L, -2, "Enums");
|
|
using F = ApiFunctions;
|
|
add_functions(L, {{"log", F::log}, {"on", F::on}});
|
|
add_functions(L, "time", {{"now", F::now}, {"after", F::after}, {"every", F::every}, {"cancel", F::cancel}});
|
|
add_functions(L, "level", {{"current", F::level}});
|
|
add_functions(L, "package", {{"read", F::read}, {"read_json", F::read_json}, {"list", F::list}, {"variants", F::variants}, {"choose", F::choose}});
|
|
add_functions(L, "json", {{"encode", F::encode}, {"decode", F::decode}});
|
|
add_functions(L, "console", {{"command", F::command}, {"print", F::console_print}});
|
|
add_functions(L, "storage", {{"get", F::storage_get}, {"set", F::storage_set}, {"add", F::storage_add}, {"keys", F::storage_keys}});
|
|
add_functions(L, "skater", {{"get", F::skater_get}, {"position", F::skater_position}, {"heading", F::skater_heading},
|
|
{"speed", F::skater_speed}, {"on_ground", F::skater_on_ground}, {"teleport", F::skater_teleport}});
|
|
add_functions(L, "score", {{"current", F::score}});
|
|
add_functions(L, "world", {{"raycast", F::raycast}});
|
|
|
|
install_ui_api(plugin, L);
|
|
install_more_api(plugin, L);
|
|
install_world_api(plugin, L);
|
|
install_services_api(plugin, L);
|
|
install_kit_api(plugin, L);
|
|
install_capture_api(plugin, L);
|
|
readonly();
|
|
lua_setglobal(L, "reskate");
|
|
lua_pushcfunction(L, ApiFunctions::print, "print");
|
|
lua_setglobal(L, "print");
|
|
lua_pushcfunction(L, ApiFunctions::require, "require");
|
|
lua_setglobal(L, "require");
|
|
}
|
|
|
|
void push_player(lua_State* L, const SessionPlayer& player) {
|
|
lua_createtable(L, 0, 4);
|
|
const auto id = std::to_string(player.id);
|
|
lua_pushlstring(L, id.data(), id.size());
|
|
lua_setfield(L, -2, "id");
|
|
lua_pushlstring(L, player.name.data(), player.name.size());
|
|
lua_setfield(L, -2, "name");
|
|
lua_pushboolean(L, player.me);
|
|
lua_setfield(L, -2, "me");
|
|
lua_pushboolean(L, player.host);
|
|
lua_setfield(L, -2, "host");
|
|
lua_pushboolean(L, player.server);
|
|
lua_setfield(L, -2, "server");
|
|
lua_getfield(L, LUA_REGISTRYINDEX, "reskate.player"); // its permissions (plugin_api_world.cpp)
|
|
lua_setmetatable(L, -2);
|
|
}
|
|
}
|