diff --git a/docs/config_schema.md b/docs/config_schema.md index 4fa6f861..7d6ca630 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -450,6 +450,29 @@ a build compiled with Vulkan support. `offer_vulkan` controls launcher visibility only; it defaults to false so game projects must explicitly expose Vulkan after validating their visuals and stability. +### Internal resolution (`supersampling`) + +`supersampling = N` renders at N times the native resolution per axis and +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. + +The runtime clamps N per backend: + +- Software and Vulkan stop at 4. +- OpenGL keeps VRAM as one `1024*N x 512*N` surface and clamps N at context + init to the driver's `GL_MAX_TEXTURE_SIZE`, `GL_MAX_RENDERBUFFER_SIZE` and + `GL_MAX_VIEWPORT_DIMS`, and to a memory budget of 2 GiB for that surface + (`PSX_GL_VRAM_BUDGET_MB` overrides it; `0` removes it). Apple's OpenGL + reports 16384, so 16 is the largest full-VRAM scale there. A surface that + still fails to allocate is retried one scale lower; the backend never drops + to software because of the scale. The log line + `GL internal scale Nx clamped to Mx (...)` names the limit that applied. + +Above 1x the OpenGL present averages the whole footprint of each output pixel +when the internal image is more than 1.25 times larger than the window (for +example 1200 internal lines into a 1080-line window), and lines are drawn one +native pixel thick at any scale. + `offer_skip_fmv` defaults to true for compatibility with the shared PSX Settings surface. A game migrating Skip FMVs into its built-in mod catalog sets it to false. The runtime then hides the Settings row, ignores stale persisted diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index b58a379d..f14b5fa5 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -410,6 +410,12 @@ if(BUILD_TESTING) target_include_directories(display_scanout_test PRIVATE ../runtime/include) add_test(NAME display_scanout_test COMMAND display_scanout_test) + # Pure header arithmetic for the OpenGL internal-resolution clamp. + add_executable(gl_scale_limits_test + ../runtime/tests/test_gl_scale_limits.c) + target_include_directories(gl_scale_limits_test PRIVATE ../runtime/include) + add_test(NAME gl_scale_limits_test COMMAND gl_scale_limits_test) + add_executable(cli_boot_path_test tests/cli_boot_path_test.cpp src/cli_boot_path.cpp @@ -822,6 +828,7 @@ if(BUILD_TESTING) hybrid_dev_any_input launcher_pad_mode_wiring jalr_link_register_guards + gl_internal_scale_guards launcher_vulkan_option mod_controller_override mod_load_acceleration diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index fac2b6c7..419724d5 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -574,9 +574,11 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& const toml::value& video = toml::find(cfg, "video"); if (video.contains("supersampling")) { const auto n = toml::find(video, "supersampling"); - if (n < 1 || n > 4) { + // 1..32: the runtime clamps per backend (software/Vulkan 4, OpenGL + // to the driver's texture limits and a memory budget). + if (n < 1 || n > 32) { throw std::runtime_error(fmt::format( - "[video] supersampling out of range (1..4): {}", n)); + "[video] supersampling out of range (1..32): {}", n)); } rt.video_supersampling = static_cast(n); } @@ -2313,7 +2315,7 @@ UserSettings load_user_settings(const fs::path& path) { }); if (v.contains("supersampling")) try_get([&]{ const auto n = toml::find(v, "supersampling"); - if (n >= 1 && n <= 4) { s.supersampling = (int)n; s.has_supersampling = true; } + if (n >= 1 && n <= 32) { s.supersampling = (int)n; s.has_supersampling = true; } }); if (v.contains("window_width")) try_get([&]{ const auto n = toml::find(v, "window_width"); diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index a222ee0d..6f11cf29 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -378,9 +378,11 @@ struct RuntimeConfig { // ---- [video] block — visual enhancement options ---- // supersampling: internal-resolution SSAA factor (per axis). 1 = native - // (default, behaves exactly as before). 2..4 render geometry/shading into + // (default, behaves exactly as before). 2..32 render geometry/shading into // an N*-scaled mirror of VRAM and downsample on present — true ordered-grid - // supersampling + edge anti-aliasing. Cost scales ~N^2 in fill rate. + // supersampling + edge anti-aliasing. Cost scales ~N^2 in fill rate. The + // runtime clamps per backend: software and Vulkan at 4, OpenGL at the + // driver's texture limits and a memory budget (gl_scale_limits.h). int video_supersampling = 1; // Optional initial window width declared by the title profile. Zero keeps @@ -1199,7 +1201,7 @@ struct UserSettings { // [video] bool has_renderer = false; int renderer = DEFAULT_VIDEO_RENDERER; // 0=software,1=opengl,2=vulkan - bool has_supersampling = false; int supersampling = 1; // 1..4 + bool has_supersampling = false; int supersampling = 1; // 1..32 (runtime clamps per backend) // Window size: width in px; height is always width*3/4 (PSX 4:3). Applies to // both the launcher and the emulator window so they boot at the same size. bool has_window_width = false; int window_width = 1280; // -> 1280x960 diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 2cbf7107..740ca488 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -85,6 +85,30 @@ if(BUILD_TESTING) else() message(STATUS "gl_readback_region_test: not registered (requires MinGW, SDL3 headers/static library in PSX_GL_READBACK_SDL_ROOT and gcc.exe/g++.exe in PSX_GL_READBACK_COMPILER_BIN)") endif() + # Real-GL internal-resolution invariance (macOS/Linux, hidden window): the + # guest-visible VRAM is identical at every scale, lines stay one native + # pixel thick, and an over-limit request clamps inside GL. Needs the SDL3 + # this tree fetched and a GPU session; labelled hardware like the readback + # test above. + if(NOT WIN32 AND Python3_Interpreter_FOUND AND TARGET SDL3::SDL3) + if(DEFINED sdl3_SOURCE_DIR) + set(_gl_scale_sdl_inc "${sdl3_SOURCE_DIR}/include") + else() + set(_gl_scale_sdl_inc "$") + endif() + add_test(NAME gl_scale_invariance_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_scale_invariance.py" + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_scale_invariance.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-scale-results") + set_tests_properties(gl_scale_invariance_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300) + unset(_gl_scale_sdl_inc) + else() + message(STATUS "gl_scale_invariance_test: not registered (needs a non-Windows host, Python 3 and SDL3)") + endif() add_executable(launcher_device_roundtrip_test tests/test_launcher_device_roundtrip.cpp) target_include_directories(launcher_device_roundtrip_test PRIVATE include) diff --git a/runtime/include/gl_scale_limits.h b/runtime/include/gl_scale_limits.h new file mode 100644 index 00000000..63fb9593 --- /dev/null +++ b/runtime/include/gl_scale_limits.h @@ -0,0 +1,96 @@ +/* gl_scale_limits.h — how large an internal-resolution scale the OpenGL + * backend may allocate. + * + * Pure arithmetic, no GL or SDL, so a test can pin it. The GL backend keeps the + * authoritative VRAM as one 1024*S x 512*S colour texture plus a same-size + * depth-stencil renderbuffer (gpu_gl_renderer.c). Two things bound S: + * + * - the driver's size limits: GL_MAX_TEXTURE_SIZE, GL_MAX_RENDERBUFFER_SIZE + * and GL_MAX_VIEWPORT_DIMS (the caller passes their minimum). Apple's GL + * on Metal reports 16384, so the full-VRAM surface tops out at S = 16; + * - a memory budget. On unified-memory machines this is system RAM. + * + * A request that does not fit is clamped here, never turned into a failed + * context: the old behaviour (allocation failure -> the whole GL backend fell + * back to the software renderer) lost far more than the resolution. */ +#pragma once + +#include + +#define PSX_GL_VRAM_W 1024 +#define PSX_GL_VRAM_H 512 + +/* RGBA8 colour + D24S8 depth-stencil per internal pixel. The copy scratch is + * sized lazily to the largest copy, so it is not part of the fixed cost. */ +#define PSX_GL_HR_BYTES_PER_PX 8u + +/* Default memory budget for the full-VRAM surface: 2 GiB (S = 22 by memory; + * the texture limit binds first on 16384-limit GPUs). */ +#define PSX_GL_DEFAULT_BUDGET_MB 2048u + +/* Why a scale was reduced. */ +enum { + PSX_GL_SCALE_OK = 0, + PSX_GL_SCALE_TEXTURE = 1, /* 1024*S exceeds the driver size limit */ + PSX_GL_SCALE_BUDGET = 2, /* surface exceeds the memory budget */ + PSX_GL_SCALE_CEILING = 4 /* above the compile-time ceiling */ +}; + +/* Bytes of the full-VRAM hr surface (colour + depth-stencil) at scale s. */ +static inline uint64_t psx_gl_full_vram_bytes(int s) { + if (s < 1) s = 1; + return (uint64_t)PSX_GL_VRAM_W * (uint64_t)s * + (uint64_t)PSX_GL_VRAM_H * (uint64_t)s * PSX_GL_HR_BYTES_PER_PX; +} + +/* 1 if a full-VRAM surface at scale s fits max_dim and budget_bytes. + * max_dim <= 0 means "unknown" (no size check). budget_bytes == 0 means no + * budget. */ +static inline int psx_gl_full_vram_fits(int s, int max_dim, uint64_t budget_bytes) { + if (s < 1) return 0; + if (max_dim > 0 && (int64_t)PSX_GL_VRAM_W * s > (int64_t)max_dim) return 0; + if (max_dim > 0 && (int64_t)PSX_GL_VRAM_H * s > (int64_t)max_dim) return 0; + if (budget_bytes && psx_gl_full_vram_bytes(s) > budget_bytes) return 0; + return 1; +} + +/* Largest scale <= req whose full-VRAM surface fits. Never below 1. When + * reason is non-NULL it receives a PSX_GL_SCALE_* mask of what bound it. */ +static inline int psx_gl_clamp_full_vram_scale(int req, int ceiling, int max_dim, + uint64_t budget_bytes, int *reason) { + int why = PSX_GL_SCALE_OK; + int s = req < 1 ? 1 : req; + if (ceiling > 0 && s > ceiling) { s = ceiling; why |= PSX_GL_SCALE_CEILING; } + while (s > 1) { + int tex_ok = !(max_dim > 0 && + ((int64_t)PSX_GL_VRAM_W * s > (int64_t)max_dim || + (int64_t)PSX_GL_VRAM_H * s > (int64_t)max_dim)); + int mem_ok = !(budget_bytes && psx_gl_full_vram_bytes(s) > budget_bytes); + if (tex_ok && mem_ok) break; + if (!tex_ok) why |= PSX_GL_SCALE_TEXTURE; + if (!mem_ok) why |= PSX_GL_SCALE_BUDGET; + s--; + } + if (reason) *reason = why; + return s; +} + +/* Widest native-wide surface (in native px) that fits max_dim at scale s. + * The GL wide surfaces are wide_w*S x 512*S. Returns 0 when nothing fits. */ +static inline int psx_gl_max_wide_width(int s, int max_dim) { + if (s < 1) s = 1; + if (max_dim <= 0) return 1 << 30; + if ((int64_t)PSX_GL_VRAM_H * s > (int64_t)max_dim) return 0; + return max_dim / s; +} + +/* Parse a budget override in MiB (PSX_GL_VRAM_BUDGET_MB). NULL/empty/invalid + * returns the default. "0" disables the budget. */ +static inline uint64_t psx_gl_budget_bytes_from_env(const char *mb) { + uint64_t v = 0; + const char *p = mb; + if (!p || !*p) return (uint64_t)PSX_GL_DEFAULT_BUDGET_MB << 20; + while (*p >= '0' && *p <= '9') { v = v * 10u + (uint64_t)(*p - '0'); p++; if (v > (1u << 24)) break; } + if (*p) return (uint64_t)PSX_GL_DEFAULT_BUDGET_MB << 20; + return v << 20; +} diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index e9ad1263..5cb988eb 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -147,6 +147,26 @@ float gl_renderer_get_post_gamma(void); * path. Textured edge expansion needs the complete mirror surface. */ void gl_renderer_set_wide_fast(int on); +/* Internal-resolution scale state of the live GL context (0 before init). */ +typedef struct GlScaleInfo { + int requested; /* scale requested before context init (gr_set_scale) */ + int effective; /* scale the hr surface was allocated at */ + int max_scale; /* largest scale this context could allocate */ + int max_dim; /* min(GL_MAX_TEXTURE_SIZE, _RENDERBUFFER_SIZE, _VIEWPORT_DIMS) */ + int clamp_reason; /* PSX_GL_SCALE_* mask (gl_scale_limits.h) */ + int alloc_retries; /* FBO allocations that failed and stepped the scale down */ + int budget_mib; /* memory budget for the hr surface (0 = none) */ + int fbo_w, fbo_h; /* hr surface size in pixels */ + int drawable_w, drawable_h; /* window drawable in pixels */ +} GlScaleInfo; +int gl_renderer_scale_info(GlScaleInfo *out); + +/* Read the display rect at internal resolution from the hr FBO: (w*S) x (h*S) + * ARGB8888, top row first. Returns the pixel count, 0 if unavailable or larger + * than cap_px. Debug/verification only (screenshot_hires under GL). */ +int gl_renderer_read_display_hires(int x, int y, int w, int h, uint32_t *out, + int cap_px, int *ow, int *oh); + void gl_renderer_shutdown(void); /* Diagnostics (debug server): read GPU-side VRAM without touching the CPU diff --git a/runtime/include/gpu_render.h b/runtime/include/gpu_render.h index ace31f11..31c47013 100644 --- a/runtime/include/gpu_render.h +++ b/runtime/include/gpu_render.h @@ -30,6 +30,12 @@ GrBackend gr_backend(void); /* effective backend after init */ void gr_init(uint16_t *vram); void gr_set_scale(int scale); int gr_scale(void); + +/* Compile-time ceiling for the OpenGL backend's internal-resolution scale. The + * effective scale is decided at GL context init from the driver's size limits + * and a memory budget (gl_scale_limits.h), so this is only an upper bound. + * Software and Vulkan stay at SW_MAX_INTERNAL_SCALE (4). */ +#define GL_MAX_INTERNAL_SCALE 32 void gr_set_texture_filter(int bilinear); int gr_texture_filter(void); diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 45f6c6ca..221e5c30 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -52,19 +52,26 @@ * * Known divergences from the software rasterizer (accepted, documented): * - GL triangle/line coverage rules differ from the PS1 DDA by ±1px on - * edges; lines use GL_LINES (width S) instead of Bresenham. + * edges; lines use GL_LINES at 1x and, above 1x, a quad one native pixel + * thick that covers both endpoints (line_to_quad) instead of Bresenham. * - No dithering (the software path doesn't dither either). * - Gouraud interpolation happens at 8-bit precision instead of 5-bit * (smoother gradients; readback re-quantizes to 5-bit). * - VRAM-wrapping draws are clamped, except GP0(02h) fills which split * into wrapped segments. Wrapping copies/draws are unused by real SDKs. * + * INTERNAL SCALE LIMITS. S is clamped at init to the driver's texture, + * renderbuffer and viewport limits and to a memory budget (gl_scale_limits.h), + * and an FBO that still fails to allocate is retried one scale lower. A large + * S therefore never costs the GL backend itself. + * * Init is all-or-nothing: if any shader/FBO fails, gl_renderer_init_context * returns 0 and the runtime falls back to the pure software renderer — no * half-GL hybrid. */ #include "gpu.h" #include "gpu_render.h" +#include "gl_scale_limits.h" #include "gpu_sw_renderer.h" #include "gpu_gl_renderer.h" #include "mod_texture_banks.h" @@ -129,7 +136,16 @@ #define VRAM_W 1024 #define VRAM_H 512 -#define GL_MAX_INTERNAL_SCALE 4 +/* GL_MAX_INTERNAL_SCALE (the compile-time ceiling) lives in gpu_render.h; the + * effective scale is clamped to the driver and memory limits at init. */ +#ifndef PSXGL_MAX_RENDERBUFFER_SIZE +#define PSXGL_MAX_RENDERBUFFER_SIZE 0x84E8 +#endif +#ifndef GL_MAX_VIEWPORT_DIMS +#define GL_MAX_VIEWPORT_DIMS 0x0D3A +#endif +/* Scratch tile edge (hr px) for the tiled stencil rebuild at S > 1. */ +#define GL_SCRATCH_TILE 2048 /* ---- Loaded modern-GL entry points ------------------------------------- */ typedef GLuint (APIENTRY *PFN_glCreateShader)(GLenum); @@ -311,6 +327,13 @@ static int s_scale = 1; /* internal-res scale (hr FBO) */ /* Netplay: SW@1× + GPU@s_scale dual write; CPU VRAM always authoritative. */ static int s_cpu_auth_dual = 0; static int s_req_scale = 1; /* requested before context init */ +/* Driver limits queried at init: min(GL_MAX_TEXTURE_SIZE, + * GL_MAX_RENDERBUFFER_SIZE, GL_MAX_VIEWPORT_DIMS); 0 before init. */ +static int s_gl_max_dim = 0; +static int s_scale_clamp_reason = 0; /* PSX_GL_SCALE_* mask */ +static int s_scale_alloc_retries = 0; /* FBO failures stepped down */ +static uint64_t s_vram_budget = 0; /* bytes; 0 = none */ +static int s_wide_refused_logged = 0; static GLuint s_present_tex = 0; /* CPU-readout present path (24bpp) */ static int s_present_w = 0, s_present_h = 0; @@ -445,7 +468,7 @@ static GLint s_uBlitSrc = -1, s_uBlitPass = -1, s_uBlitMaskset = -1; static GLint s_uBlitSrcDiv = -1, s_uBlitSrcOff = -1; /* PACK program uniforms. */ static GLint s_uPackHr = -1, s_uPackScale = -1; -static GLint s_uStencilSrc = -1; +static GLint s_uStencilSrc = -1, s_uStencilOff = -1; static uint32_t *s_conv = NULL; /* RGBA8 staging for uploads */ static int s_gpu_dirty = 0; /* CPU VRAM array may be stale */ @@ -454,6 +477,10 @@ static int s_gpu_dirty = 0; /* CPU VRAM array may be stale */ typedef struct { int x0, y0, x1, y1, set; } DirtyRect; static DirtyRect s_cpu_dirty; /* conservative GPU-written area not yet read into CPU VRAM */ static DirtyRect s_pack_dirty; /* hr FBO content not in raw mirror */ +/* Native-coords union of primitive bboxes since the last stencil rebuild: the + * only area whose stencil can lag its alpha (deferred-stencil draws). Used at + * S > 1 so a mask-check toggle rebuilds that rect, not the whole surface. */ +static DirtyRect s_stencil_stale; /* CPU writes not yet in the FBO — an EXACT rect list, NOT a single union. * @@ -786,7 +813,7 @@ static void letterbox_rect(int ww, int wh, int *x, int *y, int *w, int *h); static void present_target_quad(GLuint tex, int tex_w, int tex_h, int x, int y, int w, int h, int linear, int lx, int ly, int lw, int lh, int v_flip, - int apply_gamma); + int apply_gamma, int src_scale); static void coh_record(int kind, int x0, int y0, int x1, int y1) { GlCohEvent *e = &s_coh_ring[s_coh_seq % GL_COH_RING_CAP]; @@ -958,10 +985,33 @@ static const char *PRESENT_FS = " return r;\n" "}\n" PSX_SCANLINE_FUNC + /* Area resolve (u_sharp == 3) for a supersampled source larger than the + * output: average the whole footprint of the output pixel instead of one + * tap, which skips source texels (aliasing) once the ratio passes ~1.25. + * u_sharp_scale is output px per texel, so 1/u_sharp_scale is the + * footprint in texels. Each tap is bilinear and taps are spaced up to two + * texels apart, so every tap averages a 2x2 texel block: an exact box at + * even ratios, a close tent otherwise. Taps clamp to u_uv_rect so the + * neighbouring VRAM never bleeds in. */ + "vec4 area_resolve(vec2 uv){\n" + " vec2 fp = 1.0 / max(u_sharp_scale, vec2(1.0e-6));\n" + " vec2 nf = clamp(ceil(fp * 0.5), vec2(1.0), vec2(8.0));\n" + " ivec2 n = ivec2(nf);\n" + " vec2 st = fp / nf;\n" + " vec2 base = uv * u_tex_size - 0.5 * fp + 0.5 * st;\n" + " vec2 lo = min(u_uv_rect.xy, u_uv_rect.zw);\n" + " vec2 hi = max(u_uv_rect.xy, u_uv_rect.zw);\n" + " vec4 acc = vec4(0.0);\n" + " for (int j = 0; j < n.y; j++)\n" + " for (int i = 0; i < n.x; i++)\n" + " acc += texture(u_tex, clamp((base + vec2(i, j) * st) / u_tex_size, lo, hi));\n" + " return acc / float(n.x * n.y);\n" + "}\n" "void main(){\n" " vec2 uv = v_uv;\n" " vec4 c;\n" " if (u_sharp == 2) { c = bicubic(uv); }\n" + " else if (u_sharp == 3) { c = area_resolve(uv); }\n" " else {\n" " if (u_sharp == 1) {\n" " vec2 scale = max(u_sharp_scale, vec2(1.0));\n" @@ -1241,7 +1291,8 @@ static const char *PACK_FS = static const char *STENCIL_FS = "#version 330\n" "uniform sampler2D u_src; out vec4 frag;\n" - "void main(){ vec4 c=texelFetch(u_src,ivec2(gl_FragCoord.xy),0);\n" + "uniform ivec2 u_off; /* tile origin in the target; (0,0) for a full copy */\n" + "void main(){ vec4 c=texelFetch(u_src,ivec2(gl_FragCoord.xy)-u_off,0);\n" " if(c.a<0.5) discard; frag=vec4(0.0); }\n"; static GLuint compile_shader(GLenum type, const char *src) { @@ -1480,14 +1531,100 @@ static void rebuild_target_stencil(GLuint target_fbo, int target_w, int target_h p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); } +/* Grow the shared scratch texture to at least w x h (never shrinks). The + * scratch holds copy sources and stencil-rebuild tiles; at S > 1 it is sized + * to what those operations actually need instead of the whole hr surface. */ +static void scratch_ensure(int w, int h) { + if (w <= s_scratch_w && h <= s_scratch_h) return; + if (w > s_scratch_w) s_scratch_w = w; + if (h > s_scratch_h) s_scratch_h = h; + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, s_scratch_tex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, s_scratch_w, s_scratch_h, + 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); +} + +/* Rebuild the stencil mask of [x, x+w) x [y, y+h) (target px) from alpha, in + * tiles no larger than GL_SCRATCH_TILE so the scratch never has to match a + * multi-gigapixel surface. Same result as rebuild_target_stencil over that + * rect; used only at S > 1. */ +static void rebuild_target_stencil_tiled(GLuint target_fbo, int target_w, int target_h, + int x, int y, int w, int h) { + if (x < 0) { w += x; x = 0; } + if (y < 0) { h += y; y = 0; } + if (x + w > target_w) w = target_w - x; + if (y + h > target_h) h = target_h - y; + if (w <= 0 || h <= 0) return; + int tile_w = w < GL_SCRATCH_TILE ? w : GL_SCRATCH_TILE; + int tile_h = h < GL_SCRATCH_TILE ? h : GL_SCRATCH_TILE; + scratch_ensure(tile_w, tile_h); + for (int ty = y; ty < y + h; ty += tile_h) { + for (int tx = x; tx < x + w; tx += tile_w) { + int cw = (x + w - tx) < tile_w ? (x + w - tx) : tile_w; + int ch = (y + h - ty) < tile_h ? (y + h - ty) : tile_h; + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, target_fbo); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_scratch_fbo); + glDisable(GL_SCISSOR_TEST); + p_glBlitFramebuffer(tx, ty, tx + cw, ty + ch, 0, 0, cw, ch, + GL_COLOR_BUFFER_BIT, GL_NEAREST); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, target_fbo); + glViewport(0, 0, target_w, target_h); + glEnable(GL_SCISSOR_TEST); + glScissor(tx, ty, cw, ch); + glDisable(GL_BLEND); + glEnable(GL_STENCIL_TEST); + glStencilMask(0x01); + glClearStencil(0); + glClear(GL_STENCIL_BUFFER_BIT); + glStencilFunc(GL_ALWAYS, 1, 0x01); + glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); + glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); + p_glUseProgram(s_stencil_prog); + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, s_scratch_tex); + p_glUniform1i(s_uStencilSrc, 0); + p_glUniform2i(s_uStencilOff, tx, ty); + p_glBindVertexArray(s_empty_vao); + glDrawArrays(GL_TRIANGLES, 0, 3); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + } + } + p_glUniform2i(s_uStencilOff, 0, 0); + glDisable(GL_STENCIL_TEST); + glDisable(GL_SCISSOR_TEST); + p_glBindVertexArray(0); + p_glUseProgram(0); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); +} + static void rebuild_mask_stencils(void) { if (s_stencil_valid || !s_raster_ok) return; int hw = VRAM_W * s_scale, hh = VRAM_H * s_scale; - rebuild_target_stencil(s_hr_fbo, hw, hh); - for (int i = 0; i < WIDE_MAX_SURF; i++) { - if (s_wide_fbo[i]) - rebuild_target_stencil(s_wide_fbo[i], g_wide_w * s_scale, hh); + if (s_scale <= 1) { + /* Native: the historical whole-surface rebuild, unchanged. */ + rebuild_target_stencil(s_hr_fbo, hw, hh); + for (int i = 0; i < WIDE_MAX_SURF; i++) { + if (s_wide_fbo[i]) + rebuild_target_stencil(s_wide_fbo[i], g_wide_w * s_scale, hh); + } + } else { + /* S > 1: only primitives can leave stencil behind alpha, so rebuild + * their bbox union, tiled. Wide surfaces keep a full rebuild (their + * margins are not in canonical coordinates), also tiled. */ + if (s_stencil_stale.set) { + int S = s_scale; + rebuild_target_stencil_tiled(s_hr_fbo, hw, hh, + s_stencil_stale.x0 * S, s_stencil_stale.y0 * S, + (s_stencil_stale.x1 - s_stencil_stale.x0 + 1) * S, + (s_stencil_stale.y1 - s_stencil_stale.y0 + 1) * S); + } + for (int i = 0; i < WIDE_MAX_SURF; i++) { + if (s_wide_fbo[i]) + rebuild_target_stencil_tiled(s_wide_fbo[i], g_wide_w * s_scale, hh, + 0, 0, g_wide_w * s_scale, hh); + } } + rect_clear(&s_stencil_stale); s_stencil_valid = 1; } @@ -1607,6 +1744,7 @@ static void mark_prim_dirty(const int *xs, const int *ys, int n, int textured) { if (x1 > s_area_x2) x1 = s_area_x2; if (y1 > s_area_y2) y1 = s_area_y2; rect_add(&s_pack_dirty, x0, y0, x1, y1); + if (s_scale > 1) rect_add(&s_stencil_stale, x0, y0, x1, y1); if (!s_cpu_auth_dual) rect_add(&s_cpu_dirty, x0, y0, x1, y1); /* Dual-raster keeps CPU current via SW writes — do not mark GPU-ahead. */ if (!s_cpu_auth_dual) @@ -1954,6 +2092,44 @@ static void flush_flat_batch(void) { hr_end(); } +/* A PS1 line as a quad one NATIVE pixel thick (used at S > 1). + * + * glLineWidth(S) was the old way to keep a line's thickness at internal scale, + * but core-profile contexts only guarantee width 1 (macOS reports + * GL_ALIASED_LINE_WIDTH_RANGE 1..1 and rejects anything wider), so every line + * came out one HR pixel thick: 1/9 of native at S=9. The quad follows the PS1 + * rule instead: one pixel per step along the major axis, one pixel across the + * minor axis. It spans pixel centres p0..p1 extended half a pixel along the + * major axis at both ends (so both endpoint pixels are covered) and offset + * +-0.5 px across the minor axis. Positions are native VRAM px, the space the + * GEO shader already takes. v = two (x, y, r, g, b, a) vertices; q = six. */ +static void line_to_quad(const float *v, float *q) { + float x0 = v[0] + 0.5f, y0 = v[1] + 0.5f; + float x1 = v[6] + 0.5f, y1 = v[7] + 0.5f; + float dx = x1 - x0, dy = y1 - y0; + float ax = fabsf(dx), ay = fabsf(dy); + float ex, ey, nx, ny; /* half-pixel extension along, offset across */ + if (ax >= ay) { /* X-major (also the single-pixel case) */ + float sx = dx < 0.0f ? -1.0f : 1.0f; + ex = 0.5f * sx; ey = ax > 0.0f ? 0.5f * sx * dy / dx : 0.0f; + nx = 0.0f; ny = 0.5f; + } else { /* Y-major */ + float sy = dy < 0.0f ? -1.0f : 1.0f; + ey = 0.5f * sy; ex = 0.5f * sy * dx / dy; + nx = 0.5f; ny = 0.0f; + } + const float a[2] = { x0 - ex, y0 - ey }, b[2] = { x1 + ex, y1 + ey }; + /* corners: a-, b-, a+ / b-, a+, b+ ; colour follows the nearer endpoint */ + const float cx[6] = { a[0]-nx, b[0]-nx, a[0]+nx, b[0]-nx, a[0]+nx, b[0]+nx }; + const float cy[6] = { a[1]-ny, b[1]-ny, a[1]+ny, b[1]-ny, a[1]+ny, b[1]+ny }; + const int end[6] = { 0, 1, 0, 1, 0, 1 }; + for (int i = 0; i < 6; i++) { + const float *src = v + end[i] * 6; + q[i*6+0] = cx[i]; q[i*6+1] = cy[i]; + q[i*6+2] = src[2]; q[i*6+3] = src[3]; q[i*6+4] = src[4]; q[i*6+5] = src[5]; + } +} + /* Flat / gouraud triangles and lines share the GEO program. mode: GL_TRIANGLES * or GL_LINES; verts are (x, y, r, g, b, a) tuples with colors as 1555. */ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, @@ -1977,16 +2153,26 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, verts[i*6+4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; verts[i*6+5] = mask_a; } + /* S > 1: a line becomes a one-native-pixel quad (see line_to_quad); + * native keeps GL_LINES exactly as before. */ + float quad[6 * 6]; + GLenum draw_mode = mode; + const float *draw_verts = verts; + int draw_n = n; + if (mode == GL_LINES && n == 2 && s_scale > 1) { + line_to_quad(verts, quad); + draw_mode = GL_TRIANGLES; draw_verts = quad; draw_n = 6; + } hr_begin(1); if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); mask_stencil(s_mask_set); - if (mode == GL_LINES) glLineWidth((float)s_scale); + if (draw_mode == GL_LINES) glLineWidth((float)s_scale); p_glUseProgram(s_geo_prog); p_glBindVertexArray(s_geo_vao); p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); - p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(n * 6 * sizeof(float)), - verts, PSXGL_STREAM_DRAW); - glDrawArrays(mode, 0, n); + p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(draw_n * 6 * sizeof(float)), + draw_verts, PSXGL_STREAM_DRAW); + glDrawArrays(draw_mode, 0, draw_n); if (g_wide_cur && !s_wide_suppress && s_ws_ablate != 1 && !(!g_ws_bd_stretch_on && mirror_geo_center_only(xs, n))) { int dx = wide_dx(); @@ -1994,7 +2180,7 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, gl_perf_mirror_begin(); wide_target_begin(dx, s_geo_uXoff, s_geo_uXhalf); wide_set_bd_scale(s_geo_uXscale, s_geo_uXcenter); - if (s_ws_ablate != 2) glDrawArrays(mode, 0, n); + if (s_ws_ablate != 2) glDrawArrays(draw_mode, 0, draw_n); wide_clear_bd_scale(s_geo_uXscale, s_geo_uXcenter); wide_target_end(s_geo_uXoff, s_geo_uXhalf); gl_perf_mirror_end(); @@ -2301,11 +2487,15 @@ static void gpu_copy_rect(int sx,int sy,int dx,int dy,int w,int h) { if (w <= 0 || h <= 0) return; int S = s_scale; + /* Stage the source at the scratch ORIGIN (not at its own hr coords), so + * the scratch only has to be as large as the biggest copy. The integer + * texelFetch in BLIT_FS makes the placement invisible in the result. */ + scratch_ensure(w * S, h * S); p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_scratch_fbo); glDisable(GL_SCISSOR_TEST); p_glBlitFramebuffer(sx*S, sy*S, (sx+w)*S, (sy+h)*S, - sx*S, sy*S, (sx+w)*S, (sy+h)*S, + 0, 0, w*S, h*S, GL_COLOR_BUFFER_BIT, GL_NEAREST); p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); @@ -2318,9 +2508,9 @@ static void gpu_copy_rect(int sx,int sy,int dx,int dy,int w,int h) { glBindTexture(GL_TEXTURE_2D, s_scratch_tex); p_glUniform1i(s_uBlitSrc, 0); p_glUniform1i(s_uBlitMaskset, s_mask_set); - /* Scratch holds the source at its own hr coords: texel = frag + (src-dst)*S. */ + /* Scratch holds the source at its origin: texel = frag - dst*S. */ p_glUniform1i(s_uBlitSrcDiv, 1); - p_glUniform2i(s_uBlitSrcOff, (sx - dx) * S, (sy - dy) * S); + p_glUniform2i(s_uBlitSrcOff, -dx * S, -dy * S); float fx0 = (float)dx, fy0 = (float)dy, fx1 = (float)(dx + w), fy1 = (float)(dy + h); float verts[6 * 2] = { fx0, fy0, fx1, fy0, fx0, fy1, @@ -2349,6 +2539,8 @@ static void glb_init(uint16_t *vram) { s_vram = vram; sw_renderer_init(vram); } * (software mirror, readbacks, screenshots) stays native, so the reported * scale is 1 and the software hi-res mirror stays off. */ static void glb_set_scale(int s) { + /* Only the compile-time ceiling here: the driver and memory limits are + * known at context init (init_gpu_raster), which clamps further. */ if (s < 1) s = 1; if (s > GL_MAX_INTERNAL_SCALE) s = GL_MAX_INTERNAL_SCALE; s_req_scale = s; @@ -2828,8 +3020,75 @@ static int make_fbo(GLuint *out_fbo, GLuint color_tex, GLuint stencil_rb) { return 1; } +/* Release the scale-dependent render targets (hr colour + depth-stencil + + * copy scratch). Used when an allocation fails and init steps S down. */ +static void free_hr_targets(void) { + if (s_hr_fbo) { p_glDeleteFramebuffers(1, &s_hr_fbo); s_hr_fbo = 0; } + if (s_scratch_fbo) { p_glDeleteFramebuffers(1, &s_scratch_fbo); s_scratch_fbo = 0; } + if (s_hr_rb) { p_glDeleteRenderbuffers(1, &s_hr_rb); s_hr_rb = 0; } + if (s_hr_tex) { glDeleteTextures(1, &s_hr_tex); s_hr_tex = 0; } + if (s_scratch_tex) { glDeleteTextures(1, &s_scratch_tex); s_scratch_tex = 0; } + s_scratch_w = s_scratch_h = 0; +} + +/* Allocate the authoritative hr surface at scale S. Returns 1 when every + * target is complete and the driver reported no error (a too-large texture + * surfaces as GL_INVALID_VALUE or GL_OUT_OF_MEMORY, not always as an + * incomplete FBO). */ +static int alloc_hr_targets(int S) { + while (glGetError() != GL_NO_ERROR) {} + int hw = VRAM_W * S, hh = VRAM_H * S; + s_hr_tex = make_tex(GL_RGBA8, hw, hh, GL_RGBA, GL_UNSIGNED_BYTE); + /* Copy/stencil scratch. Up to 2x it covers the whole surface as before + * (<= 8 MiB); above that it starts at one tile and grows to the largest + * copy on demand (scratch_ensure), instead of costing 4 MiB*S^2 up front. */ + if (S <= 2) { s_scratch_w = hw; s_scratch_h = hh; } + else { + s_scratch_w = hw < GL_SCRATCH_TILE ? hw : GL_SCRATCH_TILE; + s_scratch_h = hh < GL_SCRATCH_TILE ? hh : GL_SCRATCH_TILE; + } + s_scratch_tex = make_tex(GL_RGBA8, s_scratch_w, s_scratch_h, GL_RGBA, GL_UNSIGNED_BYTE); + p_glGenRenderbuffers(1, &s_hr_rb); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, s_hr_rb); + p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, hw, hh); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); + int ok = glGetError() == GL_NO_ERROR; + if (ok) ok = make_fbo(&s_hr_fbo, s_hr_tex, s_hr_rb); + if (ok) ok = make_fbo(&s_scratch_fbo, s_scratch_tex, 0); + if (ok) ok = glGetError() == GL_NO_ERROR; + if (!ok) free_hr_targets(); + return ok; +} + static int init_gpu_raster(void) { - s_scale = s_req_scale; + /* Effective scale: the request, clamped to what the driver can allocate + * for the full-VRAM surface and to the memory budget. Logged whenever it + * differs from the request so a preset that cannot be honoured says so. */ + { + GLint max_tex = 0, max_rb = 0, max_vp[2] = { 0, 0 }; + glGetIntegerv(GL_MAX_TEXTURE_SIZE, &max_tex); + glGetIntegerv(PSXGL_MAX_RENDERBUFFER_SIZE, &max_rb); + glGetIntegerv(GL_MAX_VIEWPORT_DIMS, max_vp); + while (glGetError() != GL_NO_ERROR) {} + int lim = max_tex > 0 ? max_tex : 0; + if (max_rb > 0 && (lim == 0 || max_rb < lim)) lim = max_rb; + if (max_vp[0] > 0 && (lim == 0 || max_vp[0] < lim)) lim = max_vp[0]; + if (max_vp[1] > 0 && (lim == 0 || max_vp[1] < lim)) lim = max_vp[1]; + s_gl_max_dim = lim; + s_vram_budget = psx_gl_budget_bytes_from_env(getenv("PSX_GL_VRAM_BUDGET_MB")); + s_scale = psx_gl_clamp_full_vram_scale(s_req_scale, GL_MAX_INTERNAL_SCALE, + s_gl_max_dim, s_vram_budget, + &s_scale_clamp_reason); + if (s_scale != s_req_scale) + fprintf(stdout, "psxrecomp: GL internal scale %dx clamped to %dx " + "(%s%s%s; GPU max texture %d, budget %llu MiB)\n", + s_req_scale, s_scale, + (s_scale_clamp_reason & PSX_GL_SCALE_TEXTURE) ? "texture limit " : "", + (s_scale_clamp_reason & PSX_GL_SCALE_BUDGET) ? "memory budget " : "", + (s_scale_clamp_reason & PSX_GL_SCALE_CEILING) ? "ceiling " : "", + s_gl_max_dim, (unsigned long long)(s_vram_budget >> 20)); + s_scale_alloc_retries = 0; + } s_geo_prog = build_program(GEO_VS, GEO_FS); s_tex_prog = build_program_ex(TEX_VS, TEX_FS, 1); @@ -2841,11 +3100,6 @@ static int init_gpu_raster(void) { s_conv = (uint32_t *)malloc((size_t)VRAM_W * VRAM_H * sizeof(uint32_t)); if (!s_conv) return 0; - int hw = VRAM_W * s_scale, hh = VRAM_H * s_scale; - s_hr_tex = make_tex(GL_RGBA8, hw, hh, GL_RGBA, GL_UNSIGNED_BYTE); - s_scratch_tex = make_tex(GL_RGBA8, hw, hh, GL_RGBA, GL_UNSIGNED_BYTE); - s_scratch_w = hw; - s_scratch_h = hh; s_up_tex = make_tex(GL_RGBA8, VRAM_W, VRAM_H, GL_RGBA, GL_UNSIGNED_BYTE); s_raw_tex = make_tex(PSXGL_R16UI, VRAM_W, VRAM_H, PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT); /* Force the driver's first texture-upload allocation while the renderer is @@ -2863,14 +3117,18 @@ static int init_gpu_raster(void) { glFinish(); } - p_glGenRenderbuffers(1, &s_hr_rb); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, s_hr_rb); - p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, hw, hh); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); - - if (!make_fbo(&s_hr_fbo, s_hr_tex, s_hr_rb)) return 0; + /* The hr surface: step the scale down inside GL when the driver cannot + * allocate it (out of memory, or a limit it under-reported). Only a + * failure at 1x takes the whole backend down, as before. */ + while (!alloc_hr_targets(s_scale)) { + if (s_scale <= 1) return 0; + fprintf(stdout, "psxrecomp: GL hr surface %dx%d (%dx) failed to allocate; " + "retrying at %dx\n", VRAM_W * s_scale, VRAM_H * s_scale, + s_scale, s_scale - 1); + s_scale--; + s_scale_alloc_retries++; + } if (!make_fbo(&s_raw_fbo, s_raw_tex, 0)) return 0; - if (!make_fbo(&s_scratch_fbo, s_scratch_tex, 0)) return 0; s_uVram = p_glGetUniformLocation(s_tex_prog, "u_vram"); s_uTpage = p_glGetUniformLocation(s_tex_prog, "u_tpage"); @@ -2891,6 +3149,7 @@ static int init_gpu_raster(void) { s_uPackHr = p_glGetUniformLocation(s_pack_prog, "u_hr"); s_uPackScale = p_glGetUniformLocation(s_pack_prog, "u_scale"); s_uStencilSrc = p_glGetUniformLocation(s_stencil_prog, "u_src"); + s_uStencilOff = p_glGetUniformLocation(s_stencil_prog, "u_off"); s_geo_uXoff = p_glGetUniformLocation(s_geo_prog, "u_xoff"); s_geo_uXhalf = p_glGetUniformLocation(s_geo_prog, "u_xhalf"); s_tex_uXoff = p_glGetUniformLocation(s_tex_prog, "u_xoff"); @@ -2980,6 +3239,7 @@ static int init_gpu_raster(void) { glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); rect_clear(&s_pack_dirty); + rect_clear(&s_stencil_stale); s_up_nrects = 0; up_add(0, 0, VRAM_W - 1, VRAM_H - 1); s_gpu_dirty = 0; @@ -3010,6 +3270,50 @@ static int init_gpu_raster(void) { int gl_renderer_texture_banks_supported(void) { return s_raster_ok && !s_cpu_auth_dual; } +int gl_renderer_scale_info(GlScaleInfo *out) { + if (!out) return 0; + memset(out, 0, sizeof(*out)); + out->requested = s_req_scale; + out->effective = s_raster_ok ? s_scale : 0; + out->max_dim = s_gl_max_dim; + out->clamp_reason = s_scale_clamp_reason; + out->alloc_retries = s_scale_alloc_retries; + out->budget_mib = (int)(s_vram_budget >> 20); + out->fbo_w = s_raster_ok ? VRAM_W * s_scale : 0; + out->fbo_h = s_raster_ok ? VRAM_H * s_scale : 0; + out->max_scale = psx_gl_clamp_full_vram_scale(GL_MAX_INTERNAL_SCALE, + GL_MAX_INTERNAL_SCALE, + s_gl_max_dim, s_vram_budget, NULL); + if (s_ctx && s_win) SDL_GL_GetDrawableSize(s_win, &out->drawable_w, &out->drawable_h); + return s_raster_ok; +} + +/* Read the display rect at internal resolution straight from the hr FBO: + * (w*S) x (h*S) ARGB8888, top row first. The CPU-side hires path under GL is + * native (the software mirror stays 1x), so this is the only capture that + * shows what the internal resolution actually rendered. cap_px bounds the + * output; returns the pixel count, 0 when unavailable or too large. */ +int gl_renderer_read_display_hires(int x, int y, int w, int h, uint32_t *out, + int cap_px, int *ow, int *oh) { + if (!s_raster_ok || !s_ctx || !out || w <= 0 || h <= 0) return 0; + if (x < 0 || y < 0 || x + w > VRAM_W || y + h > VRAM_H) return 0; + int S = s_scale, W = w * S, H = h * S; + if ((int64_t)W * H > (int64_t)cap_px) return 0; + flush_flat_batch(); + flush_tex_batch(); + flush_cpu_upload(); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); + glPixelStorei(GL_PACK_ALIGNMENT, 4); + glReadPixels(x * S, y * S, W, H, GL_BGRA, GL_UNSIGNED_BYTE, out); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + /* The hr FBO stores PS1 y=0 at GL row 0 and glReadPixels reads bottom-up, + * so row 0 is already the display's top line (see glb_render_wide_display). */ + for (int64_t i = 0; i < (int64_t)W * H; i++) out[i] |= 0xFF000000u; + if (ow) *ow = W; + if (oh) *oh = H; + return W * H; +} + int gl_renderer_select_texture_bank(uint16_t id) { uint32_t width, height; const uint16_t* pixels; @@ -3478,6 +3782,21 @@ static GLuint wide_fbo_for(int base_x) { if (g_wide_w <= 0) return 0; for (int i = 0; i < WIDE_MAX_SURF; i++) if (s_wide_fbo[i] && s_wide_base[i] == base_x) return s_wide_fbo[i]; + /* An uncapped Fit aspect can ask for a surface wider than the driver can + * allocate at this scale (about 38:9 at 18x on a 16384 limit). Refuse it + * with one log line: the frame then presents pillarboxed 4:3 instead of + * failing mid-draw. */ + if (s_gl_max_dim > 0 && + ((int64_t)g_wide_w * s_scale > s_gl_max_dim || + (int64_t)VRAM_H * s_scale > s_gl_max_dim)) { + if (!s_wide_refused_logged) { + fprintf(stdout, "psxrecomp: GL native-wide surface %dx%d at %dx exceeds " + "the GPU limit %d; presenting 4:3\n", g_wide_w * s_scale, + VRAM_H * s_scale, s_scale, s_gl_max_dim); + s_wide_refused_logged = 1; + } + return 0; + } for (int i = 0; i < WIDE_MAX_SURF; i++) { if (!s_wide_fbo[i]) { int w = g_wide_w * s_scale, h = VRAM_H * s_scale; @@ -3518,7 +3837,7 @@ static void glb_wide_configure(int wide_w, int offset) { double t0 = cw_ms(); s_cw_wide_cfgs++; flush_tex_batch(); /* a queued batch's wide mirror targets the CURRENT surfaces */ if (wide_w <= 0) { wide_free_all(); g_wide_w = 0; g_wide_off = 0; s_cw_wide_ms += cw_ms() - t0; return; } - if (wide_w != g_wide_w) wide_free_all(); + if (wide_w != g_wide_w) { wide_free_all(); s_wide_refused_logged = 0; } g_wide_w = wide_w; g_wide_off = offset; s_cw_wide_ms += cw_ms() - t0; @@ -4293,23 +4612,52 @@ static void gl_swap_with_osd(void) { SDL_GL_SwapWindow(s_win); } +/* Output pixels per source texel below which a supersampled present switches + * from one tap to the area resolve (1 / 1.25: the source is 25% larger than the + * output). Only used for S > 1 sources, so native presents never change. */ +#define PRESENT_AREA_MIN_RATIO 1.25f + +/* tex_w/tex_h and x/y/w/h are in the texture's NATIVE units; src_scale is how + * many texels one native unit spans (the internal scale for the hr FBO and the + * wide surfaces, 1 for textures that hold native or already-composed pixels). + * src_scale <= 1 is exactly the historical behaviour. */ static void present_target_quad(GLuint tex, int tex_w, int tex_h, int x, int y, int w, int h, int linear, int lx, int ly, int lw, int lh, int v_flip, - int apply_gamma) { + int apply_gamma, int src_scale) { float u0, v0, u1, v1; + int area = 0; + if (src_scale > 1 && lw > 0 && lh > 0 && w > 0 && h > 0) { + float rx = (float)(w * src_scale) / (float)lw; + float ry = (float)(h * src_scale) / (float)lh; + area = (rx > PRESENT_AREA_MIN_RATIO || ry > PRESENT_AREA_MIN_RATIO); + } p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); glViewport(lx, ly, lw, lh); p_glActiveTexture(PSXGL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, tex); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST); + /* The area resolve averages bilinear taps; it needs LINEAR whatever the + * AA setting (the source is already the supersampled image). */ + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, (linear || area) ? GL_LINEAR : GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, (linear || area) ? GL_LINEAR : GL_NEAREST); p_glUseProgram(s_present_prog); p_glUniform1i(s_present_uTex, 0); present_set_gamma(s_present_uGamma, apply_gamma); /* The rasterized path already renders at the internal scale, so it has no - * low-res source to reconstruct — keep the plain sample. */ - present_set_sharp(0, 0, 0, 0, 0); + * low-res source to reconstruct — keep the plain sample, unless the + * internal image is larger than the output: then resolve its area. */ + if (area && s_present_uSharp >= 0) { + p_glUniform1i(s_present_uSharp, 3); + if (s_present_uTexSize >= 0) + p_glUniform2f(s_present_uTexSize, (float)(tex_w * src_scale), + (float)(tex_h * src_scale)); + if (s_present_uSharpScale >= 0) /* output px per source texel */ + p_glUniform2f(s_present_uSharpScale, + (float)lw / (float)(w * src_scale), + (float)lh / (float)(h * src_scale)); + } else { + present_set_sharp(0, 0, 0, 0, 0); + } /* Scanline at the native line grid. v_uv is normalized against tex_h (the * full VRAM/FBO texture), and one texel row is one PS1 scanline, so tex_h is * the phase pitch; h is the displayed line count for the output-scale gate. @@ -4320,10 +4668,16 @@ static void present_target_quad(GLuint tex, int tex_w, int tex_h, * dest pixels blend the border texel with VRAM outside the content rect * (visible edge stripe with AA on). Center-mapped UVs keep edge samples * inside the rect; interior sampling is unchanged. */ - u0 = ((float)x + 0.5f) / (float)tex_w; - v0 = ((float)y + 0.5f) / (float)tex_h; - u1 = ((float)(x + w) - 0.5f) / (float)tex_w; - v1 = ((float)(y + h) - 0.5f) / (float)tex_h; + /* The inset is half a TEXEL. For a supersampled source that is 0.5/S of a + * native unit; the old 0.5 native units cropped (S-1)/2 texels per edge + * (about 4 px at 9x) and zoomed the image. src_scale <= 1: unchanged. */ + { + float in = src_scale > 1 ? 0.5f / (float)src_scale : 0.5f; + u0 = ((float)x + in) / (float)tex_w; + v0 = ((float)y + in) / (float)tex_h; + u1 = ((float)(x + w) - in) / (float)tex_w; + v1 = ((float)(y + h) - in) / (float)tex_h; + } /* PRESENT_VS always samples with mix(v0,v1,1-p.y). For CPU/FBO guest * bands that is the correct PSX top-down → GL mapping (v_flip=1). For a * glCopyTexSubImage2D of the already-presented drawable, the texture @@ -4425,7 +4779,7 @@ int gl_renderer_present_hold_last(void) { } /* v_flip=0: drawable capture is already screen-oriented. */ present_target_quad(s_hold_tex, s_hold_tw, s_hold_th, - 0, 0, s_hold_tw, s_hold_th, 0, lx, ly, lw, lh, 0, 0); + 0, 0, s_hold_tw, s_hold_th, 0, lx, ly, lw, lh, 0, 0, 1); } else { if (s_hold_force_4_3) letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); @@ -4433,7 +4787,7 @@ int gl_renderer_present_hold_last(void) { letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); present_target_quad(s_hold_tex, s_hold_tw, s_hold_th, 0, 0, s_hold_tw, s_hold_th, s_hold_linear, - lx, ly, lw, lh, 1, 1); + lx, ly, lw, lh, 1, 1, 1); /* Upgrade to drawable after letterboxing once. Live+interp leaves * HOLD_NATIVE (no Swap on main); drawable path is the soak-proven * full-window image (GL without interp). */ @@ -4498,7 +4852,7 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, } present_bezel(ww, wh, lx, ly, lw, lh); present_target_quad(s_hr_tex, VRAM_W, VRAM_H, - disp_x, disp_y, w, h, linear, lx, ly, lw, lh, 1, 1); + disp_x, disp_y, w, h, linear, lx, ly, lw, lh, 1, 1, s_scale); pres_record(GL_PRES_VRAM, disp_x, disp_y, w, h, lx, ly, lw, lh); hold_capture_drawable(); latency_ring_mark(LAT_SWAP_BEGIN); @@ -4602,7 +4956,7 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) return 1; } present_target_quad(tex, g_wide_w, VRAM_H, - 0, disp_y, g_wide_w, disp_h, linear, lx, ly, lw, lh, 1, 1); + 0, disp_y, g_wide_w, disp_h, linear, lx, ly, lw, lh, 1, 1, s_scale); pres_record(GL_PRES_WIDE, disp_x, disp_y, g_wide_w, disp_h, lx, ly, lw, lh); hold_capture_drawable(); latency_ring_mark(LAT_SWAP_BEGIN); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 18eb5c97..23e671e7 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -403,6 +403,7 @@ static void apply_offline_pad_count(int game_players, bool multitap_enabled) /* ARGB8888 staging buffer. The 576-row maximum preserves the full interlaced * PAL active canvas. Allocated once the supersampling scale is known. */ static uint32_t* sdl_pixel_buf = nullptr; +static size_t s_pixel_buf_px = 0; /* sdl_pixel_buf capacity in pixels */ typedef void (*ModFrameHook)(void); @@ -1939,10 +1940,11 @@ static int ensure_sw_sdl_present(void) { g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest); } if (!sdl_pixel_buf) { - const int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale; - sdl_pixel_buf = (uint32_t*)std::malloc( - (size_t)640 * tex_scale * PSX_DISPLAY_PRESENT_MAX_HEIGHT * - tex_scale * sizeof(uint32_t)); + int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale; + if (tex_scale > SW_MAX_INTERNAL_SCALE) tex_scale = SW_MAX_INTERNAL_SCALE; + s_pixel_buf_px = (size_t)640 * tex_scale * PSX_DISPLAY_PRESENT_MAX_HEIGHT * + tex_scale; + sdl_pixel_buf = (uint32_t*)std::malloc(s_pixel_buf_px * sizeof(uint32_t)); if (!sdl_pixel_buf) { std::fprintf(stderr, "psxrecomp: netplay SW present: staging alloc failed\n"); return -1; @@ -1960,8 +1962,10 @@ extern "C" void psx_frontend_netplay_force_sw_gpu(void) { #ifndef PSX_SDL_NO_RENDER /* Already locked on dual-raster GL quality present. */ if (s_netplay_sw_gpu_locked && netplay_gl_dual_quality()) { + /* The GL present surface is not bound by the software mirror's 4x + * (dual-raster keeps SW at 1x); the backend clamps to its own limits. */ int want = g_video_scale < 1 ? 1 : g_video_scale; - if (want > SW_MAX_INTERNAL_SCALE) want = SW_MAX_INTERNAL_SCALE; + if (want > GL_MAX_INTERNAL_SCALE) want = GL_MAX_INTERNAL_SCALE; gr_set_backend(GR_BACKEND_OPENGL); gl_renderer_set_cpu_auth_dual(1); /* FBO scale is fixed at context init — keep request in sync. */ @@ -2001,8 +2005,10 @@ extern "C" void psx_frontend_netplay_force_sw_gpu(void) { #ifndef PSX_SDL_NO_RENDER if (g_gl_active) { /* Dual-raster: OPENGL backend, SW@1× + GPU@Nx, FBO present. */ + /* The GL present surface is not bound by the software mirror's 4x + * (dual-raster keeps SW at 1x); the backend clamps to its own limits. */ int want = g_video_scale < 1 ? 1 : g_video_scale; - if (want > SW_MAX_INTERNAL_SCALE) want = SW_MAX_INTERNAL_SCALE; + if (want > GL_MAX_INTERNAL_SCALE) want = GL_MAX_INTERNAL_SCALE; gr_set_backend(GR_BACKEND_OPENGL); gl_renderer_set_cpu_auth_dual(1); gr_set_scale(want); @@ -3166,7 +3172,7 @@ static void teardown_game_session_keep_lobby(void) { if (sdl_texture) { SDL_DestroyTexture(sdl_texture); sdl_texture = nullptr; } if (sdl_renderer) { SDL_DestroyRenderer(sdl_renderer); sdl_renderer = nullptr; } if (sdl_window) { SDL_DestroyWindow(sdl_window); sdl_window = nullptr; } - if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; } + if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; s_pixel_buf_px = 0; } /* Rematch must re-run force_sw (and prefer CPU-auth GL present again). */ s_netplay_sw_gpu_locked = 0; s_netplay_gl_present = 0; @@ -7387,8 +7393,14 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * native path for those frames (the present filter still upscales). * SW-only netplay present: native scanout. Dual-raster FBO present * already returned above at GL SSAA. */ + /* Under OpenGL the CPU-side display readout is native (the software + * mirror stays 1x; the internal resolution lives in the GL FBO), so + * this path presents at 1x. Treating that native image as S-scaled + * showed a magnified top-left slice. */ if (netplay_cpu_auth_gpu() && !netplay_gl_dual_quality()) active_scale = 1; + else if (g_gl_active) + active_scale = 1; else active_scale = (g_video_scale > 1 && !di.depth24) ? g_video_scale : 1; @@ -7406,8 +7418,12 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { int base_x = local_viewport_wide ? gpu_ws_netplay_local_viewport_base_x() : (int)di.display_x; - int n = gr_render_wide_display(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)), - base_x, (int)di.display_y, (int)h); + /* The staging buffer is sized for at most SW_MAX_INTERNAL_SCALE; + * a GL wide surface at a higher scale falls back to canonical. */ + int n = ((size_t)sw * (size_t)h * (size_t)s <= s_pixel_buf_px) + ? gr_render_wide_display(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)), + base_x, (int)di.display_y, (int)h) + : 0; if (n > 0) { active_scale = s; } else { @@ -14837,7 +14853,20 @@ session_reboot: * Dual-raster: gr_set_scale(N) arms GL hr FBO @ N× while glb_set_scale * keeps SW at 1×. SW-only netplay: force scale 1. Offline: full SSAA. */ if (g_video_scale < 1) g_video_scale = 1; - if (g_video_scale > SW_MAX_INTERNAL_SCALE) g_video_scale = SW_MAX_INTERNAL_SCALE; + { + /* Per-backend ceiling. OpenGL allocates its hr surface at context init + * and clamps there to the driver's texture limits and a memory budget + * (gl_scale_limits.h), so only its compile-time bound applies here. + * Software keeps a CPU mirror of 1 MiB*S^2 and Vulkan accepts 1..4. */ + const int ceiling = (g_video_renderer == 1) ? GL_MAX_INTERNAL_SCALE + : SW_MAX_INTERNAL_SCALE; + if (g_video_scale > ceiling) { + std::fprintf(stdout, "psxrecomp: supersampling %dx clamped to %dx " + "(%s maximum)\n", g_video_scale, ceiling, + g_video_renderer == 2 ? "vulkan" : "software"); + g_video_scale = ceiling; + } + } if (net_cfg.enabled && s_netplay_gl_present && gl_renderer_cpu_auth_dual()) { gr_set_scale(g_video_scale); if (g_video_scale > 1) { @@ -15425,10 +15454,14 @@ session_reboot: /* Staging buffer + backing texture preserve the 576-row interlaced PAL * canvas, times the supersampling factor. Netplay: 1×. */ { - const int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale; - sdl_pixel_buf = (uint32_t*)std::malloc( - (size_t)640 * tex_scale * PSX_DISPLAY_PRESENT_MAX_HEIGHT * - tex_scale * sizeof(uint32_t)); + /* The CPU present path only runs above 1x on the software backend + * (<= SW_MAX_INTERNAL_SCALE); under OpenGL it is native (see + * active_scale), so an 8K GL scale never sizes this buffer by S^2. */ + int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale; + if (tex_scale > SW_MAX_INTERNAL_SCALE) tex_scale = SW_MAX_INTERNAL_SCALE; + s_pixel_buf_px = (size_t)640 * tex_scale * PSX_DISPLAY_PRESENT_MAX_HEIGHT * + tex_scale; + sdl_pixel_buf = (uint32_t*)std::malloc(s_pixel_buf_px * sizeof(uint32_t)); if (!sdl_pixel_buf) { std::fprintf(stderr, "failed to allocate %dx staging buffer\n", tex_scale); return 1; diff --git a/runtime/tests/run_gl_scale_invariance.py b/runtime/tests/run_gl_scale_invariance.py new file mode 100644 index 00000000..54d95f5e --- /dev/null +++ b/runtime/tests/run_gl_scale_invariance.py @@ -0,0 +1,127 @@ +"""Build and run test_gl_scale_invariance.c on a hidden real OpenGL context. + +The fixture runs once per internal scale; the guest-visible (native) VRAM +digest must be identical at every scale, and each run checks line thickness +at internal resolution. Two clamp runs check that an over-limit request (32x, +and a tiny memory budget) stays on the GL backend at the clamped scale. + +macOS/Linux: pass the SDL3 include directory and static library (for example +from a runtime build tree's _deps/sdl3-src/include and +_deps/sdl3-build/libSDL3.a) and a C compiler. Evidence (commands, output) is +written to receipt.json under --output. +""" +import argparse +import json +import os +import pathlib +import platform +import re +import subprocess +import sys +import tempfile + +MAC_FRAMEWORKS = ["Cocoa", "OpenGL", "IOKit", "CoreVideo", "CoreAudio", "AudioToolbox", + "Carbon", "ForceFeedback", "GameController", "Metal", "QuartzCore", + "CoreMedia", "AVFoundation", "Foundation", "CoreHaptics", + "UniformTypeIdentifiers"] + + +def parse_run(stdout): + """Return (checks, failures, digest or None) from a fixture run.""" + summary = re.search(r"^checks=(\d+) failures=(\d+)$", stdout, re.M) + digest = re.search(r"^digest=([0-9a-f]{16})$", stdout, re.M) + if not summary: + return None + return int(summary[1]), int(summary[2]), digest[1] if digest else None + + +def digests_agree(results): + """results: {scale: digest}. All present and equal.""" + values = list(results.values()) + return bool(values) and all(v is not None for v in values) and len(set(values)) == 1 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--cc", default="cc") + ap.add_argument("--sdl-include", required=True) + ap.add_argument("--sdl-library", required=True) + ap.add_argument("--output", required=True) + ap.add_argument("--fixture", type=pathlib.Path) + ap.add_argument("--scales", default="1,2,3,5,9") + args = ap.parse_args() + # ';'-separated when CMake hands over a target's include list. + sdl_includes = [str(pathlib.Path(d).resolve()) for d in args.sdl_include.split(";") if d] + args.sdl_library = str(pathlib.Path(args.sdl_library).resolve()) + framework = pathlib.Path(__file__).resolve().parents[2] + fixture = args.fixture or framework / "runtime/tests/test_gl_scale_invariance.c" + out_root = pathlib.Path(args.output).resolve() + out_root.mkdir(parents=True, exist_ok=True) + dest = pathlib.Path(tempfile.mkdtemp(prefix="scale-", dir=out_root)) + print("Evidence directory:", dest) + receipt = [] + + def run(command, env=None): + command = [str(c) for c in command] + r = subprocess.run(command, cwd=dest, capture_output=True, text=True, + encoding="utf-8", errors="replace", env=env) + receipt.append({"cmd": command, "exit": r.returncode, + "stdout": r.stdout, "stderr": r.stderr[-4000:]}) + (dest / "receipt.json").write_text(json.dumps(receipt, indent=2), encoding="utf-8") + return r + + includes = ["-I", framework / "runtime/include", "-I", framework / "runtime/src"] + for d in sdl_includes: + includes += ["-I", d] + objs = [] + for name, src in (("probe", fixture), ("sw", framework / "runtime/src/gpu_sw_renderer.c")): + o = dest / (name + ".o") + r = run([args.cc, "-std=gnu11", "-O1", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1", + "-DGL_SILENCE_DEPRECATION=1", "-w", *includes, "-c", src, "-o", o]) + if r.returncode: + print(r.stderr[-3000:]) + return 2 + objs.append(o) + link = [args.cc, *objs, args.sdl_library, "-o", dest / "probe"] + if platform.system() == "Darwin": + for f in MAC_FRAMEWORKS: + link += ["-framework", f] + link += ["-liconv", "-lm", "-Wl,-undefined,dynamic_lookup", "-Wl,-dead_strip"] + else: + link += ["-lGL", "-lm", "-ldl", "-lpthread", "-Wl,--unresolved-symbols=ignore-all", + "-Wl,--gc-sections"] + r = run(link) + if r.returncode: + print(r.stderr[-3000:]) + return 2 + + ok = True + digests = {} + for s in [int(v) for v in args.scales.split(",") if v]: + r = run([dest / "probe", s]) + parsed = parse_run(r.stdout) + print(f"scale {s}: exit={r.returncode}", r.stdout.strip().splitlines()[-2:], + r.stderr.strip()[-600:]) + if r.returncode or not parsed or parsed[1]: + ok = False + digests[s] = parsed[2] if parsed else None + if not digests_agree(digests): + print("FAIL native VRAM digest differs across scales:", digests) + ok = False + env = os.environ.copy() + for label, s, budget in (("over-limit", 32, None), ("budget", 12, "40")): + e = dict(env) + if budget is not None: + e["PSX_GL_VRAM_BUDGET_MB"] = budget + r = run([dest / "probe", s, "clamp"], env=e) + parsed = parse_run(r.stdout) + print(f"clamp {label}: exit={r.returncode}", r.stdout.strip().splitlines()[-2:], + r.stderr.strip()[-600:]) + if r.returncode or not parsed or parsed[1]: + ok = False + print("PASS" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runtime/tests/test_gl_internal_scale_guards.py b/runtime/tests/test_gl_internal_scale_guards.py new file mode 100644 index 00000000..55accc87 --- /dev/null +++ b/runtime/tests/test_gl_internal_scale_guards.py @@ -0,0 +1,87 @@ +"""Source guards for the OpenGL internal-resolution scale (source-only). + +The real-GL behaviour is covered by run_gl_scale_invariance.py (needs a GPU and +SDL3). These guards pin the structural promises that keep native (1x) output +byte-identical and keep a large scale from costing the GL backend: + - no local 4x cap in the GL backend; the ceiling lives in gpu_render.h; + - init queries the driver limits and clamps, and only a 1x allocation + failure can drop the backend to software; + - line quads, the area resolve and the half-texel inset change are all + gated on a scale above 1; + - the dual-raster re-arm and the offline clamp no longer cap GL at the + software mirror's 4x. +Also unit-tests the invariance runner's result parsing. +""" +import pathlib +import re +import sys +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GL = (ROOT / "runtime/src/gpu_gl_renderer.c").read_text(encoding="utf-8") +MAIN = (ROOT / "runtime/src/main.cpp").read_text(encoding="utf-8") +RENDER_H = (ROOT / "runtime/include/gpu_render.h").read_text(encoding="utf-8") + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from run_gl_scale_invariance import digests_agree, parse_run # noqa: E402 + + +def body(src, signature): + start = src.index(signature) + brace = src.index("{", start) + depth = 0 + for i in range(brace, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + return src[brace:i + 1] + raise AssertionError("unbalanced " + signature) + + +class GlScaleGuards(unittest.TestCase): + def test_ceiling_is_shared(self): + self.assertIsNone(re.search(r"#define\s+GL_MAX_INTERNAL_SCALE\s+4\b", GL)) + self.assertRegex(RENDER_H, r"#define\s+GL_MAX_INTERNAL_SCALE\s+32\b") + + def test_init_clamps_to_driver_limits(self): + init = body(GL, "static int init_gpu_raster(void)") + for token in ("GL_MAX_TEXTURE_SIZE", "PSXGL_MAX_RENDERBUFFER_SIZE", + "GL_MAX_VIEWPORT_DIMS", "psx_gl_clamp_full_vram_scale"): + self.assertIn(token, init) + # Retry lower inside GL; only a 1x failure returns 0 (software fallback). + self.assertRegex(init, r"while \(!alloc_hr_targets\(s_scale\)\) \{\s*if \(s_scale <= 1\) return 0;") + + def test_s1_paths_unchanged(self): + geo = body(GL, "static void gpu_geometry(") + self.assertIn("if (mode == GL_LINES && n == 2 && s_scale > 1) {", geo) + quad = body(GL, "static void present_target_quad(GLuint tex, int tex_w, int tex_h,\n" + " int x, int y, int w, int h, int linear,\n" + " int lx, int ly, int lw, int lh, int v_flip,\n" + " int apply_gamma, int src_scale) {") + self.assertIn("if (src_scale > 1 && lw > 0", quad) + self.assertIn("float in = src_scale > 1 ? 0.5f / (float)src_scale : 0.5f;", quad) + stencil = body(GL, "static void rebuild_mask_stencils(void)") + self.assertIn("if (s_scale <= 1) {", stencil) + + def test_main_does_not_cap_gl_at_software_limit(self): + self.assertNotIn("if (want > SW_MAX_INTERNAL_SCALE) want = SW_MAX_INTERNAL_SCALE;", MAIN) + self.assertIn("(g_video_renderer == 1) ? GL_MAX_INTERNAL_SCALE", MAIN) + + +class RunnerParsing(unittest.TestCase): + def test_parse(self): + self.assertEqual(parse_run("driver=x\ndigest=0123456789abcdef\nchecks=7 failures=0\n"), + (7, 0, "0123456789abcdef")) + self.assertIsNone(parse_run("garbage")) + + def test_digests(self): + self.assertTrue(digests_agree({1: "a", 3: "a"})) + self.assertFalse(digests_agree({1: "a", 3: "b"})) + self.assertFalse(digests_agree({1: "a", 3: None})) + self.assertFalse(digests_agree({})) + + +if __name__ == "__main__": + unittest.main() diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c new file mode 100644 index 00000000..036a98a2 --- /dev/null +++ b/runtime/tests/test_gl_scale_invariance.c @@ -0,0 +1,189 @@ +/* Internal-resolution invariance on a real, hidden OpenGL context. + * + * Run once per scale by run_gl_scale_invariance.py, which compares the + * digests across runs. Checks, per scale: + * - the native VRAM the game can read back (pack of the hr surface) is the + * same at every scale: supersampling must never change guest-visible + * pixels (lines excepted, see LINE_BAND, and checked separately); + * - a line is one NATIVE pixel thick at internal resolution (S hr rows); + * - a request the driver cannot hold is clamped inside GL, never dropped to + * the software renderer. + * Original source-owned scene; no retail payload. */ +#include "gpu_gl_renderer.c" +#include "mod_texture_banks.c" +uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} +int g_psx_vram_dirty_tracking=0; +uint64_t s_frame_count=0; +void gpu_vram_dirty_mark_row_impl(uint32_t y){(void)y;} +void gpu_vram_dirty_mark_rect(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} +void gpu_vram_dirty_mark_all(void){} +int psx_netplay_active(void){return 0;} +int gpu_display_is_depth24(void){return 0;} +void gpu_get_display_info(GpuDisplayInfo *out){memset(out,0,sizeof(*out));out->width=320;out->height=240;} +int psx_ws_prim_in_backdrop(void){return 0;} +int gpu_ws_nw_flat_backdrop_enabled(void){return 0;} +int g_ws_tex_edge_pct=0; +int psx_ws_prim_is_tagged(void){return 0;} +void psx_ws_dbg_gate_frame_snapshot(void){} +void gpu_depth24_upload_span_reset(void){} +void frame_interpolation_schedule_reset(FrameInterpolationSchedule *p){memset(p,0,sizeof(*p));} + +static uint16_t vram[1024*512], peek[1024*512]; +static int checks, failures; +static void check(int ok, const char *label) { + checks++; + if (!ok) { fprintf(stderr, "FAIL %s\n", label); failures++; } +} + +/* Lines are rasterised differently by design above 1x (a one-native-pixel quad + * instead of GL_LINES, whose width is capped at 1 on core profiles), so their + * band is excluded from the cross-scale digest and checked on its own. */ +#define LINE_X0 600 +#define LINE_Y0 300 +#define LINE_X1 700 +#define LINE_Y1 360 + +static uint64_t fnv(const uint16_t *p, int n, uint64_t h) { + for (int i = 0; i < n; i++) { h ^= p[i]; h *= 0x100000001b3ull; } + return h; +} + +static void scene(void) { + /* A 4-bit texture page at (512,0) with a 16-entry CLUT at (512,256). */ + static uint16_t page[64*64], clut[16]; + for (int i = 0; i < 64*64; i++) page[i] = (uint16_t)((i * 0x1357u) ^ (i >> 3)); + for (int i = 0; i < 16; i++) clut[i] = (uint16_t)(i ? (0x0421u * (uint16_t)i) | ((i & 1) << 15) : 0); + glb_vram_transfer_in(512, 0, 64, 64, page); + glb_vram_transfer_in(512, 256, 16, 1, clut); + + glb_set_draw_area(0, 0, 1023, 511); + glb_set_draw_offset(0, 0); + glb_set_mask_bits(0, 0); + glb_set_semi_transparency(0, 0); + glb_set_color_modulation(128, 128, 128, 0); + + /* Framebuffer-like region 0..319 x 0..239. */ + glb_fill_rect(0, 0, 320, 240, 0x1084); + glb_draw_flat_rect(10, 10, 100, 60, 0x03e0); + glb_draw_gouraud_triangle(20, 100, 0x001f, 180, 120, 0x7c00, 60, 230, 0x03ff); + glb_draw_flat_triangle(200, 20, 310, 90, 230, 200, 0x5294); + /* Textured rect (4-bit, CLUT at 512,256) and a textured triangle. */ + glb_draw_textured_rect(120, 12, 64, 48, 0, 0, 512, 256, 0x0008); + glb_draw_shaded_textured_triangle(150, 140, 0, 0, 0x808080, + 300, 150, 63, 4, 0x6090b0, + 170, 235, 8, 63, 0xb09060, 512, 256, 0x0008, 0); + /* Semi-transparency, every mode. */ + for (int mode = 0; mode < 4; mode++) { + glb_set_semi_transparency(1, mode); + glb_draw_flat_rect(20 + mode * 30, 180, 40, 30, (uint16_t)(0x2108 + mode * 0x0842)); + } + glb_set_semi_transparency(0, 0); + /* Mask set, then mask-check: the second rect must not overwrite masked px. */ + glb_set_mask_bits(1, 0); + glb_draw_flat_rect(250, 150, 30, 30, 0x4210); + glb_set_mask_bits(0, 1); + glb_draw_flat_rect(240, 140, 50, 50, 0x7fff); + glb_set_mask_bits(0, 0); + /* Clipped primitive. */ + glb_set_draw_area(40, 40, 90, 90); + glb_draw_flat_rect(0, 0, 200, 200, 0x3def); + glb_set_draw_area(0, 0, 1023, 511); + /* Copies: overlapping inside the frame, and from an off-screen area in. */ + glb_copy_rect(10, 10, 14, 13, 80, 50); + glb_draw_flat_rect(400, 300, 32, 32, 0x7c1f); + glb_copy_rect(400, 300, 280, 200, 32, 32); + /* A render-to-texture round trip: draw, then sample what was drawn. */ + glb_draw_flat_rect(576, 64, 32, 32, 0x03e0); + glb_draw_textured_rect(200, 200, 32, 32, 0, 64, 512, 256, 0x0009 | (1 << 7) * 0); + + /* Lines, inside LINE_BAND only. */ + glb_draw_line(LINE_X0 + 5, LINE_Y0 + 10, LINE_X0 + 80, LINE_Y0 + 10, 0x7fff); /* horizontal */ + glb_draw_line(LINE_X0 + 5, LINE_Y0 + 20, LINE_X0 + 45, LINE_Y0 + 50, 0x03ff); /* diagonal */ + glb_draw_shaded_line(LINE_X0 + 90, LINE_Y0 + 5, 0x001f, LINE_X0 + 92, LINE_Y0 + 55, 0x7c00); /* steep */ +} + +int main(int argc, char **argv) { + int scale = argc > 1 ? atoi(argv[1]) : 1; + const char *mode = argc > 2 ? argv[2] : "scene"; + if (SDL_Init(SDL_INIT_VIDEO) != 0) return 2; + 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("Scale invariance hidden test", 0, 0, 128, 128, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + if (!win) return 2; + for (int i = 0; i < 1024*512; i++) vram[i] = 0; + glb_init(vram); + glb_set_scale(scale); + gl_renderer_set_swap_interval(0); + if (!gl_renderer_init_context(win)) { fprintf(stderr, "FAIL context\n"); return 2; } + GlScaleInfo si; + gl_renderer_scale_info(&si); + printf("driver=%s max_dim=%d requested=%d effective=%d\n", + (const char *)glGetString(GL_VERSION), si.max_dim, si.requested, si.effective); + + if (!strcmp(mode, "clamp")) { + /* Whatever was requested, the backend stays GL and never exceeds what + * the limits allow. */ + int want = psx_gl_clamp_full_vram_scale(scale, GL_MAX_INTERNAL_SCALE, si.max_dim, + psx_gl_budget_bytes_from_env(getenv("PSX_GL_VRAM_BUDGET_MB")), + NULL); + check(s_raster_ok == 1, "GL pipeline kept (no software fallback)"); + check(si.effective == want, "effective scale is the limit-clamped request"); + check(psx_gl_full_vram_fits(si.effective, si.max_dim, 0), "allocated surface within driver limit"); + check(glGetError() == GL_NO_ERROR, "GL error"); + printf("checks=%d failures=%d\n", checks, failures); + gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); + return failures ? 1 : 0; + } + + check(si.effective == scale, "requested scale allocated"); + scene(); + gl_renderer_sync_cpu(); + check(gl_renderer_fbo_peek(0, 0, 1024, 512, peek), "native peek"); + /* Digest of guest-visible VRAM, the line band masked out. */ + for (int y = LINE_Y0; y < LINE_Y1; y++) + for (int x = LINE_X0; x < LINE_X1; x++) peek[y * 1024 + x] = 0; + uint64_t digest = fnv(peek, 1024*512, 0xcbf29ce484222325ull); + check(memcmp(vram, vram, 2) == 0, "cpu mirror readable"); + + /* Line thickness at internal resolution: the horizontal line covers S hr + * rows (native 1 row); the steep line S hr columns. */ + { + int w = 100, h = 60; + uint32_t *img = (uint32_t *)malloc((size_t)w * scale * h * scale * 4); + int ow = 0, oh = 0; + int n = img ? gl_renderer_read_display_hires(LINE_X0, LINE_Y0, w, h, img, + w * scale * h * scale, &ow, &oh) : 0; + check(n == w * scale * h * scale && ow == w * scale && oh == h * scale, + "hires readback size"); + if (n) { + int col = (30) * scale + scale / 2, rows = 0; /* inside the horizontal line */ + for (int yy = 0; yy < oh; yy++) { + uint32_t p = img[yy * ow + col] & 0xFFFFFFu; + if (p == 0xF8F8F8u) rows++; + } + if (rows != scale) fprintf(stderr, "horizontal line rows=%d scale=%d\n", rows, scale); + check(rows == scale, "horizontal line is one native pixel thick"); + int row = 30 * scale + scale / 2, cols = 0; /* across the steep line */ + for (int xx = 85 * scale; xx < 98 * scale; xx++) { + uint32_t p = img[row * ow + xx]; + if ((p & 0xFFFFFFu) != 0) cols++; + } + if (cols != scale) fprintf(stderr, "steep line cols=%d scale=%d\n", cols, scale); + check(cols == scale, "steep line is one native pixel thick"); + } + free(img); + } + check(glGetError() == GL_NO_ERROR, "GL error"); + printf("digest=%016llx\n", (unsigned long long)digest); + printf("checks=%d failures=%d\n", checks, failures); + gl_renderer_shutdown(); + SDL_DestroyWindow(win); + SDL_Quit(); + return failures ? 1 : 0; +} diff --git a/runtime/tests/test_gl_scale_limits.c b/runtime/tests/test_gl_scale_limits.c new file mode 100644 index 00000000..7206b6ce --- /dev/null +++ b/runtime/tests/test_gl_scale_limits.c @@ -0,0 +1,66 @@ +/* gl_scale_limits.h: the OpenGL backend clamps an internal-resolution request + * to the driver's size limit and a memory budget instead of failing context + * init (which used to drop the whole backend to software). Pure arithmetic. */ +#include "gl_scale_limits.h" + +#include + +static int g_failures = 0; + +static void expect_int(const char *what, long long got, long long want) { + if (got != want) { + printf("FAIL %s: got %lld want %lld\n", what, got, want); + ++g_failures; + } +} + +int main(void) { + const uint64_t MiB = 1ull << 20; + const uint64_t budget = (uint64_t)PSX_GL_DEFAULT_BUDGET_MB * MiB; + int why = -1; + + /* Surface bytes: colour + depth-stencil, 8 B per internal pixel. */ + expect_int("bytes 1x", (long long)psx_gl_full_vram_bytes(1), 4 * MiB); + expect_int("bytes 9x", (long long)psx_gl_full_vram_bytes(9), 324 * MiB); + + /* Apple GL on Metal reports 16384: 16x is the largest full-VRAM scale. */ + expect_int("16384 -> 16", psx_gl_clamp_full_vram_scale(18, 32, 16384, budget, &why), 16); + expect_int("16384 reason", why, PSX_GL_SCALE_TEXTURE); + expect_int("16384 fits 16", psx_gl_full_vram_fits(16, 16384, budget), 1); + expect_int("16384 fits 17", psx_gl_full_vram_fits(17, 16384, budget), 0); + /* 32768-limit GPUs: 18x fits the texture limit; the 2 GiB budget allows 22. */ + expect_int("32768 -> 18", psx_gl_clamp_full_vram_scale(18, 32, 32768, budget, &why), 18); + expect_int("32768 reason", why, PSX_GL_SCALE_OK); + expect_int("32768 budget", psx_gl_clamp_full_vram_scale(32, 32, 32768, budget, &why), 22); + expect_int("32768 budget reason", why, PSX_GL_SCALE_BUDGET); + /* Ceiling. */ + expect_int("ceiling", psx_gl_clamp_full_vram_scale(40, 32, 0, 0, &why), 32); + expect_int("ceiling reason", why, PSX_GL_SCALE_CEILING); + /* Preset requests that fit stay exact. */ + const int presets[] = { 1, 3, 5, 6, 9, 12 }; + for (unsigned i = 0; i < sizeof presets / sizeof presets[0]; i++) { + expect_int("preset exact", psx_gl_clamp_full_vram_scale(presets[i], 32, 16384, budget, &why), + presets[i]); + expect_int("preset reason", why, PSX_GL_SCALE_OK); + } + /* Never below 1, whatever the limits. */ + expect_int("tiny limit", psx_gl_clamp_full_vram_scale(9, 32, 512, budget, NULL), 1); + expect_int("zero req", psx_gl_clamp_full_vram_scale(0, 32, 16384, budget, NULL), 1); + /* Unknown limit (0) and no budget: only the ceiling applies. */ + expect_int("unknown", psx_gl_clamp_full_vram_scale(20, 32, 0, 0, NULL), 20); + + /* Native-wide surfaces: widest width that still fits the limit. */ + expect_int("wide 16x", psx_gl_max_wide_width(16, 16384), 1024); + expect_int("wide 18x", psx_gl_max_wide_width(18, 16384), 910); + expect_int("wide too tall", psx_gl_max_wide_width(40, 16384), 0); + + /* Budget override parsing. */ + expect_int("env null", (long long)psx_gl_budget_bytes_from_env(NULL), (long long)budget); + expect_int("env 64", (long long)psx_gl_budget_bytes_from_env("64"), (long long)(64 * MiB)); + expect_int("env 0", (long long)psx_gl_budget_bytes_from_env("0"), 0); + expect_int("env junk", (long long)psx_gl_budget_bytes_from_env("12x"), (long long)budget); + + if (g_failures) return 1; + printf("gl_scale_limits: all checks passed\n"); + return 0; +}