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 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-09-26 02:20:55 -07:00
parent de8f4ef148
commit f34f91cc25
14 changed files with 790 additions and 9 deletions
+46
View File
@@ -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.
+8 -1
View File
@@ -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
+7 -3
View File
@@ -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":"<name>"}` 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":"<name>"}` 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` | ✓ | | |
+31 -1
View File
@@ -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
+6
View File
@@ -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
+49 -1
View File
@@ -14,6 +14,7 @@
#include <vector>
#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<int>(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<int>(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<int64_t>(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<int>(n);
}
if (video.contains("window_width")) {
const auto n = toml::find<int64_t>(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<int64_t>(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<int64_t>(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<bool>(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)
+17
View File
@@ -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
@@ -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<char>(f)), std::istreambuf_iterator<char>());
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();
+128
View File
@@ -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 <stddef.h>
#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;
}
+30
View File
@@ -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 <SDL.h>
#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
+92
View File
@@ -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 },
+180 -3
View File
@@ -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<int> s_present_shot_seq{0}; /* bumps on every completion */
static std::atomic<int> 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);
@@ -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"),
@@ -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 <cstdio>
#include <cstring>
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;
}