widescreen: [video] aspect_ratio — GTE X-squash + stretched present

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 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-12 17:54:49 -07:00
parent 8e2ad53424
commit be76e4d4b5
7 changed files with 162 additions and 29 deletions
+45
View File
@@ -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).
@@ -124,6 +150,16 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path&
if (video.contains("auto_skip_fmv")) {
rt.video_auto_skip_fmv = toml::find<bool>(video, "auto_skip_fmv");
}
if (video.contains("aspect_ratio")) {
const auto mode = toml::find<std::string>(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.
@@ -510,6 +546,13 @@ UserSettings load_user_settings(const fs::path& path) {
if (v.contains("auto_skip_fmv")) try_get([&]{
s.auto_skip_fmv = toml::find<bool>(v, "auto_skip_fmv"); s.has_auto_skip_fmv = true;
});
if (v.contains("aspect_ratio")) try_get([&]{
const auto m = toml::find<std::string>(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");
@@ -614,6 +657,8 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) {
}
if (s.has_auto_skip_fmv)
f << "auto_skip_fmv = " << (s.auto_skip_fmv ? "true" : "false") << "\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";
+11
View File
@@ -113,6 +113,15 @@ struct RuntimeConfig {
// all side effects (scene transition, save flags) happen exactly as normal.
bool video_auto_skip_fmv = false;
// 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
@@ -230,6 +239,8 @@ struct UserSettings {
// launcher window (mirrors snesrecomp's SkipLauncher). Overridable per-run:
// `--launcher` forces the GUI back on; `PSX_NO_LAUNCHER=1` forces it off.
bool has_skip_launcher = false; bool skip_launcher = false;
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]
+4
View File
@@ -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);
+6 -1
View File
@@ -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
+13 -3
View File
@@ -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;
+28 -1
View File
@@ -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);
+55 -24
View File
@@ -118,7 +118,7 @@ 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) */
static int g_auto_skip_fmv = 0; /* fast-forward FMVs invisibly to EOF */
@@ -126,19 +126,29 @@ static int g_auto_skip_fmv = 0; /* fast-forward FMVs invisibly to E
extern "C" int cdrom_xa_stream_active(void);
extern "C" uint32_t mdec_get_decode_count(void);
/* 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) {
/* 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 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
@@ -1295,7 +1305,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);
@@ -1423,13 +1433,15 @@ 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_auto_skip_fmv = gc.runtime.video_auto_skip_fmv ? 1 : 0;
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;
g_auto_skip_fmv = gc.runtime.video_auto_skip_fmv ? 1 : 0;
{ 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;
@@ -1488,6 +1500,10 @@ int main(int argc, char** argv) {
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_auto_skip_fmv) g_auto_skip_fmv = us.auto_skip_fmv ? 1 : 0;
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();
@@ -1533,6 +1549,8 @@ int main(int argc, char** argv) {
seed.texture_filter = g_video_texfilter; seed.has_texture_filter = true;
seed.screen_kind = g_video_screen; seed.has_screen_kind = true;
seed.auto_skip_fmv = (g_auto_skip_fmv != 0); seed.has_auto_skip_fmv = 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;
seed.skip_launcher = skip_launcher_setting; seed.has_skip_launcher = true;
if (bios_path && bios_path[0]) { seed.bios_path = bios_path; seed.has_bios_path = true; }
@@ -1559,7 +1577,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(
@@ -1596,6 +1614,8 @@ int main(int argc, char** argv) {
g_video_texfilter = seed.texture_filter;
g_video_screen = seed.screen_kind;
g_auto_skip_fmv = seed.auto_skip_fmv ? 1 : 0;
g_video_aspect_num = seed.aspect_num;
g_video_aspect_den = seed.aspect_den;
g_audio_spu_hq = seed.spu_hq;
skip_launcher_setting = seed.skip_launcher;
if (seed.has_bios_path) {
@@ -1654,6 +1674,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);
@@ -1759,12 +1787,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,
@@ -1826,12 +1855,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