Merge commit 'cb8a2e617dd5edbe1dc3dafbfc82f1a48bf3d6f6' into integrate/pr558plus-fixes-20261008

# Conflicts:
#	docs/TCP_COMMANDS.md
#	generated/OpenBIOS.emitter.sha
#	runtime/src/main.cpp
This commit is contained in:
Matthew Stanley
2026-10-08 11:00:25 -07:00
13 changed files with 558 additions and 19 deletions
+4 -2
View File
@@ -55,6 +55,7 @@ Columns: **N** = native, **D** = DuckStation oracle.
| `screenshot_wide_hires` | ✓ | | `path`, `base_x` | The displayed band of the native-wide surface at internal resolution (`wide_w×S` by `height×S`). `present_shot` is capped at the window; this checks a widescreen + internal-resolution combination at full size |
| `present_shot` | ✓ | | `path` | PNG of the **composed present surface** — the frame after the backend fits the display buffer to the window, so it carries the presented aspect. ⚠ every other capture resolves the display buffer *before* that fit: on a 508×256 display in a 4:3 window they answer 508×256 while the player sees 640×480. Use this one for anything aspect-shaped (widescreen, letterbox), where a pre-fit buffer would hide the very stage the change touches. Staged and fulfilled on the next present, so the ack means *queued* — poll `present_shot_seq`. Unavailable headless and on the Vulkan backend (its swapchain has no readback hook) |
| `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates |
| `post_aa` | ✓ | | `mode` (optional: 0 off, 1 fxaa, 2 fxaa_hq) | Read or set post-process anti-aliasing live (`[video] antialiasing_mode`). Replies `mode`, `passes` (filtered presents so far) and `gpu_us`: the mean pass time since the last query when the run has `PSX_POST_AA_TIME=N` (N passes per present between `glFinish` fences, a measurement mode), else 0 |
| `hd_textures` | ✓ | | optional `replacements`, `dump` (0 or 1), `reload` (1) | HD pack root, switches, backend support, replacement count, matched/ready/applied draw counts, and queued dump count. Optional controls update the configured host pack on the emulation thread; no configured root is an error. Reload retains unchanged upload identities. OpenGL displays replacements; software/Vulkan retain original artwork. Pair with `present_shot` for visual evidence and native VRAM probes for architectural data |
| `gl_interp` | ✓ | | — | OpenGL frame-rate presenter ([FRAME_RATE.md](FRAME_RATE.md)): enabled/suspended, host and target Hz, swaps, `source` (`vblank`/`flip`), `flip_period`, `captures` (new source frames) and `duplicates` (VBlanks that re-presented the same frame) |
| `render_pass_stats` | ✓ | | — | Render passes ([RENDER_PASSES.md](RENDER_PASSES.md)): plans, phases wanted/planned (shedding), passes, rollbacks (`nesting_repairs`: watchdog aborts whose skipped frame exits the restore undid), dropped device stores by class, `verify_mismatch` under `PSX_RENDER_PASS_VERIFY=1`, host-time split per pass, smoothed pass cost (`cost_us`; `cost_rewarms`: estimates no pass had run on for a while, measured again), presents made from pass images (`late_presents`: held past the frame's planned end because the next flip was late; `expired`: frames whose images stopped showing after several frame lengths without a flip), pass image textures allocated (`image_textures`, `image_bytes`), `status` (`psx_mod_render_pass_status`: 0 ready, 1 no presenter, 2 backend, 3 disabled, 4 session, 5 fast-forward, 6 busy), `backups_reused` (passes that reused the previous pass's VRAM backup), `spans` / `span_failures` and the last failure `span_fail` (`psx_mod_run_guest_span`: reason, exit PC, `$ra`, PC after a call that did not return) |
@@ -479,9 +480,9 @@ between Play and TCP availability.
## Complete command index (generated)
**361 commands registered** — 348 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
**362 commands registered** — 349 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
73 of 361 have prose above; **288 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
74 of 362 have prose above; **288 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code.
@@ -712,6 +713,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `phase_hot` | ✓ | | |
| `phase_profile` | ✓ | | ✓ |
| `ping` | ✓ | ✓ | ✓ |
| `post_aa` | ✓ | | ✓ |
| `present_image_ring_get` | ✓ | | |
| `present_image_ring_stats` | ✓ | | |
| `present_ring` | ✓ | | |
+52
View File
@@ -604,6 +604,58 @@ the pixels to show it. Native keeps the window exactly as before.
downsamples to the window. It accepts 1 to 32 in both `game.toml` and the
player's `settings.toml`; 1 (the default) is native and unchanged.
### Anti-aliasing (`antialiasing_mode`, `supersample`)
Both are opt-in and change nothing unless set.
```toml
[video]
supersample = 1.5 # 1.0..4.0, default 1.0
antialiasing_mode = "fxaa" # off (default) | fxaa | fxaa_hq
```
`supersample` multiplies the target of a line preset or Match display before
it becomes a scale: Match display on a 1080 px monitor with 2.0 asks for 2160
lines (S = 9) and the present's area resolve brings it back to 1080, which is
ordered-grid SSAA of everything, 2D included. Native and the legacy
`supersampling` factor are left alone. Dynamic resolution takes the result as
its ceiling and steps under it as before. `PSX_SUPERSAMPLE=<factor>` overrides
it for one run.
To make supersampling the elastic part only, set the dynamic resolution floor
to the output: `dynamic_resolution_min = "display"` (also `PSX_DYNRES_MIN=display`
and settings.toml) means the selected internal resolution *without* the
supersample factor, i.e. the monitor's own lines under Match display or the
preset's lines otherwise. Dynamic resolution then steps between the
supersampled ceiling and the output and never renders below it:
```toml
[video]
internal_resolution = "display"
supersample = 2.0
dynamic_resolution = true
dynamic_resolution_min = "display"
```
`antialiasing_mode` filters the composed game image on OpenGL: after the game
quad (4:3, native-wide, Smooth motion and generated frames alike) and before
hold-last capture, the OSD, screenshots and the swap. It reads only output
pixels, so guest VRAM, readbacks, render passes and the Smooth motion sources
are untouched; the bezel and OSD stay sharp; 24-bit FMV frames are not
filtered. The PS1 HUD is part of the same image and is filtered too. `fxaa` is one copy and one full-screen pass (on an M4, at most 0.4 /
0.7 / 1.5 ms at 1080p / 1440p / 4K, fence-timed with the copy); `fxaa_hq` lowers the contrast threshold and searches further along
long edges. `PSX_AA_MODE=off|fxaa|fxaa_hq` overrides it for one run and the
`post_aa` TCP command switches it live. The older boolean `antialiasing` key is
unrelated: it is the linear present filter the launcher already persists.
Not offered: MSAA. The hi-res surface *is* guest VRAM (texture pages, CLUTs,
VRAM copies, readbacks and the mask stencil all sample or copy it between
draws), so a multisampled target would need a resolve before every one of
those and the mask/semi-transparency rules would have to run per sample; that
is a renderer redesign, not an option. SMAA needs its area/search lookup
textures and three passes; on an image that is already area-resolved from a
higher internal resolution it buys little over FXAA, so it is left for later.
The runtime clamps N per backend:
- Software and Vulkan stop at 4.
+1 -1
View File
@@ -1 +1 @@
7d8356d54277a80240c604717bd3a801c321a19bd789bbb74adbd62e635379d1
b352f488a134fd511614abed04c6076152b9a80ab24c796a6b671126adfd4bdd
+22 -7
View File
@@ -715,8 +715,7 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path&
int value = 0;
bool ok = false;
if (ir.is_string()) {
ok = psx_ir_parse(ir.as_string().str.c_str(), &value) != 0 &&
value != PSX_IR_DISPLAY;
ok = psx_ir_parse(ir.as_string().str.c_str(), &value) != 0;
} else if (ir.is_integer()) {
const auto n = ir.as_integer();
ok = n >= PSX_IR_MIN_LINES && n <= PSX_IR_MAX_LINES;
@@ -725,7 +724,7 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path&
if (!ok) {
throw std::runtime_error(
"[video] dynamic_resolution_min must be native, 720p, 1080p, "
"1440p, 4k, 5k, 8k, or a number of lines");
"1440p, 4k, 5k, 8k, display, or a number of lines");
}
rt.video_dynamic_resolution_min = value;
}
@@ -748,6 +747,24 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path&
if (video.contains("antialiasing")) {
rt.video_antialiasing = toml::find<bool>(video, "antialiasing");
}
if (video.contains("antialiasing_mode")) {
const auto mode = toml::find<std::string>(video, "antialiasing_mode");
if (mode == "off") rt.video_antialiasing_mode = 0;
else if (mode == "fxaa") rt.video_antialiasing_mode = 1;
else if (mode == "fxaa_hq") rt.video_antialiasing_mode = 2;
else throw std::runtime_error(fmt::format(
"[video] antialiasing_mode must be \"off\", \"fxaa\" or \"fxaa_hq\": {}", mode));
}
if (video.contains("supersample")) {
const auto& v = toml::find(video, "supersample");
const double f = v.is_integer() ? static_cast<double>(v.as_integer())
: toml::get<double>(v);
if (!(f >= 1.0 && f <= 4.0)) {
throw std::runtime_error(fmt::format(
"[video] supersample out of range (1.0..4.0): {}", f));
}
rt.video_supersample_milli = static_cast<int>(f * 1000.0 + 0.5);
}
if (video.contains("texture_filtering")) {
const auto mode = toml::find<std::string>(video, "texture_filtering");
if (mode == "nearest") rt.video_texture_filter = 0;
@@ -2807,8 +2824,7 @@ UserSettings load_user_settings(const fs::path& path) {
const toml::value& ir = toml::find(v, "dynamic_resolution_min");
int value = 0;
if (ir.is_string()) {
if (psx_ir_parse(ir.as_string().str.c_str(), &value) &&
value != PSX_IR_DISPLAY) {
if (psx_ir_parse(ir.as_string().str.c_str(), &value)) {
s.dynamic_resolution_min = value; s.has_dynamic_resolution_min = true;
}
} else if (ir.is_integer()) {
@@ -3218,8 +3234,7 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) {
if (s.has_dynamic_resolution)
f << "dynamic_resolution = " << (s.dynamic_resolution ? "true" : "false") << "\n";
if (s.has_dynamic_resolution_min &&
psx_ir_value_valid(s.dynamic_resolution_min) &&
s.dynamic_resolution_min != PSX_IR_DISPLAY) {
psx_ir_value_valid(s.dynamic_resolution_min)) {
const char* id = psx_ir_id_for(s.dynamic_resolution_min);
if (id)
f << "dynamic_resolution_min = \"" << id << "\"\n";
+9
View File
@@ -450,6 +450,15 @@ struct RuntimeConfig {
// Defaults to true.
bool video_antialiasing = true;
// antialiasing_mode: post-process anti-aliasing of the composed game image
// (OpenGL; GL_POST_AA_* in gpu_gl_renderer.h): "off" (default) | "fxaa" |
// "fxaa_hq". Separate from the legacy antialiasing bool above, which the
// launcher and settings.toml already persist as the linear present filter.
int video_antialiasing_mode = 0;
// supersample: factor on the internal-resolution target (Match display or
// a line preset), 1.0..4.0, kept in thousandths (internal_resolution.h).
int video_supersample_milli = 1000;
// texture_filtering: "nearest" (default, native PSX look) | "bilinear"
// (smooths textures and 2D backgrounds). Stored as 0/1.
int video_texture_filter = 0;
+10
View File
@@ -264,6 +264,16 @@ if(BUILD_TESTING)
--sdl-library "$<TARGET_FILE:SDL3::SDL3-static>"
--output "${CMAKE_CURRENT_BINARY_DIR}/gl-filter-results")
set_tests_properties(gl_texture_filter_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 180)
add_test(NAME gl_post_aa_test
COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_texture_filter.py"
--fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_post_aa.c"
--cc "${CMAKE_C_COMPILER}"
--sdl-include "$<TARGET_PROPERTY:SDL3::Headers,INTERFACE_INCLUDE_DIRECTORIES>"
--sdl-library "$<TARGET_FILE:SDL3::SDL3-static>"
--output "${CMAKE_CURRENT_BINARY_DIR}/gl-post-aa-results")
set_tests_properties(gl_post_aa_test PROPERTIES
LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77)
add_test(NAME gl_hd_texture_pack_test
COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_texture_filter.py"
+9
View File
@@ -176,6 +176,15 @@ void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linea
* Returns 0 only if a texture could not be created. */
int gl_renderer_set_bezel(const void *rgba, int w, int h);
int gl_renderer_has_bezel(void);
/* Post-process anti-aliasing of the composed game image ([video]
* antialiasing_mode, PSX_AA_MODE). Off by default; OSD and bezel are not
* filtered and guest VRAM is never touched. Takes effect at the next present;
* safe to call from any thread. */
enum { GL_POST_AA_OFF = 0, GL_POST_AA_FXAA = 1, GL_POST_AA_FXAA_HQ = 2 };
int gl_renderer_set_post_aa(int mode);
int gl_renderer_post_aa(void);
uint64_t gl_renderer_post_aa_passes(void);
double gl_renderer_post_aa_gpu_us(void); /* debug builds, PSX_POST_AA_TIME=N: mean pass time (us), resets */
/* Clear to black + swap (display-disabled frame). */
void gl_renderer_present_blank(void);
+62
View File
@@ -112,6 +112,54 @@ static inline int psx_resolve_internal_scale(int value, int ref_lines,
return s < 1 ? 1 : s;
}
/* ---- Supersample ([video] supersample, PSX_SUPERSAMPLE) ----------------------
* A factor on the TARGET of a line-count preset or Match display, in
* thousandths (1000 = 1.0, the historical behaviour; range 1000..4000). The
* renderer already area-resolves an internal image taller than the output, so
* asking for 2x the display's lines is ordered-grid SSAA of the whole frame:
* Match display at 1080 px with 2.0 renders 2160 lines (S = 9) and resolves
* to 1080. Native and the legacy integer factor are left alone. Dynamic
* resolution takes the result as its ceiling and steps under it as usual. */
#define PSX_SS_MILLI_MIN 1000
#define PSX_SS_MILLI_MAX 4000
static inline int psx_ss_milli_clamp(int m) {
return m < PSX_SS_MILLI_MIN ? PSX_SS_MILLI_MIN
: m > PSX_SS_MILLI_MAX ? PSX_SS_MILLI_MAX : m;
}
/* "1.5", "2", "2x" -> 1500, 2000, 2000. 0 for anything else. */
static inline int psx_ss_parse_milli(const char *s) {
if (!s || !*s) return 0;
long whole = 0, frac = 0, div = 1;
const char *p = s;
while (*p >= '0' && *p <= '9') { whole = whole * 10 + (*p - '0'); p++; if (whole > 100) return 0; }
if (p == s) return 0;
if (*p == '.') { p++; while (*p >= '0' && *p <= '9') { if (div < 1000) { frac = frac * 10 + (*p - '0'); div *= 10; } p++; } }
if (*p == 'x' || *p == 'X') p++;
if (*p) return 0;
long m = whole * 1000 + frac * (1000 / div);
if (m < PSX_SS_MILLI_MIN || m > PSX_SS_MILLI_MAX) return 0;
return (int)m;
}
/* psx_resolve_internal_scale with the supersample factor on the target. */
static inline int psx_resolve_internal_scale_ss(int value, int ref_lines,
int display_px_h, int s_max,
int ss_milli) {
ss_milli = psx_ss_milli_clamp(ss_milli);
if (ss_milli != PSX_SS_MILLI_MIN) {
if (value == PSX_IR_DISPLAY) {
if (display_px_h > 0)
display_px_h = (int)(((long long)display_px_h * ss_milli + 999) / 1000);
} else if (value >= PSX_IR_MIN_LINES) {
long long t = ((long long)value * ss_milli + 999) / 1000;
value = t > 4LL * PSX_IR_MAX_LINES ? 4 * PSX_IR_MAX_LINES : (int)t;
}
}
return psx_resolve_internal_scale(value, ref_lines, display_px_h, s_max);
}
/* A legacy [video] supersampling factor as a value the launcher can show:
* the preset that resolves to the same scale (1 -> Native, 3 -> 720p,
* 5 -> 1080p, 6 -> 1440p, 9 -> 4K, 12 -> 5K, 18 -> 8K at 240 lines),
@@ -193,3 +241,17 @@ static inline PsxIrAdopted psx_ir_adopt_launcher(int has_row, int preset,
}
return a;
}
/* ---- Dynamic resolution floor ([video] dynamic_resolution_min) -------------
* A preset or line count resolves as usual. "display" (PSX_IR_DISPLAY) is the
* output's own lines: the selected internal resolution WITHOUT the supersample
* factor (Match display = the monitor's pixel height). With supersample > 1
* the controller then trades only supersampling and never drops below the
* output. Clamped to [1, ceiling]. */
static inline int psx_dynres_floor_scale(int min_value, int selected_ir, int ref_lines,
int display_px_h, int ceiling) {
const int v = (min_value == PSX_IR_DISPLAY && selected_ir != PSX_IR_UNSET)
? selected_ir : min_value;
int s = psx_resolve_internal_scale(v, ref_lines, display_px_h, ceiling);
return s > ceiling ? ceiling : s;
}
+12
View File
@@ -8557,6 +8557,17 @@ static void handle_synth_recurse(int id, const char *json)
#endif
}
/* post_aa: post-process anti-aliasing, live. {"cmd":"post_aa"} reads it,
* {"cmd":"post_aa","mode":0|1|2} sets it (off, fxaa, fxaa_hq). */
static void handle_post_aa(int id, const char *json)
{
int mode = json_get_int(json, "mode", -1);
if (mode >= 0) (void)gl_renderer_set_post_aa(mode);
send_fmt("{\"id\":%d,\"ok\":true,\"mode\":%d,\"passes\":%llu,\"gpu_us\":%.1f}", id,
gl_renderer_post_aa(), (unsigned long long)gl_renderer_post_aa_passes(),
gl_renderer_post_aa_gpu_us());
}
static void handle_frame_perf(int id, const char *json)
{
(void)json;
@@ -15648,6 +15659,7 @@ static const CmdEntry s_commands[] = {
{ "gpu_timeline", handle_gpu_timeline },
{ "nclip_stats", handle_nclip_stats },
{ "frame_perf", handle_frame_perf },
{ "post_aa", handle_post_aa },
{ "gl_ws_ablate", handle_gl_ws_ablate },
{ "gl_interp", handle_gl_interp },
{ "render_pass_stats", handle_render_pass_stats },
+185
View File
@@ -601,6 +601,7 @@ static int s_osd_tw = 0, s_osd_th = 0;
static GLuint s_present_prog = 0, s_present_vao = 0;
static GLuint s_xr_color_prog = 0, s_xr_native_tex = 0;
static void gl_swap_with_osd(void);
static int s_post_aa = 0; /* GL_POST_AA_*; post-process AA (post_aa_apply) */
static int s_native_surface_enabled, s_native_surface_pending;
static int s_native_surface_rect[4]; /* Fresh native backbuffer content, GL coordinates. */
static double s_native_surface_distance, s_native_surface_width, s_native_surface_units;
@@ -1338,8 +1339,12 @@ int gl_renderer_coh_get(uint64_t seq, GlCohEvent *out) {
static GlPresEvent s_pres_ring[GL_PRES_RING_CAP];
static uint64_t s_pres_seq = 0;
static void post_aa_apply(int lx, int ly, int lw, int lh);
static void pres_record(int path, int dx, int dy, int w, int h,
int lx, int ly, int lw, int lh) {
/* The composed game image, before hold-last capture and the OSD. */
if (s_post_aa && (path == GL_PRES_VRAM || path == GL_PRES_WIDE || path == GL_PRES_INTERP))
post_aa_apply(lx, ly, lw, lh);
s_native_surface_pending=s_native_surface_enabled &&
(path==GL_PRES_VRAM || path==GL_PRES_WIDE || path==GL_PRES_CPU || path==GL_PRES_BLANK);
s_native_surface_rect[0]=lx;s_native_surface_rect[1]=ly;
@@ -1407,6 +1412,185 @@ static const char *PRESENT_VS =
" v_uv = vec2(mix(u_uv_rect.x,u_uv_rect.z,p.x),\n"
" mix(u_uv_rect.y,u_uv_rect.w,1.0-p.y));\n"
" gl_Position = vec4(p*2.0-1.0,0.0,1.0); }\n";
/* ---- Post-process anti-aliasing ([video] antialiasing_mode) ----------------
* Opt-in, off by default: with the mode off nothing below runs and the present
* is byte-identical to a build without it. When on, the composed game image
* (the letterbox rect pres_record is told about, after the game quad and
* before hold-last capture, OSD, screenshots and the swap) is copied out of
* the drawable and redrawn through an edge filter. It works on the output
* pixels only: guest VRAM, the hr/wide surfaces, readbacks, render passes and
* the Smooth motion / frame generation sources are never touched, so it
* composes with every present path (4:3, native-wide, interpolated and
* generated frames) and with internal resolution / dynamic resolution, whose
* area resolve has already happened by then. OSD and bezel stay sharp.
*
* FXAA here is an independent implementation of the published technique
* (luma contrast test, edge orientation, bounded end-of-edge search,
* sub-pixel blend); it is one copy plus one full-screen pass. */
static GLuint build_program(const char *vs, const char *fs);
static GLuint s_paa_prog = 0, s_paa_tex = 0;
static int s_paa_tw = 0, s_paa_th = 0, s_paa_failed = 0;
static GLint s_paa_uTex = -1, s_paa_uRcp = -1, s_paa_uUvRect = -1, s_paa_uQuality = -1;
static uint64_t s_paa_passes = 0;
#ifndef PSX_NO_DEBUG_TOOLS
/* Cost of the pass (debug server post_aa), only with PSX_POST_AA_TIME=N: the
* pass runs N times (1..64; N > 1 re-filters the frame and amortizes the
* fence) bracketed by glFinish and timed on the host clock. That serializes
* the GPU, so it is a measurement mode, never on by default. (Timestamp
* queries read 0 on Apple's GL.) */
static int s_paa_time = -1;
static double s_paa_gpu_us_sum = 0.0;
static uint64_t s_paa_gpu_n = 0, s_paa_t0 = 0;
#endif
static const char *POST_AA_FS =
"#version 330\n"
"in vec2 v_uv; out vec4 o;\n"
"uniform sampler2D u_tex; uniform vec2 u_rcp; uniform int u_quality;\n"
"float L(vec3 c){ return dot(c, vec3(0.299,0.587,0.114)); }\n"
"float LS(vec2 p){ return L(textureLod(u_tex,p,0.0).rgb); }\n"
"void main(){\n"
" vec3 cM = textureLod(u_tex, v_uv, 0.0).rgb; float lM = L(cM);\n"
" float lN = LS(v_uv+vec2(0.0, u_rcp.y)), lS = LS(v_uv-vec2(0.0, u_rcp.y));\n"
" float lE = LS(v_uv+vec2(u_rcp.x,0.0)), lW = LS(v_uv-vec2(u_rcp.x,0.0));\n"
" float mx = max(lM,max(max(lN,lS),max(lE,lW)));\n"
" float mn = min(lM,min(min(lN,lS),min(lE,lW)));\n"
" float range = mx - mn;\n"
/* thresholds: absolute 1/16 (1/24 at high quality), relative 1/8 */
" float thr = u_quality > 0 ? 0.0417 : 0.0625;\n"
" if (range < max(thr, mx*0.125)) { o = vec4(cM,1.0); return; }\n"
" float lNE = LS(v_uv+u_rcp), lSW = LS(v_uv-u_rcp);\n"
" float lNW = LS(v_uv+vec2(-u_rcp.x,u_rcp.y)), lSE = LS(v_uv+vec2(u_rcp.x,-u_rcp.y));\n"
" float eH = abs(lNW+lNE-2.0*lN) + 2.0*abs(lW+lE-2.0*lM) + abs(lSW+lSE-2.0*lS);\n"
" float eV = abs(lNW+lSW-2.0*lW) + 2.0*abs(lN+lS-2.0*lM) + abs(lNE+lSE-2.0*lE);\n"
" bool horz = eH >= eV;\n"
" float l1 = horz ? lS : lW, l2 = horz ? lN : lE;\n"
" float g1 = abs(l1-lM), g2 = abs(l2-lM);\n"
" bool neg = g1 >= g2;\n"
" float grad = 0.25*max(g1,g2);\n"
" float step = horz ? u_rcp.y : u_rcp.x;\n"
" float lLocal; if (neg) { step = -step; lLocal = 0.5*(l1+lM); } else lLocal = 0.5*(l2+lM);\n"
" vec2 p = v_uv; if (horz) p.y += 0.5*step; else p.x += 0.5*step;\n"
" vec2 off = horz ? vec2(u_rcp.x,0.0) : vec2(0.0,u_rcp.y);\n"
" vec2 p1 = p - off, p2 = p + off;\n"
" float e1 = LS(p1)-lLocal, e2 = LS(p2)-lLocal;\n"
" bool d1 = abs(e1) >= grad, d2 = abs(e2) >= grad;\n"
" int steps = u_quality > 0 ? 12 : 8;\n"
" for (int i = 1; i < steps && !(d1 && d2); i++) {\n"
" float k = i < 2 ? 1.0 : (i < 5 ? 1.5 : (i < 8 ? 2.0 : 4.0));\n"
" if (!d1) { p1 -= off*k; e1 = LS(p1)-lLocal; d1 = abs(e1) >= grad; }\n"
" if (!d2) { p2 += off*k; e2 = LS(p2)-lLocal; d2 = abs(e2) >= grad; }\n"
" }\n"
" float dist1 = horz ? v_uv.x-p1.x : v_uv.y-p1.y;\n"
" float dist2 = horz ? p2.x-v_uv.x : p2.y-v_uv.y;\n"
" bool near1 = dist1 < dist2; float dmin = min(dist1,dist2);\n"
" float len = dist1 + dist2;\n"
" bool mLess = (lM - lLocal) < 0.0;\n"
" bool good = ((near1 ? e1 : e2) < 0.0) != mLess;\n"
" float eo = good ? (0.5 - dmin/len) : 0.0;\n"
" float avg = (2.0*(lN+lS+lE+lW) + lNE+lNW+lSE+lSW) / 12.0;\n"
" float sp = clamp(abs(avg-lM)/range, 0.0, 1.0);\n"
" sp = (-2.0*sp + 3.0)*sp*sp; sp = sp*sp*0.75;\n"
" float f = max(eo, sp);\n"
" vec2 q = v_uv; if (horz) q.y += f*step; else q.x += f*step;\n"
" o = vec4(textureLod(u_tex, q, 0.0).rgb, 1.0);\n"
"}\n";
int gl_renderer_set_post_aa(int mode) {
if (mode < GL_POST_AA_OFF || mode > GL_POST_AA_FXAA_HQ) mode = GL_POST_AA_OFF;
s_post_aa = mode; /* read by the GL thread at the next present */
return 1;
}
int gl_renderer_post_aa(void) { return s_post_aa; }
uint64_t gl_renderer_post_aa_passes(void) { return s_paa_passes; }
static void post_aa_release(void) {
s_paa_prog = 0; s_paa_tex = 0; s_paa_tw = s_paa_th = 0; s_paa_failed = 0;
}
/* The composed image in [lx,ly,lw,lh] of the bound window target, in place. */
static void post_aa_apply(int lx, int ly, int lw, int lh) {
if (!s_post_aa || !s_ctx || s_paa_failed) return;
int ww = 0, wh = 0;
SDL_GL_GetDrawableSize(s_win, &ww, &wh);
if (lx < 0) { lw += lx; lx = 0; }
if (ly < 0) { lh += ly; ly = 0; }
if (lx + lw > ww) lw = ww - lx;
if (ly + lh > wh) lh = wh - ly;
if (lw < 3 || lh < 3) return;
if (!s_paa_prog) {
s_paa_prog = build_program(PRESENT_VS, POST_AA_FS);
if (!s_paa_prog) { s_paa_failed = 1; return; }
s_paa_uTex = p_glGetUniformLocation(s_paa_prog, "u_tex");
s_paa_uRcp = p_glGetUniformLocation(s_paa_prog, "u_rcp");
s_paa_uUvRect = p_glGetUniformLocation(s_paa_prog, "u_uv_rect");
s_paa_uQuality = p_glGetUniformLocation(s_paa_prog, "u_quality");
}
p_glActiveTexture(PSXGL_TEXTURE0);
if (!s_paa_tex || s_paa_tw != lw || s_paa_th != lh) {
if (!s_paa_tex) glGenTextures(1, &s_paa_tex);
glBindTexture(GL_TEXTURE_2D, s_paa_tex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, lw, lh, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
s_paa_tw = lw; s_paa_th = lh;
} else {
glBindTexture(GL_TEXTURE_2D, s_paa_tex);
}
#ifndef PSX_NO_DEBUG_TOOLS
if (s_paa_time < 0) {
const char *e = getenv("PSX_POST_AA_TIME");
s_paa_time = e ? atoi(e) : 0;
if (s_paa_time < 0) s_paa_time = 0;
if (s_paa_time > 64) s_paa_time = 64;
}
if (s_paa_time) { glFinish(); s_paa_t0 = SDL_GetPerformanceCounter(); }
const int reps = s_paa_time > 1 ? s_paa_time : 1;
#else
const int reps = 1;
#endif
for (int r = 0; r < reps; r++) {
p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0);
glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, lx, ly, lw, lh);
p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0);
glViewport(lx, ly, lw, lh);
p_glUseProgram(s_paa_prog);
p_glUniform1i(s_paa_uTex, 0);
p_glUniform2f(s_paa_uRcp, 1.0f / (float)lw, 1.0f / (float)lh);
p_glUniform1i(s_paa_uQuality, s_post_aa == GL_POST_AA_FXAA_HQ ? 1 : 0);
/* PRESENT_VS flips v; (0,1,1,0) cancels it for a bottom-up copy. */
p_glUniform4f(s_paa_uUvRect, 0.0f, 1.0f, 1.0f, 0.0f);
p_glBindVertexArray(s_present_vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
p_glBindVertexArray(0);
p_glUseProgram(0);
}
glBindTexture(GL_TEXTURE_2D, 0);
#ifndef PSX_NO_DEBUG_TOOLS
if (s_paa_time) {
glFinish();
s_paa_gpu_us_sum += (double)(SDL_GetPerformanceCounter() - s_paa_t0) * 1e6 /
(double)SDL_GetPerformanceFrequency() / (double)reps;
s_paa_gpu_n++;
}
#endif
s_paa_passes++;
}
/* Mean microseconds per pass since the last call (debug builds with
* PSX_POST_AA_TIME=N; 0 otherwise). Resets the mean. */
double gl_renderer_post_aa_gpu_us(void) {
#ifndef PSX_NO_DEBUG_TOOLS
double m = s_paa_gpu_n ? s_paa_gpu_us_sum / (double)s_paa_gpu_n : 0.0;
s_paa_gpu_us_sum = 0.0; s_paa_gpu_n = 0;
return m;
#else
return 0.0;
#endif
}
/* Present sampling. u_sharp==0 is the historical behaviour: sample straight at
* v_uv, so the texture's own filter (NEAREST or LINEAR) decides everything.
*
@@ -5961,6 +6145,7 @@ void gl_renderer_shutdown(void) {
s_hold_tw = 0;
s_hold_th = 0;
s_interp_fbo = 0; /* died with the context */
post_aa_release();
}
/* CPU-readout present (24-bit FMV frames and the PSX_GL_FORCE_CPU_PRESENT
+39 -9
View File
@@ -1220,6 +1220,8 @@ extern "C" EMSCRIPTEN_KEEPALIVE void psx_web_set_smooth_60fps(int enabled) {
/* [video] options, resolved from the game config (defaults: native + AA). */
static int g_video_scale = 1; /* internal-resolution SSAA factor */
static bool g_video_aa = true; /* linear present filtering */
static int g_video_post_aa = 0; /* [video] antialiasing_mode / PSX_AA_MODE (GL_POST_AA_*) */
static int g_video_ss_milli = 1000; /* [video] supersample / PSX_SUPERSAMPLE */
/* FMV present reconstruction (VIDEO_FMV_FILTER_*), pushed to the GL renderer
* once the config is resolved. Only consulted while g_video_aa is on. */
static int g_video_fmv_filter = PSXRecompV4::VIDEO_FMV_FILTER_DEFAULT;
@@ -1370,8 +1372,9 @@ static int effective_internal_resolution(void) {
static void apply_internal_resolution(int display_px_h) {
const int preset = effective_internal_resolution();
if (preset == PSX_IR_UNSET) return;
g_video_scale = psx_resolve_internal_scale(preset, g_video_ref_lines,
display_px_h, video_scale_ceiling());
g_video_scale = psx_resolve_internal_scale_ss(preset, g_video_ref_lines,
display_px_h, video_scale_ceiling(),
g_video_ss_milli);
}
/* The value the launcher row starts on: the preset, or the legacy factor shown
@@ -9364,9 +9367,15 @@ static void dynres_setup(void) {
"dual raster)\n");
return;
}
int floor_s = psx_resolve_internal_scale(dynres_min_value(), g_video_ref_lines,
psx_sdl_display_pixel_height(nullptr),
ceiling);
/* Floor "display": the output's own lines, i.e. the selected internal
* resolution without the supersample factor (Match display = the
* monitor's pixel height). Dynamic resolution then only gives back
* supersampling and never renders below the output. */
int floor_s = psx_dynres_floor_scale(dynres_min_value(),
effective_internal_resolution(),
g_video_ref_lines,
psx_sdl_display_pixel_height(nullptr),
ceiling);
if (floor_s > ceiling) floor_s = ceiling;
DynresParams params;
dynres_default_params(&params);
@@ -15965,6 +15974,8 @@ int main(int argc, char** argv) {
g_video_win_w = gc.runtime.video_window_width;
}
g_video_aa = gc.runtime.video_antialiasing;
g_video_post_aa = gc.runtime.video_antialiasing_mode;
g_video_ss_milli = gc.runtime.video_supersample_milli;
g_video_texfilter = gc.runtime.video_texture_filter;
g_video_fmv_filter = gc.runtime.video_fmv_filter;
g_video_geometry_correction =
@@ -18259,15 +18270,31 @@ session_reboot:
else std::fprintf(stdout, "psxrecomp: PSX_INTERNAL_RESOLUTION=%s not understood "
"(native, 720p, 1080p, 1440p, 4k, 5k, 8k, display, or lines)\n", e);
}
/* PSX_SUPERSAMPLE (1.0..4.0) and PSX_AA_MODE (off, fxaa, fxaa_hq): one-run
* overrides of [video] supersample / antialiasing_mode. */
if (const char* e = std::getenv("PSX_SUPERSAMPLE")) {
const int m = psx_ss_parse_milli(e);
if (m) g_video_ss_milli = m;
else std::fprintf(stdout, "psxrecomp: PSX_SUPERSAMPLE=%s not understood (1.0..4.0)\n", e);
}
if (const char* e = std::getenv("PSX_AA_MODE")) {
if (!std::strcmp(e, "off")) g_video_post_aa = GL_POST_AA_OFF;
else if (!std::strcmp(e, "fxaa")) g_video_post_aa = GL_POST_AA_FXAA;
else if (!std::strcmp(e, "fxaa_hq")) g_video_post_aa = GL_POST_AA_FXAA_HQ;
else std::fprintf(stdout, "psxrecomp: PSX_AA_MODE=%s not understood (off, fxaa, fxaa_hq)\n", e);
}
if (g_video_ss_milli != 1000)
std::fprintf(stdout, "psxrecomp: supersample %.3gx on the internal-resolution target\n",
g_video_ss_milli / 1000.0);
apply_internal_resolution(psx_sdl_display_pixel_height(nullptr));
if (g_video_scale < 1) g_video_scale = 1;
if (const char* e = std::getenv("PSX_DYNRES"))
g_video_dynres_env = (*e && *e != '0') ? 1 : 0;
if (const char* e = std::getenv("PSX_DYNRES_MIN")) {
int v = 0;
if (psx_ir_parse(e, &v) && v != PSX_IR_DISPLAY) g_video_dynres_min_env = v;
if (psx_ir_parse(e, &v)) g_video_dynres_min_env = v;
else std::fprintf(stdout, "psxrecomp: PSX_DYNRES_MIN=%s not understood "
"(native, 720p, 1080p, 1440p, 4k, 5k, 8k, or lines)\n", e);
"(native, 720p, 1080p, 1440p, 4k, 5k, 8k, display, or lines)\n", e);
}
{
/* Per-backend ceiling. OpenGL allocates its hr surface at context init
@@ -18673,6 +18700,7 @@ session_reboot:
return 1;
}
gl_renderer_set_swap_interval(0);
(void)gl_renderer_set_post_aa(g_video_post_aa);
g_gl_active = gl_renderer_init_context(s_headless_gl_window) != 0;
if (!g_gl_active || gr_backend() != GR_BACKEND_OPENGL ||
!(SDL_GetWindowFlags(s_headless_gl_window) & SDL_WINDOW_HIDDEN)) {
@@ -18854,8 +18882,9 @@ session_reboot:
* surface is allocated at context init below. */
if (effective_internal_resolution() == PSX_IR_DISPLAY && g_video_scale_applies) {
const int dh = psx_sdl_display_pixel_height(sdl_window);
const int s = psx_resolve_internal_scale(PSX_IR_DISPLAY, g_video_ref_lines,
dh, GL_MAX_INTERNAL_SCALE);
const int s = psx_resolve_internal_scale_ss(PSX_IR_DISPLAY, g_video_ref_lines,
dh, GL_MAX_INTERNAL_SCALE,
g_video_ss_milli);
gr_set_scale(s);
g_video_requested_scale = s;
std::fprintf(stdout, "psxrecomp: internal resolution Match display: "
@@ -18882,6 +18911,7 @@ session_reboot:
#endif
gl_renderer_set_dynamic_resolution(
((dynres_requested() || dbg_live_scale) && g_video_scale_applies) ? 1 : 0);
(void)gl_renderer_set_post_aa(g_video_post_aa);
g_gl_active = (gl_renderer_init_context(sdl_window) != 0);
dynres_setup();
if (dbg_live_scale && g_gl_active) {
+131
View File
@@ -0,0 +1,131 @@
/* Post-process anti-aliasing ([video] antialiasing_mode) on a real, hidden
* OpenGL context.
*
* - off: the composed frame is untouched (byte-identical before and after a
* round trip through the AA modes; the pass never runs);
* - fxaa / fxaa_hq: a hard polygon edge gains intermediate shades (less
* stair-stepping) while flat regions away from the edge stay identical;
* - guest VRAM / readbacks are the same whatever the mode.
* With --dump PATH the off frame is written raw (RGB, bottom-up) so a run
* against another tree can be compared byte for byte. */
#define GL_PRESENT_TEST_HOOK(gen) aa_capture_hook(gen)
static void aa_capture_hook(int gen);
#define main scale_fixture_main
#include "test_gl_scale_invariance.c"
#undef main
void present_shot_done(int ok) { (void)ok; }
int host_osd_needs_present(void) { return 0; }
int psx_present_vsync_owns_cadence(void) { return 0; }
void latency_ring_mark(LatencyStage stage) { (void)stage; }
void psx_host_sleep_ms(unsigned ms) { SDL_Delay(ms); }
int present_shot_take(char* out,int n) { (void)out; (void)n; return 0; }
int host_osd_image(const uint32_t** p,int* w,int* h) { (void)p; (void)w; (void)h; return 0; }
int host_osd_volume_image(const uint32_t** p,int* w,int* h) { return host_osd_image(p,w,h); }
void host_osd_present_done(void) {}
int psx_rewind_overlay_image(const uint32_t** p,int* w,int* h) { return host_osd_image(p,w,h); }
float psx_rewind_slide(void) { return 0; }
int psx_savestate_menu_overlay_image(const uint32_t** p,int* w,int* h) { return host_osd_image(p,w,h); }
static uint8_t *s_cap; static int s_cap_w, s_cap_h, s_cap_n;
static void aa_capture_hook(int gen) {
(void)gen;
SDL_GL_GetDrawableSize(s_win, &s_cap_w, &s_cap_h);
free(s_cap);
s_cap = (uint8_t *)malloc((size_t)s_cap_w * s_cap_h * 3);
glReadBuffer(GL_BACK);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, s_cap_w, s_cap_h, GL_RGB, GL_UNSIGNED_BYTE, s_cap);
glPixelStorei(GL_PACK_ALIGNMENT, 4);
s_cap_n++;
}
/* A white polygon with a shallow top edge (long stair steps) on black. */
static uint8_t *frame(int mode, uint64_t *vram_hash) {
#ifndef AA_FIXTURE_NO_POST_AA
gl_renderer_set_post_aa(mode);
#else
(void)mode;
#endif
glb_set_draw_area(0, 0, FRAME_W - 1, FRAME_H - 1); glb_set_draw_offset(0, 0);
glb_set_mask_bits(0, 0); glb_set_semi_transparency(0, 0);
glb_fill_rect(0, 0, FRAME_W, FRAME_H, 0x0000);
glb_draw_flat_triangle(20, 150, 300, 110, 300, 200, 0x7fff);
glb_draw_flat_triangle(20, 150, 300, 200, 20, 200, 0x7fff);
gl_renderer_sync_cpu();
check(gl_renderer_fbo_peek(0, 0, FRAME_W, FRAME_H, peek), "vram readback");
*vram_hash = fnv(peek, FRAME_W * FRAME_H * 2, 0xcbf29ce484222325ull);
const int n0 = s_cap_n;
gl_renderer_present_vram(0, 0, FRAME_W, FRAME_H, 0, 0);
check(s_cap_n == n0 + 1 && s_cap, "present captured");
uint8_t *c = (uint8_t *)malloc((size_t)s_cap_w * s_cap_h * 3);
memcpy(c, s_cap, (size_t)s_cap_w * s_cap_h * 3);
return c;
}
static int mid_tones(const uint8_t *c) {
int n = 0;
for (int i = 0; i < s_cap_w * s_cap_h; i++) { int g = c[i * 3 + 1]; n += g > 24 && g < 231; }
return n;
}
/* Pixels whose 5x5 neighbourhood in ref is flat must be identical in c. */
static int flat_changed(const uint8_t *ref, const uint8_t *c) {
int bad = 0;
for (int y = 2; y < s_cap_h - 2; y++)
for (int x = 2; x < s_cap_w - 2; x++) {
const uint8_t *r = ref + ((size_t)y * s_cap_w + x) * 3;
int flat = 1;
for (int dy = -2; dy <= 2 && flat; dy++)
for (int dx = -2; dx <= 2; dx++)
if (memcmp(ref + ((size_t)(y + dy) * s_cap_w + x + dx) * 3, r, 3)) { flat = 0; break; }
if (flat && memcmp(c + ((size_t)y * s_cap_w + x) * 3, r, 3)) bad++;
}
return bad;
}
int main(int argc, char **argv) {
const char *dump = argc > 2 && !strcmp(argv[1], "--dump") ? argv[2] : NULL;
if (SDL_Init(SDL_INIT_VIDEO) != 0) { fprintf(stderr, "SKIP no video\n"); return 77; }
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
#if defined(__APPLE__)
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG);
#endif
SDL_Window *win = SDL_CreateWindow("Post AA hidden test", 0, 0, 640, 480,
SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN);
if (!win) { fprintf(stderr, "SKIP no window\n"); return 77; }
glb_init(vram); glb_set_scale(1); gl_renderer_set_swap_interval(0);
if (!gl_renderer_init_context(win)) { fprintf(stderr, "SKIP no GL context\n"); return 77; }
uint64_t h_off = 0, h_fx = 0, h_hq = 0, h_off2 = 0;
uint8_t *off = frame(GL_POST_AA_OFF, &h_off);
if (dump) {
FILE *f = fopen(dump, "wb");
check(f && fwrite(off, 1, (size_t)s_cap_w * s_cap_h * 3, f) == (size_t)s_cap_w * s_cap_h * 3, "dump");
if (f) fclose(f);
}
#ifndef AA_FIXTURE_NO_POST_AA
check(gl_renderer_post_aa_passes() == 0, "off never runs the AA pass");
uint8_t *fx = frame(GL_POST_AA_FXAA, &h_fx);
uint8_t *hq = frame(GL_POST_AA_FXAA_HQ, &h_hq);
uint8_t *off2 = frame(GL_POST_AA_OFF, &h_off2);
const size_t bytes = (size_t)s_cap_w * s_cap_h * 3;
const int m_off = mid_tones(off), m_fx = mid_tones(fx), m_hq = mid_tones(hq);
printf("drawable=%dx%d mid_tones off=%d fxaa=%d fxaa_hq=%d flat_changed fxaa=%d hq=%d passes=%llu\n",
s_cap_w, s_cap_h, m_off, m_fx, m_hq, flat_changed(off, fx), flat_changed(off, hq),
(unsigned long long)gl_renderer_post_aa_passes());
check(gl_renderer_post_aa_passes() == 2, "one pass per AA present");
check(m_fx > m_off + 40, "fxaa softens the polygon edge");
check(m_hq > m_off + 40, "fxaa_hq softens the polygon edge");
check(memcmp(off, fx, bytes) != 0, "fxaa changes the frame");
check(flat_changed(off, fx) == 0 && flat_changed(off, hq) == 0, "flat regions untouched");
check(memcmp(off, off2, bytes) == 0, "off after AA is byte-identical");
check(h_off == h_fx && h_off == h_hq && h_off == h_off2, "guest VRAM identical in every mode");
free(fx); free(hq); free(off2);
#endif
free(off);
check(glGetError() == GL_NO_ERROR, "GL errors");
printf("checks=%d failures=%d\n", checks, failures);
gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit();
return failures ? 1 : 0;
}
@@ -200,6 +200,28 @@ int main() {
expect("legacy round trip",
psx_resolve_internal_scale(psx_ir_from_supersampling(n, 240), 240, 0, 32), n);
// Supersample: a factor on the target, not on native or the legacy factor.
expect("ss parse 1.5", psx_ss_parse_milli("1.5"), 1500);
expect("ss parse 2x", psx_ss_parse_milli("2x"), 2000);
expect("ss parse 4", psx_ss_parse_milli("4"), 4000);
expect("ss reject 0.5", psx_ss_parse_milli("0.5"), 0);
expect("ss reject 5", psx_ss_parse_milli("5"), 0);
// Dynamic resolution floor "display": the output's lines, no supersample.
expect("floor display 1080 MD", psx_dynres_floor_scale(PSX_IR_DISPLAY, PSX_IR_DISPLAY, 240, 1080, 9), 5);
expect("floor display 1440 MD", psx_dynres_floor_scale(PSX_IR_DISPLAY, PSX_IR_DISPLAY, 240, 1440, 12), 6);
expect("floor display preset", psx_dynres_floor_scale(PSX_IR_DISPLAY, 1080, 240, 2160, 9), 5);
expect("floor display unset", psx_dynres_floor_scale(PSX_IR_DISPLAY, PSX_IR_UNSET, 240, 1080, 9), 5);
expect("floor display clamp", psx_dynres_floor_scale(PSX_IR_DISPLAY, PSX_IR_DISPLAY, 240, 2160, 4), 4);
expect("floor 720p", psx_dynres_floor_scale(720, PSX_IR_DISPLAY, 240, 1080, 9), 3);
expect("ss reject junk", psx_ss_parse_milli("2y"), 0);
expect("ss 1.0 is the plain resolve",
psx_resolve_internal_scale_ss(PSX_IR_DISPLAY, 240, 1080, 32, 1000), 5);
expect("ss 2.0 match display 1080", psx_resolve_internal_scale_ss(PSX_IR_DISPLAY, 240, 1080, 32, 2000), 9);
expect("ss 1.5 at 1440p", psx_resolve_internal_scale_ss(1440, 240, 0, 32, 1500), 9);
expect("ss leaves native", psx_resolve_internal_scale_ss(PSX_IR_NATIVE, 240, 1080, 32, 4000), 1);
expect("ss clamps to the ceiling", psx_resolve_internal_scale_ss(4320, 240, 0, 32, 4000), 32);
expect("ss unknown display stays 1", psx_resolve_internal_scale_ss(PSX_IR_DISPLAY, 240, 0, 32, 2000), 1);
launcher_round_trips();
if (g_failures) return 1;