Files
project-solana/launcher/solana.c
T
2026-10-15 00:23:09 -04:00

945 lines
51 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Project Solana launcher: a game library for the static recompilations.
*
* Add disc images (Add disc, Scan folder, or drag and drop). Each one is identified by
* the boot executable its SYSTEM.CNF names (BOOT2) and that executable's MD5, then
* matched against the games this build knows. A supported disc gets a Play button that
* starts that game's wrapper (rac1_wrapper.exe - <disc>) in its own folder with the
* settings below as environment variables. The library and settings are kept in
* solana.ini next to this program.
*
* Plain Win32 + GDI, everything custom-drawn (double-buffered), DPI-aware.
* Build: tools/build_frontend.py (zig cc, x86-windows-gnu). */
#ifndef UNICODE
#define UNICODE
#endif
#ifndef _UNICODE
#define _UNICODE
#endif
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <windowsx.h>
#include <shellapi.h>
#include <shlobj.h>
#include <commdlg.h>
#include <wchar.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* ---- the games this build knows ---------------------------------------------------- */
typedef struct {
const char *serial; /* BOOT2's file name, as in the disc's root */
const wchar_t *title, *region;
const char *md5; /* the executable this recompilation was made from (0: any) */
const wchar_t *dir; /* the game's folder, relative to this program's */
const wchar_t *exe; /* its wrapper in that folder (0: not supported yet) */
const wchar_t *kit; /* its GitHub release tag and folder under games\ (the Build button) */
const wchar_t *const *levels;
const wchar_t *rgn; /* build.py --region (0: pal); its output is build\windows_<rgn> */
} game_t;
static const wchar_t *const rac1_levels[] = {
L"Veldin", L"Novalis", L"Aridia", L"Kerwan", L"Eudora", L"Rilgar", L"Blarg Station", L"Umbris",
L"Batalia", L"Gaspar", L"Orxon", L"Pokitaru", L"Hoven", L"Gemlik Base", L"Oltanis", L"Quartu",
L"Kalebo III", L"Drek's Fleet", L"Veldin (Drek)", 0 };
static const game_t games[] = {
{ "SCES_509.16", L"Ratchet & Clank", L"PAL", "d86429e2eca4e1da7d7f1cc656af2574", L"games\\rac1\\build\\windows", L"rac1_wrapper.exe", L"rac1", rac1_levels },
{ "SCUS_971.99", L"Ratchet & Clank", L"NTSC-U", "0c081d4e0353b419d95e5f95eb0ebacb", L"games\\rac1\\build\\windows_us", L"rac1_wrapper.exe", L"rac1", rac1_levels, L"us" },
{ "SCPS_150.37", L"Ratchet & Clank", L"NTSC-J", 0, 0, 0, 0, 0 },
{ "SCES_516.07", L"Ratchet & Clank 2", L"PAL", 0, 0, 0, 0, 0 },
{ "SCUS_972.68", L"Ratchet & Clank: Going Commando", L"NTSC-U", 0, 0, 0, 0, 0 },
{ "SCES_524.56", L"Ratchet & Clank 3", L"PAL", 0, 0, 0, 0, 0 },
{ "SCUS_973.53", L"Ratchet & Clank: Up Your Arsenal", L"NTSC-U", 0, 0, 0, 0, 0 },
{ "SCES_532.85", L"Ratchet: Gladiator", L"PAL", 0, 0, 0, 0, 0 },
{ "SCUS_974.65", L"Ratchet: Deadlocked", L"NTSC-U", 0, 0, 0, 0, 0 },
};
#define NGAMES (int)(sizeof games / sizeof games[0])
/* ---- the library ------------------------------------------------------------------- */
enum { ST_OK, ST_REVISION, ST_NOTYET, ST_UNKNOWN, ST_NOTDISC, ST_MISSING };
typedef struct {
wchar_t path[MAX_PATH];
char serial[32], md5[33];
int game; /* index into games[], or -1 */
int status;
} disc_t;
#define MAXDISCS 64
static disc_t discs[MAXDISCS];
static int ndiscs, sel = -1;
/* settings */
static int set_upscale = 1, set_vsync, set_nomtgs, set_noglt, set_fpslog, set_capsec = 3, set_level = -1, set_minimize = 1;
static wchar_t base_dir[MAX_PATH], ini_path[MAX_PATH];
static HWND hwnd;
static float dpi = 1.0f;
#define S(x) ((int)((x) * dpi + 0.5f))
/* ---- MD5 (RFC 1321) ----------------------------------------------------------------- */
typedef struct { unsigned a, b, c, d; unsigned long long n; unsigned char buf[64]; } md5_t;
static const unsigned md5_k[64] = {
0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501,0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821,
0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8,0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a,
0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70,0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665,
0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1,0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391 };
static const unsigned char md5_r[64] = { 7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,
4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21 };
static void md5_block(md5_t *m, const unsigned char *p)
{
unsigned w[16], a = m->a, b = m->b, c = m->c, d = m->d;
for (int i = 0; i < 16; i++) w[i] = p[i * 4] | p[i * 4 + 1] << 8 | p[i * 4 + 2] << 16 | (unsigned)p[i * 4 + 3] << 24;
for (int i = 0; i < 64; i++) {
unsigned f, g;
if (i < 16) { f = (b & c) | (~b & d); g = i; }
else if (i < 32) { f = (d & b) | (~d & c); g = (5 * i + 1) & 15; }
else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) & 15; }
else { f = c ^ (b | ~d); g = (7 * i) & 15; }
unsigned t = d; d = c; c = b;
unsigned x = a + f + md5_k[i] + w[g];
b = b + ((x << md5_r[i]) | (x >> (32 - md5_r[i])));
a = t;
}
m->a += a; m->b += b; m->c += c; m->d += d;
}
static void md5_init(md5_t *m) { m->a = 0x67452301; m->b = 0xefcdab89; m->c = 0x98badcfe; m->d = 0x10325476; m->n = 0; }
static void md5_add(md5_t *m, const unsigned char *p, size_t n)
{
while (n--) { m->buf[m->n++ & 63] = *p++; if (!(m->n & 63)) md5_block(m, m->buf); }
}
static void md5_hex(md5_t *m, char out[33])
{
unsigned long long bits = m->n * 8;
unsigned char one = 0x80, zero = 0, len[8];
md5_add(m, &one, 1);
while ((m->n & 63) != 56) md5_add(m, &zero, 1);
for (int i = 0; i < 8; i++) len[i] = (unsigned char)(bits >> (8 * i));
md5_add(m, len, 8);
unsigned v[4] = { m->a, m->b, m->c, m->d };
for (int i = 0; i < 16; i++) sprintf(out + 2 * i, "%02x", (v[i / 4] >> (8 * (i & 3))) & 255);
out[32] = 0;
}
/* ---- disc images (ISO 9660, 2048-byte sectors) -------------------------------------- */
static int read_at(HANDLE f, unsigned long long off, void *buf, DWORD n)
{
LARGE_INTEGER li; li.QuadPart = (LONGLONG)off;
DWORD got = 0;
return SetFilePointerEx(f, li, 0, FILE_BEGIN) && ReadFile(f, buf, n, &got, 0) && got == n;
}
/* a file in the root directory: its first sector and size */
static int iso_find(HANDLE f, const unsigned char *root, unsigned rootn, const char *name, unsigned *lba, unsigned *size)
{
size_t nl = strlen(name);
for (unsigned o = 0; o < rootn;) {
unsigned len = root[o];
if (!len) { o = (o / 2048 + 1) * 2048; continue; }
unsigned fl = root[o + 32];
const char *fn = (const char *)root + o + 33;
if (fl >= nl && !_strnicmp(fn, name, nl) && (fl == nl || fn[nl] == ';')) {
memcpy(lba, root + o + 2, 4); memcpy(size, root + o + 10, 4);
return 1;
}
o += len;
}
(void)f;
return 0;
}
static void identify(disc_t *d)
{
d->game = -1; d->serial[0] = d->md5[0] = 0;
HANDLE f = CreateFileW(d->path, GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, FILE_FLAG_SEQUENTIAL_SCAN, 0);
if (f == INVALID_HANDLE_VALUE) { d->status = ST_MISSING; return; }
unsigned char pvd[2048];
d->status = ST_NOTDISC;
if (!read_at(f, 16 * 2048ull, pvd, 2048) || memcmp(pvd + 1, "CD001", 5)) { CloseHandle(f); return; }
unsigned rlba, rsize;
memcpy(&rlba, pvd + 156 + 2, 4); memcpy(&rsize, pvd + 156 + 10, 4);
if (rsize > (1u << 20)) rsize = 1u << 20;
unsigned char *root = malloc(rsize);
if (!root || !read_at(f, rlba * 2048ull, root, rsize)) { free(root); CloseHandle(f); return; }
unsigned lba, size;
if (iso_find(f, root, rsize, "SYSTEM.CNF", &lba, &size) && size < 4096) {
char cnf[4096] = { 0 };
if (read_at(f, lba * 2048ull, cnf, size)) {
char *b = strstr(cnf, "BOOT2");
if (b && (b = strchr(b, '\\'))) {
int k = 0;
for (b++; *b && *b != ';' && *b != '\r' && *b != '\n' && k < 31; b++) d->serial[k++] = *b;
d->serial[k] = 0;
}
}
}
d->status = ST_UNKNOWN;
for (int i = 0; i < NGAMES; i++) if (!_stricmp(games[i].serial, d->serial)) d->game = i;
if (d->game >= 0 && iso_find(f, root, rsize, d->serial, &lba, &size) && size < (64u << 20)) {
unsigned char *exe = malloc(size);
if (exe && read_at(f, lba * 2048ull, exe, size)) { md5_t m; md5_init(&m); md5_add(&m, exe, size); md5_hex(&m, d->md5); }
free(exe);
}
free(root);
CloseHandle(f);
if (d->game < 0) return;
const game_t *g = &games[d->game];
if (!g->exe) d->status = ST_NOTYET;
else if (g->md5 && strcmp(g->md5, d->md5)) d->status = ST_REVISION;
else d->status = ST_OK;
}
/* ---- game folders --------------------------------------------------------------------- */
static void game_dir(const game_t *g, wchar_t *out)
{
/* next to this program, in its games\\<kit> folder (built by the Build button), or in
* build\\windows (where development builds put it) */
const wchar_t *tries[3] = { L"", g->dir, L"build\\windows" };
for (int i = 0; i < 3; i++) {
wchar_t d[MAX_PATH], p[MAX_PATH];
if (tries[i][0]) swprintf(d, MAX_PATH, L"%ls\\%ls", base_dir, tries[i]); else wcscpy(d, base_dir);
swprintf(p, MAX_PATH, L"%ls\\%ls", d, g->exe);
if (GetFileAttributesW(p) != INVALID_FILE_ATTRIBUTES) { wcscpy(out, d); return; }
}
swprintf(out, MAX_PATH, L"%ls\\%ls", base_dir, g->dir);
}
static int game_installed(const game_t *g)
{
wchar_t d[MAX_PATH], p[MAX_PATH];
if (!g->exe) return 0;
game_dir(g, d);
swprintf(p, MAX_PATH, L"%ls\\%ls", d, g->exe);
if (GetFileAttributesW(p) == INVALID_FILE_ATTRIBUTES) return 0;
swprintf(p, MAX_PATH, L"%ls\\rac1_game.dll", d);
if (GetFileAttributesW(p) != INVALID_FILE_ATTRIBUTES) return 1;
swprintf(p, MAX_PATH, L"%ls\\rac1_game.dll.manifest", d); /* still in parts: the wrapper joins them */
return GetFileAttributesW(p) != INVALID_FILE_ATTRIBUTES;
}
/* ---- settings and library on disk ------------------------------------------------------ */
static int ini_int(const wchar_t *sec, const wchar_t *key, int def) { return (int)GetPrivateProfileIntW(sec, key, def, ini_path); }
static void ini_set_int(const wchar_t *sec, const wchar_t *key, int v) { wchar_t b[16]; swprintf(b, 16, L"%d", v); WritePrivateProfileStringW(sec, key, b, ini_path); }
static void save_all(void)
{
WritePrivateProfileStringW(L"library", 0, 0, ini_path);
ini_set_int(L"library", L"count", ndiscs);
for (int i = 0; i < ndiscs; i++) { wchar_t k[16]; swprintf(k, 16, L"disc%d", i); WritePrivateProfileStringW(L"library", k, discs[i].path, ini_path); }
ini_set_int(L"library", L"selected", sel);
ini_set_int(L"settings", L"upscaled", set_upscale);
ini_set_int(L"settings", L"vsync", set_vsync);
ini_set_int(L"settings", L"no_gs_thread", set_nomtgs);
ini_set_int(L"settings", L"no_gl_thread", set_noglt);
ini_set_int(L"settings", L"fps_log", set_fpslog);
ini_set_int(L"settings", L"capture_seconds", set_capsec);
ini_set_int(L"settings", L"start_level", set_level);
ini_set_int(L"settings", L"minimize", set_minimize);
}
static int add_disc(const wchar_t *path, int select)
{
wchar_t full[MAX_PATH];
if (!GetFullPathNameW(path, MAX_PATH, full, 0)) wcscpy(full, path);
for (int i = 0; i < ndiscs; i++) if (!_wcsicmp(discs[i].path, full)) { if (select) sel = i; return i; }
if (ndiscs >= MAXDISCS) return -1;
disc_t *d = &discs[ndiscs];
memset(d, 0, sizeof *d);
wcsncpy(d->path, full, MAX_PATH - 1);
identify(d);
if (select) sel = ndiscs;
return ndiscs++;
}
static void load_all(void)
{
int n = ini_int(L"library", L"count", 0);
for (int i = 0; i < n && i < MAXDISCS; i++) {
wchar_t k[16], p[MAX_PATH];
swprintf(k, 16, L"disc%d", i);
GetPrivateProfileStringW(L"library", k, L"", p, MAX_PATH, ini_path);
if (p[0]) add_disc(p, 0);
}
sel = ini_int(L"library", L"selected", ndiscs ? 0 : -1);
if (sel >= ndiscs) sel = ndiscs - 1;
set_upscale = ini_int(L"settings", L"upscaled", 1);
set_vsync = ini_int(L"settings", L"vsync", 0);
set_nomtgs = ini_int(L"settings", L"no_gs_thread", 0);
set_noglt = ini_int(L"settings", L"no_gl_thread", 0);
set_fpslog = ini_int(L"settings", L"fps_log", 0);
set_capsec = ini_int(L"settings", L"capture_seconds", 3);
set_level = ini_int(L"settings", L"start_level", -1);
set_minimize = ini_int(L"settings", L"minimize", 1);
if (set_capsec < 1 || set_capsec > 10) set_capsec = 3;
}
/* ---- running a game ---------------------------------------------------------------------- */
static HANDLE game_proc;
static int running = -1;
static wchar_t message[256]; /* a line under the Play button */
static int message_bad;
#define WM_GAME_EXIT (WM_APP + 1)
static DWORD WINAPI watch(LPVOID p)
{
HANDLE h = (HANDLE)p; DWORD code = 0;
WaitForSingleObject(h, INFINITE);
GetExitCodeProcess(h, &code);
PostMessageW(hwnd, WM_GAME_EXIT, code, 0);
return 0;
}
static void env(const wchar_t *k, const wchar_t *v) { SetEnvironmentVariableW(k, v); }
/* Upscaled: the smallest whole multiple of the PS2's 448 lines that covers the height of
* the monitor this window is on (1080p: 3x, 1440p and 4K: 4x), at least 2x */
static int screen_h(void)
{
MONITORINFO mi; mi.cbSize = sizeof mi;
if (GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &mi)) return mi.rcMonitor.bottom - mi.rcMonitor.top;
return GetSystemMetrics(SM_CYSCREEN);
}
static int upscale_factor(void)
{
int f = (screen_h() + 447) / 448;
return f < 2 ? 2 : f > 4 ? 4 : f;
}
static void play(int i)
{
if (i < 0 || running >= 0) return;
disc_t *d = &discs[i];
identify(d);
if (d->status != ST_OK && d->status != ST_REVISION) return;
const game_t *g = &games[d->game];
wchar_t dir[MAX_PATH], exe[MAX_PATH], cmd[2 * MAX_PATH + 64];
game_dir(g, dir);
swprintf(exe, MAX_PATH, L"%ls\\%ls", dir, g->exe);
if (!game_installed(g)) {
swprintf(message, 256, L"The game files are not in %ls", dir); message_bad = 1;
InvalidateRect(hwnd, 0, 0); return;
}
wchar_t b[32];
env(L"UYA_RENDERER", L"gl");
swprintf(b, 32, L"%d", set_upscale ? upscale_factor() : 1); env(L"UYA_SCALE", b);
env(L"UYA_VSYNC", set_vsync ? L"1" : 0);
env(L"UYA_MTGS", set_nomtgs ? L"0" : 0);
env(L"UYA_GLTHREAD", set_noglt ? L"0" : 0);
env(L"UYA_FPSLOG", set_fpslog ? L"1" : 0);
swprintf(b, 32, L"%d", set_capsec * 50); env(L"UYA_GSREC_N", b);
if (set_level >= 0) { swprintf(b, 32, L"%d@1", set_level); env(L"UYA_LEVEL", b); } else env(L"UYA_LEVEL", 0);
swprintf(cmd, sizeof cmd / sizeof cmd[0], L"\"%ls\" - \"%ls\"", exe, d->path);
STARTUPINFOW si; PROCESS_INFORMATION pi;
memset(&si, 0, sizeof si); si.cb = sizeof si;
if (!CreateProcessW(exe, cmd, 0, 0, FALSE, 0, 0, dir, &si, &pi)) {
swprintf(message, 256, L"Could not start %ls (error %lu)", g->exe, GetLastError()); message_bad = 1;
InvalidateRect(hwnd, 0, 0); return;
}
CloseHandle(pi.hThread);
game_proc = pi.hProcess; running = i;
message[0] = 0; message_bad = 0;
CloseHandle(CreateThread(0, 0, watch, pi.hProcess, 0, 0));
if (set_minimize) ShowWindow(hwnd, SW_MINIMIZE);
InvalidateRect(hwnd, 0, 0);
}
#include "solana_build.h"
static void open_log(int i)
{
if (i < 0 || discs[i].game < 0 || !games[discs[i].game].exe) return;
wchar_t dir[MAX_PATH], p[MAX_PATH];
const game_t *g = &games[discs[i].game];
game_dir(g, dir);
swprintf(p, MAX_PATH, L"%ls\\rac1_log.txt", dir);
if (!game_installed(g) || (bld.failed && bld.disc == i)) swprintf(p, MAX_PATH, L"%ls\\games\\%ls\\build_log.txt", base_dir, g->kit); /* (the build's) */
if (GetFileAttributesW(p) == INVALID_FILE_ATTRIBUTES) { wcscpy(message, L"No log yet: it is written when the game runs."); message_bad = 0; InvalidateRect(hwnd, 0, 0); return; }
ShellExecuteW(hwnd, L"open", p, 0, 0, SW_SHOWNORMAL);
}
static void open_folder(int i)
{
if (i < 0 || discs[i].game < 0 || !games[discs[i].game].exe) return;
wchar_t dir[MAX_PATH];
game_dir(&games[discs[i].game], dir);
ShellExecuteW(hwnd, L"open", dir, 0, 0, SW_SHOWNORMAL);
}
/* ---- adding discs ---------------------------------------------------------------------------- */
static void pick_files(void)
{
static wchar_t buf[32768];
OPENFILENAMEW of; memset(&of, 0, sizeof of);
buf[0] = 0;
of.lStructSize = sizeof of; of.hwndOwner = hwnd;
of.lpstrFilter = L"PS2 disc images (*.iso)\0*.iso\0All files\0*.*\0";
of.lpstrFile = buf; of.nMaxFile = 32768;
of.lpstrTitle = L"Add disc images";
of.Flags = OFN_FILEMUSTEXIST | OFN_EXPLORER | OFN_ALLOWMULTISELECT | OFN_HIDEREADONLY;
if (!GetOpenFileNameW(&of)) return;
wchar_t *p = buf + wcslen(buf) + 1;
if (!*p) add_disc(buf, 1);
else for (; *p; p += wcslen(p) + 1) { wchar_t f[MAX_PATH]; swprintf(f, MAX_PATH, L"%ls\\%ls", buf, p); add_disc(f, 1); }
save_all();
}
static int scan_dir(const wchar_t *dir, int depth)
{
wchar_t pat[MAX_PATH]; WIN32_FIND_DATAW fd; int n = 0;
swprintf(pat, MAX_PATH, L"%ls\\*", dir);
HANDLE h = FindFirstFileW(pat, &fd);
if (h == INVALID_HANDLE_VALUE) return 0;
do {
if (fd.cFileName[0] == L'.') continue;
wchar_t p[MAX_PATH]; swprintf(p, MAX_PATH, L"%ls\\%ls", dir, fd.cFileName);
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { if (depth < 4) n += scan_dir(p, depth + 1); }
else { size_t l = wcslen(fd.cFileName); if (l > 4 && !_wcsicmp(fd.cFileName + l - 4, L".iso")) { int before = ndiscs; add_disc(p, 0); n += ndiscs > before; } }
} while (FindNextFileW(h, &fd));
FindClose(h);
return n;
}
static void pick_folder(void)
{
BROWSEINFOW bi; memset(&bi, 0, sizeof bi);
bi.hwndOwner = hwnd; bi.lpszTitle = L"Find disc images (.iso) in this folder and its subfolders";
bi.ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE;
LPITEMIDLIST id = SHBrowseForFolderW(&bi);
if (!id) return;
wchar_t dir[MAX_PATH];
if (SHGetPathFromIDListW(id, dir)) {
HCURSOR c = SetCursor(LoadCursor(0, IDC_WAIT));
int n = scan_dir(dir, 0);
SetCursor(c);
swprintf(message, 256, n == 1 ? L"Found 1 new disc image." : L"Found %d new disc images.", n); message_bad = 0;
if (n && sel < 0) sel = 0;
save_all();
}
CoTaskMemFree(id);
InvalidateRect(hwnd, 0, 0);
}
/* ---- drawing ----------------------------------------------------------------------------------- */
/* the palette (assets/palette.json, "Horizon") */
#define C_BG RGB(0x0A, 0x0E, 0x1A)
#define C_SIDE RGB(0x0F, 0x14, 0x24)
#define C_CARD RGB(0x15, 0x1C, 0x30)
#define C_CARDHI RGB(0x1C, 0x25, 0x40)
#define C_SELECT RGB(0x1C, 0x25, 0x40)
#define C_LINE RGB(0x26, 0x30, 0x4D)
#define C_TEXT RGB(0xF4, 0xF6, 0xFB)
#define C_MUTED RGB(0x9A, 0xA6, 0xC6)
#define C_DIM RGB(0x5E, 0x68, 0x88)
#define C_ACCENT RGB(0xFF, 0x7A, 0x3D)
#define C_ACCENTHI RGB(0xFF, 0x94, 0x61)
#define C_ONACCENT RGB(0x0A, 0x0E, 0x1A)
#define C_CYAN RGB(0x7F, 0xD1, 0xFF)
#define C_OK RGB(0x3D, 0xD6, 0x8C)
#define C_WARN RGB(0xFF, 0xB2, 0x38)
#define C_BAD RGB(0xFF, 0x5A, 0x5F)
#include "solana_img.h"
enum { W_NONE, W_ADD, W_SCAN, W_ITEM, W_PLAY, W_SETTINGS, W_LOG, W_FOLDER, W_REMOVE, W_BACK,
W_RENDER, W_TOGGLE, W_CAPDOWN, W_CAPUP, W_LVLDOWN, W_LVLUP, W_CANCEL, W_REBUILD };
typedef struct { RECT r; int id, arg; } widget_t;
static widget_t wid[96];
static int nwid, hover_id = W_NONE, hover_arg, press_id = W_NONE, press_arg;
static int view_settings, scroll;
static HFONT f_title, f_head, f_body, f_bold, f_small, f_logo, f_btn;
static void add_w(RECT r, int id, int arg) { if (nwid < 96) { wid[nwid].r = r; wid[nwid].id = id; wid[nwid].arg = arg; nwid++; } }
static int is_hover(int id, int arg) { return hover_id == id && hover_arg == arg; }
static HFONT mkfont(int px, int weight) { return CreateFontW(-S(px), 0, 0, 0, weight, 0, 0, 0, DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI"); }
static void make_fonts(void)
{
HFONT *all[] = { &f_title, &f_head, &f_body, &f_bold, &f_small, &f_logo, &f_btn };
for (unsigned i = 0; i < sizeof all / sizeof all[0]; i++) if (*all[i]) DeleteObject(*all[i]);
f_title = mkfont(30, FW_SEMIBOLD); f_head = mkfont(15, FW_SEMIBOLD); f_body = mkfont(14, FW_NORMAL);
f_bold = mkfont(15, FW_SEMIBOLD); f_small = mkfont(12, FW_NORMAL); f_logo = mkfont(17, FW_BOLD); f_btn = mkfont(14, FW_SEMIBOLD);
}
static void fill(HDC dc, RECT r, COLORREF c) { SetDCBrushColor(dc, c); FillRect(dc, &r, GetStockObject(DC_BRUSH)); }
static void rrect(HDC dc, RECT r, int rad, COLORREF fillc, COLORREF edge)
{
SelectObject(dc, GetStockObject(DC_BRUSH)); SelectObject(dc, GetStockObject(DC_PEN));
SetDCBrushColor(dc, fillc); SetDCPenColor(dc, edge);
RoundRect(dc, r.left, r.top, r.right, r.bottom, S(rad), S(rad));
}
static void text(HDC dc, const wchar_t *s, RECT r, HFONT f, COLORREF c, UINT fmt)
{
SelectObject(dc, f); SetTextColor(dc, c); SetBkMode(dc, TRANSPARENT);
DrawTextW(dc, s, -1, &r, fmt | DT_NOPREFIX);
}
static int text_w(HDC dc, const wchar_t *s, HFONT f) { SIZE z; SelectObject(dc, f); GetTextExtentPoint32W(dc, s, (int)wcslen(s), &z); return z.cx; }
static RECT rc(int l, int t, int r, int b) { RECT x = { l, t, r, b }; return x; }
static void status_text(const disc_t *d, wchar_t *out, COLORREF *c)
{
switch (d->status) {
case ST_OK: if (bld.active && bld.disc == (int)(d - discs)) { wcscpy(out, L"Building\u2026"); *c = C_ACCENT; }
else if (games[d->game].exe && !game_installed(&games[d->game])) { wcscpy(out, games[d->game].kit ? L"Ready to build" : L"Game files missing"); *c = games[d->game].kit ? C_ACCENT : C_BAD; }
else { wcscpy(out, L"Ready to play"); *c = C_OK; } break;
case ST_REVISION: wcscpy(out, L"Different disc revision"); *c = C_WARN; break;
case ST_NOTYET: wcscpy(out, L"Not supported yet"); *c = C_DIM; break;
case ST_UNKNOWN: wcscpy(out, L"Unknown PS2 disc"); *c = C_DIM; break;
case ST_MISSING: wcscpy(out, L"Disc image not found"); *c = C_BAD; break;
default: wcscpy(out, L"Not a PS2 disc image"); *c = C_BAD; break;
}
}
static void disc_title(const disc_t *d, wchar_t *out)
{
if (d->game >= 0) { wcscpy(out, games[d->game].title); return; }
const wchar_t *n = wcsrchr(d->path, L'\\'); n = n ? n + 1 : d->path;
wcsncpy(out, n, 127); out[127] = 0;
}
/* a button: label, and an icon (assets/png/icons, white, tinted here) left of it */
static void button_i(HDC dc, RECT r, const wchar_t *label, int id, int arg, int primary, int enabled, int icon)
{
COLORREF bg = primary ? C_ACCENT : C_CARD, fg = primary ? C_ONACCENT : C_TEXT, edge = primary ? C_ACCENT : C_LINE;
if (!enabled) { bg = primary ? RGB(0x5A, 0x3A, 0x2C) : C_CARD; fg = primary ? RGB(0x9A, 0x7E, 0x70) : C_DIM; edge = primary ? bg : C_LINE; }
else if (is_hover(id, arg)) { bg = primary ? C_ACCENTHI : C_CARDHI; edge = primary ? bg : RGB(0x34, 0x40, 0x66); }
if (enabled && press_id == id && press_arg == arg && is_hover(id, arg)) OffsetRect(&r, 0, S(1));
rrect(dc, r, 6, bg, edge);
HFONT f = primary ? f_bold : f_btn;
int isz = S(18), gap = S(8), tw = text_w(dc, label, f);
int total = tw + (icon ? isz + gap : 0), x = (r.left + r.right - total) / 2;
if (icon) { img_icon(dc, icon, x, (r.top + r.bottom - isz) / 2, isz, fg); x += isz + gap; }
text(dc, label, rc(x, r.top, x + tw + S(2), r.bottom), f, fg, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
if (enabled) add_w(r, id, arg);
}
static void button(HDC dc, RECT r, const wchar_t *label, int id, int arg, int primary, int enabled) { button_i(dc, r, label, id, arg, primary, enabled, 0); }
static void toggle(HDC dc, int x, int y, int on, int arg)
{
RECT r = rc(x, y, x + S(40), y + S(22));
rrect(dc, r, 22, on ? C_ACCENT : C_LINE, on ? C_ACCENT : C_LINE);
int kx = on ? r.right - S(19) : r.left + S(3);
RECT k = rc(kx, y + S(3), kx + S(16), y + S(19));
rrect(dc, k, 16, on ? C_ONACCENT : C_TEXT, on ? C_ONACCENT : C_TEXT);
}
/* one row of the settings list: title, explanation, and the control area on the right */
static int setting_row(HDC dc, RECT area, int y, const wchar_t *title, const wchar_t *help)
{
RECT t = rc(area.left, y, area.right - S(220), y + S(22));
text(dc, title, t, f_bold, C_TEXT, DT_LEFT | DT_SINGLELINE | DT_VCENTER);
RECT h = rc(area.left, y + S(22), area.right - S(220), y + S(58));
text(dc, help, h, f_small, C_MUTED, DT_LEFT | DT_WORDBREAK);
return y + S(66);
}
static void draw_settings(HDC dc, RECT area)
{
int y = area.top;
text(dc, L"Settings", rc(area.left, y, area.right, y + S(40)), f_title, C_TEXT, DT_LEFT | DT_SINGLELINE);
button(dc, rc(area.right - S(90), y + S(4), area.right, y + S(36)), L"Done", W_BACK, 0, 0, 1);
y += S(52);
text(dc, L"Apply the next time a game starts.", rc(area.left, y, area.right, y + S(20)), f_body, C_MUTED, DT_LEFT | DT_SINGLELINE);
y += S(34);
int cx = area.right - S(200);
int y0 = y;
{ wchar_t help[200];
swprintf(help, 200, L"Native draws at the PS2's own resolution, as the game was. Upscaled draws at %dx for this %d-line screen: sharper, more GPU work.", upscale_factor(), screen_h());
y = setting_row(dc, area, y, L"Resolution", help);
}
{ const wchar_t *opt[2] = { L"Native", L"Upscaled" };
RECT box = rc(cx, y0 + S(4), area.right, y0 + S(36));
rrect(dc, box, 6, C_CARD, C_LINE);
for (int k = 0; k < 2; k++) {
RECT s = rc(box.left + S(3) + k * (S(197) / 2), box.top + S(3), box.left + S(3) + (k + 1) * (S(197) / 2) - S(3), box.bottom - S(3));
int on = (k == 1) == (set_upscale != 0);
if (on) rrect(dc, s, 6, C_ACCENT, C_ACCENT); else if (is_hover(W_RENDER, k)) rrect(dc, s, 6, C_CARDHI, C_CARDHI);
text(dc, opt[k], s, f_btn, on ? C_ONACCENT : C_TEXT, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
add_w(s, W_RENDER, k);
}
}
struct { const wchar_t *t, *h; int *v; int invert; } rows[] = {
{ L"VSync", L"Waits for the monitor's refresh. Off lets the game pace itself at 50 fps.", &set_vsync, 0 },
{ L"Graphics on its own thread", L"Runs the PS2 graphics chip beside the game. Turn off only to test a problem.", &set_nomtgs, 1 },
{ L"OpenGL calls on their own thread", L"Sends drawing to the driver from a separate thread. Turn off only to test a problem.", &set_noglt, 1 },
{ L"Performance statistics", L"Writes frame timings to rac1_log.txt every 2 seconds, for bug reports.", &set_fpslog, 0 },
{ L"Minimize while playing", L"Tucks this window away when a game starts.", &set_minimize, 0 },
};
for (unsigned i = 0; i < sizeof rows / sizeof rows[0]; i++) {
int ry = y;
y = setting_row(dc, area, y, rows[i].t, rows[i].h);
int on = rows[i].invert ? !*rows[i].v : *rows[i].v;
RECT hit = rc(area.right - S(48), ry + S(4), area.right, ry + S(30));
toggle(dc, area.right - S(44), ry + S(4), on, (int)i);
add_w(hit, W_TOGGLE, (int)i);
}
/* steppers */
{ int ry = y; wchar_t b[64];
y = setting_row(dc, area, y, L"F9 capture length", L"F9 in the game records this much graphics input to gsrec_NN.bin, for glitch reports (about 260 MB a second).");
swprintf(b, 64, L"%d second%ls", set_capsec, set_capsec == 1 ? L"" : L"s");
RECT box = rc(cx, ry + S(4), area.right, ry + S(36));
rrect(dc, box, 6, C_CARD, C_LINE);
text(dc, b, box, f_btn, C_TEXT, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
RECT l = rc(box.left + S(3), box.top + S(3), box.left + S(35), box.bottom - S(3)), r = rc(box.right - S(35), box.top + S(3), box.right - S(3), box.bottom - S(3));
if (is_hover(W_CAPDOWN, 0)) rrect(dc, l, 6, C_CARDHI, C_CARDHI);
if (is_hover(W_CAPUP, 0)) rrect(dc, r, 6, C_CARDHI, C_CARDHI);
text(dc, L"−", l, f_bold, C_TEXT, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
text(dc, L"+", r, f_bold, C_TEXT, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
add_w(l, W_CAPDOWN, 0); add_w(r, W_CAPUP, 0);
}
{ int ry = y; wchar_t b[64];
y = setting_row(dc, area, y, L"Start in a level (testing)", L"Skips the menus and loads this level with a fresh save. Off for normal play.");
if (set_level < 0) wcscpy(b, L"Off"); else swprintf(b, 64, L"%02d %ls", set_level, rac1_levels[set_level]);
RECT box = rc(cx, ry + S(4), area.right, ry + S(36));
rrect(dc, box, 6, set_level >= 0 ? C_SELECT : C_CARD, set_level >= 0 ? C_ACCENT : C_LINE);
RECT mid = rc(box.left + S(36), box.top, box.right - S(36), box.bottom);
text(dc, b, mid, f_btn, C_TEXT, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
RECT l = rc(box.left + S(3), box.top + S(3), box.left + S(35), box.bottom - S(3)), r = rc(box.right - S(35), box.top + S(3), box.right - S(3), box.bottom - S(3));
if (is_hover(W_LVLDOWN, 0)) rrect(dc, l, 6, C_CARDHI, C_CARDHI);
if (is_hover(W_LVLUP, 0)) rrect(dc, r, 6, C_CARDHI, C_CARDHI);
text(dc, L"‹", l, f_bold, C_TEXT, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
text(dc, L"›", r, f_bold, C_TEXT, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
add_w(l, W_LVLDOWN, 0); add_w(r, W_LVLUP, 0);
}
{ /* building the selected game again (a newer build kit, or after a problem) */
int ry = y;
int ok = sel >= 0 && discs[sel].game >= 0 && games[discs[sel].game].kit && (discs[sel].status == ST_OK || discs[sel].status == ST_REVISION) && !bld.active && running < 0;
y = setting_row(dc, area, y, L"Rebuild the game", L"Downloads the latest build kit and builds the selected game from your disc again. Saves are kept.");
button_i(dc, rc(cx, ry + S(4), area.right, ry + S(36)), L"Rebuild", W_REBUILD, 0, 0, ok, IMG_I_REBUILD);
}
}
static void info_row(HDC dc, int x, int *y, int w, const wchar_t *k, const wchar_t *v, COLORREF vc)
{
text(dc, k, rc(x, *y, x + S(130), *y + S(22)), f_body, C_MUTED, DT_LEFT | DT_SINGLELINE | DT_VCENTER);
text(dc, v, rc(x + S(130), *y, x + w, *y + S(22)), f_body, vc, DT_LEFT | DT_SINGLELINE | DT_VCENTER | DT_PATH_ELLIPSIS);
*y += S(30);
}
static void draw_details(HDC dc, RECT area)
{
if (sel < 0 || sel >= ndiscs) {
int cy = (area.top + area.bottom) / 2 + S(70);
img_draw(dc, IMG_EMPTY, (area.left + area.right) / 2 - S(160), cy - S(290), S(320), S(200));
text(dc, L"Your library is empty", rc(area.left, cy - S(60), area.right, cy - S(24)), f_title, C_TEXT, DT_CENTER | DT_SINGLELINE);
text(dc, L"Add your own copy of the game as a disc image (.iso), or drop one on this window.", rc(area.left, cy - S(14), area.right, cy + S(10)), f_body, C_MUTED, DT_CENTER | DT_SINGLELINE);
int bw = S(176);
button_i(dc, rc((area.left + area.right) / 2 - bw - S(6), cy + S(30), (area.left + area.right) / 2 - S(6), cy + S(70)), L"Add disc image", W_ADD, 1, 1, 1, IMG_I_ADD);
button_i(dc, rc((area.left + area.right) / 2 + S(6), cy + S(30), (area.left + area.right) / 2 + bw + S(6), cy + S(70)), L"Scan a folder", W_SCAN, 1, 0, 1, IMG_I_SCAN);
return;
}
disc_t *d = &discs[sel];
const game_t *g = d->game >= 0 ? &games[d->game] : 0;
wchar_t t[160], st[64], b[MAX_PATH + 64]; COLORREF sc;
disc_title(d, t); status_text(d, st, &sc);
int y = area.top, w = area.right - area.left;
/* banner */
RECT ban = rc(area.left, y, area.right, y + S(150));
{ /* the Horizon banner (assets/png/default_banner.png): filling the area, anchored right, under rounded corners */
HRGN rg = CreateRoundRectRgn(ban.left, ban.top, ban.right + 1, ban.bottom + 1, S(10), S(10));
SelectClipRgn(dc, rg);
fill(dc, ban, C_BG);
img_cover(dc, IMG_BANNER, ban, 1.0);
SelectClipRgn(dc, 0); DeleteObject(rg);
}
text(dc, t, rc(ban.left + S(24), ban.top + S(30), ban.right - S(200), ban.top + S(74)), f_title, C_TEXT, DT_LEFT | DT_SINGLELINE | DT_END_ELLIPSIS);
{ int x = ban.left + S(24), by = ban.top + S(84);
if (g) {
int tw = text_w(dc, g->region, f_small) + S(16);
RECT chip = rc(x, by, x + tw, by + S(22));
rrect(dc, chip, 22, C_CARD, C_LINE);
text(dc, g->region, chip, f_small, C_TEXT, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
x += tw + S(10);
}
SelectObject(dc, GetStockObject(DC_BRUSH)); SelectObject(dc, GetStockObject(NULL_PEN));
SetDCBrushColor(dc, sc); Ellipse(dc, x, by + S(7), x + S(9), by + S(16));
text(dc, st, rc(x + S(15), by, ban.right - S(200), by + S(22)), f_body, C_TEXT, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
y = ban.bottom + S(24);
/* actions */
int supported = (d->status == ST_OK || d->status == ST_REVISION) && g && g->exe;
int installed = supported && game_installed(g);
int building = bld.active && bld.disc == sel;
if (building) { /* the build's progress in place of the buttons */
RECT pc = rc(area.left, y, area.right, y + S(48));
int bw = (area.right - S(110)) - area.left;
RECT track = rc(area.left, y + S(30), area.left + bw - S(12), y + S(38));
rrect(dc, track, 8, C_CARD, C_LINE);
int fw = (int)((track.right - track.left) * (bld.frac < 0 ? 0 : bld.frac > 1 ? 1 : bld.frac));
if (fw > S(8)) rrect(dc, rc(track.left, track.top, track.left + fw, track.bottom), 8, C_ACCENT, C_ACCENT);
wchar_t pcnt[16]; swprintf(pcnt, 16, L"%d%%", (int)(bld.frac * 100 + 0.5));
text(dc, bld.status, rc(area.left, y, area.left + bw - S(70), y + S(24)), f_bold, C_TEXT, DT_LEFT | DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS);
text(dc, pcnt, rc(area.left + bw - S(70), y, area.left + bw - S(12), y + S(24)), f_bold, C_MUTED, DT_RIGHT | DT_SINGLELINE | DT_VCENTER);
button_i(dc, rc(area.right - S(110), y + S(4), area.right, y + S(44)), bld.cancel ? L"Stopping\u2026" : L"Cancel", W_CANCEL, 0, 0, !bld.cancel, IMG_I_CANCEL);
(void)pc;
} else {
int can = supported && running < 0 && (installed || (g->kit && !bld.active));
const wchar_t *pl = running == sel ? L"Running\u2026" : (supported && !installed && g->kit) ? L"Build" : L"Play";
button_i(dc, rc(area.left, y, area.left + S(170), y + S(48)), pl, W_PLAY, 0, 1, can, (supported && !installed && g->kit) ? IMG_I_BUILD : IMG_I_PLAY);
int bx = area.left + S(182);
int has_files = g && g->exe;
button_i(dc, rc(bx, y + S(4), bx + S(128), y + S(44)), L"Settings", W_SETTINGS, 0, 0, 1, IMG_I_SETTINGS); bx += S(140);
button_i(dc, rc(bx, y + S(4), bx + S(128), y + S(44)), L"Open log", W_LOG, 0, 0, has_files, IMG_I_LOG); bx += S(140);
button_i(dc, rc(bx, y + S(4), bx + S(150), y + S(44)), L"Game folder", W_FOLDER, 0, 0, has_files, IMG_I_FOLDER);
button_i(dc, rc(area.right - S(120), y + S(4), area.right, y + S(44)), L"Remove", W_REMOVE, 0, 0, running != sel && !(bld.active && bld.disc == sel), IMG_I_REMOVE);
}
y += S(60);
if (bld.failed && bld.disc == sel && !building) text(dc, bld.err, rc(area.left, y, area.right, y + S(22)), f_body, C_BAD, DT_LEFT | DT_SINGLELINE | DT_END_ELLIPSIS);
else if (message[0]) { text(dc, message, rc(area.left, y, area.right, y + S(22)), f_body, message_bad ? C_BAD : C_MUTED, DT_LEFT | DT_SINGLELINE | DT_END_ELLIPSIS); }
else if (building) text(dc, L"Building from your disc. You can keep using your PC; closing this window stops the build.", rc(area.left, y, area.right, y + S(22)), f_body, C_MUTED, DT_LEFT | DT_SINGLELINE | DT_END_ELLIPSIS);
else if (supported && !installed && g->kit) text(dc, L"First time: downloads about 95 MB of tools and source, then builds the game from your disc (10 to 30 minutes).", rc(area.left, y, area.right, y + S(22)), f_body, C_MUTED, DT_LEFT | DT_SINGLELINE | DT_END_ELLIPSIS);
else if (d->status == ST_REVISION) text(dc, L"This disc's executable differs from the one this build was made from. It may not run correctly.", rc(area.left, y, area.right, y + S(22)), f_body, C_WARN, DT_LEFT | DT_SINGLELINE | DT_END_ELLIPSIS);
else if (d->status == ST_NOTYET) text(dc, L"This game is recognised, but its recompilation is not built yet.", rc(area.left, y, area.right, y + S(22)), f_body, C_MUTED, DT_LEFT | DT_SINGLELINE);
y += S(36);
/* the disc */
RECT card = rc(area.left, y, area.right, y + S(190));
rrect(dc, card, 10, C_CARD, C_LINE);
int x = card.left + S(20), iy = card.top + S(16), iw = w - S(40);
text(dc, L"Disc", rc(x, iy, x + iw, iy + S(22)), f_head, C_TEXT, DT_LEFT | DT_SINGLELINE); iy += S(32);
info_row(dc, x, &iy, iw, L"Image", d->path, C_TEXT);
MultiByteToWideChar(CP_ACP, 0, d->serial[0] ? d->serial : "none", -1, b, 64);
info_row(dc, x, &iy, iw, L"Boot executable", b, C_TEXT);
if (d->md5[0]) {
wchar_t m[64]; MultiByteToWideChar(CP_ACP, 0, d->md5, -1, m, 64);
swprintf(b, MAX_PATH + 64, L"%ls %ls", m, d->status == ST_OK ? L"(matches)" : d->status == ST_REVISION ? L"(different revision)" : L"");
info_row(dc, x, &iy, iw, L"MD5", b, d->status == ST_REVISION ? C_WARN : C_TEXT);
} else info_row(dc, x, &iy, iw, L"MD5", L"–", C_DIM);
if (g && g->exe) {
wchar_t gd[MAX_PATH]; game_dir(g, gd);
if (game_installed(g)) info_row(dc, x, &iy, iw, L"Game files", gd, C_TEXT);
else info_row(dc, x, &iy, iw, L"Game files", g->kit ? L"Not built yet" : L"Missing", g->kit ? C_MUTED : C_BAD);
}
else info_row(dc, x, &iy, iw, L"Game files", L"–", C_DIM);
y = card.bottom + S(12);
/* the controller: the PS2's buttons and the same buttons on an Xbox pad (assets/png/controller_colour) */
if (g && g->exe && y + S(232) < area.bottom) {
RECT c2 = rc(area.left, y, area.right, y + S(232));
rrect(dc, c2, 10, C_CARD, C_LINE);
x = c2.left + S(20); iy = c2.top + S(16);
text(dc, L"Controller", rc(x, iy, x + S(200), iy + S(22)), f_head, C_TEXT, DT_LEFT | DT_SINGLELINE);
text(dc, L"Xbox, DualShock 4 and DualSense, over USB or Bluetooth", rc(x + S(110), iy, x + iw, iy + S(22)), f_small, C_MUTED, DT_RIGHT | DT_SINGLELINE | DT_VCENTER);
iy += S(36);
static const struct { int ps, xb; const wchar_t *pn, *xn; } rows[10] = {
{ IMG_PS_CROSS, IMG_XB_A, L"Cross", L"A" }, { IMG_PS_CIRCLE, IMG_XB_B, L"Circle", L"B" }, { IMG_PS_SQUARE, IMG_XB_X, L"Square", L"X" },
{ IMG_PS_TRIANGLE, IMG_XB_Y, L"Triangle", L"Y" }, { IMG_PS_START, IMG_XB_MENU, L"Start", L"Menu" },
{ IMG_PS_L1, IMG_XB_LB, L"L1", L"LB" }, { IMG_PS_R1, IMG_XB_RB, L"R1", L"RB" }, { IMG_PS_L2, IMG_XB_LT, L"L2", L"LT" },
{ IMG_PS_R2, IMG_XB_RT, L"R2", L"RT" }, { IMG_PS_SELECT, IMG_XB_VIEW, L"Select", L"View" } };
int col = iw / 2, gs = S(24), rh = S(30);
for (int i = 0; i < 10; i++) {
int cx0 = x + (i / 5) * col, ry = iy + (i % 5) * rh;
img_draw(dc, rows[i].ps, cx0, ry, gs, gs);
text(dc, rows[i].pn, rc(cx0 + gs + S(10), ry, cx0 + gs + S(100), ry + gs), f_body, C_MUTED, DT_LEFT | DT_SINGLELINE | DT_VCENTER);
text(dc, L"\u2192", rc(cx0 + S(120), ry, cx0 + S(144), ry + gs), f_body, C_DIM, DT_CENTER | DT_SINGLELINE | DT_VCENTER);
img_draw(dc, rows[i].xb, cx0 + S(154), ry, gs, gs);
text(dc, rows[i].xn, rc(cx0 + S(154) + gs + S(10), ry, cx0 + col - S(8), ry + gs), f_bold, C_TEXT, DT_LEFT | DT_SINGLELINE | DT_VCENTER);
}
}
}
static void paint(HDC dc, RECT cr)
{
nwid = 0;
{ /* the starfield tile (512 x 512 at 3x: 171 px at 100%) behind the page */
HBRUSH tb = img_tile_brush(IMG_BGTILE, S(171));
if (tb) { SetBrushOrgEx(dc, 0, 0, 0); FillRect(dc, &cr, tb); } else fill(dc, cr, C_BG);
}
int sw = S(300), hh = S(64);
/* sidebar */
RECT side = rc(0, 0, sw, cr.bottom);
fill(dc, side, C_SIDE);
fill(dc, rc(sw - 1, 0, sw, cr.bottom), C_LINE);
/* the wordmark (assets/png/wordmark.png, 260 x 40 at 100%) */
if (!img_draw(dc, IMG_WORDMARK, S(20), S(14), S(260), S(40)))
text(dc, L"PROJECT SOLANA", rc(S(20), S(18), sw - S(10), S(46)), f_logo, C_TEXT, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
text(dc, L"LIBRARY", rc(S(20), hh + S(6), sw - S(20), hh + S(26)), f_small, C_DIM, DT_LEFT | DT_SINGLELINE);
int ly = hh + S(32) - scroll, ih = S(68);
HRGN clip = CreateRectRgn(0, hh + S(30), sw - 1, cr.bottom - S(116));
SelectClipRgn(dc, clip);
for (int i = 0; i < ndiscs; i++, ly += ih + S(6)) {
RECT it = rc(S(12), ly, sw - S(12), ly + ih);
if (it.bottom < hh || it.top > cr.bottom) continue;
disc_t *d = &discs[i];
COLORREF bg = i == sel ? C_SELECT : is_hover(W_ITEM, i) ? C_CARD : C_SIDE;
rrect(dc, it, 10, bg, i == sel ? C_LINE : bg);
if (i == sel) fill(dc, rc(it.left, it.top + S(14), it.left + S(3), it.bottom - S(14)), C_ACCENT);
wchar_t t[160], st[64], sub[128]; COLORREF sc;
disc_title(d, t); status_text(d, st, &sc);
text(dc, t, rc(it.left + S(16), it.top + S(10), it.right - S(12), it.top + S(32)), f_bold, C_TEXT, DT_LEFT | DT_SINGLELINE | DT_END_ELLIPSIS);
if (d->game >= 0) swprintf(sub, 128, L"%ls", games[d->game].region); else sub[0] = 0;
int x = it.left + S(16);
if (sub[0]) { text(dc, sub, rc(x, it.top + S(36), it.right, it.top + S(56)), f_small, C_MUTED, DT_LEFT | DT_SINGLELINE); x += text_w(dc, sub, f_small) + S(10); }
SelectObject(dc, GetStockObject(DC_BRUSH)); SelectObject(dc, GetStockObject(NULL_PEN));
SetDCBrushColor(dc, sc); Ellipse(dc, x, it.top + S(42), x + S(7), it.top + S(49));
text(dc, st, rc(x + S(12), it.top + S(36), it.right - S(12), it.top + S(56)), f_small, C_MUTED, DT_LEFT | DT_SINGLELINE | DT_END_ELLIPSIS);
add_w(it, W_ITEM, i);
}
SelectClipRgn(dc, 0); DeleteObject(clip);
if (!ndiscs) text(dc, L"No games yet", rc(S(20), hh + S(36), sw - S(20), hh + S(60)), f_body, C_DIM, DT_LEFT | DT_SINGLELINE);
fill(dc, rc(0, cr.bottom - S(116), sw - 1, cr.bottom - S(115)), C_LINE);
button_i(dc, rc(S(16), cr.bottom - S(100), sw - S(16), cr.bottom - S(60)), L"Add disc image", W_ADD, 0, 0, 1, IMG_I_ADD);
button_i(dc, rc(S(16), cr.bottom - S(52), sw - S(16), cr.bottom - S(16)), L"Scan a folder", W_SCAN, 0, 0, 1, IMG_I_SCAN);
RECT area = rc(sw + S(24), S(24), cr.right - S(24), cr.bottom - S(24));
if (view_settings) draw_settings(dc, area); else draw_details(dc, area);
}
/* ---- input ---------------------------------------------------------------------------------- */
static widget_t *hit(int x, int y)
{
POINT p = { x, y };
for (int i = nwid - 1; i >= 0; i--) if (PtInRect(&wid[i].r, p)) return &wid[i];
return 0;
}
static void click(int id, int arg)
{
switch (id) {
case W_ADD: pick_files(); view_settings = 0; break;
case W_SCAN: pick_folder(); view_settings = 0; break;
case W_ITEM: sel = arg; view_settings = 0; message[0] = 0; identify(&discs[sel]); save_all(); break;
case W_PLAY:
if (sel >= 0 && discs[sel].game >= 0 && games[discs[sel].game].exe && !game_installed(&games[discs[sel].game])) build_start(sel);
else play(sel);
break;
case W_CANCEL: bld.cancel = 1; if (bld.job) TerminateJobObject(bld.job, 1); break;
case W_REBUILD: if (sel >= 0 && running < 0) { view_settings = 0; build_start(sel); } break;
case W_SETTINGS: view_settings = 1; break;
case W_BACK: view_settings = 0; save_all(); break;
case W_LOG: open_log(sel); break;
case W_FOLDER: open_folder(sel); break;
case W_REMOVE:
if (sel >= 0 && running != sel) {
memmove(&discs[sel], &discs[sel + 1], (size_t)(ndiscs - sel - 1) * sizeof discs[0]);
ndiscs--;
if (running > sel) running--;
if (sel >= ndiscs) sel = ndiscs - 1;
message[0] = 0; save_all();
}
break;
case W_RENDER: set_upscale = arg; save_all(); break;
case W_TOGGLE: {
int *v[] = { &set_vsync, &set_nomtgs, &set_noglt, &set_fpslog, &set_minimize };
*v[arg] = !*v[arg]; save_all(); break; }
case W_CAPDOWN: if (set_capsec > 1) set_capsec--; save_all(); break;
case W_CAPUP: if (set_capsec < 10) set_capsec++; save_all(); break;
case W_LVLDOWN: set_level = set_level < 0 ? 18 : set_level - 1; save_all(); break;
case W_LVLUP: set_level = set_level >= 18 ? -1 : set_level + 1; save_all(); break;
}
InvalidateRect(hwnd, 0, 0);
}
static LRESULT CALLBACK wndproc(HWND h, UINT m, WPARAM w, LPARAM l)
{
switch (m) {
case WM_PAINT: {
PAINTSTRUCT ps; HDC dc = BeginPaint(h, &ps);
RECT cr; GetClientRect(h, &cr);
HDC mem = CreateCompatibleDC(dc);
HBITMAP bm = CreateCompatibleBitmap(dc, cr.right, cr.bottom);
HGDIOBJ old = SelectObject(mem, bm);
paint(mem, cr);
BitBlt(dc, 0, 0, cr.right, cr.bottom, mem, 0, 0, SRCCOPY);
SelectObject(mem, old); DeleteObject(bm); DeleteDC(mem);
EndPaint(h, &ps);
return 0; }
case WM_ERASEBKGND: return 1;
case WM_MOUSEMOVE: {
widget_t *x = hit(GET_X_LPARAM(l), GET_Y_LPARAM(l));
int id = x ? x->id : W_NONE, arg = x ? x->arg : 0;
if (id != hover_id || arg != hover_arg) { hover_id = id; hover_arg = arg; InvalidateRect(h, 0, 0); }
SetCursor(LoadCursor(0, id != W_NONE ? IDC_HAND : IDC_ARROW));
TRACKMOUSEEVENT t = { sizeof t, TME_LEAVE, h, 0 }; TrackMouseEvent(&t);
return 0; }
case WM_MOUSELEAVE: hover_id = W_NONE; InvalidateRect(h, 0, 0); return 0;
case WM_SETCURSOR: if (LOWORD(l) == HTCLIENT) return 1; break;
case WM_LBUTTONDOWN: {
widget_t *x = hit(GET_X_LPARAM(l), GET_Y_LPARAM(l));
press_id = x ? x->id : W_NONE; press_arg = x ? x->arg : 0;
SetCapture(h); InvalidateRect(h, 0, 0);
return 0; }
case WM_LBUTTONUP: {
widget_t *x = hit(GET_X_LPARAM(l), GET_Y_LPARAM(l));
int id = press_id, arg = press_arg;
press_id = W_NONE; ReleaseCapture();
if (x && x->id == id && x->arg == arg) click(id, arg); else InvalidateRect(h, 0, 0);
return 0; }
case WM_LBUTTONDBLCLK: {
widget_t *x = hit(GET_X_LPARAM(l), GET_Y_LPARAM(l));
if (x && x->id == W_ITEM) { sel = x->arg; if (discs[sel].game >= 0 && games[discs[sel].game].exe && game_installed(&games[discs[sel].game])) play(sel); else InvalidateRect(h, 0, 0); }
return 0; }
case WM_MOUSEWHEEL: {
RECT cr; GetClientRect(h, &cr);
int total = ndiscs * (S(68) + S(6)), view = cr.bottom - S(116) - S(96);
scroll -= GET_WHEEL_DELTA_WPARAM(w) / 120 * S(60);
if (scroll > total - view) scroll = total - view;
if (scroll < 0) scroll = 0;
InvalidateRect(h, 0, 0);
return 0; }
case WM_KEYDOWN:
if (w == VK_RETURN && sel >= 0 && discs[sel].game >= 0 && games[discs[sel].game].exe && game_installed(&games[discs[sel].game])) play(sel);
else if ((w == VK_DOWN || w == VK_UP) && ndiscs) { sel += w == VK_DOWN ? 1 : -1; if (sel < 0) sel = 0; if (sel >= ndiscs) sel = ndiscs - 1; view_settings = 0; message[0] = 0; save_all(); InvalidateRect(h, 0, 0); }
else if (w == VK_ESCAPE && view_settings) { view_settings = 0; InvalidateRect(h, 0, 0); }
return 0;
case WM_DROPFILES: {
HDROP d = (HDROP)w; wchar_t p[MAX_PATH];
UINT n = DragQueryFileW(d, 0xFFFFFFFF, 0, 0);
for (UINT i = 0; i < n; i++) if (DragQueryFileW(d, i, p, MAX_PATH)) {
if (GetFileAttributesW(p) & FILE_ATTRIBUTE_DIRECTORY) scan_dir(p, 0); else add_disc(p, 1);
}
DragFinish(d);
view_settings = 0; save_all(); InvalidateRect(h, 0, 0);
return 0; }
case WM_BUILD_UPDATE:
if (w == 1) { /* finished */
if (!bld.failed) { wcscpy(message, L"Built. Ready to play."); message_bad = 0; FlashWindow(h, TRUE); }
if (IsIconic(h)) ShowWindow(h, SW_RESTORE);
}
InvalidateRect(h, 0, 0);
return 0;
case WM_CLOSE:
if (bld.active && MessageBoxW(h, L"A game is being built. Stop the build and close?", L"Project Solana", MB_YESNO | MB_ICONQUESTION) != IDYES) return 0;
DestroyWindow(h);
return 0;
case WM_GAME_EXIT:
if (game_proc) { CloseHandle(game_proc); game_proc = 0; }
if (w) { swprintf(message, 256, L"The game closed with an error (code 0x%lX). Open log shows what happened.", (unsigned long)w); message_bad = 1; }
running = -1;
if (IsIconic(h)) ShowWindow(h, SW_RESTORE);
InvalidateRect(h, 0, 0);
return 0;
case WM_GETMINMAXINFO: { MINMAXINFO *mi = (MINMAXINFO *)l; mi->ptMinTrackSize.x = S(940); mi->ptMinTrackSize.y = S(800); return 0; }
case WM_DPICHANGED: {
dpi = HIWORD(w) / 96.0f; make_fonts(); img_flush();
RECT *r = (RECT *)l; SetWindowPos(h, 0, r->left, r->top, r->right - r->left, r->bottom - r->top, SWP_NOZORDER | SWP_NOACTIVATE);
return 0; }
case WM_SIZE: InvalidateRect(h, 0, 0); return 0;
case WM_DESTROY: save_all(); PostQuitMessage(0); return 0;
}
return DefWindowProcW(h, m, w, l);
}
int WINAPI wWinMain(HINSTANCE inst, HINSTANCE prev, PWSTR cmdline, int show)
{
(void)prev; (void)cmdline;
{ /* per-monitor DPI when Windows has it (10 1703+), else system DPI */
typedef BOOL (WINAPI *ctx_t)(HANDLE);
ctx_t f = (ctx_t)(void *)GetProcAddress(GetModuleHandleW(L"user32.dll"), "SetProcessDpiAwarenessContext");
if (!f || !f((HANDLE)-4)) SetProcessDPIAware();
}
CoInitializeEx(0, COINIT_APARTMENTTHREADED);
GetModuleFileNameW(0, base_dir, MAX_PATH);
wchar_t *s = wcsrchr(base_dir, L'\\'); if (s) *s = 0;
swprintf(ini_path, MAX_PATH, L"%ls\\solana.ini", base_dir);
{ HDC dc = GetDC(0); dpi = GetDeviceCaps(dc, LOGPIXELSX) / 96.0f; ReleaseDC(0, dc); }
make_fonts();
load_all();
WNDCLASSEXW wc; memset(&wc, 0, sizeof wc);
wc.cbSize = sizeof wc; wc.style = CS_DBLCLKS; wc.lpfnWndProc = wndproc; wc.hInstance = inst;
wc.hIcon = LoadIconW(inst, MAKEINTRESOURCEW(1)); wc.hIconSm = wc.hIcon;
wc.hCursor = LoadCursor(0, IDC_ARROW); wc.lpszClassName = L"ProjectSolanaLauncher";
RegisterClassExW(&wc);
hwnd = CreateWindowExW(WS_EX_ACCEPTFILES, wc.lpszClassName, L"Project Solana", WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, CW_USEDEFAULT, S(1100), S(860), 0, 0, inst, 0);
{ /* the window's DPI may differ from the primary monitor's */
typedef UINT (WINAPI *gdw_t)(HWND);
gdw_t f = (gdw_t)(void *)GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetDpiForWindow");
if (f) { float d2 = f(hwnd) / 96.0f; if (d2 != dpi && d2 > 0) { dpi = d2; make_fonts(); SetWindowPos(hwnd, 0, 0, 0, S(1100), S(860), SWP_NOMOVE | SWP_NOZORDER); } }
}
{ /* dark title bar (Windows 10 2004+ / 11) */
HMODULE dw = LoadLibraryW(L"dwmapi.dll");
if (dw) {
typedef HRESULT (WINAPI *set_t)(HWND, DWORD, LPCVOID, DWORD);
set_t f = (set_t)(void *)GetProcAddress(dw, "DwmSetWindowAttribute");
BOOL on = TRUE; if (f) { f(hwnd, 20, &on, sizeof on); COLORREF cap = C_SIDE; f(hwnd, 35, &cap, sizeof cap); }
}
}
ShowWindow(hwnd, show);
UpdateWindow(hwnd);
MSG msg;
while (GetMessageW(&msg, 0, 0, 0) > 0) { TranslateMessage(&msg); DispatchMessageW(&msg); }
return 0;
}