From f34f91cc257ae21e7ec653b32390fdf695eca4ff Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:20:55 -0700 Subject: [PATCH] Settings: Internal resolution presets (Native ... 8K, Match display) Add a player-facing internal resolution on top of the raised GL ceiling. A preset is a target height; the runtime renders at the integer scale S = ceil(target / reference_lines), reference 240 by default: 720p 3x, 1080p 5x (area-resolved to 1080), 1440p 6x, 4K 9x, 5K 12x, 8K 18x. The backend then clamps S to what it can allocate. - runtime/include/internal_resolution.h: the preset table, stable ids, the value encoding shared with recomp-ui (0 unset, 1 native, -1 match display, N target lines) and the pure resolver. - config: [video] internal_resolution (a preset id or a number of lines) and resolution_reference_lines in game.toml; internal_resolution in settings.toml, written as a stable id beside supersampling = min(S, 4) for older runtimes. Precedence: game default < player's legacy factor < player's preset. settings.toml window_width now accepts up to 7680. - main.cpp: resolve the preset before gpu_init; Match display re-resolves from the game window's monitor (SDL3 mode height x pixel density) before the GL context allocates. The launcher gets the vocabulary (with a synthesized "Nx (L lines)" entry for a legacy factor no preset matches) behind RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION, on first boot and on the netplay soft-return. - The OpenGL game window gets SDL_WINDOW_HIGH_PIXEL_DENSITY (SDL2: ALLOW_HIGHDPI) only when the player chose more than native, so the drawable is in pixels on Retina; native keeps the window as it was. - debug server: video_info (preset, requested and effective scale, GL limit and clamp, hr surface, window points vs pixels), screenshot_hires reads the GL hr FBO at full size, screenshot_wide_hires captures the native-wide band at internal resolution. - docs: config_schema (keys, precedence), NETPLAY (a per-peer presentation setting), TCP_COMMANDS (index regenerated), ENHANCEMENTS IR1. Default-off: with no internal_resolution anywhere the scale, the window flags and the present path are unchanged. Tests: internal_resolution_test (presets x reference 240/480 x backend maxima, legacy mapping round trip), video_enhancement_settings_test (game and settings keys, stable-id round trip, legacy key still written), gl_internal_scale_guards (HiDPI only when opted in, launcher fields behind the capability macro). Co-Authored-By: Claude Opus 5.5 --- docs/ENHANCEMENTS.md | 46 +++++ docs/NETPLAY.md | 9 +- docs/TCP_COMMANDS.md | 10 +- docs/config_schema.md | 32 ++- recompiler/CMakeLists.txt | 6 + recompiler/src/config_loader.cpp | 50 ++++- recompiler/src/config_loader.h | 17 ++ .../tests/video_enhancement_settings_test.cpp | 90 +++++++++ runtime/include/internal_resolution.h | 128 ++++++++++++ runtime/include/psx_sdl.h | 30 +++ runtime/src/debug_server.c | 92 +++++++++ runtime/src/main.cpp | 183 +++++++++++++++++- .../tests/test_gl_internal_scale_guards.py | 23 +++ runtime/tests/test_internal_resolution.cpp | 83 ++++++++ 14 files changed, 790 insertions(+), 9 deletions(-) create mode 100644 runtime/include/internal_resolution.h create mode 100644 runtime/tests/test_internal_resolution.cpp diff --git a/docs/ENHANCEMENTS.md b/docs/ENHANCEMENTS.md index 81cae730..718dd222 100644 --- a/docs/ENHANCEMENTS.md +++ b/docs/ENHANCEMENTS.md @@ -868,3 +868,49 @@ tooling defect found on the way: `screenshot_hires` produces a tiled/black PNG at 768x480 scale-2 windowed (row-pitch bug in the hires readback) — the census + the player's own captures carried the session; fix it before the formal Crash/Tomba2 A/B. + +## IR1 — Internal resolution presets (Native … 8K) and the GL scale ceiling (2026-09-26) + +**What the player gets.** Settings → Display → **Internal resolution**: Native, +720p, 1080p, 1440p, 4K, 5K, 8K, Match display. A preset is a target height and +resolves to an integer scale over the title's reference height +(`[video] resolution_reference_lines`, 240 by default): 3x, 5x (1200 lines, +area-resolved to 1080), 6x, 9x, 12x, 18x. It is a Settings row, not a mod, +because it needs no game hooks; it is presentation-only, so it also applies in +netplay (each peer its own). Keys and precedence: `docs/config_schema.md`. + +**Why the old ceiling was 4.** The software renderer keeps a CPU mirror of +1 MiB·S², so it caps at 4, and every backend inherited that cap. OpenGL keeps +the hr surface on the GPU; its real limits are the driver's texture, +renderbuffer and viewport sizes (the surface is `1024·S × 512·S`) and memory. +The GL backend now clamps at context init to those limits and a 2 GiB budget, +logs the clamp, and steps down instead of failing (a failure used to drop the +whole backend to software). Measured on an Apple M4 (`4.1 Metal - 91.7`): all +three limits are 16384, so the full-VRAM surface stops at 16x. + +**Fixes that only bite above 1x** (all gated on S > 1, so native is +byte-identical): + +- Lines: `glLineWidth(S)` is capped at 1 on core profiles (macOS reports + `GL_ALIASED_LINE_WIDTH_RANGE` 1..1), so every line was one hr pixel thick. + Lines are now quads one native pixel thick that include both endpoints, like + the software rasterizer. +- Present: a supersampled source more than 1.25x the output is area-resolved + (bilinear taps over the pixel's footprint) instead of one tap, and the UV + inset is half a texel, not half a native pixel (which cropped (S-1)/2 texels + per edge). +- The copy scratch is sized to the largest copy and the mask stencil is + rebuilt in tiles over the primitive bbox union, so a large S does not cost + another full-size surface. +- The CPU present path under GL presents at 1x (its readout is native) and its + staging buffer no longer grows with S². +- The macOS game window gets a high-pixel-density drawable when the player + picks anything above native; without it the drawable is in points and the + compositor stretches it, throwing half the resolution away. + +**Verification.** `gl_scale_invariance_test` renders one GP0 scene on a hidden +real GL context at 1, 2, 3, 5 and 9x and requires identical guest-visible VRAM +(the pack of the hr surface) at every scale, one-native-pixel lines at +internal resolution, and a 32x request that stays on GL at the driver clamp. +In a running game, `video_info` reports the requested and effective scale and +the drawable; `screenshot_hires` reads the hr FBO at full size. diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index 8c4e5bbd..d9f98ba1 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -128,12 +128,19 @@ When **OpenGL** is selected and netplay is active, psxrecomp can run a | Layer | Scale | Role | |-------|-------|------| | Software rasterizer | **1×** (headless authority) | Deterministic VRAM / digests / rollback snaps | -| OpenGL | Player supersampling (e.g. 2×–4×) | Window present quality only | +| OpenGL | Player internal resolution (Native … 8K, or legacy 2×–4×) | Window present quality only | Cost: extra CPU for the 1× SW pass. Benefit: peers can use different GL settings without desyncing the sim. SW-only netplay forces scale 1 for the whole path. Offline play keeps full supersampling with no dual-raster tax. +**Internal resolution is a per-peer presentation setting.** Settings → Display → +Internal resolution is not a mod, so it is not cleared for netplay: each peer +keeps its own preset, clamped to its own GPU, and one peer at 4K next to another +at Native is a supported match. Only the GL present surface changes; the 1× +software authority, the digests and the rollback snapshots are the same on every +peer. Widescreen and frame-rate mods, by contrast, are cleared for netplay. + --- ## Connectivity: ICE, TURN, SFU, LAN diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 01eb5d27..2150903a 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -50,7 +50,9 @@ Columns: **N** = native, **D** = DuckStation oracle. | `read_scratch` | | ✓ | `addr`, `len` | Read PS1 scratchpad (0x1F800000 region) | | `read_vram` / `vram_peek` | ✓¹ | ✓ | `x`, `y`, `w`, `h` | Read 16-bit VRAM pixels (max 128×128) | | `gpu_state` | ✓ | ✓ | — | Display area, display depth, `screen_offset_y`, draw offset, GPUSTAT, clip rect, xfer state. A positive screen offset moves 24-bit scanout down from the PAL or NTSC broadcast centre | -| `screenshot_hires` | ✓ | ✓ | `path` | PNG of the **supersampled** surface (the present path the window uses), at `display × gr_scale()`. ⚠ `screenshot`/`screenshot_file` capture native 15-bit VRAM and are **blind to anything that only exists in the hi-res mirror** — geometry correction, SSAA edges, perspective UVs — so they show a clean frame while the player sees a broken one. Use this one to verify those. Falls back to the native resolve (and reports `scale: 1`) when no hi-res surface exists | +| `screenshot_hires` | ✓ | ✓ | `path` | PNG of the **supersampled** surface (the present path the window uses), at `display × gr_scale()`. ⚠ `screenshot`/`screenshot_file` capture native 15-bit VRAM and are **blind to anything that only exists in the hi-res mirror** — geometry correction, SSAA edges, perspective UVs — so they show a clean frame while the player sees a broken one. Use this one to verify those. Under OpenGL it reads the hr FBO directly (the CPU-side mirror is native there), so it is the capture that proves an internal-resolution preset: 4K is 2880×2160 for a 320×240 display. Falls back to the native resolve (and reports `scale: 1`) when no hi-res surface exists | +| `video_info` | ✓ | | — | Internal resolution end to end: `preset` (0 unset, 1 native, -1 match display, else target lines), `reference_lines`, `requested_scale` (what the preset asked for), `effective_scale` (what the backend allocated), `internal_lines`, the GL limit (`gl_max_dim`, `gl_max_scale`, `gl_clamp_reason`: 1 texture, 2 budget, 4 ceiling; `gl_alloc_retries`, `gl_budget_mib`), the hr surface (`fbo_w`/`fbo_h`), and the window in points (`window_w`/`window_h`) and pixels (`drawable_w`/`drawable_h`, `hidpi_window`) | +| `screenshot_wide_hires` | ✓ | | `path`, `base_x` | The displayed band of the native-wide surface at internal resolution (`wide_w×S` by `height×S`). `present_shot` is capped at the window; this checks a widescreen + internal-resolution combination at full size | | `present_shot` | ✓ | | `path` | PNG of the **composed present surface** — the frame after the backend fits the display buffer to the window, so it carries the presented aspect. ⚠ every other capture resolves the display buffer *before* that fit: on a 508×256 display in a 4:3 window they answer 508×256 while the player sees 640×480. Use this one for anything aspect-shaped (widescreen, letterbox), where a pre-fit buffer would hide the very stage the change touches. Staged and fulfilled on the next present, so the ack means *queued* — poll `present_shot_seq`. Unavailable headless and on the Vulkan backend (its swapchain has no readback hook) | | `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates | | `geom_correction` | | ✓ | — | `[video] geometry_correction` / `perspective_texturing` engagement: enable flag plus free-running `geometry_vertex_hits` and `perspective_triangles` totals. Both enhancements silently fall back to the faithful path on anything they cannot prove is projected geometry, so a zero counter with the flag on means the title never qualifies — sample twice and diff for a rate | @@ -336,9 +338,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**316 commands registered** — 303 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**318 commands registered** — 305 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -54 of 316 have prose above; **262 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +56 of 318 have prose above; **262 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -572,6 +574,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `screenshot` | ✓ | ✓ | ✓ | | `screenshot_file` | ✓ | ✓ | ✓ | | `screenshot_hires` | ✓ | | ✓ | +| `screenshot_wide_hires` | ✓ | | ✓ | | `set_input` | ✓ | ✓ | ✓ | | `set_snapshot` | ✓ | ✓ | ✓ | | `sio_arm_audit` | ✓ | | | @@ -613,6 +616,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `unknown_dispatch_log` | ✓ | | | | `unwatch` | ✓ | | ✓ | | `vblank_rate` | ✓ | | | +| `video_info` | ✓ | | ✓ | | `vk_perf` | ✓ | | | | `vram_peek` | ✓ | ✓ | ✓ | | `vsync_query_hle` | ✓ | | | diff --git a/docs/config_schema.md b/docs/config_schema.md index 7d6ca630..dac5b7f8 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -450,7 +450,37 @@ a build compiled with Vulkan support. `offer_vulkan` controls launcher visibility only; it defaults to false so game projects must explicitly expose Vulkan after validating their visuals and stability. -### Internal resolution (`supersampling`) +### Internal resolution (`internal_resolution`, `supersampling`) + +The player picks a preset in Settings → Display → **Internal resolution**: +Native, 720p, 1080p, 1440p, 4K, 5K, 8K or Match display. It is stored in the +player's `settings.toml`, and a game may ship a default the same way: + +```toml +[video] +internal_resolution = "4k" # native | 720p | 1080p | 1440p | 4k | 5k | 8k | display, or a number of lines +resolution_reference_lines = 240 # game.toml only: the title's usual display height (120..1024) +``` + +A preset is a target height. The runtime renders at the integer scale +`S = ceil(target / resolution_reference_lines)`: at the default 240 lines, +720p is 3x, 1080p is 5x (1200 lines, area-resolved to 1080), 1440p 6x, 4K 9x, +5K 12x and 8K 18x. A 480-line interlaced screen renders at twice the target +and is resolved down. **Match display** takes the monitor's pixel height, +measured from the game window's display when it opens. The backend clamps S +to what it can allocate (below); `video_info` over TCP reports both numbers. + +Precedence: the game's `internal_resolution` is the default; a player's legacy +`supersampling` in `settings.toml` outranks it; the player's own +`internal_resolution` outranks both. The launcher writes the preset as a stable +id (`"4k"`) beside `supersampling = min(S, 4)`, so an older runtime reading the +same file still gets the nearest factor it supports. A legacy factor with no +matching preset appears in the launcher as its own entry, for example +"2x (480 lines)". `settings.toml` `window_width` accepts 640 to 7680. + +On OpenGL, any choice above native opens the game window with a +high-pixel-density drawable (macOS Retina, Wayland scaling), so the window has +the pixels to show it. Native keeps the window exactly as before. `supersampling = N` renders at N times the native resolution per axis and downsamples to the window. It accepts 1 to 32 in both `game.toml` and the diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index f14b5fa5..cec9c228 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -416,6 +416,12 @@ if(BUILD_TESTING) target_include_directories(gl_scale_limits_test PRIVATE ../runtime/include) add_test(NAME gl_scale_limits_test COMMAND gl_scale_limits_test) + # Internal resolution presets -> integer scale (pure header). + add_executable(internal_resolution_test + ../runtime/tests/test_internal_resolution.cpp) + target_include_directories(internal_resolution_test PRIVATE ../runtime/include) + add_test(NAME internal_resolution_test COMMAND internal_resolution_test) + add_executable(cli_boot_path_test tests/cli_boot_path_test.cpp src/cli_boot_path.cpp diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 419724d5..8cf3efd1 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -14,6 +14,7 @@ #include #include "bios_rom_alias.h" +#include "../../runtime/include/internal_resolution.h" #include "host_path.h" #include "fmt/format.h" #include "ps1_exe_parser.h" @@ -582,6 +583,32 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& } rt.video_supersampling = static_cast(n); } + if (video.contains("internal_resolution")) { + const toml::value& ir = toml::find(video, "internal_resolution"); + int value = 0; + bool ok = false; + if (ir.is_string()) { + ok = psx_ir_parse(ir.as_string().str.c_str(), &value) != 0; + } else if (ir.is_integer()) { + const auto n = ir.as_integer(); + ok = n >= PSX_IR_MIN_LINES && n <= PSX_IR_MAX_LINES; + value = static_cast(n); + } + if (!ok) { + throw std::runtime_error( + "[video] internal_resolution must be native, 720p, 1080p, " + "1440p, 4k, 5k, 8k, display, or a number of lines"); + } + rt.video_internal_resolution = value; + } + if (video.contains("resolution_reference_lines")) { + const auto n = toml::find(video, "resolution_reference_lines"); + if (n < 120 || n > 1024) { + throw std::runtime_error(fmt::format( + "[video] resolution_reference_lines out of range (120..1024): {}", n)); + } + rt.video_resolution_reference_lines = static_cast(n); + } if (video.contains("window_width")) { const auto n = toml::find(video, "window_width"); if (n < 640 || n > 7680) { @@ -2317,9 +2344,23 @@ UserSettings load_user_settings(const fs::path& path) { const auto n = toml::find(v, "supersampling"); if (n >= 1 && n <= 32) { s.supersampling = (int)n; s.has_supersampling = true; } }); + if (v.contains("internal_resolution")) try_get([&]{ + const toml::value& ir = toml::find(v, "internal_resolution"); + int value = 0; + if (ir.is_string()) { + if (psx_ir_parse(ir.as_string().str.c_str(), &value)) { + s.internal_resolution = value; s.has_internal_resolution = true; + } + } else if (ir.is_integer()) { + const auto n = ir.as_integer(); + if (n >= PSX_IR_MIN_LINES && n <= PSX_IR_MAX_LINES) { + s.internal_resolution = (int)n; s.has_internal_resolution = true; + } + } + }); if (v.contains("window_width")) try_get([&]{ const auto n = toml::find(v, "window_width"); - if (n >= 640 && n <= 3840) { s.window_width = (int)n; s.has_window_width = true; } + if (n >= 640 && n <= 7680) { s.window_width = (int)n; s.has_window_width = true; } }); if (v.contains("antialiasing")) try_get([&]{ s.antialiasing = toml::find(v, "antialiasing"); s.has_antialiasing = true; @@ -2670,6 +2711,13 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { << "\"\n"; if (s.has_supersampling) f << "supersampling = " << s.supersampling << "\n"; + if (s.has_internal_resolution && psx_ir_value_valid(s.internal_resolution)) { + const char* id = psx_ir_id_for(s.internal_resolution); + if (id) + f << "internal_resolution = \"" << id << "\"\n"; + else + f << "internal_resolution = " << s.internal_resolution << "\n"; + } if (s.has_window_width) f << "window_width = " << s.window_width << "\n"; if (s.has_antialiasing) diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 6f11cf29..30525bde 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -385,6 +385,18 @@ struct RuntimeConfig { // driver's texture limits and a memory budget (gl_scale_limits.h). int video_supersampling = 1; + // internal_resolution: the "Internal resolution" preset (Settings -> + // Display), which supersedes supersampling when set. Encoding as in + // runtime/include/internal_resolution.h: 0 = unset (supersampling stands), + // 1 = native, -1 = match the display, N >= 2 = target output lines. In + // game.toml it is the shipped default ("native", "720p", "1080p", + // "1440p", "4k", "5k", "8k", "display", or a number of lines). + int video_internal_resolution = 0; + // resolution_reference_lines: the title's usual display height, which a + // preset divides into (S = ceil(target / reference)). 240 for NTSC + // 320x240 games; 120..1024. + int video_resolution_reference_lines = 240; + // Optional initial window width declared by the title profile. Zero keeps // the historical fit-to-display behavior; player settings may override it. int video_window_width = 0; @@ -1202,6 +1214,11 @@ struct UserSettings { // [video] bool has_renderer = false; int renderer = DEFAULT_VIDEO_RENDERER; // 0=software,1=opengl,2=vulkan bool has_supersampling = false; int supersampling = 1; // 1..32 (runtime clamps per backend) + // Internal resolution preset (RuntimeConfig::video_internal_resolution + // encoding). Written as a stable id ("4k") or a line count; when present + // it wins over supersampling, which is still written (capped at 4) so an + // older runtime reading the same file degrades gracefully. + bool has_internal_resolution = false; int internal_resolution = 0; // Window size: width in px; height is always width*3/4 (PSX 4:3). Applies to // both the launcher and the emulator window so they boot at the same size. bool has_window_width = false; int window_width = 1280; // -> 1280x960 diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index b5d4bb50..87028f43 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -187,7 +187,97 @@ static void test_user_settings_round_trip() { fs::remove(p); } +/* Internal resolution (Settings -> Display): game.toml shipped default, + * settings.toml precedence over the legacy factor, stable-id round trip, + * and the legacy supersampling range widened to the runtime's 1..32. */ +static void test_internal_resolution_game_toml() { + fs::path p = write_game_toml("psxrecomp_ir_default.toml", ""); + auto gc = PSXRecompV4::load_game_config(p); + check(gc.runtime.video_internal_resolution == 0, + "internal_resolution defaults unset (supersampling stands)"); + check(gc.runtime.video_resolution_reference_lines == 240, + "resolution_reference_lines defaults to 240"); + fs::remove(p); + + p = write_game_toml("psxrecomp_ir_4k.toml", + "[video]\n" + "internal_resolution = \"4K\"\n" + "resolution_reference_lines = 240\n" + "supersampling = 12\n"); + gc = PSXRecompV4::load_game_config(p); + check(gc.runtime.video_internal_resolution == 2160, "game.toml \"4K\" parses (case-insensitive)"); + check(gc.runtime.video_supersampling == 12, "supersampling accepts 12 (1..32)"); + fs::remove(p); + + p = write_game_toml("psxrecomp_ir_lines.toml", + "[video]\n" + "internal_resolution = 1600\n"); + gc = PSXRecompV4::load_game_config(p); + check(gc.runtime.video_internal_resolution == 1600, "game.toml integer lines parse"); + fs::remove(p); + + for (const char* bad : { "internal_resolution = \"9k\"\n", + "internal_resolution = 1\n", + "resolution_reference_lines = 50\n", + "supersampling = 33\n" }) { + p = write_game_toml("psxrecomp_ir_bad.toml", std::string("[video]\n") + bad); + bool rejected = false; + try { (void)PSXRecompV4::load_game_config(p); } catch (const std::exception&) { rejected = true; } + check(rejected, bad); + fs::remove(p); + } +} + +static void test_internal_resolution_settings() { + fs::path p = write_temp("psxrecomp_ir_settings.toml", + "[video]\n" + "supersampling = 2\n" + "internal_resolution = \"display\"\n" + "window_width = 7680\n"); + auto us = PSXRecompV4::load_user_settings(p); + check(us.has_internal_resolution && us.internal_resolution == -1, + "settings.toml internal_resolution = \"display\" reads -1"); + check(us.has_supersampling && us.supersampling == 2, "legacy supersampling still read"); + check(us.has_window_width && us.window_width == 7680, + "settings.toml window_width accepts 7680 (was capped at 3840)"); + fs::remove(p); + + p = write_temp("psxrecomp_ir_settings_bad.toml", + "[video]\n" + "internal_resolution = \"huge\"\n"); + us = PSXRecompV4::load_user_settings(p); + check(!us.parse_error && !us.has_internal_resolution, + "an unknown preset is ignored, not an error"); + fs::remove(p); + + const int values[] = { 1, 720, 1080, 1440, 2160, 2880, 4320, -1, 480 }; + const char* ids[] = { "native", "720p", "1080p", "1440p", "4k", "5k", "8k", "display", nullptr }; + for (int i = 0; i < 9; i++) { + PSXRecompV4::UserSettings out; + out.has_internal_resolution = true; out.internal_resolution = values[i]; + out.has_supersampling = true; out.supersampling = 4; + p = fs::temp_directory_path() / "psxrecomp_ir_roundtrip.toml"; + check(PSXRecompV4::save_user_settings(p, out), "save_user_settings writes"); + std::ifstream f(p); + std::string text((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + if (ids[i]) + check(text.find(std::string("internal_resolution = \"") + ids[i] + "\"") != std::string::npos, + "a preset is persisted by its stable id"); + else + check(text.find("internal_resolution = 480") != std::string::npos, + "a custom line count is persisted as an integer"); + auto back = PSXRecompV4::load_user_settings(p); + check(back.has_internal_resolution && back.internal_resolution == values[i], + "internal_resolution survives a save/load round trip"); + check(back.has_supersampling && back.supersampling == 4, + "the legacy supersampling key is still written beside it"); + fs::remove(p); + } +} + int main() { + test_internal_resolution_game_toml(); + test_internal_resolution_settings(); test_defaults_off(); test_game_toml_opt_in(); test_game_window_width_validation(); diff --git a/runtime/include/internal_resolution.h b/runtime/include/internal_resolution.h new file mode 100644 index 00000000..0f6ed38b --- /dev/null +++ b/runtime/include/internal_resolution.h @@ -0,0 +1,128 @@ +/* internal_resolution.h — the "Internal resolution" setting: presets, their + * persisted ids, and the integer scale each one asks the renderer for. + * + * Pure (no SDL, no GL) so both the config loader and a test can use it. + * + * VALUE ENCODING (shared with recomp-ui's Settings.internal_resolution): + * 0 unset: the legacy [video] supersampling factor stands + * 1 native (1x) + * -1 match display: the target is the monitor's pixel height + * N (N >= 2) a target output height in lines (720, 1080, ... or a + * legacy "Nx" entry expressed as reference_lines * N) + * + * SCALE. The renderer only does integer scales (the pack of the hr surface + * back to native VRAM is exact only then), so a target height becomes + * S = ceil(target_lines / reference_lines) + * clamped to [1, s_max]. reference_lines is the title's usual display height + * ([video] resolution_reference_lines, default 240): R4's races are 320x240, + * so 1080p is S = 5 (1200 lines, area-resolved to 1080) and 4K is S = 9. + * A 480-line mode (interlaced menus) renders at twice the target and is + * resolved down. The backend then clamps S again to what it can allocate. */ +#pragma once + +#include + +#define PSX_IR_UNSET 0 +#define PSX_IR_NATIVE 1 +#define PSX_IR_DISPLAY (-1) +#define PSX_IR_DEFAULT_REF_LINES 240 +/* Custom line counts accepted from config (a 16K panel is 8640 lines). */ +#define PSX_IR_MIN_LINES 2 +#define PSX_IR_MAX_LINES 8640 + +typedef struct PsxIrPreset { + const char *id; /* stable id persisted in settings.toml */ + const char *label; /* launcher label */ + int value; /* encoding above */ +} PsxIrPreset; + +static const PsxIrPreset k_psx_ir_presets[] = { + { "native", "Native", PSX_IR_NATIVE }, + { "720p", "720p", 720 }, + { "1080p", "1080p", 1080 }, + { "1440p", "1440p", 1440 }, + { "4k", "4K", 2160 }, + { "5k", "5K", 2880 }, + { "8k", "8K", 4320 }, + { "display", "Match display", PSX_IR_DISPLAY }, +}; +#define PSX_IR_PRESET_COUNT ((int)(sizeof(k_psx_ir_presets) / sizeof(k_psx_ir_presets[0]))) + +static inline int psx_ir_lower_eq(const char *a, const char *b) { + for (; *a && *b; a++, b++) { + char ca = (*a >= 'A' && *a <= 'Z') ? (char)(*a - 'A' + 'a') : *a; + if (ca != *b) return 0; + } + return *a == 0 && *b == 0; +} + +/* 1 if value is a legal encoding (unset excluded). */ +static inline int psx_ir_value_valid(int value) { + return value == PSX_IR_NATIVE || value == PSX_IR_DISPLAY || + (value >= PSX_IR_MIN_LINES && value <= PSX_IR_MAX_LINES); +} + +/* Parse a persisted value: a preset id (case-insensitive) or a decimal line + * count in [PSX_IR_MIN_LINES, PSX_IR_MAX_LINES]. Returns 1 and stores the + * value on success; 0 for anything else (the caller keeps its default). */ +static inline int psx_ir_parse(const char *s, int *out) { + if (!s || !*s) return 0; + for (int i = 0; i < PSX_IR_PRESET_COUNT; i++) + if (psx_ir_lower_eq(s, k_psx_ir_presets[i].id)) { *out = k_psx_ir_presets[i].value; return 1; } + long v = 0; + const char *p = s; + while (*p >= '0' && *p <= '9') { v = v * 10 + (*p - '0'); p++; if (v > PSX_IR_MAX_LINES) return 0; } + if (p == s || *p) return 0; + if (v < PSX_IR_MIN_LINES) return 0; /* a number is always lines, never "1 = native" */ + *out = (int)v; + return 1; +} + +/* The preset id for a value, or NULL for a custom line count (persist those + * as an integer). */ +static inline const char *psx_ir_id_for(int value) { + for (int i = 0; i < PSX_IR_PRESET_COUNT; i++) + if (k_psx_ir_presets[i].value == value) return k_psx_ir_presets[i].id; + return NULL; +} + +static inline const char *psx_ir_label_for(int value) { + for (int i = 0; i < PSX_IR_PRESET_COUNT; i++) + if (k_psx_ir_presets[i].value == value) return k_psx_ir_presets[i].label; + return NULL; +} + +/* Unclamped scale a value asks for. display_px_h <= 0 (unknown) makes Match + * display 1 until the caller can measure the monitor. */ +static inline int psx_ir_scale_for(int value, int ref_lines, int display_px_h) { + if (ref_lines < 1) ref_lines = PSX_IR_DEFAULT_REF_LINES; + int target; + if (value == PSX_IR_DISPLAY) target = display_px_h; + else if (value >= PSX_IR_MIN_LINES) target = value; + else return 1; /* native / unset */ + if (target <= ref_lines) return 1; + return (target + ref_lines - 1) / ref_lines; +} + +/* The scale to request: psx_ir_scale_for clamped to [1, s_max]. */ +static inline int psx_resolve_internal_scale(int value, int ref_lines, + int display_px_h, int s_max) { + int s = psx_ir_scale_for(value, ref_lines, display_px_h); + if (s_max >= 1 && s > s_max) s = s_max; + return s < 1 ? 1 : s; +} + +/* A legacy [video] supersampling factor as a value the launcher can show: + * the preset that resolves to the same scale (1 -> Native, 3 -> 720p, + * 5 -> 1080p, 6 -> 1440p, 9 -> 4K, 12 -> 5K, 18 -> 8K at 240 lines), + * otherwise ref_lines * n as a custom entry ("2x (480 lines)"). */ +static inline int psx_ir_from_supersampling(int n, int ref_lines) { + if (ref_lines < 1) ref_lines = PSX_IR_DEFAULT_REF_LINES; + if (n <= 1) return PSX_IR_NATIVE; + for (int i = 0; i < PSX_IR_PRESET_COUNT; i++) { + int v = k_psx_ir_presets[i].value; + if (v >= PSX_IR_MIN_LINES && psx_ir_scale_for(v, ref_lines, 0) == n) return v; + } + long lines = (long)ref_lines * n; + return lines > PSX_IR_MAX_LINES ? PSX_IR_MAX_LINES : (int)lines; +} diff --git a/runtime/include/psx_sdl.h b/runtime/include/psx_sdl.h index 1749b46f..343ff113 100644 --- a/runtime/include/psx_sdl.h +++ b/runtime/include/psx_sdl.h @@ -137,6 +137,25 @@ static inline int psx_sdl_get_current_display_mode( return 0; } +/* Opt-in high-pixel-density game window. Without it, SDL3 on a Retina Mac + * gives the GL context a drawable in POINTS (half the panel resolution) and + * the compositor stretches it, so any internal resolution above that is + * averaged away. Only set when the player chose an internal resolution. */ +#define PSX_SDL_WINDOW_HIGH_DENSITY SDL_WINDOW_HIGH_PIXEL_DENSITY + +/* Pixel height of the display a window is on (the primary display when the + * window is NULL), for "Match display". 0 when unknown. */ +static inline int psx_sdl_display_pixel_height(SDL_Window *window) +{ + SDL_DisplayID display = window ? SDL_GetDisplayForWindow(window) : 0; + if (!display) display = SDL_GetPrimaryDisplay(); + if (!display) return 0; + const SDL_DisplayMode *mode = SDL_GetCurrentDisplayMode(display); + if (!mode || mode->h <= 0) return 0; + float density = mode->pixel_density > 0.0f ? mode->pixel_density : 1.0f; + return (int)((float)mode->h * density + 0.5f); +} + static inline const Uint8 *psx_sdl_get_keyboard_state(int *count) { return (const Uint8 *)(const void *)SDL_GetKeyboardState(count); @@ -239,4 +258,15 @@ static inline int psx_sdl_cond_wait_timeout( #include +#define PSX_SDL_WINDOW_HIGH_DENSITY SDL_WINDOW_ALLOW_HIGHDPI + +static inline int psx_sdl_display_pixel_height(SDL_Window *window) +{ + int index = window ? SDL_GetWindowDisplayIndex(window) : 0; + SDL_DisplayMode mode; + if (index < 0) index = 0; + if (SDL_GetCurrentDisplayMode(index, &mode) != 0 || mode.h <= 0) return 0; + return mode.h; +} + #endif diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 655d03f4..04a3cdc1 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8910,6 +8910,16 @@ static void handle_screenshot_hires(int id, const char *json) * deterministic black, never whatever the allocator handed back. */ uint32_t *argb = (uint32_t *)calloc((size_t)ow * oh, sizeof(uint32_t)); if (!argb) { send_err(id, "alloc failed"); return; } + /* OpenGL keeps the internal resolution in its FBO only (the CPU-side + * mirror is native), so read it from there. This is the capture that + * proves what an internal-resolution preset really renders. */ + if (gr_backend() == GR_BACKEND_OPENGL && scale > 1) { + int gw = 0, gh = 0; + int n = gl_renderer_read_display_hires((int)di.display_x, (int)di.display_y, + (int)w, (int)h, argb, + (int)((size_t)ow * oh), &gw, &gh); + if (n > 0 && (uint32_t)gw == ow && (uint32_t)gh == oh) goto encode; + } /* Renderer pitches are byte strides (the live SDL presentation path uses * the same contract). Passing `ow` here advanced each row by only one * quarter of its ARGB width, overlapping four rows and producing a PNG @@ -8939,6 +8949,7 @@ static void handle_screenshot_hires(int id, const char *json) } } +encode:; uint8_t *rgb = (uint8_t *)malloc((size_t)ow * oh * 3); if (!rgb) { free(argb); send_err(id, "alloc failed"); return; } for (size_t i = 0; i < (size_t)ow * oh; i++) { @@ -8960,6 +8971,85 @@ static void handle_screenshot_hires(int id, const char *json) "\"height\":%u,\"scale\":%d}", id, path, ow, oh, scale); } +/* video_info — the Internal resolution state end to end: the preset and its + * reference height, the scale requested of the backend, the scale the GL hr + * surface was actually allocated at (after the driver/memory clamp), the + * driver limit, the hr surface size, and the window's point and pixel sizes + * (the HiDPI drawable). Everything a preset needs to be verified against. */ +static void handle_video_info(int id, const char *json) +{ + (void)json; + extern void psx_video_resolution_info(int *preset, int *ref_lines, int *requested, + int *hidpi, int *win_w, int *win_h, + int *px_w, int *px_h); + int preset = 0, ref = 0, req = 0, hidpi = 0, ww = 0, wh = 0, pw = 0, ph = 0; + psx_video_resolution_info(&preset, &ref, &req, &hidpi, &ww, &wh, &pw, &ph); + GlScaleInfo si; + int gl = gl_renderer_scale_info(&si); + GpuDisplayInfo di; + gpu_get_display_info(&di); + int eff = gr_scale(); + send_fmt("{\"id\":%d,\"ok\":true,\"backend\":\"%s\",\"preset\":%d," + "\"reference_lines\":%d,\"requested_scale\":%d,\"effective_scale\":%d," + "\"internal_lines\":%u,\"gl\":%d,\"gl_max_dim\":%d,\"gl_max_scale\":%d," + "\"gl_clamp_reason\":%d,\"gl_alloc_retries\":%d,\"gl_budget_mib\":%d," + "\"fbo_w\":%d,\"fbo_h\":%d,\"hidpi_window\":%d,\"window_w\":%d," + "\"window_h\":%d,\"drawable_w\":%d,\"drawable_h\":%d," + "\"display_w\":%u,\"display_h\":%u}", + id, gr_backend() == GR_BACKEND_OPENGL ? "opengl" + : gr_backend() == GR_BACKEND_VULKAN ? "vulkan" : "software", + preset, ref, req, eff, di.height * (unsigned)(eff > 0 ? eff : 1), gl, + si.max_dim, si.max_scale, si.clamp_reason, si.alloc_retries, si.budget_mib, + si.fbo_w, si.fbo_h, hidpi, ww, wh, pw, ph, di.width, di.height); +} + +/* screenshot_wide_hires — the displayed band of the native-wide surface at + * internal resolution (GL): wide_w*S x h*S. present_shot is capped at the + * window, so this is how a widescreen + internal-resolution combination is + * checked without an 8K monitor. */ +static void handle_screenshot_wide_hires(int id, const char *json) +{ + extern int gr_wide_dump_full(uint32_t *out, int cap_pixels, int *ow, int *oh, int base_x); + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (di.disabled || di.height == 0) { send_err(id, "display disabled"); return; } + int S = gr_scale(); + if (S < 1) S = 1; + int base_x = json_get_int(json, "base_x", (int)di.display_x); + char path[512]; + if (!json_get_str(json, "path", path, sizeof(path))) + strncpy(path, "psx_screenshot_wide_hires.png", sizeof(path) - 1); + path[sizeof(path) - 1] = '\0'; + int cap = 8192 * 8192; + uint32_t *buf = (uint32_t *)malloc((size_t)cap * sizeof(uint32_t)); + if (!buf) { send_err(id, "alloc failed"); return; } + int W = 0, H = 0; + int n = gr_wide_dump_full(buf, cap, &W, &H, base_x); + if (n <= 0) { free(buf); send_err(id, "no wide surface (native-wide not engaged?)"); return; } + int y0 = (int)di.display_y * S, oh = (int)di.height * S; + if (y0 < 0) y0 = 0; + if (y0 + oh > H) oh = H - y0; + if (oh <= 0) { free(buf); send_err(id, "display band outside the surface"); return; } + uint8_t *rgb = (uint8_t *)malloc((size_t)W * oh * 3); + if (!rgb) { free(buf); send_err(id, "alloc failed"); return; } + for (int y = 0; y < oh; y++) + for (int x = 0; x < W; x++) { + uint32_t px = buf[(size_t)(y0 + y) * W + x]; + uint8_t *p = rgb + ((size_t)y * W + x) * 3; + p[0] = (uint8_t)((px >> 16) & 0xFF); + p[1] = (uint8_t)((px >> 8) & 0xFF); + p[2] = (uint8_t)(px & 0xFF); + } + free(buf); + FILE *f = fopen(path, "wb"); + if (!f) { free(rgb); send_err(id, "cannot open file"); return; } + int ok = png_write_rgb(f, rgb, (uint32_t)W, (uint32_t)oh); + free(rgb); fclose(f); + if (!ok) { send_err(id, "png encode failed"); return; } + send_fmt("{\"id\":%d,\"ok\":true,\"path\":\"%s\",\"width\":%d,\"height\":%d," + "\"scale\":%d}", id, path, W, oh, S); +} + /* present_shot — PNG of the COMPOSED renderer output: the frame after SDL fits * the display buffer into the logical surface, i.e. at the aspect the player is * actually looking at. @@ -14034,6 +14124,8 @@ static const CmdEntry s_commands[] = { { "display_ring_stats", handle_display_ring_stats }, { "dump_buffer", handle_dump_buffer }, { "wide_full", handle_wide_full }, + { "video_info", handle_video_info }, + { "screenshot_wide_hires", handle_screenshot_wide_hires }, { "wide_shot", handle_wide_shot }, { "gpu_opcodes", handle_gpu_opcodes }, { "gpu_ring_stats", handle_gpu_ring_stats }, diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 72a12c12..ed1b986d 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -7,6 +7,7 @@ #include "cpu_state.h" #include "window_size.h" /* default game-window size */ +#include "internal_resolution.h" /* Settings -> Display -> Internal resolution */ #include "psx_scheduler.h" /* psx_scheduler_run — deterministic TCB scheduler */ #include "parity_trace.h" /* general two-process control-flow parity ring */ #include "device_trace.h" /* general two-process device-event cycle ring */ @@ -1209,6 +1210,38 @@ static int g_video_perspective_texturing = 0; static int g_video_pgxp_cpu_mode = 0; static float g_video_pgxp_tolerance = 0.5f; static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; + +/* Settings -> Display -> Internal resolution (internal_resolution.h). The + * preset supersedes the legacy supersampling factor once set; PSX_IR_UNSET + * leaves the factor as the player (or game.toml) wrote it. */ +static int g_video_internal_res = PSX_IR_UNSET; +static int g_video_ref_lines = PSX_IR_DEFAULT_REF_LINES; +/* The scale asked of the backend before its own clamp (GL reports its real + * scale only after context init), and whether that request applies (netplay + * software-only forces 1x). Used for the opt-in HiDPI window and Match display. */ +static int g_video_requested_scale = 1; +static bool g_video_scale_applies = true; +static bool g_video_hidpi_window = false; + +static int video_scale_ceiling(void) { + return g_video_renderer == 1 ? GL_MAX_INTERNAL_SCALE : SW_MAX_INTERNAL_SCALE; +} + +/* Resolve the Internal resolution preset into g_video_scale. display_px_h is + * the monitor's pixel height for Match display (0 = not known yet: 1x until + * the window exists). No-op while unset. */ +static void apply_internal_resolution(int display_px_h) { + if (g_video_internal_res == PSX_IR_UNSET) return; + g_video_scale = psx_resolve_internal_scale(g_video_internal_res, g_video_ref_lines, + display_px_h, video_scale_ceiling()); +} + +/* The value the launcher row starts on: the preset, or the legacy factor shown + * as the preset it matches (3 -> 720p) or as its own "Nx" entry. */ +static int internal_resolution_for_launcher(void) { + if (g_video_internal_res != PSX_IR_UNSET) return g_video_internal_res; + return psx_ir_from_supersampling(g_video_scale < 1 ? 1 : g_video_scale, g_video_ref_lines); +} static std::string g_bezel_path; /* mod-owned OpenGL margin artwork */ static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) * fullscreen, 2 exclusive fullscreen */ @@ -1830,6 +1863,28 @@ static bool s_present_shot_pending = false; static std::atomic s_present_shot_seq{0}; /* bumps on every completion */ static std::atomic s_present_shot_ok{0}; /* 1 = last completion wrote a PNG */ +/* video_info (debug server): the Internal resolution state as main.cpp sees + * it, plus the game window's size in points and in pixels. */ +extern "C" void psx_video_resolution_info(int *preset, int *ref_lines, int *requested, + int *hidpi, int *win_w, int *win_h, + int *px_w, int *px_h) { + if (preset) *preset = g_video_internal_res; + if (ref_lines) *ref_lines = g_video_ref_lines; + if (requested) *requested = g_video_requested_scale; + if (hidpi) *hidpi = g_video_hidpi_window ? 1 : 0; + int ww = 0, wh = 0, pw = 0, ph = 0; +#ifndef PSX_SDL_NO_RENDER + if (sdl_window) { + SDL_GetWindowSize(sdl_window, &ww, &wh); + SDL_GL_GetDrawableSize(sdl_window, &pw, &ph); + } +#endif + if (win_w) *win_w = ww; + if (win_h) *win_h = wh; + if (px_w) *px_w = pw; + if (px_h) *px_h = ph; +} + extern "C" int present_shot_request(const char *path) { if (!path || !*path) return 0; @@ -12295,6 +12350,35 @@ namespace { "Fast-forward toggle", }; +#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) + /* Internal resolution vocabulary for the launcher's Display card: the + * presets, plus the player's current legacy factor as its own "Nx (L + * lines)" entry when no preset matches it, so nothing changes silently. */ + static const char* g_ir_labels[PSX_IR_PRESET_COUNT + 1]; + static int g_ir_values[PSX_IR_PRESET_COUNT + 1]; + static int g_ir_count = 0; + static char g_ir_custom_label[48]; + static const char kIrNote[] = + "Above 4x needs OpenGL. The GPU's texture limit can lower the scale " + "(16x on Apple GPUs)."; + static void build_internal_resolution_vocab(int current) { + g_ir_count = 0; + for (int i = 0; i < PSX_IR_PRESET_COUNT; i++) { + g_ir_labels[g_ir_count] = k_psx_ir_presets[i].label; + g_ir_values[g_ir_count] = k_psx_ir_presets[i].value; + g_ir_count++; + } + if (current >= PSX_IR_MIN_LINES && !psx_ir_id_for(current)) { + int s = psx_ir_scale_for(current, g_video_ref_lines, 0); + std::snprintf(g_ir_custom_label, sizeof g_ir_custom_label, + "%dx (%d lines)", s, current); + g_ir_labels[g_ir_count] = g_ir_custom_label; + g_ir_values[g_ir_count] = current; + g_ir_count++; + } + } +#endif + void ae_rui_set_sidecar_paths(const char* argv0) { const auto exe = exe_dir_from_argv(argv0 ? argv0 : ""); g_rui_keybinds_path = (exe / "keybinds.ini").string(); @@ -12350,6 +12434,14 @@ namespace { gi->has_turbo_loads = turbo_loads_offered_b ? 1 : 0; gi->has_geometry_precision = 1; gi->has_rewind_depth = 1; +#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) + /* The Supersampling row becomes an Internal resolution list. */ + build_internal_resolution_vocab(internal_resolution_for_launcher()); + gi->internal_resolution_labels = g_ir_labels; + gi->internal_resolution_values = g_ir_values; + gi->num_internal_resolutions = g_ir_count; + gi->internal_resolution_note = kIrNote; +#endif if (language_labels && num_languages > 0) { gi->language_labels = language_labels; gi->num_languages = num_languages; @@ -12842,6 +12934,8 @@ int main(int argc, char** argv) { for (uint32_t site : gc.vsync_event_horizon_extra_sites) psx_vsync_query_hle_add_extra_event_horizon_site(site); g_video_scale = gc.runtime.video_supersampling; + g_video_internal_res = gc.runtime.video_internal_resolution; + g_video_ref_lines = gc.runtime.video_resolution_reference_lines; if (gc.runtime.video_window_width > 0) { g_video_win_w = gc.runtime.video_window_width; } @@ -13241,7 +13335,13 @@ int main(int argc, char** argv) { if (us.has_netplay_lobby_url && !us.netplay_lobby_url.empty()) g_lnch_lobby_url = us.netplay_lobby_url; #endif - if (us.has_supersampling) g_video_scale = us.supersampling; + /* A player's legacy factor outranks the game's shipped preset; their + * own preset (written by this build) outranks both. */ + if (us.has_supersampling) { + g_video_scale = us.supersampling; + g_video_internal_res = PSX_IR_UNSET; + } + if (us.has_internal_resolution) g_video_internal_res = us.internal_resolution; if (us.has_window_width) g_video_win_w = us.window_width; if (us.has_antialiasing) g_video_aa = us.antialiasing; if (us.has_texture_filter) g_video_texfilter = us.texture_filter; @@ -13792,6 +13892,8 @@ int main(int argc, char** argv) { bool match_session_bios_set = false; seed.renderer = g_video_renderer; seed.has_renderer = true; seed.supersampling = g_video_scale; seed.has_supersampling = true; + seed.internal_resolution = internal_resolution_for_launcher(); + seed.has_internal_resolution = true; seed.antialiasing = g_video_aa; seed.has_antialiasing = true; seed.texture_filter = g_video_texfilter; seed.has_texture_filter = true; seed.fmv_filter = g_video_fmv_filter; seed.has_fmv_filter = true; @@ -13992,6 +14094,9 @@ int main(int argc, char** argv) { PSXRecompV4::DEFAULT_VIDEO_RENDERER != 0) ls.renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; ls.supersampling = seed.supersampling; +#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) + ls.internal_resolution = seed.internal_resolution; +#endif ls.antialiasing = seed.antialiasing ? 1 : 0; ls.texture_filter = seed.texture_filter; ls.fmv_filter = cfg_fmv_filter_to_launcher(seed.fmv_filter); @@ -14288,6 +14393,12 @@ int main(int argc, char** argv) { seed.window_width = ls.window_width; seed.has_window_width = true; seed.renderer = ls.renderer; seed.has_renderer = true; seed.supersampling = ls.supersampling; seed.has_supersampling = true; +#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) + if (psx_ir_value_valid(ls.internal_resolution)) { + seed.internal_resolution = ls.internal_resolution; + seed.has_internal_resolution = true; + } +#endif seed.antialiasing = ls.antialiasing != 0; seed.has_antialiasing = true; seed.geometry_correction = ls.geometry_correction != 0; seed.has_geometry_correction = true; @@ -14522,6 +14633,15 @@ int main(int argc, char** argv) { } g_video_renderer = seed.renderer; g_video_scale = seed.supersampling; + if (seed.has_internal_resolution && + psx_ir_value_valid(seed.internal_resolution)) { + g_video_internal_res = seed.internal_resolution; + apply_internal_resolution(psx_sdl_display_pixel_height(nullptr)); + /* An older runtime reading this settings.toml only knows + * supersampling (1..4): leave it the nearest it can do. */ + seed.supersampling = g_video_scale < SW_MAX_INTERNAL_SCALE + ? g_video_scale : SW_MAX_INTERNAL_SCALE; + } g_video_aa = seed.antialiasing; g_video_texfilter = seed.texture_filter; g_video_fmv_filter = seed.fmv_filter; @@ -14860,6 +14980,10 @@ session_reboot: /* Internal-resolution supersampling (SSAA). Must follow gpu_init. * Dual-raster: gr_set_scale(N) arms GL hr FBO @ N× while glb_set_scale * keeps SW at 1×. SW-only netplay: force scale 1. Offline: full SSAA. */ + /* Internal resolution preset -> scale (no-op while unset). Match display + * knows the monitor only once SDL video is up (launcher path); otherwise it + * is re-resolved from the game window's display before GL context init. */ + apply_internal_resolution(psx_sdl_display_pixel_height(nullptr)); if (g_video_scale < 1) g_video_scale = 1; { /* Per-backend ceiling. OpenGL allocates its hr surface at context init @@ -14899,6 +15023,10 @@ session_reboot: * still 0/1 until the GL context comes up later, so g_video_scale does not * hold the effective factor at this point. */ const int requested_scale = g_video_scale; + g_video_requested_scale = requested_scale; + g_video_scale_applies = !(net_cfg.enabled || s_netplay_sim_native_scale) || + (net_cfg.enabled && s_netplay_gl_present && + gl_renderer_cpu_auth_dual()); g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ gr_set_texture_filter(g_video_texfilter); /* Sub-pixel vertex precision + perspective-correct UVs. Both default off; @@ -14958,10 +15086,21 @@ session_reboot: /* Present-time screen-colour model (verified-enhancement LUT). Default raw * is byte-identical; PSX_SCREEN env overrides this at scanout. */ gpu_set_screen_kind(g_video_screen); - if (g_video_scale > 1 || g_video_texfilter) + if (g_video_internal_res != PSX_IR_UNSET) { + const char* lbl = psx_ir_label_for(g_video_internal_res); + char custom[32]; + if (!lbl) { + std::snprintf(custom, sizeof custom, "%d lines", g_video_internal_res); + lbl = custom; + } + std::fprintf(stdout, + "psxrecomp: internal resolution %s (reference %d lines): " + "%dx requested\n", lbl, g_video_ref_lines, requested_scale); + } + if (requested_scale > 1 || g_video_texfilter) std::fprintf(stdout, "psxrecomp: supersampling %dx (antialiasing %s, texture filter %s)\n", - g_video_scale, g_video_aa ? "on" : "off", + requested_scale, g_video_aa ? "on" : "off", g_video_texfilter ? "bilinear" : "nearest"); if (g_video_screen != 0) std::fprintf(stdout, "psxrecomp: screen-colour model %s\n", @@ -15279,6 +15418,13 @@ session_reboot: if (g_video_renderer == 1) { configure_core_gl_context_attributes(); win_flags |= SDL_WINDOW_OPENGL; + /* Opt-in high-pixel-density drawable: only when the player asked for + * more than native resolution, so the default window, OSD and bezel + * maths stay exactly as they were. */ + g_video_hidpi_window = g_video_scale_applies && + (g_video_requested_scale > 1 || g_video_internal_res == PSX_IR_DISPLAY); + if (g_video_hidpi_window) + win_flags |= PSX_SDL_WINDOW_HIGH_DENSITY; } if (g_video_renderer == 2) win_flags |= SDL_WINDOW_VULKAN; /* Fullscreen on launch (launcher's tri-state Fullscreen control): 1 = @@ -15318,6 +15464,18 @@ session_reboot: * facade back to software (rasterization already runs through software in * this phase) and fall through to the SDL_Renderer present path below. */ if (g_video_renderer == 1) { + /* Match display: the monitor's pixel height, now that the window says + * which monitor. glb_set_scale only records the request; the hr + * surface is allocated at context init below. */ + if (g_video_internal_res == PSX_IR_DISPLAY && g_video_scale_applies) { + const int dh = psx_sdl_display_pixel_height(sdl_window); + const int s = psx_resolve_internal_scale(PSX_IR_DISPLAY, g_video_ref_lines, + dh, GL_MAX_INTERNAL_SCALE); + gr_set_scale(s); + g_video_requested_scale = s; + std::fprintf(stdout, "psxrecomp: internal resolution Match display: " + "%d px -> %dx requested\n", dh, s); + } gl_renderer_set_swap_interval(present_effective_swap_interval()); /* applied at context init */ g_gl_active = (gl_renderer_init_context(sdl_window) != 0); @@ -16065,6 +16223,9 @@ soft_return_lobby: if (ls.renderer < 0 || ls.renderer > (vulkan_offered ? 2 : 1)) ls.renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; ls.supersampling = g_video_scale; +#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) + ls.internal_resolution = internal_resolution_for_launcher(); +#endif ls.antialiasing = g_video_aa ? 1 : 0; ls.texture_filter = g_video_texfilter; ls.fmv_filter = cfg_fmv_filter_to_launcher(g_video_fmv_filter); @@ -16410,6 +16571,17 @@ soft_return_lobby: us.has_renderer = true; us.supersampling = ls.supersampling; us.has_supersampling = true; +#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) + if (psx_ir_value_valid(ls.internal_resolution)) { + us.internal_resolution = ls.internal_resolution; + us.has_internal_resolution = true; + int s = psx_resolve_internal_scale(ls.internal_resolution, g_video_ref_lines, + psx_sdl_display_pixel_height(nullptr), + ls.renderer == 1 ? GL_MAX_INTERNAL_SCALE + : SW_MAX_INTERNAL_SCALE); + us.supersampling = s < SW_MAX_INTERNAL_SCALE ? s : SW_MAX_INTERNAL_SCALE; + } +#endif us.antialiasing = ls.antialiasing != 0; us.has_antialiasing = true; us.texture_filter = ls.texture_filter; @@ -16486,6 +16658,11 @@ soft_return_lobby: } g_video_renderer = ls.renderer; g_video_scale = ls.supersampling; +#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) + if (psx_ir_value_valid(ls.internal_resolution)) + g_video_internal_res = ls.internal_resolution; + apply_internal_resolution(psx_sdl_display_pixel_height(nullptr)); +#endif g_video_aa = ls.antialiasing; g_video_texfilter = ls.texture_filter; g_video_fmv_filter = launcher_fmv_filter_to_cfg(ls.fmv_filter); diff --git a/runtime/tests/test_gl_internal_scale_guards.py b/runtime/tests/test_gl_internal_scale_guards.py index 55accc87..d9b48cea 100644 --- a/runtime/tests/test_gl_internal_scale_guards.py +++ b/runtime/tests/test_gl_internal_scale_guards.py @@ -70,6 +70,29 @@ class GlScaleGuards(unittest.TestCase): self.assertIn("(g_video_renderer == 1) ? GL_MAX_INTERNAL_SCALE", MAIN) +class InternalResolutionGuards(unittest.TestCase): + def test_hidpi_window_only_when_opted_in(self): + self.assertIn("win_flags |= PSX_SDL_WINDOW_HIGH_DENSITY;", MAIN) + self.assertRegex(MAIN, r"g_video_hidpi_window = g_video_scale_applies &&\s*" + r"\(g_video_requested_scale > 1 \|\| g_video_internal_res == PSX_IR_DISPLAY\);") + self.assertRegex(MAIN, r"if \(g_video_hidpi_window\)\s*win_flags \|= PSX_SDL_WINDOW_HIGH_DENSITY;") + + def test_vocabulary_is_optional_abi(self): + # Builds against an older recomp-ui must still compile: every use of + # the new launcher fields sits behind the capability macro. + for field in ("gi->internal_resolution_labels", "ls.internal_resolution"): + for m in re.finditer(re.escape(field), MAIN): + before = MAIN[:m.start()] + opened = before.count("#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION)") + closed = len(re.findall(r"#endif", before[before.rfind( + "#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION)"):])) + self.assertGreater(opened, 0, field) + self.assertEqual(closed, 0, field + " outside its #if block") + + def test_unset_preset_leaves_supersampling(self): + self.assertIn("if (g_video_internal_res == PSX_IR_UNSET) return;", MAIN) + + class RunnerParsing(unittest.TestCase): def test_parse(self): self.assertEqual(parse_run("driver=x\ndigest=0123456789abcdef\nchecks=7 failures=0\n"), diff --git a/runtime/tests/test_internal_resolution.cpp b/runtime/tests/test_internal_resolution.cpp new file mode 100644 index 00000000..b30d5ddc --- /dev/null +++ b/runtime/tests/test_internal_resolution.cpp @@ -0,0 +1,83 @@ +// internal_resolution.h: preset ids, the value encoding shared with the +// launcher, and the integer scale each preset asks for. Presets x reference +// 240/480 x backend maxima (the GL clamp at 16384 is 16x; 18x needs the +// windowed high-resolution surface or a 32768-limit GPU). + +#include "internal_resolution.h" + +#include +#include + +static int g_failures = 0; + +static void expect(const char* what, long long got, long long want) { + if (got != want) { + std::printf("FAIL %s: got %lld want %lld\n", what, got, want); + ++g_failures; + } +} + +int main() { + // Presets at the NTSC 240-line reference (R4 races are 320x240). + struct { const char* id; int s240; int s480; } table[] = { + { "native", 1, 1 }, { "720p", 3, 2 }, { "1080p", 5, 3 }, { "1440p", 6, 3 }, + { "4k", 9, 5 }, { "5k", 12, 6 }, { "8k", 18, 9 }, + }; + for (const auto& t : table) { + int v = 0; + expect(t.id, psx_ir_parse(t.id, &v), 1); + expect(t.id, psx_resolve_internal_scale(v, 240, 0, 32), t.s240); + expect(t.id, psx_resolve_internal_scale(v, 480, 0, 32), t.s480); + expect("id round trip", std::strcmp(psx_ir_id_for(v), t.id), 0); + } + // Backend maxima: software/Vulkan 4, full-VRAM GL at a 16384 limit 16. + int v8k = 0; + psx_ir_parse("8K", &v8k); + expect("8k sw", psx_resolve_internal_scale(v8k, 240, 0, 4), 4); + expect("8k gl16", psx_resolve_internal_scale(v8k, 240, 0, 16), 16); + expect("8k lines", v8k, 4320); + + // Match display: the monitor's pixel height, 1 until it is known. + int vd = 0; + expect("display parse", psx_ir_parse("display", &vd), 1); + expect("display value", vd, PSX_IR_DISPLAY); + expect("display unknown", psx_resolve_internal_scale(vd, 240, 0, 32), 1); + expect("display 1080", psx_resolve_internal_scale(vd, 240, 1080, 32), 5); + expect("display 1964 (14in MBP)", psx_resolve_internal_scale(vd, 240, 1964, 32), 9); + expect("display 2160", psx_resolve_internal_scale(vd, 240, 2160, 32), 9); + expect("display 2880 (5K)", psx_resolve_internal_scale(vd, 240, 2880, 32), 12); + expect("display 4320 (8K)", psx_resolve_internal_scale(vd, 240, 4320, 32), 18); + expect("display id", std::strcmp(psx_ir_id_for(vd), "display"), 0); + + // Integer line counts and rejects. + int v = 0; + expect("lines parse", psx_ir_parse("1600", &v), 1); + expect("lines value", v, 1600); + expect("lines scale", psx_resolve_internal_scale(v, 240, 0, 32), 7); + expect("custom has no id", psx_ir_id_for(1600) == nullptr, 1); + v = 77; + expect("reject junk", psx_ir_parse("4kk", &v), 0); + expect("reject empty", psx_ir_parse("", &v), 0); + expect("reject 1", psx_ir_parse("1", &v), 0); + expect("reject huge", psx_ir_parse("99999", &v), 0); + expect("untouched on reject", v, 77); + expect("unset scale", psx_resolve_internal_scale(PSX_IR_UNSET, 240, 0, 32), 1); + expect("bad reference defaults to 240", psx_resolve_internal_scale(2160, 0, 0, 32), 9); + + // Legacy supersampling shown as a preset when one resolves to it. + expect("legacy 1", psx_ir_from_supersampling(1, 240), PSX_IR_NATIVE); + expect("legacy 2", psx_ir_from_supersampling(2, 240), 480); + expect("legacy 3", psx_ir_from_supersampling(3, 240), 720); + expect("legacy 4", psx_ir_from_supersampling(4, 240), 960); + expect("legacy 5", psx_ir_from_supersampling(5, 240), 1080); + expect("legacy 9", psx_ir_from_supersampling(9, 240), 2160); + expect("legacy 18", psx_ir_from_supersampling(18, 240), 4320); + // And the legacy value resolves back to the same scale. + for (int n = 1; n <= 32; n++) + expect("legacy round trip", + psx_resolve_internal_scale(psx_ir_from_supersampling(n, 240), 240, 0, 32), n); + + if (g_failures) return 1; + std::printf("internal_resolution: all checks passed\n"); + return 0; +}