From 0e3c53720b83563c4532d20f144eecae6fe0e22c Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:09:21 -0700 Subject: [PATCH 1/3] GL: opt-in accurate blending + PS1 blend/mask accuracy test Verifies the four PS1 semi-transparency modes, set-mask, mask check and texel STP at 4x internal resolution with PGXP depth on and off, directly and on the render thread, against the PS1 formulas and the software rasterizer (new gl_blend_accuracy_test). Finding: with the historical surface bases (fills and texels stored as k*255/31, everything else as k*8) single-layer blends are off by one 5-bit step in places (1312 of the test's checked pixels, e.g. B+F/4 over a fill). [video] accurate_blending = true stores every VRAM write as k*8 so GL's 8-bit blend truncated by the readback is exactly the PS1 arithmetic; whites of fills/texels then present as 248 like uploads already do. Default off. Co-Authored-By: Claude Opus 5.5 --- docs/TCP_COMMANDS.md | 5 +- docs/config_schema.md | 24 +++ generated/OpenBIOS.emitter.sha | 2 +- recompiler/src/config_loader.cpp | 2 + recompiler/src/config_loader.h | 4 + runtime/CMakeLists.txt | 13 ++ runtime/include/gpu_gl_renderer.h | 6 + runtime/src/debug_server.c | 10 ++ runtime/src/gpu_gl_renderer.c | 64 +++++-- runtime/src/main.cpp | 4 + runtime/tests/test_gl_blend_accuracy.c | 237 +++++++++++++++++++++++++ 11 files changed, 358 insertions(+), 13 deletions(-) create mode 100644 runtime/tests/test_gl_blend_accuracy.c diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index a2cf15bb..86f079b3 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -486,15 +486,16 @@ between Play and TCP availability. ## Complete command index (generated) -**367 commands registered** — 354 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**368 commands registered** — 355 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -82 of 367 have prose above; **285 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +82 of 368 have prose above; **286 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` 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. | Command | Native | Beetle | Described above | |---|:--:|:--:|:--:| | `a0_history` | ✓ | | | +| `accurate_blending` | ✓ | | | | `audio_events` | ✓ | ✓ | | | `audio_stats` | ✓ | ✓ | | | `audio_wav` | ✓ | ✓ | | diff --git a/docs/config_schema.md b/docs/config_schema.md index cd9141f3..ce35ed30 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -698,6 +698,30 @@ keep their exact texels, and `off` is the historical path. Cost is up to 64 fetches per minified pixel. OpenGL only. `PSX_TEXTURE_LOD=0|1` and `PSX_ANISO=N` override them for one run; the `texture_lod` TCP command switches them live. +### Accurate blending (`accurate_blending`) + +```toml +[video] +accurate_blending = false # default +``` + +The OpenGL surface holds each 5-bit VRAM channel as an 8-bit value. Uploads, +copies and untextured primitives store `k*8`, but fills and texels have +always stored `k*255/31` (full-range white). Semi-transparent blending of two +operands in different bases, truncated back to 5 bits when the game reads +VRAM, is off by one step in places: B+F/4 over a fill, B/2+F/2 with an odd +sum, B+F/4 of a texel. `true` makes every VRAM write use `k*8`, so GL's +8-bit blend followed by the readback's `>>3` is exactly the PS1's 5-bit +arithmetic in all four modes (B/2+F/2, B+F, B-F, B+F/4), with set-mask, +mask check, texel STP and PGXP depth, at any internal scale (checked against +the formulas and the software rasterizer by `gl_blend_accuracy_test`). The +visible cost: textured and filled whites present as 248 instead of 255, as +uploaded images and untextured primitives already do. One divergence +remains in both settings: a second semi-transparent layer blends with the +first layer's full 8-bit result where the console would have truncated it to +5 bits first (at most one 5-bit step). OpenGL only. +`PSX_ACCURATE_BLENDING=0|1` overrides it for one run and the +`accurate_blending` TCP command switches it live. The runtime clamps N per backend: diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index f361b1b8..dbd9a359 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -27437f9269d367ba4ad2e17fc66f3523c2c1e6fcd2ee5a39f6c07ee6516c84a2 \ No newline at end of file +118a3aa7d4bcfed96cb1cd127f31ad2d0e62b167c4caa98f02ae685e66c51c0e diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 85fcf467..c7574499 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -835,6 +835,8 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& "[video] anisotropic_filtering must be 1..16: {}", n)); rt.video_anisotropic_filtering = n; } + if (video.contains("accurate_blending")) + rt.video_accurate_blending = toml::find(video, "accurate_blending"); if (video.contains("pgxp_depth_buffer")) rt.video_pgxp_depth_buffer = toml::find(video, "pgxp_depth_buffer"); if (video.contains("pgxp_color_correction")) diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 4e3461c1..d7b37237 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -573,6 +573,10 @@ struct RuntimeConfig { // default 1); only with texture_lod. OpenGL only. int video_texture_lod = 0; int video_anisotropic_filtering = 1; + // accurate_blending: one 8-bit basis (k*8) for every VRAM write so GL + // semi-transparency truncates exactly like the PS1's 5-bit blend. + // Default false (historical: fills/texels full-range). OpenGL only. + bool video_accurate_blending = false; bool video_pgxp_color_correction = false; int video_pgxp_seam = 0; double video_pgxp_depth_threshold = 4096.0; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index ab2abb6b..87caad01 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -322,6 +322,19 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-texture-lod-results") set_tests_properties(gl_texture_lod_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) + # PS1 semi-transparency modes and mask bit at 4x with PGXP depth and + # the render thread, against the formulas and the software reference + # ([video] accurate_blending on; off is measured and printed). + add_test(NAME gl_blend_accuracy_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_blend_accuracy.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-blend-accuracy-results") + set_tests_properties(gl_blend_accuracy_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) diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 0b2eabbe..8a7150d0 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -297,6 +297,12 @@ void gl_renderer_set_pgxp_depth(int on); void gl_renderer_set_texture_lod(int mode, int aniso); int gl_renderer_texture_lod(void); int gl_renderer_anisotropy(void); +/* [video] accurate_blending: store every VRAM write in the k*8/255 basis so + * 8-bit GL blending truncated to 5 bits is exactly the PS1's semi-transparency + * arithmetic (whites of fills and textures become 248 instead of 255, as + * uploads and untextured primitives already are). Off by default. */ +void gl_renderer_set_accurate_blending(int on); +int gl_renderer_accurate_blending(void); int gl_renderer_get_pgxp_depth(void); void gl_renderer_set_pgxp_color_perspective(int on); int gl_renderer_get_pgxp_color_perspective(void); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 391248c4..461d8713 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8629,6 +8629,15 @@ static void handle_texture_lod(int id, const char *json) gl_renderer_texture_lod(), gl_renderer_anisotropy()); } +/* accurate_blending: {"cmd":"accurate_blending"} reads, {"cmd": + * "accurate_blending","on":0|1} sets [video] accurate_blending live. */ +static void handle_accurate_blending(int id, const char *json) +{ + int on = json_get_int(json, "on", -1); + if (on >= 0) gl_renderer_set_accurate_blending(on); + send_fmt("{\"id\":%d,\"ok\":true,\"on\":%d}", id, gl_renderer_accurate_blending()); +} + static void handle_frame_perf(int id, const char *json) { (void)json; @@ -15854,6 +15863,7 @@ static const CmdEntry s_commands[] = { { "post_aa", handle_post_aa }, { "fmv_chroma", handle_fmv_chroma }, { "texture_lod", handle_texture_lod }, + { "accurate_blending", handle_accurate_blending }, { "gl_ws_ablate", handle_gl_ws_ablate }, { "gl_interp", handle_gl_interp }, { "render_pass_stats", handle_render_pass_stats }, diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index d05df396..817062b0 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -902,6 +902,23 @@ static int s_tex_filter = 0; /* [video] texture_lod (0 off, 1 mipmap emulation) and anisotropic_filtering * (taps along the footprint's major axis, 1..16). */ static int s_tex_lod = 0, s_tex_aniso = 1; +/* [video] accurate_blending. The FBO holds 5-bit channels as 8-bit values: + * uploads, copies and untextured primitives already store k*8, but fills and + * texels have stored k*255/31 (full-range white). Blending two operands in + * different bases and truncating to 5 bits at readback is off by one in + * places (e.g. B+F/4, B/2+F/2 with odd sums). On, every VRAM write uses + * k*8, so GL's 8-bit blend followed by >>3 is exactly the PS1's 5-bit + * arithmetic. Off (default) keeps the historical bases. */ +static int s_accurate_blend = 0; +static void c5_to_float(uint16_t c, float *r, float *g, float *b) { + if (s_accurate_blend) { + *r = (float)((c & 0x1F) << 3) / 255.0f; + *g = (float)(((c >> 5) & 0x1F) << 3) / 255.0f; + *b = (float)(((c >> 10) & 0x1F) << 3) / 255.0f; + } else { + *r = (c & 0x1F) / 31.0f; *g = ((c >> 5) & 0x1F) / 31.0f; *b = ((c >> 10) & 0x1F) / 31.0f; + } +} /* Opaque textured draws carry the exact mask bit in FBO alpha. Keeping the * duplicate stencil copy current is deferred until mask checking is requested. */ static int s_stencil_valid = 1; @@ -1955,8 +1972,10 @@ static const char *TEX_FS = " }\n" " return vram_at(v_tpage.x + u, v_tpage.y + v);\n" "}\n" + "uniform int u_accurate; /* [video] accurate_blending: VRAM in the k*8/255 basis */\n" "vec3 col5(int raw){\n" - " return vec3(float(raw & 31), float((raw >> 5) & 31), float((raw >> 10) & 31)) / 31.0;\n" + " vec3 k = vec3(float(raw & 31), float((raw >> 5) & 31), float((raw >> 10) & 31));\n" + " return u_accurate != 0 ? k * (8.0 / 255.0) : k / 31.0;\n" "}\n" "int stable_texel(ivec2 p){\n" " /* Clamp before wrap, so a footprint left of u=0 cannot pick up u=255. */\n" @@ -2240,9 +2259,18 @@ static void apply_psx_blend(int mode) { p_glBlendEquationSeparate((mode & 3) == 2 ? PSXGL_FUNC_REVERSE_SUBTRACT : PSXGL_FUNC_ADD, PSXGL_FUNC_ADD); + /* [video] accurate_blending: drivers quantise the constant blend colour + * to the target's 8-bit unorm (NVIDIA: 0.5 -> 127/255, 0.25 -> 63/255), + * which puts B/2+F/2 and B+F/4 one 8-bit step under a k*8 boundary and + * the >>3 readback one 5-bit step low. 128/255 and 64/255 are exact in + * unorm8 and bias every result up by less than one 8-bit step, which + * stays inside the PS1's truncation bucket for all 5-bit operands, so the + * result is the same on drivers that blend in float and in fixed point. */ + const float half = s_accurate_blend ? 128.0f / 255.0f : 0.5f; + const float quarter = s_accurate_blend ? 64.0f / 255.0f : 0.25f; switch (mode & 3) { case 0: - p_glBlendColor(0.5f, 0.5f, 0.5f, 0.5f); + p_glBlendColor(half, half, half, half); p_glBlendFuncSeparate(PSXGL_CONSTANT_ALPHA, PSXGL_CONSTANT_ALPHA, GL_ONE, GL_ZERO); break; case 1: @@ -2250,7 +2278,7 @@ static void apply_psx_blend(int mode) { p_glBlendFuncSeparate(GL_ONE, GL_ONE, GL_ONE, GL_ZERO); break; case 3: - p_glBlendColor(0.25f, 0.25f, 0.25f, 0.25f); + p_glBlendColor(quarter, quarter, quarter, quarter); p_glBlendFuncSeparate(PSXGL_CONSTANT_ALPHA, GL_ONE, GL_ONE, GL_ZERO); break; } @@ -4808,7 +4836,8 @@ static void gpu_fill(int x,int y,int w,int h,uint16_t c) { flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); - float r=(c&0x1F)/31.0f, g=((c>>5)&0x1F)/31.0f, b=((c>>10)&0x1F)/31.0f; + float r, g, b; + c5_to_float(c, &r, &g, &b); x &= VRAM_W - 1; y &= VRAM_H - 1; if (w > VRAM_W) w = VRAM_W; if (h > VRAM_H) h = VRAM_H; @@ -4941,6 +4970,21 @@ void gl_renderer_set_texture_lod(int mode, int aniso) { } int gl_renderer_texture_lod(void) { return s_tex_lod; } int gl_renderer_anisotropy(void) { return s_tex_aniso; } +static GLint s_uAccurate = -1; +static void accurate_apply_uniforms(void) { + if (!s_tex_prog) return; + GLint prev = 0; glGetIntegerv(GL_CURRENT_PROGRAM, &prev); + if (s_uAccurate < 0) s_uAccurate = p_glGetUniformLocation(s_tex_prog, "u_accurate"); + p_glUseProgram(s_tex_prog); p_glUniform1i(s_uAccurate, s_accurate_blend); + p_glUseProgram((GLuint)prev); +} +void gl_renderer_set_accurate_blending(int on) { + GL_RT_SYNC("accurate_blending"); + if (s_ctx) { flush_flat_batch(); flush_tex_batch(); hiw_flush_queue(); } + s_accurate_blend = on ? 1 : 0; + if (s_ctx) accurate_apply_uniforms(); +} +int gl_renderer_accurate_blending(void) { return s_accurate_blend; } static void glb_set_texture_filter(int b) { s_tex_filter = b >= 0 && b <= 2 ? b : 0; sw_set_texture_filter(b != 0); } static int glb_texture_filter(void) { return s_tex_filter; } @@ -5806,6 +5850,8 @@ static int init_gpu_raster(void) { s_uFilter = p_glGetUniformLocation(s_tex_prog, "u_filter"); s_uAniso = -1; lod_apply_uniforms(); + s_uAccurate = -1; + accurate_apply_uniforms(); s_uLimits = p_glGetUniformLocation(s_tex_prog, "u_limits"); s_uHdTexture = p_glGetUniformLocation(s_tex_prog, "u_hd_texture"); s_uBlitSrc = p_glGetUniformLocation(s_blit_prog, "u_src"); @@ -6779,9 +6825,8 @@ static void glb_wide_clear(int base_x, int y, int h, uint16_t color) { if (y0 < 0) y0 = 0; if (y1 > H) y1 = H; if (y1 <= y0) return; - float r = (color & 0x1F) / 31.0f; - float g = ((color >> 5) & 0x1F) / 31.0f; - float b = ((color >> 10) & 0x1F) / 31.0f; + float r, g, b; + c5_to_float(color, &r, &g, &b); float a = (color >> 15) & 1 ? 1.0f : 0.0f; p_glBindFramebuffer(PSXGL_FRAMEBUFFER, fbo); glViewport(0, 0, g_wide_w * s_out_scale, H); @@ -6819,9 +6864,8 @@ static void glb_wide_clear_margins(int base_x, int y, int h, uint16_t color, int s_cw_wide_ms += cw_ms() - t0; return; } - float r = (color & 0x1F) / 31.0f; - float g = ((color >> 5) & 0x1F) / 31.0f; - float b = ((color >> 10) & 0x1F) / 31.0f; + float r, g, b; + c5_to_float(color, &r, &g, &b); float a = (color >> 15) & 1 ? 1.0f : 0.0f; p_glBindFramebuffer(PSXGL_FRAMEBUFFER, fbo); glViewport(0, 0, W, H); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index e74a632f..68bddf3a 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1283,6 +1283,7 @@ static int g_video_pgxp_depth_buffer = 0; static int g_video_fmv_chroma = 0; /* [video] fmv_chroma_smoothing / PSX_FMV_CHROMA */ /* [video] texture_lod / anisotropic_filtering; PSX_TEXTURE_LOD / PSX_ANISO. */ static int g_video_texture_lod = 0, g_video_aniso = 1; +static int g_video_accurate_blending = 0; /* [video] accurate_blending / PSX_ACCURATE_BLENDING */ static int g_video_pgxp_color_correction = 0; static int g_video_pgxp_seam = 0; static float g_video_pgxp_depth_threshold = 4096.0f; @@ -16068,6 +16069,7 @@ int main(int argc, char** argv) { g_video_fmv_chroma = gc.runtime.video_fmv_chroma_smoothing ? 1 : 0; g_video_texture_lod = gc.runtime.video_texture_lod; g_video_aniso = gc.runtime.video_anisotropic_filtering; + g_video_accurate_blending = gc.runtime.video_accurate_blending ? 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; @@ -18459,6 +18461,8 @@ session_reboot: if (const char* e = std::getenv("PSX_TEXTURE_LOD")) g_video_texture_lod = std::atoi(e); if (const char* e = std::getenv("PSX_ANISO")) g_video_aniso = std::atoi(e); gl_renderer_set_texture_lod(g_video_texture_lod, g_video_aniso); + if (const char* e = std::getenv("PSX_ACCURATE_BLENDING")) g_video_accurate_blending = (*e && *e != '0'); + gl_renderer_set_accurate_blending(g_video_accurate_blending); 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); diff --git a/runtime/tests/test_gl_blend_accuracy.c b/runtime/tests/test_gl_blend_accuracy.c new file mode 100644 index 00000000..45509c45 --- /dev/null +++ b/runtime/tests/test_gl_blend_accuracy.c @@ -0,0 +1,237 @@ +/* PS1 semi-transparency and mask-bit accuracy of the OpenGL renderer at + * internal resolution with PGXP depth, on a real, hidden OpenGL context. + * + * The same scene goes through the renderer facade twice: once on the + * software rasterizer (the reference) and once on OpenGL at 4x internal + * resolution, with PGXP depth off and on, directly and on the render thread. + * Every mode (0: B/2+F/2, 1: B+F, 2: B-F, 3: B+F/4) is drawn over a ramp of + * background colours, as flat rects, depth-tagged flat triangles, raw + * textured rects whose texels mix STP=1 (blended), STP=0 (opaque) and 0 + * (transparent), and modulated textured triangles; then the mask bit: rects + * drawn with set-mask, and semi-transparent draws over them with mask check. + * + * With [video] accurate_blending (every VRAM write in one 8-bit basis) the + * following must hold; with it off (the historical bases) the mismatch count + * is printed for the record. + * Checked: the native VRAM a game reads back equals the software reference + * exactly in every configuration, and the flat rects also equal the PS1 + * formula computed here (an oracle independent of both renderers): per 5-bit + * channel, mode 0 = (B+F)>>1, 1 = min(B+F,31), 2 = max(B-F,0), + * 3 = min(B+(F>>2),31); mask bit = set-mask OR source STP; a masked + * destination is never written while check is on. + * Original source-owned scene; no retail payload. */ +#define main rt_fixture_main +#include "test_gl_render_thread.c" +#undef main + +#define TP 0x0108 /* 15-bit page at (512,0) */ +static const uint16_t BG[8] = {0x0000, 0x7fff, 0x4210, 0x001f, 0x03e0, 0x7c00, 0x1ce7, 0x6318}; +static const uint16_t FG[4] = {0x7fff, 0x2d6b, 0x4210, 0x0c63}; + +static void page(void) { + static uint16_t pg[16 * 16]; + for (int y = 0; y < 16; y++) + for (int x = 0; x < 16; x++) { + uint16_t c = (uint16_t)(0x0421 * (1 + ((x + 2 * y) % 30))); + int k = (x + y) % 4; + pg[y * 16 + x] = k == 0 ? 0 : k == 1 ? (uint16_t)(c | 0x8000) : c; + } + cpu_upload(512, 0, 16, 16, pg); +} +static void scene(int depth) { + gr_set_draw_area(0, 0, 319, 239); + gr_set_draw_offset(0, 0); + mask_bits(0, 0); + gr_set_semi_transparency(0, 0); + for (int i = 0; i < 8; i++) gr_fill_rect(0, i * 30, 320, 30, BG[i]); + for (int m = 0; m < 4; m++) { + int x = m * 80; + gr_set_semi_transparency(1, m); + gr_draw_flat_rect(x, 0, 16, 240, FG[m]); + if (depth) gr_set_depth_triangle(1, 700.0f + m, 700.0f + m, 700.0f + m); + gr_draw_flat_triangle(x + 18, 2, x + 34, 2, x + 18, 236, FG[(m + 1) & 3]); + gr_set_color_modulation(128, 128, 128, 1); + for (int y = 0; y < 240; y += 16) gr_draw_textured_rect(x + 36, y, 16, 16, 0, 0, 0, 0, TP); + gr_set_color_modulation(128, 128, 128, 0); + if (depth) gr_set_depth_triangle(1, 650.0f, 650.0f, 650.0f); + gr_draw_shaded_textured_triangle(x + 54, 0, 0, 0, 0x6080a0, x + 78, 0, 15, 0, 0xa08060, + x + 54, 239, 0, 15, 0x806080, 0, 0, TP, 1); + } + /* Mask: set-mask rects, then every mode over them with check on. */ + gr_set_semi_transparency(0, 0); + mask_bits(1, 0); + for (int m = 0; m < 4; m++) gr_draw_flat_rect(m * 80 + 4, 100, 8, 8, 0x1234); + mask_bits(0, 1); + for (int m = 0; m < 4; m++) { + gr_set_semi_transparency(1, m); + gr_draw_flat_rect(m * 80, 96, 16, 16, 0x7fff); + } + mask_bits(1, 0); /* set-mask with semi: result carries bit 15 */ + for (int m = 0; m < 4; m++) { + gr_set_semi_transparency(1, m); + gr_draw_flat_rect(m * 80, 200, 16, 8, FG[m]); + } + mask_bits(0, 0); + gr_set_semi_transparency(0, 0); + gl_renderer_render_thread_frame_boundary(); +} +static int ch(uint16_t p, int c) { return (p >> (5 * c)) & 31; } +static uint16_t blend(uint16_t b, uint16_t f, int m) { + uint16_t o = 0; + for (int c = 0; c < 3; c++) { + int B = ch(b, c), F = ch(f, c), r; + if (m == 0) r = (B + F) >> 1; + else if (m == 1) r = B + F > 31 ? 31 : B + F; + else if (m == 2) r = B - F < 0 ? 0 : B - F; + else r = B + (F >> 2) > 31 ? 31 : B + (F >> 2); + o |= (uint16_t)(r << (5 * c)); + } + return o; +} +static int near1(uint16_t a, uint16_t b) { + for (int c = 0; c < 3; c++) if (abs(ch(a, c) - ch(b, c)) > 1) return 0; + return (a & 0x8000) == (b & 0x8000); +} +/* Single-layer blends must be exact. Rows 96..111 and 200..207 stack a + * second semi-transparent layer on a blended one: the hardware truncates the + * first result to 5 bits before the second blend, while the 8-bit surface + * keeps the first result's extra precision (a fixed-function blend cannot + * truncate), so those rows may differ by one 5-bit step per channel. */ +static long oracle_errors(const uint16_t *v) { + long bad = 0; + for (int m = 0; m < 4; m++) + for (int y = 0; y < 240; y++) + for (int x = m * 80; x < m * 80 + 16; x++) { + uint16_t got = v[y * 1024 + x], want; + int stacked = 0; + if (y >= 96 && y < 112) { /* masked square kept, rest blended white */ + int masked = x >= m * 80 + 4 && x < m * 80 + 12 && y >= 100 && y < 108; + uint16_t under = blend(BG[y / 30], FG[m], m); + want = masked ? 0x9234 : blend(under, 0x7fff, m); + stacked = !masked; + } else if (y >= 200 && y < 208) { + want = (uint16_t)(blend(blend(BG[y / 30], FG[m], m), FG[m], m) | 0x8000); + stacked = 1; + } else want = blend(BG[y / 30], FG[m], m); + const int ok = stacked ? near1(got, want) : got == want; + if (!ok && getenv("BLEND_VERBOSE") && bad < 12) + printf(" oracle (%d,%d) got=%04x want=%04x\n", x, y, got, want); + bad += !ok; + } + return bad; +} +/* Flat triangles: the GL and software edges differ by a pixel (documented + * coverage rules), so only the interior, one pixel in from every edge, is + * checked, against the formula. */ +static long oracle_tri_errors(const uint16_t *v) { + long bad = 0; + for (int m = 0; m < 4; m++) + for (int y = 4; y < 234; y++) { + if ((y >= 96 && y < 112) || (y >= 200 && y < 208)) continue; + const double span = 16.0 * (1.0 - (y - 2) / 234.0); + for (int lx = 1; lx + 1.5 < span; lx++) { + const uint16_t got = v[y * 1024 + m * 80 + 18 + lx]; + bad += got != blend(BG[y / 30], FG[(m + 1) & 3], m); + } + } + return bad; +} +/* The software rasterizer drops a texel's STP bit instead of writing it to + * bit 15 (the PS1 writes texel STP OR set-mask), so bit 15 is compared + * against the oracle on its own (stp_errors) and masked here. */ +/* Rects against the software reference: exact where a single layer is drawn + * from 5-bit operands (flat and raw-textured rects), within one step in the + * stacked rows. */ +static long diff(const uint16_t *a, const uint16_t *b) { + long n = 0; + for (int y = 0; y < 240; y++) for (int x = 0; x < 320; x++) { + const uint16_t p = a[y * 1024 + x] | 0x8000, q = b[y * 1024 + x] | 0x8000; + const int loose = (y >= 96 && y < 112) || (y >= 200 && y < 208) || (x % 80) >= 52; + if ((x % 80) >= 16 && ((x % 80) < 36 || (x % 80) >= 52)) + continue; /* triangles: edges differ by a pixel; see oracle_tri_errors */ + n += loose ? !near1(p, q) : p != q; + } + return n; +} +/* Raw textured rects: bit 15 of each written pixel is its texel's STP. */ +static long stp_errors(const uint16_t *v) { + long bad = 0; + for (int m = 0; m < 4; m++) + for (int y = 0; y < 240; y++) { + if (y >= 96 && y < 112) continue; + for (int tx = 0; tx < 16; tx++) { + int k = (tx + (y & 15)) % 4; + if (k == 0) continue; /* transparent texel: background stays */ + bad += ((v[y * 1024 + m * 80 + 36 + tx] >> 15) & 1) != (k == 1); + } + } + return bad; +} + +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 + static uint16_t ref[1024 * 512], out[1024 * 512]; + /* Software reference. */ + gr_set_backend(GR_BACKEND_SOFTWARE); + gr_init(vram); + page(); + scene(0); + gr_vram_transfer_out(0, 0, 1024, 512, ref); + const long ref_oracle = oracle_errors(ref); + printf("software reference: oracle mismatches=%ld\n", ref_oracle); + check(ref_oracle == 0, "software reference matches the PS1 formulas"); + + SDL_Window *win = SDL_CreateWindow("Blend accuracy hidden test", 0, 0, 320, 240, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + if (!win) { fprintf(stderr, "SKIP no window\n"); return 77; } + memset(vram, 0, sizeof vram); + gr_set_backend(GR_BACKEND_OPENGL); + gr_init(vram); + gr_set_scale(4); + gl_renderer_set_swap_interval(0); + if (!gl_renderer_init_context(win)) { fprintf(stderr, "SKIP no GL context\n"); return 77; } + page(); + /* Default bases (accurate_blending off): measured, not required. */ + scene(0); + gr_vram_transfer_out(0, 0, 1024, 512, out); + printf("gl 4x accurate_blending=0: oracle mismatches=%ld (historical bases)\n", oracle_errors(out)); + gl_renderer_set_accurate_blending(1); + for (int threaded = 0; threaded < 2; threaded++) + for (int depth = 0; depth < 2; depth++) { + if (threaded && depth == 0) check(gl_renderer_render_thread_start(2) == 1, "render thread started"); + gl_renderer_set_pgxp_depth(depth); + scene(depth); + if (threaded) gl_renderer_render_thread_sync("blend"); + gr_vram_transfer_out(0, 0, 1024, 512, out); + const long d = diff(out, ref), o = oracle_errors(out); + printf("gl 4x pgxp_depth=%d render_thread=%d: differs from software=%ld oracle mismatches=%ld\n", + depth, threaded, d, o); + check(o == 0, "GL matches the PS1 blend/mask formulas"); + check(oracle_tri_errors(out) == 0, "depth-tested triangle interiors match the formulas"); + check(stp_errors(out) == 0, "textured pixels carry their texel's STP in bit 15"); + check(d == 0, "GL native VRAM equals the software reference"); + if (d) { + int shown = 0; + for (int y = 0; y < 240 && shown < 8; y++) for (int x = 0; x < 320 && shown < 8; x++) + if ((out[y * 1024 + x] | 0x8000) != (ref[y * 1024 + x] | 0x8000) && (x % 80) < 52 && + ((x % 80) < 16 || (x % 80) >= 36) && + !((y >= 96 && y < 112) || (y >= 200 && y < 208))) { + printf(" (%d,%d) gl=%04x sw=%04x\n", x, y, out[y * 1024 + x], ref[y * 1024 + x]); + shown++; + } + } + } + gl_renderer_render_thread_stop(); + gl_renderer_set_pgxp_depth(0); + gl_renderer_set_accurate_blending(0); + 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; +} From 70c735944b7999b93f0a12ad50475ed0d0618222 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:32:16 -0700 Subject: [PATCH 2/3] GL accurate blending: unorm8-exact blend constants (NVIDIA), regen OpenBIOS stamp NVIDIA quantises the constant blend colour to the 8-bit target (0.5 -> 127/255), putting B/2+F/2 and B+F/4 one step under a k*8 boundary, so gl_blend_accuracy_test failed there (2016 mismatches). With accurate blending on, use 128/255 and 64/255: exact in unorm8, and the small upward bias stays inside the PS1 truncation bucket for every 5-bit operand, so fixed-point and float blenders agree. Passes on Apple M-series GL and an RTX 4080 SUPER (NVIDIA 610.88). Default path unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/config_schema.md | 2 +- generated/OpenBIOS.emitter.sha | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config_schema.md b/docs/config_schema.md index ce35ed30..0585d31b 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -713,7 +713,7 @@ VRAM, is off by one step in places: B+F/4 over a fill, B/2+F/2 with an odd sum, B+F/4 of a texel. `true` makes every VRAM write use `k*8`, so GL's 8-bit blend followed by the readback's `>>3` is exactly the PS1's 5-bit arithmetic in all four modes (B/2+F/2, B+F, B-F, B+F/4), with set-mask, -mask check, texel STP and PGXP depth, at any internal scale (checked against +mask check, texel STP and PGXP depth, at any internal scale and on drivers that quantise the blend constant to 8 bits (it uses 128/255 and 64/255, exact in unorm8, instead of 0.5 and 0.25) (checked against the formulas and the software rasterizer by `gl_blend_accuracy_test`). The visible cost: textured and filled whites present as 248 instead of 255, as uploaded images and untextured primitives already do. One divergence diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index dbd9a359..b7f70d5c 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -118a3aa7d4bcfed96cb1cd127f31ad2d0e62b167c4caa98f02ae685e66c51c0e +83f82f8b2daed07e75765c7ae4adb78cdb1a9fd7396fb879089c2352c8dc78a6 From 75dc9e84a785761cd799cac4a3d44472c4c4e881 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:45:01 -0700 Subject: [PATCH 3/3] CMake: register the facade GL fixtures where their SDL include and target exist gl_texture_lod_test (#585) and gl_blend_accuracy_test sat in the SDL3::SDL3-static block after _gl_scale_sdl_inc was unset, so they got an empty --sdl-include and were not registered at all where the static target is absent. Move them into the gl_scale_invariance block that defines both. Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 52 +++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 87caad01..59147897 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -262,6 +262,32 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-frame-gen-results") set_tests_properties(gl_frame_gen_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300 SKIP_RETURN_CODE 77) + # [video] texture_lod / anisotropic_filtering: off and untracked/ + # magnified prims are unchanged; minified world textures average + # inside their UV bounds; render thread and PGXP depth agree. + add_test(NAME gl_texture_lod_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_texture_lod.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-texture-lod-results") + set_tests_properties(gl_texture_lod_test PROPERTIES + LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) + # PS1 semi-transparency modes and mask bit at 4x with PGXP depth and + # the render thread, against the formulas and the software reference + # ([video] accurate_blending on; off is measured and printed). + add_test(NAME gl_blend_accuracy_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_blend_accuracy.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-blend-accuracy-results") + set_tests_properties(gl_blend_accuracy_test PROPERTIES + LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) unset(_gl_scale_sdl_inc) else() message(STATUS "gl_scale_invariance_test: not registered (needs macOS, Linux or MinGW, Python 3 and SDL3)") @@ -309,32 +335,6 @@ if(BUILD_TESTING) --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) - # [video] texture_lod / anisotropic_filtering: off and untracked/ - # magnified prims are unchanged; minified world textures average - # inside their UV bounds; render thread and PGXP depth agree. - add_test(NAME gl_texture_lod_test - COMMAND "${Python3_EXECUTABLE}" - "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single - --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_texture_lod.c" - --cc "${CMAKE_C_COMPILER}" - --sdl-include "${_gl_scale_sdl_inc}" - --sdl-library "$" - --output "${CMAKE_CURRENT_BINARY_DIR}/gl-texture-lod-results") - set_tests_properties(gl_texture_lod_test PROPERTIES - LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) - # PS1 semi-transparency modes and mask bit at 4x with PGXP depth and - # the render thread, against the formulas and the software reference - # ([video] accurate_blending on; off is measured and printed). - add_test(NAME gl_blend_accuracy_test - COMMAND "${Python3_EXECUTABLE}" - "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single - --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_blend_accuracy.c" - --cc "${CMAKE_C_COMPILER}" - --sdl-include "${_gl_scale_sdl_inc}" - --sdl-library "$" - --output "${CMAKE_CURRENT_BINARY_DIR}/gl-blend-accuracy-results") - set_tests_properties(gl_blend_accuracy_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)