From cf43231b3ef58139cc9ea9330909df50d9464fb4 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Tue, 20 Oct 2026 14:26:04 -0500 Subject: [PATCH] Plugins: reskate.tricks.label and record_as, and Rock to Fakie as a mod on them Two general trick APIs on the hooks TheRealEJR found for his Rock to Fakie mod (pull request 285): - tricks.label(from, to) ("hud"): the trick ticker shows `to` where the game's label is exactly `from`, on the ticker's own call route and in its own buffer only, when it has room; 16 labels a plugin, given back when it stops. - tricks.record_as(from, to, within_ms) ("skater.control"): the next trick the recorder gets whose id is one of `from`, within the window, is recorded as `to`, through a changed copy of the packet. One ask a plugin, used once; refused while a throwdown runs, as trick replacements are. The hooks pass everything on unchanged while no plugin asks. contrib/plugins/rock-to-fakie is his feature in Lua: while the physics state holds a stall (400 or 401) the ticker shows Rock to Fakie for a Board Stall or a Lip Stall, and B pressed in it asks that the stall be recorded as the game's fakie_rock (187). Off until `rocktofakie on`. Checked in the plugin tests, with the new functions' permissions. --- Engine/Game/Build/20260929/trick_rewrite.h | 32 +++ Extension/Plugins/Test/plugin_tests.cpp | 52 +++++ Extension/Plugins/plugin_api_services.cpp | 38 ++- Extension/Plugins/plugin_game.h | 7 + Extension/Plugins/plugin_game_runtime.cpp | 10 + Extension/Skater/trick_rewrite.cpp | 218 ++++++++++++++++++ Extension/Skater/trick_rewrite.h | 37 +++ Runtime/runtime_init.cpp | 3 + cmake/Runtime.cmake | 1 + contrib/plugins/README.md | 3 + contrib/plugins/rock-to-fakie/README.md | 28 +++ contrib/plugins/rock-to-fakie/plugin/main.lua | 81 +++++++ .../plugins/rock-to-fakie/reskate-plugin.json | 6 + 13 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 Engine/Game/Build/20260929/trick_rewrite.h create mode 100644 Extension/Skater/trick_rewrite.cpp create mode 100644 Extension/Skater/trick_rewrite.h create mode 100644 contrib/plugins/rock-to-fakie/README.md create mode 100644 contrib/plugins/rock-to-fakie/plugin/main.lua create mode 100644 contrib/plugins/rock-to-fakie/reskate-plugin.json diff --git a/Engine/Game/Build/20260929/trick_rewrite.h b/Engine/Game/Build/20260929/trick_rewrite.h new file mode 100644 index 00000000..950fe442 --- /dev/null +++ b/Engine/Game/Build/20260929/trick_rewrite.h @@ -0,0 +1,32 @@ +#pragma once +#include +#include + +namespace dingosdk::game::build::v20260929::trick_rewrite { +// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9. +// Found by TheRealEJR for the Rock to Fakie mod (TheRealEJR/ReSkate-Rock-to-Fakie-mod v1.4.15 to +// v1.4.42, pull request 285), with a read-only recorder of the local skater's physics states, the +// trick recorder's packets and the localized labels the trick ticker asks for. + +// void record(recorder, const packet*): the trick recorder. It copies the 104-byte packet into its +// history before it returns. Packet +0x0c is the trick id (at most 0x150), +0x1c nonzero when valid. +// In the same catalogue a Board Stall records id 9 (bs_board), a Lip Stall id 12 (bs_lip), and 187 is +// the game's own fakie_rock (the internal-name lookup at 0x4783030 resolves all three). +inline constexpr std::uintptr_t record_trick = 0x47803a0; +inline constexpr std::array record_trick_prefix{ + 0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x80,0x7a,0x1c,0x00,0x48,0x8b, + 0xda,0x48,0x8b,0xf9,0x74,0x75,0x81,0x7a,0x0c,0x50,0x01,0x00,0x00,0x77,0x6c,0x48}; +inline constexpr std::size_t trick_packet_size = 0x68; +inline constexpr std::uintptr_t trick_packet_id = 0x0c, trick_packet_valid = 0x1c; +inline constexpr std::uint32_t trick_id_max = 0x150; + +// bool resolve(object, const char* key, char* buffer, uint32 capacity): a virtual method (found +// only in a vtable, no direct calls) that writes a key's localized text into the caller's buffer; +// AL is true on success. The trick ticker's labels come through it ("Board Stall", "Lip Stall"), +// with these three return addresses as frames 2, 3 and 4 of the call's unwound stack (frame 0 the +// hook, frame 1 its caller). No label but the ticker's was seen on that route. +inline constexpr std::uintptr_t resolve_localized_key = 0x2e1ac10; +inline constexpr std::array resolve_localized_key_prefix{ + 0x40,0x53,0x55,0x56,0x57,0x41,0x56,0x48,0x83,0xec,0x20}; +inline constexpr std::array ticker_label_route{0x758078, 0x59c1241, 0x16d2178}; +} diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index d62b62cd..7cd0d2b0 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -85,6 +85,16 @@ struct FakeGame final : Game { if (words.empty()) return "game"; return words.front() == "ringing" ? "1" : ""; } + std::string trick_label(std::string_view plugin, std::string_view from, std::string_view to) override { + calls.push_back(std::format("trick label {} {}={}", plugin, from, to)); + return {}; + } + std::string trick_record_as(std::string_view plugin, std::vector from, std::uint32_t to, int within_ms) override { + std::string ids; + for (const auto id : from) ids += (ids.empty() ? "" : ",") + std::to_string(id); + calls.push_back(std::format("trick record {} {}->{} {}", plugin, ids, to, within_ms)); + return {}; + } std::string board_toss(std::string_view plugin, std::vector bones, const Vec3& velocity, const Vec3&, float ground) override { calls.push_back(std::format("board toss {} {} {:.0f} {:.0f}", plugin, bones.size(), velocity[1], ground)); return {}; @@ -530,6 +540,10 @@ void a_permission_must_be_declared() { assert(reskate.package.variants() == nil, "a mod without variants has none") ok, err = pcall(reskate.package.choose, "wide") assert(not ok and err:find("needs the \"variants\" permission"), err) + ok, err = pcall(reskate.tricks.label, "Board Stall", "Rock to Fakie") + assert(not ok and err:find("needs the \"hud\" permission"), err) + ok, err = pcall(reskate.tricks.record_as, {9}, 187, 100) + assert(not ok and err:find("needs the \"skater.control\" permission"), err) reskate.console.print("never") )")}); stopped(host, "bare", "raised an error"); @@ -1851,6 +1865,43 @@ void dual_stick_edits_the_companions_settings() { "Goofy picked on the page is written:\n" + after + environment.all()); } +// Rock to Fakie as a mod (contrib/plugins/rock-to-fakie), on reskate.tricks.label and record_as: off +// until switched on; then a held stall shows the label, B pressed in it asks for fakie_rock, and +// leaving the stall gives the label back. +void rock_to_fakie_relabels_and_records_the_stall() { + TestEnvironment environment; + std::filesystem::remove_all(scratch / "storage" / "rock-to-fakie"); + auto& game = environment.fake; + game.telemetry = SkaterTelemetry{}; + game.telemetry->watch.valid = true; + game.telemetry->watch.state = 400; + game.pad_bits_now = 0u; + Host host(environment, Side::client); + host.apply({contributed("rock-to-fakie")}); + running(host, "rock-to-fakie", environment); + ticks(host, environment, 5); + check(!game.called("trick label"), "Off by default: nothing is asked\n" + environment.all()); + check(host.run_console("rocktofakie on") && environment.printed("rocktofakie: on."), "rocktofakie on"); + ticks(host, environment, 2); + check(game.called("trick label rock-to-fakie Board Stall=Rock to Fakie") && game.called("trick label rock-to-fakie Lip Stall=Rock to Fakie"), + "A held stall is labelled\n" + environment.all()); + game.pad_bits_now = 0x2000u; + ticks(host, environment, 1); + check(game.called("trick record rock-to-fakie 9,12->187 250"), "B in the stall asks for fakie_rock\n" + environment.all()); + game.calls.clear(); + ticks(host, environment, 3); + check(!game.called("trick record"), "B held asks once\n" + environment.all()); + game.telemetry->watch.state = 604; + game.pad_bits_now = 0u; + ticks(host, environment, 1); + check(game.called("trick label rock-to-fakie Board Stall=") && game.called("trick label rock-to-fakie Lip Stall="), + "Leaving the stall gives the labels back\n" + environment.all()); + game.calls.clear(); + game.pad_bits_now = 0x2000u; + ticks(host, environment, 1); + check(!game.called("trick record"), "B outside a stall asks nothing\n" + environment.all()); +} + void board_break_snaps_the_board_on_a_slam() { TestEnvironment environment; std::filesystem::remove_all(scratch / "storage" / "board-break"); @@ -2592,6 +2643,7 @@ int main() { reskate_test_passes_its_own_checks(); road_rash_marks_the_skater_as_it_bails(); dual_stick_edits_the_companions_settings(); + rock_to_fakie_relabels_and_records_the_stall(); board_break_snaps_the_board_on_a_slam(); fpv_drone_flies_and_follows(); hall_of_meat_scores_a_bail(); diff --git a/Extension/Plugins/plugin_api_services.cpp b/Extension/Plugins/plugin_api_services.cpp index 452cbc84..774668f4 100644 --- a/Extension/Plugins/plugin_api_services.cpp +++ b/Extension/Plugins/plugin_api_services.cpp @@ -274,6 +274,42 @@ int gesture(lua_State* L) { return 1; } +// tricks.label(from, to): the trick ticker shows `to` where the game's label is exactly `from` +// ("Board Stall"); to nil, that label is the game's again. 1 to 63 characters each, 16 labels a plugin. +int label(lua_State* L) { + auto& plugin = allowed(L, "hud", "reskate.tricks.label"); + std::size_t from_length = 0, to_length = 0; + const char* from = luaL_checklstring(L, 1, &from_length); + const char* to = lua_isnoneornil(L, 2) ? "" : luaL_checklstring(L, 2, &to_length); + if (!from_length || from_length > 63 || to_length > 63) luaL_error(L, "a label is 1 to 63 characters"); + return result(L, game_of(plugin).trick_label(plugin.id(), std::string_view(from, from_length), std::string_view(to, to_length))); +} +// tricks.record_as(from, to, within_ms): the next trick the game records whose id is one of `from` +// (a list of 1 to 16 ids, or one id), within `within_ms` (1 to 2000), is recorded as the id `to`. +// The game's trick ids, 0 to 336. One pending record a plugin: a new one replaces it. +int record_as(lua_State* L) { + auto& plugin = allowed(L, "skater.control", "reskate.tricks.record_as"); + const auto id_at = [&](int index) { + const double value = luaL_checknumber(L, index); + if (!(value >= 0 && value <= 336) || value != std::floor(value)) luaL_error(L, "a trick id is a whole number, 0 to 336"); + return static_cast(value); + }; + std::vector from; + if (lua_istable(L, 1)) { + const int count = lua_objlen(L, 1); + if (count < 1 || count > 16) luaL_error(L, "a record names 1 to 16 trick ids"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, 1, i); + from.push_back(id_at(lua_gettop(L))); + lua_pop(L, 1); + } + } else from.push_back(id_at(1)); + const auto to = id_at(2); + const double within = luaL_checknumber(L, 3); + if (!(within >= 1 && within <= 2000)) luaL_error(L, "a record waits 1 to 2000 ms"); + return result(L, game_of(plugin).trick_record_as(plugin.id(), std::move(from), to, static_cast(within))); +} + } // namespace void install_services_api(Plugin&, lua_State* L) { @@ -282,6 +318,6 @@ void install_services_api(Plugin&, lua_State* L) { add_functions(L, "session", {{"spectate", spectate}, {"world", world}}); add_functions(L, "camera", {{"free", free_camera}, {"pad_flight", pad_flight}}); add_functions(L, "physics", {{"competition", competition}, {"stock_ready", stock_ready}, {"prepare", prepare}}); - add_functions(L, "tricks", {{"feed", trick_feed}, {"gesture", gesture}}); + add_functions(L, "tricks", {{"feed", trick_feed}, {"gesture", gesture}, {"label", label}, {"record_as", record_as}}); } } // namespace dingosdk::plugins diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index de3b71cf..9b673705 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -362,6 +362,13 @@ public: // The flick diagram of a flip trick as skate. names it (trick_gestures.h): read in the // background the first time; empty until then, or for a trick without one. virtual std::vector> trick_gesture(std::string_view /*trick*/) { return {}; } + // What the game shows and records of the local skater's tricks (Extension/Skater/trick_rewrite.h): + // the ticker's label `from` shown as `to` (empty: that label is the game's again), and the next + // trick with one of `from`'s ids, within `within_ms`, recorded as `to`. Empty when done, else why not. + virtual std::string trick_label(std::string_view /*plugin*/, std::string_view /*from*/, std::string_view /*to*/) { return unavailable; } + virtual std::string trick_record_as(std::string_view /*plugin*/, std::vector /*from*/, std::uint32_t /*to*/, int /*within_ms*/) { + return unavailable; + } // ---- files: the plugin's own data folder, /Mods/.reskate_plugin_data// ----------- // Paths are relative to it and already checked (plugin_manifest.h's safe_relative_path). A listing // names the files and folders in `folder` (empty: the top), folders ending in "/". diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 42d637f7..ab0e2fc3 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -47,6 +47,7 @@ #include "Extension/Skater/trick_feed.h" #include "Extension/Skater/skater_push.h" #include "Extension/Skater/trick_gestures.h" +#include "Extension/Skater/trick_rewrite.h" #include "Extension/Throwdowns/throwdown_lab.h" #include "Extension/Console/commands.h" #include "Extension/Settings/local_gameplay_settings.h" @@ -857,6 +858,14 @@ public: tricks::prepare_trick_gestures(); return tricks::trick_gesture(trick); } + std::string trick_label(std::string_view plugin, std::string_view from, std::string_view to) override { + return trick_rewrite::label(plugin, from, to); + } + // A trick recorded as another changes the score: not while a throwdown runs, as trick replacements. + std::string trick_record_as(std::string_view plugin, std::vector from, std::uint32_t to, int within_ms) override { + if (multiplayer::local_throwdown_active()) return "not while a throwdown runs"; + return trick_rewrite::record_as(plugin, std::move(from), to, within_ms); + } std::string bone_cam(std::string_view plugin, const std::vector& words) override { if (words.empty()) return bonecam::bone_cam_setting(); const auto& setting = words.front(); @@ -991,6 +1000,7 @@ public: hud_corner::release(plugin); game_speed::ask(plugin, 1.0f); grind_exit::release(plugin); + trick_rewrite::release(plugin); if (const auto seen = telemetry_seen_.find(plugin); seen != telemetry_seen_.end()) telemetry_seen_.erase(seen); plugin_feed::release_tags(std::string(plugin)); if (std::erase_if(binds_, [&](const auto& item) { return item.first.first == plugin; })) publish_binds(); diff --git a/Extension/Skater/trick_rewrite.cpp b/Extension/Skater/trick_rewrite.cpp new file mode 100644 index 00000000..274dcad6 --- /dev/null +++ b/Extension/Skater/trick_rewrite.cpp @@ -0,0 +1,218 @@ +#include "trick_rewrite.h" +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/trick_rewrite.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::trick_rewrite { +namespace { +namespace tr = addr::trick_rewrite; + +using RecordTrick = void (*)(std::uintptr_t recorder, std::uintptr_t packet); +using ResolveLocalizedKey = bool (*)(std::uintptr_t object, const char* key, char* buffer, std::uint32_t capacity); + +struct Record { + std::vector from; + std::uint32_t to{}; + std::int64_t until{}; // performance-counter ticks +}; +struct State { + std::atomic base{}; + std::atomic started{}, label_ready{}, record_ready{}; + std::atomic record_original{}; + std::atomic resolve_original{}; + // Read by the hooks without the lock: whether anything is asked for at all. + std::atomic any_labels{}, any_records{}; + std::mutex mutex; + std::map>, std::less<>> labels; // owner -> from -> to + std::map> records; // owner -> pending + std::atomic labelled{}, recorded{}, no_room{}; +}; +State& state() { + static auto* value = new State; // process lifetime: the hooks may call in until exit + return *value; +} +struct PreserveError { + DWORD value = GetLastError(); + ~PreserveError() { SetLastError(value); } +}; +std::int64_t qpc_now() noexcept { + LARGE_INTEGER value{}; + QueryPerformanceCounter(&value); + return value.QuadPart; +} +std::int64_t qpc_ms(int ms) noexcept { + static const std::int64_t frequency = [] { + LARGE_INTEGER value{}; + QueryPerformanceFrequency(&value); + return value.QuadPart; + }(); + return frequency * ms / 1000; +} +void refresh(State& s) { + s.any_labels.store(std::any_of(s.labels.begin(), s.labels.end(), [](const auto& owner) { return !owner.second.empty(); }), + std::memory_order_release); + s.any_records.store(!s.records.empty(), std::memory_order_release); +} + +// Game thread(s). The game's method runs first and fills its caller's buffer; only then, and only for +// a label asked for on the ticker's route, is the text in that buffer replaced. +__declspec(noinline) bool resolve_localized_key(std::uintptr_t object, const char* key, char* buffer, std::uint32_t capacity) { + auto& s = state(); + const bool success = s.resolve_original.load(std::memory_order_acquire)(object, key, buffer, capacity); + if (!success || !buffer || !capacity || !s.any_labels.load(std::memory_order_acquire)) return success; + PreserveError preserve; + char text[most_label_text + 1]{}; + const auto length = memory::peek_cstring(reinterpret_cast(buffer), text, sizeof(text)); + if (length <= 0 || static_cast(length) >= capacity) return success; + std::string replacement; + { + std::unique_lock lock(s.mutex, std::try_to_lock); + if (!lock.owns_lock()) return success; + for (const auto& [owner, labels] : s.labels) + if (const auto found = labels.find(std::string_view(text, static_cast(length))); found != labels.end()) { + replacement = found->second; + break; + } + } + if (replacement.empty()) return success; + // Captured here, in the hook itself: frame 0 is this function, frame 1 its caller. + std::array frames{}; + const auto count = RtlCaptureStackBackTrace(0, static_cast(frames.size()), frames.data(), nullptr); + const auto base = s.base.load(std::memory_order_relaxed); + bool route = count == frames.size(); + for (std::size_t i = 0; route && i < tr::ticker_label_route.size(); ++i) + route = reinterpret_cast(frames[2 + i]) == base + tr::ticker_label_route[i]; + if (!route) return success; + if (capacity <= replacement.size()) { // never cut a label short + s.no_room.fetch_add(1, std::memory_order_relaxed); + return success; + } + std::memcpy(buffer, replacement.c_str(), replacement.size() + 1); + s.labelled.fetch_add(1, std::memory_order_relaxed); + return success; +} + +// Game thread. A trick a pending record asks for is recorded as its `to`, through a copy of the +// packet; anything else goes to the recorder untouched. +__declspec(noinline) void record_trick(std::uintptr_t recorder, std::uintptr_t packet) { + auto& s = state(); + const auto original = s.record_original.load(std::memory_order_acquire); + if (s.any_records.load(std::memory_order_acquire) && packet >= 0x10000 && + packet < memory::highest_user_address - tr::trick_packet_size) { + PreserveError preserve; + std::uint8_t valid{}; + std::uint32_t id{}; + if (memory::peek(packet + tr::trick_packet_valid, valid) && valid && memory::peek(packet + tr::trick_packet_id, id)) { + std::optional to; + { + std::lock_guard lock(s.mutex); + const auto now = qpc_now(); + std::erase_if(s.records, [&](const auto& entry) { return now > entry.second.until; }); + for (auto it = s.records.begin(); it != s.records.end(); ++it) + if (std::find(it->second.from.begin(), it->second.from.end(), id) != it->second.from.end()) { + to = it->second.to; + s.records.erase(it); // once + break; + } + refresh(s); + } + std::array copy{}; + if (to && memory::peek(packet, copy)) { + std::memcpy(copy.data() + tr::trick_packet_id, &*to, sizeof(*to)); + s.recorded.fetch_add(1, std::memory_order_relaxed); + // The recorder copies the packet before it returns, so the copy may live here. + original(recorder, reinterpret_cast(copy.data())); + return; + } + } + } + original(recorder, packet); +} + +template +bool install(std::uintptr_t base, std::uintptr_t rva, const std::array& prefix, + Function replacement, std::atomic& original) noexcept { + std::array actual{}; + if (!memory::peek(base + rva, actual) || actual != prefix) return false; + auto* const target = reinterpret_cast(base + rva); + void* previous{}; + if (hook_prepare(target, reinterpret_cast(replacement), &previous) != HookOk) return false; + if (!previous) { + (void)hook_remove(target); + return false; + } + original.store(reinterpret_cast(previous), std::memory_order_release); + if (hook_enable(target) != HookOk) { + (void)hook_remove(target); + return false; + } + return true; +} +} // namespace + +bool start(std::uintptr_t image_base) noexcept { + auto& s = state(); + if (s.started.exchange(true, std::memory_order_acq_rel)) return labels_available() || records_available(); + s.base.store(image_base, std::memory_order_release); + s.label_ready.store(install(image_base, tr::resolve_localized_key, tr::resolve_localized_key_prefix, &resolve_localized_key, + s.resolve_original), std::memory_order_release); + s.record_ready.store(install(image_base, tr::record_trick, tr::record_trick_prefix, &record_trick, s.record_original), + std::memory_order_release); + logging::log(logging::Level::info, logging::Channel::skater, "Trick labels and records for plugins: ticker labels {}, trick records {}.", + s.label_ready.load() ? "hooked" : "unavailable (this build's code differs)", + s.record_ready.load() ? "hooked" : "unavailable (this build's code differs)"); + return s.label_ready.load() || s.record_ready.load(); +} +bool labels_available() noexcept { return state().label_ready.load(std::memory_order_acquire); } +bool records_available() noexcept { return state().record_ready.load(std::memory_order_acquire); } + +std::string label(std::string_view owner, std::string_view from, std::string_view to) { + if (from.empty() || from.size() > most_label_text || to.size() > most_label_text) return "a label is 1 to 63 characters"; + auto& s = state(); + if (!labels_available()) return "trick labels are unavailable for this game build"; + std::lock_guard lock(s.mutex); + auto& mine = s.labels[std::string(owner)]; + if (to.empty()) mine.erase(std::string(from)); + else { + if (!mine.contains(from) && mine.size() >= most_labels) return "a plugin has at most 16 labels"; + mine[std::string(from)] = std::string(to); + } + if (mine.empty()) s.labels.erase(std::string(owner)); + refresh(s); + return {}; +} +std::string record_as(std::string_view owner, std::vector from, std::uint32_t to, int within_ms) { + if (from.empty() || from.size() > 16) return "a record names 1 to 16 trick ids"; + for (const auto id : from) + if (id > most_trick_id) return "a trick id is 0 to 336"; + if (to > most_trick_id) return "a trick id is 0 to 336"; + if (within_ms < 1 || within_ms > most_within_ms) return "a record waits 1 to 2000 ms"; + auto& s = state(); + if (!records_available()) return "trick records are unavailable for this game build"; + std::lock_guard lock(s.mutex); + s.records[std::string(owner)] = Record{std::move(from), to, qpc_now() + qpc_ms(within_ms)}; + refresh(s); + return {}; +} +void release(std::string_view owner) { + auto& s = state(); + std::lock_guard lock(s.mutex); + if (const auto found = s.labels.find(owner); found != s.labels.end()) s.labels.erase(found); + if (const auto found = s.records.find(owner); found != s.records.end()) s.records.erase(found); + refresh(s); +} +Counts counts() noexcept { + const auto& s = state(); + return {s.labelled.load(), s.recorded.load(), s.no_room.load()}; +} +} diff --git a/Extension/Skater/trick_rewrite.h b/Extension/Skater/trick_rewrite.h new file mode 100644 index 00000000..dcf85d7f --- /dev/null +++ b/Extension/Skater/trick_rewrite.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include +#include + +// What the game shows and records of the local skater's tricks, as plugins ask (reskate.tricks.label +// and record_as; the hooks TheRealEJR found for Rock to Fakie, pull request 285): +// - A label: the trick ticker shows `to` where the game's text is exactly `from`. Only the label the +// ticker asks for on the one call route seen for it (Engine/Game/Build/20260929/trick_rewrite.h), +// in the ticker's own buffer and only when it has room: the string database, other text and +// other players' tickers are the game's. +// - A record: the next trick the recorder gets whose id is one of `from`, within `within_ms`, is +// recorded as `to`. The recorder is given a changed copy of the packet, never the game's. +// Each owner (a plugin) has its own labels and one pending record; both go with release(). With none +// asked for, the hooks pass everything on unchanged. Any thread. +namespace dingosdk::trick_rewrite { +// Installs the hooks when the game's code matches. +bool start(std::uintptr_t image_base) noexcept; +bool labels_available() noexcept; +bool records_available() noexcept; + +inline constexpr std::size_t most_label_text = 63, most_labels = 16; +inline constexpr std::uint32_t most_trick_id = 0x150; +inline constexpr int most_within_ms = 2000; + +// Empty when done, else why not. An empty `to` takes that owner's label for `from` away. +std::string label(std::string_view owner, std::string_view from, std::string_view to); +// Replaces that owner's pending record. Empty when done, else why not. +std::string record_as(std::string_view owner, std::vector from, std::uint32_t to, int within_ms); +void release(std::string_view owner); + +struct Counts { + std::uint64_t labelled{}, recorded{}, no_room{}; +}; +Counts counts() noexcept; +} diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 5f576169..a4bb764c 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -37,6 +37,7 @@ #include "Extension/UI/Overlay/game_images.h" #include "Extension/Plugins/plugin_runtime.h" #include "Extension/Skater/grind_exit.h" +#include "Extension/Skater/trick_rewrite.h" #include "Extension/Skater/camera_observer.h" #include "Extension/Progression/entitlement_request_hook.h" #include "Extension/Skater/skater_observer.h" @@ -464,6 +465,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { std::string(xray ? "true" : "false") + "}"); const bool grind_exits = dingosdk::grind_exit::start(r.base); record("{\"event\":\"grind_exit_initialized\",\"active\":" + std::string(grind_exits ? "true" : "false") + "}"); + const bool trick_rewrites = dingosdk::trick_rewrite::start(r.base); + record("{\"event\":\"trick_rewrite_initialized\",\"active\":" + std::string(trick_rewrites ? "true" : "false") + "}"); const bool noclip_velocity = dingosdk::start_client_noclip_velocity(r.base); record("{\"event\":\"noclip_velocity_initialized\",\"active\":" + std::string(noclip_velocity ? "true" : "false") + "}"); const bool loading_screens = dingosdk::loading_screen::start(r.base); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 5ac3a17d..ece4bafe 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -141,6 +141,7 @@ add_library(dingosdk_runtime SHARED Extension/Skater/xray_skeleton.cpp Extension/Skater/game_speed.cpp Extension/Skater/grind_exit.cpp + Extension/Skater/trick_rewrite.cpp Extension/UI/hud_corner.cpp Extension/Assets/morph_memory_pool.cpp Extension/Assets/native_render_resource_pool.cpp diff --git a/contrib/plugins/README.md b/contrib/plugins/README.md index 7ba703bd..226c48b8 100644 --- a/contrib/plugins/README.md +++ b/contrib/plugins/README.md @@ -36,6 +36,9 @@ test`) that `dingosdk_plugin_tests` runs too, and its own page in the ReSkate me - **dual-stick**: the settings of GoalTrains' Dual Stick Controls companion on its page, in the companion's `session_controls.ini` in the plugin's own data folder (`reskate.files`). Made from pull request 254. `dualstick status`, `dualstick preset xl`. +- **rock-to-fakie**: TheRealEJR's Rock to Fakie: a Board Stall or Lip Stall shows as Rock to Fakie, + and left with B is recorded as the game's fakie_rock (`reskate.tricks.label`, `record_as`). From + pull request 285. `rocktofakie on`. ## Throwdown modes diff --git a/contrib/plugins/rock-to-fakie/README.md b/contrib/plugins/rock-to-fakie/README.md new file mode 100644 index 00000000..3e14edb3 --- /dev/null +++ b/contrib/plugins/rock-to-fakie/README.md @@ -0,0 +1,28 @@ +# Rock to Fakie (plugin) + +TheRealEJR's [Rock to Fakie](https://github.com/TheRealEJR/ReSkate-Rock-to-Fakie-mod) as a +sandboxed Luau plugin, from his pull request 285. skate. has no button for a rock to fakie, but the +move is in the game: ride up to a coping, Board Stall or Lip Stall on it, and press **B** (Circle on +a PlayStation pad) to roll back in fakie. With this on, the game calls it what it is: + +- **The trick ticker** shows Rock to Fakie for a Board Stall or a Lip Stall while the skater holds + it (`reskate.tricks.label`). A BS / FS prefix the ticker adds is the game's and stays. +- **A stall left with B** is recorded as the game's own `fakie_rock` (trick 187) instead of the stall + (9 or 12): B pressed while the stall is held asks that the stall the game records within 250 ms be + recorded as `fakie_rock` (`reskate.tricks.record_as`). + +The stall is the physics state the game chose (`reskate.skater.telemetry().watch.state`): 400 or 401 +holds a stall or a grind, and only a stall's label or trick id is ever changed. Off until switched +on: `rocktofakie on|off|status`, or its page in the ReSkate menu's PLUGINS section. + +## Install + +Copy this folder into the game's `Mods` folder. + +## Limits + +- The hooks behind `reskate.tricks` are tied to the supported game build: on another, the status + says why nothing is asked. +- It does not detect a real coping contact: any Board Stall or Lip Stall counts, wherever it is done. +- A recorded trick is your game's: whether a multiplayer host sees it the same has not been checked. + It is not asked while a throwdown runs. diff --git a/contrib/plugins/rock-to-fakie/plugin/main.lua b/contrib/plugins/rock-to-fakie/plugin/main.lua new file mode 100644 index 00000000..caac6242 --- /dev/null +++ b/contrib/plugins/rock-to-fakie/plugin/main.lua @@ -0,0 +1,81 @@ +-- Rock to Fakie, by TheRealEJR (his Rock to Fakie mod, TheRealEJR/ReSkate-Rock-to-Fakie-mod, ported +-- to ReSkate in pull request 285), as a plugin. skate. has no button for a rock to fakie, but the +-- move is in the game: ride up to a coping, Board Stall or Lip Stall on it, and press B to roll back +-- in fakie. With this on, the game calls it what it is: +-- - the trick ticker shows Rock to Fakie for a Board Stall or a Lip Stall while the skater holds +-- it (reskate.tricks.label, only while the stall is held); +-- - a stall left with B is recorded as the game's own fakie_rock (reskate.tricks.record_as: B +-- pressed while the stall is held asks that the stall the game records within a moment of it be +-- recorded as fakie_rock). +-- The stall is the physics state the game chose (reskate.skater.telemetry's watch): 400 or 401 +-- holds a stall or a grind, and only a stall's label or trick id is changed. Off until switched on. +local held_states = {[400] = true, [401] = true} +local board_stall, lip_stall, fakie_rock = 9, 12, 187 -- the game's trick ids (bs_board, bs_lip, fakie_rock) +local stall_labels = {"Board Stall", "Lip Stall"} +local name = "Rock to Fakie" +local b_button = 0x2000 -- XInput B, Circle on a PlayStation pad +-- B pressed while the stall is held, then the stall left: the game records it a moment later. +local record_window_ms = 250 + +local on = reskate.storage.get("on") == true +local labelled = false +local b_down = false +local asked, refused = 0, nil + +local function show_labels(show) + if show == labelled then return end + labelled = show + for _, label in stall_labels do + local ok, why = reskate.tricks.label(label, if show then name else nil) + if not ok then refused = why end + end +end + +reskate.on("tick", function() + if not on then + show_labels(false) + b_down = false + return + end + local t = reskate.skater.telemetry() + local watch = t and t.watch + local stalled = watch ~= nil and watch.valid and held_states[watch.state] == true + show_labels(stalled) + local pad = reskate.input.pad_bits() + local down = pad ~= nil and bit32.band(pad, b_button) ~= 0 + if down and not b_down and stalled then + local ok, why = reskate.tricks.record_as({board_stall, lip_stall}, fakie_rock, record_window_ms) + if ok then asked += 1 else refused = why end + end + b_down = down +end) + +local function set_on(value) + on = value + reskate.storage.set("on", value) + if not value then show_labels(false) end +end +local function status() + return string.format("%s. %d rock to fakie(s) asked for this game%s.", if on then "on" else "off", asked, + if refused then "; last refusal: " .. refused else "") +end + +reskate.console.command("rocktofakie", "Rock to Fakie: on|off|status", function(args) + local word = args[1] or "status" + if word == "on" or word == "1" then set_on(true) + elseif word == "off" or word == "0" then set_on(false) + elseif word ~= "status" then return reskate.console.print("rocktofakie: say on, off or status") end + reskate.console.print("rocktofakie: " .. status()) +end) + +reskate.menu.page("Rock to Fakie", function(ui) + ui:card("ROCK TO FAKIE", "Board Stall or Lip Stall, then B") + local value = ui:toggle("on", "Rock to Fakie", on, + "Ride up to a coping, Board Stall or Lip Stall on it, and press B to roll back in fakie: the ticker shows Rock to Fakie, and the trick is recorded as one.") + if value ~= on then set_on(value) end + ui:note("Any Board Stall or Lip Stall counts, wherever it is done: the game does not say whether it is on a coping.") + ui:info("Status", status()) +end) + +reskate.on("unload", function() show_labels(false) end) +reskate.log("Rock to Fakie " .. (if on then "on" else "off")) diff --git a/contrib/plugins/rock-to-fakie/reskate-plugin.json b/contrib/plugins/rock-to-fakie/reskate-plugin.json new file mode 100644 index 00000000..81976952 --- /dev/null +++ b/contrib/plugins/rock-to-fakie/reskate-plugin.json @@ -0,0 +1,6 @@ +{ + "api": 1, + "entry": "plugin/main.lua", + "side": "client", + "permissions": ["skater.read", "skater.control", "input", "hud", "storage", "menu", "console"] +}