diff --git a/recompiler/tests/quality_presets_test.cpp b/recompiler/tests/quality_presets_test.cpp index c1b33fd2..b3d28cc7 100644 --- a/recompiler/tests/quality_presets_test.cpp +++ b/recompiler/tests/quality_presets_test.cpp @@ -15,6 +15,7 @@ #include "host_overlay.h" #include +#include #include #include #include @@ -538,6 +539,144 @@ static void test_ingame_menu_txn() { "menu: the governed edit is Custom from High and keeps the value"); } +/* The in-game menu host path (main.cpp mh_launcher_settings_get -> + * provider -> mh_launcher_settings_apply -> host_pause_service -> + * quality_take_launcher + psx_video_apply_live's settle_save), on the same + * psxq functions, with a provider that behaves like recomp-launcher's + * QualityTracker (a pick writes the preset's rows; any changed preset row + * reports Custom from the preset). The review fixture: Ultra has + * geometry_correction on, High has it off; neither governs antialiasing. + * Start Ultra, pick High in the menu, then toggle Antialiasing: NONE. */ +struct MenuHostFixture { + PSXRecompV4::GameConfig gc; + std::vector keys; + psxq::Decision run; + psxq::MenuSelection sel; + psxq::Rows live; /* what is in force now */ + PSXRecompV4::UserSettings disk; + struct Seed { psxq::Rows rows; int preset1 = 0, base1 = 0; }; + + bool rows_for(int tier, psxq::Rows* out) const { + for (const auto& p : gc.quality_presets) + if (psx_quality_from_name(p.name.c_str()) == tier) { + *out = psxq::rows_from_runtime(p.runtime, false, 0, false, 0); + return true; + } + return false; + } + Seed get() const { /* mh_launcher_settings_get */ + Seed s; + s.rows = sel.seed(live); + s.preset1 = run.preset >= 0 ? run.preset + 1 : 0; + s.base1 = run.base >= 0 ? run.base + 1 : 0; + return s; + } + psxq::Edit apply(const Seed& post) { /* host_pause_service */ + const psxq::LauncherEdit le = psxq::classify_launcher( + post.preset1, post.base1, post.rows, run.preset, run.base, keys, + [this](int t, psxq::Rows* o) { return rows_for(t, o); }); + if (le.set_base) run.base = le.base; + /* psx_video_apply_live: the rows the menu shows apply now (here: + * antialiasing, texture filter); geometry waits for the next launch. */ + live.antialiasing = post.rows.antialiasing; + live.texture_filter = post.rows.texture_filter; + PSXRecompV4::UserSettings us = disk; + us.has_antialiasing = true; us.antialiasing = post.rows.antialiasing != 0; + us.has_geometry_correction = true; us.geometry_correction = post.rows.geometry_correction != 0; + psxq::settle_save(us, disk, run, le.edit, le.picked, keys); + disk = save_load(us); + sel.record(post.rows, live); + return le.edit; + } + /* recomp-launcher's QualityTracker, on rows. */ + Seed tracker_pick(Seed s, int tier) const { + rows_for(tier, &s.rows); + s.preset1 = s.base1 = tier + 1; + return s; + } + Seed tracker_observe(Seed s) const { + psxq::Rows expect; + if (s.preset1 >= 1 && s.preset1 <= 4 && rows_for(s.preset1 - 1, &expect) && + std::memcmp(&expect, &s.rows, sizeof expect) != 0) { + s.base1 = s.preset1; + s.preset1 = PSX_QUALITY_CUSTOM + 1; + } + return s; + } +}; + +static void test_ingame_menu_host_path() { + fs::path p = write_game_toml("psxrecomp_quality_menu.toml", + "geometry_correction = true\n" + "[quality.ultra]\n" + "[quality.high]\n" + "geometry_correction = false\n"); + MenuHostFixture h; + h.gc = PSXRecompV4::load_game_config(p); + fs::remove(p); + h.keys = psxq::governed_keys(h.gc); + check(std::find(h.keys.begin(), h.keys.end(), "antialiasing") == h.keys.end(), + "fixture: antialiasing is not governed"); + PsxHostInfo m4 = host("Apple M4", "Apple M4", 10, 16); + h.disk.has_quality_preset = true; h.disk.quality_preset = "ultra"; + h.run = psxq::decide(psxq::offered_mask(h.gc), h.disk, m4, nullptr, false, nullptr, true); + check(h.run.preset == PSX_QUALITY_ULTRA, "fixture: Ultra in force"); + h.rows_for(PSX_QUALITY_ULTRA, &h.live); + check(h.live.geometry_correction == 1, "fixture: Ultra has geometry correction"); + + /* Pick High in the menu. */ + MenuHostFixture::Seed s = h.tracker_pick(h.get(), PSX_QUALITY_HIGH); + check(h.apply(s) == psxq::Edit::PICK, "menu host: picking High is a PICK"); + check(h.disk.quality_preset == "high", "menu host: High saved"); + check(h.live.geometry_correction == 1, "menu host: geometry waits for the next launch"); + + /* Re-seed (the provider reopens), adopt, toggle Antialiasing only. */ + MenuHostFixture::Seed again = h.get(); + check(again.preset1 == PSX_QUALITY_HIGH + 1, "menu host: re-seed says High"); + check(again.rows.geometry_correction == 0, + "menu host: re-seed carries High's pending geometry, not the live Ultra value"); + check(h.tracker_observe(again).preset1 == PSX_QUALITY_HIGH + 1, + "menu host: the re-seed is High as the tracker sees it (no spurious Custom)"); + again.rows.antialiasing ^= 1; + again = h.tracker_observe(again); /* the tracker reports Custom from High */ + check(h.apply(again) == psxq::Edit::NONE, + "menu host: Ultra -> High, then Antialiasing is NONE (not GOVERNED_EDIT)"); + check(h.disk.quality_preset == "high", "menu host: High stays saved"); + + /* A governed edit after the pick still makes Custom from High. */ + MenuHostFixture::Seed g = h.get(); + g.rows.geometry_correction = 1; + g = h.tracker_observe(g); + check(h.apply(g) == psxq::Edit::GOVERNED_EDIT, "menu host: a governed edit is Custom"); + check(h.disk.quality_preset == "custom" && h.disk.quality_base == "high", + "menu host: Custom from High"); + check(h.get().rows.geometry_correction == 1, "menu host: the Custom override is kept"); + + /* A netplay match defers Smooth motion: the seed keeps the selection + * until the live value catches up, and other rows follow live. */ + { + psxq::MenuSelection ms; + psxq::Rows live_now; live_now.frame_generation = 1; live_now.texture_filter = 0; + psxq::Rows chosen = live_now; chosen.frame_generation = 0; + ms.record(chosen, live_now); + check(ms.seed(live_now).frame_generation == 0, "netplay: a deferred row stays selected"); + live_now.texture_filter = 3; /* a hotkey/mod change after the apply */ + check(ms.seed(live_now).texture_filter == 3, "netplay: other rows follow live"); + } + + /* Without the selection (the bug): the re-seed mixes High with live Ultra + * geometry and the same Antialiasing toggle would classify GOVERNED_EDIT. */ + psxq::Rows ultra_live; h.rows_for(PSX_QUALITY_ULTRA, &ultra_live); + psxq::Rows mixed; h.rows_for(PSX_QUALITY_HIGH, &mixed); + mixed.geometry_correction = ultra_live.geometry_correction; + mixed.antialiasing ^= 1; + const psxq::LauncherEdit bad = psxq::classify_launcher( + PSX_QUALITY_CUSTOM + 1, PSX_QUALITY_HIGH + 1, mixed, PSX_QUALITY_HIGH, PSX_QUALITY_HIGH, + h.keys, [&](int t, psxq::Rows* o) { return h.rows_for(t, o); }); + check(bad.edit == psxq::Edit::GOVERNED_EDIT, + "menu host: a live-mixed seed is what misclassified (guards the fixture)"); +} + /* The supported quality-key set (docs/QUALITY_PRESETS.md): anything else in * a preset is a load error, and every player-editable key is masked and * copied (ownership enforced). */ @@ -636,6 +775,7 @@ int main() { test_supported_keys(); test_launcher_classification(); test_ingame_menu_txn(); + test_ingame_menu_host_path(); if (failures) { std::fprintf(stderr, "quality_presets_test: %d failure(s)\n", failures); return 1; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 95749872..76f588f6 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -228,6 +228,8 @@ if(BUILD_TESTING) add_test(NAME menu_video_snapshot_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_menu_video_snapshot.py --cxx ${CMAKE_CXX_COMPILER}) + add_test(NAME menu_quality_callbacks_test COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_menu_quality_callbacks.py) add_test(NAME host_pause_first_present_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_host_pause_first_present.py --cxx ${CMAKE_CXX_COMPILER}) diff --git a/runtime/include/quality_presets.h b/runtime/include/quality_presets.h index f5d3fef4..90832d21 100644 --- a/runtime/include/quality_presets.h +++ b/runtime/include/quality_presets.h @@ -8,7 +8,7 @@ #include #include -namespace PSXRecompV4 { struct UserSettings; struct GameConfig; } +namespace PSXRecompV4 { struct UserSettings; struct GameConfig; struct RuntimeConfig; } struct PsxHostVideoSettings; namespace psxq { @@ -88,6 +88,63 @@ Edit launcher_custom_edit(const std::vector& keys, int base, int cu const std::function& row_changed, int* picked); +/* The settings-editor rows a preset can set, in the launcher ABI's encodings + * (RecompLauncherCSettings fields of the same names). Launcher-neutral so the + * host's preset logic is testable without a frontend header. */ +struct Rows { + int texture_filter = 0, fmv_filter = 0, antialiasing = 0; + int dynamic_resolution = 0, dynamic_resolution_min = 0; + int frame_generation = 0, render_thread = 0, present_thread = 0; + int geometry_correction = 0, perspective_texturing = 0; + int frame_interp = 0, frame_interp_fps = 0; +}; +/* A preset's rows from its parsed [video] block. Pure: reads nothing but its + * arguments, so a frontend may call it from any thread (the in-game menu's + * draw thread included). dynres_env / dynres_min_env are the startup env + * overrides (< 0 / PSX_IR_UNSET-like `none` = not set; pass has_* false). */ +Rows rows_from_runtime(const PSXRecompV4::RuntimeConfig& r, + bool has_dynres_env, int dynres_env, + bool has_dynres_min_env, int dynres_min_env); +/* Does the row a governed key owns differ? (preset-only keys have no row) */ +bool row_differs(const std::string& key, const Rows& a, const Rows& b); +/* The host's classification of a frontend submission (Settings.quality_preset + * and quality_base, 1-based; 5 = Custom). preset_rows(tier, out) gives a + * tier's rows. current/current_base: the run's state before this submission. + * - a tier other than the current one: PICK of that tier; + * - Custom from a base: launcher_custom_edit against the base's rows (an + * unrelated row is NONE, a new base with only unrelated rows is PICK); + * set_base/base say the run's Custom base moves to it. */ +struct LauncherEdit { + Edit edit = Edit::NONE; + int picked = PSX_QUALITY_NONE; + bool set_base = false; + int base = PSX_QUALITY_NONE; + bool base_changed = false; /* Custom moved to another base */ +}; +LauncherEdit classify_launcher(int preset1, int base1, const Rows& submitted, + int current, int current_base, + const std::vector& keys, + const std::function& preset_rows); +/* The in-game menu's selected-versus-live rows. A provider is seeded with + * what the player selected, not with live values that only catch up later: + * a preset pick's rows that apply at the next launch, and rows a netplay + * match defers, stay pending until the live value matches. Every other row + * follows the live value (so a hotkey or mod change still shows). Owned by + * the host's main thread; providers only see seed() results. */ +class MenuSelection { +public: + void reset() { valid_ = false; pending_ = 0; } + /* After the host applied a submission: what the player selected, and the + * live rows after the apply (deferred rows still differ). */ + void record(const Rows& selected, const Rows& live_after); + /* The rows to seed a provider with. */ + Rows seed(const Rows& live) const; + bool pending() const { return valid_ && pending_ != 0; } +private: + Rows sel_{}; + unsigned pending_ = 0; + bool valid_ = false; +}; /* Copy one governed key's settings.toml field(s) (value and presence). */ void copy_key(PSXRecompV4::UserSettings& dst, const PSXRecompV4::UserSettings& src, const std::string& key); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 34c40923..00f1faf3 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -16096,88 +16096,80 @@ namespace { /* Graphics presets in the launcher (docs/QUALITY_PRESETS.md). */ std::string g_quality_summary_s, g_quality_reason_s; - /* Fill the launcher rows a preset sets: run the preset's [video] values - * through the same globals -> launcher encodings the seed uses, then put - * the globals back (the launch path applies the pick). */ + /* The display height the internal-resolution rows are seeded for. Set on + * the main thread (pre-game fill, in-game menu setup); the mapper below + * only reads it. */ + std::atomic g_quality_display_h{0}; + + /* A preset's rows, side-effect free: computed from its parsed [video] + * block and startup constants (reference lines, env overrides), never + * from or into the live globals. Safe on any thread -- the in-game menu + * calls it from its draw thread while the game runs. */ + psxq::Rows quality_rows_for(const PSXRecompV4::RuntimeConfig& r) { + return psxq::rows_from_runtime(r, g_video_dynres_env >= 0, g_video_dynres_env, + g_video_dynres_min_env != PSX_IR_UNSET, + g_video_dynres_min_env); + } + psxq::Rows quality_rows_of(const RecompLauncherCSettings& s) { + psxq::Rows r; + r.texture_filter = s.texture_filter; + r.fmv_filter = s.fmv_filter; + r.antialiasing = s.antialiasing; +#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) + r.dynamic_resolution = s.dynamic_resolution; + r.dynamic_resolution_min = s.dynamic_resolution_min; +#endif +#if defined(RECOMP_LAUNCHER_HAS_RENDER_PIPELINE) + r.render_thread = s.render_thread; + r.present_thread = s.present_thread; + r.frame_generation = s.frame_generation; +#endif + r.geometry_correction = s.geometry_correction; + r.perspective_texturing = s.perspective_texturing; + r.frame_interp = s.frame_interp; + r.frame_interp_fps = s.frame_interp_fps; + return r; + } + void quality_rows_to(const psxq::Rows& r, RecompLauncherCSettings* s) { + s->texture_filter = r.texture_filter; + s->fmv_filter = r.fmv_filter; + s->antialiasing = r.antialiasing; +#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) + s->dynamic_resolution = r.dynamic_resolution; + s->dynamic_resolution_min = r.dynamic_resolution_min; +#endif +#if defined(RECOMP_LAUNCHER_HAS_RENDER_PIPELINE) + s->render_thread = r.render_thread; + s->present_thread = r.present_thread; + s->frame_generation = r.frame_generation; +#endif + s->geometry_correction = r.geometry_correction; + s->perspective_texturing = r.perspective_texturing; + s->frame_interp = r.frame_interp; + s->frame_interp_fps = r.frame_interp_fps; + } + bool quality_preset_rows(int tier, psxq::Rows* out) { + const auto* p = quality_preset_for(tier); + if (!p) return false; + *out = quality_rows_for(p->runtime); + return true; + } + + /* Fill the launcher rows a preset sets (Settings encodings). Pure. */ void quality_launcher_apply(int preset, RecompLauncherCSettings* s) { const auto* p = quality_preset_for(preset - 1); if (!p || !s) return; - const int sv_scale = g_video_scale, sv_ir = g_video_internal_res, - sv_dyn = g_video_dynres, sv_dynmin = g_video_dynres_min, - sv_aa = g_video_aa, sv_paa = g_video_post_aa, sv_ss = g_video_ss_milli, - sv_tf = g_video_texfilter, sv_fmv = g_video_fmv_filter, - sv_gc = g_video_geometry_correction, sv_pt = g_video_perspective_texturing, - sv_pfb = g_video_pgxp_position_fallback, - sv_ppp = g_video_pgxp_preserve_projection, - sv_pdb = g_video_pgxp_depth_buffer, sv_pcc = g_video_pgxp_color_correction, - sv_seam = g_video_pgxp_seam, sv_rt = g_render_thread, - sv_pth = g_present_thread, sv_fg = g_frame_generation, - sv_fgm = g_frame_generation_method, sv_fi = g_frame_interpolation, - sv_fif = g_frame_interpolation_fps; - const float sv_tol = g_video_pgxp_tolerance, sv_dth = g_video_pgxp_depth_threshold, - sv_bloom = g_video_bloom; - const int sv_chroma = g_video_fmv_chroma, sv_lod = g_video_texture_lod, - sv_aniso = g_video_aniso, sv_dith = g_video_dithering; - quality_apply_video_globals(p->runtime); + const PSXRecompV4::RuntimeConfig& r = p->runtime; + quality_rows_to(quality_rows_for(r), s); #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) - s->internal_resolution = internal_resolution_for_launcher(); + s->internal_resolution = r.video_internal_resolution != PSX_IR_UNSET + ? r.video_internal_resolution + : psx_ir_from_supersampling(r.video_supersampling < 1 ? 1 : r.video_supersampling, + g_video_ref_lines); #endif s->supersampling = psx_ir_launcher_seed_supersampling( - kLauncherHasInternalResolution, g_video_internal_res, g_video_scale, - g_video_ref_lines, psx_sdl_display_pixel_height(nullptr)); -#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) - s->dynamic_resolution = dynres_requested() != 0; - s->dynamic_resolution_min = dynres_min_value(); -#endif -#if defined(RECOMP_LAUNCHER_HAS_RENDER_PIPELINE) - s->render_thread = g_render_thread ? 1 : 0; - s->present_thread = g_present_thread ? 1 : 0; - s->frame_generation = g_frame_generation ? 1 : 0; -#endif - s->antialiasing = g_video_aa ? 1 : 0; - s->texture_filter = g_video_texfilter; - s->fmv_filter = cfg_fmv_filter_to_launcher(g_video_fmv_filter); - s->geometry_correction = g_video_geometry_correction ? 1 : 0; - s->perspective_texturing = g_video_perspective_texturing ? 1 : 0; - s->frame_interp = g_frame_interpolation ? 1 : 0; - s->frame_interp_fps = g_frame_interpolation_fps; - g_video_scale = sv_scale; g_video_internal_res = sv_ir; g_video_dynres = sv_dyn; - g_video_dynres_min = sv_dynmin; g_video_aa = sv_aa; g_video_post_aa = sv_paa; - g_video_ss_milli = sv_ss; g_video_texfilter = sv_tf; g_video_fmv_filter = sv_fmv; - g_video_geometry_correction = sv_gc; g_video_perspective_texturing = sv_pt; - g_video_pgxp_tolerance = sv_tol; g_video_pgxp_position_fallback = sv_pfb; - g_video_pgxp_preserve_projection = sv_ppp; g_video_pgxp_depth_buffer = sv_pdb; - g_video_pgxp_color_correction = sv_pcc; g_video_pgxp_seam = sv_seam; - g_video_pgxp_depth_threshold = sv_dth; g_render_thread = sv_rt; - g_present_thread = sv_pth; g_frame_generation = sv_fg; - g_frame_generation_method = sv_fgm; g_frame_interpolation = sv_fi; - g_frame_interpolation_fps = sv_fif; - g_video_bloom = sv_bloom; g_video_fmv_chroma = sv_chroma; - g_video_texture_lod = sv_lod; g_video_aniso = sv_aniso; g_video_dithering = sv_dith; - } - - /* Does a launcher row a governed, player-editable key owns differ between - * two Settings? (Host-side Custom classification: only the keys this - * title's presets govern count.) */ - static bool quality_row_differs(const std::string& k, const RecompLauncherCSettings& a, - const RecompLauncherCSettings& b) { - if (k == "texture_filtering") return a.texture_filter != b.texture_filter; - if (k == "fmv_filter") return a.fmv_filter != b.fmv_filter; - if (k == "antialiasing") return (a.antialiasing != 0) != (b.antialiasing != 0); -#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) - if (k == "dynamic_resolution") return (a.dynamic_resolution != 0) != (b.dynamic_resolution != 0); - if (k == "dynamic_resolution_min") return a.dynamic_resolution_min != b.dynamic_resolution_min; -#endif -#if defined(RECOMP_LAUNCHER_HAS_RENDER_PIPELINE) - if (k == "frame_generation") return (a.frame_generation != 0) != (b.frame_generation != 0); - if (k == "render_thread") return (a.render_thread != 0) != (b.render_thread != 0); - if (k == "present_thread") return (a.present_thread != 0) != (b.present_thread != 0); -#endif - if (k == "geometry_correction") return (a.geometry_correction != 0) != (b.geometry_correction != 0); - if (k == "perspective_texturing") return (a.perspective_texturing != 0) != (b.perspective_texturing != 0); - if (k == "frame_interpolation") return (a.frame_interp != 0) != (b.frame_interp != 0); - if (k == "frame_interpolation_fps") return a.frame_interp_fps != b.frame_interp_fps; - return false; /* preset-only keys have no row */ + kLauncherHasInternalResolution, r.video_internal_resolution, r.video_supersampling, + g_video_ref_lines, g_quality_display_h.load()); } void quality_publish_strings(void) { @@ -16205,6 +16197,7 @@ namespace { void quality_fill_game_info(RecompLauncherCGameInfo* gi) { if (!g_quality_offered) return; quality_publish_strings(); + g_quality_display_h = psx_sdl_display_pixel_height(nullptr); gi->quality_offered_mask = (int)g_quality_offered; gi->quality_detected = (g_quality.detected >= 0 && g_quality.detected < PSX_QUALITY_COUNT) ? g_quality.detected + 1 : 0; @@ -16227,50 +16220,31 @@ namespace { * settled when settings.toml is saved (quality_save_user_settings). */ void quality_take_launcher(const RecompLauncherCSettings* ls, PSXRecompV4::UserSettings* /*us*/, bool apply_now) { - if (!g_quality_offered || ls->quality_preset < 1 || ls->quality_preset > 5) return; - const int preset = ls->quality_preset - 1; - if (preset == PSX_QUALITY_CUSTOM) { - /* A player may pick a different tier and edit one of its rows - * before leaving the launcher. Custom belongs to that tier, - * including its values which have no launcher row. */ - const int base = ls->quality_base - 1; - const auto* p = quality_preset_for(base); - /* The frontends flag Custom on any row their snapshot tracks; - * only a row this title's presets govern makes it Custom. */ - if (p && g_quality.preset != PSX_QUALITY_CUSTOM) { - RecompLauncherCSettings expect = *ls; - quality_launcher_apply(base + 1, &expect); - int picked = PSX_QUALITY_NONE; - const psxq::Edit e = psxq::launcher_custom_edit( - g_quality_keys, base, g_quality.preset, - [&](const std::string& k) { return quality_row_differs(k, *ls, expect); }, - &picked); - if (e == psxq::Edit::NONE) return; /* unrelated rows only */ - if (e == psxq::Edit::PICK) { /* a pick, then unrelated rows */ - g_quality_edit = e; - g_quality_picked = picked; - if (apply_now && !g_quality.env_override) quality_apply_video_globals(p->runtime); - return; + if (!g_quality_offered) return; + const psxq::LauncherEdit le = psxq::classify_launcher( + ls->quality_preset, ls->quality_base, quality_rows_of(*ls), + g_quality.preset, g_quality.base, g_quality_keys, quality_preset_rows); + if (le.edit == psxq::Edit::NONE && !le.set_base) return; + if (le.edit != psxq::Edit::NONE) g_quality_edit = le.edit; + if (le.edit == psxq::Edit::PICK) { + g_quality_picked = le.picked; + if (apply_now && !g_quality.env_override) { + if (const auto* p = quality_preset_for(le.picked)) { + /* The launcher wrote this preset's values into its rows, + * which the launch path applies next; this covers the rest + * (supersample, PGXP extras, ...). */ + quality_apply_video_globals(p->runtime); + if (ls->quality_preset - 1 != PSX_QUALITY_CUSTOM) + std::fprintf(stdout, "psxrecomp: graphics preset %s\n", + psx_quality_name(le.picked)); } } - const bool changed_base = p && base != g_quality.base; - if (g_quality.preset != PSX_QUALITY_CUSTOM || changed_base) - g_quality_edit = psxq::Edit::GOVERNED_EDIT; - if (p) g_quality.base = base; - if (apply_now && changed_base) quality_apply_video_globals(p->runtime); return; } - if (preset == g_quality.preset) return; - g_quality_edit = psxq::Edit::PICK; - g_quality_picked = preset; - if (apply_now && !g_quality.env_override) { - if (const auto* p = quality_preset_for(preset)) { - /* The launcher wrote this preset's values into its rows, - * which the launch path applies next; this covers the rest - * (supersample, PGXP extras, ...). */ - quality_apply_video_globals(p->runtime); - std::fprintf(stdout, "psxrecomp: graphics preset %s\n", psx_quality_name(preset)); - } + if (le.set_base) { + g_quality.base = le.base; + if (apply_now && le.base_changed) + if (const auto* p = quality_preset_for(le.base)) quality_apply_video_globals(p->runtime); } } #endif @@ -16558,14 +16532,52 @@ bool g_ovl_netplay = false; /* Only the main thread reads live runtime state. Provider draw callbacks * consume a coherent snapshot under the same mutex as their posted edits. */ +#if defined(RECOMP_LAUNCHER) && defined(RECOMP_LAUNCHER_HAS_QUALITY_PRESETS) +/* Graphics presets in the menu: the run's preset state and the live rows a + * preset governs, captured on the main thread; the player's selection + * (psxq::MenuSelection) so a provider is re-seeded with what it chose, not + * with live values a pick or a netplay match defers. */ +int g_ovl_q_preset = 0, g_ovl_q_base = 0; +std::atomic g_ovl_q_detected{0}; +psxq::Rows g_ovl_q_live{}; +psxq::MenuSelection g_ovl_q_sel; +std::string g_ovl_q_summary, g_ovl_q_reason; /* fixed at menu setup */ +psxq::Rows mh_quality_live_rows(const PsxHostVideoSettings& v) { + psxq::Rows r; + r.texture_filter = v.texture_filter; + r.fmv_filter = cfg_fmv_filter_to_launcher(v.fmv_filter); + r.antialiasing = v.present_linear; + r.dynamic_resolution = v.dynamic_resolution; + r.dynamic_resolution_min = dynres_min_value(); + r.frame_generation = v.frame_generation; + r.render_thread = v.render_thread; + r.present_thread = v.present_thread; + r.geometry_correction = g_video_geometry_correction ? 1 : 0; + r.perspective_texturing = g_video_perspective_texturing ? 1 : 0; + r.frame_interp = g_frame_interpolation ? 1 : 0; + r.frame_interp_fps = g_frame_interpolation_fps; + return r; +} +#endif + void mh_refresh_snapshot() { PsxHostVideoSettings current; psx_video_current(¤t); const bool netplay = psx_netplay_active() != 0; +#if defined(RECOMP_LAUNCHER) && defined(RECOMP_LAUNCHER_HAS_QUALITY_PRESETS) + const psxq::Rows qlive = mh_quality_live_rows(current); + RecompLauncherCSettings q{}; + quality_seed_launcher(&q); +#endif std::lock_guard lk(g_ovl_mu); g_ovl_video_current = current; g_ovl_video_boot = g_video_boot; g_ovl_netplay = netplay; +#if defined(RECOMP_LAUNCHER) && defined(RECOMP_LAUNCHER_HAS_QUALITY_PRESETS) + g_ovl_q_live = qlive; + g_ovl_q_preset = q.quality_preset; + g_ovl_q_base = q.quality_base; +#endif } int mh_netplay_active(void) { std::lock_guard lk(g_ovl_mu); @@ -16704,16 +16716,16 @@ void mh_launcher_settings_get(RecompLauncherCSettings* out) { ls.render_thread = v.render_thread; ls.present_thread = v.present_thread; #if defined(RECOMP_LAUNCHER_HAS_QUALITY_PRESETS) - /* Rows the menu may not show but a preset governs: as in force, so the - * preset classification (quality_take_launcher) compares like for like. */ -#if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) - ls.dynamic_resolution_min = dynres_min_value(); -#endif - ls.geometry_correction = g_video_geometry_correction ? 1 : 0; - ls.perspective_texturing = g_video_perspective_texturing ? 1 : 0; - ls.frame_interp = g_frame_interpolation ? 1 : 0; - ls.frame_interp_fps = g_frame_interpolation_fps; - quality_seed_launcher(&ls); + /* Every row a preset governs, the hidden ones included, as the player + * selected them (main-thread snapshot): a pick's next-launch rows and + * netplay-deferred rows stay selected, so the host's classification + * compares like for like with the preset in quality_preset/base. */ + { + std::lock_guard lk(g_ovl_mu); + quality_rows_to(g_ovl_q_sel.seed(g_ovl_q_live), &ls); + ls.quality_preset = g_ovl_q_preset; + ls.quality_base = g_ovl_q_base; + } #endif #if defined(RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK) && \ defined(PSX_HAS_JOGCON_FFB_PLAYER_SETTING) @@ -16734,6 +16746,9 @@ unsigned mh_launcher_settings_apply(const RecompLauncherCSettings* s) { } return mh_restart_bits(next); } +#if defined(RECOMP_LAUNCHER_HAS_QUALITY_PRESETS) +int mh_quality_redetect(void) { return g_ovl_q_detected.load(); } +#endif void mh_launcher_game_info(RecompLauncherCGameInfo* out) { if (!out) return; RecompLauncherCGameInfo& gi = *out; @@ -16752,8 +16767,19 @@ void mh_launcher_game_info(RecompLauncherCGameInfo* out) { gi.has_rewind_depth = 0; gi.mods = PSXRecompV4::mod_runtime_launcher_provider(); #if defined(RECOMP_LAUNCHER_HAS_QUALITY_PRESETS) - /* The Graphics preset row, as in the pre-game launcher. */ - quality_fill_game_info(&gi); + /* The Graphics preset contract, for providers that implement it. Only + * thread-safe callbacks: quality_apply is a pure preset -> rows mapping, + * and re-detect returns this session's detection (a fresh probe needs + * the GL context and mutates the run's state: it stays in the pre-game + * launcher). The strings were fixed on the main thread at menu setup. */ + if (g_quality_offered) { + gi.quality_offered_mask = (int)g_quality_offered; + gi.quality_detected = g_ovl_q_detected.load(); + gi.quality_summary = g_ovl_q_summary.c_str(); + gi.quality_reason = g_ovl_q_reason.c_str(); + gi.quality_apply = quality_launcher_apply; + gi.quality_redetect = mh_quality_redetect; + } #endif #if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) /* The same lobby the pre-game launcher drives, so the menu can host/join. */ @@ -16791,6 +16817,23 @@ void ovl_draw(int w, int h, void*) { if (g_ovl && g_ovl->draw) g_ovl->draw(g_ovl #endif static void recomp_overlay_setup(const char* argv0, const std::string& title) { +#if defined(RECOMP_LAUNCHER) && defined(RECOMP_LAUNCHER_HAS_QUALITY_PRESETS) + /* Main thread: the preset strings and detection the menu publishes, and + * the display the preset rows are seeded for. Fixed before any provider + * attaches, never written while one can read them. */ + if (g_quality_offered && !g_ovl_on) { + g_ovl_q_summary = g_quality.summary; + g_ovl_q_reason = g_quality.reason; + g_ovl_q_detected = (g_quality.detected >= 0 && g_quality.detected < PSX_QUALITY_COUNT) + ? g_quality.detected + 1 : 0; + g_quality_display_h = psx_sdl_display_pixel_height(nullptr); + { + std::lock_guard lk(g_ovl_mu); + g_ovl_q_sel.reset(); + } + mh_refresh_snapshot(); + } +#endif const char* e = std::getenv("PSX_OVERLAY"); const PsxMenuProvider* p = psx_menu_provider(); g_menu_host.default_selection = 0; @@ -16884,6 +16927,21 @@ static void host_pause_service(void) { #endif const PsxHostVideoSettings v = ovl_video_from(post); (void)psx_video_apply_live(&v); +#if defined(RECOMP_LAUNCHER_HAS_QUALITY_PRESETS) + /* What the player selected versus what is live now: rows that wait + * (a pick's next-launch rows, netplay-deferred rows) stay selected + * for the next seed. Then refresh the preset state the save settled. */ + { + PsxHostVideoSettings now; + psx_video_current(&now); + const psxq::Rows live = mh_quality_live_rows(now); + { + std::lock_guard lk(g_ovl_mu); + g_ovl_q_sel.record(quality_rows_of(post), live); + } + mh_refresh_snapshot(); + } +#endif #if defined(RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK) && \ defined(PSX_HAS_JOGCON_FFB_PLAYER_SETTING) /* Controls > Wheel force feedback (experimental): live, and saved to diff --git a/runtime/src/quality_presets.cpp b/runtime/src/quality_presets.cpp index 5131e424..a7300e66 100644 --- a/runtime/src/quality_presets.cpp +++ b/runtime/src/quality_presets.cpp @@ -223,6 +223,102 @@ Edit launcher_custom_edit(const std::vector& keys, int base, int cu return Edit::NONE; } +Rows rows_from_runtime(const PSXRecompV4::RuntimeConfig& r, + bool has_dynres_env, int dynres_env, + bool has_dynres_min_env, int dynres_min_env) { + Rows o; + o.texture_filter = r.video_texture_filter; + int fmv = r.video_fmv_filter; /* cfg -> launcher (cfg_fmv_filter_to_launcher) */ + if (fmv < 0 || fmv >= PSXRecompV4::VIDEO_FMV_FILTER_COUNT) + fmv = PSXRecompV4::VIDEO_FMV_FILTER_DEFAULT; + o.fmv_filter = fmv + 1; + o.antialiasing = r.video_antialiasing ? 1 : 0; + o.dynamic_resolution = (has_dynres_env ? dynres_env : (r.video_dynamic_resolution ? 1 : 0)) ? 1 : 0; + o.dynamic_resolution_min = has_dynres_min_env ? dynres_min_env : r.video_dynamic_resolution_min; + o.render_thread = r.video_render_thread ? 1 : 0; + o.present_thread = r.video_present_thread ? 1 : 0; + o.frame_generation = r.video_frame_generation ? 1 : 0; + o.geometry_correction = r.video_geometry_correction ? 1 : 0; + o.perspective_texturing = r.video_perspective_texturing ? 1 : 0; + o.frame_interp = r.video_frame_interpolation ? 1 : 0; + o.frame_interp_fps = r.video_frame_interpolation_fps; + return o; +} + +bool row_differs(const std::string& k, const Rows& a, const Rows& b) { + if (k == "texture_filtering") return a.texture_filter != b.texture_filter; + if (k == "fmv_filter") return a.fmv_filter != b.fmv_filter; + if (k == "antialiasing") return (a.antialiasing != 0) != (b.antialiasing != 0); + if (k == "dynamic_resolution") return (a.dynamic_resolution != 0) != (b.dynamic_resolution != 0); + if (k == "dynamic_resolution_min") return a.dynamic_resolution_min != b.dynamic_resolution_min; + if (k == "frame_generation") return (a.frame_generation != 0) != (b.frame_generation != 0); + if (k == "render_thread") return (a.render_thread != 0) != (b.render_thread != 0); + if (k == "present_thread") return (a.present_thread != 0) != (b.present_thread != 0); + if (k == "geometry_correction") return (a.geometry_correction != 0) != (b.geometry_correction != 0); + if (k == "perspective_texturing") return (a.perspective_texturing != 0) != (b.perspective_texturing != 0); + if (k == "frame_interpolation") return (a.frame_interp != 0) != (b.frame_interp != 0); + if (k == "frame_interpolation_fps") return a.frame_interp_fps != b.frame_interp_fps; + return false; /* preset-only keys have no row */ +} + +LauncherEdit classify_launcher(int preset1, int base1, const Rows& submitted, + int current, int current_base, + const std::vector& keys, + const std::function& preset_rows) { + LauncherEdit out; + if (preset1 < 1 || preset1 > 5) return out; + const int preset = preset1 - 1; + if (preset != PSX_QUALITY_CUSTOM) { + if (preset != current) { out.edit = Edit::PICK; out.picked = preset; } + return out; + } + /* A player may pick a different tier and edit one of its rows before + * leaving the editor: Custom belongs to that tier. The frontends flag + * Custom on any row their snapshot tracks; only a row this title's + * presets govern makes it Custom. */ + const int base = base1 - 1; + Rows expect; + const bool have = base >= 0 && base < PSX_QUALITY_COUNT && preset_rows && preset_rows(base, &expect); + if (have && current != PSX_QUALITY_CUSTOM) { + int picked = PSX_QUALITY_NONE; + const Edit e = launcher_custom_edit( + keys, base, current, + [&](const std::string& k) { return row_differs(k, submitted, expect); }, &picked); + if (e == Edit::NONE) return out; /* unrelated rows only */ + if (e == Edit::PICK) { out.edit = e; out.picked = picked; return out; } + } + out.base_changed = have && base != current_base; + if (current != PSX_QUALITY_CUSTOM || out.base_changed) out.edit = Edit::GOVERNED_EDIT; + if (have) { out.set_base = true; out.base = base; } + return out; +} + +namespace { +int Rows::* const kRowFields[] = { + &Rows::texture_filter, &Rows::fmv_filter, &Rows::antialiasing, + &Rows::dynamic_resolution, &Rows::dynamic_resolution_min, + &Rows::frame_generation, &Rows::render_thread, &Rows::present_thread, + &Rows::geometry_correction, &Rows::perspective_texturing, + &Rows::frame_interp, &Rows::frame_interp_fps, +}; +} // namespace + +void MenuSelection::record(const Rows& selected, const Rows& live_after) { + sel_ = selected; + pending_ = 0; + for (unsigned i = 0; i < sizeof kRowFields / sizeof kRowFields[0]; ++i) + if (selected.*kRowFields[i] != live_after.*kRowFields[i]) pending_ |= 1u << i; + valid_ = true; +} + +Rows MenuSelection::seed(const Rows& live) const { + Rows out = live; + if (!valid_) return out; + for (unsigned i = 0; i < sizeof kRowFields / sizeof kRowFields[0]; ++i) + if (pending_ & (1u << i)) out.*kRowFields[i] = sel_.*kRowFields[i]; + return out; +} + static void copy_meta(PSXRecompV4::UserSettings& d, const PSXRecompV4::UserSettings& s) { d.has_quality_preset = s.has_quality_preset; d.quality_preset = s.quality_preset; d.has_quality_base = s.has_quality_base; d.quality_base = s.quality_base; diff --git a/runtime/tests/test_menu_quality_callbacks.py b/runtime/tests/test_menu_quality_callbacks.py new file mode 100644 index 00000000..c5c63dcd --- /dev/null +++ b/runtime/tests/test_menu_quality_callbacks.py @@ -0,0 +1,36 @@ +"""The in-game menu's preset callbacks run on a provider's draw thread while +the game runs: they must not write runtime state (psxrecomp#674 review P1). + +quality_apply is a pure preset -> rows mapping, re-detect returns this +session's detection, settings_get reads only the main-thread snapshot, and +the menu's game info never publishes the pre-game launcher's stateful +callbacks (quality_fill_game_info: quality_launcher_redetect, string +publication).""" +import re +from pathlib import Path +from test_netplay_load_apply_flow import function_body, require + +source = (Path(__file__).resolve().parents[1] / "src/main.cpp").read_text(encoding="utf-8") +WRITE = re.compile(r"\bg_\w+\s*(?:\.\w+\s*)?(?:=(?!=)|\+\+|--|\+=|-=)") +STATEFUL = ("quality_apply_video_globals", "quality_publish_strings", "psxq::decide", + "quality_launcher_redetect", "psx_video_current", "quality_fill_game_info", + "psx_sdl_display_pixel_height") + +for sig in ("psxq::Rows quality_rows_for(const PSXRecompV4::RuntimeConfig& r)", + "void quality_rows_to(const psxq::Rows& r, RecompLauncherCSettings* s)", + "bool quality_preset_rows(int tier, psxq::Rows* out)", + "void quality_launcher_apply(int preset, RecompLauncherCSettings* s)", + "int mh_quality_redetect(void)", + "void mh_launcher_settings_get(RecompLauncherCSettings* out)", + "void mh_launcher_game_info(RecompLauncherCGameInfo* out)"): + body = function_body(source, sig) + m = WRITE.search(body) + require(m is None, f"{sig}: writes runtime state ({m.group(0) if m else ''})") + for name in STATEFUL: + require(name not in body, f"{sig}: calls {name}") + +gi = function_body(source, "void mh_launcher_game_info(RecompLauncherCGameInfo* out)") +require("gi.quality_apply = quality_launcher_apply" in gi and + "gi.quality_redetect = mh_quality_redetect" in gi, + "menu game info publishes the thread-safe preset callbacks") +print("menu quality callbacks: draw-thread safe PASS")