#include "buildsettings.h" #define _CRT_SECURE_NO_WARNINGS #include #include #include #include #include #include #include #include #include #include #ifndef defer struct defer_dummy { }; template struct deferrer { F f; ~deferrer() { f(); } }; template deferrer 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 #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); } }