diff --git a/docs/controls.md b/docs/controls.md index 49484d37..92e94d9a 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -76,6 +76,8 @@ The game's *Swap Jump Controls* option trades B and X (jump on B, put away on X) | F10 | L3 + R3 (both stick clicks) | performance overlay: off, frame rate, full panel | | F11 | | borderless fullscreen on or off, saved to `nx2navi.ini` | +The window opens on the monitor `NX2NAVI_MONITOR` (`monitor` in `nx2navi.ini`) names, and F11 fullscreen covers the monitor the window is on. `nx2navi.exe --list-monitors` prints each monitor's index, device name, name, desktop rectangle, primary flag and DPI without game files; `nx2navi.exe --monitor ` sets `NX2NAVI_MONITOR` for one run and wins over the file. The value is `auto` (default), `primary`, an index from the list (counting from 1), or part of a device name (`display3`) or monitor name; when nothing matches, the log lists the monitors and the window is placed as with `auto`. Windows only. + The Minus+Plus combo never reaches the game. In the menu the arrow keys, the D-pad or the left stick move, Enter or A selects, Q and E or L and R switch tabs, Esc or B goes back (Switch button names, as in the table above). ## Audio output diff --git a/nx2navi.ini.example b/nx2navi.ini.example index 86916dc5..b5d6e10f 100644 --- a/nx2navi.ini.example +++ b/nx2navi.ini.example @@ -192,6 +192,12 @@ # here. Changes while the game runs take effect at once. # window_size = 1920x1080 # fullscreen = 0 +# Which monitor the game window opens on, centred in its work area; fullscreen covers that monitor. auto (default): the +# system places the window. primary; a number, counting from 1 in the order `nx2navi.exe --list-monitors` prints; or part +# of a GDI device name (display3) or of the monitor's name, ignoring case. For a virtual display that may not exist yet, +# use its name: when nothing matches, the log lists the monitors and the window is placed as with auto. The command line +# `--monitor ` overrides this for one run. Read at start; Windows only. +# monitor = auto # 1, the default: the game keeps running while you are in another window; the keyboard stays quiet until the window is # in front again. 0: it pauses when its window loses focus, as it does on the Switch when Home is pressed; a load # interrupted that way can stop for good. Read at each focus change. diff --git a/runtime/cfg.c b/runtime/cfg.c index c9213818..e77b217d 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -47,7 +47,7 @@ static const char *const known[] = { "HDR_EXPOSURE", "HDR_FLARE", "HDR_GAMMA", "HDR_GAME_NITS", "HDR_GRADE", "HDR_HIGHLIGHT_SAT", "HDR_HIGHLIGHTS", "HDR_HUE", "HDR_PEAK_NITS", "HDR_SATURATION", "HDR_SHADOWS", "HDR_TONEMAP", "HDR_UI_NITS", "HIGHFPS_FIX", "INPUT", "INPUT_RECORD", "INPUT_SCRIPT", "LANG", "LIB_PREBUILD", "BIG_BUILD_FREE_MB", "BIG_BUILD_MAX_MB", "LIB_PREBUILD_BIG", "LIB_PREBUILD_BIG_BG", "LIB_PREBUILD_KEEP", "LIB_PREBUILD_THREADS", "LOCALE", "LOD_GUARD", "LSTICK_DEADZONE", "MEMORY", "MENU_LANG", "MENU_SOUND_VOLUME", "MENU_TEST", - "MENU_CURSOR", "MENU_CURSOR_LOG", "MENU_TEST_DIR", "MENU_TEST_SET", "MINIMAL_LAYOUT", "MIRROR_GAP", "MODS_DIR", "MODS_DISABLED", "MODS_ORDER", "MODS_REBUILD", "MOTION", "MOUSE_MODE", "MOUSE_DIRECT_PATCH", "MOUSE_SENS", "MOVIE_HOST", "MOVIE_PACE", + "MENU_CURSOR", "MENU_CURSOR_LOG", "MENU_TEST_DIR", "MENU_TEST_SET", "MINIMAL_LAYOUT", "MIRROR_GAP", "MODS_DIR", "MODS_DISABLED", "MODS_ORDER", "MODS_REBUILD", "MONITOR", "MOTION", "MOUSE_MODE", "MOUSE_DIRECT_PATCH", "MOUSE_SENS", "MOVIE_HOST", "MOVIE_PACE", "MVOBJ_ALL", "MVOBJ_BANKS", "MVOBJ_EQUAL", "MVOBJ_EXACT", "MVOBJ_EXEC", "MVOBJ_NOC4", "MVOBJ_ONLY", "MVOBJ_TIMES", "MVPREV_CHECK", "MVPREV_GROW", "MVPREV_SKIP", "MVPREV_TRUST", "MV_CHECK", "MV_SURVEY", "ONE_PER_CORE", "OPERATION_MODE", "OUTPUT_RES", "PAD_LAYOUT", "PACE_HZ", "PASS_CRUMBS", "PATCH_OUTPUT", "PATCH_RES_FIXED", "PATCH_SCENE", "PATCH_WIDE_ASPECT", "PATCH_WIDE_HUD", "PATCH_WIDE_MOVIE", "PATCH_WIDE_OUT", "PATCH_WIDE_SCENE", "PCSS", "PCSS_K", "PERF_OVERLAY", "PERF_SLOW_MS", "PIPELINE_CACHE", "PIPELINE_CACHE_MAX_MB", "PIPE_BINARIES", "PIPE_BINARIES_MAX_MB", "PIPE_FEEDBACK", "PIPE_MEMO", "PIPE_PREBUILD", "PIPE_SKIP", "PIPE_SYNC", "PIPE_WAIT", "POWER_FULL", "TEX_SAME", "PRESENT_HELD", "HELD_SYNC", @@ -74,7 +74,7 @@ static const struct { const char *key, *section; } sections[] = { {"FPS_LIMIT", "frame rate"}, {"FPS60", "frame rate"}, {"RTSS", "frame rate"}, {"REALTIME", "frame rate"}, {"CLOTH_REALTIME", "frame rate"}, {"HIGHFPS_FIX", "frame rate"}, {"FULLSCREEN", "display"}, {"WINDOW_SIZE", "display"}, {"PERF_OVERLAY", "display"}, {"VK_PRESENT", "display"}, - {"GPU_INDEX", "display"}, {"OUTPUT_RES", "display"}, {"GAME_ASPECT", "display"}, {"GAME_RES", "display"}, {"RENDER_SCALE", "display"}, {"UPSCALER", "display"}, + {"GPU_INDEX", "display"}, {"MONITOR", "display"}, {"OUTPUT_RES", "display"}, {"GAME_ASPECT", "display"}, {"GAME_RES", "display"}, {"RENDER_SCALE", "display"}, {"UPSCALER", "display"}, {"SHARPNESS", "display"}, {"ASPECT", "display"}, {"FOV", "display"}, {"ANISO", "display"}, {"COLOR_PRESET", "display"}, {"COLOR_BRIGHTNESS", "display"}, {"COLOR_CONTRAST", "display"}, {"COLOR_SATURATION", "display"}, @@ -127,6 +127,7 @@ static const CfgMeta metas[] = { // Screen {"FULLSCREEN", "0", "0|1", LIVE}, {"WINDOW_SIZE", "1920x1080", NULL, LIVE}, {"VK_PRESENT", "mailbox", "mailbox|fifo|immediate", LIVE}, {"GPU_INDEX", "", NULL, RESTART}, + {"MONITOR", "auto", NULL, RESTART}, // auto|primary|<1-based index>| (gfx_window_win.c chosen_monitor) {"PERF_OVERLAY", "off", "off|fps|full", LIVE}, {"OUTPUT_RES", "1920x1080", NULL, RESTART}, {"RENDER_SCALE", "game", "game|50|67|75|100|125|150|200", RESTART}, {"UPSCALER", "fsr", "fsr|bilinear", LIVE}, {"SHARPNESS", "80", "0..100", LIVE}, diff --git a/runtime/cfg_probe.c b/runtime/cfg_probe.c index c7b4eea6..83bb317b 100644 --- a/runtime/cfg_probe.c +++ b/runtime/cfg_probe.c @@ -153,6 +153,8 @@ int probe_cfg(void) { "meta: prefix and case are accepted"); m = cfg_meta("RUMBLE_STRENGTH"); CHECK(m && EQ(m->def, "130") && EQ(m->values, "0..300"), "meta: rumble strength 130 in 0..300"); + m = cfg_meta("MONITOR"); + CHECK(m && EQ(m->def, "auto") && !m->values && !m->live, "meta: monitor is auto by default, free text, read at start"); m = cfg_meta("PERF_OVERLAY"); CHECK(m && EQ(m->def, "off") && EQ(m->values, "off|fps|full") && m->live, "meta: perf_overlay is live, off by default"); m = cfg_meta("AUDIO_DEVICE"); diff --git a/runtime/gfx_window_sdl.c b/runtime/gfx_window_sdl.c index bdb63e0b..689fb911 100644 --- a/runtime/gfx_window_sdl.c +++ b/runtime/gfx_window_sdl.c @@ -265,6 +265,9 @@ static void open_window(void *arg) { note_applied(); refresh_rate_update(); if (cfg_get("NX2NAVI_BACKGROUND_WINDOW")) rt_log("gfx: NX2NAVI_BACKGROUND_WINDOW has no effect with SDL windows"); + const char *monitor = cfg_get("NX2NAVI_MONITOR"); // open_window runs once, so this is logged once + if (monitor && *monitor && strcasecmp(monitor, "auto")) + rt_log("gfx: NX2NAVI_MONITOR=%s has no effect with SDL windows: monitor selection is Win32-only (gfx_window_win.c)", monitor); if (gfx_cfg_fullscreen()) SDL_SetWindowFullscreen(g_win, true); SDL_SyncWindow(g_win); input_sdl_window(g_win); @@ -277,6 +280,12 @@ int wsys_open(void) { return open_ok; } +// --list-monitors (main.c) lists Win32 monitors only; see open_window for the setting. +int list_monitors(void) { + fprintf(stderr, "list-monitors: monitor selection is Win32-only; this build uses SDL windows\n"); + return 1; +} + typedef struct { VkInstance inst; VkSurfaceKHR surface; } SurfaceReq; static void make_surface(void *arg) { diff --git a/runtime/gfx_window_win.c b/runtime/gfx_window_win.c index 4dabdcaf..85749b11 100644 --- a/runtime/gfx_window_win.c +++ b/runtime/gfx_window_win.c @@ -1,5 +1,5 @@ // 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 and NX2NAVI_FULLSCREEN, the monitor's refresh rate, focus, the Vulkan surface. +// 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). // @@ -62,25 +62,33 @@ static int is_fullscreen(HWND h) { return !(GetWindowLongW(h, GWL_STYLE) & WS_OV 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. The frame is measured 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) { +// 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, h ? GetDpiForWindow(h) : GetDpiForSystem()); + AdjustWindowRectExForDpi(&r, WND_STYLE, FALSE, 0, dpi); *ow = r.right - r.left; *oh = r.bottom - r.top; } -// The client size cw x ch scaled down, in its own proportions, so the whole window fits the primary monitor's work -// area, 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) { - RECT work; +// 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 (!SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0)) return; - outer_size(NULL, *cw, *ch, &ow, &oh); - int aw = (work.right - work.left) - (ow - *cw), ah = (work.bottom - work.top) - (oh - *ch); + 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; @@ -222,10 +230,235 @@ static void refresh_rate_update(HWND h, int force) { gfx_window_set_refresh(monitor_rate_hz(mon)); } -// Before the window exists (widescreen.c wide_init at start), the primary monitor: the one a fullscreen window opens on. +// ---- 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. +static UINT monitor_dpi(HMONITOR mon) { + typedef HRESULT(WINAPI * GetDpiForMonitorFn)(HMONITOR, int, UINT *, UINT *); + HMODULE lib = LoadLibraryExW(L"shcore.dll", NULL, LOAD_LIBRARY_SEARCH_SYSTEM32); + GetDpiForMonitorFn fn = lib ? (GetDpiForMonitorFn)(void *)GetProcAddress(lib, "GetDpiForMonitor") : NULL; + 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}; - HMONITOR mon = g_hwnd ? MonitorFromWindow(g_hwnd, MONITOR_DEFAULTTOPRIMARY) : MonitorFromPoint((POINT){0, 0}, MONITOR_DEFAULTTOPRIMARY); + 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; @@ -412,10 +645,19 @@ static void window_main(void *arg) { GetSystemMetrics(SM_CYSMICON), LR_SHARED), .hCursor = pointer_cursor(), .lpszClassName = L"NX2naviRecompWindow"}; RegisterClassExW(&wc); - int cw, ch, ow, oh; + int cw, ch, ow, oh, x = CW_USEDEFAULT, y = CW_USEDEFAULT; cfg_window_size(&cw, &ch); - fit_work_area(&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 @@ -424,8 +666,8 @@ static void window_main(void *arg) { // 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, CW_USEDEFAULT, CW_USEDEFAULT, ow, oh, NULL, - NULL, inst, NULL); + 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; @@ -435,11 +677,21 @@ static void window_main(void *arg) { // 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. ImmAssociateContext(g_hwnd, NULL); - // The frame was measured at the system DPI; the window may have opened on a monitor at another scale. + // 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, &ow, &oh); - int fs = !background && cfg_fullscreen() && fullscreen_before_show(g_hwnd, ow, oh); - SetWindowPos(g_hwnd, HWND_BOTTOM, 0, 0, ow, oh, SWP_NOMOVE | SWP_NOACTIVATE | SWP_SHOWWINDOW | (fs ? SWP_NOSIZE : 0)); + 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 diff --git a/runtime/host.h b/runtime/host.h index e15115f1..6f9bd09e 100644 --- a/runtime/host.h +++ b/runtime/host.h @@ -350,6 +350,7 @@ void rt_boot(void); // boot.c int list_audio_devices(void); // audio_mixer.c: --list-audio-devices (SDL3 on every window back end) int probe_cfg(void); // cfg_probe.c int probe_dpi(void); // dpi_probe_win.c (Windows only) +int list_monitors(void); // --list-monitors: gfx_window_win.c, gfx_window_sdl.c int probe_mods(void); // mods_probe.c void discord_init(void); // discord_rpc.c: the Rich Presence thread, game runs only int probe_discord(void); // discord_rpc.c diff --git a/runtime/main.c b/runtime/main.c index b68e7b2b..fff0d402 100644 --- a/runtime/main.c +++ b/runtime/main.c @@ -1,11 +1,16 @@ // nx2navi.exe entry: reserves the guest address space, parses the command line and runs one mode. // -// nx2navi.exe [--exefs DIR] [--romfs DIR] [--profile DIR] [--audio-device VALUE] +// nx2navi.exe [--exefs DIR] [--romfs DIR] [--profile DIR] [--audio-device VALUE] [--monitor VALUE] // --audio-device VALUE play the sound on one output device: sets NX2NAVI_AUDIO_DEVICE before the settings are read, so // it wins over nx2navi.ini's `audio_device =`. VALUE is default (the system default device), // a 1-based index as --list-audio-devices prints it (devices sorted by name), or part of a device name, case-insensitive // --list-audio-devices print each playback device's index, name, channels and frequency, mark the system default and // show which device NX2NAVI_AUDIO_DEVICE picks; needs no game files +// --monitor VALUE open the game window on one monitor: sets NX2NAVI_MONITOR before the settings are read, so it wins +// over nx2navi.ini's `monitor =`. VALUE is auto (default), primary, a 1-based index as --list-monitors +// prints it, or part of a GDI device name (DISPLAY3) or of a monitor's name, case-insensitive +// --list-monitors print each monitor's index, GDI device name, name, desktop rectangle, primary flag and DPI; +// needs no game files // --selftest layout, dispatch table and host-function registry; needs no game files // --probe-mods the mods folder scan, order and switches (mods.c); needs no game files // --probe-loader load and relocate the modules, check them against the measured 1.2.1 values @@ -480,7 +485,7 @@ static int probe_crash(int null) { } static int usage(const char *argv0) { - fprintf(stderr, "usage: %s [--exefs DIR] [--romfs DIR] [--profile DIR] [--audio-device VALUE] [--list-audio-devices | --selftest | --probe-loader | " + fprintf(stderr, "usage: %s [--exefs DIR] [--romfs DIR] [--profile DIR] [--audio-device VALUE] [--monitor VALUE] [--list-audio-devices | --list-monitors | --selftest | --probe-loader | " "--probe-cfg | --probe-dpi | --probe-mods | --probe-kernel | --probe-ipc | --probe-services | --probe-fs | --probe-gfx-abi | " "--probe-nvn | --probe-nvn-tex | --probe-window | --probe-shader | --probe-vkmem | " "--probe-vkexec | --probe-astc | --probe-movie | --probe-crash-call | --probe-crash-null | --probe-discord | " @@ -565,6 +570,12 @@ int main(int argc, char **argv) { // (plat_setenv) and hand the choice back to nx2navi.ini, so "default" is the spelling for the system default. if (++i == argc || !*argv[i]) return usage(argv[0]); plat_setenv("NX2NAVI_AUDIO_DEVICE", argv[i]); + } else if (!strcmp(argv[i], "--monitor")) { + // The settings read NX2NAVI_MONITOR from the environment first (cfg.c raw_get), and nothing has read it yet: + // the window opens in rt_boot. An empty value would remove the variable (plat_setenv) and hand the choice back + // to nx2navi.ini, so "auto" is the spelling for "no monitor choice" on the command line. + if (++i == argc || !*argv[i]) return usage(argv[0]); + plat_setenv("NX2NAVI_MONITOR", argv[i]); } else if (!strcmp(argv[i], "--seconds")) { char *end; if (++i == argc) return usage(argv[0]); @@ -591,6 +602,7 @@ int main(int argc, char **argv) { } if (!strcmp(mode, "--selftest")) return selftest(); if (!strcmp(mode, "--list-audio-devices")) return list_audio_devices(); + if (!strcmp(mode, "--list-monitors")) return list_monitors(); if (!strcmp(mode, "--probe-loader")) return probe_loader(); if (!strcmp(mode, "--probe-crash-call")) return probe_crash(0); if (!strcmp(mode, "--probe-crash-null")) return probe_crash(1);