diff --git a/CMakeLists.txt b/CMakeLists.txt index ab609f8b..f1cea474 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/Extension/Objects/local_placements_runtime.cpp b/Extension/Objects/local_placements_runtime.cpp index fcc0c81d..7fd21167 100644 --- a/Extension/Objects/local_placements_runtime.cpp +++ b/Extension/Objects/local_placements_runtime.cpp @@ -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 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 transform{ch,0,-sh,0, 0,1,0,0, sh,0,ch,0, at[0], at[1], at[2], 1}; alignas(16) std::array transition{}; r.transition_ctor(transition.data()); struct Destroy { decltype(r.transition_destroy) call; void* value; ~Destroy() { call(value); } } destroy{r.transition_destroy, transition.data()}; diff --git a/Extension/Objects/local_placements_runtime.h b/Extension/Objects/local_placements_runtime.h index 511d3d4c..197c8faa 100644 --- a/Extension/Objects/local_placements_runtime.h +++ b/Extension/Objects/local_placements_runtime.h @@ -78,6 +78,9 @@ struct PlacementsRuntime { // A teleport of the local skater to a position (console tp, the host's tpall). std::optional> 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 position_teleport_heading; std::optional inflight; std::uint64_t sent_at{}, next_poll{}, map_since{}, map_generation{}, next_id{1}; bool failed{}, restore_failed{}; diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 7a5f08da..a731c4b7 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -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(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; diff --git a/Extension/Profile/local_profile_objects.cpp b/Extension/Profile/local_profile_objects.cpp index 64087b28..c70914a5 100644 --- a/Extension/Profile/local_profile_objects.cpp +++ b/Extension/Profile/local_profile_objects.cpp @@ -56,6 +56,19 @@ bool teleport_local_skater(const std::array& 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& 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; } diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index 88110207..01eaeadd 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -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& 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& 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. diff --git a/Extension/Skater/Test/trick_watch_tests.cpp b/Extension/Skater/Test/trick_watch_tests.cpp new file mode 100644 index 00000000..051a7996 --- /dev/null +++ b/Extension/Skater/Test/trick_watch_tests.cpp @@ -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 +#include +#include + +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; + } +} diff --git a/Extension/Skater/score_hud.cpp b/Extension/Skater/score_hud.cpp new file mode 100644 index 00000000..0750188e --- /dev/null +++ b/Extension/Skater/score_hud.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +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{{ + {"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 now{}; + bool read{}; +}; +struct Hud { + Clock::time_point next_look; + Address manager{}; + menu_data::Value model; + std::array 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(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 bool with_ui_models(Address base, Use&& use) { + if (!base) return false; + const auto ui = menu_data::read
(base + addr::engine::ui_manager); + const auto manager = ui ? menu_data::read
(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 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(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])}; +} +} diff --git a/Extension/Skater/score_hud.h b/Extension/Skater/score_hud.h new file mode 100644 index 00000000..495805f1 --- /dev/null +++ b/Extension/Skater/score_hud.h @@ -0,0 +1,16 @@ +#pragma once +#include +#include + +// 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 read_score_hud(std::uintptr_t image_base) noexcept; +} diff --git a/Extension/Skater/trick_watch.cpp b/Extension/Skater/trick_watch.cpp new file mode 100644 index 00000000..062961a5 --- /dev/null +++ b/Extension/Skater/trick_watch.cpp @@ -0,0 +1,52 @@ +#include "trick_watch.h" +#include +#include + +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 {}; +} +} diff --git a/Extension/Skater/trick_watch.h b/Extension/Skater/trick_watch.h new file mode 100644 index 00000000..16a08567 --- /dev/null +++ b/Extension/Skater/trick_watch.h @@ -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 +}; +} diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 2abeef33..8ef56459 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -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 diff --git a/contrib/plugins/README.md b/contrib/plugins/README.md new file mode 100644 index 00000000..12903e85 --- /dev/null +++ b/contrib/plugins/README.md @@ -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 ` 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`). diff --git a/contrib/plugins/reskate-hostile/manifest.json b/contrib/plugins/reskate-hostile/manifest.json new file mode 100644 index 00000000..66fe2ac6 --- /dev/null +++ b/contrib/plugins/reskate-hostile/manifest.json @@ -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": [] +} diff --git a/contrib/plugins/reskate-hostile/plugin/main.lua b/contrib/plugins/reskate-hostile/plugin/main.lua new file mode 100644 index 00000000..5dd9baa9 --- /dev/null +++ b/contrib/plugins/reskate-hostile/plugin/main.lua @@ -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 ", 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") diff --git a/contrib/plugins/reskate-hostile/reskate-plugin.json b/contrib/plugins/reskate-hostile/reskate-plugin.json new file mode 100644 index 00000000..f8dd8d1e --- /dev/null +++ b/contrib/plugins/reskate-hostile/reskate-plugin.json @@ -0,0 +1,6 @@ +{ + "api": 1, + "entry": "plugin/main.lua", + "side": "client", + "permissions": ["console"] +} diff --git a/contrib/plugins/reskate-test/data/sample.json b/contrib/plugins/reskate-test/data/sample.json new file mode 100644 index 00000000..99651561 --- /dev/null +++ b/contrib/plugins/reskate-test/data/sample.json @@ -0,0 +1 @@ +{"name": "sample", "list": [1, 2, 3], "nested": {"yes": true}} diff --git a/contrib/plugins/reskate-test/data/sample.txt b/contrib/plugins/reskate-test/data/sample.txt new file mode 100644 index 00000000..1dcde7a8 --- /dev/null +++ b/contrib/plugins/reskate-test/data/sample.txt @@ -0,0 +1 @@ +ptest sample diff --git a/contrib/plugins/reskate-test/manifest.json b/contrib/plugins/reskate-test/manifest.json new file mode 100644 index 00000000..941ef5ce --- /dev/null +++ b/contrib/plugins/reskate-test/manifest.json @@ -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": [] +} diff --git a/contrib/plugins/reskate-test/plugin/checks/base.lua b/contrib/plugins/reskate-test/plugin/checks/base.lua new file mode 100644 index 00000000..774d23a8 --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/checks/base.lua @@ -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) diff --git a/contrib/plugins/reskate-test/plugin/checks/console.lua b/contrib/plugins/reskate-test/plugin/checks/console.lua new file mode 100644 index 00000000..5384de08 --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/checks/console.lua @@ -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) diff --git a/contrib/plugins/reskate-test/plugin/checks/json.lua b/contrib/plugins/reskate-test/plugin/checks/json.lua new file mode 100644 index 00000000..088999c4 --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/checks/json.lua @@ -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) diff --git a/contrib/plugins/reskate-test/plugin/checks/level.lua b/contrib/plugins/reskate-test/plugin/checks/level.lua new file mode 100644 index 00000000..ab587c3a --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/checks/level.lua @@ -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) diff --git a/contrib/plugins/reskate-test/plugin/checks/package.lua b/contrib/plugins/reskate-test/plugin/checks/package.lua new file mode 100644 index 00000000..c20ef688 --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/checks/package.lua @@ -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) diff --git a/contrib/plugins/reskate-test/plugin/checks/require.lua b/contrib/plugins/reskate-test/plugin/checks/require.lua new file mode 100644 index 00000000..f8eb055d --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/checks/require.lua @@ -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) diff --git a/contrib/plugins/reskate-test/plugin/checks/sandbox.lua b/contrib/plugins/reskate-test/plugin/checks/sandbox.lua new file mode 100644 index 00000000..159ddec8 --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/checks/sandbox.lua @@ -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) diff --git a/contrib/plugins/reskate-test/plugin/checks/storage.lua b/contrib/plugins/reskate-test/plugin/checks/storage.lua new file mode 100644 index 00000000..9781c5b6 --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/checks/storage.lua @@ -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) diff --git a/contrib/plugins/reskate-test/plugin/checks/time.lua b/contrib/plugins/reskate-test/plugin/checks/time.lua new file mode 100644 index 00000000..578c6e1a --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/checks/time.lua @@ -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) diff --git a/contrib/plugins/reskate-test/plugin/harness.lua b/contrib/plugins/reskate-test/plugin/harness.lua new file mode 100644 index 00000000..aa7295af --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/harness.lua @@ -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 diff --git a/contrib/plugins/reskate-test/plugin/lib/counter.lua b/contrib/plugins/reskate-test/plugin/lib/counter.lua new file mode 100644 index 00000000..20071321 --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/lib/counter.lua @@ -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, +} diff --git a/contrib/plugins/reskate-test/plugin/lib/cycle_a.lua b/contrib/plugins/reskate-test/plugin/lib/cycle_a.lua new file mode 100644 index 00000000..dbea8c39 --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/lib/cycle_a.lua @@ -0,0 +1,2 @@ +-- For the require checks: a and b require each other. +return require("lib/cycle_b") diff --git a/contrib/plugins/reskate-test/plugin/lib/cycle_b.lua b/contrib/plugins/reskate-test/plugin/lib/cycle_b.lua new file mode 100644 index 00000000..44f15a4b --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/lib/cycle_b.lua @@ -0,0 +1,2 @@ +-- For the require checks: a and b require each other. +return require("lib/cycle_a") diff --git a/contrib/plugins/reskate-test/plugin/main.lua b/contrib/plugins/reskate-test/plugin/main.lua new file mode 100644 index 00000000..4af35ccf --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/main.lua @@ -0,0 +1,22 @@ +-- reskate-test: calls every plugin API and says what worked. Run `ptest all` in the console, or +-- `ptest ` 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 , 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") diff --git a/contrib/plugins/reskate-test/reskate-plugin.json b/contrib/plugins/reskate-test/reskate-plugin.json new file mode 100644 index 00000000..261b77ca --- /dev/null +++ b/contrib/plugins/reskate-test/reskate-plugin.json @@ -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"] +}