Files
psxrecomp/runtime/launcher/launcher.cpp
Matthew Stanley 970670bac4 launcher: Aspect ratio setting (4:3 Native / 16:9 Widescreen / 21:9)
One build serves both presentations: the launcher Settings panel gets
an Aspect ratio toggle wired to the existing UserSettings aspect
plumbing (persists to settings.toml, overrides game.toml). Window-size
label height now follows the chosen aspect. Default 4:3 (Native).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:19:23 -07:00

920 lines
39 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// launcher.cpp — see launcher.h. RmlUi (HTML/CSS) front-end over SDL2 + GL3.
//
// Uses RmlUi's official SDL platform + GL3 renderer backends (lib/RmlUi/Backends).
// The base RenderInterface_GL3 is used directly (no SDL_image dependency) — the
// minimal launcher draws with CSS, not external <img> bitmaps; image-rich polish
// is a later phase.
#include "launcher.h"
#include "config_loader.h"
#include "disc_identity.h"
extern "C" {
#include "memcard.h"
}
#include <RmlUi/Core.h>
#include <RmlUi/Core/Context.h>
#include <RmlUi/Core/DataModelHandle.h>
#include <RmlUi/Core/ElementDocument.h>
#include "RmlUi_Platform_SDL.h"
#include "RmlUi_Renderer_GL3.h"
#include "third_party/stb_image.h"
#include <SDL.h>
#include <cstdio>
#include <ctime>
#include <filesystem>
#include <string>
#include <vector>
#if defined(_WIN32)
# include <windows.h>
# include <commdlg.h>
#endif
namespace fs = std::filesystem;
namespace {
// RenderInterface_GL3 only decodes uncompressed TGA. Override LoadTexture to
// decode PNG via stb_image (falling back to the base TGA path), so the launcher
// can use <img> with PNG art. RmlUi textures are premultiplied-alpha RGBA.
class LauncherRenderInterface : public RenderInterface_GL3 {
public:
Rml::TextureHandle LoadTexture(Rml::Vector2i& dims, const Rml::String& source) override {
Rml::FileInterface* fi = Rml::GetFileInterface();
Rml::FileHandle fh = fi ? fi->Open(source) : Rml::FileHandle(0);
if (!fh) return RenderInterface_GL3::LoadTexture(dims, source);
fi->Seek(fh, 0, SEEK_END);
const size_t sz = (size_t)fi->Tell(fh);
fi->Seek(fh, 0, SEEK_SET);
std::vector<unsigned char> buf(sz);
fi->Read(buf.data(), sz, fh);
fi->Close(fh);
int w = 0, h = 0, comp = 0;
unsigned char* px = stbi_load_from_memory(buf.data(), (int)sz, &w, &h, &comp, 4);
if (!px) return RenderInterface_GL3::LoadTexture(dims, source); // maybe a TGA
const size_t n = (size_t)w * (size_t)h;
for (size_t i = 0; i < n; i++) { // straight -> premultiplied alpha
const unsigned a = px[i * 4 + 3];
px[i * 4 + 0] = (unsigned char)(px[i * 4 + 0] * a / 255);
px[i * 4 + 1] = (unsigned char)(px[i * 4 + 1] * a / 255);
px[i * 4 + 2] = (unsigned char)(px[i * 4 + 2] * a / 255);
}
dims.x = w; dims.y = h;
Rml::TextureHandle th = GenerateTexture({px, n * 4}, dims);
stbi_image_free(px);
return th;
}
};
// Route RmlUi's own diagnostics to stdout. The base SystemInterface logs via
// OutputDebugString on Windows (invisible to a normal console/redirect), which
// hides data-binding errors; surfacing them here keeps RML issues debuggable.
class LauncherSystemInterface : public SystemInterface_SDL {
public:
bool LogMessage(Rml::Log::Type type, const Rml::String& message) override {
// Surface problems (warnings/errors/asserts); skip routine info spam.
if (type == Rml::Log::LT_INFO || type == Rml::Log::LT_DEBUG) return true;
const char* tag = type == Rml::Log::LT_ERROR ? "error"
: type == Rml::Log::LT_ASSERT ? "assert" : "warning";
std::fprintf(stdout, "launcher/rml %s: %s\n", tag, message.c_str());
std::fflush(stdout);
return true;
}
};
// Mirror of the user-tunable settings, in the value shapes the RML binds to.
struct LauncherModel {
int renderer = 0; // 0=software, 1=opengl
int supersampling = 1; // 1..4
bool antialiasing = true;
int texture_filter = 0; // 0=nearest, 1=bilinear
int crt = 0; // 0=raw,1=crt,2=composite,3=trinitron
bool spu_hq = false;
int aspect_index = 0; // index into kAspects (0 = 4:3 native)
int window_width = 1280; // window size (height = width*den/num per aspect)
Rml::String bios_path;
Rml::String disc_path;
// Display labels (kept in sync with the enum/int values above).
Rml::String renderer_label;
Rml::String crt_label;
Rml::String texfilter_label;
Rml::String aspect_label;
Rml::String winsize_label;
// Disc verification (recomputed whenever disc_path changes).
Rml::String disc_file; // file name only, e.g. "tomba.cue"
Rml::String disc_region; // "NTSC-U (USA)" | "PAL" | "NTSC-J" | "—"
Rml::String disc_serial; // "SCUS-94236" | "—"
bool v_header = false; // ISO9660 header present
bool v_crc = false; // CRC/hash (or serial identity) check passed
bool v_verified = false; // overall verdict good
Rml::String verdict_title; // big line, e.g. "Tomba! disc verified"
Rml::String verdict_detail; // sub line
Rml::String verdict_state; // "ok" | "warn" | "bad" | "none" — drives colour
// View toggle: "dashboard" (default) | "settings".
Rml::String view = "dashboard";
// Player cards — real device routing. Each port picks a device (None /
// Keyboard / a plugged-in SDL controller) and a pad type (DualShock=analog).
int p1_dev_index = 1; // index into the shared device option list
int p2_dev_index = 0;
bool p1_analog = false; // DualShock (analog) vs digital pad
bool p2_analog = false;
Rml::String p1_dev_label = "Keyboard";
Rml::String p2_dev_label = "None";
Rml::String p1_status, p2_status; // resolved status line
Rml::String p1_dot, p2_dot; // "" (on) | "off"
Rml::String p1_options, p2_options; // data-rml option-list markup
Rml::String dd_open; // "" | "p1" | "p2" (which list is open)
// Memory cards — real introspection of the on-disk .mcd images. Each slot
// has a resolved file path, an enable toggle, and parsed directory stats.
bool mc1_enabled = true;
bool mc2_enabled = true;
Rml::String mc1_path, mc2_path; // resolved absolute .mcd path
Rml::String mc1_name, mc2_name; // file name only
Rml::String mc1_size, mc2_size; // "128 KB (15 blocks)" | "—"
Rml::String mc1_used, mc2_used; // "7 / 15" | "—"
Rml::String mc1_foot, mc2_foot; // "Last modified — …" | status line
// 15-cell block grids, built as RML markup and injected via data-rml. (A
// data-for over a bound array is the natural fit, but the structural
// data-for view does not capture inner-xml in this build; data-rml is the
// robust path and the markup is fully launcher-controlled.)
Rml::String mc1_grid, mc2_grid;
bool launch_requested = false;
bool quit_requested = false;
};
// Format a unix timestamp as e.g. "Jun 12, 2026". Empty for 0/unknown.
std::string fmt_mtime(long long secs) {
if (secs <= 0) return std::string();
const std::time_t t = (std::time_t)secs;
std::tm tmv{};
#if defined(_WIN32)
localtime_s(&tmv, &t);
#else
localtime_r(&t, &tmv);
#endif
char buf[32];
if (std::strftime(buf, sizeof(buf), "%b %d, %Y", &tmv) == 0) return std::string();
return std::string(buf);
}
// Resolve a slot's effective .mcd path: explicit override, else <dir>/card<N>.mcd.
std::string memcard_slot_path(const PSXRecompV4::UserSettings& io, int slot /*0|1*/) {
const bool has = slot == 0 ? io.has_memcard1_path : io.has_memcard2_path;
const std::filesystem::path& p = slot == 0 ? io.memcard1_path : io.memcard2_path;
if (has && !p.empty()) return p.generic_string();
fs::path dir = io.has_memcard_dir ? io.memcard_dir : fs::path();
if (dir.empty()) return std::string();
return (dir / (std::string("card") + (slot == 0 ? "1" : "2") + ".mcd")).generic_string();
}
// Parse the slot's .mcd file and fill the model's display fields for it.
void refresh_memcard(LauncherModel& m, int slot /*0|1*/) {
Rml::String& path = slot == 0 ? m.mc1_path : m.mc2_path;
Rml::String& name = slot == 0 ? m.mc1_name : m.mc2_name;
Rml::String& size = slot == 0 ? m.mc1_size : m.mc2_size;
Rml::String& used = slot == 0 ? m.mc1_used : m.mc2_used;
Rml::String& foot = slot == 0 ? m.mc1_foot : m.mc2_foot;
Rml::String& grid = slot == 0 ? m.mc1_grid : m.mc2_grid;
auto build_grid = [](const uint8_t used[15]) {
Rml::String html;
for (int i = 0; i < 15; i++)
html += used[i] ? "<span class=\"blk b\"></span>" : "<span class=\"blk\"></span>";
return html;
};
const uint8_t empty15[15] = {0};
grid = build_grid(empty15);
name = path.empty() ? Rml::String("(no card)")
: fs::path(std::string(path)).filename().generic_string();
if (path.empty()) {
size = used = "—";
foot = "No card configured.";
return;
}
MemcardSummary s;
memcard_summary_path(std::string(path).c_str(), &s);
grid = build_grid(s.block_used);
if (!s.exists) {
size = "128 KB (15 blocks)";
used = "0 / 15";
foot = "New blank card — created on launch.";
return;
}
if (!s.valid) {
size = used = "—";
foot = "Not a valid memory-card image.";
return;
}
size = "128 KB (15 blocks)";
used = std::to_string(s.used_blocks) + " / 15";
const std::string when = fmt_mtime(s.mtime);
foot = when.empty() ? Rml::String("On-disk memory card.")
: Rml::String("Last modified — " + when);
}
// ---- input-device enumeration (None / Keyboard / plugged-in controllers) ----
struct DeviceOption {
int kind; // 0=none, 1=keyboard, 2=controller
std::string guid; // SDL joystick GUID string when kind==controller
std::string label; // display name
};
std::vector<DeviceOption> enumerate_devices() {
std::vector<DeviceOption> opts;
opts.push_back({0, "", "None"});
opts.push_back({1, "", "Keyboard"});
const int n = SDL_NumJoysticks();
for (int i = 0; i < n; i++) {
if (!SDL_IsGameController(i)) continue;
SDL_JoystickGUID g = SDL_JoystickGetDeviceGUID(i);
char buf[40] = {0};
SDL_JoystickGetGUIDString(g, buf, sizeof(buf));
const char* nm = SDL_GameControllerNameForIndex(i);
opts.push_back({2, std::string(buf), nm ? std::string(nm) : std::string("Controller")});
}
return opts;
}
// The settings device string ("none"/"keyboard"/<guid>) for an option.
std::string device_string(const DeviceOption& o) {
if (o.kind == 0) return "none";
if (o.kind == 1) return "keyboard";
return o.guid;
}
// Minimal RML/attribute text escape for injected option labels.
std::string rml_escape(const std::string& s) {
std::string o;
for (char c : s) {
switch (c) {
case '&': o += "&amp;"; break;
case '<': o += "&lt;"; break;
case '>': o += "&gt;"; break;
case '"': o += "&quot;"; break;
case '\'':o += "&#39;"; break;
default: o += c; break;
}
}
return o;
}
// Resolve a saved device string to an index in opts. A saved controller GUID
// that is not currently plugged in is appended as an "(offline)" option so the
// user's selection survives across unplug/replug.
int find_or_add_device_index(std::vector<DeviceOption>& opts, const std::string& dev) {
if (dev.empty() || dev == "none") return 0;
if (dev == "keyboard") return 1;
for (size_t i = 0; i < opts.size(); i++)
if (opts[i].kind == 2 && opts[i].guid == dev) return (int)i;
opts.push_back({2, dev, "Saved controller (offline)"});
return (int)opts.size() - 1;
}
// Build the data-rml option-list markup for a player's dropdown. Each row is a
// clickable element whose data-event-click selects that option (and closes).
std::string build_options_rml(int player, const std::vector<DeviceOption>& opts) {
std::string s;
for (size_t i = 0; i < opts.size(); i++) {
s += "<p class=\"dd-opt\" data-event-click=\"pick_device(";
s += std::to_string(player);
s += ",";
s += std::to_string(i);
s += ")\">";
s += rml_escape(opts[i].label);
s += "</p>";
}
return s;
}
// Recompute a player's derived display fields from its selected option index.
void refresh_player(LauncherModel& m, int player, const std::vector<DeviceOption>& opts) {
int& idx = player == 0 ? m.p1_dev_index : m.p2_dev_index;
Rml::String& label = player == 0 ? m.p1_dev_label : m.p2_dev_label;
Rml::String& status = player == 0 ? m.p1_status : m.p2_status;
Rml::String& dot = player == 0 ? m.p1_dot : m.p2_dot;
Rml::String& options= player == 0 ? m.p1_options : m.p2_options;
const bool analog = player == 0 ? m.p1_analog : m.p2_analog;
if (idx < 0 || idx >= (int)opts.size()) idx = 0;
const DeviceOption& o = opts[idx];
label = o.label;
options = build_options_rml(player, opts);
const char* type = analog ? "DualShock (analog)" : "digital pad";
if (o.kind == 0) { status = "No device — port empty"; dot = "off"; }
else if (o.kind == 1) { status = Rml::String("Keyboard \xE2\x80\x94 ") + type; dot = ""; }
else { status = o.label + Rml::String(" \xE2\x80\x94 ") + type; dot = ""; }
}
const char* renderer_name(int v) { return v == 1 ? "OpenGL" : "Software"; }
const char* texfilter_name(int v) { return v == 1 ? "Bilinear" : "Nearest"; }
const char* crt_name(int v) {
switch (v) {
case 1: return "CRT";
case 2: return "Composite";
case 3: return "Trinitron";
default: return "Raw (off)";
}
}
// Offered display aspects. 4:3 is the native presentation every game ships
// with; wider aspects enable the runtime widescreen hack (GTE X-squash +
// stretched present — see [video] aspect_ratio in config_loader.h).
const int kAspects[][2] = { {4, 3}, {16, 9}, {21, 9} };
const int kNumAspects = (int)(sizeof(kAspects) / sizeof(kAspects[0]));
const char* aspect_name(int i) {
switch (i) {
case 1: return "16:9 (Widescreen)";
case 2: return "21:9 (Ultrawide)";
default: return "4:3 (Native)";
}
}
int aspect_index_for(int num, int den) {
for (int i = 0; i < kNumAspects; i++)
if (kAspects[i][0] == num && kAspects[i][1] == den) return i;
return 0;
}
// Offered window widths (height follows the chosen aspect). 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, int aspect_index) {
const int num = kAspects[aspect_index][0], den = kAspects[aspect_index][1];
return std::to_string(width) + " \xC3\x97 " + std::to_string(width * den / num); // "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.aspect_label = aspect_name(m.aspect_index);
m.winsize_label = winsize_label_for(m.window_width, m.aspect_index);
}
std::string region_long(const std::string& r) {
if (r == "NTSC-U") return "NTSC-U (USA)";
if (r == "NTSC-J") return "NTSC-J (Japan)";
if (r == "PAL") return "PAL (Europe)";
return r;
}
// Re-run disc verification against m.disc_path and update the panel fields.
// `expected_serial`/`expected_crc` come from game.toml (via GameInfo);
// `game_name` is used for the verdict headline.
void refresh_disc_status(LauncherModel& m, const std::string& game_name,
const std::string& expected_serial,
uint32_t expected_crc, bool has_expected_crc) {
m.v_header = m.v_crc = m.v_verified = false;
m.disc_region = m.disc_serial = "—";
m.disc_file = "—";
if (m.disc_path.empty()) {
m.verdict_title = "No disc selected";
m.verdict_detail = "Choose a disc image to verify it against this build.";
m.verdict_state = "none";
return;
}
m.disc_file = fs::path(std::string(m.disc_path)).filename().generic_string();
// Only spend time hashing when there is an expected CRC to compare against.
const PSXRecompV4::DiscIdentity id = PSXRecompV4::identify_disc(
fs::path(std::string(m.disc_path)), expected_serial,
expected_crc, has_expected_crc, /*compute_crc=*/has_expected_crc);
if (!id.opened) {
m.verdict_title = "Disc not found";
m.verdict_detail = "Could not open the image or its CUE-referenced BIN.";
m.verdict_state = "bad";
return;
}
if (!id.has_header) {
m.verdict_title = "Not a PlayStation disc";
m.verdict_detail = "No ISO9660 header at the expected sectors.";
m.verdict_state = "bad";
return;
}
m.v_header = true;
if (!id.detected_serial.empty()) m.disc_serial = id.detected_serial;
else if (!expected_serial.empty()) m.disc_serial = expected_serial;
if (!id.region.empty()) m.disc_region = region_long(id.region);
const bool serial_ok = !id.expected_serial_given || id.serial_matches;
// Middle check: exact CRC match if we have one to compare, otherwise the
// serial-based identity match stands in for the hash.
if (id.expected_crc_given && id.crc_computed)
m.v_crc = id.crc_matches;
else
m.v_crc = serial_ok && id.expected_serial_given;
m.v_verified = m.v_header && serial_ok &&
(!id.expected_crc_given || id.crc_matches);
const std::string nm = game_name.empty() ? std::string("Disc") : game_name;
if (m.v_verified) {
m.verdict_title = nm + " disc verified";
m.verdict_detail = "Correct disc image loaded. Ready to launch.";
m.verdict_state = "ok";
} else if (!serial_ok) {
m.verdict_title = "Wrong disc?";
m.verdict_detail = "Serial does not match this build (expected " + expected_serial + ").";
m.verdict_state = "bad";
} else if (id.expected_crc_given && id.crc_computed && !id.crc_matches) {
m.verdict_title = "Disc image differs";
m.verdict_detail = "Right game, but the image hash does not match the expected dump.";
m.verdict_state = "warn";
} else {
// Header ok, nothing authoritative to compare against.
m.verdict_title = "PlayStation disc";
m.verdict_detail = "Recognised PlayStation disc. No reference hash configured.";
m.verdict_state = "ok";
m.v_verified = true;
}
}
#if defined(_WIN32)
// Native open-file dialog. Returns "" if cancelled.
std::string win_pick_file(SDL_Window* parent, const char* title, const char* filter) {
char buf[MAX_PATH] = {0};
OPENFILENAMEA ofn = {0};
ofn.lStructSize = sizeof(ofn);
ofn.lpstrFilter = filter; // e.g. "BIOS (*.bin)\0*.bin\0All files\0*.*\0\0"
ofn.lpstrFile = buf;
ofn.nMaxFile = sizeof(buf);
ofn.lpstrTitle = title;
ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
(void)parent;
if (GetOpenFileNameA(&ofn)) return std::string(buf);
return std::string();
}
// Native save-file dialog (overwrite-prompts). Returns "" if cancelled.
std::string win_pick_save_file(SDL_Window* parent, const char* title,
const char* filter, const char* default_ext,
const std::string& initial) {
char buf[MAX_PATH] = {0};
if (!initial.empty()) {
std::snprintf(buf, sizeof(buf), "%s", initial.c_str());
for (char* p = buf; *p; ++p) if (*p == '/') *p = '\\'; // native sep
}
OPENFILENAMEA ofn = {0};
ofn.lStructSize = sizeof(ofn);
ofn.lpstrFilter = filter;
ofn.lpstrFile = buf;
ofn.nMaxFile = sizeof(buf);
ofn.lpstrTitle = title;
ofn.lpstrDefExt = default_ext; // appended if the user types no extension
ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
(void)parent;
if (GetSaveFileNameA(&ofn)) return std::string(buf);
return std::string();
}
#else
std::string win_pick_file(SDL_Window*, const char*, const char*) { return std::string(); }
std::string win_pick_save_file(SDL_Window*, const char*, const char*, const char*,
const std::string&) { return std::string(); }
#endif
// Load at least one font face so RmlUi can render text. Tries bundled fonts in
// assets_dir, then a couple of platform fallbacks. Returns true if any loaded.
bool load_fonts(const fs::path& assets_dir) {
const char* bundled[] = {
"fonts/LatoLatin-Regular.ttf",
"fonts/LatoLatin-Bold.ttf",
"LatoLatin-Regular.ttf",
};
bool any = false;
for (const char* rel : bundled) {
const fs::path p = assets_dir / rel;
std::error_code ec;
if (fs::exists(p, ec) && Rml::LoadFontFace(p.generic_string())) any = true;
}
if (any) return true;
#if defined(_WIN32)
const char* sys[] = { "C:/Windows/Fonts/segoeui.ttf", "C:/Windows/Fonts/arial.ttf" };
for (const char* p : sys)
if (Rml::LoadFontFace(p)) any = true;
#endif
return any;
}
} // namespace
namespace psx_launcher {
Result run(SDL_Window* window, void* gl_context,
PSXRecompV4::UserSettings& io,
const GameInfo& game, const char* assets_dir)
{
(void)gl_context; // already created + current; we only need the window.
const std::string expected_serial = game.expected_serial ? game.expected_serial : "";
const uint32_t expected_crc = game.expected_crc;
const bool has_expected_crc = game.has_expected_crc;
if (!RmlGL3::Initialize()) {
std::fprintf(stderr, "launcher: RmlGL3::Initialize failed\n");
return Result::Unavailable;
}
LauncherSystemInterface system_interface;
system_interface.SetWindow(window);
LauncherRenderInterface render_interface;
if (!render_interface) {
std::fprintf(stderr, "launcher: GL3 render interface init failed\n");
RmlGL3::Shutdown();
return Result::Unavailable;
}
Rml::SetSystemInterface(&system_interface);
Rml::SetRenderInterface(&render_interface);
if (!Rml::Initialise()) {
std::fprintf(stderr, "launcher: Rml::Initialise failed\n");
RmlGL3::Shutdown();
return Result::Unavailable;
}
const fs::path assets = assets_dir ? fs::path(assets_dir) : fs::current_path();
if (!load_fonts(assets))
std::fprintf(stderr, "launcher: warning — no font face loaded; text will not render\n");
int win_w = 0, win_h = 0;
SDL_GL_GetDrawableSize(window, &win_w, &win_h);
if (win_w <= 0 || win_h <= 0) { win_w = 1280; win_h = 800; }
render_interface.SetViewport(win_w, win_h);
Rml::Context* context = Rml::CreateContext("launcher", Rml::Vector2i(win_w, win_h));
if (!context) {
std::fprintf(stderr, "launcher: CreateContext failed\n");
Rml::Shutdown();
RmlGL3::Shutdown();
return Result::Unavailable;
}
// ---- Seed the model from the effective settings ----
LauncherModel m;
m.renderer = io.renderer;
m.supersampling = io.supersampling;
m.antialiasing = io.antialiasing;
m.texture_filter = io.texture_filter;
m.crt = io.screen_kind;
m.spu_hq = io.spu_hq;
m.aspect_index = io.has_aspect_ratio ? aspect_index_for(io.aspect_num, io.aspect_den) : 0;
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);
const std::string game_name_s = game.name ? game.name : "";
refresh_disc_status(m, game_name_s, expected_serial, expected_crc, has_expected_crc);
// ---- Seed the memory-card slots from the effective settings ----
if (io.has_memcard1_enabled) m.mc1_enabled = io.memcard1_enabled;
if (io.has_memcard2_enabled) m.mc2_enabled = io.memcard2_enabled;
m.mc1_path = memcard_slot_path(io, 0);
m.mc2_path = memcard_slot_path(io, 1);
refresh_memcard(m, 0);
refresh_memcard(m, 1);
// ---- Seed the controller slots: enumerate devices, resolve selections ----
std::vector<DeviceOption> dev_opts = enumerate_devices();
m.p1_analog = io.has_p1_analog ? io.p1_analog : false;
m.p2_analog = io.has_p2_analog ? io.p2_analog : false;
if (io.has_p1_device) {
m.p1_dev_index = find_or_add_device_index(dev_opts, io.p1_device);
} else {
// Zero-config default: first plugged-in controller, else keyboard.
m.p1_dev_index = (dev_opts.size() > 2) ? 2 : 1;
}
m.p2_dev_index = io.has_p2_device ? find_or_add_device_index(dev_opts, io.p2_device) : 0;
refresh_player(m, 0, dev_opts);
refresh_player(m, 1, dev_opts);
// ---- Data model: bind fields + action callbacks ----
Rml::DataModelConstructor c = context->CreateDataModel("settings");
if (!c) {
Rml::Shutdown();
RmlGL3::Shutdown();
return Result::Unavailable;
}
Rml::String title = game.name ? Rml::String(game.name) : Rml::String("PSX");
c.BindFunc("game_name", [title](Rml::Variant& out) { out = title; });
c.Bind("supersampling", &m.supersampling);
c.Bind("antialiasing", &m.antialiasing);
c.Bind("spu_hq", &m.spu_hq);
c.Bind("renderer_label", &m.renderer_label);
c.Bind("crt_label", &m.crt_label);
c.Bind("aspect_label", &m.aspect_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);
c.Bind("disc_file", &m.disc_file);
c.Bind("disc_region", &m.disc_region);
c.Bind("disc_serial", &m.disc_serial);
c.Bind("v_header", &m.v_header);
c.Bind("v_crc", &m.v_crc);
c.Bind("v_verified", &m.v_verified);
c.Bind("verdict_title", &m.verdict_title);
c.Bind("verdict_detail", &m.verdict_detail);
c.Bind("verdict_state", &m.verdict_state);
c.Bind("view", &m.view);
c.Bind("p1_analog", &m.p1_analog);
c.Bind("p2_analog", &m.p2_analog);
c.Bind("p1_dev_label", &m.p1_dev_label);
c.Bind("p2_dev_label", &m.p2_dev_label);
c.Bind("p1_status", &m.p1_status);
c.Bind("p2_status", &m.p2_status);
c.Bind("p1_dot", &m.p1_dot);
c.Bind("p2_dot", &m.p2_dot);
c.Bind("p1_options", &m.p1_options);
c.Bind("p2_options", &m.p2_options);
c.Bind("dd_open", &m.dd_open);
c.Bind("mc1_enabled", &m.mc1_enabled);
c.Bind("mc2_enabled", &m.mc2_enabled);
c.Bind("mc1_name", &m.mc1_name);
c.Bind("mc2_name", &m.mc2_name);
c.Bind("mc1_size", &m.mc1_size);
c.Bind("mc2_size", &m.mc2_size);
c.Bind("mc1_used", &m.mc1_used);
c.Bind("mc2_used", &m.mc2_used);
c.Bind("mc1_foot", &m.mc1_foot);
c.Bind("mc2_foot", &m.mc2_foot);
c.Bind("mc1_grid", &m.mc1_grid);
c.Bind("mc2_grid", &m.mc2_grid);
Rml::DataModelHandle handle = c.GetModelHandle();
c.BindEventCallback("cycle_renderer",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.renderer ^= 1; refresh_labels(m);
handle.DirtyVariable("renderer_label");
});
c.BindEventCallback("cycle_ss",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.supersampling = (m.supersampling % 4) + 1;
handle.DirtyVariable("supersampling");
});
c.BindEventCallback("toggle_aa",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.antialiasing = !m.antialiasing;
handle.DirtyVariable("antialiasing");
});
c.BindEventCallback("cycle_texfilter",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.texture_filter ^= 1; refresh_labels(m);
handle.DirtyVariable("texfilter_label");
});
c.BindEventCallback("cycle_crt",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.crt = (m.crt + 1) % 4; refresh_labels(m);
handle.DirtyVariable("crt_label");
});
c.BindEventCallback("cycle_aspect",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.aspect_index = (m.aspect_index + 1) % kNumAspects; refresh_labels(m);
handle.DirtyVariable("aspect_label");
handle.DirtyVariable("winsize_label"); /* height follows aspect */
});
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;
handle.DirtyVariable("spu_hq");
});
c.BindEventCallback("browse_bios",
[&m, window, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
std::string p = win_pick_file(window, "Select PlayStation BIOS",
"BIOS image (*.bin;*.rom)\0*.bin;*.rom\0All files (*.*)\0*.*\0\0");
if (!p.empty()) {
m.bios_path = fs::path(p).generic_string();
handle.DirtyVariable("bios_path");
}
});
auto do_browse_disc =
[&m, window, handle, game_name_s, expected_serial, expected_crc, has_expected_crc]
(Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
std::string p = win_pick_file(window, "Select disc image",
"Disc image (*.cue;*.bin;*.iso)\0*.cue;*.bin;*.iso\0All files (*.*)\0*.*\0\0");
if (!p.empty()) {
m.disc_path = fs::path(p).generic_string();
refresh_disc_status(m, game_name_s, expected_serial, expected_crc, has_expected_crc);
for (const char* v : {"disc_path", "disc_file", "disc_region", "disc_serial",
"v_header", "v_crc", "v_verified",
"verdict_title", "verdict_detail", "verdict_state"})
handle.DirtyVariable(v);
}
};
c.BindEventCallback("browse_disc", do_browse_disc);
c.BindEventCallback("change_iso", do_browse_disc);
c.BindEventCallback("show_settings",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.view = "settings"; handle.DirtyVariable("view");
});
c.BindEventCallback("show_dashboard",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.view = "dashboard"; handle.DirtyVariable("view");
});
// ---- controller: device dropdown + DualShock toggle ----
auto dirty_player = [handle](int player) mutable {
const char* v0[] = {"p1_dev_label","p1_status","p1_dot","p1_options","p1_analog"};
const char* v1[] = {"p2_dev_label","p2_status","p2_dot","p2_options","p2_analog"};
for (const char* v : (player == 0 ? v0 : v1)) handle.DirtyVariable(v);
};
// dev_opts is captured by value: the device list is fixed for the launcher
// session (a hot-plug here would require a re-enumerate, deferred).
c.BindEventCallback("open_dd",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList& args) mutable {
const int player = args.empty() ? 0 : (int)args[0].Get<int>();
const char* key = player == 0 ? "p1" : "p2";
m.dd_open = (m.dd_open == key) ? Rml::String() : Rml::String(key);
handle.DirtyVariable("dd_open");
});
c.BindEventCallback("close_dd",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.dd_open = Rml::String(); handle.DirtyVariable("dd_open");
});
c.BindEventCallback("pick_device",
[&m, handle, dev_opts, dirty_player](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList& args) mutable {
if (args.size() < 2) return;
const int player = (int)args[0].Get<int>();
const int idx = (int)args[1].Get<int>();
(player == 0 ? m.p1_dev_index : m.p2_dev_index) = idx;
refresh_player(m, player, dev_opts);
m.dd_open = Rml::String();
dirty_player(player);
handle.DirtyVariable("dd_open");
});
c.BindEventCallback("toggle_ds_p1",
[&m, handle, dev_opts, dirty_player](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.p1_analog = !m.p1_analog; refresh_player(m, 0, dev_opts); dirty_player(0);
});
c.BindEventCallback("toggle_ds_p2",
[&m, handle, dev_opts, dirty_player](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.p2_analog = !m.p2_analog; refresh_player(m, 1, dev_opts); dirty_player(1);
});
c.BindEventCallback("toggle_mc1",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.mc1_enabled = !m.mc1_enabled; handle.DirtyVariable("mc1_enabled");
});
c.BindEventCallback("toggle_mc2",
[&m, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable {
m.mc2_enabled = !m.mc2_enabled; handle.DirtyVariable("mc2_enabled");
});
auto dirty_mc = [handle](int slot) mutable {
const char* v0[] = {"mc1_name","mc1_size","mc1_used","mc1_foot","mc1_grid"};
const char* v1[] = {"mc2_name","mc2_size","mc2_used","mc2_foot","mc2_grid"};
for (const char* v : (slot == 0 ? v0 : v1)) handle.DirtyVariable(v);
};
auto browse_mc = [&m, window, dirty_mc](int slot) mutable {
std::string p = win_pick_file(window, "Select memory-card image",
"Memory card (*.mcd;*.mc;*.mcr)\0*.mcd;*.mc;*.mcr\0All files (*.*)\0*.*\0\0");
if (p.empty()) return;
(slot == 0 ? m.mc1_path : m.mc2_path) = fs::path(p).generic_string();
refresh_memcard(m, slot);
dirty_mc(slot);
};
auto new_mc = [&m, window, dirty_mc](int slot) mutable {
Rml::String& cur = (slot == 0 ? m.mc1_path : m.mc2_path);
std::string p = win_pick_save_file(window, "Create new memory card",
"Memory card (*.mcd)\0*.mcd\0All files (*.*)\0*.*\0\0", "mcd",
std::string(cur));
if (p.empty()) return;
if (memcard_format_file(p.c_str()) != 0) return; // I/O failure: leave as-is
cur = fs::path(p).generic_string();
refresh_memcard(m, slot);
dirty_mc(slot);
};
c.BindEventCallback("browse_mc1",
[browse_mc](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable { browse_mc(0); });
c.BindEventCallback("browse_mc2",
[browse_mc](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable { browse_mc(1); });
c.BindEventCallback("new_mc1",
[new_mc](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable { new_mc(0); });
c.BindEventCallback("new_mc2",
[new_mc](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable { new_mc(1); });
c.BindEventCallback("launch",
[&m](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) { m.launch_requested = true; });
c.BindEventCallback("quit",
[&m](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) { m.quit_requested = true; });
// ---- Load the document ----
const fs::path rml = assets / "launcher.rml";
Rml::ElementDocument* doc = context->LoadDocument(rml.generic_string());
if (!doc) {
std::fprintf(stderr, "launcher: failed to load %s — booting without launcher\n",
rml.generic_string().c_str());
Rml::Shutdown();
RmlGL3::Shutdown();
return Result::Unavailable;
}
doc->Show();
// ---- Main loop ----
Result result = Result::Quit;
bool running = true;
while (running) {
SDL_Event ev;
while (SDL_PollEvent(&ev)) {
switch (ev.type) {
case SDL_QUIT:
m.quit_requested = true;
break;
case SDL_WINDOWEVENT:
if (ev.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
SDL_GL_GetDrawableSize(window, &win_w, &win_h);
render_interface.SetViewport(win_w, win_h);
context->SetDimensions(Rml::Vector2i(win_w, win_h));
}
RmlSDL::InputEventHandler(context, ev);
break;
default:
RmlSDL::InputEventHandler(context, ev);
break;
}
}
if (m.launch_requested) { result = Result::Launch; running = false; }
if (m.quit_requested) { result = Result::Quit; running = false; }
context->Update();
render_interface.Clear();
render_interface.BeginFrame();
context->Render();
render_interface.EndFrame();
SDL_GL_SwapWindow(window);
}
// ---- Commit choices on launch ----
if (result == Result::Launch) {
io.renderer = m.renderer; io.has_renderer = true;
io.supersampling = m.supersampling; io.has_supersampling = true;
io.antialiasing = m.antialiasing; io.has_antialiasing = true;
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.aspect_num = kAspects[m.aspect_index][0];
io.aspect_den = kAspects[m.aspect_index][1];
io.has_aspect_ratio = 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; }
io.memcard1_enabled = m.mc1_enabled; io.has_memcard1_enabled = true;
io.memcard2_enabled = m.mc2_enabled; io.has_memcard2_enabled = true;
if (!m.mc1_path.empty()) { io.memcard1_path = fs::path(std::string(m.mc1_path)); io.has_memcard1_path = true; }
if (!m.mc2_path.empty()) { io.memcard2_path = fs::path(std::string(m.mc2_path)); io.has_memcard2_path = true; }
const int i1 = (m.p1_dev_index >= 0 && m.p1_dev_index < (int)dev_opts.size()) ? m.p1_dev_index : 0;
const int i2 = (m.p2_dev_index >= 0 && m.p2_dev_index < (int)dev_opts.size()) ? m.p2_dev_index : 0;
io.p1_device = device_string(dev_opts[i1]); io.has_p1_device = true;
io.p2_device = device_string(dev_opts[i2]); io.has_p2_device = true;
io.p1_analog = m.p1_analog; io.has_p1_analog = true;
io.p2_analog = m.p2_analog; io.has_p2_analog = true;
}
Rml::Shutdown();
RmlGL3::Shutdown();
return result;
}
} // namespace psx_launcher