diff --git a/docs/controls.md b/docs/controls.md index 1f5127fe..7c139b2b 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -76,7 +76,7 @@ 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 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, name (on Windows its device name too), 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 monitor name (on Windows also a device name such as `display3`; on Linux the name carries the connector, such as `dp-2`); when nothing matches, the log lists the monitors and the window is placed as with `auto`. On Wayland a window cannot place itself: only fullscreen (`fullscreen = 1`) opens on the chosen monitor, and a windowed window opens where the compositor puts it. 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). diff --git a/nx2navi.ini.example b/nx2navi.ini.example index 1c324cce..d2abef85 100644 --- a/nx2navi.ini.example +++ b/nx2navi.ini.example @@ -223,9 +223,11 @@ # 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. +# of the monitor's name, ignoring case (on Windows also of a GDI device name, display3; on Linux the name carries the +# connector, such as dp-2). 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. On Wayland a window cannot place itself: only fullscreen opens on the chosen monitor, and a +# windowed window opens where the compositor puts it. # 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 diff --git a/runtime/cfg.c b/runtime/cfg.c index 9e10c45e..3b434cd4 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -129,7 +129,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) + {"MONITOR", "auto", NULL, RESTART}, // auto|primary|<1-based index>| (gfx_window_win.c, gfx_window_sdl.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/gfx_window_sdl.c b/runtime/gfx_window_sdl.c index 689fb911..8a1ef5dc 100644 --- a/runtime/gfx_window_sdl.c +++ b/runtime/gfx_window_sdl.c @@ -1,7 +1,7 @@ // The SDL3 back end of the window (gfx_wsys.h), for hosts without Win32: the window on a thread of its own that also -// runs SDL's event loop, F11 fullscreen (borderless, the desktop's mode), the settings NX2NAVI_WINDOW_SIZE and -// NX2NAVI_FULLSCREEN, the display's refresh rate, focus, the Vulkan surface. The swapchain and the present are -// gfx_present.c's. Keyboard and mouse events go to input_sdl.c and to the menu (ui/menu_sdl.cpp). +// runs SDL's event loop, F11 fullscreen (borderless, the desktop's mode), the settings NX2NAVI_WINDOW_SIZE, +// NX2NAVI_FULLSCREEN and NX2NAVI_MONITOR, the display's refresh rate, focus, the Vulkan surface. The swapchain and the +// present are gfx_present.c's. Keyboard and mouse events go to input_sdl.c and to the menu (ui/menu_sdl.cpp). // // SDL wants its video calls on the thread that initialised video, so this thread does them all: the others hand it // work through run_on_window (a user event and a wait) or post a user event. Video is initialised before the window @@ -121,7 +121,7 @@ void gfx_window_request_close(void) { if (g_win) post(EV_CLOSE); } -// ---- the display's refresh rate and size +// ---- the display's refresh rate static void refresh_rate_update(void) { const SDL_DisplayMode *m = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(g_win)); double hz = 0; @@ -130,15 +130,6 @@ static void refresh_rate_update(void) { gfx_window_set_refresh(hz); } -int gfx_window_monitor_size(int *w, int *h) { - if (!g_win) return 0; - const SDL_DisplayMode *m = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(g_win)); - if (!m) return 0; - *w = (int)(m->w * m->pixel_density + 0.5f); - *h = (int)(m->h * m->pixel_density + 0.5f); - return 1; -} - static void density_update(void) { float d = SDL_GetWindowPixelDensity(g_win); __atomic_store_n(&g_density_milli, (int32_t)((d > 0 ? d : 1) * 1000 + 0.5f), __ATOMIC_RELAXED); @@ -248,6 +239,187 @@ int gfx_window_vk_presentable(VkInstance inst, VkPhysicalDevice pd, uint32_t fam return video_up() && SDL_Vulkan_GetPresentationSupport(inst, pd, family); } +// ---- monitor choice (NX2NAVI_MONITOR, --monitor, --list-monitors) +// The monitors in SDL_GetDisplays order, which is the index the setting counts in (1-based) and --list-monitors prints. +// The video driver sets that order, which need not match the desktop's own list of monitors: it holds while the same +// monitors stay connected, but a monitor that appears or goes away can move the ones after it. SDL has one name per +// monitor: make and model, with the connector (such as DP-2) on Wayland and X11. The name pins one monitor more firmly +// than its index. These calls belong to the thread that initialised video: the window thread, or the main thread in +// --list-monitors. +#define MON_MAX 32 + +typedef struct { + SDL_DisplayID id; + SDL_Rect rc; // the monitor's pixels, at its position in the desktop's coordinates + int primary; + unsigned dpi; // the display scale as a DPI, 96 at 100% + char name[128]; // SDL_GetDisplayName; empty when SDL has none +} MonInfo; + +typedef struct { MonInfo m[MON_MAX]; int n; } MonList; + +static int enum_monitors(MonList *l) { + *l = (MonList){0}; + int n = 0; + SDL_DisplayID *ids = SDL_GetDisplays(&n); + if (!ids) return 0; + SDL_DisplayID primary = SDL_GetPrimaryDisplay(); + for (int i = 0; i < n && l->n < MON_MAX; i++) { + SDL_Rect rc; + if (!SDL_GetDisplayBounds(ids[i], &rc)) continue; + // A scaled desktop (Wayland at 125%) counts its coordinates in scaled units: the pixel density turns the size + // back into the monitor's pixels, and the content scale and the density together are the display scale + // (SDL_GetWindowDisplayScale gives a window the same product). The position stays in the desktop's units. + const SDL_DisplayMode *mode = SDL_GetCurrentDisplayMode(ids[i]); + float density = mode && mode->pixel_density > 0 ? mode->pixel_density : 1, scale = SDL_GetDisplayContentScale(ids[i]); + if (!(scale > 0)) scale = 1; + const char *name = SDL_GetDisplayName(ids[i]); + MonInfo *m = &l->m[l->n++]; + *m = (MonInfo){.id = ids[i], .rc = {rc.x, rc.y, (int)(rc.w * density + 0.5f), (int)(rc.h * density + 0.5f)}, + .primary = ids[i] == primary, .dpi = (unsigned)(96 * scale * density + 0.5f)}; + snprintf(m->name, sizeof m->name, "%s", name ? name : ""); + } + SDL_free(ids); + return l->n; +} + +// `2 "HP Inc. HP E243 CNK0161ZY2 (DP-3)" 1920x1080 at 3456,756 dpi 120 (125%)`: one monitor, for the log and +// --list-monitors, with " primary" after the rectangle on the primary one. +static void monitor_line(const MonList *l, int i, char *out, size_t cap) { + const MonInfo *m = &l->m[i]; + snprintf(out, cap, "%d \"%s\" %dx%d at %d,%d%s dpi %u (%u%%)", i + 1, m->name, m->rc.w, m->rc.h, m->rc.x, m->rc.y, + m->primary ? " primary" : "", m->dpi, m->dpi * 100 / 96); +} + +static int monitor_unset(const char *v) { return !v || !*v || !SDL_strcasecmp(v, "auto"); } + +// 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 name (the first monitor in list order when several match). why gets the rule that matched, or the reason +// nothing did. The same rules as gfx_window_win.c resolve_monitor, which also matches the GDI device name. +static int resolve_monitor(const MonList *l, const char *want, char *why, size_t cap) { + if (!SDL_strcasecmp(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; + } + int first = -1, hits = 0; + for (int i = 0; i < l->n; i++) + if (SDL_strcasestr(l->m[i].name, want)) { + if (first < 0) first = i; + hits++; + } + if (first < 0) { + snprintf(why, cap, "no monitor name contains it"); + return -1; + } + if (hits > 1) snprintf(why, cap, "monitor name contains it; the first of %d matches", hits); + else snprintf(why, cap, "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. Window thread. +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. It +// runs on the main thread before any window thread exists, so that thread initialises video here and shuts it down again. +int list_monitors(void) { + if (!SDL_Init(SDL_INIT_VIDEO)) { + fprintf(stderr, "list-monitors: SDL video init failed (%s)\n", SDL_GetError()); + return 1; + } + MonList l; + char line[512], why[256]; + int n = enum_monitors(&l); + if (!n) { + fprintf(stderr, "list-monitors: no monitor found\n"); + SDL_Quit(); + return 1; + } + printf("index 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); + } + SDL_Quit(); + return 0; +} + +typedef struct { int w, h, ok; } SizeReq; + +static void chosen_size(void *arg) { + SizeReq *r = arg; + MonInfo mon; + if (chosen_monitor(&mon, 0)) r->w = mon.rc.w, r->h = mon.rc.h, r->ok = 1; +} + +// Pixels of the monitor the window is on; 0 when unknown. Before the window exists (widescreen.c wide_init at start) a +// fullscreen window opens on the monitor NX2NAVI_MONITOR picks, so that one's size; without a choice the system decides +// where the window goes, which is not known yet. +int gfx_window_monitor_size(int *w, int *h) { + if (g_win) { + const SDL_DisplayMode *m = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(g_win)); + if (!m) return 0; + *w = (int)(m->w * m->pixel_density + 0.5f); + *h = (int)(m->h * m->pixel_density + 0.5f); + return 1; + } + SizeReq r = {0}; + if (monitor_unset(cfg_get("NX2NAVI_MONITOR")) || !video_up()) return 0; + run_on_window(chosen_size, &r); + *w = r.w, *h = r.h; + return r.ok; +} + // ---- gfx_wsys.h static int open_ok; @@ -255,21 +427,46 @@ static void open_window(void *arg) { (void)arg; int w, h; gfx_cfg_window_size(&w, &h); - g_win = SDL_CreateWindow("NX2navi", w, h, SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY); + // NX2NAVI_MONITOR: the window is created centred on the chosen monitor; without a choice the system places it. + MonInfo mon; + int on_mon = chosen_monitor(&mon, 1); + SDL_PropertiesID props = SDL_CreateProperties(); + SDL_SetStringProperty(props, SDL_PROP_WINDOW_CREATE_TITLE_STRING, "NX2navi"); + SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_WIDTH_NUMBER, w); + SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_HEIGHT_NUMBER, h); + SDL_SetBooleanProperty(props, SDL_PROP_WINDOW_CREATE_VULKAN_BOOLEAN, true); + SDL_SetBooleanProperty(props, SDL_PROP_WINDOW_CREATE_RESIZABLE_BOOLEAN, true); + SDL_SetBooleanProperty(props, SDL_PROP_WINDOW_CREATE_HIGH_PIXEL_DENSITY_BOOLEAN, true); + if (on_mon) { + SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_X_NUMBER, SDL_WINDOWPOS_CENTERED_DISPLAY(mon.id)); + SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_Y_NUMBER, SDL_WINDOWPOS_CENTERED_DISPLAY(mon.id)); + } + g_win = SDL_CreateWindowWithProperties(props); + SDL_DestroyProperties(props); if (!g_win) { rt_log("gfx: SDL_CreateWindow failed (%s)", SDL_GetError()); return; } set_pixel_size(w, h); // w x h were window units; the setting is in pixels + // Centred again at the size it ended up with. SDL also takes this position as the display the fullscreen below + // covers: a Wayland window cannot be moved, and this is how its fullscreen still opens on the chosen output. + if (on_mon) SDL_SetWindowPosition(g_win, SDL_WINDOWPOS_CENTERED_DISPLAY(mon.id), SDL_WINDOWPOS_CENTERED_DISPLAY(mon.id)); density_update(); 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); + int wayland = !SDL_strcmp(SDL_GetCurrentVideoDriver(), "wayland"); + // open_window runs once, so this is logged once + if (on_mon && wayland) + rt_log("gfx: Wayland leaves the placement of a windowed window to the compositor, so NX2NAVI_MONITOR only decides " + "where the game opens in fullscreen"); if (gfx_cfg_fullscreen()) SDL_SetWindowFullscreen(g_win, true); SDL_SyncWindow(g_win); + SDL_DisplayID at = SDL_GetDisplayForWindow(g_win); + if (on_mon && (gfx_cfg_fullscreen() || !wayland) && at != mon.id) { + const char *name = SDL_GetDisplayName(at); + rt_log("gfx: the window is not on the chosen monitor; it is on \"%s\"", name ? name : "an unknown monitor"); + } input_sdl_window(g_win); open_ok = 1; } @@ -280,12 +477,6 @@ 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/main.c b/runtime/main.c index b6b8dbb9..29624cb3 100644 --- a/runtime/main.c +++ b/runtime/main.c @@ -8,9 +8,10 @@ // 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 +// prints it, or part of a monitor's name (on Windows also of its GDI device name, DISPLAY3), +// case-insensitive. Wayland places a windowed window itself: only fullscreen opens on the monitor +// --list-monitors print each monitor's index, name (on Windows also its GDI device 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