diff --git a/docs/ENHANCEMENTS.md b/docs/ENHANCEMENTS.md index 718dd222..fb3ddaf4 100644 --- a/docs/ENHANCEMENTS.md +++ b/docs/ENHANCEMENTS.md @@ -914,3 +914,39 @@ real GL context at 1, 2, 3, 5 and 9x and requires identical guest-visible VRAM internal resolution, and a 32x request that stays on GL at the driver clamp. In a running game, `video_info` reports the requested and effective scale and the drawable; `screenshot_hires` reads the hr FBO at full size. + +### IR2 — True 8K past a 16384 texture limit: the high-resolution window (2026-09-26) + +8K is 18x at the usual 240-line reference, and a full-VRAM surface at 18x is +18432 px wide: over the 16384 limit of Apple's GL (and Intel, MoltenVK). The +M4 returns `GL_INVALID_VALUE` for it. A title only needs the displayed frame at +18x, so past that limit the GL backend splits the job: + +- The **authoritative** VRAM stays the ordinary hr surface at **1x**. It is the + native renderer unchanged, so pack, CPU readback, render-to-texture sampling + and VRAM copies are exactly the native results. +- A **presentation-only** surface at S covers the displayed columns, + W = [x0, x1) × all 512 rows (R4: x 0..319, 5760×9216 at 18x, 405 MiB). Every + GPU write that touches W is mirrored into it: textured and flat batches, + lines (as quads), fills, uploads and the depth24 clear, and VRAM copies + (hi→hi when the source lies in W, otherwise the 1x source upscaled). It has + its own stencil, rebuilt from alpha like the hr surface. +- W starts empty and grows, rounded to 64 columns, the first time a display + rectangle outside it is presented, seeded by upscaling the 1x content. The + present, hold-last, interpolation capture, `screenshot_hires` and the + native-wide centre read W; a display it cannot hold presents at 1x. +- The mirror costs one extra submission of each primitive that touches W + (the 1x pass is cheap to fill), like the native-wide mirror. + +It engages only when the full-VRAM surface cannot hold the requested scale; +below that (up to 16x on the M4) nothing changes. `PSX_GL_HIRES_WINDOW=0/1` +disables or forces it. + +**Evidence.** `gl_scale_invariance_test` forces the window at 2, 3, 5 and 9x +and requires the frame at internal resolution to be byte-identical to the +full-VRAM surface at the same scale (copies inside, into and across the +window edge, fills and uploads across it, mask set/check, all four blend +modes), and the guest-visible VRAM to be identical to 1x; it also runs the +window at 18x. In R4 on an M4, the 8K preset reports +`effective_scale 18, internal_lines 4320, hr_scale 1, hires_fbo 5760x9216`, and +`screenshot_hires` in a race is 5760×4320 with the rear-view mirror present. diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 2150903a..57d44c0b 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -50,8 +50,8 @@ Columns: **N** = native, **D** = DuckStation oracle. | `read_scratch` | | ✓ | `addr`, `len` | Read PS1 scratchpad (0x1F800000 region) | | `read_vram` / `vram_peek` | ✓¹ | ✓ | `x`, `y`, `w`, `h` | Read 16-bit VRAM pixels (max 128×128) | | `gpu_state` | ✓ | ✓ | — | Display area, display depth, `screen_offset_y`, draw offset, GPUSTAT, clip rect, xfer state. A positive screen offset moves 24-bit scanout down from the PAL or NTSC broadcast centre | -| `screenshot_hires` | ✓ | ✓ | `path` | PNG of the **supersampled** surface (the present path the window uses), at `display × gr_scale()`. ⚠ `screenshot`/`screenshot_file` capture native 15-bit VRAM and are **blind to anything that only exists in the hi-res mirror** — geometry correction, SSAA edges, perspective UVs — so they show a clean frame while the player sees a broken one. Use this one to verify those. Under OpenGL it reads the hr FBO directly (the CPU-side mirror is native there), so it is the capture that proves an internal-resolution preset: 4K is 2880×2160 for a 320×240 display. Falls back to the native resolve (and reports `scale: 1`) when no hi-res surface exists | -| `video_info` | ✓ | | — | Internal resolution end to end: `preset` (0 unset, 1 native, -1 match display, else target lines), `reference_lines`, `requested_scale` (what the preset asked for), `effective_scale` (what the backend allocated), `internal_lines`, the GL limit (`gl_max_dim`, `gl_max_scale`, `gl_clamp_reason`: 1 texture, 2 budget, 4 ceiling; `gl_alloc_retries`, `gl_budget_mib`), the hr surface (`fbo_w`/`fbo_h`), and the window in points (`window_w`/`window_h`) and pixels (`drawable_w`/`drawable_h`, `hidpi_window`) | +| `screenshot_hires` | ✓ | ✓ | `path` | PNG of the **supersampled** surface (the present path the window uses), at `display × gr_scale()`. ⚠ `screenshot`/`screenshot_file` capture native 15-bit VRAM and are **blind to anything that only exists in the hi-res mirror** — geometry correction, SSAA edges, perspective UVs — so they show a clean frame while the player sees a broken one. Use this one to verify those. Under OpenGL it reads the hr FBO directly (the CPU-side mirror is native there), or the high-resolution window when that mode is on, so it is the capture that proves an internal-resolution preset: 4K is 2880×2160 and 8K 5760×4320 for a 320×240 display. Falls back to the native resolve (and reports `scale: 1`) when no hi-res surface exists | +| `video_info` | ✓ | | — | Internal resolution end to end: `preset` (0 unset, 1 native, -1 match display, else target lines), `reference_lines`, `requested_scale` (what the preset asked for), `effective_scale` (what the backend allocated), `internal_lines`, the GL limit (`gl_max_dim`, `gl_max_scale`, `gl_clamp_reason`: 1 texture, 2 budget, 4 ceiling; `gl_alloc_retries`, `gl_budget_mib`), the hr surface (`fbo_w`/`fbo_h`, `hr_scale`), the window in points (`window_w`/`window_h`) and pixels (`drawable_w`/`drawable_h`, `hidpi_window`), and the windowed high-resolution surface when a scale is past the full-VRAM limit (`windowed`, `hires_window_x`/`hires_window_w` in native columns, `hires_fbo_w`/`hires_fbo_h`, `hires_window_grows`) | | `screenshot_wide_hires` | ✓ | | `path`, `base_x` | The displayed band of the native-wide surface at internal resolution (`wide_w×S` by `height×S`). `present_shot` is capped at the window; this checks a widescreen + internal-resolution combination at full size | | `present_shot` | ✓ | | `path` | PNG of the **composed present surface** — the frame after the backend fits the display buffer to the window, so it carries the presented aspect. ⚠ every other capture resolves the display buffer *before* that fit: on a 508×256 display in a 4:3 window they answer 508×256 while the player sees 640×480. Use this one for anything aspect-shaped (widescreen, letterbox), where a pre-fit buffer would hide the very stage the change touches. Staged and fulfilled on the next present, so the ack means *queued* — poll `present_shot_seq`. Unavailable headless and on the Vulkan backend (its swapchain has no readback hook) | | `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates | diff --git a/docs/config_schema.md b/docs/config_schema.md index ec6ac636..dabafbb3 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -511,6 +511,15 @@ The runtime clamps N per backend: to software because of the scale. The log line `GL internal scale Nx clamped to Mx (...)` names the limit that applied. +**Past the full-VRAM limit** (8K is 18x, over Apple's 16384), OpenGL keeps +the whole VRAM at 1x — the native renderer, so everything the game reads back +is exactly native — and renders only the columns it displays at the full +scale, in a separate high-resolution window (`5760x9216` for a 320-wide +game at 18x, about 405 MiB). The window grows to cover each displayed +rectangle the first time it is shown; a copy whose source lies outside it is +taken from the 1x surface. `PSX_GL_HIRES_WINDOW=0` turns the mode off (the +scale is then clamped as above) and `=1` forces it at any scale above 1. + 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 diff --git a/runtime/include/gl_scale_limits.h b/runtime/include/gl_scale_limits.h index dfc6d78a..ce1c0831 100644 --- a/runtime/include/gl_scale_limits.h +++ b/runtime/include/gl_scale_limits.h @@ -118,6 +118,30 @@ static inline int psx_gl_fit_wide_aspect(int disp_w, int max_w, int *num, int *d return 1; } +/* Windowed high-resolution mode: the authoritative VRAM stays at 1x and only + * a window of the displayed columns (window_w native px, full 512-row height) + * is kept at S. Largest S <= req whose smallest useful window (min_window_w, + * e.g. a 320-px framebuffer) fits max_dim and budget_bytes. */ +static inline uint64_t psx_gl_window_bytes(int s, int window_w) { + if (s < 1) s = 1; + return (uint64_t)window_w * (uint64_t)s * (uint64_t)PSX_GL_VRAM_H * (uint64_t)s * + PSX_GL_HR_BYTES_PER_PX; +} +static inline int psx_gl_clamp_window_scale(int req, int ceiling, int max_dim, + uint64_t budget_bytes, int min_window_w) { + int s = req < 1 ? 1 : req; + if (ceiling > 0 && s > ceiling) s = ceiling; + while (s > 1) { + int dim_ok = !(max_dim > 0 && + ((int64_t)PSX_GL_VRAM_H * s > (int64_t)max_dim || + (int64_t)min_window_w * s > (int64_t)max_dim)); + int mem_ok = !(budget_bytes && psx_gl_window_bytes(s, min_window_w) > budget_bytes); + if (dim_ok && mem_ok) break; + s--; + } + return 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) { diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index e7586cd9..bff2b8eb 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -158,6 +158,14 @@ typedef struct GlScaleInfo { 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 */ + /* Windowed high-resolution mode (true 8K on 16384-limit GPUs): the hr + * surface stays at hr_scale (1) and a window of columns [window_x, + * window_x + window_w) is kept at `effective` scale. */ + int hr_scale; + int windowed; + int window_x, window_w; + int window_fbo_w, window_fbo_h; + int window_grows; } GlScaleInfo; int gl_renderer_scale_info(GlScaleInfo *out); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index feb1ca6b..4f176a51 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8995,12 +8995,16 @@ static void handle_video_info(int id, const char *json) "\"gl_clamp_reason\":%d,\"gl_alloc_retries\":%d,\"gl_budget_mib\":%d," "\"fbo_w\":%d,\"fbo_h\":%d,\"hidpi_window\":%d,\"window_w\":%d," "\"window_h\":%d,\"drawable_w\":%d,\"drawable_h\":%d," - "\"display_w\":%u,\"display_h\":%u}", + "\"display_w\":%u,\"display_h\":%u,\"hr_scale\":%d,\"windowed\":%d," + "\"hires_window_x\":%d,\"hires_window_w\":%d,\"hires_fbo_w\":%d," + "\"hires_fbo_h\":%d,\"hires_window_grows\":%d}", id, gr_backend() == GR_BACKEND_OPENGL ? "opengl" : gr_backend() == GR_BACKEND_VULKAN ? "vulkan" : "software", preset, ref, req, eff, di.height * (unsigned)(eff > 0 ? eff : 1), gl, si.max_dim, si.max_scale, si.clamp_reason, si.alloc_retries, si.budget_mib, - si.fbo_w, si.fbo_h, hidpi, ww, wh, pw, ph, di.width, di.height); + si.fbo_w, si.fbo_h, hidpi, ww, wh, pw, ph, di.width, di.height, + si.hr_scale, si.windowed, si.window_x, si.window_w, si.window_fbo_w, + si.window_fbo_h, si.window_grows); } /* screenshot_wide_hires — the displayed band of the native-wide surface at diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 5fdd0494..748ec277 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -323,7 +323,11 @@ static SDL_GLContext s_ctx = NULL; static uint16_t *s_vram = NULL; /* CPU VRAM array (gpu.c's storage) */ static int s_swap_interval = 1; /* SDL_GL swap interval (vsync mode) */ -static int s_scale = 1; /* internal-res scale (hr FBO) */ +static int s_scale = 1; /* internal-res scale presented (gr_scale) */ +/* Scale of the authoritative hr surface. Equal to s_scale, except in the + * windowed high-resolution mode (below), where hr stays at 1x and a separate + * S-scaled surface covers only the displayed columns. */ +static int s_hr_scale = 1; /* 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 */ @@ -335,6 +339,37 @@ 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; +/* ---- windowed high-resolution surface (true 8K on 16384-limit GPUs) ------- + * The full-VRAM hr surface is 1024*S wide, so a 16384 texture limit stops it + * at 16x (3840 lines). 8K needs 18x. In windowed mode the AUTHORITATIVE VRAM + * stays the ordinary hr surface at 1x — exactly the native renderer, so pack, + * CPU readback, render-to-texture and copies are the 1x results — and a + * second, presentation-only surface at S covers just the columns the game + * displays: W = [s_hiw_x0, s_hiw_x1) x full VRAM height, W*S x 512*S. Every + * GPU write that touches W (draws, fills, copies, uploads, the depth24 clear) + * is mirrored into it, like the native-wide surfaces; present, hold-last, + * interpolation and the wide centre read it. W starts empty and grows to the + * union of displayed rects (rounded to 64 px) the first time each is shown, + * seeded by upscaling the 1x content, so it never needs to guess the layout. + * A copy whose source lies outside W is taken from the 1x surface (nearest). + * Engaged only when the full-VRAM surface cannot hold the requested scale + * (driver limit or memory budget), or with PSX_GL_HIRES_WINDOW=1. */ +#define HIW_ALIGN 64 +static int s_hiw = 0; /* windowed mode engaged */ +static int s_hiw_x0 = 0, s_hiw_x1 = 0;/* window columns; empty until shown */ +static GLuint s_hiw_tex = 0, s_hiw_fbo = 0, s_hiw_rb = 0; +static GLuint s_hiw_scratch_tex = 0, s_hiw_scratch_fbo = 0; +static int s_hiw_scratch_w = 0, s_hiw_scratch_h = 0; +static int s_hiw_refused_logged = 0; +static int s_hiw_grows = 0; /* reallocations (diagnostic) */ +static int hiw_on(void); +static int hiw_ensure(int x0, int x1); +static GLuint make_tex(GLenum internal, int w, int h, GLenum fmt, GLenum type); +static int make_fbo(GLuint *out_fbo, GLuint color_tex, GLuint stencil_rb); +static void hiw_clear_rect(int x, int y, int w, int h, float r, float g, float b, + float a, int stencil); +static void hiw_mirror_copy(int sx, int sy, int dx, int dy, int w, int h); + static GLuint s_present_tex = 0; /* CPU-readout present path (24bpp) */ static int s_present_w = 0, s_present_h = 0; static GLuint s_osd_tex = 0; @@ -408,6 +443,13 @@ static GLuint s_tex_prog = 0, s_tex_vao = 0, s_tex_vbo = 0; * pre-feature pipeline. */ #define TEXV 20 static GLuint s_blit_prog = 0, s_blit_vao = 0, s_blit_vbo = 0; +static GLuint s_blit_hi_prog = 0; /* windowed hi surface blit */ +static GLint s_uBhSrc = -1, s_uBhPass = -1, s_uBhMaskset = -1, s_uBhSrcDiv = -1; +static GLint s_uBhSrcOff = -1, s_uBhShift = -1, s_uBhXoff = -1, s_uBhXhalf = -1; +/* Sample-grid shift per surface scale (see GEO_VS): the hr surface at + * s_hr_scale, the hi window and the wide surfaces at s_scale. */ +static float s_shift_hr = 0.0f, s_shift_hi = 0.0f; +static GLint s_geo_uShift = -1, s_tex_uShift = -1; static GLuint s_pack_prog = 0, s_stencil_prog = 0, s_empty_vao = 0; /* Sub-pixel vertex override + perspective UV weights for the next triangle @@ -481,6 +523,7 @@ static DirtyRect s_pack_dirty; /* hr FBO content not in raw mirror * * 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; +static void hiw_mirror_uploads(const DirtyRect *rects, int nrects); /* CPU writes not yet in the FBO — an EXACT rect list, NOT a single union. * @@ -880,7 +923,7 @@ static void pres_record(int path, int dx, int dy, int w, int h, if (probe_pixels && (path == GL_PRES_VRAM) && w > 0 && h > 0 && s_hr_fbo) { p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); glPixelStorei(GL_PACK_ALIGNMENT, 1); - glReadPixels((dx + w / 2) * s_scale, (dy + h / 2) * s_scale, + glReadPixels((dx + w / 2) * s_hr_scale, (dy + h / 2) * s_hr_scale, 1, 1, GL_RGB, GL_UNSIGNED_BYTE, sp); glPixelStorei(GL_PACK_ALIGNMENT, 4); p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); @@ -1246,6 +1289,18 @@ static const char *BLIT_VS = "uniform float u_shift;\n" "void main(){\n" " gl_Position = vec4((a_pos.x+u_shift)/512.0 - 1.0, (a_pos.y+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; +/* The same blit into the windowed high-resolution surface: its viewport is the + * window (W*S px), so x is translated by u_xoff and clipped at u_xhalf like + * the native-wide passes. A separate program keeps BLIT_VS (the canonical + * path) untouched. */ +static const char *BLIT_VS_HI = + "#version 330\n" + "layout(location=0) in vec2 a_pos; /* native VRAM px */\n" + "uniform float u_shift;\n" + "uniform float u_xoff;\n" + "uniform float u_xhalf;\n" + "void main(){\n" + " gl_Position = vec4((a_pos.x+u_shift+u_xoff)/u_xhalf - 1.0, (a_pos.y+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; static const char *BLIT_FS = "#version 330\n" "out vec4 frag;\n" @@ -1385,13 +1440,13 @@ static void apply_psx_blend(int mode) { /* ---- hr FBO render-state bracket ---------------------------------------- */ static void hr_begin(int clip_to_draw_area) { p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); - glViewport(0, 0, VRAM_W * s_scale, VRAM_H * s_scale); + glViewport(0, 0, VRAM_W * s_hr_scale, VRAM_H * s_hr_scale); glEnable(GL_SCISSOR_TEST); if (clip_to_draw_area) { int sw = s_area_x2 - s_area_x1 + 1, sh = s_area_y2 - s_area_y1 + 1; if (sw < 0) sw = 0; if (sh < 0) sh = 0; - glScissor(s_area_x1 * s_scale, s_area_y1 * s_scale, - sw * s_scale, sh * s_scale); + glScissor(s_area_x1 * s_hr_scale, s_area_y1 * s_hr_scale, + sw * s_hr_scale, sh * s_hr_scale); } } static void hr_end(void) { @@ -1468,7 +1523,7 @@ static void flush_cpu_upload(void) { glBindTexture(GL_TEXTURE_2D, s_up_tex); p_glUniform1i(s_uBlitSrc, 0); p_glUniform1i(s_uBlitMaskset, 0); - p_glUniform1i(s_uBlitSrcDiv, s_scale); + p_glUniform1i(s_uBlitSrcDiv, s_hr_scale); p_glUniform2i(s_uBlitSrcOff, 0, 0); p_glBindVertexArray(s_blit_vao); p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_blit_vbo); @@ -1476,7 +1531,7 @@ static void flush_cpu_upload(void) { int x = rects[i].x0, y = rects[i].y0; int w = rects[i].x1 - rects[i].x0 + 1; int h = rects[i].y1 - rects[i].y0 + 1; - glScissor(x * s_scale, y * s_scale, w * s_scale, h * s_scale); + glScissor(x * s_hr_scale, y * s_hr_scale, w * s_hr_scale, h * s_hr_scale); float fx0 = (float)x, fy0 = (float)y, fx1 = (float)(x + w), fy1 = (float)(y + h); float verts[6 * 2] = { fx0, fy0, fx1, fy0, fx0, fy1, @@ -1487,6 +1542,7 @@ static void flush_cpu_upload(void) { plain_stencil(1); p_glUniform1i(s_uBlitPass, 2); glDrawArrays(GL_TRIANGLES, 0, 6); } hr_end(); + hiw_mirror_uploads(rects, nrects); if (diag) s_rt_up_diag[5] += SDL_GetPerformanceCounter() - draw_t0; } @@ -1599,7 +1655,7 @@ static void rebuild_target_stencil_tiled(GLuint target_fbo, int target_w, int ta 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; + int hw = VRAM_W * s_hr_scale, hh = VRAM_H * s_hr_scale; if (s_scale <= 1) { /* Native: the historical whole-surface rebuild, unchanged. */ rebuild_target_stencil(s_hr_fbo, hw, hh); @@ -1610,24 +1666,276 @@ static void rebuild_mask_stencils(void) { } 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. */ + * margins are not in canonical coordinates), also tiled. In windowed + * mode the hr surface is 1x and the hi window takes the same rect. */ if (s_stencil_stale.set) { - int S = s_scale; + int S = s_hr_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); + if (hiw_on()) { + int x0 = s_stencil_stale.x0 > s_hiw_x0 ? s_stencil_stale.x0 : s_hiw_x0; + int x1 = s_stencil_stale.x1 + 1 < s_hiw_x1 ? s_stencil_stale.x1 + 1 : s_hiw_x1; + if (x1 > x0) + rebuild_target_stencil_tiled(s_hiw_fbo, (s_hiw_x1 - s_hiw_x0) * s_scale, + VRAM_H * s_scale, (x0 - s_hiw_x0) * s_scale, + s_stencil_stale.y0 * s_scale, (x1 - x0) * s_scale, + (s_stencil_stale.y1 - s_stencil_stale.y0 + 1) * s_scale); + } } 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); + rebuild_target_stencil_tiled(s_wide_fbo[i], g_wide_w * s_scale, + VRAM_H * s_scale, 0, 0, + g_wide_w * s_scale, VRAM_H * s_scale); } } rect_clear(&s_stencil_stale); s_stencil_valid = 1; } +/* ---- windowed high-resolution surface: mirror plumbing ------------------ */ +static int hiw_on(void) { return s_hiw && s_hiw_fbo && s_hiw_x1 > s_hiw_x0; } + +/* Bind the hi window for the mirror of a draw already issued into hr (the + * program, VAO/VBO, blend and stencil state stay as the caller set them). + * Scissor = draw area (or all of VRAM) intersected with the window, in + * window-local hr px; projection via u_xoff/u_xhalf like the wide passes, + * with the hi surface's own sample-grid shift. 0 = nothing to draw. */ +static int hiw_target_begin(int clip_to_draw_area, GLint uShift, GLint uXoff, + GLint uXhalf) { + if (!hiw_on()) return 0; + int S = s_scale, ww = s_hiw_x1 - s_hiw_x0; + int cx0 = s_hiw_x0, cx1 = s_hiw_x1, cy0 = 0, cy1 = VRAM_H; + if (clip_to_draw_area) { + if (s_area_x1 > cx0) cx0 = s_area_x1; + if (s_area_x2 + 1 < cx1) cx1 = s_area_x2 + 1; + cy0 = s_area_y1 < 0 ? 0 : s_area_y1; + cy1 = s_area_y2 + 1 > VRAM_H ? VRAM_H : s_area_y2 + 1; + } + if (cx1 <= cx0 || cy1 <= cy0) return 0; + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hiw_fbo); + glViewport(0, 0, ww * S, VRAM_H * S); + glEnable(GL_SCISSOR_TEST); + glScissor((cx0 - s_hiw_x0) * S, cy0 * S, (cx1 - cx0) * S, (cy1 - cy0) * S); + p_glUniform1f(uShift, s_shift_hi); + p_glUniform1f(uXoff, (float)(-s_hiw_x0)); + p_glUniform1f(uXhalf, (float)ww / 2.0f); + return 1; +} +/* Restore the canonical projection on the program and rebind hr. */ +static void hiw_target_end(GLint uShift, GLint uXoff, GLint uXhalf) { + p_glUniform1f(uShift, s_shift_hr); + p_glUniform1f(uXoff, 0.0f); + p_glUniform1f(uXhalf, 512.0f); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); + glViewport(0, 0, VRAM_W * s_hr_scale, VRAM_H * s_hr_scale); +} + +/* Scissored clear of [x, x+w) x [y, y+h) (native) in the hi window. Leaves the + * scissor test enabled and the hi FBO bound; callers rebind. */ +static void hiw_clear_rect(int x, int y, int w, int h, float r, float g, float b, + float a, int stencil) { + if (!hiw_on() || w <= 0 || h <= 0) return; + int x0 = x > s_hiw_x0 ? x : s_hiw_x0, x1 = x + w < s_hiw_x1 ? x + w : s_hiw_x1; + if (x1 <= x0) return; + int S = s_scale; + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hiw_fbo); + glViewport(0, 0, (s_hiw_x1 - s_hiw_x0) * S, VRAM_H * S); + glEnable(GL_SCISSOR_TEST); + glScissor((x0 - s_hiw_x0) * S, y * S, (x1 - x0) * S, h * S); + glClearColor(r, g, b, a); + glClearStencil(stencil); + glStencilMask(0xFF); + glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); +} + +/* Bind the hi blit program for the window: projection and shift set, the + * source texture on unit 0. */ +static void hiw_blit_begin(GLuint src_tex, int src_div, int off_x, int off_y, int maskset) { + int S = s_scale, ww = s_hiw_x1 - s_hiw_x0; + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hiw_fbo); + glViewport(0, 0, ww * S, VRAM_H * S); + glEnable(GL_SCISSOR_TEST); + glDisable(GL_BLEND); + p_glUseProgram(s_blit_hi_prog); + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, src_tex); + p_glUniform1i(s_uBhSrc, 0); + p_glUniform1i(s_uBhMaskset, maskset); + p_glUniform1i(s_uBhSrcDiv, src_div); + p_glUniform2i(s_uBhSrcOff, off_x, off_y); + p_glUniform1f(s_uBhShift, s_shift_hi); + p_glUniform1f(s_uBhXoff, (float)(-s_hiw_x0)); + p_glUniform1f(s_uBhXhalf, (float)ww / 2.0f); + p_glBindVertexArray(s_blit_vao); + p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_blit_vbo); +} + +static void blit_quad_verts(int x, int y, int w, int h) { + float fx0 = (float)x, fy0 = (float)y, fx1 = (float)(x + w), fy1 = (float)(y + h); + float verts[6 * 2] = { + fx0, fy0, fx1, fy0, fx0, fy1, + fx1, fy0, fx0, fy1, fx1, fy1, + }; + p_glBufferData(PSXGL_ARRAY_BUFFER, sizeof verts, verts, PSXGL_STREAM_DRAW); +} + +/* CPU->VRAM uploads into the window: the same staged native texels (s_up_tex, + * VRAM-aligned) drawn at S, texel = frag/S + window origin. */ +static void hiw_mirror_uploads(const DirtyRect *rects, int nrects) { + if (!hiw_on() || nrects <= 0) return; + int S = s_scale; + int any = 0; + for (int i = 0; i < nrects; i++) { + int x0 = rects[i].x0 > s_hiw_x0 ? rects[i].x0 : s_hiw_x0; + int x1 = rects[i].x1 + 1 < s_hiw_x1 ? rects[i].x1 + 1 : s_hiw_x1; + if (x1 <= x0) continue; + if (!any) { hiw_blit_begin(s_up_tex, S, s_hiw_x0, 0, 0); any = 1; } + int y = rects[i].y0, h = rects[i].y1 - rects[i].y0 + 1; + glScissor((x0 - s_hiw_x0) * S, y * S, (x1 - x0) * S, h * S); + blit_quad_verts(x0, y, x1 - x0, h); + plain_stencil(0); p_glUniform1i(s_uBhPass, 1); glDrawArrays(GL_TRIANGLES, 0, 6); + plain_stencil(1); p_glUniform1i(s_uBhPass, 2); glDrawArrays(GL_TRIANGLES, 0, 6); + } + if (any) hr_end(); +} + +/* VRAM->VRAM copy into the window. The canonical copy (1x) has just staged its + * source at the scratch origin (rows [0, h)); gpu_copy_rect sized the scratch + * so the window's own source fits below it (rows [h, h + h*S)). Dest columns + * whose source also lies in the window copy hi->hi at S; the rest take the 1x + * source, upscaled nearest. Both sources are staged before anything is drawn, + * so an overlapping copy reads pre-copy pixels, like the canonical one. Two + * passes by source bit15, so the window's stencil tracks the mask. */ +static void hiw_mirror_copy(int sx, int sy, int dx, int dy, int w, int h) { + if (!hiw_on()) return; + int S = s_scale; + int d0 = dx > s_hiw_x0 ? dx : s_hiw_x0; + int d1 = dx + w < s_hiw_x1 ? dx + w : s_hiw_x1; + if (d1 <= d0) return; + /* Dest columns whose source column is inside the window. */ + int shift = sx - dx; + int h0 = d0, h1 = d1; + if (s_hiw_x0 - shift > h0) h0 = s_hiw_x0 - shift; + if (s_hiw_x1 - shift < h1) h1 = s_hiw_x1 - shift; + if (h1 < h0) h1 = h0; + if (h1 > h0) { + int cw = h1 - h0, src_x = h0 + shift; + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hiw_fbo); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_scratch_fbo); + glDisable(GL_SCISSOR_TEST); + p_glBlitFramebuffer((src_x - s_hiw_x0) * S, sy * S, + (src_x - s_hiw_x0 + cw) * S, (sy + h) * S, + 0, h, cw * S, h + h * S, GL_COLOR_BUFFER_BIT, GL_NEAREST); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + } + /* 1x-sourced columns: [d0, h0) and [h1, d1), or all of [d0, d1). */ + { + int parts[2][2] = { { d0, h1 > h0 ? h0 : d1 }, { h1 > h0 ? h1 : d1, d1 } }; + int began = 0; + for (int i = 0; i < 2; i++) { + int a = parts[i][0], b = parts[i][1]; + if (b <= a) continue; + if (!began) { hiw_blit_begin(s_scratch_tex, S, s_hiw_x0 - dx, -dy, s_mask_set); began = 1; } + glScissor((a - s_hiw_x0) * S, dy * S, (b - a) * S, h * S); + blit_quad_verts(a, dy, b - a, h); + mask_stencil(s_mask_set); p_glUniform1i(s_uBhPass, 1); glDrawArrays(GL_TRIANGLES, 0, 6); + mask_stencil(1); p_glUniform1i(s_uBhPass, 2); glDrawArrays(GL_TRIANGLES, 0, 6); + } + if (began) hr_end(); + } + if (h1 > h0) { + int cw = h1 - h0; + /* texel = frag - dest origin + (0, h) (the window's source rows). */ + hiw_blit_begin(s_scratch_tex, 1, -(h0 - s_hiw_x0) * S, h - dy * S, s_mask_set); + glScissor((h0 - s_hiw_x0) * S, dy * S, cw * S, h * S); + blit_quad_verts(h0, dy, cw, h); + mask_stencil(s_mask_set); p_glUniform1i(s_uBhPass, 1); glDrawArrays(GL_TRIANGLES, 0, 6); + mask_stencil(1); p_glUniform1i(s_uBhPass, 2); glDrawArrays(GL_TRIANGLES, 0, 6); + hr_end(); + } +} + +/* Create or grow the window to cover columns [x0, x1). The new surface is + * seeded by upscaling the 1x content (nearest), the old window's pixels are + * carried over at S, and the stencil is rebuilt from alpha. Returns 0 (and + * logs once) when the grown window would exceed the driver limit or the + * memory budget; that display then presents from the 1x surface. */ +static int hiw_ensure(int x0, int x1) { + if (!s_hiw || !s_raster_ok) return 0; + if (x0 < 0) x0 = 0; + if (x1 > VRAM_W) x1 = VRAM_W; + if (x1 <= x0) return 0; + if (s_hiw_fbo && x0 >= s_hiw_x0 && x1 <= s_hiw_x1) return 1; + int nx0 = x0, nx1 = x1; + if (s_hiw_fbo) { + if (s_hiw_x0 < nx0) nx0 = s_hiw_x0; + if (s_hiw_x1 > nx1) nx1 = s_hiw_x1; + } + nx0 = nx0 / HIW_ALIGN * HIW_ALIGN; + nx1 = (nx1 + HIW_ALIGN - 1) / HIW_ALIGN * HIW_ALIGN; + if (nx1 > VRAM_W) nx1 = VRAM_W; + int S = s_scale, W = (nx1 - nx0) * S, H = VRAM_H * S; + uint64_t bytes = (uint64_t)W * (uint64_t)H * PSX_GL_HR_BYTES_PER_PX; + if ((s_gl_max_dim > 0 && (W > s_gl_max_dim || H > s_gl_max_dim)) || + (s_vram_budget && bytes > s_vram_budget)) { + if (!s_hiw_refused_logged) { + fprintf(stdout, "psxrecomp: GL high-resolution window [%d,%d) at %dx " + "(%dx%d) exceeds the GPU limit %d or the %llu MiB budget; " + "that display presents at 1x\n", nx0, nx1, S, W, H, + s_gl_max_dim, (unsigned long long)(s_vram_budget >> 20)); + s_hiw_refused_logged = 1; + } + return 0; + } + while (glGetError() != GL_NO_ERROR) {} + GLuint tex = make_tex(GL_RGBA8, W, H, GL_RGBA, GL_UNSIGNED_BYTE), rb = 0, fbo = 0; + p_glGenRenderbuffers(1, &rb); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, rb); + p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, W, H); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); + int ok = glGetError() == GL_NO_ERROR && make_fbo(&fbo, tex, rb); + if (!ok) { + if (fbo) p_glDeleteFramebuffers(1, &fbo); + p_glDeleteRenderbuffers(1, &rb); + glDeleteTextures(1, &tex); + if (!s_hiw_refused_logged) { + fprintf(stdout, "psxrecomp: GL high-resolution window %dx%d failed to " + "allocate; that display presents at 1x\n", W, H); + s_hiw_refused_logged = 1; + } + return 0; + } + /* Seed from the authoritative 1x surface, then carry the old window over. */ + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, fbo); + glDisable(GL_SCISSOR_TEST); + p_glBlitFramebuffer(nx0 * s_hr_scale, 0, nx1 * s_hr_scale, VRAM_H * s_hr_scale, + 0, 0, W, H, GL_COLOR_BUFFER_BIT, GL_NEAREST); + if (s_hiw_fbo) { + int ow = (s_hiw_x1 - s_hiw_x0) * S; + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hiw_fbo); + p_glBlitFramebuffer(0, 0, ow, H, (s_hiw_x0 - nx0) * S, 0, + (s_hiw_x0 - nx0) * S + ow, H, + GL_COLOR_BUFFER_BIT, GL_NEAREST); + p_glDeleteFramebuffers(1, &s_hiw_fbo); + p_glDeleteRenderbuffers(1, &s_hiw_rb); + glDeleteTextures(1, &s_hiw_tex); + } + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + s_hiw_tex = tex; s_hiw_rb = rb; s_hiw_fbo = fbo; + s_hiw_x0 = nx0; s_hiw_x1 = nx1; + s_hiw_grows++; + rebuild_target_stencil_tiled(s_hiw_fbo, W, H, 0, 0, W, H); + fprintf(stdout, "psxrecomp: GL high-resolution window x=[%d,%d) at %dx: %dx%d " + "(%llu MiB)\n", nx0, nx1, S, W, H, (unsigned long long)(bytes >> 20)); + return 1; +} + /* ---- coherency: hr FBO -> raw mirror (pack) ------------------------------ */ static void pack_flush(void) { if (!s_raster_ok || !s_pack_dirty.set) return; @@ -1647,7 +1955,7 @@ static void pack_flush(void) { p_glActiveTexture(PSXGL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, s_hr_tex); p_glUniform1i(s_uPackHr, 0); - p_glUniform1i(s_uPackScale, s_scale); + p_glUniform1i(s_uPackScale, s_hr_scale); p_glBindVertexArray(s_empty_vao); glDrawArrays(GL_TRIANGLES, 0, 3); p_glBindVertexArray(0); @@ -1776,6 +2084,10 @@ static void mark_prim_dirty(const int *xs, const int *ys, int n, int textured) { static void wide_target_begin(int dx, GLint uXoff, GLint uXhalf) { if (s_ws_ablate != 3) /* ablate 3: no FBO rebind (draws land in hr — perf probe) */ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, g_wide_cur); + /* Windowed high-resolution mode: the wide surfaces are at s_scale while + * the canonical pass ran at 1x, so they take the S sample-grid shift. */ + if (s_hiw) + p_glUniform1f(uXoff == s_tex_uXoff ? s_tex_uShift : s_geo_uShift, s_shift_hi); glViewport(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); glEnable(GL_SCISSOR_TEST); { @@ -1789,6 +2101,8 @@ static void wide_target_begin(int dx, GLint uXoff, GLint uXhalf) { p_glUniform1f(uXhalf, (float)g_wide_w / 2.0f); } static void wide_target_end(GLint uXoff, GLint uXhalf) { + if (s_hiw) + p_glUniform1f(uXoff == s_tex_uXoff ? s_tex_uShift : s_geo_uShift, s_shift_hr); p_glUniform1f(uXoff, 0.0f); p_glUniform1f(uXhalf, 512.0f); if (s_ws_ablate != 3) @@ -2021,6 +2335,13 @@ static void flush_tex_batch(void) { tex_batch_draw_passes(nverts, semi); + /* Windowed high-resolution surface: the same batch at S, clipped to the + * draw area inside the window. No-op unless that mode is engaged. */ + if (hiw_on() && hiw_target_begin(1, s_tex_uShift, s_tex_uXoff, s_tex_uXhalf)) { + tex_batch_draw_passes(nverts, semi); + hiw_target_end(s_tex_uShift, s_tex_uXoff, s_tex_uXhalf); + } + /* Native-wide mirror — skipped for a batch fully inside the 4:3 frame (its * centre content comes from the present-time canonical blit; nothing to add * to the margins). A backdrop-stretched batch (s_tb_gate) widens past the @@ -2075,6 +2396,10 @@ static void flush_flat_batch(void) { p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(nverts * 6 * sizeof(float)), s_fb, PSXGL_STREAM_DRAW); glDrawArrays(GL_TRIANGLES, 0, nverts); + if (hiw_on() && hiw_target_begin(1, s_geo_uShift, s_geo_uXoff, s_geo_uXhalf)) { + glDrawArrays(GL_TRIANGLES, 0, nverts); + hiw_target_end(s_geo_uShift, s_geo_uXoff, s_geo_uXhalf); + } if (g_wide_cur && !s_wide_suppress && s_ws_ablate != 1 && !(!g_ws_bd_stretch_on && mirror_flat_batch_center_only(nverts))) { @@ -2154,25 +2479,38 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, 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. */ + * native keeps GL_LINES exactly as before. The hr surface draws at + * s_hr_scale; the presentation surfaces (the high-resolution window, + * the wide surfaces) at s_scale, which differ only in windowed mode. */ float quad[6 * 6]; + const int is_line = (mode == GL_LINES && n == 2); + if (is_line && s_scale > 1) line_to_quad(verts, quad); 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); + if (is_line && s_hr_scale > 1) { 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 (draw_mode == GL_LINES) glLineWidth((float)s_scale); + if (draw_mode == GL_LINES) glLineWidth((float)s_hr_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)(draw_n * 6 * sizeof(float)), draw_verts, PSXGL_STREAM_DRAW); glDrawArrays(draw_mode, 0, draw_n); + if (is_line && s_scale > 1 && draw_mode == GL_LINES) { + /* windowed: hr drew a 1x line; the S surfaces take the quad */ + p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(6 * 6 * sizeof(float)), + quad, PSXGL_STREAM_DRAW); + draw_mode = GL_TRIANGLES; draw_n = 6; + } + if (hiw_on() && hiw_target_begin(1, s_geo_uShift, s_geo_uXoff, s_geo_uXhalf)) { + glDrawArrays(draw_mode, 0, draw_n); + hiw_target_end(s_geo_uShift, s_geo_uXoff, s_geo_uXhalf); + } 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(); @@ -2358,12 +2696,14 @@ static void wide_flat_rect_direct(int wx, int y, int ww, int h, uint16_t c, int if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); mask_stencil(s_mask_set); p_glUseProgram(s_geo_prog); + if (s_hiw) p_glUniform1f(s_geo_uShift, s_shift_hi); p_glUniform1f(s_geo_uXoff, 0.0f); p_glUniform1f(s_geo_uXhalf, (float)g_wide_w / 2.0f); p_glBindVertexArray(s_geo_vao); p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); p_glBufferData(PSXGL_ARRAY_BUFFER, sizeof verts, verts, PSXGL_STREAM_DRAW); glDrawArrays(GL_TRIANGLES, 0, 6); + if (s_hiw) p_glUniform1f(s_geo_uShift, s_shift_hr); p_glUniform1f(s_geo_uXoff, 0.0f); p_glUniform1f(s_geo_uXhalf, 512.0f); p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); @@ -2432,7 +2772,7 @@ static void gpu_textured_rect(int x,int y,int w,int h, * segment is exactly this. */ static void fill_segment(int x, int y, int w, int h, float r, float g, float b) { if (w <= 0 || h <= 0) return; - glScissor(x * s_scale, y * s_scale, w * s_scale, h * s_scale); + glScissor(x * s_hr_scale, y * s_hr_scale, w * s_hr_scale, h * s_hr_scale); glClearColor(r, g, b, 0.0f); glClearStencil(0); glStencilMask(0xFF); @@ -2455,12 +2795,18 @@ static void gpu_fill(int x,int y,int w,int h,uint16_t c) { if (y + h > VRAM_H) { h1 = VRAM_H - y; h2 = h - h1; } p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); - glViewport(0, 0, VRAM_W * s_scale, VRAM_H * s_scale); + glViewport(0, 0, VRAM_W * s_hr_scale, VRAM_H * s_hr_scale); glEnable(GL_SCISSOR_TEST); fill_segment(x, y, w1, h1, r, g, b); if (w2) fill_segment(0, y, w2, h1, r, g, b); if (h2) fill_segment(x, 0, w1, h2, r, g, b); if (w2 && h2) fill_segment(0, 0, w2, h2, r, g, b); + if (hiw_on()) { + hiw_clear_rect(x, y, w1, h1, r, g, b, 0.0f, 0); + if (w2) hiw_clear_rect(0, y, w2, h1, r, g, b, 0.0f, 0); + if (h2) hiw_clear_rect(x, 0, w1, h2, r, g, b, 0.0f, 0); + if (w2 && h2) hiw_clear_rect(0, 0, w2, h2, r, g, b, 0.0f, 0); + } glDisable(GL_SCISSOR_TEST); p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); if (!s_cpu_auth_dual) @@ -2486,10 +2832,14 @@ static void gpu_copy_rect(int sx,int sy,int dx,int dy,int w,int h) { if (dy + h > VRAM_H) h = VRAM_H - dy; if (w <= 0 || h <= 0) return; - int S = s_scale; + int S = s_hr_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. */ + * texelFetch in BLIT_FS makes the placement invisible in the result. + * With the high-resolution window on, leave room below for its own + * S-scaled source (hiw_mirror_copy): growing the scratch later would + * discard the 1x source staged here. */ + if (hiw_on()) scratch_ensure(w * s_scale, h + h * s_scale); scratch_ensure(w * S, h * S); p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_scratch_fbo); @@ -2523,6 +2873,7 @@ static void gpu_copy_rect(int sx,int sy,int dx,int dy,int w,int h) { mask_stencil(s_mask_set); p_glUniform1i(s_uBlitPass, 1); glDrawArrays(GL_TRIANGLES, 0, 6); mask_stencil(1); p_glUniform1i(s_uBlitPass, 2); glDrawArrays(GL_TRIANGLES, 0, 6); hr_end(); + hiw_mirror_copy(sx, sy, dx, dy, w, h); /* scratch still holds the 1x source */ rect_add(&s_pack_dirty, dx, dy, dx + w - 1, dy + h - 1); if (!s_cpu_auth_dual) rect_add(&s_cpu_dirty, dx, dy, dx + w - 1, dy + h - 1); @@ -2747,7 +3098,7 @@ static void depth24_clear_skipped_fb(void) { int x0 = s_d24_skip_fb.x0, y0 = s_d24_skip_fb.y0; int x1 = s_d24_skip_fb.x1, y1 = s_d24_skip_fb.y1; int rw = x1 - x0 + 1, rh = y1 - y0 + 1; - int S = s_scale; + int S = s_hr_scale; p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); glDisable(GL_BLEND); glDisable(GL_STENCIL_TEST); @@ -2758,6 +3109,7 @@ static void depth24_clear_skipped_fb(void) { glClearStencil(0); glStencilMask(0xFF); glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + hiw_clear_rect(x0, y0, rw, rh, 0.f, 0.f, 0.f, 0.f, 0); if (s_raw_fbo) { p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_raw_fbo); glViewport(0, 0, VRAM_W, VRAM_H); @@ -3079,7 +3431,35 @@ static int init_gpu_raster(void) { 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) + s_hr_scale = s_scale; + /* Beyond the full-VRAM limit, keep the full request for the displayed + * area only: a 1x authoritative surface plus the high-resolution + * window (see s_hiw). PSX_GL_HIRES_WINDOW=0 disables it (clamp as + * above); =1 forces it at any scale above 1 (testing). */ + s_hiw = 0; + s_hiw_x0 = s_hiw_x1 = 0; + s_hiw_tex = s_hiw_fbo = s_hiw_rb = 0; + s_hiw_refused_logged = 0; + s_hiw_grows = 0; + { + const char *env = getenv("PSX_GL_HIRES_WINDOW"); + int force = env && env[0] == '1', allow = !(env && env[0] == '0'); + /* Sized for the smallest framebuffer a window must hold (320). */ + int want = psx_gl_clamp_window_scale(s_req_scale, GL_MAX_INTERNAL_SCALE, + s_gl_max_dim, s_vram_budget, 320); + if (allow && want > 1 && (force || want > s_scale)) { + fprintf(stdout, "psxrecomp: GL internal scale %dx: the full-VRAM surface " + "stops at %dx here (GPU max texture %d, budget %llu MiB); keeping " + "VRAM at 1x and the displayed area at %dx (high-resolution window)\n", + s_req_scale, s_scale, s_gl_max_dim, + (unsigned long long)(s_vram_budget >> 20), want); + s_hiw = 1; + s_scale = want; + s_hr_scale = 1; + s_scale_clamp_reason = want < s_req_scale ? PSX_GL_SCALE_TEXTURE : 0; + } + } + if (!s_hiw && 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, @@ -3093,9 +3473,11 @@ static int init_gpu_raster(void) { s_geo_prog = build_program(GEO_VS, GEO_FS); s_tex_prog = build_program_ex(TEX_VS, TEX_FS, 1); s_blit_prog = build_program(BLIT_VS, BLIT_FS); + s_blit_hi_prog = build_program(BLIT_VS_HI, BLIT_FS); s_pack_prog = build_program(PACK_VS, PACK_FS); s_stencil_prog = build_program(PACK_VS, STENCIL_FS); - if (!s_geo_prog || !s_tex_prog || !s_blit_prog || !s_pack_prog || !s_stencil_prog) return 0; + if (!s_geo_prog || !s_tex_prog || !s_blit_prog || !s_pack_prog || !s_stencil_prog || + !s_blit_hi_prog) return 0; s_conv = (uint32_t *)malloc((size_t)VRAM_W * VRAM_H * sizeof(uint32_t)); if (!s_conv) return 0; @@ -3120,12 +3502,13 @@ static int init_gpu_raster(void) { /* 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; + while (!alloc_hr_targets(s_hr_scale)) { + if (s_hr_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--; + "retrying at %dx\n", VRAM_W * s_hr_scale, VRAM_H * s_hr_scale, + s_hr_scale, s_hr_scale - 1); + s_hr_scale--; + s_scale = s_hr_scale; s_scale_alloc_retries++; } if (!make_fbo(&s_raw_fbo, s_raw_tex, 0)) return 0; @@ -3150,6 +3533,14 @@ static int init_gpu_raster(void) { 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_uBhSrc = p_glGetUniformLocation(s_blit_hi_prog, "u_src"); + s_uBhPass = p_glGetUniformLocation(s_blit_hi_prog, "u_stp_pass"); + s_uBhMaskset = p_glGetUniformLocation(s_blit_hi_prog, "u_maskset"); + s_uBhSrcDiv = p_glGetUniformLocation(s_blit_hi_prog, "u_src_div"); + s_uBhSrcOff = p_glGetUniformLocation(s_blit_hi_prog, "u_src_off"); + s_uBhShift = p_glGetUniformLocation(s_blit_hi_prog, "u_shift"); + s_uBhXoff = p_glGetUniformLocation(s_blit_hi_prog, "u_xoff"); + s_uBhXhalf = p_glGetUniformLocation(s_blit_hi_prog, "u_xhalf"); 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"); @@ -3173,9 +3564,15 @@ static int init_gpu_raster(void) { * PS1 texel for |uv slope| < 64; mirrored (negative-slope) mappings can * be off by one texel at exact-integer uv — accepted. */ { - float shift = 0.5f / (float)s_scale - 1.0f / 64.0f; + /* hr at s_hr_scale; the high-resolution window and wide surfaces at + * s_scale (the same value unless windowed). */ + float shift = 0.5f / (float)s_hr_scale - 1.0f / 64.0f; + s_shift_hr = shift; + s_shift_hi = 0.5f / (float)s_scale - 1.0f / 64.0f; + s_geo_uShift = p_glGetUniformLocation(s_geo_prog, "u_shift"); + s_tex_uShift = p_glGetUniformLocation(s_tex_prog, "u_shift"); p_glUseProgram(s_geo_prog); - p_glUniform1f(p_glGetUniformLocation(s_geo_prog, "u_shift"), shift); + p_glUniform1f(s_geo_uShift, shift); /* Native-wide projection defaults: x translation 0, clip half-extent * 512 — so the GEO_VS x term reduces to (x+u_shift)/512-1, bit-identical * to the pre-native-wide projection. The wide passes set these, then @@ -3183,7 +3580,7 @@ static int init_gpu_raster(void) { p_glUniform1f(s_geo_uXoff, 0.0f); p_glUniform1f(s_geo_uXhalf, 512.0f); p_glUseProgram(s_tex_prog); - p_glUniform1f(p_glGetUniformLocation(s_tex_prog, "u_shift"), shift); + p_glUniform1f(s_tex_uShift, shift); p_glUniform1f(s_tex_uXoff, 0.0f); p_glUniform1f(s_tex_uXhalf, 512.0f); p_glUseProgram(s_blit_prog); @@ -3259,11 +3656,12 @@ static int init_gpu_raster(void) { gl_perf_init(); /* frame_perf GPU/CPU phase timing (no-op if queries absent) */ if (s_cpu_auth_dual) { fprintf(stdout, "psxrecomp: GL GPU pipeline ready (dual-raster, " - "internal scale %dx, SW@1x authority, no FBO readback)\n", - s_scale); + "internal scale %dx%s, SW@1x authority, no FBO readback)\n", + s_scale, s_hiw ? " windowed" : ""); } else { - fprintf(stdout, "psxrecomp: GL GPU pipeline ready (internal scale %dx, " - "mask-bit stencil, texture window, GPU copy/upload)\n", s_scale); + fprintf(stdout, "psxrecomp: GL GPU pipeline ready (internal scale %dx%s, " + "mask-bit stencil, texture window, GPU copy/upload)\n", s_scale, + s_hiw ? " windowed" : ""); } return 1; } @@ -3285,11 +3683,24 @@ int gl_renderer_scale_info(GlScaleInfo *out) { 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->fbo_w = s_raster_ok ? VRAM_W * s_hr_scale : 0; + out->fbo_h = s_raster_ok ? VRAM_H * s_hr_scale : 0; + out->hr_scale = s_raster_ok ? s_hr_scale : 0; + out->windowed = s_hiw; + out->window_x = s_hiw_x0; + out->window_w = s_hiw_x1 - s_hiw_x0; + out->window_fbo_w = s_hiw_fbo ? (s_hiw_x1 - s_hiw_x0) * s_scale : 0; + out->window_fbo_h = s_hiw_fbo ? VRAM_H * s_scale : 0; + out->window_grows = s_hiw_grows; 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); + /* The high-resolution window lifts the ceiling to what one 512-row + * column of VRAM can hold. */ + if (s_gl_max_dim > 0 && s_gl_max_dim / VRAM_H > out->max_scale) { + int wmax = s_gl_max_dim / VRAM_H; + out->max_scale = wmax > GL_MAX_INTERNAL_SCALE ? GL_MAX_INTERNAL_SCALE : wmax; + } if (s_ctx && s_win) SDL_GL_GetDrawableSize(s_win, &out->drawable_w, &out->drawable_h); return s_raster_ok; } @@ -3308,9 +3719,17 @@ int gl_renderer_read_display_hires(int x, int y, int w, int h, uint32_t *out, flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); + GLuint fbo = s_hr_fbo; + int rx = x; + if (s_hiw) { + /* Windowed: the S-scaled pixels exist only inside the window. */ + if (!hiw_on() || x < s_hiw_x0 || x + w > s_hiw_x1) return 0; + fbo = s_hiw_fbo; + rx = x - s_hiw_x0; + } + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); glPixelStorei(GL_PACK_ALIGNMENT, 4); - glReadPixels(x * S, y * S, W, H, GL_BGRA, GL_UNSIGNED_BYTE, out); + glReadPixels(rx * 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). */ @@ -3789,12 +4208,13 @@ static GLuint wide_fbo_for(int base_x) { 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]; /* A native-wide surface is g_wide_w*S wide, and the driver limit bounds - * it: on a 16384 limit, 1024 native columns at 16x, about 38:9 at a - * 320-px display. Fit to Window narrows its aspect to fit - * (gl_renderer_fit_wide_aspect, main.cpp); a fixed aspect on a wide - * display mode can still ask for more. Refuse that with one log line - * instead of failing mid-draw: the frame then presents 4:3 through the CPU - * path at 1x, without the native-wide margins. */ + * it: on a 16384 limit, 1024 native columns at 16x (about 38:9 at a + * 320-px display) and 910 at 18x in the window mode (about 34:9). Fit to + * Window narrows its aspect to fit (gl_renderer_fit_wide_aspect, + * main.cpp); a fixed aspect on a wide display mode can still ask for more. + * Refuse that with one log line instead of failing mid-draw: the frame + * then presents 4:3 through the CPU path at 1x, without the native-wide + * margins. */ 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)) { @@ -4839,18 +5259,34 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, * display mode (e.g. 216/224-line menus) must fill the rect as before. * The FMV band fix lives in the depth24/CPU present paths only. */ + /* Present source: the hr surface, or in windowed high-resolution mode the + * window holding this display (grown to it on first sight). A display the + * window cannot hold presents from the 1x surface without temporal + * blending (whose captures assume the S-scaled source). */ + GLuint src_fbo = s_hr_fbo, src_tex = s_hr_tex, interp_fbo = s_hr_fbo; + int src_tw = VRAM_W, src_x = disp_x, src_scale = s_scale; + if (s_hiw) { + if (hiw_ensure(disp_x, disp_x + w)) { + src_fbo = interp_fbo = s_hiw_fbo; src_tex = s_hiw_tex; + src_tw = s_hiw_x1 - s_hiw_x0; src_x = disp_x - s_hiw_x0; + } else { + src_scale = s_hr_scale; + interp_fbo = 0; + } + } + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); glDisable(GL_SCISSOR_TEST); glViewport(0, 0, ww, wh); if (lx != 0 || ly != 0 || lw != ww || lh != wh) { glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); } - int interp_pair = interp_capture(s_hr_fbo, disp_x, disp_y, w, h, + int interp_pair = interp_capture(interp_fbo, src_x, disp_y, w, h, linear, force_4_3, GL_PRES_VRAM); if (interp_pair) { /* Temporal blending owns this stock frame interval. Capture hold-last * before pacing; every blend and Swap remains on this context/thread. */ - hold_capture_native_fbo(s_hr_fbo, disp_x, disp_y, w, h, force_4_3, linear); + hold_capture_native_fbo(src_fbo, src_x, disp_y, w, h, force_4_3, linear); interp_present_source_interval(); gl_perf_present_exit(0); present_dirty_rect(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1, 0); @@ -4860,8 +5296,8 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, return; } 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, s_scale); + present_target_quad(src_tex, src_tw, VRAM_H, + src_x, disp_y, w, h, linear, lx, ly, lw, lh, 1, 1, src_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); @@ -4897,6 +5333,41 @@ static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h * column makes the wide CENTRE bit-identical to what the full mirror would * have drawn there for every band, so nothing the present reads is ever left * black. The margins (x outside the centre) are untouched. */ + if (s_hiw) { + /* Windowed high-resolution mode: the centre comes from the window at + * S; any column the window cannot hold is the 1x surface, upscaled. */ + int cx0 = base_x, cx1 = base_x + native_w; + int in = hiw_ensure(cx0, cx1); + int h0 = cx0, h1 = cx0; + if (in || hiw_on()) { + h0 = cx0 > s_hiw_x0 ? cx0 : s_hiw_x0; + h1 = cx1 < s_hiw_x1 ? cx1 : s_hiw_x1; + if (h1 < h0) h1 = h0; + } + glDisable(GL_SCISSOR_TEST); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, wide_fbo); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); + int R = s_hr_scale; + int lo_parts[2][2] = { { cx0, h1 > h0 ? h0 : cx1 }, { h1 > h0 ? h1 : cx1, cx1 } }; + for (int i = 0; i < 2; i++) { + int a = lo_parts[i][0], b = lo_parts[i][1]; + if (b <= a) continue; + p_glBlitFramebuffer(a * R, 0, b * R, VRAM_H * R, + (g_wide_off + a - cx0) * S, 0, + (g_wide_off + b - cx0) * S, VRAM_H * S, + GL_COLOR_BUFFER_BIT, GL_NEAREST); + } + if (h1 > h0) { + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hiw_fbo); + p_glBlitFramebuffer((h0 - s_hiw_x0) * S, 0, (h1 - s_hiw_x0) * S, VRAM_H * S, + (g_wide_off + h0 - cx0) * S, 0, + (g_wide_off + h1 - cx0) * S, VRAM_H * S, + GL_COLOR_BUFFER_BIT, GL_NEAREST); + } + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + return; + } p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, wide_fbo); glDisable(GL_SCISSOR_TEST); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 94c0b995..03cb90ab 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1795,11 +1795,12 @@ static void update_adaptive_widescreen() { den /= divisor; } /* Native-wide margins live in a GL surface g_wide_w*S wide, which the - * driver limit bounds (1024 native columns at 16x on a 16384 limit, about - * 38:9 at a 320-px display). The backend refuses a wider one and that - * frame drops to a 1x CPU present without the margins, so narrow the - * aspect to the widest the surface holds; the window pillarboxes the rest. - * A no-op unless the surface would be refused. */ + * driver limit bounds (on a 16384 limit, 1024 native columns at 16x and + * 910 at 8K's 18x: about 38:9 and 34:9 at a 320-px display). The backend + * refuses a wider one and that frame drops to a 1x CPU present without + * the margins, so narrow the aspect to the widest the surface holds; the + * window pillarboxes the rest. A no-op unless the surface would be + * refused. */ if (g_ws_native_wide && (int64_t)num * 3 > (int64_t)den * 4) gl_renderer_fit_wide_aspect(gpu_ws_display_width(), &num, &den); if (num == g_video_aspect_num && den == g_video_aspect_den) return; @@ -12382,8 +12383,8 @@ namespace { static int g_ir_count = 0; static char g_ir_custom_label[48]; static const char kIrNote[] = - "Above 4x needs OpenGL. The GPU's texture limit can lower the scale " - "(16x on Apple GPUs)."; + "Above 4x needs OpenGL. Past the GPU's full-VRAM limit (16x on Apple " + "GPUs) the displayed area alone renders at full resolution."; static void build_internal_resolution_vocab(int current) { g_ir_count = 0; for (int i = 0; i < PSX_IR_PRESET_COUNT; i++) { diff --git a/runtime/tests/run_gl_scale_invariance.py b/runtime/tests/run_gl_scale_invariance.py index ca38af4e..39661bd5 100644 --- a/runtime/tests/run_gl_scale_invariance.py +++ b/runtime/tests/run_gl_scale_invariance.py @@ -2,8 +2,11 @@ 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. +at internal resolution. Window runs (PSX_GL_HIRES_WINDOW=1) keep VRAM at 1x +and only the frame at S: their native digest must match too, and their frame +at internal resolution must equal the full-VRAM surface's at the same scale. +An 18x window run is 8K past a 16384 texture limit. Two clamp runs check that +an over-limit request (32x, and a tiny memory budget) stays on GL. macOS/Linux: pass the SDL3 include directory and static library (for example from a runtime build tree's _deps/sdl3-src/include and @@ -35,6 +38,11 @@ def parse_run(stdout): return int(summary[1]), int(summary[2]), digest[1] if digest else None +def parse_hires(stdout): + m = re.search(r"^hires=([0-9a-f]{16})$", stdout, re.M) + return m[1] if m else None + + def digests_agree(results): """results: {scale: digest}. All present and equal.""" values = list(results.values()) @@ -49,6 +57,7 @@ def main(): ap.add_argument("--output", required=True) ap.add_argument("--fixture", type=pathlib.Path) ap.add_argument("--scales", default="1,2,3,5,9") + ap.add_argument("--window-scales", default="2,3,5,9,18") 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] @@ -108,18 +117,35 @@ def main(): ok = True digests = {} + hires_full = {} 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:], + print(f"scale {s}: exit={r.returncode}", r.stdout.strip().splitlines()[-3:], 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 + digests[("full", s)] = parsed[2] if parsed else None + hires_full[s] = parse_hires(r.stdout) env = os.environ.copy() + wenv = dict(env) + wenv["PSX_GL_HIRES_WINDOW"] = "1" + for s in [int(v) for v in args.window_scales.split(",") if v]: + r = run([dest / "probe", s, "window"], env=wenv) + parsed = parse_run(r.stdout) + print(f"window {s}: exit={r.returncode}", r.stdout.strip().splitlines()[-3:], + r.stderr.strip()[-600:]) + if r.returncode or not parsed or parsed[1]: + ok = False + digests[("window", s)] = parsed[2] if parsed else None + h = parse_hires(r.stdout) + if s in hires_full and hires_full[s] != h: + print(f"FAIL window {s}x frame differs from the full-VRAM surface at {s}x:", + h, hires_full[s]) + ok = False + if not digests_agree(digests): + print("FAIL native VRAM digest differs across scales/modes:", digests) + ok = False for label, s, budget in (("over-limit", 32, None), ("budget", 12, "40")): e = dict(env) if budget is not None: diff --git a/runtime/tests/test_gl_internal_scale_guards.py b/runtime/tests/test_gl_internal_scale_guards.py index 09db319f..58907463 100644 --- a/runtime/tests/test_gl_internal_scale_guards.py +++ b/runtime/tests/test_gl_internal_scale_guards.py @@ -27,8 +27,14 @@ from run_gl_scale_invariance import digests_agree, parse_run # noqa: E402 def body(src, signature): + # The definition, not a prototype: the first occurrence whose next '{' + # comes before any ';'. start = src.index(signature) - brace = src.index("{", start) + while True: + brace, semi = src.find("{", start), src.find(";", start) + if brace != -1 and (semi == -1 or brace < semi): + break + start = src.index(signature, start + 1) depth = 0 for i in range(brace, len(src)): if src[i] == "{": @@ -51,11 +57,12 @@ class GlScaleGuards(unittest.TestCase): "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;") + self.assertRegex(init, r"while \(!alloc_hr_targets\(s_hr_scale\)\) \{\s*if \(s_hr_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) + self.assertIn("if (is_line && s_hr_scale > 1) {", geo) + self.assertIn("if (draw_mode == GL_LINES) glLineWidth((float)s_hr_scale);", 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" @@ -70,6 +77,30 @@ class GlScaleGuards(unittest.TestCase): self.assertIn("(g_video_renderer == 1) ? GL_MAX_INTERNAL_SCALE", MAIN) +class HiresWindowGuards(unittest.TestCase): + """Windowed high-resolution mode (true 8K past a 16384 texture limit): + engaged only beyond the full-VRAM clamp (or forced for tests), and every + mirror is a no-op unless it is.""" + + def test_engaged_only_past_full_vram(self): + init = body(GL, "static int init_gpu_raster(void)") + self.assertIn("if (allow && want > 1 && (force || want > s_scale)) {", init) + self.assertIn("s_hr_scale = 1;", init) + + def test_mirrors_gated(self): + for fn in ("static void hiw_mirror_uploads(", "static void hiw_mirror_copy(", + "static void hiw_clear_rect(", "static int hiw_target_begin("): + self.assertIn("if (!hiw_on()", body(GL, fn)) + present = body(GL, "void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear,") + self.assertIn("if (s_hiw) {", present) + self.assertIn("int src_tw = VRAM_W, src_x = disp_x, src_scale = s_scale;", present) + + def test_canonical_shaders_untouched(self): + # The window has its own blit program; BLIT_VS keeps the fixed 1024x512 + # projection the canonical path always used. + self.assertIn('" gl_Position = vec4((a_pos.x+u_shift)/512.0 - 1.0, (a_pos.y+u_shift)/256.0 - 1.0, 0.0, 1.0); }\\n";', GL) + + class InternalResolutionGuards(unittest.TestCase): def test_hidpi_window_only_when_opted_in(self): self.assertIn("win_flags |= PSX_SDL_WINDOW_HIGH_DENSITY;", MAIN) diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index f9e0ef79..560277eb 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -1,13 +1,19 @@ /* 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); + * Run once per scale (and mode) by run_gl_scale_invariance.py, which compares + * the digests across runs. Checks, per run: + * - the native VRAM the game can read back (pack of the authoritative + * surface) is the same at every scale and in every mode: supersampling + * must never change guest-visible pixels (lines excepted, see LINE_*, + * which are drawn differently above 1x and checked on their own); * - 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. + * - a request the driver cannot hold is clamped inside GL, never dropped + * to the software renderer; + * - mode "window" (PSX_GL_HIRES_WINDOW=1): the windowed high-resolution + * surface renders the frame region exactly like the full-VRAM surface at + * the same scale (the runner compares the hires digests), including copies + * whose source straddles or lies outside the window, fills and uploads + * that cross its edge, and scales past the full-VRAM limit (18x = 8K). * Original source-owned scene; no retail payload. */ #include "gpu_gl_renderer.c" #include "mod_texture_banks.c" @@ -35,24 +41,27 @@ static void check(int ok, const char *label) { 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 +/* The "displayed frame": 320x240 at the VRAM origin, the window in "window" + * mode. Lines live in LINE_* inside it, over a black fill. */ +#define FRAME_W 320 +#define FRAME_H 240 +#define LINE_X0 200 +#define LINE_Y0 100 +#define LINE_X1 300 +#define LINE_Y1 140 -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; } +static uint64_t fnv(const void *pp, size_t n, uint64_t h) { + const uint8_t *p = (const uint8_t *)pp; + for (size_t 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]; + static uint16_t page[64*64], clut[16], patch[16*8]; 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); + for (int i = 0; i < 16*8; i++) patch[i] = (uint16_t)(0x0C63 + i * 0x0101); glb_vram_transfer_in(512, 0, 64, 64, page); glb_vram_transfer_in(512, 256, 16, 1, clut); @@ -62,8 +71,7 @@ static void scene(void) { 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_fill_rect(0, 0, FRAME_W, FRAME_H, 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); @@ -88,23 +96,31 @@ static void scene(void) { 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. */ + /* Copies: overlapping inside the frame; from an off-screen area in; and + * one whose source straddles the frame's right edge (x 300..339). */ 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); + glb_draw_flat_rect(318, 150, 30, 20, 0x2d6b); + glb_copy_rect(300, 150, 250, 160, 40, 20); + /* A fill and an upload that cross the frame's edge. */ + glb_fill_rect(300, 220, 40, 10, 0x1234); + glb_vram_transfer_in(312, 60, 16, 8, patch); /* 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); + glb_draw_textured_rect(200, 200, 32, 32, 0, 64, 512, 256, 0x0009); - /* Lines, inside LINE_BAND only. */ + /* Lines over a black band. */ + glb_fill_rect(LINE_X0, LINE_Y0, LINE_X1 - LINE_X0, LINE_Y1 - LINE_Y0, 0); 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 */ + glb_draw_line(LINE_X0 + 5, LINE_Y0 + 20, LINE_X0 + 45, LINE_Y0 + 35, 0x03ff); /* diagonal */ + glb_draw_shaded_line(LINE_X0 + 90, LINE_Y0 + 2, 0x001f, LINE_X0 + 92, LINE_Y0 + 38, 0x7c00); /* steep */ } int main(int argc, char **argv) { int scale = argc > 1 ? atoi(argv[1]) : 1; const char *mode = argc > 2 ? argv[2] : "scene"; + int window = !strcmp(mode, "window"); 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); @@ -122,18 +138,26 @@ int main(int argc, char **argv) { 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); + printf("driver=%s max_dim=%d requested=%d effective=%d windowed=%d hr_scale=%d\n", + (const char *)glGetString(GL_VERSION), si.max_dim, si.requested, si.effective, + si.windowed, si.hr_scale); 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, + /* Whatever was requested, the backend stays GL and never allocates + * past the driver limit. With the window allowed (default) a request + * past the full-VRAM limit keeps its scale for the displayed area. */ + int full = 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"); + if (si.windowed) { + check(si.hr_scale == 1, "windowed: authoritative surface at 1x"); + check(si.effective > full && (long)si.effective * 512 <= si.max_dim, + "windowed: effective scale above the full-VRAM clamp, rows within the limit"); + } else { + check(si.effective == full, "effective scale is the limit-clamped request"); + } + check(psx_gl_full_vram_fits(si.hr_scale, si.max_dim, 0), "hr 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(); @@ -141,45 +165,43 @@ int main(int argc, char **argv) { } check(si.effective == scale, "requested scale allocated"); + if (window) { + check(si.windowed && si.hr_scale == 1, "window mode engaged"); + check(hiw_ensure(0, FRAME_W), "window covers the frame"); + } 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"); + uint64_t digest = fnv(peek, sizeof peek, 0xcbf29ce484222325ull); - /* 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); + /* The frame at internal resolution. */ + int fw = FRAME_W * scale, fh = FRAME_H * scale; + uint32_t *img = (uint32_t *)malloc((size_t)fw * fh * 4); + int ow = 0, oh = 0; + int n = img ? gl_renderer_read_display_hires(0, 0, FRAME_W, FRAME_H, img, fw * fh, &ow, &oh) : 0; + check(n == fw * fh && ow == fw && oh == fh, "hires readback size"); + if (n) { + /* Line thickness: the horizontal line covers S rows at x=230; the + * steep line S columns at y=120. */ + int col = 230 * scale + scale / 2, rows = 0; + for (int yy = LINE_Y0 * scale; yy < LINE_Y1 * scale; yy++) + if ((img[yy * ow + col] & 0xFFFFFFu) == 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 = 120 * scale + scale / 2, cols = 0; + for (int xx = 285 * scale; xx < 298 * scale; xx++) + if ((img[row * ow + xx] & 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"); } + uint64_t hires = n ? fnv(img, (size_t)fw * fh * 4, 0xcbf29ce484222325ull) : 0; + free(img); check(glGetError() == GL_NO_ERROR, "GL error"); printf("digest=%016llx\n", (unsigned long long)digest); + printf("hires=%016llx\n", (unsigned long long)hires); printf("checks=%d failures=%d\n", checks, failures); gl_renderer_shutdown(); SDL_DestroyWindow(win); diff --git a/runtime/tests/test_gl_scale_limits.c b/runtime/tests/test_gl_scale_limits.c index 324a8443..bad753e4 100644 --- a/runtime/tests/test_gl_scale_limits.c +++ b/runtime/tests/test_gl_scale_limits.c @@ -89,6 +89,13 @@ int main(void) { } } } + /* Windowed mode: 512 rows bound S at 32 on a 16384 limit; 8K (18x) fits, + * as does the 2 GiB budget for a 320-wide window (405 MiB at 18x). */ + expect_int("window 18x", psx_gl_clamp_window_scale(18, 32, 16384, budget, 320), 18); + expect_int("window 40x", psx_gl_clamp_window_scale(40, 32, 16384, budget, 320), 32); + expect_int("window bytes 18x", (long long)(psx_gl_window_bytes(18, 320) >> 20), 405); + expect_int("window 8192 limit", psx_gl_clamp_window_scale(18, 32, 8192, budget, 320), 16); + expect_int("window budget", psx_gl_clamp_window_scale(18, 32, 16384, 40 * MiB, 320), 5); /* Budget override parsing. */ expect_int("env null", (long long)psx_gl_budget_bytes_from_env(NULL), (long long)budget);