Files
ReSkate/Extension/Trainer/trainer_page.cpp
T

1041 lines
65 KiB
C++

#include "trainer_page.h"
#include "trainer.h"
#include "Extension/UI/Overlay/skate_menu_internal.h"
#include "Extension/UI/skate_theme.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdarg>
#include <format>
#include <string>
#include <vector>
// The TRAINER page and the skating HUD. Presentation thread: reads the trainer's snapshots
// and queues `trainer ...` console commands; it never touches the game.
namespace dingosdk::overlay::menu {
namespace {
struct Page {
int tab{}; // opens on TUNE: the dials and switches
std::array<char, 96> search{};
int mode{1}; // the Tune tab: 0 Realistic, 1 Fun (two short lists), 2 Everything
int group{1}; // Everything: 1: every group, 2..: one of the view's groups
bool only_changed{}, graph_points{}, show_unused{};
std::size_t hidden_unused{}; // rows the filters would show but for "no use found"
// The rows the filters leave, rebuilt when the snapshot or a filter changes.
std::vector<std::size_t> shown;
std::uint64_t shown_revision{};
std::string shown_key;
// The value under the pointer while it is dragged: the snapshot lags a frame or two.
std::string active;
double active_value{};
std::array<char, 49> preset_name{};
std::array<float, 3> teleport{};
double speed_edit{-1}, speed_until{};
float hippy_edit{1}, nocomply_edit{1}, boneless_edit{1}, offboard_edit{1}, flip_edit{1}, pump_edit{1};
std::array<float, trainer::flip_tricks.size()> trick_edit{}; // the speed of each flip trick
std::array<bool, trainer::flip_tricks.size()> trick_editing{};
float revert_edit{};
bool revert_editing{};
// The Tricklining list: rows of the value table per group, for one snapshot.
std::array<std::vector<std::size_t>, 5> trick_rows;
std::uint64_t trick_revision{~0ull};
bool hippy_editing{}, nocomply_editing{}, boneless_editing{}, offboard_editing{}, flip_editing{}, pump_editing{};
std::uint64_t open_serial{}; // the last `trainer open` acted on
std::uint64_t share_serial{}; // the last `preset export` put on the clipboard
bool share_seen{};
bool show_page{};
};
void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view, const std::string &words);
void feel_buttons(SkateMenu &menu, const CallbacksV3 &callbacks, const trainer::View &view);
void trickline_section(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view);
Page &page() {
static auto *value = new Page;
return *value;
}
// A tooltip that wraps at a readable width instead of running off the screen as one line.
void wrapped_tooltip(const char *format, ...) {
if (!ImGui::BeginTooltip()) return;
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 26.0f);
va_list arguments;
va_start(arguments, format);
ImGui::TextV(format, arguments);
va_end(arguments);
ImGui::PopTextWrapPos();
ImGui::EndTooltip();
}
// What the control just drawn does, shown while the pointer (or the controller's focus) is on it.
void tip(const char *text) {
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) wrapped_tooltip("%s", text);
}
std::string lower(std::string_view text) {
std::string result(text);
std::ranges::transform(result, result.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return result;
}
bool contains_words(const std::string &haystack, const std::string &words) {
std::size_t start = 0;
while (start < words.size()) {
auto end = words.find(' ', start);
if (end == std::string::npos) end = words.size();
if (end > start && haystack.find(words.substr(start, end - start)) == std::string::npos) return false;
start = end + 1;
}
return true;
}
std::string number(double value) { return std::format("{:.6g}", value); }
void trainer_command(SkateMenu &menu, const CallbacksV3 &callbacks, const std::string &text) {
send_console(menu, callbacks, "trainer " + text);
}
void filter_rows(Page &p, const trainer::View &view) {
const auto words = lower(p.search.data());
const auto key = std::format("{}|{}|{}|{}|{}|{}", words, p.group, p.only_changed, p.graph_points, p.show_unused, p.mode);
if (p.shown_revision == view.revision && p.shown_key == key) return;
p.shown_revision = view.revision;
p.shown_key = key;
p.shown.clear();
p.hidden_unused = 0;
const std::string *group = p.mode == 2 && p.group > 1 && static_cast<std::size_t>(p.group) <= view.groups.size() + 1
? &view.groups[static_cast<std::size_t>(p.group) - 2] : nullptr;
const bool essentials = p.mode < 2 && words.empty(); // searching looks through every value, whatever the list
const std::uint8_t list = p.mode == 0 ? trainer::mode_realistic : trainer::mode_fun;
for (std::size_t i = 0; i < view.rows.size(); ++i) {
const auto &row = view.rows[i];
if (essentials) {
// Only what the game was found or seen to read: a short list has no room for maybes.
if (!row.friendly.empty() && (row.modes & list) && (row.used || (row.modes & trainer::mode_trial))) p.shown.push_back(i);
continue;
}
if (row.detail && !p.graph_points && !row.touched) continue;
if (p.only_changed && !row.touched && !row.frozen) continue;
// Searching looks through every group.
if (words.empty() ? (group && row.group != *group) : !contains_words(lower(row.id + " " + row.label + " " + row.friendly), words))
continue;
// A value nothing in the game reads would be a slider that does nothing.
if (!row.used && !p.show_unused && !row.touched && !row.frozen) {
++p.hidden_unused;
continue;
}
p.shown.push_back(i);
}
if (essentials) std::ranges::sort(p.shown, {}, [&](std::size_t i) { return view.rows[i].rank; });
}
void value_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view, const trainer::Row &row,
bool with_group, bool friendly) {
ImGui::PushID(row.id.c_str());
bool frozen = row.frozen;
if (ImGui::Checkbox("##freeze", &frozen)) trainer_command(menu, callbacks, std::format("freeze {} {}", row.id, frozen ? 1 : 0));
if (ImGui::IsItemHovered()) wrapped_tooltip("Lock: presets and Reset values leave this value alone.\nReset everything (top of the page) clears locks too.\nYou do not need it for a change to apply.");
ImGui::SameLine();
const float column = ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x * 0.52f;
const auto label = friendly && !row.friendly.empty() ? row.friendly : with_group ? row.group + " / " + row.label : row.label;
const bool tinted = row.touched || !row.used;
if (tinted) ImGui::PushStyleColor(ImGuiCol_Text, row.used ? skate_theme::blue : IM_COL32(150, 150, 150, 255));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(row.used ? label.c_str() : (label + " (no use found)").c_str());
if (tinted) ImGui::PopStyleColor();
if (ImGui::IsItemHovered())
wrapped_tooltip("%s%s%s\nStock: %s%s", row.help.c_str(), row.help.empty() ? "" : "\n", row.id.c_str(), number(row.stock).c_str(),
row.used ? "" : "\nThe game was not found (or seen) reading this value, so changing it may do nothing.");
ImGui::SameLine(column);
const float reset = ImGui::CalcTextSize("Reset").x + ImGui::GetStyle().FramePadding.x * 2;
ImGui::BeginDisabled(!view.editable || !callbacks.queue_console_command);
double value = p.active == row.id ? p.active_value : row.value;
bool edited = false;
if (row.kind == trainer::Kind::flag) {
bool on = value != 0;
if (ImGui::Checkbox("##value", &on)) {
value = on ? 1 : 0;
edited = true;
}
} else {
ImGui::SetNextItemWidth(std::max(px(90), ImGui::GetContentRegionAvail().x - reset - ImGui::GetStyle().ItemSpacing.x));
const bool scale = row.kind == trainer::Kind::curve || row.kind == trainer::Kind::graph;
const bool whole = row.kind == trainer::Kind::integer;
const double low = scale ? -1.0e9 : -1.0e12, high = scale ? 1.0e9 : 1.0e12; // none of the trainer's own
const auto speed = whole ? 0.1f : scale ? 0.01f
: static_cast<float>(std::max({std::abs(row.stock), std::abs(value), 0.01}) * 0.004);
edited = ImGui::DragScalar("##value", ImGuiDataType_Double, &value, speed, &low, &high, whole ? "%.0f" : scale ? "x %.2f" : "%.4g",
ImGuiSliderFlags_AlwaysClamp);
if (ImGui::IsItemActive()) {
p.active = row.id;
p.active_value = value;
} else if (p.active == row.id) {
p.active.clear();
}
}
if (!ImGui::IsItemActive())
tip(row.kind == trainer::Kind::flag ? "Tick to switch this on, untick to switch it off."
: "Drag left or right to change it, or double-click to type a number. Blue means changed from the game's own.");
if (edited) trainer_command(menu, callbacks, std::format("set {} {}", row.id, number(value)));
if (row.touched && row.kind != trainer::Kind::flag) {
ImGui::SameLine();
if (ImGui::Button("Reset", ImVec2(reset, 0))) trainer_command(menu, callbacks, "reset " + row.id);
tip("Puts this one value back to the game's own.");
}
ImGui::EndDisabled();
ImGui::PopID();
}
// A built-in preset as a switch: blue while every value it sets holds what it sets.
void preset_switch(SkateMenu &menu, const CallbacksV3 &callbacks, const trainer::PresetRow &preset) {
if (preset.active) ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue);
const bool pressed = ImGui::Button(preset.name.c_str());
if (preset.active) ImGui::PopStyleColor();
if (pressed) trainer_command(menu, callbacks, std::string(preset.active ? "preset remove " : "preset apply ") + preset.name);
if (ImGui::IsItemHovered() && !preset.note.empty()) wrapped_tooltip("%s", preset.note.c_str());
}
// A built-in preset as a dial: one multiplier that moves everything the preset moves.
void dial_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::PresetRow &preset) {
ImGui::PushID(preset.name.c_str());
const float column = ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x * 0.40f;
const bool moved = std::abs(preset.factor - 1.0) > 1e-4;
if (moved) ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::blue);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(preset.title.c_str());
if (moved) ImGui::PopStyleColor();
if (ImGui::IsItemHovered())
wrapped_tooltip("%s\n1 is the game's own; \"%s\" is x %s.", preset.note.c_str(), preset.name.c_str(), number(preset.amount).c_str());
ImGui::SameLine(column);
const float button = ImGui::CalcTextSize("Easy Body Flips ").x + ImGui::GetStyle().FramePadding.x * 2;
const float reset = ImGui::CalcTextSize("Reset").x + ImGui::GetStyle().FramePadding.x * 2;
const auto id = "dial:" + preset.name;
double value = p.active == id ? p.active_value : preset.factor;
// A preset that turns its value down gets a dial with more room below 1.
const double low = preset.amount < 1 ? 0.05 : 0.2, high = preset.amount < 1 ? 3.0 : 10.0;
const float box = px(64);
ImGui::SetNextItemWidth(std::max(px(70), ImGui::GetContentRegionAvail().x - box - button - reset - ImGui::GetStyle().ItemSpacing.x * 3));
if (ImGui::SliderScalar("##dial", ImGuiDataType_Double, &value, &low, &high, "x %.2f", ImGuiSliderFlags_Logarithmic) && value > 0)
trainer_command(menu, callbacks, std::format("dial {} {}", number(value), preset.name));
if (ImGui::IsItemActive()) {
p.active = id;
p.active_value = value;
} else if (p.active == id) {
p.active.clear();
}
if (!ImGui::IsItemActive() && ImGui::IsItemHovered()) wrapped_tooltip("%s 1 is the game's own.", preset.note.c_str());
ImGui::SameLine();
// Any number at all: the slider only covers the sensible range.
double typed = preset.factor;
ImGui::SetNextItemWidth(box);
if (ImGui::InputDouble("##typed", &typed, 0, 0, "%.6g", ImGuiInputTextFlags_EnterReturnsTrue) && typed > 0)
trainer_command(menu, callbacks, std::format("dial {} {}", number(typed), preset.name));
if (ImGui::IsItemHovered()) wrapped_tooltip("Type any multiplier and press Enter: the slider's ends are not a limit.");
ImGui::SameLine();
if (preset.active) ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue);
const bool pressed = ImGui::Button(preset.name.c_str(), ImVec2(button, 0));
if (preset.active) ImGui::PopStyleColor();
if (pressed) trainer_command(menu, callbacks, std::string(preset.active ? "preset remove " : "preset apply ") + preset.name);
if (ImGui::IsItemHovered()) wrapped_tooltip("%s", preset.note.c_str());
if (moved) {
ImGui::SameLine();
if (ImGui::Button("Reset", ImVec2(reset, 0))) trainer_command(menu, callbacks, "dial 1 " + preset.name);
tip("Puts this dial back to 1, the game's own.");
}
ImGui::PopID();
}
// One screen for everything that changes how the game plays: the presets as dials and
// switches, the trick multipliers, the player's own presets and the values themselves. All of
// them show the same values, so none can disagree with another.
void tune_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) {
if (!view.ready) {
begin_card(menu, "tune-wait", "PHYSICS TUNING");
note(view.status.c_str());
note("The values appear once a level is loaded.");
end_card();
return;
}
// Two short lists, the trick line section and the whole table (modes 0, 1, 3 and 2).
{
static constexpr std::pair<const char *, int> modes[]{{"REALISTIC", 0}, {"FUN", 1}, {"TRICKLINING", 3}, {"EVERYTHING", 2}};
const float width = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 3) / 4;
bool first = true;
for (const auto &[label, mode] : modes) {
if (!first) ImGui::SameLine();
first = false;
const bool on = p.mode == mode;
if (on) ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue);
if (ImGui::Button(label, ImVec2(width, 0))) p.mode = mode;
tip(mode == 0 ? "A short list for toning the game down: lower pops, slower pushes, harder bails."
: mode == 1 ? "A short list for turning the game up: bigger pops, faster pushes, moon jumps."
: mode == 3 ? "Everything that makes a trick line in one place: board bending, reverts, pumping, pops, spins, manuals and grinds."
: "Every dial, your own presets and every tuning value the trainer knows.");
if (on) ImGui::PopStyleColor();
}
}
const std::uint8_t list = p.mode == 0 ? trainer::mode_realistic : p.mode == 1 ? trainer::mode_fun : std::uint8_t{0xff};
const bool can_edit = view.editable && callbacks.queue_console_command != nullptr;
// One search for the whole tab: dials, switches, trick sliders and every value.
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
ImGui::InputTextWithHint("##search", "Search: dials, tricks and every value...", p.search.data(), p.search.size());
tip("Type a word to find dials, trick sliders and tuning values on every list at once. Clear the box to go back to the list.");
const auto words = lower(p.search.data());
const bool searching = !words.empty();
const auto listed = [&](const trainer::PresetRow &preset) {
return searching ? contains_words(lower(preset.title + " " + preset.name + " " + preset.note), words) : p.mode == 2 || (preset.modes & list) != 0;
};
if (!searching) {
begin_card(menu, "feel", "PLAY LIKE", "The game's own tuning, or Skate 3's on one of its difficulties");
if (p.mode == 3)
note("A Skate 3 choice sets Skate 3's own numbers for everything the two games share (pop, grind pops, pushing, pumping, steering, "
"manuals, spins, bails), and for grinds the way they were before skate.: the older grind friction, Skate 3's lock-on distances, wider slide "
"angles, and no automatic turning between grinds. What a number cannot change stays skate.'s: animations and which stick "
"motion asks for which grind.");
ImGui::BeginDisabled(!can_edit);
feel_buttons(menu, callbacks, view);
ImGui::EndDisabled();
end_card();
}
if (p.mode == 3 && !searching) {
trickline_section(menu, callbacks, p, view);
return;
}
begin_card(menu, "dials", p.mode == 0 ? "FEEL: TONE IT DOWN" : p.mode == 1 ? "FEEL: TURN IT UP" : "FEEL",
"1 is the game's own. A dial moves every value its preset moves.");
ImGui::BeginDisabled(!can_edit);
for (const auto &preset : view.presets)
if (preset.builtin && preset.dial && listed(preset)) dial_row(menu, callbacks, p, preset);
// The presets that are plain switches, and the ones that set several dials at once.
bool first = true;
for (const auto &preset : view.presets) {
if (!preset.builtin || preset.dial || preset.name == "Stock" || preset.name.starts_with("Skate 3") || !listed(preset)) continue;
if (!first && ImGui::GetItemRectMax().x + ImGui::CalcTextSize(preset.name.c_str()).x + ImGui::GetStyle().FramePadding.x * 4 <
ImGui::GetWindowPos().x + ImGui::GetContentRegionMax().x)
ImGui::SameLine();
first = false;
preset_switch(menu, callbacks, preset);
}
ImGui::EndDisabled();
if (p.mode != 0) {
// The game's own bail protection, the same switch as Skater > Movement.
const auto &debug = model.debug;
bool no_bail = debug.no_bail;
if (toggle_row(menu, "Never bail", "No wipeouts at all. Recover from a bail before switching it on.", no_bail,
(debug.no_bail_available || debug.no_bail) && callbacks.queue_debug != nullptr))
debug_request(menu, callbacks, {DebugAction::set_no_bail, no_bail});
}
ImGui::BeginDisabled(!can_edit || !view.touched);
if (ImGui::Button("Reset values")) trainer_command(menu, callbacks, "reset all");
if (ImGui::IsItemHovered()) wrapped_tooltip("Every tuning value back to the game's own. Locked values stay.");
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::AlignTextToFramePadding();
ImGui::TextDisabled("%zu values changed", view.touched);
end_card();
ImGui::BeginDisabled(!can_edit);
trick_heights(menu, callbacks, p, view, words);
ImGui::EndDisabled();
if (p.mode == 2 && !searching) {
begin_card(menu, "preset-save", "YOUR PRESETS", "Save what is changed right now");
ImGui::BeginDisabled(!can_edit);
const float button = ImGui::CalcTextSize("Turn off").x + ImGui::GetStyle().FramePadding.x * 2;
for (const auto &preset : view.presets) {
if (preset.builtin) continue;
ImGui::PushID(preset.name.c_str());
if (preset.active) {
ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue);
if (ImGui::Button("Turn off", ImVec2(button, 0))) trainer_command(menu, callbacks, "preset remove " + preset.name);
ImGui::PopStyleColor();
} else if (ImGui::Button("Turn on", ImVec2(button, 0))) {
trainer_command(menu, callbacks, "preset apply " + preset.name);
}
tip("Turn on sets every value saved in this preset. Turn off puts those values back to the game's own.");
ImGui::SameLine();
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(preset.name.c_str());
ImGui::SameLine();
ImGui::TextDisabled("%s", preset.note.c_str());
ImGui::SameLine(ImGui::GetContentRegionAvail().x + ImGui::GetCursorPosX() - px(170));
if (ImGui::SmallButton("This map")) trainer_command(menu, callbacks, "profile set " + preset.name);
tip("Applies this preset by itself every time this map loads.");
ImGui::SameLine();
if (ImGui::SmallButton("Share")) trainer_command(menu, callbacks, "preset export " + preset.name);
if (ImGui::IsItemHovered()) wrapped_tooltip("Copies this preset to the clipboard as one line of text. Paste it in a chat;\nanyone with the trainer can import it.");
ImGui::SameLine();
if (ImGui::SmallButton("Delete")) trainer_command(menu, callbacks, "preset delete " + preset.name);
tip("Removes this preset from your list.");
ImGui::PopID();
}
ImGui::EndDisabled();
field(menu, "Name");
const float save = ImGui::CalcTextSize("Save").x + ImGui::GetStyle().FramePadding.x * 2;
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - save - ImGui::GetStyle().ItemSpacing.x);
ImGui::InputText("##preset-name", p.preset_name.data(), p.preset_name.size());
tip("A name for the preset you are about to save.");
ImGui::SameLine();
ImGui::BeginDisabled(!p.preset_name[0] || !view.touched);
if (ImGui::Button("Save", ImVec2(save, 0))) trainer_command(menu, callbacks, std::string("preset save ") + p.preset_name.data());
tip("Saves every value, dial and trick setting that is changed right now as a preset under this name. Share it from its row afterwards.");
ImGui::EndDisabled();
// Sharing: a preset travels as one line of text on the clipboard.
ImGui::BeginDisabled(view.stock);
if (ImGui::Button("Share what is changed now")) trainer_command(menu, callbacks, "preset export current");
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) wrapped_tooltip("Copies your current setup to the clipboard as one line of text.");
ImGui::SameLine();
if (ImGui::Button("Import from clipboard")) {
const char *text = ImGui::GetClipboardText();
if (text && trainer::stage_import(text)) trainer_command(menu, callbacks, "preset import");
else feedback(menu, "The clipboard holds no text.");
}
if (ImGui::IsItemHovered()) wrapped_tooltip("Copy a preset line someone shared (it starts with RST1:), then press this.\nIt is added to your presets; nothing changes until you turn it on.");
if (!view.map.empty()) {
info(menu, "This map applies", view.profile_preset.empty() ? "nothing" : view.profile_preset);
if (!view.profile_preset.empty() && ImGui::Button("Stop applying it on this map")) trainer_command(menu, callbacks, "profile clear");
note("\"This map\" beside one of your presets applies it every time this map loads.");
}
end_card();
}
// The values themselves: the short list's, or all of them.
ImGui::Spacing();
ImGui::SeparatorText(p.mode == 2 ? "EVERY VALUE" : "FINE TUNING");
if (p.mode == 2) {
// The list includes the game's tuning classes, which the trainer has to find in memory
// before an edit to one does anything: with the list open, it looks now.
trainer::note_class_list_shown();
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.42f);
if (p.group < 1) p.group = 1;
const char *current = p.group > 1 && static_cast<std::size_t>(p.group) <= view.groups.size() + 1
? view.groups[static_cast<std::size_t>(p.group) - 2].c_str() : "Every group";
if (ImGui::BeginCombo("##group", current)) {
if (ImGui::Selectable("Every group", p.group == 1)) p.group = 1;
for (std::size_t i = 0; i < view.groups.size(); ++i)
if (ImGui::Selectable(view.groups[i].c_str(), p.group == static_cast<int>(i) + 2)) p.group = static_cast<int>(i) + 2;
ImGui::EndCombo();
}
tip("Shows one group of tuning values at a time.");
ImGui::Checkbox("Only what I changed", &p.only_changed);
tip("Lists only the values that differ from the game's own.");
ImGui::SameLine();
ImGui::Checkbox("Graph points", &p.graph_points);
if (ImGui::IsItemHovered()) wrapped_tooltip("Show every point of the tuning graphs, not just one multiplier per graph.");
ImGui::SameLine();
ImGui::Checkbox("Values with no use found", &p.show_unused);
if (ImGui::IsItemHovered())
wrapped_tooltip("Also list the tuning values that the game was never found or seen reading.\nChanging those will probably do nothing.");
} else if (searching) {
trainer::note_class_list_shown();
} else {
note("The same values the dials move, one at a time. The box on the left locks a value against presets, dials and Reset values.");
}
filter_rows(p, view);
if (p.mode != 2 && searching) ImGui::TextDisabled("%zu values match", p.shown.size());
if (p.mode == 2) {
ImGui::SameLine();
if (p.hidden_unused) ImGui::TextDisabled("%zu shown, %zu changed, %zu hidden", p.shown.size(), view.touched, p.hidden_unused);
else ImGui::TextDisabled("%zu shown, %zu changed", p.shown.size(), view.touched);
}
const bool with_group = searching || (p.mode == 2 && (p.group <= 1 || p.only_changed));
const bool friendly = p.mode < 2;
ImGuiListClipper clipper;
clipper.Begin(static_cast<int>(p.shown.size()));
while (clipper.Step())
for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i)
value_row(menu, callbacks, p, view, view.rows[p.shown[static_cast<std::size_t>(i)]], with_group, friendly);
if (p.shown.empty()) note("Nothing matches.");
}
void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view, const std::string &words) {
static constexpr const char *advanced_label = "Advanced trick speed (a speed for each flip trick: kickflip, heelflip, 360 flip, varial, shuvit, hardflip, laser flip, impossible)";
static constexpr const char *labels[]{"Flip trick speed", advanced_label, "Pump power (pumping transitions)", "Catch flips at a set point of the jump (realism, percent of air time)", "No comply height", "Boneless height", "Hippy jump height", "Off-board jump height",
"Board bending boost (revert speed)"};
const auto wanted = [&](const char *label) { return words.empty() || contains_words(lower(std::string(label) + " tricks"), words); };
if (std::ranges::none_of(labels, wanted)) return;
begin_card(menu, "trick-heights", "TRICKS", "1.0 is the game's own");
// `limited`: the game itself stops at the slider's end, so a typed number is held to it too.
const auto slider = [&](const char *label, const char *option, float value, float &edit, bool &editing, const char *help, float high = 50.0f, bool limited = false, bool always = false) {
if (!always && !wanted(label)) return false;
field(menu, label);
ImGui::PushID(option);
float shown = editing ? edit : value;
const float box = px(64);
ImGui::SetNextItemWidth(std::max(px(70), ImGui::GetContentRegionAvail().x - box - ImGui::GetStyle().ItemSpacing.x));
if (ImGui::SliderFloat("##height", &shown, 0.1f, high, "x %.2f", (limited ? ImGuiSliderFlags_AlwaysClamp : 0) | ImGuiSliderFlags_Logarithmic)) {
edit = shown;
editing = true;
}
if (ImGui::IsItemDeactivatedAfterEdit()) trainer_command(menu, callbacks, std::format("option {} {:.2f}", option, edit));
if (!ImGui::IsItemActive() && editing && std::abs(value - edit) < 0.005f * std::max(1.0f, std::abs(edit))) editing = false;
if (!ImGui::IsItemActive()) tip(help);
ImGui::SameLine();
float typed = value;
ImGui::SetNextItemWidth(box);
if (ImGui::InputFloat("##typed", &typed, 0, 0, "%.6g", ImGuiInputTextFlags_EnterReturnsTrue) && typed > 0) {
editing = false;
trainer_command(menu, callbacks, std::format("option {} {}", option, typed));
}
if (ImGui::IsItemHovered())
wrapped_tooltip(limited ? "Type a multiplier and press Enter." : "Type any multiplier and press Enter: the slider's end is not a limit.");
ImGui::PopID();
return true;
};
slider("Flip trick speed", "flip_speed", view.flip_speed, p.flip_edit, p.flip_editing,
"How fast the board turns in every flip trick. It does not change how high you pop. Below 1 flips are slower, above 1 faster. A slow "
"flip is slow from the start and is not hurried round before you land: with too little air you land on a board that is still turning. "
"It takes about 15 seconds to take hold after every level load: the trainer has to find the game's flip animations again first.",
3.0f);
if (!view.flip_live && (view.flip_speed != 1.0f || view.flip_advanced) && wanted("Flip trick speed"))
ImGui::TextDisabled("Finding the flip animations: flip speeds take hold about 15 seconds after a level loads.");
if (wanted(advanced_label)) {
bool on = view.flip_advanced;
if (ImGui::Checkbox("Advanced trick speed", &on)) trainer_command(menu, callbacks, std::format("option flip_advanced {}", on ? 1 : 0));
tip("Gives every flip trick a speed of its own, on top of the flip trick speed above: a kickflip at 1.0 and a 360 flip at 0.6, say. "
"A trick turns at the speed above times its own. Nollie tricks use their regular trick's speed. Pop height is not changed. "
"Unticked, the speeds below are kept but not used. Like the speed above, they take hold about 15 seconds after every level load.");
if (view.flip_advanced_blocked) ImGui::TextDisabled("Off in this multiplayer game: the host's rules apply here.");
if (on && ImGui::TreeNodeEx("Speed of each flip trick", ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_DefaultOpen)) {
for (std::size_t i = 0; i < trainer::flip_tricks.size(); ++i) {
const auto &trick = trainer::flip_tricks[i];
const std::string label(trick.label), option(trick.key);
slider(label.c_str(), option.c_str(), view.flip_trick[i], p.trick_edit[i], p.trick_editing[i],
"How fast the board turns in this trick, times the flip trick speed above. 1 leaves it at that speed.", 3.0f, false, true);
}
ImGui::TreePop();
}
}
if (wanted("Catch flips at a set point of the jump (realism, percent of air time)")) {
bool on = view.catch_at;
if (ImGui::Checkbox("Catch flips at a set point of the jump", &on)) trainer_command(menu, callbacks, std::format("option catch_at {}", on ? 1 : 0));
if (ImGui::IsItemHovered())
wrapped_tooltip("%s", "For realism. Every flip trick turns so that the board is round, and caught, at the percent of the jump's air time "
"you set: 70 means the catch comes 70% of the way from the pop to the landing, on a small ollie and a big one alike. "
"While this is on the trainer sets the flip speed and the finish-before-landing rule itself, so the flip speed "
"above stands aside. The air time is worked out from the pop over level ground: off a drop or a gap the catch "
"comes earlier than asked. 100 or close to it means you land still flipping.");
ImGui::SameLine();
float percent = view.catch_percent;
ImGui::SetNextItemWidth(px(64));
if (ImGui::InputFloat("##catch-percent", &percent, 0, 0, "%.6g", ImGuiInputTextFlags_EnterReturnsTrue))
trainer_command(menu, callbacks, std::format("option catch_percent {}", percent));
if (ImGui::IsItemHovered()) wrapped_tooltip("%s", "Percent of the air time at which the flip is caught. Type a number from 1 to 100 and press Enter.");
ImGui::SameLine();
ImGui::TextUnformatted("% of the air time");
if (view.catch_at && view.flip_gate_blocked) ImGui::TextDisabled("Off in this multiplayer game: the host's rules apply here.");
else if (on && !view.flip_gate_found) ImGui::TextDisabled("Not found in the game yet: it takes effect a few seconds after a level loads.");
}
slider("No comply height", "nocomply_height", view.nocomply_height, p.nocomply_edit, p.nocomply_editing, "How high a no comply pops. 1 is the game's own.");
slider("Boneless height", "boneless_height", view.boneless_height, p.boneless_edit, p.boneless_editing, "How high a boneless pops. 1 is the game's own.");
slider("Hippy jump height", "hippy_height", view.hippy_height, p.hippy_edit, p.hippy_editing, "How high you jump off the board in a hippy jump. 1 is the game's own.");
slider("Off-board jump height", "offboard_height", view.offboard_height, p.offboard_edit, p.offboard_editing, "How high you jump on foot. 1 is the game's own.");
if (slider("Pump power (pumping transitions)", "pump_power", view.pump_power, p.pump_edit, p.pump_editing,
"How much speed pumping a transition gives. 1 is the game's own; 2 gives about one more of the game's pumps on top of each "
"of yours, 10 about nine more, and under 1 takes speed away. The game still decides when you are pumping: the extra comes "
"for as long as it counts you as pumping. It is found in the game about 15 seconds after a level loads.")) {
if (view.pump_blocked) ImGui::TextDisabled("Off in this multiplayer game: the host's rules apply here.");
else if (view.pump_power != 1.0f && !view.pump_live) ImGui::TextDisabled("Finding the game's pumping in memory: it acts about 15 seconds after a level loads.");
}
if (wanted("Board bending boost (revert speed)")) {
// Not a multiplier: 0 is the game's own (no boost).
field(menu, "Board bending boost");
ImGui::PushID("revert_boost");
float shown = p.revert_editing ? p.revert_edit : view.revert_boost;
const float box = px(64);
ImGui::SetNextItemWidth(std::max(px(70), ImGui::GetContentRegionAvail().x - box - ImGui::GetStyle().ItemSpacing.x));
if (ImGui::SliderFloat("##strength", &shown, 0.0f, 5.0f, shown <= 0.0f ? "off" : "x %.2f")) {
p.revert_edit = shown;
p.revert_editing = true;
}
if (ImGui::IsItemDeactivatedAfterEdit()) trainer_command(menu, callbacks, std::format("option revert_boost {:.2f}", p.revert_edit));
if (!ImGui::IsItemActive() && p.revert_editing && std::abs(view.revert_boost - p.revert_edit) < 0.006f) p.revert_editing = false;
if (ImGui::IsItemHovered())
wrapped_tooltip("Board bending: land with the board turned out of line and the game snaps it straight. With this on, that snap\n"
"throws you forward, as it did in Skate 3: chain bends to keep and build speed without pushing.\n"
"x 1 gives about +1.5 to +2.6 m/s a landing. 0 is off, the game's own. The Tricklining list has the details.\n"
"From AutoRevertBoost by Sivaes, jaq and OVM.");
ImGui::SameLine();
float typed = view.revert_boost;
ImGui::SetNextItemWidth(box);
if (ImGui::InputFloat("##typed", &typed, 0, 0, "%.6g", ImGuiInputTextFlags_EnterReturnsTrue) && typed >= 0) {
p.revert_editing = false;
trainer_command(menu, callbacks, std::format("option revert_boost {}", typed));
}
if (ImGui::IsItemHovered()) wrapped_tooltip("Type any strength and press Enter: the slider's end is not a limit.");
ImGui::PopID();
}
if (ImGui::Button("Reset tricks")) {
p.revert_editing = false;
p.hippy_editing = p.nocomply_editing = p.boneless_editing = p.offboard_editing = p.flip_editing = p.pump_editing = false;
p.trick_editing = {};
trainer_command(menu, callbacks, "reset tricks");
}
tip("Puts every slider and switch on this card back to the game's own.");
note("Not tuning values: the game sets these in its trick scripts and flip curves. Type in the box to go past a slider's end.");
end_card();
}
void practice_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) {
const auto telemetry = trainer::telemetry();
begin_card(menu, "speed", "GAME SPEED", "Slow motion for learning a line");
{
const NamedSettingModel *setting = nullptr;
for (const auto &row : model.engine_settings)
if (row.name == "SimulationTime.TimeScale") {
setting = &row;
break;
}
if (!setting || !setting->available) {
note(setting && !setting->reason.empty() ? setting->reason.c_str() : "Game speed is unavailable right now.");
// The engine resolves a setting the first time it is asked for: ask.
if (setting && callbacks.queue_console_command && ImGui::GetTime() >= p.speed_until) {
p.speed_until = ImGui::GetTime() + 2.0;
p.speed_edit = -1;
std::array<char, 256> ignored{};
callbacks.queue_console_command(callbacks.user, "reset SimulationTime.TimeScale", ignored.data(), ignored.size());
}
} else {
double current = 1;
try {
const auto first = setting->value.find_first_of("-.0123456789");
if (first != std::string::npos) current = std::stod(setting->value.substr(first));
} catch (...) {}
if (p.speed_edit >= 0 && ImGui::GetTime() < p.speed_until) current = p.speed_edit;
const auto set = [&](double speed) {
p.speed_edit = speed;
p.speed_until = ImGui::GetTime() + 1.0;
send_console(menu, callbacks, speed == 1.0 ? "reset SimulationTime.TimeScale" : "SimulationTime.TimeScale " + number(speed));
};
field(menu, "Speed");
auto value = static_cast<float>(current);
if (ImGui::SliderFloat("##game-speed", &value, 0.05f, 2.0f, current == 0.0 ? "paused" : "%.2fx", ImGuiSliderFlags_AlwaysClamp))
set(value);
if (!ImGui::IsItemActive()) tip("Slows the whole game down or speeds it up. 1x is normal speed.");
if (ImGui::Button(current == 0.0 ? "Resume" : "Pause")) set(current == 0.0 ? 1.0 : 0.0);
tip("Freezes the game where it is. Press again to carry on.");
for (const auto quick : {0.1, 0.25, 0.5, 0.75, 1.0}) {
ImGui::SameLine();
if (ImGui::Button(std::format("{}x", number(quick)).c_str())) set(quick);
tip("Sets the game speed to this straight away.");
}
note("The jump read-out measures real time, so it reads low while the game is slowed.");
}
}
end_card();
begin_card(menu, "markers", "MARKERS", "Saved for each map");
int slot = view.slot;
if (choice(menu, "marker-slot", slot, {"1", "2", "3", "4", "5"})) trainer_command(menu, callbacks, std::format("slot {}", slot + 1));
const auto &marker = view.markers[static_cast<std::size_t>(std::clamp(view.slot, 0, static_cast<int>(trainer::marker_slots) - 1))];
info(menu, "Selected", marker.set ? std::format("{:.1f}, {:.1f}, {:.1f}", marker.position[0], marker.position[1], marker.position[2]) : "empty");
ImGui::BeginDisabled(view.map.empty());
ImGui::BeginDisabled(!telemetry.skater);
if (ImGui::Button("Save here")) trainer_command(menu, callbacks, "marker save");
tip("Saves where you are standing in the selected slot, for this map.");
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::BeginDisabled(!marker.set);
if (ImGui::Button("Go")) trainer_command(menu, callbacks, "marker go");
tip("Puts you back at the selected marker.");
ImGui::SameLine();
if (ImGui::Button("Clear")) trainer_command(menu, callbacks, "marker clear");
tip("Empties the selected slot.");
ImGui::EndDisabled();
ImGui::EndDisabled();
bool auto_return = view.auto_return;
if (toggle_row(menu, "Return after a bail", "Go back to the selected marker when the skater wipes out.", auto_return))
trainer_command(menu, callbacks, std::format("option auto_return {}", auto_return ? 1 : 0));
field(menu, "Return delay");
float delay = view.return_delay;
ImGui::SliderFloat("##return-delay", &delay, 0.0f, 10.0f, "%.1f s", ImGuiSliderFlags_AlwaysClamp);
if (!ImGui::IsItemActive()) tip("How long after a bail before you are put back at the marker.");
if (ImGui::IsItemDeactivatedAfterEdit()) trainer_command(menu, callbacks, std::format("option return_delay {:.1f}", delay));
bool pad = view.pad_shortcuts;
if (toggle_row(menu, "Controller shortcuts", "Hold LB + RB, then D-pad: up saves, down goes, left / right pick the slot.", pad))
trainer_command(menu, callbacks, std::format("option pad {}", pad ? 1 : 0));
bool pad_menu = view.pad_menu;
if (toggle_row(menu, "Open this menu from the controller",
"LB + RB + click the right stick opens and closes the menu. Inside: D-pad or left stick moves, A presses, B goes back, LB / RB change a slider in big steps.", pad_menu))
trainer_command(menu, callbacks, std::format("option pad_menu {}", pad_menu ? 1 : 0));
end_card();
begin_card(menu, "teleport", "TELEPORT");
if (telemetry.skater)
info(menu, "You are at", std::format("{:.2f}, {:.2f}, {:.2f}", telemetry.position[0], telemetry.position[1], telemetry.position[2]));
field(menu, "X, Y, Z");
ImGui::InputFloat3("##teleport", p.teleport.data(), "%.2f");
tip("A position in the game's own coordinates: X, height, Z.");
ImGui::BeginDisabled(!telemetry.skater);
if (ImGui::Button("Here")) p.teleport = telemetry.position;
tip("Fills the boxes with where you are now.");
ImGui::SameLine();
if (ImGui::Button("Go##teleport"))
trainer_command(menu, callbacks, std::format("tp {:.3f} {:.3f} {:.3f}", p.teleport[0], p.teleport[1], p.teleport[2]));
tip("Moves you to the position in the boxes. You arrive on foot.");
ImGui::SameLine();
if (ImGui::Button("Copy position"))
ImGui::SetClipboardText(std::format("{:.3f}, {:.3f}, {:.3f}", telemetry.position[0], telemetry.position[1], telemetry.position[2]).c_str());
tip("Copies where you are to the clipboard as X, Y, Z.");
ImGui::SameLine();
if (ImGui::Button("Copy for Blender"))
ImGui::SetClipboardText(std::format("{:.3f}, {:.3f}, {:.3f}", telemetry.position[0], -telemetry.position[2], telemetry.position[1]).c_str());
tip("Copies where you are in Blender's axes (Z up), for map makers.");
ImGui::EndDisabled();
end_card();
}
void jump_lines(SkateMenu &menu, const trainer::Jump &jump) {
info(menu, "Takeoff", std::format("{:.1f} km/h at {:.1f} degrees", jump.takeoff_speed * 3.6f, jump.takeoff_angle));
info(menu, "Air time", std::format("{:.2f} s", jump.air_time));
info(menu, "Height", std::format("{:.2f} m", jump.height));
info(menu, "Distance", std::format("{:.2f} m, {:.2f} m drop", jump.distance, jump.drop));
info(menu, "Landing", std::format("{:.1f} km/h at {:.1f}, {:.1f}, {:.1f}", jump.landing_speed * 3.6f, jump.landing[0], jump.landing[1],
jump.landing[2]));
info(menu, "Rotation", std::format("spin {:.0f} degrees (peak {:.0f} per second), flip {:.0f} degrees", jump.spin, jump.spin_rate, jump.flip));
}
// Which game the skating plays like: the game's own, or Skate 3 on one of its three difficulties.
// One is always lit; a Skate 3 one goes out again as soon as one of its values is changed by hand.
void feel_buttons(SkateMenu &menu, const CallbacksV3 &callbacks, const trainer::View &view) {
struct Choice {
const char *label, *preset, *command, *tip;
};
static constexpr Choice choices[]{
{"skate.", "", "feel stock", "The game's own tuning."},
{"Skate 3 Easy", "Skate 3 Easy", "feel easy", "Skate 3 on Easy: full-height pops every time, strong pushes, generous grind lock-on, easy spins."},
{"Skate 3", "Skate 3", "feel normal", "Skate 3 on Normal: its pop, grind pops, pushing, pumping, steering, manuals, spins, flips and bails."},
{"Skate 3 Hardcore", "Skate 3 Hardcore", "feel hardcore", "Skate 3 on Hardcore: lower pops, weaker pushes, tight grind lock-on, slow auto spins."},
};
const auto active = [&](const char *preset) {
return std::ranges::any_of(view.presets, [&](const trainer::PresetRow &row) { return row.builtin && row.active && row.name == preset; });
};
const bool any = active("Skate 3 Easy") || active("Skate 3") || active("Skate 3 Hardcore");
const float width = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 3) / 4;
for (std::size_t i = 0; i < std::size(choices); ++i) {
if (i) ImGui::SameLine();
const bool on = i == 0 ? !any : active(choices[i].preset);
if (on) ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue);
if (ImGui::Button(choices[i].label, ImVec2(width, 0))) trainer_command(menu, callbacks, choices[i].command);
if (on) ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) wrapped_tooltip("%s", choices[i].tip);
}
}
// Everything that makes a trick line, in one place: the same values and sliders as the Tune tab,
// gathered, with one button for Skate 3's and one for the game's own.
void trickline_section(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) {
const bool can_edit = view.editable && callbacks.queue_console_command != nullptr;
ImGui::BeginDisabled(!can_edit);
begin_card(menu, "trickline", "TRICKLINING EXTRAS", "What Skate 3's tricklining had beyond its tuning");
bool extras = view.revert_boost > 0;
if (toggle_row(menu, "Skate 3's tricklining extras",
"The board bending boost below at x 1, and heavier revert and powerslide friction (x 3 and x 1.5: a first guess, tune them below).", extras))
trainer_command(menu, callbacks, extras ? "trickline extras on" : "trickline extras off");
note("Pick the game to play like above, then tune the tricklining part of it here. Blue = changed; Reset puts one value back. Type in a box to go past a slider's end.");
end_card();
// Rows of the value table by id, looked up once per snapshot.
static constexpr const char *groups[][2]{
{"REVERTS AND POWERSLIDES", "onboard_powerslide.frictionscalar_revert onboard_powerslide.frictionscalar_autorevert "
"onboard_powerslide.powerslide_forwardforcescalar_revert onboard_powerslide.powerslide_forwardforcescalar_autorevert "
"onboard_powerslide.frictionscalar_slide onboard_powerslide.powerslide_forwardforcescalar_slide"},
{"PUMPING", "physicsmode.pumpeffectfactor physicsmode.pumpmaxacceleration physicsmode.pumpmaxdeceleration physicsmode.unintentionalpumpscalar "
"physicspumping.pumpangle physicspumping.pumpminfactor physicspumping.pumpoutoftransitionscalar"},
{"POPS", "physicsmode.jumpmaxheight physicsmode.jumpminheight physicsmode.jumpminheightmanual physicsmode.grindjumpcommonmax physicsmode.grindjumpcommonmin "
"physicsmode.grindjumpboardslidemax physicsmode.grindjumpboardslidemin physicsmode.grindjumptipslidemax physicsmode.grindjumptipslidemin "
"physicsonboardjumptuning.jumpadjustvelocityboostlimitground physicsonboardjumptuning.jumpadjustvelocityboostlimitgrind"},
{"SPINS AND FLIPS", "physicsairstates.maxspinspeed physicsmode.maxautobodyspinspeed physicsmode.easybodyspins physicsreckoning.flipscalar "
"physicsreckoning.flipmaxspeed heldflip.straightflipcatchtime heldflip.shuvmincatchtime heldflip.varialmincatchtime "
"heldflip.bigflipmincatchtime"},
{"MANUALS AND GRINDS", "physicsmanual.manualscalar_p physicsmanual.manualscalar_i physicsmanual.manualscalar_d physicsmanual.maxtiltangle "
"physicsmode.grindlockdist physicsgrind.commonfrictionscalar physicsgrindsair.maxdistboardslide physicsgrindsair.maxdisttipslide "
"physicsgrind.grind_tipslide_minangletoprimitive physicsgrind.slowgrindexitspeed physicsgrind.copingexitstarttime "
"physicsgrind.curbexitstarttime physicsgrind.fairlysteepexitstarttime physicsfriction.noinputtime "
"physicssteering.generalscalar physicssteering.damping"},
};
// What each group is for in a line, said once at the top of its card.
static constexpr const char *group_notes[]{
"",
"Pumping is speed without pushing: crouch into a transition and stand up out of it. Strength is how much a pump gives, the limit how "
"fast it may build. Skate 3 pumped harder (18 against 13) but capped it lower.",
"A line lives on pops you can count on. Max is a held pop, min a quick flick; bring them together and every pop is the same height. "
"Each kind of grind has its own pair, and the stick boost is the speed you can add by pushing the stick as you pop.",
"How fast you and the board turn, and how long a flip may take to come round (its catch time). Slower spins and longer catch times "
"read as style; faster ones fit more into a small gap.",
"Manual balance (the three balance numbers are how hard the game corrects you), how far a rail reaches out to grab you, how quickly a "
"grind loses speed, and when the game ends a grind for you. The tuning skate. grew out of ended slow grinds by itself; in skate. those exit timers are off (-1).",
};
if (p.trick_revision != view.revision) {
p.trick_revision = view.revision;
for (std::size_t g = 0; g < std::size(groups); ++g) {
p.trick_rows[g].clear();
const std::string keys = groups[g][1];
for (std::size_t start = 0; start < keys.size();) {
auto end = keys.find(' ', start);
if (end == std::string::npos) end = keys.size();
const auto key = keys.substr(start, end - start);
for (std::size_t i = 0; i < view.rows.size() && !key.empty(); ++i)
if (lower(view.rows[i].id) == key) {
p.trick_rows[g].push_back(i);
break;
}
start = end + 1;
}
}
}
const auto rows = [&](std::size_t g) {
for (const auto i : p.trick_rows[g])
if (i < view.rows.size()) value_row(menu, callbacks, p, view, view.rows[i], false, true);
};
begin_card(menu, "trick-revert", "BOARD BENDING, REVERTS AND POWERSLIDES", "Speed out of a bent landing");
{
// The revert speed boost: its strength, then what counts and what it is worth.
const auto number_row = [&](const char *label, const char *command, float value, float low, float high, const char *format, const char *tip) {
ImGui::PushID(command);
field(menu, label);
const auto key = std::string("revert:") + command;
float shown = p.active == key ? static_cast<float>(p.active_value) : value;
ImGui::SetNextItemWidth(std::max(px(70), ImGui::GetContentRegionAvail().x - px(64) - ImGui::GetStyle().ItemSpacing.x));
if (ImGui::SliderFloat("##value", &shown, low, high, format)) trainer_command(menu, callbacks, std::format("{} {:.3f}", command, shown));
if (ImGui::IsItemActive()) {
p.active = key;
p.active_value = shown;
} else if (p.active == key) {
p.active.clear();
}
if (ImGui::IsItemHovered()) wrapped_tooltip("%s", tip);
ImGui::SameLine();
float typed = value;
ImGui::SetNextItemWidth(px(64));
if (ImGui::InputFloat("##typed", &typed, 0, 0, "%.6g", ImGuiInputTextFlags_EnterReturnsTrue)) trainer_command(menu, callbacks, std::format("{} {}", command, typed));
if (ImGui::IsItemHovered()) wrapped_tooltip("%s", "Type any number and press Enter: the slider's ends are not a limit.");
ImGui::PopID();
};
const auto &r = view.revert;
note("Board bending is how tricklining keeps its speed. Land a spin with the board twisted off your body "
"and the game snaps the board straight. In Skate 3 that snap threw you forward, so "
"liners bend the board on every landing instead of pushing. skate. gives nothing for it; this boost puts the speed back.");
number_row("Board bending boost", "option revert_boost", view.revert_boost, 0.0f, 5.0f, view.revert_boost <= 0 ? "off" : "x %.2f",
"How hard a bend throws you forward. 0 is off, the game's own; x 1 is about +2 m/s (7 km/h) a landing.\nFrom AutoRevertBoost by Sivaes, jaq and OVM.");
ImGui::Spacing();
if (ImGui::TreeNodeEx("What counts as a bend, and what it is worth", ImGuiTreeNodeFlags_SpanAvailWidth)) {
number_row("Spin needed", "revert spin", r.min_spin, 0.0f, 180.0f, "%.0f deg", "How far you must have turned in the air. Lower it and smaller turns count as bends.");
number_row("Board off your body", "revert twist", r.min_twist, 0.0f, 90.0f, "%.0f deg",
"The rule that makes a bend: the board must land twisted at least this far off your body. Clean and short 180s land 0 to 3 degrees off, "
"auto reverts and board bends 70 to 90.");
number_row("or off the travel", "revert slip", r.min_slip, 0.0f, 90.0f, r.min_slip >= 90.0f ? "off" : "%.0f deg",
"Off at 90, as shipped. Lower it and a landing also counts when you and the board together land this far out of line with where you are "
"going. Careful: the game straightens ordinary 180s that way too, so a low number gives speed on plain 180s and flip trick 180s. A "
"landing that counts by this rule alone gets the auto revert boost at most.");
number_row("Boost: a small bend", "revert bend", r.bend_boost, 0.0f, 10.0f, "+%.1f m/s", "Speed added when the board turned 40 degrees past the body over the flight (times the boost strength).");
number_row("Boost: a full bend", "revert full", r.full_boost, 0.0f, 10.0f, "+%.1f m/s", "The same at 360 degrees; in between it rises in a straight line up to 140 degrees.");
number_row("Boost: an auto revert", "revert auto", r.auto_boost, 0.0f, 10.0f, "+%.1f m/s", "Speed added when the board turned more than 140 degrees past the body.");
number_row("No boost above", "revert max_speed", r.max_speed, 5.0f, 100.0f, "%.0f m/s", "Reverts stop adding speed here (1 m/s = 3.6 km/h).");
number_row("Time between boosts", "revert cooldown", r.cooldown, 0.0f, 3.0f, "%.2f s", "How soon the next revert can boost again.");
number_row("Shortest flight", "revert min_air", r.min_air, 0.0f, 2.0f, "%.2f s", "A hop shorter than this never boosts.");
if (ImGui::Button("Reset these rules")) trainer_command(menu, callbacks, "revert reset");
tip("Puts the rules above back to the trainer's defaults.");
ImGui::TreePop();
}
ImGui::Spacing();
}
note("Friction: how much speed a revert or a powerslide scrubs off (higher loses more). Skate 3's were heavier, which is why bending "
"mattered there. Forward force: a push the game adds while you revert or slide.");
rows(0);
end_card();
trick_heights(menu, callbacks, p, view, std::string{});
for (std::size_t g = 1; g < std::size(groups); ++g) {
if (p.trick_rows[g].empty()) continue;
begin_card(menu, groups[g][0], groups[g][0]);
note(group_notes[g]);
rows(g);
end_card();
}
ImGui::EndDisabled();
}
void map_tab(SkateMenu &menu, const CallbacksV3 &callbacks, const trainer::View &view) {
const auto telemetry = trainer::telemetry();
begin_card(menu, "hud", "HUD");
bool hud = view.hud, jump = view.hud_jump, logging = view.logging;
if (toggle_row(menu, "Speed and air HUD", "Speed, air time and height in the corner while you skate.", hud))
trainer_command(menu, callbacks, std::format("option hud {}", hud ? 1 : 0));
if (toggle_row(menu, "Jump read-out", "After each landing: takeoff speed and angle, height, distance, landing speed.", jump))
trainer_command(menu, callbacks, std::format("option hud_jump {}", jump ? 1 : 0));
if (toggle_row(menu, "Record telemetry", "Write position and speed every frame to a CSV in %LOCALAPPDATA%\\ReSkate\\trainer\\telemetry.",
logging))
trainer_command(menu, callbacks, std::format("option log {}", logging ? 1 : 0));
end_card();
begin_card(menu, "now", "RIGHT NOW");
if (!telemetry.skater) {
note("No skater yet.");
} else {
info(menu, "Speed", std::format("{:.1f} km/h ({:.2f} m/s), top {:.1f} km/h", telemetry.speed * 3.6f, telemetry.speed, telemetry.top_speed * 3.6f));
info(menu, "Position", std::format("{:.2f}, {:.2f}, {:.2f}", telemetry.position[0], telemetry.position[1], telemetry.position[2]));
info(menu, "Heading", std::format("{:.0f} degrees", telemetry.heading));
}
end_card();
if (telemetry.last.serial) {
begin_card(menu, "last-jump", "LAST JUMP", std::format("Jump {}", telemetry.last.serial).c_str());
jump_lines(menu, telemetry.last);
end_card();
}
if (telemetry.best.serial && telemetry.best.serial != telemetry.last.serial) {
begin_card(menu, "best-jump", "LONGEST JUMP ON THIS MAP");
jump_lines(menu, telemetry.best);
end_card();
}
begin_card(menu, "map", "THIS MAP", view.map.empty() ? "No level loaded" : view.map.c_str());
if (!view.map_note.empty()) note(view.map_note.c_str());
if (!view.map_preset.empty()) {
info(menu, "Author's preset", view.map_preset);
ImGui::BeginDisabled(!view.editable);
if (ImGui::Button("Apply the author's preset")) trainer_command(menu, callbacks, "preset apply map");
tip("Sets the values this map's maker recommends for it.");
ImGui::EndDisabled();
}
for (std::size_t i = 0; i < view.spots.size(); ++i) {
ImGui::PushID(static_cast<int>(i));
if (ImGui::Button("Go")) trainer_command(menu, callbacks, std::format("spot {}", i + 1));
tip("Takes you to this spot.");
ImGui::SameLine();
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(view.spots[i].name.c_str());
ImGui::PopID();
}
if (view.map_note.empty() && view.map_preset.empty() && view.spots.empty())
note("Map makers can ship a trainer.json in their mod folder with spots and a recommended preset.");
end_card();
}
} // namespace
bool trainer_take_open() {
const auto view = trainer::view();
auto &p = page();
if (view->open_serial == p.open_serial) return false;
p.open_serial = view->open_serial;
// `trainer open` still takes "presets": they live on the Tune tab now.
p.tab = view->open_tab == 3 ? 2 : view->open_tab == 2 ? 1 : 0; // by name, see `trainer open`
if (view->open_tab == 8) p.mode = 3; // the trick line section of the Tune tab
if (view->open_tab >= 4 && view->open_tab < 7) p.mode = std::clamp(view->open_tab - 4, 0, 2);
p.show_page = true;
return true;
}
bool trainer_page_wanted() {
auto &p = page();
const bool wanted = p.show_page;
p.show_page = false;
return wanted;
}
void trainer_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks) {
auto &p = page();
const auto view = trainer::view();
// A guest under the host's physics would only wipe a setup they cannot see at work here.
ImGui::BeginDisabled(!callbacks.queue_console_command || view->session_enforced);
if (ImGui::Button("RESET EVERYTHING")) {
p.hippy_editing = p.nocomply_editing = p.boneless_editing = p.offboard_editing = p.flip_editing = p.pump_editing = p.revert_editing = false;
p.trick_editing = {};
trainer_command(menu, callbacks, "reset everything");
}
tip("Puts back every value, lock, preset, dial, trick setting: the game exactly as it shipped.");
ImGui::EndDisabled();
if (view->session_enforced && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
wrapped_tooltip("%s", "Off in this multiplayer game: the host controls physics here. Your own setup is kept for when you leave.");
ImGui::SameLine();
ImGui::AlignTextToFramePadding();
if (view->session_enforced) ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.3f, 1.0f), "HOST'S PHYSICS: your own setup is kept for when you leave.");
else if (view->ready && view->stock) ImGui::TextColored(ImVec4(0.45f, 0.85f, 0.45f, 1.0f), "STOCK: the game is exactly as it shipped. Nothing of the trainer's is on.");
else if (view->ready) ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.3f, 1.0f), "MODIFIED: this button puts back every value, lock, preset and trick slider.");
else ImGui::TextDisabled("The game as it shipped: every value, lock, preset and trick slider.");
// `preset export` leaves its line here; the clipboard belongs to this thread.
if (!p.share_seen) {
p.share_seen = true;
p.share_serial = view->share_serial;
} else if (p.share_serial != view->share_serial) {
p.share_serial = view->share_serial;
ImGui::SetClipboardText(view->share_text.c_str());
}
// Above the tabs, so every tab says why its controls do nothing in this session.
if (view->session_enforced) {
begin_card(menu, "session-note", "MULTIPLAYER: THE HOST CONTROLS PHYSICS");
warn("You are in a multiplayer game where the host controls physics. This menu will not work in this multiplayer game.");
note("You skate with the host's setup. HUD and markers still work. Your own setup comes back when you leave.");
end_card();
} else if (view->boosts_blocked) {
begin_card(menu, "session-note", "MULTIPLAYER: BOOSTS ARE OFF");
warn("The host has turned boosts off in this multiplayer game: trick sliders, flip speed, auto push and the board bending boost will not work here.");
note("The physics values still apply. The rest comes back when the host allows boosts or you leave.");
end_card();
}
category_tabs(menu, p.tab, {"TUNE", "PRACTICE", "MAP & HUD"}, "trainer-tabs");
ImGui::PushID(p.tab);
ImGui::BeginChild("trainer-tab", ImVec2(0, page_body_height(menu)));
switch (p.tab) {
case 0: tune_tab(menu, model, callbacks, p, *view); break;
case 1: practice_tab(menu, model, callbacks, p, *view); break;
default: map_tab(menu, callbacks, *view); break;
}
ImGui::EndChild();
ImGui::PopID();
}
} // namespace dingosdk::overlay::menu
namespace dingosdk::overlay {
bool trainer_open_requested() { return menu::trainer_take_open(); }
bool trainer_pad_menu_pressed(bool menu_visible) {
static ControllerComboLatch latch;
ControllerInput input;
DingoSDKOverlayReadControllerInput(&input, true);
constexpr std::uint32_t combo = 0x100 | 0x200 | 0x80; // LB + RB + R3
if (!latch.update(combo, input, !trainer::view()->pad_menu)) return false;
if (!menu_visible) menu::page().show_page = true;
return true;
}
bool trainer_hud_pending() {
const auto view = trainer::view();
return (view->hud || view->hud_jump) && trainer::telemetry().skater;
}
void draw_trainer_hud() {
const auto view = trainer::view();
if (!view->hud && !view->hud_jump) return;
const auto telemetry = trainer::telemetry();
if (!telemetry.skater) return;
static std::uint64_t shown_jump{};
static double jump_until{};
if (telemetry.last.serial != shown_jump) {
shown_jump = telemetry.last.serial;
jump_until = shown_jump ? ImGui::GetTime() + 7.0 : 0.0;
}
std::vector<std::string> lines;
if (view->hud) {
lines.push_back(std::format("{:.0f} km/h", telemetry.speed * 3.6f));
if (telemetry.airborne) lines.push_back(std::format("air {:.2f} s {:.2f} m", telemetry.air_time, telemetry.height));
}
if (view->hud_jump && telemetry.last.serial && ImGui::GetTime() < jump_until) {
const auto &jump = telemetry.last;
lines.push_back(std::format("jump {}: {:.1f} km/h at {:.0f} deg", jump.serial, jump.takeoff_speed * 3.6f, jump.takeoff_angle));
lines.push_back(std::format("{:.2f} s {:.2f} m high {:.2f} m far", jump.air_time, jump.height, jump.distance));
lines.push_back(std::format("landed at {:.1f} km/h, {:.2f} m lower", jump.landing_speed * 3.6f, jump.drop));
if (jump.spin >= 45.0f || jump.flip >= 90.0f) lines.push_back(std::format("spin {:.0f} deg flip {:.0f} deg", jump.spin, jump.flip));
}
if (lines.empty()) return;
auto *draw = ImGui::GetForegroundDrawList();
const auto &display = ImGui::GetIO().DisplaySize;
const float scale = std::clamp(display.y / 1080.0f, 0.75f, 2.5f);
auto *font = ImGui::GetFont();
const float big = 34.0f * scale, small = 18.0f * scale, pad = 10.0f * scale;
float width = 0, height = pad * 2;
for (std::size_t i = 0; i < lines.size(); ++i) {
const float size = i == 0 && view->hud ? big : small;
width = std::max(width, font->CalcTextSizeA(size, FLT_MAX, 0, lines[i].c_str()).x);
height += size + 4.0f * scale;
}
const ImVec2 origin(display.x - width - pad * 2 - 24.0f * scale, 24.0f * scale);
draw->AddRectFilled(origin, ImVec2(origin.x + width + pad * 2, origin.y + height), IM_COL32(12, 14, 18, 170), 6.0f * scale);
float y = origin.y + pad;
for (std::size_t i = 0; i < lines.size(); ++i) {
const float size = i == 0 && view->hud ? big : small;
draw->AddText(font, size, ImVec2(origin.x + pad, y), IM_COL32(245, 245, 240, 255), lines[i].c_str());
y += size + 4.0f * scale;
}
}
} // namespace dingosdk::overlay