Plugins: the reskate-test and reskate-hostile plugins, and trick reading and a facing teleport in the core

- contrib/plugins/reskate-test calls every plugin API and says what worked: ptest all, ptest
  <module>, ptest list. Its 45 checks cover the base set, timers, the level, package files, JSON,
  require, the console, storage and the sandbox. It is the example to copy for a plugin.
- contrib/plugins/reskate-hostile attacks the sandbox, one attack per command (hostile list):
  loops, memory and buffer bombs, caught errors, error and log floods, timers, recursion, an
  unload that never ends, and 17 ways out. Each must end stopped or refused.
- dingosdk_plugin_tests runs both from the repository: every reskate-test check passes, and every
  hostile attack is stopped or refused with nothing escaping.
- Taken from Domination (PR 279) for the plugin API to use: the trick watch (when a trick starts,
  lands and what it scored, or a slam breaks it; Domination's spots left out) in
  Extension/Skater/trick_watch.h with its tests, the score HUD reader in score_hud.h, and
  teleport_local_skater_heading, whose hunks are the PR's own. Neither is wired to anything yet.
This commit is contained in:
AyeZeeBB committed 2026-10-19 12:08:26 -05:00
1 parent 6d542536e1
commit a3e75ba10e
34 files changed
+978 -1

No files matched your search

+6
View File
@@ -412,7 +412,13 @@ if(DINGOSDK_BUILD_PLUGIN_TESTS AND WIN32)
add_executable(dingosdk_plugin_tests Extension/Plugins/Test/plugin_tests.cpp)
target_include_directories(dingosdk_plugin_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_plugin_tests PRIVATE dingosdk_plugins)
# It also runs the test plugins in contrib/plugins.
target_compile_definitions(dingosdk_plugin_tests PRIVATE "RESKATE_SOURCE_DIR=\"${PROJECT_SOURCE_DIR}\"")
add_test(NAME plugins COMMAND dingosdk_plugin_tests)
# Tricks read from the score HUD's values (Extension/Skater/trick_watch.h), what plugins hear.
add_executable(dingosdk_trick_watch_tests Extension/Skater/Test/trick_watch_tests.cpp Extension/Skater/trick_watch.cpp)
target_include_directories(dingosdk_trick_watch_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME trick_watch COMMAND dingosdk_trick_watch_tests)
endif()
get_property(project_targets DIRECTORY PROPERTY BUILDSYSTEM_TARGETS)
foreach(target IN LISTS project_targets)
@@ -631,9 +631,14 @@ void update_position_teleport() {
!read(local_runtime().base + addr::local_placements::teleport_manager, manager) || !manager ||
!read(manager, state) || state != 0) return;
const auto at = *r.position_teleport;
const auto heading = r.position_teleport_heading; // set only by teleport_local_skater_heading
r.position_teleport.reset();
r.position_teleport_heading.reset();
// Upright at the spot. The native teleport manager handles streaming, ground checks and physics reset.
alignas(16) const std::array<float, 16> transform{1,0,0,0, 0,1,0,0, 0,0,1,0, at[0], at[1], at[2], 1};
// With a heading h the root's rows are right (cos h, 0, -sin h), up, forward (sin h, 0, cos h):
// h = 0 is the identity, facing +Z (measured: the skater faces h within 0.002 degrees).
const float sh = heading ? std::sin(*heading) : 0.0f, ch = heading ? std::cos(*heading) : 1.0f;
alignas(16) const std::array<float, 16> transform{ch,0,-sh,0, 0,1,0,0, sh,0,ch,0, at[0], at[1], at[2], 1};
alignas(16) std::array<std::uint64_t, 2> transition{};
r.transition_ctor(transition.data());
struct Destroy { decltype(r.transition_destroy) call; void* value; ~Destroy() { call(value); } } destroy{r.transition_destroy, transition.data()};
@@ -78,6 +78,9 @@ struct PlacementsRuntime {
// A teleport of the local skater to a position (console tp, the host's tpall).
std::optional<std::array<float, 3>> position_teleport;
std::uint64_t position_teleport_at{};
// The heading the pending position teleport faces (teleport_local_skater_heading), radians about
// world up; unset means the identity rotation the other teleports use (facing +Z).
std::optional<float> position_teleport_heading;
std::optional<profile::PlacedObject> inflight;
std::uint64_t sent_at{}, next_poll{}, map_since{}, map_generation{}, next_id{1};
bool failed{}, restore_failed{};
+58
View File
@@ -438,6 +438,62 @@ void discovery_finds_the_plugins_among_the_mods_left_on() {
std::sort(again.begin(), again.end(), [](const auto& a, const auto& b) { return a.id < b.id; });
check(again[0].fingerprint != before, "A changed file changes the fingerprint, which reloads it");
}
// contrib/plugins, as they are in the repository.
Candidate contributed(const std::string& id) {
const auto directory = std::filesystem::path(RESKATE_SOURCE_DIR) / "contrib" / "plugins" / id;
return {id, directory, "1", check_package_files(directory)};
}
std::string printed_lines(const TestEnvironment& environment) {
std::string text;
for (const auto& line : environment.lines) text += line + "\n";
return text;
}
void reskate_test_passes_its_own_checks() {
TestEnvironment environment;
std::filesystem::remove_all(scratch / "storage" / "reskate-test");
Host host(environment, Side::client);
host.apply({contributed("reskate-test")});
running(host, "reskate-test", environment);
ticks(host, environment, 5);
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: 45 passed, 0 failed") != 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");
running(host, "reskate-test", environment);
}
void reskate_hostile_is_stopped_or_refused_every_time() {
TestEnvironment environment;
Host host(environment, Side::client);
host.apply({contributed("reskate-hostile")});
running(host, "reskate-hostile", environment);
struct Case { const char* attack; bool stops; int ticks; };
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},
};
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);
const auto state = status_of(host, "reskate-hostile").state;
check((state == "stopped") == item.stops, std::string(item.attack) + " left it " + state + "\n" + environment.all());
}
check(!environment.printed("ESCAPED"), "No attack got out:\n" + printed_lines(environment));
check(environment.printed("hostile escapes: 17 of 17 refused"), "and every escape was tried\n" + printed_lines(environment));
host.reload("reskate-hostile");
host.run_console("hostile slow-unload");
const auto start = std::chrono::steady_clock::now();
host.switch_off("reskate-hostile");
const auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
check(ms < 500, "An unload handler that never ends is cut off: " + std::to_string(ms) + " ms");
}
}
int main() {
@@ -458,6 +514,8 @@ int main() {
timers_run_when_due();
a_package_with_a_program_or_on_the_wrong_side_is_not_loaded();
discovery_finds_the_plugins_among_the_mods_left_on();
reskate_test_passes_its_own_checks();
reskate_hostile_is_stopped_or_refused_every_time();
std::filesystem::remove_all(scratch);
std::cout << "plugin tests passed\n";
return 0;
@@ -56,6 +56,19 @@ bool teleport_local_skater(const std::array<float, 3>& position) {
for (const auto v : position)
if (!std::isfinite(v) || std::abs(v) > 1e6f) return false;
r.position_teleport = position;
r.position_teleport_heading.reset(); // the identity rotation
r.position_teleport_at = GetTickCount64();
return true;
}
bool teleport_local_skater_heading(const std::array<float, 3>& position, float heading) {
std::lock_guard lock(local_runtime().native_mutex);
auto& r = placements_runtime();
if (!local_runtime().active || !r.teleport || !r.transition_ctor || !r.transition_destroy) return false;
for (const auto v : position)
if (!std::isfinite(v) || std::abs(v) > 1e6f) return false;
if (!std::isfinite(heading)) return false;
r.position_teleport = position;
r.position_teleport_heading = heading;
r.position_teleport_at = GetTickCount64();
return true;
}
@@ -85,6 +85,8 @@ bool teleport_to_local_placed_object(std::string_view map, std::uint64_t token);
// Teleports the local skater to a world position (sent within 5 s, when the skater can be
// moved). False when the game's teleport is unavailable.
bool teleport_local_skater(const std::array<float, 3>& position);
// The same, facing `heading` (radians about world up; 0 = +Z, the way the other teleports face).
bool teleport_local_skater_heading(const std::array<float, 3>& position, float heading);
// The highest collision surface straight down at (x, z) between world heights `top` and
// `bottom`, from the client physics world (the park editor's native ray). Empty when nothing
// is there yet (collision still streaming) or the query is unavailable. Client update thread.
+106
View File
@@ -0,0 +1,106 @@
// Reading tricks from the score HUD's values, apart from the game: a trick's start, its points
// once it lands, a slam, and a trick whose points never come. Moved with the trick watch from
// Domination (PR 279), which tests its spots on top.
#include "Extension/Skater/trick_watch.h"
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace dingosdk::tricks;
namespace {
void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); }
TrickSignals tick(bool action, float score, float multiplier, bool slam = false, bool wipeout = false) {
TrickSignals in;
in.action = action;
in.line_score = score;
in.multiplier = multiplier;
in.slam = slam;
in.wipeout = wipeout;
return in;
}
// The last event over `ticks` updates.
TrickEvent feed(TrickWatch& watch, const TrickSignals& in, int ticks) {
TrickEvent last;
for (int i = 0; i < ticks; ++i) {
const auto e = watch.update(in);
if (e.kind != TrickEvent::Kind::none) last = e;
}
return last;
}
using K = TrickEvent::Kind;
void a_trick_starts_then_lands_with_its_points() {
TrickWatch w;
feed(w, tick(false, 0, 0), 5);
check(w.update(tick(true, 0, 0)).kind == K::started, "A trick starts the tick ActionActive goes on");
check(feed(w, tick(true, 0, 0), 30).kind == K::none && w.in_trick(), "and says it once");
check(w.update(tick(false, 0, 0)).kind == K::none && w.waiting(), "Its points are not in the tick it ends");
const auto e = w.update(tick(false, 11, 1.5f));
check(e.kind == K::landed && std::abs(e.points - 11) < 0.01f && std::abs(e.multiplier - 1.5f) < 0.01f,
"Landed: 11 points, the first trick of a line");
}
void a_second_trick_in_a_line_scores_its_own_points() {
TrickWatch w;
feed(w, tick(false, 0, 0), 5);
feed(w, tick(true, 0, 0), 30);
w.update(tick(false, 0, 0));
w.update(tick(false, 11, 1.5f));
feed(w, tick(true, 11, 1.5f), 20);
w.update(tick(false, 11, 1.5f));
const auto f = w.update(tick(false, 708, 2.0f));
const float points = (708 / 2.0f - 11 / 1.5f) * 2.0f;
check(f.kind == K::landed && std::abs(f.points - points) < 0.01f, "The multiplier's rise scales only the new trick's points");
feed(w, tick(true, 708, 2.0f), 20);
w.update(tick(false, 708, 2.0f));
const auto g = w.update(tick(false, 958, 2.0f));
check(g.kind == K::landed && std::abs(g.points - 250) < 0.01f, "and the next one its own again");
}
void a_slam_ends_the_trick_for_nothing() {
TrickWatch w;
feed(w, tick(false, 0, 1), 2);
feed(w, tick(true, 0, 1), 10);
feed(w, tick(false, 300, 1), 2);
feed(w, tick(true, 300, 1), 10);
const auto e = w.update(tick(true, 300, 1, true));
check(e.kind == K::broken && !w.in_trick(), "A slam ends the trick going on");
check(feed(w, tick(false, 0, 0), 30).kind == K::none, "which scores nothing");
check(feed(w, tick(false, 0, 0, false, true), 2).kind == K::none, "A wipeout with nothing going on says nothing");
feed(w, tick(true, 0, 1), 5);
check(w.update(tick(false, 0, 1, false, true)).kind == K::broken, "and one while the points are awaited breaks it");
}
void a_trick_whose_points_never_come_is_skipped() {
TrickWatch w(5);
feed(w, tick(false, 50, 1), 2);
feed(w, tick(true, 50, 1), 10);
const auto none = feed(w, tick(false, 50, 1), 10);
check(!w.waiting(), "Waiting gives up after its ticks");
feed(w, tick(true, 50, 1), 10);
const auto e = feed(w, tick(false, 90, 1), 2);
check(none.kind == K::none && e.kind == K::landed && std::abs(e.points - 40) < 0.01f, "and the next trick is still read");
}
void the_line_ending_is_not_a_trick() {
TrickWatch w;
feed(w, tick(false, 500, 2), 5);
check(feed(w, tick(false, 0, 0), 5).kind == K::none, "The line's score going back to 0 is no event");
check(feed(w, tick(false, 100, 1), 5).kind == K::none, "nor is a rise with no trick before it");
}
}
int main() {
try {
a_trick_starts_then_lands_with_its_points();
a_second_trick_in_a_line_scores_its_own_points();
a_slam_ends_the_trick_for_nothing();
a_trick_whose_points_never_come_is_skipped();
the_line_ending_is_not_a_trick();
std::cout << "trick watch tests passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << "trick watch test failed: " << error.what() << '\n';
return 1;
}
}
+141
View File
@@ -0,0 +1,141 @@
#include "score_hud.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Platform/memory.h"
#include "Engine/Game/Abi/native_data.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/engine.h"
#include "Engine/Game/Build/20260929/hud_corner.h"
#include "Extension/UI/NativeMenu/native_menu_data.h"
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstring>
#include <format>
#include <string>
#include <utility>
namespace dingosdk::tricks {
namespace {
namespace menu_data = multiplayer::menu_data;
using Address = std::uintptr_t;
using Clock = std::chrono::steady_clock;
// ScoringHUDViewModel's fields, by the hashes of their names in the game's own type data
// (ui/features/hud/scoring/ScoringHUDViewModel; HudWidgetActive 0x3aa2f739 and ExtraInfoStyle
// 0x0426783f are the ones hud_corner.h already uses).
struct Watched {
const char* name;
std::uint32_t hash;
};
enum : std::size_t { line_score, is_slam, multiplier, action_active };
constexpr std::array<Watched, 4> watched{{
{"LineScore", 1086322279u},
{"IsSlam", 2943226809u},
{"Multiplier", 1782700230u},
{"ActionActive", 2058053468u},
}};
constexpr std::size_t value_bytes = 16; // read per field
struct Bound {
bool present{};
std::uint16_t offset{};
unsigned size{}, kind{};
std::array<unsigned char, value_bytes> now{};
bool read{};
};
struct Hud {
Clock::time_point next_look;
Address manager{};
menu_data::Value model;
std::array<Bound, watched.size()> fields{};
bool warned{};
};
Hud hud; // client thread
// A score field's value: the game's widget logic keeps scores as floats (kind 19, as LineTimer
// and Multiplier); an integer reading is taken if the float one is not sensible.
float score_of(const Bound& field) {
if (!field.present || !field.read || field.size != 4) return 0;
float f{};
std::int32_t i{};
std::memcpy(&f, field.now.data(), 4);
std::memcpy(&i, field.now.data(), 4);
if (std::isfinite(f) && (f == 0 || (std::abs(f) > 1e-3f && std::abs(f) < 1e9f))) return f;
return static_cast<float>(i);
}
bool flag_of(const Bound& field) { return field.present && field.read && field.now[0] != 0; }
// Runs `use(context)` on the game's UI model registry under its model lock (as Hall of Meat's
// HUD code does). False while there is no registry.
template <class Use> bool with_ui_models(Address base, Use&& use) {
if (!base) return false;
const auto ui = menu_data::read<Address>(base + addr::engine::ui_manager);
const auto manager = ui ? menu_data::read<Address>(ui + 0x140) : 0;
if (!manager) return false;
const menu_data::Context context(base, manager);
game::ModelWriteLock lock(manager);
use(context);
return true;
}
bool bind_model(Hud& h, const menu_data::Context& context) {
if (h.manager != context.manager) {
h.manager = context.manager;
h.model = {};
}
if (h.model.handle && context.type_of(h.model.handle) == h.model.type) return true;
h.model = {};
const auto now = Clock::now();
if (now < h.next_look) return false;
h.next_look = now + std::chrono::seconds(1);
const auto roots = context.roots({addr::hud_corner::score_view_schema});
if (roots.empty()) return false;
const auto model = roots.front().model;
std::string layout;
bool any = false;
for (std::size_t n = 0; n < watched.size(); ++n) {
auto& field = h.fields[n];
field = {};
try {
const auto member = context.member(model.type, watched[n].hash);
field.present = any = true;
field.offset = member.offset;
field.size = menu_data::size(member.type);
field.kind = menu_data::kind(member.type);
layout += std::format(" {}@+0x{:x}:{}b/k{}", watched[n].name, field.offset, field.size, field.kind);
} catch (const std::exception&) {
layout += std::format(" {}:missing", watched[n].name);
}
}
if (!any) return false;
h.model = model;
logging::write(logging::Level::info, logging::Channel::skater, "Score HUD model found:" + layout);
return true;
}
}
std::optional<ScoreHud> read_score_hud(std::uintptr_t image_base) noexcept {
auto& h = hud;
bool ok = false;
try {
with_ui_models(image_base, [&](const menu_data::Context& context) {
if (!bind_model(h, context)) return;
const auto at = context.address(h.model);
for (auto& field : h.fields)
field.read = field.present && memory::peek_bytes(at + field.offset, field.now.data(), std::min<unsigned>(field.size, value_bytes));
ok = true;
});
h.warned = false;
} catch (const std::exception& error) {
h.model = {};
if (!std::exchange(h.warned, true))
logging::log(logging::Level::warning, logging::Channel::skater, "The score HUD cannot be read: {}", error.what());
} catch (...) {
h.model = {};
}
if (!ok) return std::nullopt;
const auto& f = h.fields;
return ScoreHud{flag_of(f[action_active]), flag_of(f[is_slam]), score_of(f[line_score]), score_of(f[multiplier])};
}
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <cstdint>
#include <optional>
// skate.'s score HUD as values (ScoringHUDViewModel, read through the game's UI models), what
// trick_watch.h turns into tricks. Moved from Domination (PR 279).
namespace dingosdk::tricks {
struct ScoreHud {
bool action{}; // a trick is going on
bool slam{};
float line_score{}, multiplier{};
};
// Client thread. Empty while there is no score HUD model (the menu, a level loading), or when this
// game build's model has none of the fields; the model is looked for at most once a second.
std::optional<ScoreHud> read_score_hud(std::uintptr_t image_base) noexcept;
}
+52
View File
@@ -0,0 +1,52 @@
#include "trick_watch.h"
#include <algorithm>
#include <utility>
namespace dingosdk::tricks {
namespace {
// The line's points before the multiplier.
float unscaled(float score, float multiplier) { return multiplier > 0.01f ? score / multiplier : score; }
}
TrickEvent TrickWatch::update(const TrickSignals& in) {
if (!std::exchange(seen_, true)) {
score_ = in.line_score;
multiplier_ = in.multiplier;
}
// A slam or a wipeout: skate. drops the line, and the trick going on scores nothing.
if (in.slam || in.wipeout) {
const bool had = action_ || waiting_;
action_ = false;
waiting_ = 0;
score_ = in.line_score;
multiplier_ = in.multiplier;
return had ? TrickEvent{TrickEvent::Kind::broken} : TrickEvent{};
}
if (in.action) {
if (action_) return {};
// A trick starts (a previous one whose points never came scored nothing).
action_ = true;
waiting_ = 0;
return {TrickEvent::Kind::started};
}
if (action_) { // the trick ended this tick: its points show in the line's score shortly
action_ = false;
waiting_ = wait_ticks_;
}
if (waiting_ > 0) {
if (in.line_score > score_ + 0.5f) {
const float scale = in.multiplier > 0.01f ? in.multiplier : 1.0f;
const float points = std::max(0.0f, (unscaled(in.line_score, in.multiplier) - unscaled(score_, multiplier_)) * scale);
score_ = in.line_score;
multiplier_ = in.multiplier;
waiting_ = 0;
return {TrickEvent::Kind::landed, points, scale};
}
if (--waiting_ > 0) return {};
}
// No trick pending: the line's score follows whatever else moves it (its end resets it to 0).
score_ = in.line_score;
multiplier_ = in.multiplier;
return {};
}
}
+40
View File
@@ -0,0 +1,40 @@
#pragma once
// When a trick lands and what it was worth, from the score HUD's values each tick (score_hud.h).
// Apart from the game, so its rules are tested alone. Moved from Domination (PR 279), which adds
// its spots on top: the start event is where a caller records where a trick began.
//
// Measured in play (a recording of the score HUD's values in a play session, 2026-10-14):
// ActionActive is on while a trick goes on; LineScore rises one tick after it goes off. LineScore
// looks like the line's points so far times the Multiplier (a rise in the multiplier also raises
// earlier tricks' share), so a trick's points are taken from the unscaled rise, at the new
// multiplier (not yet checked against the game's own trick list).
namespace dingosdk::tricks {
// The score HUD's values (ScoringHUDViewModel), and whether the skater is in a wipeout (physics
// states 300-399).
struct TrickSignals {
bool action{};
float line_score{}, multiplier{};
bool slam{}, wipeout{};
};
struct TrickEvent {
enum class Kind { none, started, landed, broken } kind{Kind::none};
float points{}; // landed: the trick's points at the multiplier it raised the line to
float multiplier{}; // landed: that multiplier
};
class TrickWatch {
public:
// `wait_ticks`: how long after a trick ends its points may take to show in the line's score.
explicit TrickWatch(int wait_ticks = 20) : wait_ticks_(wait_ticks) {}
TrickEvent update(const TrickSignals& signals);
bool in_trick() const noexcept { return action_; }
// Waiting for the points of a trick that just ended.
bool waiting() const noexcept { return waiting_ > 0; }
private:
int wait_ticks_;
bool seen_{}, action_{};
int waiting_{};
float score_{}, multiplier_{}; // the line's score and multiplier before the trick
};
}
+2
View File
@@ -181,6 +181,8 @@ add_library(dingosdk_runtime SHARED
Extension/Trainer/trainer_waypoint.cpp
Extension/Skater/offboard_flight.cpp
Extension/Skater/camera_observer.cpp
Extension/Skater/trick_watch.cpp
Extension/Skater/score_hud.cpp
Extension/Boot/offline_boot.cpp
Extension/Progression/progression_service_guard.cpp
Extension/Progression/mission_progression_override.cpp
+15
View File
@@ -0,0 +1,15 @@
# Test plugins
Two plugins that check ReSkate's plugin host (`Extension/Plugins`). To try one in game, copy its
folder into the game's `Mods` folder and open the console.
- **reskate-test** calls every plugin API and says what worked. `ptest all` runs every check,
`ptest <module>` one module, and `ptest list` names them. It prints only the failures and a
summary line. Each API ReSkate adds gets a module in `plugin/checks/`. It's also the example to
copy when making a plugin.
- **reskate-hostile** attacks the sandbox on purpose, one attack per command. `hostile list` names
them with what should happen. An attack that gets it stopped is expected: `plugin on
reskate-hostile` starts it again for the next one. Any line starting with `ESCAPED` is a hole in
the sandbox.
Both also run without the game, in `dingosdk_plugin_tests` (`DINGOSDK_BUILD_PLUGIN_TESTS`).
@@ -0,0 +1,7 @@
{
"name": "ReSkate_Hostile",
"version_number": "0.1.0",
"website_url": "https://github.com/Dingo-Shenanigans/ReSkate",
"description": "Attacks the ReSkate plugin sandbox on purpose, to check it holds. For testing only.",
"dependencies": []
}
@@ -0,0 +1,112 @@
-- reskate-hostile: a plugin written to break the sandbox, one attack per command. Every attack
-- must end with this plugin stopped or refused and the game running as before. `hostile list`
-- names them; after one that stops it, `plugin on reskate-hostile` starts it again.
local attacks, order = {}, {}
local function attack(name, expect, fn)
attacks[name] = {expect = expect, fn = fn}
table.insert(order, name)
end
attack("loop", "stopped: longer than 50 ms", function()
while true do end
end)
attack("caught-loop", "stopped: longer than 50 ms, though it catches the error", function()
while true do pcall(function() while true do end end) end
end)
attack("tick-loop", "stopped at the next tick", function()
reskate.on("tick", function() while true do end end)
end)
attack("memory", "stopped: more than 64 MB", function()
local t = {}
while true do t[#t + 1] = string.rep("x", 8000000) end
end)
attack("caught-memory", "stopped: more than 64 MB, though it catches the error", function()
pcall(function()
local t = {}
while true do t[#t + 1] = string.rep("y", 8000000) end
end)
reskate.console.print("ESCAPED: carried on after a memory bomb")
end)
attack("huge-string", "stopped: more than 64 MB", function()
local s = string.rep("x", 100000000)
return #s
end)
attack("buffer", "stopped: more than 64 MB", function()
local b = buffer.create(1e9)
return buffer.len(b)
end)
attack("coroutines", "stopped: its time or its memory", function()
while true do coroutine.wrap(function() end)() end
end)
attack("errors", "stopped after 5 errors in 10 s", function()
reskate.on("tick", function() error("hostile: an error every tick") end)
end)
attack("slow", "stopped after 3 s over 2 ms a tick", function()
reskate.on("tick", function()
local start = os.clock()
while os.clock() - start < 0.004 do end
end)
end)
attack("log-flood", "keeps running: 50 of the 1000 lines are logged", function()
for i = 1, 1000 do reskate.log("hostile flood " .. i) end
end)
attack("timers", "keeps running: refused past 1024 timers", function()
for i = 1, 2000 do reskate.time.after(600000, function() end) end
end)
attack("recursion", "keeps running: an error, not a crash", function()
local function deep(n) return deep(n + 1) + 1 end
return deep(1)
end)
attack("slow-unload", "an unload handler that never ends: cut off at 20 ms when it is switched off", function()
reskate.on("unload", function() while true do end end)
reskate.console.print("hostile: now switch it off: plugin off reskate-hostile")
end)
-- Each returns true when that way out worked.
local escapes = {
{"io", function() return io ~= nil end},
{"loadstring or load", function() return loadstring ~= nil or load ~= nil end},
{"dofile or loadfile", function() return dofile ~= nil or loadfile ~= nil end},
{"package", function() return package ~= nil end},
{"os beyond the clock", function() return os.execute ~= nil or os.getenv ~= nil or os.remove ~= nil or os.exit ~= nil end},
{"changing reskate", function() return pcall(function() reskate.log = nil end) end},
{"rawset on reskate", function() return pcall(rawset, reskate, "x", 1) end},
{"a metatable on reskate", function() return pcall(setmetatable, reskate, {}) end},
{"changing a library", function() return pcall(function() string.rep = nil end) end},
{"the string metatable", function() return pcall(function() getmetatable("").__index = {} end) end},
{"reading outside the package", function() return pcall(reskate.package.read, "../../../Windows/win.ini") end},
{"a junction-style path", function() return pcall(reskate.package.read, "..\\..\\Windows\\win.ini") end},
{"require outside", function() return pcall(require, "C:/Windows/win.ini") end},
{"require up", function() return pcall(require, "../manifest") end},
{"taking a plugin command", function() return pcall(reskate.console.command, "plugin off", "x", function() end) end},
{"taking a ReSkate command", function() return pcall(reskate.console.command, "load", "x", function() end) end},
{"storage without the permission", function() return pcall(reskate.storage.get, "x") end},
}
attack("escapes", "keeps running: every way out refused", function()
local escaped = 0
for _, try in escapes do
local ok, worked = pcall(try[2])
if ok and worked then
escaped += 1
reskate.console.print("ESCAPED: " .. try[1])
end
end
reskate.console.print("hostile escapes: " .. (#escapes - escaped) .. " of " .. #escapes .. " refused")
end)
reskate.console.command("hostile", "Attacks the plugin sandbox: hostile list, hostile <attack>", function(args)
local name = args[1]
if not name or name == "list" then
for _, n in order do reskate.console.print(n .. ": " .. attacks[n].expect) end
return
end
local found = attacks[name]
if not found then
reskate.console.print("hostile: there is no attack " .. name .. " (hostile list names them)")
return
end
reskate.console.print("hostile: " .. name .. ", expected: " .. found.expect)
found.fn()
end)
reskate.log("reskate-hostile loaded: hostile list names its attacks")
@@ -0,0 +1,6 @@
{
"api": 1,
"entry": "plugin/main.lua",
"side": "client",
"permissions": ["console"]
}
@@ -0,0 +1 @@
{"name": "sample", "list": [1, 2, 3], "nested": {"yes": true}}
@@ -0,0 +1 @@
ptest sample
@@ -0,0 +1,7 @@
{
"name": "ReSkate_Test",
"version_number": "0.1.0",
"website_url": "https://github.com/Dingo-Shenanigans/ReSkate",
"description": "Checks every ReSkate plugin API: run ptest all in the console.",
"dependencies": []
}
@@ -0,0 +1,25 @@
local h = require("harness")
local t = h.module("base")
local ticks, loaded, last_dt = 0, false, nil
reskate.on("load", function() loaded = true end)
reskate.on("tick", function(dt)
ticks += 1
last_dt = dt
end)
t.check("api_version is 1", function() assert(reskate.api_version == 1, "it is " .. tostring(reskate.api_version)) end)
t.check("id is the mod folder's name", function() assert(type(reskate.id) == "string" and #reskate.id > 0) end)
t.check("the load handler ran", function() assert(loaded) end)
t.check("tick handlers run, with the time since the last tick", function()
assert(ticks > 0, "no tick yet")
assert(type(last_dt) == "number" and last_dt >= 0, "dt is " .. tostring(last_dt))
end)
t.check("log takes info, warn and error", function()
reskate.log("ptest: an info line")
reskate.log("ptest: a warning line", "warn")
end)
t.check("print goes to the log", function() print("ptest: printed", 1, true) end)
t.check("a made-up log level is refused", function() h.refused(reskate.log, "x", "loud") end)
t.check("a made-up event is refused", function() h.refused(reskate.on, "made_up", function() end) end)
t.check("an event needs a function", function() h.refused(reskate.on, "tick", 5) end)
@@ -0,0 +1,25 @@
local h = require("harness")
local t = h.module("console")
-- Try it by hand: ptest echo a b c
reskate.console.command("ptest echo", "Prints its words back (reskate-test)", function(args)
reskate.console.print("ptest echo: " .. table.concat(args, " "))
end)
t.check("print works", function() reskate.console.print("ptest: console.print works") end)
t.check("a ReSkate command's name is refused", function()
h.refused(reskate.console.command, "load mine", "x", function() end)
h.refused(reskate.console.command, "plugin mine", "x", function() end)
h.refused(reskate.console.command, "help mine", "x", function() end)
end)
t.check("names must be 1 to 3 lowercase words", function()
for _, name in {"", "Bad", "a b c d", " lead", "trail ", "two spaces", "sym!"} do
h.refused(reskate.console.command, name, "x", function() end)
end
end)
t.check("a name registered already is refused", function()
h.refused(reskate.console.command, "ptest", "x", function() end)
end)
t.check("a command needs a function", function()
h.refused(reskate.console.command, "ptest other", "x", 5)
end)
@@ -0,0 +1,22 @@
local h = require("harness")
local t = h.module("json")
t.check("encode and decode round trip", function()
local back = reskate.json.decode(reskate.json.encode({a = {1, 2, 3}, b = "text", c = true, d = 1.5}))
assert(back.a[3] == 3 and back.b == "text" and back.c == true and back.d == 1.5)
end)
t.check("encode can be pretty", function()
assert(reskate.json.encode({a = 1}, true):find("\n", 1, true))
end)
t.check("bad JSON is refused", function()
h.refused(reskate.json.decode, "{nope")
h.refused(reskate.json.decode, "")
end)
t.check("values JSON cannot hold are refused", function()
h.refused(reskate.json.encode, print)
h.refused(reskate.json.encode, 0 / 0)
h.refused(reskate.json.encode, {[{}] = 1})
local cycle = {}
cycle.me = cycle
h.refused(reskate.json.encode, cycle)
end)
@@ -0,0 +1,15 @@
local t = require("harness").module("level")
local heard = {}
reskate.on("level_loaded", function(name) table.insert(heard, "loaded " .. name) end)
reskate.on("level_unloading", function(name) table.insert(heard, "leaving " .. name) end)
t.check("current is nil, or a table with the level's name", function()
local level = reskate.level.current()
assert(level == nil or (type(level) == "table" and type(level.name) == "string" and #level.name > 0))
end)
t.check("level events name the level", function()
for _, line in heard do
assert(#line > #"loaded ", "an event without a name: " .. line)
end
end)
@@ -0,0 +1,27 @@
local h = require("harness")
local t = h.module("package")
t.check("read returns a file's text", function()
local text = reskate.package.read("data/sample.txt")
assert(text and text:sub(1, 12) == "ptest sample", tostring(text))
end)
t.check("read returns nil for a file that is not there", function()
assert(reskate.package.read("data/missing.txt") == nil)
end)
t.check("read_json returns the file as tables", function()
local data = reskate.package.read_json("data/sample.json")
assert(data.name == "sample" and data.list[3] == 3 and data.nested.yes == true)
end)
t.check("list names a folder's files, sorted", function()
local names = table.concat(reskate.package.list("data"), ",")
assert(names == "sample.json,sample.txt", names)
end)
t.check("list with no folder is the package, folders ending in /", function()
local names = table.concat(reskate.package.list(), ",")
assert(names:find("plugin/", 1, true) and names:find("reskate-plugin.json", 1, true), names)
end)
t.check("paths out of the package are refused", function()
for _, path in {"../x", "/x", "C:/Windows/win.ini", "data\\sample.txt", "data/../../x", "data/./sample.txt"} do
h.refused(reskate.package.read, path)
end
end)
@@ -0,0 +1,17 @@
local h = require("harness")
local t = h.module("require")
t.check("a module runs once, and every require gets it", function()
local first = require("lib/counter")
assert(require("./lib/counter") == first, "a second copy")
assert(first.loads() == 1, "it ran " .. first.loads() .. " times")
end)
t.check("a module that requires itself is an error", function()
local why = h.refused(require, "lib/cycle_a")
assert(why:find("requires itself", 1, true), why)
end)
t.check("a module that is not there is an error", function() h.refused(require, "lib/missing") end)
t.check("a module outside the package is refused", function()
h.refused(require, "../manifest")
h.refused(require, "C:/Windows/win")
end)
@@ -0,0 +1,28 @@
local h = require("harness")
local t = h.module("sandbox")
t.check("no files, programs or code loading", function()
assert(io == nil and package == nil and dofile == nil and loadfile == nil, "a file library")
assert(loadstring == nil and load == nil, "code loading")
assert(os.execute == nil and os.remove == nil and os.rename == nil and os.getenv == nil and os.exit == nil, "os")
end)
t.check("the reskate table cannot be changed", function()
h.refused(function() reskate.log = nil end)
h.refused(function() reskate.storage.get = print end)
h.refused(rawset, reskate, "x", 1)
h.refused(setmetatable, reskate, {})
end)
t.check("the libraries cannot be changed", function()
h.refused(function() string.rep = nil end)
h.refused(function() table.insert = nil end)
h.refused(function() getmetatable("").__index = {} end)
end)
t.check("its own globals work", function()
ptest_global = 1
assert(ptest_global == 1)
ptest_global = nil
end)
t.check("endless recursion is an error, not a crash", function()
local function deep(n) return deep(n + 1) + 1 end
h.refused(deep, 1)
end)
@@ -0,0 +1,49 @@
local h = require("harness")
local t = h.module("storage")
-- Its keys start with ptest. and are removed after each check.
local function clean()
for _, key in reskate.storage.keys() do
if key:sub(1, 6) == "ptest." then reskate.storage.set(key, nil) end
end
end
t.check("set and get keep tables, lists, text and numbers", function()
reskate.storage.set("ptest.table", {a = 1, list = {1, 2, 3}, name = "x", yes = true})
local back = reskate.storage.get("ptest.table")
clean()
assert(back.a == 1 and back.list[3] == 3 and back.name == "x" and back.yes == true)
end)
t.check("add counts, from nothing", function()
assert(reskate.storage.add("ptest.count") == 1)
assert(reskate.storage.add("ptest.count", 4) == 5)
reskate.storage.set("ptest.text", "x")
local why = h.refused(reskate.storage.add, "ptest.text")
clean()
assert(why:find("number", 1, true), why)
end)
t.check("nil removes a key", function()
reskate.storage.set("ptest.gone", 1)
reskate.storage.set("ptest.gone", nil)
assert(reskate.storage.get("ptest.gone") == nil)
end)
t.check("keys lists them", function()
reskate.storage.set("ptest.listed", 1)
local found = table.find(reskate.storage.keys(), "ptest.listed")
clean()
assert(found)
end)
t.check("a plugin has 1 MB", function()
local why = h.refused(reskate.storage.set, "ptest.big", string.rep("x", 2000000))
assert(why:find("full", 1, true), why)
assert(reskate.storage.get("ptest.big") == nil, "the failed write was kept")
end)
t.check("bad keys and values are refused", function()
h.refused(reskate.storage.set, "", 1)
h.refused(reskate.storage.set, string.rep("k", 200), 1)
h.refused(reskate.storage.set, "ptest.f", print)
local cycle = {}
cycle.me = cycle
h.refused(reskate.storage.set, "ptest.c", cycle)
clean()
end)
@@ -0,0 +1,30 @@
local h = require("harness")
local t = h.module("time")
t.check("now is a number that moves forward", function()
local a = reskate.time.now()
assert(type(a) == "number" and a > 0)
assert(reskate.time.now() >= a)
end)
t.later("after runs once", 2000, function(done)
local count = 0
reskate.time.after(50, function() count += 1 end)
reskate.time.after(400, function() done(count == 1, "it ran " .. count .. " times") end)
end)
t.later("every repeats, and cancel stops it", 2000, function(done)
local count = 0
local id = reskate.time.every(50, function() count += 1 end)
reskate.time.after(300, function()
reskate.time.cancel(id)
local at = count
reskate.time.after(200, function()
done(count >= 3 and count == at, "it counted " .. count .. ", and " .. at .. " when cancelled")
end)
end)
end)
t.check("negative, NaN and zero repeats are refused", function()
h.refused(reskate.time.after, -1, print)
h.refused(reskate.time.after, 0 / 0, print)
h.refused(reskate.time.every, 0, print)
h.refused(reskate.time.after, 10, "not a function")
end)
@@ -0,0 +1,104 @@
-- The test runner: each file in checks/ is a module of checks, and `ptest` runs them. A check
-- passes when its function returns without an error; a later check passes when it calls done(true)
-- in time. Only failures and the summary are printed, to stay under the 50 lines a second a plugin has.
local harness = {}
local modules, order = {}, {}
local running = nil
function harness.module(name)
if not modules[name] then
modules[name] = {}
table.insert(order, name)
end
local list = modules[name]
local m = {}
function m.check(title, fn)
table.insert(list, {title = title, fn = fn})
end
-- fn(done): call done(ok, why) within `within` ms of the run starting.
function m.later(title, within, fn)
table.insert(list, {title = title, fn = fn, within = within})
end
return m
end
-- What the call raised; an error when it was allowed.
function harness.refused(fn, ...)
local ok, err = pcall(fn, ...)
if ok then error("was allowed", 2) end
return tostring(err)
end
function harness.names()
return table.clone(order)
end
function harness.has(name)
return modules[name] ~= nil
end
local function finish(run)
running = nil
local shown = 0
for _, line in run.failures do
shown += 1
if shown > 20 then
reskate.console.print("FAIL ... and " .. (#run.failures - 20) .. " more")
break
end
reskate.console.print("FAIL " .. line)
end
local parts = {}
for _, name in run.names do
local tally = run.per[name]
table.insert(parts, name .. " " .. tally.passed .. "/" .. tally.total)
end
reskate.console.print("ptest: " .. run.passed .. " passed, " .. run.failed .. " failed (" .. table.concat(parts, ", ") .. ")")
end
local function record(run, name, title, ok, why)
local tally = run.per[name]
if ok then
run.passed += 1
tally.passed += 1
else
run.failed += 1
table.insert(run.failures, name .. ": " .. title .. ": " .. tostring(why or "failed"))
end
end
function harness.run(names)
if running then
reskate.console.print("ptest: a run is still going on")
return
end
local run = {names = names, passed = 0, failed = 0, pending = 0, failures = {}, per = {}}
running = run
for _, name in names do
run.per[name] = {passed = 0, total = #modules[name]}
end
for _, name in names do
for _, item in modules[name] do
if item.within then
run.pending += 1
local finished = false
local function done(ok, why)
if finished then return end
finished = true
record(run, name, item.title, ok, why)
run.pending -= 1
if run.pending == 0 then finish(run) end
end
reskate.time.after(item.within, function() done(false, "did not finish within " .. item.within .. " ms") end)
local ok, err = pcall(item.fn, done)
if not ok then done(false, err) end
else
local ok, err = pcall(item.fn)
record(run, name, item.title, ok, err)
end
end
end
if run.pending == 0 then finish(run) end
end
return harness
@@ -0,0 +1,5 @@
-- For the require checks: counts how many times this module ran.
ptest_counter_loads = (ptest_counter_loads or 0) + 1
return {
loads = function() return ptest_counter_loads end,
}
@@ -0,0 +1,2 @@
-- For the require checks: a and b require each other.
return require("lib/cycle_b")
@@ -0,0 +1,2 @@
-- For the require checks: a and b require each other.
return require("lib/cycle_a")
@@ -0,0 +1,22 @@
-- reskate-test: calls every plugin API and says what worked. Run `ptest all` in the console, or
-- `ptest <module>` for one (`ptest list` names them). Each API ReSkate adds gets a module here.
local harness = require("harness")
for _, name in {"base", "time", "level", "package", "json", "require", "console", "storage", "sandbox"} do
require("checks/" .. name)
end
reskate.console.command("ptest", "Checks the plugin API: ptest all, ptest <module>, ptest list", function(args)
local what = args[1] or "all"
if what == "list" then
reskate.console.print("ptest modules: " .. table.concat(harness.names(), ", "))
elseif what == "all" then
harness.run(harness.names())
elseif harness.has(what) then
harness.run({what})
else
reskate.console.print("ptest: there is no module " .. what .. " (ptest list names them)")
end
end)
reskate.log("reskate-test loaded: ptest all checks every plugin API")
@@ -0,0 +1,11 @@
{
"api": 1,
"entry": "plugin/main.lua",
"side": "client",
"permissions": ["console", "storage", "overlay", "menu", "ui.screen", "input", "hud", "events.tricks",
"events.skater", "skater.read", "skater.teleport", "skater.control", "skater.animation",
"trainer", "camera", "world.raycast", "world.objects", "world.environment", "world.ai",
"travel", "session.read", "session.skaters", "session.nametags", "net.session", "net.http",
"voice.control", "chat.read", "chat.send", "throwdowns", "replay", "music",
"progression.read", "settings.read", "audio.ui", "mods.read"]
}