skate3recomp-multiplayer/src/skate3_win_icon.cpp

369 lines
13 KiB
C++

// Application icon from the user's own game files, see skate3_win_icon.h.
#include "skate3_win_icon.h"
#if defined(_WIN32)
#include <windows.h>
// initguid must precede wincodec so the WIC CLSIDs/IIDs are defined in this
// translation unit (no dependency on uuid.lib link order).
#include <initguid.h>
#include <wincodec.h>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <thread>
#include <vector>
#include <rex/logging.h>
namespace skate3 {
namespace {
// First embedded PNG in the nxeart dashboard-art package = the 64x64 title
// tile (two identical copies exist; either is fine).
std::vector<uint8_t> ExtractFirstPng(const std::filesystem::path& path) {
std::ifstream f(path, std::ios::binary);
if (!f) {
return {};
}
std::vector<uint8_t> data((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
static const uint8_t kMagic[8] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
static const uint8_t kIend[4] = {'I', 'E', 'N', 'D'};
for (size_t i = 0; i + 8 <= data.size(); ++i) {
if (std::memcmp(data.data() + i, kMagic, 8) != 0) {
continue;
}
for (size_t j = i + 8; j + 8 <= data.size(); ++j) {
if (std::memcmp(data.data() + j, kIend, 4) == 0) {
// IEND chunk data end + 4-byte CRC.
return std::vector<uint8_t>(data.begin() + i, data.begin() + j + 8);
}
}
break;
}
return {};
}
// Decode the PNG and scale to `size` x `size` 32bpp BGRA (top-down rows).
bool WicDecodeScaled(IWICImagingFactory* wic, const std::vector<uint8_t>& png,
uint32_t size, std::vector<uint8_t>& bgra_out) {
IWICStream* stream = nullptr;
if (FAILED(wic->CreateStream(&stream))) {
return false;
}
bool ok = false;
IWICBitmapDecoder* decoder = nullptr;
IWICBitmapFrameDecode* frame = nullptr;
IWICBitmapScaler* scaler = nullptr;
IWICFormatConverter* converter = nullptr;
do {
if (FAILED(stream->InitializeFromMemory(
const_cast<BYTE*>(png.data()), DWORD(png.size())))) {
break;
}
if (FAILED(wic->CreateDecoderFromStream(stream, nullptr,
WICDecodeMetadataCacheOnDemand,
&decoder))) {
break;
}
if (FAILED(decoder->GetFrame(0, &frame))) {
break;
}
if (FAILED(wic->CreateBitmapScaler(&scaler)) ||
FAILED(scaler->Initialize(frame, size, size,
WICBitmapInterpolationModeFant))) {
break;
}
if (FAILED(wic->CreateFormatConverter(&converter)) ||
FAILED(converter->Initialize(scaler, GUID_WICPixelFormat32bppBGRA,
WICBitmapDitherTypeNone, nullptr, 0.0,
WICBitmapPaletteTypeCustom))) {
break;
}
bgra_out.resize(size_t(size) * size * 4);
if (FAILED(converter->CopyPixels(nullptr, size * 4,
UINT(bgra_out.size()), bgra_out.data()))) {
break;
}
ok = true;
} while (false);
if (converter) converter->Release();
if (scaler) scaler->Release();
if (frame) frame->Release();
if (decoder) decoder->Release();
if (stream) stream->Release();
return ok;
}
// Icon-resource DIB image: BITMAPINFOHEADER with DOUBLED height, XOR (BGRA)
// rows bottom-up, then an all-zero AND mask (alpha carries transparency).
std::vector<uint8_t> BuildIconDib(uint32_t size,
const std::vector<uint8_t>& bgra_topdown) {
const uint32_t xor_bytes = size * size * 4;
const uint32_t and_stride = ((size + 31) / 32) * 4;
const uint32_t and_bytes = and_stride * size;
std::vector<uint8_t> out(sizeof(BITMAPINFOHEADER) + xor_bytes + and_bytes, 0);
auto* bih = reinterpret_cast<BITMAPINFOHEADER*>(out.data());
bih->biSize = sizeof(BITMAPINFOHEADER);
bih->biWidth = LONG(size);
bih->biHeight = LONG(size) * 2; // XOR + AND, both bottom-up
bih->biPlanes = 1;
bih->biBitCount = 32;
bih->biCompression = BI_RGB;
bih->biSizeImage = xor_bytes + and_bytes;
uint8_t* xor_dst = out.data() + sizeof(BITMAPINFOHEADER);
for (uint32_t y = 0; y < size; ++y) {
std::memcpy(xor_dst + size_t(y) * size * 4,
bgra_topdown.data() + size_t(size - 1 - y) * size * 4,
size_t(size) * 4);
}
return out;
}
#pragma pack(push, 2)
struct GrpIconDirEntry {
BYTE bWidth;
BYTE bHeight;
BYTE bColorCount;
BYTE bReserved;
WORD wPlanes;
WORD wBitCount;
DWORD dwBytesInRes;
WORD nId;
};
struct GrpIconDir {
WORD idReserved;
WORD idType;
WORD idCount;
};
#pragma pack(pop)
// The renamed-aside original exe (still the locked running image) cannot be
// deleted from this process. Hand it to a detached waiter that deletes it
// the moment this process exits (clean exit or crash), so a freshly
// deployed exe does not leave a .pre-icon copy sitting next to it between
// runs. The next-run cleanup in EnsureExeIconResource stays as the backstop
// if the helper never gets to run.
void SpawnPreIconDeleter(const std::filesystem::path& pre) {
// PowerShell single-quoted literal: double any embedded single quotes.
std::wstring esc;
for (wchar_t c : pre.wstring()) {
esc += c;
if (c == L'\'') {
esc += c;
}
}
std::wstring cmd =
L"powershell.exe -NoProfile -WindowStyle Hidden -Command "
L"\"Wait-Process -Id " +
std::to_wstring(GetCurrentProcessId()) +
L" -ErrorAction SilentlyContinue; Remove-Item -LiteralPath '" + esc +
L"' -Force -ErrorAction SilentlyContinue\"";
std::vector<wchar_t> mutable_cmd(cmd.begin(), cmd.end());
mutable_cmd.push_back(L'\0');
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (CreateProcessW(nullptr, mutable_cmd.data(), nullptr, nullptr, FALSE,
CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) {
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
}
}
// One-time self-patch: write RT_ICON entries + a MAINICON RT_GROUP_ICON into
// the exe file. The running image is write-locked but RENAMABLE: patch a
// copy, then swap it into place. The renamed original stays locked until
// exit; a detached helper deletes it at process exit (next run = backstop).
void EnsureExeIconResource(const std::vector<uint8_t>& png) {
wchar_t exe_w[MAX_PATH];
if (GetModuleFileNameW(nullptr, exe_w, MAX_PATH) == 0) {
return;
}
const std::filesystem::path exe(exe_w);
const std::filesystem::path pre_local = exe.wstring() + L".pre-icon";
wchar_t temp_dir[MAX_PATH];
const bool have_temp = GetTempPathW(MAX_PATH, temp_dir) != 0;
if (FindResourceW(GetModuleHandleW(nullptr), L"MAINICON",
reinterpret_cast<LPCWSTR>(RT_GROUP_ICON)) != nullptr) {
// Already patched (or a build that ships an icon): backstop-sweep any
// leftover pre-patch original the exit-time deleter didn't get to:
// the exe-adjacent legacy name and the temp-dir parking spots (a
// still-running other instance's parked file just fails the delete).
DeleteFileW(pre_local.c_str());
if (have_temp) {
WIN32_FIND_DATAW fd;
const std::wstring pattern = std::wstring(temp_dir) + L"skate3-pre-icon-*.old";
HANDLE find = FindFirstFileW(pattern.c_str(), &fd);
if (find != INVALID_HANDLE_VALUE) {
do {
DeleteFileW((std::wstring(temp_dir) + fd.cFileName).c_str());
} while (FindNextFileW(find, &fd));
FindClose(find);
}
}
return;
}
// Build the multi-size images. WIC failure falls back to a single raw-PNG
// entry (valid icon image format since Vista).
struct IconImage {
uint32_t size;
std::vector<uint8_t> data;
};
std::vector<IconImage> images;
const HRESULT co = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
{
IWICImagingFactory* wic = nullptr;
if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr,
CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(&wic)))) {
for (uint32_t s : {16u, 24u, 32u, 48u, 64u, 256u}) {
std::vector<uint8_t> bgra;
if (WicDecodeScaled(wic, png, s, bgra)) {
images.push_back({s, BuildIconDib(s, bgra)});
}
}
wic->Release();
}
}
if (SUCCEEDED(co)) {
CoUninitialize();
}
if (images.empty()) {
images.push_back({64, png});
}
GrpIconDir dir{0, 1, WORD(images.size())};
std::vector<uint8_t> group(sizeof(GrpIconDir) +
images.size() * sizeof(GrpIconDirEntry));
std::memcpy(group.data(), &dir, sizeof(dir));
for (size_t i = 0; i < images.size(); ++i) {
GrpIconDirEntry e{};
e.bWidth = BYTE(images[i].size >= 256 ? 0 : images[i].size);
e.bHeight = e.bWidth;
e.wPlanes = 1;
e.wBitCount = 32;
e.dwBytesInRes = DWORD(images[i].data.size());
e.nId = WORD(i + 1);
std::memcpy(group.data() + sizeof(GrpIconDir) + i * sizeof(e), &e,
sizeof(e));
}
// Patch a COPY first so a failure changes nothing.
const std::filesystem::path temp = exe.wstring() + L".icon-new";
if (!CopyFileW(exe.c_str(), temp.c_str(), FALSE)) {
REXLOG_INFO("win-icon: exe copy for icon patch failed ({}); skipping",
GetLastError());
return;
}
const WORD lang = MAKELANGID(LANG_ENGLISH, SUBLANG_ENGLISH_US);
HANDLE update = BeginUpdateResourceW(temp.c_str(), FALSE);
bool ok = update != nullptr;
for (size_t i = 0; ok && i < images.size(); ++i) {
ok = UpdateResourceW(update, reinterpret_cast<LPCWSTR>(RT_ICON),
MAKEINTRESOURCEW(WORD(i + 1)), lang,
images[i].data.data(), DWORD(images[i].data.size()));
}
if (ok) {
ok = UpdateResourceW(update, reinterpret_cast<LPCWSTR>(RT_GROUP_ICON),
L"MAINICON", lang, group.data(), DWORD(group.size()));
}
if (update != nullptr) {
// Commit (discard on failure).
ok = EndUpdateResourceW(update, !ok) && ok;
}
if (!ok) {
REXLOG_INFO("win-icon: icon resource patch failed ({}); skipping",
GetLastError());
DeleteFileW(temp.c_str());
return;
}
// Swap: rename the (locked but renamable) running exe aside, move the
// patched copy into place. On the second step failing, restore. Parking
// spot: %TEMP% (a locked image can be renamed anywhere on the same
// volume, metadata-only op), so no duplicate ever shows up next to the
// exe; falls back to exe-adjacent when temp is on another volume.
std::filesystem::path pre =
have_temp ? std::filesystem::path(std::wstring(temp_dir) +
L"skate3-pre-icon-" +
std::to_wstring(GetCurrentProcessId()) +
L".old")
: pre_local;
if (!MoveFileExW(exe.c_str(), pre.c_str(), MOVEFILE_REPLACE_EXISTING)) {
pre = pre_local;
if (!MoveFileExW(exe.c_str(), pre.c_str(), MOVEFILE_REPLACE_EXISTING)) {
REXLOG_INFO("win-icon: could not move running exe aside ({}); skipping",
GetLastError());
DeleteFileW(temp.c_str());
return;
}
}
if (!MoveFileW(temp.c_str(), exe.c_str())) {
MoveFileW(pre.c_str(), exe.c_str());
return;
}
SetFileAttributesW(pre.c_str(), FILE_ATTRIBUTE_HIDDEN);
SpawnPreIconDeleter(pre);
REXLOG_INFO(
"win-icon: Explorer icon patched into {} from the game's own art "
"(one-time; {} removed when this process exits)",
exe.filename().string(), pre.filename().string());
}
} // namespace
void ApplyGameIconFromGameData(const std::filesystem::path& game_data_root,
void* native_window_handle) {
std::filesystem::path nxeart = game_data_root / "nxeart";
std::error_code ec;
if (!std::filesystem::exists(nxeart, ec)) {
nxeart = game_data_root / "game" / "nxeart";
}
HWND hwnd = static_cast<HWND>(native_window_handle);
std::thread([nxeart, hwnd] {
const std::vector<uint8_t> png = ExtractFirstPng(nxeart);
if (png.empty()) {
REXLOG_INFO("win-icon: no title icon found in {}", nxeart.string());
return;
}
if (hwnd != nullptr) {
// CreateIconFromResourceEx accepts PNG image data (Vista+). Explicit
// small size for crisp titlebar/taskbar small renditions.
HICON icon_big = CreateIconFromResourceEx(
const_cast<PBYTE>(png.data()), DWORD(png.size()), TRUE, 0x00030000,
0, 0, LR_DEFAULTCOLOR);
HICON icon_small = CreateIconFromResourceEx(
const_cast<PBYTE>(png.data()), DWORD(png.size()), TRUE, 0x00030000,
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON),
LR_DEFAULTCOLOR);
DWORD_PTR unused = 0;
if (icon_big) {
SendMessageTimeoutW(hwnd, WM_SETICON, ICON_BIG,
reinterpret_cast<LPARAM>(icon_big), SMTO_NORMAL,
2000, &unused);
}
if (icon_small) {
SendMessageTimeoutW(hwnd, WM_SETICON, ICON_SMALL,
reinterpret_cast<LPARAM>(icon_small), SMTO_NORMAL,
2000, &unused);
}
// HICONs stay alive for the window's lifetime (never destroyed:
// process-lifetime singletons).
}
EnsureExeIconResource(png);
}).detach();
}
} // namespace skate3
#else // !_WIN32
namespace skate3 {
void ApplyGameIconFromGameData(const std::filesystem::path&, void*) {}
} // namespace skate3
#endif