From 164fdffbd061f9a8c83192d141a5f49d14ccaf75 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Thu, 15 Oct 2026 13:57:11 -0500 Subject: [PATCH] Descriptor heaps: dense custom maps no longer overflow the GPU's descriptor heap and crash it The renderer gives each frame 131,072 shader-visible descriptors past a persistent 120,000, in heaps of 333,333. Dense custom maps (the Los Santos City sublevels, San Andreas) draw more than that in a frame, and the renderer wrote past the end of the heap: shaders read garbage descriptors, which faulted or hung the GPU (page faults at nonsense addresses, in draws, texture fetches and ray tracing alike). ReSkate now patches the device setup at boot so each heap is D3D12's 1,000,000 and a frame may use 524,288; heaps are 644,288 descriptors. LS-B2, LS-B3 and San Andreas (210,000 a frame at its peak) now hold. Also in this change: - Memory: perf memory owners/heap/gpu/report, a ledger of who commits the game's RAM; the render-resource pool no longer allocates its 115 MB twice; the EadpErrorsData arena is capped at 32 MiB (EA's error reporting is blocked anyway). - Crash reports: tagged with the map, destination, graphics card and driver version (protocol v3), so Sentry can group GPU hangs by map and card. - GPU diagnostics (--gpu-diagnostics): a fuller DRED report, the device-removal and video memory watches, a large-draw watch, a record of which game code made and freed each GPU heap and resource, checks of every GPU address bound, and a meter of each descriptor heap's use. - World pools: peak use of entity pages, render instances and spatial blocks in the log. - dingosdk_mod_mesh_check: checks every MeshSet a mod ships against its geometry; the MeshSet reader now knows static meshes' layout. --- Engine/Core/Debug/Test/crash_report_tests.cpp | 7 + Engine/Core/Debug/client.cpp | 14 + Engine/Core/Debug/crash_report.h | 6 + Engine/Core/Debug/protocol.h | 8 +- Engine/Core/Platform/memory.h | 27 + .../Build/20260929/descriptor_heap_size.h | 31 + .../Game/Build/20260929/eadp_errors_arena.h | 21 + .../Build/20260929/render_resource_pool.h | 5 + Engine/Game/Build/20260929/world_pool_usage.h | 46 + Engine/Resource/mesh_set.cpp | 113 ++ Engine/Resource/mesh_set.h | 10 + Engine/Vfs/Test/mod_mesh_check.cpp | 191 +++ .../Assets/native_render_resource_pool.cpp | 150 ++- .../Assets/native_render_resource_pool.h | 5 + Extension/Boot/descriptor_heap_size.cpp | 61 + Extension/Boot/descriptor_heap_size.h | 18 + Extension/Boot/eadp_errors_arena.cpp | 93 ++ Extension/Boot/eadp_errors_arena.h | 17 + Extension/Console/memory_ledger.cpp | 812 +++++++++++++ Extension/Console/memory_ledger.h | 41 + Extension/Console/perf_commands.cpp | 162 +-- Extension/UI/Overlay/gpu_diagnostics.cpp | 1060 ++++++++++++++++- Extension/UI/Overlay/gpu_diagnostics.h | 24 + Extension/UI/Overlay/overlay.cpp | 1 + Extension/UI/Overlay/overlay_render.cpp | 4 + Extension/World/level_loading_progress.h | 3 + Extension/World/world_pool_usage.cpp | 171 +++ Extension/World/world_pool_usage.h | 13 + Launcher/crash_reporter.cpp | 18 + Runtime/bootstrap.cpp | 17 + Runtime/client_tick.cpp | 24 +- Runtime/runtime_init.cpp | 1 - cmake/Apps.cmake | 4 + cmake/Runtime.cmake | 6 +- 34 files changed, 2968 insertions(+), 216 deletions(-) create mode 100644 Engine/Game/Build/20260929/descriptor_heap_size.h create mode 100644 Engine/Game/Build/20260929/eadp_errors_arena.h create mode 100644 Engine/Game/Build/20260929/world_pool_usage.h create mode 100644 Engine/Vfs/Test/mod_mesh_check.cpp create mode 100644 Extension/Boot/descriptor_heap_size.cpp create mode 100644 Extension/Boot/descriptor_heap_size.h create mode 100644 Extension/Boot/eadp_errors_arena.cpp create mode 100644 Extension/Boot/eadp_errors_arena.h create mode 100644 Extension/Console/memory_ledger.cpp create mode 100644 Extension/Console/memory_ledger.h create mode 100644 Extension/World/world_pool_usage.cpp create mode 100644 Extension/World/world_pool_usage.h diff --git a/Engine/Core/Debug/Test/crash_report_tests.cpp b/Engine/Core/Debug/Test/crash_report_tests.cpp index a77cb78..97b31ed 100644 --- a/Engine/Core/Debug/Test/crash_report_tests.cpp +++ b/Engine/Core/Debug/Test/crash_report_tests.cpp @@ -229,6 +229,11 @@ void crash_check(const std::filesystem::path& root, bool native) { (native ? "game-native" : "unhandled-exception"), "Wrong capture source"); check(Json::parse(read(dump.wstring() + L".json")).at("attachment.log").string() == "complete" && read(log_attachment_path(dump)) == log, "Crash did not snapshot its session log"); + const auto tags = Json::parse(read(dump.wstring() + L".json")); + check(tags.at("game.map").string() == "dingolevel_reskate_lsc_b2" && + tags.at("game.destination").string() == "Levels/Game/DingoLevel_Root/DingoLevel_Root + levels/game/dingolevel_reskate_lsc_b2/dingolevel_reskate_lsc_b2 @ DefaultStartPoint" && + tags.at("gpu.name").string() == "Fixture GPU" && tags.at("gpu.driver").string() == "32.0.21025.1", + "Crash was not tagged with its level and graphics card"); if (native) { const auto source = root / L"local" / L"ReSkate" / L"Game" / L"Skate" / L"CrashDumps"; const auto pid = GetProcessId(child.value), created = process_creation_seconds(child.value); @@ -298,6 +303,8 @@ int wmain(int argc, wchar_t** argv) { logging::Options options; options.directory = argv[2]; options.truncate_file = true; check(logging::initialize(options), "Fixture logging failed"); if (native) SetUnhandledExceptionFilter(native_child_handler); // Reproduce the game's later handler registration. + set_destination(L"Levels/Game/DingoLevel_Root/DingoLevel_Root + levels/game/dingolevel_reskate_lsc_b2/dingolevel_reskate_lsc_b2 @ DefaultStartPoint"); + set_gpu(L"Fixture GPU", L"32.0.21025.1"); RaiseException(test_exception, EXCEPTION_NONCONTINUABLE, 0, nullptr); return 3; } diff --git a/Engine/Core/Debug/client.cpp b/Engine/Core/Debug/client.cpp index cf7213d..8f368fd 100644 --- a/Engine/Core/Debug/client.cpp +++ b/Engine/Core/Debug/client.cpp @@ -22,6 +22,11 @@ template bool copy(std::array& target, const std::wst target[value.size()] = 0; return true; } +template void copy_cut(std::array& target, std::wstring_view value) noexcept { + const auto size = std::min(value.size(), N - 1); + std::copy_n(value.begin(), size, target.begin()); + target[size] = 0; +} } StartResult start(const std::filesystem::path& log_directory) noexcept { try { @@ -109,6 +114,15 @@ void capture(EXCEPTION_POINTERS* exception) noexcept { WaitForMultipleObjects(2, wait_for, FALSE, capture_timeout_ms); } } +void set_destination(std::wstring_view destination) noexcept { + if (auto* const shared = report) copy_cut(shared->destination, destination); +} +void set_gpu(std::wstring_view name, std::wstring_view driver) noexcept { + if (auto* const shared = report) { + copy_cut(shared->gpu_name, name); + copy_cut(shared->gpu_driver, driver); + } +} void stop() noexcept { // An exception already using these handles owns them until process exit. // Never unmap its report or close its events from a concurrent shutdown. diff --git a/Engine/Core/Debug/crash_report.h b/Engine/Core/Debug/crash_report.h index b36e46b..a5828dd 100644 --- a/Engine/Core/Debug/crash_report.h +++ b/Engine/Core/Debug/crash_report.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include namespace dingosdk::crash_report { enum class StartResult { disabled, ready, failed }; @@ -9,4 +10,9 @@ StartResult start(const std::filesystem::path& log_directory) noexcept; void stop() noexcept; // Last-chance path: only signals the prestarted helper and waits at most 15 seconds. void capture(EXCEPTION_POINTERS* exception) noexcept; +// The level being loaded ("root + sublevel @ start point") and the graphics card the game renders +// on, for a report's tags: game.map, game.destination, gpu.name and gpu.driver. Nothing while +// reporting is off; too long a value is cut short. +void set_destination(std::wstring_view destination) noexcept; +void set_gpu(std::wstring_view name, std::wstring_view driver) noexcept; } diff --git a/Engine/Core/Debug/protocol.h b/Engine/Core/Debug/protocol.h index 9de6dc2..fb2d49a 100644 --- a/Engine/Core/Debug/protocol.h +++ b/Engine/Core/Debug/protocol.h @@ -4,7 +4,7 @@ #include namespace dingosdk::crash_report { -inline constexpr DWORD protocol_version = 2; +inline constexpr DWORD protocol_version = 3; inline constexpr DWORD capture_timeout_ms = 15000; struct SharedReport { DWORD version{protocol_version}; @@ -14,6 +14,12 @@ struct SharedReport { std::array directory{}; std::array application{}; std::array native_directory{}; + // What the game was doing, for the report's tags: the level it loaded last, and the graphics + // card it renders on with its driver's version. Written by the game as they change, read by + // the helper after a crash (a torn read only spoils a tag). + std::array destination{}; + std::array gpu_name{}; + std::array gpu_driver{}; }; // Explicitly inherited unnamed handles; no token or paths on the command line. enum HandleIndex : std::size_t { mapping, requested, captured, stopping, ready, parent, handle_count }; diff --git a/Engine/Core/Platform/memory.h b/Engine/Core/Platform/memory.h index 589416b..403a642 100644 --- a/Engine/Core/Platform/memory.h +++ b/Engine/Core/Platform/memory.h @@ -33,6 +33,33 @@ inline bool peek_bytes(std::uintptr_t address, void* destination, std::size_t si template requires std::is_trivially_copyable_v bool peek(std::uintptr_t address, T& value) noexcept { return peek_bytes(address, &value, sizeof(value)); } +// Fresh read/write memory within reach of a rel32 jump from an image at base: the first free +// spot past its end, for a code stub that a patched instruction jumps to. Null when nothing is +// free within 1.75 GB of the image. +inline void* allocate_near_image(std::uintptr_t base, std::size_t image_size, std::size_t bytes) noexcept { + SYSTEM_INFO information{}; + GetSystemInfo(&information); + const auto granularity = static_cast(information.dwAllocationGranularity); + const auto align_up = [granularity](std::uintptr_t value) { return (value + granularity - 1) & ~(granularity - 1); }; + auto cursor = align_up(base + image_size); + const auto limit = base + 0x70000000ULL; + while (cursor < limit) { + MEMORY_BASIC_INFORMATION region{}; + if (!VirtualQuery(reinterpret_cast(cursor), ®ion, sizeof(region))) break; + const auto region_base = reinterpret_cast(region.BaseAddress); + const auto region_end = region_base + region.RegionSize; + if (region.State == MEM_FREE) { + const auto candidate = align_up(cursor > region_base ? cursor : region_base); + if (candidate <= region_end && region_end - candidate >= bytes) + if (auto* const result = VirtualAlloc(reinterpret_cast(candidate), bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)) + return result; + } + if (region_end <= cursor) break; + cursor = align_up(region_end); + } + return nullptr; +} + // A NUL-terminated string copied the same way: the length (terminator written at destination[length]), // or -1 if it is unreadable or has no terminator within capacity bytes. Reads byte by byte, so it never // touches memory past the terminator. diff --git a/Engine/Game/Build/20260929/descriptor_heap_size.h b/Engine/Game/Build/20260929/descriptor_heap_size.h new file mode 100644 index 0000000..6399a09 --- /dev/null +++ b/Engine/Game/Build/20260929/descriptor_heap_size.h @@ -0,0 +1,31 @@ +// Reviewed native contract for supported_build::game_sha256 (September 29 2026). +// The size of the renderer's shader-visible CBV/SRV/UAV descriptor heaps: a persistent part +// (120,000) and, in each frame's heap, a part for that frame's descriptors: what is left of a +// total of 333,333 (a third of D3D12's 1,000,000), capped at 131,072. +#pragma once + +#include +#include + +namespace dingosdk::game::build::v20260929::descriptor_heap_size { +// In the renderer's device setup (0x0410e3f0), where the total each heap may have (333,333, from +// the device or from the platform's own description, PQXD; both paths join here, at the first +// instruction) is capped at 1,000,000 unless the device's resource binding is tier 3: +// cmp dword ptr [rsi + 0x7a4], 3 +// je +0x0a ; past the cap +// mov edx, 1000000 / cmp ecx, edx / cmova ecx, edx (left as they are) +inline constexpr std::uintptr_t total_cap_rva = 0x0410eaa0; +inline constexpr std::array total_cap{0x83, 0xbe, 0xa4, 0x07, 0x00, 0x00, 0x03, 0x74, 0x0a}; +// In the renderer's device setup (0x0410e3f0), the per-frame part: what the device allows past +// the persistent part, divided among the heaps, then capped by the setting at desc+8 +// (0xffffffff: no cap): +// mov ecx, dword ptr [r14 + 8] ; the cap +// div r9d ; eax = room / heaps +// mov edi, eax +// mov eax, 0xffffffff +// cmp ecx, eax / je / cmp edi, ecx / cmovb ecx, edi / mov edi, ecx (left as they are) +// No branch lands inside these four instructions. +inline constexpr std::uintptr_t cap_load_rva = 0x0410eaee; +inline constexpr std::array cap_load{ + 0x41, 0x8b, 0x4e, 0x08, 0x41, 0xf7, 0xf1, 0x8b, 0xf8, 0xb8, 0xff, 0xff, 0xff, 0xff}; +} // namespace dingosdk::game::build::v20260929::descriptor_heap_size diff --git a/Engine/Game/Build/20260929/eadp_errors_arena.h b/Engine/Game/Build/20260929/eadp_errors_arena.h new file mode 100644 index 0000000..38bdc22 --- /dev/null +++ b/Engine/Game/Build/20260929/eadp_errors_arena.h @@ -0,0 +1,21 @@ +// Reviewed native contract for supported_build::game_sha256 (September 29 2026). +// The arena EA's error reporting (EadpErrorsData) allocates from: 80 MiB, made +// about seven seconds into startup, before the named settings can be changed. +#pragma once + +#include +#include + +namespace dingosdk::game::build::v20260929::eadp_errors_arena { +// In the arena's factory (0x02318660): the load of its size from the settings +// object (EadpErrorsData.EadpErrorsDataPlatformSpecificMemPoolSize) and the +// vtable load after it, the two complete instructions moved into the stub. +// mov eax, dword ptr [rax + 0x7c] +// lea rcx, [rip + 0x03fffd17] ; 0x063183d8 +// No branch in the factory lands inside them; the factory goes on at resume_rva. +inline constexpr std::uintptr_t size_load_rva = 0x023186b7; +inline constexpr std::array size_load{ + 0x8b, 0x40, 0x7c, 0x48, 0x8d, 0x0d, 0x17, 0xfd, 0xff, 0x03}; +inline constexpr std::uintptr_t vtable_rva = 0x063183d8; +inline constexpr std::uintptr_t resume_rva = 0x023186c1; +} // namespace dingosdk::game::build::v20260929::eadp_errors_arena diff --git a/Engine/Game/Build/20260929/render_resource_pool.h b/Engine/Game/Build/20260929/render_resource_pool.h index df45bfc..9830a50 100644 --- a/Engine/Game/Build/20260929/render_resource_pool.h +++ b/Engine/Game/Build/20260929/render_resource_pool.h @@ -90,6 +90,11 @@ inline constexpr std::ptrdiff_t resource_state_vector_offset = 0x2da920; 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. +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; inline constexpr std::uintptr_t resource_flags_visibility_rva = 0x03f5789a; diff --git a/Engine/Game/Build/20260929/world_pool_usage.h b/Engine/Game/Build/20260929/world_pool_usage.h new file mode 100644 index 0000000..3f75fe3 --- /dev/null +++ b/Engine/Game/Build/20260929/world_pool_usage.h @@ -0,0 +1,46 @@ +// Reviewed native contract for supported_build::game_sha256 (September 29 2026). +// Where the three world pools ReSkate enlarges (entity_pages.h, spatial_buckets.h) +// can be read for how full they are: for telling whether a level needs more than +// the game's own sizes. +#pragma once + +#include +#include +#include + +namespace dingosdk::game::build::v20260929::world_pool_usage { +// Entity snapshots: the page allocator, (owner, allocator, pages). It fills the +// snapshot's 128 page pointers at pages[0..127], each page zeroed when handed out, +// so the highest non-zero entry is the highest entity slot in use. +inline constexpr std::uintptr_t entity_page_allocator_rva = 0x048bd000; +inline constexpr std::array entity_page_allocator_entry{ + 0x40, 0x53, 0x55, 0x56, 0x57, 0x41, 0x56, 0x48, 0x83, 0xec}; +// The game's own entity slots (128 pages of 1,024). +inline constexpr std::size_t entity_slots_stock = 131'072; + +// Render instances: the pool is [[global] + 0x4a0] + 0x1300 (as the one caller of +// its allocator loads it), and its next-index counter, taken with lock xadd, is +// the most it has ever handed out. +inline constexpr std::uintptr_t render_instance_load_rva = 0x050e59ac; +inline constexpr std::array render_instance_load{ + 0x48, 0x8b, 0x05, 0x25, 0xcf, 0x7c, 0x02, 0x48, 0x8b, 0xd9, 0x48, 0x8b, 0xd1, 0x48, + 0x8b, 0xa8, 0xa0, 0x04, 0x00, 0x00, 0x48, 0x8d, 0x8d, 0x00, 0x13, 0x00, 0x00}; +inline constexpr std::uintptr_t render_instance_owner_global = 0x078b28d8; +inline constexpr std::ptrdiff_t render_instance_manager_offset = 0x4a0; +inline constexpr std::ptrdiff_t render_instance_pool_offset = 0x1300; +inline constexpr std::uintptr_t render_instance_counter_rva = 0x050e23ec; +inline constexpr std::array render_instance_counter{ + 0xf0, 0x0f, 0xc1, 0xb7, 0x08, 0x10, 0x00, 0x00}; // lock xadd [rdi+0x1008], esi +inline constexpr std::ptrdiff_t render_instance_counter_offset = 0x1008; +// The game's own render instances (512 pages of 512). +inline constexpr std::size_t render_instances_stock = 262'144; + +// Spatial buckets: a query, (root, ...), four register arguments; the root's node +// count and each node's block count are at spatial_buckets.h's offsets. +inline constexpr std::uintptr_t spatial_query_rva = 0x01967420; +inline constexpr std::array spatial_query_entry{ + 0x4c, 0x8b, 0xdc, 0x4d, 0x89, 0x4b, 0x20, 0x41, 0x55, 0x41, 0x56, 0x48, 0x83, 0xec, 0x68}; +inline constexpr std::ptrdiff_t spatial_nodes_offset = 0x130; +// The game's own blocks per bucket (its root bucket overflowed at 2,049). +inline constexpr std::size_t spatial_blocks_stock = 2'048; +} // namespace dingosdk::game::build::v20260929::world_pool_usage diff --git a/Engine/Resource/mesh_set.cpp b/Engine/Resource/mesh_set.cpp index 3e1fb9d..63511bc 100644 --- a/Engine/Resource/mesh_set.cpp +++ b/Engine/Resource/mesh_set.cpp @@ -1,4 +1,5 @@ #include "mesh_set.h" +#include #include #include #include @@ -131,6 +132,118 @@ std::vector> read_mesh_positions(std::span return result; } +namespace { +// The two layouts check_mesh_lod reads: the skinned one above, and the static one world meshes +// have (192, 176, 384, 0), whose levels of detail are laid out the same. A static mesh's sections +// are 384 or 416 bytes, and nothing found yet says which: check_mesh_lod takes the size under +// which every section reads as one. +struct Layout { + bool skinned{}; +}; +Layout layout_of(const Reader& r) { + const auto first = r.at(0), second = r.at(4), third = r.at(8); + if (first == 208 && second == 188 && third == 384) return {true}; + if (first == 192 && second == 176 && third == 384) return {false}; + require(false, "its layout is not one this reader knows"); + return {}; +} +// Whether every section, `stride` bytes apart, reads as one: a triangle list whose indices are in +// the index buffer and whose vertices start in the vertex buffer. +bool sections_read(const Reader& r, std::size_t table, std::uint32_t sections, std::size_t stride, std::size_t index_size, + std::size_t vertex_size, std::size_t index_total) { + for (std::uint32_t section = 0; section < sections; ++section) { + const auto s = table + section * stride; + if (s + stride > r.data.size()) return false; + const std::size_t triangles = r.at(s + triangle_count); + if (!triangles) continue; + if (r.at(s + primitive) != triangle_list || r.at(s + vertex_start) >= vertex_size || + std::size_t{r.at(s + first_index)} * index_size + triangles * 3 * index_size > index_total) + return false; + } + return true; +} +} + +std::vector check_mesh_lod(std::span resource, std::size_t lod, + std::span geometry, std::uint32_t* format_out) { + const Reader r{resource}; + const auto shape = layout_of(r); + require(lod < r.at(lod_count), "it has no such level of detail"); + const auto at = r.pointer(lod_table + lod * 8); + const std::size_t vertex_size = r.at(at + vertex_bytes), index_size_total = r.at(at + index_bytes); + const auto format = r.at(at + index_format); + if (format_out) *format_out = format; + const std::size_t index_size = format == sixteen_bit_indices ? 2 : 4; + std::vector problems; + const auto say = [&](std::string text) { if (problems.size() < 8) problems.push_back(std::move(text)); }; + if (geometry.size() < vertex_size + index_size_total) + say("the geometry chunk holds " + std::to_string(geometry.size()) + " bytes of the " + + std::to_string(vertex_size + index_size_total) + " its buffers need"); + const auto sections = r.at(at + section_count); + require(sections <= 4096, "it has too many sections"); + if (!sections) return problems; + const auto table = r.pointer(at + section_table); + std::size_t stride = section_size; + if (!shape.skinned) { + if (sections_read(r, table, sections, 384, index_size, vertex_size, index_size_total)) stride = 384; + else if (sections_read(r, table, sections, 416, index_size, vertex_size, index_size_total)) stride = 416; + else say("its sections read as neither 384 nor 416 bytes apart: one of them is out of its buffers"); + } + for (std::uint32_t section = 0; section < sections; ++section) { + const auto s = table + section * stride; + const auto name = "section " + std::to_string(section); + const auto type = r.at(s + primitive); + const std::size_t count = r.at(s + vertex_count), triangles = r.at(s + triangle_count); + if (!triangles) continue; + if (type != triangle_list) { say(name + " is primitive type " + std::to_string(type) + ", not a triangle list"); continue; } + if (!count) { say(name + " has " + std::to_string(triangles) + " triangles and no vertices"); continue; } + const std::size_t first = r.at(s + vertex_start); + if (first >= vertex_size) say(name + "'s vertices start at byte " + std::to_string(first) + " of a " + std::to_string(vertex_size) + "-byte vertex buffer"); + if (shape.skinned) { + // (Where its vertices end: each stream's follow the one before.) + const auto d = s + declaration; + const auto streams = r.at(d + stream_total); + std::size_t end = first; + for (std::size_t stream = 0; stream < streams && stream < 16; ++stream) + end += std::size_t{r.at(d + stream_table + stream * 2)} * count; + if (end > vertex_size) + say(name + "'s vertices end at byte " + std::to_string(end) + " of a " + std::to_string(vertex_size) + "-byte vertex buffer"); + } + const std::size_t start = std::size_t{r.at(s + first_index)} * index_size; + const std::size_t length = triangles * 3 * index_size; + if (start + length > index_size_total) { + say(name + "'s indices end at byte " + std::to_string(start + length) + " of a " + std::to_string(index_size_total) + "-byte index buffer"); + continue; + } + const auto base = vertex_size + start; + if (base + length > geometry.size()) continue; + std::size_t outside{}, largest{}; + for (std::size_t i = 0; i < triangles * 3; ++i) { + std::size_t index{}; + std::memcpy(&index, geometry.data() + base + i * index_size, index_size); + if (index >= count) ++outside, largest = std::max(largest, index); + } + if (outside) + say(name + ": " + std::to_string(outside) + " of " + std::to_string(triangles * 3) + " indices name a vertex outside its " + + std::to_string(count) + " (the largest " + std::to_string(largest) + ")"); + } + return problems; +} + +MeshLod any_mesh_lod_geometry(std::span resource, std::size_t lod, std::size_t* lods) { + const Reader r{resource}; + (void)layout_of(r); + const std::size_t total = r.at(lod_count); + if (lods) *lods = total; + require(lod < total, "it has no such level of detail"); + const auto at = r.pointer(lod_table + lod * 8); + MeshLod result; + result.chunk = r.at(at + geometry_chunk); + result.vertex_bytes = r.at(at + vertex_bytes); + result.index_bytes = r.at(at + index_bytes); + return result; +} + SkinnedMesh read_skinned_mesh(std::span resource, std::size_t lod, std::span geometry) { const auto layout = read_mesh_lod(resource, lod); const Reader r{resource}, g{geometry}; diff --git a/Engine/Resource/mesh_set.h b/Engine/Resource/mesh_set.h index 4cc2eac..958e2eb 100644 --- a/Engine/Resource/mesh_set.h +++ b/Engine/Resource/mesh_set.h @@ -7,6 +7,7 @@ #include #include #include +#include #include namespace dingosdk::frostbite { @@ -28,6 +29,15 @@ struct MeshLod { [[nodiscard]] std::vector> read_mesh_positions(std::span resource, std::size_t lod, std::span geometry); +// A level of detail's geometry chunk and buffer sizes, of either layout check_mesh_lod reads (the +// skinned one, and world meshes' static one); `lods` gets how many levels there are. +[[nodiscard]] MeshLod any_mesh_lod_geometry(std::span resource, std::size_t lod, std::size_t* lods = nullptr); +// What is wrong with a level of detail's geometry as the GPU would read it, a skinned or a static +// MeshSet: a section whose vertices or indices run past their buffer, or a triangle that +// names a vertex outside its section. Empty when it is sound. `index_format` gets the level's +// index format (33: 16-bit; anything else is read as 32-bit). Throws for another layout. +[[nodiscard]] std::vector check_mesh_lod(std::span resource, std::size_t lod, + std::span geometry, std::uint32_t* index_format = nullptr); struct SkinnedVertex { std::array position{}; // model space std::array bones{}; // skeleton bones diff --git a/Engine/Vfs/Test/mod_mesh_check.cpp b/Engine/Vfs/Test/mod_mesh_check.cpp new file mode 100644 index 0000000..0e547e2 --- /dev/null +++ b/Engine/Vfs/Test/mod_mesh_check.cpp @@ -0,0 +1,191 @@ +// The meshes a mod ships, checked as the GPU would read them (frostbite::check_mesh_lod): a +// section whose vertices or indices run past their buffer, or a triangle naming a vertex outside +// its section, sends a draw, or a ray traced against the mesh, to memory that is not the mesh's. +// For finding what in a map hangs or faults the GPU when it loads. +// +// Arguments: []. Every MeshSet the mod's own files +// hold is read, each level of detail checked; what is wrong is listed by bundle and mesh, then +// how many meshes each bundle has and how many are wrong. +#include "Engine/Resource/binary_bundle.h" +#include "Engine/Resource/cas_codec.h" +#include "Engine/Resource/mesh_set.h" +#include "Engine/Resource/toc.h" +#include "Engine/Vfs/mod_merge_internal.h" +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +namespace fb = dingosdk::frostbite; +using namespace dingosdk::mods::detail; + +namespace { +constexpr std::uint32_t mesh_set_type = 0x49b156d4; + +struct Tally { + std::size_t meshes{}, wrong{}, unreadable{}; +}; +} + +int wmain(int argc, wchar_t** argv) { + if (argc < 3) { + std::cerr << "Usage: dingosdk_mod_mesh_check []\n"; + return 2; + } + const fs::path game_root = argv[1], mod = argv[2]; + std::string filter; + if (argc > 3) for (const wchar_t* c = argv[3]; *c; ++c) filter.push_back(static_cast(*c)); + const auto base_root = game_root / L"Data"; + try { + const auto layout = dingosdk::vfs::read_layout(base_root / L"layout.toc"); + // (Nothing is written: the store is only read through.) + const auto scratch = fs::temp_directory_path() / L"reskate-mesh-check"; + CasStore store(base_root, scratch, layout.root); + std::map tallies; + std::map formats; + for (const auto& relative : scan(mod).tocs) { + fb::TocDocument own; + try { + own = fb::read_toc(read_file(mod / fs::path(relative))); + } catch (const std::exception& error) { + std::cout << "unreadable toc " << fs::path(relative).string() << ": " << error.what() << "\n"; + continue; + } + for (const auto& bundle : own.bundles) { + if (!filter.empty() && bundle.name.find(filter) == std::string::npos) continue; + std::optional listing; + try { + listing = list_bundle(store, mod, base_root, bundle, game_root); + } catch (const std::exception& error) { + std::cout << "unreadable bundle " << bundle.name << ": " << error.what() << "\n"; + continue; + } + if (!listing) continue; + const auto& manifest = listing->manifest; + auto& tally = tallies[bundle.name]; + for (std::size_t index = 0; index < manifest.resources.size(); ++index) { + const auto file = manifest.ebx.size() + index; + const auto at = listing->first + file; + if (manifest.resources[index].resourceType != mesh_set_type || at >= listing->files.size() || + !listing->files[at].location.patch) + continue; // (the game's own meshes are not the mod's to check) + const auto& name = manifest.resources[index].name; + ++tally.meshes; + std::vector resource; + std::size_t lods{}; + try { + resource = read_asset(store, mod, base_root, *listing, file, game_root); + (void)fb::any_mesh_lod_geometry(resource, 0, &lods); + } catch (const std::exception& error) { + ++tally.unreadable; + // (Each layout this reader does not know, once: its first words and size.) + static std::map shown; + std::string head; + for (std::size_t word = 0; word < 16 && (word + 1) * 4 <= resource.size(); ++word) { + std::uint32_t value{}; + std::memcpy(&value, resource.data() + word * 4, 4); + head += std::to_string(value) + " "; + } + if (!shown[head.substr(0, 48)]) { + shown[head.substr(0, 48)] = true; + std::cout << "layout " << head << "(" << resource.size() << " bytes) e.g. " << name << ": " << error.what() << "\n"; + // (RESKATE_MESH_DUMP=1: the first one whole, eight bytes a line, for working out its layout.) + static bool dumped{}; + if (!dumped && GetEnvironmentVariableW(L"RESKATE_MESH_DUMP", nullptr, 0)) { + dumped = true; + for (std::size_t offset = 0; offset + 8 <= resource.size(); offset += 8) { + std::uint32_t low{}, high{}; + std::memcpy(&low, resource.data() + offset, 4); + std::memcpy(&high, resource.data() + offset + 4, 4); + std::cout << " +" << at << ": " << low << " " << high << "\n"; + } + } + } + continue; + } + // (RESKATE_MESH_DUMP_NAME=: that mesh's resource, offset and 32-bit word a line.) + if (wchar_t wanted_name[256]{}; GetEnvironmentVariableW(L"RESKATE_MESH_DUMP_NAME", wanted_name, 256)) { + std::string part; + for (const wchar_t* c = wanted_name; *c; ++c) part.push_back(static_cast(*c)); + if (name.find(part) != std::string::npos) + for (std::size_t offset = 0; offset + 4 <= resource.size(); offset += 4) { + std::uint32_t value{}; + std::memcpy(&value, resource.data() + offset, 4); + std::cout << "dump " << offset << " " << value << "\n"; + } + } + bool wrong{}; + for (std::size_t lod = 0; lod < lods; ++lod) { + try { + const auto wanted = fb::any_mesh_lod_geometry(resource, lod).chunk; + std::vector geometry; + for (std::size_t chunk = 0; chunk < manifest.chunks.size() && geometry.empty(); ++chunk) + if (manifest.chunks[chunk].guid == wanted) + geometry = read_asset(store, mod, base_root, *listing, + manifest.ebx.size() + manifest.resources.size() + chunk, game_root); + for (auto chunk = own.chunks.begin(); chunk != own.chunks.end() && geometry.empty(); ++chunk) + if (chunk->guid == wanted && !chunk->removed) + geometry = fb::decode_cas(store.read(chunk->location.patch ? mod : base_root, chunk->location, + chunk->offset, chunk->size), {game_root}); + if (geometry.empty()) { + std::cout << bundle.name << " | " << name << " | lod " << lod << " | its geometry chunk is in neither the bundle nor its superbundle\n"; + wrong = true; + continue; + } + std::uint32_t format{}; + const auto problems = fb::check_mesh_lod(resource, lod, geometry, &format); + // (RESKATE_MESH_STATS=1: each level's size, for comparing a map's meshes with another's: + // sections, buffer bytes, and the most vertices and triangles a section has.) + if (GetEnvironmentVariableW(L"RESKATE_MESH_STATS", nullptr, 0)) { + const auto word = [&](std::size_t offset) { + std::uint64_t value{}; + std::memcpy(&value, resource.data() + offset, 8); + return value; + }; + const auto lod_at = static_cast(word(48 + lod * 8)) + 16; + const auto sections = static_cast(word(lod_at + 8)); + const auto table = static_cast(word(lod_at + 12)) + 16; + const auto sizes = fb::any_mesh_lod_geometry(resource, lod); + // (Static meshes' sections are 384 or 416 bytes; the larger section counts read by either.) + std::uint64_t most_vertices{}, most_triangles{}; + for (std::uint32_t section = 0; section < sections; ++section) + for (const std::size_t stride : {384u, 416u}) { + const auto at_section = table + section * stride; + if (at_section + 48 > resource.size()) continue; + std::uint32_t triangles{}, vertices{}; + std::memcpy(&triangles, resource.data() + at_section + 32, 4); + std::memcpy(&vertices, resource.data() + at_section + 44, 4); + if (vertices <= sizes.vertex_bytes && triangles * 6ull <= sizes.index_bytes + 6ull) { + most_vertices = std::max(most_vertices, vertices); + most_triangles = std::max(most_triangles, triangles); + } + } + std::cout << "stats " << bundle.name << " " << name << " " << lod << " " << sections << " " << sizes.vertex_bytes + << " " << sizes.index_bytes << " " << most_vertices << " " << most_triangles << "\n"; + } + ++formats[format]; + for (const auto& problem : problems) std::cout << bundle.name << " | " << name << " | lod " << lod << " | " << problem << "\n"; + wrong = wrong || !problems.empty(); + } catch (const std::exception& error) { + std::cout << bundle.name << " | " << name << " | lod " << lod << " | unreadable: " << error.what() << "\n"; + } + } + tally.wrong += wrong; + } + } + } + std::cout << "\nBundle | meshes | wrong | unreadable\n"; + for (const auto& [name, tally] : tallies) + if (tally.meshes) std::cout << name << " | " << tally.meshes << " | " << tally.wrong << " | " << tally.unreadable << "\n"; + std::cout << "Index formats seen:"; + for (const auto& [format, count] : formats) std::cout << " " << format << " (" << count << " levels)"; + std::cout << "\n"; + } catch (const std::exception& error) { + std::cerr << error.what() << "\n"; + return 1; + } + return 0; +} diff --git a/Extension/Assets/native_render_resource_pool.cpp b/Extension/Assets/native_render_resource_pool.cpp index b9abbc4..7dda8bc 100644 --- a/Extension/Assets/native_render_resource_pool.cpp +++ b/Extension/Assets/native_render_resource_pool.cpp @@ -1,11 +1,14 @@ #include "native_render_resource_pool.h" #include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" #include "Engine/Game/Build/supported_build.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/render_resource_pool.h" #include +#include #include #include #include @@ -13,6 +16,7 @@ #include #include #include +#include namespace dingosdk { namespace { @@ -61,6 +65,10 @@ static_assert(relation_mask_bytes == 0x00300000); using NativeConstructor = void* (__fastcall *)(void*, void*); using IndexTableConstructor = void* (__fastcall *)(void*, void*, void*); +// Records and metadata every renderer built on the constructor uses; the +// resource states and relation masks only the state owner has +// (contract::state_owner_call_rva). 115.5 MB of states went to the other +// renderer as well, which never touches them. struct Buffers { std::byte* records{}; std::uint32_t* metadata{}; @@ -68,9 +76,8 @@ struct Buffers { std::byte* resource_states{}; std::uint64_t* relation_masks{}; - explicit operator bool() const noexcept { - return records && metadata && resource_flags && resource_states && relation_masks; - } + explicit operator bool() const noexcept { return records && metadata && resource_flags; } + bool has_states() const noexcept { return resource_states && relation_masks; } }; struct IndexTableBuffers { @@ -89,6 +96,10 @@ std::array reserved_index_tables{}; std::size_t next_reserved_index_table{}; std::byte* resource_flag_stubs{}; std::uintptr_t installed_base{}; +// Where the state owner's constructor call returns to in the game. +std::atomic state_owner_return{}; +// The renderer that has resource states, once constructed. +std::atomic state_owner{}; bool read_memory(const void* source, void* destination, std::size_t size) noexcept { SIZE_T count{}; @@ -122,13 +133,22 @@ bool validate_contract(std::uintptr_t base) noexcept { contract::resource_flags_read_priority) || !matches(base + contract::resource_flags_visibility_rva, contract::resource_flags_read_visibility)) return false; + // The state owner's call to the constructor: an E8 call whose target is it. + std::array call{}; + std::int32_t displacement{}; + if (!read_memory(reinterpret_cast(base + contract::state_owner_call_rva), call.data(), call.size()) || + call[0] != 0xe8) return false; + std::memcpy(&displacement, call.data() + 1, sizeof(displacement)); + if (contract::state_owner_call_rva + call.size() + displacement != contract::constructor_rva) return false; for (const auto& patch : contract::patches) if (patch.expected.size() != patch.replacement.size() || !matches(base + patch.rva, patch.expected)) return false; return true; } -Buffers allocate_buffers() noexcept { +void free_buffers(Buffers& buffers) noexcept; + +Buffers allocate_buffers(bool states) noexcept { Buffers result{}; result.records = static_cast(VirtualAlloc( nullptr, record_bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)); @@ -140,19 +160,13 @@ Buffers allocate_buffers() noexcept { return {}; } result.resource_flags = reinterpret_cast(result.metadata) + metadata_bytes; + if (!states) return result; result.resource_states = static_cast(VirtualAlloc( nullptr, resource_state_bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)); - if (!result.resource_states) { - VirtualFree(result.metadata, 0, MEM_RELEASE); - VirtualFree(result.records, 0, MEM_RELEASE); - return {}; - } - result.relation_masks = static_cast(VirtualAlloc( + result.relation_masks = !result.resource_states ? nullptr : static_cast(VirtualAlloc( nullptr, relation_mask_bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)); - if (!result.relation_masks) { - VirtualFree(result.resource_states, 0, MEM_RELEASE); - VirtualFree(result.metadata, 0, MEM_RELEASE); - VirtualFree(result.records, 0, MEM_RELEASE); + if (!result.has_states()) { + free_buffers(result); return {}; } return result; @@ -195,16 +209,34 @@ void free_reserved_allocations() noexcept { next_reserved_index_table = 0; } -Buffers take_buffers() noexcept { +Buffers take_buffers(bool states) noexcept { std::lock_guard lock(allocation_mutex); + // Two renderers are built on the constructor: the first takes the reserved + // records and metadata, the state owner the reserved states, whichever + // comes first. Another instance costs an isolated allocation of its own + // and avoids aliasing records. + Buffers result{}; if (reserved_buffers) { - const auto result = reserved_buffers; - reserved_buffers = {}; + result.records = std::exchange(reserved_buffers.records, nullptr); + result.metadata = std::exchange(reserved_buffers.metadata, nullptr); + result.resource_flags = std::exchange(reserved_buffers.resource_flags, nullptr); + } else { + result = allocate_buffers(false); + if (!result) return {}; + } + if (!states) return result; + if (reserved_buffers.has_states()) { + result.resource_states = std::exchange(reserved_buffers.resource_states, nullptr); + result.relation_masks = std::exchange(reserved_buffers.relation_masks, nullptr); return result; } - // The renderer is normally a singleton. Supporting a subsequent native - // instance costs another isolated allocation and avoids aliasing records. - return allocate_buffers(); + const auto fresh = allocate_buffers(true); + if (!fresh) { free_buffers(result); return {}; } + result.resource_states = fresh.resource_states; + result.relation_masks = fresh.relation_masks; + VirtualFree(fresh.metadata, 0, MEM_RELEASE); + VirtualFree(fresh.records, 0, MEM_RELEASE); + return result; } IndexTableBuffers take_index_table_buffers() noexcept { @@ -259,9 +291,15 @@ void* __fastcall construct_index_table(void* self, void* first, void* second) { void* __fastcall construct_native_renderer(void* self, void* configuration) { const auto incoming_error = GetLastError(); - const auto buffers = take_buffers(); + // (The hook is jumped to, so this returns where the game's call does.) + const auto caller = reinterpret_cast(_ReturnAddress()); + const bool states = caller == state_owner_return.load(std::memory_order_acquire); + const auto buffers = take_buffers(states); const auto original = original_constructor.load(std::memory_order_acquire); - if (!self || !buffers || !original) allocation_failure(); + if (!self || !buffers || (states && !buffers.has_states()) || !original) allocation_failure(); + logging::log(logging::Level::info, logging::Channel::graphics, + "Render-resource pool: renderer built from Skate.exe+{:#x}, {}.", caller - installed_base, + states ? "with the enlarged resource states" : "without resource states (it has none)"); auto* const record_end = buffers.records + record_bytes; auto* const metadata_end = buffers.metadata + native_render_resource_capacity; @@ -272,8 +310,10 @@ void* __fastcall construct_native_renderer(void* self, void* configuration) { store_field(self, vector_begin_offset, buffers.records); 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); + if (states) { + store_field(self, resource_state_vector_offset, buffers.resource_states); + store_field(self, relation_mask_vector_offset, relation_mask_begin); + } SetLastError(incoming_error); auto* const result = original(self, configuration); @@ -281,6 +321,7 @@ void* __fastcall construct_native_renderer(void* self, void* configuration) { // 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 (load_field(self, vector_begin_offset) != buffers.records || load_field(self, vector_capacity_offset) != record_end || load_field(self, vector_end_offset) < buffers.records || @@ -314,34 +355,6 @@ bool relative_jump(std::uintptr_t instruction, std::uintptr_t destination, return true; } -std::byte* allocate_near_game_image(std::uintptr_t base) noexcept { - SYSTEM_INFO information{}; - GetSystemInfo(&information); - const auto granularity = static_cast(information.dwAllocationGranularity); - const auto align_up = [granularity](std::uintptr_t value) { - return (value + granularity - 1) & ~(granularity - 1); - }; - auto cursor = align_up(base + supported_build::game_image_size); - const auto limit = base + 0x70000000ULL; - while (cursor < limit) { - MEMORY_BASIC_INFORMATION region{}; - if (!VirtualQuery(reinterpret_cast(cursor), ®ion, sizeof(region))) break; - const auto region_base = reinterpret_cast(region.BaseAddress); - const auto region_end = region_base + region.RegionSize; - if (region.State == MEM_FREE) { - const auto candidate = align_up(cursor > region_base ? cursor : region_base); - if (candidate <= region_end && region_end - candidate >= resource_flag_stub_page_bytes) { - if (auto* const result = static_cast(VirtualAlloc( - reinterpret_cast(candidate), resource_flag_stub_page_bytes, - MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE))) return result; - } - } - if (region_end <= cursor) break; - cursor = align_up(region_end); - } - return nullptr; -} - template bool write_stub(std::byte* destination, std::span prefix, std::uintptr_t return_address) noexcept { @@ -357,7 +370,8 @@ bool write_stub(std::byte* destination, std::span prefix, } bool prepare_resource_flag_stubs(std::uintptr_t base) noexcept { - resource_flag_stubs = allocate_near_game_image(base); + resource_flag_stubs = static_cast( + memory::allocate_near_image(base, supported_build::game_image_size, resource_flag_stub_page_bytes)); if (!resource_flag_stubs) return false; std::memset(resource_flag_stubs, 0xcc, resource_flag_stub_page_bytes); if (!write_stub(resource_flag_stubs + priority_stub_offset, @@ -431,6 +445,33 @@ void restore_patches(std::uintptr_t base, std::size_t count) noexcept { } } // namespace +void report_native_render_resources() noexcept { + const auto owner = state_owner.load(std::memory_order_acquire); + std::uintptr_t begin{}, end{}; + if (!owner || !memory::peek(owner + vector_begin_offset, begin) || !memory::peek(owner + vector_end_offset, end) || + end < begin || end > begin + record_bytes) + return; + const auto used = (end - begin) / native_render_resource_record_size; + static std::size_t said{}; + static bool warned{}, full{}; + // (A new high, in steps of 1/32: how close a level came is worth having in a log.) + if (used < said + native_render_resource_capacity / 32 && !(used >= native_render_resource_capacity && !full)) return; + said = used; + const auto percent = used * 100 / native_render_resource_capacity; + if (used >= native_render_resource_capacity) { + full = true; + logging::log(logging::Level::error, logging::Channel::graphics, + "Render-resource pool: all {} records are taken; the level needs more than the game can address.", used); + } else if (percent >= 90 && !warned) { + warned = true; + logging::log(logging::Level::warning, logging::Channel::graphics, "Render-resource pool: {} of {} records taken ({}%).", used, + native_render_resource_capacity, percent); + } else { + logging::log(logging::Level::info, logging::Channel::graphics, "Render-resource pool: {} of {} records taken ({}%).", used, + native_render_resource_capacity, percent); + } +} + bool start_native_render_resource_pool(std::uintptr_t base, std::string& error) { std::lock_guard lock(install_mutex); error.clear(); @@ -446,7 +487,7 @@ bool start_native_render_resource_pool(std::uintptr_t base, std::string& error) { std::lock_guard allocation_lock(allocation_mutex); - reserved_buffers = allocate_buffers(); + reserved_buffers = allocate_buffers(true); for (auto& table : reserved_index_tables) table = allocate_index_table_buffers(); next_reserved_index_table = 0; if (!reserved_buffers || !reserved_index_tables[0] || !reserved_index_tables[1]) { @@ -520,6 +561,7 @@ bool start_native_render_resource_pool(std::uintptr_t base, std::string& error) return false; } + state_owner_return.store(base + contract::state_owner_call_rva + 5, std::memory_order_release); original_constructor.store(original, std::memory_order_release); original_index_table_constructor.store(original_index, std::memory_order_release); const auto queued_index = hook_queue_enable(index_target); diff --git a/Extension/Assets/native_render_resource_pool.h b/Extension/Assets/native_render_resource_pool.h index 7e26673..85df997 100644 --- a/Extension/Assets/native_render_resource_pool.h +++ b/Extension/Assets/native_render_resource_pool.h @@ -14,4 +14,9 @@ inline constexpr std::size_t native_render_resource_record_size = 0xf0; // constructing its records normally, but its backing storage and all parallel // per-resource arrays are redirected to process-lifetime allocations. bool start_native_render_resource_pool(std::uintptr_t base, std::string& error); + +// How many of the pool's records the renderer that has resource states has taken: a level that +// needs more than native_render_resource_capacity cannot be drawn. Said in the log as it grows, +// checked every frame so the last count before a crash is there. Game thread. +void report_native_render_resources() noexcept; } // namespace dingosdk diff --git a/Extension/Boot/descriptor_heap_size.cpp b/Extension/Boot/descriptor_heap_size.cpp new file mode 100644 index 0000000..aadd262 --- /dev/null +++ b/Extension/Boot/descriptor_heap_size.cpp @@ -0,0 +1,61 @@ +#include "descriptor_heap_size.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/descriptor_heap_size.h" + +#include +#include +#include + +namespace dingosdk { +namespace { +namespace contract = addr::descriptor_heap_size; + +bool supported_image(std::uintptr_t base) noexcept { + IMAGE_DOS_HEADER dos{}; + IMAGE_NT_HEADERS64 nt{}; + return base && memory::read(base, dos) && dos.e_magic == IMAGE_DOS_SIGNATURE && dos.e_lfanew > 0 && + dos.e_lfanew <= 0x1000 && memory::read(base + dos.e_lfanew, nt) && nt.Signature == IMAGE_NT_SIGNATURE && + nt.OptionalHeader.SizeOfImage == supported_build::game_image_size; +} + +bool write_code(std::uintptr_t address, const void* bytes, std::size_t size) noexcept { + auto* const destination = reinterpret_cast(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; +} +} // namespace + +bool start_descriptor_heap_size(std::uintptr_t base, std::string& error) { + std::array actual{}; + std::array total{}; + if (!supported_image(base) || !memory::read_bytes(base + contract::cap_load_rva, actual.data(), actual.size()) || + actual != contract::cap_load || !memory::read_bytes(base + contract::total_cap_rva, total.data(), total.size()) || + total != contract::total_cap) { + error = "the renderer's descriptor heap setup does not match the supported game"; + return false; + } + // The same divide, with the cap a constant instead of the setting (which is never set): + // div r9d / mov edi, eax / mov ecx, cap / or eax, -1 / nop + std::array code{ + 0x41, 0xf7, 0xf1, 0x8b, 0xf8, 0xb9, 0, 0, 0, 0, 0x83, 0xc8, 0xff, 0x90}; + std::memcpy(code.data() + 6, &frame_descriptor_cap, 4); + // The total, a constant, then capped at 1,000,000 as before: mov ecx, total / nop (4 bytes). + std::array load{0xb9, 0, 0, 0, 0, 0x0f, 0x1f, 0x40, 0x00}; + std::memcpy(load.data() + 1, &heap_descriptor_total, 4); + if (!write_code(base + contract::cap_load_rva, code.data(), code.size()) || + !write_code(base + contract::total_cap_rva, load.data(), load.size())) { + (void)write_code(base + contract::cap_load_rva, contract::cap_load.data(), contract::cap_load.size()); + (void)write_code(base + contract::total_cap_rva, contract::total_cap.data(), contract::total_cap.size()); + error = "the heap sizes could not be patched"; + return false; + } + return true; +} +} // namespace dingosdk diff --git a/Extension/Boot/descriptor_heap_size.h b/Extension/Boot/descriptor_heap_size.h new file mode 100644 index 0000000..40b74b3 --- /dev/null +++ b/Extension/Boot/descriptor_heap_size.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace dingosdk { +// Each frame's share of the renderer's shader-visible descriptor heap is capped at 131,072 +// descriptors, in a heap of 333,333. A dense level draws more than that in a frame (the Los Santos +// City sublevels with more than about 65,000 mesh sections), and the renderer then writes past +// the end of the heap: shaders read descriptors that are not there and fault or hang the GPU. +// Each heap is allowed D3D12's 1,000,000 instead, and a frame's share is capped at this, which +// makes each frame's heap 644,288 descriptors. +inline constexpr std::uint32_t heap_descriptor_total = 1'000'000; +inline constexpr std::uint32_t frame_descriptor_cap = 524'288; + +// Installs before executable entry, while the game is suspended. +bool start_descriptor_heap_size(std::uintptr_t base, std::string& error); +} // namespace dingosdk diff --git a/Extension/Boot/eadp_errors_arena.cpp b/Extension/Boot/eadp_errors_arena.cpp new file mode 100644 index 0000000..0992a8a --- /dev/null +++ b/Extension/Boot/eadp_errors_arena.cpp @@ -0,0 +1,93 @@ +#include "eadp_errors_arena.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/eadp_errors_arena.h" + +#include +#include +#include +#include + +namespace dingosdk { +namespace { +namespace contract = addr::eadp_errors_arena; + +constexpr std::size_t stub_page_bytes = 0x1000; + +bool supported_image(std::uintptr_t base) noexcept { + IMAGE_DOS_HEADER dos{}; + IMAGE_NT_HEADERS64 nt{}; + return base && memory::read(base, dos) && dos.e_magic == IMAGE_DOS_SIGNATURE && dos.e_lfanew > 0 && + dos.e_lfanew <= 0x1000 && memory::read(base + dos.e_lfanew, nt) && nt.Signature == IMAGE_NT_SIGNATURE && + nt.OptionalHeader.SizeOfImage == supported_build::game_image_size; +} + +bool rel32(std::uintptr_t next_instruction, std::uintptr_t target, std::int32_t& displacement) noexcept { + const auto delta = static_cast(target) - static_cast(next_instruction); + if (delta < std::numeric_limits::min() || delta > std::numeric_limits::max()) return false; + displacement = static_cast(delta); + return true; +} + +bool write_code(std::uintptr_t address, const void* bytes, std::size_t size) noexcept { + auto* const destination = reinterpret_cast(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; +} +} // namespace + +bool start_eadp_errors_arena(std::uintptr_t base, std::string& error) { + std::array actual{}; + if (!supported_image(base) || !memory::read_bytes(base + contract::size_load_rva, actual.data(), actual.size()) || + actual != contract::size_load) { + error = "the arena's factory does not match the supported game"; + return false; + } + auto* const stub = static_cast(memory::allocate_near_image(base, supported_build::game_image_size, stub_page_bytes)); + if (!stub) { + error = "no room for its stub near the game"; + return false; + } + const auto at = reinterpret_cast(stub); + // The two moved instructions, with the size capped between them: + // mov eax, [rax+0x7c] / cmp eax, cap / jbe +5 / mov eax, cap / lea rcx, [vtable] / jmp resume + std::array code{0x8b, 0x40, 0x7c, 0x3d, 0, 0, 0, 0, 0x76, 0x05, 0xb8, 0, 0, 0, 0, + 0x48, 0x8d, 0x0d, 0, 0, 0, 0, 0xe9, 0, 0, 0, 0}; + std::memcpy(code.data() + 4, &eadp_errors_arena_bytes, 4); + std::memcpy(code.data() + 11, &eadp_errors_arena_bytes, 4); + std::int32_t vtable{}, resume{}, into{}; + if (!rel32(at + 22, base + contract::vtable_rva, vtable) || !rel32(at + 27, base + contract::resume_rva, resume) || + !rel32(base + contract::size_load_rva + 5, at, into)) { + VirtualFree(stub, 0, MEM_RELEASE); + error = "its stub is out of reach of the game"; + return false; + } + std::memcpy(code.data() + 18, &vtable, 4); + std::memcpy(code.data() + 23, &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); + error = "its stub could not be made executable"; + return false; + } + std::array jump{0xe9}; + std::memcpy(jump.data() + 1, &into, 4); + std::memset(jump.data() + 5, 0x90, jump.size() - 5); + if (!write_code(base + contract::size_load_rva, jump.data(), jump.size())) { + (void)write_code(base + contract::size_load_rva, contract::size_load.data(), contract::size_load.size()); + VirtualFree(stub, 0, MEM_RELEASE); + error = "the size load could not be redirected"; + return false; + } + return true; +} +} // namespace dingosdk diff --git a/Extension/Boot/eadp_errors_arena.h b/Extension/Boot/eadp_errors_arena.h new file mode 100644 index 0000000..ddfd6e5 --- /dev/null +++ b/Extension/Boot/eadp_errors_arena.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include + +namespace dingosdk { +// The arena EA's error reporting allocates from is 80 MiB, made at startup and kept in RAM, of +// which about 10 MB was in use: reporting is off (ea_service_block, and the EadpErrorsData +// settings in client_tick.cpp), so its reports are never built. It is made before those settings +// can be changed, so its size is capped where the game reads it instead. A smaller configured +// size is left as it is. +inline constexpr std::uint32_t eadp_errors_arena_bytes = 32u << 20; + +// Installs before executable entry, while the game is suspended. +bool start_eadp_errors_arena(std::uintptr_t base, std::string& error); +} // namespace dingosdk diff --git a/Extension/Console/memory_ledger.cpp b/Extension/Console/memory_ledger.cpp new file mode 100644 index 0000000..dfe8fa6 --- /dev/null +++ b/Extension/Console/memory_ledger.cpp @@ -0,0 +1,812 @@ +#include "Extension/Console/memory_ledger.h" +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::memory_ledger { +namespace { +constexpr std::size_t depth = 14; +constexpr std::size_t caller_slots = 8192; +constexpr unsigned block_bits = 18; +constexpr std::size_t block_slots = std::size_t{1} << block_bits; +constexpr std::uint32_t gone = 0xffffffff; + +struct Caller { + std::uint64_t hash{}; + std::array frames{}; + unsigned frame_count{}; + // What was asked for while `perf memory trace` was on: reservations and commits of the + // traced sizes. smallest/largest: the sizes of those. + std::uint64_t asked_count{}, asked_bytes{}, smallest{}, largest{}; +}; +// A reservation the process still holds and who made it. caller: an index into callers plus +// one; 0 is an empty slot, `gone` one whose reservation was given back (kept so a search +// for one further along still finds it). +struct Block { + std::uintptr_t base{}; + std::uint32_t caller{}; + std::uint32_t made{}; // seconds after the ledger started +}; + +// Never held while allocating: the heap may grow and come back through the hook on the same +// thread. What is copied out under it goes into storage allocated before. +// (None of this is ever destroyed: the hooks stay on while the process exits, and what other +// modules give back then still passes them.) +std::mutex& mutex = *new std::mutex; +std::vector& callers = *new std::vector(caller_slots); +std::vector& blocks = *new std::vector(block_slots); +std::size_t blocks_live{}, blocks_gone{}; +std::uint64_t untracked{}; // reservations the tables had no room for +std::atomic running{}, counting{}; +std::atomic least{1}, most{UINT64_MAX}; +std::atomic began{}; +std::mutex& start_mutex = *new std::mutex; +bool prepared{}; + +using Allocate = LONG (NTAPI*)(HANDLE, PVOID*, ULONG_PTR, PSIZE_T, ULONG, ULONG); +using AllocateEx = LONG (NTAPI*)(HANDLE, PVOID*, PSIZE_T, ULONG, ULONG, void*, ULONG); +using Free = LONG (NTAPI*)(HANDLE, PVOID*, PSIZE_T, ULONG); +Allocate original{}; +AllocateEx original_ex{}; +Free original_free{}; + +std::size_t block_slot(std::uintptr_t base) noexcept { + return static_cast(((base >> 16) * 0x9e3779b97f4a7c15ull) >> (64 - block_bits)); +} +// Under the lock. The search is bounded: a hook cannot take long. +void remember(std::uintptr_t base, std::uint32_t caller, std::uint32_t made) noexcept { + std::size_t free_at = block_slots; + for (std::size_t probe = 0; probe < 2048; ++probe) { + const auto at = (block_slot(base) + probe) & (block_slots - 1); + auto& block = blocks[at]; + if (!block.caller) { if (free_at == block_slots) free_at = at; break; } + if (block.caller == gone) { if (free_at == block_slots) free_at = at; continue; } + // (A release that was not seen: the same base is made again.) + if (block.base == base) { block.caller = caller; block.made = made; return; } + } + if (free_at == block_slots || (!blocks[free_at].caller && (blocks_live + blocks_gone) * 8 >= block_slots * 7)) { + ++untracked; + return; + } + if (blocks[free_at].caller == gone) --blocks_gone; + blocks[free_at] = {base, caller, made}; + ++blocks_live; +} +void forget(std::uintptr_t base) noexcept { + for (std::size_t probe = 0; probe < 2048; ++probe) { + auto& block = blocks[(block_slot(base) + probe) & (block_slots - 1)]; + if (!block.caller) return; + if (block.caller != gone && block.base == base) { + block.caller = gone; + --blocks_live; + ++blocks_gone; + return; + } + } +} +const Block* find(const std::vector& table, std::uintptr_t base) noexcept { + for (std::size_t probe = 0; probe < 2048; ++probe) { + const auto& block = table[(block_slot(base) + probe) & (block_slots - 1)]; + if (!block.caller) return nullptr; + if (block.caller != gone && block.base == base) return █ + } + return nullptr; +} +// Under the lock: the caller's index plus one, made if new; 0 when the table is full. +std::uint32_t caller_of(std::uint64_t hash, const std::array& frames, unsigned count) noexcept { + for (std::size_t probe = 0; probe < 128; ++probe) { + const auto at = (hash + probe) % caller_slots; + auto& caller = callers[at]; + if (caller.hash && caller.hash != hash) continue; + if (!caller.hash) { + caller = {}; + caller.hash = hash; + caller.frames = frames; + caller.frame_count = count; + } + return static_cast(at + 1); + } + return 0; +} + +// This process, by its pseudo-handle or a real handle to it (which some code opens for itself). +bool own(HANDLE process) noexcept { + return process == GetCurrentProcess() || (process && GetProcessId(process) == GetCurrentProcessId()); +} + +// (Not inlined: the stack is captured from a known depth, two frames under the caller of +// NtAllocateVirtualMemory.) +__declspec(noinline) void note(HANDLE process, void* base, SIZE_T asked, ULONG type) noexcept { + if (!running.load(std::memory_order_relaxed) || !(type & (MEM_COMMIT | MEM_RESERVE)) || !own(process)) + return; + const bool reserve = (type & MEM_RESERVE) != 0; + const bool counted = counting.load(std::memory_order_relaxed) && asked >= least.load(std::memory_order_relaxed) && + asked <= most.load(std::memory_order_relaxed); + if (!reserve && !counted) return; // (a commit inside a reservation that is already noted) + std::array frames{}; + const unsigned count = CaptureStackBackTrace(2, static_cast(depth), frames.data(), nullptr); + std::uint64_t hash = 1469598103934665603ull; + for (unsigned i = 0; i < count; ++i) hash = hash * 1099511628211ull ^ reinterpret_cast(frames[i]); + if (!hash) hash = 1; + const auto made = static_cast((GetTickCount64() - began.load(std::memory_order_relaxed)) / 1000); + std::lock_guard lock(mutex); + const auto index = caller_of(hash, frames, count); + if (!index) { if (reserve) ++untracked; return; } + if (counted) { + auto& caller = callers[index - 1]; + caller.smallest = caller.asked_count ? std::min(caller.smallest, asked) : asked; + caller.largest = std::max(caller.largest, asked); + ++caller.asked_count; + caller.asked_bytes += asked; + } + if (reserve && base) remember(reinterpret_cast(base), index, made); +} +LONG NTAPI hooked(HANDLE process, PVOID* base, ULONG_PTR zero_bits, PSIZE_T size, ULONG type, ULONG protect) { + const auto asked = size ? *size : 0; + const auto status = original(process, base, zero_bits, size, type, protect); + if (status >= 0) note(process, base ? *base : nullptr, asked, type); + return status; +} +// The newer call for the same (VirtualAlloc2, which graphics drivers and the D3D12 runtime use). +LONG NTAPI hooked_ex(HANDLE process, PVOID* base, PSIZE_T size, ULONG type, ULONG protect, void* parameters, ULONG parameter_count) { + const auto asked = size ? *size : 0; + const auto status = original_ex(process, base, size, type, protect, parameters, parameter_count); + if (status >= 0) note(process, base ? *base : nullptr, asked, type); + return status; +} +LONG NTAPI hooked_free(HANDLE process, PVOID* base, PSIZE_T size, ULONG type) { + const auto at = base ? reinterpret_cast(*base) : 0; + const auto status = original_free(process, base, size, type); + // (A placeholder split or joined is still reserved: MEM_COALESCE/PRESERVE_PLACEHOLDER.) + if (status >= 0 && at && (type & MEM_RELEASE) && !(type & 0x3) && running.load(std::memory_order_relaxed) && + own(process)) { + std::lock_guard lock(mutex); + forget(at); + } + return status; +} + +void* ntdll_function(const char* name) { + return reinterpret_cast(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), name)); +} + +std::string megabytes(std::uint64_t bytes) { + return bytes >= (1ull << 30) ? std::format("{:.2f} GB", static_cast(bytes) / (1ull << 30)) + : std::format("{:.1f} MB", static_cast(bytes) / (1ull << 20)); +} +std::string age(std::uint32_t seconds) { + return seconds < 120 ? std::format("{} s", seconds) : seconds < 7200 ? std::format("{} min", seconds / 60) + : std::format("{:.1f} h", seconds / 3600.0); +} + +// ---- reading what the process holds ---- +struct Range { + std::uintptr_t base{}; + std::uint64_t bytes{}; + DWORD protect{}; +}; +struct Allocation { + std::uint64_t committed{}, reserved{}; // reserved: the whole reservation, committed or not + DWORD reserved_as{}; // the protection it was reserved with + DWORD committed_as{}; // every protection its committed parts have, together + std::vector ranges; // what of it is committed (kept only when asked for) +}; +struct Held { + std::map private_memory; + std::uint64_t committed{}, mapped{}, image{}; +}; +Held read_held(bool keep_ranges) { + Held held; + SYSTEM_INFO system{}; + GetSystemInfo(&system); + auto at = reinterpret_cast(system.lpMinimumApplicationAddress); + const auto end = reinterpret_cast(system.lpMaximumApplicationAddress); + MEMORY_BASIC_INFORMATION info{}; + while (at < end && VirtualQuery(reinterpret_cast(at), &info, sizeof(info)) == sizeof(info)) { + if (info.State != MEM_FREE) { + if (info.Type == MEM_PRIVATE) { + auto& allocation = held.private_memory[reinterpret_cast(info.AllocationBase)]; + allocation.reserved += info.RegionSize; + allocation.reserved_as = info.AllocationProtect; + if (info.State == MEM_COMMIT) { + allocation.committed += info.RegionSize; + allocation.committed_as |= info.Protect; + held.committed += info.RegionSize; + if (keep_ranges) + allocation.ranges.push_back({reinterpret_cast(info.BaseAddress), info.RegionSize, info.Protect}); + } + } else if (info.State == MEM_COMMIT) { + (info.Type == MEM_IMAGE ? held.image : held.mapped) += info.RegionSize; + } + } + at = reinterpret_cast(info.BaseAddress) + info.RegionSize; + } + return held; +} + +struct Copy { + std::vector callers; + std::vector blocks; + std::uint64_t untracked{}; + std::size_t live{}; +}; +Copy copy_tables() { + Copy copy; + copy.callers.resize(caller_slots); + copy.blocks.resize(block_slots); + std::lock_guard lock(mutex); + std::copy(callers.begin(), callers.end(), copy.callers.begin()); + std::copy(blocks.begin(), blocks.end(), copy.blocks.begin()); + copy.untracked = untracked; + copy.live = blocks_live; + return copy; +} + +// The module an address is in, by name; empty when it is in none. +struct Module { + std::string name; + std::uintptr_t base{}; +}; +Module module_at(void* address) { + HMODULE module{}; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(address), &module) || !module) + return {}; + wchar_t path[MAX_PATH]{}; + GetModuleFileNameW(module, path, MAX_PATH); + const wchar_t* name = path; + for (const wchar_t* c = path; *c; ++c) if (*c == L'\\') name = c + 1; + Module result{{}, reinterpret_cast(module)}; + for (; *name; ++name) result.name += static_cast(*name < 128 ? *name : '?'); + return result; +} +std::string place(void* address) { + const auto module = module_at(address); + if (module.name.empty()) return std::format("{:#x}", reinterpret_cast(address)); + return std::format("{}+{:#x}", module.name, reinterpret_cast(address) - module.base); +} +std::string lower(std::string text) { + for (auto& c : text) c = static_cast(c >= 'A' && c <= 'Z' ? c + 32 : c); + return text; +} +// Who a caller's memory belongs to: the first module on its stack that is not Windows' or the +// C runtime's way to memory. Through a heap when Windows' heap asked (the heap grew for +// whoever was allocating when it ran out). +std::string owner_of(const Caller& caller) { + static const std::array passing{"ntdll.dll", "kernelbase.dll", "kernel32.dll", "ucrtbase.dll", + "vcruntime140.dll", "vcruntime140_1.dll", "msvcp140.dll"}; + bool heap{}; + for (unsigned i = 0; i < caller.frame_count; ++i) { + const auto module = module_at(caller.frames[i]); + const auto name = lower(module.name); + if (i == 0 && name == "ntdll.dll") heap = true; + if (name.empty() || std::ranges::find(passing, name) != passing.end()) continue; + return heap ? module.name + " (through a heap)" : module.name; + } + return heap ? "Windows (a heap)" : "Windows"; +} +// "read/write, write-combined": what a page protection allows, in words. +std::string protection(DWORD protect) { + std::string text; + const auto add = [&](std::string_view word) { text += (text.empty() ? "" : "+") + std::string(word); }; + constexpr std::pair kinds[] = {{PAGE_NOACCESS, "no access"}, {PAGE_READONLY, "read"}, + {PAGE_READWRITE, "read/write"}, {PAGE_WRITECOPY, "write-copy"}, {PAGE_EXECUTE, "execute"}, + {PAGE_EXECUTE_READ, "execute/read"}, {PAGE_EXECUTE_READWRITE, "execute/read/write"}, + {PAGE_EXECUTE_WRITECOPY, "execute/write-copy"}}; + for (const auto& [bit, word] : kinds) if (protect & bit) add(word); + if (protect & PAGE_GUARD) text += ", guard"; + if (protect & PAGE_NOCACHE) text += ", uncached"; + if (protect & PAGE_WRITECOMBINE) text += ", write-combined"; + return text.empty() ? std::format("{:#x}", protect) : text; +} + +std::string stack_of(const Caller& caller) { + std::string line; + for (unsigned i = 0; i < caller.frame_count; ++i) line += " < " + place(caller.frames[i]); + return line; +} + +constexpr std::string_view unseen = "not seen being made (before the ledger started, or by Windows itself: thread stacks)"; + +// How much of an allocation is in RAM, and of a few of those pages, how many hold only zeros. +struct Use { + std::uint64_t resident{}; + unsigned sampled{}, zero{}; +}; +Use use_of(const Allocation& allocation, std::vector& pages) { + Use use; + const auto total_pages = allocation.committed / 4096; + const std::uint64_t stride = std::max(1, total_pages / 8); + std::uint64_t page_number{}; + std::array bytes{}; + for (const auto& range : allocation.ranges) { + // (Pages that would be spoiled or slow to read are not read: a guard page would lose + // its guard, and write-combined or uncached memory is the GPU's.) + const bool readable = !(range.protect & (PAGE_GUARD | PAGE_NOACCESS | PAGE_WRITECOMBINE | PAGE_NOCACHE)); + for (std::uint64_t done = 0; done < range.bytes;) { + const auto count = static_cast(std::min(pages.size(), (range.bytes - done) / 4096)); + if (!count) break; + for (std::size_t i = 0; i < count; ++i) pages[i].VirtualAddress = reinterpret_cast(range.base + done + i * 4096); + if (!QueryWorkingSetEx(GetCurrentProcess(), pages.data(), static_cast(count * sizeof(pages[0])))) return use; + for (std::size_t i = 0; i < count; ++i, ++page_number) { + if (!pages[i].VirtualAttributes.Valid) continue; + use.resident += 4096; + if (!readable || page_number % stride || use.sampled >= 8) continue; + SIZE_T got{}; + if (!ReadProcessMemory(GetCurrentProcess(), pages[i].VirtualAddress, bytes.data(), bytes.size(), &got) || + got != bytes.size()) + continue; + ++use.sampled; + use.zero += std::all_of(bytes.begin(), bytes.end(), [](unsigned char byte) { return byte == 0; }); + } + done += count * 4096ull; + } + } + return use; +} + +struct Totals { + std::uint64_t committed{}, reserved{}, resident{}, zero{}, blocks{}; + std::uint32_t first_made{UINT32_MAX}, last_made{}; // seconds after the ledger started + std::uint64_t smallest{UINT64_MAX}, largest{}; + void add(const Allocation& allocation, const Use& use, std::uint32_t made, bool known) { + committed += allocation.committed; + reserved += allocation.reserved; + resident += use.resident; + // (An estimate: the share of the pages looked at that were all zero.) + if (use.sampled) zero += use.resident * use.zero / use.sampled; + ++blocks; + smallest = std::min(smallest, allocation.committed); + largest = std::max(largest, allocation.committed); + if (known) first_made = std::min(first_made, made), last_made = std::max(last_made, made); + } +}; +std::string totals_text(const Totals& t) { + return std::format("{} committed, {} in RAM, {} never used or moved out, ~{} in RAM but all zero; {} reserved in {} block(s)", + megabytes(t.committed), megabytes(t.resident), megabytes(t.committed - std::min(t.committed, t.resident)), megabytes(t.zero), + megabytes(t.reserved), t.blocks); +} + +std::uint32_t seconds_running() noexcept { + return static_cast((GetTickCount64() - began.load()) / 1000); +} + +// ---- the leak watcher ---- +std::mutex& watch_mutex = *new std::mutex; +std::string& watched_owners = *new std::string; +struct History { + std::array committed{}; + unsigned count{}; + std::uint64_t said_at{}; +}; +std::unordered_map& histories = *new std::unordered_map; // by caller hash; the watcher's own +std::unordered_map& owner_names = *new std::unordered_map; + +void rebuild_if_worn() { + { + std::lock_guard lock(mutex); + if (blocks_gone * 8 < block_slots) return; + } + std::vector fresh(block_slots); + { + std::lock_guard lock(mutex); + fresh.swap(blocks); + blocks_live = blocks_gone = 0; + for (const auto& block : fresh) + if (block.caller && block.caller != gone) remember(block.base, block.caller, block.made); + } +} + +void watch_once() { + rebuild_if_worn(); + const auto held = read_held(false); + const auto copy = copy_tables(); + std::unordered_map by_caller; + std::map by_owner; + std::uint64_t unseen_bytes{}; + for (const auto& [base, allocation] : held.private_memory) { + if (!allocation.committed) continue; + if (const auto* block = find(copy.blocks, base)) by_caller[block->caller] += allocation.committed; + else unseen_bytes += allocation.committed; + } + const auto now = GetTickCount64(); + for (const auto& [index, committed] : by_caller) { + const auto& caller = copy.callers[index - 1]; + auto& name = owner_names[caller.hash]; + if (name.empty()) name = owner_of(caller); + by_owner[name] += committed; + auto& history = histories[caller.hash]; + std::rotate(history.committed.begin(), history.committed.begin() + 1, history.committed.end()); + history.committed.back() = committed; + history.count = std::min(history.count + 1, static_cast(history.committed.size())); + // Growing: over the last ten minutes, by 64 MB or more, and larger at nearly every look. + constexpr unsigned span = 10; + if (history.count <= span || now - history.said_at < 600000) continue; + const auto* last = history.committed.data() + history.committed.size() - span - 1; + unsigned rises{}, falls{}; + for (unsigned i = 1; i <= span; ++i) rises += last[i] > last[i - 1], falls += last[i] < last[i - 1]; + if (last[span] < last[0] + (64ull << 20) || rises < span * 7 / 10 || falls > 1) continue; + history.said_at = now; + logging::log(logging::Level::warning, logging::Channel::runtime, + "Memory keeps growing: {} has {} committed, {} more than {} min ago; made by{}", name, megabytes(last[span]), + megabytes(last[span] - last[0]), span, stack_of(caller)); + } + // (The callers no longer holding anything are dropped from the history.) + std::unordered_set holding; + for (const auto& [index, committed] : by_caller) holding.insert(copy.callers[index - 1].hash); + std::erase_if(histories, [&](const auto& entry) { return !holding.contains(entry.first); }); + std::vector> order; + for (const auto& [name, committed] : by_owner) order.emplace_back(committed, name); + order.emplace_back(unseen_bytes, "not seen made"); + std::ranges::sort(order, std::greater{}); + std::string line = "Committed by owner:"; + for (std::size_t i = 0; i < order.size() && i < 5; ++i) + line += std::format("{} {} {}", i ? "," : "", order[i].second, megabytes(order[i].first)); + std::lock_guard lock(watch_mutex); + watched_owners = std::move(line); +} + +void watch() { + SetThreadDescription(GetCurrentThread(), L"ReSkate memory ledger"); + for (;;) { + Sleep(60000); + try { watch_once(); } catch (...) {} + } +} + +// ---- heaps ---- +struct SizeCount { + std::uint64_t size{}, count{}; +}; +// Block sizes go into storage made before a heap is locked: allocating from a heap while +// walking it would change what is walked. +struct SizeTable { + static constexpr std::size_t slots = std::size_t{1} << 16; + std::vector counts = std::vector(slots); + std::uint64_t other_count{}, other_bytes{}; + void add(std::uint64_t size) noexcept { + for (std::size_t probe = 0; probe < 64; ++probe) { + auto& slot = counts[((size * 0x9e3779b97f4a7c15ull >> 48) + probe) & (slots - 1)]; + if (slot.count && slot.size != size) continue; + slot.size = size; + ++slot.count; + return; + } + ++other_count; + other_bytes += size; + } +}; +struct HeapSeen { + HANDLE heap{}; + std::uint64_t committed{}, busy{}, free{}, largest_free{}, busy_blocks{}; + bool whole{}; +}; +// (A fault in the walk is caught: a heap made without its own lock may change under it.) +void walk_heap(HeapSeen& heap, SizeTable& sizes) noexcept { + if (!HeapLock(heap.heap)) return; + heap.whole = true; + __try { + PROCESS_HEAP_ENTRY entry{}; + while (HeapWalk(heap.heap, &entry)) { + if (entry.wFlags & PROCESS_HEAP_REGION) { + heap.committed += entry.Region.dwCommittedSize; + } else if (entry.wFlags & PROCESS_HEAP_ENTRY_BUSY) { + heap.busy += entry.cbData; + ++heap.busy_blocks; + sizes.add(entry.cbData); + } else if (!(entry.wFlags & PROCESS_HEAP_UNCOMMITTED_RANGE)) { + heap.free += entry.cbData; + heap.largest_free = std::max(heap.largest_free, entry.cbData); + } + } + } __except (EXCEPTION_EXECUTE_HANDLER) { + heap.whole = false; + } + HeapUnlock(heap.heap); +} +std::mutex heap_mutex; +std::map heap_mark; // block size -> count +bool heap_marked{}; +} // namespace + +bool start(std::string& error) { + std::lock_guard start_lock(start_mutex); + if (running.load()) return true; + if (!prepared) { + const auto allocate = ntdll_function("NtAllocateVirtualMemory"); + const auto release = ntdll_function("NtFreeVirtualMemory"); + if (!allocate || !release || hook_prepare(allocate, reinterpret_cast(&hooked), reinterpret_cast(&original)) != HookOk) { + error = "the allocation hook could not be prepared"; + return false; + } + if (hook_prepare(release, reinterpret_cast(&hooked_free), reinterpret_cast(&original_free)) != HookOk) { + hook_remove(allocate); + error = "the release hook could not be prepared"; + return false; + } + // (Missing on an older Windows: then only the first is followed.) + if (const auto ex = ntdll_function("NtAllocateVirtualMemoryEx"); + ex && hook_prepare(ex, reinterpret_cast(&hooked_ex), reinterpret_cast(&original_ex)) != HookOk) + original_ex = nullptr; + prepared = true; + } + began.store(GetTickCount64()); + running.store(true); + // (The release hook first: a block noted and given back before it is on would stay noted.) + if (hook_enable(ntdll_function("NtFreeVirtualMemory")) != HookOk || hook_enable(ntdll_function("NtAllocateVirtualMemory")) != HookOk) { + running.store(false); + hook_disable(ntdll_function("NtFreeVirtualMemory")); + error = "the allocation hooks could not be attached"; + return false; + } + if (original_ex) hook_enable(ntdll_function("NtAllocateVirtualMemoryEx")); + std::thread(watch).detach(); + logging::log(logging::Level::info, logging::Channel::runtime, + "Memory ledger on: who makes each reservation of memory is noted (`perf memory owners`), and an owner whose memory keeps growing is said here."); + return true; +} +bool on() noexcept { return running.load(); } + +bool start_asking(std::uint64_t smallest, std::uint64_t largest, std::string& error) { + if (!start(error)) return false; + { + std::lock_guard lock(mutex); + for (auto& caller : callers) caller.asked_count = caller.asked_bytes = caller.smallest = caller.largest = 0; + } + least.store(smallest); + most.store(largest); + counting.store(true); + return true; +} +void stop_asking() noexcept { counting.store(false); } +bool asking() noexcept { return counting.load(); } + +void asked_report(const Line& out) { + const auto copy = copy_tables(); + std::vector seen; + for (const auto& caller : copy.callers) if (caller.asked_count) seen.push_back(&caller); + std::ranges::sort(seen, [](const Caller* a, const Caller* b) { return a->asked_bytes > b->asked_bytes; }); + out(std::format("Memory asked for while tracing (blocks of {} and up), by who asked ({} callers), the most first:", + megabytes(least.load()), seen.size())); + for (std::size_t i = 0; i < seen.size() && i < 16; ++i) { + out(seen[i]->smallest == seen[i]->largest + ? std::format(" {} x {} bytes = {}", seen[i]->asked_count, seen[i]->smallest, megabytes(seen[i]->asked_bytes)) + : std::format(" {} blocks of {} to {} bytes = {}", seen[i]->asked_count, seen[i]->smallest, seen[i]->largest, + megabytes(seen[i]->asked_bytes))); + out(" " + stack_of(*seen[i])); + } +} + +void owners_report(const Line& out, std::size_t most_callers) { + if (std::string error; !start(error)) { out("error: " + error); return; } + const auto copy = copy_tables(); + const auto held = read_held(true); + std::vector pages(16384); + std::unordered_map by_caller; + std::map by_owner; + Totals all, unseen_totals; + struct Unseen { std::uintptr_t base; const Allocation* allocation; Use use; }; + std::vector unseen_blocks; + for (const auto& [base, allocation] : held.private_memory) { + if (!allocation.committed) continue; + const auto use = use_of(allocation, pages); + const auto* block = find(copy.blocks, base); + all.add(allocation, use, 0, false); + if (!block) { + unseen_totals.add(allocation, use, 0, false); + unseen_blocks.push_back({base, &allocation, use}); + continue; + } + by_caller[block->caller].add(allocation, use, block->made, true); + } + std::unordered_map names; + for (const auto& [index, totals] : by_caller) { + auto& name = names[index] = owner_of(copy.callers[index - 1]); + auto& owner = by_owner[name]; + owner.committed += totals.committed; + owner.reserved += totals.reserved; + owner.resident += totals.resident; + owner.zero += totals.zero; + owner.blocks += totals.blocks; + } + by_owner[std::string(unseen)] = unseen_totals; + + out(std::format("Memory: {} committed, {} of it in RAM; {} of files mapped in, {} of programs.", megabytes(held.committed), + megabytes(all.resident), megabytes(held.mapped), megabytes(held.image))); + out(std::format("Of what is committed: {} never used or moved out of RAM by Windows, and about {} in RAM but all zero.", + megabytes(all.committed - std::min(all.committed, all.resident)), megabytes(all.zero))); + gpu_report(out); + out(std::format("Ledger: on for {}, {} reservations held by {} callers{}.", age(seconds_running()), copy.live, + std::ranges::count_if(copy.callers, [](const Caller& c) { return c.hash != 0; }), + copy.untracked ? std::format(", {} not noted (its tables were full)", copy.untracked) : std::string())); + out("By owner, the most committed first:"); + std::vector> owners; + for (const auto& [name, totals] : by_owner) owners.emplace_back(name, &totals); + std::ranges::sort(owners, [](const auto& a, const auto& b) { return a.second->committed > b.second->committed; }); + for (const auto& [name, totals] : owners) + if (totals->committed) out(std::format(" {}: {}", name, totals_text(*totals))); + + // What the memory no hook saw being made is: memory Windows or a driver maps in from the + // kernel (a GPU's memory is write-combined or uncached) never passes NtAllocateVirtualMemory. + std::map unseen_by_kind; + for (const auto& block : unseen_blocks) + unseen_by_kind[std::format("committed {}; reserved {}", protection(block.allocation->committed_as), + protection(block.allocation->reserved_as))].add(*block.allocation, block.use, 0, false); + out("Not seen being made, by protection:"); + std::vector> kinds; + for (const auto& [kind, totals] : unseen_by_kind) kinds.emplace_back(kind, &totals); + std::ranges::sort(kinds, [](const auto& a, const auto& b) { return a.second->committed > b.second->committed; }); + for (const auto& [kind, totals] : kinds) out(std::format(" {}: {}", kind, totals_text(*totals))); + std::ranges::sort(unseen_blocks, [](const Unseen& a, const Unseen& b) { return a.allocation->committed > b.allocation->committed; }); + out("The largest of them:"); + for (std::size_t i = 0; i < unseen_blocks.size() && i < 16; ++i) { + const auto& block = unseen_blocks[i]; + out(std::format(" {:#x}: {} committed of {} reserved, {} in RAM{}; committed {}, reserved {}, in {} part(s)", block.base, + megabytes(block.allocation->committed), megabytes(block.allocation->reserved), megabytes(block.use.resident), + block.use.sampled ? std::format(" ({} of {} pages looked at all zero)", block.use.zero, block.use.sampled) : std::string(), + protection(block.allocation->committed_as), protection(block.allocation->reserved_as), block.allocation->ranges.size())); + } + + std::vector> order; + for (const auto& [index, totals] : by_caller) order.emplace_back(index, &totals); + std::ranges::sort(order, [](const auto& a, const auto& b) { return a.second->committed > b.second->committed; }); + out(std::format("By caller ({}), the most committed first:", order.size())); + for (std::size_t i = 0; i < order.size() && i < most_callers; ++i) { + const auto& totals = *order[i].second; + out(std::format(" {}: {}", names[order[i].first], totals_text(totals))); + out(std::format(" blocks of {} to {}; the newest made {} ago, the oldest {} ago", megabytes(totals.smallest), + megabytes(totals.largest), age(seconds_running() - std::min(seconds_running(), totals.last_made)), + age(seconds_running() - std::min(seconds_running(), totals.first_made)))); + out(" " + stack_of(copy.callers[order[i].first - 1])); + } +} + +void heap_report(const Line& out, bool mark, std::size_t most_sizes) { + std::lock_guard report_lock(heap_mutex); + struct Summary { DWORD size; SIZE_T allocated, committed, reserved, most; }; + static const auto heap_summary = reinterpret_cast( + GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "HeapSummary")); + std::array heaps{}; + const auto heap_count = std::min(GetProcessHeaps(static_cast(heaps.size()), heaps.data()), static_cast(heaps.size())); + SizeTable sizes; + std::vector seen(heap_count); + std::uint64_t total_committed{}, total_busy{}, total_free{}; + std::size_t partial{}; + for (DWORD i = 0; i < heap_count; ++i) { + auto& heap = seen[i]; + heap.heap = heaps[i]; + walk_heap(heap, sizes); + partial += !heap.whole; + // (A heap whose segments the walk does not show, such as one of the segment heap's: its + // summary says what it holds.) + if (Summary summary{sizeof(Summary)}; heap_summary && heap_summary(heaps[i], 0, &summary) && summary.committed > heap.committed) + heap.committed = summary.committed; + total_committed += heap.committed; + total_busy += heap.busy; + total_free += heap.free; + } + out(std::format("Heaps: {} committed in {} heap(s), {} of it in use, {} free inside them{}.", megabytes(total_committed), + heap_count, megabytes(total_busy), megabytes(total_free), + partial ? std::format(" ({} could not be walked whole)", partial) : std::string())); + std::ranges::sort(seen, [](const HeapSeen& a, const HeapSeen& b) { return a.committed > b.committed; }); + for (std::size_t i = 0; i < seen.size() && i < 8; ++i) { + if (!seen[i].committed) continue; + out(std::format(" heap {:#x}{}: {} committed, {} in use in {} blocks, {} free (the largest free piece {})", + reinterpret_cast(seen[i].heap), seen[i].heap == GetProcessHeap() ? " (the process heap)" : "", + megabytes(seen[i].committed), megabytes(seen[i].busy), seen[i].busy_blocks, megabytes(seen[i].free), + megabytes(seen[i].largest_free))); + } + std::map now; + for (const auto& slot : sizes.counts) if (slot.count) now[slot.size] = slot.count; + std::vector> order(now.begin(), now.end()); // size, count + std::ranges::sort(order, [](const auto& a, const auto& b) { return a.first * a.second > b.first * b.second; }); + out("Blocks in use by size, the sizes that add up to most first:"); + for (std::size_t i = 0; i < order.size() && i < most_sizes; ++i) + out(std::format(" {} x {} bytes = {}", order[i].second, order[i].first, megabytes(order[i].first * order[i].second))); + if (sizes.other_count) out(std::format(" (and {} blocks of other sizes, {})", sizes.other_count, megabytes(sizes.other_bytes))); + if (mark) { + heap_mark = std::move(now); + heap_marked = true; + out("Marked. Do what you want to measure, then run `perf memory heap` again."); + return; + } + if (!heap_marked) return; + // What was added since the mark, by size: a leak shows as a size whose count only climbs. + std::vector> added; // size, count added + for (const auto& [size, count] : now) { + const auto before = heap_mark.find(size); + const auto was = before == heap_mark.end() ? 0 : before->second; + if (count > was) added.emplace_back(size, static_cast(count - was)); + } + std::ranges::sort(added, [](const auto& a, const auto& b) { + return std::get<0>(a) * std::get<1>(a) > std::get<0>(b) * std::get<1>(b); + }); + out("Added since the mark, by size:"); + for (std::size_t i = 0; i < added.size() && i < most_sizes; ++i) + out(std::format(" +{} x {} bytes = {}", std::get<1>(added[i]), std::get<0>(added[i]), + megabytes(std::get<0>(added[i]) * static_cast(std::get<1>(added[i]))))); +} + +namespace { +struct Gpu { + std::string name; + std::uint64_t local{}, local_budget{}, shared{}, shared_budget{}; +}; +std::vector read_gpus() { + std::vector gpus; + IDXGIFactory4* factory{}; + if (FAILED(CreateDXGIFactory1(__uuidof(IDXGIFactory4), reinterpret_cast(&factory))) || !factory) return gpus; + IDXGIAdapter1* adapter{}; + for (UINT i = 0; factory->EnumAdapters1(i, &adapter) != DXGI_ERROR_NOT_FOUND; ++i) { + DXGI_ADAPTER_DESC1 description{}; + IDXGIAdapter3* adapter3{}; + if (SUCCEEDED(adapter->GetDesc1(&description)) && !(description.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) && + SUCCEEDED(adapter->QueryInterface(__uuidof(IDXGIAdapter3), reinterpret_cast(&adapter3)))) { + DXGI_QUERY_VIDEO_MEMORY_INFO local{}, shared{}; + adapter3->QueryVideoMemoryInfo(0, DXGI_MEMORY_SEGMENT_GROUP_LOCAL, &local); + adapter3->QueryVideoMemoryInfo(0, DXGI_MEMORY_SEGMENT_GROUP_NON_LOCAL, &shared); + // (Only an adapter this process uses.) + if (local.CurrentUsage || shared.CurrentUsage) { + Gpu gpu{{}, local.CurrentUsage, local.Budget, shared.CurrentUsage, shared.Budget}; + for (const wchar_t* c = description.Description; *c; ++c) gpu.name += static_cast(*c < 128 ? *c : '?'); + gpus.push_back(std::move(gpu)); + } + adapter3->Release(); + } + adapter->Release(); + } + factory->Release(); + return gpus; +} +} // namespace + +void gpu_report(const Line& out) { + for (const auto& gpu : read_gpus()) + out(std::format("GPU {}: {} of video memory (budget {}), {} of system memory (budget {}).", gpu.name, + megabytes(gpu.local), megabytes(gpu.local_budget), megabytes(gpu.shared), megabytes(gpu.shared_budget))); +} + +std::string owners_line() { + if (!running.load()) return {}; + std::lock_guard lock(watch_mutex); + return watched_owners; +} +std::string gpu_line() { + std::string line; + for (const auto& gpu : read_gpus()) + line += std::format("{}GPU: {} video memory, {} system memory", line.empty() ? "" : "; ", megabytes(gpu.local), megabytes(gpu.shared)); + return line; +} + +std::filesystem::path write_report() { + SYSTEMTIME now{}; + GetLocalTime(&now); + const auto path = logging::status().directory / std::format("memory-{:04}{:02}{:02}-{:02}{:02}{:02}.txt", now.wYear, now.wMonth, + now.wDay, now.wHour, now.wMinute, now.wSecond); + std::string text; + const Line add = [&](const std::string& line) { text += line + "\n"; }; + owners_report(add, 60); + add(""); + heap_report(add, false, 60); + if (asking()) { + add(""); + asked_report(add); + } + std::ofstream file(path, std::ios::binary); + file << text; + return file ? path : std::filesystem::path{}; +} +} diff --git a/Extension/Console/memory_ledger.h b/Extension/Console/memory_ledger.h new file mode 100644 index 0000000..174f407 --- /dev/null +++ b/Extension/Console/memory_ledger.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include +#include + +// Who owns the memory the process holds, and how much of it is used (`perf memory owners`). +// While it is on, every reservation of memory from Windows is noted with the code that asked +// for it, and forgotten when it is given back; a report then joins what the process holds now +// with who made it: what is committed, what of that is in RAM, and what is in RAM but all zero. +// A minute at a time it also looks for an owner whose memory only grows, and says so in the log. +// It is a research aid and off until asked for: every allocation in the process passes it. +namespace dingosdk::memory_ledger { +using Line = std::function; + +// Starts noting reservations. What was made before is reported as not seen being made. +bool start(std::string& error); +bool on() noexcept; +// `perf memory trace`: also counts what is asked for (commits too) of sizes in [least, most] +// by who asked, from now on. Starts the ledger if it is off. +bool start_asking(std::uint64_t least, std::uint64_t most, std::string& error); +void stop_asking() noexcept; +bool asking() noexcept; +void asked_report(const Line& out); + +// What the process holds by owner and by caller (the ledger is started if it is off). +// `most` callers are listed, each with its stack. +void owners_report(const Line& out, std::size_t most = 12); +// The process's heaps: what each holds and has free inside it, and its blocks by size; with +// `mark`, the sizes are remembered and the next report says what was added since. +void heap_report(const Line& out, bool mark, std::size_t most = 20); +// Video memory and the system memory the GPU uses for this process, one line per adapter. +void gpu_report(const Line& out); +// Everything above at length, into memory-