From b263cbbb1bb834593fd95947ff4111aaa72f8cdc Mon Sep 17 00:00:00 2001 From: Niko Date: Wed, 14 Oct 2026 17:54:29 +0200 Subject: [PATCH] feat(libc): print the text that registers point at in Windows crash reports Unreal titles end in their own libc fatal path with the UTF-16 LowLevelFatalError message in a register, and the crash report shows only the first 64 bytes of it as hex words, so the message is lost (#4238: Demon Slayer PPSA03490, Stray PPSA02100). For every general register that points at readable memory starting with at least four printable ASCII characters, as UTF-16LE or as single bytes, the report now prints the text up to a NUL, the first other character, 512 characters or the end of readable memory (each page is checked with VirtualQuery before it is read). Co-Authored-By: Claude Opus 5.5 --- core/libs/CMakeLists.txt | 19 ++++++ core/libs/prx/libc/include/CrashText.hpp | 59 +++++++++++++++++++ .../src/specifics/windows/CrashReport.cpp | 10 ++++ core/libs/tests/CrashText.cpp | 56 ++++++++++++++++++ core/libs/tests/WindowsCrashText.cpp | 45 ++++++++++++++ docs/user/USAGE.md | 2 + 6 files changed, 191 insertions(+) create mode 100644 core/libs/prx/libc/include/CrashText.hpp create mode 100644 core/libs/tests/CrashText.cpp create mode 100644 core/libs/tests/WindowsCrashText.cpp diff --git a/core/libs/CMakeLists.txt b/core/libs/CMakeLists.txt index c9f54c0bf0..8de4d5141b 100644 --- a/core/libs/CMakeLists.txt +++ b/core/libs/CMakeLists.txt @@ -281,6 +281,25 @@ if(BUILD_TESTING) PASS_REGULAR_EXPRESSION "C\\+\\+ exception St9bad_alloc raised on thread [0-9]+ cannot be delivered: 0x[0-9a-f]+ undeliverable_host_exception_tests\\.exe\\+0x[0-9a-f]+ has no unwind data") endif() + add_test_executable(crash_text_tests tests/CrashText.cpp) + target_include_directories(crash_text_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + if(MINGW) + target_compile_options(crash_text_tests PRIVATE -fasynchronous-unwind-tables) + endif() + add_test(NAME crash_text COMMAND crash_text_tests) + + if(WIN32) + add_test_executable(windows_crash_text_tests tests/WindowsCrashText.cpp) + add_dependencies(windows_crash_text_tests libc) + if(MINGW) + target_compile_options(windows_crash_text_tests PRIVATE -fasynchronous-unwind-tables) + endif() + add_test(NAME windows_crash_text COMMAND windows_crash_text_tests $) + set_tests_properties(windows_crash_text PROPERTIES TIMEOUT 20 + PASS_REGULAR_EXPRESSION "faulting[^~]*FATAL: unhandled exception 0xc0000005[^~]* \\[r12\\] \"allocation failed in the heap\"[\r\n]+ \\[r13\\] u\"LowLevelFatalError \\[File:Unknown\\] \\[Line: 42\\] Out of memory\"[\r\n]+ \\[r14\\] \"PAGE END\"[.][.][.]" + FAIL_REGULAR_EXPRESSION "\\[rbx\\] u?\"|did not fault") + endif() + add_test_executable(guest_voice_port_attr_tests tests/GuestVoicePortAttr.cpp) target_include_directories(guest_voice_port_attr_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(guest_voice_port_attr_tests PRIVATE libSceVoice libc) diff --git a/core/libs/prx/libc/include/CrashText.hpp b/core/libs/prx/libc/include/CrashText.hpp new file mode 100644 index 0000000000..675cc7c096 --- /dev/null +++ b/core/libs/prx/libc/include/CrashText.hpp @@ -0,0 +1,59 @@ +#ifndef CORE_LIBS_PRX_LIBC_INCLUDE_CRASHTEXT_HPP +#define CORE_LIBS_PRX_LIBC_INCLUDE_CRASHTEXT_HPP + +#include +#include + +namespace crash_text { + +constexpr std::size_t MaximumCharacters = 512; +constexpr std::size_t MinimumCharacters = 4; +constexpr std::size_t MaximumBytes = 2 * (MaximumCharacters + 1); + +inline std::uint16_t Unit(const std::uint8_t* bytes, std::size_t index, std::size_t width) { + if (width == 1) return bytes[index]; + return static_cast(bytes[2 * index] | bytes[2 * index + 1] << 8); +} + +inline bool IsText(std::uint16_t unit) { + return (unit >= 0x20 && unit < 0x7f) || unit == '\t' || unit == '\n' || unit == '\r'; +} + +inline bool StartsWithText(const std::uint8_t* bytes, std::size_t size, std::size_t width) { + if (size < MinimumCharacters * width) return false; + for (std::size_t index = 0; index < MinimumCharacters; ++index) { + if (!IsText(Unit(bytes, index, width))) return false; + } + return true; +} + +inline bool Format(const std::uint8_t* bytes, std::size_t size, char* out, std::size_t capacity) { + const std::size_t width = StartsWithText(bytes, size, 2) ? 2 : StartsWithText(bytes, size, 1) ? 1 : 0; + if (width == 0 || capacity < 8) return false; + std::size_t length = 0; + if (width == 2) out[length++] = 'u'; + out[length++] = '"'; + bool terminated = false; + for (std::size_t index = 0; (index + 1) * width <= size; ++index) { + const std::uint16_t unit = Unit(bytes, index, width); + if (unit == 0) { + terminated = true; + break; + } + if (index == MaximumCharacters || !IsText(unit)) break; + const char escape = unit == '\n' ? 'n' : unit == '\r' ? 'r' : unit == '\t' ? 't' : unit == '"' || unit == '\\' ? static_cast(unit) : '\0'; + if (length + (escape != '\0' ? 2 : 1) + 5 > capacity) break; + if (escape != '\0') out[length++] = '\\'; + out[length++] = escape != '\0' ? escape : static_cast(unit); + } + out[length++] = '"'; + if (!terminated) { + for (int dot = 0; dot < 3; ++dot) out[length++] = '.'; + } + out[length] = '\0'; + return true; +} + +} + +#endif diff --git a/core/libs/prx/libc/src/specifics/windows/CrashReport.cpp b/core/libs/prx/libc/src/specifics/windows/CrashReport.cpp index 19691b7964..47cfaed9fc 100644 --- a/core/libs/prx/libc/src/specifics/windows/CrashReport.cpp +++ b/core/libs/prx/libc/src/specifics/windows/CrashReport.cpp @@ -5,6 +5,7 @@ #include "prx/libc/include/FailureReport.hpp" #include "prx/libc/include/GuestArena.hpp" #include "prx/libc/include/Shutdown.hpp" +#include "prx/libc/include/CrashText.hpp" #include #include #include @@ -355,6 +356,15 @@ LONG WINAPI ReportCrash(EXCEPTION_POINTERS* info) { } } } + { + const std::pair registers[] = {{"rax", context->Rax}, {"rbx", context->Rbx}, {"rcx", context->Rcx}, {"rdx", context->Rdx}, {"rsi", context->Rsi}, {"rdi", context->Rdi}, {"rbp", context->Rbp}, {"r8", context->R8}, {"r9", context->R9}, {"r10", context->R10}, {"r11", context->R11}, {"r12", context->R12}, {"r13", context->R13}, {"r14", context->R14}, {"r15", context->R15}}; + static std::uint8_t bytes[crash_text::MaximumBytes]; + static char text[960]; + for (const auto& [name, value] : registers) { + if (value < 0x10000) continue; + if (crash_text::Format(bytes, ReadCode(value, bytes, sizeof(bytes)), text, sizeof(text))) Report(" [%s] %s\n", name, text); + } + } Report(" frame pointer chain:\n"); std::uint64_t frame = context->Rbp; for (int depth = 0; depth < 32 && frame != 0 && (frame & 7) == 0 && IsReadable(frame) && IsReadable(frame + 8); ++depth) { diff --git a/core/libs/tests/CrashText.cpp b/core/libs/tests/CrashText.cpp new file mode 100644 index 0000000000..e2076b0c84 --- /dev/null +++ b/core/libs/tests/CrashText.cpp @@ -0,0 +1,56 @@ +#include "prx/libc/include/CrashText.hpp" +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; + +std::vector Wide(const std::u16string& text) { + std::vector bytes; + for (const char16_t unit : text) { + bytes.push_back(static_cast(unit & 0xff)); + bytes.push_back(static_cast(unit >> 8)); + } + return bytes; +} + +std::vector Narrow(const std::string& text) { + return std::vector(text.begin(), text.end()); +} + +void Expect(const char* name, const std::vector& bytes, const std::string& expected, std::size_t capacity = 960) { + std::vector out(capacity, '#'); + const bool formatted = crash_text::Format(bytes.data(), bytes.size(), out.data(), capacity); + const std::string actual = formatted ? std::string(out.data()) : std::string(""); + if (actual == expected && actual.size() < capacity) return; + std::fprintf(stderr, "%s: expected %s, got %s\n", name, expected.c_str(), actual.c_str()); + ++failures; +} + +} + +int main() { + using namespace std::string_literals; + Expect("terminated UTF-16", Wide(u"LowLevelFatalError [File:Unknown]\0tail"s), "u\"LowLevelFatalError [File:Unknown]\""); + Expect("terminated ASCII with escapes", Narrow("PS5 Out of Memory:\n\t\"a\\b\"\r\0tail"s), "\"PS5 Out of Memory:\\n\\t\\\"a\\\\b\\\"\\r\""); + Expect("UTF-16 up to the end of readable memory", Wide(u"Ran out of memory"), "u\"Ran out of memory\"..."); + Expect("ASCII up to the end of readable memory", Narrow("PAGE END"), "\"PAGE END\"..."); + Expect("UTF-16 before a non-ASCII unit", Wide(u"Memory\u00e9 more"), "u\"Memory\"..."); + Expect("short ASCII", Narrow("abc\0"s), ""); + Expect("short UTF-16", Wide(u"abc\0"s), ""); + Expect("three readable bytes", Narrow("abc"), ""); + Expect("pointer value", std::vector{0x08, 0xd4, 0x8e, 0x5b, 0xf6, 0x7f, 0x00, 0x00}, ""); + Expect("control byte first", Narrow("\x01text\0"s), ""); + Expect("character limit", Wide(std::u16string(600, u'A') + u'\0'), "u\"" + std::string(crash_text::MaximumCharacters, 'A') + "\"..."); + Expect("NUL right at the character limit", Wide(std::u16string(crash_text::MaximumCharacters, u'B') + u'\0'), "u\"" + std::string(crash_text::MaximumCharacters, 'B') + "\""); + Expect("output capacity", Narrow(std::string(100, 'x')), "\"" + std::string(10, 'x') + "\"...", 16); + Expect("escape at the output capacity", Narrow("abcdefghi\n" + std::string(20, 'z')), "\"abcdefghi\"...", 16); + Expect("too small output", Narrow("long enough text"), "", 7); + if (failures != 0) return 1; + std::puts("crash text: all cases passed"); + return 0; +} diff --git a/core/libs/tests/WindowsCrashText.cpp b/core/libs/tests/WindowsCrashText.cpp new file mode 100644 index 0000000000..677fbcb609 --- /dev/null +++ b/core/libs/tests/WindowsCrashText.cpp @@ -0,0 +1,45 @@ +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc != 2) { + std::fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + SetUnhandledExceptionFilter([](EXCEPTION_POINTERS*) -> LONG { + TerminateProcess(GetCurrentProcess(), 3); + return EXCEPTION_EXECUTE_HANDLER; + }); + if (LoadLibraryExA(argv[1], nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS) == nullptr) { + std::fprintf(stderr, "%s: LoadLibraryEx failed with error %lu\n", argv[1], GetLastError()); + return 1; + } + static const char narrow[] = "allocation failed in the heap"; + static const char16_t wide[] = u"LowLevelFatalError [File:Unknown] [Line: 42] Out of memory"; + static const std::uint8_t binary[] = {0x08, 0xd4, 0x8e, 0x5b, 0xf6, 0x7f, 0x00, 0x00}; + auto* pages = static_cast(VirtualAlloc(nullptr, 0x2000, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)); + DWORD previous = 0; + if (pages == nullptr || !VirtualProtect(pages + 0x1000, 0x1000, PAGE_NOACCESS, &previous)) { + std::fprintf(stderr, "cannot prepare the page edge: error %lu\n", GetLastError()); + return 1; + } + char* const edge = pages + 0x1000 - 8; + std::memcpy(edge, "PAGE END", 8); + std::fputs("faulting\n", stderr); + std::fflush(stderr); + const std::uintptr_t unmapped = 8; + asm volatile( + "movq %0, %%r12\n\t" + "movq %1, %%r13\n\t" + "movq %2, %%r14\n\t" + "movq %3, %%rbx\n\t" + "movq (%4), %%rax" + : + : "r"(narrow), "r"(wide), "r"(edge), "r"(binary), "r"(unmapped) + : "rax", "rbx", "r12", "r13", "r14", "memory"); + std::fputs("the read of address 8 did not fault\n", stderr); + return 1; +} diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index 73c49d52c6..63a1e937a3 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -206,6 +206,8 @@ Limits are listed in the [libc](../dev/debt/libc.md) and [relinker](../dev/debt/ ### Windows crash diagnostics +When a register points at readable memory that starts with at least four printable ASCII characters, either as UTF-16LE or as single bytes, the crash report prints that text after the register memory dump, as `[r13] u"..."` for UTF-16LE or `[rdi] "..."` for single bytes. The text ends at a NUL, at the first character that is not printable ASCII, a tab, a line feed or a carriage return, after 512 characters or at the end of readable memory. Tabs, line breaks, quotes and backslashes are escaped, and `...` after the closing quote means that no NUL ended the text within those limits. + Set `APS5_CRASH_INDIRECT_MEMORY=1` before launching the executable to include up to 64 bytes from each pointer found in the register memory dump. The report labels each block with its register, pointer offset and target address. Inaccessible, incomplete and executable targets are skipped. This additional dump is disabled when the variable is unset or empty. ### GPU submission rings and stall dumps