Files
NX2navi/runtime/gfx_window_win.c
chrg6 a135c19450 Ignore the focus loss that the close's own hide of the window sends, so the exit summary keeps the session's frames.
The review of the window close change found that hiding the active window in gfx_window_win.c exit_start makes
Windows send WM_ACTIVATEAPP(FALSE), which wnd_proc passed on to gfx_window_focus_changed(0). In play, perf.c
perf_focus_lost then asked for the session's reports, and the present thread, which runs until rt_exit_steps reaches
prof_write, wrote "frametime: at focus loss" for the whole session at its next present, with the stutter map and the
memory ranking, and started a new period. "frametime: exit" then covered only the few frames since the hide, or was
missing when none came, so a normal close no longer left the exit summary the play-test's "Normal close" item reads.

wnd_proc now returns at a focus loss once g_exit_started is set. exit_start sets it on the window thread before
ShowWindow, and the message comes on the same thread inside that call, so the check has no race. A gained focus is
still passed on.

Not reproduced before the change (it needs a play run). tests/exit_order_check.py checks statically that
WM_ACTIVATEAPP returns before gfx_window_focus_changed for a loss once g_exit_started is set and that exit_start sets
the flag before the hide; the check fails on the code before this change and passes after it. The compile check of
gfx_window_win.c passes with no new -Wall warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:43:23 -05:00

825 lines
41 KiB
C

