#include "startup_window.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace dingosdk::startup { namespace { using Clock = std::chrono::steady_clock; using Microsoft::WRL::ComPtr; using namespace std::chrono_literals; constexpr auto fade_in = 150ms; constexpr auto fade_out = 250ms; // How often the window looks for the game's own. constexpr auto window_check = 200ms; // A startup that never shows a window still takes this one down. constexpr auto longest_open = 180s; // The overlay's palette (ui/overlay/skate_style.h) as GDI colours. constexpr COLORREF ink = RGB(19, 20, 22); constexpr COLORREF paper = RGB(241, 240, 232); constexpr COLORREF blue = RGB(0, 145, 255); constexpr COLORREF shade = RGB(50, 52, 56); constexpr COLORREF soft = RGB(205, 206, 208); constexpr COLORREF track = RGB(30, 31, 34); // In 96-dpi pixels: the size of the game's own launch picture. A picture of any // other shape is scaled to cover it and cropped evenly, never stretched. constexpr int window_width = 640; constexpr int window_height = 380; constexpr int text_left = 16; constexpr int bar_height = 4; constexpr int shadow_height = 120; constexpr wchar_t class_name[] = L"ReSkateStartupWindow"; constexpr const wchar_t* art_files[] = {L"ReSkate.Splash.png", L"ReSkate.Splash.jpg"}; constexpr wchar_t art_resource[] = L"STARTUP_SPLASH"; struct Shared { std::mutex mutex; std::wstring heading{L"Starting ReSkate\x2026"}, detail; float progress{-1.0f}; bool opened{}, closing{}; std::atomic shown{}; }; Shared& shared() { // Kept for the life of the process: the window thread can outlive static teardown. static auto* value = new Shared; return *value; } std::wstring widen(std::string_view text) { if (text.empty()) return {}; const auto size = static_cast(text.size()); const auto length = MultiByteToWideChar(CP_UTF8, 0, text.data(), size, nullptr, 0); if (length <= 0) return {}; std::wstring wide(static_cast(length), L'\0'); MultiByteToWideChar(CP_UTF8, 0, text.data(), size, wide.data(), length); return wide; } void fill(HDC dc, RECT area, COLORREF colour) { SetDCBrushColor(dc, colour); FillRect(dc, &area, static_cast(GetStockObject(DC_BRUSH))); } HMODULE this_module() { static const char anchor{}; HMODULE module{}; GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast(&anchor), &module); return module; } std::filesystem::path module_path(HMODULE module) { std::wstring name(32768, L'\0'); const auto length = GetModuleFileNameW(module, name.data(), static_cast(name.size())); if (!length || length >= name.size()) return {}; name.resize(length); return name; } // Windows 10 1607 and later; resolved so an older system just gets an unscaled window. template Function user32(const char* name) { const auto module = GetModuleHandleW(L"user32.dll"); #pragma warning(push) #pragma warning(disable: 4191) return module ? reinterpret_cast(GetProcAddress(module, name)) : nullptr; #pragma warning(pop) } HBITMAP make_dib(int width, int height, void** bits) { BITMAPINFO info{}; info.bmiHeader.biSize = sizeof(info.bmiHeader); info.bmiHeader.biWidth = width; info.bmiHeader.biHeight = -height; // top-down info.bmiHeader.biPlanes = 1; info.bmiHeader.biBitCount = 32; info.bmiHeader.biCompression = BI_RGB; return CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, bits, nullptr, 0); } // The game shows a window of its own once it starts rendering; that is this // one's cue to go. struct Search { HWND own; bool found; }; BOOL CALLBACK find_game_window(HWND window, LPARAM parameter) { auto& search = *reinterpret_cast(parameter); DWORD process{}; GetWindowThreadProcessId(window, &process); if (process != GetCurrentProcessId() || window == search.own || !IsWindowVisible(window)) return TRUE; if (GetWindowLongPtrW(window, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) return TRUE; RECT bounds{}; if (!GetWindowRect(window, &bounds) || bounds.right - bounds.left < 200 || bounds.bottom - bounds.top < 150) return TRUE; search.found = true; return FALSE; } bool game_window_open(HWND own) { Search search{own, false}; EnumWindows(find_game_window, reinterpret_cast(&search)); return search.found; } ComPtr imaging() { ComPtr factory; CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory)); return factory; } // The splash picture (PNG or JPEG), as premultiplied BGRA: ReSkate.Splash.png or // .jpg beside the DLL, else the copy built into it. Null when there is neither. ComPtr open_art() { const auto factory = imaging(); if (!factory) return nullptr; ComPtr decoder; const auto directory = module_path(this_module()).parent_path(); for (const auto* name : art_files) { if (SUCCEEDED(factory->CreateDecoderFromFilename((directory / name).c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnLoad, &decoder))) break; decoder.Reset(); } if (!decoder) { const auto module = this_module(); const auto found = FindResourceW(module, art_resource, MAKEINTRESOURCEW(10) /* RT_RCDATA */); const auto loaded = found ? LoadResource(module, found) : nullptr; auto* bytes = loaded ? static_cast(LockResource(loaded)) : nullptr; const auto size = found ? SizeofResource(module, found) : 0; ComPtr stream; if (!bytes || !size || FAILED(factory->CreateStream(&stream)) || FAILED(stream->InitializeFromMemory(bytes, size)) || FAILED(factory->CreateDecoderFromStream(stream.Get(), nullptr, WICDecodeMetadataCacheOnLoad, &decoder))) return nullptr; } ComPtr frame; ComPtr converter; UINT source_width{}, source_height{}; if (FAILED(decoder->GetFrame(0, &frame)) || FAILED(factory->CreateFormatConverter(&converter)) || FAILED(converter->Initialize(frame.Get(), GUID_WICPixelFormat32bppPBGRA, WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)) || FAILED(converter->GetSize(&source_width, &source_height)) || !source_width || !source_height) return nullptr; return converter; } // `art` cover-fitted to width x height, as a top-down DIB. HBITMAP scale_art(IWICBitmapSource* art, int width, int height) { const auto factory = imaging(); UINT source_width{}, source_height{}; if (!factory || FAILED(art->GetSize(&source_width, &source_height)) || !source_width || !source_height) return nullptr; const double scale = std::max(static_cast(width) / source_width, static_cast(height) / source_height); const auto scaled_width = std::max(static_cast(width), static_cast(std::ceil(source_width * scale))); const auto scaled_height = std::max(static_cast(height), static_cast(std::ceil(source_height * scale))); ComPtr scaler; if (FAILED(factory->CreateBitmapScaler(&scaler))) return nullptr; if (FAILED(scaler->Initialize(art, scaled_width, scaled_height, WICBitmapInterpolationModeHighQualityCubic)) && FAILED(scaler->Initialize(art, scaled_width, scaled_height, WICBitmapInterpolationModeFant))) return nullptr; void* bits{}; const auto bitmap = make_dib(width, height, &bits); if (!bitmap) return nullptr; const WICRect crop{static_cast(scaled_width - static_cast(width)) / 2, static_cast(scaled_height - static_cast(height)) / 2, width, height}; if (FAILED(scaler->CopyPixels(&crop, static_cast(width) * 4, static_cast(width) * static_cast(height) * 4, static_cast(bits)))) { DeleteObject(bitmap); return nullptr; } return bitmap; } struct Window { HWND window{}; UINT dpi{96}; int width{}, height{}; HFONT title{}, heading{}, body{}; HBITMAP background{}, shadow{}; HICON large_icon{}, small_icon{}; ComPtr art; Clock::time_point opened_at{}, leave_at{}, checked_at{}; bool leaving{}; int px(int value) const { return MulDiv(value, static_cast(dpi), 96); } void layout() { width = px(window_width); height = px(window_height); for (auto* font : {&title, &heading, &body}) if (*font) { DeleteObject(*font); *font = nullptr; } const auto font = [&](int size, int weight, bool italic) { return CreateFontW(-px(size), 0, 0, 0, weight, italic, FALSE, FALSE, DEFAULT_CHARSET, OUT_TT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_SWISS, L"Arial"); }; title = font(84, FW_BOLD, true); heading = font(16, FW_BOLD, false); body = font(12, FW_NORMAL, false); make_background(); make_shadow(); } // The picture, or ReSkate's own card when there is none. void make_background() { if (background) DeleteObject(background); void* bits{}; background = make_dib(width, height, &bits); if (!background) return; const auto dc = CreateCompatibleDC(nullptr); const auto previous = SelectObject(dc, background); fill(dc, {0, 0, width, height}, ink); if (const auto picture = art ? scale_art(art.Get(), width, height) : nullptr) { const auto source = CreateCompatibleDC(nullptr); const auto kept = SelectObject(source, picture); const BLENDFUNCTION blend{AC_SRC_OVER, 0, 255, AC_SRC_ALPHA}; AlphaBlend(dc, 0, 0, width, height, source, 0, 0, width, height, blend); SelectObject(source, kept); DeleteDC(source); DeleteObject(picture); } else { draw_card(dc); } SelectObject(dc, previous); DeleteDC(dc); } void draw_card(HDC dc) const { // Halftone washing in from two corners, as on the game's own picture. SelectObject(dc, GetStockObject(NULL_PEN)); SelectObject(dc, GetStockObject(DC_BRUSH)); SetDCBrushColor(dc, shade); const int step = px(8); const float reach = static_cast(px(260)); for (int y = 0; y < height; y += step) { for (int x = (y / step % 2) * step / 2; x < width; x += step) { const float corner = std::min(std::hypot(static_cast(width - x), static_cast(y)), std::hypot(static_cast(x), static_cast(height - y))); const float radius = (1.0f - corner / reach) * static_cast(px(3)); if (radius < 0.6f) continue; const int r = std::max(1, static_cast(std::lround(radius))); Ellipse(dc, x - r, y - r, x + r + 1, y + r + 1); } } // The wordmark and the blue torn rule under it. SelectObject(dc, title); SetBkMode(dc, TRANSPARENT); SetTextColor(dc, paper); constexpr wchar_t wordmark[] = L"RESKATE"; SIZE extent{}; GetTextExtentPoint32W(dc, wordmark, static_cast(std::size(wordmark) - 1), &extent); const int left = (width - extent.cx) / 2, top = height * 2 / 5 - extent.cy / 2; TextOutW(dc, left, top, wordmark, static_cast(std::size(wordmark) - 1)); const float rule = static_cast(extent.cx) * 0.55f; const float x0 = static_cast(left) + static_cast(extent.cx) * 0.06f; const float y0 = static_cast(top + extent.cy + px(2)); const float unit = static_cast(px(2)); // A torn-paper rule: fractions along the rule, and heights. const std::array, 8> torn{{{0.0f, 2.0f}, {0.23f, 0.0f}, {0.57f, 1.0f}, {1.0f, -1.0f}, {0.99f, 3.0f}, {0.68f, 4.0f}, {0.31f, 3.0f}, {0.004f, 5.0f}}}; std::array points{}; for (std::size_t i = 0; i < torn.size(); ++i) points[i] = {static_cast(x0 + torn[i].first * rule), static_cast(y0 + torn[i].second * unit)}; SetDCBrushColor(dc, blue); Polygon(dc, points.data(), static_cast(points.size())); } // Darkens the bottom of the picture so the status stays readable on any art. void make_shadow() { if (shadow) DeleteObject(shadow); void* bits{}; constexpr int rows = 64; shadow = make_dib(1, rows, &bits); if (!shadow) return; auto* pixels = static_cast(bits); for (int row = 0; row < rows; ++row) { const auto t = static_cast(row) / (rows - 1); pixels[row] = static_cast(t * t * 200.0f) << 24; // premultiplied black } } void tick() { auto& state = shared(); const auto now = Clock::now(); bool closing; { std::lock_guard lock(state.mutex); closing = state.closing; } if (!closing && now - checked_at >= window_check) { checked_at = now; closing = game_window_open(window) || now - opened_at >= longest_open; if (closing) { std::lock_guard lock(state.mutex); state.closing = true; } } float alpha = std::min(1.0f, std::chrono::duration(now - opened_at) / fade_in); if (closing && !leaving) { leaving = true; leave_at = now; } if (leaving) { const auto gone = std::chrono::duration(now - leave_at) / fade_out; if (gone >= 1.0f) { DestroyWindow(window); return; } alpha = std::min(alpha, 1.0f - gone); } SetLayeredWindowAttributes(window, 0, static_cast(255.0f * std::clamp(alpha, 0.0f, 1.0f)), LWA_ALPHA); InvalidateRect(window, nullptr, FALSE); } void paint() { PAINTSTRUCT paint{}; const auto target = BeginPaint(window, &paint); const auto dc = CreateCompatibleDC(target); const auto frame = CreateCompatibleBitmap(target, width, height); const auto previous = SelectObject(dc, frame); std::wstring status, detail; float progress; { auto& state = shared(); std::lock_guard lock(state.mutex); status = state.heading; detail = state.detail; progress = state.closing ? 1.0f : state.progress; } const auto source = CreateCompatibleDC(dc); if (background) { const auto kept = SelectObject(source, background); BitBlt(dc, 0, 0, width, height, source, 0, 0, SRCCOPY); SelectObject(source, kept); } else { fill(dc, {0, 0, width, height}, ink); } if (shadow) { const auto kept = SelectObject(source, shadow); const BLENDFUNCTION blend{AC_SRC_OVER, 0, 255, AC_SRC_ALPHA}; AlphaBlend(dc, 0, height - px(shadow_height), width, px(shadow_height), source, 0, 0, 1, 64, blend); SelectObject(source, kept); } DeleteDC(source); SetBkMode(dc, TRANSPARENT); const int bar = height - px(bar_height); const auto line = [&](HFONT font, COLORREF colour, const std::wstring& text, int top, int line_height) { SelectObject(dc, font); RECT row{px(text_left), top, width - px(text_left), top + line_height}; RECT behind = row; OffsetRect(&behind, 1, 1); SetTextColor(dc, RGB(0, 0, 0)); DrawTextW(dc, text.c_str(), static_cast(text.size()), &behind, DT_LEFT | DT_TOP | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); SetTextColor(dc, colour); DrawTextW(dc, text.c_str(), static_cast(text.size()), &row, DT_LEFT | DT_TOP | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); }; if (detail.empty()) { line(heading, paper, status, bar - px(30), px(22)); } else { line(heading, paper, status, bar - px(46), px(22)); line(body, soft, detail, bar - px(24), px(17)); } fill(dc, {0, bar, width, height}, track); if (progress >= 0.0f) { fill(dc, {0, bar, static_cast(width * std::min(progress, 1.0f)), height}, blue); } else { const auto seconds = std::chrono::duration(Clock::now() - opened_at).count(); const int length = width / 4; const int start = -length + static_cast(std::fmod(seconds * 0.7f, 1.0f) * (width + length)); fill(dc, {std::max(start, 0), bar, std::min(start + length, width), height}, blue); } BitBlt(target, 0, 0, width, height, dc, 0, 0, SRCCOPY); SelectObject(dc, previous); DeleteObject(frame); DeleteDC(dc); EndPaint(window, &paint); } void release() { for (auto* font : {&title, &heading, &body}) if (*font) { DeleteObject(*font); *font = nullptr; } for (auto* bitmap : {&background, &shadow}) if (*bitmap) { DeleteObject(*bitmap); *bitmap = nullptr; } for (auto* icon : {&large_icon, &small_icon}) if (*icon) { DestroyIcon(*icon); *icon = nullptr; } art.Reset(); // before COM goes away on this thread } }; LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { if (message == WM_NCCREATE) { const auto* create = reinterpret_cast(lparam); SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast(create->lpCreateParams)); } auto* self = reinterpret_cast(GetWindowLongPtrW(window, GWLP_USERDATA)); switch (message) { case WM_TIMER: if (self) self->tick(); return 0; case WM_PAINT: if (self) { self->paint(); return 0; } break; case WM_ERASEBKGND: return 1; case WM_MOUSEACTIVATE: return MA_NOACTIVATE; // the game's window gets the focus, not this one case WM_DPICHANGED: if (self) { self->dpi = HIWORD(wparam); self->layout(); const auto* suggested = reinterpret_cast(lparam); SetWindowPos(window, nullptr, suggested->left, suggested->top, self->width, self->height, SWP_NOZORDER | SWP_NOACTIVATE); } return 0; case WM_DESTROY: PostQuitMessage(0); return 0; default: break; } return DefWindowProcW(window, message, wparam, lparam); } void run() { auto& state = shared(); // A late start, with the game already on screen, has nothing to cover. if (game_window_open(nullptr)) { std::lock_guard lock(state.mutex); state.closing = true; return; } const auto com = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); // Windows this thread makes scale themselves; the game's DPI handling is untouched. using SetAwareness = DPI_AWARENESS_CONTEXT(WINAPI*)(DPI_AWARENESS_CONTEXT); using WindowDpi = UINT(WINAPI*)(HWND); if (const auto set_awareness = user32("SetThreadDpiAwarenessContext")) set_awareness(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); const auto instance = this_module(); WNDCLASSEXW description{}; description.cbSize = sizeof(description); description.style = CS_DROPSHADOW; description.lpfnWndProc = window_proc; description.hInstance = instance; description.lpszClassName = class_name; description.hCursor = LoadCursorW(nullptr, IDC_ARROW); RegisterClassExW(&description); MONITORINFO monitor{}; monitor.cbSize = sizeof(monitor); GetMonitorInfoW(MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY), &monitor); Window self; // Never focused, so the game's window takes the focus when it comes; on the // taskbar, so it reads as the game starting rather than a stray popup. self.window = CreateWindowExW(WS_EX_TOPMOST | WS_EX_APPWINDOW | WS_EX_NOACTIVATE | WS_EX_LAYERED, class_name, L"ReSkate", WS_POPUP, monitor.rcWork.left, monitor.rcWork.top, 1, 1, nullptr, nullptr, instance, &self); if (!self.window) { if (SUCCEEDED(com)) CoUninitialize(); return; } if (const auto window_dpi = user32("GetDpiForWindow")) if (const auto dpi = window_dpi(self.window)) self.dpi = dpi; self.art = open_art(); self.layout(); const auto game = module_path(nullptr); if (!game.empty() && ExtractIconExW(game.c_str(), 0, &self.large_icon, &self.small_icon, 1)) { SendMessageW(self.window, WM_SETICON, ICON_BIG, reinterpret_cast(self.large_icon)); SendMessageW(self.window, WM_SETICON, ICON_SMALL, reinterpret_cast(self.small_icon)); } SetLayeredWindowAttributes(self.window, 0, 0, LWA_ALPHA); const auto& work = monitor.rcWork; // Shown with SetWindowPos rather than ShowWindow, which would spend the // game's STARTUPINFO show command on this window instead of its own. SetWindowPos(self.window, HWND_TOPMOST, work.left + (work.right - work.left - self.width) / 2, work.top + (work.bottom - work.top - self.height) / 2, self.width, self.height, SWP_NOACTIVATE | SWP_SHOWWINDOW); self.opened_at = self.checked_at = Clock::now(); state.shown = true; SetTimer(self.window, 1, 16, nullptr); MSG message{}; while (GetMessageW(&message, nullptr, 0, 0) > 0) { TranslateMessage(&message); DispatchMessageW(&message); } self.release(); if (SUCCEEDED(com)) CoUninitialize(); } } // namespace void open() noexcept { auto& state = shared(); { std::lock_guard lock(state.mutex); if (state.opened) return; state.opened = true; } try { std::thread(run).detach(); } catch (...) { std::lock_guard lock(state.mutex); state.closing = true; // no window is no harm } } void status(std::string_view heading, std::string_view detail, float progress) noexcept { try { auto wide_heading = widen(heading); auto wide_detail = widen(detail); auto& state = shared(); std::lock_guard lock(state.mutex); state.heading = std::move(wide_heading); state.detail = std::move(wide_detail); state.progress = progress; } catch (...) { } } void close() noexcept { auto& state = shared(); std::lock_guard lock(state.mutex); state.closing = true; } bool shown() noexcept { return shared().shown.load(); } } // namespace dingosdk::startup