GL: opt-in FMV chroma smoothing for 24-bit MDEC frames

[video] fmv_chroma_smoothing = true makes the OpenGL present of a 24-bit
(MDEC) frame keep each sample's luma and take its chroma from a [1 2 1]
tent over the 3x3 source pixels, hiding the 2x2 colour blocks of 4:2:0
video without softening luma. Pairs with the existing fmv_filter upscale.
Guest VRAM/MDEC output untouched; 15-bit presents and the default unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-10-08 14:11:55 -07:00
parent 67a21b7395
commit ab13278bdf
11 changed files with 291 additions and 3 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 |
| `fmv_chroma` | ✓ | | `on` (optional: 0/1) | Read or set `[video] fmv_chroma_smoothing` live. Replies `on` and `frames` (24-bit presents smoothed so far) |
| `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) |
@@ -480,9 +481,9 @@ between Play and TCP availability.
## Complete command index (generated)
**362 commands registered** — 349 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
**363 commands registered** — 350 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
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.
75 of 363 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.
@@ -585,6 +586,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `event_ring_tail` | ✓ | | |
| `exc_ring` | | ✓ | |
| `first_failure` | ✓ | ✓ | ✓ |
| `fmv_chroma` | ✓ | | ✓ |
| `fmv_state` | ✓ | | |
| `fn_clear` | ✓ | | |
| `fn_disable` | ✓ | | |
+20
View File
@@ -656,6 +656,26 @@ 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.
### FMV chroma smoothing (`fmv_chroma_smoothing`)
```toml
[video]
fmv_chroma_smoothing = false # default
fmv_filter = "bicubic" # optional upscale filter (needs antialiasing on)
```
MDEC movies are 4:2:0: one colour (Cb/Cr) sample per 2x2 pixels, which the
decoder replicates, so saturated edges in a 24-bit movie frame show 2-pixel
colour blocks around sharp luma. With `fmv_chroma_smoothing = true` the
OpenGL present of a 24-bit frame keeps each sample's luma (from whatever
`fmv_filter` reconstructs) and takes its chroma from a [1 2 1] tent over the
3x3 source pixels around it (BT.601), which removes the colour blocks without
softening luma detail. Guest VRAM and MDEC output are untouched; 15-bit frames
are never affected. It pairs with the existing `fmv_filter` upscale choice
(`nearest`, `bilinear`, `sharp`, `bicubic`). OpenGL only.
`PSX_FMV_CHROMA=0|1` overrides it for one run and the `fmv_chroma` TCP
command switches it live.
The runtime clamps N per backend:
- Software and Vulkan stop at 4.
+1 -1
View File
@@ -1 +1 @@
1a049b61e3b599c223bf00aa56dc55ddb9d5b6e2a894b9c34f6abaddf0cec844
e5321c2360a49803d1595745f7076fb43655ec019c702167df2df9309cac67f7
+2
View File
@@ -813,6 +813,8 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path&
rt.video_pgxp_preserve_projection =
toml::find<bool>(video, "pgxp_preserve_projection");
}
if (video.contains("fmv_chroma_smoothing"))
rt.video_fmv_chroma_smoothing = toml::find<bool>(video, "fmv_chroma_smoothing");
if (video.contains("pgxp_depth_buffer"))
rt.video_pgxp_depth_buffer = toml::find<bool>(video, "pgxp_depth_buffer");
if (video.contains("pgxp_color_correction"))
+3
View File
@@ -563,6 +563,9 @@ struct RuntimeConfig {
// pgxp_depth_buffer) to close T-junction cracks above 1x ("off",
// "fine" = 1 output px, "wide" = half a native px).
bool video_pgxp_depth_buffer = false;
// fmv_chroma_smoothing: on 24-bit (MDEC) presents keep luma and smooth
// chroma over 3x3 source pixels (hides 4:2:0 colour blocks). OpenGL only.
bool video_fmv_chroma_smoothing = false;
bool video_pgxp_color_correction = false;
int video_pgxp_seam = 0;
double video_pgxp_depth_threshold = 4096.0;
+12
View File
@@ -285,6 +285,18 @@ if(BUILD_TESTING)
--sdl-library "$<TARGET_FILE:SDL3::SDL3-static>"
--output "${CMAKE_CURRENT_BINARY_DIR}/gl-hd-texture-results")
set_tests_properties(gl_hd_texture_pack_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 180)
# [video] fmv_chroma_smoothing: 24-bit presents lose their 2x2 chroma
# blocks, luma kept; 15-bit presents and off are byte-identical.
add_test(NAME gl_fmv_chroma_test
COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_texture_filter.py"
--fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_fmv_chroma.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-fmv-chroma-results")
set_tests_properties(gl_fmv_chroma_test PROPERTIES
LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77)
endif()
add_executable(launcher_device_roundtrip_test
tests/test_launcher_device_roundtrip.cpp)
+8
View File
@@ -226,6 +226,14 @@ void gl_renderer_set_cpu_auth_dual(int on);
* config enum VIDEO_FMV_FILTER_* (0 nearest, 1 bilinear, 2 sharp, 3 bicubic).
* Only consulted while video antialiasing is on; AA off is always nearest. */
void gl_renderer_set_fmv_filter(int cfg_value);
/* [video] fmv_chroma_smoothing: on 24-bit (MDEC) presents, keep each sample's
* luma and smooth its chroma over the 3x3 source pixels around it, hiding the
* 2x2 colour blocks of 4:2:0 video. Off by default. main.cpp notes whether
* the next gl_renderer_present is a 24-bit scanout. */
void gl_renderer_set_fmv_chroma_smoothing(int on);
int gl_renderer_fmv_chroma_smoothing(void);
uint64_t gl_renderer_fmv_chroma_frames(void);
void gl_renderer_note_present_depth24(int d24);
int gl_renderer_cpu_auth_dual(void);
/* Post-savestate freeze probe: skip/swap/dirty-mark counters (GL present path).
+12
View File
@@ -8573,6 +8573,17 @@ static void handle_post_aa(int id, const char *json)
gl_renderer_post_aa_gpu_us());
}
/* fmv_chroma: {"cmd":"fmv_chroma"} reads, {"cmd":"fmv_chroma","on":0|1} sets
* [video] fmv_chroma_smoothing live. Replies the smoothed-present count. */
static void handle_fmv_chroma(int id, const char *json)
{
int on = json_get_int(json, "on", -1);
if (on >= 0) gl_renderer_set_fmv_chroma_smoothing(on);
send_fmt("{\"id\":%d,\"ok\":true,\"on\":%d,\"frames\":%llu}", id,
gl_renderer_fmv_chroma_smoothing(),
(unsigned long long)gl_renderer_fmv_chroma_frames());
}
static void handle_frame_perf(int id, const char *json)
{
(void)json;
@@ -15665,6 +15676,7 @@ static const CmdEntry s_commands[] = {
{ "nclip_stats", handle_nclip_stats },
{ "frame_perf", handle_frame_perf },
{ "post_aa", handle_post_aa },
{ "fmv_chroma", handle_fmv_chroma },
{ "gl_ws_ablate", handle_gl_ws_ablate },
{ "gl_interp", handle_gl_interp },
{ "render_pass_stats", handle_render_pass_stats },
+44
View File
@@ -1649,6 +1649,7 @@ static const char *PRESENT_FS =
"uniform vec2 u_sharp_scale;\n"
"uniform int u_sharp;\n"
"uniform float u_gamma;\n"
"uniform int u_chroma; /* [video] fmv_chroma_smoothing (24-bit frames) */\n"
PSX_SCANLINE_UNIFORMS
/* Catmull-Rom bicubic via 9 bilinear taps. Sharper than plain bilinear at
* the same smoothness, with mild overshoot that reads as edge definition.
@@ -1721,6 +1722,26 @@ static const char *PRESENT_FS =
" }\n"
" c = texture(u_tex, uv);\n"
" }\n"
/* MDEC decodes 4:2:0 video: one Cb/Cr pair per 2x2 pixels, upsampled by
* replication, so saturated edges in a 24-bit movie frame show 2-pixel
* colour blocks around sharp luma. Chroma smoothing keeps the luma of the
* reconstructed sample and takes its chroma from a [1 2 1] tent over the
* 3x3 source pixels around it (BT.601), which spreads each block's colour
* into its neighbours without softening the luma detail. */
" if (u_chroma != 0) {\n"
" vec2 ts = 1.0 / u_tex_size;\n"
" vec2 lo = min(u_uv_rect.xy, u_uv_rect.zw), hi = max(u_uv_rect.xy, u_uv_rect.zw);\n"
" vec2 base = (floor(v_uv * u_tex_size) + 0.5) * ts;\n"
" vec2 cc = vec2(0.0);\n"
" for (int j = -1; j <= 1; j++) for (int i = -1; i <= 1; i++) {\n"
" vec3 s = texture(u_tex, clamp(base + vec2(i, j) * ts, lo, hi)).rgb;\n"
" float w = float((2 - abs(i)) * (2 - abs(j)));\n"
" cc += w * vec2(dot(s, vec3(-0.168736, -0.331264, 0.5)), dot(s, vec3(0.5, -0.418688, -0.081312)));\n"
" }\n"
" cc /= 16.0;\n"
" float y = dot(c.rgb, vec3(0.299, 0.587, 0.114));\n"
" c.rgb = clamp(vec3(y + 1.402 * cc.y, y - 0.344136 * cc.x - 0.714136 * cc.y, y + 1.772 * cc.x), 0.0, 1.0);\n"
" }\n"
" c.rgb = psx_scanline(c.rgb, v_uv.y);\n"
" if (u_gamma > 0.0 && u_gamma != 1.0) c.rgb = pow(max(c.rgb, vec3(0.0)), vec3(1.0 / u_gamma));\n"
" frag = c;\n"
@@ -5317,6 +5338,18 @@ static void upload_present_tex(const uint32_t *pixels, int w, int h, int linear)
}
static int s_fmv_filter_cfg = 0; /* VIDEO_FMV_FILTER_NEAREST */
/* [video] fmv_chroma_smoothing, and whether the frame being presented is a
* 24-bit (MDEC) scanout (main.cpp notes it before gl_renderer_present). */
static int s_fmv_chroma = 0, s_present_d24 = 0;
static GLint s_present_uChroma = -1;
static uint64_t s_fmv_chroma_frames = 0;
void gl_renderer_set_fmv_chroma_smoothing(int on) {
GL_RT_SYNC("fmv_chroma");
s_fmv_chroma = on ? 1 : 0;
}
int gl_renderer_fmv_chroma_smoothing(void) { return s_fmv_chroma; }
uint64_t gl_renderer_fmv_chroma_frames(void) { GL_RT_SYNC("fmv_chroma_stats"); return s_fmv_chroma_frames; }
void gl_renderer_note_present_depth24(int d24) { GL_RT_SYNC("present_d24"); s_present_d24 = d24 ? 1 : 0; }
void gl_renderer_set_fmv_filter(int cfg_value) {
GL_RT_SYNC("set_fmv_filter");
@@ -6046,6 +6079,8 @@ int gl_renderer_init_context(SDL_Window *win) {
p_glGetUniformLocation(s_present_prog, "u_sharp");
s_present_uGamma =
p_glGetUniformLocation(s_present_prog, "u_gamma");
s_present_uChroma =
p_glGetUniformLocation(s_present_prog, "u_chroma");
s_present_uScanline =
p_glGetUniformLocation(s_present_prog, "u_scanline");
s_present_uScanStrength =
@@ -6245,7 +6280,16 @@ void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linea
} else {
p_glUniform4f(s_present_uUvRect, 0.f, 0.f, 1.f, 1.f);
}
const int chroma = s_fmv_chroma && s_present_d24;
if (chroma) {
/* present_set_sharp only sets the source size for filtered modes; the
* chroma tent needs it in every mode. */
p_glUniform1i(s_present_uChroma, 1);
if (s_present_uTexSize >= 0) p_glUniform2f(s_present_uTexSize, (float)src_w, (float)src_h);
s_fmv_chroma_frames++;
}
p_glBindVertexArray(s_present_vao); glDrawArrays(GL_TRIANGLES, 0, 3);
if (chroma) p_glUniform1i(s_present_uChroma, 0);
p_glBindVertexArray(0); p_glUseProgram(0);
pres_record(GL_PRES_CPU, 0, 0, src_w, src_h, lx, ly, lw, lh);
hold_capture_drawable();
+5
View File
@@ -1280,6 +1280,7 @@ static int g_video_pgxp_position_fallback = 1;
static int g_video_pgxp_preserve_projection = 0;
/* PGXP renderer features (G1.14); PSX_PGXP_DEPTH / _COLOR / _SEAM override. */
static int g_video_pgxp_depth_buffer = 0;
static int g_video_fmv_chroma = 0; /* [video] fmv_chroma_smoothing / PSX_FMV_CHROMA */
static int g_video_pgxp_color_correction = 0;
static int g_video_pgxp_seam = 0;
static float g_video_pgxp_depth_threshold = 4096.0f;
@@ -9192,6 +9193,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
* right-edge jump the old comment describes is 0 with filtering on.
* Set video AA off to get nearest back. */
gl_renderer_set_fmv_filter(g_video_fmv_filter);
gl_renderer_note_present_depth24(depth24_frame ? 1 : 0);
gl_renderer_present(sdl_pixel_buf, src_w, src_h,
g_video_aa ? 1 : 0,
pin_43 ? 1 : 0, 0 /* full width */);
@@ -16069,6 +16071,7 @@ int main(int argc, char** argv) {
gc.runtime.video_pgxp_preserve_projection ? 1 : 0;
g_video_pgxp_mod_only = gc.runtime.video_pgxp_mod_only ? 1 : 0;
g_video_pgxp_depth_buffer = gc.runtime.video_pgxp_depth_buffer ? 1 : 0;
g_video_fmv_chroma = gc.runtime.video_fmv_chroma_smoothing ? 1 : 0;
g_video_pgxp_color_correction = gc.runtime.video_pgxp_color_correction ? 1 : 0;
g_video_pgxp_seam = gc.runtime.video_pgxp_seam;
g_video_pgxp_depth_threshold = (float)gc.runtime.video_pgxp_depth_threshold;
@@ -18455,6 +18458,8 @@ session_reboot:
if (const char* e = std::getenv("PSX_PGXP_COLOR")) g_video_pgxp_color_correction = (*e && *e != '0');
if (const char* e = std::getenv("PSX_PGXP_SEAM")) g_video_pgxp_seam = std::atoi(e);
gl_renderer_set_pgxp_depth(g_video_pgxp_depth_buffer);
if (const char* e = std::getenv("PSX_FMV_CHROMA")) g_video_fmv_chroma = (*e && *e != '0');
gl_renderer_set_fmv_chroma_smoothing(g_video_fmv_chroma);
gl_renderer_set_pgxp_color_perspective(g_video_pgxp_color_correction);
gl_renderer_set_pgxp_seam(g_video_pgxp_seam);
if (const char* e = std::getenv("PSX_PGXP_DEPTH_THRESHOLD")) g_video_pgxp_depth_threshold = (float)std::atof(e);
+180
View File
@@ -0,0 +1,180 @@
/* [video] fmv_chroma_smoothing on a real, hidden OpenGL context.
*
* A synthetic 4:2:0-style 24-bit frame (constant luma, chroma constant per
* 2x2 block and flipping between saturated hues block to block, plus a sharp
* luma edge on the right) is presented through gl_renderer_present, the
* CPU/24-bit present path:
* - off, and on for a frame that is not a 24-bit scanout, are byte-identical;
* - on, chroma steps between neighbouring output pixels shrink a lot while
* luma stays (the flat-luma area keeps its luma within a few levels, and
* the sharp luma edge stays as sharp);
* - the smoothed-present counter counts only 24-bit presents.
* Original source-owned scene; no retail payload. */
#define GL_PRESENT_TEST_HOOK(gen) cap_hook(gen)
static void cap_hook(int gen);
#define main scale_fixture_main
#include "test_gl_scale_invariance.c"
#undef main
void gpu_hd_textures_begin_upload(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;}
void gpu_hd_textures_track_upload(int x,int y,int w,int h,const uint16_t* p){(void)x;(void)y;(void)w;(void)h;(void)p;}
void gpu_hd_textures_invalidate(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;}
void gpu_hd_textures_begin_copy(int sx,int sy,int x,int y,int w,int h){(void)sx;(void)sy;(void)x;(void)y;(void)w;(void)h;}
void gpu_hd_textures_end_copy(void){}
#ifndef PSX_NO_DEBUG_TOOLS
int present_image_ring_accepting(void){return 0;}
int present_image_ring_thumb_w(int w,int h){(void)w;(void)h;return 0;}
void present_image_ring_push(uint32_t f,const uint8_t* p,int w,int pitch,int flip){(void)f;(void)p;(void)w;(void)pitch;(void)flip;}
void present_image_ring_push_argb(uint32_t f,const uint32_t* p,int w,int h,int pitch){(void)f;(void)p;(void)w;(void)h;(void)pitch;}
#endif
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 cap_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++;
}
#define SW 160
#define SH 120
static uint32_t src[SW * SH];
static uint32_t rgb(double y, double cb, double cr) {
double r = y + 1.402 * cr, g = y - 0.344136 * cb - 0.714136 * cr, b = y + 1.772 * cb;
int R = (int)(r < 0 ? 0 : r > 1 ? 255 : r * 255 + 0.5);
int G = (int)(g < 0 ? 0 : g > 1 ? 255 : g * 255 + 0.5);
int B = (int)(b < 0 ? 0 : b > 1 ? 255 : b * 255 + 0.5);
return 0xff000000u | ((uint32_t)R << 16) | ((uint32_t)G << 8) | (uint32_t)B; /* BGRA bytes */
}
static void frame_src(void) {
for (int y = 0; y < SH; y++)
for (int x = 0; x < SW; x++) {
int blk = ((x >> 1) + (y >> 1)) & 1;
if (x < 120) src[y * SW + x] = rgb(0.5, blk ? 0.15 : -0.15, blk ? -0.15 : 0.15);
else src[y * SW + x] = rgb(x < 140 ? 0.15 : 0.85, 0.0, 0.0); /* sharp grey luma edge */
}
}
static uint8_t *present(int d24) {
const int n0 = s_cap_n;
gl_renderer_note_present_depth24(d24);
gl_renderer_present(src, SW, SH, 0, 1, 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 double luma(const uint8_t *p) { return 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]; }
static double cb(const uint8_t *p) { return -0.168736 * p[0] - 0.331264 * p[1] + 0.5 * p[2]; }
/* Over the chroma-block area of the letterboxed image (middle rows, left
* 70%): mean |Cb step| between horizontal neighbours, and max luma error
* against the flat luma. Then the luma edge's width in output pixels. */
static double s_mean_sat;
static void stats(const uint8_t *c, double *step, double *lerr, int *edge_w) {
double s = 0, sat = 0; int n = 0; *lerr = 0;
int y0 = s_cap_h / 3, y1 = 2 * s_cap_h / 3;
for (int y = y0; y < y1; y += 3)
for (int x = s_cap_w / 8 + 4; x < s_cap_w * 55 / 100; x++) {
const uint8_t *p = c + ((size_t)y * s_cap_w + x) * 3;
s += fabs(cb(p + 3) - cb(p)); n++;
int mx = p[0] > p[1] ? (p[0] > p[2] ? p[0] : p[2]) : (p[1] > p[2] ? p[1] : p[2]);
int mn = p[0] < p[1] ? (p[0] < p[2] ? p[0] : p[2]) : (p[1] < p[2] ? p[1] : p[2]);
sat += mx - mn;
double e = fabs(luma(p) - 127.5);
if (e > *lerr) *lerr = e;
}
*step = s / n;
s_mean_sat = sat / n;
int w = 0;
const int y = s_cap_h / 2;
for (int x = s_cap_w * 125 / 160; x < s_cap_w * 155 / 160; x++) {
double l = luma(c + ((size_t)y * s_cap_w + x) * 3);
w += l > 60 && l < 195;
}
*edge_w = w;
}
int main(void) {
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("FMV chroma 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; }
frame_src();
check(gl_renderer_fmv_chroma_smoothing() == 0, "off by default");
uint8_t *off = present(1);
gl_renderer_set_fmv_chroma_smoothing(1);
uint8_t *not24 = present(0);
const size_t bytes = (size_t)s_cap_w * s_cap_h * 3;
check(memcmp(off, not24, bytes) == 0, "a 15-bit present is untouched");
check(gl_renderer_fmv_chroma_frames() == 0, "only 24-bit presents are smoothed");
uint8_t *on = present(1);
check(gl_renderer_fmv_chroma_frames() == 1, "24-bit present smoothed");
double s_off, s_on, l_off, l_on; int e_off, e_on;
stats(off, &s_off, &l_off, &e_off);
const double sat_off = s_mean_sat;
stats(on, &s_on, &l_on, &e_on);
const double sat_on = s_mean_sat;
printf("mean saturation off=%.1f on=%.1f\n", sat_off, sat_on);
printf("drawable=%dx%d cb_step off=%.2f on=%.2f luma_err off=%.1f on=%.1f edge_w off=%d on=%d\n",
s_cap_w, s_cap_h, s_off, s_on, l_off, l_on, e_off, e_on);
check(s_on < s_off / 3.0, "chroma block steps shrink");
/* The blocks alternate between two hues, so the tent's average keeps a
* known fraction of the colour: never grey. A flat-chroma frame keeps
* its saturation exactly (below). */
check(sat_on > 2.0, "colour is not lost");
check(l_on < 6.0, "luma kept");
check(e_on <= e_off + 1, "luma edge stays as sharp");
/* Uniform chroma inside letterbox bars: smoothing must keep the colour
* exactly (within rounding). A tent that lost the source size would
* sample the black corner and turn the movie grey. */
/* A letterboxed movie: black bars around a uniform colour. */
for (int y = 0; y < SH; y++)
for (int x = 0; x < SW; x++)
src[y * SW + x] = (y < 12 || y >= SH - 12) ? rgb(0.0, 0.0, 0.0) : rgb(0.45, 0.12, -0.10);
uint8_t *flat_on = present(1);
gl_renderer_set_fmv_chroma_smoothing(0);
uint8_t *flat_off = present(1);
int worst = 0;
/* Away from the bars and the image's outer columns (the tent
* legitimately mixes at an edge). */
for (int y = 3 * s_cap_h / 8; y < 5 * s_cap_h / 8; y++)
for (int x = 3 * 8; x < (s_cap_w - 8) * 3; x++) {
size_t i = (size_t)y * s_cap_w * 3 + x;
int d = abs((int)flat_on[i] - (int)flat_off[i]);
if (d > worst) worst = d;
}
printf("flat chroma worst channel delta=%d\n", worst);
check(worst <= 2, "uniform chroma kept exactly");
free(flat_on); free(flat_off);
frame_src();
uint8_t *off2 = present(1);
check(memcmp(off, off2, bytes) == 0, "off again is byte-identical");
free(off); free(not24); free(on); free(off2);
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;
}