From b55d92124dce6c5aaabc310a8a4911331dd11ffd Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:03:49 -0700 Subject: [PATCH 1/3] GL: opt-in xBR edge-directed texture filter (texture_filtering = "xbr") Filter mode 3 magnifies textures with an edge-directed rule after Hyllian's xBR (written from the published description, no code reused): for the texel corner nearest a sample, weighted YUV colour distances over a 5x5 neighbourhood decide whether an edge runs along the corner's diagonal; if so the corner is cut with an anti-aliased boundary and takes the closer side neighbour. Flat areas, straight edges, gradients, cutouts and 1:1 draws stay nearest. Selectable from [video] texture_filtering, settings.toml, PSX_TEXTURE_FILTER and the new texture_filter TCP command. Default unchanged. Co-Authored-By: Claude Opus 5.5 --- THIRD_PARTY_ATTRIBUTION.md | 30 ++++++++ docs/TCP_COMMANDS.md | 5 +- docs/config_schema.md | 22 ++++++ generated/OpenBIOS.emitter.sha | 2 +- recompiler/src/config_loader.cpp | 6 +- recompiler/src/config_loader.h | 1 + runtime/CMakeLists.txt | 12 +++ runtime/src/debug_server.c | 10 +++ runtime/src/gpu_gl_renderer.c | 51 ++++++++++++- runtime/src/main.cpp | 5 +- runtime/tests/test_gl_xbr.c | 122 +++++++++++++++++++++++++++++++ 11 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 runtime/tests/test_gl_xbr.c diff --git a/THIRD_PARTY_ATTRIBUTION.md b/THIRD_PARTY_ATTRIBUTION.md index ccbe9a2c..64ee340a 100644 --- a/THIRD_PARTY_ATTRIBUTION.md +++ b/THIRD_PARTY_ATTRIBUTION.md @@ -58,6 +58,36 @@ into `licenses/`. Developers can use the verified archive or an explicit `FETCHCONTENT_SOURCE_DIR_PSX_LIBWEBP` / `PSX_LIBWEBP_SOURCE_DIR` source override. PNG and JPEG continue to use the existing shared stb_image implementation. +## xBR — edge-directed texture filter (Hyllian) + +The `texture_filtering = "xbr"` mode in `runtime/src/gpu_gl_renderer.c` +(`xbr_d`, `xbr_sample`) follows Hyllian's xBR algorithm: its 5x5 neighbourhood, +YUV-weighted colour distance and the `4*d(H,F)` / `4*d(E,I)` edge sums. It is +an adaptation (CLUT texel fetches, primitive UV clamping, an anti-aliased corner +cut), not a copy of a shader file, but it derives from that work, licensed MIT: + +``` +Copyright (C) 2011-2016 Hyllian - sergiogdb@gmail.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + ## Vendored libraries These are checked in under `recompiler/lib/` and `runtime/third_party/` with diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index a2cf15bb..2cde629f 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -486,9 +486,9 @@ 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. @@ -799,6 +799,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `stereo_dump` | ✓ | | | | `stereo_stats` | ✓ | | | | `synth_recurse` | ✓ | | | +| `texture_filter` | ✓ | | | | `texture_lod` | ✓ | | ✓ | | `thread_ctx_ring` | ✓ | | | | `thread_trace` | ✓ | | | diff --git a/docs/config_schema.md b/docs/config_schema.md index cd9141f3..9d262db7 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -698,6 +698,28 @@ 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. +### Texture filtering (`texture_filtering`) + +```toml +[video] +texture_filtering = "nearest" # nearest (default) | bilinear | xbr +``` + +`nearest` is the PS1's point sampling. `bilinear` blends the four nearest +texels (opacity-gated, so cutout edges keep their colour). `xbr` (OpenGL +only) is edge-directed magnification after Hyllian's xBR (MIT; see +`THIRD_PARTY_ATTRIBUTION.md`): for the texel +corner nearest each sample it compares weighted colour distances over a 5x5 +neighbourhood, and where an edge runs along that corner's diagonal the corner +is cut and takes the closer side neighbour, with an anti-aliased boundary one +output pixel wide. Flat areas, straight edges and gradients stay crisp +texels; diagonals and curves lose their staircase. It acts only where a +texel spans two or more output pixels (high internal scale or a magnified +texture), so a 1:1 draw is exactly nearest; cutouts and STP stay the centre +texel's. It costs up to 13 texel fetches per pixel. JINC2 is not offered. +`PSX_TEXTURE_FILTER=0..3` overrides it for one run (2 is the mod-only "stable +world" minification filter) and the `texture_filter` TCP command switches it +live. The launcher's toggle still offers nearest and bilinear. The runtime clamps N per backend: diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index f361b1b8..858e9e48 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -27437f9269d367ba4ad2e17fc66f3523c2c1e6fcd2ee5a39f6c07ee6516c84a2 \ No newline at end of file +35fe14259a39a6d39ecb53fc996bd16455934f90a805dd3062d14bf03cc028de diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 85fcf467..c1af57f5 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -776,8 +776,9 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& const auto mode = toml::find(video, "texture_filtering"); if (mode == "nearest") rt.video_texture_filter = 0; else if (mode == "bilinear") rt.video_texture_filter = 1; + else if (mode == "xbr") rt.video_texture_filter = 3; else throw std::runtime_error(fmt::format( - "[video] texture_filtering must be \"nearest\" or \"bilinear\": {}", mode)); + "[video] texture_filtering must be \"nearest\", \"bilinear\" or \"xbr\": {}", mode)); } if (video.contains("fmv_filter")) { const auto mode = toml::find(video, "fmv_filter"); @@ -2867,6 +2868,7 @@ UserSettings load_user_settings(const fs::path& path) { const auto m = toml::find(v, "texture_filtering"); if (m == "nearest") { s.texture_filter = 0; s.has_texture_filter = true; } else if (m == "bilinear") { s.texture_filter = 1; s.has_texture_filter = true; } + else if (m == "xbr") { s.texture_filter = 3; s.has_texture_filter = true; } }); if (v.contains("fmv_filter")) try_get([&]{ const auto m = toml::find(v, "fmv_filter"); @@ -3268,7 +3270,7 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { if (s.has_antialiasing) f << "antialiasing = " << (s.antialiasing ? "true" : "false") << "\n"; if (s.has_texture_filter) - f << "texture_filtering = \"" << (s.texture_filter ? "bilinear" : "nearest") << "\"\n"; + f << "texture_filtering = \"" << (s.texture_filter == 3 ? "xbr" : s.texture_filter ? "bilinear" : "nearest") << "\"\n"; if (s.has_fmv_filter) f << "fmv_filter = \"" << video_fmv_filter_name(s.fmv_filter) << "\"\n"; if (s.has_geometry_correction) diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 4e3461c1..eb8ac4ab 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -460,6 +460,7 @@ struct RuntimeConfig { int video_supersample_milli = 1000; // texture_filtering: "nearest" (default, native PSX look) | "bilinear" + // | "xbr" (3: edge-directed magnification, OpenGL only) // (smooths textures and 2D backgrounds). Stored as 0/1. int video_texture_filter = 0; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index ab2abb6b..40ef7653 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -322,6 +322,18 @@ 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) + # texture_filtering = "xbr": diagonals lose the staircase, straight + # edges, flat areas, cutouts and 1:1 draws stay nearest; RT agrees. + add_test(NAME gl_xbr_filter_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_xbr.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-xbr-results") + set_tests_properties(gl_xbr_filter_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/src/debug_server.c b/runtime/src/debug_server.c index 391248c4..0b5191d0 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()); } +/* texture_filter: {"cmd":"texture_filter"} reads, {"cmd":"texture_filter", + * "mode":0..3} sets it live (nearest, bilinear, stable world, xbr). */ +static void handle_texture_filter(int id, const char *json) +{ + int mode = json_get_int(json, "mode", -1); + if (mode >= 0) gr_set_texture_filter(mode); + send_fmt("{\"id\":%d,\"ok\":true,\"mode\":%d}", id, gr_texture_filter()); +} + 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 }, + { "texture_filter", handle_texture_filter }, { "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..9cd1fbae 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -1921,7 +1921,7 @@ static const char *TEX_FS = "uniform int u_semipass; /* 0=all texels, 1=STP=0 only, 2=STP=1 only */\n" "uniform int u_semimode; /* PS1 blend mode; drives dual-source factors */\n" "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" - "uniform int u_filter; /* bits 0-3: 0 nearest, 1 bilinear, 2 stable; bit 4: LOD */\n" + "uniform int u_filter; /* bits 0-3: 0 nearest, 1 bilinear, 2 stable, 3 xbr; bit 4: LOD */\n" "uniform int u_aniso; /* [video] anisotropic_filtering, 1..16 */\n" "uniform float u_shift;\n" "int vram_at(int x, int y){\n" @@ -2015,6 +2015,47 @@ static const char *TEX_FS = " }\n" " return sum.a > 0.0 ? sum.rgb / sum.a : base;\n" "}\n" + /* [video] texture_filtering = "xbr": edge-directed magnification after + * Hyllian's xBR (MIT; notice in THIRD_PARTY_ATTRIBUTION.md): the same + * E/F/H/I neighbourhood and 4x-weighted edge sums, adapted to CLUT + * texels and an anti-aliased corner cut. For the + * texel corner nearest the sample, two weighted colour-distance sums + * across a 5x5 neighbourhood decide whether an edge runs along the + * corner's anti-diagonal; if so, the corner is cut by that diagonal and + * takes the closer of the two side neighbours, with an anti-aliased + * boundary one output pixel wide. Smooth gradients and straight edges are + * left as nearest texels, so pixel art keeps its crisp blocks while + * diagonals and curves lose their staircase. Transparent (0) texels never + * supply a colour; cutout and STP stay the centre texel's. */ + "vec3 xbr_c(int raw){ return col5(raw); }\n" + "float xbr_d(int a, int b){\n" + " vec3 x = xbr_c(a), y = xbr_c(b);\n" + " vec3 dv = x - y;\n" + " float yy = dot(dv, vec3(0.299, 0.587, 0.114));\n" + " float u = dot(dv, vec3(-0.169, -0.331, 0.5)), v = dot(dv, vec3(0.5, -0.419, -0.081));\n" + " return 48.0*abs(yy) + 7.0*abs(u) + 6.0*abs(v) + (((a==0)!=(b==0)) ? 64.0 : 0.0);\n" + "}\n" + "int xbr_at(ivec2 e, ivec2 s, int x, int y){ return stable_texel(e + ivec2(x*s.x, y*s.y)); }\n" + "vec3 xbr_sample(vec2 uv, int e_raw, vec2 fw){\n" + " ivec2 e = ivec2(floor(uv)); vec2 f = fract(uv);\n" + " ivec2 s = ivec2(f.x < 0.5 ? -1 : 1, f.y < 0.5 ? -1 : 1);\n" + " vec2 l = vec2(s.x > 0 ? f.x : 1.0 - f.x, s.y > 0 ? f.y : 1.0 - f.y); /* 0.5..1 toward the corner */\n" + " int E=e_raw;\n" + " if (max(fw.x, fw.y) >= 0.5) return xbr_c(E); /* under 2x: stay nearest, no fetches */\n" + " int F=xbr_at(e,s,1,0), H=xbr_at(e,s,0,1);\n" + " if (E == F || E == H) return xbr_c(E);\n" + " int I=xbr_at(e,s,1,1);\n" + " int B=xbr_at(e,s,0,-1), C=xbr_at(e,s,1,-1), D=xbr_at(e,s,-1,0), G=xbr_at(e,s,-1,1);\n" + " int F4=xbr_at(e,s,2,0), I4=xbr_at(e,s,2,1), H5=xbr_at(e,s,0,2), I5=xbr_at(e,s,1,2);\n" + " float we = xbr_d(E,C)+xbr_d(E,G)+xbr_d(I,H5)+xbr_d(I,F4)+4.0*xbr_d(H,F);\n" + " float wi = xbr_d(H,D)+xbr_d(H,I5)+xbr_d(F,I4)+xbr_d(F,B)+4.0*xbr_d(E,I);\n" + " if (!(we < wi)) return xbr_c(E);\n" + " int px = xbr_d(E,F) <= xbr_d(E,H) ? F : H;\n" + " if (px == 0) return xbr_c(E);\n" + " float w = max(fw.x + fw.y, 1e-3);\n" + " float a = clamp((l.x + l.y - 1.5) / w + 0.5, 0.0, 1.0);\n" + " return mix(xbr_c(E), xbr_c(px), a);\n" + "}\n" "void main(){\n" " int stp; vec3 rgb;\n" " int fmode = u_filter & 15;\n" @@ -2044,6 +2085,12 @@ static const char *TEX_FS = " if (raw == 0) discard;\n" " rgb = col5(raw);\n" " stp = (raw >> 15) & 1;\n" + " } else if (fmode == 3) {\n" + " /* The nearest texel stays the PS1 one (floor of the unshifted uv). */\n" + " int raw = stable_texel(ivec2(floor(uv)));\n" + " if (raw == 0) discard;\n" + " stp = (raw >> 15) & 1;\n" + " rgb = xbr_sample(uv, raw, abs(dx) + abs(dy));\n" " } else if (fmode == 2) {\n" " uv+=vec2(u_shift); int raw=stable_texel(ivec2(floor(uv)));\n" " if(raw==0) discard; stp=(raw>>15)&1;\n" @@ -4941,7 +4988,7 @@ 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 void glb_set_texture_filter(int b) { s_tex_filter = b >= 0 && b <= 2 ? b : 0; sw_set_texture_filter(b != 0); } +static void glb_set_texture_filter(int b) { s_tex_filter = b >= 0 && b <= 3 ? b : 0; sw_set_texture_filter(b != 0); } static int glb_texture_filter(void) { return s_tex_filter; } static void glb_set_semi_transparency(int e, int m) { s_semi_en = e; s_semi_mode = m & 3; sw_set_semi_transparency(e, m); } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index e74a632f..da0e4172 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9489,7 +9489,7 @@ static int debug_toggles_on(void) { } static int s_dbg_scale_target = 0; static const char *dbg_texfilter_name(int f) { - return f == 1 ? "bilinear" : f == 2 ? "stable world" : "nearest"; + return f == 1 ? "bilinear" : f == 2 ? "stable world" : f == 3 ? "xbr" : "nearest"; } static void debug_toggle_summary(char *buf, size_t cap) { GlDynresStats st; gl_renderer_dynres_stats(&st); @@ -9545,7 +9545,7 @@ static int debug_toggle_key(int key, char *out, int cap) { on && !g_render_thread ? " (inert: render thread off)" : ""); break; } case SDLK_5: { - const int f = (gr_texture_filter() + 1) % 3; + const int f = (gr_texture_filter() + 1) % 4; gr_set_texture_filter(f); std::snprintf(msg, sizeof msg, "Texture filter: %s", dbg_texfilter_name(f)); break; } @@ -18417,6 +18417,7 @@ session_reboot: (net_cfg.enabled && s_netplay_gl_present && gl_renderer_cpu_auth_dual()); g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ + if (const char* e = std::getenv("PSX_TEXTURE_FILTER")) g_video_texfilter = std::atoi(e); gr_set_texture_filter(g_mod_texfilter < 0 ? g_video_texfilter : g_mod_texfilter); /* Sub-pixel vertex precision + perspective-correct UVs. Both default off; * with both off every setter below leaves the tracking caches disabled and diff --git a/runtime/tests/test_gl_xbr.c b/runtime/tests/test_gl_xbr.c new file mode 100644 index 00000000..5001c967 --- /dev/null +++ b/runtime/tests/test_gl_xbr.c @@ -0,0 +1,122 @@ +/* [video] texture_filtering = "xbr" on a real, hidden OpenGL context, driven + * through the renderer facade (gr_*). + * + * - a 45-degree red/blue edge magnified 8x loses its 8-pixel staircase: the + * boundary column in each row follows the diagonal closely; + * - straight edges and flat areas stay exactly the nearest-texel image, and + * a 1:1 (unmagnified) draw is byte-identical to nearest; + * - cutouts (texel 0) stay cutouts and never lend their colour; + * - the render thread gives the same pixels as the direct path; + * - the other filter modes are unchanged by the new one (nearest after xbr + * restores the exact image). + * Original source-owned scene; no retail payload. */ +#define main rt_fixture_main +#include "test_gl_render_thread.c" +#undef main + +static uint16_t nat[1024 * 512]; +#define TP 0x0108 /* 15-bit page at (512,0) */ + +static void pages(void) { + static uint16_t pg[32 * 16]; + for (int y = 0; y < 16; y++) + for (int x = 0; x < 32; x++) { + uint16_t c; + if (x < 16) c = x > y ? 0x001f : 0x7c00; /* diagonal edge */ + else if (x < 24) c = x < 20 ? 0x001f : 0x7c00; /* vertical edge */ + else c = (x == 28 && y == 8) ? 0x0000 : 0x03e0; /* green with a hole */ + pg[y * 32 + x] = c; + } + cpu_upload(512, 0, 32, 16, pg); +} +/* Each triangle carries GTE provenance (precise vertices), as world geometry + * does, so [video] texture_lod sets its LOD bit on these draws. */ +static void quad(int x, int y, int w, int h, int u0, int v0, int uw, int vh) { + gr_set_precise_triangle(1, x << 16, y << 16, (x + w) << 16, y << 16, x << 16, (y + h) << 16); + gr_draw_textured_triangle(x, y, u0, v0, x + w, y, u0 + uw, v0, x, y + h, u0, v0 + vh, 0, 0, TP); + gr_set_precise_triangle(1, (x + w) << 16, y << 16, (x + w) << 16, (y + h) << 16, x << 16, (y + h) << 16); + gr_draw_textured_triangle(x + w, y, u0 + uw, v0, x + w, y + h, u0 + uw, v0 + vh, x, y + h, u0, v0 + vh, 0, 0, TP); +} +static void scene(void) { + gr_set_draw_area(0, 0, 319, 239); + gr_set_draw_offset(0, 0); + mask_bits(0, 0); + gr_set_semi_transparency(0, 0); + gr_set_color_modulation(128, 128, 128, 1); + gr_fill_rect(0, 0, 320, 240, 0x1084); + quad(0, 0, 128, 128, 0, 0, 16, 16); /* diagonal, 8x */ + quad(140, 0, 64, 128, 16, 0, 8, 16); /* vertical edge, 8x */ + quad(210, 0, 64, 128, 24, 0, 8, 16); /* flat green with a hole, 8x */ + quad(0, 150, 32, 16, 0, 0, 32, 16); /* 1:1 */ + gl_renderer_render_thread_frame_boundary(); + gr_vram_transfer_out(0, 0, 1024, 512, nat); +} +/* Mean distance between each row's first red-dominant column and the ideal + * diagonal (x = y + 0.5 texel, in pixels). */ +static double stair_error(void) { + double e = 0; int n = 0; + for (int y = 4; y < 124; y++) { + int edge = 128; + for (int x = 0; x < 128; x++) { + uint16_t p = nat[y * 1024 + x]; + if ((p & 31) > ((p >> 10) & 31)) { edge = x; break; } + } + e += fabs((double)edge - ((double)y + 4.0)); n++; + } + return e / n; +} +static int region_equal(const uint16_t *a, const uint16_t *b, int x, int y, int w, int h) { + for (int r = y; r < y + h; r++) + if (memcmp(a + r * 1024 + x, b + r * 1024 + x, (size_t)w * 2)) return 0; + return 1; +} + +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("xBR hidden test", 0, 0, 320, 240, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + if (!win) { fprintf(stderr, "SKIP no window\n"); return 77; } + gr_set_backend(GR_BACKEND_OPENGL); + gr_init(vram); + gr_set_scale(1); + gl_renderer_set_swap_interval(0); + if (!gl_renderer_init_context(win)) { fprintf(stderr, "SKIP no GL context\n"); return 77; } + pages(); + static uint16_t ref[1024 * 512], x[1024 * 512]; + gr_set_texture_filter(0); + scene(); memcpy(ref, nat, sizeof ref); + const double e_near = stair_error(); + gr_set_texture_filter(3); + check(gr_texture_filter() == 3, "xbr selectable"); + scene(); memcpy(x, nat, sizeof x); + const double e_xbr = stair_error(); + printf("stair error nearest=%.2f xbr=%.2f\n", e_near, e_xbr); + check(e_xbr < e_near * 0.7, "diagonal staircase smoothed"); + check(region_equal(ref, x, 140, 0, 64, 128), "straight edge unchanged"); + check(region_equal(ref, x, 210, 0, 64, 128), "flat area and cutout unchanged"); + check(region_equal(ref, x, 0, 150, 32, 16), "1:1 draw identical to nearest"); + /* texture_lod adds bit 4 to the filter key; xbr must still run (the + * magnified quads keep their texels under LOD). */ + gl_renderer_set_texture_lod(1, 16); + scene(); + check(memcmp(nat, x, sizeof x) == 0, "xbr unchanged with texture_lod on"); + gl_renderer_set_texture_lod(0, 1); + check(gl_renderer_render_thread_start(2) == 1, "render thread started"); + scene(); + gl_renderer_render_thread_sync("xbr"); + check(memcmp(nat, x, sizeof x) == 0, "render thread gives the same pixels"); + gl_renderer_render_thread_stop(); + gr_set_texture_filter(0); + scene(); + check(memcmp(nat, ref, sizeof ref) == 0, "nearest after xbr restores the exact image"); + 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 c22ac50b9aca0a82f97ff6d65c6b74cb958aa029 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:34:16 -0700 Subject: [PATCH 2/3] xBR: Hyllian MIT attribution, fmode == 3 with the LOD bitfield, early-out before fetches - THIRD_PARTY_ATTRIBUTION.md carries Hyllian's MIT notice; the shader comment says the filter derives from xBR instead of 'written from the published description'. - Rebased on #585: the branch tests fmode == 3 so xBR still runs when the LOD bit is set; gl_xbr_filter_test now draws tracked quads with texture_lod on and fails on the old u_filter == 3 test. - The under-2x early-out runs before any neighbour fetch, and the other ten fetches wait for the E==F/E==H test. - OpenBIOS emitter stamp regenerated. Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 858e9e48..fa951347 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -35fe14259a39a6d39ecb53fc996bd16455934f90a805dd3062d14bf03cc028de +fcb3412a5245142593c6d5cdaabfda1f5d58ff878e09b175fd8aa10dee1554db From ff573219bf2bab8f758114134e70cd7130325fd5 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:07:59 -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_xbr_filter_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 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 | 50 +++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 40ef7653..757f33c9 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -262,6 +262,31 @@ 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) + # texture_filtering = "xbr": diagonals lose the staircase, straight + # edges, flat areas, cutouts and 1:1 draws stay nearest; RT agrees. + add_test(NAME gl_xbr_filter_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_xbr.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-xbr-results") + set_tests_properties(gl_xbr_filter_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,31 +334,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) - # texture_filtering = "xbr": diagonals lose the staircase, straight - # edges, flat areas, cutouts and 1:1 draws stay nearest; RT agrees. - add_test(NAME gl_xbr_filter_test - COMMAND "${Python3_EXECUTABLE}" - "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single - --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_xbr.c" - --cc "${CMAKE_C_COMPILER}" - --sdl-include "${_gl_scale_sdl_inc}" - --sdl-library "$" - --output "${CMAKE_CURRENT_BINARY_DIR}/gl-xbr-results") - set_tests_properties(gl_xbr_filter_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)