Merge pull request #65 from Eplisium/trainer-map-waypoint

Trainer: teleport to the pause-map waypoint, onto the surface there
This commit is contained in:
zeex64 authored and GitHub committed 2026-10-08 20:52:22 -05:00
commit 30f000f90d
15 files changed
+479 -4

No files matched your search

+4
View File
@@ -219,6 +219,10 @@ if(DINGOSDK_BUILD_TRAINER_TESTS)
add_executable(dingosdk_trainer_session_tests Extension/Trainer/Test/session_extras_tests.cpp Extension/Trainer/trainer_session.cpp)
target_include_directories(dingosdk_trainer_session_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME trainer_session_extras COMMAND dingosdk_trainer_session_tests)
# Ground-safe arrivals: the downward ray, the landing watch, and the remembered map waypoint.
add_executable(dingosdk_trainer_landing_tests Extension/Trainer/Test/landing_tests.cpp)
target_include_directories(dingosdk_trainer_landing_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME trainer_landing COMMAND dingosdk_trainer_landing_tests)
add_executable(dingosdk_trainer_asset_list Extension/Trainer/Test/asset_list.cpp)
target_include_directories(dingosdk_trainer_asset_list PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_trainer_asset_list PRIVATE dingosdk_game_archives)
@@ -1,6 +1,8 @@
#include "runtime_internal.h"
#include "Extension/Objects/local_placements_runtime.h"
#include "Extension/Objects/ParkEditor/park_editor_runtime.h"
#include "Extension/Objects/ParkEditor/park_editor_surface.h"
#include <cmath>
#include "Extension/World/local_world_layers.h"
namespace dingosdk {
@@ -57,6 +59,29 @@ bool teleport_local_skater(const std::array<float, 3>& position) {
r.position_teleport_at = GetTickCount64();
return true;
}
std::optional<float> local_ground_height(float x, float z, float top, float bottom) {
std::lock_guard lock(local_runtime().native_mutex);
auto& r = placements_runtime();
if (!local_runtime().active || !r.context) return {};
for (const auto v : {x, z, top, bottom})
if (!std::isfinite(v) || std::abs(v) > 1e5f) return {};
if (!(top > bottom)) return {};
static std::uintptr_t api_base{};
static editor::NativeSurfaceApi api;
if (api_base != local_runtime().base) { api = editor::surface_api(local_runtime().base); api_base = local_runtime().base; }
if (!api.ready) return {};
// The client physics world, resolved from the client TLS context like the park editor's
// surface probe (the server context has no world).
std::array<std::uintptr_t, 2> context{};
r.context(context.data());
if (!context[0]) return {};
const auto world = api.world(context[0]);
if (!world) return {};
// Closest hit from the top: the topmost surface (a roof or deck above the street wins).
const auto hit = editor::cast_surface(api, world, {x, top, z}, {x, bottom, z});
if (!hit) return {};
return (*hit)[1];
}
bool teleport_to_local_placed_object(std::string_view map, std::uint64_t token) {
std::lock_guard lock(local_runtime().native_mutex);
auto& r = placements_runtime();
@@ -76,6 +76,10 @@ bool teleport_to_local_placed_object(std::string_view map, std::uint64_t token);
// Teleports the local skater to a world position (sent within 5 s, when the skater can be
// moved). False when the game's teleport is unavailable.
bool teleport_local_skater(const std::array<float, 3>& position);
// The highest collision surface straight down at (x, z) between world heights `top` and
// `bottom`, from the client physics world (the park editor's native ray). Empty when nothing
// is there yet (collision still streaming) or the query is unavailable. Client update thread.
std::optional<float> local_ground_height(float x, float z, float top, float bottom);
// Small allowlisted progression commands, executed on the game update thread.
bool set_local_progression(const std::vector<std::string>& arguments);
// Small ReSkate-owned booleans saved beside the profile under a "ReSkate."
+6
View File
@@ -19,6 +19,12 @@ Changes made when the trainer moved into ReSkate itself.
- **Guests are locked by the session's own switch**, from the moment they join, rather than by
whether the host's tuning had arrived yet. A guest's class values also no longer come back
after a map change in such a session.
- **Teleport to your map waypoint.** PRACTICE > TELEPORT shows the waypoint you placed on the
pause map, with a button to go there (`trainer waypoint [info]`). You land on the topmost
surface there, not at the waypoint's own height, which isn't the ground and could drop you
through the map. If collision there is still loading, a short watch puts you back on the
surface once it arrives. `trainer ground <x> <z>` does the same for any spot.
The map's registry is only read while the TELEPORT card is on screen or the command runs.
## v0.3.0 - 2026-10-04
+11 -3
View File
@@ -9,7 +9,7 @@ telemetry HUD. It ships no game data: the list of values is built at run time fr
| Tab | What you get |
|---|---|
| **TUNE** | Three lists: REALISTIC, FUN and EVERYTHING. Each opens on the dials: every built-in preset as one slider (Ollie height, Push speed, Body flip and spin speed, Bail resistance, Grind lock-on and friction, on-foot jump and sprint, glide, torpedo), where 1 is the game's own and the preset's button jumps to the preset; then the switches (Auto Push, No Speed Wobble, Smooth Surfaces, Long Wheelbase, Never bail), the trick sliders (flip trick speed, no comply, boneless, hippy and off-board jump height) and the values themselves: a short plainly named list, or under EVERYTHING the whole table (the physics tuning's values, one multiplier per curve and graph, and the 314 values of the game's other tuning classes) with search, groups, "only what I changed", locks, your own saved presets and a preset a map applies every time it loads. Changes apply while you skate. Values the game was never found or seen reading are hidden unless you ask for them. **Reset everything**, above the tabs, puts the game back as it shipped. |
| **PRACTICE** | Game speed and pause, five marker slots per map (save / go / clear), return to the marker after a bail, teleport to coordinates, copy your position (game or Blender axes). |
| **PRACTICE** | Game speed and pause, five marker slots per map (save / go / clear), return to the marker after a bail, teleport to coordinates or to the waypoint you placed on the pause map (onto the surface there), copy your position (game or Blender axes). |
| **MAP & HUD** | Speed and air-time HUD, a read-out after every jump (takeoff speed and angle, height, distance, drop, landing speed, spin and flip), telemetry recording to CSV, and whatever the map's author ships for the trainer. |
The HUD, the jump read-out and the controller shortcuts are off until you switch them on (MAP & HUD,
@@ -21,7 +21,7 @@ Controller, once switched on: hold **LB + RB**, then D-pad **up** saves the mark
Everything is also a console command (`~`): `trainer open [tune|practice|map|realistic|fun|everything]`, `trainer status`, `trainer set <id> <value>`,
`trainer find <words>`, `trainer preset apply|remove <name>`, `trainer dial <multiplier> <preset name>`,
`trainer reset <id>|all|tricks|presets|everything`, `trainer marker save|go|clear [slot]`,
`trainer tp <x> <y> <z>`, `trainer where`, `trainer jumps`, `trainer dump`, `trainer selftest`.
`trainer tp <x> <y> <z>`, `trainer waypoint [info]`, `trainer ground <x> <z>`, `trainer where`, `trainer jumps`, `trainer dump`, `trainer selftest`.
## For map makers: `trainer.json`
@@ -73,6 +73,13 @@ It unlocks no cosmetics or entitlements.
snapshots (`trainer_view.cpp`).
- Jumps are measured from the skater's position each client tick: the game's own physics state says when the skater is in the air.
Wipeouts come from the same state, which the no-bail hook already sees.
- The pause map's waypoint is a point of interest in the map's registry (the one ReSkate's party
markers use, `addr::native_party::map_manager`), kind `DingoMapPOIType_Waypoint` (3). The registry
is walked only while the TELEPORT card is on screen or `trainer waypoint` runs (`trainer_waypoint.cpp`).
- A waypoint's height is not the ground. `trainer waypoint` and `trainer ground` cast the park
editor's downward ray through the client physics world and land on the topmost surface; if
collision there is still streaming in, a short watch puts the skater back on it once it arrives.
The decisions are in `trainer_landing.h`, tested by `dingosdk_trainer_landing_tests`.
- Settings, presets, markers: `%LOCALAPPDATA%\ReSkate\trainer\trainer.json`.
## Checking a build
@@ -83,7 +90,8 @@ ReSkateLauncher.exe --no-gui --no-update
```
then read the `trainer selftest:` lines in `logs\ReSkate.log`. `dingosdk_trainer_tuning_dump <Skate
folder>` (CMake option `DINGOSDK_BUILD_TRAINER_TESTS`) lists the tuning values without the game.
folder>` (CMake option `DINGOSDK_BUILD_TRAINER_TESTS`) lists the tuning values without the game;
the same option builds the `trainer_session_extras` and `trainer_landing` tests (`ctest -R trainer`).
## Known limits
+90
View File
@@ -0,0 +1,90 @@
// The decisions behind a ground-safe arrival (trainer_landing.h): where the downward ray
// starts, the watch that puts a skater who sinks back on the surface, and when a pause-map
// waypoint is remembered or forgotten.
#include "Extension/Trainer/trainer_landing.h"
#include <iostream>
#include <string>
namespace {
using namespace dingosdk::trainer::landing;
int failures = 0;
void check(bool condition, const std::string &message) {
if (!condition) { std::cerr << "FAIL: " << message << '\n'; ++failures; }
}
Sample at(std::uint64_t now, Vec3 position, std::optional<float> ground, float vertical = 0.0f, bool may_resend = true) {
return Sample{.now = now, .position = position, .vertical = vertical, .ground = ground, .may_resend = may_resend};
}
} // namespace
int main() {
// ---- the ray --------------------------------------------------------------------------
check(first_ray(12.0f).top == ray_ceiling && first_ray(12.0f).bottom == ray_floor, "a low hint starts at the usual ceiling");
check(first_ray(1800.0f).top == 2300.0f, "a high hint starts 500 above it");
check(std::isfinite(first_ray(std::nanf("")).top), "a broken hint still gives a usable ray");
// A waypoint at Y 12 over a custom map's ground at Y 1939 (seen in game): the first ray
// starts below that ground and misses, so the retry covers what lies above the first one.
const auto retry = retry_ray(first_ray(12.0f));
check(retry && retry->top >= 1939.0f && retry->bottom == ray_ceiling, "a miss is cast again from far above");
check(!retry_ray(Ray{ray_retry_ceiling, 0.0f}), "nothing to retry from the top of the world");
// ---- the landing watch ----------------------------------------------------------------
{
auto w = start(100.0f, 200.0f, 50.5f, 1000);
check(step(w, at(1000, {0, 0, 0}, 50.0f)) == Step::wait, "the teleport gets a moment to arrive");
check(step(w, at(2600, {100.0f, 50.6f, 200.0f}, 50.0f)) == Step::landed, "standing on the surface ends the watch");
check(!w.active, "a landed watch is over");
check(step(w, at(2800, {100.0f, -20.0f, 200.0f}, 50.0f)) == Step::wait, "an ended watch does nothing");
}
{
auto w = start(100.0f, 200.0f, 50.5f, 1000);
check(step(w, at(2600, {100.0f, 30.0f, 200.0f}, 50.0f)) == Step::put_back, "sinking under the surface puts the skater back");
check(w.fixes == 1, "a correction is counted");
check(step(w, at(2700, {100.0f, 30.0f, 200.0f}, 50.0f)) == Step::wait, "the next look waits for the correction to arrive");
check(step(w, at(4200, {100.0f, 50.4f, 200.0f}, 50.0f)) == Step::landed, "then it lands");
}
{
auto w = start(100.0f, 200.0f, 50.5f, 1000);
check(step(w, at(2600, {100.0f, 30.0f, 200.0f}, std::nullopt)) == Step::wait, "no ground yet: collision is streaming, wait");
check(w.active, "still watching");
check(step(w, at(2800, {100.0f, 49.0f, 200.0f}, 50.0f, -8.0f)) == Step::wait, "still falling onto it: not landed yet");
}
{
auto w = start(100.0f, 200.0f, 50.5f, 1000);
check(step(w, at(2600, {0, 0, 0}, 50.0f)) == Step::wait, "not arrived, too early to resend");
check(step(w, at(3600, {0, 0, 0}, 50.0f, 0.0f, false)) == Step::wait, "never resend while the game ignores teleports");
check(step(w, at(3800, {0, 0, 0}, 50.0f)) == Step::resend, "a teleport that never arrived is sent again");
check(w.resends == max_resends - 1, "the resend is counted");
check(step(w, at(4000, {0, 0, 0}, 50.0f)) == Step::wait, "resends are spaced out");
int sent = 1;
for (std::uint64_t now = 4000; now < 60000 && w.active; now += 200)
if (step(w, at(now, {0, 0, 0}, 50.0f)) == Step::resend) ++sent;
check(sent == max_resends, "at most three resends");
check(!w.active, "a teleport that never arrives ends the watch");
}
{
auto w = start(100.0f, 200.0f, 50.5f, 1000);
int fixes = 0;
Step last{};
for (std::uint64_t now = 2600; now < 60000 && w.active; now += 100)
if ((last = step(w, at(now, {100.0f, 0.0f, 200.0f}, 50.0f))) == Step::put_back) ++fixes;
check(fixes == max_fixes && last == Step::ended, "a skater who keeps sinking is corrected six times, then left alone");
}
{
auto w = start(100.0f, 200.0f, 50.5f, 1000);
check(step(w, at(1000 + watch_ms + 1, {100.0f, 70.0f, 200.0f}, 50.0f)) == Step::ended, "the watch times out");
}
// ---- the remembered waypoint ----------------------------------------------------------
const std::optional<Vec3> old = Vec3{1.0f, 2.0f, 3.0f};
const Vec3 fresh{4.0f, 5.0f, 6.0f};
check(remembered(old, Reading{.read = true, .typed = 58, .waypoint = fresh}) == fresh, "a placed waypoint replaces the old one");
check(!remembered(old, Reading{.read = true, .typed = 58, .waypoint = std::nullopt}), "a filled map without one: it was removed");
check(remembered(old, Reading{.read = true, .typed = 0, .waypoint = std::nullopt}) == old, "an empty map says nothing: keep it");
check(remembered(old, Reading{}) == old, "an unreadable registry says nothing: keep it");
check(!remembered(std::nullopt, Reading{}), "nothing seen, nothing remembered");
if (failures) std::cerr << failures << " failure(s)\n";
else std::cout << "landing: all checks passed\n";
return failures ? 1 : 0;
}
+105 -1
View File
@@ -3,6 +3,8 @@
#include "trainer_jump.h"
#include "trainer_presets.h"
#include "trainer_session.h"
#include "trainer_landing.h"
#include "trainer_waypoint.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Platform/path_text.h"
@@ -146,6 +148,14 @@ struct State {
bool wipeouts_known{}; // the wipeout count the session started with was read
int open_tab{};
std::uint32_t pad_previous{};
// The pause map's waypoint. The registry is read only while the player has a use for the
// answer (the TELEPORT card is on screen, or `trainer waypoint`), and the waypoint is
// forgotten on a new level: it belonged to the old one.
std::optional<Vec3> waypoint;
std::string waypoint_detail, waypoint_level;
std::uint64_t waypoint_wanted_until{}, waypoint_next_read{};
// A ground-snapped arrival being watched (trainer_landing.h).
landing::Watch landing;
Motion motion;
Telemetry telemetry;
std::ofstream log;
@@ -930,6 +940,7 @@ void enter_map(const std::string &level) {
s.motion = {};
s.telemetry.last = {};
s.telemetry.best = {};
s.landing = {};
s.telemetry.top_speed = 0;
s.return_at = 0;
s.profile_due = false;
@@ -963,6 +974,73 @@ std::string go_to(const Vec3 &position, const std::string &what) {
return "Teleporting to " + what + ".";
}
// ---- ground-safe arrivals -------------------------------------------------------------
// The topmost collision surface at x, z (trainer_landing.h for the ray), from the client
// physics world. Empty while collision there is still streaming in.
std::optional<float> topmost_ground(float x, float z, float hint_y) {
const auto first = landing::first_ray(hint_y);
if (const auto hit = local_ground_height(x, z, first.top, first.bottom)) return hit;
if (const auto retry = landing::retry_ray(first)) return local_ground_height(x, z, retry->top, retry->bottom);
return std::nullopt;
}
// Teleports onto the surface at x, z rather than to a raw height: a map waypoint's height is
// not the ground, and arriving under it drops the skater through the map.
std::string land_at(float x, float z, float hint_y, const std::string &what) {
auto &s = state();
const auto ground = topmost_ground(x, z, hint_y);
// Collision far away may not be loaded yet: go to the hint's height, and the landing
// watch puts the skater on the surface once it streams in.
const Vec3 to{x, ground ? *ground + landing::stand_height : hint_y + 2.0f, z};
const auto answer = go_to(to, what);
if (answer.starts_with("error")) return answer;
s.landing = landing::start(x, z, to[1], GetTickCount64());
say(logging::Level::info, ground ? std::format("Trainer: landing on the surface at {:.1f}, {:.1f}, {:.1f}.", to[0], to[1], to[2])
: std::format("Trainer: no collision at {:.1f}, {:.1f} yet; going to height {:.1f} and waiting for the ground.", x, z, to[1]));
const auto stem = answer.substr(0, answer.size() - 1);
return ground ? stem + std::format(" (ground at {:.1f}).", *ground)
: stem + " (the ground there is still loading; you'll be put on it when it arrives).";
}
void update_landing(std::uint64_t now) {
auto &s = state();
auto &w = s.landing;
if (!w.active || now < w.next || !s.telemetry.skater) return;
const auto &p = s.telemetry.position;
landing::Sample sample{.now = now, .position = p, .vertical = s.telemetry.vertical, .ground = std::nullopt, .may_resend = true};
const float dx = p[0] - w.x, dz = p[2] - w.z;
if (dx * dx + dz * dz <= landing::arrived_radius * landing::arrived_radius) sample.ground = topmost_ground(p[0], p[2], p[1]);
switch (landing::step(w, sample)) {
case landing::Step::resend: {
const auto ground = topmost_ground(w.x, w.z, w.y);
(void)go_to({w.x, ground ? *ground + landing::stand_height : w.y, w.z}, "the same spot");
say(logging::Level::info, "Trainer: the teleport hadn't arrived; sent it again.");
break;
}
case landing::Step::put_back:
(void)go_to({p[0], *sample.ground + landing::stand_height, p[2]}, "the surface");
say(logging::Level::info, std::format("Trainer: sank to {:.1f} under the surface at {:.1f}; put back on it.", p[1], *sample.ground));
break;
case landing::Step::landed:
say(logging::Level::info, std::format("Trainer: landed at {:.1f}, {:.1f}, {:.1f}.", p[0], p[1], p[2]));
break;
case landing::Step::ended:
say(logging::Level::info, std::format("Trainer: landing watch ended at {:.1f}, {:.1f}, {:.1f} after {} correction(s).", p[0], p[1], p[2], w.fixes));
break;
case landing::Step::wait: break;
}
}
void refresh_waypoint() {
auto &s = state();
const auto read = read_map_waypoint(s.base);
s.waypoint_detail = read.detail;
if (const auto next = landing::remembered(s.waypoint, read.reading); next != s.waypoint) {
s.waypoint = next;
s.view_due = true;
}
}
// ---- telemetry -------------------------------------------------------------------------
std::int64_t clock_now() {
LARGE_INTEGER value;
@@ -1367,6 +1445,8 @@ void build_view() {
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->waypoint_set = s.waypoint.has_value();
if (s.waypoint) next->waypoint = *s.waypoint;
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()) {
@@ -1399,9 +1479,20 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::s
sync_session();
s.boosts = boosts_in_force();
if (const auto key = lower(playing ? level : std::string{}); key != s.map) enter_map(key);
if (const auto key = lower(level); !key.empty() && key != s.waypoint_level) {
s.waypoint_level = key;
s.waypoint.reset();
s.view_due = true;
}
if (take_teleport_card_shown()) s.waypoint_wanted_until = now + 1000;
if (now < s.waypoint_wanted_until && now >= s.waypoint_next_read) {
s.waypoint_next_read = now + 250;
refresh_waypoint();
}
if (playing) {
observe(base, client, now);
shortcuts();
update_landing(now);
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)];
@@ -1603,6 +1694,19 @@ std::string run(std::string_view verb, const std::vector<std::string> &a) {
}
return "error: usage: trainer marker save|go|clear [slot]";
}
if (v == "ground") {
const auto x = number(arg(0)), z = number(arg(1));
if (!x || !z) return "error: usage: trainer ground <x> <z> (teleport to the topmost surface there)";
return land_at(static_cast<float>(*x), static_cast<float>(*z), s.telemetry.position[1], "the ground there");
}
if (v == "waypoint") {
refresh_waypoint();
if (lower(arg(0)) == "info")
return "Map waypoint: " + (s.waypoint ? std::format("{:.1f}, {:.1f}, {:.1f}", (*s.waypoint)[0], (*s.waypoint)[1], (*s.waypoint)[2]) : std::string("none")) +
" (" + s.waypoint_detail + ").";
if (!s.waypoint) return "error: no waypoint on the map. Open the pause map and place one first.";
return land_at((*s.waypoint)[0], (*s.waypoint)[2], (*s.waypoint)[1], "your map waypoint");
}
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>";
@@ -1716,7 +1820,7 @@ std::string run(std::string_view verb, const std::vector<std::string> &a) {
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";
return "error: trainer status|open|set|freeze|reset|find|preset|slot|marker|tp|ground|waypoint|spot|option|profile|jumps|where|states|dump|selftest";
}
} // namespace
+7
View File
@@ -80,6 +80,9 @@ struct View {
// The loaded map and what its author ships for the trainer (Mods/<mod>/trainer.json).
std::string map, map_note, map_preset, profile_preset;
std::vector<Spot> spots;
// The waypoint placed on the game's pause map (trainer_waypoint.h), if any.
bool waypoint_set{};
std::array<float, 3> waypoint{};
// `trainer open <tab>`: each new serial opens the menu on the trainer page at that tab.
std::uint64_t open_serial{};
int open_tab{};
@@ -117,6 +120,10 @@ void publish(const Telemetry &) noexcept;
// for a player who has a use for it: this is one. Any thread; the game thread takes the note.
void note_class_list_shown() noexcept;
bool take_class_list_shown() noexcept;
// The menu is drawing the TELEPORT card, which shows the pause map's waypoint. The map's
// registry is read only while it is. Any thread; the game thread takes the note.
void note_teleport_card_shown() noexcept;
bool take_teleport_card_shown() noexcept;
// Game thread (trainer.cpp).
// Each client tick; `playing` while a local skater can exist, `level` the loaded level asset.
+2
View File
@@ -22,6 +22,8 @@ void register_trainer_commands(Commands &registry) {
{"slot", "Select a marker slot", "<1-5>", false},
{"marker", "Save, go to or clear a marker", "save|go|clear [slot]", false},
{"tp", "Teleport the skater", "<x> <y> <z>", false},
{"waypoint", "Teleport to the waypoint placed on the pause map, onto the surface there", "[info]", false},
{"ground", "Teleport to the topmost surface at a map position", "<x> <z>", false},
{"spot", "Go to one of the map author's spots", "<number>", false},
{"option", "Trainer options", "hud|hud_jump|auto_return|pad|log 0|1, return_delay <seconds>, hippy_height|nocomply_height|boneless_height|offboard_height|flip_speed <x>", true},
{"profile", "The preset this map applies on load", "set <preset>|clear", false},
+107
View File
@@ -0,0 +1,107 @@
#pragma once
// The decisions behind a ground-safe arrival (the pause-map waypoint, `trainer ground`) and
// the remembered waypoint. They have no game in them, so the trainer tests run them on their
// own (Test/landing_tests.cpp); trainer.cpp feeds them positions, times and ray results.
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <optional>
namespace dingosdk::trainer::landing {
using Vec3 = std::array<float, 3>;
// "The topmost surface at x, z" is a downward ray: its closest hit is the highest surface (a
// deck or a roof wins over the street under it). The first ray starts well above the height
// the caller knows of; a custom map can sit higher than that, so a miss is cast once more from
// far above, down to where the first one started.
constexpr float ray_ceiling = 1500.0f, ray_floor = -1500.0f, ray_retry_ceiling = 20000.0f;
// Above the surface: the game's teleport settles the skater from here.
constexpr float stand_height = 0.5f;
struct Ray { float top, bottom; };
inline Ray first_ray(float hint_y) noexcept {
const float hint = std::isfinite(hint_y) ? std::clamp(hint_y, ray_floor, ray_retry_ceiling - 500.0f) : 0.0f;
return {std::max(ray_ceiling, hint + 500.0f), ray_floor};
}
inline std::optional<Ray> retry_ray(const Ray &first) noexcept {
if (first.top >= ray_retry_ceiling) return std::nullopt;
return Ray{ray_retry_ceiling, first.top};
}
// After a ground-snapped teleport, collision at the spot may still be streaming in. The
// skater is watched for a while and put back on the surface if it sinks through it.
constexpr std::uint64_t watch_ms = 12000, first_look_ms = 1500, look_every_ms = 150, after_fix_ms = 1500;
constexpr std::uint64_t resend_every_ms = 2500, resend_extends_ms = 8000;
constexpr int max_fixes = 6, max_resends = 3;
constexpr float arrived_radius = 30.0f, sunk_below = 1.0f, landed_within = 2.5f, settled_speed = 1.5f;
struct Watch {
bool active{};
float x{}, z{}, y{}; // where the skater was sent
std::uint64_t until{}, next{}, resend_at{};
int fixes{}, resends{};
};
inline Watch start(float x, float z, float y, std::uint64_t now) noexcept {
return Watch{.active = true, .x = x, .z = z, .y = y, .until = now + watch_ms, .next = now + first_look_ms,
.resend_at = now + resend_every_ms, .fixes = 0, .resends = max_resends};
}
struct Sample {
std::uint64_t now{};
Vec3 position{};
float vertical{}; // vertical speed, up is positive
std::optional<float> ground; // the topmost surface at the skater's x, z, if known yet
bool may_resend{true}; // the game will act on a teleport now (not paused on a menu)
};
enum class Step {
wait, // nothing to do yet
resend, // the teleport never arrived: send it to the watch's x, y, z again
put_back, // sank under the surface: teleport to the ground at the skater's x, z
landed, // standing on the surface; the watch is over
ended, // gave up (timed out or corrected too often); the watch is over
};
inline Step step(Watch &w, const Sample &s) noexcept {
if (!w.active || s.now < w.next) return Step::wait;
w.next = s.now + look_every_ms;
if (s.now > w.until || w.fixes >= max_fixes) {
w.active = false;
return Step::ended;
}
const float dx = s.position[0] - w.x, dz = s.position[2] - w.z;
if (dx * dx + dz * dz > arrived_radius * arrived_radius) {
if (w.resends > 0 && s.may_resend && s.now >= w.resend_at) {
--w.resends;
w.resend_at = s.now + resend_every_ms;
w.until = std::max(w.until, s.now + resend_extends_ms);
return Step::resend;
}
return Step::wait;
}
if (!s.ground) return Step::wait; // collision still streaming in
if (s.position[1] < *s.ground - sunk_below) {
++w.fixes;
w.next = s.now + after_fix_ms;
return Step::put_back;
}
if (std::abs(s.position[1] - *s.ground) < landed_within && std::abs(s.vertical) < settled_speed) {
w.active = false;
return Step::landed;
}
return Step::wait;
}
// One read of the pause map's point-of-interest registry.
struct Reading {
bool read{}; // the registry was found and walked
unsigned typed{}; // map POIs whose kind was read (0: the map has not built them)
std::optional<Vec3> waypoint; // the player's waypoint, if one is placed
};
// What to remember after a read. A registry the map has filled but without a waypoint means
// the player removed it; one that is missing, unreadable or empty says nothing, so the last
// waypoint seen is kept.
inline std::optional<Vec3> remembered(const std::optional<Vec3> &before, const Reading &r) noexcept {
if (r.waypoint) return r.waypoint;
if (r.read && r.typed > 0) return std::nullopt;
return before;
}
} // namespace dingosdk::trainer::landing
+7
View File
@@ -470,6 +470,13 @@ void practice_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callba
end_card();
begin_card(menu, "teleport", "TELEPORT");
// The waypoint line is why the map's registry is read at all: only while this is drawn.
trainer::note_teleport_card_shown();
info(menu, "Map waypoint", view.waypoint_set ? std::format("{:.1f}, {:.1f}, {:.1f}", view.waypoint[0], view.waypoint[1], view.waypoint[2])
: "none - place one on the pause map");
ImGui::BeginDisabled(!view.waypoint_set || !telemetry.skater);
if (ImGui::Button("Go to map waypoint")) trainer_command(menu, callbacks, "waypoint");
ImGui::EndDisabled();
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");
+3
View File
@@ -11,6 +11,7 @@ struct Feed {
std::shared_ptr<const View> view = std::make_shared<const View>();
Telemetry telemetry;
std::atomic<bool> class_list_shown{};
std::atomic<bool> teleport_card_shown{};
};
Feed &feed() {
static auto *value = new Feed;
@@ -40,4 +41,6 @@ void publish(const Telemetry &next) noexcept {
}
void note_class_list_shown() noexcept { feed().class_list_shown.store(true, std::memory_order_release); }
bool take_class_list_shown() noexcept { return feed().class_list_shown.exchange(false, std::memory_order_acq_rel); }
void note_teleport_card_shown() noexcept { feed().teleport_card_shown.store(true, std::memory_order_release); }
bool take_teleport_card_shown() noexcept { return feed().teleport_card_shown.exchange(false, std::memory_order_acq_rel); }
} // namespace dingosdk::trainer
+91
View File
@@ -0,0 +1,91 @@
#include "trainer_waypoint.h"
#include "Engine/Core/Platform/memory.h"
#include "Engine/Game/Abi/native_data.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/native_party.h"
#include <cmath>
#include <format>
#include <map>
// The pause map keeps every point of interest in one registry (the map POI manager, the same
// one ReSkate's party markers register into: native_party_hooks.cpp update_map_markers). A
// waypoint the player places with the map cursor is a POI whose kind (MapObjectData @404) is
// DingoMapPOIType_Waypoint (3); its world transform is the matrix at @0.
namespace dingosdk::trainer {
namespace {
namespace party = addr::native_party;
using Address = std::uintptr_t;
using Handle = std::uint64_t;
constexpr std::uint32_t kind_waypoint = 3;
constexpr std::uint32_t kind_offset = 404;
constexpr std::uint32_t max_buckets = 65536, max_entries = 20000;
template<class T> bool get(Address address, T &value) { return address && memory::peek(address, value); }
Address model_type(Address base, Address manager, Handle handle) {
const auto record = reinterpret_cast<Address (*)(Address, Handle, std::uint8_t)>(base + party::model_record)(manager, handle, 0);
if (!record) return 0;
return reinterpret_cast<Address (*)(Address, Handle, Address, std::uint8_t)>(base + party::model_type)(manager, handle, record, 0);
}
bool sane(const std::array<float, 16> &world) {
for (const auto i : {12, 13, 14})
if (!std::isfinite(world[i]) || std::abs(world[i]) > 1e6f) return false;
return true;
}
MapWaypointRead walk(Address base) {
MapWaypointRead out;
Address map{}, vtable{}, manager{};
if (!get(base + party::map_manager, map) || !map) { out.detail = "no map POI manager"; return out; }
if (!get(map, vtable) || vtable != base + party::map_manager_vtable) { out.detail = "map POI manager type differs"; return out; }
if (!get(map + 0x18, manager) || !manager) { out.detail = "no map model manager"; return out; }
auto &n = game::native_data().models;
if (!n.value || !n.lock || !n.unlock) { out.detail = "native model API unavailable"; return out; }
game::ModelWriteLock lock(manager);
Address table{};
std::uint32_t buckets{}, count{};
if (!get(map + 0x48, table) || !get(map + 0x50, buckets) || !get(map + 0x54, count) || !table ||
buckets > max_buckets || count > max_entries) { out.detail = "map POI registry unreadable"; return out; }
const auto poi_type = base + party::map_poi_type;
std::map<std::uint32_t, unsigned> kinds;
unsigned visited{};
for (std::uint32_t i = 0; i < buckets && visited < count; ++i) {
Address node{};
if (!get(table + i * 8, node)) break;
while (node && visited++ < count) {
Handle handle{};
if (!get(node, handle)) break;
if (handle && model_type(base, manager, handle) == poi_type) {
if (const auto value = n.value(manager, handle, 0, 0)) {
std::uint32_t kind{};
if (get(value + kind_offset, kind)) {
++kinds[kind];
++out.reading.typed;
std::array<float, 16> world{};
if (kind == kind_waypoint && !out.reading.waypoint && get(value, world) && sane(world))
out.reading.waypoint = landing::Vec3{world[12], world[13], world[14]};
}
}
}
if (!get(node + 8, node)) break;
}
}
out.reading.read = true;
out.detail = std::format("{} map POIs", visited);
for (const auto &[kind, number] : kinds) out.detail += std::format(", kind {} x{}", kind, number);
return out;
}
} // namespace
MapWaypointRead read_map_waypoint(std::uintptr_t base) noexcept {
if (!base) return {};
try {
return walk(base);
} catch (const std::exception &e) {
return MapWaypointRead{.reading = {}, .detail = e.what()};
} catch (...) {
return MapWaypointRead{.reading = {}, .detail = "map POI walk failed"};
}
}
} // namespace dingosdk::trainer
+16
View File
@@ -0,0 +1,16 @@
#pragma once
// The waypoint the player placed on the pause map (DingoMapPOIType_Waypoint), read from the
// map's point-of-interest registry so the trainer can teleport to it. Game thread only.
#include "trainer_landing.h"
#include <cstdint>
#include <string>
namespace dingosdk::trainer {
struct MapWaypointRead {
landing::Reading reading;
std::string detail; // what the walk saw, for `trainer waypoint info` and the log
};
// One walk of the registry under the map model manager's lock. Cheap, but it walks every
// POI, so the trainer only calls it while the player has a use for the answer.
MapWaypointRead read_map_waypoint(std::uintptr_t base) noexcept;
} // namespace dingosdk::trainer
+1
View File
@@ -116,6 +116,7 @@ add_library(dingosdk_runtime SHARED
Extension/Trainer/trainer_classes.cpp
Extension/Trainer/trainer_commands.cpp
Extension/Trainer/trainer_session.cpp
Extension/Trainer/trainer_waypoint.cpp
Extension/Skater/offboard_flight.cpp
Extension/Skater/camera_observer.cpp
Extension/Boot/offline_boot.cpp