Files
2026-10-02 14:40:41 -05:00

583 lines
23 KiB
C++

#include "startup_window.h"
#include <Windows.h>
#include <shellapi.h>
#include <wincodec.h>
#include <wrl/client.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
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<bool> 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<int>(text.size());
const auto length = MultiByteToWideChar(CP_UTF8, 0, text.data(), size, nullptr, 0);
if (length <= 0) return {};
std::wstring wide(static_cast<std::size_t>(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<HBRUSH>(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<LPCWSTR>(&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<DWORD>(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<class Function> Function user32(const char* name) {
const auto module = GetModuleHandleW(L"user32.dll");
#pragma warning(push)
#pragma warning(disable: 4191)
return module ? reinterpret_cast<Function>(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<Search*>(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<LPARAM>(&search));
return search.found;
}
ComPtr<IWICImagingFactory> imaging() {
ComPtr<IWICImagingFactory> 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<IWICBitmapSource> open_art() {
const auto factory = imaging();
if (!factory) return nullptr;
ComPtr<IWICBitmapDecoder> 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<BYTE*>(LockResource(loaded)) : nullptr;
const auto size = found ? SizeofResource(module, found) : 0;
ComPtr<IWICStream> stream;
if (!bytes || !size || FAILED(factory->CreateStream(&stream)) ||
FAILED(stream->InitializeFromMemory(bytes, size)) ||
FAILED(factory->CreateDecoderFromStream(stream.Get(), nullptr, WICDecodeMetadataCacheOnLoad, &decoder)))
return nullptr;
}
ComPtr<IWICBitmapFrameDecode> frame;
ComPtr<IWICFormatConverter> 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<double>(width) / source_width,
static_cast<double>(height) / source_height);
const auto scaled_width = std::max(static_cast<UINT>(width), static_cast<UINT>(std::ceil(source_width * scale)));
const auto scaled_height = std::max(static_cast<UINT>(height), static_cast<UINT>(std::ceil(source_height * scale)));
ComPtr<IWICBitmapScaler> 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<INT>(scaled_width - static_cast<UINT>(width)) / 2,
static_cast<INT>(scaled_height - static_cast<UINT>(height)) / 2, width, height};
if (FAILED(scaler->CopyPixels(&crop, static_cast<UINT>(width) * 4,
static_cast<UINT>(width) * static_cast<UINT>(height) * 4,
static_cast<BYTE*>(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<IWICBitmapSource> art;
Clock::time_point opened_at{}, leave_at{}, checked_at{};
bool leaving{};
int px(int value) const { return MulDiv(value, static_cast<int>(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<float>(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<float>(width - x), static_cast<float>(y)),
std::hypot(static_cast<float>(x), static_cast<float>(height - y)));
const float radius = (1.0f - corner / reach) * static_cast<float>(px(3));
if (radius < 0.6f) continue;
const int r = std::max(1, static_cast<int>(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<int>(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<int>(std::size(wordmark) - 1));
const float rule = static_cast<float>(extent.cx) * 0.55f;
const float x0 = static_cast<float>(left) + static_cast<float>(extent.cx) * 0.06f;
const float y0 = static_cast<float>(top + extent.cy + px(2));
const float unit = static_cast<float>(px(2));
// A torn-paper rule: fractions along the rule, and heights.
const std::array<std::pair<float, float>, 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<POINT, torn.size()> points{};
for (std::size_t i = 0; i < torn.size(); ++i)
points[i] = {static_cast<LONG>(x0 + torn[i].first * rule), static_cast<LONG>(y0 + torn[i].second * unit)};
SetDCBrushColor(dc, blue);
Polygon(dc, points.data(), static_cast<int>(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<std::uint32_t*>(bits);
for (int row = 0; row < rows; ++row) {
const auto t = static_cast<float>(row) / (rows - 1);
pixels[row] = static_cast<std::uint32_t>(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<float>(now - opened_at) / fade_in);
if (closing && !leaving) {
leaving = true;
leave_at = now;
}
if (leaving) {
const auto gone = std::chrono::duration<float>(now - leave_at) / fade_out;
if (gone >= 1.0f) {
DestroyWindow(window);
return;
}
alpha = std::min(alpha, 1.0f - gone);
}
SetLayeredWindowAttributes(window, 0, static_cast<BYTE>(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<int>(text.size()), &behind,
DT_LEFT | DT_TOP | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS);
SetTextColor(dc, colour);
DrawTextW(dc, text.c_str(), static_cast<int>(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<int>(width * std::min(progress, 1.0f)), height}, blue);
} else {
const auto seconds = std::chrono::duration<float>(Clock::now() - opened_at).count();
const int length = width / 4;
const int start = -length + static_cast<int>(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<const CREATESTRUCTW*>(lparam);
SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(create->lpCreateParams));
}
auto* self = reinterpret_cast<Window*>(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<const RECT*>(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<SetAwareness>("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<WindowDpi>("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<LPARAM>(self.large_icon));
SendMessageW(self.window, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(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