mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 13:57:05 +02:00
Freezes and crashes: the game's threads no longer wait on ReSkate's slow work
Players reported the game hanging for about a second while skating. A profile showed the memory line in the log walking every heap (HeapSummary) on the client thread, 0.5 to 2 s every two minutes. An audit of everything that runs on the game's update and render threads found more of the same, and Sentry's top crashes had causes of ours. Freezes: - Memory log line: no heap walk or thread snapshot; the GPU part and the write happen on a thread of their own. - Profile database: synchronous=NORMAL in WAL mode. A save is still kept when the game crashes; every play event no longer waits for the disk inside the game's hooks. - ReSkate's own preferences and values (menu clicks, session sliders, Discord) are saved by a background writer in order; reads see a value until it is saved. - Input: the foreground window and the keyboard are sampled on a thread of their own (GetAncestor and GetAsyncKeyState stalled the client tick 40-50 ms when another program held Windows' lock); flight input uses the sample; ClipCursor at most twice a second while a menu has the pointer. - Hooks installed while the game runs go in one transaction per place (HookBatch): throwdowns, remote players, the party, Hall of Meat. - Off the update thread: the host's map package lookup (one table read in the background, not the Mods folder per offer), the trainer's map file and settings save, Board Break's settings save, mod music playlists and their covers, the Spotify station covers. - Off the render thread: the Modding page's and the deck card's Mods folder reads. The overlay's three startup waits share five seconds. - Less work per frame: Steam names reused while one a frame is asked again, 2 friends a tick, steam_api64.dll checked once per process, cosmetic names read in place rather than a system call per character, an outfit load no longer copies the inventory per slot, the throwdown type scan runs at background priority over resident memory, the Board Break deck label is kept a minute. Crashes: - Render-resource pool: the metadata fields are written to the state owner only. The other renderer is 0x2daa00 bytes, and +0x11aae28 is far past its end (store_field crash; heap corruption in every release). - Other players' skateboard effects: a stale skateboard component is no longer followed, and the joint lookup is walked with a bound first (an endless loop in reused memory ended in GameStall). - Launcher: Oodle decodes into the buffer it asks for with corrupt data possible, and its assertions no longer stop the process (heap corruption in the store-copy scan; a breakpoint in oo2core). Timings (Send performance timings): - A frame of half a second or more in a level is sent as game.freeze: where the time went (ReSkate's tick, the game's frame, or outside it), whether the renderer kept presenting, and ReSkate's busiest zones. - Closing the game sends the level session it was in. Music after a connection drop: - Spotify device: an account Spotify could not be reached with is kept and tried again (3 s doubling to a minute); a session that drops is taken up again. Only an account Spotify refuses is asked for anew. - YouTube playlist pictures that could not be fetched are tried again, from 30 s to 10 minutes apart.
This commit is contained in:
1 parent
310922556c
commit
719cba1166
42 files changed
+1130
-239
No files matched your search
@@ -78,7 +78,7 @@ void configure(State& s) {
|
||||
}
|
||||
// Set by start() and never destroyed: the worker thread may outlive static
|
||||
// destruction, and a DLL must not join a thread while the loader lock is held.
|
||||
std::atomic<State*> active{};
|
||||
std::atomic<State*> started_state{};
|
||||
constexpr std::uint64_t hitch_ns = 50ull * 1000 * 1000;
|
||||
struct Pacing {
|
||||
std::atomic<std::uint64_t> last{}, frames{}, total{}, hitches{}, worst{};
|
||||
@@ -165,17 +165,18 @@ std::string envelope(const Transaction& transaction) {
|
||||
}
|
||||
bool start() noexcept {
|
||||
try {
|
||||
if (active.load()) return true;
|
||||
if (started_state.load()) return true;
|
||||
auto s = std::make_unique<State>();
|
||||
configure(*s);
|
||||
if (!s->enabled) return false;
|
||||
State* expected{};
|
||||
if (active.compare_exchange_strong(expected, s.get())) (void)s.release();
|
||||
if (started_state.compare_exchange_strong(expected, s.get())) (void)s.release();
|
||||
return true;
|
||||
} catch (...) { return false; }
|
||||
}
|
||||
bool active() noexcept { return started_state.load(std::memory_order_relaxed) != nullptr; }
|
||||
void frame(std::uint64_t now_ns) noexcept {
|
||||
if (!active.load(std::memory_order_relaxed)) return;
|
||||
if (!started_state.load(std::memory_order_relaxed)) return;
|
||||
const auto previous = pacing.last.exchange(now_ns, std::memory_order_relaxed);
|
||||
if (!previous || now_ns <= previous) return;
|
||||
const auto took = now_ns - previous;
|
||||
@@ -191,7 +192,7 @@ FramePacing take_frame_pacing() noexcept {
|
||||
void record(std::string_view name, std::string_view operation, std::chrono::milliseconds duration,
|
||||
bool ok, Tags tags, Measurements measurements) noexcept {
|
||||
try {
|
||||
auto* const started = active.load();
|
||||
auto* const started = started_state.load();
|
||||
if (!started) return;
|
||||
auto& s = *started;
|
||||
Transaction transaction;
|
||||
@@ -218,7 +219,7 @@ void record(std::string_view name, std::string_view operation, std::chrono::mill
|
||||
}
|
||||
void flush(std::chrono::milliseconds timeout) noexcept {
|
||||
try {
|
||||
auto* const started = active.load();
|
||||
auto* const started = started_state.load();
|
||||
if (!started) return;
|
||||
auto& s = *started;
|
||||
std::unique_lock lock(s.mutex);
|
||||
|
||||
@@ -18,6 +18,8 @@ using Measurements = std::vector<Measurement>;
|
||||
// unless RESKATE_TRACING=1 (the launcher's "Send performance timings" setting),
|
||||
// and off whenever crash reporting is (RESKATE_CRASH_REPORTING=0).
|
||||
bool start() noexcept;
|
||||
// Whether this launch sends timings (start() succeeded).
|
||||
bool active() noexcept;
|
||||
// Queues one finished operation as a Sentry transaction. Never waits on the
|
||||
// network and never throws. A launch's launcher and game share one trace.
|
||||
void record(std::string_view name, std::string_view operation, std::chrono::milliseconds duration,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk {
|
||||
@@ -193,14 +194,27 @@ HookStatus WINAPI hook_prepare(void* target, void* replacement, void** original)
|
||||
return HookOk;
|
||||
});
|
||||
}
|
||||
namespace {
|
||||
thread_local unsigned batch_depth{}, batch_queued{};
|
||||
}
|
||||
HookStatus WINAPI hook_enable(void* target) {
|
||||
return locked([&](Registry& r) {
|
||||
if (!r.initialized) return HookNotInitialized;
|
||||
auto* hook = find(r, target); if (!hook) return HookNotFound;
|
||||
if (hook->attached) return HookOk;
|
||||
if (batch_depth) {
|
||||
hook->queued = true;
|
||||
++batch_queued;
|
||||
return HookOk;
|
||||
}
|
||||
return transact({{hook, true}});
|
||||
});
|
||||
}
|
||||
HookBatch::HookBatch() noexcept { ++batch_depth; }
|
||||
HookBatch::~HookBatch() {
|
||||
if (--batch_depth || !std::exchange(batch_queued, 0)) return;
|
||||
(void)hook_apply_queued();
|
||||
}
|
||||
HookStatus WINAPI hook_disable(void* target) {
|
||||
return locked([&](Registry& r) {
|
||||
if (!r.initialized) return HookNotInitialized;
|
||||
|
||||
@@ -30,6 +30,19 @@ HookStatus WINAPI hook_disable(void* target);
|
||||
HookStatus WINAPI hook_remove(void* target);
|
||||
HookStatus WINAPI hook_queue_enable(void* target);
|
||||
HookStatus WINAPI hook_apply_queued();
|
||||
// While one is alive on a thread, hook_enable on that thread only queues the hook (and answers
|
||||
// HookOk); the outermost one, as it ends, attaches all of them in one transaction. Each
|
||||
// transaction lists every thread on the system and suspends every one of the game's, a few
|
||||
// milliseconds each, so installs made while the game runs are grouped: on the client thread,
|
||||
// a frame paid for each of them (2026-10-15). A hook that then fails to attach stays detached,
|
||||
// as a failed hook_enable leaves it.
|
||||
class HookBatch {
|
||||
public:
|
||||
HookBatch() noexcept;
|
||||
~HookBatch();
|
||||
HookBatch(const HookBatch&) = delete;
|
||||
HookBatch& operator=(const HookBatch&) = delete;
|
||||
};
|
||||
HookStatus WINAPI hook_shutdown();
|
||||
const char* WINAPI hook_status_string(HookStatus status);
|
||||
// Captured on the calling thread; formatting/logging happens only after Detours
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include "path_text.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <mutex>
|
||||
#include <map>
|
||||
#include <Windows.h>
|
||||
#include <bcrypt.h>
|
||||
#else
|
||||
@@ -304,7 +306,8 @@ void validate_game_file(const fs::path& path) {
|
||||
detail::fail("Skate.exe SHA-256 does not match the supported build");
|
||||
}
|
||||
|
||||
void validate_steam_api_file(const fs::path& path) {
|
||||
namespace {
|
||||
void check_steam_api_file(const fs::path& path) {
|
||||
std::error_code error;
|
||||
const auto size = fs::file_size(path, error);
|
||||
if (error || size != expected_steam_api_file_size)
|
||||
@@ -317,6 +320,35 @@ void validate_steam_api_file(const fs::path& path) {
|
||||
if (sha256_file(path) != expected_steam_api_sha256)
|
||||
detail::fail("steam_api64.dll is not the supported original Steam library (SHA-256 mismatch)");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// Checked once a file for the process: the file is read and hashed, and the game's Steam
|
||||
// features each ask on first use, some again every few seconds while Steam is not ready, on the
|
||||
// client thread (2026-10-15). What it found, either way, is the answer after that.
|
||||
void validate_steam_api_file(const fs::path& path) {
|
||||
static std::mutex mutex;
|
||||
static std::map<std::wstring, std::string> checked; // path -> the failure, empty when it passed
|
||||
const auto key = path.wstring();
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
if (const auto found = checked.find(key); found != checked.end()) {
|
||||
if (!found->second.empty()) detail::fail(found->second.c_str());
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::string failure;
|
||||
try {
|
||||
check_steam_api_file(path);
|
||||
} catch (const std::exception& error) {
|
||||
failure = error.what();
|
||||
if (failure.empty()) failure = "steam_api64.dll could not be checked";
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
checked.emplace(key, failure);
|
||||
}
|
||||
if (!failure.empty()) detail::fail(failure.c_str());
|
||||
}
|
||||
|
||||
#else // _WIN32
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ std::atomic<bool> sampling{false};
|
||||
namespace {
|
||||
std::atomic<Site*> sites{nullptr};
|
||||
std::atomic<bool> hud_visible{false}, window_visible{false}, monitor_running{false};
|
||||
std::atomic<bool> zones_kept{false};
|
||||
std::atomic<std::uint64_t> own_latest{0}, present_count{0};
|
||||
|
||||
// Recent values for the HUD's graphs. One writer each (the client update thread, the present
|
||||
// thread); the overlay reads them racily, which at worst shows one value twice.
|
||||
@@ -241,7 +243,7 @@ void monitor() {
|
||||
std::lock_guard lock(summary_mutex);
|
||||
latest_summary = std::move(next);
|
||||
}
|
||||
zones_enabled.store(false, std::memory_order_relaxed);
|
||||
zones_enabled.store(zones_kept.load(std::memory_order_relaxed), std::memory_order_relaxed);
|
||||
monitor_running.store(false, std::memory_order_release);
|
||||
// Something may have asked again between the last check and the flag being cleared.
|
||||
if (wanted()) detail::wake_monitor();
|
||||
@@ -270,6 +272,7 @@ Site::Site(const char* site_name) noexcept : name(site_name) {
|
||||
void record_client_update(std::uint64_t own_ns) noexcept {
|
||||
// Known even while off, so a sample can target it straight away.
|
||||
detail::client_thread.store(GetCurrentThreadId(), std::memory_order_relaxed);
|
||||
own_latest.store(own_ns, std::memory_order_relaxed);
|
||||
if (!zones_enabled.load(std::memory_order_relaxed)) return;
|
||||
static std::uint64_t last{};
|
||||
const auto now = now_ns();
|
||||
@@ -290,6 +293,7 @@ void record_client_frame(std::uint64_t frame_ns) noexcept {
|
||||
|
||||
void record_present() noexcept {
|
||||
detail::present_thread.store(GetCurrentThreadId(), std::memory_order_relaxed);
|
||||
present_count.fetch_add(1, std::memory_order_relaxed);
|
||||
if (!zones_enabled.load(std::memory_order_relaxed)) return;
|
||||
static std::uint64_t last{};
|
||||
const auto now = now_ns();
|
||||
@@ -315,6 +319,51 @@ void set_window(bool visible) noexcept { window_visible.store(visible, std::memo
|
||||
bool window() noexcept { return window_visible.load(std::memory_order_acquire); }
|
||||
bool active() noexcept { return wanted(); }
|
||||
|
||||
void keep_zones(bool on) noexcept {
|
||||
zones_kept.store(on, std::memory_order_relaxed);
|
||||
if (on) zones_enabled.store(true, std::memory_order_relaxed);
|
||||
else if (!monitor_running.load(std::memory_order_acquire)) zones_enabled.store(false, std::memory_order_relaxed);
|
||||
}
|
||||
std::uint64_t last_client_own_ns() noexcept { return own_latest.load(std::memory_order_relaxed); }
|
||||
std::uint64_t presents() noexcept { return present_count.load(std::memory_order_relaxed); }
|
||||
|
||||
void mark_zones(ZoneMark& mark) noexcept {
|
||||
try {
|
||||
mark.totals.clear();
|
||||
for (const auto* site = sites.load(std::memory_order_acquire); site; site = site->next)
|
||||
mark.totals.emplace_back(site, site->ticks.load(std::memory_order_relaxed));
|
||||
mark.tsc = __rdtsc();
|
||||
mark.ns = now_ns();
|
||||
} catch (...) { mark.totals.clear(); }
|
||||
}
|
||||
|
||||
std::vector<ZoneTime> zones_since(const ZoneMark& mark, std::size_t most) noexcept {
|
||||
std::vector<ZoneTime> result;
|
||||
try {
|
||||
const auto ns = now_ns(), tsc = __rdtsc();
|
||||
if (ns <= mark.ns || tsc <= mark.tsc) return result;
|
||||
// Ticks to milliseconds over this very stretch.
|
||||
const double ms_per_tick = static_cast<double>(ns - mark.ns) / 1e6 / static_cast<double>(tsc - mark.tsc);
|
||||
for (const auto* site = sites.load(std::memory_order_acquire); site; site = site->next) {
|
||||
const auto now = site->ticks.load(std::memory_order_relaxed);
|
||||
// (Sites only ever join the list's head, so one the mark lacks began at zero; the
|
||||
// profiler window's summary sets them back to zero each second, and a total below
|
||||
// the mark's started again since.)
|
||||
const auto found = std::ranges::find(mark.totals, site, &std::pair<const Site*, std::uint64_t>::first);
|
||||
const auto before = found == mark.totals.end() ? 0 : found->second;
|
||||
const auto ticks = now >= before ? now - before : now;
|
||||
if (!ticks) continue;
|
||||
const auto ms = static_cast<double>(ticks) * ms_per_tick;
|
||||
const auto same = std::ranges::find(result, std::string_view(site->name), &ZoneTime::name);
|
||||
if (same != result.end()) same->milliseconds += ms;
|
||||
else result.push_back({site->name, ms});
|
||||
}
|
||||
std::ranges::sort(result, [](const ZoneTime& a, const ZoneTime& b) { return a.milliseconds > b.milliseconds; });
|
||||
if (result.size() > most) result.resize(most);
|
||||
} catch (...) { result.clear(); }
|
||||
return result;
|
||||
}
|
||||
|
||||
std::shared_ptr<const Summary> summary() noexcept {
|
||||
std::lock_guard lock(summary_mutex);
|
||||
return latest_summary;
|
||||
|
||||
@@ -71,6 +71,28 @@ enum class Series { client_interval, client_frame, client_own, present_interval
|
||||
// The most recent values in milliseconds, oldest first; returns how many were written.
|
||||
std::size_t history(Series series, std::span<float> out) noexcept;
|
||||
|
||||
// ---- describing a freeze ----------------------------------------------------------------------
|
||||
// Kept on while this launch sends timings (Runtime/frame_timing.cpp): zones then time ReSkate's
|
||||
// work whatever the profiler window shows, so a frame that froze can say what of ReSkate's ran in
|
||||
// it. Off again leaves them to the window.
|
||||
void keep_zones(bool on) noexcept;
|
||||
// ReSkate's own share of the latest client update, and how many presents there have been: both
|
||||
// counted always (a store and an add).
|
||||
std::uint64_t last_client_own_ns() noexcept;
|
||||
std::uint64_t presents() noexcept;
|
||||
// Every zone's running total at one moment; zones_since then gives the zones that took the most
|
||||
// time between that moment and now, longest first. A mark is reused from frame to frame.
|
||||
struct ZoneMark {
|
||||
std::vector<std::pair<const Site*, std::uint64_t>> totals;
|
||||
std::uint64_t tsc{}, ns{};
|
||||
};
|
||||
void mark_zones(ZoneMark& mark) noexcept;
|
||||
struct ZoneTime {
|
||||
std::string name;
|
||||
double milliseconds{};
|
||||
};
|
||||
std::vector<ZoneTime> zones_since(const ZoneMark& mark, std::size_t most) noexcept;
|
||||
|
||||
// ---- what is on ------------------------------------------------------------------------------
|
||||
// The HUD and window are the overlay's (both start off each launch); turning either on starts
|
||||
// the timing and thread monitor.
|
||||
|
||||
@@ -212,7 +212,12 @@ void apply_changes(sqlite3* db, SaveSchema schema, const Json* before, const Jso
|
||||
void configure_writer(sqlite3* db) {
|
||||
Statement mode(db, "PRAGMA journal_mode=WAL");
|
||||
check(mode.row() && mode.text(0) == "wal" && !mode.row(), "Cannot enable save database WAL");
|
||||
execute(db, "PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL; PRAGMA wal_autocheckpoint=256; PRAGMA journal_size_limit=1048576; PRAGMA max_page_count=16384;");
|
||||
// NORMAL, not FULL: in WAL mode a commit then no longer waits for the disk. A transaction is
|
||||
// still kept when the game crashes (it is in the operating system's hands); only a power
|
||||
// loss or a crash of Windows itself can take back the last commits, never corrupt the file.
|
||||
// FULL made every commit wait for a flush, on the game's own threads: each play event the
|
||||
// game saves, each option, under locks the game's hooks wait on (2026-10-15).
|
||||
execute(db, "PRAGMA foreign_keys=ON; PRAGMA synchronous=NORMAL; PRAGMA wal_autocheckpoint=256; PRAGMA journal_size_limit=1048576; PRAGMA max_page_count=16384;");
|
||||
}
|
||||
void check_size(const std::filesystem::path& path) { check(std::filesystem::file_size(path) <= max_bytes, "Save database exceeds size limit"); }
|
||||
void clear_staging(const std::filesystem::path& path) {
|
||||
|
||||
@@ -34,6 +34,10 @@ inline constexpr std::size_t component_owner = 0x18;
|
||||
// (rig, joint id, 1): the joint's index in the rig's skeleton, or -1. Reads no pose.
|
||||
inline constexpr Function joint_index{
|
||||
0x12e08b0, {0x48,0x8b,0x89,0x38,0x01,0x00,0x00,0xe9,0x84,0x76,0x5b,0x03,0xcc,0xcc,0xcc,0xcc}};
|
||||
// joint_index looks the joint up in the skeleton's map (EASTL: a node's right child at +0, its
|
||||
// left at +8, its joint id at +0x20; the root at skeleton +0x68), with no bound on the walk
|
||||
// (0x4897f40).
|
||||
inline constexpr std::size_t skeleton_joints_root = 0x68, joint_node_right = 0, joint_node_left = 8, joint_node_id = 0x20;
|
||||
// An effects component's rig: component -> its rig's owner -> the rig; the rig's skeleton. An
|
||||
// effect set's effects (an array of pointers), and the joint each sits on (-1: none).
|
||||
inline constexpr std::size_t component_rig_owner = 0x50, rig_owner_rig = 0x78, rig_skeleton = 0x138, set_items = 0x30,
|
||||
|
||||
@@ -91,9 +91,11 @@ inline constexpr std::ptrdiff_t relation_mask_vector_offset = 0x114ab60;
|
||||
|
||||
inline constexpr std::uintptr_t constructor_rva = 0x03e7f300;
|
||||
// The constructor is the base of two renderers. Only the one constructed here
|
||||
// (vtable 0x64f2c10) has the resource-state and relation-mask vectors: every
|
||||
// read of owner+0x2da920 and owner+0x114ab60 is in its code, and the other
|
||||
// (constructed at 0x030a0a46, vtable 0x6414168) keeps fields of its own there.
|
||||
// (vtable 0x64f2c10, 0x11ab080 bytes) has the resource-state and relation-mask
|
||||
// vectors and the metadata fields: every read of owner+0x2da920, owner+0x114ab60
|
||||
// and owner+0x11aae28 is in its code. The other (constructed at 0x030a0a46,
|
||||
// vtable 0x6414168) is 0x2daa00 bytes (Skate.exe+0x30c64cc): it keeps fields of
|
||||
// its own at +0x2da920, and the rest are past its end.
|
||||
inline constexpr std::uintptr_t state_owner_call_rva = 0x03f33a46;
|
||||
inline constexpr std::uintptr_t index_table_constructor_rva = 0x03e7e400;
|
||||
inline constexpr std::uintptr_t resource_flags_priority_rva = 0x03f56073;
|
||||
|
||||
@@ -8,3 +8,8 @@ extern "C" __declspec(dllexport) std::int64_t __cdecl OodleLZ_Decompress(
|
||||
*static_cast<unsigned char*>(output) = *static_cast<const unsigned char*>(input);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The buffer a decode into `rawSize` bytes needs: as the real codec, a little more than asked.
|
||||
extern "C" __declspec(dllexport) std::int64_t __cdecl OodleLZ_GetDecodeBufferSize(int, std::int64_t rawSize, int) {
|
||||
return rawSize + 16;
|
||||
}
|
||||
@@ -50,6 +50,10 @@ private:
|
||||
|
||||
// Oodle is the game's own module, so it is resolved once and never unloaded.
|
||||
#ifdef _WIN32
|
||||
// What Oodle does when one of its checks fails on what it is given: its default stops the
|
||||
// process at a breakpoint (the launcher's mod scan, "EXCEPTION_BREAKPOINT" in oo2core,
|
||||
// 2026-10-15). Not stopping, the call fails and the block is reported as unreadable.
|
||||
int __cdecl oodle_assertion(const char*, int, const char*, const char*) { return 0; }
|
||||
HMODULE oodle(const std::filesystem::path& gameRoot) {
|
||||
static std::mutex mutex;
|
||||
static HMODULE handle{};
|
||||
@@ -74,6 +78,13 @@ HMODULE oodle(const std::filesystem::path& gameRoot) {
|
||||
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32);
|
||||
if (!handle) throw std::runtime_error("Cannot load " + path_utf8(matches.back()) +
|
||||
" (Windows error " + std::to_string(GetLastError()) + ")");
|
||||
// (Only for a copy loaded here: the game's own, found above, keeps the game's handler.)
|
||||
using Assertion = int(__cdecl*)(const char*, int, const char*, const char*);
|
||||
#pragma warning(push)
|
||||
#pragma warning(disable: 4191)
|
||||
if (const auto set = reinterpret_cast<Assertion (__cdecl*)(Assertion)>(GetProcAddress(handle, "OodleCore_Plugins_SetAssertion")))
|
||||
#pragma warning(pop)
|
||||
set(&oodle_assertion);
|
||||
return handle;
|
||||
}
|
||||
|
||||
@@ -134,12 +145,24 @@ std::vector<std::byte> oodle_decode(std::span<const std::byte> input, std::size_
|
||||
#ifdef _WIN32
|
||||
using Function = std::int64_t(__cdecl*)(const void*, std::int64_t, void*, std::int64_t, int, int,
|
||||
int, void*, std::int64_t, void*, void*, void*, std::int64_t, int);
|
||||
using BufferSize = std::int64_t(__cdecl*)(int, std::int64_t, int);
|
||||
const auto decode = oodle_function<Function>(gameRoot, "OodleLZ_Decompress");
|
||||
std::vector<std::byte> output(size);
|
||||
// A block can be corrupt, or not a block at all (read_manifest tries a payload as one when
|
||||
// it does not read as a manifest), and its size is only what its header says. Oodle may
|
||||
// then write past that size: the buffer it says such data needs (for any compressor) is
|
||||
// given, and cut to the size afterwards. With exactly the size, it overwrote the heap
|
||||
// (the launcher's mod scan crashing in malloc after an Oodle block, 2026-10-15).
|
||||
constexpr int any_compressor = -1; // OodleLZ_Compressor_Invalid
|
||||
constexpr std::int64_t block = 0x40000; // OODLELZ_BLOCK_LEN
|
||||
const auto needed = oodle_function<BufferSize>(gameRoot, "OodleLZ_GetDecodeBufferSize")(
|
||||
any_compressor, static_cast<std::int64_t>(size), 1);
|
||||
const auto padded = std::max<std::int64_t>(needed, static_cast<std::int64_t>(size) + block);
|
||||
std::vector<std::byte> output(static_cast<std::size_t>(padded));
|
||||
if (decode(input.data(), static_cast<std::int64_t>(input.size()), output.data(),
|
||||
static_cast<std::int64_t>(output.size()), 1, 0, 0, nullptr, 0, nullptr, nullptr,
|
||||
static_cast<std::int64_t>(size), 1, 0, 0, nullptr, 0, nullptr, nullptr,
|
||||
nullptr, 0, 3) != static_cast<std::int64_t>(size))
|
||||
throw std::runtime_error("Oodle CAS block failed to decode");
|
||||
output.resize(size);
|
||||
return output;
|
||||
#else
|
||||
(void)input;
|
||||
|
||||
@@ -131,13 +131,52 @@ void ask() {
|
||||
ask_until.store(std::min(deadline.load(), multiplayer::now_us() + policy::confirmation_us));
|
||||
advance(Stage::checking, Stage::asking);
|
||||
}
|
||||
// The packages of the maps installed here, by level (package_of): forgotten when one is installed.
|
||||
struct Known {
|
||||
std::string package;
|
||||
std::uint64_t until{};
|
||||
// The packages of the maps installed here, by level (lower case), read from the Mods folder in
|
||||
// one pass (package_of). Forgotten when a map is installed; read again in the background two
|
||||
// minutes after it was read. It was read for each level asked, on the client thread, with every
|
||||
// map offer a host sends (a guest joining, a map change): tens of milliseconds, a second and more
|
||||
// with many mods (2026-10-15).
|
||||
struct KnownPackages {
|
||||
std::map<std::string, std::string, std::less<>> by_level;
|
||||
std::uint64_t read_at{};
|
||||
bool valid{}, reading{};
|
||||
};
|
||||
std::mutex known_mutex;
|
||||
std::map<std::string, Known, std::less<>> known_packages;
|
||||
KnownPackages known_packages;
|
||||
|
||||
std::map<std::string, std::string, std::less<>> read_known_packages() {
|
||||
std::map<std::string, std::string, std::less<>> table;
|
||||
const auto list = mods::scan_mods(mods::engine_data_root());
|
||||
for (const auto& entry : list.entries) {
|
||||
if (!entry.enabled || !entry.mod.provides_levels) continue;
|
||||
const auto package = multiplayer::map_package_name(entry.mod.name, entry.mod.version);
|
||||
for (auto level : entry.mod.levels) {
|
||||
std::transform(level.begin(), level.end(), level.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
table.emplace(std::move(level), package); // (the first mod that has a level is the one)
|
||||
}
|
||||
}
|
||||
return table;
|
||||
}
|
||||
// Reads the table on a thread of its own when there is none, or it is two minutes old. Under
|
||||
// known_mutex.
|
||||
void refresh_known_packages(std::uint64_t now) {
|
||||
if (known_packages.reading || (known_packages.valid && now - known_packages.read_at < 120000)) return;
|
||||
known_packages.reading = true;
|
||||
try {
|
||||
std::thread([] {
|
||||
std::map<std::string, std::string, std::less<>> table;
|
||||
bool ok{};
|
||||
try { table = read_known_packages(); ok = true; } catch (...) {}
|
||||
std::lock_guard lock(known_mutex);
|
||||
if (ok) {
|
||||
known_packages.by_level = std::move(table);
|
||||
known_packages.read_at = GetTickCount64();
|
||||
known_packages.valid = true;
|
||||
}
|
||||
known_packages.reading = false;
|
||||
}).detach();
|
||||
} catch (...) { known_packages.reading = false; }
|
||||
}
|
||||
|
||||
// The mod's row in mods.json when its folder is here: whether it is switched on, and what it
|
||||
// says of itself.
|
||||
@@ -273,7 +312,7 @@ void fetch() {
|
||||
if (!continuing()) return;
|
||||
{
|
||||
std::lock_guard known(known_mutex);
|
||||
known_packages.clear();
|
||||
known_packages.valid = false;
|
||||
}
|
||||
} else {
|
||||
if (!advance(Stage::downloading, Stage::installing)) return;
|
||||
@@ -451,6 +490,7 @@ View view() {
|
||||
}
|
||||
|
||||
void tick() {
|
||||
warm_packages();
|
||||
std::optional<multiplayer::MapNeed> next;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
@@ -540,26 +580,32 @@ void tick() {
|
||||
}
|
||||
}
|
||||
|
||||
void warm_packages() noexcept {
|
||||
try {
|
||||
std::lock_guard lock(known_mutex);
|
||||
if (!known_packages.valid) refresh_known_packages(GetTickCount64());
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
std::string package_of(std::string_view asset) {
|
||||
auto& cache_mutex = known_mutex;
|
||||
auto& cache = known_packages;
|
||||
if (asset.empty()) return {};
|
||||
std::string key(asset);
|
||||
std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
const auto now = GetTickCount64();
|
||||
std::lock_guard lock(cache_mutex);
|
||||
if (const auto found = cache.find(key); found != cache.end() && now < found->second.until) return found->second.package;
|
||||
// Asked with every map offer a host sends: the Mods folder is read at most every two
|
||||
// minutes for a level.
|
||||
std::string result;
|
||||
const auto list = mods::scan_mods(mods::engine_data_root());
|
||||
for (const auto& entry : list.entries) {
|
||||
if (!entry.enabled || !entry.mod.provides_levels) continue;
|
||||
if (std::none_of(entry.mod.levels.begin(), entry.mod.levels.end(), [&](const std::string& level) { return same(level, key); })) continue;
|
||||
result = multiplayer::map_package_name(entry.mod.name, entry.mod.version);
|
||||
break;
|
||||
std::unique_lock lock(known_mutex);
|
||||
// (Only with no table at all, just after a map was installed, is the Mods folder read here.)
|
||||
if (!known_packages.valid) {
|
||||
lock.unlock();
|
||||
auto table = read_known_packages();
|
||||
lock.lock();
|
||||
if (!known_packages.valid) {
|
||||
known_packages.by_level = std::move(table);
|
||||
known_packages.read_at = now;
|
||||
known_packages.valid = true;
|
||||
}
|
||||
}
|
||||
cache[key] = {result, now + 120000};
|
||||
return result;
|
||||
refresh_known_packages(now);
|
||||
const auto found = known_packages.by_level.find(key);
|
||||
return found != known_packages.by_level.end() ? found->second : std::string{};
|
||||
}
|
||||
}
|
||||
@@ -60,4 +60,7 @@ void tick();
|
||||
// The Thunderstore package an enabled mod's level comes from (its folder and manifest), for a
|
||||
// host to tell its guests; empty when the level is the game's own or the mod is not a package.
|
||||
std::string package_of(std::string_view asset);
|
||||
// Reads the table package_of answers from, on a thread of its own, if it has not been (so the
|
||||
// first map offer does not wait on the Mods folder). Cheap to call each frame; tick() does.
|
||||
void warm_packages() noexcept;
|
||||
}
|
||||
@@ -308,9 +308,13 @@ void* __fastcall construct_native_renderer(void* self, void* configuration) {
|
||||
// The patched constructor loads its initial vector pointer from +0x90,
|
||||
// constructs the native default record there, and advances +0x98 itself.
|
||||
store_field(self, vector_begin_offset, buffers.records);
|
||||
store_field(self, metadata_begin_offset, buffers.metadata);
|
||||
store_field(self, metadata_end_offset, metadata_end);
|
||||
// The metadata fields, like the state vectors, are the state owner's alone: the other
|
||||
// renderer is 0x2daa00 bytes (Skate.exe+0x30c64cc), and +0x11aae28 is 18 MB past its end.
|
||||
// Written there for every launch until 2026-10-15, they went into whatever memory lay there
|
||||
// (a crash in store_field when it was not mapped).
|
||||
if (states) {
|
||||
store_field(self, metadata_begin_offset, buffers.metadata);
|
||||
store_field(self, metadata_end_offset, metadata_end);
|
||||
store_field(self, resource_state_vector_offset, buffers.resource_states);
|
||||
store_field(self, relation_mask_vector_offset, relation_mask_begin);
|
||||
}
|
||||
@@ -319,9 +323,11 @@ void* __fastcall construct_native_renderer(void* self, void* configuration) {
|
||||
|
||||
// The side-array fields are initialized by later renderer setup on the
|
||||
// stock build. Keep the external values authoritative across construction.
|
||||
store_field(self, metadata_begin_offset, buffers.metadata);
|
||||
store_field(self, metadata_end_offset, metadata_end);
|
||||
if (states) state_owner.store(reinterpret_cast<std::uintptr_t>(self), std::memory_order_release);
|
||||
if (states) {
|
||||
store_field(self, metadata_begin_offset, buffers.metadata);
|
||||
store_field(self, metadata_end_offset, metadata_end);
|
||||
state_owner.store(reinterpret_cast<std::uintptr_t>(self), std::memory_order_release);
|
||||
}
|
||||
if (load_field<std::byte*>(self, vector_begin_offset) != buffers.records ||
|
||||
load_field<std::byte*>(self, vector_capacity_offset) != record_end ||
|
||||
load_field<std::byte*>(self, vector_end_offset) < buffers.records ||
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
#include <Windows.h>
|
||||
#include <shlobj.h>
|
||||
#include <algorithm>
|
||||
#include <thread>
|
||||
#include <sstream>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
@@ -455,13 +457,13 @@ void load_feel(Feel& f) {
|
||||
}
|
||||
} catch (...) {}
|
||||
}
|
||||
// Written on a thread of its own: this is called from the client tick every few seconds while
|
||||
// wear changes, and a write (a folder lookup, a file truncated and written, whatever the virus
|
||||
// scanner does on close) held the game's frame up (2026-10-15). The text is taken under the lock;
|
||||
// the writes are kept in order by their own.
|
||||
void save_feel(Feel& f) {
|
||||
try {
|
||||
const auto path = settings_path();
|
||||
if (path.empty()) return;
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(path.parent_path(), error);
|
||||
std::ofstream out(path, std::ios::trunc);
|
||||
std::ostringstream out;
|
||||
for (const auto& info : setting_info) out << info.name << "=" << setting_value(f.settings, info.name) << "\n";
|
||||
for (const auto& [deck, worn] : f.wear) out << "wear." << deck << "=" << std::format("{:.4f}", worn) << "\n";
|
||||
if (!f.metal.empty()) {
|
||||
@@ -470,6 +472,18 @@ void save_feel(Feel& f) {
|
||||
out << "\n";
|
||||
}
|
||||
f.dirty = false;
|
||||
std::thread([text = out.str()] {
|
||||
static std::mutex writing;
|
||||
std::lock_guard lock(writing);
|
||||
try {
|
||||
const auto path = settings_path();
|
||||
if (path.empty()) return;
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(path.parent_path(), error);
|
||||
std::ofstream file(path, std::ios::trunc);
|
||||
file << text;
|
||||
} catch (...) {}
|
||||
}).detach();
|
||||
} catch (...) {}
|
||||
}
|
||||
// The settings into the atomics the physics thread reads.
|
||||
@@ -520,7 +534,7 @@ DeckSetup read_deck_setup(std::string_view deck) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The same, kept a few seconds per deck: the menus ask every frame.
|
||||
// The same, kept a minute per deck: the menus ask every frame.
|
||||
DeckSetup setup_of(std::string_view deck) {
|
||||
struct Kept {
|
||||
DeckSetup setup;
|
||||
@@ -531,7 +545,9 @@ DeckSetup setup_of(std::string_view deck) {
|
||||
const auto now = GetTickCount64();
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
if (const auto at = kept.find(deck); at != kept.end() && now - at->second.at < 3000) return at->second.setup;
|
||||
// (A minute: each look lists every mod's folder, and the world model asked every few seconds
|
||||
// on the client thread while a breakable deck was out.)
|
||||
if (const auto at = kept.find(deck); at != kept.end() && now - at->second.at < 60000) return at->second.setup;
|
||||
}
|
||||
auto setup = read_deck_setup(deck);
|
||||
std::lock_guard lock(mutex);
|
||||
|
||||
@@ -10,9 +10,11 @@ namespace {
|
||||
constexpr ULONGLONG grace_ms = 8000, watch_ms = 120000;
|
||||
std::atomic<bool> watching{};
|
||||
std::atomic<ULONGLONG> destroyed_at{};
|
||||
std::atomic<CloseNotice> close_notice{};
|
||||
|
||||
void watch(HWND window) noexcept {
|
||||
const auto asked = GetTickCount64();
|
||||
if (const auto notice = close_notice.load()) notice();
|
||||
ULONGLONG stuck_since{};
|
||||
for (;;) {
|
||||
Sleep(500);
|
||||
@@ -37,6 +39,7 @@ void watch(HWND window) noexcept {
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void when_closing(CloseNotice notice) noexcept { close_notice = notice; }
|
||||
void note_window_message(HWND window, UINT message, WPARAM wp) noexcept {
|
||||
const bool closing = message == WM_CLOSE || message == WM_DESTROY || (message == WM_SYSCOMMAND && (wp & 0xfff0) == SC_CLOSE) ||
|
||||
(message == WM_ENDSESSION && wp);
|
||||
|
||||
@@ -7,4 +7,8 @@ namespace dingosdk::exit_watch {
|
||||
// asked the game to close, this gives it a few seconds and then ends the process.
|
||||
// Called from the game window's procedure with each message of that window.
|
||||
void note_window_message(HWND window, UINT message, WPARAM wp) noexcept;
|
||||
// Called, on the watch's own thread, each time the player asks the game to close, before the
|
||||
// close is watched: for what has to be sent before the process goes. It may take a few seconds.
|
||||
using CloseNotice = void (*)() noexcept;
|
||||
void when_closing(CloseNotice notice) noexcept;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "local_customization_runtime.h"
|
||||
#include "Extension/Profile/runtime_internal.h"
|
||||
@@ -5,6 +6,7 @@
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/engine.h"
|
||||
#include "Engine/Game/Build/20260929/local_customization.h"
|
||||
#include <array>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
|
||||
@@ -36,16 +38,19 @@ std::uint32_t cosmetic_hash(std::string_view value) {
|
||||
return hash;
|
||||
}
|
||||
|
||||
// A name of up to 255 characters, read in place (memory::peek_cstring). It was read a character
|
||||
// at a time through ReadProcessMemory: a system call each, some 100 for a name, for every one of
|
||||
// up to 8192 items and their categories when the catalog is walked, which is done on the client
|
||||
// thread under native_mutex (2026-10-15).
|
||||
bool cosmetic_text(std::uintptr_t p, std::string& value) {
|
||||
value.clear();
|
||||
for (std::size_t i = 0; i <= 255; ++i) {
|
||||
unsigned char c{};
|
||||
if (!read(p + i, c)) return false;
|
||||
if (!c) return true;
|
||||
if (c < 32 || c == 127) return false;
|
||||
value += static_cast<char>(c);
|
||||
}
|
||||
return false;
|
||||
std::array<char, 257> text{};
|
||||
const auto length = dingosdk::memory::peek_cstring(p, text.data(), 256);
|
||||
if (length < 0) return false;
|
||||
for (std::ptrdiff_t i = 0; i < length; ++i)
|
||||
if (static_cast<unsigned char>(text[i]) < 32 || text[i] == 127) return false;
|
||||
value.assign(text.data(), static_cast<std::size_t>(length));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool cosmetic_array(std::uintptr_t p, std::size_t stride, std::size_t limit, std::uint32_t& count) {
|
||||
@@ -339,6 +344,12 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) {
|
||||
std::vector<CosmeticBorrowedArray<CosmeticNativeItem>> item_arrays;
|
||||
words.reserve(16 * 257); item_arrays.reserve(16);
|
||||
std::vector<CosmeticRuntime::HeldSlot> held;
|
||||
// (Looked up once: Json::value returns a copy, and this was asked for every slot, a copy
|
||||
// of the whole inventory each time, in the game's own load call.)
|
||||
static const auto no_inventory = dingosdk::Json::object();
|
||||
const auto inventory_found = snapshot.customization.find("inventory");
|
||||
const auto& inventory = inventory_found != snapshot.customization.end() && inventory_found->is_object()
|
||||
? *inventory_found : no_inventory;
|
||||
for (std::size_t i = 0; i < saved->recipes.size(); ++i) {
|
||||
const auto& recipe = saved->recipes[i]; const auto& expected = defaults.recipes[i];
|
||||
if (recipe.template_key != expected.template_key || recipe.template_version != expected.template_version ||
|
||||
@@ -384,7 +395,6 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) {
|
||||
held.push_back({i, *slot, expected.items[j].asset});
|
||||
slot = &expected.items[j];
|
||||
} else {
|
||||
const auto& inventory = snapshot.customization.value("inventory", dingosdk::Json::object());
|
||||
if (inventory.contains(slot->asset) && !inventory.at(slot->asset).get<bool>())
|
||||
slot = &expected.items[j];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "hall_of_meat.h"
|
||||
#include "Engine/Core/Hooks/hooks.h"
|
||||
#include "hall_of_meat_hud.h"
|
||||
#include "hall_of_meat_overlay.h"
|
||||
#include "hall_of_meat_render.h"
|
||||
@@ -129,8 +130,11 @@ void on_client_tick() noexcept {
|
||||
if (!on && !s.active) return; // switched off: nothing hooked, nothing to give back
|
||||
if (on && !s.active) {
|
||||
prepare_skeleton(); // once, in the background
|
||||
(void)hook_render(true);
|
||||
(void)hook_hud(true);
|
||||
{
|
||||
HookBatch batch; // (one pause of the game's threads for both)
|
||||
(void)hook_render(true);
|
||||
(void)hook_hud(true);
|
||||
}
|
||||
s.active = true;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "native_party_internal.h"
|
||||
#include "Engine/Core/Hooks/hooks.h"
|
||||
#include "Extension/Multiplayer/Steam/steam_social.h"
|
||||
#include "Extension/Customization/local_customization_runtime.h"
|
||||
#include "Extension/Customization/local_player_card_runtime.h"
|
||||
@@ -333,7 +334,10 @@ void update_native_party(std::uintptr_t base, const PartyRoster &roster, unsigne
|
||||
const bool active = std::any_of(roster.begin(), roster.end(), [](const auto &p) { return p.id != 0; });
|
||||
const auto steam = steam_social_snapshot();
|
||||
if (!active && !s.manager && steam->friends.empty()) return;
|
||||
if (!s.attempted) install(base);
|
||||
if (!s.attempted) {
|
||||
HookBatch batch; // (its hooks in one pause of the game's threads)
|
||||
install(base);
|
||||
}
|
||||
if (!s.installed.load(std::memory_order_acquire)) return;
|
||||
const auto now = GetTickCount64();
|
||||
auto previous = s.published.load(std::memory_order_acquire);
|
||||
|
||||
@@ -160,7 +160,25 @@ bool owns(Address component, Address entity) {
|
||||
if (const auto q = ptr(component, off); q && (q == entity || ptr(q) == entity)) return true;
|
||||
return false;
|
||||
}
|
||||
// Whether the game's lookup of a joint in this rig would come to an end. It walks the
|
||||
// skeleton's map with no bound, and a rig whose memory has been taken for something else can
|
||||
// hold a loop: the game's frame then never ends and its watchdog closes it ("GameStall", seen
|
||||
// with this frame's thread in call_joint, 2026-10-15). The same walk is made here first, with a
|
||||
// bound no real skeleton comes near.
|
||||
bool joint_lookup_ends(Address rig, std::uint32_t joint) {
|
||||
auto node = ptr(ptr(rig, vfx::rig_skeleton), vfx::skeleton_joints_root);
|
||||
for (int steps = 0; steps < 64; ++steps) {
|
||||
if (!node) return true;
|
||||
std::uint32_t id{};
|
||||
if (!readable(node + vfx::joint_node_id, &id, sizeof(id))) return false;
|
||||
Address next{};
|
||||
if (!readable(node + (id < joint ? vfx::joint_node_right : vfx::joint_node_left), &next, sizeof(next))) return false;
|
||||
node = next;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
int call_joint(State &s, Address rig, std::uint32_t joint) noexcept {
|
||||
if (!joint_lookup_ends(rig, joint)) return -1;
|
||||
__try {
|
||||
return reinterpret_cast<JointIndex>(s.base + vfx::joint_index.rva)(rig, joint, 1);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
@@ -196,7 +214,12 @@ Address build_hook(Address manager, std::uint64_t *entity, int id, Address *sett
|
||||
if (pair != s.pairs.end()) {
|
||||
block.assign(1, 0);
|
||||
if (owns(component, pair->skater)) {
|
||||
const auto rig = ptr(ptr(pair->board_component, vfx::component_rig_owner), vfx::rig_owner_rig);
|
||||
// (The skateboard's component as it was when the pair was made: one the game has
|
||||
// since destroyed is no longer an effects component of that skateboard, and its
|
||||
// rig is not looked at. No set then fits.)
|
||||
const bool live = ptr(pair->board_component) == s.base + vfx::component_vtable &&
|
||||
owns(pair->board_component, pair->board);
|
||||
const auto rig = live ? ptr(ptr(pair->board_component, vfx::component_rig_owner), vfx::rig_owner_rig) : 0;
|
||||
std::vector<Address> moved;
|
||||
for (std::uint32_t i = 0; i < count; ++i) {
|
||||
const auto element = ptr(data, i * 8ULL);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "session_internal.h"
|
||||
#include "Engine/Core/Hooks/hooks.h"
|
||||
#include "Extension/Skater/drone_beacon.h"
|
||||
#include "Engine/Core/Debug/trace.h"
|
||||
#include "Extension/Multiplayer/Hud/native_player_ui.h"
|
||||
@@ -894,7 +895,11 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_vi
|
||||
~VoicePlaybackTick() { voice.process_native(base); }
|
||||
} voice_playback_tick{s.voice, base};
|
||||
s.base = base;
|
||||
initialize_native_throwdowns(base);
|
||||
{
|
||||
// (Its hooks, and the throwdown lab's, go in together: one pause of the game's threads.)
|
||||
HookBatch batch;
|
||||
initialize_native_throwdowns(base);
|
||||
}
|
||||
profile_runtime::install_script_error_log(base);
|
||||
profile_runtime::install_board_wear_hold(base);
|
||||
s.gameplay_ready = ready;
|
||||
@@ -980,13 +985,17 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_vi
|
||||
if (ready && !map_name.empty() && !s.hooks_prepared) {
|
||||
s.hooks_prepared = true;
|
||||
const auto started = now_us();
|
||||
prepare_native_indicators(base);
|
||||
prepare_player_ui(base);
|
||||
std::string entity_detail;
|
||||
if (!install_entity_hooks(base, entity_detail) && !entity_detail.empty())
|
||||
logging::log(logging::Level::warning, logging::Channel::runtime, "Multiplayer: {}", entity_detail);
|
||||
prepare_remote_audio(base);
|
||||
prepare_effects(base);
|
||||
{
|
||||
// All of them in one pause of the game's threads, not one each.
|
||||
HookBatch batch;
|
||||
prepare_native_indicators(base);
|
||||
prepare_player_ui(base);
|
||||
if (!install_entity_hooks(base, entity_detail) && !entity_detail.empty())
|
||||
logging::log(logging::Level::warning, logging::Channel::runtime, "Multiplayer: {}", entity_detail);
|
||||
prepare_remote_audio(base);
|
||||
prepare_effects(base);
|
||||
}
|
||||
logging::log(logging::Level::info, logging::Channel::runtime,
|
||||
"Multiplayer: remote-player hooks installed in {} ms (once, before anyone joins).",
|
||||
(now_us() - started) / 1000);
|
||||
|
||||
@@ -75,7 +75,9 @@ std::shared_ptr<const SteamSocialSnapshot> steam_social_snapshot() {
|
||||
void *friends{};
|
||||
SteamSocialSnapshot result;
|
||||
} refresh;
|
||||
constexpr int friends_per_tick = 8;
|
||||
// (Two: each is four or five calls into the Steam client, on the client thread, and a call waits
|
||||
// whenever Steam is busy. A pass over a few hundred friends still takes a few seconds.)
|
||||
constexpr int friends_per_tick = 2;
|
||||
std::lock_guard lock(mutex);
|
||||
const auto now = GetTickCount64();
|
||||
const auto publish = [&](SteamSocialSnapshot &result) {
|
||||
|
||||
@@ -174,6 +174,7 @@ struct SteamTransport::Impl {
|
||||
std::to_string(static_cast<int>(s.m_flInBytesPerSec / 1024.f + .5f)) + " KB/s in";
|
||||
}
|
||||
std::uint64_t frame{};
|
||||
std::uint64_t name_asked_frame{}; // the poll frame a known name was last asked of Steam again
|
||||
ULONGLONG next_measure{}, next_sweep{};
|
||||
struct Name {
|
||||
std::string text;
|
||||
@@ -689,10 +690,12 @@ void SteamTransport::poll() {
|
||||
p.run_callbacks(p.sockets);
|
||||
const auto now = GetTickCount64();
|
||||
// Link names only label the first connection in diagnostics; the session keeps its own.
|
||||
// (From the names already read: asking Steam for every link here was a burst of its calls.)
|
||||
if (now >= p.name_refresh) {
|
||||
p.name_refresh = now + 30000;
|
||||
for (auto &[id, link] : p.links)
|
||||
link.name = name(id);
|
||||
if (const auto known = p.names.find(id); known != p.names.end() && !known->second.text.empty())
|
||||
link.name = known->second.text;
|
||||
}
|
||||
std::deque<SteamNetConnectionStatusChangedCallback_t> events;
|
||||
{
|
||||
@@ -898,9 +901,15 @@ std::string SteamTransport::name(std::uint64_t id) {
|
||||
return {};
|
||||
// Each lookup is two calls into the Steam client. Names change rarely: ask again
|
||||
// after 10 s, or after 1 s while Steam does not know the name yet.
|
||||
// A name read before is given as it is while it is asked again, and only one of those a
|
||||
// frame is asked: the roster asks for every player's every two seconds, and their names ran
|
||||
// out together, so one frame asked Steam for all of them (ten milliseconds and more each).
|
||||
const auto now = GetTickCount64();
|
||||
if (const auto cached = p.names.find(id); cached != p.names.end() && now < cached->second.expires)
|
||||
return cached->second.text;
|
||||
if (const auto cached = p.names.find(id); cached != p.names.end()) {
|
||||
if (now < cached->second.expires) return cached->second.text;
|
||||
if (!cached->second.text.empty() && p.name_asked_frame == p.frame) return cached->second.text;
|
||||
if (!cached->second.text.empty()) p.name_asked_frame = p.frame;
|
||||
}
|
||||
p.request_name(p.friends, id, true);
|
||||
const auto *value = p.persona_name(p.friends, id);
|
||||
auto text = value ? bounded(value, 128) : std::string{};
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
#include "Extension/Profile/runtime_internal.h"
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <thread>
|
||||
|
||||
namespace dingosdk::profile_runtime {
|
||||
// Snapshot the game's registered music assets, as the cosmetic catalog does
|
||||
@@ -39,11 +43,11 @@ bool music_asset_type(std::uintptr_t asset, std::uintptr_t vtable_rva) {
|
||||
return asset && read(asset, vtable) && vtable == local_runtime().base + vtable_rva;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// reskate-music.json in each enabled mod: {"schema":1,"playlists":[{"name":..,
|
||||
// "songs":["artist - title",..]}]}.
|
||||
// Read on every call: it only runs while the music page waits for its catalog. A bad
|
||||
// file is logged and skipped; its songs still play from the master playlist.
|
||||
std::vector<mods::MusicPlaylist> mod_music_playlists() {
|
||||
// "songs":["artist - title",..]}]}. A bad file is logged and skipped; its songs still play
|
||||
// from the master playlist.
|
||||
std::vector<mods::MusicPlaylist> read_mod_music_playlists() {
|
||||
std::vector<mods::MusicPlaylist> result;
|
||||
for (const auto& mod : mods::catalog().mods) {
|
||||
const auto path = mod.directory / L"reskate-music.json";
|
||||
@@ -65,6 +69,97 @@ std::vector<mods::MusicPlaylist> mod_music_playlists() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// What the files said, as last read, and when each was written then. Reading them takes a
|
||||
// path check for every song's cover and, the first time, its PNG: seconds of disk for a large
|
||||
// pack. That was done on the game's frame, holding native_mutex (which the park tick waits
|
||||
// on), every 250 ms while the music page waited for its catalog, until the game's watchdog
|
||||
// closed the game ("GameStall", 2026-10-15). So they are read on a thread of their own, and
|
||||
// read again only when one of the files has changed.
|
||||
struct ModMusic {
|
||||
std::mutex mutex;
|
||||
std::shared_ptr<const std::vector<mods::MusicPlaylist>> ready; // null until the first read is done
|
||||
std::vector<std::filesystem::file_time_type> stamps; // of the files `ready` was read from
|
||||
bool reading{};
|
||||
std::uint64_t looked{}; // GetTickCount64, the last read was started
|
||||
};
|
||||
ModMusic& mod_music() {
|
||||
static auto* music = new ModMusic;
|
||||
return *music;
|
||||
}
|
||||
std::vector<std::filesystem::file_time_type> mod_music_stamps() {
|
||||
std::vector<std::filesystem::file_time_type> stamps;
|
||||
for (const auto& mod : mods::catalog().mods) {
|
||||
std::error_code error;
|
||||
const auto stamp = std::filesystem::last_write_time(mod.directory / L"reskate-music.json", error);
|
||||
stamps.push_back(error ? std::filesystem::file_time_type::min() : stamp);
|
||||
}
|
||||
return stamps;
|
||||
}
|
||||
void read_mod_music() noexcept {
|
||||
auto& music = mod_music();
|
||||
std::shared_ptr<const std::vector<mods::MusicPlaylist>> read;
|
||||
std::vector<std::filesystem::file_time_type> stamps;
|
||||
try {
|
||||
stamps = mod_music_stamps();
|
||||
bool same{};
|
||||
{
|
||||
std::lock_guard lock(music.mutex);
|
||||
same = music.ready && stamps == music.stamps;
|
||||
}
|
||||
if (!same) read = std::make_shared<const std::vector<mods::MusicPlaylist>>(read_mod_music_playlists());
|
||||
} catch (...) {}
|
||||
std::lock_guard lock(music.mutex);
|
||||
if (read) {
|
||||
music.ready = std::move(read);
|
||||
music.stamps = std::move(stamps);
|
||||
} else if (!music.ready) {
|
||||
// (It could not be read at all: the page is not kept waiting on it.)
|
||||
music.ready = std::make_shared<const std::vector<mods::MusicPlaylist>>();
|
||||
}
|
||||
music.reading = false;
|
||||
}
|
||||
// Starts a read when none is under way and there is none yet, or (`again`) the last began
|
||||
// two seconds ago or more. Called with the mutex held.
|
||||
void start_mod_music_read(ModMusic& music, bool again) {
|
||||
const auto now = GetTickCount64();
|
||||
if (music.reading || (music.ready && (!again || now - music.looked < 2000))) return;
|
||||
music.reading = true;
|
||||
music.looked = now;
|
||||
try {
|
||||
std::thread(read_mod_music).detach();
|
||||
} catch (...) {
|
||||
music.reading = false;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::shared_ptr<const std::vector<mods::MusicPlaylist>> mod_music_playlists() {
|
||||
auto& music = mod_music();
|
||||
std::lock_guard lock(music.mutex);
|
||||
start_mod_music_read(music, true);
|
||||
return music.ready;
|
||||
}
|
||||
void prepare_mod_music_playlists() {
|
||||
auto& music = mod_music();
|
||||
{
|
||||
std::lock_guard lock(music.mutex);
|
||||
start_mod_music_read(music, false);
|
||||
}
|
||||
// The Spotify stations' own pictures are written out and served the first time a catalog is
|
||||
// read, on the client thread: written now, on a thread of their own, they are ready by then.
|
||||
static std::atomic<bool> covers{};
|
||||
if (!covers.exchange(true)) {
|
||||
try {
|
||||
std::thread([] {
|
||||
try {
|
||||
(void)spotify_cover::station_url();
|
||||
(void)spotify_cover::liked_url();
|
||||
} catch (...) {}
|
||||
}).detach();
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
|
||||
bool read_music_catalog(MusicCatalog& result) {
|
||||
const auto base = local_runtime().base;
|
||||
auto& native = music_asset_functions();
|
||||
@@ -168,40 +263,61 @@ bool read_music_catalog(MusicCatalog& result) {
|
||||
if (!read(node, hash) || hash % bucket_count != bucket ||
|
||||
!read(node + 8, graph)) return false;
|
||||
graph &= ~std::uintptr_t{4};
|
||||
if (!music_asset_type(graph, addr::local_music::graph_asset_vtable) || !read(graph + 0x20, metadata)) return false;
|
||||
metadata &= ~std::uintptr_t{4};
|
||||
if (!music_asset_type(metadata, addr::local_music::metadata_vtable) || !read(metadata + 0x18, artist) ||
|
||||
!read(metadata + 0x20, title) || !read(graph + 0x28, tags)) return false;
|
||||
// What the song is. One that does not read as a song is left out of the screen, and
|
||||
// said once, rather than taking every song with it: a single song with no artist or
|
||||
// title (a music pack's, 2026-10-15) left the music screen empty with nothing in the
|
||||
// log. (A registry changing under the read is still caught by the check after it.)
|
||||
MusicSong song;
|
||||
if (!cosmetic_text(artist, song.artist) || !cosmetic_text(title, song.title) ||
|
||||
song.artist.empty() || song.title.empty()) return false;
|
||||
// Exact native registration input, NOT MusicGraph.NameHash.
|
||||
// The native map already resolves duplicate artist/title pairs.
|
||||
song.id = song.artist + " - " + song.title;
|
||||
if (cosmetic_hash(song.id) != hash || !song_ids.insert(song.id).second) return false;
|
||||
const char* problem = [&]() -> const char* {
|
||||
if (!music_asset_type(graph, addr::local_music::graph_asset_vtable) || !read(graph + 0x20, metadata)) return "it is not a song's asset";
|
||||
metadata &= ~std::uintptr_t{4};
|
||||
if (!music_asset_type(metadata, addr::local_music::metadata_vtable) || !read(metadata + 0x18, artist) ||
|
||||
!read(metadata + 0x20, title) || !read(graph + 0x28, tags)) return "its artist and title could not be read";
|
||||
if (!cosmetic_text(artist, song.artist) || !cosmetic_text(title, song.title)) return "its artist or title is not text";
|
||||
if (song.artist.empty() || song.title.empty()) return "it has no artist or no title";
|
||||
// Exact native registration input, NOT MusicGraph.NameHash.
|
||||
// The native map already resolves duplicate artist/title pairs.
|
||||
song.id = song.artist + " - " + song.title;
|
||||
if (cosmetic_hash(song.id) != hash) return "it is registered under another name";
|
||||
if (song_ids.contains(song.id)) return "another song has the same artist and title";
|
||||
|
||||
std::uint32_t tag_count{};
|
||||
if (tags && !cosmetic_array(tags, 8, 128, tag_count)) return false;
|
||||
for (std::uint32_t index = 0; index < tag_count; ++index) {
|
||||
std::uintptr_t tag{}, enumeration{}, selection{}, path{}, name{};
|
||||
if (!read(tags + index * 8ULL, tag)) return false;
|
||||
tag &= ~std::uintptr_t{4};
|
||||
if (!music_asset_type(tag, addr::local_music::tag_vtable) || !read(tag + 0x18, enumeration) || !read(tag + 0x20, selection)) return false;
|
||||
enumeration &= ~std::uintptr_t{4}; selection &= ~std::uintptr_t{4};
|
||||
std::string enum_path, playlist;
|
||||
if (!enumeration || !read(enumeration + 0x18, path) ||
|
||||
!cosmetic_text(path, enum_path)) return false;
|
||||
// Other music tags are not playlist membership declarations.
|
||||
if (_stricmp(enum_path.c_str(), "Audio/Music/Playlist/DGO_MUS_Playlist_FilterTags") != 0)
|
||||
continue;
|
||||
if (!music_asset_type(selection, addr::local_music::tag_selection_vtable) || !read(selection + 0x18, name) ||
|
||||
!cosmetic_text(name, playlist) || playlist.empty()) return false;
|
||||
if (playlist == "Invalid" || playlist == "ChallengeMusic_Test") continue;
|
||||
if (std::find(song.playlists.begin(), song.playlists.end(), playlist) == song.playlists.end()) {
|
||||
song.playlists.push_back(playlist);
|
||||
playlists[playlist].push_back(song.id);
|
||||
std::uint32_t tag_count{};
|
||||
if (tags && !cosmetic_array(tags, 8, 128, tag_count)) return "its tags could not be read";
|
||||
for (std::uint32_t index = 0; index < tag_count; ++index) {
|
||||
std::uintptr_t tag{}, enumeration{}, selection{}, path{}, name{};
|
||||
if (!read(tags + index * 8ULL, tag)) return "its tags could not be read";
|
||||
tag &= ~std::uintptr_t{4};
|
||||
if (!music_asset_type(tag, addr::local_music::tag_vtable) || !read(tag + 0x18, enumeration) || !read(tag + 0x20, selection))
|
||||
return "a tag of it is not a music tag";
|
||||
enumeration &= ~std::uintptr_t{4}; selection &= ~std::uintptr_t{4};
|
||||
std::string enum_path, playlist;
|
||||
if (!enumeration || !read(enumeration + 0x18, path) ||
|
||||
!cosmetic_text(path, enum_path)) return "a tag of it names nothing";
|
||||
// Other music tags are not playlist membership declarations.
|
||||
if (_stricmp(enum_path.c_str(), "Audio/Music/Playlist/DGO_MUS_Playlist_FilterTags") != 0)
|
||||
continue;
|
||||
if (!music_asset_type(selection, addr::local_music::tag_selection_vtable) || !read(selection + 0x18, name) ||
|
||||
!cosmetic_text(name, playlist) || playlist.empty()) return "a playlist tag of it names no playlist";
|
||||
if (playlist == "Invalid" || playlist == "ChallengeMusic_Test") continue;
|
||||
if (std::find(song.playlists.begin(), song.playlists.end(), playlist) == song.playlists.end())
|
||||
song.playlists.push_back(playlist);
|
||||
}
|
||||
return nullptr;
|
||||
}();
|
||||
if (problem) {
|
||||
static std::set<std::string, std::less<>> said;
|
||||
const auto what = song.artist.empty() && song.title.empty() ? std::string("a song") : "\"" + song.artist + " - " + song.title + "\"";
|
||||
// (Its asset's name, at +0x18 as for the tags' assets, says which mod it came from.)
|
||||
std::string asset;
|
||||
if (std::uintptr_t name{}; !graph || !read(graph + 0x18, name) || !cosmetic_text(name, asset) || asset.empty()) asset = "unnamed";
|
||||
if (said.size() < 64 && said.insert(what + problem + asset).second)
|
||||
dingosdk::logging::log(dingosdk::logging::Level::warning, dingosdk::logging::Channel::music,
|
||||
"Music screen: {} is left out of it: {} (asset {}).", what, problem, asset);
|
||||
if (!read(node + 0x10, node)) return false;
|
||||
continue;
|
||||
}
|
||||
song_ids.insert(song.id);
|
||||
for (const auto& playlist : song.playlists) playlists[playlist].push_back(song.id);
|
||||
if (std::uint32_t flags{}; graph >= 16 && read(graph + 0x14, flags) && (flags & 0x100)) {
|
||||
std::array<unsigned char, 16> identity{};
|
||||
if (dingosdk::memory::peek_bytes(graph - 16, identity.data(), identity.size()) && identities.emplace(song.id, identity).second)
|
||||
@@ -256,7 +372,10 @@ bool read_music_catalog(MusicCatalog& result) {
|
||||
}
|
||||
// Playlists music mods declare in reskate-music.json (ReSkateMusicPacker writes it):
|
||||
// a name and the ids of songs the mod adds, which carry no station tag of their own.
|
||||
for (const auto& playlist : mod_music_playlists()) {
|
||||
// (Still being read for the first time: the page waits for them, as for the game's songs.)
|
||||
const auto mod_lists = mod_music_playlists();
|
||||
if (!mod_lists) return false;
|
||||
for (const auto& playlist : *mod_lists) {
|
||||
++mod_playlists;
|
||||
merge(playlist.id, playlist.name, playlist.artwork, playlist.songs);
|
||||
for (const auto& [id, artwork] : playlist.song_artwork)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "Engine/Vfs/mod_music.h"
|
||||
#include "Extension/Profile/runtime_internal.h"
|
||||
#include <memory>
|
||||
|
||||
namespace dingosdk::profile_runtime {
|
||||
struct MusicSong {
|
||||
@@ -29,8 +30,12 @@ MusicAssetFunctions& music_asset_functions();
|
||||
|
||||
bool music_asset_type(std::uintptr_t asset, std::uintptr_t vtable_rva);
|
||||
|
||||
// Playlists the enabled mods declare in reskate-music.json (see Engine/Vfs/mod_music.h).
|
||||
std::vector<mods::MusicPlaylist> mod_music_playlists();
|
||||
// Playlists the enabled mods declare in reskate-music.json (see Engine/Vfs/mod_music.h), with
|
||||
// their covers' URLs, as last read. The files are read on a thread of their own, and again when
|
||||
// one has changed: null until the first read is done. Never waits on the disk.
|
||||
std::shared_ptr<const std::vector<mods::MusicPlaylist>> mod_music_playlists();
|
||||
// Starts the first read of them, if it has not been started.
|
||||
void prepare_mod_music_playlists();
|
||||
|
||||
bool read_music_catalog(MusicCatalog& result);
|
||||
}
|
||||
@@ -307,8 +307,9 @@ std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mo
|
||||
// The Mods row only when an enabled mod actually declares a playlist with songs, rather
|
||||
// than an empty one; the Spotify row always (it has the Spotify playlist at the least).
|
||||
bool has_mod_playlists = false;
|
||||
for (const auto& playlist : mod_music_playlists())
|
||||
if (!playlist.songs.empty()) { has_mod_playlists = true; break; }
|
||||
if (const auto mod_lists = mod_music_playlists())
|
||||
for (const auto& playlist : *mod_lists)
|
||||
if (!playlist.songs.empty()) { has_mod_playlists = true; break; }
|
||||
const auto report = music_shelf_append_one(local_runtime().base, manager, handle, {spotify_station::spotify_used(), youtube_music::has(youtube_music::Kind::youtube), youtube_music::has(youtube_music::Kind::files), youtube_music::has(youtube_music::Kind::radio),
|
||||
!external_audio::app().empty(), has_mod_playlists});
|
||||
dingosdk::logging::event(dingosdk::logging::Channel::music,
|
||||
|
||||
@@ -306,6 +306,9 @@ void music_ui_initialize_hook(std::uint64_t all, std::uint64_t hidden, std::uint
|
||||
void update_music_catalog() {
|
||||
auto& s = local_runtime(); auto& ui = music_ui_runtime();
|
||||
if (!s.active.load(std::memory_order_acquire) || ui.updating || cosmetic_runtime().update_thread != GetCurrentThreadId()) return;
|
||||
// (The mods' playlists are read in the background from the first tick on: ready by the time
|
||||
// the music shelf or page is first opened, as a rule.)
|
||||
try { prepare_mod_music_playlists(); } catch (...) {}
|
||||
PreserveError preserve; std::lock_guard lock(s.native_mutex);
|
||||
if (!ui.pending) return;
|
||||
if (ui.pending->thread != GetCurrentThreadId() || !music_ui_current(*ui.pending)) { ui.pending.reset(); return; }
|
||||
|
||||
@@ -538,8 +538,8 @@ std::vector<std::uint8_t> picture(Kind kind, const Song& song, const std::string
|
||||
#endif
|
||||
}
|
||||
// Each playlist's tile is its first song's picture (of a folder, the first of its first few
|
||||
// files that has one).
|
||||
void make_covers() {
|
||||
// files that has one). Returns how many tiles got one, and (`missing`) how many still have none.
|
||||
std::size_t make_covers(std::size_t* missing = nullptr) {
|
||||
struct Wanted {
|
||||
std::size_t index;
|
||||
Kind kind;
|
||||
@@ -556,6 +556,7 @@ void make_covers() {
|
||||
wanted.push_back({i, playlist.kind, playlist.url, {playlist.songs.begin(), playlist.songs.begin() + static_cast<std::ptrdiff_t>(count)}});
|
||||
}
|
||||
}
|
||||
std::size_t made{};
|
||||
for (const auto& want : wanted)
|
||||
for (const auto& song : want.songs) {
|
||||
const auto image = picture(want.kind, song, want.icon);
|
||||
@@ -563,9 +564,14 @@ void make_covers() {
|
||||
const auto address = spotify_cover::serve(image, std::string(kind_name(want.kind)) + "-" + song.id + ".png");
|
||||
if (address.empty()) continue;
|
||||
std::lock_guard lock(mutex);
|
||||
if (want.index < library.size() && !library[want.index].songs.empty() && library[want.index].songs.front().id == want.songs.front().id) library[want.index].artwork = address;
|
||||
if (want.index < library.size() && !library[want.index].songs.empty() && library[want.index].songs.front().id == want.songs.front().id) {
|
||||
library[want.index].artwork = address;
|
||||
++made;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (missing) *missing = wanted.size() - std::min(made, wanted.size());
|
||||
return made;
|
||||
}
|
||||
|
||||
std::string hex_sha256(const std::filesystem::path& file) {
|
||||
@@ -654,14 +660,33 @@ void run() noexcept {
|
||||
signed_in = have_tools && (have_cookies || !browser_name.empty());
|
||||
}
|
||||
if (signed_in) fetch_runtime(); // (a sign-in set by a ReSkate that did not fetch one)
|
||||
make_covers();
|
||||
} catch (...) {}
|
||||
// Tiles whose picture could not be fetched (YouTube's are fetched from i.ytimg.com) are tried
|
||||
// again, sooner then later: fetched once at start, a game started while the connection was
|
||||
// down kept its YouTube playlists without pictures until a command (2026-10-15).
|
||||
std::size_t missing{};
|
||||
try { make_covers(&missing); } catch (...) {}
|
||||
auto retry_after = std::chrono::seconds(30);
|
||||
for (;;) {
|
||||
std::string command;
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
is_busy = false;
|
||||
wake.wait(lock, [] { return !commands.empty(); });
|
||||
const bool asked = missing ? wake.wait_for(lock, retry_after, [] { return !commands.empty(); })
|
||||
: (wake.wait(lock, [] { return !commands.empty(); }), true);
|
||||
if (!asked) {
|
||||
is_busy = true;
|
||||
lock.unlock();
|
||||
std::size_t made{};
|
||||
try { made = make_covers(&missing); } catch (...) {}
|
||||
if (made) {
|
||||
profile_runtime::request_music_catalog_refresh();
|
||||
retry_after = std::chrono::seconds(30);
|
||||
} else {
|
||||
retry_after = std::min<std::chrono::seconds>(retry_after * 2, std::chrono::minutes(10));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
command = std::move(commands.front());
|
||||
commands.pop_front();
|
||||
is_busy = true;
|
||||
@@ -678,7 +703,7 @@ void run() noexcept {
|
||||
search_now(command.substr(7));
|
||||
continue; // (nothing of the music screen's has changed)
|
||||
}
|
||||
make_covers();
|
||||
make_covers(&missing);
|
||||
profile_runtime::request_music_catalog_refresh();
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "runtime_internal.h"
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
|
||||
namespace dingosdk {
|
||||
using namespace profile_runtime;
|
||||
@@ -141,11 +146,92 @@ bool local_profile_store_ready() noexcept {
|
||||
try { return local_runtime().store != nullptr; } catch (...) { return false; }
|
||||
}
|
||||
|
||||
namespace {
|
||||
// ReSkate's own preferences and values are saved on a thread of their own, in the order they were
|
||||
// set. They are set from the game's threads: a menu click on the render thread, a console command
|
||||
// or a session's slider on the client thread, and a save (a database transaction) held that
|
||||
// frame up. Until one is saved, it is what reads of it give.
|
||||
struct PendingValues {
|
||||
std::mutex mutex;
|
||||
std::condition_variable wake;
|
||||
std::deque<std::vector<std::pair<std::string, Json>>> queue;
|
||||
std::map<std::string, Json, std::less<>> latest; // set and not saved yet, by full key
|
||||
bool started{};
|
||||
};
|
||||
PendingValues& pending_values() {
|
||||
static auto* pending = new PendingValues; // process lifetime, as its writer
|
||||
return *pending;
|
||||
}
|
||||
void save_values(std::vector<std::pair<std::string, Json>> values) {
|
||||
auto& s = local_runtime();
|
||||
try {
|
||||
s.store->set_user_values(values);
|
||||
for (const auto& [key, value] : values) {
|
||||
if (!value.is_boolean()) continue;
|
||||
const auto saved = s.store->user_value(key);
|
||||
if (!saved || !saved->is_boolean() || saved->get<bool>() != value.get<bool>())
|
||||
dingosdk::logging::event(dingosdk::logging::Channel::profile,
|
||||
dingosdk::Json{{"event", "local_preference_save_failed"}, {"key", key}, {"reason", "readback_mismatch"}}.dump().c_str());
|
||||
else
|
||||
dingosdk::logging::event(dingosdk::logging::Channel::profile,
|
||||
dingosdk::Json{{"event", "local_preference_saved"}, {"key", key}, {"value", value.get<bool>()}}.dump().c_str());
|
||||
}
|
||||
} catch (...) {
|
||||
dingosdk::logging::event(dingosdk::logging::Channel::profile, "{\"event\":\"local_preference_save_failed\",\"reason\":\"exception\"}");
|
||||
}
|
||||
}
|
||||
void writer() {
|
||||
auto& p = pending_values();
|
||||
for (;;) {
|
||||
std::vector<std::pair<std::string, Json>> values;
|
||||
{
|
||||
std::unique_lock lock(p.mutex);
|
||||
p.wake.wait(lock, [&] { return !p.queue.empty(); });
|
||||
values = std::move(p.queue.front());
|
||||
p.queue.pop_front();
|
||||
}
|
||||
save_values(values);
|
||||
std::lock_guard lock(p.mutex);
|
||||
// (A value set again meanwhile is still waiting: it stays.)
|
||||
for (const auto& [key, value] : values)
|
||||
if (const auto found = p.latest.find(key); found != p.latest.end() && found->second == value) p.latest.erase(found);
|
||||
}
|
||||
}
|
||||
// Full keys ("ReSkate." ...). Saved in place when no writer could be started.
|
||||
void queue_values(std::vector<std::pair<std::string, Json>> values) {
|
||||
auto& p = pending_values();
|
||||
{
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (!p.started) {
|
||||
try {
|
||||
std::thread(writer).detach();
|
||||
p.started = true;
|
||||
} catch (...) {}
|
||||
}
|
||||
if (p.started) {
|
||||
for (const auto& [key, value] : values) p.latest[key] = value;
|
||||
p.queue.push_back(std::move(values));
|
||||
p.wake.notify_one();
|
||||
return;
|
||||
}
|
||||
}
|
||||
save_values(std::move(values));
|
||||
}
|
||||
std::optional<Json> pending_value(std::string_view full_key) {
|
||||
auto& p = pending_values();
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (const auto found = p.latest.find(full_key); found != p.latest.end()) return found->second;
|
||||
return {};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::optional<bool> local_preference(std::string_view key) noexcept {
|
||||
try {
|
||||
auto& s = local_runtime();
|
||||
if (!s.store) return {};
|
||||
const auto value = s.store->user_value("ReSkate." + std::string(key));
|
||||
const auto full_key = "ReSkate." + std::string(key);
|
||||
if (const auto waiting = pending_value(full_key); waiting && waiting->is_boolean()) return waiting->get<bool>();
|
||||
const auto value = s.store->user_value(full_key);
|
||||
if (value && value->is_boolean()) return value->get<bool>();
|
||||
} catch (...) { /* A preference is never worth failing a caller over. */ }
|
||||
return {};
|
||||
@@ -159,18 +245,7 @@ void set_local_preference(std::string_view key, bool value) noexcept {
|
||||
"{\"event\":\"local_preference_save_failed\",\"reason\":\"profile_unavailable\"}");
|
||||
return;
|
||||
}
|
||||
const auto full_key = "ReSkate." + std::string(key);
|
||||
s.store->set_user_value(full_key, value);
|
||||
const auto saved = s.store->user_value(full_key);
|
||||
if (!saved || !saved->is_boolean() || saved->get<bool>() != value) {
|
||||
dingosdk::logging::event(dingosdk::logging::Channel::profile,
|
||||
dingosdk::Json{{"event", "local_preference_save_failed"},
|
||||
{"key", full_key}, {"reason", "readback_mismatch"}}.dump().c_str());
|
||||
return;
|
||||
}
|
||||
dingosdk::logging::event(dingosdk::logging::Channel::profile,
|
||||
dingosdk::Json{{"event", "local_preference_saved"},
|
||||
{"key", full_key}, {"value", value}}.dump().c_str());
|
||||
queue_values({{"ReSkate." + std::string(key), Json(value)}});
|
||||
} catch (...) {
|
||||
dingosdk::logging::event(dingosdk::logging::Channel::profile,
|
||||
"{\"event\":\"local_preference_save_failed\",\"reason\":\"exception\"}");
|
||||
@@ -180,7 +255,10 @@ void set_local_preference(std::string_view key, bool value) noexcept {
|
||||
std::optional<Json> local_value(std::string_view key) noexcept {
|
||||
try {
|
||||
auto& s = local_runtime();
|
||||
if (s.store) return s.store->user_value("ReSkate." + std::string(key));
|
||||
if (!s.store) return {};
|
||||
const auto full_key = "ReSkate." + std::string(key);
|
||||
if (auto waiting = pending_value(full_key)) return waiting;
|
||||
return s.store->user_value(full_key);
|
||||
} catch (...) { /* An unreadable value just means the default. */ }
|
||||
return {};
|
||||
}
|
||||
@@ -192,7 +270,7 @@ void set_local_values(const std::vector<std::pair<std::string, Json>>& values) n
|
||||
std::vector<std::pair<std::string, Json>> prefixed;
|
||||
prefixed.reserve(values.size());
|
||||
for (const auto& [key, value] : values) prefixed.emplace_back("ReSkate." + key, value);
|
||||
s.store->set_user_values(prefixed);
|
||||
queue_values(std::move(prefixed));
|
||||
} catch (...) { /* Saving is best effort; the runtime values already applied. */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -1352,12 +1352,15 @@ void remember_player(std::uint32_t id) {
|
||||
}
|
||||
}
|
||||
// Type objects of the injected events; found on the heap and kept until the level is left.
|
||||
std::string ensure_types(std::initializer_list<std::uint32_t> hashes) {
|
||||
// `worker`: called on a thread of its own, which scans as the party focus's does (background
|
||||
// priority, only memory the game has in RAM). The full scan read every committed page and paged
|
||||
// the game's memory in while it skated, every few seconds while a type was missing (2026-10-15).
|
||||
std::string ensure_types(std::initializer_list<std::uint32_t> hashes, bool worker = false) {
|
||||
std::vector<NativeTypeQuery> queries;
|
||||
for (const auto hash : hashes) if (!type_of(hash)) queries.push_back({hash, 0});
|
||||
if (queries.empty()) return {};
|
||||
const auto world = lab().world.load(std::memory_order_acquire);
|
||||
const auto missing = find_native_types(queries);
|
||||
const auto missing = find_native_types(queries, worker);
|
||||
std::lock_guard lock(lab().mutex);
|
||||
if (lab().world.load(std::memory_order_acquire) != world) return "the level changed while the event types were looked for";
|
||||
for (const auto& q : queries) if (q.object) lab().types[q.hash] = q.object;
|
||||
@@ -1958,7 +1961,7 @@ bool beacon_types_ready() {
|
||||
std::thread([] {
|
||||
auto& l = lab();
|
||||
try {
|
||||
if (!ensure_types({event_beacon_move, event_beacon_despawn}).empty())
|
||||
if (!ensure_types({event_beacon_move, event_beacon_despawn}, true).empty())
|
||||
l.beacon_scan_retry.store(now_ms() + 10000, std::memory_order_release);
|
||||
} catch (...) { l.beacon_scan_retry.store(now_ms() + 10000, std::memory_order_release); }
|
||||
l.type_scan_running.store(false, std::memory_order_release);
|
||||
@@ -1995,7 +1998,7 @@ bool prepare_throwdown_injection() noexcept {
|
||||
const auto error = ensure_types({event_debug_spawn, event_add_ai, event_force_start, event_destroy,
|
||||
event_remove_participant, event_player_score, event_request_turn_end,
|
||||
event_skate_submit, event_force_destroy, event_criteria_attempt,
|
||||
event_slam_hit, event_leave_in_progress, event_onboarding_complete});
|
||||
event_slam_hit, event_leave_in_progress, event_onboarding_complete}, true);
|
||||
if (!error.empty()) {
|
||||
l.type_scan_retry.store(now_ms() + 5000, std::memory_order_release);
|
||||
logging::log(logging::Level::warning, logging::Channel::progression, "Throwdowns: {}", error);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
#include "Extension/Skater/no_bail.h"
|
||||
#include "Extension/Skater/physics_tuning.h"
|
||||
#include "Extension/UI/Overlay/overlay.h"
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <windows.h>
|
||||
#include <shlobj.h>
|
||||
#include <algorithm>
|
||||
@@ -332,21 +334,29 @@ void save_store() {
|
||||
maps[level] = std::move(row);
|
||||
}
|
||||
json["maps"] = std::move(maps);
|
||||
const auto directory = data_directory();
|
||||
if (directory.empty()) return;
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(directory, error);
|
||||
const auto target = directory / L"trainer.json", temporary = directory / L"trainer.json.tmp";
|
||||
{
|
||||
std::ofstream file(temporary, std::ios::binary | std::ios::trunc);
|
||||
file << json.dump(2);
|
||||
if (!file) return;
|
||||
}
|
||||
std::filesystem::rename(temporary, target, error);
|
||||
if (error) {
|
||||
std::filesystem::remove(target, error);
|
||||
std::filesystem::rename(temporary, target, error);
|
||||
}
|
||||
// Written on a thread of its own (the client tick saves 1.5 s after a change: a folder
|
||||
// lookup, a file written and renamed). The writes are kept in order by their own lock.
|
||||
std::thread([text = json.dump(2)] {
|
||||
static std::mutex writing;
|
||||
std::lock_guard lock(writing);
|
||||
try {
|
||||
const auto directory = data_directory();
|
||||
if (directory.empty()) return;
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(directory, error);
|
||||
const auto target = directory / L"trainer.json", temporary = directory / L"trainer.json.tmp";
|
||||
{
|
||||
std::ofstream file(temporary, std::ios::binary | std::ios::trunc);
|
||||
file << text;
|
||||
if (!file) return;
|
||||
}
|
||||
std::filesystem::rename(temporary, target, error);
|
||||
if (error) {
|
||||
std::filesystem::remove(target, error);
|
||||
std::filesystem::rename(temporary, target, error);
|
||||
}
|
||||
} catch (...) {}
|
||||
}).detach();
|
||||
} catch (...) {
|
||||
say(logging::Level::warning, "Trainer: could not save trainer.json.");
|
||||
}
|
||||
@@ -933,10 +943,55 @@ MapFile read_map_file(const std::string &level) {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// The map's file is read on a thread of its own and taken by the tick when it is there: read
|
||||
// here, it listed every mod's folder and opened two files in each on the client thread, as the
|
||||
// player spawned (2026-10-15).
|
||||
struct MapFileRead {
|
||||
std::mutex mutex;
|
||||
std::string level;
|
||||
std::optional<MapFile> file;
|
||||
};
|
||||
MapFileRead &map_file_read() {
|
||||
static auto *read = new MapFileRead; // process lifetime, as the threads that fill it
|
||||
return *read;
|
||||
}
|
||||
void start_map_file_read(const std::string &level) {
|
||||
auto &r = map_file_read();
|
||||
{
|
||||
std::lock_guard lock(r.mutex);
|
||||
r.level = level;
|
||||
r.file.reset();
|
||||
}
|
||||
if (level.empty()) return;
|
||||
try {
|
||||
std::thread([level] {
|
||||
MapFile file;
|
||||
try { file = read_map_file(level); } catch (...) {}
|
||||
auto &r = map_file_read();
|
||||
std::lock_guard lock(r.mutex);
|
||||
if (r.level == level) r.file = std::move(file);
|
||||
}).detach();
|
||||
} catch (...) {}
|
||||
}
|
||||
void take_map_file() {
|
||||
auto &s = state();
|
||||
auto &r = map_file_read();
|
||||
{
|
||||
std::lock_guard lock(r.mutex);
|
||||
if (!r.file || r.level != s.map) return;
|
||||
s.map_file = std::move(*r.file);
|
||||
r.file.reset();
|
||||
}
|
||||
if (!s.map_file.spots.empty() || !s.map_file.preset.empty())
|
||||
say(logging::Level::info, std::format("Trainer: the map ships {} spots and {} preset values.", s.map_file.spots.size(),
|
||||
s.map_file.preset.size()));
|
||||
changed(false);
|
||||
}
|
||||
void enter_map(const std::string &level) {
|
||||
auto &s = state();
|
||||
s.map = level;
|
||||
s.map_file = level.empty() ? MapFile{} : read_map_file(level);
|
||||
s.map_file = {};
|
||||
start_map_file_read(level);
|
||||
s.motion = {};
|
||||
s.telemetry.last = {};
|
||||
s.telemetry.best = {};
|
||||
@@ -952,9 +1007,6 @@ void enter_map(const std::string &level) {
|
||||
if (!level.empty()) {
|
||||
const auto found = s.maps.find(level);
|
||||
s.profile_due = found != s.maps.end() && !found->second.preset.empty();
|
||||
if (!s.map_file.spots.empty() || !s.map_file.preset.empty())
|
||||
say(logging::Level::info, std::format("Trainer: the map ships {} spots and {} preset values.", s.map_file.spots.size(),
|
||||
s.map_file.preset.size()));
|
||||
}
|
||||
changed(false);
|
||||
}
|
||||
@@ -1479,6 +1531,7 @@ 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);
|
||||
take_map_file();
|
||||
if (const auto key = lower(level); !key.empty() && key != s.waypoint_level) {
|
||||
s.waypoint_level = key;
|
||||
s.waypoint.reset();
|
||||
|
||||
@@ -2,14 +2,95 @@
|
||||
#include "cursor.h"
|
||||
#include "input_capture.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::overlay::detail {
|
||||
bool game_window_foreground(HWND window) {
|
||||
namespace {
|
||||
bool query_foreground(HWND window) {
|
||||
if (!window || !IsWindow(window)) return false;
|
||||
const auto root = GetAncestor(window, GA_ROOT);
|
||||
return GetForegroundWindow() == (root ? root : window) && !IsIconic(root ? root : window);
|
||||
}
|
||||
|
||||
// Whether the game's window is in front, and which keys are held, as Windows said a few
|
||||
// milliseconds ago, asked on a thread of its own. Those calls (GetAncestor, GetForegroundWindow,
|
||||
// GetAsyncKeyState) wait on a lock Windows shares among every program's windows, and another
|
||||
// program holding it held up the game's own frame with them: 40 to 50 ms at a time inside
|
||||
// GetAncestor, from the client tick's controller reads (a player's profile, 2026-10-15). The
|
||||
// readers, the client tick among them, take the answer from here instead.
|
||||
struct DesktopSample {
|
||||
std::atomic<HWND> window{};
|
||||
std::atomic<bool> foreground{};
|
||||
std::array<std::atomic<std::uint64_t>, 4> keys{};
|
||||
std::atomic<ULONGLONG> at{}; // GetTickCount64 when the last sample was complete
|
||||
std::atomic<ULONGLONG> wanted{}; // the last time a reader asked for it
|
||||
std::atomic<bool> started{};
|
||||
};
|
||||
DesktopSample& desktop() {
|
||||
static auto* sample = new DesktopSample; // process lifetime, as its thread
|
||||
return *sample;
|
||||
}
|
||||
// A sample is used however old it is once there is one: one that is late means the sampler is
|
||||
// waiting on Windows, and asking Windows from the game's thread then would wait as well. Before
|
||||
// the first, the caller asks itself.
|
||||
constexpr ULONGLONG idle_ms = 2000;
|
||||
constexpr LONGLONG period_100ns = 4 * 10000; // 4 ms: 250 samples a second while read
|
||||
|
||||
DWORD WINAPI sample_desktop(void*) noexcept {
|
||||
auto& d = desktop();
|
||||
const HANDLE timer = CreateWaitableTimerExW(nullptr, nullptr, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS);
|
||||
for (;;) {
|
||||
// (Nothing asking, as when the game is minimised or closing: no polling.)
|
||||
if (GetTickCount64() - d.wanted.load(std::memory_order_relaxed) > idle_ms) {
|
||||
Sleep(50);
|
||||
continue;
|
||||
}
|
||||
const auto window = state().window.load();
|
||||
const bool front = query_foreground(window);
|
||||
std::array<std::uint64_t, 4> keys{};
|
||||
if (front) {
|
||||
OverlayInputAccess access; // the real keyboard, whatever the menus hold from the game
|
||||
for (int key = 1; key < 255; ++key)
|
||||
if (GetAsyncKeyState(key) & 0x8000) keys[key / 64] |= std::uint64_t{1} << (key % 64);
|
||||
}
|
||||
d.window.store(window, std::memory_order_relaxed);
|
||||
d.foreground.store(front, std::memory_order_relaxed);
|
||||
for (std::size_t i = 0; i < keys.size(); ++i) d.keys[i].store(keys[i], std::memory_order_relaxed);
|
||||
d.at.store(GetTickCount64(), std::memory_order_release);
|
||||
LARGE_INTEGER due{};
|
||||
due.QuadPart = -period_100ns;
|
||||
if (timer && SetWaitableTimer(timer, &due, 0, nullptr, nullptr, FALSE)) WaitForSingleObject(timer, INFINITE);
|
||||
else Sleep(4);
|
||||
}
|
||||
}
|
||||
// The sample of this window, when there is a recent one; starts the sampler the first time.
|
||||
bool recent_sample(HWND window) noexcept {
|
||||
auto& d = desktop();
|
||||
const auto now = GetTickCount64();
|
||||
d.wanted.store(now, std::memory_order_relaxed);
|
||||
if (!d.started.load(std::memory_order_relaxed) && !d.started.exchange(true)) {
|
||||
if (const auto thread = CreateThread(nullptr, 0, sample_desktop, nullptr, 0, nullptr)) CloseHandle(thread);
|
||||
else d.started.store(false);
|
||||
}
|
||||
return d.at.load(std::memory_order_acquire) && d.window.load(std::memory_order_relaxed) == window;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool game_window_foreground(HWND window) {
|
||||
if (recent_sample(window)) return desktop().foreground.load(std::memory_order_relaxed);
|
||||
return query_foreground(window); // (before the first sample, or with the sampler stopped)
|
||||
}
|
||||
|
||||
bool sampled_keys(HWND window, std::array<std::uint64_t, 4>& held) {
|
||||
if (!recent_sample(window)) return false;
|
||||
auto& d = desktop();
|
||||
if (!d.foreground.load(std::memory_order_relaxed)) return false;
|
||||
for (std::size_t i = 0; i < held.size(); ++i) held[i] = d.keys[i].load(std::memory_order_relaxed);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool owns_menu_cursor(const State& s) {
|
||||
// WM_KILLFOCUS can refer to a child control. A cached flag must not let the
|
||||
// game resume clipping/centering while its top-level window is still active.
|
||||
@@ -38,9 +119,16 @@ void sync_menu_cursor(bool release) {
|
||||
const bool own = !release && owns_menu_cursor(s);
|
||||
changed = own != s.cursor_released;
|
||||
if (own) {
|
||||
if (!s.cursor_released) s.cursor_clip_pending = GetClipCursor(&s.cursor_clip) != FALSE;
|
||||
s.cursor_released = true;
|
||||
clip(nullptr);
|
||||
// Freed when the menu takes the pointer, then again at most twice a second: this runs
|
||||
// each frame the hub is drawn, and ClipCursor takes Windows' input lock for itself.
|
||||
static ULONGLONG freed_at{};
|
||||
const auto now = GetTickCount64();
|
||||
if (!s.cursor_released || now - freed_at >= 500) {
|
||||
if (!s.cursor_released) s.cursor_clip_pending = GetClipCursor(&s.cursor_clip) != FALSE;
|
||||
s.cursor_released = true;
|
||||
freed_at = now;
|
||||
clip(nullptr);
|
||||
}
|
||||
} else if (s.cursor_released) {
|
||||
s.cursor_released = false;
|
||||
// Do not restore a game's clip over another foreground application.
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
#pragma once
|
||||
#include <Windows.h>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
namespace dingosdk::overlay::detail {
|
||||
// Whether the game's window is in front: as last sampled on the overlay's own thread (cursor.cpp),
|
||||
// so no game thread waits on Windows for it.
|
||||
bool game_window_foreground(HWND window);
|
||||
// The keys held (a bit per virtual key) as last sampled, while the window is in front. False when
|
||||
// there is no recent sample: the caller then asks Windows itself.
|
||||
bool sampled_keys(HWND window, std::array<std::uint64_t, 4>& held);
|
||||
void sync_menu_cursor(bool release = false);
|
||||
UINT cursor_sync_message();
|
||||
void request_window_cursor_sync(HWND window);
|
||||
|
||||
@@ -10,13 +10,60 @@
|
||||
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <thread>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::overlay {
|
||||
namespace {
|
||||
// What the Mods folder holds, read again on a thread of its own every `period` while a page asks
|
||||
// for it; the page draws the last that was read. These pages are drawn on the game's render
|
||||
// thread, and reading the folder there (every mod's files, a whole folder's fingerprint for an
|
||||
// excluded one) held each frame up for tens of milliseconds to seconds, every 1.5 s (2026-10-15).
|
||||
template<class T> class FolderRead {
|
||||
public:
|
||||
template<class Read> std::shared_ptr<const T> get(Read read, ULONGLONG period) {
|
||||
const auto now = GetTickCount64();
|
||||
if (now >= next_.load() && !reading_.exchange(true)) {
|
||||
next_ = now + period;
|
||||
try {
|
||||
std::thread([this, read] {
|
||||
std::shared_ptr<const T> fresh;
|
||||
try { fresh = std::make_shared<const T>(read()); } catch (...) {}
|
||||
if (fresh) {
|
||||
std::lock_guard lock(mutex_);
|
||||
value_ = std::move(fresh);
|
||||
}
|
||||
reading_ = false;
|
||||
}).detach();
|
||||
} catch (...) { reading_ = false; }
|
||||
}
|
||||
std::lock_guard lock(mutex_);
|
||||
return value_;
|
||||
}
|
||||
// What the page itself has just changed, shown until the next read.
|
||||
void set(T value) {
|
||||
std::lock_guard lock(mutex_);
|
||||
value_ = std::make_shared<const T>(std::move(value));
|
||||
}
|
||||
void again() { next_ = 0; }
|
||||
private:
|
||||
std::mutex mutex_;
|
||||
std::shared_ptr<const T> value_;
|
||||
std::atomic<bool> reading_{};
|
||||
std::atomic<ULONGLONG> next_{};
|
||||
};
|
||||
struct DeckScan {
|
||||
std::vector<std::string> installed, titles; // each installed deck's name for people
|
||||
std::string chosen;
|
||||
};
|
||||
}
|
||||
namespace menu {
|
||||
// Board Break's decks (Extension/BoardBreak/board_break.h). The game has one deck model and each
|
||||
// deck is a mod's copy of it, so picking one applies the mods with that deck first and loads the
|
||||
@@ -24,17 +71,18 @@ namespace menu {
|
||||
// installed, and for any other deck that is; the Mods folder is read again every 1.5 s while the
|
||||
// card shows. A deck goes by the name its deck.ini gives it (label), else by its folder's.
|
||||
void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
|
||||
static std::vector<std::string> installed;
|
||||
static std::vector<std::string> titles; // each installed deck's name for people
|
||||
static std::string chosen;
|
||||
static ULONGLONG next_scan{};
|
||||
if (const auto now = GetTickCount64(); now >= next_scan) {
|
||||
installed = mods::installed_board_break_decks();
|
||||
chosen = mods::wanted_board_break_deck();
|
||||
titles.clear();
|
||||
for (const auto& deck : installed) titles.push_back(board_break::deck_label(deck));
|
||||
next_scan = now + 1500;
|
||||
}
|
||||
static auto* scans = new FolderRead<DeckScan>; // process lifetime, as the threads that fill it
|
||||
const auto scan = scans->get([] {
|
||||
DeckScan read;
|
||||
read.installed = mods::installed_board_break_decks();
|
||||
read.chosen = mods::wanted_board_break_deck();
|
||||
for (const auto& deck : read.installed) read.titles.push_back(board_break::deck_label(deck));
|
||||
return read;
|
||||
}, 1500);
|
||||
if (!scan) return;
|
||||
const auto& installed = scan->installed;
|
||||
const auto& titles = scan->titles;
|
||||
const auto& chosen = scan->chosen;
|
||||
if (!model.board_break.available || installed.empty()) return;
|
||||
const auto there = [&](const std::string& deck) { return std::find(installed.begin(), installed.end(), deck) != installed.end(); };
|
||||
// The known decks in their own order, then the others that are installed: the numbers are the
|
||||
@@ -102,14 +150,14 @@ std::string mod_contents(const mods::Mod& mod) {
|
||||
// while the game runs (it takes effect at the next level load), including a
|
||||
// mod folder added since launch.
|
||||
void draw_installed_mods(SkateMenu& menu) {
|
||||
static mods::ModList list;
|
||||
static ULONGLONG next_scan{};
|
||||
static auto* scans = new FolderRead<mods::ModList>; // process lifetime, as the threads that fill it
|
||||
static std::string action_error;
|
||||
const auto now = GetTickCount64();
|
||||
if (now >= next_scan) {
|
||||
list = mods::scan_mods(mods::engine_data_root());
|
||||
next_scan = now + 1500;
|
||||
const auto scanned = scans->get([] { return mods::scan_mods(mods::engine_data_root()); }, 1500);
|
||||
if (!scanned) {
|
||||
menu::note("Reading the Mods folder...");
|
||||
return;
|
||||
}
|
||||
const auto& list = *scanned;
|
||||
const auto& launch = mods::catalog();
|
||||
const auto applied = live_mods::applied_mods();
|
||||
// A Board Break deck pack is in effect when one of the decks inside it is (mod_catalog.h).
|
||||
@@ -260,7 +308,10 @@ void draw_installed_mods(SkateMenu& menu) {
|
||||
auto entries = list.entries;
|
||||
entries[*toggle].enabled = !entries[*toggle].enabled;
|
||||
mods::save_mod_order(list.root, entries);
|
||||
next_scan = 0;
|
||||
auto shown = list;
|
||||
shown.entries = std::move(entries);
|
||||
scans->set(std::move(shown));
|
||||
scans->again();
|
||||
action_error.clear();
|
||||
} catch (const std::exception& error) { action_error = error.what(); }
|
||||
}
|
||||
|
||||
@@ -508,9 +508,14 @@ extern "C" unsigned DingoSDKOverlayReadPromptKeys() {
|
||||
// (Not while a menu, the console or chat has the keyboard; the card itself having the pointer is no reason.)
|
||||
const bool typing = s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load() || s.hub_typing.load();
|
||||
if (!window || s.stop.load() || s.failed.load() || typing || !game_window_foreground(window)) return 0;
|
||||
OverlayInputAccess access;
|
||||
unsigned keys{};
|
||||
constexpr int wanted[]{VK_LEFT, VK_RIGHT, VK_RETURN, VK_ESCAPE};
|
||||
if (std::array<std::uint64_t, 4> held{}; sampled_keys(window, held)) {
|
||||
for (unsigned bit = 0; bit < 4; ++bit)
|
||||
if (held[wanted[bit] / 64] >> (wanted[bit] % 64) & 1) keys |= 1u << bit;
|
||||
return keys;
|
||||
}
|
||||
OverlayInputAccess access;
|
||||
for (unsigned bit = 0; bit < 4; ++bit)
|
||||
if (GetAsyncKeyState(wanted[bit]) & 0x8000) keys |= 1u << bit;
|
||||
return keys;
|
||||
@@ -524,8 +529,13 @@ extern "C" void DingoSDKOverlayReadControllerInput(dingosdk::ControllerInput* ou
|
||||
const HWND window = s.window.load();
|
||||
if (!window || s.stop.load() || s.failed.load() || (!allow_menu && interactive_visible(s)) ||
|
||||
!game_window_foreground(window)) return;
|
||||
// Read through the overlay's bypass: freecam capture hides keys from the game.
|
||||
{
|
||||
// Read through the overlay's bypass: freecam capture hides keys from the game. (As sampled
|
||||
// off this thread when there is a recent sample, cursor.cpp.)
|
||||
if (std::array<std::uint64_t, 4> held{}; sampled_keys(window, held)) {
|
||||
for (unsigned key = 0; key < 256; ++key)
|
||||
if (dingosdk::bindable_keyboard_key(key) && (held[key / 64] >> (key % 64) & 1))
|
||||
output->keys[key / 64] |= std::uint64_t{1} << (key % 64);
|
||||
} else {
|
||||
OverlayInputAccess access;
|
||||
for (unsigned key = 0; key < 256; ++key) {
|
||||
if (dingosdk::bindable_keyboard_key(key) && (GetAsyncKeyState(key) & 0x8000)) {
|
||||
@@ -567,7 +577,13 @@ extern "C" void DingoSDKOverlayReadFlightInput(dingosdk::overlay::FlightInput* o
|
||||
return;
|
||||
}
|
||||
OverlayInputAccess editor_access;
|
||||
const auto held = [](int key) { return (GetAsyncKeyState(key) & 0x8000) != 0; };
|
||||
// (The keys as sampled off this thread when there is a sample, cursor.cpp: this runs every
|
||||
// client frame while flying.)
|
||||
std::array<std::uint64_t, 4> sampled{};
|
||||
const bool have_sample = sampled_keys(window, sampled);
|
||||
const auto held = [&](int key) {
|
||||
return have_sample ? ((sampled[key / 64] >> (key % 64)) & 1) != 0 : (GetAsyncKeyState(key) & 0x8000) != 0;
|
||||
};
|
||||
// Physical right button: the logical one unless the buttons are swapped.
|
||||
const bool swapped = GetSystemMetrics(SM_SWAPBUTTON) != 0;
|
||||
const bool raw = raw_mouse_live(s);
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include "chat_emotes.h"
|
||||
#include "input_capture.h"
|
||||
#include "cursor.h"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <dxgi1_6.h>
|
||||
#include <imgui_internal.h>
|
||||
|
||||
@@ -364,9 +366,16 @@ bool setup_graphics() {
|
||||
// Thumbnails are read from the game's own data at startup; the read is
|
||||
// normally long finished by the time the first frame gets here.
|
||||
// Emotes and Hall of Meat's images reserve their room before the park previews build the atlas, and fill it after.
|
||||
const auto emote_count = reserve_chat_emotes(*ImGui::GetIO().Fonts, std::chrono::seconds(5));
|
||||
reserve_hall_of_meat_images(*ImGui::GetIO().Fonts, std::chrono::seconds(5));
|
||||
const auto preview_count = load_park_previews(*ImGui::GetIO().Fonts, std::chrono::seconds(5));
|
||||
// (Five seconds for the three together, not each: this is the game's render thread, and three
|
||||
// reads still under way at the first frame held it up to fifteen.)
|
||||
const auto waited_until = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
||||
const auto left = [&] {
|
||||
return std::max(std::chrono::milliseconds(0),
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(waited_until - std::chrono::steady_clock::now()));
|
||||
};
|
||||
const auto emote_count = reserve_chat_emotes(*ImGui::GetIO().Fonts, left());
|
||||
reserve_hall_of_meat_images(*ImGui::GetIO().Fonts, left());
|
||||
const auto preview_count = load_park_previews(*ImGui::GetIO().Fonts, left());
|
||||
fill_chat_emotes(*ImGui::GetIO().Fonts);
|
||||
fill_hall_of_meat_images(*ImGui::GetIO().Fonts);
|
||||
if (emote_count)
|
||||
@@ -599,6 +608,7 @@ void render(IDXGISwapChain* presented, UINT flags) {
|
||||
auto& s = state();
|
||||
if (flags & DXGI_PRESENT_TEST) return;
|
||||
dingosdk::profiler::record_present();
|
||||
DINGO_PROFILE_ZONE("present/overlay");
|
||||
std::lock_guard lock(s.render_mutex);
|
||||
// Said once: a log without this line is a game whose presentations never reach the overlay.
|
||||
if (static bool said{}; !std::exchange(said, true))
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/engine.h"
|
||||
#include "Engine/Game/Build/20260929/level_loading.h"
|
||||
#include "Extension/Boot/exit_watch.h"
|
||||
#include <atomic>
|
||||
#include <format>
|
||||
#include <mutex>
|
||||
@@ -106,24 +107,49 @@ std::chrono::milliseconds since_process_start() noexcept {
|
||||
const auto ticks = [](const FILETIME& time) { return (static_cast<std::uint64_t>(time.dwHighDateTime) << 32) | time.dwLowDateTime; };
|
||||
return std::chrono::milliseconds(ticks(now) > ticks(created) ? (ticks(now) - ticks(created)) / 10000 : 0);
|
||||
}
|
||||
// The time spent in the level now loaded (GetTickCount64 when it became playable, 0 when none
|
||||
// is) and its destination. Under the observer's lock.
|
||||
std::uint64_t session_started{};
|
||||
std::string session_destination;
|
||||
// The frame pacing of the time spent in a level, for Sentry. Short stays say nothing about its
|
||||
// frame rate. Called with the observer's lock held.
|
||||
void send_level_session(std::uint64_t at) {
|
||||
if (!session_started) return;
|
||||
const auto pacing = trace::take_frame_pacing();
|
||||
const auto lasted = at >= session_started ? at - session_started : 0;
|
||||
if (lasted >= 10000 && pacing.frames && pacing.total_ns)
|
||||
trace::record("game.level_session", "app.session", std::chrono::milliseconds(lasted), true,
|
||||
{{"destination", session_destination}},
|
||||
{{"fps_avg", static_cast<double>(pacing.frames) * 1e9 / static_cast<double>(pacing.total_ns)},
|
||||
{"hitches", static_cast<double>(pacing.hitches)},
|
||||
{"worst_frame", static_cast<double>(pacing.worst_ns) / 1e6, "millisecond"}});
|
||||
session_started = 0;
|
||||
}
|
||||
// The player closing the game (exit_watch, on a thread of its own): the level they are in is
|
||||
// sent now, and given a moment to go out. It used to be sent only when the next level began
|
||||
// loading, so a stay that ended with the game closing (the long ones, as often as not) was
|
||||
// never sent. A close the game turns down starts the stay afresh.
|
||||
void send_level_session_on_close() noexcept {
|
||||
try {
|
||||
auto& o = observer();
|
||||
{
|
||||
std::unique_lock lock(o.mutex, std::defer_lock);
|
||||
for (int tries = 0; tries < 50 && !lock.try_lock(); ++tries) Sleep(10);
|
||||
if (!lock.owns_lock()) return;
|
||||
if (!session_started) return;
|
||||
const auto now = GetTickCount64();
|
||||
send_level_session(now);
|
||||
session_started = now;
|
||||
}
|
||||
trace::flush(std::chrono::seconds(3));
|
||||
} catch (...) {}
|
||||
}
|
||||
// Timings for Sentry (Engine/Core/Debug/trace.h), from the same observations as the log:
|
||||
// each load, the launch up to the first playable level, and the frame pacing of the time
|
||||
// spent in a level. Called with the observer's lock held.
|
||||
void trace_progress(const level_logging::Progress& progress, bool was_running, std::uint64_t started, std::uint64_t at) {
|
||||
static std::uint64_t session_started{};
|
||||
static std::string session_destination;
|
||||
if (!was_running && progress.running && session_started) {
|
||||
// A new load ends the level before it. Short stays say nothing about its frame rate.
|
||||
const auto pacing = trace::take_frame_pacing();
|
||||
const auto lasted = at >= session_started ? at - session_started : 0;
|
||||
if (lasted >= 10000 && pacing.frames && pacing.total_ns)
|
||||
trace::record("game.level_session", "app.session", std::chrono::milliseconds(lasted), true,
|
||||
{{"destination", session_destination}},
|
||||
{{"fps_avg", static_cast<double>(pacing.frames) * 1e9 / static_cast<double>(pacing.total_ns)},
|
||||
{"hitches", static_cast<double>(pacing.hitches)},
|
||||
{"worst_frame", static_cast<double>(pacing.worst_ns) / 1e6, "millisecond"}});
|
||||
session_started = 0;
|
||||
}
|
||||
// A new load ends the level before it.
|
||||
if (!was_running && progress.running) send_level_session(at);
|
||||
// A load that just reached a terminal state; "first" is the one behind the splash screen.
|
||||
if (!was_running || progress.running || progress.started != started || at < started) return;
|
||||
const bool active = progress.state == 13 || progress.state == 21;
|
||||
@@ -289,6 +315,7 @@ bool start_level_loading_logging(std::uintptr_t base, BeforeLevelTransition befo
|
||||
}
|
||||
o.base = base;
|
||||
o.before_transition = before_transition;
|
||||
exit_watch::when_closing(&send_level_session_on_close);
|
||||
auto* target = reinterpret_cast<void*>(base + level_transition_contract.rva);
|
||||
void* original{};
|
||||
auto status = hook_prepare(target, reinterpret_cast<void*>(&transition), &original);
|
||||
|
||||
+25
-39
@@ -47,7 +47,6 @@
|
||||
#include "Extension/World/level_loading.h"
|
||||
#include "Extension/World/loading_screen.h"
|
||||
#include <Windows.h>
|
||||
#include <TlHelp32.h>
|
||||
#include <dxgi.h>
|
||||
#include <Psapi.h>
|
||||
#include <algorithm>
|
||||
@@ -57,6 +56,7 @@
|
||||
#include <iomanip>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <format>
|
||||
#include <thread>
|
||||
|
||||
namespace dingosdk::runtime::detail {
|
||||
@@ -1311,47 +1311,33 @@ void tick(std::uintptr_t client, std::uintptr_t update) {
|
||||
said_at = GetTickCount64();
|
||||
said_private = counters.PrivateUsage; said_players = players; said_skaters = skaters;
|
||||
const auto gigabytes = [](std::uint64_t bytes) { return static_cast<double>(bytes) / (1024.0 * 1024.0 * 1024.0); };
|
||||
// Whose memory it is. The game allocates from its own pools; what ReSkate, the
|
||||
// Spotify library in it and Windows' own libraries allocate comes from the
|
||||
// process's heaps, so memory that grows there is not the game's (a leak looked
|
||||
// for in the music player, 2026-10-13). Threads and handles grow with a leak of
|
||||
// those. HeapSummary is asked for by name: older Windows SDKs do not declare it.
|
||||
struct Summary { DWORD size; SIZE_T allocated, committed, reserved, most; };
|
||||
static const auto heap_summary = reinterpret_cast<BOOL (WINAPI*)(HANDLE, DWORD, Summary*)>(
|
||||
GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "HeapSummary"));
|
||||
std::uint64_t heap_allocated{}, heap_committed{};
|
||||
std::array<HANDLE, 256> heaps{};
|
||||
const auto heap_count = GetProcessHeaps(static_cast<DWORD>(heaps.size()), heaps.data());
|
||||
for (DWORD i = 0; heap_summary && i < heap_count && i < heaps.size(); ++i) {
|
||||
Summary summary{sizeof(Summary)};
|
||||
if (heap_summary(heaps[i], 0, &summary)) {
|
||||
heap_allocated += summary.allocated;
|
||||
heap_committed += summary.committed;
|
||||
}
|
||||
}
|
||||
// Handles grow with a leak of them (a leak looked for in the music player,
|
||||
// 2026-10-13); counting them is cheap.
|
||||
//
|
||||
// The heaps' sizes and the threads were counted here as well, and that held the
|
||||
// game's frame up: HeapSummary walks a whole heap with its lock held, which took
|
||||
// the client thread from half a second to two seconds each time (a player's
|
||||
// profile, 2026-10-15), every two minutes and at each change above. They are not
|
||||
// counted any more. The GPU's share and the memory ledger (a DXGI factory, a lock)
|
||||
// are written from a thread of their own, so the line costs the frame nothing.
|
||||
DWORD handles{};
|
||||
GetProcessHandleCount(GetCurrentProcess(), &handles);
|
||||
unsigned threads{};
|
||||
if (const auto snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); snapshot != INVALID_HANDLE_VALUE) {
|
||||
THREADENTRY32 entry{sizeof(entry)};
|
||||
const auto self = GetCurrentProcessId();
|
||||
for (BOOL more = Thread32First(snapshot, &entry); more; more = Thread32Next(snapshot, &entry))
|
||||
if (entry.th32OwnerProcessID == self) ++threads;
|
||||
CloseHandle(snapshot);
|
||||
}
|
||||
// The GPU's share (its system memory is not in the committed figure), and while
|
||||
// the memory ledger is on, who holds the most (memory_ledger.h).
|
||||
std::string more;
|
||||
try {
|
||||
if (const auto gpu = dingosdk::memory_ledger::gpu_line(); !gpu.empty()) more += " " + gpu + ".";
|
||||
if (const auto owners = dingosdk::memory_ledger::owners_line(); !owners.empty()) more += " " + owners + ".";
|
||||
} catch (...) {}
|
||||
dingosdk::logging::printf(dingosdk::logging::Level::info, dingosdk::logging::Channel::runtime,
|
||||
"Memory: %.2f GB committed, %.2f GB in RAM; %d player(s) in the session, %d built as skaters (player distance %.0f m); map %s. "
|
||||
"Outside the game's own pools: %.2f GB in use of %.2f GB in %lu heap(s); %u thread(s), %lu handle(s).%s",
|
||||
auto line = std::format(
|
||||
"Memory: {:.2f} GB committed, {:.2f} GB in RAM; {} player(s) in the session, {} built as skaters "
|
||||
"(player distance {:.0f} m); map {}. {} handle(s).",
|
||||
gigabytes(counters.PrivateUsage), gigabytes(counters.WorkingSetSize), players, skaters, mp.player_distance,
|
||||
(session && !mp.map.empty() ? mp.map : r.multiplayer_map).c_str(),
|
||||
gigabytes(heap_allocated), gigabytes(heap_committed), heap_count, threads, handles, more.c_str());
|
||||
session && !mp.map.empty() ? mp.map : r.multiplayer_map, handles);
|
||||
try {
|
||||
std::thread([line = std::move(line)]() mutable {
|
||||
try {
|
||||
// The GPU's share (its system memory is not in the committed figure),
|
||||
// and while the memory ledger is on, who holds the most (memory_ledger.h).
|
||||
if (const auto gpu = dingosdk::memory_ledger::gpu_line(); !gpu.empty()) line += " " + gpu + ".";
|
||||
if (const auto owners = dingosdk::memory_ledger::owners_line(); !owners.empty()) line += " " + owners + ".";
|
||||
dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::runtime, line);
|
||||
} catch (...) {}
|
||||
}).detach();
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
#include "Engine/Core/Profiling/profiler.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/profiler_labels.h"
|
||||
#include "Extension/World/level_loading.h"
|
||||
#include <format>
|
||||
|
||||
// The game's client frame job, timed for the profiler (Engine/Core/Profiling/profiler.h). It runs
|
||||
// the whole client frame, ReSkate's tick included, so its time against the sim budget (16.7 ms at
|
||||
@@ -12,11 +14,82 @@ namespace dingosdk::runtime::detail {
|
||||
namespace {
|
||||
using ClientFrameJob = std::uintptr_t (*)(std::uintptr_t, std::uintptr_t, std::uintptr_t, std::uintptr_t);
|
||||
ClientFrameJob original_client_frame_job{};
|
||||
|
||||
// A freeze: half a second or more from one client frame's start to the next. When this launch
|
||||
// sends timings, each is sent as a "game.freeze" saying where the time went:
|
||||
// - the game's client frame (which ReSkate's tick runs inside), and ReSkate's own tick in it;
|
||||
// - the rest, outside the frame: the game waiting on rendering, the GPU or loading;
|
||||
// - how many frames the renderer presented meanwhile (none: it stopped as well);
|
||||
// - ReSkate's zones (profiler.h) that took the most time in it, on any thread.
|
||||
// Players reported freezes of about a second while skating (2026-10-15); the level session's
|
||||
// timing only gave their length.
|
||||
constexpr std::uint64_t freeze_ns = 500'000'000;
|
||||
constexpr unsigned freezes_per_launch = 30;
|
||||
struct Frames { // the client thread's alone
|
||||
std::uint64_t start{}, frame_ns{}, own_ns{}, presents{};
|
||||
dingosdk::profiler::ZoneMark mark;
|
||||
unsigned sent{};
|
||||
bool watching{};
|
||||
};
|
||||
Frames& frames() {
|
||||
static auto* value = new Frames;
|
||||
return *value;
|
||||
}
|
||||
void describe_freeze(Frames& f, std::uint64_t start) {
|
||||
const auto ms = [](std::uint64_t ns) { return static_cast<double>(ns) / 1e6; };
|
||||
const auto interval = start - f.start;
|
||||
const auto frame = std::min(f.frame_ns, interval);
|
||||
const auto own = std::min(f.own_ns, frame);
|
||||
const auto presented = dingosdk::profiler::presents() - f.presents;
|
||||
const char* where = own * 2 >= interval ? "ReSkate's tick"
|
||||
: frame * 2 >= interval ? "the game's frame"
|
||||
: "outside the frame (rendering, GPU or loading)";
|
||||
dingosdk::trace::Tags tags{{"destination", dingosdk::last_level_destination()},
|
||||
{"where", where},
|
||||
{"renderer", presented ? "kept presenting" : "stopped too"}};
|
||||
dingosdk::trace::Measurements measurements{
|
||||
{"freeze", ms(interval), "millisecond"}, {"client_frame", ms(frame), "millisecond"},
|
||||
{"reskate_tick", ms(own), "millisecond"}, {"outside_frame", ms(interval - frame), "millisecond"},
|
||||
{"presents", static_cast<double>(presented)}};
|
||||
const auto zones = dingosdk::profiler::zones_since(f.mark, 4);
|
||||
for (std::size_t i = 0; i < zones.size(); ++i)
|
||||
tags.emplace_back(std::format("zone.{}", i + 1), std::format("{} ({:.0f} ms)", zones[i].name, zones[i].milliseconds));
|
||||
dingosdk::trace::record("game.freeze", "app.freeze",
|
||||
std::chrono::milliseconds(static_cast<long long>(interval / 1'000'000)), true, std::move(tags), std::move(measurements));
|
||||
}
|
||||
// At each client frame's start: the stretch since the last one, described if it froze, and the
|
||||
// marks the next is measured against.
|
||||
void watch_frame(std::uint64_t start) noexcept {
|
||||
if (!dingosdk::trace::active()) return;
|
||||
try {
|
||||
auto& f = frames();
|
||||
if (!f.watching) {
|
||||
f.watching = true;
|
||||
dingosdk::profiler::keep_zones(true);
|
||||
}
|
||||
// (A level loading holds the frame up by design: only freezes in a loaded level are sent.)
|
||||
if (f.start && start > f.start && start - f.start >= freeze_ns && f.sent < freezes_per_launch &&
|
||||
dingosdk::level_content_active()) {
|
||||
++f.sent;
|
||||
describe_freeze(f, start);
|
||||
}
|
||||
f.start = start;
|
||||
f.presents = dingosdk::profiler::presents();
|
||||
dingosdk::profiler::mark_zones(f.mark);
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
std::uintptr_t client_frame_job(std::uintptr_t job, std::uintptr_t timing, std::uintptr_t dependencies, std::uintptr_t extra) {
|
||||
const auto start = dingosdk::profiler::now_ns();
|
||||
dingosdk::trace::frame(start); // frame pacing for a level session's timing; a few atomic adds
|
||||
watch_frame(start);
|
||||
const auto result = original_client_frame_job(job, timing, dependencies, extra);
|
||||
dingosdk::profiler::record_client_frame(dingosdk::profiler::now_ns() - start);
|
||||
const auto took = dingosdk::profiler::now_ns() - start;
|
||||
dingosdk::profiler::record_client_frame(took);
|
||||
if (auto& f = frames(); f.watching) {
|
||||
f.frame_ns = took;
|
||||
f.own_ns = dingosdk::profiler::last_client_own_ns();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ use librespot_connect::{ConnectConfig, LoadContextOptions, LoadRequest, LoadRequ
|
||||
use librespot_core::authentication::Credentials;
|
||||
use librespot_core::cache::Cache;
|
||||
use librespot_core::config::SessionConfig;
|
||||
use librespot_core::error::ErrorKind;
|
||||
use librespot_core::session::Session;
|
||||
use librespot_discovery::{DeviceType, Discovery};
|
||||
use librespot_metadata::audio::UniqueFields;
|
||||
@@ -131,6 +132,8 @@ async fn run(name: String, cache_folder: Option<PathBuf>, mut asked: mpsc::Unbou
|
||||
};
|
||||
|
||||
let mut credentials: Option<Credentials> = cache.as_ref().and_then(|cache| cache.credentials());
|
||||
// Tries in a row that could not reach Spotify with the account (the wait grows with them).
|
||||
let mut unreachable: u32 = 0;
|
||||
loop {
|
||||
// Until there is an account: one kept from before, or one handed over now.
|
||||
let account = match credentials.take() {
|
||||
@@ -160,16 +163,39 @@ async fn run(name: String, cache_folder: Option<PathBuf>, mut asked: mpsc::Unbou
|
||||
let player = Player::new(player_config, session.clone(), mixer.get_soft_volume(), || Box::new(GameSink));
|
||||
let mut events = player.get_player_event_channel();
|
||||
let connect_config = ConnectConfig { name: name.clone(), device_type: DeviceType::Computer, initial_volume: u16::MAX, ..ConnectConfig::default() };
|
||||
let kept = account.clone();
|
||||
let (spirc, task) = match Spirc::new(connect_config, session.clone(), account, player.clone(), mixer).await {
|
||||
Ok(made) => made,
|
||||
Err(error) => {
|
||||
// (An account kept from before that Spotify no longer takes: it is asked for anew.)
|
||||
log::error!("Spotify did not take the account: {error}");
|
||||
tell(&["DISCONNECTED"]);
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
let wait = match error.kind {
|
||||
// (An account Spotify refuses, or one not Premium: it is asked for anew.)
|
||||
ErrorKind::Unauthenticated | ErrorKind::PermissionDenied | ErrorKind::InvalidArgument => {
|
||||
unreachable = 0;
|
||||
Duration::from_secs(3)
|
||||
}
|
||||
// Spotify could not be reached (the connection was down, its service did not
|
||||
// answer): the same account is tried again, waiting longer each time. It used
|
||||
// to be dropped here, and the device then waited to be picked in Spotify's
|
||||
// own app: a game started while the connection was down played no Spotify
|
||||
// until it was restarted (2026-10-15).
|
||||
_ => {
|
||||
credentials = Some(kept);
|
||||
unreachable = unreachable.saturating_add(1);
|
||||
let seconds = (3u64 << unreachable.saturating_sub(1).min(5)).min(60);
|
||||
log::warn!("Spotify could not be reached; trying the account again in {seconds} s");
|
||||
Duration::from_secs(seconds)
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(wait) => {}
|
||||
line = asked.recv() => if matches!(line.as_deref(), Some("quit") | None) { return; },
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
unreachable = 0;
|
||||
// (With the account taken, the device is the player's to use.)
|
||||
tell(&["CONNECTED", &session.username()]);
|
||||
tokio::pin!(task);
|
||||
@@ -178,6 +204,15 @@ async fn run(name: String, cache_folder: Option<PathBuf>, mut asked: mpsc::Unbou
|
||||
tokio::select! {
|
||||
_ = &mut task => {
|
||||
tell(&["DISCONNECTED"]);
|
||||
// (The connection to Spotify ended under it: the account is taken up again,
|
||||
// as kept since it was given, rather than waiting to be picked in Spotify's app.)
|
||||
if credentials.is_none() {
|
||||
credentials = cache.as_ref().and_then(|cache| cache.credentials()).or_else(|| Some(kept.clone()));
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(3)) => {}
|
||||
line = asked.recv() => if matches!(line.as_deref(), Some("quit") | None) { return; },
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
event = events.recv() => match event {
|
||||
|
||||
Reference in new issue
Block a user