diff --git a/Engine/Core/Debug/trace.cpp b/Engine/Core/Debug/trace.cpp index 56fc569..a97216b 100644 --- a/Engine/Core/Debug/trace.cpp +++ b/Engine/Core/Debug/trace.cpp @@ -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 active{}; +std::atomic started_state{}; constexpr std::uint64_t hitch_ns = 50ull * 1000 * 1000; struct Pacing { std::atomic 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(); 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); diff --git a/Engine/Core/Debug/trace.h b/Engine/Core/Debug/trace.h index 497c93a..f643e20 100644 --- a/Engine/Core/Debug/trace.h +++ b/Engine/Core/Debug/trace.h @@ -18,6 +18,8 @@ using Measurements = std::vector; // 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, diff --git a/Engine/Core/Hooks/hooks.cpp b/Engine/Core/Hooks/hooks.cpp index aef3e25..974fb1a 100644 --- a/Engine/Core/Hooks/hooks.cpp +++ b/Engine/Core/Hooks/hooks.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include 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; diff --git a/Engine/Core/Hooks/hooks.h b/Engine/Core/Hooks/hooks.h index 408bda1..1f831be 100644 --- a/Engine/Core/Hooks/hooks.h +++ b/Engine/Core/Hooks/hooks.h @@ -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 diff --git a/Engine/Core/Platform/launcher_pe.cpp b/Engine/Core/Platform/launcher_pe.cpp index 64a72a4..6fdd5be 100644 --- a/Engine/Core/Platform/launcher_pe.cpp +++ b/Engine/Core/Platform/launcher_pe.cpp @@ -3,6 +3,8 @@ #include "path_text.h" #ifdef _WIN32 +#include +#include #include #include #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 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 diff --git a/Engine/Core/Profiling/profiler.cpp b/Engine/Core/Profiling/profiler.cpp index a58087b..b730b9e 100644 --- a/Engine/Core/Profiling/profiler.cpp +++ b/Engine/Core/Profiling/profiler.cpp @@ -17,6 +17,8 @@ std::atomic sampling{false}; namespace { std::atomic sites{nullptr}; std::atomic hud_visible{false}, window_visible{false}, monitor_running{false}; +std::atomic zones_kept{false}; +std::atomic 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 zones_since(const ZoneMark& mark, std::size_t most) noexcept { + std::vector 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(ns - mark.ns) / 1e6 / static_cast(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::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(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 summary() noexcept { std::lock_guard lock(summary_mutex); return latest_summary; diff --git a/Engine/Core/Profiling/profiler.h b/Engine/Core/Profiling/profiler.h index 029738d..d1e5d11 100644 --- a/Engine/Core/Profiling/profiler.h +++ b/Engine/Core/Profiling/profiler.h @@ -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 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> totals; + std::uint64_t tsc{}, ns{}; +}; +void mark_zones(ZoneMark& mark) noexcept; +struct ZoneTime { + std::string name; + double milliseconds{}; +}; +std::vector 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. diff --git a/Engine/Core/Storage/save_database.cpp b/Engine/Core/Storage/save_database.cpp index b2ce12d..5f41476 100644 --- a/Engine/Core/Storage/save_database.cpp +++ b/Engine/Core/Storage/save_database.cpp @@ -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) { diff --git a/Engine/Game/Build/20260929/native_vfx.h b/Engine/Game/Build/20260929/native_vfx.h index b857707..c8446bf 100644 --- a/Engine/Game/Build/20260929/native_vfx.h +++ b/Engine/Game/Build/20260929/native_vfx.h @@ -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, diff --git a/Engine/Game/Build/20260929/render_resource_pool.h b/Engine/Game/Build/20260929/render_resource_pool.h index 9830a50..463e504 100644 --- a/Engine/Game/Build/20260929/render_resource_pool.h +++ b/Engine/Game/Build/20260929/render_resource_pool.h @@ -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; diff --git a/Engine/Resource/Test/oodle_test_codec.cpp b/Engine/Resource/Test/oodle_test_codec.cpp index 9c794ac..a095754 100644 --- a/Engine/Resource/Test/oodle_test_codec.cpp +++ b/Engine/Resource/Test/oodle_test_codec.cpp @@ -8,3 +8,8 @@ extern "C" __declspec(dllexport) std::int64_t __cdecl OodleLZ_Decompress( *static_cast(output) = *static_cast(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; +} diff --git a/Engine/Resource/cas_codec.cpp b/Engine/Resource/cas_codec.cpp index 83b4570..a1ab564 100644 --- a/Engine/Resource/cas_codec.cpp +++ b/Engine/Resource/cas_codec.cpp @@ -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(GetProcAddress(handle, "OodleCore_Plugins_SetAssertion"))) +#pragma warning(pop) + set(&oodle_assertion); return handle; } @@ -134,12 +145,24 @@ std::vector oodle_decode(std::span 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(gameRoot, "OodleLZ_Decompress"); - std::vector 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(gameRoot, "OodleLZ_GetDecodeBufferSize")( + any_compressor, static_cast(size), 1); + const auto padded = std::max(needed, static_cast(size) + block); + std::vector output(static_cast(padded)); if (decode(input.data(), static_cast(input.size()), output.data(), - static_cast(output.size()), 1, 0, 0, nullptr, 0, nullptr, nullptr, + static_cast(size), 1, 0, 0, nullptr, 0, nullptr, nullptr, nullptr, 0, 3) != static_cast(size)) throw std::runtime_error("Oodle CAS block failed to decode"); + output.resize(size); return output; #else (void)input; diff --git a/Extension/Assets/map_download.cpp b/Extension/Assets/map_download.cpp index 96e3dc8..d685318 100644 --- a/Extension/Assets/map_download.cpp +++ b/Extension/Assets/map_download.cpp @@ -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> by_level; + std::uint64_t read_at{}; + bool valid{}, reading{}; }; std::mutex known_mutex; -std::map> known_packages; +KnownPackages known_packages; + +std::map> read_known_packages() { + std::map> 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(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> 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 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(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{}; } } diff --git a/Extension/Assets/map_download.h b/Extension/Assets/map_download.h index 4c0ae27..13f4c1f 100644 --- a/Extension/Assets/map_download.h +++ b/Extension/Assets/map_download.h @@ -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; } diff --git a/Extension/Assets/native_render_resource_pool.cpp b/Extension/Assets/native_render_resource_pool.cpp index 7dda8bc..b2ede62 100644 --- a/Extension/Assets/native_render_resource_pool.cpp +++ b/Extension/Assets/native_render_resource_pool.cpp @@ -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(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(self), std::memory_order_release); + } if (load_field(self, vector_begin_offset) != buffers.records || load_field(self, vector_capacity_offset) != record_end || load_field(self, vector_end_offset) < buffers.records || diff --git a/Extension/BoardBreak/board_break.cpp b/Extension/BoardBreak/board_break.cpp index 144f464..396b9aa 100644 --- a/Extension/BoardBreak/board_break.cpp +++ b/Extension/BoardBreak/board_break.cpp @@ -21,6 +21,8 @@ #include #include #include +#include +#include #include #include #include @@ -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); diff --git a/Extension/Boot/exit_watch.cpp b/Extension/Boot/exit_watch.cpp index b329111..6986665 100644 --- a/Extension/Boot/exit_watch.cpp +++ b/Extension/Boot/exit_watch.cpp @@ -10,9 +10,11 @@ namespace { constexpr ULONGLONG grace_ms = 8000, watch_ms = 120000; std::atomic watching{}; std::atomic destroyed_at{}; +std::atomic 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); diff --git a/Extension/Boot/exit_watch.h b/Extension/Boot/exit_watch.h index 94d7c43..4f5c9e6 100644 --- a/Extension/Boot/exit_watch.h +++ b/Extension/Boot/exit_watch.h @@ -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; } diff --git a/Extension/Customization/local_customization_runtime.cpp b/Extension/Customization/local_customization_runtime.cpp index 3ff43b4..9f24edf 100644 --- a/Extension/Customization/local_customization_runtime.cpp +++ b/Extension/Customization/local_customization_runtime.cpp @@ -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 #include #include @@ -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(c); - } - return false; + std::array 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(text[i]) < 32 || text[i] == 127) return false; + value.assign(text.data(), static_cast(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> item_arrays; words.reserve(16 * 257); item_arrays.reserve(16); std::vector 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()) slot = &expected.items[j]; } diff --git a/Extension/HallOfMeat/hall_of_meat.cpp b/Extension/HallOfMeat/hall_of_meat.cpp index e47e7b2..1d2a913 100644 --- a/Extension/HallOfMeat/hall_of_meat.cpp +++ b/Extension/HallOfMeat/hall_of_meat.cpp @@ -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 { diff --git a/Extension/Multiplayer/Hud/native_party.cpp b/Extension/Multiplayer/Hud/native_party.cpp index 39d05ae..6a175c1 100644 --- a/Extension/Multiplayer/Hud/native_party.cpp +++ b/Extension/Multiplayer/Hud/native_party.cpp @@ -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); diff --git a/Extension/Multiplayer/Remote/native_vfx.cpp b/Extension/Multiplayer/Remote/native_vfx.cpp index e051517..af32caa 100644 --- a/Extension/Multiplayer/Remote/native_vfx.cpp +++ b/Extension/Multiplayer/Remote/native_vfx.cpp @@ -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(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
moved; for (std::uint32_t i = 0; i < count; ++i) { const auto element = ptr(data, i * 8ULL); diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 1ad2a03..f08962a 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -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); diff --git a/Extension/Multiplayer/Steam/steam_social.cpp b/Extension/Multiplayer/Steam/steam_social.cpp index 42a6e54..04286dd 100644 --- a/Extension/Multiplayer/Steam/steam_social.cpp +++ b/Extension/Multiplayer/Steam/steam_social.cpp @@ -75,7 +75,9 @@ std::shared_ptr 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) { diff --git a/Extension/Multiplayer/Steam/steam_transport.cpp b/Extension/Multiplayer/Steam/steam_transport.cpp index 81c5e14..c77dae8 100644 --- a/Extension/Multiplayer/Steam/steam_transport.cpp +++ b/Extension/Multiplayer/Steam/steam_transport.cpp @@ -174,6 +174,7 @@ struct SteamTransport::Impl { std::to_string(static_cast(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 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{}; diff --git a/Extension/Music/local_music_assets.cpp b/Extension/Music/local_music_assets.cpp index fee3e79..c8b42f4 100644 --- a/Extension/Music/local_music_assets.cpp +++ b/Extension/Music/local_music_assets.cpp @@ -18,7 +18,11 @@ #include "Extension/Profile/runtime_internal.h" #include #include +#include +#include +#include #include +#include 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 mod_music_playlists() { +// "songs":["artist - title",..]}]}. A bad file is logged and skipped; its songs still play +// from the master playlist. +std::vector read_mod_music_playlists() { std::vector result; for (const auto& mod : mods::catalog().mods) { const auto path = mod.directory / L"reskate-music.json"; @@ -65,6 +69,97 @@ std::vector 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> ready; // null until the first read is done + std::vector 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 mod_music_stamps() { + std::vector 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> read; + std::vector 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>(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>(); + } + 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> 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 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> 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 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) diff --git a/Extension/Music/local_music_assets.h b/Extension/Music/local_music_assets.h index f3ba05d..86e8d2d 100644 --- a/Extension/Music/local_music_assets.h +++ b/Extension/Music/local_music_assets.h @@ -1,6 +1,7 @@ #pragma once #include "Engine/Vfs/mod_music.h" #include "Extension/Profile/runtime_internal.h" +#include 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 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> 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); } diff --git a/Extension/Music/local_music_shelf.cpp b/Extension/Music/local_music_shelf.cpp index 00e3d7f..d5dfaf9 100644 --- a/Extension/Music/local_music_shelf.cpp +++ b/Extension/Music/local_music_shelf.cpp @@ -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, diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index b120df0..f815cbc 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -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; } diff --git a/Extension/Music/youtube_music.cpp b/Extension/Music/youtube_music.cpp index a700298..3c54d1c 100644 --- a/Extension/Music/youtube_music.cpp +++ b/Extension/Music/youtube_music.cpp @@ -538,8 +538,8 @@ std::vector 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(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(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 (...) {} } diff --git a/Extension/Profile/local_profile_preferences.cpp b/Extension/Profile/local_profile_preferences.cpp index 55d007e..bb86507 100644 --- a/Extension/Profile/local_profile_preferences.cpp +++ b/Extension/Profile/local_profile_preferences.cpp @@ -1,5 +1,10 @@ #include "Engine/Core/Log/logging.h" #include "runtime_internal.h" +#include +#include +#include +#include +#include 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>> queue; + std::map> 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> 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() != value.get()) + 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()}}.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> 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> 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 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 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(); + const auto value = s.store->user_value(full_key); if (value && value->is_boolean()) return value->get(); } 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() != 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 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>& values) n std::vector> 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. */ } } diff --git a/Extension/Throwdowns/throwdown_lab.cpp b/Extension/Throwdowns/throwdown_lab.cpp index 1aef52b..1003d6c 100644 --- a/Extension/Throwdowns/throwdown_lab.cpp +++ b/Extension/Throwdowns/throwdown_lab.cpp @@ -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 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 hashes, bool worker = false) { std::vector 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); diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index 511285e..bebe5cf 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -19,6 +19,8 @@ #include "Extension/Skater/no_bail.h" #include "Extension/Skater/physics_tuning.h" #include "Extension/UI/Overlay/overlay.h" +#include +#include #include #include #include @@ -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 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(); diff --git a/Extension/UI/Overlay/cursor.cpp b/Extension/UI/Overlay/cursor.cpp index 57dc0e3..a991e0f 100644 --- a/Extension/UI/Overlay/cursor.cpp +++ b/Extension/UI/Overlay/cursor.cpp @@ -2,14 +2,95 @@ #include "cursor.h" #include "input_capture.h" #include "Engine/Core/Log/logging.h" +#include +#include +#include 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 window{}; + std::atomic foreground{}; + std::array, 4> keys{}; + std::atomic at{}; // GetTickCount64 when the last sample was complete + std::atomic wanted{}; // the last time a reader asked for it + std::atomic 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 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& 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. diff --git a/Extension/UI/Overlay/cursor.h b/Extension/UI/Overlay/cursor.h index ee35300..83fc88d 100644 --- a/Extension/UI/Overlay/cursor.h +++ b/Extension/UI/Overlay/cursor.h @@ -1,7 +1,14 @@ #pragma once #include +#include +#include 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& held); void sync_menu_cursor(bool release = false); UINT cursor_sync_message(); void request_window_cursor_sync(HWND window); diff --git a/Extension/UI/Overlay/modding_menu.cpp b/Extension/UI/Overlay/modding_menu.cpp index f42f325..19f8a80 100644 --- a/Extension/UI/Overlay/modding_menu.cpp +++ b/Extension/UI/Overlay/modding_menu.cpp @@ -10,13 +10,60 @@ #include #include +#include #include +#include +#include #include +#include #include #include #include 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 FolderRead { +public: + template std::shared_ptr 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 fresh; + try { fresh = std::make_shared(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(std::move(value)); + } + void again() { next_ = 0; } +private: + std::mutex mutex_; + std::shared_ptr value_; + std::atomic reading_{}; + std::atomic next_{}; +}; +struct DeckScan { + std::vector 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 installed; - static std::vector 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; // 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; // 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(); } } diff --git a/Extension/UI/Overlay/overlay_input.cpp b/Extension/UI/Overlay/overlay_input.cpp index b82a4da..ffb01b2 100644 --- a/Extension/UI/Overlay/overlay_input.cpp +++ b/Extension/UI/Overlay/overlay_input.cpp @@ -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 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 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 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); diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index cb50975..d1029ae 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -9,6 +9,8 @@ #include "chat_emotes.h" #include "input_capture.h" #include "cursor.h" +#include +#include #include #include @@ -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(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)) diff --git a/Extension/World/level_loading.cpp b/Extension/World/level_loading.cpp index e41c6d1..06256b4 100644 --- a/Extension/World/level_loading.cpp +++ b/Extension/World/level_loading.cpp @@ -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 #include #include @@ -106,24 +107,49 @@ std::chrono::milliseconds since_process_start() noexcept { const auto ticks = [](const FILETIME& time) { return (static_cast(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(pacing.frames) * 1e9 / static_cast(pacing.total_ns)}, + {"hitches", static_cast(pacing.hitches)}, + {"worst_frame", static_cast(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(pacing.frames) * 1e9 / static_cast(pacing.total_ns)}, - {"hitches", static_cast(pacing.hitches)}, - {"worst_frame", static_cast(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(base + level_transition_contract.rva); void* original{}; auto status = hook_prepare(target, reinterpret_cast(&transition), &original); diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index d4466f4..525bf75 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -47,7 +47,6 @@ #include "Extension/World/level_loading.h" #include "Extension/World/loading_screen.h" #include -#include #include #include #include @@ -57,6 +56,7 @@ #include #include #include +#include #include 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(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( - GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "HeapSummary")); - std::uint64_t heap_allocated{}, heap_committed{}; - std::array heaps{}; - const auto heap_count = GetProcessHeaps(static_cast(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 (...) {} } } } diff --git a/Runtime/frame_timing.cpp b/Runtime/frame_timing.cpp index 5ca11b8..02c367b 100644 --- a/Runtime/frame_timing.cpp +++ b/Runtime/frame_timing.cpp @@ -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 // 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(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(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(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; } } diff --git a/SpotifyHelper/src/lib.rs b/SpotifyHelper/src/lib.rs index 5bce273..cae8194 100644 --- a/SpotifyHelper/src/lib.rs +++ b/SpotifyHelper/src/lib.rs @@ -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, mut asked: mpsc::Unbou }; let mut credentials: Option = 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, 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, 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 {