Plugins: trick events (trick_started, trick_landed, line_broken) and reskate.score.current

- With events.tricks a plugin hears the local skater's tricks: trick_started (where), trick_landed
  (points, multiplier, where it started and landed, how long it took) and line_broken (a slam or
  a wipeout); reskate.score.current() is the score HUD's line, multiplier and whether a trick is
  going on. Without the permission the events cannot be registered.
- The score HUD is read into the trick watch (Extension/Skater/score_hud.h, trick_watch.h) on the
  client tick only while a running plugin has events.tricks; a new level starts a new line.
  plugins::start takes the image base for it.
- reskate-test has a tricks module (skipped until a trick lands) and ptest trick, which waits 30 s
  for one and prints what its events said; reskate-hostile has trick-loop.

Not yet checked against real tricks in game.
This commit is contained in:
AyeZeeBB committed 2026-10-19 12:43:44 -05:00
1 parent 373c141381
commit f37abef426
13 files changed
+333 -15

No files matched your search

+64 -3
View File
@@ -31,6 +31,8 @@ struct TestEnvironment final : Environment {
std::vector<std::pair<Vec3, float>> teleports;
int rays{};
std::optional<SkaterState> skater() override { return local_skater; }
std::optional<ScoreState> score_now;
std::optional<ScoreState> score() override { return score_now; }
Teleport teleport(const Vec3& position, float heading) override {
if (teleport_forbidden) return Teleport::forbidden;
if (teleport_unavailable || !local_skater) return Teleport::unavailable;
@@ -455,6 +457,53 @@ void the_skater_can_be_read_moved_and_the_world_probed() {
running(host, "mover", environment);
}
void tricks_reach_plugins_that_ask_for_them() {
TestEnvironment environment;
Host host(environment, Side::client);
host.apply({script("bare", R"(
local ok, err = pcall(reskate.on, "trick_landed", function() end)
assert(not ok and err:find("events.tricks"), err)
ok, err = pcall(reskate.score.current)
assert(not ok and err:find("events.tricks"), err)
reskate.log("bare refused")
)")});
check(environment.logged("bare refused") && !host.wants_tricks(), "Tricks need events.tricks, and none is read without it");
host.apply({script("bare", "reskate.log('bare')"), script("tricks", R"(
reskate.on("trick_started", function(e) reskate.log(string.format("started at %.0f %.0f %.0f", e.at.x, e.at.y, e.at.z)) end)
reskate.on("trick_landed", function(e)
reskate.log(string.format("landed %.0f x%.1f from %.0f to %.0f in %.0f ms", e.points, e.multiplier, e.started_at.x, e.landed_at.x, e.ms))
end)
reskate.on("line_broken", function(e) reskate.log("broken by a " .. e.reason) end)
reskate.console.command("score", "x", function()
local s = reskate.score.current()
reskate.log(s and string.format("score %.0f x%.1f %s", s.line, s.multiplier, tostring(s.in_trick)) or "no score")
end)
)", R"("console", "events.tricks")")});
check(host.wants_tricks(), "With a plugin that has events.tricks, tricks are read");
TrickNews started;
started.started_at = {1, 2, 3};
host.trick(started);
TrickNews landed;
landed.kind = TrickNews::Kind::landed;
landed.points = 250;
landed.multiplier = 2;
landed.started_at = {1, 2, 3};
landed.landed_at = {9, 2, 3};
landed.ms = 1200;
host.trick(landed);
TrickNews broken;
broken.kind = TrickNews::Kind::broken;
broken.reason = "slam";
host.trick(broken);
check(environment.logged("[tricks] started at 1 2 3") && environment.logged("[tricks] landed 250 x2.0 from 1 to 9 in 1200 ms") &&
environment.logged("[tricks] broken by a slam"), "Each trick event reaches it, with its values\n" + environment.all());
host.run_console("score");
environment.score_now = ScoreState{708, 2, true};
host.run_console("score");
check(environment.logged("[tricks] no score") && environment.logged("[tricks] score 708 x2.0 true"), "and the score HUD as it is");
running(host, "tricks", environment);
}
void level_events_reach_plugins() {
TestEnvironment environment;
Host host(environment, Side::client);
@@ -554,7 +603,7 @@ void reskate_test_passes_its_own_checks() {
check(host.run_console("ptest all"), "ptest is its command");
ticks(host, environment, 120); // its later checks run on timers
const auto output = printed_lines(environment);
check(output.find("ptest: 46 passed, 0 failed, 4 skipped") != std::string::npos &&
check(output.find("ptest: 47 passed, 0 failed, 7 skipped") != std::string::npos &&
output.find("FAIL") == std::string::npos, "Every reskate-test check passes:\n" + output + environment.all());
check(host.run_console("ptest echo a b") && environment.printed("ptest echo: a b"), "and its echo command answers");
// With a skater on the ground, its skater checks run too.
@@ -563,6 +612,16 @@ void reskate_test_passes_its_own_checks() {
check(host.run_console("ptest skater"), "ptest skater");
check(environment.printed("ptest: 5 passed, 0 failed (skater 5/5)"), "The skater checks pass with a skater:\n" + printed_lines(environment));
check(host.run_console("ptest tp") && environment.teleports.size() == 1, "and ptest tp teleports it");
// ptest trick waits for a trick and says what its events held.
check(host.run_console("ptest trick"), "ptest trick");
host.trick({TrickNews::Kind::started, 0, 0, {1, 0, 1}});
host.trick({TrickNews::Kind::landed, 250, 2, {1, 0, 1}, {4, 0, 5}, 900});
check(environment.printed("ptest trick: started at (1.0, 0.0, 1.0)") &&
environment.printed("ptest trick: landed 250 points at x2.0 in 900 ms, from (1.0, 0.0, 1.0) to (4.0, 0.0, 5.0)"),
"ptest trick prints the trick");
environment.lines.clear();
check(host.run_console("ptest tricks") && environment.printed("ptest: 3 passed, 0 failed, 1 skipped"),
"and the tricks checks pass once one landed");
running(host, "reskate-test", environment);
}
@@ -571,17 +630,18 @@ void reskate_hostile_is_stopped_or_refused_every_time() {
Host host(environment, Side::client);
host.apply({contributed("reskate-hostile")});
running(host, "reskate-hostile", environment);
struct Case { const char* attack; bool stops; int ticks; };
struct Case { const char* attack; bool stops; int ticks; bool trick = false; };
const Case cases[] = {
{"loop", true, 0}, {"caught-loop", true, 0}, {"tick-loop", true, 1}, {"memory", true, 0},
{"caught-memory", true, 0}, {"huge-string", true, 0}, {"buffer", true, 0}, {"coroutines", true, 0},
{"errors", true, 10}, {"slow", true, 60 * 5}, {"log-flood", false, 1}, {"timers", false, 1},
{"recursion", false, 1}, {"escapes", false, 1}, {"teleport-spam", false, 1}, {"raycast-flood", false, 1},
{"recursion", false, 1}, {"escapes", false, 1}, {"teleport-spam", false, 1}, {"raycast-flood", false, 1}, {"trick-loop", true, 0, true},
};
for (const auto& item : cases) {
check(host.reload("reskate-hostile").find("reloaded") != std::string::npos, std::string("reloads before ") + item.attack);
check(host.run_console(std::string("hostile ") + item.attack), item.attack);
ticks(host, environment, item.ticks);
if (item.trick) host.trick({TrickNews::Kind::landed, 100, 1});
const auto state = status_of(host, "reskate-hostile").state;
check((state == "stopped") == item.stops, std::string(item.attack) + " left it " + state + "\n" + environment.all());
}
@@ -611,6 +671,7 @@ int main() {
a_plugin_over_its_time_budget_is_stopped();
a_plugin_reloads_when_its_files_change();
the_skater_can_be_read_moved_and_the_world_probed();
tricks_reach_plugins_that_ask_for_them();
level_events_reach_plugins();
timers_run_when_due();
a_package_with_a_program_or_on_the_wrong_side_is_not_loaded();
+35 -3
View File
@@ -13,7 +13,14 @@
// luaL_error, or a C++ exception, which Luau turns into a Lua error with its message.
namespace dingosdk::plugins {
namespace {
constexpr std::array<std::string_view, 5> events{"load", "unload", "tick", "level_loaded", "level_unloading"};
// Events and the permission each needs (none for the base set).
struct Event {
std::string_view name, permission;
};
constexpr std::array<Event, 8> events{{
{"load", {}}, {"unload", {}}, {"tick", {}}, {"level_loaded", {}}, {"level_unloading", {}},
{"trick_started", "events.tricks"}, {"trick_landed", "events.tricks"}, {"line_broken", "events.tricks"},
}};
constexpr std::size_t maximum_key = 128;
constexpr std::size_t maximum_command = 48;
constexpr std::size_t maximum_listing = 1000;
@@ -197,8 +204,10 @@ struct ApiFunctions {
auto& plugin = plugin_of(L);
const std::string event = luaL_checkstring(L, 1);
luaL_checktype(L, 2, LUA_TFUNCTION);
if (std::find(events.begin(), events.end(), event) == events.end())
luaL_error(L, "there is no event \"%s\" in this ReSkate", event.c_str());
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);
@@ -493,6 +502,24 @@ struct ApiFunctions {
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");
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) {
@@ -620,6 +647,11 @@ void Api::install(Plugin& plugin, lua_State* L) {
readonly();
lua_setfield(L, -2, "skater");
lua_createtable(L, 0, 1);
set_function(L, "current", ApiFunctions::score);
readonly();
lua_setfield(L, -2, "score");
lua_createtable(L, 0, 1);
set_function(L, "raycast", ApiFunctions::raycast);
readonly();
+14
View File
@@ -1,6 +1,7 @@
#include "plugin_host.h"
#include "plugin_api.h"
#include "Engine/Core/Console/console_core.h"
#include <algorithm>
#include <format>
#include <utility>
@@ -130,6 +131,19 @@ void Host::set_level(std::string level) {
}
}
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);
+6
View File
@@ -35,6 +35,7 @@ public:
virtual std::optional<SkaterState> skater() { return std::nullopt; }
virtual Teleport teleport(const Vec3&, float) { return Teleport::unavailable; }
virtual std::optional<Vec3> raycast(const Vec3&, const Vec3&) { return std::nullopt; }
virtual std::optional<ScoreState> score() { return std::nullopt; }
};
struct Status {
@@ -58,6 +59,10 @@ public:
void apply(const std::vector<Candidate>& found);
void tick(double dt_ms);
void set_level(std::string level);
// A trick of the local skater, to every plugin with events.tricks.
void trick(const TrickNews& news);
// Whether a running plugin has events.tricks: the score HUD is read only then.
bool wants_tricks() const;
// Runs a plugin's console command when the text starts with one; false when none matched.
bool run_console(std::string_view text);
std::vector<Status> status() const;
@@ -91,6 +96,7 @@ private:
std::optional<SkaterState> skater() override { return environment_.skater(); }
Teleport teleport(const Vec3& position, float heading) override { return environment_.teleport(position, heading); }
std::optional<Vec3> raycast(const Vec3& from, const Vec3& to) override { return environment_.raycast(from, to); }
std::optional<ScoreState> score() override { return environment_.score(); }
Environment& environment_;
Side side_;
+65 -2
View File
@@ -5,6 +5,8 @@
#include "Engine/Game/Multiplayer/session_tools.h"
#include "Extension/Console/commands.h"
#include "Extension/Profile/local_profile_runtime.h"
#include "Extension/Skater/score_hud.h"
#include "Extension/Skater/trick_watch.h"
#include "Extension/Trainer/trainer.h"
#include <Windows.h>
#include <ShlObj.h>
@@ -53,6 +55,8 @@ public:
return teleport_local_skater_heading(position, heading) ? Teleport::sent : Teleport::unavailable;
}
std::optional<Vec3> raycast(const Vec3& from, const Vec3& to) override { return local_surface_hit(from, to); }
std::optional<ScoreState> score() override { return score_now; }
std::optional<ScoreState> score_now; // read each tick while a plugin wants tricks
private:
std::filesystem::path storage_;
@@ -68,6 +72,55 @@ std::vector<Candidate> found; // under found_mutex
std::uint64_t found_generation{}; // under found_mutex
std::uint64_t applied_generation{};
std::chrono::steady_clock::time_point last_tick{};
std::uintptr_t base{};
// The local skater's tricks (Extension/Skater/trick_watch.h), and where and when the one going on began.
tricks::TrickWatch trick_watch;
Vec3 started_at{};
std::chrono::steady_clock::time_point started_time{};
std::string watched_level;
// The score HUD into the trick watch, and what it saw to the plugins. Client thread.
void read_tricks() {
const auto hud = tricks::read_score_hud(base);
if (!hud) {
environment->score_now.reset();
return;
}
const auto skater = trainer::telemetry();
tricks::TrickSignals signals;
signals.action = hud->action;
signals.line_score = hud->line_score;
signals.multiplier = hud->multiplier;
signals.slam = hud->slam;
// A wipeout is physics states 300-399 (as Domination reads it).
signals.wipeout = skater.skater && skater.physics_state >= 300 && skater.physics_state < 400;
const auto event = trick_watch.update(signals);
environment->score_now = ScoreState{hud->line_score, hud->multiplier, trick_watch.in_trick()};
const auto now = std::chrono::steady_clock::now();
TrickNews news;
switch (event.kind) {
case tricks::TrickEvent::Kind::none: return;
case tricks::TrickEvent::Kind::started:
started_at = skater.position;
started_time = now;
news.kind = TrickNews::Kind::started;
news.started_at = started_at;
break;
case tricks::TrickEvent::Kind::landed:
news.kind = TrickNews::Kind::landed;
news.points = event.points;
news.multiplier = event.multiplier;
news.started_at = started_at;
news.landed_at = skater.position;
news.ms = std::chrono::duration<double, std::milli>(now - started_time).count();
break;
case tricks::TrickEvent::Kind::broken:
news.kind = TrickNews::Kind::broken;
news.reason = signals.slam ? "slam" : "wipeout";
break;
}
host->trick(news);
}
void watch() {
FileChecks checks;
@@ -96,9 +149,10 @@ std::filesystem::path local_storage() {
}
}
bool start() noexcept {
bool start(std::uintptr_t image_base) noexcept {
try {
if (host) return true;
base = image_base;
std::array<wchar_t, 32768> path{};
const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast<DWORD>(path.size()));
if (!length || length >= path.size()) return false;
@@ -136,6 +190,8 @@ void on_client_tick() noexcept {
const double dt = last_tick == std::chrono::steady_clock::time_point{} ? 0
: std::chrono::duration<double, std::milli>(now - last_tick).count();
last_tick = now;
if (host->wants_tricks()) read_tricks();
else environment->score_now.reset();
host->tick(dt);
} catch (const std::exception& error) {
logging::log(logging::Level::error, logging::Channel::plugins, "Plugin tick failed: {}", error.what());
@@ -144,7 +200,14 @@ void on_client_tick() noexcept {
void set_level(std::string_view level) noexcept {
if (!host) return;
try { host->set_level(std::string(level)); }
try {
// A new level starts a new line: a trick going on before it scores nothing.
if (level != watched_level) {
watched_level = std::string(level);
trick_watch = tricks::TrickWatch{};
}
host->set_level(std::string(level));
}
catch (const std::exception& error) {
logging::log(logging::Level::error, logging::Channel::plugins, "Plugin level change failed: {}", error.what());
}
+4 -2
View File
@@ -1,4 +1,5 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
@@ -8,8 +9,9 @@
//
// Every function but start is for the client thread, the one that runs console commands.
namespace dingosdk::plugins {
// Once, at startup. Plugins load at the first client tick after discovery finds them.
bool start() noexcept;
// Once, at startup, with the game's image base (the score HUD is read from it). Plugins load at the
// first client tick after discovery finds them.
bool start(std::uintptr_t image_base) noexcept;
void on_client_tick() noexcept;
// The level being played, or empty without one: plugins hear level_loaded and level_unloading.
void set_level(std::string_view level) noexcept;
+55 -2
View File
@@ -281,6 +281,13 @@ void Plugin::unload() {
}
void Plugin::fire(std::string_view event, const std::vector<std::string>& arguments) {
fire_with(event, [&](lua_State* L) {
for (const auto& argument : arguments) lua_pushlstring(L, argument.data(), argument.size());
return static_cast<int>(arguments.size());
});
}
void Plugin::fire_with(std::string_view event, const std::function<int(lua_State*)>& push) {
if (state_ != State::running) return;
const auto found = handlers_.find(event);
if (found == handlers_.end()) return;
@@ -289,8 +296,54 @@ void Plugin::fire(std::string_view event, const std::vector<std::string>& argume
for (const int handler : handlers) {
if (state_ != State::running) return;
lua_getref(L_, handler);
for (const auto& argument : arguments) lua_pushlstring(L_, argument.data(), argument.size());
call(static_cast<int>(arguments.size()), what, limits_.call_ms);
int count = 0;
try {
count = push(L_);
} catch (const std::exception& error) {
// Out of memory building the event: the VM is closed next, so its stack is left as it is.
stop(std::string("its event could not be handed over: ") + error.what());
return;
}
call(count, what, limits_.call_ms);
}
}
void Plugin::trick(const TrickNews& news) {
if (state_ != State::running || !has("events.tricks")) return;
const auto vector = [](lua_State* L, const Vec3& v) { lua_pushvector(L, v[0], v[1], v[2]); };
switch (news.kind) {
case TrickNews::Kind::started:
fire_with("trick_started", [&](lua_State* L) {
lua_createtable(L, 0, 1);
vector(L, news.started_at);
lua_setfield(L, -2, "at");
return 1;
});
break;
case TrickNews::Kind::landed:
fire_with("trick_landed", [&](lua_State* L) {
lua_createtable(L, 0, 5);
lua_pushnumber(L, news.points);
lua_setfield(L, -2, "points");
lua_pushnumber(L, news.multiplier);
lua_setfield(L, -2, "multiplier");
vector(L, news.started_at);
lua_setfield(L, -2, "started_at");
vector(L, news.landed_at);
lua_setfield(L, -2, "landed_at");
lua_pushnumber(L, news.ms);
lua_setfield(L, -2, "ms");
return 1;
});
break;
case TrickNews::Kind::broken:
fire_with("line_broken", [&](lua_State* L) {
lua_createtable(L, 0, 1);
lua_pushlstring(L, news.reason.data(), news.reason.size());
lua_setfield(L, -2, "reason");
return 1;
});
break;
}
}
+19
View File
@@ -5,6 +5,7 @@
#include <chrono>
#include <cstddef>
#include <filesystem>
#include <functional>
#include <map>
#include <optional>
#include <set>
@@ -55,6 +56,18 @@ struct SkaterState {
float air_time{}; // seconds into the jump going on
};
enum class Teleport { sent, unavailable, forbidden };
// A trick of the local skater, read from the score HUD (Extension/Skater/trick_watch.h).
struct TrickNews {
enum class Kind { started, landed, broken } kind{Kind::started};
float points{}, multiplier{}; // landed
Vec3 started_at{}, landed_at{};
double ms{}; // landed: from its start
std::string reason; // broken: "slam" or "wipeout"
};
struct ScoreState {
float line{}, multiplier{};
bool in_trick{};
};
// What a plugin's VM can reach outside itself. The host implements it.
class Services {
@@ -72,6 +85,8 @@ public:
virtual Teleport teleport(const Vec3& position, float heading) = 0;
// Where the line first meets the world's collision, or empty when it is clear (or not loaded).
virtual std::optional<Vec3> raycast(const Vec3& from, const Vec3& to) = 0;
// The score HUD now, or empty without one.
virtual std::optional<ScoreState> score() = 0;
};
enum class State { loading, running, stopped };
@@ -95,6 +110,8 @@ public:
void tick(double dt_ms);
void level_changed(const std::string& previous, const std::string& current);
// trick_started, trick_landed or line_broken, to a plugin with events.tricks.
void trick(const TrickNews& news);
// A console command it registered: the words after the command's name.
void run_command(const std::string& name, const std::vector<std::string>& arguments);
void save_storage();
@@ -133,6 +150,8 @@ private:
// The compiled chunk of a source file on the main stack, or false and the error on it.
bool load_chunk(lua_State* L, const std::string& relative, const std::filesystem::path& file);
void fire(std::string_view event, const std::vector<std::string>& arguments);
// Each handler of `event` with what `push` puts on the stack (it returns how many values).
void fire_with(std::string_view event, const std::function<int(lua_State*)>& push);
void close();
std::string id_;