From bb2864bc6012cb7b7223e90dc8600d1f356f99ae Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:24:11 -0700 Subject: [PATCH] Settings: an older launcher's Supersampling pick wins over a preset A host built against a recomp-ui without the Internal resolution row (RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION undefined) still seeded internal_resolution on first boot and applied it after the launcher. From a fresh start the seed was "native", so a Supersampling pick of 3x was reset to 1x and "native" was saved; on the netplay soft-return the sticky preset was re-applied at session_reboot. The legacy row stopped working. Both launcher exits now go through two pure helpers in internal_resolution.h: - psx_ir_launcher_seed_supersampling: with the row, the factor as before; without it, the factor itself when no preset is configured (the historical round trip), else the preset's scale capped at the row's 4x. - psx_ir_adopt_launcher: with the row, the row's preset as before; without it, a pick in the Supersampling row drops the preset, and an untouched row keeps a preset the player cannot see (game.toml or a hand-edited settings.toml). It also returns what settings.toml saves: no internal_resolution key unless a preset is kept. internal_resolution_test drives seed -> launcher -> adopt for both launcher kinds, first boot and soft-return, against a model of the legacy row (clamp to 1..4, cycle); keeping the old sticky preset fails it. gl_internal_scale_guards pins both exits to the helpers. Co-Authored-By: Claude Opus 5.5 --- docs/config_schema.md | 8 ++ runtime/include/internal_resolution.h | 67 ++++++++++ runtime/src/main.cpp | 87 +++++++----- .../tests/test_gl_internal_scale_guards.py | 15 ++- runtime/tests/test_internal_resolution.cpp | 125 ++++++++++++++++++ 5 files changed, 267 insertions(+), 35 deletions(-) diff --git a/docs/config_schema.md b/docs/config_schema.md index b07495ee..ec6ac636 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -478,6 +478,14 @@ 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. +A host built against a recomp-ui without the Internal resolution row keeps +the legacy Supersampling row (1x to 4x) and that row stays in charge: with no +preset configured the launcher round trip is exactly the historical one, and +no `internal_resolution` key is written. A preset from `game.toml` or a +hand-edited `settings.toml` starts the row on the nearest factor it can show +and survives if the row is left alone; picking a different factor there +drops the preset. + `PSX_INTERNAL_RESOLUTION=` overrides every layer for one run (validation, and two local netplay peers that share one `settings.toml`). It is never written to `settings.toml`: the launcher shows and saves the diff --git a/runtime/include/internal_resolution.h b/runtime/include/internal_resolution.h index 0f6ed38b..9362d209 100644 --- a/runtime/include/internal_resolution.h +++ b/runtime/include/internal_resolution.h @@ -126,3 +126,70 @@ static inline int psx_ir_from_supersampling(int n, int ref_lines) { long lines = (long)ref_lines * n; return lines > PSX_IR_MAX_LINES ? PSX_IR_MAX_LINES : (int)lines; } + +/* ---- The launcher round trip ------------------------------------------------- + * The host seeds the launcher with its video state, the player may change it, + * and the host adopts what comes back. A recomp-ui built with the Internal + * resolution row (RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) owns the preset. + * An older one only has the legacy Supersampling row, a 1x..4x cycle that + * clamps whatever it is seeded with. There a pick in that row must win over + * any preset, and a preset the player cannot see (game.toml, a hand-edited + * settings.toml) must survive an untouched row. With no preset the round trip + * is exactly the historical one: seed the factor, take the factor back. */ +#define PSX_IR_LEGACY_SS_MAX 4 /* the legacy row's range, and settings.toml's + * supersampling for an older runtime */ + +/* The value seeded into the legacy Supersampling row. has_row: the launcher + * shows the Internal resolution row instead (the factor passes through). */ +static inline int psx_ir_launcher_seed_supersampling(int has_row, int preset, + int supersampling, + int ref_lines, + int display_px_h) { + if (has_row || preset == PSX_IR_UNSET) return supersampling; + /* The preset's scale, as near as the row can show it. */ + return psx_resolve_internal_scale(preset, ref_lines, display_px_h, + PSX_IR_LEGACY_SS_MAX); +} + +typedef struct PsxIrAdopted { + int preset; /* the preset kept (PSX_IR_UNSET: the factor stands) */ + int scale; /* the scale to request */ + int save_ss; /* settings.toml supersampling */ + int save_ir; /* settings.toml internal_resolution; PSX_IR_UNSET omits it */ +} PsxIrAdopted; + +/* What the host adopts when the launcher returns. + * has_row the launcher showed the Internal resolution row + * preset the host's preset when it seeded the launcher + * ss_seed what psx_ir_launcher_seed_supersampling returned + * ss_result the Supersampling row's value on return + * ir_result the Internal resolution row's value (has_row only) + * s_max the chosen renderer's ceiling */ +static inline PsxIrAdopted psx_ir_adopt_launcher(int has_row, int preset, + int ss_seed, int ss_result, + int ir_result, int ref_lines, + int display_px_h, int s_max) { + PsxIrAdopted a; + if (has_row) { + if (psx_ir_value_valid(ir_result)) a.preset = ir_result; + else if (preset != PSX_IR_UNSET) a.preset = preset; + else a.preset = psx_ir_from_supersampling(ss_seed < 1 ? 1 : ss_seed, ref_lines); + } else { + int shown = ss_seed < 1 ? 1 : ss_seed > PSX_IR_LEGACY_SS_MAX + ? PSX_IR_LEGACY_SS_MAX : ss_seed; + a.preset = (preset != PSX_IR_UNSET && ss_result == shown) ? preset + : PSX_IR_UNSET; + } + if (a.preset == PSX_IR_UNSET) { + a.scale = ss_result; + a.save_ss = ss_result; + a.save_ir = PSX_IR_UNSET; + } else { + a.scale = psx_resolve_internal_scale(a.preset, ref_lines, display_px_h, s_max); + /* An older runtime reading settings.toml only knows supersampling + * (1..4): leave it the nearest it can do. */ + a.save_ss = a.scale < PSX_IR_LEGACY_SS_MAX ? a.scale : PSX_IR_LEGACY_SS_MAX; + a.save_ir = a.preset; + } + return a; +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 0bcb6620..94c0b995 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -96,6 +96,13 @@ extern "C" void psx_event_step_conservative_env_init(void); #if defined(RECOMP_LAUNCHER) #include "recomp_launcher.h" /* shared recomp-ui Dear ImGui launcher */ +/* A recomp-ui that shows the Internal resolution row; an older one only has + * the legacy Supersampling row (internal_resolution.h, launcher round trip). */ +#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) +static constexpr int kLauncherHasInternalResolution = 1; +#else +static constexpr int kLauncherHasInternalResolution = 0; +#endif #include "launcher_profile.h" /* per-system variant profile (theme/caps bundle) */ #include "launcher_boot_timing.h" /* PSX_LAUNCHER_BOOT_TIMING stamps */ #if defined(PSX_HAS_CODEGEN_SETUP_HOST) @@ -1228,6 +1235,9 @@ static int g_video_requested_scale = 1; static bool g_video_scale_applies = true; static bool g_video_hidpi_window = false; +static_assert(PSX_IR_LEGACY_SS_MAX == SW_MAX_INTERNAL_SCALE, + "settings.toml supersampling for an older runtime is the software ceiling"); + static int video_scale_ceiling(void) { return g_video_renderer == 1 ? GL_MAX_INTERNAL_SCALE : SW_MAX_INTERNAL_SCALE; } @@ -1251,7 +1261,7 @@ static void apply_internal_resolution(int display_px_h) { /* 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) { +[[maybe_unused]] 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); } @@ -13904,9 +13914,19 @@ int main(int argc, char** argv) { std::filesystem::path match_session_bios_path; bool match_session_bios_set = false; seed.renderer = g_video_renderer; seed.has_renderer = true; - seed.supersampling = g_video_scale; seed.has_supersampling = true; + /* Internal resolution round trip (internal_resolution.h): only a + * launcher with the row seeds and returns the preset. An older one + * gets the legacy Supersampling row, and a pick there wins. */ + const int ir_preset_seeded = g_video_internal_res; + const int ir_ss_seeded = psx_ir_launcher_seed_supersampling( + kLauncherHasInternalResolution, g_video_internal_res, g_video_scale, + g_video_ref_lines, psx_sdl_display_pixel_height(nullptr)); + seed.supersampling = ir_ss_seeded; seed.has_supersampling = true; + int ir_row_result = PSX_IR_UNSET; /* the row's pick, when it has one */ +#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) seed.internal_resolution = internal_resolution_for_launcher(); seed.has_internal_resolution = true; +#endif 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; @@ -14407,10 +14427,7 @@ int main(int argc, char** argv) { 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; - } + ir_row_result = ls.internal_resolution; #endif seed.antialiasing = ls.antialiasing != 0; seed.has_antialiasing = true; seed.geometry_correction = ls.geometry_correction != 0; @@ -14645,15 +14662,16 @@ int main(int argc, char** argv) { g_netplay_from_lobby = 0; } 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; + { + const PsxIrAdopted ir = psx_ir_adopt_launcher( + kLauncherHasInternalResolution, ir_preset_seeded, ir_ss_seeded, + seed.supersampling, ir_row_result, g_video_ref_lines, + psx_sdl_display_pixel_height(nullptr), video_scale_ceiling()); + g_video_internal_res = ir.preset; + g_video_scale = ir.scale; + seed.supersampling = ir.save_ss; + seed.has_internal_resolution = ir.save_ir != PSX_IR_UNSET; + seed.internal_resolution = ir.save_ir; } g_video_aa = seed.antialiasing; g_video_texfilter = seed.texture_filter; @@ -16250,7 +16268,12 @@ soft_return_lobby: ls.renderer = g_video_renderer; if (ls.renderer < 0 || ls.renderer > (vulkan_offered ? 2 : 1)) ls.renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; - ls.supersampling = g_video_scale; + /* Internal resolution round trip, as at first boot. */ + const int ir_preset_seeded = g_video_internal_res; + const int ir_ss_seeded = psx_ir_launcher_seed_supersampling( + kLauncherHasInternalResolution, g_video_internal_res, g_video_scale, + g_video_ref_lines, psx_sdl_display_pixel_height(nullptr)); + ls.supersampling = ir_ss_seeded; #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) ls.internal_resolution = internal_resolution_for_launcher(); #endif @@ -16534,6 +16557,15 @@ soft_return_lobby: player_deadzone[i] = ls.deadzone[i] * 32767 / 100; } } + int ir_row_result = PSX_IR_UNSET; +#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) + ir_row_result = ls.internal_resolution; +#endif + const PsxIrAdopted ir = psx_ir_adopt_launcher( + kLauncherHasInternalResolution, ir_preset_seeded, ir_ss_seeded, + ls.supersampling, ir_row_result, g_video_ref_lines, + psx_sdl_display_pixel_height(nullptr), + ls.renderer == 1 ? GL_MAX_INTERNAL_SCALE : SW_MAX_INTERNAL_SCALE); /* Persist controller (and rematch video) choices without wiping * the rest of settings.toml — merge into the on-disk file. */ { @@ -16597,19 +16629,10 @@ soft_return_lobby: } us.renderer = ls.renderer; us.has_renderer = true; - us.supersampling = ls.supersampling; + us.supersampling = ir.save_ss; 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.internal_resolution = ir.save_ir; + us.has_internal_resolution = ir.save_ir != PSX_IR_UNSET; us.antialiasing = ls.antialiasing != 0; us.has_antialiasing = true; us.texture_filter = ls.texture_filter; @@ -16685,12 +16708,8 @@ soft_return_lobby: (void)PSXRecompV4::save_user_settings(settings_path, us); } 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_internal_res = ir.preset; + g_video_scale = ir.scale; 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 37d8b4c4..09db319f 100644 --- a/runtime/tests/test_gl_internal_scale_guards.py +++ b/runtime/tests/test_gl_internal_scale_guards.py @@ -81,7 +81,8 @@ class InternalResolutionGuards(unittest.TestCase): 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 field in ("gi->internal_resolution_labels", "ls.internal_resolution", + "= internal_resolution_for_launcher();"): for m in re.finditer(re.escape(field), MAIN): before = MAIN[:m.start()] opened = before.count("#if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION)") @@ -94,6 +95,18 @@ class InternalResolutionGuards(unittest.TestCase): self.assertIn("if (preset == PSX_IR_UNSET) return;", body(MAIN, "static void apply_internal_resolution(int display_px_h)")) + def test_launcher_trips_use_the_round_trip_helpers(self): + # Both launcher exits (first boot, netplay soft-return) seed and adopt + # through internal_resolution.h, whose behaviour with and without the + # Internal resolution row is unit-tested (internal_resolution_test). + # A bare factor copy next to a sticky preset let the preset override + # a pick in an older launcher's Supersampling row. + self.assertEqual(MAIN.count("psx_ir_launcher_seed_supersampling("), 2) + self.assertEqual(MAIN.count("psx_ir_adopt_launcher("), 2) + self.assertEqual(MAIN.count("kLauncherHasInternalResolution, ir_preset_seeded, ir_ss_seeded,"), 2) + self.assertNotRegex(MAIN, r"g_video_scale\s*=\s*(seed|ls)\.supersampling;") + self.assertIn("g_video_internal_res = ir.preset;", MAIN) + def test_env_override_is_never_persisted(self): # PSX_INTERNAL_RESOLUTION wins for the run only: the launcher shows and # settings.toml saves the configured preset. diff --git a/runtime/tests/test_internal_resolution.cpp b/runtime/tests/test_internal_resolution.cpp index b30d5ddc..e8219104 100644 --- a/runtime/tests/test_internal_resolution.cpp +++ b/runtime/tests/test_internal_resolution.cpp @@ -17,6 +17,129 @@ static void expect(const char* what, long long got, long long want) { } } +// ---- Launcher round trip: seed -> launcher -> adopt ------------------------- +// A model of recomp-ui's legacy Supersampling row (before the Internal +// resolution row): launcher_model clamps the seeded value to 1..4 on load and +// each click cycles 1 -> 2 -> 3 -> 4 -> 1. +static int legacy_row(int seeded, int clicks) { + int v = seeded < 1 ? 1 : seeded > 4 ? 4 : seeded; + for (int i = 0; i < clicks; i++) v = (v % 4) + 1; + return v; +} + +struct Host { // the host's video state between launcher trips + int preset; // g_video_internal_res + int scale; // g_video_scale +}; + +// One trip, the way main.cpp runs it at first boot and on the netplay +// soft-return. has_row = RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION. +// Legacy: ss_clicks cycles the Supersampling row. Row: row_pick > 0 picks a +// preset in the Internal resolution row (0 leaves it). +static PsxIrAdopted trip(Host& h, int has_row, int ss_clicks, int row_pick, + int s_max = 32, int display_px_h = 2160) { + const int seeded_preset = h.preset; + const int ss_seed = psx_ir_launcher_seed_supersampling(has_row, h.preset, h.scale, + 240, display_px_h); + const int ss_result = has_row ? ss_seed : legacy_row(ss_seed, ss_clicks); + int ir_result = PSX_IR_UNSET; + if (has_row) { + const int shown = h.preset != PSX_IR_UNSET + ? h.preset : psx_ir_from_supersampling(h.scale, 240); + ir_result = row_pick ? row_pick : shown; + } + PsxIrAdopted a = psx_ir_adopt_launcher(has_row, seeded_preset, ss_seed, ss_result, + ir_result, 240, display_px_h, s_max); + h.preset = a.preset; + h.scale = a.scale; + return a; +} + +static void launcher_round_trips() { + // Older recomp-ui (no Internal resolution row), nothing configured: the + // historical round trip. The player's Supersampling pick is what runs and + // what is saved, and no internal_resolution key appears. + { + Host h{PSX_IR_UNSET, 1}; + PsxIrAdopted a = trip(h, 0, 2, 0); // 1x -> 3x + expect("legacy pick scale", a.scale, 3); + expect("legacy pick preset", a.preset, PSX_IR_UNSET); + expect("legacy pick save ss", a.save_ss, 3); + expect("legacy pick no ir key", a.save_ir, PSX_IR_UNSET); + // Netplay soft-return: seeded with the running scale, picked again. + a = trip(h, 0, 1, 0); // 3x -> 4x + expect("legacy soft-return pick", a.scale, 4); + expect("legacy soft-return no ir key", a.save_ir, PSX_IR_UNSET); + a = trip(h, 0, 0, 0); // untouched + expect("legacy untouched", a.scale, 4); + } + { + Host h{PSX_IR_UNSET, 8}; // hand-edited 8 + PsxIrAdopted a = trip(h, 0, 0, 0); + expect("legacy clamps like before", a.scale, 4); // the row returns 4 + expect("legacy clamps save", a.save_ss, 4); + } + // A settings.toml "native" written by a build with the row, then an older + // launcher: the preset must not reset the player's pick (the reported bug). + { + Host h{PSX_IR_NATIVE, 1}; + PsxIrAdopted a = trip(h, 0, 2, 0); // 1x -> 3x + expect("native preset yields to pick", a.scale, 3); + expect("native preset dropped", a.preset, PSX_IR_UNSET); + expect("native preset key dropped", a.save_ir, PSX_IR_UNSET); + expect("native preset save ss", a.save_ss, 3); + } + // A preset the older launcher cannot show (game.toml or hand-edited 4K): + // the row starts on the nearest it can show (4x) and an untouched row + // keeps the preset, first boot and soft-return alike. + { + Host h{2160, 1}; + PsxIrAdopted a = trip(h, 0, 0, 0); + expect("hidden preset kept", a.preset, 2160); + expect("hidden preset scale", a.scale, 9); + expect("hidden preset save ss", a.save_ss, 4); + expect("hidden preset save ir", a.save_ir, 2160); + a = trip(h, 0, 0, 0); // soft-return, running 9x + expect("hidden preset soft-return kept", a.scale, 9); + a = trip(h, 0, 1, 0); // 4x -> 1x + expect("hidden preset replaced", a.scale, 1); + expect("hidden preset replaced preset", a.preset, PSX_IR_UNSET); + expect("hidden preset replaced key", a.save_ir, PSX_IR_UNSET); + } + { + Host h{PSX_IR_DISPLAY, 1}; // 2160-px display + PsxIrAdopted a = trip(h, 0, 0, 0); + expect("display kept", a.scale, 9); + a = trip(h, 0, 2, 0); // 4x -> 1x -> 2x + expect("display replaced", a.scale, 2); + } + // Launcher with the row: the row owns the preset, as before. + { + Host h{PSX_IR_UNSET, 3}; + PsxIrAdopted a = trip(h, 1, 0, 0); // shows 720p, untouched + expect("row untouched preset", a.preset, 720); + expect("row untouched scale", a.scale, 3); + expect("row untouched save", a.save_ir, 720); + a = trip(h, 1, 0, 4320); // 8K + expect("row 8k scale", a.scale, 18); + expect("row 8k save ss", a.save_ss, 4); + expect("row 8k save ir", a.save_ir, 4320); + a = trip(h, 1, 0, 4320, 4); // software ceiling + expect("row 8k software", a.scale, 4); + a = trip(h, 1, 0, PSX_IR_NATIVE); + expect("row native", a.scale, 1); + expect("row native save", a.save_ir, PSX_IR_NATIVE); + } + // Invalid row values never reach the host: the seeded preset stands. + { + PsxIrAdopted a = psx_ir_adopt_launcher(1, 1080, 1, 1, 0, 240, 0, 32); + expect("row invalid keeps preset", a.preset, 1080); + a = psx_ir_adopt_launcher(1, PSX_IR_UNSET, 2, 2, -7, 240, 0, 32); + expect("row invalid legacy factor", a.preset, 480); + expect("row invalid legacy scale", a.scale, 2); + } +} + int main() { // Presets at the NTSC 240-line reference (R4 races are 320x240). struct { const char* id; int s240; int s480; } table[] = { @@ -77,6 +200,8 @@ int main() { expect("legacy round trip", psx_resolve_internal_scale(psx_ir_from_supersampling(n, 240), 240, 0, 32), n); + launcher_round_trips(); + if (g_failures) return 1; std::printf("internal_resolution: all checks passed\n"); return 0;