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-