// The Win32 back end of the window (gfx_wsys.h): the window and its message loop on a thread of their own, F11
// fullscreen, the settings NX2NAVI_WINDOW_SIZE, NX2NAVI_FULLSCREEN and NX2NAVI_MONITOR, the monitor's refresh rate, focus, the Vulkan surface.
// The swapchain and the present are gfx_present.c's. The in-game menu's keyboard and mouse come from the window
// procedure (ui/menu_win.cpp), and so do the game's (input_win.c).
//
// The process is per-monitor DPI aware (nx2navi.manifest): every size here is physical pixels, NX2NAVI_WINDOW_SIZE included,
// and the swapchain matches the screen pixel for pixel. A window moved to a monitor at another display scale keeps its
// client size (WM_GETDPISCALEDSIZE); the menu takes its text size floor from the window's DPI (gfx_window_dpi).
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <windows.h>
#include <imm.h>
#include "cfg.h"
#include "flight.h"
#include "fs.h"
#include "gfx.h"
#include "gfx_wsys.h"
#include "input.h"
#include "input_win.h"
#include "nvn_api.h"
#include "ui/menu.h"
#include "vk.h"
#include "vk_dlssg.h"
#include "platform/platform.h"
#include <vulkan/vulkan_win32.h>
// ---- window state
static HWND g_hwnd;
static PlatEvent g_wnd_ready; // manual-reset: the window thread made the window or gave up
static int g_wnd_failed;
static int g_background; // NX2NAVI_BACKGROUND_WINDOW: the window stays behind the user's windows
static int g_fs_pending; // window thread: NX2NAVI_FULLSCREEN did not apply before the first show; retried at the first activation
static volatile int32_t g_dpi = USER_DEFAULT_SCREEN_DPI; // the window's DPI, set by the window thread
HWND gfx_window_handle(void) { return g_hwnd; }
int gfx_window_vk_extensions(const char **out, int max) {
if (max < 2) return 0;
out[0] = VK_KHR_SURFACE_EXTENSION_NAME;
out[1] = VK_KHR_WIN32_SURFACE_EXTENSION_NAME;
return 2;
}
int gfx_window_vk_presentable(VkInstance inst, VkPhysicalDevice pd, uint32_t family) {
(void)inst;
return vkGetPhysicalDeviceWin32PresentationSupportKHR(pd, family) != VK_FALSE;
}
// ---- window thread
#define WND_STYLE WS_OVERLAPPEDWINDOW
#define WM_APP_SETTINGS (WM_APP + 1)
#define WM_APP_FULLSCREEN (WM_APP + 2) // the fullscreen setting, retried at the first activation
#define WM_APP_TEXT_INPUT (WM_APP + 3) // the menu's text field takes text (wParam 1) or no longer (0)
#define WM_APP_PASTE (WM_APP + 4) // the menu's Paste: the clipboard's text, read on the window thread
static WINDOWPLACEMENT g_saved_place = {sizeof g_saved_place}; // the window behind fullscreen (window thread)
static int is_fullscreen(HWND h) { return !(GetWindowLongW(h, GWL_STYLE) & WS_OVERLAPPEDWINDOW); }
#define cfg_fullscreen gfx_cfg_fullscreen
#define cfg_window_size gfx_cfg_window_size
unsigned gfx_window_dpi(void) { return (unsigned)plat_cas32(&g_dpi, 0, 0); }
// The outer size of a framed window whose client area is cw x ch, with the frame measured at dpi.
static void outer_size_at(int cw, int ch, UINT dpi, int *ow, int *oh) {
RECT r = {0, 0, cw, ch};
AdjustWindowRectExForDpi(&r, WND_STYLE, FALSE, 0, dpi);
*ow = r.right - r.left;
*oh = r.bottom - r.top;
}
// The same at the window's DPI, or the system DPI before the window exists: plain AdjustWindowRect always takes the
// system DPI, which is wrong on a monitor at another display scale.
static void outer_size(HWND h, int cw, int ch, int *ow, int *oh) {
outer_size_at(cw, ch, h ? GetDpiForWindow(h) : GetDpiForSystem(), ow, oh);
}
// The client size cw x ch scaled down, in its own proportions, so the whole window fits a work area: the chosen
// monitor's (work, with the frame measured at dpi) or, with work NULL, the primary monitor's, where CW_USEDEFAULT opens
// it. The default 1920x1080 on a 1080p laptop put the bottom of the picture (the HUD) under the taskbar and past the
// screen. The setting itself stays; the menu's size list offers what fits.
static void fit_work_area(int *cw, int *ch, const RECT *work, UINT dpi) {
RECT prim;
int ow, oh;
if (!work) {
if (!SystemParametersInfoW(SPI_GETWORKAREA, 0, &prim, 0)) return;
work = &prim;
}
outer_size_at(*cw, *ch, dpi, &ow, &oh);
int aw = (work->right - work->left) - (ow - *cw), ah = (work->bottom - work->top) - (oh - *ch);
if (aw <= 0 || ah <= 0 || (*cw <= aw && *ch <= ah)) return;
double k = (double)aw / *cw < (double)ah / *ch ? (double)aw / *cw : (double)ah / *ch;
int w = (int)(*cw * k) & ~1, hh = (int)(*ch * k) & ~1;
if (w < 640 || hh < 360) return;
rt_log("gfx: window %dx%d does not fit the screen's work area; opening at %dx%d", *cw, *ch, w, hh);
*cw = w;
*ch = hh;
}
// Windows turns the display off and sleeps after the idle timeout, and gamepad input does not count as activity. While
// the window is up and not minimized it holds the display and the system on, as other games do. The state belongs to
// the calling thread, so only the window thread (window_main and wnd_proc) calls this; the process exit releases it.
static void keep_awake(int on) {
static int cur = -1;
if (on == cur) return;
cur = on;
SetThreadExecutionState(on ? ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED : ES_CONTINUOUS);
}
// F11: borderless window over the whole monitor, and back to the saved window. The swapchain follows through WM_SIZE.
// Not Alt+Enter: Enter is the Plus button (input_win.c). A background window (test runs) keeps its place behind the
// user's windows and takes no focus.
static void toggle_fullscreen(HWND h) {
LONG style = GetWindowLongW(h, GWL_STYLE);
if (style & WS_OVERLAPPEDWINDOW) {
MONITORINFO mi = {sizeof mi};
if (!GetWindowPlacement(h, &g_saved_place) || !GetMonitorInfoW(MonitorFromWindow(h, MONITOR_DEFAULTTOPRIMARY), &mi))
return;
SetWindowLongW(h, GWL_STYLE, (style & ~WS_OVERLAPPEDWINDOW) | WS_POPUP);
SetWindowPos(h, HWND_TOP, mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right - mi.rcMonitor.left,
mi.rcMonitor.bottom - mi.rcMonitor.top,
SWP_NOOWNERZORDER | SWP_FRAMECHANGED | (g_background ? SWP_NOZORDER | SWP_NOACTIVATE : 0));
} else {
SetWindowLongW(h, GWL_STYLE, (style & ~WS_POPUP) | WND_STYLE);
if (g_background) g_saved_place.showCmd = SW_SHOWNOACTIVATE;
SetWindowPlacement(h, &g_saved_place);
SetWindowPos(h, NULL, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED |
(g_background ? SWP_NOACTIVATE : 0));
}
}
// Window thread, before the window is first shown: gives it the outer size ow x oh (the windowed size F11 returns to)
// and then makes it fullscreen on the monitor it was created on, so the first thing on screen is the fullscreen window.
// toggle_fullscreen saves the placement of a window that was never visible, which is its normal rectangle as just set.
// The window stays hidden here; the caller shows it. Returns whether the window is fullscreen now.
static int fullscreen_before_show(HWND h, int ow, int oh) {
SetWindowPos(h, NULL, 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
toggle_fullscreen(h);
g_saved_place.showCmd = SW_SHOWNORMAL; // a hidden window may report SW_HIDE, which F11 would restore as hidden
return is_fullscreen(h);
}
// The window size and fullscreen state last taken from the settings (window thread). apply_settings acts on a change of
// either value only, so a window resized by hand keeps its size when another setting changes.
static char g_applied_size[32];
static int g_applied_fs;
static void note_applied(void) {
snprintf(g_applied_size, sizeof g_applied_size, "%s", cfg_value("NX2NAVI_WINDOW_SIZE"));
g_applied_fs = cfg_fullscreen();
}
// Window thread: the window takes the size and fullscreen state of the settings when they changed. In fullscreen the
// size goes into the placement restored when fullscreen ends.
static void apply_settings(HWND h) {
int size_changed = strcmp(cfg_value("NX2NAVI_WINDOW_SIZE"), g_applied_size) != 0, fs = cfg_fullscreen(), fs_changed = fs != g_applied_fs;
note_applied();
if (size_changed) {
int cw, ch, ow, oh;
cfg_window_size(&cw, &ch);
outer_size(h, cw, ch, &ow, &oh);
if (is_fullscreen(h)) {
RECT *n = &g_saved_place.rcNormalPosition;
n->right = n->left + ow;
n->bottom = n->top + oh;
} else {
RECT have;
if (GetClientRect(h, &have) && (have.right != cw || have.bottom != ch)) {
if (IsZoomed(h)) ShowWindow(h, SW_RESTORE);
SetWindowPos(h, NULL, 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
}
}
}
if (fs_changed && fs != is_fullscreen(h)) {
input_cursor_suspend(1);
toggle_fullscreen(h);
input_cursor_suspend(0);
}
}
void gfx_window_apply_settings(void) {
if (g_hwnd) PostMessageW(g_hwnd, WM_APP_SETTINGS, 0, 0);
}
void gfx_window_request_close(void) {
if (g_hwnd) PostMessageW(g_hwnd, WM_CLOSE, 0, 0);
}
// The menu's text field lives in this window, which has no IME (detached at creation, below): while the field takes
// text the window gets the default one, so a player can type a name in Chinese, Japanese or Korean (Windows hands the
// composed characters over as WM_CHAR, which menu_window_msg passes on), and it is detached again after. Posted to
// the window thread, which owns the window's input context.
void gfx_window_text_input(int on) {
if (g_hwnd) PostMessageW(g_hwnd, WM_APP_TEXT_INPUT, (WPARAM)(on != 0), 0);
}
void gfx_window_paste(void) {
if (g_hwnd) PostMessageW(g_hwnd, WM_APP_PASTE, 0, 0);
}
// ---- refresh rate of the window's monitor (NX2NAVI_FPS_LIMIT=display), measured when the window opens, lands on another
// monitor or the display mode changes (gfx_window_set_refresh keeps it). QueryDisplayConfig gives the exact rational
// rate (59.94, 143.998); EnumDisplaySettings' whole hertz is the fallback.
static HMONITOR g_rate_mon;
static double monitor_rate_hz(HMONITOR mon) {
MONITORINFOEXW mi = {.cbSize = sizeof mi};
if (!GetMonitorInfoW(mon, (MONITORINFO *)&mi)) return 0;
double hz = 0;
UINT32 np = 0, nm = 0;
if (GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &np, &nm) == ERROR_SUCCESS && np && nm) {
DISPLAYCONFIG_PATH_INFO *paths = calloc(np, sizeof *paths);
DISPLAYCONFIG_MODE_INFO *modes = calloc(nm, sizeof *modes);
if (paths && modes && QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &np, paths, &nm, modes, NULL) == ERROR_SUCCESS)
for (UINT32 i = 0; i < np && !hz; i++) {
DISPLAYCONFIG_SOURCE_DEVICE_NAME src = {.header = {.type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME,
.size = sizeof src,
.adapterId = paths[i].sourceInfo.adapterId,
.id = paths[i].sourceInfo.id}};
DISPLAYCONFIG_RATIONAL r = paths[i].targetInfo.refreshRate;
if (DisplayConfigGetDeviceInfo(&src.header) == ERROR_SUCCESS && !wcscmp(src.viewGdiDeviceName, mi.szDevice) &&
r.Denominator)
hz = (double)r.Numerator / r.Denominator;
}
free(paths);
free(modes);
}
DEVMODEW dm = {.dmSize = sizeof dm};
if (!hz && EnumDisplaySettingsW(mi.szDevice, ENUM_CURRENT_SETTINGS, &dm) && dm.dmDisplayFrequency > 1)
hz = dm.dmDisplayFrequency; // 0 and 1 mean the hardware default
return hz;
}
// Window thread. force: the display mode changed, measure even on the same monitor.
static void refresh_rate_update(HWND h, int force) {
HMONITOR mon = MonitorFromWindow(h, MONITOR_DEFAULTTOPRIMARY);
if (!force && mon == g_rate_mon) return;
g_rate_mon = mon;
gfx_window_set_refresh(monitor_rate_hz(mon));
}
// ---- monitor choice (NX2NAVI_MONITOR, --monitor, --list-monitors)
// The monitors in EnumDisplayMonitors order, which is the index the setting counts in (1-based) and --list-monitors
// prints. The order follows the display driver's adapter and output order, so it holds while the same monitors stay
// attached and enabled, but a monitor that appears or goes away (a virtual display) can move the ones after it: the GDI
// device name or the monitor's name pins one monitor more firmly than its index.
#define MON_MAX 32
typedef struct {
HMONITOR h;
RECT rc, work; // the monitor's rectangle and its work area (without the taskbar), desktop pixels
int primary;
UINT dpi; // effective DPI of the monitor, 96 at 100% display scale
wchar_t device[CCHDEVICENAME]; // GDI device name, such as \\.\DISPLAY3
wchar_t name[64]; // the monitor's friendly name from the display configuration; empty when it has none
} MonInfo;
typedef struct { MonInfo m[MON_MAX]; int n; } MonList;
static void to_utf8(const wchar_t *w, char *out, size_t n) {
if (!WideCharToMultiByte(CP_UTF8, 0, w, -1, out, (int)n, NULL, NULL)) out[0] = 0;
}
// GetDpiForMonitor lives in shcore.dll, which the build does not link; 0 when it is missing. The library is loaded once
// and kept: the monitor list asks for every monitor's DPI each time it is built.
static UINT monitor_dpi(HMONITOR mon) {
typedef HRESULT(WINAPI * GetDpiForMonitorFn)(HMONITOR, int, UINT *, UINT *);
static GetDpiForMonitorFn fn;
static int looked;
if (!looked) {
HMODULE lib = LoadLibraryExW(L"shcore.dll", NULL, LOAD_LIBRARY_SEARCH_SYSTEM32);
fn = lib ? (GetDpiForMonitorFn)(void *)GetProcAddress(lib, "GetDpiForMonitor") : NULL;
looked = 1;
}
UINT x = 0, y = 0;
if (!fn || fn(mon, 0 /* MDT_EFFECTIVE_DPI */, &x, &y) != S_OK) x = 0;
return x;
}
static BOOL CALLBACK enum_monitor(HMONITOR h, HDC dc, LPRECT rc, LPARAM lp) {
(void)dc, (void)rc;
MonList *l = (MonList *)lp;
MONITORINFOEXW mi = {.cbSize = sizeof mi};
if (l->n >= MON_MAX) return FALSE;
if (!GetMonitorInfoW(h, (MONITORINFO *)&mi)) return TRUE;
MonInfo *m = &l->m[l->n++];
*m = (MonInfo){.h = h, .rc = mi.rcMonitor, .work = mi.rcWork, .primary = (mi.dwFlags & MONITORINFOF_PRIMARY) != 0,
.dpi = monitor_dpi(h)};
wcsncpy(m->device, mi.szDevice, CCHDEVICENAME - 1);
return TRUE;
}
// Fills MonInfo.name: an active display path ties a GDI device (the source) to the monitor attached to it (the target),
// and the target knows the monitor's name. Cloned monitors share a source, and the first path with a name wins.
static void fill_monitor_names(MonList *l) {
DISPLAYCONFIG_PATH_INFO *paths = NULL;
DISPLAYCONFIG_MODE_INFO *modes = NULL;
for (int attempt = 0; attempt < 3; attempt++) { // the topology can change between the size query and the query
UINT32 np = 0, nm = 0;
if (GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &np, &nm) != ERROR_SUCCESS || !np || !nm) break;
free(paths);
free(modes);
paths = calloc(np, sizeof *paths);
modes = calloc(nm, sizeof *modes);
if (!paths || !modes) break;
LONG rc = QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &np, paths, &nm, modes, NULL);
if (rc == ERROR_INSUFFICIENT_BUFFER) continue;
if (rc != ERROR_SUCCESS) break;
for (UINT32 i = 0; i < np; i++) {
DISPLAYCONFIG_SOURCE_DEVICE_NAME src = {.header = {.type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME,
.size = sizeof src,
.adapterId = paths[i].sourceInfo.adapterId,
.id = paths[i].sourceInfo.id}};
DISPLAYCONFIG_TARGET_DEVICE_NAME tgt = {.header = {.type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME,
.size = sizeof tgt,
.adapterId = paths[i].targetInfo.adapterId,
.id = paths[i].targetInfo.id}};
if (DisplayConfigGetDeviceInfo(&src.header) != ERROR_SUCCESS ||
DisplayConfigGetDeviceInfo(&tgt.header) != ERROR_SUCCESS)
continue;
for (int k = 0; k < l->n; k++)
if (!l->m[k].name[0] && !_wcsicmp(src.viewGdiDeviceName, l->m[k].device))
wcsncpy(l->m[k].name, tgt.monitorFriendlyDeviceName, (sizeof l->m[k].name / sizeof(wchar_t)) - 1);
}
break;
}
free(paths);
free(modes);
}
static int enum_monitors(MonList *l) {
*l = (MonList){0};
EnumDisplayMonitors(NULL, NULL, enum_monitor, (LPARAM)l);
fill_monitor_names(l);
return l->n;
}
// "2 \\.\DISPLAY3 "Name" 1920x1080 at 2560,0 primary dpi 144 (150%)": one monitor, for the log and --list-monitors.
static void monitor_line(const MonList *l, int i, char *out, size_t cap) {
const MonInfo *m = &l->m[i];
char dev[CCHDEVICENAME * 4], name[64 * 4];
to_utf8(m->device, dev, sizeof dev);
to_utf8(m->name, name, sizeof name);
snprintf(out, cap, "%d %s \"%s\" %ldx%ld at %ld,%ld%s dpi %u (%u%%)", i + 1, dev, name, m->rc.right - m->rc.left,
m->rc.bottom - m->rc.top, m->rc.left, m->rc.top, m->primary ? " primary" : "", m->dpi,
m->dpi * 100 / USER_DEFAULT_SCREEN_DPI);
}
static int monitor_unset(const char *v) { return !v || !*v || !_stricmp(v, "auto"); }
// Whether text contains needle_lower, which is already lower-cased; the comparison ignores case.
static int contains_nocase(const wchar_t *text, const wchar_t *needle_lower) {
wchar_t buf[CCHDEVICENAME + 64];
if (!*needle_lower) return 0;
wcsncpy(buf, text, (sizeof buf / sizeof *buf) - 1);
buf[(sizeof buf / sizeof *buf) - 1] = 0;
CharLowerBuffW(buf, (DWORD)wcslen(buf));
return wcsstr(buf, needle_lower) != NULL;
}
// Which monitor of the list a NX2NAVI_MONITOR value (not empty, not auto) names, as a 0-based index; -1 when none.
// Rules, in this order: "primary"; all digits, a 1-based index; any other text, a case-insensitive substring of a
// monitor's GDI device name or friendly name (the first monitor in list order when several match). why gets the
// rule that matched, or the reason nothing did.
static int resolve_monitor(const MonList *l, const char *want, char *why, size_t cap) {
if (!_stricmp(want, "primary")) {
for (int i = 0; i < l->n; i++)
if (l->m[i].primary) {
snprintf(why, cap, "the primary monitor");
return i;
}
snprintf(why, cap, "no monitor is marked primary");
return -1;
}
if (want[strspn(want, "0123456789")] == 0) {
long idx = strtol(want, NULL, 10);
if (idx >= 1 && idx <= l->n) {
snprintf(why, cap, "index %ld of %d", idx, l->n);
return (int)idx - 1;
}
snprintf(why, cap, "index %s is outside 1..%d", want, l->n);
return -1;
}
wchar_t needle[CCHDEVICENAME + 64];
if (!MultiByteToWideChar(CP_UTF8, 0, want, -1, needle, (int)(sizeof needle / sizeof *needle))) {
snprintf(why, cap, "the text is not valid UTF-8 or is too long");
return -1;
}
CharLowerBuffW(needle, (DWORD)wcslen(needle));
int first = -1, hits = 0;
for (int i = 0; i < l->n; i++)
if (contains_nocase(l->m[i].device, needle) || contains_nocase(l->m[i].name, needle)) {
if (first < 0) first = i;
hits++;
}
if (first < 0) {
snprintf(why, cap, "no device name or monitor name contains it");
return -1;
}
if (hits > 1) snprintf(why, cap, "device or monitor name contains it; the first of %d matches", hits);
else snprintf(why, cap, "device or monitor name contains it");
return first;
}
// The monitor NX2NAVI_MONITOR picks, copied to *out; 0 when it is unset or auto (the system places the window), or when
// it matches nothing now (the monitors present are logged, and the default placement applies). log: say what was decided
// and why. The window thread asks with log set; the size query below, which widescreen.c makes before the window
// exists, stays quiet.
static int chosen_monitor(MonInfo *out, int log) {
const char *want = cfg_get("NX2NAVI_MONITOR");
if (monitor_unset(want)) return 0;
MonList l;
char why[256], line[512];
int n = enum_monitors(&l), pick = -1;
if (n) pick = resolve_monitor(&l, want, why, sizeof why);
else snprintf(why, sizeof why, "no monitor was found");
if (pick < 0) {
if (log) {
rt_log("gfx: NX2NAVI_MONITOR=%s picks no monitor (%s); the window gets the default placement. Monitors present:",
want, why);
for (int i = 0; i < n; i++) {
monitor_line(&l, i, line, sizeof line);
rt_log("gfx: %s", line);
}
}
return 0;
}
if (log) {
monitor_line(&l, pick, line, sizeof line);
rt_log("gfx: NX2NAVI_MONITOR=%s picks monitor %s (%s)", want, line, why);
}
*out = l.m[pick];
return 1;
}
// --list-monitors (main.c): one line per monitor, then what the current NX2NAVI_MONITOR would pick. No game files.
int list_monitors(void) {
MonList l;
char line[512], why[256];
int n = enum_monitors(&l);
if (!n) {
fprintf(stderr, "list-monitors: no monitor found\n");
return 1;
}
printf("index device name desktop rectangle primary dpi\n");
for (int i = 0; i < n; i++) {
monitor_line(&l, i, line, sizeof line);
printf("%s\n", line);
}
const char *want = cfg_get("NX2NAVI_MONITOR");
if (monitor_unset(want)) {
printf("NX2NAVI_MONITOR is not set (auto): the system places the window\n");
} else {
int pick = resolve_monitor(&l, want, why, sizeof why);
if (pick >= 0) printf("NX2NAVI_MONITOR=%s picks monitor %d (%s)\n", want, pick + 1, why);
else printf("NX2NAVI_MONITOR=%s picks no monitor (%s)\n", want, why);
}
return 0;
}
// The top-left that centres an ow x oh window in work; a window larger than the work area keeps its corner at the
// work area's corner, so its title bar stays reachable.
static void center_in(const RECT *work, int ow, int oh, int *x, int *y) {
int ww = work->right - work->left, wh = work->bottom - work->top;
*x = work->left + (ww > ow ? (ww - ow) / 2 : 0);
*y = work->top + (wh > oh ? (wh - oh) / 2 : 0);
}
// Before the window exists (widescreen.c wide_init at start), the monitor a fullscreen window opens on: the one
// NX2NAVI_MONITOR picks (window_main places the window there), else the primary monitor.
int gfx_window_monitor_size(int *w, int *h) {
MONITORINFO mi = {sizeof mi};
MonInfo chosen;
HMONITOR mon = g_hwnd ? MonitorFromWindow(g_hwnd, MONITOR_DEFAULTTOPRIMARY)
: chosen_monitor(&chosen, 0) ? chosen.h
: MonitorFromPoint((POINT){0, 0}, MONITOR_DEFAULTTOPRIMARY);
if (!GetMonitorInfoW(mon, &mi)) return 0;
*w = mi.rcMonitor.right - mi.rcMonitor.left;
*h = mi.rcMonitor.bottom - mi.rcMonitor.top;
return 1;
}
// The window's close runs on a thread of its own: gfx_window_closed saves the settings, the caches and the saves and
// ends the process, which takes seconds while pipeline builds run (vk_pipeline_save_exit waits up to 5 s for them). On
// the window thread the window stopped answering meanwhile; after 5 s Windows showed it as not responding, and an
// "End task" there killed the process in the middle of its saves. The window is hidden as the end starts, so a window
// that still answers does not invite a second close (one would find the end under way: gfx_present.c rt_exit_begin).
static volatile int32_t g_exit_started;
static void exit_main(void *arg) {
(void)arg;
gfx_window_closed();
}
// The end's thread, NULL when the end had started already or runs here (no thread could be made).
static PlatThread *exit_start(HWND h) {
if (plat_xchg32(&g_exit_started, 1)) return NULL;
ShowWindow(h, SW_HIDE);
PlatThread *t = plat_thread_new(exit_main, NULL, "exit");
if (!t) gfx_window_closed();
return t;
}
static LRESULT CALLBACK wnd_proc(HWND h, UINT m, WPARAM w, LPARAM l) {
LRESULT input_res;
if (input_window_msg(h, m, w, l, &input_res)) return input_res;
if (menu_window_msg(h, m, w, l, &input_res)) return input_res;
switch (m) {
case WM_KEYDOWN:
// bit 30: auto-repeat. Not with Ctrl or Shift held: overlays and capture tools take such F11 shortcuts for their
// own (Alt+F11 comes as WM_SYSKEYDOWN)
if (w == VK_F11 && !(l & (1 << 30)) && GetKeyState(VK_CONTROL) >= 0 && GetKeyState(VK_SHIFT) >= 0) {
input_cursor_suspend(1);
toggle_fullscreen(h);
input_cursor_suspend(0);
cfg_set("NX2NAVI_FULLSCREEN", is_fullscreen(h) ? "1" : "0"); // the menu shows it; the next start keeps it
cfg_save();
note_applied(); // what cfg_get reads now (the environment may still override it)
}
break;
case WM_APP_SETTINGS:
apply_settings(h);
return 0;
case WM_APP_FULLSCREEN:
if (cfg_fullscreen() && !is_fullscreen(h)) {
input_cursor_suspend(1);
toggle_fullscreen(h);
input_cursor_suspend(0);
}
return 0;
case WM_APP_TEXT_INPUT:
if (w) ImmAssociateContextEx(h, NULL, IACE_DEFAULT);
else ImmAssociateContext(h, NULL);
return 0;
case WM_APP_PASTE: {
char text[256];
plat_clipboard_get(text, sizeof text);
menu_event_paste(text);
return 0;
}
case WM_SIZE:
keep_awake(w != SIZE_MINIMIZED);
if (w != SIZE_MINIMIZED) gfx_window_mark_dirty();
refresh_rate_update(h, 0);
return 0;
case WM_EXITSIZEMOVE: // dragged onto another monitor
refresh_rate_update(h, 0);
break;
case WM_DISPLAYCHANGE: // a resolution, layout or HDR change of the desktop
refresh_rate_update(h, 1);
if (is_fullscreen(h) && !IsIconic(h)) { // keep covering the monitor at its new size (a same size sends no WM_SIZE)
MONITORINFO mi = {sizeof mi};
if (GetMonitorInfoW(MonitorFromWindow(h, MONITOR_DEFAULTTONEAREST), &mi))
SetWindowPos(h, NULL, mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right - mi.rcMonitor.left,
mi.rcMonitor.bottom - mi.rcMonitor.top, SWP_NOZORDER | SWP_NOACTIVATE);
}
gfx_window_mark_dirty(); // the swapchain is made again: its size, and its colour space after an HDR switch
break;
case WM_GETDPISCALEDSIZE: // a framed window keeps its client size in pixels on a monitor at another scale
if (!is_fullscreen(h) && !IsZoomed(h)) {
RECT c, r;
if (GetClientRect(h, &c)) {
r = (RECT){0, 0, c.right, c.bottom};
if (AdjustWindowRectExForDpi(&r, WND_STYLE, FALSE, 0, LOWORD(w))) {
*(SIZE *)l = (SIZE){r.right - r.left, r.bottom - r.top};
return TRUE;
}
}
}
break; // DefWindowProc: Windows scales the size with the DPI
case WM_DPICHANGED: { // display scale changed, or the window reached a monitor at another scale
const RECT *s = (const RECT *)l;
plat_xchg32(&g_dpi, LOWORD(w));
rt_log("gfx: window DPI %u (%u%%)", LOWORD(w), LOWORD(w) * 100u / USER_DEFAULT_SCREEN_DPI);
if (is_fullscreen(h)) { // keep covering the monitor the suggested rect lies on
MONITORINFO mi = {sizeof mi};
if (GetMonitorInfoW(MonitorFromRect(s, MONITOR_DEFAULTTONEAREST), &mi))
SetWindowPos(h, NULL, mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right - mi.rcMonitor.left,
mi.rcMonitor.bottom - mi.rcMonitor.top, SWP_NOZORDER | SWP_NOACTIVATE);
} else {
SetWindowPos(h, NULL, s->left, s->top, s->right - s->left, s->bottom - s->top, SWP_NOZORDER | SWP_NOACTIVATE);
}
return 0;
}
case WM_ACTIVATEAPP: {
int in = w != 0;
// The end's own hide (exit_start, which sets g_exit_started first on this thread) deactivates the window: no
// player's focus loss. Taken as one, the present thread's next present wrote the session's report for the whole
// run and started a new period (perf.c perf_focus_lost), so "frametime: exit" covered only the frames since.
if (!in && __atomic_load_n(&g_exit_started, __ATOMIC_ACQUIRE)) return 0;
gfx_window_focus_changed(in);
if (in && g_fs_pending) {
g_fs_pending = 0;
PostMessageW(h, WM_APP_FULLSCREEN, 0, 0);
}
return 0;
}
case WM_ENDSESSION:
// Windows is shutting down, restarting or logging off: it ends the process once this message returns, without a
// WM_CLOSE, so the close's end runs (the settings menu's pending save, RTSS's profile, a save Commit in flight)
// and this thread waits for it here; the end does not return
if (w) {
PlatThread *t = exit_start(h);
if (t) plat_thread_join(t);
for (;;) plat_sleep_ms(1000); // a close had started the end already
}
return 0;
case WM_CLOSE:
exit_start(h);
return 0;
case WM_ERASEBKGND:
return 1;
}
return DefWindowProcW(h, m, w, l);
}
// Brings the game window to the front. Windows refuses SetForegroundWindow to a process that was not the last to get
// input, which is the case for a game started by a frontend; attaching this thread's input queue to the foreground
// window's thread for the call is the documented way around it. The window is not left topmost.
static void raise_window(HWND h) {
HWND fg = GetForegroundWindow();
DWORD me = GetCurrentThreadId(), other = fg ? GetWindowThreadProcessId(fg, NULL) : 0;
int attached = other && other != me && AttachThreadInput(me, other, TRUE);
ShowWindow(h, IsIconic(h) ? SW_RESTORE : SW_SHOW);
BringWindowToTop(h);
SetForegroundWindow(h);
SetActiveWindow(h);
if (attached) AttachThreadInput(me, other, FALSE);
if (GetForegroundWindow() != h) { // still refused: a topmost toggle puts it above the others without keeping it there
SetWindowPos(h, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
SetWindowPos(h, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
}
}
// The window's pointer: a gold arrowhead in the menu's colours, its tip the hotspot at the top left, the half towards
// the tip's right edge a lighter facet, with a dark outline; sized for the system DPI (32 px at 96 DPI) and drawn with
// 4x4 samples per pixel into a 32-bit cursor with alpha. The arrow when anything fails.
static int in_tri(float px, float py, float ax, float ay, float bx, float by, float cx, float cy) {
float d1 = (px - bx) * (ay - by) - (ax - bx) * (py - by), d2 = (px - cx) * (by - cy) - (bx - cx) * (py - cy),
d3 = (px - ax) * (cy - ay) - (cx - ax) * (py - ay);
int neg = d1 < 0 || d2 < 0 || d3 < 0, pos = d1 > 0 || d2 > 0 || d3 > 0;
return !(neg && pos);
}
static HCURSOR pointer_cursor(void) {
UINT dpi = GetDpiForSystem();
int n = (int)(32 * (dpi ? dpi : 96) / 96);
if (n < 24) n = 24;
if (n > 96) n = 96;
BITMAPV5HEADER bh = {.bV5Size = sizeof bh, .bV5Width = n, .bV5Height = -n, .bV5Planes = 1, .bV5BitCount = 32,
.bV5Compression = BI_BITFIELDS, .bV5RedMask = 0x00ff0000, .bV5GreenMask = 0x0000ff00,
.bV5BlueMask = 0x000000ff, .bV5AlphaMask = 0xff000000};
HDC dc = GetDC(NULL);
uint32_t *px = NULL;
HBITMAP color = CreateDIBSection(dc, (BITMAPINFO *)&bh, DIB_RGB_COLORS, (void **)&px, NULL, 0);
ReleaseDC(NULL, dc);
HBITMAP mask = CreateBitmap(n, n, 1, 1, NULL);
if (!color || !mask || !px) {
if (color) DeleteObject(color);
if (mask) DeleteObject(mask);
return LoadCursorW(NULL, (LPCWSTR)IDC_ARROW);
}
// the arrowhead: tip (ax, ay), the right wing (bx, by), the lower wing (cx, cy), the notch (mx, my) between them
float o = n * 0.07f, ax = o, ay = o, bx = o + n * 0.70f, by = o + n * 0.46f, cx = o + n * 0.30f, cy = o + n * 0.80f;
float mx = o + n * 0.36f, my = o + n * 0.50f, e = n * 0.055f;
for (int y = 0; y < n; y++)
for (int x = 0; x < n; x++) {
int light = 0, deep = 0, edge = 0;
for (int sy = 0; sy < 4; sy++)
for (int sx = 0; sx < 4; sx++) {
float fx = x + (sx + 0.5f) / 4, fy = y + (sy + 0.5f) / 4;
int l = in_tri(fx, fy, ax, ay, bx, by, mx, my), d = !l && in_tri(fx, fy, ax, ay, mx, my, cx, cy);
int out = in_tri(fx, fy, ax - e, ay - e * 1.3f, bx + e * 1.4f, by + e * 0.2f, mx, my + e * 1.2f) ||
in_tri(fx, fy, ax - e * 1.3f, ay - e, mx + e * 1.2f, my, cx + e * 0.2f, cy + e * 1.4f);
light += l, deep += d, edge += out && !l && !d;
}
int cov = light + deep + edge;
uint32_t a = (uint32_t)(cov * 255 / 16), r = 0, g = 0, b = 0;
if (cov) {
r = (uint32_t)((255 * light + 214 * deep + 44 * edge) / cov);
g = (uint32_t)((226 * light + 158 * deep + 28 * edge) / cov);
b = (uint32_t)((138 * light + 58 * deep + 10 * edge) / cov);
}
// premultiplied by alpha, as 32-bit cursors want
px[y * n + x] = a << 24 | (r * a / 255) << 16 | (g * a / 255) << 8 | (b * a / 255);
}
ICONINFO ii = {.fIcon = FALSE, .xHotspot = (DWORD)o, .yHotspot = (DWORD)o, .hbmMask = mask, .hbmColor = color};
HCURSOR cur = CreateIconIndirect(&ii);
DeleteObject(color);
DeleteObject(mask);
return cur ? cur : LoadCursorW(NULL, (LPCWSTR)IDC_ARROW);
}
static void window_main(void *arg) {
(void)arg;
HINSTANCE inst = GetModuleHandleW(NULL);
// Icon resource 1 of nx2navi.rc (runtime/nx2navi.ico): the title bar, the taskbar button and Alt+Tab.
WNDCLASSEXW wc = {.cbSize = sizeof wc, .lpfnWndProc = wnd_proc, .hInstance = inst,
.hIcon = LoadImageW(inst, MAKEINTRESOURCEW(1), IMAGE_ICON, GetSystemMetrics(SM_CXICON),
GetSystemMetrics(SM_CYICON), LR_SHARED),
.hIconSm = LoadImageW(inst, MAKEINTRESOURCEW(1), IMAGE_ICON, GetSystemMetrics(SM_CXSMICON),
GetSystemMetrics(SM_CYSMICON), LR_SHARED),
.hCursor = pointer_cursor(), .lpszClassName = L"NX2naviRecompWindow"};
RegisterClassExW(&wc);
int cw, ch, ow, oh, x = CW_USEDEFAULT, y = CW_USEDEFAULT;
cfg_window_size(&cw, &ch);
// NX2NAVI_MONITOR: the window is created centred in the chosen monitor's work area, with its frame measured at that
// monitor's DPI, so it opens there at the scale it will have. Without a choice the system places it. Either way
// the size is scaled down first if it does not fit the work area it opens in.
MonInfo mon;
int on_mon = chosen_monitor(&mon, 1);
fit_work_area(&cw, &ch, on_mon ? &mon.work : NULL, on_mon && mon.dpi ? mon.dpi : GetDpiForSystem());
outer_size(NULL, cw, ch, &ow, &oh);
if (on_mon) {
outer_size_at(cw, ch, mon.dpi ? mon.dpi : GetDpiForSystem(), &ow, &oh);
center_in(&mon.work, ow, oh, &x, &y);
}
// The window opens behind the user's windows first and is then raised (raise_window): a game started by a frontend
// has no other way to reach it. The game counts itself in focus until the first WM_ACTIVATEAPP, so loading goes on,
// and keyboard input waits for the foreground (input_win.c). If Windows still refuses the foreground, the taskbar
// button flashes. With NX2NAVI_FULLSCREEN the window is made fullscreen on the monitor it was created on before it
// is first shown (fullscreen_before_show), so no framed window appears; the windowed rectangle stays in g_saved_place
// for F11. If that fails, g_fs_pending retries at the first activation (wnd_proc, WM_ACTIVATEAPP).
// NX2NAVI_BACKGROUND_WINDOW=1 (test runs): no flashing, never fullscreen at start, and F11 keeps the window's place.
int background = g_background = cfg_get("NX2NAVI_BACKGROUND_WINDOW") && atoi(cfg_get("NX2NAVI_BACKGROUND_WINDOW"));
g_hwnd = CreateWindowExW(0, wc.lpszClassName, L"NX2navi", WND_STYLE, x, y, ow, oh, NULL, NULL,
inst, NULL);
if (!g_hwnd) {
rt_log("gfx: CreateWindowEx failed (%lu)", GetLastError());
g_wnd_failed = 1;
} else {
// An IME switched on over the game attaches to this window and then shows its candidate window and eats the keys
// it composes, although there is nothing here to type into (a Chinese IME takes Shift, the B button by default).
// Detaching the game's window leaves the software keyboard alone: its edit box sits in a window of its own
// (swkbd_win.c) and gets the IME it needs. The menu's text field attaches one while it takes text
// (gfx_window_text_input).
ImmAssociateContext(g_hwnd, NULL);
// The frame was measured at the system DPI (or the chosen monitor's); the window may have opened on a monitor at
// another scale. When the size changes on a chosen monitor, the window is centred again in its work area;
// otherwise it keeps the place it was created at.
int nw, nh, nx = 0, ny = 0;
UINT flags = SWP_NOACTIVATE | SWP_SHOWWINDOW;
plat_xchg32(&g_dpi, (int32_t)GetDpiForWindow(g_hwnd));
outer_size(g_hwnd, cw, ch, &nw, &nh);
if (on_mon && (nw != ow || nh != oh)) center_in(&mon.work, nw, nh, &nx, &ny);
else flags |= SWP_NOMOVE;
// the fullscreen setting: the window turns fullscreen on the monitor it is on before it is shown, and keeps
// that place and size (fullscreen_before_show); the windowed size stays for F11
int fs = !background && cfg_fullscreen() && fullscreen_before_show(g_hwnd, nw, nh);
SetWindowPos(g_hwnd, HWND_BOTTOM, nx, ny, nw, nh, flags | (fs ? SWP_NOSIZE | SWP_NOMOVE : 0));
if (on_mon && MonitorFromWindow(g_hwnd, MONITOR_DEFAULTTONEAREST) != mon.h)
rt_log("gfx: the window is not on the chosen monitor; F11 fullscreen covers the monitor it is on");
if (!background) {
// Pending before the raise: the activation it gets is the first WM_ACTIVATEAPP, which takes the fullscreen
// setting; set after it, fullscreen waited for the next activation and the game started in a window. Only
// a window that did not become fullscreen before it was shown still needs that retry.
g_fs_pending = cfg_fullscreen() && !fs;
raise_window(g_hwnd); // a frontend's player may have no way to reach a window left behind
FLASHWINFO fw = {.cbSize = sizeof fw, .hwnd = g_hwnd, .dwFlags = FLASHW_TRAY | FLASHW_TIMERNOFG};
FlashWindowEx(&fw);
}
note_applied();
refresh_rate_update(g_hwnd, 1);
keep_awake(1);
}
plat_event_set(&g_wnd_ready);
if (g_wnd_failed) return;
MSG msg;
while (GetMessageW(&msg, NULL, 0, 0) > 0) {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
return;
}
// ---- gfx_wsys.h
// Makes the window on its thread and waits until it is there.
int wsys_open(void) {
g_wnd_failed = 0;
plat_event_init(&g_wnd_ready, 1, 0);
if (!plat_thread_spawn(window_main, NULL, "window", 0)) rt_fatal("gfx: cannot start the window thread");
plat_event_wait(&g_wnd_ready);
return !g_wnd_failed;
}
VkSurfaceKHR wsys_surface(VkInstance inst) {
VkWin32SurfaceCreateInfoKHR si = {.sType = VK_STRUCTURE_TYPE_WIN32_SURFACE_CREATE_INFO_KHR,
.hinstance = GetModuleHandleW(NULL), .hwnd = g_hwnd};
VkSurfaceKHR surface = VK_NULL_HANDLE;
// through Streamline when DLSS Frame Generation is loaded: it maps swapchains to windows from this call
PFN_vkCreateWin32SurfaceKHR create = (PFN_vkCreateWin32SurfaceKHR)vk_dlssg_instance_proc(inst, "vkCreateWin32SurfaceKHR");
VkResult r = (create ? create : vkCreateWin32SurfaceKHR)(inst, &si, NULL, &surface);
if (r != VK_SUCCESS) rt_log("gfx: vkCreateWin32SurfaceKHR failed (%d)", (int)r);
return r == VK_SUCCESS ? surface : VK_NULL_HANDLE;
}
void wsys_client_size(int *w, int *h) {
RECT rc = {0};
GetClientRect(g_hwnd, &rc);
*w = rc.right, *h = rc.bottom;
}
int wsys_minimized(void) { return IsIconic(g_hwnd) != 0; }
void wsys_resize(int cw, int ch) {
int ow, oh;
outer_size(g_hwnd, cw, ch, &ow, &oh);
SetWindowPos(g_hwnd, NULL, 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
}
void wsys_probe_focus(int in) { SendMessageW(g_hwnd, WM_ACTIVATEAPP, in ? TRUE : FALSE, 0); }
void wsys_probe_escape(void) { SendMessageW(g_hwnd, WM_KEYDOWN, VK_ESCAPE, 0); }
int wsys_probe_click(int where) {
if (where == 0) SendMessageW(g_hwnd, WM_LBUTTONDOWN, MK_LBUTTON, 0);
else SendMessageW(g_hwnd, WM_MOUSEACTIVATE, (WPARAM)g_hwnd, MAKELPARAM(where == 1 ? HTCLIENT : HTCAPTION, WM_LBUTTONDOWN));
return 1;
}