launcher: 4:3 window-size picker for launcher + game

The game window was hardcoded to 640x480, so it booted into a tiny window
while the launcher was much larger — a jarring shrink on LAUNCH. Add a
window-size setting that both windows honor, with a default that matches.

- config_loader: settings.toml [video] window_width (height always w*3/4 to
  keep the PSX 4:3 aspect); load/save, validated 640..3840.
- main.cpp: the game window now opens at the chosen size (default 1280x960)
  instead of 640x480; the launcher opens at the same size (floored at 1280
  wide so the dense dashboard stays usable). clamp_window_4x3() fits an
  oversized choice (e.g. 1920 on a 1080p panel) to the display's usable area.
  Both present paths already scale a 640x480 logical space to the window, so
  the larger window just fills with no aspect distortion.
- launcher: "Window size (4:3)" toggle in the VIDEO settings cycling
  960x720 / 1280x960 / 1600x1200 / 1920x1440; seeded from settings, persisted
  on launch.

Verified: default launcher and game windows both open at 1280x960 (matched,
no resize jump); settings.toml window_width=960 boots the game at 960x720 and
the PS logo renders correct 4:3 with no distortion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-12 15:29:02 -07:00
parent 5f0654f188
commit 4ea9ecd2a2
5 changed files with 76 additions and 2 deletions
+6
View File
@@ -485,6 +485,10 @@ UserSettings load_user_settings(const fs::path& path) {
const auto n = toml::find<int64_t>(v, "supersampling");
if (n >= 1 && n <= 4) { s.supersampling = (int)n; s.has_supersampling = true; }
});
if (v.contains("window_width")) try_get([&]{
const auto n = toml::find<int64_t>(v, "window_width");
if (n >= 640 && n <= 3840) { s.window_width = (int)n; s.has_window_width = true; }
});
if (v.contains("antialiasing")) try_get([&]{
s.antialiasing = toml::find<bool>(v, "antialiasing"); s.has_antialiasing = true;
});
@@ -584,6 +588,8 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) {
f << "renderer = \"" << (s.renderer ? "opengl" : "software") << "\"\n";
if (s.has_supersampling)
f << "supersampling = " << s.supersampling << "\n";
if (s.has_window_width)
f << "window_width = " << s.window_width << "\n";
if (s.has_antialiasing)
f << "antialiasing = " << (s.antialiasing ? "true" : "false") << "\n";
if (s.has_texture_filter)
+3
View File
@@ -211,6 +211,9 @@ struct UserSettings {
// [video]
bool has_renderer = false; int renderer = 0; // 0=software,1=opengl
bool has_supersampling = false; int supersampling = 1; // 1..4
// Window size: width in px; height is always width*3/4 (PSX 4:3). Applies to
// both the launcher and the emulator window so they boot at the same size.
bool has_window_width = false; int window_width = 1280; // -> 1280x960
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)
+3
View File
@@ -146,6 +146,7 @@ button:hover { background-color: #232d3c; }
.set-lbl { width: 230dp; flex: 0 0 230dp; color: #adbac7; }
.set-ctl { flex: 1 1 auto; display: flex; flex-direction: row; align-items: center; }
.toggle { width: 132dp; text-align: center; }
.toggle.wide { width: 164dp; }
.value { color: #ffffff; font-weight: bold; }
.path { flex: 1 1 auto; min-width: 0dp; margin-left: 12dp; color: #7d8590;
white-space: nowrap; overflow: hidden; }
@@ -300,6 +301,8 @@ button:hover { background-color: #232d3c; }
<div data-if="view == 'settings'">
<div class="panel">
<div class="eyebrow">VIDEO</div>
<div class="set-row"><span class="set-lbl">Window size (4:3)</span><span class="set-ctl">
<button class="toggle wide" data-event-click="cycle_winsize"><span class="value">{{winsize_label}}</span></button></span></div>
<div class="set-row"><span class="set-lbl">Renderer</span><span class="set-ctl">
<button class="toggle" data-event-click="cycle_renderer"><span class="value">{{renderer_label}}</span></button></span></div>
<div class="set-row"><span class="set-lbl">Supersampling</span><span class="set-ctl">
+30
View File
@@ -99,6 +99,7 @@ struct LauncherModel {
int texture_filter = 0; // 0=nearest, 1=bilinear
int crt = 0; // 0=raw,1=crt,2=composite,3=trinitron
bool spu_hq = false;
int window_width = 1280; // window size (4:3; height = w*3/4)
Rml::String bios_path;
Rml::String disc_path;
@@ -107,6 +108,7 @@ struct LauncherModel {
Rml::String renderer_label;
Rml::String crt_label;
Rml::String texfilter_label;
Rml::String winsize_label;
// Disc verification (recomputed whenever disc_path changes).
Rml::String disc_file; // file name only, e.g. "tomba.cue"
@@ -332,10 +334,29 @@ const char* crt_name(int v) {
}
}
// Offered 4:3 window widths (height = w*3/4). The toggle cycles through these.
const int kWinWidths[] = { 960, 1280, 1600, 1920 };
const int kNumWinWidths = (int)(sizeof(kWinWidths) / sizeof(kWinWidths[0]));
// Snap an arbitrary width to the nearest offered option index.
int winsize_index(int width) {
int best = 1, bestd = 1 << 30; // default to 1280
for (int i = 0; i < kNumWinWidths; i++) {
int d = width > kWinWidths[i] ? width - kWinWidths[i] : kWinWidths[i] - width;
if (d < bestd) { bestd = d; best = i; }
}
return best;
}
std::string winsize_label_for(int width) {
return std::to_string(width) + " \xC3\x97 " + std::to_string(width * 3 / 4); // "1280 × 960"
}
void refresh_labels(LauncherModel& m) {
m.renderer_label = renderer_name(m.renderer);
m.crt_label = crt_name(m.crt);
m.texfilter_label = texfilter_name(m.texture_filter);
m.winsize_label = winsize_label_for(m.window_width);
}
std::string region_long(const std::string& r) {
@@ -546,6 +567,7 @@ Result run(SDL_Window* window, void* gl_context,
m.texture_filter = io.texture_filter;
m.crt = io.screen_kind;
m.spu_hq = io.spu_hq;
m.window_width = kWinWidths[winsize_index(io.has_window_width ? io.window_width : 1280)];
m.bios_path = io.has_bios_path ? io.bios_path.generic_string() : Rml::String();
m.disc_path = io.has_disc_path ? io.disc_path.generic_string() : Rml::String();
refresh_labels(m);
@@ -588,6 +610,7 @@ Result run(SDL_Window* window, void* gl_context,
c.Bind("spu_hq", &m.spu_hq);
c.Bind("renderer_label", &m.renderer_label);
c.Bind("crt_label", &m.crt_label);
c.Bind("winsize_label", &m.winsize_label);
c.Bind("texfilter_label",&m.texfilter_label);
c.Bind("bios_path", &m.bios_path);
c.Bind("disc_path", &m.disc_path);
@@ -652,6 +675,12 @@ Result run(SDL_Window* window, void* gl_context,
m.crt = (m.crt + 1) % 4; refresh_labels(m);
handle.DirtyVariable("crt_label");
});
c.BindEventCallback("cycle_winsize",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
int i = (winsize_index(m.window_width) + 1) % kNumWinWidths;
m.window_width = kWinWidths[i]; refresh_labels(m);
handle.DirtyVariable("winsize_label");
});
c.BindEventCallback("toggle_spu",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.spu_hq = !m.spu_hq;
@@ -831,6 +860,7 @@ Result run(SDL_Window* window, void* gl_context,
io.texture_filter = m.texture_filter; io.has_texture_filter = true;
io.screen_kind = m.crt; io.has_screen_kind = true;
io.spu_hq = m.spu_hq; io.has_spu_hq = true;
io.window_width = m.window_width; io.has_window_width = true;
if (!m.bios_path.empty()) { io.bios_path = fs::path(std::string(m.bios_path)); io.has_bios_path = true; }
if (!m.disc_path.empty()) { io.disc_path = fs::path(std::string(m.disc_path)); io.has_disc_path = true; }
+34 -2
View File
@@ -118,7 +118,23 @@ 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 bool g_audio_spu_hq = false; /* SPU float-shadow (env overrides) */
/* 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) {
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;
}
*w = width;
*h = width * 3 / 4;
}
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
* to force the software readout path instead — a diagnostic/fallback that also
@@ -1408,6 +1424,7 @@ int main(int argc, char** argv) {
PSXRecompV4::load_user_settings(settings_path);
if (us.has_renderer) g_video_renderer = us.renderer;
if (us.has_supersampling) g_video_scale = us.supersampling;
if (us.has_window_width) g_video_win_w = us.window_width;
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;
@@ -1460,17 +1477,25 @@ int main(int argc, char** argv) {
seed.p2_device = p2_device; seed.has_p2_device = true;
seed.p1_analog = p1_analog; seed.has_p1_analog = true;
seed.p2_analog = p2_analog; seed.has_p2_analog = true;
seed.window_width = g_video_win_w; seed.has_window_width = true;
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
/* Launcher opens at the same 4:3 size the game will, so there's no
* jarring resize on LAUNCH. The dense dashboard needs a usable
* minimum, so the launcher floor is 1280 wide even if the game
* 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);
std::string lwin_title = (game_name.empty() ? std::string("PSX") : game_name)
+ " \xE2\x80\x94 Launcher";
SDL_Window* lwin = SDL_CreateWindow(
lwin_title.c_str(), SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
1280, 924, SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE);
lwin_w, lwin_h, SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE);
psx_launcher::Result lr = psx_launcher::Result::Unavailable;
if (lwin) {
SDL_GLContext lctx = SDL_GL_CreateContext(lwin);
@@ -1513,6 +1538,7 @@ int main(int argc, char** argv) {
if (seed.has_memcard2_path) memcard2_path = seed.memcard2_path;
p1_device = seed.p1_device; p2_device = seed.p2_device;
p1_analog = seed.p1_analog; p2_analog = seed.p2_analog;
g_video_win_w = seed.window_width;
/* Persist the user's choices next to the exe. */
PSXRecompV4::save_user_settings(
exe_dir_from_argv(argv[0]) / "settings.toml", seed);
@@ -1662,10 +1688,16 @@ 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
* 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. */
int game_w = g_video_win_w, game_h = 0;
clamp_window_4x3(&game_w, &game_h);
sdl_window = SDL_CreateWindow(
window_title.c_str(),
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
640, 480,
game_w, game_h,
win_flags
);
if (!sdl_window) {