mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-27 11:57:08 +01:00
Crashes: the Trainer no longer writes into freed memory, throwdown models go before a level unload, and a gone texture draws nothing instead of crashing
From the first 6k Sentry reports of 55a7342 (2026-10-24), whose top sites were all new since 2.1.1:
- Trainer: scale_underflip_bands wrote the underflip rate bands' MinScale and MaxScale every tick on
every copy it had ever found, checking only that the address was readable. A level's copies go back
to the game's heap with it, so 1.0 and 0.6 landed in the upper half of whatever the game made there
next (Skate.exe+0x157f930, +0x12a1549, +0x10084de, +0x11ef49e, +0x3db81ea, +0x12c21ca: about a
third of the crashes, every dump with a pointer whose high dword is one of those floats). A copy is
now written only while it still is that band and holds the game's band or the last one written;
any other is forgotten.
- Throwdowns: models the page released were destroyed 3 s later by the menu tick, which does not run
while a level loads, so the game's own flush of the unloaded level's types destroyed them after
their assets were freed (Skate.exe+0x3dc5eaa, 18%). Before a level unload they are destroyed at
once, as the native menu's own are.
- Textures: the guard's "default for the id" is the entry itself, so for a dead entry resolve gave a
null texture, and the texture view cache asked it for its key (Skate.exe+0x3e6ddc1, 17%, every
report with TheVlixOne-Nike_Pack). A stub at the cache insert hands back the empty view the cache
already built when the texture is null; the guard is on only when that patch applies.
- Physics: the wake guard widens every pass because ReSkate raises the owner pool to 160000; its log
line now says so once instead of claiming a prevented crash thousands of times a session.
Built; not yet checked in the game.
This commit is contained in:
1 parent
55a7342dc6
commit
7dabd99a40
9 files changed
+142
-23
No files matched your search
@@ -18,6 +18,9 @@
|
||||
// later free crashes in the allocator (Skate.exe+0x3d99e2f, +0x3d9ee43, +0x3d9ee47; indices
|
||||
// 20199, 20203 and 20215 in three dumps, the next block's size off by exactly that bit;
|
||||
// 2026-10-21).
|
||||
// The owner count is past 20000 in every session because ReSkate raises the owner pool to 160000
|
||||
// (Extension/World/physics_world_size.cpp) and the generation list is sized to the pool; the setting
|
||||
// is written only by the system's init (0x3d2fe20) and read only by the wake pass (2026-10-24).
|
||||
namespace dingosdk::game::build::v20260929::physics_wake {
|
||||
inline constexpr Fingerprint wake{0x3d2f780, {
|
||||
0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x7c,0x24,0x10,0x55,0x48,0x8d,0x6c,0x24,0xa9,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
// The renderer's texture handles (Extension/World/texture_handle_guard.cpp).
|
||||
@@ -16,10 +17,34 @@
|
||||
// that: an entry with no texture and no redirect. Both callers then read through null
|
||||
// (Skate.exe+0x3e897fb, a third of 2.1.1's crash reports, every one with TheVlixOne-Nike_Pack
|
||||
// 2.3.x installed; 2026-10-21).
|
||||
//
|
||||
// The "default for the id" (0x3e89740) is the entry itself, so resolve then answers the entry's
|
||||
// own texture at + 0, which for a dead entry is null too: with an empty holder from redirect,
|
||||
// resolve gives null instead of crashing. Its caller for a material's texture parameter (0x3f86270,
|
||||
// slot 17 of the table at 0x64ff6e8) passes that through 0x3e61260 to the texture view cache
|
||||
// (0x3e6dbb0, keyed by the texture at DAT 0x76e9548 + 0xb10). On a miss the cache builds the view
|
||||
// state without the texture (0x469a000 checks it for null and makes the same empty view the game
|
||||
// makes for an empty slot), then, under its write lock from 0x3e6dcf1, asks the texture for its key
|
||||
// (texture vtbl + 0xf8) to keep the state: through null (Skate.exe+0x3e6ddc1, every report with the
|
||||
// guard's line just before it and TheVlixOne-Nike_Pack installed; 2026-10-24). For a null texture
|
||||
// the cache insert is skipped (`view_cache_insert`): no lock is held there, rsi is saved on every
|
||||
// path to it, and no branch lands inside its two instructions.
|
||||
namespace dingosdk::game::build::v20260929::texture_handles {
|
||||
inline constexpr std::uintptr_t textures = 0x76e0010;
|
||||
// redirect(pool, id) -> the texture holder of the entry `id` points at, null when it points at none.
|
||||
inline constexpr Fingerprint redirect{0x3e9b770, {
|
||||
0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x44,
|
||||
0x0f,0xb7,0xc2,0x4c,0x8d,0x89,0xb8,0x00,0x00,0x00,0x41,0x83,0xe8,0x02,0x48,0x8b}};
|
||||
// In the view cache (0x3e6dbb0), the texture in r15, the built state in r14:
|
||||
// mov rbx, [rip + 0x0387b850] ; DAT 0x76e9548, the cache
|
||||
// add rbx, 0xae8 ; its write lock
|
||||
// the two complete instructions moved into the stub, which goes on at `view_cache_resume`, or for a
|
||||
// null texture to `view_cache_return` (mov rsi, [rsp + 0xe8]; mov rax, r14; the cookie check; ret).
|
||||
inline constexpr std::uintptr_t view_cache_insert_rva = 0x3e6dcf1;
|
||||
inline constexpr std::array<unsigned char, 14> view_cache_insert{
|
||||
0x48,0x8b,0x1d,0x50,0xb8,0x87,0x03,0x48,0x81,0xc3,0xe8,0x0a,0x00,0x00};
|
||||
inline constexpr std::uintptr_t view_cache_resume_rva = 0x3e6dcff;
|
||||
inline constexpr std::uintptr_t view_cache_return_rva = 0x3e6df03;
|
||||
inline constexpr std::uintptr_t view_cache = 0x76e9548;
|
||||
inline constexpr std::uint32_t view_cache_lock_offset = 0xae8;
|
||||
}
|
||||
@@ -297,19 +297,26 @@ void find_state_objects(std::uintptr_t start, std::size_t size) noexcept {
|
||||
} __except (1) {}
|
||||
}
|
||||
int scale_underflip_bands(float low_factor, float high_factor) noexcept {
|
||||
band_factor.store(low_factor, std::memory_order_relaxed);
|
||||
band_high_factor.store(high_factor, std::memory_order_relaxed);
|
||||
const float was_low = band_factor.exchange(low_factor, std::memory_order_relaxed),
|
||||
was_high = band_high_factor.exchange(high_factor, std::memory_order_relaxed);
|
||||
int found = 0;
|
||||
for (auto &band : underflip_bands)
|
||||
for (auto &slot : band.at) {
|
||||
const auto at = slot.load(std::memory_order_relaxed);
|
||||
if (!at) continue;
|
||||
// A band's memory goes back to the game's heap with its level and stays readable, so only a copy that still
|
||||
// is that band and still holds the game's band, or the last one written, is written. Writing a freed copy
|
||||
// put 1.0 and 0.6 into the upper half of whatever the game made there next (a third of the crash reports
|
||||
// of 55a7342: Skate.exe+0x157f930, +0x12a1549, +0x10084de, +0x11ef49e, +0x3db81ea, +0x12c21ca; 2026-10-24).
|
||||
float low{}, high{};
|
||||
if (!peek(at + rate_min_scale, low) || !peek(at + rate_max_scale, high)) {
|
||||
slot.store(0, std::memory_order_relaxed); // freed with its level
|
||||
const float want_low = band.stock_min * low_factor, want_high = band.stock_max * high_factor;
|
||||
if (!is_named(band.id, band.name, at - object_after_id) || !peek(at + rate_min_scale, low) ||
|
||||
!peek(at + rate_max_scale, high) ||
|
||||
!((low == band.stock_min && high == band.stock_max) || (low == band.stock_min * was_low && high == band.stock_max * was_high) ||
|
||||
(low == want_low && high == want_high))) {
|
||||
slot.store(0, std::memory_order_relaxed); // freed with its level, or reused
|
||||
continue;
|
||||
}
|
||||
const float want_low = band.stock_min * low_factor, want_high = band.stock_max * high_factor;
|
||||
if (low != want_low) (void)put(at + rate_min_scale, want_low);
|
||||
if (high != want_high) (void)put(at + rate_max_scale, want_high);
|
||||
++found;
|
||||
|
||||
@@ -657,7 +657,7 @@ void tick_page(std::uintptr_t base, bool loading) noexcept {
|
||||
s.next_render = 0;
|
||||
}
|
||||
bool prepare_native_menu_level_load(std::uintptr_t base) noexcept {
|
||||
if (!release_native_throwdown_menu(base)) return false;
|
||||
if (!release_native_throwdown_menu(base, true)) return false;
|
||||
if (std::all_of(page_slots.begin(), page_slots.end(), [](unsigned slot) { return page_state(slot).owned_models.empty(); }))
|
||||
return true;
|
||||
try {
|
||||
|
||||
@@ -2276,14 +2276,28 @@ void prepare_native_throwdown_match_panel() noexcept {
|
||||
"Throwdowns: native Details presentation preparation deferred: {}",e.what());
|
||||
}
|
||||
}
|
||||
bool release_native_throwdown_menu(std::uintptr_t base) noexcept {
|
||||
bool release_native_throwdown_menu(std::uintptr_t base,bool before_unload) noexcept {
|
||||
overlay::capture_challenge_menu_input(false);
|
||||
sync_custom_activity_ui_presence(false);
|
||||
auto& s=state(); if(s.owned.empty()) { s.owner.store(0); s.setup_open.store(false); return true; }
|
||||
auto& s=state(); if(s.owned.empty() && (!before_unload || s.doomed.empty())) { s.owner.store(0); s.setup_open.store(false); return true; }
|
||||
try { const auto ui=read<Address>(base+build::engine::ui_manager); const auto manager=ui?read<Address>(ui+0x140):0;
|
||||
require(manager!=0,"Throwdowns UI manager unavailable during cleanup.");
|
||||
if(manager!=s.manager) { forget_manager(); return true; }
|
||||
const Context c(base,manager); game::ModelWriteLock lock(manager); release(c); s.generations.clear(); return true;
|
||||
const Context c(base,manager); game::ModelWriteLock lock(manager);
|
||||
if(!s.owned.empty()) { release(c); s.generations.clear(); }
|
||||
else { s.owner.store(0); s.setup_open.store(false); }
|
||||
// Before a level unload the released models go now, not 3 s later: the tick does not run while
|
||||
// the level loads, and the game's own flush of the unloaded level's types destroyed them after
|
||||
// the assets they name were freed (Skate.exe+0x3dc5eaa, the most reported crash of 55a7342;
|
||||
// 2026-10-24). The native menu's own models go the same way (native_menu.cpp).
|
||||
if(before_unload && !s.doomed.empty()) {
|
||||
logging::log(logging::Level::info,logging::Channel::ui,
|
||||
"Throwdowns: {} released native model(s) destroyed before the level unload.",s.doomed.size());
|
||||
for(const auto& entry:s.doomed)
|
||||
if(c.type_of(entry.first.handle)==entry.first.type)c.destroy(entry.first);
|
||||
s.doomed.clear();
|
||||
}
|
||||
return true;
|
||||
} catch(const std::exception& e) { logging::log(logging::Level::warning,logging::Channel::ui,"Throwdowns native menu cleanup: {}",e.what()); return false; }
|
||||
}
|
||||
// A document prompt's input pressed while its prompt shows: the plugin hears panel_command.
|
||||
@@ -2410,7 +2424,7 @@ void tick_native_throwdown_menu(std::uintptr_t base,bool loading,bool in_menu) n
|
||||
gameplay_hud(c,now);
|
||||
} catch(const std::exception& e) {
|
||||
logging::log(logging::Level::warning,logging::Channel::ui,"Throwdowns native menu: {}",e.what());
|
||||
s.next_tick=now+5000; release_native_throwdown_menu(base);
|
||||
s.next_tick=now+5000; release_native_throwdown_menu(base,false);
|
||||
}
|
||||
}
|
||||
} // namespace dingosdk::multiplayer
|
||||
@@ -2,7 +2,7 @@
|
||||
#include <cstdint>
|
||||
namespace dingosdk::multiplayer {
|
||||
void tick_native_throwdown_menu(std::uintptr_t base, bool loading, bool in_menu) noexcept;
|
||||
bool release_native_throwdown_menu(std::uintptr_t base) noexcept;
|
||||
bool release_native_throwdown_menu(std::uintptr_t base, bool before_unload) noexcept;
|
||||
bool native_throwdown_setup_open() noexcept;
|
||||
bool native_throwdown_hud_ready() noexcept;
|
||||
bool native_throwdown_match_panel_open() noexcept;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
#include "Engine/Game/Build/20260929/physics_wake.h"
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <bit>
|
||||
|
||||
namespace dingosdk::physics_wake_guard {
|
||||
namespace {
|
||||
@@ -17,14 +16,15 @@ std::atomic<bool> installed{};
|
||||
std::atomic<std::uint32_t> most{};
|
||||
std::atomic<std::uint64_t> widened{};
|
||||
|
||||
// Said the first few times, then each power of two: a session past the limit stays past it.
|
||||
// Said once: with the owner pool raised (Extension/World/physics_world_size.cpp) every pass of every
|
||||
// session is widened, so the line says how the pass is sized, not a crash each time (55a7342's logs
|
||||
// had it thousands of times per session; 2026-10-24).
|
||||
__declspec(noinline) void note(std::uint32_t owners, std::uint32_t limit) noexcept {
|
||||
const auto count = widened.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
if (count > 4 && !std::has_single_bit(count)) return;
|
||||
if (widened.fetch_add(1, std::memory_order_relaxed) != 0) return;
|
||||
try {
|
||||
logging::log(logging::Level::info, logging::Channel::runtime,
|
||||
"Physics: the world has {} physics owners, more than the {} the game's wake pass makes room for, where the game "
|
||||
"would have crashed; the pass makes room for all of them ({} pass(es) since the game started).", owners, limit, count);
|
||||
"Physics: the wake pass makes room for the world's {} physics owners (the game's own size is {}, below the "
|
||||
"raised owner pool).", owners, limit);
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/supported_build.h"
|
||||
#include "Engine/Game/Build/20260929/texture_handles.h"
|
||||
#include <Windows.h>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <bit>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
|
||||
namespace dingosdk::texture_handle_guard {
|
||||
namespace {
|
||||
@@ -16,8 +20,9 @@ Redirect original{};
|
||||
std::atomic<bool> installed{};
|
||||
std::atomic<std::uint64_t> misses{};
|
||||
// What both callers read when there is nothing: a holder with no texture in it, which sends them
|
||||
// to the pool's default for the id.
|
||||
// to the entry's own texture, null for a dead entry (texture_handles.h).
|
||||
std::uintptr_t no_texture{};
|
||||
constexpr std::size_t stub_page_bytes = 0x1000;
|
||||
|
||||
// Said for the first few, then each power of two: a stale handle is resolved on every frame it
|
||||
// is drawn.
|
||||
@@ -26,8 +31,8 @@ __declspec(noinline) void note(std::uint16_t id) noexcept {
|
||||
if (count > 4 && !std::has_single_bit(count)) return;
|
||||
try {
|
||||
logging::log(logging::Level::warning, logging::Channel::runtime,
|
||||
"Textures: a texture handle ({:#x}) names a texture that is gone, where the game would have crashed; the default "
|
||||
"texture is drawn for it ({} time(s) since the game started). A mod's material that keeps a texture another "
|
||||
"Textures: a texture handle ({:#x}) names a texture that is gone, where the game would have crashed; nothing is "
|
||||
"drawn for it ({} time(s) since the game started). A mod's material that keeps a texture another "
|
||||
"bundle unloaded does this.", id, count);
|
||||
} catch (...) {}
|
||||
}
|
||||
@@ -37,6 +42,64 @@ std::uintptr_t* guarded(std::uintptr_t pool, std::uint16_t id) {
|
||||
note(id);
|
||||
return &no_texture;
|
||||
}
|
||||
|
||||
bool rel32(std::uintptr_t next_instruction, std::uintptr_t target, std::int32_t& displacement) noexcept {
|
||||
const auto delta = static_cast<std::int64_t>(target) - static_cast<std::int64_t>(next_instruction);
|
||||
if (delta < std::numeric_limits<std::int32_t>::min() || delta > std::numeric_limits<std::int32_t>::max()) return false;
|
||||
displacement = static_cast<std::int32_t>(delta);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool write_code(std::uintptr_t address, const void* bytes, std::size_t size) noexcept {
|
||||
auto* const destination = reinterpret_cast<void*>(address);
|
||||
DWORD previous{};
|
||||
if (!VirtualProtect(destination, size, PAGE_EXECUTE_READWRITE, &previous)) return false;
|
||||
std::memcpy(destination, bytes, size);
|
||||
const bool flushed = FlushInstructionCache(GetCurrentProcess(), destination, size) != FALSE;
|
||||
DWORD discarded{};
|
||||
return VirtualProtect(destination, size, previous, &discarded) != FALSE && flushed;
|
||||
}
|
||||
|
||||
// The null the guard leads resolve to reaches the texture view cache, which asks the texture for its
|
||||
// key before keeping the view it built (texture_handles.h). The stub skips the keeping for a null
|
||||
// texture, so the cache hands back the empty view:
|
||||
// test r15, r15 / jz return / mov rbx, [cache] / add rbx, lock / jmp resume
|
||||
bool skip_null_view_insert(std::uintptr_t base) noexcept {
|
||||
std::array<unsigned char, th::view_cache_insert.size()> actual{};
|
||||
if (!memory::peek(base + th::view_cache_insert_rva, actual) || actual != th::view_cache_insert) return false;
|
||||
auto* const stub = static_cast<std::uint8_t*>(memory::allocate_near_image(base, supported_build::game_image_size, stub_page_bytes));
|
||||
if (!stub) return false;
|
||||
const auto at = reinterpret_cast<std::uintptr_t>(stub);
|
||||
std::array<std::uint8_t, 28> code{0x4d, 0x85, 0xff, 0x0f, 0x84, 0, 0, 0, 0, 0x48, 0x8b, 0x1d, 0, 0, 0, 0,
|
||||
0x48, 0x81, 0xc3, 0, 0, 0, 0, 0xe9, 0, 0, 0, 0};
|
||||
std::memcpy(code.data() + 19, &th::view_cache_lock_offset, 4);
|
||||
std::int32_t leave{}, cache{}, resume{}, into{};
|
||||
if (!rel32(at + 9, base + th::view_cache_return_rva, leave) || !rel32(at + 16, base + th::view_cache, cache) ||
|
||||
!rel32(at + 28, base + th::view_cache_resume_rva, resume) || !rel32(base + th::view_cache_insert_rva + 5, at, into)) {
|
||||
VirtualFree(stub, 0, MEM_RELEASE);
|
||||
return false;
|
||||
}
|
||||
std::memcpy(code.data() + 5, &leave, 4);
|
||||
std::memcpy(code.data() + 12, &cache, 4);
|
||||
std::memcpy(code.data() + 24, &resume, 4);
|
||||
std::memset(stub, 0xcc, stub_page_bytes);
|
||||
std::memcpy(stub, code.data(), code.size());
|
||||
DWORD previous{};
|
||||
if (!VirtualProtect(stub, stub_page_bytes, PAGE_EXECUTE_READ, &previous) ||
|
||||
!FlushInstructionCache(GetCurrentProcess(), stub, stub_page_bytes)) {
|
||||
VirtualFree(stub, 0, MEM_RELEASE);
|
||||
return false;
|
||||
}
|
||||
std::array<std::uint8_t, th::view_cache_insert.size()> jump{0xe9};
|
||||
std::memcpy(jump.data() + 1, &into, 4);
|
||||
std::memset(jump.data() + 5, 0x90, jump.size() - 5);
|
||||
if (!write_code(base + th::view_cache_insert_rva, jump.data(), jump.size())) {
|
||||
(void)write_code(base + th::view_cache_insert_rva, th::view_cache_insert.data(), th::view_cache_insert.size());
|
||||
VirtualFree(stub, 0, MEM_RELEASE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool install(std::uintptr_t base) noexcept {
|
||||
@@ -48,6 +111,12 @@ bool install(std::uintptr_t base) noexcept {
|
||||
"Texture handle guard: this build's texture code differs; it is left as it is.");
|
||||
return false;
|
||||
}
|
||||
// Without it the null only crashes a step later (Skate.exe+0x3e6ddc1), so the guard needs both.
|
||||
if (!skip_null_view_insert(base)) {
|
||||
logging::log(logging::Level::warning, logging::Channel::runtime,
|
||||
"Texture handle guard: the game's texture view cache differs or could not be patched; it is left as it is.");
|
||||
return false;
|
||||
}
|
||||
const auto target = reinterpret_cast<void*>(base + th::redirect.rva);
|
||||
if (hook_prepare(target, reinterpret_cast<void*>(&guarded), reinterpret_cast<void**>(&original)) != HookOk ||
|
||||
hook_enable(target) != HookOk) {
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
// report): a render job resolves a texture handle a material parameter still holds after the
|
||||
// texture went, and the game reads through the null its redirect lookup gives back
|
||||
// (Engine/Game/Build/<build>/texture_handles.h). The guard gives that lookup an empty holder
|
||||
// instead, so the job takes the game's own default texture for the id, the way it does for an id
|
||||
// whose texture is simply gone; what it draws may show the default texture where the game would
|
||||
// have closed.
|
||||
// instead, so resolve answers no texture, and has the texture view cache hand back the empty view
|
||||
// it builds for no texture rather than ask the missing texture for its key (Skate.exe+0x3e6ddc1,
|
||||
// where 2.1.2's first guard only moved the crash). The material draws without that texture where
|
||||
// the game would have closed.
|
||||
namespace dingosdk::texture_handle_guard {
|
||||
// Checks this build's function and hooks it; once. False when the build differs (no guard).
|
||||
bool install(std::uintptr_t base) noexcept;
|
||||
|
||||
Reference in new issue
Block a user