Files
ReSkate/Extension/Plugins/plugin_api_capture.cpp
T
AyeZeeBB dce293e25d Fit Check as a plugin: screen capture, the menu's preview turned and read, and what the skater wears
Pull request 326 split into general plugin APIs and contrib/plugins/fit-check:
- reskate.capture (start, stop, discard, state, compare, kept, save): the screen
  recorded before the overlay draws, cut out by the game's depth or by colour,
  saved as GIF, APNG, PNG or WebM with an optional card; the "capture" permission.
- reskate.room.turn: a stand-in pad's stick held over to turn the menu's preview.
- reskate.menu.game and reskate.menu.grid: what the game's menu and its item grid show.
- reskate.outfit.worn, board and title: what the skater wears, by name.
- reskate.files.reveal: a data folder opened in Explorer.
The plugin finds where the turn comes round, eases the stick off, picks evenly
spaced frames and saves them with the board's parts or the outfit on the card.
2026-10-21 17:16:56 -05:00

315 lines
15 KiB
C++

#include "plugin_api.h"
#include "plugin_game.h"
#include "lua.h"
#include "lualib.h"
#include <algorithm>
#include <cmath>
#include <string>
// reskate.capture (the screen as the game draws it, saved as a GIF, APNG, PNG or WebM), the game
// menu's preview turned (reskate.room.turn) and read (reskate.menu.game, reskate.menu.grid), what the
// skater wears (reskate.outfit.worn, board, title) and reskate.files.reveal: from Fit Check (pull
// request 326), whose turn finding and card are now a plugin's (contrib/plugins/fit-check).
namespace dingosdk::plugins {
namespace {
int result(lua_State* L, const std::string& why) {
lua_pushboolean(L, why.empty());
if (why.empty()) return 1;
lua_pushlstring(L, why.data(), why.size());
return 2;
}
void push_text(lua_State* L, const std::string& text, const char* field) {
lua_pushlstring(L, text.data(), text.size());
lua_setfield(L, -2, field);
}
// An option of a table at `index`: a number within [least, most] (raises otherwise), or `fallback`.
double number_option(lua_State* L, int index, const char* name, double fallback, double least, double most) {
if (lua_isnoneornil(L, index)) return fallback;
lua_getfield(L, index, name);
double value = fallback;
if (!lua_isnil(L, -1)) {
value = luaL_checknumber(L, -1);
if (!(value >= least && value <= most)) luaL_error(L, "%s is %g to %g", name, least, most);
}
lua_pop(L, 1);
return value;
}
bool flag_option(lua_State* L, int index, const char* name, bool fallback) {
if (lua_isnoneornil(L, index)) return fallback;
lua_getfield(L, index, name);
const bool value = lua_isnil(L, -1) ? fallback : lua_toboolean(L, -1) != 0;
lua_pop(L, 1);
return value;
}
std::string text_option(lua_State* L, int index, const char* name, const char* fallback) {
if (lua_isnoneornil(L, index)) return fallback;
lua_getfield(L, index, name);
std::string value = fallback;
if (!lua_isnil(L, -1)) value = luaL_checkstring(L, -1);
lua_pop(L, 1);
return value;
}
// A per-second budget; false past it.
bool within(double now, double& second, int& count, int most) {
if (now - second >= 1000) {
second = now;
count = 0;
}
return ++count <= most;
}
} // namespace
struct CaptureApi {
static Game& game(Plugin& plugin) { return plugin.services_.game(); }
static Plugin& capturing(lua_State* L, const char* what) {
auto& plugin = plugin_of(L);
need(L, plugin, "capture", what);
return plugin;
}
// start({area = {left, top, right, bottom}, frame = "screen"|"menu", depth, height, skip, frames, seconds})
static int start(lua_State* L) {
auto& plugin = capturing(L, "reskate.capture.start");
Game::CaptureStart request;
if (!lua_isnoneornil(L, 1)) {
luaL_checktype(L, 1, LUA_TTABLE);
lua_getfield(L, 1, "area");
if (!lua_isnil(L, -1)) {
luaL_checktype(L, -1, LUA_TTABLE);
float edges[4]{};
for (int i = 0; i < 4; ++i) {
lua_rawgeti(L, -1, i + 1);
const double v = luaL_checknumber(L, -1);
if (!(v >= 0 && v <= 1)) luaL_error(L, "an area is {left, top, right, bottom}, each 0 to 1");
edges[i] = static_cast<float>(v);
lua_pop(L, 1);
}
if (!(edges[2] > edges[0] && edges[3] > edges[1])) luaL_error(L, "an area's right is past its left, and its bottom past its top");
request.left = edges[0], request.top = edges[1], request.right = edges[2], request.bottom = edges[3];
}
lua_pop(L, 1);
const auto frame = text_option(L, 1, "frame", "screen");
if (frame != "screen" && frame != "menu") luaL_error(L, "frame is \"screen\" or \"menu\"");
request.menu = frame == "menu";
request.depth = flag_option(L, 1, "depth", true);
request.height = static_cast<std::uint32_t>(number_option(L, 1, "height", 0, 0, 4096));
if (request.height && request.height < 64) luaL_error(L, "height is 0 (as on screen) or 64 to 4096");
request.skip = static_cast<std::uint32_t>(number_option(L, 1, "skip", 0, 0, 600));
request.most = static_cast<std::uint32_t>(number_option(L, 1, "frames", 2400, 1, 20000));
request.seconds = number_option(L, 1, "seconds", 60, 1, 120);
}
if (!within(plugin.services_.now_ms(), plugin.control_second_, plugin.controls_, 4)) return result(L, "asked more than 4 times in a second");
return result(L, game(plugin).capture_start(plugin.id(), request));
}
static int stop(lua_State* L) {
auto& plugin = capturing(L, "reskate.capture.stop");
game(plugin).capture_stop(plugin.id());
return 0;
}
static int discard(lua_State* L) {
auto& plugin = capturing(L, "reskate.capture.discard");
game(plugin).capture_discard(plugin.id());
return 0;
}
static int state(lua_State* L) {
auto& plugin = capturing(L, "reskate.capture.state");
const auto now = game(plugin).capture_status(plugin.id());
lua_createtable(L, 0, 6);
push_text(L, now.state, "state");
lua_pushinteger(L, static_cast<int>(now.taken));
lua_setfield(L, -2, "taken");
lua_pushinteger(L, static_cast<int>(now.kept));
lua_setfield(L, -2, "kept");
lua_pushboolean(L, now.depth);
lua_setfield(L, -2, "depth");
if (!now.message.empty()) push_text(L, now.message, "message");
if (!now.file.empty()) push_text(L, now.file, "file");
return 1;
}
static std::uint32_t frame_number(lua_State* L, int index) {
const double n = luaL_checknumber(L, index);
if (!(n >= 1 && n <= 20000) || n != std::floor(n)) luaL_argerror(L, index, "a frame is a whole number from 1");
return static_cast<std::uint32_t>(n) - 1;
}
// compare(a, b): how unlike two frames taken look, 0 to 255; nil when either is not known.
static int compare(lua_State* L) {
auto& plugin = capturing(L, "reskate.capture.compare");
const float d = game(plugin).capture_compare(plugin.id(), frame_number(L, 1), frame_number(L, 2));
if (d < 0) lua_pushnil(L);
else lua_pushnumber(L, d);
return 1;
}
static int kept(lua_State* L) {
auto& plugin = capturing(L, "reskate.capture.kept");
const auto list = game(plugin).capture_kept(plugin.id());
lua_createtable(L, static_cast<int>(list.size()), 0);
for (std::size_t i = 0; i < list.size(); ++i) {
lua_pushinteger(L, static_cast<int>(list[i]) + 1);
lua_rawseti(L, -2, static_cast<int>(i + 1));
}
return 1;
}
// save(path, {frames, format, fps, size, background, cutout, solid, card = {title, lines}})
static int save(lua_State* L) {
auto& plugin = capturing(L, "reskate.capture.save");
need(L, plugin, "files", "reskate.capture.save");
Game::CaptureSave request;
request.path = data_path(L, 1, false);
luaL_checktype(L, 2, LUA_TTABLE);
lua_getfield(L, 2, "frames");
luaL_checktype(L, -1, LUA_TTABLE);
const int count = lua_objlen(L, -1);
if (count < 1 || count > 360) luaL_error(L, "a capture saves 1 to 360 frames");
for (int i = 1; i <= count; ++i) {
lua_rawgeti(L, -1, i);
request.frames.push_back(frame_number(L, lua_gettop(L)));
lua_pop(L, 1);
}
lua_pop(L, 1);
request.format = text_option(L, 2, "format", "gif");
if (request.format != "gif" && request.format != "apng" && request.format != "png" && request.format != "webm")
luaL_error(L, "format is \"gif\", \"apng\", \"png\" or \"webm\"");
if (request.format == "png" && count != 1) luaL_error(L, "a png is one frame");
request.fps = static_cast<std::uint32_t>(number_option(L, 2, "fps", 30, 1, 60));
request.size = static_cast<std::uint32_t>(number_option(L, 2, "size", 512, 64, 2048));
lua_getfield(L, 2, "background");
if (lua_isnumber(L, -1)) {
const double c = lua_tonumber(L, -1);
if (!(c >= 0 && c <= 0xFFFFFF) || c != std::floor(c)) luaL_error(L, "background is 0xRRGGBB, or \"transparent\"");
request.background = static_cast<std::uint32_t>(c);
} else if (!lua_isnil(L, -1) && std::string(luaL_checkstring(L, -1)) != "transparent")
luaL_error(L, "background is 0xRRGGBB, or \"transparent\"");
lua_pop(L, 1);
request.cutout = text_option(L, 2, "cutout", "auto");
if (request.cutout != "auto" && request.cutout != "depth" && request.cutout != "colour" && request.cutout != "none")
luaL_error(L, "cutout is \"auto\", \"depth\", \"colour\" or \"none\"");
request.solid = flag_option(L, 2, "solid", true);
lua_getfield(L, 2, "card");
if (!lua_isnil(L, -1)) {
luaL_checktype(L, -1, LUA_TTABLE);
const int card = lua_gettop(L);
request.card_title = text_option(L, card, "title", "");
if (request.card_title.size() > 32) luaL_error(L, "a card's title is at most 32 bytes");
lua_getfield(L, card, "lines");
luaL_checktype(L, -1, LUA_TTABLE);
const int lines = lua_objlen(L, -1);
if (lines > 16) luaL_error(L, "a card has at most 16 lines");
for (int i = 1; i <= lines; ++i) {
lua_rawgeti(L, -1, i);
luaL_checktype(L, -1, LUA_TTABLE);
lua_rawgeti(L, -1, 1);
lua_rawgeti(L, -2, 2);
std::string label = luaL_checkstring(L, -2), value = luaL_checkstring(L, -1);
if (label.size() > 64 || value.size() > 64) luaL_error(L, "a card's line is {label, value}, each at most 64 bytes");
request.card.emplace_back(std::move(label), std::move(value));
lua_pop(L, 3);
}
lua_pop(L, 1);
}
lua_pop(L, 1);
if (!within(plugin.services_.now_ms(), plugin.files_second_, plugin.file_calls_, 64)) return result(L, "more than 64 file calls in a second");
return result(L, game(plugin).capture_save(plugin.id(), request));
}
// room.turn(value, stick): the game menu's preview turned by a stand-in pad's stick, held 300 ms.
static int room_turn(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "room", "reskate.room.turn");
const double value = luaL_checknumber(L, 1);
if (!(value >= -1 && value <= 1)) luaL_argerror(L, 1, "-1 to 1");
const std::string stick = luaL_optstring(L, 2, "right_x");
static constexpr const char* sticks[] = {"left_x", "left_y", "right_x", "right_y"};
const auto at = std::find_if(std::begin(sticks), std::end(sticks), [&](const char* s) { return stick == s; });
if (at == std::end(sticks)) luaL_argerror(L, 2, "\"left_x\", \"left_y\", \"right_x\" or \"right_y\"");
return result(L, game(plugin).room_turn(plugin.id(), static_cast<float>(value), static_cast<int>(at - std::begin(sticks))));
}
static int menu_game(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "menu", "reskate.menu.game");
const auto menu = game(plugin).game_menu();
lua_createtable(L, 0, 3);
lua_pushboolean(L, menu.open);
lua_setfield(L, -2, "open");
lua_pushboolean(L, menu.open && menu.skaters);
lua_setfield(L, -2, "skaters");
lua_createtable(L, static_cast<int>(menu.buttons.size()), 0);
for (std::size_t i = 0; i < menu.buttons.size() && i < 64; ++i) {
lua_pushlstring(L, menu.buttons[i].data(), menu.buttons[i].size());
lua_rawseti(L, -2, static_cast<int>(i + 1));
}
lua_setfield(L, -2, "buttons");
return 1;
}
static int menu_grid(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "menu", "reskate.menu.grid");
const auto grid = game(plugin).menu_grid();
if (!grid) {
lua_pushnil(L);
return 1;
}
lua_createtable(L, 0, 6);
if (!grid->focused.empty()) push_text(L, grid->focused, "focused");
if (!grid->title.empty()) push_text(L, grid->title, "title");
if (!grid->category.empty()) push_text(L, grid->category, "category");
lua_createtable(L, static_cast<int>(std::min<std::size_t>(grid->items.size(), 4000)), 0);
for (std::size_t i = 0; i < grid->items.size() && i < 4000; ++i) {
lua_pushlstring(L, grid->items[i].data(), grid->items[i].size());
lua_rawseti(L, -2, static_cast<int>(i + 1));
}
lua_setfield(L, -2, "items");
lua_pushinteger(L, static_cast<int>(grid->shown));
lua_setfield(L, -2, "shown");
lua_pushinteger(L, static_cast<int>(grid->total));
lua_setfield(L, -2, "total");
return 1;
}
static int push_worn(lua_State* L, const std::vector<Game::WornItem>& list) {
lua_createtable(L, static_cast<int>(list.size()), 0);
for (std::size_t i = 0; i < list.size(); ++i) {
lua_createtable(L, 0, 3);
push_text(L, list[i].slot, "slot");
push_text(L, list[i].item, "item");
push_text(L, list[i].title, "title");
lua_rawseti(L, -2, static_cast<int>(i + 1));
}
return 1;
}
static int outfit_worn(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "outfit", "reskate.outfit.worn");
return push_worn(L, game(plugin).outfit_worn());
}
static int outfit_board(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "outfit", "reskate.outfit.board");
return push_worn(L, game(plugin).board_parts());
}
static int outfit_title(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "outfit", "reskate.outfit.title");
const auto key = checked_text(L, 1, 255, "an item's key");
const auto title = game(plugin).item_title(key);
lua_pushlstring(L, title.data(), title.size());
return 1;
}
static int files_reveal(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "files", "reskate.files.reveal");
const auto folder = data_path(L, 1, true);
if (!within(plugin.services_.now_ms(), plugin.reveal_second_, plugin.reveals_, 1)) return result(L, "once a second");
return result(L, game(plugin).files_reveal(plugin.id(), folder));
}
static void install(lua_State* L) {
add_functions(L, "capture", {{"start", start}, {"stop", stop}, {"discard", discard}, {"state", state}, {"compare", compare},
{"kept", kept}, {"save", save}});
add_functions(L, "room", {{"turn", room_turn}});
add_functions(L, "menu", {{"game", menu_game}, {"grid", menu_grid}});
add_functions(L, "outfit", {{"worn", outfit_worn}, {"board", outfit_board}, {"title", outfit_title}});
add_functions(L, "files", {{"reveal", files_reveal}});
}
};
void install_capture_api(Plugin&, lua_State* L) { CaptureApi::install(L); }
} // namespace dingosdk::plugins