In-game presets: draw-thread-safe callbacks and a coherent menu seed

P1: quality_launcher_apply is now a pure preset -> rows mapping
(psxq::rows_from_runtime plus startup constants); it never writes the live
globals. The in-game menu publishes only thread-safe callbacks: that
mapper, and a re-detect that returns this session's detection (a fresh
probe needs the GL context and mutates the run's state, so it stays in the
pre-game launcher). Summary/reason strings and the detection are fixed on
the main thread at menu setup; settings_get reads only the main-thread
snapshot.

P2: psxq::MenuSelection keeps what the player selected versus what is live.
A pick's next-launch rows and netplay-deferred rows stay selected for the
next seed until the live value matches; other rows follow live. The host
classification moves to psxq::classify_launcher/row_differs (same rules).

Tests: quality_presets_test test_ingame_menu_host_path runs the review's
fixture through the host get/pick/apply/save path (Ultra -> High, then
Antialiasing = NONE; a governed edit is still Custom from High; a netplay-
deferred row stays selected). menu_quality_callbacks_test guards the
draw-thread callbacks against runtime writes and stateful calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-10-15 19:09:49 -07:00
parent 004749ca8a
commit b279afdc4a
6 changed files with 518 additions and 129 deletions
+140
View File
@@ -15,6 +15,7 @@
#include "host_overlay.h"
#include <cstdio>
#include <algorithm>
#include <cstring>
#include <filesystem>
#include <fstream>
@@ -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<std::string> 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;
+2
View File
@@ -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})
+58 -1
View File
@@ -8,7 +8,7 @@
#include <string>
#include <vector>
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<std::string>& keys, int base, int cu
const std::function<bool(const std::string&)>& 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<std::string>& keys,
const std::function<bool(int, Rows*)>& 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);
+186 -128
View File
@@ -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<int> 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<int> 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(&current);
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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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
+96
View File
@@ -223,6 +223,102 @@ Edit launcher_custom_edit(const std::vector<std::string>& 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<std::string>& keys,
const std::function<bool(int, Rows*)>& 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;
@@ -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")