mirror of
https://github.com/1dot13/source.git
synced 2026-07-29 13:52:17 +02:00
Symbolize crash reports with a first-party tool
Adds tools/symbolize_crash.cpp, built alongside the game as a console exe: it reads a crash_report_*.txt written by writeExceptionBacktrace and prints the backtrace with function names, source lines and inlined frames, in call order, coloured when standard output is a console. Symbols come from DbgHelp against the build's PDB rather than an external symbolizer. SymLoadModuleEx takes the runtime base out of the report's module table, so the relocation arithmetic /DYNAMICBASE forces on us is DbgHelp's problem now; a report predating the module table loads at the image's preferred base, which is where it ran. Verified under Wine: PDB line info and inline traces both resolve. The tool wants C++23 (std::print, std::format) where the game is C++17, so the standard is set on the target alone. Both MSVC 19.51 and clang-cl build it clean in all three configurations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
majcosta
co-authored by
Claude Opus 5
parent
58e00e0d66
commit
3794e3a274
@@ -85,6 +85,9 @@ include(cmake/Warnings.cmake)
|
||||
# ja2export utility
|
||||
add_subdirectory("export/src")
|
||||
|
||||
# developer tools, built alongside the game
|
||||
add_subdirectory(tools)
|
||||
|
||||
# static libraries whose translation units don't rely on Application preprocessor definitions.
|
||||
add_subdirectory(lua)
|
||||
add_subdirectory(Multiplayer)
|
||||
|
||||
@@ -216,7 +216,7 @@ void StackTrace::PrintBacktrace(const char* msg) {
|
||||
// and take the report down with it. So this path is deliberately heap-free: only
|
||||
// stack buffers and raw Win32. It emits runtime addresses plus the module table
|
||||
// needed to make sense of them; symbolize offline against the build's PDB:
|
||||
// llvm-symbolizer --obj=JA2.exe --adjust-vma=$((<JA2.exe base> - 0x400000)) <addr>
|
||||
// symbolize_crash crash_report_001.txt JA2.exe
|
||||
namespace sgp {
|
||||
|
||||
// Stamped into every crash report so a report maps to the exact build's PDB.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Developer tools. Console programs, no game code and no Application preprocessor
|
||||
# definitions, so each is built once no matter which applications are configured.
|
||||
|
||||
# Reads a crash report written by sgp::writeExceptionBacktrace and resolves its
|
||||
# addresses against a build's PDB. Wants C++23 (std::print, std::format); the game
|
||||
# itself is C++17, hence the per-target standard.
|
||||
add_executable(symbolize_crash symbolize_crash.cpp)
|
||||
target_link_libraries(symbolize_crash PRIVATE "dbghelp.lib")
|
||||
set_target_properties(symbolize_crash PROPERTIES
|
||||
CXX_STANDARD 23
|
||||
CXX_STANDARD_REQUIRED ON
|
||||
)
|
||||
@@ -0,0 +1,306 @@
|
||||
// Symbolize a JA2 1.13 crash report: turn the runtime addresses the crash handler
|
||||
// dumped into function names and source lines, against the matching build.
|
||||
//
|
||||
// symbolize_crash <report.txt> [path\to\JA2.exe]
|
||||
//
|
||||
// The exe and the PDB beside it must be the exact build the report's `build <sha>`
|
||||
// line names, or the addresses resolve to the wrong lines, silently. The default
|
||||
// object is JA2.exe in the current directory.
|
||||
//
|
||||
// Frames print in call order: outermost caller first, descending to the fault.
|
||||
//
|
||||
// Addresses in a report are runtime VAs, and our executables are /DYNAMICBASE: the
|
||||
// loader may put the image anywhere -- Wine keeps it at the preferred base,
|
||||
// Windows ASLR does not. The report's module table records where it actually
|
||||
// landed, and that is the base we hand DbgHelp, so the relocation arithmetic is
|
||||
// its problem rather than ours. Reports predating the module table are assumed
|
||||
// unrelocated, which is what they were.
|
||||
|
||||
#include <windows.h>
|
||||
#include <dbghelp.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <format>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <optional>
|
||||
#include <print>
|
||||
#include <regex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
template <typename... Args>
|
||||
[[noreturn]] void fatal(std::format_string<Args...> format, Args&&... args) {
|
||||
std::print(stderr, "symbolize_crash: ");
|
||||
std::println(stderr, format, std::forward<Args>(args)...);
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
// --- colour ----------------------------------------------------------------
|
||||
|
||||
// Every field is empty when colour is off, so the format strings below never have
|
||||
// to know which mode they are in.
|
||||
struct Ink {
|
||||
std::string_view heading, label, code, function, location, address, fault,
|
||||
inlined, faded, off;
|
||||
};
|
||||
|
||||
constexpr Ink kPlain{};
|
||||
constexpr Ink kAnsi{
|
||||
.heading = "\x1b[1;91m", // bright red
|
||||
.label = "\x1b[90m", // grey
|
||||
.code = "\x1b[1;93m", // bright yellow
|
||||
.function = "\x1b[1;96m", // bright cyan
|
||||
.location = "\x1b[32m", // green
|
||||
.address = "\x1b[90m",
|
||||
.fault = "\x1b[1;91m",
|
||||
.inlined = "\x1b[35m", // magenta
|
||||
.faded = "\x1b[90m",
|
||||
.off = "\x1b[0m",
|
||||
};
|
||||
|
||||
Ink ink = kPlain;
|
||||
|
||||
// Colour only when standard output is a console we can put in ANSI mode: a report
|
||||
// piped to a file or a pager should not collect escape sequences.
|
||||
void enableColour() {
|
||||
if (std::getenv("NO_COLOR"))
|
||||
return;
|
||||
HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE);
|
||||
DWORD mode = 0;
|
||||
if (!GetConsoleMode(out, &mode))
|
||||
return;
|
||||
if (mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING ||
|
||||
SetConsoleMode(out, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING))
|
||||
ink = kAnsi;
|
||||
}
|
||||
|
||||
// --- the report ------------------------------------------------------------
|
||||
|
||||
struct Module {
|
||||
DWORD64 base = 0;
|
||||
DWORD64 size = 0;
|
||||
std::string path;
|
||||
};
|
||||
|
||||
struct Frame {
|
||||
int index = 0;
|
||||
DWORD64 address = 0;
|
||||
};
|
||||
|
||||
struct Report {
|
||||
std::string code = "????????";
|
||||
std::string accessViolation, time, build, handle;
|
||||
std::vector<Module> modules; // the loader lists the main image first
|
||||
std::vector<Frame> frames;
|
||||
};
|
||||
|
||||
std::string slurp(const char* path) {
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file)
|
||||
fatal("cannot read {}", path);
|
||||
return { std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>() };
|
||||
}
|
||||
|
||||
// Reports are CRLF and ECMAScript treats CR as a line terminator, so neither "."
|
||||
// nor "$" ever crosses one -- captures come back without the carriage return.
|
||||
std::string capture(const std::string& text, const char* pattern) {
|
||||
std::smatch match;
|
||||
std::regex expression(pattern, std::regex::ECMAScript | std::regex::multiline);
|
||||
return std::regex_search(text, match, expression) ? match[1].str() : std::string();
|
||||
}
|
||||
|
||||
std::vector<std::smatch> captureAll(const std::string& text, const char* pattern) {
|
||||
std::regex expression(pattern, std::regex::ECMAScript | std::regex::multiline);
|
||||
return { std::sregex_iterator(text.begin(), text.end(), expression),
|
||||
std::sregex_iterator() };
|
||||
}
|
||||
|
||||
DWORD64 fromHex(const std::string& digits) {
|
||||
return std::strtoull(digits.c_str(), nullptr, 16);
|
||||
}
|
||||
|
||||
Report parse(const std::string& text) {
|
||||
Report report;
|
||||
if (auto code = capture(text, R"(code=(\w+))"); !code.empty())
|
||||
report.code = code;
|
||||
report.accessViolation = capture(text, R"(access violation: (.+))");
|
||||
report.time = capture(text, R"(^\s+time (.+))");
|
||||
report.build = capture(text, R"(^\s+build (.+))");
|
||||
report.handle = capture(text, R"(^\s+handle (.+))");
|
||||
|
||||
for (const auto& match : captureAll(text, R"(^ {4}([0-9A-Fa-f]{8}) ([0-9A-Fa-f]{8}) (.+)$)"))
|
||||
report.modules.push_back({ fromHex(match[1]), fromHex(match[2]), match[3] });
|
||||
for (const auto& match : captureAll(text, R"(^\s+\[(\d+)\] ([0-9A-Fa-f]{8}))"))
|
||||
report.frames.push_back({ std::stoi(match[1]), fromHex(match[2]) });
|
||||
return report;
|
||||
}
|
||||
|
||||
// --- symbols ---------------------------------------------------------------
|
||||
|
||||
struct Location {
|
||||
std::string function = "<no symbol>";
|
||||
std::string file;
|
||||
DWORD line = 0;
|
||||
};
|
||||
|
||||
// DbgHelp writes the name into the tail of the structure, hence the raw buffer.
|
||||
struct SymbolBuffer {
|
||||
SYMBOL_INFO* get() {
|
||||
auto* symbol = reinterpret_cast<SYMBOL_INFO*>(storage);
|
||||
symbol->SizeOfStruct = sizeof(SYMBOL_INFO);
|
||||
symbol->MaxNameLen = MAX_SYM_NAME;
|
||||
return symbol;
|
||||
}
|
||||
alignas(SYMBOL_INFO) char storage[sizeof(SYMBOL_INFO) + MAX_SYM_NAME] = {};
|
||||
};
|
||||
|
||||
// Outermost first: the function that owns the address, then everything the
|
||||
// optimizer inlined into it, down to the code the address really belongs to.
|
||||
std::vector<Location> resolve(HANDLE process, DWORD64 address) {
|
||||
std::vector<Location> locations;
|
||||
|
||||
SymbolBuffer buffer;
|
||||
DWORD64 offset = 0;
|
||||
DWORD displacement = 0;
|
||||
IMAGEHLP_LINE64 line{ sizeof(IMAGEHLP_LINE64) };
|
||||
|
||||
Location outer;
|
||||
if (SymFromAddr(process, address, &offset, buffer.get()))
|
||||
outer.function = buffer.get()->Name;
|
||||
if (SymGetLineFromAddr64(process, address, &displacement, &line)) {
|
||||
outer.file = line.FileName;
|
||||
outer.line = line.LineNumber;
|
||||
}
|
||||
locations.push_back(std::move(outer));
|
||||
|
||||
// Inline contexts run innermost first, so walk them backwards to keep the
|
||||
// caller-to-callee order the rest of the listing uses.
|
||||
DWORD inlined = SymAddrIncludeInlineTrace(process, address);
|
||||
DWORD context = 0, frameIndex = 0;
|
||||
if (inlined == 0 || !SymQueryInlineTrace(process, address, 0, address, address,
|
||||
&context, &frameIndex))
|
||||
return locations;
|
||||
|
||||
for (DWORD i = inlined; i-- > 0;) {
|
||||
Location location;
|
||||
if (SymFromInlineContext(process, address, context + i, &offset, buffer.get()))
|
||||
location.function = buffer.get()->Name;
|
||||
line = { sizeof(IMAGEHLP_LINE64) };
|
||||
if (SymGetLineFromInlineContext(process, address, context + i, 0,
|
||||
&displacement, &line)) {
|
||||
location.file = line.FileName;
|
||||
location.line = line.LineNumber;
|
||||
}
|
||||
locations.push_back(std::move(location));
|
||||
}
|
||||
return locations;
|
||||
}
|
||||
|
||||
std::string_view baseName(std::string_view path) {
|
||||
size_t separator = path.find_last_of("\\/");
|
||||
return separator == std::string_view::npos ? path : path.substr(separator + 1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 2) {
|
||||
std::println(stderr, "usage: {} <report.txt> [JA2.exe]", baseName(argv[0]));
|
||||
return 2;
|
||||
}
|
||||
const char* objectPath = argc > 2 ? argv[2] : "JA2.exe";
|
||||
enableColour();
|
||||
|
||||
const Report report = parse(slurp(argv[1]));
|
||||
if (report.frames.empty())
|
||||
fatal("no [n] ADDR frames in {}", argv[1]);
|
||||
|
||||
// Only the main image can be symbolized against this exe's PDB. Sibling
|
||||
// modules are named with an offset, and addresses inside no module at all are
|
||||
// stack debris the frame-pointer walk picked up, so they are dropped.
|
||||
const Module* image = report.modules.empty() ? nullptr : &report.modules.front();
|
||||
const auto owner = [&](DWORD64 address) -> const Module* {
|
||||
auto found = std::ranges::find_if(report.modules, [&](const Module& module) {
|
||||
return module.base <= address && address < module.base + module.size;
|
||||
});
|
||||
return found == report.modules.end() ? nullptr : &*found;
|
||||
};
|
||||
|
||||
HANDLE process = GetCurrentProcess();
|
||||
SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME | SYMOPT_NO_PROMPTS);
|
||||
// The PDB sits next to the exe, which is not necessarily the current directory.
|
||||
const std::string_view object(objectPath);
|
||||
const std::string_view directory =
|
||||
object.substr(0, object.size() - baseName(object).size());
|
||||
const std::string searchPath(directory.empty() ? "." : directory);
|
||||
if (!SymInitialize(process, searchPath.c_str(), FALSE))
|
||||
fatal("SymInitialize failed ({})", GetLastError());
|
||||
|
||||
// A base of zero tells DbgHelp to use the image's own preferred base, which is
|
||||
// exactly right for a report from before the module table existed.
|
||||
const DWORD64 loadedAt = SymLoadModuleEx(process, nullptr, objectPath, nullptr,
|
||||
image ? image->base : 0, image ? static_cast<DWORD>(image->size) : 0, nullptr, 0);
|
||||
if (loadedAt == 0)
|
||||
fatal("cannot load {} ({})", objectPath, GetLastError());
|
||||
|
||||
IMAGEHLP_MODULE64 moduleInfo{ sizeof(IMAGEHLP_MODULE64) };
|
||||
if (SymGetModuleInfo64(process, loadedAt, &moduleInfo) && moduleInfo.SymType != SymPdb)
|
||||
std::println(" {}no PDB beside {} -- expect export names and no source lines{}",
|
||||
ink.fault, objectPath, ink.off);
|
||||
|
||||
std::println("\n {}CRASH {}{}{}{}{}", ink.heading, ink.code, report.code, ink.off,
|
||||
report.accessViolation.empty() ? "" : " ",
|
||||
report.accessViolation.empty() ? std::string()
|
||||
: std::format("{}({}){}", ink.faded, report.accessViolation, ink.off));
|
||||
for (auto [label, value] : { std::pair{ "time", &report.time },
|
||||
std::pair{ "build", &report.build }, std::pair{ "handle", &report.handle } })
|
||||
if (!value->empty())
|
||||
std::println(" {}{:<6}{} {}", ink.label, label, ink.off, *value);
|
||||
std::println(" {}{:<6}{} {:08X}", ink.label, "base", ink.off, loadedAt);
|
||||
std::println("");
|
||||
|
||||
// Call order: the outermost caller ([n]) first, the fault ([0]) last.
|
||||
auto frames = report.frames;
|
||||
std::ranges::sort(frames, std::ranges::greater{}, &Frame::index);
|
||||
|
||||
int depth = 0;
|
||||
for (const Frame& frame : frames) {
|
||||
const Module* module = owner(frame.address);
|
||||
if (!report.modules.empty() && module == nullptr)
|
||||
continue;
|
||||
const std::string indent(2 * depth++, ' ');
|
||||
const std::string tag = frame.index == 0
|
||||
? std::format(" {}<-- fault{}", ink.fault, ink.off) : "";
|
||||
const std::string address =
|
||||
std::format("{}[{:08X}]{}", ink.address, frame.address, ink.off);
|
||||
|
||||
if (module != nullptr && module != image) {
|
||||
std::println(" {}{}{}+0x{:X}{}{} {}", indent, ink.function,
|
||||
baseName(module->path), frame.address - module->base, ink.off, tag, address);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool first = true;
|
||||
for (const Location& location : resolve(process, frame.address)) {
|
||||
std::println(" {}{}{}{}{}{}", indent, first ? "" : " ",
|
||||
first ? ink.function : ink.inlined,
|
||||
first ? location.function : std::format("(inlined) {}", location.function),
|
||||
ink.off, first ? tag : "");
|
||||
if (!location.file.empty())
|
||||
std::println(" {}{} {}{}:{}{}{}", indent, first ? "" : " ",
|
||||
ink.location, location.file, location.line, ink.off,
|
||||
first ? std::format(" {}", address) : "");
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
std::println("");
|
||||
|
||||
SymCleanup(process);
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user