mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 06:05:15 +02:00
As in skate. 3: when the local skater bails, the bones it hurt show over the world through the body, yellow where a hit bruised one and red where one broke it, with the bail's Meat card in the bottom left corner in the place of skate.'s own HUD there: its time, hits (head hits double, vehicles half again), broken bones, road rash, airtime, fall, top speed and rotations, each scoring, and the map's best. A bone breaking pulses the screen's edges red and, in single player, slows the game down a moment. Off until switched on, as in skate.: in skate.'s menu (Custom Stuff > Player) or with `hallofmeat 0|1`, saved with the profile. Everything it draws is skate.'s own, read from the installed game: its skeleton mesh (the Dem Bones costume) posed with the renderer's own skinning matrices, its scoring icons, the THRASHER wordmark and its UI brush strokes. Nothing is shipped. How it works: No Bail already hooks the skeleton response, which runs once a physics step right after the body contacts; it now hands that step on, with the local skater it follows, but only while Hall of Meat is on. Each step reads what every ragdoll body hit and how hard, and whether the skater is a ragdoll; the bail runs on the game's own clock so slow motion measures what the body did. The feature's own hooks, on the renderer's skinned draw packets and main view and on the UI model's write, are prepared at startup and switched on only with the feature, and off again once the HUD corner and the game's speed are given back: switched off, the game never calls them and the physics step pays nothing. The skeleton mesh is read the first time it is on; only the images are read at startup, since the overlay builds its atlas once. The renderer's picture of the skater is read only while a bail shows. The menu's switch reads the overlay model like the others. Every address is in the build table with a fingerprint checked before use. Extension/HallOfMeat/README.md describes the parts; its tests build with DINGOSDK_BUILD_HALL_OF_MEAT_TESTS. The slow motion changes SimulationTime's rate, frame cap and time scale as a session override and steps the local skater along, since skate. sets a skater's step only when it builds its core. It runs the same at every break, but never faster than the game ran: with the trainer's game speed below 1 it only takes the part that is slower still. Afterwards it writes back what it found, each setting only while it still holds what the slow motion wrote, so the trainer's speed comes back as it was; a setting changed from elsewhere meanwhile is left as it is and ends that slow motion. named_setting_value reads a setting's value for it. Tuned in play: a bruise from 7 m/s and a break from 12 m/s along the contact normal (raised twice: a hard fall on flat ground broke bones), a break worth ten of the lightest hits (a heavy bail's hits and breaks outweighed the rest of the card) and a fall half of skate. 3's rate. Fix: the overlay now draws in the back buffer's pixels, not the window's, so it lines up on displays Windows scales (1920x1080 borderless on 4K). The skater's render hooks, its skeleton and its body maps were first found by gBGYo's ReSkate fork (github.com/gBGYo/ReSkate). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1056 lines
62 KiB
C++
1056 lines
62 KiB
C++
#include "runtime_internal.h"
|
|
#include "Extension/Customization/developer_hoodie.h"
|
|
#include "Extension/Customization/developer_board.h"
|
|
#include "Extension/Assets/live_mods.h"
|
|
#include "Engine/Core/Log/logging.h"
|
|
#include "Engine/Core/Profiling/profiler.h"
|
|
#include "Extension/Settings/job_spin.h"
|
|
#include "Engine/Game/World/client_state.h"
|
|
#include "Extension/HallOfMeat/hall_of_meat.h"
|
|
#include "Extension/Skater/camera_observer.h"
|
|
#include "Extension/UI/NativeMenu/native_menu.h"
|
|
#include "Extension/Multiplayer/Hud/native_party.h"
|
|
#include "Extension/Multiplayer/Hud/custom_nametags.h"
|
|
#include "Extension/Multiplayer/developer_identity.h"
|
|
#include "Extension/Multiplayer/Hud/follow_camera.h"
|
|
#include "Engine/Game/UI/game_view.h"
|
|
#include "Extension/Multiplayer/Steam/steam_friend_join.h"
|
|
#include "Extension/Objects/network_object_runtime.h"
|
|
#include "Extension/Profile/local_profile_runtime.h"
|
|
#include "Extension/Progression/fixed_stop_entitlement_provider.h"
|
|
#include "Extension/Progression/mission_progression_override.h"
|
|
#include "Extension/Progression/neighborhood_unlock_override.h"
|
|
#include "Extension/Settings/gameplay_settings_override.h"
|
|
#include "Extension/Settings/named_settings.h"
|
|
#include "Extension/Skater/ai_skaters.h"
|
|
#include "Extension/Skater/client_source_spawn.h"
|
|
#include "Extension/Skater/skater_slot_override.h"
|
|
#include "Extension/Throwdowns/native_throwdowns.h"
|
|
#include "Extension/Trainer/trainer.h"
|
|
#include "Extension/World/level_loading.h"
|
|
#include "Extension/World/loading_screen.h"
|
|
#include <dxgi.h>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cctype>
|
|
#include <chrono>
|
|
#include <iomanip>
|
|
#include <optional>
|
|
#include <sstream>
|
|
#include <thread>
|
|
|
|
namespace dingosdk::runtime::detail {
|
|
namespace {
|
|
using HostedSelector = bool (*)(const char*);
|
|
const char* state_name(DWORD state) { return dingosdk::client_state_name(state); }
|
|
// What update_model learned about this tick's client, reused by the rest of the
|
|
// tick instead of reading the same native chains again.
|
|
struct TickState {
|
|
bool valid{};
|
|
DWORD state{}, game_type{};
|
|
std::optional<bool> context; // native_context
|
|
std::optional<const char*> camera_issue; // camera_probe_unavailable_reason, nullptr when ready
|
|
};
|
|
void refresh_interactive_model(std::uintptr_t client, DWORD state, DWORD game_type) {
|
|
auto& r = runtime();
|
|
auto world = dingosdk::local_profile_world_layers();
|
|
bool ready{};
|
|
{
|
|
std::lock_guard lock(r.mutex);
|
|
ready = !r.observer_failed && !r.requests.loading() && r.model.can_queue_load &&
|
|
state == r.previous_state && (state == 13 || state == 21) && world.ready;
|
|
}
|
|
const auto map = ready && native_context(r.base, client, game_type) ? world.map : dingosdk::WorldMap::none;
|
|
// Commands already run on the verified client thread. Apply their settings
|
|
// and publish the result now, without waiting for the 500 ms catalog scan.
|
|
dingosdk::update_local_world_controls(map);
|
|
dingosdk::update_local_graphics_controls();
|
|
auto controls = dingosdk::local_profile_world_controls();
|
|
auto graphics = dingosdk::local_profile_graphics_controls();
|
|
auto parks = dingosdk::local_profile_parks();
|
|
auto progression = dingosdk::local_profile_progression();
|
|
auto player_card = dingosdk::local_profile_player_card();
|
|
auto object_persistence = dingosdk::local_profile_object_persistence();
|
|
auto editor = dingosdk::local_park_editor();
|
|
auto bindings = dingosdk::local_profile_controller_bindings();
|
|
const auto missions = dingosdk::local_profile_missions();
|
|
std::vector<dingosdk::overlay::MissionRow> rows;
|
|
for (const auto& row : missions.rows) rows.push_back({row.id, row.group, row.completed});
|
|
std::lock_guard lock(r.mutex);
|
|
r.model.world = std::move(world);
|
|
r.model.world_controls = std::move(controls);
|
|
r.model.graphics = std::move(graphics);
|
|
r.model.parks = std::move(parks);
|
|
r.model.progression = std::move(progression);
|
|
r.model.player_card = std::move(player_card);
|
|
r.model.object_persistence = std::move(object_persistence);
|
|
r.model.editor = std::move(editor);
|
|
r.model.bindings = std::move(bindings);
|
|
r.model.missions_available = missions.available;
|
|
r.model.mission_feedback = missions.feedback;
|
|
r.model.missions = std::move(rows);
|
|
++r.model_revision;
|
|
}
|
|
// Engine settings that cost simulation time for nothing here, set once through the named
|
|
// setter (retried until the settings registry answers):
|
|
// - telemetry and the performance tracker only feed EA's backend, absent offline;
|
|
// - the world-transform update, which grows with every remote player's entities, is split
|
|
// over more jobs than Game.cfg's 2.
|
|
void apply_performance_settings() {
|
|
static bool applied = false;
|
|
static ULONGLONG next_attempt = 0;
|
|
if (applied) return;
|
|
const auto now = GetTickCount64();
|
|
if (now < next_attempt) return;
|
|
next_attempt = now + 500;
|
|
try {
|
|
const auto jobs = std::to_string(std::clamp(std::thread::hardware_concurrency() / 2, 2u, 8u));
|
|
const std::array<std::pair<const char*, std::string>, 7> settings{{
|
|
{"DingoTelemetry.Enable", "0"},
|
|
{"DingoTelemetry.EnablePlayerTickEvents", "0"},
|
|
{"DingoTelemetry.EnablePerformanceEvents", "0"},
|
|
{"DingoTelemetry.EnableCPUBenchmark", "0"},
|
|
{"PerformanceTracker.Enabled", "0"},
|
|
{"PerformanceTracker.JuiceLogPerformance", "0"},
|
|
{"EcsWorldTransform.ParallelWorldTransformUpdateJobCount", jobs},
|
|
}};
|
|
bool pending = false;
|
|
std::string results;
|
|
for (const auto& [name, value] : settings) {
|
|
const auto result = dingosdk::change_named_setting(name, value, false);
|
|
pending = pending || result.starts_with("error: ");
|
|
results += (results.empty() ? "" : " | ") + result;
|
|
}
|
|
if (!pending) {
|
|
applied = true;
|
|
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::runtime,
|
|
"Performance settings: {}", results);
|
|
}
|
|
} catch (...) {}
|
|
}
|
|
// The mesh streaming pool holds every render mesh the world has loaded, in video memory.
|
|
// GraphicsPC.lua fixes it at 532960 KB (520 MB) on every PC quality level, sized for San Van's
|
|
// streamed cells; a big custom map's meshes do not fit, so its level either never finishes
|
|
// loading or waits out the transition's 180 s world-quality timeout
|
|
// (MeshStreamingLoadStateProvider never reports high) and spawns the skater invisible. The pools
|
|
// are re-created from this setting at every level start (FUN_14507a2d0), so raising it before a
|
|
// load is enough; the quality script sets it again whenever graphics settings are applied, so it
|
|
// is kept here. The engine turns it into bytes in 32 bits (PoolSize << 10, plus
|
|
// PoolHeadroomSize), so the combined allocation must remain below 4 GiB.
|
|
// How far it is raised depends on the card: 1.0.0 set 3.5 GiB on every card, the likely cause
|
|
// of older cards resetting the device while loading (DXGI_ERROR_DEVICE_RESET in
|
|
// gameRendBeginFrame on an RX 580, 2026-10-03). A quarter of the card's dedicated memory, from
|
|
// the game's own 520 MB up to 3.5 GiB (from 16 GB cards up); cards of 6 GB or less keep the
|
|
// game's value. Cards of up to 12 GB stop at 2 GiB: a quarter was 3 GiB of a 12 GB card, and
|
|
// with a big map and texture mods on top the game ran out of video memory (E_OUTOFMEMORY from
|
|
// CreateCommittedResource on an RTX 4070 SUPER, 2026-10-04).
|
|
namespace mesh_pool {
|
|
constexpr std::uint32_t stock_kb = 532960, most_kb = 3584u * 1024u, most_to_12gb_kb = 2048u * 1024u;
|
|
static_assert((std::uint64_t{most_kb} + 24576u) * 1024u <= 0xffffffffull);
|
|
struct Card { std::uint64_t memory{}; std::string name; };
|
|
// The hardware adapter with the most dedicated memory: the one the game renders on (a laptop's
|
|
// integrated GPU has little or none). The game's own dxgi.dll, so nothing new is loaded.
|
|
Card largest_card() {
|
|
Card card;
|
|
const auto module = GetModuleHandleW(L"dxgi.dll");
|
|
using Create = HRESULT(WINAPI*)(REFIID, void**);
|
|
const auto create = module ? reinterpret_cast<Create>(GetProcAddress(module, "CreateDXGIFactory1")) : nullptr;
|
|
IDXGIFactory1* factory{};
|
|
if (!create || FAILED(create(__uuidof(IDXGIFactory1), reinterpret_cast<void**>(&factory))) || !factory) return card;
|
|
IDXGIAdapter1* adapter{};
|
|
for (UINT i = 0; factory->EnumAdapters1(i, &adapter) != DXGI_ERROR_NOT_FOUND; ++i) {
|
|
DXGI_ADAPTER_DESC1 desc{};
|
|
if (SUCCEEDED(adapter->GetDesc1(&desc)) && !(desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) &&
|
|
desc.DedicatedVideoMemory > card.memory) {
|
|
card.memory = desc.DedicatedVideoMemory;
|
|
card.name.clear();
|
|
for (const auto* c = desc.Description; *c; ++c) card.name.push_back(*c < 128 ? static_cast<char>(*c) : '?');
|
|
}
|
|
adapter->Release();
|
|
}
|
|
factory->Release();
|
|
return card;
|
|
}
|
|
// KiB for the pool on this card, or 0 to keep the game's own value.
|
|
std::uint32_t size_kb(std::uint64_t memory) {
|
|
// Cards report a little under or over their nominal size.
|
|
constexpr std::uint64_t six_gib = 6ull << 30, twelve_gib = 12ull << 30, slack = 256ull << 20;
|
|
if (memory <= six_gib + slack) return 0;
|
|
const auto most = memory <= twelve_gib + slack ? most_to_12gb_kb : most_kb;
|
|
return static_cast<std::uint32_t>(std::clamp<std::uint64_t>(memory / 4 / 1024, stock_kb, most));
|
|
}
|
|
}
|
|
void apply_mesh_streaming_pool() {
|
|
static const auto card = mesh_pool::largest_card();
|
|
static const auto pool_kb = mesh_pool::size_kb(card.memory);
|
|
static bool reported = false;
|
|
if (!reported) {
|
|
reported = true;
|
|
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::runtime,
|
|
"Mesh streaming pool: {} for {} ({:.1f} GB of video memory).",
|
|
pool_kb ? std::format("{} MB", pool_kb / 1024) : std::string("the game's own 520 MB"),
|
|
card.name.empty() ? std::string("an unknown card") : card.name, static_cast<double>(card.memory) / (1ull << 30));
|
|
}
|
|
if (!pool_kb) return;
|
|
static ULONGLONG next_check = 0;
|
|
static std::string last_result;
|
|
const auto now = GetTickCount64();
|
|
if (now < next_check) return;
|
|
next_check = now + 2000;
|
|
try {
|
|
const auto result = dingosdk::change_named_setting("MeshStreaming.PoolSize", std::to_string(pool_kb), false);
|
|
if (result.starts_with("error: ")) { next_check = now + 500; return; }
|
|
if (result != last_result && !result.ends_with("(unchanged)"))
|
|
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::runtime,
|
|
"Mesh streaming pool raised for big custom maps (applies from the next level load): {}", result);
|
|
last_result = result;
|
|
} catch (...) {}
|
|
}
|
|
void apply_throwdown_modes() {
|
|
// The throwdowner flow picks between the mode-select page and straight Jam
|
|
// placement by reading these by name (GetBoolSetting). Retail receives them
|
|
// from live config; S.K.A.T.E. ships off in the native constructor. Go
|
|
// through the named setter so registered setting listeners are notified.
|
|
static bool applied = false;
|
|
static ULONGLONG next_attempt = 0;
|
|
static std::string last_result;
|
|
if (applied) return;
|
|
const auto now = GetTickCount64();
|
|
if (now < next_attempt) return;
|
|
next_attempt = now + 500;
|
|
try {
|
|
bool pending = false;
|
|
std::string results;
|
|
// EnableAdvancedParameters adds the host's Customize button and the
|
|
// parameter panel (players, timer, privacy, scored actions) to the
|
|
// throwdown details page; it also ships off.
|
|
for (const auto* name : {"DingoThrowdowns.EnableSpotBattle", "DingoThrowdowns.EnableSKATE",
|
|
"DingoThrowdowns.EnableAdvancedParameters"}) {
|
|
const auto result = dingosdk::change_named_setting(name, "1", false);
|
|
pending = pending || result.starts_with("error: ");
|
|
results += (results.empty() ? "" : " | ") + result;
|
|
}
|
|
{
|
|
// Local play has nobody to wait for: with the single-player queue
|
|
// enabled (native_throwdowns.cpp) the 120 s join countdown would only
|
|
// delay the host. Revisit when throwdowns are relayed to other players.
|
|
const auto result = dingosdk::change_named_setting("DingoThrowdowns.QueueStartTimer", "1", false);
|
|
pending = pending || result.starts_with("error: ");
|
|
results += " | " + result;
|
|
}
|
|
if (results != last_result) {
|
|
last_result = results;
|
|
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::progression,
|
|
"Throwdown modes: {}", results);
|
|
}
|
|
applied = !pending;
|
|
} catch (...) {}
|
|
}
|
|
void update_model(std::uintptr_t client, TickState& frame) {
|
|
auto& r = runtime();
|
|
std::uintptr_t vtable{};
|
|
DWORD state{}, game_type{};
|
|
// Every client tick: peeked, not read (three ReadProcessMemory calls a tick, profiled 2026-10-02).
|
|
if (!memory::peek(client, vtable) || vtable != r.base + engine::client_vtable || client > highest - 0x2a0 ||
|
|
!memory::peek(client + 0xc4, state) || state > 26 || !memory::peek(client + 0xc0, game_type) || game_type > 3) return;
|
|
DWORD no_thread{};
|
|
r.engine_thread.compare_exchange_strong(no_thread, GetCurrentThreadId());
|
|
if (r.engine_thread.load() != GetCurrentThreadId()) return;
|
|
frame.valid = true;
|
|
frame.state = state;
|
|
frame.game_type = game_type;
|
|
// Some two dozen native reads: one result serves the whole tick.
|
|
const auto native_context_ready = [&] {
|
|
if (!frame.context) frame.context = native_context(r.base, client, game_type);
|
|
return *frame.context;
|
|
};
|
|
// Engine settings are only shown by the menu and console; refresh them
|
|
// quickly (and hand them over) only while one of them is reading.
|
|
const auto settings_now = GetTickCount64();
|
|
const bool settings_wanted = settings_now < r.named_settings_wanted_until.load(std::memory_order_relaxed);
|
|
dingosdk::refresh_named_settings(settings_wanted);
|
|
apply_throwdown_modes();
|
|
apply_performance_settings();
|
|
dingosdk::job_spin::apply_default();
|
|
apply_mesh_streaming_pool();
|
|
dingosdk::multiplayer::apply_throwdown_strings(r.base);
|
|
const bool named_context_ready = (state == 13 || state == 21) && native_context_ready();
|
|
{
|
|
std::lock_guard lock(r.mutex);
|
|
r.named_context_ready = named_context_ready;
|
|
}
|
|
if (settings_wanted && settings_now >= r.next_named_settings_publish) {
|
|
r.next_named_settings_publish = settings_now + 250;
|
|
publish_named_settings(r);
|
|
}
|
|
// RESKATE_STARTUP_COMMANDS="load skate1map;noclip 1" (development): console commands
|
|
// queued in order once the world is up and can accept them, so a build can be exercised
|
|
// without anyone driving the overlay. A load waits for its destination to be listed.
|
|
{
|
|
static std::optional<std::vector<std::string>> startup_commands;
|
|
if (!startup_commands) {
|
|
startup_commands.emplace();
|
|
std::wstring value(4096, L'\0');
|
|
const auto length = GetEnvironmentVariableW(L"RESKATE_STARTUP_COMMANDS", value.data(), 4096);
|
|
if (length && length < 4096) {
|
|
std::string narrow;
|
|
for (const auto character : value.substr(0, length))
|
|
narrow.push_back(character < 128 ? static_cast<char>(character) : '?');
|
|
for (std::size_t start = 0; start <= narrow.size();) {
|
|
auto end = narrow.find(';', start);
|
|
if (end == std::string::npos) end = narrow.size();
|
|
auto piece = narrow.substr(start, end - start);
|
|
const auto first = piece.find_first_not_of(" \t");
|
|
const auto last = piece.find_last_not_of(" \t");
|
|
if (first != std::string::npos) startup_commands->push_back(piece.substr(first, last - first + 1));
|
|
start = end + 1;
|
|
}
|
|
}
|
|
}
|
|
if (!startup_commands->empty() && named_context_ready) {
|
|
std::lock_guard lock(r.mutex);
|
|
const auto& text = startup_commands->front();
|
|
const auto lower = [](std::string value) {
|
|
for (auto& character : value) character = static_cast<char>(std::tolower(static_cast<unsigned char>(character)));
|
|
return value;
|
|
};
|
|
// "wait <seconds>" pauses the list, e.g. to let a multiplayer join finish.
|
|
static std::optional<std::chrono::steady_clock::time_point> wait_until;
|
|
const bool wait = lower(text).starts_with("wait ");
|
|
if (wait) {
|
|
const auto now = std::chrono::steady_clock::now();
|
|
if (!wait_until) wait_until = now + std::chrono::milliseconds(static_cast<int>(1000 * std::atof(text.c_str() + 5)));
|
|
if (now >= *wait_until) {
|
|
wait_until.reset();
|
|
startup_commands->erase(startup_commands->begin());
|
|
}
|
|
}
|
|
bool ready = !wait && r.model.can_queue_load && !r.requests.loading();
|
|
if (ready && lower(text).starts_with("load ")) {
|
|
const auto wanted = lower(text.substr(5, text.find(' ', 5) == std::string::npos ? std::string::npos : text.find(' ', 5) - 5));
|
|
ready = std::ranges::any_of(r.model.levels, [&](const dingosdk::overlay::Level& level) {
|
|
return level.can_load && (lower(level.asset).find(wanted) != std::string::npos ||
|
|
lower(level.display_name) == wanted);
|
|
});
|
|
}
|
|
if (ready && r.requests.enqueue(ConsoleRequest{text}, request_context(r), GetCurrentThreadId())) {
|
|
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::level,
|
|
"Startup command queued: {}", text);
|
|
startup_commands->erase(startup_commands->begin());
|
|
}
|
|
}
|
|
}
|
|
std::optional<ConsoleRequest> console_command;
|
|
{
|
|
std::lock_guard lock(r.mutex);
|
|
console_command = r.requests.take<ConsoleRequest>(GetCurrentThreadId());
|
|
}
|
|
if (console_command) {
|
|
ScheduledWorkScope work{r, true};
|
|
const auto started = std::chrono::steady_clock::now();
|
|
execute_console_command(console_command->text);
|
|
const auto executed = std::chrono::steady_clock::now();
|
|
refresh_interactive_model(client, state, game_type);
|
|
const auto published = std::chrono::steady_clock::now();
|
|
const auto milliseconds = [](auto duration) { return std::chrono::duration<double, std::milli>(duration).count(); };
|
|
record("{\"event\":\"menu_command_timing\",\"queue_ms\":" +
|
|
std::to_string(milliseconds(started - console_command->queued_at)) + ",\"execute_ms\":" +
|
|
std::to_string(milliseconds(executed - started)) + ",\"publish_ms\":" +
|
|
std::to_string(milliseconds(published - executed)) + "}");
|
|
}
|
|
const auto now = GetTickCount64();
|
|
bool has_request{};
|
|
{ std::lock_guard lock(r.mutex); has_request = r.requests.pending_load(); }
|
|
// Camera phase validation is necessary before camera/motion operations,
|
|
// not on every idle frame. Validate afresh after any action in this tick
|
|
// that can have changed the native camera's identity (frame.camera_issue is
|
|
// cleared then); until one runs, the first check serves the whole tick.
|
|
const auto camera_phase_issue = [&]() -> const char* {
|
|
if (!frame.camera_issue) {
|
|
std::uintptr_t context{};
|
|
frame.camera_issue = read(client + 8, context) ? dingosdk::camera_probe_unavailable_reason(context) :
|
|
"Waiting for the local camera context.";
|
|
}
|
|
return *frame.camera_issue;
|
|
};
|
|
const auto camera_phase_ready = [&] { return camera_phase_issue() == nullptr; };
|
|
std::optional<dingosdk::overlay::DebugRequest> debug_request;
|
|
bool debug_busy{}, controller_busy{};
|
|
std::uint32_t noclip_combo{}, forward_velocity_combo{}, up_velocity_combo{};
|
|
{
|
|
std::lock_guard lock(r.mutex);
|
|
debug_busy = r.requests.loading();
|
|
controller_busy = !r.requests.idle();
|
|
noclip_combo = r.model.bindings.available ? r.model.bindings.noclip_combo : 0;
|
|
forward_velocity_combo = r.model.bindings.available ? r.model.bindings.forward_velocity_combo : 0;
|
|
up_velocity_combo = r.model.bindings.available ? r.model.bindings.up_velocity_combo : 0;
|
|
debug_request = r.requests.take<dingosdk::overlay::DebugRequest>(GetCurrentThreadId());
|
|
}
|
|
dingosdk::ControllerInput controller;
|
|
if (noclip_combo || forward_velocity_combo || up_velocity_combo) DingoSDKOverlayReadControllerInput(&controller);
|
|
const bool bind_blocked = r.observer_failed || controller_busy || debug_request.has_value() ||
|
|
(state != 13 && state != 21) || !r.debug_model.noclip_available ||
|
|
(noclip_combo && !camera_phase_ready());
|
|
const bool noclip_triggered = r.noclip_bind_latch.update(noclip_combo, controller, bind_blocked);
|
|
if (noclip_triggered) {
|
|
{
|
|
std::lock_guard lock(r.mutex);
|
|
const dingosdk::overlay::DebugRequest request{dingosdk::overlay::DebugAction::set_noclip, !r.debug_model.noclip};
|
|
if (r.requests.enqueue(request, request_context(r), GetCurrentThreadId()))
|
|
debug_request = r.requests.take<dingosdk::overlay::DebugRequest>(GetCurrentThreadId());
|
|
}
|
|
record("{\"event\":\"controller_binding_triggered\",\"action\":\"noclip\"}");
|
|
}
|
|
const bool forward_velocity_blocked = r.observer_failed || controller_busy || debug_request.has_value() ||
|
|
(state != 13 && state != 21) || !r.debug_model.forward_velocity_available ||
|
|
(forward_velocity_combo && forward_velocity_combo == noclip_combo);
|
|
if (r.forward_velocity_bind_latch.update(forward_velocity_combo, controller, forward_velocity_blocked)) {
|
|
{
|
|
std::lock_guard lock(r.mutex);
|
|
const dingosdk::overlay::DebugRequest request{dingosdk::overlay::DebugAction::add_forward_velocity};
|
|
if (r.requests.enqueue(request, request_context(r), GetCurrentThreadId()))
|
|
debug_request = r.requests.take<dingosdk::overlay::DebugRequest>(GetCurrentThreadId());
|
|
}
|
|
record("{\"event\":\"controller_binding_triggered\",\"action\":\"forward_velocity\"}");
|
|
}
|
|
const bool up_velocity_blocked = r.observer_failed || controller_busy || debug_request.has_value() ||
|
|
(state != 13 && state != 21) || !r.debug_model.up_velocity_available ||
|
|
(up_velocity_combo && (up_velocity_combo == noclip_combo || up_velocity_combo == forward_velocity_combo));
|
|
if (r.up_velocity_bind_latch.update(up_velocity_combo, controller, up_velocity_blocked)) {
|
|
std::lock_guard lock(r.mutex);
|
|
const dingosdk::overlay::DebugRequest request{dingosdk::overlay::DebugAction::add_up_velocity};
|
|
if (r.requests.enqueue(request, request_context(r), GetCurrentThreadId())) {
|
|
debug_request = r.requests.take<dingosdk::overlay::DebugRequest>(GetCurrentThreadId());
|
|
record("{\"event\":\"controller_binding_triggered\",\"action\":\"up_velocity\"}");
|
|
}
|
|
}
|
|
if (debug_request || r.debug_model.free_camera || r.debug_model.first_person || r.debug_model.noclip ||
|
|
now >= r.next_debug) {
|
|
ScheduledWorkScope work{r, debug_request.has_value()};
|
|
r.next_debug = now + 100;
|
|
const bool debug_ready = !r.observer_failed && !debug_busy && (state == 13 || state == 21) && native_context_ready();
|
|
dingosdk::overlay::FlightInput flight_input;
|
|
DingoSDKOverlayReadFlightInput(&flight_input, (r.debug_model.free_camera || r.debug_model.noclip) && debug_ready,
|
|
r.debug_model.noclip);
|
|
const auto phase_issue = camera_phase_issue();
|
|
auto debug = dingosdk::on_client_debug_tick(r.base, client, debug_ready,
|
|
phase_issue == nullptr,
|
|
debug_request ? &*debug_request : nullptr, &flight_input);
|
|
// A debug action, or a mode the tick ended by itself (host policy, lost
|
|
// camera), can have replaced the native camera: validate it again.
|
|
if (debug_request || debug.free_camera != r.debug_model.free_camera ||
|
|
debug.first_person != r.debug_model.first_person || debug.noclip != r.debug_model.noclip ||
|
|
debug.park_editor != r.debug_model.park_editor)
|
|
frame.camera_issue.reset();
|
|
if (debug_ready && phase_issue && !debug.camera_available) {
|
|
debug.camera_unavailable = phase_issue;
|
|
debug.noclip_unavailable = phase_issue;
|
|
}
|
|
// Ignore brief loading/ownership transitions. Report a persistent reason
|
|
// once, then report recovery; the menu always shows the current reason.
|
|
const std::string issue = debug_ready && debug.skater_position_valid && !debug.noclip_available
|
|
? debug.noclip_unavailable : std::string{};
|
|
if (issue != r.flight_unavailable_issue) {
|
|
r.flight_unavailable_issue = issue;
|
|
r.flight_unavailable_since = now;
|
|
}
|
|
if (!issue.empty() && now - r.flight_unavailable_since >= 2000 && issue != r.flight_unavailable_logged) {
|
|
dingosdk::logging::log(dingosdk::logging::Level::warning, dingosdk::logging::Channel::skater,
|
|
"{} unavailable: {}", debug.camera_available ? "Noclip" : "Freecam and Noclip", issue);
|
|
r.flight_unavailable_logged = issue;
|
|
} else if (debug.noclip_available && !r.flight_unavailable_logged.empty()) {
|
|
dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::skater,
|
|
"Freecam and Noclip are available again.");
|
|
r.flight_unavailable_logged.clear();
|
|
}
|
|
if (debug.noclip_velocity_updates && !r.debug_model.noclip_velocity_updates)
|
|
record("{\"event\":\"noclip_velocity_applied\",\"count\":" + std::to_string(debug.noclip_velocity_updates) + "}");
|
|
if (debug.noclip_motion_updates && !r.debug_model.noclip_motion_updates)
|
|
record("{\"event\":\"noclip_offboard_motion_applied\",\"count\":" + std::to_string(debug.noclip_motion_updates) + "}");
|
|
if (debug.forward_velocity_updates != r.debug_model.forward_velocity_updates)
|
|
record("{\"event\":\"forward_velocity_applied\",\"count\":" + std::to_string(debug.forward_velocity_updates) + "}");
|
|
if (debug.up_velocity_updates != r.debug_model.up_velocity_updates)
|
|
record("{\"event\":\"up_velocity_applied\",\"count\":" + std::to_string(debug.up_velocity_updates) + "}");
|
|
if (r.debug_model.noclip && !debug.noclip && !debug_request) {
|
|
std::ostringstream stopped;
|
|
stopped << "{\"event\":\"noclip_stopped\",\"reason\":" << std::quoted(debug.status) << '}';
|
|
record(stopped.str());
|
|
}
|
|
if (debug_request) record("{\"event\":\"interactive_debug_result\",\"action\":" +
|
|
std::to_string(static_cast<int>(debug_request->action)) + ",\"free_camera\":" +
|
|
(debug.free_camera ? "true" : "false") + ",\"noclip\":" + (debug.noclip ? "true" : "false") + ",\"game_ui_hidden\":" +
|
|
(debug.game_ui_hidden ? "true" : "false") + ",\"no_bail\":" + (debug.no_bail ? "true" : "false") +
|
|
",\"no_bail_active\":" + (debug.no_bail_active ? "true" : "false") + ",\"speed\":" + std::to_string(debug.camera_speed) +
|
|
",\"velocity_updates\":" + std::to_string(debug.noclip_velocity_updates) + ",\"motion_updates\":" + std::to_string(debug.noclip_motion_updates) + "}");
|
|
if (debug_request) {
|
|
std::ostringstream message;
|
|
message << "Noclip " << (debug.noclip ? "on" : "off") << " | free camera " << (debug.free_camera ? "on" : "off")
|
|
<< " | No Bail " << (debug.no_bail_active ? "active" : debug.no_bail ? "waiting" : "off")
|
|
<< (debug.no_bail_active && !debug.no_bail ? " (noclip)" : "")
|
|
<< " | speed " << debug.camera_speed << " | game UI "
|
|
<< (debug.game_ui_hidden ? "hidden" : "visible");
|
|
if (!debug.status.empty()) message << " | " << debug.status;
|
|
activity_line(dingosdk::ConsoleSource::runtime, message.str());
|
|
}
|
|
std::lock_guard lock(r.mutex);
|
|
r.debug_model = std::move(debug);
|
|
++r.debug_revision;
|
|
}
|
|
std::optional<dingosdk::overlay::OfflineFeatureRequest> offline_request;
|
|
{
|
|
std::lock_guard lock(r.mutex);
|
|
offline_request = r.requests.take<dingosdk::overlay::OfflineFeatureRequest>(GetCurrentThreadId());
|
|
}
|
|
if (offline_request || now >= r.next_offline) {
|
|
ScheduledWorkScope work{r, offline_request.has_value()};
|
|
DINGO_PROFILE_ZONE("tick/update_model/features (500 ms)");
|
|
r.next_offline = now + 500;
|
|
auto offline = dingosdk::update_gameplay_settings_override(
|
|
offline_request ? &*offline_request : nullptr);
|
|
if (offline_request)
|
|
dingosdk::logging::printf(dingosdk::logging::Level::info, dingosdk::logging::Channel::runtime,
|
|
"Offline feature %d -> %d applied: %s (board wear available %d, effective %d).",
|
|
static_cast<int>(offline_request->group), offline_request->enabled, offline.model.status.c_str(),
|
|
offline.model.board_wear.available, offline.model.board_wear.effective);
|
|
auto slot_action = dingosdk::SkaterSlotOverrideAction::tick;
|
|
if (offline_request &&
|
|
offline_request->group == dingosdk::overlay::OfflineFeatureGroup::restore_all)
|
|
slot_action = dingosdk::SkaterSlotOverrideAction::restore;
|
|
const auto profile_access = dingosdk::local_profile_access();
|
|
{
|
|
DINGO_PROFILE_ZONE("tick/update_model/features (500 ms)/local profile");
|
|
dingosdk::update_local_customization();
|
|
}
|
|
// Saved access owns these prerequisites. This runs on the recorded
|
|
// game-update thread and reacquires/validates each native settings object.
|
|
const auto enable_saved_feature = [&](const char* name, bool requested) {
|
|
if (!requested) return;
|
|
const auto variable = std::find_if(offline.model.variables.begin(), offline.model.variables.end(),
|
|
[&](const auto& field) { return field.name == name; });
|
|
if (variable != offline.model.variables.end() && variable->available && !variable->value)
|
|
offline = dingosdk::update_gameplay_engine_variable({name, dingosdk::EngineVariableAction::set, true});
|
|
};
|
|
enable_saved_feature("FastTravelPointsEnabled", profile_access.bus_stops);
|
|
enable_saved_feature("EnableNeighborhoodRank", profile_access.neighborhoods || profile_access.neighborhood_ranks);
|
|
enable_saved_feature("EnableCASArtistSandbox", profile_access.cosmetics);
|
|
enable_saved_feature("EnableMyStuffMenu", profile_access.cosmetics);
|
|
if (profile_access.preset_slots) slot_action = dingosdk::SkaterSlotOverrideAction::enable;
|
|
const auto slots = dingosdk::update_skater_slot_override(slot_action, dingosdk::local_customization_selected_preset(),
|
|
dingosdk::local_customization_outfits_loadable());
|
|
if (slots.manager_available && slots.ui_ready)
|
|
dingosdk::observe_local_customization_selection(slots.selected_slot);
|
|
const bool missions_requested = offline.model.activities.available &&
|
|
offline.model.activities.effective &&
|
|
offline.model.activities.override_active;
|
|
if (missions_requested)
|
|
dingosdk::arm_main_mission_override(r.base, true);
|
|
else
|
|
dingosdk::restore_main_mission_override();
|
|
const auto missions = dingosdk::main_mission_override_observation();
|
|
const bool fixed_stop_requested = r.fixed_stop_entitlement_route_ready &&
|
|
(profile_access.bus_stops || (offline.model.fast_travel.available &&
|
|
offline.model.fast_travel.effective &&
|
|
offline.model.fast_travel.override_active));
|
|
dingosdk::set_fixed_stop_entitlement_provider_enabled(fixed_stop_requested);
|
|
const bool neighborhood_requested = profile_access.neighborhoods || (offline.model.progression.available &&
|
|
offline.model.progression.effective &&
|
|
offline.model.progression.override_active);
|
|
(void)dingosdk::set_neighborhood_unlock_override_enabled(neighborhood_requested);
|
|
const auto fixed_stops = dingosdk::fixed_stop_entitlement_provider_observation();
|
|
const auto neighborhoods = dingosdk::neighborhood_unlock_override_observation();
|
|
merge_skater_slot_observation(offline.model, slots);
|
|
merge_main_mission_observation(offline.model, missions);
|
|
merge_progression_provider_observations(offline.model, fixed_stops, neighborhoods,
|
|
r.fixed_stop_entitlement_route_ready);
|
|
if (offline.json != r.last_gameplay_settings_override_observation) {
|
|
record(offline.json);
|
|
r.last_gameplay_settings_override_observation = offline.json;
|
|
}
|
|
if (slots.json != r.last_skater_slot_override_observation) {
|
|
record(slots.json);
|
|
r.last_skater_slot_override_observation = slots.json;
|
|
}
|
|
if (missions.json != r.last_main_mission_override_observation) {
|
|
record(missions.json);
|
|
r.last_main_mission_override_observation = missions.json;
|
|
}
|
|
if (fixed_stops.json != r.last_fixed_stop_entitlement_provider_observation) {
|
|
record(fixed_stops.json);
|
|
r.last_fixed_stop_entitlement_provider_observation = fixed_stops.json;
|
|
}
|
|
if (neighborhoods.json != r.last_neighborhood_unlock_override_observation) {
|
|
record(neighborhoods.json);
|
|
r.last_neighborhood_unlock_override_observation = neighborhoods.json;
|
|
}
|
|
std::lock_guard lock(r.mutex);
|
|
r.offline_model = std::move(offline.model);
|
|
++r.offline_revision;
|
|
// Skater slots, teleports and customization ran: check the camera again.
|
|
frame.camera_issue.reset();
|
|
}
|
|
if (r.debug_model.park_editor && !r.observer_failed && !debug_busy && (state == 13 || state == 21) &&
|
|
native_context_ready()) {
|
|
{
|
|
DINGO_PROFILE_ZONE("tick/update_model/park editor");
|
|
dingosdk::tick_local_park_editor();
|
|
}
|
|
frame.camera_issue.reset();
|
|
auto editor = dingosdk::local_park_editor();
|
|
std::lock_guard lock(r.mutex);
|
|
r.model.editor = std::move(editor);
|
|
++r.model_revision;
|
|
} else {
|
|
// Other players' objects still to be created: queue the next as soon as the
|
|
// last one is in, not at the 500 ms customization pace.
|
|
DINGO_PROFILE_ZONE("tick/update_model/network objects");
|
|
dingosdk::tick_network_objects();
|
|
}
|
|
if (r.observer_failed) return; // Keep the bounded restore/telemetry path available after catalog failure.
|
|
dingosdk::hall_of_meat::on_client_tick();
|
|
if (!has_request && now < r.next_model && state == r.previous_state) return;
|
|
r.next_model = now + 500;
|
|
DINGO_PROFILE_ZONE("tick/update_model/world model (500 ms)");
|
|
auto description = dingosdk::read_world_description(GetCurrentProcess(), client + 0x198);
|
|
r.multiplayer_map = description.level.empty() ? std::string{} : description.level + "|" + description.lm_level;
|
|
const auto& current_level = description.lm_level.empty() ? description.level : description.lm_level;
|
|
dingosdk::live_mods::set_current_level(current_level);
|
|
dingosdk::hall_of_meat::set_level(current_level);
|
|
// A live mod apply hands over the destinations its mods declare. A map
|
|
// being played that is no longer among them (its mod disabled or deleted)
|
|
// cannot stay loaded: the player goes to San Van.
|
|
bool left_map{}, manifests_applied{};
|
|
if (auto manifests = dingosdk::live_mods::take_level_manifests()) {
|
|
manifests_applied = true;
|
|
auto next = read_custom_levels(*manifests);
|
|
const auto listed = [&](const dingosdk::CustomLevelManifest& set) {
|
|
return std::ranges::any_of(set.levels, [&](const auto& level) { return equals(level.asset, description.lm_level); });
|
|
};
|
|
left_map = !description.lm_level.empty() && listed(r.custom_levels) && !listed(next);
|
|
r.custom_levels = std::move(next);
|
|
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::level,
|
|
"Custom-level destinations after the live mod apply: {}.", r.custom_levels.levels.size());
|
|
}
|
|
// Handed out with the manifests: once the merge is in, load the level being
|
|
// played again ("Apply and reload level"), or San Van when that map is gone.
|
|
const bool reload = dingosdk::live_mods::take_reload();
|
|
if (left_map || reload) {
|
|
constexpr std::string_view san_van = "levels/game/BAM_LevelRoot/BAM_LevelRoot";
|
|
const std::string target = left_map ? std::string(san_van) : current_level;
|
|
std::lock_guard lock(r.mutex);
|
|
if (target.empty() || !r.requests.enqueue(ConsoleRequest{"load " + target}, request_context(r), GetCurrentThreadId()))
|
|
dingosdk::logging::log(dingosdk::logging::Level::warning, dingosdk::logging::Channel::level,
|
|
"Mods applied, but the level could not be {}; load a level to use them.", left_map ? "left" : "reloaded");
|
|
else if (left_map)
|
|
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::level,
|
|
"{} was removed by the mod apply; loading San Van.", description.lm_level);
|
|
else
|
|
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::level,
|
|
"Reloading {} to use the applied mods.", target);
|
|
}
|
|
// The native level registry changes only with a mod apply or a level load,
|
|
// and the server's sublevels settle during loads: read both again then, and
|
|
// whenever this client is not in play; otherwise every 10 s and 2 s.
|
|
bool requests_loading{};
|
|
{ std::lock_guard lock(r.mutex); requests_loading = r.requests.loading(); }
|
|
const bool world_changed = manifests_applied || requests_loading || (state != 13 && state != 21) ||
|
|
state != r.previous_state || description.level != r.last_level || description.lm_level != r.catalog_level;
|
|
r.catalog_level = description.lm_level;
|
|
if (!r.native_catalog || !r.native_catalog->available || world_changed || now >= r.next_catalog_read) {
|
|
r.native_catalog = dingosdk::read_world_catalog(GetCurrentProcess(), r.base);
|
|
r.next_catalog_read = now + 10000;
|
|
}
|
|
const bool sublevels_read = !r.sublevel_catalog || !r.sublevel_catalog->available || world_changed ||
|
|
now >= r.next_sublevel_read;
|
|
if (sublevels_read) {
|
|
r.sublevel_catalog = dingosdk::read_server_sublevels(GetCurrentProcess(), r.base);
|
|
r.next_sublevel_read = now + 2000;
|
|
}
|
|
auto catalog = *r.native_catalog;
|
|
const auto native_level_count = catalog.levels.size();
|
|
catalog = dingosdk::merge_custom_levels(std::move(catalog), r.custom_levels);
|
|
const auto& sublevels = *r.sublevel_catalog;
|
|
const auto client_content = read_client_content(r.base, client, description.lm_level);
|
|
const bool context_ready = native_context_ready();
|
|
const bool manifest_custom_active = (state == 13 || state == 21) &&
|
|
context_ready && description.available &&
|
|
std::any_of(catalog.levels.begin(), catalog.levels.end(), [&](const auto& level) {
|
|
return level.manifest_only && equals(level.asset, description.lm_level);
|
|
});
|
|
// Schema 1 Studio maps are authored exclusively from BAM's bounded
|
|
// service/environment closure. This hint permits that reduced controller
|
|
// graph to identify its family without weakening native-map detection.
|
|
dingosdk::set_local_world_layer_map_hint(
|
|
manifest_custom_active ? dingosdk::WorldMap::bam : dingosdk::WorldMap::none);
|
|
dingosdk::ConsoleActivity activity;
|
|
activity.state = state;
|
|
activity.native_loading_logging = r.native_loading_logging;
|
|
activity.state_name = state_name(state);
|
|
activity.level = description.level;
|
|
activity.sublevel = description.lm_level;
|
|
for (const auto& level : sublevels.levels)
|
|
activity.sublevel_active |= sublevels.available && sublevels.active && level.state == 5 &&
|
|
equals(level.asset, description.lm_level);
|
|
activity.client_sublevel_present = client_content.sublevels_available &&
|
|
client_content.requested_sublevel_present;
|
|
activity.players_available = client_content.players_available;
|
|
activity.players = client_content.local_player_count;
|
|
activity.controllables = client_content.controllable_reference_count;
|
|
{
|
|
std::lock_guard lock(r.mutex);
|
|
if (r.debug_model.skater_position_valid) {
|
|
activity.skater = r.debug_model.skater_identity;
|
|
activity.position = r.debug_model.skater_position;
|
|
}
|
|
}
|
|
r.console_activity.update(activity, &activity_line);
|
|
const auto content_json = "{\"event\":\"client_content\",\"sublevels_available\":" +
|
|
std::string(client_content.sublevels_available ? "true" : "false") +
|
|
",\"sublevel_count\":" + std::to_string(client_content.sublevel_count) +
|
|
",\"requested_sublevel_present\":" + (client_content.requested_sublevel_present ? "true" : "false") +
|
|
",\"player_manager_present\":" + (client_content.player_manager_present ? "true" : "false") +
|
|
",\"players_available\":" + (client_content.players_available ? "true" : "false") +
|
|
",\"local_player_count\":" + std::to_string(client_content.local_player_count) +
|
|
",\"controllable_reference_count\":" + std::to_string(client_content.controllable_reference_count) + "}";
|
|
if (content_json != r.last_client_content) { record(content_json); r.last_client_content = content_json; }
|
|
if (sublevels_read) {
|
|
const auto sublevel_json = dingosdk::sublevel_catalog_json(sublevels);
|
|
if (sublevel_json != r.last_sublevels) {
|
|
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Context::server,
|
|
dingosdk::logging::Channel::level, "Server sublevel catalog: {} entries; available={}, root active={}.",
|
|
sublevels.levels.size(), sublevels.available, sublevels.active);
|
|
dingosdk::logging::event(dingosdk::logging::Context::server, dingosdk::logging::Channel::level,
|
|
"{\"event\":\"server_sublevels\",\"catalog\":" + sublevel_json + "}");
|
|
r.last_sublevels = sublevel_json;
|
|
}
|
|
}
|
|
if (state != r.previous_state || description.level != r.last_level) {
|
|
record("{\"event\":\"engine_state\",\"state\":" + std::to_string(state) +
|
|
",\"description\":" + dingosdk::world_description_json(description) + "}");
|
|
r.previous_state = state;
|
|
r.last_level = description.level;
|
|
}
|
|
if (!r.catalog_logged && catalog.available && native_level_count && !catalog.levels.empty()) {
|
|
record("{\"event\":\"level_catalog\",\"catalog\":" + dingosdk::world_catalog_json(catalog) + "}");
|
|
r.catalog_logged = true;
|
|
const auto manifest_count = static_cast<std::size_t>(std::count_if(
|
|
catalog.levels.begin(), catalog.levels.end(), [](const auto& level) { return level.manifest_only; }));
|
|
activity_line(dingosdk::ConsoleSource::level,
|
|
"Discovered " + std::to_string(catalog.levels.size()) + " levels (" +
|
|
std::to_string(native_level_count) + " native, " + std::to_string(manifest_count) +
|
|
" manifest). Ready for manual selection.");
|
|
}
|
|
dingosdk::overlay::Model model;
|
|
const auto missions_model = dingosdk::local_profile_missions();
|
|
model.progression = dingosdk::local_profile_progression();
|
|
model.player_card = dingosdk::local_profile_player_card();
|
|
model.hall_of_meat = {dingosdk::hall_of_meat::available(), dingosdk::hall_of_meat::enabled()};
|
|
model.bindings = dingosdk::local_profile_controller_bindings();
|
|
model.parks = dingosdk::local_profile_parks();
|
|
model.world = dingosdk::local_profile_world_layers();
|
|
model.missions_available = missions_model.available;
|
|
model.mission_feedback = missions_model.feedback;
|
|
for (const auto& row : missions_model.rows) model.missions.push_back({row.id, row.group, row.completed});
|
|
model.state = state_name(state);
|
|
model.detail = description.level.empty() ? "Waiting for a level description" : description.level;
|
|
if (!description.lm_level.empty()) {
|
|
bool active = false;
|
|
for (const auto& level : sublevels.levels)
|
|
active = active || (sublevels.available && sublevels.active && level.state == 5 && equals(level.asset, description.lm_level));
|
|
model.detail += "\nSublevel: " + description.lm_level + (active ? " (active on local server)" : " (not yet confirmed active)");
|
|
model.detail += "\nClient sublevel: " + std::string(!client_content.sublevels_available ? "unavailable" :
|
|
(client_content.requested_sublevel_present ? "present" : "not present"));
|
|
}
|
|
model.detail += client_content.players_available ? "\nLocal players: " + std::to_string(client_content.local_player_count) +
|
|
" | Controllable references: " + std::to_string(client_content.controllable_reference_count) : "\nLocal players: unavailable";
|
|
for (const auto& level : catalog.levels) {
|
|
dingosdk::overlay::Level choice;
|
|
choice.asset = level.asset;
|
|
choice.display_name = level.display_name;
|
|
choice.manifest_start_point = level.manifest_start_point;
|
|
choice.native_registered = !level.manifest_only;
|
|
choice.custom = level.custom;
|
|
for (const auto& point : level.start_points) choice.start_points.push_back(point.name);
|
|
choice.can_load = catalog.available && context_ready;
|
|
choice.load_block_reason = "Waiting for this process's native local world context.";
|
|
model.levels.push_back(std::move(choice));
|
|
}
|
|
const bool local = (state == 13 || state == 21) && context_ready && catalog.available && !catalog.levels.empty();
|
|
model.can_queue_load = local;
|
|
model.load_block_reason = local ? "" : "Waiting for an active local level and initialized level manager.";
|
|
if (!catalog.available) model.load_block_reason = catalog.issue;
|
|
const auto readiness = "{\"event\":\"load_readiness\",\"can_load\":" + std::string(local ? "true" : "false") +
|
|
",\"state\":" + std::to_string(state) +
|
|
",\"context_ready\":" + (context_ready ? "true" : "false") +
|
|
",\"game_type\":" + std::to_string(game_type) +
|
|
",\"catalog_available\":" + (catalog.available ? "true" : "false") +
|
|
",\"level_count\":" + std::to_string(catalog.levels.size()) + "}";
|
|
if (readiness != r.last_readiness) {
|
|
record(readiness);
|
|
r.last_readiness = readiness;
|
|
}
|
|
bool load_busy{};
|
|
{ std::lock_guard lock(r.mutex); load_busy = r.requests.loading(); }
|
|
dingosdk::update_local_world_controls(!r.observer_failed && !load_busy && local && context_ready &&
|
|
(state == 13 || state == 21) && model.world.ready ? model.world.map : dingosdk::WorldMap::none);
|
|
model.world_controls = dingosdk::local_profile_world_controls();
|
|
dingosdk::update_local_graphics_controls();
|
|
model.graphics = dingosdk::local_profile_graphics_controls();
|
|
model.object_persistence = dingosdk::local_profile_object_persistence();
|
|
// The park list is a disk walk of the Mods folder: only while the menu or the editor shows it.
|
|
dingosdk::set_park_mod_list_visible(r.debug_model.park_editor ||
|
|
GetTickCount64() < r.named_settings_wanted_until.load(std::memory_order_relaxed));
|
|
model.editor = dingosdk::local_park_editor();
|
|
// Spectating only needs one controllable local player; it works on every
|
|
// map, including custom mod levels.
|
|
r.spectate_ready = local && !load_busy && client_content.players_available &&
|
|
client_content.local_player_count == 1 && client_content.controllable_reference_count == 1;
|
|
r.spectate_client = client;
|
|
std::optional<Request> request;
|
|
{
|
|
std::lock_guard lock(r.mutex);
|
|
if (r.requests.inflight()) {
|
|
r.transition_seen = r.transition_seen || state != r.requested_from;
|
|
const bool manifest_sublevel = !r.last_lm_level.empty() &&
|
|
std::any_of(catalog.levels.begin(), catalog.levels.end(), [&](const auto& level) {
|
|
return level.manifest_only && equals(level.asset, r.last_lm_level);
|
|
});
|
|
bool sublevel_active = r.last_lm_level.empty();
|
|
for (const auto& level : sublevels.levels)
|
|
sublevel_active = sublevel_active || (sublevels.available && sublevels.active && level.state == 5 &&
|
|
equals(level.asset, r.last_lm_level));
|
|
const bool client_sublevel_present = r.last_lm_level.empty() ||
|
|
(client_content.sublevels_available && client_content.requested_sublevel_present);
|
|
// Native destinations are corroborated by both stock sublevel
|
|
// registries. A Studio manifest destination may not be published by
|
|
// one or both supplemental registries, so the engine's settled state
|
|
// plus its exact active WorldDescription is authoritative.
|
|
const bool manifest_description_active = manifest_sublevel && description.available &&
|
|
equals(description.level, r.last_request) &&
|
|
equals(description.lm_level, r.last_lm_level);
|
|
const bool content_confirmed = manifest_sublevel ? manifest_description_active :
|
|
(sublevel_active && client_sublevel_present);
|
|
const bool settled = r.transition_seen && (state == 13 || state == 21) && content_confirmed;
|
|
const bool timed_out = now - r.request_started >= 90000 && (state == 13 || state == 21) && local;
|
|
const bool cancelled = state == 2 || state == 24 || state == 25;
|
|
if (settled || timed_out || cancelled) {
|
|
const bool destination_matched = settled && equals(description.level, r.last_request) &&
|
|
(r.last_lm_level.empty() || equals(description.lm_level, r.last_lm_level));
|
|
const bool start_points_matched =
|
|
(r.last_start.empty() || equals(description.start_point, r.last_start)) &&
|
|
(r.last_lm_start.empty() || equals(description.lm_start_point, r.last_lm_start));
|
|
// A start point is a placement request, not part of a detached
|
|
// custom level's loaded identity. Preserve the comparison in
|
|
// diagnostics, but do not report a loaded custom world as failed
|
|
// if the engine normalizes or substitutes its default spawn.
|
|
const bool matched = destination_matched &&
|
|
(manifest_sublevel || start_points_matched);
|
|
r.load_result = matched ? (manifest_sublevel ?
|
|
"Requested custom destination is active; player control is not yet verified." :
|
|
"Requested root loaded and sublevel content is present; player control is not yet verified.") :
|
|
"The requested level/start point did not finish loading as expected.";
|
|
record("{\"event\":\"native_level_result\",\"matched\":" + std::string(matched ? "true" : "false") +
|
|
",\"timed_out\":" + (timed_out ? "true" : "false") +
|
|
",\"manifest_sublevel\":" + (manifest_sublevel ? "true" : "false") +
|
|
",\"manifest_description_active\":" + (manifest_description_active ? "true" : "false") +
|
|
",\"start_points_matched\":" + (start_points_matched ? "true" : "false") +
|
|
",\"server_sublevel_active\":" + (sublevel_active ? "true" : "false") +
|
|
",\"client_sublevel_present\":" + (client_sublevel_present ? "true" : "false") +
|
|
",\"description\":" + dingosdk::world_description_json(description) + "}");
|
|
activity_line(dingosdk::ConsoleSource::level,
|
|
matched ? "Level content loaded: " + r.last_request +
|
|
(r.last_lm_level.empty() ? "" : " + " + r.last_lm_level)
|
|
: (timed_out ? "Level load timed out: " : "Level load did not complete: ") + r.last_request,
|
|
matched ? dingosdk::ConsoleSeverity::success : dingosdk::ConsoleSeverity::warning);
|
|
r.requests.finish_load();
|
|
dingosdk::loading_screen::cancel();
|
|
}
|
|
}
|
|
if (!r.load_result.empty()) model.detail += "\n" + r.load_result;
|
|
if (r.requests.inflight()) {
|
|
model.can_queue_load = false;
|
|
model.load_block_reason = "Waiting for the requested level to finish loading.";
|
|
}
|
|
request = r.requests.take_load();
|
|
r.menu_splash_ready = local && description.available &&
|
|
equals(description.level, "levels/Game/DingoLevel_Splash/DingoLevel_Splash");
|
|
if (!r.menu_splash_ready || (!r.requests.loading() && !request)) r.menu_load_queued = false;
|
|
r.model = std::move(model);
|
|
++r.model_revision;
|
|
}
|
|
// Start San Van as soon as the splash world's local loader is ready. Use
|
|
// the normal request scheduler, preserving user/join requests and all
|
|
// transition cleanup. This is a once-per-process startup action.
|
|
if (!r.startup_load_queued) {
|
|
if (request || (local && description.available && !r.menu_splash_ready))
|
|
r.startup_load_queued = true;
|
|
else if (queue_menu_bam()) r.startup_load_queued = true;
|
|
}
|
|
if (!request) return;
|
|
const dingosdk::LevelInfo* selected_level = nullptr;
|
|
for (const auto& level : catalog.levels)
|
|
if (!level.manifest_only && equals(level.asset, request->asset)) selected_level = &level;
|
|
if (!local || !registered_request(catalog, *request) || !selected_level) {
|
|
activity_line(dingosdk::ConsoleSource::level,
|
|
"Load rejected: local context or selected level changed.", dingosdk::ConsoleSeverity::warning);
|
|
record("{\"event\":\"native_level_rejected\",\"reason\":\"context_or_selection_changed\"}");
|
|
std::lock_guard lock(r.mutex);
|
|
r.requests.finish_load();
|
|
r.model.load_block_reason = "Load rejected: the local context or registered selection changed.";
|
|
++r.model_revision;
|
|
return;
|
|
}
|
|
// Validated afresh for the restore, which may itself change the camera.
|
|
frame.camera_issue.reset();
|
|
const bool restored = dingosdk::restore_client_debug(r.base, client, camera_phase_ready());
|
|
frame.camera_issue.reset();
|
|
if (!restored) {
|
|
std::lock_guard lock(r.mutex);
|
|
r.requests.finish_load();
|
|
r.load_result = "Load cancelled: restore the current debug camera/settings first, then retry.";
|
|
activity_line(dingosdk::ConsoleSource::level, r.load_result, dingosdk::ConsoleSeverity::warning);
|
|
return;
|
|
}
|
|
if (!dingosdk::multiplayer::prepare_native_menu_level_load(r.base)) {
|
|
std::lock_guard lock(r.mutex);
|
|
r.requests.finish_load();
|
|
r.load_result = "Load cancelled: menu cleanup could not finish. Close the pause menu and retry.";
|
|
activity_line(dingosdk::ConsoleSource::level, r.load_result, dingosdk::ConsoleSeverity::warning);
|
|
return;
|
|
}
|
|
{
|
|
std::lock_guard lock(r.mutex);
|
|
r.requests.submitted_load();
|
|
r.transition_seen = false;
|
|
r.requested_from = state;
|
|
r.request_started = now;
|
|
r.last_request = request->asset;
|
|
r.last_start = request->start;
|
|
r.last_lm_level = request->lm_level;
|
|
r.last_lm_start = request->lm_start;
|
|
r.load_result.clear();
|
|
r.model.can_queue_load = false;
|
|
r.model.load_block_reason = "Level request submitted; waiting for engine loading.";
|
|
++r.model_revision;
|
|
}
|
|
dingosdk::WorldDescription selected;
|
|
selected.available = true; selected.level = request->asset; selected.start_point = request->start;
|
|
selected.lm_level = request->lm_level; selected.lm_start_point = request->lm_start;
|
|
selected.flags[0] = selected_level->level_flag68;
|
|
const bool hosted = reinterpret_cast<HostedSelector>(r.base + rt::hosted_level_selector)(request->asset.c_str());
|
|
activity_line(dingosdk::ConsoleSource::level, "Loading " + request->asset +
|
|
(request->lm_level.empty() ? "" : " + " + request->lm_level) +
|
|
" (" + (hosted ? "local hosted server" : "single player") + ").");
|
|
record("{\"event\":\"native_level_request\",\"predicted_route\":\"" + std::string(hosted ? "HostedLocal" : "SinglePlayer") +
|
|
"\",\"selection\":" + dingosdk::world_description_json(selected) + "}");
|
|
dingosdk::multiplayer::host_map_change(request->asset + "|" + request->lm_level);
|
|
submit_local(r.base, client, *request, selected_level->level_flag68);
|
|
}
|
|
}
|
|
void tick(std::uintptr_t client, std::uintptr_t update) {
|
|
const auto incoming_error = GetLastError();
|
|
// The profiler's client update timing: the native update, and ReSkate's own work around it.
|
|
const auto tick_start = dingosdk::profiler::now_ns();
|
|
auto& r = runtime();
|
|
dingosdk::poll_level_loading(client);
|
|
dingosdk::camera_tick_enter(client);
|
|
struct CameraTickScope {
|
|
~CameraTickScope() {
|
|
const auto error = GetLastError();
|
|
dingosdk::camera_tick_leave();
|
|
SetLastError(error);
|
|
}
|
|
} camera_tick_scope;
|
|
SetLastError(incoming_error);
|
|
const auto native_start = dingosdk::profiler::now_ns();
|
|
r.original_tick(client, update);
|
|
const auto native_time = dingosdk::profiler::now_ns() - native_start;
|
|
struct RecordUpdate {
|
|
std::uint64_t start, native;
|
|
~RecordUpdate() { dingosdk::profiler::record_client_update(dingosdk::profiler::now_ns() - start - native); }
|
|
} record_update{tick_start, native_time};
|
|
struct PreserveTickError {
|
|
DWORD value{GetLastError()};
|
|
~PreserveTickError() { SetLastError(value); }
|
|
} preserve_tick_error;
|
|
dingosdk::camera_tick_native_return();
|
|
dingosdk::poll_level_loading(client);
|
|
try {
|
|
TickState tick_state;
|
|
{
|
|
DINGO_PROFILE_ZONE("tick/update_model");
|
|
update_model(client, tick_state);
|
|
}
|
|
if (r.engine_thread.load() != GetCurrentThreadId()) return;
|
|
DWORD state{},game_type{};
|
|
bool loading{};
|
|
{ std::lock_guard lock(r.mutex); loading=r.requests.loading(); }
|
|
// update_model's context check, when it made one for this same state and mode.
|
|
const auto context_ready = [&] {
|
|
return tick_state.valid && tick_state.context && tick_state.state == state && tick_state.game_type == game_type
|
|
? *tick_state.context : native_context(r.base, client, game_type);
|
|
};
|
|
const bool multiplayer_ready=!r.observer_failed && !loading &&
|
|
memory::peek(client+0xc4,state) && (state==13 || state==21) && memory::peek(client+0xc0,game_type) &&
|
|
context_ready();
|
|
{
|
|
DINGO_PROFILE_ZONE("tick/multiplayer");
|
|
dingosdk::multiplayer::tick(r.base,client,multiplayer_ready,r.multiplayer_map,load_multiplayer_map);
|
|
if (auto notice = dingosdk::multiplayer::take_leave_notice(); !notice.empty())
|
|
dingosdk::overlay::notify(dingosdk::overlay::NoticeLevel::warning, "Map not installed", std::move(notice));
|
|
}
|
|
dingosdk::multiplayer::refresh_identity_lists();
|
|
dingosdk::tick_local_developer_hoodie(r.base, client, multiplayer_ready);
|
|
dingosdk::tick_local_developer_board(r.base, client, multiplayer_ready);
|
|
// The session spawns and places skaters and can teleport: check the camera again.
|
|
tick_state.camera_issue.reset();
|
|
// The spectate camera follows a moving skater, so it runs every client tick, after the
|
|
// session placed the remote skaters for this frame (and also out of play, to give up).
|
|
std::uintptr_t camera_context{};
|
|
const bool camera_phase = tick_state.camera_issue ? *tick_state.camera_issue == nullptr :
|
|
memory::peek(client + 8, camera_context) && !dingosdk::camera_probe_unavailable_reason(camera_context);
|
|
{
|
|
DINGO_PROFILE_ZONE("tick/native party");
|
|
dingosdk::multiplayer::tick_native_party_actions(r.base, client,
|
|
r.spectate_ready && r.spectate_client == client && (state == 13 || state == 21), camera_phase);
|
|
}
|
|
if (state == 13 || state == 21) {
|
|
DINGO_PROFILE_ZONE("tick/camera view");
|
|
// The camera ReSkate's nametags start from, and the spectate camera's lesson in how
|
|
// the game frames the local skater.
|
|
if (dingosdk::publish_local_camera_view(r.base, client))
|
|
if (const auto view = dingosdk::latest_game_view())
|
|
dingosdk::multiplayer::observe_gameplay_camera(r.base, *view);
|
|
}
|
|
// Same verified client scope in which remote skater actors are created.
|
|
{
|
|
DINGO_PROFILE_ZONE("tick/AI skaters");
|
|
dingosdk::ai_skaters::tick(r.base,client,multiplayer_ready);
|
|
}
|
|
{
|
|
DINGO_PROFILE_ZONE("tick/trainer");
|
|
// A custom map is a sublevel of the root level: that is the map the player means.
|
|
dingosdk::trainer::tick(r.base, client, multiplayer_ready, r.catalog_level.empty() ? r.last_level : r.catalog_level);
|
|
}
|
|
{
|
|
DINGO_PROFILE_ZONE("tick/Steam friend join");
|
|
dingosdk::multiplayer::tick_steam_friend_join();
|
|
}
|
|
// Model creation requires cleanup on every native unload, including
|
|
// transitions that bypass the ReSkate request scheduler.
|
|
if (r.native_loading_logging) {
|
|
DINGO_PROFILE_ZONE("tick/native menu");
|
|
dingosdk::multiplayer::tick_native_menu(r.base, loading);
|
|
}
|
|
}
|
|
catch (const std::exception&) {
|
|
if (!r.observer_failed.exchange(true)) activity_line(dingosdk::ConsoleSource::runtime,
|
|
"Debug controller stopped after an observation error. See the runtime log.", dingosdk::ConsoleSeverity::error);
|
|
std::lock_guard lock(r.mutex);
|
|
r.model.can_queue_load = false;
|
|
r.model.load_block_reason = "Debug controller stopped after a local observation error.";
|
|
++r.model_revision;
|
|
r.requests.fault();
|
|
}
|
|
}
|
|
}
|