Files
ReSkate/Extension/Trainer/trainer.cpp
T
andrewnakasandClaude Opus 5.5 da0aeff752 Trainer 0.2.0: Realistic and Fun modes, the game's other tuning classes, push speed on every push
The game keeps much of its tuning in data-defined classes (push speeds,
everything on foot, dive and glide, bail speeds...). The trainer finds 24 of
them in memory by the defaults and field order the game's data ships and lists
their 314 values by name. Push speed now drives the push class's own speeds;
auto push, off-board jump height and wider trick height ranges come with it.
The Tune tab opens on a Realistic or a Fun short list. Built on ReSkate 1.0.7.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018szL7EmApPZxh9GK4Phw6x
2026-10-04 01:04:24 -06:00

1472 lines
68 KiB
C++

#include "trainer.h"
#include "trainer_classes.h"
#include "trainer_jump.h"
#include "trainer_presets.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/no_bail.h"
#include "Engine/Game/Build/20260929/physics_tuning.h"
#include "Engine/Game/Multiplayer/session_tools.h"
#include "Extension/Profile/local_profile_runtime.h"
#include "Extension/Skater/client_source_spawn.h"
#include "Extension/Skater/client_source_spawn_internal.h"
#include "Extension/Skater/no_bail.h"
#include "Extension/Skater/physics_tuning.h"
#include "Extension/UI/Overlay/overlay.h"
#include <windows.h>
#include <shlobj.h>
#include <algorithm>
#include <charconv>
#include <cmath>
#include <cstring>
#include <filesystem>
#include <format>
#include <fstream>
#include <map>
#include <numbers>
#include <optional>
// Game thread only. See trainer.h for how the menu reaches this.
namespace dingosdk::trainer {
namespace {
namespace tuning = dingosdk::physics_tuning;
using Vec3 = std::array<float, 3>;
constexpr float max_coordinate = 1.0e6f;
constexpr float height_low = 0.05f, height_high = 100.0f; // trick height multipliers
constexpr std::uint32_t air_states_first = 200, air_states_end = 300;
constexpr std::uint32_t xinput_up = 0x1, xinput_down = 0x2, xinput_left = 0x4, xinput_right = 0x8,
xinput_lb = 0x100, xinput_rb = 0x200;
struct Entry {
std::string id, key, label, group; // key: lower-case id
Kind kind{};
std::uint16_t offset{}, size{};
std::size_t curve{};
double value{}, stock{};
bool touched{}, frozen{};
bool detail{};
bool used{}; // the game's code was found to read it (trainer_used.inc), or it is linked
std::vector<std::size_t> drives; // graph multipliers this value scales (value_links)
std::vector<std::size_t> members; // graph: its outputs (Y0.., Min_Y, Max_Y)
int class_field{-1}; // a field of one of the game's data-defined classes (trainer_classes.h), not of the tuning asset
};
struct MapData {
std::array<Marker, marker_slots> markers{};
std::string preset;
};
struct MapFile { // Mods/<mod>/trainer.json
std::string note, preset_name;
std::vector<std::pair<std::string, double>> preset;
std::vector<Spot> spots;
};
struct Saved {
double value{};
bool frozen{};
};
struct SelfTest {
int step{}; // 0: idle
std::uint64_t until{};
std::size_t entry{};
double before{}, target{};
Vec3 origin{};
int failures{};
};
struct Motion {
bool valid{};
Vec3 position{}, velocity{};
std::int64_t stamp{};
bool airborne{};
Vec3 takeoff{}, takeoff_velocity{};
std::int64_t takeoff_stamp{};
float apex{};
bool axes_valid{};
Vec3 forward{}, up{};
float spin{}, spin_rate{}, flip{};
std::uint32_t air_state{}, ground_state{};
};
struct State {
bool loaded{}; // the store was read
std::shared_ptr<const tuning::Model> model;
std::vector<Entry> entries;
std::map<std::string, std::size_t, std::less<>> index;
std::uintptr_t base{}, asset{}, entity{};
tuning::Values own, want;
bool write_due{}, view_due{true}, save_due{};
bool profile_due{}; // the loaded map's preset is still to be applied
bool refresh_due{}; // values were written while the skater had no cached copy to refresh
std::uint64_t next_hold{}, next_save{}, next_skater{}, revision{};
std::string status = "Waiting for the game.", last;
bool ready{};
std::map<std::string, Saved, std::less<>> saved; // by key
std::map<std::string, std::map<std::string, double, std::less<>>, std::less<>> user; // presets
std::vector<std::string> active; // presets applied since Stock
std::map<std::string, MapData, std::less<>> maps;
std::string map;
MapFile map_file;
int slot{};
bool auto_return{}, pad_shortcuts{true}, hud{}, hud_jump{true}, logging{};
// The hippy jump's height is set by the game's trick scripts, not by tuning: the trainer
// scales the upward velocity when it sees one start.
float hippy_height{1};
// An on-foot jump: the same scaling, when a skater standing off the board starts to rise.
float offboard_height{1};
bool offboard_grounded{}; // off the board and not moving up or down at the last tick
const char *boost_name{""};
// The no comply and the boneless are launched by the trick scripts too (trainer_jump.cpp).
float nocomply_height{1}, boneless_height{1};
std::uint32_t last_state{};
float boost_factor{}; // velocity factor of the jump now starting, 0: none
std::uint64_t boost_until{};
float return_delay{1.5f};
std::uint64_t seen_wipeouts{}, return_at{}, open_serial{};
std::uint64_t class_search_at{}; // when to look for the game's tuning classes (0: not due)
int class_search_tries{};
bool classes_stock{}; // a session's host tuning is in force: the classes hold their defaults
bool wipeouts_known{}; // the wipeout count the session started with was read
int open_tab{};
std::uint32_t pad_previous{};
Motion motion;
Telemetry telemetry;
std::ofstream log;
std::int64_t log_start{};
std::map<std::uint32_t, std::uint64_t> states; // physics state -> ticks seen
SelfTest test;
};
State &state() {
static auto *value = new State;
return *value;
}
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;
}
// "MaxPushSpeed" -> "Max Push Speed"
std::string spaced(std::string_view text) {
std::string result;
for (std::size_t i = 0; i < text.size(); ++i) {
const auto c = static_cast<unsigned char>(text[i]);
const bool upper = std::isupper(c) != 0;
if (i && upper) {
const auto before = static_cast<unsigned char>(text[i - 1]);
const bool next_lower = i + 1 < text.size() && std::islower(static_cast<unsigned char>(text[i + 1]));
if (std::islower(before) || std::isdigit(before) || (std::isupper(before) && next_lower)) result += ' ';
}
result += text[i] == '.' ? ' ' : text[i];
if (text[i] == '.') result += "/ ";
}
return result;
}
std::optional<double> number(std::string_view text) {
double value{};
const auto *last = text.data() + text.size();
const auto parsed = std::from_chars(text.data(), last, value);
if (parsed.ec != std::errc{} || parsed.ptr != last || !std::isfinite(value)) return std::nullopt;
return value;
}
void say(logging::Level level, const std::string &text) { logging::write(level, logging::Channel::skater, text); }
// ---- storage ---------------------------------------------------------------------------
std::filesystem::path data_directory() {
PWSTR raw{};
std::filesystem::path result;
if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &raw)) && raw)
result = std::filesystem::path(raw) / L"ReSkate" / L"trainer";
if (raw) CoTaskMemFree(raw);
return result;
}
std::filesystem::path game_directory() {
std::vector<wchar_t> exe(32768);
const auto length = GetModuleFileNameW(nullptr, exe.data(), static_cast<DWORD>(exe.size()));
if (!length || length >= exe.size()) return {};
return std::filesystem::path(exe.data()).parent_path();
}
std::optional<Json> read_json(const std::filesystem::path &path) {
try {
std::ifstream file(path, std::ios::binary);
if (!file) return std::nullopt;
const std::string text((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
if (text.empty() || text.size() > 1024 * 1024) return std::nullopt;
return Json::parse(text);
} catch (...) {
return std::nullopt;
}
}
std::optional<Vec3> read_position(const Json &value) {
if (!value.is_array() || value.size() != 3) return std::nullopt;
Vec3 result{};
for (std::size_t i = 0; i < 3; ++i) {
if (!value.at(i).is_number()) return std::nullopt;
result[i] = value.at(i).get<float>();
if (!std::isfinite(result[i]) || std::abs(result[i]) > max_coordinate) return std::nullopt;
}
return result;
}
Json write_position(const Vec3 &p) { return Json::array({Json(p[0]), Json(p[1]), Json(p[2])}); }
void load_store() {
auto &s = state();
s.loaded = true;
const auto json = read_json(data_directory() / L"trainer.json");
if (!json || !json->is_object()) return;
try {
if (json->contains("options") && json->at("options").is_object()) {
const auto &o = json->at("options");
s.auto_return = o.value("auto_return", false);
s.pad_shortcuts = o.value("pad_shortcuts", true);
s.hud = o.value("hud", false);
s.hud_jump = o.value("hud_jump", true);
s.return_delay = std::clamp(o.value("return_delay", 1.5f), 0.0f, 10.0f);
s.hippy_height = std::clamp(o.value("hippy_height", 1.0f), height_low, height_high);
s.nocomply_height = std::clamp(o.value("nocomply_height", 1.0f), height_low, height_high);
s.offboard_height = std::clamp(o.value("offboard_height", 1.0f), height_low, height_high);
s.boneless_height = std::clamp(o.value("boneless_height", 1.0f), height_low, height_high);
s.slot = std::clamp(o.value("slot", 0), 0, static_cast<int>(marker_slots) - 1);
}
if (json->contains("values") && json->at("values").is_object())
for (const auto &[key, row] : json->at("values").items())
if (row.is_object() && row.contains("value") && row.at("value").is_number())
s.saved[lower(key)] = {row.at("value").get<double>(), row.value("frozen", false)};
if (json->contains("presets") && json->at("presets").is_object())
for (const auto &[name, values] : json->at("presets").items()) {
if (!values.is_object() || name.empty() || name.size() > 48) continue;
auto &preset = s.user[name];
for (const auto &[key, value] : values.items())
if (value.is_number()) preset[lower(key)] = value.get<double>();
}
if (json->contains("active") && json->at("active").is_array())
for (const auto &name : json->at("active"))
if (name.is_string()) s.active.push_back(name.string());
if (json->contains("maps") && json->at("maps").is_object())
for (const auto &[level, row] : json->at("maps").items()) {
if (!row.is_object()) continue;
auto &map = s.maps[lower(level)];
map.preset = row.value("preset", "");
if (row.contains("markers") && row.at("markers").is_array())
for (std::size_t i = 0; i < marker_slots && i < row.at("markers").size(); ++i)
if (const auto position = read_position(row.at("markers").at(i))) map.markers[i] = {true, *position};
}
} catch (...) {
say(logging::Level::warning, "Trainer: trainer.json is damaged; starting from what could be read.");
}
}
void save_store() {
auto &s = state();
s.save_due = false;
try {
Json json = Json::object();
json["schema"] = 1;
Json options = Json::object();
options["auto_return"] = s.auto_return;
options["pad_shortcuts"] = s.pad_shortcuts;
options["hud"] = s.hud;
options["hud_jump"] = s.hud_jump;
options["return_delay"] = s.return_delay;
options["hippy_height"] = s.hippy_height;
options["nocomply_height"] = s.nocomply_height;
options["offboard_height"] = s.offboard_height;
options["boneless_height"] = s.boneless_height;
options["slot"] = s.slot;
json["options"] = std::move(options);
Json values = Json::object();
// Values of fields this build's tuning does not have are kept as they were read.
for (const auto &[key, saved] : s.saved) {
Json row = Json::object();
row["value"] = saved.value;
row["frozen"] = saved.frozen;
values[key] = std::move(row);
}
json["values"] = std::move(values);
Json presets = Json::object();
for (const auto &[name, preset] : s.user) {
Json row = Json::object();
for (const auto &[key, value] : preset) row[key] = value;
presets[name] = std::move(row);
}
json["presets"] = std::move(presets);
Json active = Json::array();
for (const auto &name : s.active) active.push_back(name);
json["active"] = std::move(active);
Json maps = Json::object();
for (const auto &[level, map] : s.maps) {
if (level.empty()) continue;
Json row = Json::object();
Json markers = Json::array();
for (const auto &marker : map.markers) markers.push_back(marker.set ? write_position(marker.position) : Json(nullptr));
row["markers"] = std::move(markers);
row["preset"] = map.preset;
maps[level] = std::move(row);
}
json["maps"] = std::move(maps);
const auto directory = data_directory();
if (directory.empty()) return;
std::error_code error;
std::filesystem::create_directories(directory, error);
const auto target = directory / L"trainer.json", temporary = directory / L"trainer.json.tmp";
{
std::ofstream file(temporary, std::ios::binary | std::ios::trunc);
file << json.dump(2);
if (!file) return;
}
std::filesystem::rename(temporary, target, error);
if (error) {
std::filesystem::remove(target, error);
std::filesystem::rename(temporary, target, error);
}
} catch (...) {
say(logging::Level::warning, "Trainer: could not save trainer.json.");
}
}
void changed(bool save = true) {
auto &s = state();
s.view_due = true;
if (save) {
s.save_due = true;
s.next_save = GetTickCount64() + 1500;
}
}
// ---- values ----------------------------------------------------------------------------
double read_field(const std::vector<std::uint8_t> &image, const Entry &e) {
const auto *at = image.data() + e.offset;
if (e.kind == Kind::real) {
float value;
std::memcpy(&value, at, 4);
return value;
}
if (e.kind == Kind::flag) return *at ? 1 : 0;
switch (e.size) {
case 1: { std::int8_t v; std::memcpy(&v, at, 1); return v; }
case 2: { std::int16_t v; std::memcpy(&v, at, 2); return v; }
case 4: { std::int32_t v; std::memcpy(&v, at, 4); return v; }
default: { std::int64_t v; std::memcpy(&v, at, 8); return static_cast<double>(v); }
}
}
void write_field(std::vector<std::uint8_t> &image, const Entry &e, double value) {
auto *at = image.data() + e.offset;
if (e.kind == Kind::real) {
const auto v = static_cast<float>(value);
std::memcpy(at, &v, 4);
} else if (e.kind == Kind::flag) {
*at = value != 0 ? 1 : 0;
} else {
switch (e.size) {
case 1: { const auto v = static_cast<std::int8_t>(std::clamp(value, -128.0, 127.0)); std::memcpy(at, &v, 1); break; }
case 2: { const auto v = static_cast<std::int16_t>(std::clamp(value, -32768.0, 32767.0)); std::memcpy(at, &v, 2); break; }
case 4: { const auto v = static_cast<std::int32_t>(std::clamp(value, -2147483648.0, 2147483647.0)); std::memcpy(at, &v, 4); break; }
default: { const auto v = static_cast<std::int64_t>(value); std::memcpy(at, &v, 8); break; }
}
}
}
// A curve's outputs times `factor`: a point is 0x1c bytes with its output at +0x14.
tuning::Curve scaled_curve(const tuning::Curve &source, double factor) {
auto result = source;
for (std::size_t p = 0; p + addr::physics_tuning::curve_point_size <= result.points.size(); p += addr::physics_tuning::curve_point_size) {
float y;
std::memcpy(&y, result.points.data() + p + 0x14, 4);
y = static_cast<float>(y * factor);
if (std::isfinite(y)) std::memcpy(result.points.data() + p + 0x14, &y, 4);
}
return result;
}
double sane(const Entry &e, double value) {
if (!std::isfinite(value)) return e.stock;
if (e.kind == Kind::flag) return value != 0 ? 1 : 0;
if (e.kind == Kind::integer) return std::round(value);
if (e.kind == Kind::curve || e.kind == Kind::graph) return std::clamp(value, 0.0, 100.0);
return std::clamp(value, -1.0e6, 1.0e6);
}
void apply_entry(Entry &e) {
auto &s = state();
if (e.class_field >= 0) {
want_class_value(static_cast<std::size_t>(e.class_field), static_cast<float>(e.touched ? e.value : e.stock));
return;
}
if (e.kind == Kind::graph) {
// A point the player set by hand keeps their value.
for (const auto index : e.members) {
const auto &member = s.entries[index];
if (!member.touched && static_cast<std::size_t>(member.offset) + member.size <= s.want.image.size())
write_field(s.want.image, member, e.touched ? member.stock * e.value : member.stock);
}
} else if (e.kind == Kind::curve) {
if (e.curve < s.own.curves.size() && s.own.curves[e.curve] && e.curve < s.want.curves.size())
s.want.curves[e.curve] = e.touched ? scaled_curve(*s.own.curves[e.curve], e.value) : *s.own.curves[e.curve];
} else if (static_cast<std::size_t>(e.offset) + e.size <= s.want.image.size()) {
write_field(s.want.image, e, e.touched ? e.value : e.stock);
}
s.write_due = true;
}
void remember(const Entry &e) {
auto &s = state();
if (e.touched || e.frozen) s.saved[e.key] = {e.value, e.frozen};
else s.saved.erase(e.key);
}
void set_entry(Entry &e, double value) {
e.value = sane(e, value);
e.touched = e.value != e.stock;
apply_entry(e);
remember(e);
// A linked value scales its graphs by the same ratio (the graphs are what the game reads).
if (!e.drives.empty() && e.stock != 0) {
const auto ratio = e.value / e.stock;
const auto targets = e.drives; // set_entry may not touch `e` again, but keep the list stable
auto &s = state();
for (const auto index : targets)
if (index < s.entries.size() && !s.entries[index].frozen) {
auto &target = s.entries[index];
const bool multiplier = target.kind == Kind::curve || target.kind == Kind::graph;
set_entry(target, multiplier ? ratio : target.stock * ratio);
}
}
}
Entry *find_entry(std::string_view id) {
auto &s = state();
const auto found = s.index.find(lower(id));
return found == s.index.end() ? nullptr : &s.entries[found->second];
}
void build_entries() {
auto &s = state();
s.entries.clear();
s.index.clear();
const auto &m = *s.model;
const auto add = [&](std::string name, Kind kind, std::uint16_t offset, std::uint16_t size, std::size_t curve) {
if (name.empty()) name = std::format("Unnamed.0x{:x}", offset);
Entry e;
e.id = kind == Kind::curve || kind == Kind::graph ? name + " x" : name;
e.key = lower(e.id);
if (s.index.contains(e.key)) { // arrays of structures repeat names
e.id += std::format(" @0x{:x}", offset);
e.key = lower(e.id);
}
const auto dot = name.find('.');
auto group = dot == std::string::npos ? std::string("General") : name.substr(0, dot);
for (const std::string_view prefix : {"SkatePhysics", "Physics"})
if (group.size() > prefix.size() && group.starts_with(prefix)) {
group.erase(0, prefix.size());
break;
}
e.group = spaced(group);
e.label = spaced(dot == std::string::npos ? name : name.substr(dot + 1));
if (kind == Kind::curve || kind == Kind::graph) e.label += " (x)";
e.kind = kind;
e.offset = offset;
e.size = size;
e.curve = curve;
e.used = value_used(offset);
s.index.emplace(e.key, s.entries.size());
s.entries.push_back(std::move(e));
};
for (std::size_t i = 0; i < m.fields.size(); ++i) {
const auto &f = m.fields[i];
add(i < m.field_names.size() ? m.field_names[i] : std::string{}, f.real ? Kind::real : f.flag ? Kind::flag : Kind::integer,
f.offset, f.size, 0);
}
for (std::size_t i = 0; i < m.curve_slots.size(); ++i)
add(i < m.curve_names.size() ? m.curve_names[i] : std::string{}, Kind::curve, m.curve_slots[i], 8, i);
// Graphs stored in the asset: sixteen X and Y values, a point count and bounds. Each
// becomes one multiplier row; its points stay editable but out of the way.
const auto plain = s.entries.size();
for (std::size_t i = 0; i < plain; ++i) {
if (!s.entries[i].id.ends_with(".Y0") || s.entries[i].kind != Kind::real) continue;
const auto parent = s.entries[i].id.substr(0, s.entries[i].id.size() - 3);
const auto offset = s.entries[i].offset;
std::vector<std::size_t> outputs;
for (std::size_t j = 0; j < plain; ++j) {
auto &other = s.entries[j];
if (other.id.size() <= parent.size() + 1 || !other.id.starts_with(parent) || other.id[parent.size()] != '.') continue;
const std::string_view leaf(other.id.c_str() + parent.size() + 1);
if (leaf.find('.') != std::string_view::npos) continue;
other.detail = true;
const bool output = (leaf.starts_with("Y") && leaf.size() <= 3) || leaf == "Min_Y" || leaf == "Max_Y";
if (output && other.kind == Kind::real) outputs.push_back(j);
}
add(parent, Kind::graph, offset, 0, 0);
s.entries.back().members = std::move(outputs);
}
// The game's data-defined classes: one row per number field, under the class's name.
for (std::size_t c = 0; c < class_count; ++c)
for (std::size_t i = 0; i < class_specs[c].count; ++i) {
const auto &field = class_fields[class_specs[c].first + i];
Entry e;
e.id = std::format("{}.{}", class_specs[c].key, field.name);
e.key = lower(e.id);
if (s.index.contains(e.key)) continue;
e.group = class_specs[c].group;
e.label = spaced(field.name);
e.kind = Kind::real;
e.used = true;
e.class_field = static_cast<int>(class_specs[c].first + i);
e.stock = e.value = field.stock;
s.index.emplace(e.key, s.entries.size());
s.entries.push_back(std::move(e));
}
for (const auto &link : value_links()) {
const auto from = s.index.find(link.value), to = s.index.find(link.drives);
if (from == s.index.end() || to == s.index.end()) continue;
s.entries[from->second].drives.push_back(to->second);
s.entries[from->second].used = true;
}
say(logging::Level::info, std::format("Trainer: {} tuning values and {} curves are editable.", m.fields.size(), m.curve_slots.size()));
}
// A new copy of the asset (first sight, or a level load): its values are the stock ones.
void adopt(const tuning::Values &live) {
auto &s = state();
s.own = live;
s.want = live;
for (auto &e : s.entries) {
e.stock = e.class_field >= 0 ? class_fields[e.class_field].stock : e.kind == Kind::curve || e.kind == Kind::graph ? 1.0 : read_field(s.own.image, e);
const auto found = s.saved.find(e.key);
if (found != s.saved.end()) {
e.frozen = found->second.frozen;
e.value = sane(e, found->second.value);
e.touched = e.value != e.stock;
} else if (!e.touched) {
e.value = e.stock;
}
if (e.touched) apply_entry(e);
}
// A linked value saved by itself (before links existed, or edited in the file) still
// drives its graphs.
for (auto &e : s.entries) {
if (e.drives.empty() || !e.touched || e.stock == 0) continue;
for (const auto index : e.drives) {
auto &target = s.entries[index];
if (target.touched || target.frozen) continue;
const bool multiplier = target.kind == Kind::curve || target.kind == Kind::graph;
target.value = sane(target, multiplier ? e.value / e.stock : target.stock * e.value / e.stock);
target.touched = target.value != target.stock;
if (target.touched) {
apply_entry(target);
remember(target);
}
}
}
s.write_due = true;
changed(false);
}
std::string apply_preset(std::string_view name);
bool editable(std::string *why = nullptr) {
// A guest's tuning is the host's (physics_tuning::enforce); writing over it would only
// fight the session, so the trainer stands down while that is in force.
if (multiplayer_session_active() && !tuning::status().empty()) {
if (why) *why = "This session's host sets the physics tuning.";
return false;
}
return true;
}
void hold(std::uint64_t now) {
auto &s = state();
if (!s.base) return;
if (!s.model) {
tuning::prepare();
std::string error;
s.model = tuning::game_tuning(&error);
if (!s.model) {
const auto next = error.empty() ? std::string("Reading the game's physics tuning...") : "Physics tuning is unavailable: " + error + ".";
if (next != s.status) { s.status = next; changed(false); }
return;
}
build_entries();
}
if (!s.write_due && now < s.next_hold) return;
s.next_hold = now + 500;
tuning::Values live;
std::uintptr_t asset{};
if (!tuning::read_live(s.base, live, &asset)) {
if (s.ready) { s.ready = false; s.status = "Waiting for a level."; changed(false); }
return;
}
if (asset != s.asset || !s.ready) {
s.asset = asset;
adopt(live);
s.ready = true;
s.status = "Ready.";
}
if (!editable()) return;
if (s.profile_due) {
s.profile_due = false;
if (const auto found = s.maps.find(s.map); found != s.maps.end() && !found->second.preset.empty())
say(logging::Level::info, "Trainer: this map's preset - " + apply_preset(found->second.preset));
}
const bool pending = s.write_due || std::ranges::any_of(s.entries, [](const Entry &e) { return e.touched; });
s.write_due = false;
if (!pending) return;
const auto written = tuning::write_live(s.base, s.entity, s.want);
if (written.failed) {
s.status = "The physics tuning could not be written.";
changed(false);
}
// The skater skates from its own copy of part of the tuning: keep asking until it took it.
if (written.values || written.curves) {
s.refresh_due = !written.refreshed;
say(logging::Level::info, std::format("Trainer: wrote {} values and {} curves; skater copy refreshed: {}.", written.values, written.curves,
written.refreshed ? "yes" : "not yet"));
} else if (s.refresh_due && tuning::refresh_skater(s.base, s.entity)) {
s.refresh_due = false;
say(logging::Level::info, "Trainer: skater copy refreshed.");
}
}
// ---- presets ---------------------------------------------------------------------------
bool matches(const std::string &key, std::string_view pattern) {
std::size_t start = 0;
while (start < pattern.size()) {
auto end = pattern.find(' ', start);
if (end == std::string_view::npos) end = pattern.size();
const auto token = pattern.substr(start, end - start);
if (!token.empty()) {
const bool exclude = token.front() == '!';
const auto word = exclude ? token.substr(1) : token;
if ((key.find(word) != std::string::npos) == exclude) return false;
}
start = end + 1;
}
return true;
}
std::string remove_preset(std::string_view name) {
auto &s = state();
const auto wanted = lower(name);
std::size_t count{};
std::string removed;
const auto put_back = [&](Entry &e) {
if (e.frozen) return;
if (e.touched) {
set_entry(e, e.stock);
++count;
return;
}
// A linked value at stock whose graphs were set directly (by an older version's preset).
for (const auto index : e.drives)
if (auto &target = s.entries[index]; target.touched && !target.frozen) {
set_entry(target, target.stock);
++count;
}
};
for (const auto &[user_name, values] : s.user) {
if (lower(user_name) != wanted) continue;
for (const auto &saved : values)
if (auto *e = find_entry(saved.first)) put_back(*e);
removed = user_name;
break;
}
if (removed.empty())
for (const auto &preset : builtin_presets()) {
if (lower(preset.name) != wanted) continue;
for (const auto &rule : preset.rules)
for (auto &e : s.entries) {
const bool scale = e.kind == Kind::curve || e.kind == Kind::graph;
if (e.detail || scale != rule.curves || !matches(e.key, rule.pattern)) continue;
put_back(e);
}
removed = preset.name;
break;
}
if (removed.empty()) return "error: no preset is called \"" + std::string(name) + "\".";
std::erase(s.active, removed);
// Presets that are still on may share values with the one switched off: set theirs again.
const auto still_on = s.active;
for (const auto &other : still_on) (void)apply_preset(other);
changed();
return std::format("{} off: {} values put back.", removed, count);
}
std::size_t reset_all() {
auto &s = state();
std::size_t count{};
for (auto &e : s.entries) {
if (e.frozen || !e.touched) continue;
set_entry(e, e.stock);
++count;
}
s.active.clear();
return count;
}
std::string apply_preset(std::string_view name) {
auto &s = state();
const auto wanted = lower(name);
if (wanted == "stock") {
const auto count = reset_all();
changed();
return std::format("Stock: {} values put back (frozen values kept).", count);
}
std::size_t count{}, locked{};
std::string applied;
if (wanted == "map") {
if (s.map_file.preset.empty()) return "error: this map ships no trainer preset.";
for (const auto &[id, value] : s.map_file.preset)
if (auto *e = find_entry(id); e && !e->frozen) { set_entry(*e, value); ++count; }
applied = s.map_file.preset_name.empty() ? "Map preset" : s.map_file.preset_name;
}
if (applied.empty())
for (const auto &[user_name, values] : s.user) {
if (lower(user_name) != wanted) continue;
for (const auto &[key, value] : values)
if (auto *e = find_entry(key); e && !e->frozen) { set_entry(*e, value); ++count; }
applied = user_name;
break;
}
if (applied.empty())
for (const auto &preset : builtin_presets()) {
if (lower(preset.name) != wanted) continue;
for (const auto &rule : preset.rules)
for (auto &e : s.entries) {
const bool scale = e.kind == Kind::curve || e.kind == Kind::graph;
if (e.detail || scale != rule.curves || !matches(e.key, rule.pattern)) continue;
if (e.kind == Kind::flag && rule.multiply) continue;
if (e.frozen) {
++locked;
continue;
}
set_entry(e, rule.multiply ? e.stock * rule.amount : rule.amount);
++count;
}
applied = preset.name;
break;
}
if (applied.empty()) return "error: no preset is called \"" + std::string(name) + "\".";
if (std::ranges::find(s.active, applied) == s.active.end()) s.active.push_back(applied);
changed();
return std::format("{}: {} values set{}.", applied, count,
locked ? std::format("; {} locked values left alone (untick their boxes in Tune to let presets change them)", locked) : "");
}
// ---- maps ------------------------------------------------------------------------------
MapFile read_map_file(const std::string &level) {
MapFile result;
const auto mods = game_directory() / L"Mods";
std::error_code error;
for (std::filesystem::directory_iterator it(mods, error), end; it != end && !error; it.increment(error)) {
if (!it->is_directory(error)) continue;
const auto json = read_json(it->path() / L"trainer.json");
if (!json || !json->is_object()) continue;
try {
// The file names its level, or it stands for every level its mod adds.
bool mine = false;
if (json->contains("level") && json->at("level").is_string()) mine = lower(json->at("level").string()) == level;
else if (const auto levels = read_json(it->path() / L"reskate-levels.json"); levels && levels->contains("levels"))
for (const auto &row : levels->at("levels"))
if (row.is_object() && lower(row.value("asset", "")) == level) mine = true;
if (!mine) continue;
result.note = json->value("note", "");
if (json->contains("preset") && json->at("preset").is_object()) {
const auto &preset = json->at("preset");
result.preset_name = preset.value("name", "");
if (preset.contains("values") && preset.at("values").is_object())
for (const auto &[id, value] : preset.at("values").items())
if (value.is_number() && result.preset.size() < 512) result.preset.emplace_back(id, value.get<double>());
}
if (json->contains("spots") && json->at("spots").is_array())
for (const auto &row : json->at("spots")) {
if (!row.is_object() || !row.contains("position") || result.spots.size() >= 64) continue;
if (const auto position = read_position(row.at("position")))
result.spots.push_back({row.value("name", "Spot"), *position});
}
return result;
} catch (...) {}
}
return result;
}
void enter_map(const std::string &level) {
auto &s = state();
s.map = level;
s.map_file = level.empty() ? MapFile{} : read_map_file(level);
s.motion = {};
s.telemetry.last = {};
s.telemetry.best = {};
s.telemetry.top_speed = 0;
s.return_at = 0;
s.profile_due = false;
// A level brings fresh copies of the game's tuning classes: look for them once it has settled.
s.class_search_at = level.empty() ? 0 : GetTickCount64() + 10000;
s.class_search_tries = 0;
if (!level.empty()) {
const auto found = s.maps.find(level);
s.profile_due = found != s.maps.end() && !found->second.preset.empty();
if (!s.map_file.spots.empty() || !s.map_file.preset.empty())
say(logging::Level::info, std::format("Trainer: the map ships {} spots and {} preset values.", s.map_file.spots.size(),
s.map_file.preset.size()));
}
changed(false);
}
MapData &current_map() { return state().maps[state().map]; }
bool teleport_allowed(std::string &why) {
if (multiplayer_session_active() && !session_noclip_allowed()) {
why = "The host has turned off teleporting in this session.";
return false;
}
return true;
}
std::string go_to(const Vec3 &position, const std::string &what) {
std::string why;
if (!teleport_allowed(why)) return "error: " + why;
if (!teleport_local_skater(position)) return "error: teleporting is unavailable right now.";
state().motion.valid = false;
return "Teleporting to " + what + ".";
}
// ---- telemetry -------------------------------------------------------------------------
std::int64_t clock_now() {
LARGE_INTEGER value;
QueryPerformanceCounter(&value);
return value.QuadPart;
}
double clock_seconds(std::int64_t ticks) {
static const double frequency = [] {
LARGE_INTEGER value;
QueryPerformanceFrequency(&value);
return static_cast<double>(value.QuadPart);
}();
return static_cast<double>(ticks) / frequency;
}
float horizontal(const Vec3 &v) { return std::sqrt(v[0] * v[0] + v[2] * v[2]); }
void finish_jump(const Vec3 &landing, float landing_speed, std::int64_t landed_at) {
auto &s = state();
auto &m = s.motion;
const auto air = static_cast<float>(clock_seconds(landed_at - m.takeoff_stamp));
m.airborne = false;
s.telemetry.airborne = false;
if (air < 0.25f) return;
Jump jump;
jump.serial = s.telemetry.last.serial + 1;
jump.air_time = air;
jump.takeoff = m.takeoff;
jump.landing = landing;
jump.takeoff_speed = std::sqrt(m.takeoff_velocity[0] * m.takeoff_velocity[0] + m.takeoff_velocity[1] * m.takeoff_velocity[1] +
m.takeoff_velocity[2] * m.takeoff_velocity[2]);
jump.takeoff_angle = std::atan2(m.takeoff_velocity[1], std::max(horizontal(m.takeoff_velocity), 0.01f)) * 180.0f / std::numbers::pi_v<float>;
jump.height = m.apex - m.takeoff[1];
jump.distance = horizontal({landing[0] - m.takeoff[0], 0, landing[2] - m.takeoff[2]});
jump.drop = m.takeoff[1] - landing[1];
jump.landing_speed = landing_speed;
jump.spin = std::abs(m.spin);
jump.spin_rate = m.spin_rate;
jump.flip = m.flip;
jump.state = m.air_state;
s.telemetry.last = jump;
if (jump.distance > s.telemetry.best.distance) s.telemetry.best = jump;
say(logging::Level::info,
std::format("Trainer jump {}: takeoff {:.1f} km/h at {:.1f} deg, air {:.2f} s, height {:.2f} m, distance {:.2f} m, drop {:.2f} m, "
"landing {:.1f} km/h at ({:.1f}, {:.1f}, {:.1f}); spin {:.0f} deg (peak {:.0f} deg/s), flip {:.0f} deg, "
"states {}>{}.",
jump.serial, jump.takeoff_speed * 3.6f, jump.takeoff_angle, jump.air_time, jump.height, jump.distance, jump.drop,
jump.landing_speed * 3.6f, landing[0], landing[1], landing[2], jump.spin, jump.spin_rate, jump.flip, m.ground_state,
m.air_state));
}
void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) {
auto &s = state();
auto &t = s.telemetry;
auto &m = s.motion;
overlay::DebugModel skater;
std::array<float, 16> matrix{};
bool found = false;
if (now >= s.next_skater) {
try {
matrix = client_source::detail::debug_skater(base, client, skater);
found = skater.skater_position_valid;
} catch (...) {
s.next_skater = now + 250; // no skater: menus, loading, a respawn
}
}
if (!found) {
if (t.skater) { t.skater = false; m.valid = false; s.entity = 0; }
return;
}
s.entity = skater.skater_identity;
watch_physics_state(client, s.entity);
const auto watch = watched_physics_state();
const auto stamp = clock_now();
const Vec3 position = skater.skater_position;
t.skater = true;
t.position = position;
// Rows: right, up, forward, position.
t.heading = std::atan2(matrix[8], matrix[10]) * 180.0f / std::numbers::pi_v<float>;
if (t.heading < 0) t.heading += 360.0f;
t.physics_state = watch.valid ? watch.state : 0;
if (watch.valid) ++s.states[watch.state];
// A hippy jump leaves the board (504) straight from riding (100): measured. A plain
// dismount does the same but is not rising, so it is left alone.
if (watch.valid && watch.state != s.last_state) {
if (watch.state == addr::no_bail::offboard_physics_state && s.last_state == 100 && s.hippy_height != 1.0f) {
s.boost_factor = std::sqrt(s.hippy_height); // height goes with the square of the speed
s.boost_until = now + 250;
s.boost_name = "hippy jump";
}
s.last_state = watch.state;
}
if (s.boost_factor > 0) {
const auto result = take_jump_scale_result();
if (result.outcome > 0) {
say(logging::Level::info, std::format("Trainer: {} boosted: up speed {:.2f} -> {:.2f} m/s.", s.boost_name, result.up_speed,
result.up_speed * s.boost_factor));
s.boost_factor = 0;
} else if (now >= s.boost_until || result.outcome == -2) {
s.boost_factor = 0;
} else if (!multiplayer_session_active() || session_boosts_allowed()) {
(void)queue_jump_scale(client, s.entity, s.boost_factor); // again each tick until the skater is rising
}
}
const auto seconds = m.valid ? clock_seconds(stamp - m.stamp) : 0.0;
const Vec3 step{position[0] - m.position[0], position[1] - m.position[1], position[2] - m.position[2]};
const bool jumped = std::sqrt(step[0] * step[0] + step[1] * step[1] + step[2] * step[2]) > 25.0f;
if (!m.valid || seconds <= 0.0005 || seconds > 0.25 || jumped) {
// First sample, a hitch, or a teleport: nothing can be measured across it.
m = {};
m.valid = true;
m.position = position;
m.stamp = stamp;
t.airborne = false;
t.air_time = t.height = 0;
return;
}
const auto dt = static_cast<float>(seconds);
const Vec3 raw{step[0] / dt, step[1] / dt, step[2] / dt};
const Vec3 velocity{m.velocity[0] * 0.5f + raw[0] * 0.5f, m.velocity[1] * 0.5f + raw[1] * 0.5f, m.velocity[2] * 0.5f + raw[2] * 0.5f};
// An on-foot jump: off the board, level at the last tick and now rising faster than stairs
// or a slope can lift a runner.
if (watch.valid && watch.state == addr::no_bail::offboard_physics_state) {
if (s.offboard_grounded && raw[1] > 2.2f && s.boost_factor == 0 && s.offboard_height != 1.0f) {
s.boost_factor = std::sqrt(s.offboard_height);
s.boost_until = now + 250;
s.boost_name = "off-board jump";
}
s.offboard_grounded = std::abs(raw[1]) < 0.6f;
} else {
s.offboard_grounded = false;
}
// The skater's physics says when it is in the air: its states 200-299 are the pop and
// the flight (100s ride the ground, 300 is a wipeout, 500s are off the board).
const bool flying = watch.valid && watch.state >= air_states_first && watch.state < air_states_end;
// Rotation in the air: about the vertical (spins) and of the up axis (flips and rolls).
const Vec3 forward{matrix[8], matrix[9], matrix[10]}, up{matrix[4], matrix[5], matrix[6]};
if (m.airborne && m.axes_valid) {
const float yaw = std::atan2(m.forward[2] * forward[0] - m.forward[0] * forward[2], m.forward[0] * forward[0] + m.forward[2] * forward[2]);
const float degrees = yaw * 180.0f / std::numbers::pi_v<float>;
// A ragdoll or a pose snap turns further in one tick than any trick does: leave those out.
if (std::abs(degrees) < 45.0f) {
m.spin += degrees;
m.spin_rate = std::max(m.spin_rate, std::abs(degrees) / dt);
}
const float cosine = std::clamp(m.up[0] * up[0] + m.up[1] * up[1] + m.up[2] * up[2], -1.0f, 1.0f);
if (const float tumble = std::acos(cosine) * 180.0f / std::numbers::pi_v<float>; tumble < 45.0f) m.flip += tumble;
}
m.forward = forward;
m.up = up;
m.axes_valid = true;
if (!flying && watch.valid) m.ground_state = watch.state;
if (flying && !m.airborne) {
m.spin = m.spin_rate = m.flip = 0;
m.air_state = watch.state;
m.airborne = true;
m.takeoff = m.position;
m.takeoff_velocity = velocity;
m.takeoff_stamp = m.stamp;
m.apex = std::max(m.position[1], position[1]);
}
if (m.airborne) {
m.apex = std::max(m.apex, position[1]);
t.air_time = static_cast<float>(clock_seconds(stamp - m.takeoff_stamp));
t.height = m.apex - m.takeoff[1];
if (!flying)
finish_jump(position, std::sqrt(m.velocity[0] * m.velocity[0] + m.velocity[1] * m.velocity[1] + m.velocity[2] * m.velocity[2]),
stamp);
}
t.airborne = m.airborne;
t.speed = horizontal(velocity);
t.vertical = velocity[1];
t.top_speed = std::max(t.top_speed, t.speed);
m.velocity = velocity;
m.position = position;
m.stamp = stamp;
if (s.logging && s.log) {
s.log << std::format("{:.4f},{:.3f},{:.3f},{:.3f},{:.3f},{:.3f},{:.1f},{},{}\n", clock_seconds(stamp - s.log_start), position[0],
position[1], position[2], t.speed, t.vertical, t.heading, t.airborne ? 1 : 0, t.physics_state);
}
// A bail: back to the selected marker once the skater has had time to fall.
if (watch.valid && (!s.wipeouts_known || watch.wipeouts != s.seen_wipeouts)) {
if (s.wipeouts_known && s.auto_return) {
const bool marked = current_map().markers[static_cast<std::size_t>(s.slot)].set;
if (marked) s.return_at = now + static_cast<std::uint64_t>(s.return_delay * 1000.0f);
say(logging::Level::info, marked ? std::format("Trainer: bail seen; back to marker {} in {:.1f} s.", s.slot + 1, s.return_delay)
: std::format("Trainer: bail seen, but marker {} is empty.", s.slot + 1));
}
s.wipeouts_known = true;
s.seen_wipeouts = watch.wipeouts;
}
}
void set_logging(bool on) {
auto &s = state();
if (s.log.is_open()) s.log.close();
s.logging = false;
if (!on) return;
const auto directory = data_directory() / L"telemetry";
std::error_code error;
std::filesystem::create_directories(directory, error);
std::string name = s.map.empty() ? "unknown" : s.map.substr(s.map.find_last_of('/') + 1);
for (auto &c : name)
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '_' && c != '-') c = '_';
SYSTEMTIME time;
GetLocalTime(&time);
const auto file = directory / std::format("{}-{:04}{:02}{:02}-{:02}{:02}{:02}.csv", name, time.wYear, time.wMonth, time.wDay, time.wHour,
time.wMinute, time.wSecond);
s.log.open(file, std::ios::binary | std::ios::trunc);
if (!s.log) return;
s.log << "seconds,x,y,z,speed_mps,vertical_mps,heading_deg,airborne,physics_state\n";
s.log_start = clock_now();
s.logging = true;
}
void shortcuts() {
auto &s = state();
ControllerInput input;
DingoSDKOverlayReadControllerInput(&input);
const auto buttons = input.available ? input.buttons : 0;
const auto pressed = buttons & ~s.pad_previous;
s.pad_previous = buttons;
if (!s.pad_shortcuts || (buttons & (xinput_lb | xinput_rb)) != (xinput_lb | xinput_rb)) return;
std::string result;
if (pressed & xinput_up) result = command("marker", {"save"});
else if (pressed & xinput_down) result = command("marker", {"go"});
else if (pressed & xinput_left) result = command("slot", {std::to_string((s.slot + static_cast<int>(marker_slots) - 1) % static_cast<int>(marker_slots) + 1)});
else if (pressed & xinput_right) result = command("slot", {std::to_string((s.slot + 1) % static_cast<int>(marker_slots) + 1)});
if (!result.empty()) overlay::notify(overlay::NoticeLevel::info, "TRAINER", result);
}
// ---- self test -------------------------------------------------------------------------
void test_line(bool ok, const std::string &text) {
auto &s = state();
if (!ok) ++s.test.failures;
say(ok ? logging::Level::info : logging::Level::error, std::format("trainer selftest: {} {}", ok ? "OK" : "FAIL", text));
}
void run_selftest(std::uint64_t now) {
auto &s = state();
auto &t = s.test;
if (!t.step || now < t.until) return;
tuning::Values live;
switch (t.step) {
case 1: {
test_line(s.model != nullptr, std::format("game tuning read ({} values, {} curves)", s.model ? s.model->fields.size() : 0,
s.model ? s.model->curve_slots.size() : 0));
std::size_t named{};
if (s.model) named = static_cast<std::size_t>(std::ranges::count_if(s.model->field_names, [](const std::string &n) { return !n.empty(); }));
test_line(s.model && named == s.model->fields.size(), std::format("field names ({} of {})", named, s.model ? s.model->fields.size() : 0));
test_line(s.ready && s.asset, "running tuning asset found");
test_line(s.telemetry.skater, "local skater found");
// The first plain real that is not zero and that the player has not touched.
t.entry = s.entries.size();
for (std::size_t i = 0; i < s.entries.size(); ++i)
if (s.entries[i].kind == Kind::real && !s.entries[i].detail && !s.entries[i].touched && std::abs(s.entries[i].stock) > 0.01) {
t.entry = i;
break;
}
if (!s.ready || t.entry == s.entries.size() || !editable()) {
test_line(false, "no value to write (tuning not ready or locked by a session)");
t.step = 4;
break;
}
auto &e = s.entries[t.entry];
t.before = e.stock;
t.target = e.stock * 1.01;
set_entry(e, t.target);
t.step = 2;
t.until = now + 1200;
break;
}
case 2: {
auto &e = s.entries[t.entry];
const bool read = tuning::read_live(s.base, live);
test_line(read && std::abs(read_field(live.image, e) - static_cast<float>(t.target)) < 1e-4 * std::max(1.0, std::abs(t.target)),
std::format("write and hold {} = {:.5g}", e.id, t.target));
set_entry(e, t.before);
t.step = 3;
t.until = now + 1200;
break;
}
case 3: {
auto &e = s.entries[t.entry];
const bool read = tuning::read_live(s.base, live);
test_line(read && read_field(live.image, e) == static_cast<float>(t.before) && !e.touched, std::format("restore {} = {:.5g}", e.id, t.before));
t.step = 4;
break;
}
case 4: {
if (!s.telemetry.skater) {
test_line(false, "teleport (no skater)");
t.step = 6;
break;
}
t.origin = s.telemetry.position;
const Vec3 target{t.origin[0] + 3.0f, t.origin[1] + 1.0f, t.origin[2]};
const auto result = go_to(target, "a test point");
test_line(!result.starts_with("error"), "teleport request: " + result);
t.origin = target;
t.step = result.starts_with("error") ? 6 : 5;
t.until = now + 700;
break;
}
case 5: {
const auto &p = s.telemetry.position;
// The skater may already be rolling away from where it was put.
const float away = horizontal({p[0] - t.origin[0], 0, p[2] - t.origin[2]});
test_line(s.telemetry.skater && away < 25.0f, std::format("teleport put the skater {:.2f} m from the target", away));
t.step = 6;
t.until = now + 1500;
break;
}
default:
say(t.failures ? logging::Level::error : logging::Level::info,
std::format("trainer selftest: DONE {} failures", t.failures));
t = {};
changed(false);
break;
}
}
void build_view() {
auto &s = state();
s.view_due = false;
auto next = std::make_shared<View>();
next->revision = ++s.revision;
next->ready = s.ready;
next->status = s.status;
next->last = s.last;
next->editable = editable(&next->blocked);
next->rows.reserve(s.entries.size());
for (const auto &e : s.entries) {
int rank{};
std::uint8_t modes{};
const auto friendly = essential_name(e.key, &rank, &modes);
next->rows.push_back({e.id, e.label, e.group, e.kind, e.value, e.stock, e.touched, e.frozen, e.detail, std::string(friendly), rank, modes,
e.used});
if (e.touched) ++next->touched;
if (std::ranges::find(next->groups, e.group) == next->groups.end()) next->groups.push_back(e.group);
}
const auto is_active = [&](std::string_view name) { return std::ranges::find(s.active, name) != s.active.end(); };
next->presets.push_back({"Stock", "Everything back to how this map loaded (frozen values stay).", true, s.active.empty() && !next->touched});
for (const auto &preset : builtin_presets())
next->presets.push_back({std::string(preset.name), std::string(preset.note), true, is_active(preset.name)});
for (const auto &[name, values] : s.user) next->presets.push_back({name, std::format("{} values", values.size()), false, is_active(name)});
next->slot = s.slot;
next->auto_return = s.auto_return;
next->hippy_height = s.hippy_height;
next->nocomply_height = s.nocomply_height;
next->offboard_height = s.offboard_height;
next->boneless_height = s.boneless_height;
next->return_delay = s.return_delay;
next->pad_shortcuts = s.pad_shortcuts;
next->hud = s.hud;
next->hud_jump = s.hud_jump;
next->logging = s.logging;
next->map = s.map;
next->map_note = s.map_file.note;
next->map_preset = s.map_file.preset.empty() ? std::string{} : s.map_file.preset_name.empty() ? "Map preset" : s.map_file.preset_name;
next->spots = s.map_file.spots;
next->open_serial = s.open_serial;
next->open_tab = s.open_tab;
if (const auto found = s.maps.find(s.map); found != s.maps.end()) {
next->markers = found->second.markers;
next->profile_preset = found->second.preset;
}
publish(std::move(next));
}
bool flag(const std::string &text, bool &out) {
const auto value = lower(text);
if (value == "1" || value == "on" || value == "true") { out = true; return true; }
if (value == "0" || value == "off" || value == "false") { out = false; return true; }
return false;
}
std::optional<int> slot_argument(const std::vector<std::string> &arguments, std::size_t index) {
auto &s = state();
if (arguments.size() <= index) return s.slot;
const auto value = number(arguments[index]);
if (!value || *value < 1 || *value > static_cast<double>(marker_slots) || *value != std::floor(*value)) return std::nullopt;
return static_cast<int>(*value) - 1;
}
} // namespace
void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::string &level) noexcept {
try {
auto &s = state();
const auto now = GetTickCount64();
if (!s.loaded) load_store();
s.base = base;
if (const auto key = lower(playing ? level : std::string{}); key != s.map) enter_map(key);
if (playing) {
observe(base, client, now);
shortcuts();
if (s.return_at && now >= s.return_at) {
s.return_at = 0;
const auto &marker = current_map().markers[static_cast<std::size_t>(s.slot)];
if (marker.set) (void)go_to(marker.position, "your marker");
}
} else if (s.telemetry.skater) {
s.telemetry.skater = false;
s.motion.valid = false;
s.entity = 0;
}
(void)start_trick_heights(base);
if (s.class_search_at && now >= s.class_search_at && playing && s.telemetry.skater && !finding_classes()) {
// Until the push class turns up: the skater's own classes arrive a little after it does.
const bool have = class_searches() && class_copies(0) != 0;
s.class_search_at = have || ++s.class_search_tries > 6 ? 0 : now + 15000;
if (!have) find_classes();
}
if (const bool stock = !editable(); stock != s.classes_stock) {
s.classes_stock = stock;
for (auto &e : s.entries)
if (e.class_field >= 0) want_class_value(static_cast<std::size_t>(e.class_field), static_cast<float>(e.touched && !stock ? e.value : e.stock));
}
(void)apply_classes();
// Like the hippy jump's, these are boosts: a session that turns boosts off turns them off.
const bool boosts = !multiplayer_session_active() || session_boosts_allowed();
set_trick_heights(boosts ? s.nocomply_height : 1.0f, boosts ? s.boneless_height : 1.0f);
// The speeds pushes aim for are the push class's (value_links ties them to the tuning's top
// pushing speed, which only gates whether a push may start).
const auto *top_speed = find_entry("physicspush.maxpushablespeed");
// Auto push is the trainer's doing as well: the game's flag reaches only its animation.
const auto *auto_push = find_entry("physicsmode.autopushenabled"), *auto_speed = find_entry("physicspush.maxspeedforautopush");
const bool cruising = boosts && editable() && auto_push && auto_speed && auto_push->touched && auto_push->value != 0;
set_push_speed(client, s.entity, 1.0f, top_speed ? static_cast<float>(top_speed->stock) : 0.0f,
cruising ? static_cast<float>(auto_speed->value) : 0.0f);
for (TrickLaunch launch; take_trick_launch(launch);)
say(logging::Level::info, std::format("Trainer trick: {} launched at {:.2f} m/s up, x{:.2f}.",
launch.trick == Trick::boneless ? "boneless" : "no comply", launch.up_speed, launch.factor));
hold(now);
run_selftest(now);
publish(s.telemetry);
if (s.view_due) build_view();
if (s.save_due && now >= s.next_save) save_store();
} catch (...) {}
}
namespace {
std::string run(std::string_view verb, const std::vector<std::string> &a) {
auto &s = state();
const auto v = lower(verb);
const auto arg = [&](std::size_t i) { return i < a.size() ? a[i] : std::string{}; };
if (v == "status") {
std::size_t touched{}, frozen{};
for (const auto &e : s.entries) { touched += e.touched; frozen += e.frozen; }
return std::format("{} {} values ({} changed, {} frozen). Map: {}. Presets: {}.", s.status, s.entries.size(), touched, frozen,
s.map.empty() ? "none" : s.map, s.active.empty() ? "stock" : std::to_string(s.active.size()) + " applied");
}
if (v == "set" || v == "freeze" || v == "reset") {
if (v == "reset" && lower(arg(0)) == "all") {
const auto count = reset_all();
changed();
return std::format("{} values put back.", count);
}
auto *e = find_entry(arg(0));
if (!e) return "error: no tuning value is called \"" + arg(0) + "\". Try: trainer find <text>";
std::string why;
if (v != "freeze" && !editable(&why)) return "error: " + why;
if (v == "set") {
const auto value = number(arg(1));
if (!value) return "error: " + e->id + " needs a number.";
set_entry(*e, *value);
} else if (v == "freeze") {
if (!flag(arg(1), e->frozen)) return "error: freeze needs 0 or 1.";
remember(*e);
} else {
set_entry(*e, e->stock);
}
changed();
return std::format("{} = {:.6g}{}{}", e->id, e->value, e->touched ? std::format(" (stock {:.6g})", e->stock) : "", e->frozen ? ", frozen" : "");
}
if (v == "find") {
const auto pattern = lower(arg(0));
std::string result;
std::size_t count{};
for (const auto &e : s.entries)
if (matches(e.key, pattern) && ++count <= 12) result += std::format("{}{} = {:.6g}", result.empty() ? "" : "; ", e.id, e.value);
return count ? std::format("{} match: {}{}", count, result, count > 12 ? "; ..." : "") : "Nothing matches.";
}
if (v == "preset") {
const auto action = lower(arg(0));
std::string name;
for (std::size_t i = 1; i < a.size(); ++i) name += (i > 1 ? " " : "") + a[i];
if (name.empty()) return "error: usage: trainer preset apply|remove|save|delete <name>";
if (action == "remove") {
std::string why;
if (!editable(&why)) return "error: " + why;
return remove_preset(name);
}
if (action == "apply") {
std::string why;
if (!editable(&why)) return "error: " + why;
return apply_preset(name);
}
if (action == "save") {
if (name.size() > 48) return "error: that name is too long.";
for (const auto &preset : builtin_presets())
if (lower(preset.name) == lower(name)) return "error: that name belongs to a built-in preset.";
if (lower(name) == "stock" || lower(name) == "map") return "error: that name is reserved.";
std::map<std::string, double, std::less<>> values;
for (const auto &e : s.entries)
if (e.touched) values[e.key] = e.value;
if (values.empty()) return "error: nothing is changed, so there is nothing to save.";
const auto count = values.size();
s.user[name] = std::move(values);
changed();
return std::format("Saved \"{}\" ({} values).", name, count);
}
if (action == "delete") {
if (!s.user.erase(name)) return "error: no preset of yours is called \"" + name + "\".";
std::erase(s.active, name);
changed();
return "Deleted \"" + name + "\".";
}
return "error: usage: trainer preset apply|save|delete <name>";
}
if (v == "slot") {
const auto slot = slot_argument(a, 0);
if (!slot) return std::format("error: slots are 1 to {}.", marker_slots);
s.slot = *slot;
changed();
return std::format("Marker slot {}{}.", s.slot + 1, current_map().markers[static_cast<std::size_t>(s.slot)].set ? "" : " (empty)");
}
if (v == "marker") {
const auto action = lower(arg(0));
const auto slot = slot_argument(a, 1);
if (!slot) return std::format("error: slots are 1 to {}.", marker_slots);
if (s.map.empty()) return "error: load a level first.";
auto &marker = current_map().markers[static_cast<std::size_t>(*slot)];
if (action == "save") {
if (!s.telemetry.skater) return "error: no skater to mark.";
marker = {true, s.telemetry.position};
changed();
return std::format("Marker {} saved at ({:.1f}, {:.1f}, {:.1f}).", *slot + 1, marker.position[0], marker.position[1], marker.position[2]);
}
if (action == "go") {
if (!marker.set) return std::format("error: marker {} is empty.", *slot + 1);
return go_to(marker.position, std::format("marker {}", *slot + 1));
}
if (action == "clear") {
marker = {};
changed();
return std::format("Marker {} cleared.", *slot + 1);
}
return "error: usage: trainer marker save|go|clear [slot]";
}
if (v == "tp") {
const auto x = number(arg(0)), y = number(arg(1)), z = number(arg(2));
if (!x || !y || !z) return "error: usage: trainer tp <x> <y> <z>";
return go_to({static_cast<float>(*x), static_cast<float>(*y), static_cast<float>(*z)}, "that point");
}
if (v == "spot") {
const auto index = number(arg(0));
if (!index || *index < 1 || *index > static_cast<double>(s.map_file.spots.size())) return "error: this map has no such spot.";
const auto &spot = s.map_file.spots[static_cast<std::size_t>(*index) - 1];
return go_to(spot.position, spot.name);
}
if (v == "option") {
const auto name = lower(arg(0));
bool on{};
if (name == "hippy_height" || name == "nocomply_height" || name == "boneless_height" || name == "offboard_height") {
const auto value = number(arg(1));
if (!value) return "error: " + name + " needs a multiplier, 1 = the game's own height.";
(name == "hippy_height" ? s.hippy_height : name == "nocomply_height" ? s.nocomply_height : name == "offboard_height" ? s.offboard_height : s.boneless_height) =
std::clamp(static_cast<float>(*value), height_low, height_high);
} else if (name == "return_delay") {
const auto value = number(arg(1));
if (!value) return "error: return_delay needs seconds.";
s.return_delay = std::clamp(static_cast<float>(*value), 0.0f, 10.0f);
} else if (!flag(arg(1), on)) {
return "error: usage: trainer option hud|hud_jump|auto_return|pad|log 0|1";
} else if (name == "hud") s.hud = on;
else if (name == "hud_jump") s.hud_jump = on;
else if (name == "auto_return") s.auto_return = on;
else if (name == "pad") s.pad_shortcuts = on;
else if (name == "log") {
set_logging(on);
if (on && !s.logging) return "error: the telemetry file could not be created.";
} else return "error: no option is called \"" + name + "\".";
changed();
return name + " = " + arg(1);
}
if (v == "profile") {
const auto action = lower(arg(0));
if (s.map.empty()) return "error: load a level first.";
if (action == "clear") {
current_map().preset.clear();
changed();
return "This map no longer applies a preset on load.";
}
std::string name;
for (std::size_t i = 1; i < a.size(); ++i) name += (i > 1 ? " " : "") + a[i];
if (action != "set" || name.empty()) return "error: usage: trainer profile set <preset> | clear";
current_map().preset = name;
changed();
return "\"" + name + "\" will be applied whenever this map loads.";
}
if (v == "open") {
const auto tab = lower(arg(0));
// The last three are the Tune tab on one of its lists.
const std::array<std::string_view, 7> tabs{"tune", "presets", "practice", "map", "realistic", "fun", "everything"};
const auto found = std::ranges::find(tabs, tab);
if (!tab.empty() && found == tabs.end()) return "error: usage: trainer open [tune|presets|practice|map|realistic|fun|everything]";
s.open_tab = tab.empty() ? 1 : static_cast<int>(found - tabs.begin());
++s.open_serial;
s.view_due = true;
return "Opening the trainer.";
}
if (v == "jumps") {
const auto &j = s.telemetry.last;
if (!j.serial) return "No jump measured yet.";
return std::format("Jump {}: takeoff {:.1f} km/h at {:.1f} deg, air {:.2f} s, height {:.2f} m, distance {:.2f} m, drop {:.2f} m, landing {:.1f} km/h.",
j.serial, j.takeoff_speed * 3.6f, j.takeoff_angle, j.air_time, j.height, j.distance, j.drop, j.landing_speed * 3.6f);
}
if (v == "where") {
if (!s.telemetry.skater) return "error: no skater.";
const auto &p = s.telemetry.position;
return std::format("({:.2f}, {:.2f}, {:.2f}) heading {:.0f}, {:.1f} km/h. Blender: ({:.2f}, {:.2f}, {:.2f})", p[0], p[1], p[2],
s.telemetry.heading, s.telemetry.speed * 3.6f, p[0], -p[2], p[1]);
}
if (v == "classes") {
if (lower(arg(0)) == "find") {
find_classes();
return "Looking for the game's tuning classes.";
}
return "Copies found: " + classes_summary();
}
if (v == "refresh") return tuning::refresh_skater(s.base, s.entity) ? "Skater copy refreshed." : "error: the skater has no cached copy right now.";
if (v == "states") {
std::string result;
for (const auto &[id, ticks] : s.states) result += std::format("{}{}: {}", result.empty() ? "" : ", ", id, ticks);
return result.empty() ? "No physics state seen yet." : "Physics states seen (id: ticks): " + result;
}
if (v == "dump") {
const auto directory = data_directory();
std::error_code error;
std::filesystem::create_directories(directory, error);
std::ofstream file(directory / L"tuning-values.txt", std::ios::binary | std::ios::trunc);
if (!file) return "error: could not write the list.";
for (const auto &e : s.entries)
file << std::format("{}\t0x{:x}\t{}\t{:.6g}\t{:.6g}\n", e.id, e.offset,
e.kind == Kind::real ? "real" : e.kind == Kind::flag ? "flag" : e.kind == Kind::curve ? "curve" :
e.kind == Kind::graph ? "graph" : "int", e.stock, e.value);
return std::format("Wrote {} values to {}.", s.entries.size(), (directory / L"tuning-values.txt").string());
}
if (v == "selftest") {
if (s.test.step) return "The self test is already running.";
s.test = {};
s.test.step = 1;
s.test.until = GetTickCount64() + 500;
return "Self test started; see the log for \"trainer selftest:\" lines.";
}
return "error: trainer status|open|set|freeze|reset|find|preset|slot|marker|tp|spot|option|profile|jumps|where|states|dump|selftest";
}
} // namespace
std::string command(std::string_view verb, const std::vector<std::string> &arguments) {
auto result = run(verb, arguments);
// The menu shows the last answer, except for the ones a dragged slider sends every frame.
if (verb != "set" || result.starts_with("error")) {
auto &s = state();
s.last = result;
s.view_due = true;
}
return result;
}
} // namespace dingosdk::trainer