mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-23 13:05:54 +02:00
A plugin names folders in its manifest's "content" (at most 8, with mods.read), and reads them in every enabled mod: reskate.mods.content(folder) lists each mod's files there (mod, title, path, size; 1000 at most, four folders down), reskate.mods.read_content(mod, folder, path) reads one (1 MB at most, never outside that folder). A folder the manifest does not name is refused. Other mods can then ship data for a plugin with no code: parks for a game mode such as Domination (pull request 279), routes, presets. Checked in the plugin tests, with the manifest's new errors.
1235 lines
66 KiB
C++
1235 lines
66 KiB
C++
#include "plugin_game_runtime.h"
|
|
#include "plugin_session_feed.h"
|
|
#include "Runtime/runtime_internal.h"
|
|
#include "plugin_ui.h"
|
|
#include "Engine/Game/World/park_rotation.h"
|
|
#include "Engine/Core/Json/json.h"
|
|
#include "Engine/Game/Input/controller_bindings.h"
|
|
#include "Engine/Game/Multiplayer/session_tools.h"
|
|
#include "Engine/Game/World/world_layer_catalog.h"
|
|
#include "Engine/Game/World/world_layer_types.h"
|
|
#include "Extension/Multiplayer/Hud/game_ui_state.h"
|
|
#include "Extension/Multiplayer/Hud/native_party.h"
|
|
#include "Extension/Skater/client_source_spawn.h"
|
|
#include "Extension/Skater/skater_animation.h"
|
|
#include "Extension/Plugins/plugin_joystick.h"
|
|
#include "plugin_sound.h"
|
|
#include "Extension/Skater/skater_events.h"
|
|
#include "Extension/Skater/outfit_overlay.h"
|
|
#include "Extension/Skater/board_pose.h"
|
|
#include "Engine/Vfs/mod_catalog.h"
|
|
#include "Engine/Core/Log/logging.h"
|
|
#include <mutex>
|
|
#include "Extension/Assets/live_mods.h"
|
|
#include "Extension/Skater/game_speed.h"
|
|
#include "Extension/Skater/grind_exit.h"
|
|
#include "Extension/Skater/xray.h"
|
|
#include "Extension/UI/hud_corner.h"
|
|
#include "Extension/Throwdowns/custom_modes.h"
|
|
#include "Extension/Throwdowns/throwdown_kit.h"
|
|
#include "plugin_camera.h"
|
|
#include "Extension/Multiplayer/Session/session.h"
|
|
#include "Extension/Music/external_player.h"
|
|
#include "Extension/Music/spotify_station.h"
|
|
#include "Extension/Objects/ParkEditor/park_editor_runtime.h"
|
|
#include "Extension/Profile/local_profile_runtime.h"
|
|
#include "Extension/Settings/named_settings.h"
|
|
#include "Extension/Skater/ai_skaters.h"
|
|
#include "Extension/Skater/no_bail.h"
|
|
#include "Extension/Throwdowns/native_throwdowns.h"
|
|
#include "Extension/Throwdowns/throwdown_relay.h"
|
|
#include "Extension/Throwdowns/throwdown_debug_text.h"
|
|
#include "Extension/Trainer/trainer.h"
|
|
#include "Extension/Travel/local_location_travel.h"
|
|
#include "Extension/World/world_entities.h"
|
|
#include "Extension/Skater/bone_cam.h"
|
|
#include "Extension/Skater/pad_flight.h"
|
|
#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"
|
|
#include "Extension/Skater/physics_tuning.h"
|
|
#include "Engine/Game/Multiplayer/session_competition.h"
|
|
#include "Extension/UI/NativeMenu/throwdown_menu.h"
|
|
#include <filesystem>
|
|
#include "Extension/UI/Overlay/overlay.h"
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cmath>
|
|
#include <format>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
#include <map>
|
|
#include <set>
|
|
|
|
namespace dingosdk::plugins {
|
|
namespace {
|
|
// A request on the queue the console's own debug commands use; its outcome shows in the console's
|
|
// model a tick later, so "" here means it was queued.
|
|
std::string queue(overlay::DebugAction action, bool enabled = false, float value = 0) {
|
|
std::array<char, 512> reply{};
|
|
const overlay::DebugRequest request{action, enabled, value};
|
|
if (runtime::detail::queue_debug(nullptr, request, reply.data(), reply.size())) return {};
|
|
reply.back() = '\0';
|
|
return reply[0] ? std::string(reply.data()) : std::string("the game is busy; try again");
|
|
}
|
|
|
|
// The game's folder (the executable's), where Mods is.
|
|
std::filesystem::path game_folder() {
|
|
std::array<wchar_t, 32768> executable{};
|
|
const auto length = GetModuleFileNameW(nullptr, executable.data(), static_cast<DWORD>(executable.size()));
|
|
if (!length || length == executable.size()) throw std::runtime_error("The game folder is unavailable.");
|
|
return std::filesystem::path(executable.data()).parent_path();
|
|
}
|
|
|
|
class RealGame final : public Game {
|
|
public:
|
|
explicit RealGame(std::uintptr_t base) : base_(base) {}
|
|
|
|
// ---- the local skater ----------------------------------------------------------------------
|
|
std::optional<std::uint32_t> physics_state() override {
|
|
const auto t = trainer::telemetry();
|
|
if (!t.skater) return std::nullopt;
|
|
return t.physics_state;
|
|
}
|
|
std::uint64_t wipeouts() override { return watched_physics_state().wipeouts; }
|
|
std::string control(std::string_view action) override {
|
|
using overlay::DebugAction;
|
|
if (action == "noclip_on" || action == "noclip_off") {
|
|
const bool on = action == "noclip_on";
|
|
if (on && !session_noclip_allowed()) return "the host has turned noclip off";
|
|
return queue(DebugAction::set_noclip, on);
|
|
}
|
|
if (!session_boosts_allowed()) return "the host has turned boosts off";
|
|
if (action == "boost_forward") return queue(DebugAction::add_forward_velocity, true);
|
|
if (action == "boost_up") return queue(DebugAction::add_up_velocity, true);
|
|
if (action == "boost_offboard") return queue(DebugAction::add_offboard_up_velocity, true);
|
|
return "there is no such control";
|
|
}
|
|
// The game's whole HUD, as `hideui` hides it, while any plugin asks; as it was when none does.
|
|
void hide_hud(std::string_view plugin, bool hidden) override {
|
|
const bool before = !hud_hiders_.empty();
|
|
if (hidden) hud_hiders_.insert(std::string(plugin));
|
|
else hud_hiders_.erase(std::string(plugin));
|
|
const bool now = !hud_hiders_.empty();
|
|
if (now == before) return;
|
|
if (now) hud_was_hidden_ = base_ && multiplayer::sample_game_ui_state(base_).ui_hidden;
|
|
if (!hud_was_hidden_) queue(overlay::DebugAction::set_game_ui_hidden, now);
|
|
}
|
|
|
|
// ---- the world -------------------------------------------------------------------------------
|
|
std::vector<CatalogItem> object_catalog() override {
|
|
std::vector<CatalogItem> result;
|
|
const auto model = local_park_editor();
|
|
if (model.assets)
|
|
for (const auto& asset : *model.assets) result.push_back({asset.key, asset.title, asset.category});
|
|
return result;
|
|
}
|
|
std::vector<GameObject> objects() override {
|
|
std::vector<GameObject> result;
|
|
for (const auto& o : local_park_editor().objects) result.push_back({o.id, o.item, o.position, o.rotation, o.scale});
|
|
return result;
|
|
}
|
|
// Through the park editor's own edits (park_editor_runtime.h), its limits and the session's
|
|
// object rules included. The editor need not be open.
|
|
std::string edit_object(std::string_view operation, const GameObject& object) override {
|
|
const auto model = local_park_editor();
|
|
if (!model.available) return "objects cannot be placed here now";
|
|
if (model.busy) return "the last edit is still being applied";
|
|
profile::PlacedObject placed;
|
|
placed.id = object.id;
|
|
placed.item = object.item;
|
|
placed.position = object.position;
|
|
placed.rotation = object.rotation;
|
|
placed.scale = object.scale;
|
|
const auto status = edit_local_park(operation, model.map, model.generation, model.revision, {}, placed);
|
|
return status.starts_with("error: ") ? status.substr(7) : std::string{};
|
|
}
|
|
std::string entity_set(std::string_view plugin, std::uint32_t id, std::string_view asset, const std::array<float, 16>& matrix, bool hidden) override {
|
|
return world_entities::set(plugin, id, std::string(asset), matrix, hidden);
|
|
}
|
|
void entity_remove(std::string_view plugin, std::uint32_t id) override { world_entities::remove(plugin, id); }
|
|
void entity_clear(std::string_view plugin) override { world_entities::clear(plugin); }
|
|
int entity_status(std::string_view plugin, std::uint32_t id) override { return static_cast<int>(world_entities::status(plugin, id)); }
|
|
bool entities_ready(std::string_view plugin) override { return world_entities::ready(plugin); }
|
|
std::string set_environment(std::string_view kind, std::string_view key, std::string_view value) override {
|
|
if (kind == "tod") return time_of_day(key);
|
|
if (kind == "control") {
|
|
float number{};
|
|
try {
|
|
number = std::stof(std::string(value));
|
|
} catch (...) {
|
|
return "not a number";
|
|
}
|
|
return set_local_world_control(key, number) ? std::string{} : "the game refused it (no map loaded, or not that control)";
|
|
}
|
|
if (kind == "park") {
|
|
const auto lot = std::find_if(park_lots.begin(), park_lots.end(), [&](const auto& l) { return l.key == key; });
|
|
if (lot == park_lots.end() || !valid_park(static_cast<unsigned>(lot - park_lots.begin()), value))
|
|
return "that is not a lot and one of its layouts";
|
|
if (set_local_park(key, value)) return {};
|
|
return "the host chooses the parks, or that is not a lot and layout";
|
|
}
|
|
return "there is no such setting";
|
|
}
|
|
std::vector<Destination> destinations() override {
|
|
std::vector<Destination> result;
|
|
for (const auto& [id, d] : profile_runtime::location_travel_runtime().policy.destinations) result.push_back({id, d.name, d.map});
|
|
return result;
|
|
}
|
|
std::vector<LevelInfo> levels() override {
|
|
std::vector<LevelInfo> result;
|
|
auto& r = runtime::detail::runtime();
|
|
std::lock_guard lock(r.mutex);
|
|
for (const auto& level : r.model.levels) result.push_back({level.asset, level.display_name, level.can_load});
|
|
return result;
|
|
}
|
|
std::string travel(std::string_view kind, std::string_view target) override {
|
|
if (multiplayer_controls_level(multiplayer::model())) return "only the session's host changes where everyone is";
|
|
if (kind == "location") {
|
|
const auto& destinations = profile_runtime::location_travel_runtime().policy.destinations;
|
|
const auto found = destinations.find(target);
|
|
if (found == destinations.end()) return "there is no destination " + std::string(target);
|
|
return runtime::detail::queue_location_travel(found->second.map.c_str()) ? std::string{} : "it cannot travel now";
|
|
}
|
|
std::array<char, 512> reply{};
|
|
const std::string asset(target);
|
|
if (runtime::detail::queue_load(nullptr, asset.c_str(), "", "", "", reply.data(), reply.size())) return {};
|
|
reply.back() = '\0';
|
|
return reply[0] ? std::string(reply.data()) : std::string("that level cannot be loaded now");
|
|
}
|
|
std::string ai(std::string_view operation, int count) override {
|
|
if (operation == "clear") {
|
|
ai_skaters::request_clear();
|
|
return {};
|
|
}
|
|
if (multiplayer_session_active()) return "AI skaters are left out of sessions: the other players would never see them";
|
|
if (static_cast<int>(ai_skaters::active_count()) + count > 16) return "at most 16 AI skaters";
|
|
ai_skaters::request_spawn(static_cast<unsigned>(count), true, true);
|
|
return {};
|
|
}
|
|
int ai_count() override { return static_cast<int>(ai_skaters::active_count()); }
|
|
|
|
// ---- the session ------------------------------------------------------------------------------
|
|
std::vector<PeerSkater> peer_skaters() override {
|
|
std::vector<PeerSkater> result;
|
|
for (const auto& p : plugin_feed::latest_peers()) {
|
|
const auto& q = p.rotation;
|
|
// Where the skater faces: the rotation's +Z about world up, as the local skater's heading.
|
|
const float heading = std::atan2(2 * (q[0] * q[2] + q[3] * q[1]), 1 - 2 * (q[0] * q[0] + q[1] * q[1]));
|
|
result.push_back({p.id, p.position, heading, p.age_ms});
|
|
}
|
|
return result;
|
|
}
|
|
void set_nametag(std::string_view plugin, std::uint64_t player, std::string text, std::uint32_t colour) override {
|
|
plugin_feed::set_tag(std::string(plugin), player, std::move(text), colour);
|
|
}
|
|
std::string voice_volume(std::uint64_t player, float volume) override {
|
|
const auto id = std::to_string(player);
|
|
const auto mute = multiplayer::command("voice-mute", id + (volume <= 0 ? " on" : " off"));
|
|
if (volume <= 0) return reply(mute);
|
|
return reply(multiplayer::command("voice-volume", id + " " + std::format("{:.2f}", volume)));
|
|
}
|
|
std::vector<ChatLine> chat_since(std::uint64_t sequence) override {
|
|
std::vector<ChatLine> result;
|
|
const auto chat = multiplayer::chat();
|
|
if (chat.latest <= sequence) return result;
|
|
for (const auto& line : chat.lines)
|
|
if (line.sequence > sequence && line.tag != "Plugin") result.push_back({line.sequence, line.sender, line.name, line.text, line.local, line.server});
|
|
return result;
|
|
}
|
|
std::string send_chat(std::string_view text) override {
|
|
if (!multiplayer_session_active()) return "not in a session";
|
|
const auto answer = multiplayer::command("chat", text);
|
|
return answer == "Message sent." ? std::string{} : answer;
|
|
}
|
|
std::string local_chat(std::string_view plugin, std::string_view text, std::uint32_t colour) override {
|
|
return multiplayer::plugin_chat(plugin, text, colour);
|
|
}
|
|
ThrowdownState throwdowns() override {
|
|
ThrowdownState state{multiplayer::local_throwdown_active(), multiplayer::local_throwdown_host(), {}};
|
|
for (const auto& p : multiplayer::skate_hud().players) state.skate.push_back({p.name, p.letters, p.up, p.out});
|
|
return state;
|
|
}
|
|
|
|
// ---- the rest -------------------------------------------------------------------------------------
|
|
Track track() override {
|
|
if (!music_started_) {
|
|
music_started_ = true;
|
|
external_player::start();
|
|
}
|
|
const auto now = external_player::now();
|
|
return {now.available, now.playing, now.app, now.title, now.artist, now.album, now.revision};
|
|
}
|
|
std::string music(std::string_view command) override {
|
|
using external_player::Command;
|
|
if (!music_started_) {
|
|
music_started_ = true;
|
|
external_player::start();
|
|
}
|
|
if (command == "play") external_player::send(Command::play);
|
|
else if (command == "pause") external_player::send(Command::pause);
|
|
else if (command == "next") external_player::send(Command::next);
|
|
else if (command == "previous") external_player::send(Command::previous);
|
|
else return "there is no such command";
|
|
return {};
|
|
}
|
|
std::optional<Track> game_track() override {
|
|
const auto song = spotify_station::game_song();
|
|
if (!song) return std::nullopt;
|
|
return Track{true, true, "skate.", song->title, song->artist, {}, 0};
|
|
}
|
|
std::string game_music(std::string_view command) override {
|
|
if (command != "next") return "there is no such command";
|
|
return spotify_station::game_next() ? std::string{} : "the game's music cannot skip now (a Spotify station is on, or no song has started)";
|
|
}
|
|
Progress progress() override {
|
|
const auto model = local_profile_progression();
|
|
Progress p;
|
|
p.available = model.available;
|
|
for (const auto& d : model.districts) p.districts.push_back({d.id, d.name, static_cast<int>(d.rank)});
|
|
for (const auto& c : model.challenges)
|
|
p.challenges.push_back({c.id, c.type, c.neighborhood, static_cast<int>(c.attempts), static_cast<int>(c.completed_goals.size()),
|
|
static_cast<int>(c.goal_count)});
|
|
p.score = static_cast<double>(model.score);
|
|
p.score_cap = static_cast<double>(model.score_cap);
|
|
p.score_level = model.score_level;
|
|
return p;
|
|
}
|
|
std::optional<std::string> setting(std::string_view name) override { return named_setting_value(name); }
|
|
std::vector<std::string> setting_names() override { return named_setting_names(); }
|
|
|
|
// ---- input -------------------------------------------------------------------------------------------
|
|
std::string bind(std::string_view plugin, std::string_view action, std::uint32_t combo) override {
|
|
if (!valid_action_binding(combo)) return "that is not a key or pad combo";
|
|
binds_[{std::string(plugin), std::string(action)}].own = combo;
|
|
publish_binds();
|
|
return {};
|
|
}
|
|
// Each bind's press, once, while the game itself has the input (no menu, console or text box).
|
|
// The player's own combo from the Binds page wins over the plugin's.
|
|
std::vector<std::pair<std::string, std::string>> take_presses() override {
|
|
apply_rebinds();
|
|
std::vector<std::pair<std::string, std::string>> presses;
|
|
if (binds_.empty()) return presses;
|
|
ControllerInput input;
|
|
DingoSDKOverlayReadControllerInput(&input, false);
|
|
for (auto& [key, bind] : binds_)
|
|
if (bind.latch.update(combo_in_use(key, bind), input, false)) presses.push_back(key);
|
|
return presses;
|
|
}
|
|
std::string host_throwdown(std::string_view plugin, std::string_view series, std::vector<std::uint64_t> players, int most) override {
|
|
if (!multiplayer_session_active()) return "not in a session";
|
|
if (!thrower_.empty() && thrower_ != plugin) {
|
|
const auto status = multiplayer::throwdown_relay_host_status();
|
|
if (status == "asked" || status == "spawning" || status == "open") return thrower_ + " is hosting a drop";
|
|
}
|
|
auto why = multiplayer::throwdown_relay_host(std::string(series), std::move(players), static_cast<std::uint32_t>(std::max(most, 0)));
|
|
if (why.empty()) thrower_ = plugin;
|
|
return why;
|
|
}
|
|
void cancel_throwdown(std::string_view plugin) override {
|
|
if (thrower_ != plugin) return;
|
|
multiplayer::throwdown_relay_cancel_host(); // its status says "cancelled" from the relay's next tick
|
|
}
|
|
std::string throwdown_status(std::string_view plugin) override {
|
|
return thrower_ == plugin ? multiplayer::throwdown_relay_host_status() : std::string{};
|
|
}
|
|
// ---- board -----------------------------------------------------------------------------------------
|
|
std::optional<std::array<BoardBone, 16>> board_bones() override {
|
|
const auto drawn = board_pose::read();
|
|
if (!drawn) return std::nullopt;
|
|
std::array<BoardBone, 16> out{};
|
|
for (std::size_t i = 0; i < out.size(); ++i) out[i] = {(*drawn)[i].position, (*drawn)[i].rotation, (*drawn)[i].scale};
|
|
return out;
|
|
}
|
|
std::string board_set(std::string_view plugin, int bone, const BoardBone& value, bool world) override {
|
|
return board_pose::set(plugin, bone, {value.position, value.rotation, value.scale}, world ? board_pose::Space::world : board_pose::Space::local);
|
|
}
|
|
std::string board_hide(std::string_view plugin, int bone) override { return board_pose::hide(plugin, bone); }
|
|
void board_clear(std::string_view plugin, int bone) override { board_pose::clear(plugin, bone); }
|
|
void board_park(std::string_view plugin, std::vector<int> bones) override { board_pose::park(plugin, std::move(bones)); }
|
|
std::string board_toss(std::string_view plugin, std::vector<int> bones, const Vec3& velocity, const Vec3& spin, float ground) override {
|
|
return board_pose::toss(plugin, std::move(bones), velocity, spin, ground);
|
|
}
|
|
std::string board_fit(std::string_view plugin, std::optional<BoardFit> fit) override {
|
|
if (!fit) return board_pose::fit(plugin, std::nullopt);
|
|
return board_pose::fit(plugin, board_pose::Fit{fit->plates, fit->axle});
|
|
}
|
|
// ---- variants ------------------------------------------------------------------------------------
|
|
// The mod's folder is read again at most every 2 s: a menu page asks every frame.
|
|
std::optional<VariantState> variants(std::string_view plugin, const std::filesystem::path& package) override {
|
|
const auto now = GetTickCount64();
|
|
auto& kept = variant_reads_[std::string(plugin)];
|
|
if (!kept.at || now - kept.at > 2000) {
|
|
std::vector<std::string> notes;
|
|
const auto mod = mods::describe_mod(package, std::string(plugin), notes);
|
|
kept.declared = mod.variants.has_value();
|
|
kept.installed = kept.declared ? mods::installed_variants(mod) : std::vector<std::string>{};
|
|
kept.at = now;
|
|
}
|
|
if (!kept.declared) return std::nullopt;
|
|
return VariantState{kept.installed, mods::chosen_variant(plugin), mods::variant_in_play(plugin)};
|
|
}
|
|
std::string choose_variant(std::string_view plugin, const std::filesystem::path& package, std::string_view variant) override {
|
|
const auto state = variants(plugin, package);
|
|
if (!state) return "its mod declares no variants";
|
|
std::string name(variant);
|
|
for (auto& c : name) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
if (std::find(state->installed.begin(), state->installed.end(), name) == state->installed.end()) return "its mod has no variant " + name;
|
|
if (live_mods::busy()) return "the mods are being applied";
|
|
if (!mods::save_variant(plugin, name)) return "the choice could not be saved";
|
|
if (name != state->in_play) (void)live_mods::apply(true);
|
|
return {};
|
|
}
|
|
// ---- outfit --------------------------------------------------------------------------------------
|
|
std::string outfit_set(std::string_view plugin, std::string_view slot, std::string_view item, std::vector<float> placement, bool covers) override {
|
|
return outfit_overlay::set(std::string(plugin), std::string(slot), std::string(item), std::move(placement), covers);
|
|
}
|
|
void outfit_clear(std::string_view plugin, std::string_view slot) override { outfit_overlay::clear(plugin, slot); }
|
|
std::string outfit_find(std::string_view ending) override { return outfit_overlay::find_item(ending); }
|
|
std::vector<OutfitSlot> outfit_slots() override {
|
|
std::vector<OutfitSlot> out;
|
|
for (auto& s : outfit_overlay::slots()) out.push_back({std::move(s.name), std::move(s.item), std::move(s.parameters)});
|
|
return out;
|
|
}
|
|
std::string outfit_status() override { return outfit_overlay::status(); }
|
|
// ---- the skater's physics ------------------------------------------------------------------------
|
|
SkaterNews take_skater_news() override {
|
|
skater_events::want();
|
|
SkaterNews news;
|
|
for (const auto& l : skater_events::take_landings()) news.landings.push_back({l.down_speed, l.speed, l.rail});
|
|
for (const auto& h : skater_events::take_hits())
|
|
news.hits.push_back({skater_body::names[std::min<std::size_t>(h.bone, skater_body::count - 1)], h.impact, h.vehicle, h.board});
|
|
for (const auto& f : skater_events::take_falls()) {
|
|
SkaterNews::Fall fall{f.seconds, f.left, f.right, {}};
|
|
for (std::size_t i = 1; i < skater_body::count; ++i)
|
|
if (f.impact[i] > 0 || f.slide[i] > 0) fall.bones.push_back({skater_body::names[i], f.impact[i], f.slide[i]});
|
|
news.falls.push_back(std::move(fall));
|
|
}
|
|
return news;
|
|
}
|
|
std::vector<PhysicsStep> take_physics_steps() override {
|
|
skater_events::want_steps();
|
|
std::vector<PhysicsStep> out;
|
|
for (const auto& news : skater_events::take_steps()) {
|
|
const auto& s = news.step;
|
|
PhysicsStep step{news.at, s.seconds, s.wipeout, s.airborne, s.ragdoll, std::nullopt, s.spin, {}};
|
|
if (s.velocity) step.velocity = Vec3{(*s.velocity)[0], (*s.velocity)[1], (*s.velocity)[2]};
|
|
for (std::size_t i = 1; i < skater_body::count; ++i) {
|
|
const auto& c = s.body.bodies[i];
|
|
if (!c.touching) continue;
|
|
const float slide = std::sqrt(c.slide[0] * c.slide[0] + c.slide[1] * c.slide[1] + c.slide[2] * c.slide[2]);
|
|
step.bodies.push_back({skater_body::names[i], c.impact, std::isfinite(slide) ? slide : 0.0f, c.hit.board, c.hit.vehicle,
|
|
c.hit.world || c.hit.kind_5 || c.hit.kind_11});
|
|
}
|
|
out.push_back(std::move(step));
|
|
}
|
|
return out;
|
|
}
|
|
std::string xray(std::string_view plugin, bool on) override {
|
|
if (!xray::available()) return "the x-ray is unavailable for this game build";
|
|
xray::prepare(plugin, on);
|
|
return {};
|
|
}
|
|
std::string hud_corner(std::string_view plugin, bool hidden) override {
|
|
hud_corner::ask(plugin, hidden);
|
|
return {};
|
|
}
|
|
std::string game_speed(std::string_view plugin, float speed) override {
|
|
if (speed < 1.0f && multiplayer_session_active()) return "never in a multiplayer session";
|
|
game_speed::ask(plugin, speed);
|
|
return {};
|
|
}
|
|
float game_speed_now() override { return game_speed::current(); }
|
|
std::string grind_exit(std::string_view plugin, std::optional<GrindExitWish> wish) override {
|
|
std::optional<grind_exit::Wish> w;
|
|
if (wish) {
|
|
using Side = grind_exit::Wish::Side;
|
|
w = grind_exit::Wish{wish->side == "toe" ? Side::toe : wish->side == "heel" ? Side::heel : wish->side == "right" ? Side::right : Side::left,
|
|
wish->stance, wish->strength};
|
|
}
|
|
return grind_exit::wish(plugin, w);
|
|
}
|
|
std::string set_throwdown_mode(std::string_view plugin, const ThrowdownMode& m) override {
|
|
multiplayer::custom_throwdowns::Mode mode;
|
|
mode.plugin = plugin;
|
|
mode.id = m.id;
|
|
mode.name = m.name;
|
|
mode.description = m.description;
|
|
mode.art = m.art;
|
|
mode.flag = m.flag;
|
|
mode.replaces = m.replaces;
|
|
mode.card = m.card;
|
|
mode.order = m.order;
|
|
mode.color = m.color;
|
|
mode.min_players = m.min_players;
|
|
mode.max_players = m.max_players;
|
|
mode.players = m.players;
|
|
mode.cursor_height = m.cursor_height;
|
|
return multiplayer::custom_throwdowns::set_mode(std::move(mode));
|
|
}
|
|
// ---- the throwdown kit -------------------------------------------------------------------------
|
|
std::string publish_throwdown_ui(std::string_view plugin, const Json& document) override { return throwdowns::kit::publish(plugin, document); }
|
|
bool throwdown_flag_begin(std::string_view mode, std::uint32_t game, const throwdowns::kit::flag::Options& options) override {
|
|
return throwdowns::kit::flag::begin(mode, game, options);
|
|
}
|
|
throwdowns::kit::flag::State throwdown_flag_state() override { return throwdowns::kit::flag::state(); }
|
|
// Only the plugin whose mode the registration is may let it go.
|
|
void throwdown_flag_leave(std::string_view plugin) override {
|
|
if (throwdowns::kit::flag::state().mode.starts_with(std::string(plugin) + "/")) throwdowns::kit::flag::leave();
|
|
}
|
|
// ---- files: <game>/Mods/.reskate_plugin_data/<plugin>/ (mod_list.h plugin_data_folder) -------------
|
|
static constexpr std::size_t most_file_bytes = 1u << 20, most_files = 1000;
|
|
// Before 2026-10-20 the folder was Mods/.reskate/data/<plugin>/, which a full merge deletes. The
|
|
// merge moves what is still there first (mods::keep_plugin_data); a merge that kept its patch did
|
|
// not run it, so the first look for a plugin's folder does.
|
|
static void move_old_data() {
|
|
static std::once_flag once;
|
|
std::call_once(once, [] {
|
|
for (const auto& plugin : mods::keep_plugin_data(game_folder() / mods::mods_folder))
|
|
logging::write(logging::Level::info, logging::Channel::plugins,
|
|
"Plugins: " + plugin + "'s data folder moved to Mods/" + mods::plugin_data_folder);
|
|
});
|
|
}
|
|
// The plugin's folder and the path in it, or why not. A path that resolves outside the folder
|
|
// (through a junction someone made in it) is refused.
|
|
static std::string data_file(std::string_view plugin, std::string_view path, std::filesystem::path& root, std::filesystem::path& file) {
|
|
try {
|
|
root = game_folder() / mods::mods_folder / mods::plugin_data_folder / std::filesystem::path(std::string(plugin));
|
|
move_old_data();
|
|
file = path.empty() ? root : root / std::filesystem::path(std::string(path));
|
|
std::error_code error;
|
|
const auto real_root = std::filesystem::weakly_canonical(root, error);
|
|
const auto real_file = error ? std::filesystem::path{} : std::filesystem::weakly_canonical(file, error);
|
|
if (error) return "the data folder cannot be read";
|
|
const auto relative = real_file.lexically_relative(real_root);
|
|
if (relative.empty() || *relative.begin() == "..") return "outside the data folder";
|
|
return {};
|
|
} catch (const std::exception& e) {
|
|
return e.what();
|
|
}
|
|
}
|
|
std::string files_list(std::string_view plugin, std::string_view folder, std::vector<std::string>& names) override {
|
|
std::filesystem::path root, at;
|
|
if (auto why = data_file(plugin, folder, root, at); !why.empty()) return why;
|
|
std::error_code error;
|
|
if (!std::filesystem::exists(at, error)) return folder.empty() ? std::string{} : std::string("not found");
|
|
if (!std::filesystem::is_directory(at, error)) return "not a folder";
|
|
for (std::filesystem::directory_iterator it(at, error), end; !error && it != end && names.size() < most_files; it.increment(error)) {
|
|
auto name = it->path().filename().string();
|
|
if (name.ends_with(".tmp")) continue; // a write going on
|
|
if (it->is_directory(error)) name += "/";
|
|
names.push_back(std::move(name));
|
|
}
|
|
if (error) return "the folder cannot be read";
|
|
std::sort(names.begin(), names.end());
|
|
return {};
|
|
}
|
|
std::string files_read(std::string_view plugin, std::string_view path, std::string& text) override {
|
|
std::filesystem::path root, file;
|
|
if (auto why = data_file(plugin, path, root, file); !why.empty()) return why;
|
|
std::error_code error;
|
|
if (!std::filesystem::is_regular_file(file, error)) return "not found";
|
|
const auto size = std::filesystem::file_size(file, error);
|
|
if (error) return "the file cannot be read";
|
|
if (size > most_file_bytes) return "the file is larger than 1 MB";
|
|
std::ifstream in(file, std::ios::binary);
|
|
text.resize(static_cast<std::size_t>(size));
|
|
if (!in || !in.read(text.data(), static_cast<std::streamsize>(size))) return "the file cannot be read";
|
|
return {};
|
|
}
|
|
std::string files_write(std::string_view plugin, std::string_view path, std::string_view text) override {
|
|
if (text.size() > most_file_bytes) return "a file is at most 1 MB";
|
|
std::filesystem::path root, file;
|
|
if (auto why = data_file(plugin, path, root, file); !why.empty()) return why;
|
|
std::error_code error;
|
|
if (std::filesystem::is_directory(file, error)) return "a folder is there";
|
|
if (!std::filesystem::exists(file, error)) {
|
|
std::size_t count = 0;
|
|
if (std::filesystem::exists(root, error))
|
|
for (std::filesystem::recursive_directory_iterator it(root, error), end; !error && it != end; it.increment(error))
|
|
if (it->is_regular_file(error) && ++count >= most_files) return "the data folder holds 1000 files already";
|
|
}
|
|
std::filesystem::create_directories(file.parent_path(), error);
|
|
if (error) return "the folder cannot be made";
|
|
auto temporary = file;
|
|
temporary += ".tmp";
|
|
{
|
|
std::ofstream out(temporary, std::ios::binary | std::ios::trunc);
|
|
if (!out || !out.write(text.data(), static_cast<std::streamsize>(text.size()))) return "the file cannot be written";
|
|
}
|
|
std::filesystem::rename(temporary, file, error);
|
|
if (error) {
|
|
std::filesystem::remove(temporary, error);
|
|
return "the file cannot be written";
|
|
}
|
|
return {};
|
|
}
|
|
std::string files_remove(std::string_view plugin, std::string_view path) override {
|
|
std::filesystem::path root, file;
|
|
if (auto why = data_file(plugin, path, root, file); !why.empty()) return why;
|
|
std::error_code error;
|
|
if (!std::filesystem::exists(file, error)) return "not found";
|
|
if (std::filesystem::is_directory(file, error) && !std::filesystem::is_empty(file, error)) return "the folder is not empty";
|
|
if (!std::filesystem::remove(file, error) || error) return "it cannot be removed";
|
|
return {};
|
|
}
|
|
// ---- ui.dialog ----------------------------------------------------------------------------------
|
|
std::string dialog_open(std::string_view plugin, const DialogSpec& spec) override { return overlay::open_plugin_dialog(plugin, spec); }
|
|
void dialog_close(std::string_view plugin, std::string_view id) override { overlay::close_plugin_dialog(plugin, id); }
|
|
// ---- skater.push / hold --------------------------------------------------------------------------
|
|
// A velocity the next on-board physics step writes (Extension/Skater/skater_push.h), and a hold
|
|
// that keeps the board still. Both are written on the game's stock physics only (client_noclip.cpp):
|
|
// without it they would be dropped unseen, so they say so.
|
|
static constexpr char needs_stock_physics[] = "needs the game's stock physics: reskate.physics.competition(true) first";
|
|
std::string skater_push(std::string_view, const Vec3& velocity) override {
|
|
if (!::dingosdk::skater_push::valid(velocity)) return "a push is horizontal (y 0) and at most 120 m/s";
|
|
if (!competition_stock_physics()) return std::string("a push ") + needs_stock_physics;
|
|
::dingosdk::skater_push::request(velocity);
|
|
return {};
|
|
}
|
|
std::string skater_push_wait(std::string_view) override {
|
|
::dingosdk::skater_push::wait();
|
|
return {};
|
|
}
|
|
std::string skater_push_reset(std::string_view) override {
|
|
::dingosdk::skater_push::reset();
|
|
return {};
|
|
}
|
|
std::string skater_hold(std::string_view plugin, bool on) override {
|
|
if (on && !competition_stock_physics()) return std::string("a hold ") + needs_stock_physics;
|
|
if (on) hold_owner_ = plugin;
|
|
else if (hold_owner_ != plugin) return {};
|
|
else hold_owner_.clear();
|
|
::dingosdk::skater_push::hold(on);
|
|
return {};
|
|
}
|
|
void remove_throwdown_mode(std::string_view plugin, std::string_view id) override {
|
|
multiplayer::custom_throwdowns::remove_mode(plugin, id);
|
|
}
|
|
std::vector<GrindPop> take_grind_pops(std::string_view plugin) override {
|
|
std::vector<GrindPop> out;
|
|
for (auto& p : grind_exit::take_pops(plugin)) out.push_back({p.applied, std::move(p.reason), std::move(p.basis), p.toward, p.before, p.after});
|
|
return out;
|
|
}
|
|
std::string hide_pad(std::string_view plugin, bool lt, bool rt, bool left_x) override {
|
|
if (!grind_exit::available()) return "hiding pad controls is unavailable for this game build";
|
|
grind_exit::hide(plugin, {lt, rt, left_x});
|
|
return {};
|
|
}
|
|
std::optional<Vec3> board_velocity() override {
|
|
skater_events::want();
|
|
return skater_events::board_velocity();
|
|
}
|
|
std::optional<SlamNews> last_slam() override {
|
|
const auto slam = skater_events::last_slam();
|
|
if (!slam.at) return std::nullopt;
|
|
return SlamNews{slam.force, slam.cause, static_cast<double>(GetTickCount64() - slam.at)};
|
|
}
|
|
std::string wipeout(float force) override {
|
|
if (multiplayer::local_throwdown_active()) return "not while a throwdown runs";
|
|
no_bail_request_wipeout(std::clamp(force, 0.5f, 3.0f));
|
|
return {};
|
|
}
|
|
// ---- audio ---------------------------------------------------------------------------------------
|
|
std::uint64_t play_sound(std::string_view plugin, SoundStart sound) override {
|
|
plugin_sound::Voice voice;
|
|
voice.owner = plugin;
|
|
voice.wave = std::move(sound.wave);
|
|
voice.volume = sound.volume;
|
|
voice.pitch = sound.pitch;
|
|
voice.range = sound.range;
|
|
voice.throttle = sound.throttle;
|
|
voice.speed = sound.speed;
|
|
voice.loop = sound.loop;
|
|
voice.position = sound.position;
|
|
return plugin_sound::start(std::move(voice));
|
|
}
|
|
bool change_sound(std::string_view plugin, std::uint64_t id, const SoundChange& change) override {
|
|
auto* voice = plugin_sound::find(id, plugin);
|
|
if (!voice) return false;
|
|
if (change.volume) voice->volume = *change.volume;
|
|
if (change.pitch) voice->pitch = *change.pitch;
|
|
if (change.range) voice->range = *change.range;
|
|
if (change.throttle) voice->throttle = *change.throttle;
|
|
if (change.speed) voice->speed = *change.speed;
|
|
if (change.position) voice->position = *change.position;
|
|
return true;
|
|
}
|
|
void stop_sound(std::string_view plugin, std::uint64_t id) override { plugin_sound::stop(id, plugin); }
|
|
bool sound_playing(std::string_view plugin, std::uint64_t id) override { return plugin_sound::find(id, plugin) != nullptr; }
|
|
// ---- input -------------------------------------------------------------------------------------
|
|
bool camera_mine(std::string_view plugin) const { return !camera_holder_.empty() && camera_holder_ == plugin && camera_shown_; }
|
|
std::optional<FlightInput> flight_input(std::string_view plugin) override {
|
|
if (!camera_mine(plugin)) return std::nullopt;
|
|
// Read once a tick: the mouse's movement is used up as it is read.
|
|
const auto now = GetTickCount64();
|
|
if (now - flight_read_at_ > 4) {
|
|
flight_read_at_ = now;
|
|
overlay::FlightInput in;
|
|
DingoSDKOverlayReadFlightInput(&in, true, false);
|
|
flight_ = in.active ? std::optional<FlightInput>(FlightInput{in.right, in.up, in.forward, in.look_x, in.look_y, in.boost})
|
|
: std::nullopt;
|
|
}
|
|
return flight_;
|
|
}
|
|
std::optional<PadInput> gamepad() override {
|
|
if (!joystick::game_in_foreground()) return std::nullopt;
|
|
overlay::Gamepad pad;
|
|
{
|
|
joystick::InputAccess access; // seen even while captured from the game
|
|
pad = overlay::read_gamepad();
|
|
}
|
|
if (!pad.valid) return std::nullopt;
|
|
return PadInput{pad.left_x, pad.left_y, pad.right_x, pad.right_y, pad.left_trigger, pad.right_trigger, pad.buttons, pad.playstation};
|
|
}
|
|
std::optional<StickInput> joystick() override {
|
|
if (!joystick::game_in_foreground()) return std::nullopt;
|
|
const auto sample = joystick::poll();
|
|
if (!sample.valid) return std::nullopt;
|
|
return StickInput{sample.axes, sample.buttons, sample.name};
|
|
}
|
|
std::string capture_input(std::string_view plugin, bool on) override {
|
|
if (!on) {
|
|
if (capturer_ == plugin) capturer_.clear();
|
|
return {};
|
|
}
|
|
if (!camera_mine(plugin)) return "only while the plugin's camera is shown";
|
|
capturer_ = plugin;
|
|
capture_until_ = GetTickCount64() + 250;
|
|
return {};
|
|
}
|
|
bool capturing() const noexcept {
|
|
return !capturer_.empty() && camera_mine(capturer_) && GetTickCount64() < capture_until_;
|
|
}
|
|
// ---- skater.animation ---------------------------------------------------------------------------
|
|
std::string play_clip(std::string_view plugin, ClipRef clip, float speed, float blend, bool loop) override {
|
|
if (multiplayer::local_throwdown_active()) return "not while a throwdown runs";
|
|
if (!player_.empty() && player_ != plugin && skater_animation::playing()) return player_ + " is playing a clip";
|
|
player_ = plugin;
|
|
skater_animation::play(skater_animation::Play{std::move(clip), speed, blend, loop});
|
|
return {};
|
|
}
|
|
void stop_clip(std::string_view plugin) override {
|
|
if (player_ != plugin) return;
|
|
skater_animation::play(std::nullopt);
|
|
player_.clear();
|
|
}
|
|
bool clip_playing(std::string_view plugin) override { return player_ == plugin && skater_animation::playing(); }
|
|
std::string replace_trick(std::string_view plugin, std::string trick, std::string pop, ClipRef clip, float speed) override {
|
|
const auto key = std::make_pair(trick, pop);
|
|
if (const auto found = replacements_.find(key); found != replacements_.end() && found->second.first != plugin)
|
|
return found->second.first + " replaces that trick already";
|
|
if (!clip) replacements_.erase(key);
|
|
else {
|
|
const auto mine = std::count_if(replacements_.begin(), replacements_.end(), [&](const auto& r) { return r.second.first == plugin; });
|
|
if (!replacements_.contains(key) && mine >= 32) return "a plugin replaces at most 32 tricks";
|
|
replacements_[key] = {std::string(plugin), skater_animation::Replacement{std::move(trick), std::move(pop), std::move(clip), speed}};
|
|
}
|
|
publish_replacements();
|
|
return {};
|
|
}
|
|
std::string record_clip(std::string_view plugin, float seconds) override {
|
|
if (!recorder_.empty() && recorder_ != plugin && skater_animation::recording()) return recorder_ + " is recording";
|
|
if (!skater_animation::record(seconds)) return "a recording is going on";
|
|
recorder_ = plugin;
|
|
return {};
|
|
}
|
|
ClipRef take_recording(std::string_view plugin) override {
|
|
if (recorder_ != plugin || skater_animation::recording()) return nullptr;
|
|
auto clip = skater_animation::take_recording();
|
|
if (clip) recorder_.clear();
|
|
return clip;
|
|
}
|
|
std::string current_trick() override { return skater_animation::current_trick(); }
|
|
void tick_animation(std::uintptr_t base, std::uintptr_t client) {
|
|
xray::tick();
|
|
hud_corner::tick();
|
|
game_speed::tick();
|
|
grind_exit::tick(client);
|
|
outfit_overlay::tick();
|
|
board_pose::tick(base, local_skater_component(base, client));
|
|
const bool allowed = !multiplayer::local_throwdown_active();
|
|
skater_animation::tick(base, local_skater_component(base, client), allowed);
|
|
}
|
|
// ---- general services for a game layer in Lua ------------------------------------------------------
|
|
std::optional<SkaterTelemetry> skater_telemetry(std::string_view plugin) override {
|
|
const auto t = trainer::telemetry();
|
|
const auto w = watched_physics_state();
|
|
SkaterTelemetry m;
|
|
m.skater = t.skater;
|
|
m.airborne = t.airborne;
|
|
m.position = t.position;
|
|
m.heading = t.heading;
|
|
m.speed = t.speed;
|
|
m.vertical = t.vertical;
|
|
m.air_time = t.air_time;
|
|
m.height = t.height;
|
|
m.top_speed = t.top_speed;
|
|
m.physics_state = t.physics_state;
|
|
const auto& j = t.last;
|
|
m.last = {j.serial, j.takeoff_speed, j.takeoff_angle, j.air_time, j.height, j.distance, j.drop, j.landing_speed,
|
|
j.spin, j.spin_rate, j.flip, j.board_turn, j.state, j.takeoff, j.landing};
|
|
m.watch = {w.valid, w.ragdoll_known, w.ragdoll, w.state, w.previous, w.previous_seconds, w.changes, w.wipeouts, w.runouts};
|
|
m.teleport_pending = local_skater_teleport_pending();
|
|
// Each plugin hears of a slam or a strike once, whoever else reads the telemetry; its first
|
|
// read only takes its place.
|
|
const auto slams = bonecam::bone_cam_slams(), strikes = bonecam::bone_cam_strikes();
|
|
auto seen = telemetry_seen_.find(plugin);
|
|
if (seen == telemetry_seen_.end()) seen = telemetry_seen_.emplace(std::string(plugin), std::pair{slams, strikes}).first;
|
|
m.slam = slams != seen->second.first;
|
|
m.struck = strikes != seen->second.second;
|
|
seen->second = {slams, strikes};
|
|
m.body = bonecam::bail_body_position();
|
|
return m;
|
|
}
|
|
std::optional<std::uint32_t> pad_bits(bool menu) override {
|
|
ControllerInput pad;
|
|
DingoSDKOverlayReadControllerInput(&pad, menu);
|
|
if (!pad.available) return std::nullopt;
|
|
return pad.buttons;
|
|
}
|
|
bool key_down(int virtual_key) override { return overlay::key_down(virtual_key); }
|
|
int mouse_wheel() override { return overlay::take_mouse_wheel(); }
|
|
void game_buttons(std::string_view plugin, std::uint16_t hidden, std::uint16_t held, bool paused) override {
|
|
buttons_owner_ = plugin;
|
|
overlay::hide_game_buttons(hidden);
|
|
overlay::hold_game_buttons(held);
|
|
if (paused) overlay::pause_game_input(true);
|
|
}
|
|
bool input_allowed() override {
|
|
DWORD process{};
|
|
const auto window = GetForegroundWindow();
|
|
const bool focused = window && GetWindowThreadProcessId(window, &process) && process == GetCurrentProcessId();
|
|
return focused && !overlay::interface_open() && multiplayer::native_throwdown_gameplay_input_allowed();
|
|
}
|
|
std::string free_camera(std::string_view plugin, bool on) override {
|
|
if (on) free_camera_owner_ = plugin;
|
|
else if (free_camera_owner_ != plugin) return {};
|
|
else free_camera_owner_.clear();
|
|
console::request_debug(overlay::DebugAction::set_free_camera, on);
|
|
return {};
|
|
}
|
|
std::string pad_flight(std::string_view plugin, bool on) override {
|
|
if (on) {
|
|
if (!pad_flight_owner_.empty() && pad_flight_owner_ != plugin) return pad_flight_owner_ + " flies the camera from the pad";
|
|
pad_flight_owner_ = plugin;
|
|
} else if (pad_flight_owner_ != plugin) return {};
|
|
else pad_flight_owner_.clear();
|
|
::dingosdk::pad_flight::request(on);
|
|
return {};
|
|
}
|
|
void competition(std::string_view plugin, bool on) override {
|
|
if (on) competition_owner_ = plugin;
|
|
else if (competition_owner_ != plugin) return;
|
|
else competition_owner_.clear();
|
|
if (on) physics_tuning::prepare();
|
|
set_competition_stock_physics(on);
|
|
}
|
|
bool stock_physics_ready() override { return physics_tuning::game_tuning() != nullptr; }
|
|
std::string world_map() override { return local_profile_object_persistence().map; }
|
|
std::uint64_t world_id() override {
|
|
// The session's world in a session, else the native throwdown world (the one native flag
|
|
// placement and the flags echo back).
|
|
const auto world = multiplayer_session_active() ? multiplayer::throwdown_relay_world() : 0;
|
|
return world ? world : multiplayer::native_throwdown_world();
|
|
}
|
|
bool world_barred() override { return multiplayer::throwdown_relay_barred(); }
|
|
std::optional<bool> stance_goofy() override { return profile_runtime::game_stance_goofy(); }
|
|
void trick_feed(std::string_view plugin, bool on) override { tricks::feed::ask(std::string(plugin), on); }
|
|
std::vector<TrickNote> take_trick_notes() override {
|
|
std::vector<TrickNote> out;
|
|
for (auto& n : tricks::feed::take()) out.push_back({n.entry, n.kind, std::move(n.text)});
|
|
return out;
|
|
}
|
|
void prepare_physics() override { physics_tuning::prepare(); }
|
|
InputFocus input_focus() override {
|
|
DWORD process{};
|
|
const auto window = GetForegroundWindow();
|
|
return {window && GetWindowThreadProcessId(window, &process) && process == GetCurrentProcessId(), overlay::interface_open(),
|
|
multiplayer::native_throwdown_gameplay_input_allowed()};
|
|
}
|
|
void spectate(std::uint64_t player) override { multiplayer::spectate_party_member(player); }
|
|
std::vector<std::array<float, 2>> trick_gesture(std::string_view trick) override {
|
|
tricks::prepare_trick_gestures();
|
|
return tricks::trick_gesture(trick);
|
|
}
|
|
// ---- content: <mod>/<folder>/ of every enabled mod ---------------------------------------------
|
|
// The enabled mods, read again at most every 2 s: a page may list them every frame.
|
|
struct EnabledMod {
|
|
std::string name, title;
|
|
std::filesystem::path directory;
|
|
};
|
|
std::vector<EnabledMod> enabled_mods() {
|
|
const auto now = GetTickCount64();
|
|
if (enabled_read_at_ && now - enabled_read_at_ < 2000) return enabled_mods_;
|
|
enabled_read_at_ = now;
|
|
enabled_mods_.clear();
|
|
const auto list = mods::scan_mods(mods::engine_data_root());
|
|
if (list.issue.empty())
|
|
for (const auto& entry : list.entries)
|
|
if (entry.enabled) enabled_mods_.push_back({entry.mod.name, entry.mod.title.empty() ? entry.mod.name : entry.mod.title, entry.mod.directory});
|
|
return enabled_mods_;
|
|
}
|
|
static constexpr std::size_t most_content_files = 1000, most_content_bytes = 1u << 20;
|
|
std::string content_list(std::string_view folder, std::vector<ContentFile>& files) override {
|
|
for (const auto& mod : enabled_mods()) {
|
|
const auto root = mod.directory / std::filesystem::path(std::string(folder));
|
|
std::error_code error;
|
|
if (!std::filesystem::is_directory(root, error)) continue;
|
|
std::vector<ContentFile> found;
|
|
for (std::filesystem::recursive_directory_iterator it(root, std::filesystem::directory_options::none, error), end;
|
|
!error && it != end; it.increment(error)) {
|
|
if (it.depth() >= 4) it.disable_recursion_pending();
|
|
if (!it->is_regular_file(error)) continue;
|
|
const auto relative = it->path().lexically_relative(root).generic_wstring();
|
|
std::string path;
|
|
bool plain = true;
|
|
for (const auto c : relative) {
|
|
if (c < 0x20 || c > 0x7e) plain = false;
|
|
path.push_back(static_cast<char>(c));
|
|
}
|
|
if (!plain || path.ends_with(".tmp")) continue; // a name a plugin could not ask for again
|
|
found.push_back({mod.name, mod.title, std::move(path), static_cast<std::uint64_t>(it->file_size(error))});
|
|
if (files.size() + found.size() >= most_content_files) break;
|
|
}
|
|
std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.path < b.path; });
|
|
for (auto& file : found) files.push_back(std::move(file));
|
|
if (files.size() >= most_content_files) break;
|
|
}
|
|
return {};
|
|
}
|
|
std::string content_read(std::string_view mod, std::string_view folder, std::string_view path, std::string& text) override {
|
|
const auto mods_now = enabled_mods();
|
|
const auto found = std::find_if(mods_now.begin(), mods_now.end(), [&](const EnabledMod& each) {
|
|
return each.name.size() == mod.size() && _strnicmp(each.name.c_str(), mod.data(), mod.size()) == 0;
|
|
});
|
|
if (found == mods_now.end()) return "no such mod is on";
|
|
const auto root = found->directory / std::filesystem::path(std::string(folder));
|
|
const auto file = root / std::filesystem::path(std::string(path));
|
|
std::error_code error;
|
|
const auto real_root = std::filesystem::weakly_canonical(root, error);
|
|
const auto real_file = error ? std::filesystem::path{} : std::filesystem::weakly_canonical(file, error);
|
|
if (error) return "not found";
|
|
const auto relative = real_file.lexically_relative(real_root);
|
|
if (relative.empty() || *relative.begin() == "..") return "outside the mod's folder";
|
|
if (!std::filesystem::is_regular_file(file, error)) return "not found";
|
|
const auto size = std::filesystem::file_size(file, error);
|
|
if (error) return "the file cannot be read";
|
|
if (size > most_content_bytes) return "the file is larger than 1 MB";
|
|
std::ifstream in(file, std::ios::binary);
|
|
text.resize(static_cast<std::size_t>(size));
|
|
if (!in || !in.read(text.data(), static_cast<std::streamsize>(size))) return "the file cannot be read";
|
|
return {};
|
|
}
|
|
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<std::uint32_t> 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<std::string>& words) override {
|
|
if (words.empty()) return bonecam::bone_cam_setting();
|
|
const auto& setting = words.front();
|
|
if (words.size() == 1 && setting == "status") return bonecam::bone_cam_command({});
|
|
if (words.size() == 1 && setting == "bail") {
|
|
bonecam::bone_cam_bail();
|
|
return {};
|
|
}
|
|
if (words.size() == 1 && setting == "ringing") return bonecam::bone_cam_ringing() ? "1" : "0";
|
|
// "game on|off": this plugin's game asks for the Bone Cam (the "game" setting).
|
|
if (words.size() == 2 && setting == "game" && (words[1] == "on" || words[1] == "off")) {
|
|
bonecam::bone_cam_game(plugin, words[1] == "on");
|
|
return {};
|
|
}
|
|
return bonecam::bone_cam_command(words);
|
|
}
|
|
// ---- the lobby host's authority ------------------------------------------------------------------
|
|
std::string kick(std::uint64_t player, std::string_view why) override { return multiplayer::host_kick(player, why); }
|
|
// The host's own chat, under the plugin's name: a lobby has no server to speak for it.
|
|
std::string announce(std::string_view plugin, std::string_view text) override {
|
|
if (!multiplayer_session_active()) return "not in a session";
|
|
const auto answer = multiplayer::command("chat", "[" + std::string(plugin) + "] " + std::string(text));
|
|
return answer == "Message sent." ? std::string{} : answer;
|
|
}
|
|
std::string move_player(std::uint64_t player, const Vec3& at) override { return multiplayer::host_teleport(player, at); }
|
|
std::string tell(std::string_view, std::uint64_t, std::string_view) override {
|
|
return "a lobby has no private chat: send that player's copy a message (reskate.net.send) and show it there";
|
|
}
|
|
// ---- camera ------------------------------------------------------------------------------------
|
|
std::optional<CameraView> camera_view() override {
|
|
const auto view = plugin_camera::camera_now();
|
|
if (!view || !(view->vertical_fov > 1 && view->vertical_fov < 175)) return std::nullopt;
|
|
const auto& m = view->world;
|
|
const auto free = client_free_camera_view();
|
|
return CameraView{{m[12], m[13], m[14]}, {-m[8], -m[9], -m[10]}, view->vertical_fov, {m[0], m[1], m[2]}, {m[4], m[5], m[6]},
|
|
free != 0 && view->camera == free};
|
|
}
|
|
std::string set_camera(std::string_view plugin, const std::array<float, 16>& pose, float fov) override {
|
|
if (!camera_holder_.empty() && camera_holder_ != plugin) return camera_holder_ + " has the camera";
|
|
if (!follower_.empty() && follower_ != plugin) return follower_ + " is following a player with it";
|
|
camera_holder_ = plugin;
|
|
camera_pose_ = pose;
|
|
camera_fov_ = fov;
|
|
camera_releasing_ = false;
|
|
return {};
|
|
}
|
|
std::string follow_player(std::string_view plugin, std::uint64_t player) override {
|
|
if (!camera_holder_.empty() && camera_holder_ != plugin) return camera_holder_ + " has the camera";
|
|
if (!player) {
|
|
if (follower_ == plugin) {
|
|
multiplayer::spectate_party_member(0);
|
|
follower_.clear();
|
|
}
|
|
return {};
|
|
}
|
|
if (!multiplayer_session_active()) return "not in a session";
|
|
if (camera_holder_ == plugin) release_camera(plugin);
|
|
follower_ = plugin;
|
|
multiplayer::spectate_party_member(player);
|
|
return {};
|
|
}
|
|
void release_camera(std::string_view plugin) override {
|
|
if (follower_ == plugin) follow_player(plugin, 0);
|
|
if (camera_holder_ != plugin) return;
|
|
camera_holder_.clear();
|
|
camera_releasing_ = true;
|
|
camera_status_.clear();
|
|
}
|
|
std::string camera_status(std::string_view plugin) override {
|
|
if (follower_ == plugin) return multiplayer::party_spectating() ? std::string{} : "Spectate is starting";
|
|
if (camera_holder_ != plugin) return "it does not hold the camera";
|
|
return camera_shown_ ? std::string{} : camera_status_.empty() ? "waiting for the game's camera" : camera_status_;
|
|
}
|
|
void apply_camera(std::uintptr_t base, std::uintptr_t client, bool ready, bool phase) {
|
|
if (multiplayer::party_spectating()) {
|
|
// Spectate has the lease now, and gives the game's camera back itself.
|
|
camera_shown_ = camera_releasing_ = false;
|
|
if (!camera_holder_.empty()) camera_status_ = "Spectate has the camera";
|
|
return;
|
|
}
|
|
std::string detail;
|
|
if (!camera_holder_.empty()) {
|
|
camera_shown_ = update_party_camera(base, client, ready, phase, &camera_pose_, detail, camera_fov_);
|
|
if (!camera_shown_ && !detail.empty()) camera_status_ = detail;
|
|
} else if (camera_releasing_ && update_party_camera(base, client, ready, phase, nullptr, detail)) {
|
|
camera_releasing_ = false;
|
|
}
|
|
}
|
|
std::string freeze(std::string_view plugin, bool on) override {
|
|
const bool before = !freezers_.empty();
|
|
if (on) {
|
|
if (!before && !session_noclip_allowed()) return "the host has turned noclip off";
|
|
freezers_.insert(std::string(plugin));
|
|
} else freezers_.erase(std::string(plugin));
|
|
const bool now = !freezers_.empty();
|
|
if (now == before) return {};
|
|
const auto why = queue(overlay::DebugAction::set_noclip, now);
|
|
if (!why.empty() && now) freezers_.erase(std::string(plugin));
|
|
return why;
|
|
}
|
|
bool frozen() const noexcept { return !freezers_.empty(); }
|
|
void release(std::string_view plugin) override {
|
|
if (hold_owner_ == plugin) (void)skater_hold(plugin, false);
|
|
world_entities::clear(plugin);
|
|
throwdown_flag_leave(plugin);
|
|
dialog_close(plugin, {});
|
|
tricks::feed::ask(std::string(plugin), false);
|
|
bonecam::bone_cam_release(plugin);
|
|
if (buttons_owner_ == plugin) {
|
|
overlay::hide_game_buttons(0);
|
|
overlay::hold_game_buttons(0);
|
|
buttons_owner_.clear();
|
|
}
|
|
if (free_camera_owner_ == plugin) (void)free_camera(plugin, false);
|
|
if (pad_flight_owner_ == plugin) (void)pad_flight(plugin, false);
|
|
if (competition_owner_ == plugin) competition(plugin, false);
|
|
stop_clip(plugin);
|
|
plugin_sound::stop_all(plugin);
|
|
outfit_overlay::clear(plugin);
|
|
board_pose::clear(plugin);
|
|
board_pose::park(plugin, {});
|
|
(void)board_pose::fit(plugin, std::nullopt);
|
|
cancel_throwdown(plugin);
|
|
multiplayer::custom_throwdowns::remove_plugin(plugin);
|
|
if (capturer_ == plugin) capturer_.clear();
|
|
if (std::erase_if(replacements_, [&](const auto& r) { return r.second.first == plugin; })) publish_replacements();
|
|
if (recorder_ == plugin) recorder_.clear();
|
|
release_camera(plugin);
|
|
(void)freeze(plugin, false);
|
|
hide_hud(plugin, false);
|
|
xray::prepare(plugin, false);
|
|
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();
|
|
}
|
|
void set_storage(const std::filesystem::path& storage) {
|
|
if (!binds_file_.empty() || storage.empty()) return;
|
|
binds_file_ = storage / "binds.json";
|
|
load_overrides();
|
|
}
|
|
|
|
private:
|
|
void publish_replacements() {
|
|
std::vector<skater_animation::Replacement> list;
|
|
for (const auto& [key, value] : replacements_) list.push_back(value.second);
|
|
skater_animation::set_replacements(std::move(list));
|
|
}
|
|
std::string player_, recorder_, thrower_, capturer_;
|
|
std::string buttons_owner_, free_camera_owner_, pad_flight_owner_, competition_owner_, hold_owner_;
|
|
std::uint64_t capture_until_{}, flight_read_at_{};
|
|
std::optional<FlightInput> flight_;
|
|
// By (trick, pop): the plugin and its clip.
|
|
std::map<std::pair<std::string, std::string>, std::pair<std::string, skater_animation::Replacement>> replacements_;
|
|
// What each plugin's mod held of variants when last read (variants).
|
|
struct VariantRead {
|
|
std::uint64_t at{};
|
|
bool declared{};
|
|
std::vector<std::string> installed;
|
|
};
|
|
std::map<std::string, VariantRead, std::less<>> variant_reads_;
|
|
std::vector<EnabledMod> enabled_mods_;
|
|
std::uint64_t enabled_read_at_{};
|
|
// The Bone Cam's slam and strike counts each plugin last read with its telemetry.
|
|
std::map<std::string, std::pair<std::uint64_t, std::uint64_t>, std::less<>> telemetry_seen_;
|
|
using BindKey = std::pair<std::string, std::string>;
|
|
struct Bind {
|
|
std::uint32_t own{};
|
|
ControllerComboLatch latch;
|
|
};
|
|
std::uint32_t combo_in_use(const BindKey& key, const Bind& bind) const {
|
|
const auto found = overrides_.find(key);
|
|
return found != overrides_.end() ? found->second : bind.own;
|
|
}
|
|
void publish_binds() {
|
|
std::vector<plugin_ui::Bind> list;
|
|
for (const auto& [key, bind] : binds_) list.push_back({key.first, key.second, bind.own, combo_in_use(key, bind)});
|
|
plugin_ui::publish_binds(std::move(list));
|
|
}
|
|
void apply_rebinds() {
|
|
auto changes = plugin_ui::take_rebinds();
|
|
if (changes.empty()) return;
|
|
for (auto& change : changes) {
|
|
BindKey key{std::move(change.plugin), std::move(change.action)};
|
|
if (!change.combo) overrides_.erase(key);
|
|
else if (!*change.combo || valid_action_binding(*change.combo)) overrides_[std::move(key)] = *change.combo;
|
|
}
|
|
save_overrides();
|
|
publish_binds();
|
|
}
|
|
// binds.json: {"<plugin>": {"<action>": combo}}, the player's choices only.
|
|
void load_overrides() {
|
|
try {
|
|
std::ifstream in(binds_file_, std::ios::binary);
|
|
if (!in) return;
|
|
const std::string text((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
|
const auto json = Json::parse(text, {256 * 1024, 4, 4096});
|
|
if (!json.is_object()) return;
|
|
for (const auto& [plugin, actions] : json.items()) {
|
|
if (!actions.is_object()) continue;
|
|
for (const auto& [action, combo] : actions.items())
|
|
if (combo.is_number_unsigned() && combo.get<std::uint64_t>() <= 0xffffffffu)
|
|
overrides_[{plugin, action}] = static_cast<std::uint32_t>(combo.get<std::uint64_t>());
|
|
}
|
|
} catch (const std::exception&) {
|
|
overrides_.clear(); // a broken file is the plugins' own binds again
|
|
}
|
|
}
|
|
void save_overrides() {
|
|
if (binds_file_.empty()) return;
|
|
auto json = Json::object();
|
|
for (const auto& [key, combo] : overrides_) {
|
|
if (!json.contains(key.first)) json[key.first] = Json::object();
|
|
json[key.first][key.second] = combo;
|
|
}
|
|
try {
|
|
std::filesystem::create_directories(binds_file_.parent_path());
|
|
auto temporary = std::filesystem::path(binds_file_) += ".tmp";
|
|
{
|
|
std::ofstream out(temporary, std::ios::binary | std::ios::trunc);
|
|
const auto text = json.dump(2);
|
|
if (!out || !out.write(text.data(), static_cast<std::streamsize>(text.size()))) return;
|
|
}
|
|
std::filesystem::rename(temporary, binds_file_);
|
|
} catch (const std::exception&) {
|
|
}
|
|
}
|
|
static std::string reply(const std::string& answer) {
|
|
return answer.starts_with("error") || answer.find("not") != std::string::npos ? answer : std::string{};
|
|
}
|
|
// As the console's `tod`: each map has seven time layers, "<map>_tod_<n>_<name>"; one is
|
|
// forced on and the rest off, or all seven go back to the level's own choice.
|
|
std::string time_of_day(std::string_view wanted) {
|
|
static constexpr std::array<std::string_view, 8> times{"default", "morning", "noon", "afternoon", "evening", "night",
|
|
"weatherday", "weathernight"};
|
|
const auto found = std::find(times.begin(), times.end(), wanted);
|
|
if (found == times.end()) return "a time of day is default, morning, noon, afternoon, evening, night, weatherday or weathernight";
|
|
const auto slot = static_cast<std::size_t>(found - times.begin());
|
|
const auto world = local_profile_world_layers();
|
|
if (world.controlled_by_host) return "the host chooses the time of day";
|
|
if (world.map == WorldMap::none) return "no map is loaded";
|
|
std::vector<std::size_t> layers;
|
|
for (unsigned n = 1; n < times.size(); ++n) {
|
|
const auto prefix = std::string(world_map_key(world.map)) + "_tod_" + std::to_string(n) + "_";
|
|
const auto layer = std::find_if(world_layers().begin(), world_layers().end(),
|
|
[&](const auto& item) { return item.key.starts_with(prefix); });
|
|
if (layer == world_layers().end()) return "this map has no time of day";
|
|
layers.push_back(static_cast<std::size_t>(layer - world_layers().begin()));
|
|
}
|
|
// The others switch off before the new one comes on, so two never load together.
|
|
bool saved = true;
|
|
for (std::size_t i = 0; i < layers.size(); ++i)
|
|
if (i + 1 != slot) saved = saved && set_local_world_layer(world_layers()[layers[i]].key, slot ? "off" : "default");
|
|
if (slot) saved = saved && set_local_world_layer(world_layers()[layers[slot - 1]].key, "on");
|
|
return saved ? std::string{} : local_profile_world_layers().feedback;
|
|
}
|
|
|
|
std::uintptr_t base_;
|
|
std::string camera_holder_, follower_, camera_status_;
|
|
std::array<float, 16> camera_pose_{};
|
|
float camera_fov_{};
|
|
bool camera_shown_{}, camera_releasing_{};
|
|
std::set<std::string, std::less<>> freezers_;
|
|
std::set<std::string, std::less<>> hud_hiders_;
|
|
bool hud_was_hidden_{};
|
|
bool music_started_{};
|
|
std::map<BindKey, Bind> binds_;
|
|
std::map<BindKey, std::uint32_t> overrides_;
|
|
std::filesystem::path binds_file_;
|
|
};
|
|
RealGame* the_game{};
|
|
}
|
|
|
|
Game& real_game(std::uintptr_t image_base, const std::filesystem::path& storage) {
|
|
static auto* game = new RealGame(image_base); // the process's lifetime, as the host's
|
|
the_game = game;
|
|
game->set_storage(storage);
|
|
return *game;
|
|
}
|
|
void apply_plugin_camera(std::uintptr_t image_base, std::uintptr_t client, bool ready, bool phase) noexcept {
|
|
try {
|
|
if (the_game) the_game->apply_camera(image_base, client, ready, phase);
|
|
} catch (...) {}
|
|
}
|
|
bool plugin_freeze_active() noexcept { return the_game && the_game->frozen(); }
|
|
bool plugin_input_capture() noexcept { return the_game && the_game->capturing(); }
|
|
void tick_plugin_sounds(std::uintptr_t image_base, std::uintptr_t client, bool ready) noexcept {
|
|
plugin_sound::tick(image_base, client, ready);
|
|
}
|
|
void tick_plugin_animation(std::uintptr_t image_base, std::uintptr_t client) noexcept {
|
|
try {
|
|
if (the_game) the_game->tick_animation(image_base, client);
|
|
} catch (...) {}
|
|
}
|
|
}
|