From f8a29e8a9789be83e146bfc12ea99cc6a1d91c33 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 12 Jun 2026 17:54:49 -0700 Subject: [PATCH] =?UTF-8?q?widescreen:=20[video]=20aspect=5Fratio=20?= =?UTF-8?q?=E2=80=94=20GTE=20X-squash=20+=20stretched=20present?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the DuckStation-style widescreen hack as a config option, implemented in the runtime (no regen needed): - gte.cpp: gte_set_display_aspect(num, den). RTPS/RTPT scale the IR1*h/sz screen-X term by (4*den)/(3*num) around the game's own OFX projection centre (identity at 4:3). Games cull against the SXY values they read back from us, so post-projection screen- bounds culls stay aligned with the visible frame. - present: GL letterbox_rect generalised to the configured aspect (gl_renderer_set_display_aspect); SDL_Renderer path gets an aspect-derived logical size + dst rect. Window opens at the configured aspect (clamp_window_4x3 -> clamp_window_aspect). - config: [video] aspect_ratio = W:H (4:3 default, 16:9, up to 32:9) in game.toml, settings.toml (load+save), launcher seed. World geometry keeps correct proportions on the stretched present; screen-space 2D (HUD, FMV, sprite quad widths) stretches — the known limitation of the squash tier. EXPERIMENTAL. Co-Authored-By: Claude Fable 5 --- recompiler/src/config_loader.cpp | 45 ++++++++++++++++++ recompiler/src/config_loader.h | 11 +++++ runtime/include/cpu_state.h | 4 ++ runtime/include/gpu_gl_renderer.h | 7 ++- runtime/src/gpu_gl_renderer.c | 16 +++++-- runtime/src/gte.cpp | 29 +++++++++++- runtime/src/main.cpp | 76 +++++++++++++++++++++---------- 7 files changed, 160 insertions(+), 28 deletions(-) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 221ce190..75feb206 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -28,6 +28,32 @@ static uint32_t parse_hex(const std::string& s, const std::string& field) { } } +// Parse a display aspect "W:H" string (e.g. "4:3", "16:9", "21:9"). Accepts +// only aspects between native 4:3 and 32:9 — narrower than 4:3 has no +// widescreen meaning and ultra-extreme values would squash the GTE X axis +// into unusability. Returns false on malformed input or out-of-range aspect. +static bool parse_aspect_ratio(const std::string& s, int* num, int* den) { + const size_t colon = s.find(':'); + if (colon == std::string::npos || colon == 0 || colon + 1 >= s.size()) + return false; + int n = 0, d = 0; + try { + size_t used = 0; + n = std::stoi(s.substr(0, colon), &used); + if (used != colon) return false; + const std::string rest = s.substr(colon + 1); + d = std::stoi(rest, &used); + if (used != rest.size()) return false; + } catch (const std::exception&) { + return false; + } + if (n <= 0 || d <= 0 || n > 99 || d > 99) return false; + if (3 * n < 4 * d) return false; // narrower than 4:3 + if (9 * n > 32 * d) return false; // wider than 32:9 + *num = n; *den = d; + return true; +} + // Parse the optional [runtime] block. All fields optional; absent fields // leave has_* = false on the returned struct. Paths are resolved relative // to `root` (project root). @@ -121,6 +147,16 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& "[video] crt_filter must be \"raw\"|\"crt\"|\"composite\"|\"trinitron\": {}", mode)); } + if (video.contains("aspect_ratio")) { + const auto mode = toml::find(video, "aspect_ratio"); + int n = 0, d = 0; + if (!parse_aspect_ratio(mode, &n, &d)) + throw std::runtime_error(fmt::format( + "[video] aspect_ratio must be \"W:H\", no narrower than 4:3 " + "and no wider than 32:9 (e.g. \"4:3\", \"16:9\"): {}", mode)); + rt.video_aspect_num = n; + rt.video_aspect_den = d; + } } // Optional [audio] block. @@ -504,6 +540,13 @@ UserSettings load_user_settings(const fs::path& path) { else if (m == "composite") { s.screen_kind = 2; s.has_screen_kind = true; } else if (m == "trinitron") { s.screen_kind = 3; s.has_screen_kind = true; } }); + if (v.contains("aspect_ratio")) try_get([&]{ + const auto m = toml::find(v, "aspect_ratio"); + int n = 0, d = 0; + if (parse_aspect_ratio(m, &n, &d)) { + s.aspect_num = n; s.aspect_den = d; s.has_aspect_ratio = true; + } + }); } if (doc.contains("audio")) { const toml::value& a = toml::find(doc, "audio"); @@ -600,6 +643,8 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { : s.screen_kind == 3 ? "trinitron" : "raw"; f << "crt_filter = \"" << k << "\"\n"; } + if (s.has_aspect_ratio) + f << "aspect_ratio = \"" << s.aspect_num << ":" << s.aspect_den << "\"\n"; f << "\n[audio]\n"; if (s.has_spu_hq) f << "spu_hq = " << (s.spu_hq ? "true" : "false") << "\n"; diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index b6da41a0..65d3e13a 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -105,6 +105,15 @@ struct RuntimeConfig { // PSX_SCREEN env var overrides this at runtime (debug path). int video_screen_kind = 0; + // aspect_ratio: display aspect "W:H" (default "4:3" = native). A wider + // aspect (e.g. "16:9") enables the widescreen hack: the GTE squashes + // screen-X by (4*H)/(3*W) around the game's projection centre and the + // present path stretches the 4:3 frame to W:H — net effect is a wider + // field of view for GTE-projected geometry. Screen-space 2D (HUD, FMV, + // sprite widths) stretches; world geometry keeps correct proportions. + int video_aspect_num = 4; + int video_aspect_den = 3; + // ---- [audio] block ---- // spu_hq: enable the SPU float-shadow re-render (Catmull-Rom resample, float // headroom). Verified-enhancement, default OFF — spu_render output is @@ -217,6 +226,8 @@ struct UserSettings { bool has_antialiasing = false; bool antialiasing = true; bool has_texture_filter = false; int texture_filter = 0; // 0=nearest,1=bilinear bool has_screen_kind = false; int screen_kind = 0; // 0..3 (ScreenKind) + bool has_aspect_ratio = false; int aspect_num = 4; // display aspect W:H + int aspect_den = 3; // (4:3 = native) // [audio] bool has_spu_hq = false; bool spu_hq = false; // [bios] / [disc] / [memcard] diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index e473508d..89722e52 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -50,6 +50,10 @@ extern void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys); /* GTE (COP2) — defined in runtime/src/gte.cpp */ extern void gte_execute(CPUState* cpu, uint32_t cmd); +/* Widescreen X-squash: display aspect num:den (4,3 = identity/off). Scales + * RTPS/RTPT screen-X around the game's OFX so a 4:3 frame stretched to the + * wide aspect shows a wider field of view. */ +extern void gte_set_display_aspect(int num, int den); extern uint32_t gte_read_data(CPUState* cpu, uint8_t reg); extern uint32_t gte_read_ctrl(CPUState* cpu, uint8_t reg); extern void gte_write_data(CPUState* cpu, uint8_t reg, uint32_t val); diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index ceeb0536..91c56cac 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -32,10 +32,15 @@ void gl_renderer_present_blank(void); void gl_renderer_sync_cpu(void); /* THE present path for 15-bit frames: blit the display region straight from - * the authoritative VRAM FBO into a 4:3 letterboxed rect (no readback). + * the authoritative VRAM FBO into a letterboxed rect (no readback). * Deterministic — used for every 15-bit frame. linear = filter on scale. */ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear); +/* Display aspect for the present letterbox (default 4:3). A wide aspect + * stretches the 4:3 frame; pair with gte_set_display_aspect (cpu_state.h) + * for the widescreen field-of-view hack. */ +void gl_renderer_set_display_aspect(int num, int den); + void gl_renderer_shutdown(void); /* Diagnostics (debug server): read GPU-side VRAM without touching the CPU diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 2085723b..e4776d98 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -1110,10 +1110,20 @@ static void upload_present_tex(const uint32_t *pixels, int w, int h, int linear) } } -/* 4:3 letterbox: largest 4:3 rect centered in the drawable. */ +/* Display aspect for the present letterbox. Default 4:3 (native). When a wide + * aspect is configured the 4:3 frame is stretched into it — paired with the + * GTE X-squash (gte_set_display_aspect) this nets a wider field of view. */ +static int s_aspect_num = 4, s_aspect_den = 3; + +void gl_renderer_set_display_aspect(int num, int den) { + if (num <= 0 || den <= 0) { num = 4; den = 3; } + s_aspect_num = num; s_aspect_den = den; +} + +/* Letterbox: largest s_aspect rect centered in the drawable. */ static void letterbox_rect(int ww, int wh, int *x, int *y, int *w, int *h) { - int dw = ww, dh = (ww * 3) / 4; - if (dh > wh) { dh = wh; dw = (wh * 4) / 3; } + int dw = ww, dh = (ww * s_aspect_den) / s_aspect_num; + if (dh > wh) { dh = wh; dw = (wh * s_aspect_num) / s_aspect_den; } *x = (ww - dw) / 2; *y = (wh - dh) / 2; *w = dw; *h = dh; diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 068f5d6c..013db3e8 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -105,6 +105,31 @@ static void depth_cue_from_ir(GTEState* gte) { gte->IR3 = gte->saturate_ir(gte->MAC3, 3, true); } +// --------------------------------------------------------------------------- +// Widescreen X-squash (verified-enhancement, default off = identity). +// +// For a display aspect wider than the native 4:3, screen-space X is scaled by +// (4*den)/(3*num) around the projection centre OFX — e.g. 3/4 for 16:9 — and +// the present path stretches the 4:3 frame to the wide aspect, netting a wider +// horizontal field of view (the DuckStation/Beetle "widescreen hack", but +// applied in our GTE library so every RTPS/RTPT caller — generated code, +// interpreter, overlay DLLs — sees it). Only the IR1*h/sz term is scaled, NOT +// OFX, so the squash is centred on the game's own projection centre and +// games' post-projection screen-bounds culls (which read SXY back from us) +// stay aligned with the visible frame. +// --------------------------------------------------------------------------- +static int32_t s_ws_xnum = 1, s_ws_xden = 1; + +extern "C" void gte_set_display_aspect(int num, int den) { + if (num <= 0 || den <= 0) { s_ws_xnum = s_ws_xden = 1; return; } + // squash = (4/3) / (num/den) = (4*den) / (3*num); identity for 4:3. + int32_t n = 4 * den, d = 3 * num; + int32_t a = n, b = d; + while (b) { int32_t t = a % b; a = b; b = t; } // gcd + s_ws_xnum = n / a; + s_ws_xden = d / a; +} + // --------------------------------------------------------------------------- // RTPS — Perspective Transformation (internal, operates on given vertex V) // --------------------------------------------------------------------------- @@ -141,7 +166,9 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0) { int32_t h_div_sz = gte_divide(gte->H, gte->SZ[3], gte->FLAG); // Step 4: Project to screen coordinates - int64_t sx = (gte->OFX + (int64_t)gte->IR1 * h_div_sz) >> 16; + int64_t xterm = (int64_t)gte->IR1 * h_div_sz; + if (s_ws_xnum != s_ws_xden) xterm = xterm * s_ws_xnum / s_ws_xden; + int64_t sx = (gte->OFX + xterm) >> 16; int64_t sy = (gte->OFY + (int64_t)gte->IR2 * h_div_sz) >> 16; gte->push_sxy(sx, sy); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index fedede8e..a52edbcb 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -118,22 +118,31 @@ static bool g_video_aa = true; /* linear present filtering */ static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ static int g_video_renderer = 0; /* 0=software, 1=opengl (requested) */ static int g_video_screen = 0; /* 0=raw,1=crt,2=composite,3=trinitron */ -static int g_video_win_w = 1280; /* window width (height = w*3/4, 4:3) */ +static int g_video_win_w = 1280; /* window width (height follows aspect) */ static bool g_audio_spu_hq = false; /* SPU float-shadow (env overrides) */ +/* Display aspect W:H (default 4:3 = native). Wider aspects enable the + * widescreen hack: GTE X-squash + stretched present (see [video] aspect_ratio + * in config_loader.h). */ +static int g_video_aspect_num = 4; +static int g_video_aspect_den = 3; +/* Logical present width for the SDL_Renderer (software) path; 640*scale at + * 4:3, wider for wide aspects. Height is always 480*scale. Set at window + * creation alongside SDL_RenderSetLogicalSize. */ +static int g_logical_w = 640; -/* Clamp a requested 4:3 window width to the primary display's usable area so an - * oversized choice (e.g. 1920 on a 1080p panel) still fits on screen. Keeps the - * 4:3 aspect: height = width*3/4. */ -static void clamp_window_4x3(int* w, int* h) { +/* Clamp a requested window width to the primary display's usable area so an + * oversized choice (e.g. 1920 on a 1080p panel) still fits on screen. Keeps + * the given aspect: height = width*den/num. */ +static void clamp_window_aspect(int* w, int* h, int num, int den) { int width = *w; if (width < 640) width = 640; SDL_Rect bounds; if (SDL_GetDisplayUsableBounds(0, &bounds) == 0 && bounds.w > 0 && bounds.h > 0) { - if (width > bounds.w) width = bounds.w; - if (width * 3 / 4 > bounds.h) width = bounds.h * 4 / 3; + if (width > bounds.w) width = bounds.w; + if (width * den / num > bounds.h) width = bounds.h * num / den; } *w = width; - *h = width * 3 / 4; + *h = width * den / num; } static bool g_gl_active = false; /* GL context live -> GL present path */ /* Present straight from the FBO (fast, no readback). Set PSX_GL_FORCE_CPU_PRESENT=1 @@ -1250,7 +1259,7 @@ static void sdl_vblank_present(void) { SDL_UpdateTexture(sdl_texture, &src, sdl_pixel_buf, (int)(src_w * sizeof(uint32_t))); - SDL_Rect dst = { 0, 0, 640 * g_video_scale, 480 * g_video_scale }; + SDL_Rect dst = { 0, 0, g_logical_w, 480 * g_video_scale }; SDL_RenderClear(sdl_renderer); SDL_RenderCopy(sdl_renderer, sdl_texture, &src, &dst); @@ -1367,12 +1376,14 @@ int main(int argc, char** argv) { g_turbo_loads_enabled = 1; std::fprintf(stdout, "psxrecomp: turbo_loads enabled (opt-in)\n"); } - g_video_scale = gc.runtime.video_supersampling; - g_video_aa = gc.runtime.video_antialiasing; - g_video_texfilter = gc.runtime.video_texture_filter; - g_video_renderer = gc.runtime.video_renderer; - g_video_screen = gc.runtime.video_screen_kind; - g_audio_spu_hq = gc.runtime.audio_spu_hq; + g_video_scale = gc.runtime.video_supersampling; + g_video_aa = gc.runtime.video_antialiasing; + g_video_texfilter = gc.runtime.video_texture_filter; + g_video_renderer = gc.runtime.video_renderer; + g_video_screen = gc.runtime.video_screen_kind; + g_video_aspect_num = gc.runtime.video_aspect_num; + g_video_aspect_den = gc.runtime.video_aspect_den; + g_audio_spu_hq = gc.runtime.audio_spu_hq; { const char *e = std::getenv("PSX_GL_FORCE_CPU_PRESENT"); if (e && e[0] && e[0] != '0') g_gl_fbo_present = 0; } game_entry_pc = gc.entry_pc; @@ -1428,6 +1439,10 @@ int main(int argc, char** argv) { if (us.has_antialiasing) g_video_aa = us.antialiasing; if (us.has_texture_filter) g_video_texfilter = us.texture_filter; if (us.has_screen_kind) g_video_screen = us.screen_kind; + if (us.has_aspect_ratio) { + g_video_aspect_num = us.aspect_num; + g_video_aspect_den = us.aspect_den; + } if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; if (us.has_bios_path && !bios_from_cli) { settings_bios_storage = us.bios_path.string(); @@ -1465,6 +1480,8 @@ int main(int argc, char** argv) { seed.antialiasing = g_video_aa; seed.has_antialiasing = true; seed.texture_filter = g_video_texfilter; seed.has_texture_filter = true; seed.screen_kind = g_video_screen; seed.has_screen_kind = true; + seed.aspect_num = g_video_aspect_num; + seed.aspect_den = g_video_aspect_den; seed.has_aspect_ratio = true; seed.spu_hq = g_audio_spu_hq; seed.has_spu_hq = true; if (bios_path && bios_path[0]) { seed.bios_path = bios_path; seed.has_bios_path = true; } if (!resolved_disc.empty()) { seed.disc_path = resolved_disc; seed.has_disc_path = true; } @@ -1490,7 +1507,7 @@ int main(int argc, char** argv) { * window is set smaller. */ int lwin_w = g_video_win_w < 1280 ? 1280 : g_video_win_w; int lwin_h = 0; - clamp_window_4x3(&lwin_w, &lwin_h); + clamp_window_aspect(&lwin_w, &lwin_h, 4, 3); std::string lwin_title = (game_name.empty() ? std::string("PSX") : game_name) + " \xE2\x80\x94 Launcher"; SDL_Window* lwin = SDL_CreateWindow( @@ -1526,6 +1543,8 @@ int main(int argc, char** argv) { g_video_aa = seed.antialiasing; g_video_texfilter = seed.texture_filter; g_video_screen = seed.screen_kind; + g_video_aspect_num = seed.aspect_num; + g_video_aspect_den = seed.aspect_den; g_audio_spu_hq = seed.spu_hq; if (seed.has_bios_path) { settings_bios_storage = seed.bios_path.string(); @@ -1583,6 +1602,14 @@ int main(int argc, char** argv) { gr_set_scale(g_video_scale); g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ gr_set_texture_filter(g_video_texfilter); + /* Display aspect (widescreen hack). Identity at the default 4:3. The GTE + * squashes screen-X around the game's projection centre; both present + * paths stretch the 4:3 frame to the configured aspect. */ + gte_set_display_aspect(g_video_aspect_num, g_video_aspect_den); + gl_renderer_set_display_aspect(g_video_aspect_num, g_video_aspect_den); + if (g_video_aspect_num * 3 != g_video_aspect_den * 4) + std::fprintf(stdout, "psxrecomp: widescreen %d:%d (GTE X-squash + stretched present)\n", + g_video_aspect_num, g_video_aspect_den); /* Present-time screen-colour model (verified-enhancement LUT). Default raw * is byte-identical; PSX_SCREEN env overrides this at scanout. */ gpu_set_screen_kind(g_video_screen); @@ -1688,12 +1715,13 @@ int main(int argc, char** argv) { Uint32 win_flags = SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE; if (g_video_renderer == 1) win_flags |= SDL_WINDOW_OPENGL; - /* Open at the user-chosen 4:3 window size (default 1280x960) instead of the + /* Open at the user-chosen window size (default 1280 wide) instead of the * old hardcoded 640x480, so the game doesn't boot into a tiny window. The - * present path keeps a 640x480 logical space, so the image scales to fill - * the larger window with no aspect distortion. */ + * height follows the configured display aspect (4:3 native, wider for the + * widescreen hack); the present path letterboxes to the same aspect, so + * the image scales to fill the larger window with no further distortion. */ int game_w = g_video_win_w, game_h = 0; - clamp_window_4x3(&game_w, &game_h); + clamp_window_aspect(&game_w, &game_h, g_video_aspect_num, g_video_aspect_den); sdl_window = SDL_CreateWindow( window_title.c_str(), SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, @@ -1756,12 +1784,14 @@ int main(int argc, char** argv) { return 1; } - /* Present in a 640x480 logical space (scaled by the supersampling factor - * so the full internal resolution reaches a large/fullscreen window; SDL + /* Present in a logical space of the configured aspect (640x480 at native + * 4:3, wider at widescreen aspects; scaled by the supersampling factor so + * the full internal resolution reaches a large/fullscreen window; SDL * still scales and letterboxes to the real output). Identity in the * default window when supersampling is off, so native rendering is * unchanged. */ - SDL_RenderSetLogicalSize(sdl_renderer, 640 * g_video_scale, 480 * g_video_scale); + g_logical_w = 480 * g_video_aspect_num * g_video_scale / g_video_aspect_den; + SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * g_video_scale); } /* Staging buffer + backing texture are sized for the internal resolution