mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 17:27:19 +02:00
Merge branch 'main' of https://github.com/Dingo-Shenanigans/ReSkate
This commit is contained in:
commit
62b99171db
18 files changed
+608
-261
No files matched your search
@@ -11,6 +11,12 @@ inline constexpr std::uintptr_t recipe_copy = 0x5230b0;
|
||||
inline constexpr std::array<unsigned char, 24> recipe_copy_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0x41,0x08,0x48,0x8b,
|
||||
0xf9,0x48,0x8b,0x98,0x90,0x00,0x00,0x00};
|
||||
// Publishes this AppearanceItem's per-instance named material map to its render meshes.
|
||||
// (item, map_index), with map_index 0 for addMaterial("Top_mat").
|
||||
inline constexpr std::uintptr_t publish_materials = 0x1331420;
|
||||
inline constexpr std::array<unsigned char, 24> publish_materials_prefix{
|
||||
0x48,0x89,0x5c,0x24,0x08,0x89,0x54,0x24,0x10,0x55,0x56,0x57,0x41,0x54,0x41,0x55,
|
||||
0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x20};
|
||||
// Finds a named entry in a loaded table by the FNV-1a hash of its name:
|
||||
// (out, name, table, flag, context) -> out, the fifth passed on the stack.
|
||||
// The skater loader's preset lookups reach it through a Lua binding that
|
||||
|
||||
@@ -3,15 +3,23 @@
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::game::build::v20260929::startup_interventions {
|
||||
// Interpreter frame clear (REP STOSB) that overlaps the protected frame state.
|
||||
inline constexpr std::uintptr_t frame = 0x0880d7d5;
|
||||
inline constexpr std::array<std::uint8_t, 2> frame_instruction{0xf3, 0xaa};
|
||||
// Handler and data expected in R9/R14 at the frame clear.
|
||||
inline constexpr std::uintptr_t frame_handler = 0x0880d710;
|
||||
inline constexpr std::uintptr_t frame_data = 0x086772fc;
|
||||
// Exit thunk (JMP [rip+...]) and the return address it is expected to be called from.
|
||||
// Build 25414733 clears the saved native frame through the VM's byte store,
|
||||
// not the 20260908 REP STOSB handler. Capture before its first zero byte.
|
||||
inline constexpr std::uintptr_t frame = 0x08ae2785;
|
||||
inline constexpr std::array<std::uint8_t, 3> frame_instruction{0x40, 0x88, 0x32}; // MOV [RDX], SIL
|
||||
inline constexpr std::array<std::uint8_t, 8> frame_fingerprint{
|
||||
0x40, 0x88, 0x32, 0x5e, 0x66, 0x8b, 0x41, 0x08};
|
||||
inline constexpr std::uintptr_t frame_data = 0x08b7bf3e; // R11: clear bytecode
|
||||
inline constexpr std::uintptr_t frame_cursor = 0x08b7bf72; // [RCX + 0xd8]
|
||||
inline constexpr std::uintptr_t frame_size = 0xc0;
|
||||
inline constexpr std::uintptr_t frame_stack_offset = 0xb70;
|
||||
inline constexpr std::uintptr_t frame_vm_offset = 0xb40;
|
||||
inline constexpr std::uintptr_t frame_saved_registers_offset = 0x1b0;
|
||||
inline constexpr std::uintptr_t frame_cursor_offset = 0xd8;
|
||||
inline constexpr std::uint64_t frame_count = 0xdaf400c0;
|
||||
inline constexpr std::uintptr_t exit_stack_offset = 0x128;
|
||||
inline constexpr std::uintptr_t exit_thunk = 0x08a06ff3;
|
||||
inline constexpr std::uintptr_t exit_return = 0x087755f2;
|
||||
inline constexpr std::uintptr_t exit_return = 0x087fe7fb;
|
||||
inline constexpr std::array<std::uint8_t, 6> exit_instruction{
|
||||
0xff, 0x25, 0x7f, 0x43, 0x5e, 0xfd};
|
||||
}
|
||||
@@ -9,195 +9,201 @@
|
||||
|
||||
namespace dingosdk {
|
||||
namespace {
|
||||
|
||||
// Re-observed on the supported image: the interpreter frame is based on RBP,
|
||||
// and its 0x630-byte clear overlaps that frame. These are not Season 4 offsets.
|
||||
constexpr std::uint64_t protected_state = 0x343cfc26;
|
||||
constexpr std::uint32_t direction_flag = 0x400;
|
||||
namespace pins = addr::startup_interventions;
|
||||
constexpr std::uint8_t breakpoint_opcode = 0xcc;
|
||||
|
||||
StartupInterventionMachine machine;
|
||||
std::atomic<std::uintptr_t> installed_base{};
|
||||
// The frame site sits in protector code that is regenerated for every game
|
||||
// build; when the table has no verified site for this build only the exit
|
||||
// intervention is armed.
|
||||
std::atomic_bool frame_armed{};
|
||||
PVOID handler_handle{};
|
||||
SRWLOCK handler_lock = SRWLOCK_INIT;
|
||||
std::array<std::uint8_t, startup_frame_size> saved_frame{};
|
||||
std::uintptr_t saved_frame_address{};
|
||||
|
||||
struct HandlerGuard {
|
||||
DWORD last_error{GetLastError()};
|
||||
HandlerGuard() noexcept { AcquireSRWLockExclusive(&handler_lock); }
|
||||
~HandlerGuard() { ReleaseSRWLockExclusive(&handler_lock); SetLastError(last_error); }
|
||||
};
|
||||
|
||||
template<class T>
|
||||
bool read_local(std::uintptr_t address, T& output) noexcept {
|
||||
SIZE_T count{};
|
||||
return address && ReadProcessMemory(GetCurrentProcess(),
|
||||
reinterpret_cast<const void*>(address), &output, sizeof(output), &count) &&
|
||||
count == sizeof(output);
|
||||
reinterpret_cast<const void*>(address), &output, sizeof(output), &count) && count == sizeof(output);
|
||||
}
|
||||
|
||||
bool executable_image_range(std::uintptr_t image_base, std::uintptr_t address,
|
||||
std::size_t size) noexcept {
|
||||
bool writable_range(std::uintptr_t address, std::size_t size) noexcept {
|
||||
MEMORY_BASIC_INFORMATION memory{};
|
||||
if (!address || !size || !VirtualQuery(reinterpret_cast<const void*>(address), &memory, sizeof(memory)) ||
|
||||
memory.State != MEM_COMMIT || (memory.Protect & (PAGE_GUARD | PAGE_NOACCESS))) return false;
|
||||
switch (memory.Protect & 0xff) {
|
||||
case PAGE_READWRITE: case PAGE_WRITECOPY: case PAGE_EXECUTE_READWRITE: case PAGE_EXECUTE_WRITECOPY: break;
|
||||
default: return false;
|
||||
}
|
||||
const auto region = reinterpret_cast<std::uintptr_t>(memory.BaseAddress);
|
||||
return address >= region && address - region < memory.RegionSize && size <= memory.RegionSize - (address - region);
|
||||
}
|
||||
|
||||
bool write_local(std::uintptr_t address, const void* data, std::size_t size) noexcept {
|
||||
SIZE_T count{};
|
||||
return writable_range(address, size) && WriteProcessMemory(GetCurrentProcess(),
|
||||
reinterpret_cast<void*>(address), data, size, &count) && count == size;
|
||||
}
|
||||
|
||||
bool executable_image_range(std::uintptr_t image_base, std::uintptr_t address, std::size_t size) noexcept {
|
||||
MEMORY_BASIC_INFORMATION memory{};
|
||||
if (!image_base || !address || !size ||
|
||||
!VirtualQuery(reinterpret_cast<const void*>(address), &memory, sizeof(memory))) return false;
|
||||
const auto executable = [](DWORD protection) {
|
||||
if (protection & (PAGE_GUARD | PAGE_NOACCESS)) return false;
|
||||
switch (protection & 0xff) {
|
||||
case PAGE_EXECUTE:
|
||||
case PAGE_EXECUTE_READ:
|
||||
case PAGE_EXECUTE_READWRITE:
|
||||
case PAGE_EXECUTE_WRITECOPY:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const auto end = reinterpret_cast<std::uintptr_t>(memory.BaseAddress) + memory.RegionSize;
|
||||
!VirtualQuery(reinterpret_cast<const void*>(address), &memory, sizeof(memory)) ||
|
||||
(memory.Protect & (PAGE_GUARD | PAGE_NOACCESS))) return false;
|
||||
switch (memory.Protect & 0xff) {
|
||||
case PAGE_EXECUTE: case PAGE_EXECUTE_READ: case PAGE_EXECUTE_READWRITE: case PAGE_EXECUTE_WRITECOPY: break;
|
||||
default: return false;
|
||||
}
|
||||
const auto region = reinterpret_cast<std::uintptr_t>(memory.BaseAddress);
|
||||
return memory.State == MEM_COMMIT && memory.Type == MEM_IMAGE &&
|
||||
reinterpret_cast<std::uintptr_t>(memory.AllocationBase) == image_base &&
|
||||
executable(memory.Protect) && address <= end && size <= end - address;
|
||||
address >= region && address - region < memory.RegionSize && size <= memory.RegionSize - (address - region);
|
||||
}
|
||||
|
||||
bool write_opcode(std::uintptr_t address, std::uint8_t opcode) noexcept {
|
||||
DWORD old_protection{};
|
||||
if (!VirtualProtect(reinterpret_cast<void*>(address), 1, PAGE_EXECUTE_READWRITE,
|
||||
&old_protection)) return false;
|
||||
if (!VirtualProtect(reinterpret_cast<void*>(address), 1, PAGE_EXECUTE_READWRITE, &old_protection)) return false;
|
||||
SIZE_T count{};
|
||||
const auto wrote = WriteProcessMemory(GetCurrentProcess(), reinterpret_cast<void*>(address),
|
||||
&opcode, sizeof(opcode), &count) && count == sizeof(opcode);
|
||||
const auto flushed = wrote && FlushInstructionCache(
|
||||
GetCurrentProcess(), reinterpret_cast<void*>(address), 1) != FALSE;
|
||||
const auto wrote = WriteProcessMemory(GetCurrentProcess(), reinterpret_cast<void*>(address), &opcode, 1, &count) && count == 1;
|
||||
const auto flushed = wrote && FlushInstructionCache(GetCurrentProcess(), reinterpret_cast<void*>(address), 1) != FALSE;
|
||||
DWORD ignored{};
|
||||
const auto protected_again = VirtualProtect(
|
||||
reinterpret_cast<void*>(address), 1, old_protection, &ignored) != FALSE;
|
||||
const auto protected_again = VirtualProtect(reinterpret_cast<void*>(address), 1, old_protection, &ignored) != FALSE;
|
||||
return wrote && flushed && protected_again;
|
||||
}
|
||||
|
||||
StartupInterventionRegisters capture_registers(const CONTEXT& context,
|
||||
std::uintptr_t exception_address) noexcept {
|
||||
return {exception_address, context.Rsp, context.Rax, context.Rbx, context.Rcx,
|
||||
context.Rdx, context.Rbp, context.Rsi, context.Rdi, context.R8,
|
||||
context.R9, context.R14, context.EFlags};
|
||||
StartupInterventionRegisters capture_registers(const CONTEXT& c, std::uintptr_t address) noexcept {
|
||||
return {address, c.Rsp, c.Rax, c.Rbx, c.Rcx, c.Rdx, c.Rbp, c.Rsi, c.Rdi,
|
||||
c.R8, c.R9, c.R10, c.R11, c.R14, c.R15, c.EFlags};
|
||||
}
|
||||
|
||||
void apply_registers(CONTEXT& context, const StartupInterventionRegisters& registers) noexcept {
|
||||
context.Rip = registers.rip;
|
||||
context.Rsp = registers.rsp;
|
||||
context.Rax = registers.rax;
|
||||
context.Rcx = registers.rcx;
|
||||
context.Rdi = registers.rdi;
|
||||
bool frame_shape_matches(std::uintptr_t base, const StartupInterventionRegisters& r) noexcept {
|
||||
return base == supported_build::preferred_image_base && r.rip == base + startup_frame_rva &&
|
||||
r.rbp >= pins::frame_stack_offset && r.rsp == r.rbp - pins::frame_stack_offset &&
|
||||
r.rcx == r.rbp - pins::frame_vm_offset && r.rdx == r.rbp - startup_frame_size &&
|
||||
r.r15 == r.rbp - pins::frame_saved_registers_offset &&
|
||||
r.r11 == base + startup_frame_data_rva && r.r9 == pins::frame_count && r.r10 == r.r9 &&
|
||||
(r.rsi & 0xff) == 0;
|
||||
}
|
||||
|
||||
LONG CALLBACK intervention_handler(EXCEPTION_POINTERS* pointers) {
|
||||
if (!pointers || !pointers->ExceptionRecord || !pointers->ContextRecord ||
|
||||
pointers->ExceptionRecord->ExceptionCode != EXCEPTION_BREAKPOINT)
|
||||
pointers->ExceptionRecord->ExceptionCode != EXCEPTION_BREAKPOINT) return EXCEPTION_CONTINUE_SEARCH;
|
||||
const auto base = installed_base.load(std::memory_order_acquire);
|
||||
const auto address = reinterpret_cast<std::uintptr_t>(pointers->ExceptionRecord->ExceptionAddress);
|
||||
if (!base || (address != base + startup_frame_rva && address != base + startup_exit_rva))
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
const auto image_base = installed_base.load(std::memory_order_acquire);
|
||||
if (!image_base) return EXCEPTION_CONTINUE_SEARCH;
|
||||
const auto address = reinterpret_cast<std::uintptr_t>(
|
||||
pointers->ExceptionRecord->ExceptionAddress);
|
||||
auto registers = capture_registers(*pointers->ContextRecord, address);
|
||||
HandlerGuard guard;
|
||||
auto& context = *pointers->ContextRecord;
|
||||
auto registers = capture_registers(context, address);
|
||||
const auto thread = GetCurrentThreadId();
|
||||
|
||||
if (address == image_base + startup_frame_rva) {
|
||||
std::array<std::uint8_t, 2> patched{};
|
||||
std::uint64_t state{};
|
||||
if (!read_local(address, patched) || patched[0] != breakpoint_opcode ||
|
||||
registers.rbp > std::numeric_limits<std::uintptr_t>::max() - startup_frame_state_offset ||
|
||||
!read_local(registers.rbp + startup_frame_state_offset, state)) {
|
||||
machine.fail();
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
if (address == base + startup_frame_rva) {
|
||||
std::array<std::uint8_t, 3> instruction{};
|
||||
if (!read_local(address, instruction) ||
|
||||
(instruction[0] != breakpoint_opcode && instruction[0] != startup_frame_instruction[0])) return EXCEPTION_CONTINUE_SEARCH;
|
||||
const bool patched = instruction[0] == breakpoint_opcode;
|
||||
instruction[0] = startup_frame_instruction[0];
|
||||
if (instruction != startup_frame_instruction) { machine.fail(); return EXCEPTION_CONTINUE_SEARCH; }
|
||||
// Another thread may have fetched INT3 before capture restored the site.
|
||||
if (!patched) { context.Rip = address; return EXCEPTION_CONTINUE_EXECUTION; }
|
||||
if (machine.phase() == StartupInterventionPhase::armed && frame_shape_matches(base, registers)) {
|
||||
StartupFrameMetadata metadata{};
|
||||
if (!read_local(registers.rcx + startup_frame_cursor_offset, metadata.cursor)) {
|
||||
machine.fail(); return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
if (startup_frame_clear_matches(base, registers, instruction, metadata)) {
|
||||
const auto frame_address = registers.rbp - startup_frame_size;
|
||||
if (!read_local(frame_address, saved_frame) ||
|
||||
!machine.capture_frame(base, registers, instruction, metadata, thread) ||
|
||||
!write_opcode(address, startup_frame_instruction[0])) {
|
||||
machine.fail(); return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
saved_frame_address = frame_address;
|
||||
// Let the original clear run; restore this exact frame only if the
|
||||
// guarded exit arrives on the same thread and native stack frame.
|
||||
context.Rip = address;
|
||||
logging::log(logging::Level::info, logging::Channel::runtime,
|
||||
"Startup frame captured: {} bytes, site {:#x}, bytecode {:#x}",
|
||||
saved_frame.size(), startup_frame_rva, startup_frame_data_rva);
|
||||
return EXCEPTION_CONTINUE_EXECUTION;
|
||||
}
|
||||
}
|
||||
const std::array<std::uint8_t, 2> original{startup_frame_instruction[0], patched[1]};
|
||||
if (!machine.preserve_frame(image_base, registers, original, state) ||
|
||||
!write_opcode(address, startup_frame_instruction[0])) {
|
||||
// This VM opcode also performs ordinary byte stores before the target
|
||||
// clear. Reproduce MOV [RDX], SIL without changing flags or registers.
|
||||
const auto byte = static_cast<std::uint8_t>(registers.rsi);
|
||||
if (!write_local(registers.rdx, &byte, sizeof(byte))) {
|
||||
machine.fail();
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
logging::write(logging::Level::error, logging::Channel::runtime,
|
||||
"Startup byte-store intervention could not replay an ordinary store");
|
||||
if (!write_opcode(address, startup_frame_instruction[0])) return EXCEPTION_CONTINUE_SEARCH;
|
||||
context.Rip = address;
|
||||
return EXCEPTION_CONTINUE_EXECUTION;
|
||||
}
|
||||
apply_registers(*pointers->ContextRecord, registers);
|
||||
context.Rip = address + startup_frame_instruction.size();
|
||||
return EXCEPTION_CONTINUE_EXECUTION;
|
||||
}
|
||||
|
||||
if (address == image_base + startup_exit_rva) {
|
||||
std::array<std::uint8_t, 6> patched{};
|
||||
std::uint64_t return_address{};
|
||||
if (!read_local(address, patched) || patched[0] != breakpoint_opcode ||
|
||||
registers.rsp > std::numeric_limits<std::uintptr_t>::max() - sizeof(return_address) ||
|
||||
!read_local(registers.rsp, return_address)) {
|
||||
machine.fail();
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
patched[0] = startup_exit_instruction[0];
|
||||
logging::log(logging::Level::info, logging::Channel::runtime,
|
||||
"Startup exit intervention hit: return {:#x}, rcx {:#x}, rdx {:#x}, frame site {}",
|
||||
return_address >= image_base ? return_address - image_base : return_address, registers.rcx,
|
||||
registers.rdx, frame_armed.load() ? "armed" : "not armed");
|
||||
if (!machine.return_from_exit(image_base, registers, patched, return_address) ||
|
||||
!write_opcode(address, startup_exit_instruction[0])) {
|
||||
machine.fail();
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
apply_registers(*pointers->ContextRecord, registers);
|
||||
return EXCEPTION_CONTINUE_EXECUTION;
|
||||
std::array<std::uint8_t, 6> instruction{};
|
||||
std::uint64_t return_address{};
|
||||
if (!read_local(address, instruction) || instruction[0] != breakpoint_opcode ||
|
||||
!read_local(registers.rsp, return_address)) { machine.fail(); return EXCEPTION_CONTINUE_SEARCH; }
|
||||
instruction[0] = startup_exit_instruction[0];
|
||||
logging::log(logging::Level::info, logging::Channel::runtime,
|
||||
"Startup exit intervention hit: return {:#x}, rcx {:#x}, rdx {:#x}, frame captured {}",
|
||||
return_address >= base ? return_address - base : return_address, registers.rcx, registers.rdx,
|
||||
machine.phase() == StartupInterventionPhase::frame_captured);
|
||||
if (!machine.return_from_exit(base, registers, instruction, return_address, thread) ||
|
||||
!write_local(saved_frame_address, saved_frame.data(), saved_frame.size()) ||
|
||||
!write_opcode(address, startup_exit_instruction[0])) {
|
||||
machine.fail();
|
||||
logging::write(logging::Level::error, logging::Channel::runtime, "Startup exit/frame restoration guard failed");
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
context.Rax = registers.rax;
|
||||
context.Rip = registers.rip;
|
||||
context.Rsp = registers.rsp;
|
||||
logging::log(logging::Level::info, logging::Channel::runtime,
|
||||
"Startup frame restored: {} bytes; exit intervention completed", saved_frame.size());
|
||||
return EXCEPTION_CONTINUE_EXECUTION;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool startup_frame_clear_matches(std::uintptr_t base, const StartupInterventionRegisters& registers,
|
||||
const std::array<std::uint8_t, 3>& instruction, const StartupFrameMetadata& metadata) noexcept {
|
||||
return frame_shape_matches(base, registers) && instruction == startup_frame_instruction &&
|
||||
metadata.cursor == base + startup_frame_cursor_rva;
|
||||
}
|
||||
|
||||
bool StartupInterventionMachine::arm() noexcept {
|
||||
auto expected = StartupInterventionPhase::uninitialized;
|
||||
return phase_.compare_exchange_strong(expected, StartupInterventionPhase::armed,
|
||||
std::memory_order_acq_rel);
|
||||
return phase_.compare_exchange_strong(expected, StartupInterventionPhase::armed, std::memory_order_acq_rel);
|
||||
}
|
||||
|
||||
bool StartupInterventionMachine::preserve_frame(
|
||||
std::uintptr_t image_base, StartupInterventionRegisters& registers,
|
||||
const std::array<std::uint8_t, 2>& instruction,
|
||||
std::uint64_t state) noexcept {
|
||||
bool StartupInterventionMachine::capture_frame(std::uintptr_t base, const StartupInterventionRegisters& registers,
|
||||
const std::array<std::uint8_t, 3>& instruction, const StartupFrameMetadata& metadata, std::uint32_t thread) noexcept {
|
||||
auto expected = StartupInterventionPhase::armed;
|
||||
if (!phase_.compare_exchange_strong(expected, StartupInterventionPhase::handling_frame,
|
||||
std::memory_order_acq_rel)) return false;
|
||||
const auto matches = image_base == supported_build::preferred_image_base &&
|
||||
registers.rip == image_base + startup_frame_rva && registers.rsp == registers.rbp &&
|
||||
registers.rcx == 0x630 &&
|
||||
registers.rdi <= std::numeric_limits<std::uint64_t>::max() - 0x210 &&
|
||||
registers.rdi + 0x210 == registers.rsp && registers.r8 == registers.rdi &&
|
||||
registers.r9 == image_base + startup_frame_handler_rva &&
|
||||
registers.r14 == image_base + startup_frame_data_rva &&
|
||||
(registers.rax & 0xff) == 0 && !(registers.eflags & direction_flag) &&
|
||||
instruction == startup_frame_instruction && state == protected_state &&
|
||||
registers.rdi <= std::numeric_limits<std::uint64_t>::max() - registers.rcx;
|
||||
if (!matches) {
|
||||
phase_.store(StartupInterventionPhase::failed, std::memory_order_release);
|
||||
return false;
|
||||
}
|
||||
registers.rdi += registers.rcx;
|
||||
registers.rcx = 0;
|
||||
registers.rip += instruction.size();
|
||||
phase_.store(StartupInterventionPhase::frame_preserved, std::memory_order_release);
|
||||
if (!phase_.compare_exchange_strong(expected, StartupInterventionPhase::handling_frame, std::memory_order_acq_rel)) return false;
|
||||
if (!thread || !startup_frame_clear_matches(base, registers, instruction, metadata)) { fail(); return false; }
|
||||
frame_base_ = registers.rbp;
|
||||
frame_thread_ = thread;
|
||||
phase_.store(StartupInterventionPhase::frame_captured, std::memory_order_release);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool StartupInterventionMachine::return_from_exit(
|
||||
std::uintptr_t image_base, StartupInterventionRegisters& registers,
|
||||
const std::array<std::uint8_t, 6>& instruction,
|
||||
std::uint64_t return_address) noexcept {
|
||||
const bool frame = frame_armed.load(std::memory_order_acquire);
|
||||
auto expected = frame ? StartupInterventionPhase::frame_preserved : StartupInterventionPhase::armed;
|
||||
if (!phase_.compare_exchange_strong(expected, StartupInterventionPhase::handling_exit,
|
||||
std::memory_order_acq_rel)) return false;
|
||||
// With the frame site verified the exact protector return address is pinned
|
||||
// too; without it, TerminateProcess(current process, 0x32) from this build's
|
||||
// protector thunk back into the image is the guard.
|
||||
const bool return_matches = return_address == image_base + startup_exit_return_rva ||
|
||||
(!frame && return_address >= image_base && return_address - image_base < startup_expected_image_size);
|
||||
const auto matches = image_base == supported_build::preferred_image_base &&
|
||||
registers.rip == image_base + startup_exit_rva &&
|
||||
registers.rcx == std::numeric_limits<std::uint64_t>::max() &&
|
||||
registers.rdx == 0x32 && return_matches &&
|
||||
instruction == startup_exit_instruction &&
|
||||
registers.rsp <= std::numeric_limits<std::uint64_t>::max() - sizeof(return_address);
|
||||
if (!matches) {
|
||||
phase_.store(StartupInterventionPhase::failed, std::memory_order_release);
|
||||
return false;
|
||||
}
|
||||
bool StartupInterventionMachine::return_from_exit(std::uintptr_t base, StartupInterventionRegisters& registers,
|
||||
const std::array<std::uint8_t, 6>& instruction, std::uint64_t return_address, std::uint32_t thread) noexcept {
|
||||
auto expected = StartupInterventionPhase::frame_captured;
|
||||
if (!phase_.compare_exchange_strong(expected, StartupInterventionPhase::handling_exit, std::memory_order_acq_rel)) return false;
|
||||
const bool matches = base == supported_build::preferred_image_base && registers.rip == base + startup_exit_rva &&
|
||||
registers.rcx == std::numeric_limits<std::uint64_t>::max() && registers.rdx == 0x32 &&
|
||||
instruction == startup_exit_instruction && return_address == base + startup_exit_return_rva &&
|
||||
thread == frame_thread_ && registers.rbp == frame_base_ &&
|
||||
frame_base_ >= pins::exit_stack_offset && registers.rsp == frame_base_ - pins::exit_stack_offset;
|
||||
if (!matches) { fail(); return false; }
|
||||
registers.rax = FALSE;
|
||||
registers.rip = return_address;
|
||||
registers.rsp += sizeof(return_address);
|
||||
@@ -205,84 +211,60 @@ bool StartupInterventionMachine::return_from_exit(
|
||||
return true;
|
||||
}
|
||||
|
||||
void StartupInterventionMachine::fail() noexcept {
|
||||
phase_.store(StartupInterventionPhase::failed, std::memory_order_release);
|
||||
void StartupInterventionMachine::fail() noexcept { phase_.store(StartupInterventionPhase::failed, std::memory_order_release); }
|
||||
StartupInterventionPhase StartupInterventionMachine::phase() const noexcept { return phase_.load(std::memory_order_acquire); }
|
||||
|
||||
bool startup_intervention_fingerprints_match(std::uintptr_t base, std::uint32_t size,
|
||||
const std::array<std::uint8_t, 8>& frame, const std::array<std::uint8_t, 6>& exit) noexcept {
|
||||
return base == supported_build::preferred_image_base && size == startup_expected_image_size &&
|
||||
frame == startup_frame_fingerprint && exit == startup_exit_instruction;
|
||||
}
|
||||
|
||||
StartupInterventionPhase StartupInterventionMachine::phase() const noexcept {
|
||||
return phase_.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
bool startup_intervention_fingerprints_match(
|
||||
std::uintptr_t image_base, std::uint32_t image_size,
|
||||
const std::array<std::uint8_t, 6>& exit_instruction) noexcept {
|
||||
return image_base == supported_build::preferred_image_base &&
|
||||
image_size == startup_expected_image_size &&
|
||||
exit_instruction == startup_exit_instruction;
|
||||
}
|
||||
|
||||
bool start_startup_interventions(std::uintptr_t image_base, std::string& error) noexcept {
|
||||
bool start_startup_interventions(std::uintptr_t base, std::string& error) noexcept {
|
||||
error.clear();
|
||||
try {
|
||||
if (handler_handle || installed_base.load())
|
||||
throw std::runtime_error("Startup interventions are already installed");
|
||||
if (handler_handle || installed_base.load()) throw std::runtime_error("Startup interventions are already installed");
|
||||
IMAGE_DOS_HEADER dos{};
|
||||
if (!read_local(image_base, dos) || dos.e_magic != IMAGE_DOS_SIGNATURE ||
|
||||
dos.e_lfanew <= 0 || dos.e_lfanew > 0x100000)
|
||||
if (!read_local(base, dos) || dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew <= 0 || dos.e_lfanew > 0x100000)
|
||||
throw std::runtime_error("Startup intervention image has an invalid DOS header");
|
||||
IMAGE_NT_HEADERS64 nt{};
|
||||
if (!read_local(image_base + static_cast<std::uintptr_t>(dos.e_lfanew), nt) ||
|
||||
nt.Signature != IMAGE_NT_SIGNATURE ||
|
||||
nt.FileHeader.Machine != IMAGE_FILE_MACHINE_AMD64 ||
|
||||
nt.OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC)
|
||||
if (!read_local(base + static_cast<std::uintptr_t>(dos.e_lfanew), nt) || nt.Signature != IMAGE_NT_SIGNATURE ||
|
||||
nt.FileHeader.Machine != IMAGE_FILE_MACHINE_AMD64 || nt.OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC)
|
||||
throw std::runtime_error("Startup intervention image has an invalid PE header");
|
||||
const auto frame_address = image_base + startup_frame_rva;
|
||||
const auto exit_address = image_base + startup_exit_rva;
|
||||
if (exit_address < image_base ||
|
||||
!executable_image_range(image_base, exit_address, startup_exit_instruction.size()))
|
||||
const auto frame_address = base + startup_frame_rva;
|
||||
const auto exit_address = base + startup_exit_rva;
|
||||
if (!executable_image_range(base, frame_address, startup_frame_fingerprint.size()) ||
|
||||
!executable_image_range(base, exit_address, startup_exit_instruction.size()))
|
||||
throw std::runtime_error("Startup intervention sites are outside executable image pages");
|
||||
std::array<std::uint8_t, 2> frame{};
|
||||
std::array<std::uint8_t, 8> frame{};
|
||||
std::array<std::uint8_t, 6> exit{};
|
||||
if (!read_local(exit_address, exit) ||
|
||||
!startup_intervention_fingerprints_match(image_base,
|
||||
nt.OptionalHeader.SizeOfImage, exit))
|
||||
if (!read_local(frame_address, frame) || !read_local(exit_address, exit) ||
|
||||
!startup_intervention_fingerprints_match(base, nt.OptionalHeader.SizeOfImage, frame, exit))
|
||||
throw std::runtime_error("Startup intervention image fingerprint does not match");
|
||||
const bool arm_frame = frame_address >= image_base &&
|
||||
executable_image_range(image_base, frame_address, startup_frame_instruction.size()) &&
|
||||
read_local(frame_address, frame) && frame == startup_frame_instruction;
|
||||
frame_armed.store(arm_frame, std::memory_order_release);
|
||||
|
||||
installed_base.store(image_base, std::memory_order_release);
|
||||
installed_base.store(base, std::memory_order_release);
|
||||
handler_handle = AddVectoredExceptionHandler(1, intervention_handler);
|
||||
if (!handler_handle) {
|
||||
installed_base.store(0, std::memory_order_release);
|
||||
throw std::runtime_error("Cannot install startup intervention exception handler");
|
||||
if (!handler_handle) { installed_base.store(0); throw std::runtime_error("Cannot install startup intervention exception handler"); }
|
||||
bool frame_attempted = false;
|
||||
if (!write_opcode(exit_address, breakpoint_opcode))
|
||||
error = "Cannot arm the guarded startup-exit intervention";
|
||||
if (error.empty()) {
|
||||
frame_attempted = true;
|
||||
if (!write_opcode(frame_address, breakpoint_opcode))
|
||||
error = "Cannot arm the guarded frame-capture intervention";
|
||||
}
|
||||
bool frame_patched = false;
|
||||
bool exit_patched = false;
|
||||
if (write_opcode(exit_address, breakpoint_opcode)) exit_patched = true;
|
||||
else error = "Cannot arm the guarded startup-exit intervention";
|
||||
if (error.empty() && arm_frame) {
|
||||
if (write_opcode(frame_address, breakpoint_opcode)) frame_patched = true;
|
||||
else error = "Cannot arm the guarded frame-preservation intervention";
|
||||
}
|
||||
if (error.empty() && !machine.arm())
|
||||
error = "Cannot arm the startup intervention state machine";
|
||||
if (error.empty() && !machine.arm()) error = "Cannot arm the startup intervention state machine";
|
||||
if (!error.empty()) {
|
||||
if (frame_patched) write_opcode(frame_address, startup_frame_instruction[0]);
|
||||
if (exit_patched) write_opcode(exit_address, startup_exit_instruction[0]);
|
||||
if (frame_attempted) write_opcode(frame_address, startup_frame_instruction[0]);
|
||||
write_opcode(exit_address, startup_exit_instruction[0]);
|
||||
RemoveVectoredExceptionHandler(handler_handle);
|
||||
handler_handle = nullptr;
|
||||
installed_base.store(0, std::memory_order_release);
|
||||
installed_base.store(0);
|
||||
machine.fail();
|
||||
return false;
|
||||
}
|
||||
logging::log(logging::Level::info, logging::Channel::runtime,
|
||||
"Startup interventions armed: frame {:#x}, exit {:#x}", startup_frame_rva, startup_exit_rva);
|
||||
return true;
|
||||
} catch (const std::exception& exception) {
|
||||
error = exception.what();
|
||||
machine.fail();
|
||||
return false;
|
||||
}
|
||||
} catch (const std::exception& exception) { error = exception.what(); machine.fail(); return false; }
|
||||
}
|
||||
|
||||
} // namespace dingosdk
|
||||
@@ -1,6 +1,5 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Build/image_identity.h"
|
||||
|
||||
#include "Engine/Game/Build/supported_build.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/startup_interventions.h"
|
||||
@@ -14,67 +13,61 @@ namespace dingosdk {
|
||||
|
||||
inline constexpr std::uint32_t startup_expected_image_size = supported_build::game_image_size;
|
||||
inline constexpr std::uintptr_t startup_frame_rva = addr::startup_interventions::frame;
|
||||
inline constexpr std::uintptr_t startup_frame_handler_rva = addr::startup_interventions::frame_handler;
|
||||
inline constexpr std::uintptr_t startup_frame_data_rva = addr::startup_interventions::frame_data;
|
||||
inline constexpr std::uintptr_t startup_frame_state_offset = 0x70;
|
||||
inline constexpr std::uintptr_t startup_frame_cursor_rva = addr::startup_interventions::frame_cursor;
|
||||
inline constexpr std::uintptr_t startup_frame_size = addr::startup_interventions::frame_size;
|
||||
inline constexpr std::uintptr_t startup_frame_cursor_offset = addr::startup_interventions::frame_cursor_offset;
|
||||
inline constexpr std::uintptr_t startup_exit_rva = addr::startup_interventions::exit_thunk;
|
||||
inline constexpr std::uintptr_t startup_exit_return_rva = addr::startup_interventions::exit_return;
|
||||
inline constexpr auto startup_frame_instruction = addr::startup_interventions::frame_instruction;
|
||||
inline constexpr auto startup_frame_fingerprint = addr::startup_interventions::frame_fingerprint;
|
||||
inline constexpr auto startup_exit_instruction = addr::startup_interventions::exit_instruction;
|
||||
|
||||
enum class StartupInterventionPhase : std::uint32_t {
|
||||
uninitialized,
|
||||
armed,
|
||||
handling_frame,
|
||||
frame_preserved,
|
||||
handling_exit,
|
||||
complete,
|
||||
failed,
|
||||
uninitialized, armed, handling_frame, frame_captured, handling_exit, complete, failed,
|
||||
};
|
||||
|
||||
struct StartupInterventionRegisters {
|
||||
std::uint64_t rip{};
|
||||
std::uint64_t rsp{};
|
||||
std::uint64_t rax{};
|
||||
std::uint64_t rbx{};
|
||||
std::uint64_t rcx{};
|
||||
std::uint64_t rdx{};
|
||||
std::uint64_t rbp{};
|
||||
std::uint64_t rsi{};
|
||||
std::uint64_t rdi{};
|
||||
std::uint64_t r8{};
|
||||
std::uint64_t r9{};
|
||||
std::uint64_t r14{};
|
||||
std::uint64_t rip{}, rsp{}, rax{}, rbx{}, rcx{}, rdx{}, rbp{}, rsi{}, rdi{};
|
||||
std::uint64_t r8{}, r9{}, r10{}, r11{}, r14{}, r15{};
|
||||
std::uint32_t eflags{};
|
||||
};
|
||||
|
||||
// Pure guarded state machine shared by the runtime exception handler and its
|
||||
// focused tests. A matching transition is accepted once; a guard mismatch at
|
||||
// an armed site permanently fails the sequence.
|
||||
struct StartupFrameMetadata {
|
||||
std::uint64_t cursor{};
|
||||
};
|
||||
|
||||
bool startup_frame_clear_matches(std::uintptr_t image_base,
|
||||
const StartupInterventionRegisters& registers,
|
||||
const std::array<std::uint8_t, 3>& instruction,
|
||||
const StartupFrameMetadata& metadata) noexcept;
|
||||
|
||||
// One-shot capture/exit sequence. The runtime serializes transitions and owns
|
||||
// the frame bytes; ordinary VM byte stores do not advance this state machine.
|
||||
class StartupInterventionMachine {
|
||||
public:
|
||||
bool arm() noexcept;
|
||||
bool preserve_frame(std::uintptr_t image_base, StartupInterventionRegisters& registers,
|
||||
const std::array<std::uint8_t, 2>& instruction,
|
||||
std::uint64_t protected_state) noexcept;
|
||||
bool capture_frame(std::uintptr_t image_base, const StartupInterventionRegisters& registers,
|
||||
const std::array<std::uint8_t, 3>& instruction,
|
||||
const StartupFrameMetadata& metadata, std::uint32_t thread_id) noexcept;
|
||||
bool return_from_exit(std::uintptr_t image_base, StartupInterventionRegisters& registers,
|
||||
const std::array<std::uint8_t, 6>& instruction,
|
||||
std::uint64_t return_address) noexcept;
|
||||
std::uint64_t return_address, std::uint32_t thread_id) noexcept;
|
||||
void fail() noexcept;
|
||||
StartupInterventionPhase phase() const noexcept;
|
||||
|
||||
private:
|
||||
std::atomic<StartupInterventionPhase> phase_{StartupInterventionPhase::uninitialized};
|
||||
std::uint64_t frame_base_{};
|
||||
std::uint32_t frame_thread_{};
|
||||
};
|
||||
|
||||
// The frame site is checked separately: it is optional (see start_startup_interventions).
|
||||
bool startup_intervention_fingerprints_match(
|
||||
std::uintptr_t image_base, std::uint32_t image_size,
|
||||
const std::array<std::uint8_t, 6>& exit_instruction) noexcept;
|
||||
bool startup_intervention_fingerprints_match(std::uintptr_t image_base, std::uint32_t image_size,
|
||||
const std::array<std::uint8_t, 8>& frame, const std::array<std::uint8_t, 6>& exit) noexcept;
|
||||
|
||||
// Installs two process-wide, one-shot breakpoint interventions for the exact
|
||||
// inspected Skate image. The caller keeps all existing game threads suspended
|
||||
// during installation; future worker threads are covered by the code sites.
|
||||
// Installs both verified sites while the caller holds existing game threads
|
||||
// suspended. Capture retires the byte-store breakpoint; exit restores the
|
||||
// captured native frame and retires the exit breakpoint before returning FALSE.
|
||||
bool start_startup_interventions(std::uintptr_t image_base, std::string& error) noexcept;
|
||||
|
||||
} // namespace dingosdk
|
||||
@@ -0,0 +1,87 @@
|
||||
#include "developer_hoodie.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Extension/Multiplayer/Steam/steam_social.h"
|
||||
#include "Extension/Multiplayer/Remote/native_skater.h"
|
||||
#include <Windows.h>
|
||||
#include <cstring>
|
||||
|
||||
namespace dingosdk {
|
||||
namespace {
|
||||
// Report a changed failure/activation at most once per actor per five seconds.
|
||||
// Cosmetic streaming is transient; no per-frame log output or cached writes.
|
||||
void report_hoodie(std::uintptr_t entity, std::uint64_t steam_id, std::string_view message,
|
||||
logging::Level level) noexcept {
|
||||
struct Report { std::uintptr_t entity{}; std::uint64_t at{}; std::uint32_t message{}; };
|
||||
static std::array<Report, 33> reports{};
|
||||
const auto now = GetTickCount64();
|
||||
const auto hash = developer_hoodie_detail::material_hash(message);
|
||||
auto *entry = &reports.front();
|
||||
for (auto &candidate : reports) {
|
||||
if (candidate.entity == entity) { entry = &candidate; break; }
|
||||
if (candidate.at < entry->at) entry = &candidate;
|
||||
}
|
||||
if (entry->entity == entity && (entry->message == hash || now - entry->at < 5000)) return;
|
||||
*entry = {entity, now, hash};
|
||||
logging::log(level, logging::Channel::customization, "Developer hoodie (Steam {}, actor {:#x}): {}",
|
||||
steam_id, entity, message);
|
||||
}
|
||||
// NativeValue is owned by this material. Preserve its padding, type, priority,
|
||||
// flags and key, and set the same dirty byte as the native addEsVector setter.
|
||||
bool write_color(const developer_hoodie_detail::Binding &binding, const developer_hoodie_detail::Color &color) noexcept {
|
||||
__try {
|
||||
std::memcpy(reinterpret_cast<void *>(binding.node + 0x10), color.data(), sizeof(color));
|
||||
*reinterpret_cast<std::uint8_t *>(binding.material + 0x2f6) = 1;
|
||||
return true;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
||||
}
|
||||
bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept {
|
||||
__try {
|
||||
reinterpret_cast<void (*)(std::uintptr_t, int)>(base + addr::native_cosmetics::publish_materials)(item, 0);
|
||||
return true;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
||||
}
|
||||
} // namespace
|
||||
void update_developer_hoodie(std::uintptr_t base, std::uintptr_t entity, std::uint64_t steam_id,
|
||||
std::uint64_t generation, DeveloperHoodieState &state) noexcept {
|
||||
const bool developer = multiplayer::reskate_developer(steam_id);
|
||||
if (!entity) { state = {}; return; }
|
||||
if (!developer && !state.count) return;
|
||||
try {
|
||||
auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); };
|
||||
std::array<unsigned char, addr::native_cosmetics::publish_materials_prefix.size()> prefix{};
|
||||
if (!read(base + addr::native_cosmetics::publish_materials, prefix.data(), prefix.size()) ||
|
||||
prefix != addr::native_cosmetics::publish_materials_prefix) {
|
||||
report_hoodie(entity, steam_id, "material publisher differs from the supported build", logging::Level::warning);
|
||||
state = {}; return;
|
||||
}
|
||||
const auto live = developer_hoodie_detail::materials(read, base, entity);
|
||||
bool published = true;
|
||||
auto publish = [base, &published](std::uintptr_t item) { published = publish_materials(base, item); };
|
||||
developer_hoodie_detail::animate(state, live, entity, generation, developer, GetTickCount64(), write_color, publish);
|
||||
if (!published) report_hoodie(entity, steam_id, "native material publication failed", logging::Level::warning);
|
||||
else if (state.count) report_hoodie(entity, steam_id, "RGB active on the owned hoodie material", logging::Level::info);
|
||||
else if (developer && live.eligible)
|
||||
report_hoodie(entity, steam_id, "waiting for the assigned top slot's color parameters", logging::Level::warning);
|
||||
} catch (const std::exception &error) {
|
||||
report_hoodie(entity, steam_id, error.what(), logging::Level::warning);
|
||||
} catch (...) {
|
||||
// Streaming/replacement may invalidate a controller. Never recover by
|
||||
// writing cached addresses, or let an optional cosmetic stop the session.
|
||||
}
|
||||
}
|
||||
void tick_local_developer_hoodie(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept {
|
||||
static DeveloperHoodieState state;
|
||||
// Pausing/loading can temporarily make the local actor unavailable without
|
||||
// destroying it. Keep originals until a fresh ownership walk succeeds.
|
||||
if (!ready) return;
|
||||
try {
|
||||
const auto social = multiplayer::steam_social_snapshot();
|
||||
const auto id = social ? social->local.id : 0;
|
||||
if (!multiplayer::reskate_developer(id) && !state.count) return;
|
||||
const auto local = multiplayer::capture_local(base, client, false);
|
||||
if (local.ready) update_developer_hoodie(base, local.entity, id, local.context, state);
|
||||
else report_hoodie(client, id, local.detail, logging::Level::warning);
|
||||
} catch (...) {}
|
||||
}
|
||||
} // namespace dingosdk
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include "developer_hoodie_material.h"
|
||||
|
||||
namespace dingosdk {
|
||||
// Verified client update thread only. Remote identity comes from the Steam roster,
|
||||
// generation from the current native actor; neither is a client cosmetic parameter.
|
||||
void update_developer_hoodie(std::uintptr_t base, std::uintptr_t entity, std::uint64_t steam_id,
|
||||
std::uint64_t generation, DeveloperHoodieState &state) noexcept;
|
||||
void tick_local_developer_hoodie(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept;
|
||||
} // namespace dingosdk
|
||||
@@ -0,0 +1,181 @@
|
||||
#pragma once
|
||||
#include "Extension/Multiplayer/developer_identity.h"
|
||||
#include "Extension/Multiplayer/Remote/native_cosmetics_layout.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk {
|
||||
namespace developer_hoodie_detail {
|
||||
inline constexpr std::string_view asset = "Own_TopShirt_Gen_HoodieRelaxed_00001";
|
||||
// The compiled appearance preset names controllers by their assigned CAS slot.
|
||||
// "TopItem" is a Lua construction name, not the live controller's name.
|
||||
inline constexpr std::string_view top_slot = "Characters/MainCharacters/Generic/CAS/Common/Slots/CAS_Top_Slot";
|
||||
// Serialized ParamDbKey identities from this hoodie's AppearanceShaderExpressionPreset.
|
||||
// Only the six fabric color regions: textures, stitches and other clothing stay intact.
|
||||
inline constexpr std::array<std::uint64_t, 6> color_keys{
|
||||
0x8c46cd354b18a9e2ULL, 0x1567b5742f4ec0b2ULL, 0x1564a942cb33c53bULL,
|
||||
0xf526305270d94cb3ULL, 0x99879bf202d1960aULL, 0x998450425dbd9044ULL};
|
||||
using Color = std::array<float, 3>;
|
||||
struct Binding {
|
||||
std::uintptr_t material{}, node{}, type{};
|
||||
std::uint64_t key{};
|
||||
Color color{};
|
||||
bool operator==(const Binding &) const = default;
|
||||
};
|
||||
struct SavedColor {
|
||||
Binding binding;
|
||||
Color original{}, last{};
|
||||
};
|
||||
inline constexpr std::size_t max_bindings = color_keys.size() * 4;
|
||||
struct Materials {
|
||||
std::uintptr_t component{}, appearance{}, item{};
|
||||
std::array<Binding, max_bindings> bindings{};
|
||||
std::size_t count{};
|
||||
bool eligible{};
|
||||
};
|
||||
inline std::uint32_t material_hash(std::string_view name) noexcept {
|
||||
std::uint32_t hash = 5381;
|
||||
for (auto c : name) hash = hash * 33 ^ static_cast<unsigned char>(c >= 'A' && c <= 'Z' ? c + 32 : c);
|
||||
return hash;
|
||||
}
|
||||
inline Color rainbow(std::uint64_t milliseconds) noexcept {
|
||||
// One continuous hue cycle every eight seconds, in the material's existing gamut.
|
||||
const auto hue = static_cast<float>(milliseconds % 8000) * (6.f / 8000.f);
|
||||
const auto sector = static_cast<unsigned>(hue);
|
||||
constexpr float high = .8f, low = .025f;
|
||||
const auto rising = low + (high - low) * (hue - static_cast<float>(sector));
|
||||
const auto falling = high + low - rising;
|
||||
switch (sector) {
|
||||
case 0: return {high, rising, low};
|
||||
case 1: return {falling, high, low};
|
||||
case 2: return {low, high, rising};
|
||||
case 3: return {low, falling, high};
|
||||
case 4: return {rising, low, high};
|
||||
default: return {high, low, falling};
|
||||
}
|
||||
}
|
||||
// All discovery is bounded and follows the actor's own component/controller/material
|
||||
// backlinks. Never edit the shared AppearanceShaderExpressionPreset or recipe.
|
||||
template<class Read> Materials materials(Read &read, std::uintptr_t base, std::uintptr_t entity) {
|
||||
using namespace multiplayer;
|
||||
CosmeticMemory<Read &> m{read, base};
|
||||
Materials out;
|
||||
m.check(m.ptr(entity) == base + addr::engine::skater_entity_vtable, "Hoodie actor type differs.");
|
||||
out.component = m.component(entity);
|
||||
const bool pending = m.template get<std::uint8_t>(out.component, 0x10a) || m.template get<std::uint8_t>(out.component, 0x10d);
|
||||
const auto recipe = m.ptr(out.component, 0x158);
|
||||
const auto items = m.count(recipe, sizeof(NativeCosmeticItem), max_cosmetic_slots);
|
||||
const auto wanted = cosmetic_asset_hash(asset);
|
||||
for (std::size_t i = 0; i < items; ++i) {
|
||||
const auto item = m.template get<NativeCosmeticItem>(recipe, i * sizeof(NativeCosmeticItem));
|
||||
if (item.hash == wanted) {
|
||||
// Native recipes may store only the hash (an empty CString).
|
||||
const auto name = m.text(reinterpret_cast<std::uintptr_t>(item.asset));
|
||||
out.eligible = !pending && (name.empty() || name == asset);
|
||||
}
|
||||
}
|
||||
out.appearance = read_native_component(read, entity, base + addr::engine::skater_appearance_vtable);
|
||||
const auto begin = m.ptr(out.appearance, 0x90), end = m.ptr(out.appearance, 0x98), capacity = m.ptr(out.appearance, 0xa0);
|
||||
m.check(begin <= end && end <= capacity && (end - begin) % 8 == 0 && (capacity - begin) % 8 == 0 &&
|
||||
(capacity - begin) / 8 <= 64 && (begin || !capacity), "Hoodie controller array exceeds bounds.");
|
||||
for (auto entry = begin; entry < end; entry += 8) {
|
||||
const auto item = m.ptr(entry);
|
||||
if (!item || m.ptr(item, 0x48) != out.appearance + 0x40 || m.text(m.ptr(item, 0x30)) != top_slot) continue;
|
||||
m.check(!out.item, "Hoodie has multiple top controllers.");
|
||||
out.item = item;
|
||||
if (!(m.template get<std::uint32_t>(item, 0xb0) & 2)) { out.eligible = false; continue; }
|
||||
const auto buckets = m.ptr(item, 0x1f0);
|
||||
const auto size = m.template get<std::uint32_t>(item, 0x1f8), total = m.template get<std::uint32_t>(item, 0x1fc);
|
||||
if (!total) continue;
|
||||
m.check(size && size <= 256 && total <= 128, "Hoodie material map exceeds bounds.");
|
||||
const auto sentinel = m.ptr(buckets, size * 8ULL);
|
||||
const auto hash = material_hash("Top_mat");
|
||||
auto node = m.ptr(buckets, (hash % size) * 8ULL);
|
||||
for (std::uint32_t visited = 0; node && node != sentinel && visited < total; ++visited, node = m.ptr(node, 0x28)) {
|
||||
if (m.template get<std::uint32_t>(node) != hash) continue;
|
||||
std::array<std::uintptr_t, 4> seen{};
|
||||
for (std::size_t variant = 0; variant < seen.size(); ++variant) {
|
||||
const auto material = m.ptr(node, 8 + variant * 8);
|
||||
if (!material || std::find(seen.begin(), seen.begin() + variant, material) != seen.begin() + variant) continue;
|
||||
seen[variant] = material;
|
||||
const auto params = m.ptr(material, 0x268);
|
||||
const auto n = m.template get<std::uint32_t>(material, 0x270), count = m.template get<std::uint32_t>(material, 0x274);
|
||||
if (!count) continue;
|
||||
m.check(n && n <= 1024 && count <= 512, "Hoodie parameter map exceeds bounds.");
|
||||
const auto stop = m.ptr(params, n * 8ULL);
|
||||
for (const auto key : color_keys) {
|
||||
auto parameter = m.ptr(params, (key % n) * 8ULL);
|
||||
for (std::uint32_t v = 0; parameter && parameter != stop && v < count; ++v, parameter = m.ptr(parameter, 0x40)) {
|
||||
if (m.template get<std::uint64_t>(parameter, 8) != key) continue;
|
||||
// The EBX boxed ColorRgb has a serialized gamut word; the
|
||||
// loaded native ColorRgb is three floats (12 bytes). Validate
|
||||
// the actual type descriptor, not the serialized asset size.
|
||||
const auto type = m.ptr(parameter, 0x20);
|
||||
m.check(type >= base && type < base + 0x09144000 &&
|
||||
m.template get<std::uint16_t>(parameter, 2) == sizeof(Color) &&
|
||||
m.template get<std::uint32_t>(m.ptr(type)) == 0x885eff59U &&
|
||||
m.template get<std::uint16_t>(m.ptr(type), 6) == sizeof(Color) && m.template get<std::uint16_t>(parameter, 6) == 1 &&
|
||||
(m.template get<std::uint32_t>(parameter, 0x28) & 1) &&
|
||||
m.template get<std::uint16_t>(type, 8) == m.template get<std::uint16_t>(parameter, 4),
|
||||
"Hoodie color parameter type differs.");
|
||||
const auto color = m.template get<Color>(parameter, 0x10);
|
||||
m.check(std::all_of(color.begin(), color.end(), [](float c) { return std::isfinite(c); }) &&
|
||||
out.count < out.bindings.size(), "Hoodie color exceeds bounds.");
|
||||
out.bindings[out.count++] = {material, parameter, type, key, color};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
inline bool same_binding(const Binding &a, const Binding &b) noexcept {
|
||||
return a.material == b.material && a.node == b.node && a.type == b.type && a.key == b.key;
|
||||
}
|
||||
} // namespace developer_hoodie_detail
|
||||
struct DeveloperHoodieState {
|
||||
std::uintptr_t entity{}, component{}, appearance{}, item{};
|
||||
std::uint64_t generation{};
|
||||
std::array<developer_hoodie_detail::SavedColor, developer_hoodie_detail::max_bindings> colors{};
|
||||
std::size_t count{};
|
||||
};
|
||||
namespace developer_hoodie_detail {
|
||||
// Writes are only to freshly rediscovered bindings. Restoration additionally
|
||||
// requires the current color to still be ours; a new native preset takes precedence.
|
||||
template<class Write, class Publish>
|
||||
void animate(DeveloperHoodieState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation,
|
||||
bool developer, std::uint64_t milliseconds, Write &write, Publish &publish) {
|
||||
if (state.entity != entity || state.generation != generation || state.component != live.component ||
|
||||
state.appearance != live.appearance || state.item != live.item) state = {};
|
||||
const bool enabled = developer && live.eligible;
|
||||
const auto color = rainbow(milliseconds);
|
||||
DeveloperHoodieState next;
|
||||
if (enabled) {
|
||||
next.entity = entity; next.generation = generation; next.component = live.component;
|
||||
next.appearance = live.appearance; next.item = live.item;
|
||||
}
|
||||
bool changed{};
|
||||
for (std::size_t i = 0; i < live.count; ++i) {
|
||||
const auto &binding = live.bindings[i];
|
||||
const SavedColor *saved{};
|
||||
for (std::size_t j = 0; j < state.count; ++j)
|
||||
if (same_binding(binding, state.colors[j].binding)) { saved = &state.colors[j]; break; }
|
||||
if (enabled) {
|
||||
const auto original = saved && binding.color == saved->last ? saved->original : binding.color;
|
||||
if (binding.color == color || write(binding, color)) {
|
||||
next.colors[next.count++] = {binding, original, color};
|
||||
changed = changed || binding.color != color;
|
||||
}
|
||||
} else if (saved && binding.color == saved->last && binding.color != saved->original) {
|
||||
changed = write(binding, saved->original) || changed;
|
||||
}
|
||||
}
|
||||
state = next;
|
||||
if (changed) publish(live.item);
|
||||
}
|
||||
} // namespace developer_hoodie_detail
|
||||
} // namespace dingosdk
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include "Extension/UI/Overlay/overlay.h"
|
||||
#include "Extension/Multiplayer/developer_identity.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
@@ -22,16 +23,7 @@ inline constexpr std::uint32_t nametag_white = 0xffffffffU, nametag_developer =
|
||||
nametag_host = 0xffffc85eU, // blue
|
||||
nametag_friend = 0xff8ae07bU; // green
|
||||
inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU; // #6E20FF, IM_COL32 layout
|
||||
// ReSkate's developers, by SteamID64 (Steam authenticates it; it cannot be claimed).
|
||||
inline constexpr std::array<std::uint64_t, 2> reskate_developers{
|
||||
76561198084159190ULL, // zee_x64
|
||||
76561198255588397ULL, // reglitched
|
||||
};
|
||||
inline bool reskate_developer(std::uint64_t id) noexcept {
|
||||
for (const auto developer : reskate_developers)
|
||||
if (developer == id) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Client thread, once per rendered frame: everyone to label and the local skater's position
|
||||
// (for distances). Also reads whether the game is hiding its own nametags right now.
|
||||
void publish_custom_nametags(std::uintptr_t base, std::vector<NametagPlayer> players,
|
||||
|
||||
@@ -444,6 +444,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui
|
||||
// the spectate position are still refreshed (sound events are released one per frame).
|
||||
if (was_visible && local.ready && !p.render_failed && p.far_interval && now < p.next_far_sample &&
|
||||
!(p.far_for_view && !out_of_view(p, view))) {
|
||||
update_developer_hoodie(s.base, remote_skater_entity(), p.member.id, remote_skater_generation(), p.developer_hoodie);
|
||||
present_audio(p);
|
||||
update_party_position(&p.render_pose);
|
||||
if (labels) label(p);
|
||||
@@ -518,6 +519,8 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui
|
||||
sampled ? "Waiting for the player's cosmetic recipe." : "Waiting for player poses.";
|
||||
}
|
||||
}
|
||||
update_developer_hoodie(s.base, p.visible ? remote_skater_entity() : 0, p.member.id,
|
||||
remote_skater_generation(), p.developer_hoodie);
|
||||
if (!p.visible) {
|
||||
stop_remote_audio();
|
||||
p.presented_audio.reset();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Multiplayer/chat_rate.h"
|
||||
#include "session.h"
|
||||
#include "Extension/Customization/developer_hoodie.h"
|
||||
#include "Extension/Multiplayer/Remote/native_skater.h"
|
||||
#include "Extension/Multiplayer/Voice/voice_chat.h"
|
||||
#include "Extension/Multiplayer/Steam/steam_transport.h"
|
||||
@@ -96,6 +97,7 @@ struct Peer {
|
||||
// Bumped per accepted outfit; render compares it with the one its actor wears
|
||||
// (0 after a spawn) to spread native recipe applies over frames.
|
||||
std::uint64_t cosmetic_revision{}, applied_cosmetics{};
|
||||
DeveloperHoodieState developer_hoodie;
|
||||
AudioBuffer audio;
|
||||
std::uint32_t voice_sequence{};
|
||||
bool received_voice{};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::multiplayer {
|
||||
// Shared by developer tags and cosmetics. Steam authenticates these identities.
|
||||
inline constexpr std::array<std::uint64_t, 2> reskate_developers{
|
||||
76561198084159190ULL, // zee_x64
|
||||
76561198255588397ULL, // reglitched
|
||||
};
|
||||
inline bool reskate_developer(std::uint64_t id) noexcept {
|
||||
for (const auto developer : reskate_developers)
|
||||
if (developer == id) return true;
|
||||
return false;
|
||||
}
|
||||
} // namespace dingosdk::multiplayer
|
||||
+52
-6
@@ -173,6 +173,25 @@ void apply_style() {
|
||||
}
|
||||
|
||||
|
||||
// True once `process` shows its startup splash (ReSkateStartupWindow) or, if the
|
||||
// splash is disabled, the game's own window.
|
||||
bool game_window_shown(DWORD process) {
|
||||
struct Search { DWORD process; bool found; } search{process, false};
|
||||
EnumWindows([](HWND window, LPARAM parameter) -> BOOL {
|
||||
auto& target = *reinterpret_cast<Search*>(parameter);
|
||||
DWORD owner{};
|
||||
GetWindowThreadProcessId(window, &owner);
|
||||
if (owner != target.process || !IsWindowVisible(window)) return TRUE;
|
||||
std::array<wchar_t, 64> name{};
|
||||
GetClassNameW(window, name.data(), static_cast<int>(name.size()));
|
||||
const std::wstring_view kind(name.data());
|
||||
if (kind == L"ReSkateStartupWindow" || kind == L"Skate") { target.found = true; return FALSE; }
|
||||
return TRUE;
|
||||
}, reinterpret_cast<LPARAM>(&search));
|
||||
return search.found;
|
||||
}
|
||||
|
||||
|
||||
} // namespace
|
||||
} // namespace detail
|
||||
|
||||
@@ -258,6 +277,9 @@ int run(const launcher_app::Session& session, const std::vector<std::wstring>& a
|
||||
auto& ui = *ui_storage;
|
||||
ModsPanel mods_panel;
|
||||
bool running = true;
|
||||
HANDLE game{};
|
||||
DWORD game_id{};
|
||||
bool hidden{};
|
||||
while (running) {
|
||||
MSG message;
|
||||
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
|
||||
@@ -270,12 +292,35 @@ int run(const launcher_app::Session& session, const std::vector<std::wstring>& a
|
||||
launcher.restart();
|
||||
break;
|
||||
}
|
||||
// The launcher is done the moment injection succeeded and Skate is running
|
||||
// on its own, so close immediately instead of staying beside the game. This
|
||||
// is the earliest safe point: a launch that fails never sets launched(), so
|
||||
// it still gets to report itself in the window.
|
||||
if (launcher.launched()) break;
|
||||
if (IsIconic(window)) { Sleep(50); continue; }
|
||||
// A successful launch closes immediately unless the user keeps the
|
||||
// launcher alive. In that mode it sleeps hidden while the game runs.
|
||||
if (launcher.launched() && !launcher.settings().keep_open_after_launch) break;
|
||||
if (!game && launcher.game()) {
|
||||
game_id = launcher.game();
|
||||
game = OpenProcess(SYNCHRONIZE, FALSE, game_id);
|
||||
}
|
||||
if (game) {
|
||||
if (!hidden && game_window_shown(game_id)) {
|
||||
ShowWindow(window, SW_HIDE);
|
||||
hidden = true;
|
||||
}
|
||||
if (hidden && !launcher.busy() && launcher.snapshot().phase == Phase::failed) {
|
||||
ShowWindow(window, SW_SHOWNORMAL);
|
||||
SetForegroundWindow(window);
|
||||
hidden = false;
|
||||
}
|
||||
if (WaitForSingleObject(game, 0) == WAIT_OBJECT_0) {
|
||||
CloseHandle(game);
|
||||
game = nullptr;
|
||||
launcher.game_exited(hidden);
|
||||
if (hidden) {
|
||||
ShowWindow(window, SW_SHOWNORMAL);
|
||||
SetForegroundWindow(window);
|
||||
hidden = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hidden || IsIconic(window)) { Sleep(hidden ? 100 : 50); continue; }
|
||||
ImGui_ImplDX12_NewFrame();
|
||||
ImGui_ImplWin32_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
@@ -284,6 +329,7 @@ int run(const launcher_app::Session& session, const std::vector<std::wstring>& a
|
||||
renderer.render();
|
||||
}
|
||||
ShowWindow(window, SW_HIDE);
|
||||
if (game) CloseHandle(game);
|
||||
launcher.cancel();
|
||||
// Closing the window with Settings still open must not lose changes.
|
||||
try { launcher.save(); } catch (...) {}
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
|
||||
namespace dingosdk::launcher_gui {
|
||||
|
||||
// Runs the windowed launcher until the user closes it or Skate starts.
|
||||
// Runs the launcher until it is closed. The saved setting can retain it hidden
|
||||
// while Skate runs and show it again after the game exits.
|
||||
// `arguments` are the launcher's own command-line arguments, kept for a
|
||||
// restart after a self-update.
|
||||
int run(const launcher_app::Session& session, const std::vector<std::wstring>& arguments);
|
||||
|
||||
@@ -122,6 +122,7 @@ struct Settings {
|
||||
int console_key{static_cast<int>(launcher::default_console_key)};
|
||||
int log_level{2};
|
||||
std::string arguments;
|
||||
bool keep_open_after_launch{};
|
||||
// Off: never replace ReSkate.dll or the launcher (keeps a test build someone handed out).
|
||||
bool updates{true};
|
||||
// Off: a crash uploads nothing (RESKATE_CRASH_REPORTING=0 for the launcher and the game).
|
||||
@@ -159,9 +160,10 @@ public:
|
||||
bool restart_requested() const { return restart_; }
|
||||
// Process id of the Skate this launcher started, once it exists.
|
||||
DWORD game() const { return game_; }
|
||||
// Injection finished and the game is running on its own; the launcher closes
|
||||
// as soon as this is true.
|
||||
// Injection finished and the game is running on its own.
|
||||
bool launched() const { return launched_; }
|
||||
// Resets the launch state after the retained game process exits.
|
||||
void game_exited(bool seen);
|
||||
Settings& settings() { return settings_; }
|
||||
const launcher_app::Session& session() const { return session_; }
|
||||
void save();
|
||||
|
||||
@@ -56,6 +56,8 @@ Settings load_settings(const fs::path& path) {
|
||||
for (std::size_t index = 0; index < log_levels.size(); ++index)
|
||||
if (level == log_levels[index]) settings.log_level = static_cast<int>(index);
|
||||
settings.arguments = json.value("arguments", std::string());
|
||||
// Keep the original settings key so existing launcher preferences survive.
|
||||
settings.keep_open_after_launch = !json.value("close_on_launch", !settings.keep_open_after_launch);
|
||||
settings.updates = json.value("updates", settings.updates);
|
||||
settings.crash_reports = json.value("crash_reports", settings.crash_reports);
|
||||
settings.steam_username = json.value("steam_username", std::string());
|
||||
@@ -79,6 +81,7 @@ void save_settings(const fs::path& path, const Settings& settings) {
|
||||
json["console_key"] = settings.console_key;
|
||||
json["log_level"] = log_levels[static_cast<std::size_t>(settings.log_level)];
|
||||
json["arguments"] = settings.arguments;
|
||||
json["close_on_launch"] = !settings.keep_open_after_launch;
|
||||
json["updates"] = settings.updates;
|
||||
json["crash_reports"] = settings.crash_reports;
|
||||
json["steam_username"] = settings.steam_username;
|
||||
@@ -131,6 +134,14 @@ State Launcher::snapshot() {
|
||||
return state_;
|
||||
}
|
||||
|
||||
void Launcher::game_exited(bool seen) {
|
||||
game_ = 0;
|
||||
launched_ = false;
|
||||
if (busy_ || snapshot().phase == Phase::failed) return;
|
||||
if (seen) check();
|
||||
else fail("Skate closed while it was starting.");
|
||||
}
|
||||
|
||||
void Launcher::save() {
|
||||
save_settings(settings_path(session_), settings_);
|
||||
// The game inherits the launcher's environment, so this reaches the next launch; the
|
||||
@@ -390,6 +401,7 @@ void Launcher::run_play() {
|
||||
launched_ = true;
|
||||
set(Phase::launching, "Skate is starting", options.offline || !launcher_app::steam_signed_in() ?
|
||||
"Offline mode: playing without Steam as Unknown Player; multiplayer is hidden." :
|
||||
settings_.keep_open_after_launch ? "The launcher hides while you play and returns when Skate closes." :
|
||||
"The launcher closes now; Skate keeps starting on its own.");
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,9 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui
|
||||
break;
|
||||
default:
|
||||
section_caption(fonts, "LAUNCHER");
|
||||
setting_check("Keep the launcher open after launch",
|
||||
"Hide while you play and return when Skate closes. Turn off to close after a successful launch.",
|
||||
settings.keep_open_after_launch);
|
||||
setting_check("Install ReSkate updates",
|
||||
"Turn off to keep the ReSkate.dll and launcher you have, such as a test build someone gave you. "
|
||||
"Skate's own Steam updates are not affected.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "runtime_internal.h"
|
||||
#include "Extension/Customization/developer_hoodie.h"
|
||||
#include "Extension/Assets/live_mods.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Profiling/profiler.h"
|
||||
@@ -983,6 +984,7 @@ void tick(std::uintptr_t client, std::uintptr_t update) {
|
||||
DINGO_PROFILE_ZONE("tick/multiplayer");
|
||||
dingosdk::multiplayer::tick(r.base,client,multiplayer_ready,r.multiplayer_map,load_multiplayer_map);
|
||||
}
|
||||
dingosdk::tick_local_developer_hoodie(r.base, client, multiplayer_ready);
|
||||
// The session spawns and places skaters and can teleport: check the camera again.
|
||||
tick_state.camera_issue.reset();
|
||||
// The spectate camera follows a moving skater, so it runs every client tick, after the
|
||||
|
||||
@@ -91,6 +91,7 @@ add_library(dingosdk_runtime SHARED
|
||||
Extension/World/level_loading.cpp
|
||||
Extension/World/loading_screen.cpp
|
||||
Extension/Customization/preset_lookup_guard.cpp
|
||||
Extension/Customization/developer_hoodie.cpp
|
||||
Extension/Skater/skater_model.cpp
|
||||
Extension/Skater/skater_observer.cpp
|
||||
Extension/Skater/client_source_spawn.cpp
|
||||
|
||||
Reference in new issue
Block a user