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:
Marco Antonio J. Costa
2026-07-28 10:25:47 -03:00
committed by majcosta
co-authored by Claude Opus 5
parent 58e00e0d66
commit 3794e3a274
4 changed files with 322 additions and 1 deletions
+3
View File
@@ -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)
+1 -1
View File
@@ -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.
+12
View File
@@ -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
)
+306
View File
@@ -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;
}