You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

574 lines
21 KiB
C++

#include "buildsettings.h"
#define _CRT_SECURE_NO_WARNINGS
#include <Windows.h>
#include <winternl.h>
#include <DbgHelp.h>
#include <commctrl.h>
#include <winhttp.h>
#include <shellapi.h>
#include <shlwapi.h>
#include <stdio.h>
#include <time.h>
#include <stdint.h>
#ifndef defer
struct defer_dummy
{
};
template <class F>
struct deferrer
{
F f;
~deferrer() { f(); }
};
template <class F>
deferrer<F> operator*(defer_dummy, F f) { return {f}; }
#define DEFER_(LINE) zz_defer##LINE
#define DEFER(LINE) DEFER_(LINE)
#define defer auto DEFER(__LINE__) = defer_dummy{} *[&]()
#endif // defer
#include <Shlobj.h>
#pragma comment(lib, "Ole32.lib")
#pragma comment(lib, "Oleaut32.lib")
#pragma comment(lib, "Shell32.lib")
bool view_file_in_system_file_browser(const wchar_t *wfullpath)
{
bool res = false;
if (wfullpath)
{
{ // Good version of the functionality
const wchar_t *wpath = wfullpath;
if (!wcsncmp(wfullpath, L"\\\\?\\", 4))
{
wpath += 4;
}
if (wpath)
{
HRESULT hr = CoInitialize(nullptr);
if (hr == S_OK || hr == S_FALSE)
{
PIDLIST_ABSOLUTE pidl;
SFGAOF flags;
if (SHParseDisplayName(wpath, nullptr, &pidl, 0, &flags) == S_OK)
{
if (SHOpenFolderAndSelectItems(pidl, 0, nullptr, 0) == S_OK)
{
res = true;
}
CoTaskMemFree(pidl);
}
CoUninitialize();
}
}
}
if (!res)
{ // Worse Fallback if good version fails
WCHAR wcmd[65536];
wnsprintfW(wcmd, ARRAYSIZE(wcmd), L"explorer /select,\"%s\"", wfullpath);
if (_wsystem(wcmd) >= 0)
{
res = true;
}
}
}
return res;
}
#define DISCORD_WEBHOOK_ENDPOINT "/api/webhooks/922366591751053342/pR59Za5xL1HcvGSrxJ7hb1UCa85zfdtZyDet15I_CZgrY9RkAq73uAQ2Obo1Zi9QBvvX"
#define ErrBox(msg, flags) MessageBoxW(nullptr, L"" msg, L"Fatal Error", flags | MB_SYSTEMMODAL | MB_SETFOREGROUND)
#define fatal_init_error(s) ErrBox("The game had a fatal error and must close.\n\"" s "\"\nPlease report this to team@happenlance.com or the Happenlance Discord.", MB_OK | MB_ICONERROR)
extern "C" {
// TODO: Internationalization for error messages.
#pragma comment(lib, "DbgHelp.lib")
#pragma comment(lib, "ComCtl32.lib")
#pragma comment(lib, "WinHTTP.lib")
#pragma comment(lib, "Shlwapi.lib")
void do_crash_handler()
{
// Get the command line
int argc = 0;
wchar_t *cmd = GetCommandLineW();
if (!cmd || !cmd[0])
{
return; // Error: just run the app without a crash handler.
}
wchar_t **wargv = CommandLineToArgvW(cmd, &argc); // Passing nullptr here crashes!
if (!wargv || !wargv[0])
{
return; // Error: just run the app without a crash handler.
}
// Parse the command line for -no-crash-handler
bool crashHandler = true;
for (int i = 0; i < argc; ++i)
{
if (!wcscmp(wargv[i], L"-no-crash-handler"))
{
crashHandler = false;
}
}
if (!crashHandler)
{ // We already *are* the subprocess - continue with the main program!
return;
}
// Concatenate -no-crash-handler onto the command line for the subprocess
int cmdLen = 0;
while (cmd[cmdLen])
{ // could use wcslen() here, but Clang ASan's wcslen() can be bugged sometimes
cmdLen++;
}
const wchar_t *append = L" -no-crash-handler";
int appendLen = 0;
while (append[appendLen])
{
appendLen++;
}
wchar_t *cmdNew = (wchar_t *)calloc(cmdLen + appendLen + 1, sizeof(wchar_t)); // @Leak
if (!cmdNew)
{
return; // Error: just run the app without a crash handler.
}
memcpy(cmdNew, cmd, cmdLen * sizeof(wchar_t));
memcpy(cmdNew + cmdLen, append, appendLen * sizeof(wchar_t));
// Crash handler loop: run the program until it succeeds or the user chooses not to restart it
restart:;
// Parameters for starting the subprocess
STARTUPINFOW siw = {};
siw.cb = sizeof(siw);
siw.dwFlags = STARTF_USESTDHANDLES;
siw.hStdInput = GetStdHandle(STD_INPUT_HANDLE); // @Leak: CloseHandle()
siw.hStdOutput = GetStdHandle(STD_ERROR_HANDLE);
siw.hStdError = GetStdHandle(STD_OUTPUT_HANDLE);
PROCESS_INFORMATION pi = {}; // @Leak: CloseHandle()
// Launch suspended, then read-modify-write the PEB (see below), then resume -p 2022-03-04
if (!CreateProcessW(nullptr, cmdNew, nullptr, nullptr, true,
CREATE_SUSPENDED | DEBUG_ONLY_THIS_PROCESS, nullptr, nullptr, &siw, &pi))
{
// If we couldn't create a subprocess, then just run the program without a crash handler.
// That's not great, but it's presumably better than stopping the user from running at all!
return;
}
// NOTE: SteamAPI_Init() takes WAY longer On My Machine(tm) when a debugger is present.
// (The DLL file steam_api64.dll does indeed call IsDebuggerPresent() sometimes.)
// It's clear that Steam does extra niceness for us when debugging, but we DO NOT
// want this to destroy our load times; I measure 3.5x slowdown (0.6s -> 2.1s).
// The only way I know to trick the child process into thinking it is free of a
// debugger is to clear the BeingDebugged byte in the Process Environment Block.
// If we are unable to perform this advanced maneuver, we will gracefully step back
// and allow Steam to ruin our loading times. -p 2022-03-04
auto persuade_process_no_debugger_is_present = [](HANDLE hProcess)
{
// Load NTDLL
HMODULE ntdll = LoadLibraryA("ntdll.dll");
if (!ntdll)
return;
// Get NtQueryInformationProcess function
auto NtQueryInformationProcess =
(/*__kernel_entry*/ NTSTATUS(*)(HANDLE, PROCESSINFOCLASS, PVOID, ULONG, PULONG))
GetProcAddress(ntdll, "NtQueryInformationProcess");
if (!NtQueryInformationProcess)
return;
// Query process information to find the PEB address
PROCESS_BASIC_INFORMATION pbi = {};
DWORD queryBytesRead = 0;
if (NtQueryInformationProcess(hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), &queryBytesRead) != 0 || queryBytesRead != sizeof(pbi))
return;
// Read the PEB of the child process
PEB peb = {};
SIZE_T processBytesRead = NULL;
if (!ReadProcessMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), &processBytesRead) || processBytesRead != sizeof(peb))
return;
printf("Child process's peb.BeingDebugged is %d, setting to 0...\n", peb.BeingDebugged);
// Gaslight the child into believing we are not watching
peb.BeingDebugged = 0;
// Write back the modified PEB
SIZE_T processBytesWritten = NULL;
if (!WriteProcessMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), &processBytesWritten) || processBytesWritten != sizeof(peb))
return;
};
persuade_process_no_debugger_is_present(pi.hProcess);
// Helper function to destroy the subprocess
auto exit_child = [&]
{
TerminateProcess(pi.hProcess, 1); // Terminate before detaching, so you don't see Windows Error Reporting.
DebugActiveProcessStop(GetProcessId(pi.hProcess)); // Detach
WaitForSingleObject(pi.hProcess, 2000); // Wait for child to die, but not forever.
};
// Kick off the subprocess
if (ResumeThread(pi.hThread) != 1)
{
exit_child();
fatal_init_error("Could not start main game thread");
ExitProcess(1); // @Note: could potentially "return;" here instead if you wanted.
}
// Debugger loop: catch (and ignore) all debug events until the program exits or hits a last-chance exception
char filename[65536] = {0};
WCHAR wfilename[65536] = {0};
HANDLE file = nullptr;
for (;;)
{
// Get debug event
DEBUG_EVENT de = {};
if (!WaitForDebugEvent(&de, INFINITE))
{
exit_child();
fatal_init_error("Waiting for debug event failed");
ExitProcess(1);
}
// If the process exited, nag about failure, or silently exit on success
if (de.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT && de.dwProcessId == pi.dwProcessId)
{
// If the process exited unsuccessfully, prompt to restart it
// @Todo: in these cases, no dump can be made, so upload just the stdout log and profiling trace
if (de.u.ExitThread.dwExitCode != 0)
{
// Terminate & detach just to be safe
exit_child();
// Prompt to restart
MessageBeep(MB_ICONINFORMATION); // MB_ICONQUESTION makes no sound
if (MessageBoxW(nullptr,
L"The game had a fatal error and must close.\n"
"Unfortunately, a crash report could not be generated. Sorry!\n"
"Please report this to team@happenlance.com or the Happenlance Discord.\n"
"Restart the game?\n",
L"Fatal Error",
MB_YESNO | MB_ICONQUESTION | MB_SYSTEMMODAL | MB_SETFOREGROUND) == IDYES)
{
goto restart;
}
}
// Bubble up the failure code - this is where successful program runs will end up!
ExitProcess(de.u.ExitThread.dwExitCode);
}
// If the process had some other debug stuff, we don't care.
if (de.dwDebugEventCode != EXCEPTION_DEBUG_EVENT)
{
ContinueDebugEvent(de.dwProcessId, de.dwThreadId, DBG_CONTINUE);
continue;
}
// Skip first-chance exceptions or exceptions for processes we don't care about (shouldn't ever happen).
if (de.u.Exception.dwFirstChance || de.dwProcessId != GetProcessId(pi.hProcess))
{
ContinueDebugEvent(de.dwProcessId, de.dwThreadId, DBG_EXCEPTION_NOT_HANDLED);
continue;
}
// By here, we have hit a real, last-chance exception. This is a crash we should generate a dump for.
#define crash_report_failure(str) \
ErrBox( \
"The game had a fatal error and must close.\n" \
"A crash report could not be produced:\n\"" str "\"\n" \
"Please report this to team@happenlance.com or the Happenlance Discord.", \
MB_OK | MB_ICONERROR);
// Create crash dump filename
snprintf(filename, sizeof(filename), "./CrashDump_%d.dmp", (int)time(NULL));
// Convert filename to UTF-16
MultiByteToWideChar(CP_UTF8, 0, filename, -1, wfilename, ARRAYSIZE(wfilename));
// Create crash dump file
file = CreateFileW(wfilename, GENERIC_WRITE | GENERIC_READ, 0, nullptr,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (file == INVALID_HANDLE_VALUE)
{
exit_child();
crash_report_failure("The crash dump file could not be created. Sorry!");
ExitProcess(1);
}
// Generate exception pointers out of excepting thread context
CONTEXT c = {};
if (HANDLE thread = OpenThread(THREAD_ALL_ACCESS, true, de.dwThreadId))
{
c.ContextFlags = CONTEXT_ALL;
GetThreadContext(thread, &c);
CloseHandle(thread);
}
EXCEPTION_POINTERS ep = {};
ep.ExceptionRecord = &de.u.Exception.ExceptionRecord;
ep.ContextRecord = &c;
MINIDUMP_EXCEPTION_INFORMATION mei = {};
mei.ThreadId = de.dwThreadId;
mei.ExceptionPointers = &ep;
mei.ClientPointers = false;
// You could add some others here, but these should be good.
int flags = MiniDumpNormal | MiniDumpWithHandleData | MiniDumpScanMemory | MiniDumpWithUnloadedModules | MiniDumpWithProcessThreadData | MiniDumpWithThreadInfo | MiniDumpIgnoreInaccessibleMemory;
// Write minidump
if (!MiniDumpWriteDump(pi.hProcess, GetProcessId(pi.hProcess), file,
(MINIDUMP_TYPE)flags, &mei, nullptr, nullptr))
{
exit_child();
crash_report_failure("The crash dump could not be written. Sorry!");
ExitProcess(1);
}
// @Todo: ZIP compress the crash dump files here, with graceful fallback to uncompressed dumps.
// Cleanup: Destroy subprocess now that we have a dump.
// Note that we want to do this before doing any blocking interface dialogs,
// because otherwise you would leave an arbitrarily broken program lying around
// longer than you need to.
exit_child();
break;
}
// Prompt to upload crash dump
int res = 0;
bool uploaded = false;
if (!(res = ErrBox(
"The game had a fatal error and must close.\n"
"Send anonymous crash report?\n"
"This will go directly to the developers on Discord,\n"
"and help fix the problem.",
MB_YESNO | MB_ICONERROR)))
ExitProcess(1);
// Upload crash dump
if (res == IDYES)
{
// Setup window class for progress window
WNDCLASSEXW wcex = {sizeof(wcex)};
wcex.style = CS_HREDRAW | CS_VREDRAW;
wcex.lpszClassName = L"bar";
wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW);
wcex.hCursor = LoadCursor(GetModuleHandleA(nullptr), IDC_ARROW);
wcex.lpfnWndProc = [](HWND h, UINT m, WPARAM w, LPARAM l) -> LRESULT
{
return m == WM_QUIT || m == WM_CLOSE || m == WM_DESTROY ? 0 : DefWindowProcW(h, m, w, l);
};
wcex.hInstance = GetModuleHandleA(nullptr);
if (!RegisterClassExW(&wcex))
{
ExitProcess(1);
}
HWND hWnd = nullptr;
HWND ctrl = nullptr;
// Initialize common controls for progress bar
INITCOMMONCONTROLSEX iccex = {sizeof(iccex)};
iccex.dwICC = ICC_PROGRESS_CLASS;
if (InitCommonControlsEx(&iccex))
{
// Create progress window and progress bar child-window
hWnd = CreateWindowExW(0, wcex.lpszClassName, L"Uploading...",
WS_SYSMENU | WS_CAPTION | WS_VISIBLE, CW_USEDEFAULT, SW_SHOW,
320, 80, nullptr, nullptr, GetModuleHandleA(nullptr), nullptr);
ctrl = CreateWindowExW(0, PROGRESS_CLASSW, L"",
WS_CHILD | WS_VISIBLE | PBS_SMOOTH, 10, 10,
280, 20, hWnd, (HMENU)12345, GetModuleHandleA(nullptr), nullptr);
}
else
{
ExitProcess(1);
}
// Infinite loop: Attempt to upload the crash dump until the user cancels or it succeeds
do
{
// Position the progress window to the centre of the screen
RECT r;
GetWindowRect(hWnd, &r);
int ww = r.right - r.left, wh = r.bottom - r.top;
int sw = GetSystemMetrics(SM_CXSCREEN), sh = GetSystemMetrics(SM_CYSCREEN);
SetWindowPos(hWnd, HWND_TOP, (sw - ww) / 2, (sh - wh) / 2, 0, 0, SWP_NOSIZE);
// Helper function to set the loading bar to a certain position.
auto update_loading_bar = [&](float amt)
{
if (hWnd && ctrl)
{
SendMessageW(ctrl, PBM_SETPOS, (WPARAM)(amt * 100), 0);
ShowWindow(hWnd, SW_SHOW);
UpdateWindow(hWnd);
MSG msg = {};
while (PeekMessageW(&msg, nullptr, 0, 0, 1) > 0)
{
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
};
auto try_upload = [&]() -> bool
{
float x = 0;
update_loading_bar(x);
// Build MIME multipart-form payload
static char body[1 << 23]; // ouch that's a big static buffer!!!
const char *bodyPrefix =
"--19024605111143684786787635207\r\n"
"Content-Disposition: form-data; name=\"payload_json\"\r\n\r\n{\"content\":\""
"Astris v"
#define STRINGIZE_(N) #N
#define STRINGIZE(N) STRINGIZE_(N)
STRINGIZE(GIT_RELEASE_TAG) " Crash Report"
"\"}\r\n--19024605111143684786787635207\r\n"
"Content-Disposition: form-data; name=\"files[0]\"; filename=\"";
const char *bodyInfix = "\"\r\n"
"Content-Type: application/octet-stream\r\n"
"\r\n";
const char *bodyPostfix = "\r\n--19024605111143684786787635207--\r\n";
// Printf the prefix, filename, infix
int headerLen = snprintf(body, sizeof(body), "%s%s%s", bodyPrefix, filename, bodyInfix);
if (headerLen != strlen(bodyPrefix) + strlen(filename) + strlen(bodyInfix))
return false;
update_loading_bar(x += 0.1f);
// Get crash dump file size
LARGE_INTEGER fileSizeInt = {};
GetFileSizeEx(file, &fileSizeInt);
uint64_t fileSize = fileSizeInt.QuadPart;
if (fileSize >= 8000000)
return false; // discord limit
int bodyLen = headerLen + (int)fileSize + (int)(sizeof(bodyPostfix)-1);
if (bodyLen >= sizeof(body))
return false; // buffer overflow
update_loading_bar(x += 0.1f);
// Seek file to start
if (SetFilePointer(file, 0, nullptr, FILE_BEGIN) != 0)
return false;
// Copy entire file into the space after the body infix
DWORD bytesRead = 0;
if (!ReadFile(file, body + headerLen, (DWORD)fileSize, &bytesRead, nullptr))
return false;
if (bytesRead != fileSize)
return false;
update_loading_bar(x += 0.1f);
// Print the body postfix after the data file (overflow already checked)
strncpy(body + headerLen + fileSize, bodyPostfix, sizeof(body) - headerLen - fileSize);
update_loading_bar(x += 0.1f);
// Windows HTTPS stuff from here on out...
HINTERNET hSession = WinHttpOpen(L"Discord Crashdump Webhook",
WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME,
WINHTTP_NO_PROXY_BYPASS, 0);
if (!hSession)
return false;
defer { WinHttpCloseHandle(hSession); };
update_loading_bar(x += 0.1f);
// Connect to domain
HINTERNET hConnect = WinHttpConnect(hSession, L"discord.com", INTERNET_DEFAULT_HTTPS_PORT, 0);
if (!hConnect)
return false;
defer { WinHttpCloseHandle(hConnect); };
update_loading_bar(x += 0.1f);
// Begin POST request to the discord webhook endpoint
HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"POST",
L"" DISCORD_WEBHOOK_ENDPOINT,
nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE);
if (!hRequest)
return false;
defer { WinHttpCloseHandle(hRequest); };
update_loading_bar(x += 0.1f);
// Send request once - don't handle auth challenge, credentials, reauth, redirects
const wchar_t ContentType[] = L"Content-Type: multipart/form-data; boundary=19024605111143684786787635207";
if (!WinHttpSendRequest(hRequest, ContentType, ARRAYSIZE(ContentType),
body, bodyLen, bodyLen, 0))
return false;
update_loading_bar(x += 0.1f);
// Wait for response
if (!WinHttpReceiveResponse(hRequest, nullptr))
return false;
update_loading_bar(x += 0.1f);
// Pull headers from response
DWORD dwStatusCode, dwSize = sizeof(dwStatusCode);
if (!WinHttpQueryHeaders(hRequest,
WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
nullptr, &dwStatusCode, &dwSize, nullptr))
return false;
if (dwStatusCode != 200)
return false;
update_loading_bar(x += 0.1f);
return true;
};
res = 0;
uploaded = try_upload();
if (!uploaded)
{
if (!(res = MessageBoxW(hWnd, L"Sending failed. Retry?", L"Fatal Error", MB_RETRYCANCEL | MB_ICONWARNING | MB_SYSTEMMODAL)))
ExitProcess(1);
}
} while (res == IDRETRY);
// Cleanup
if (hWnd)
{
DestroyWindow(hWnd);
}
UnregisterClassW(wcex.lpszClassName, GetModuleHandleA(nullptr));
}
// Cleanup
CloseHandle(file);
// Prompt to restart
MessageBeep(MB_ICONINFORMATION); // MB_ICONQUESTION makes no sound
if (MessageBoxW(nullptr, uploaded ? L"Thank you for sending the crash report.\n"
"You can send more info to the #bugs channel\n"
"in the Astris Discord.\n"
"Restart the game?\n"
: view_file_in_system_file_browser(wfilename) ? L"The crash report folder has been opened.\n"
"You can send the report to the #bugs channel\n"
"in the Astris Discord.\n"
"Restart the game?\n"
: L"The crash report can be found in the program installation directory.\n"
"You can send the report to the #bugs channel\n"
"in the Astris Discord.\n"
"Restart the game?\n",
L"Fatal Error",
MB_YESNO | MB_ICONQUESTION | MB_SYSTEMMODAL | MB_SETFOREGROUND) == IDYES)
{
goto restart;
}
// Return 1 because the game crashed, not because the crash report failed
ExitProcess(1);
}
}