Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ a display buffer a second time into a wider canvas in a strip of its target besi
textures sit beside the buffers), with full-screen untextured 2D stretched. The game's command stream, the frame hash
and the software picture do not change; the VRAM part stays exact. Rejected: squeezing the GTE's projection (it
changes what the game draws and stretches every 2D sprite). The game names the scenes (`port_video_widescreen`); a
scene whose 2D ends at the screen's edge stays 4:3. The first game's issue #71 has the measurements.
scene whose 2D ends at the screen's edge stays 4:3. The first game's issue #71 has the measurements. Since
2026-10-24 the canvas follows the window's aspect up to 16:9 ("The picture fits the screen; whole multiples are a
choice"): 16:9 is the widest the first game's scenes were checked at, and a 16:10 screen gets a 16:10 picture instead
of a 16:9 one with bars.

## Texture keys by content, SHA-1, per VRAM word (2026-10-08)
Texture replacement (docs/PORT.md "Texture replacement") names a texture by the transfer that loaded it and by its
Expand Down Expand Up @@ -282,6 +285,17 @@ launcher (the AppImage path the self-update keeps), so an update needs no change
choice (game.json `launcher.steam_art`, optional): embedded in the launcher at build time, so a release stays the
launcher and the game with nothing beside them, and written as the files Steam's own grid folder holds per appid.

## The picture fits the screen; whole multiples are a choice (2026-10-24)

The picture is placed by `video.scaling`, `"fit"` by default: as large as the window or the screen allows at its
aspect. It used to be as tall as a whole multiple of the image's lines fits (every line the same height), which on a
screen that is not such a multiple wastes much of it: 720 of the Steam Deck's 800 lines, 960 of 1080, and a 16:9
picture a few pixels too wide for any 16:9 screen (428 columns at 4:3's pixel aspect is 1.3375 times 4:3, not 4/3)
dropped a whole multiple, smaller than the 4:3 field. Integer scaling stays (`"integer"`) for those who want even
lines, and `"stretch"` fills the screen out of shape. The wide canvas is now rounded down to whole columns (426 at
16:9), so a whole multiple of it fits: integer shows it pixel for pixel, fit and stretch spread it to the screen's
aspect (less than a column). Uneven lines at fit are what the `sharp` filter and the internal scale are for.

## Player 2: a gamepad by connection order, off by default, never a hotkey (2026-10-24)

A second pad (issue #101) is one gamepad chosen by `input.player2` (`"first"` or `"second"`, by SDL's instance ids,
Expand Down
3 changes: 2 additions & 1 deletion docs/GAME_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ C the game compiles into the port alongside the runtime. It implements:
hooks are the game's; the option types, the manifest reader, the hotkeys, `--print-mods`, the settings section and
the stack's own mods (fast-forward and faster loading, which every game gets) belong to the stack. The manifests (`mods/<id>/mod.json`) sit in the game
repo and the build copies them next to the binary; `port_video_widescreen_enable()` and `port_video_widescreen(on)`
let a game mod show the scenes it names 16:9 (the hardware renderer's wide canvas: docs/PORT.md "Rendering");
let a game mod show the scenes it names wide, at the window's aspect up to 16:9 (the hardware renderer's wide
canvas: docs/PORT.md "Rendering");
`port_fast_forward_request()` lets a game mod ask for
fast-forward.
- **Save states:** `game_savestate(PortState *s)` names the adapter's own state that lives across vsyncs outside the
Expand Down
1 change: 1 addition & 0 deletions docs/LAUNCHER.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ and absolute paths and exits 0, or exits 64 naming the bad key.
| `video.window` | bool | true | false runs headless (tests, a check run) |
| `video.scale` | integer 1-16 | 2 | The window is 320*scale x 240*scale |
| `video.fullscreen` | bool | false | |
| `video.scaling` | `"fit"`, `"integer"` or `"stretch"` | `"fit"` | The picture's size in the window or on the screen (fullscreen): as large as it allows at the picture's aspect; as tall as a whole multiple of the game's lines allows (every line the same height, black bars around it); or the whole of it, out of shape. Applied live from the in-game menu. The launcher writes it once chosen. A game built before it logs the key and ignores it (it scales by whole multiples) |
| `video.refresh` | 50 or 60 | the game's nominal rate | The nominal rate, or the game's other one ("The rate") |
| `video.renderer` | `"software"` or `"gpu"` | `"software"` | The window's renderer: SDL_Renderer, or the hardware renderer (SDL_GPU; it falls back to software when no device can present). The launcher's "Renderer" choice. A game built before it logs the key and ignores it |
| `video.internal_scale` | integer 1-8 | 1 | The hardware renderer's resolution, N times the PS1's (1: the software picture). The launcher's "Resolution" slider, shown for the GPU renderer |
Expand Down
9 changes: 6 additions & 3 deletions docs/PORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,11 +418,14 @@ against the PS1 or an emulator) are listed in `psyq/README.md` "Behaviour assume
- **Widescreen** (`runtime/render_gpu_wide.c`, its header comment; DECISIONS "Widescreen: a wide canvas beside the
VRAM"): a game mod asks for it (`port_video_widescreen_enable`, then `port_video_widescreen(on)` every vsync, on for
the scenes whose 3D the game already sends past the display's edges). The target gets a strip of 512 VRAM columns
right of the VRAM (1.5 times the memory), where each display buffer has a canvas 16:9 at the display's pixel
aspect (320 -> 428 wide); every unit drawn into a buffer is drawn there a second time, translated, with the drawing
right of the VRAM (1.5 times the memory), where each display buffer has a canvas at the window's aspect, from 4:3
to 16:9, at the display's pixel aspect, in whole columns not wider than it (16:9: 320 -> 426 wide; 16:10, the
Steam Deck: 384; 16:9 without a window); every unit drawn into a buffer is drawn there a second time, translated, with the drawing
area widened where it reaches the buffer's edge and full-screen untextured 2D (clears, fades) stretched to the
canvas's edges. The VRAM part is unchanged, so the canvas's middle is the 4:3 picture. The present shows the canvas
at 16:9 inside the window (a new window opens 16:9); `--gpu-screenshot` gives the wide image; the software
at that aspect inside the window (a new window opens 16:9), placed by `video.scaling` (integer: at the canvas's
own aspect, pixel for pixel); `--gpu-screenshot` gives
the wide image; the software
renderer, `--screenshot` and the frame hash stay 4:3.
- The software GPU stays the reference and the default; every existing test uses it.

Expand Down
8 changes: 5 additions & 3 deletions docs/RUNTIME.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,11 @@ own size: 15-bit (one VRAM pixel per screen pixel, 5-bit components widened as `
shown) or 24-bit (`isrgb24`, the movies and the title: 3 bytes per screen pixel, so a 320-pixel line spans 480 VRAM
pixels; `disp.w` counts screen pixels), any width up to 640 and height up to 576 (the title's 320x480 interlaced frame
is shown whole, both fields), wrapping in the VRAM as the GPU does; black while `SetDispMask(0)` holds. The image is
drawn at 4:3, nearest-neighbour, as tall as an integer multiple of its lines fits the window (centred; scaled to fit when
the window is smaller than the image). `--scale N` opens a 320N x 240N window (default 2; with an even N a 240-line and
a 480-line display come out the same size), `--fullscreen` (F11 toggles). `--screenshot FRAME:PATH` (repeatable; any
drawn at 4:3, nearest-neighbour, centred, as large as `--scaling` (`video.scaling`) says: `fit` (the default) as large
as the window allows, `integer` as tall as an integer multiple of its lines fits (every line the same height; fit when
the window is smaller than the image; with an even scale a 240-line and a 480-line display come out the same size),
`stretch` the whole window, out of shape. `--scale N` opens a 320N x 240N window (default 2: every scaling is the same
picture there), `--fullscreen` (F11 toggles: the screen's size, so the scaling decides). `--screenshot FRAME:PATH` (repeatable; any
build, also headless) writes the image of vsync FRAME as a binary PPM: the texture's pixels, independent of the window.
**The renderer** (docs/PORT.md "Rendering"): `--renderer software` (the default) presents through
SDL_Renderer; `--renderer gpu` (or `video.renderer: "gpu"`) through the hardware renderer, `runtime/render_gpu.c` on SDL_GPU
Expand Down
14 changes: 11 additions & 3 deletions include/psxstack/port_harness.h
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,17 @@ const char *port_filter_choices(int quoted);
* 0 with the reason in `err` when it is malformed. */
int port_filter_parse(const char *spec, PortFilter *f, char *err, size_t err_size);
void port_video_set_filter(const PortFilter *f);
/* The window's settings changed live (the menu; runtime/live.c): the scale, fullscreen, the filter and the sub-pixel
* mode; nothing without a window. */
void port_video_apply(int scale, int fullscreen, const PortFilter *filter, int subpixel);
/* The picture's size in the window (`--scaling`, video.scaling; video.c video_dest; video_filter.c the names): fit
* (the default: as large as the window allows at the picture's aspect), integer (as tall as a whole multiple of its
* lines allows: every line the same height, black bars where the window is not such a multiple) or stretch (the whole
* window, the aspect not kept). */
enum { PORT_SCALING_FIT, PORT_SCALING_INTEGER, PORT_SCALING_STRETCH, PORT_SCALING_COUNT };
extern const char *const port_scaling_names[PORT_SCALING_COUNT];
int port_scaling_from_name(const char *name); /* PORT_SCALING_*; -1 for another name */
void port_video_set_scaling(int mode);
/* The window's settings changed live (the menu; runtime/live.c): the scale, fullscreen, the scaling, the filter and
* the sub-pixel mode; nothing without a window. */
void port_video_apply(int scale, int fullscreen, int scaling, const PortFilter *filter, int subpixel);
int port_video_gpu_screenshot_add(const char *spec);
/* A run without a window that has --gpu-screenshot: the GPU device and the rasteriser opened now, before the game
* draws (SDL build; logged and skipped without a device). */
Expand Down
1 change: 1 addition & 0 deletions include/psxstack/settings.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ typedef struct PortSettings {
int window; /* video.window (default 1) */
int scale; /* video.scale, 1..16 (default 2) */
int fullscreen; /* video.fullscreen (default 0) */
int scaling; /* video.scaling: PORT_SCALING_* (default 0, "fit"): the picture's size in the window */
int refresh; /* video.refresh, 50 or 60 (default 50) */
int gpu; /* video.renderer: 0 "software" (default), 1 "gpu" (the hardware renderer; issue #31) */
int internal_scale; /* video.internal_scale, 1..8 (default 1): the hardware renderer's resolution, x 1024x512 */
Expand Down
25 changes: 25 additions & 0 deletions launcher/src/selftest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,31 @@ static void test_settings_file(const std::string &root) {
g.load(sp);
check(g.messages().size() == 1 && g.values.subpixel == "on", "a bad video.subpixel: one warning, \"on\"");
}
// video.scaling: not added to a file that never chose one; "integer" read and written back; a bad value warned
// about, "fit" used.
{
const std::string sc = path_join(root, "scaling");
path_make_dir(sc, nullptr);
write(path_join(sc, SETTINGS_FILE), R"({"schema":1})");
SettingsFile f;
f.load(sc);
f.values.mute = true;
check(f.values.scaling == "fit" && f.save(&err) &&
Json::parse(read(f.path()), nullptr).find("video")->find("scaling") == nullptr,
"a file that never chose a scaling gets no video.scaling");
write(path_join(sc, SETTINGS_FILE), R"({"schema":1,"video":{"scaling":"integer"}})");
SettingsFile h;
h.load(sc);
check(h.messages().empty() && h.values.scaling == "integer", "video.scaling \"integer\" is read");
h.values.scaling = "stretch";
check(h.save(&err) && Json::parse(read(h.path()), nullptr).find("video")->find("scaling")->as_string() ==
"stretch",
"video.scaling is written");
write(path_join(sc, SETTINGS_FILE), R"({"schema":1,"video":{"scaling":"zoom"}})");
SettingsFile g;
g.load(sc);
check(g.messages().size() == 1 && g.values.scaling == "fit", "a bad video.scaling: one warning, \"fit\"");
}
// video.menu_size (the in-game menu's): not added to a file that never chose one; "larger" read and written back;
// a bad value warned about, "auto" used.
{
Expand Down
8 changes: 8 additions & 0 deletions launcher/src/settings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@ Settings settings_from_json(const Json &doc, std::vector<std::string> *w) {
warn(w, "video.filter: expected a filter's name (docs/LAUNCHER.md \"Members\"); using \"none\"");
s.filter = "none";
}
read_string(video, "video", "scaling", &s.scaling, w);
if (std::find(std::begin(SCALING_NAMES), std::end(SCALING_NAMES), s.scaling) == std::end(SCALING_NAMES)) {
warn(w, "video.scaling: expected \"fit\", \"integer\" or \"stretch\"; using \"fit\"");
s.scaling = "fit";
}
read_string(video, "video", "menu_size", &s.menu_size, w);
if (std::find(std::begin(MENU_SIZE_NAMES), std::end(MENU_SIZE_NAMES), s.menu_size) == std::end(MENU_SIZE_NAMES)) {
warn(w, "video.menu_size: expected \"smaller\", \"auto\" or \"larger\"; using \"auto\"");
Expand Down Expand Up @@ -388,6 +393,9 @@ void settings_to_json(const Settings &s, Json *doc) {
if (s.filter != "none" || video.find("filter") != nullptr) { // a file that never chose one stays as it was
video.set("filter", Json::string(s.filter));
}
if (s.scaling != "fit" || video.find("scaling") != nullptr) { // as video.filter
video.set("scaling", Json::string(s.scaling));
}
if (s.menu_size != "auto" || video.find("menu_size") != nullptr) { // as video.filter
video.set("menu_size", Json::string(s.menu_size));
}
Expand Down
3 changes: 3 additions & 0 deletions launcher/src/settings.h
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ struct MemoryCard {

// video.filter's values, the game's (runtime/video_filter.c port_filter_names), in the Filter combo's order.
inline const char *const FILTER_NAMES[] = { "none", "sharp", "scanlines", "crt", "smooth" };
// video.scaling's values (the game's runtime/video_filter.c port_scaling_names), in the Settings screen's order.
inline const char *const SCALING_NAMES[] = { "fit", "integer", "stretch" };
// video.menu_size's values (the in-game menu's text, launcher/ui/menu.cpp), in the Settings screen's order.
inline const char *const MENU_SIZE_NAMES[] = { "smaller", "auto", "larger" };
// input.player2's values (the game's runtime/input.c): none, or the gamepad by connection order that is player 2.
Expand All @@ -85,6 +87,7 @@ struct Settings {
std::string disc_sha1; // the SHA-1 the launcher verified for disc_path ("" = not verified)
int scale = 2; // the window: 320*scale x 240*scale (1..16)
bool fullscreen = false;
std::string scaling = "fit"; // the picture's size in the window (video.scaling): one of SCALING_NAMES
int refresh = GAME_RATE; // 50 or 60: the nominal rate, or the game's other one (docs/LAUNCHER.md "The rate")
std::string renderer = "software"; // "software" or "gpu" (the hardware renderer; docs/LAUNCHER.md "Members")
int internal_scale = 1; // the hardware renderer's resolution, 1..8 times the PS1's
Expand Down
23 changes: 23 additions & 0 deletions launcher/ui/screens.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,29 @@ void Screens::draw_settings() {
ImGui::SameLine();
wrap_for(ImGui::CalcTextSize("(F11 in the game, by default)").x, label_w);
ImGui::TextDisabled("(F11 in the game, by default)");
// video.scaling: the picture's size in the window (the game's runtime/video.c video_dest), applied live.
{
static const char *const labels[] = { "Fit", "Integer", "Stretch" };
static_assert(SDL_arraysize(labels) == SDL_arraysize(SCALING_NAMES), "a label per scaling");
ImGui::TextDisabled("Scaling");
ImGui::SameLine(label_w);
for (int i = 0; i < (int)SDL_arraysize(labels); i++) {
if (i > 0) {
ImGui::SameLine();
wrap_for(radio_width(labels[i]), label_w);
}
if (ImGui::RadioButton(labels[i], s.scaling == SCALING_NAMES[i])) {
s.scaling = SCALING_NAMES[i];
dirty_ = true;
}
}
ImGui::Indent(label_w);
ImGui::PushTextWrapPos(0);
ImGui::TextDisabled("Fit: as large as the screen allows. Integer: every line the same height, with black bars "
"where the screen is not a whole multiple. Stretch: the whole screen, out of shape.");
ImGui::PopTextWrapPos();
ImGui::Unindent(label_w);
}
// video.menu_size: the in-game menu's size, applied as soon as it is chosen (launcher/ui/menu.cpp).
{
static const char *const labels[] = { "Smaller", "Auto", "Larger" };
Expand Down
4 changes: 2 additions & 2 deletions runtime/live.c
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* The settings applied live (docs/LAUNCHER.md "In-game menu"): the in-game menu wrote the settings file (through
* the launcher's own code, so it is well formed) and asks the runtime to follow it without a restart. What follows:
* the window's scale and fullscreen, the hardware renderer's filter and sub-pixel mode (video.c), audio.mute
* the window's scale, fullscreen and scaling, the hardware renderer's filter and sub-pixel mode (video.c), audio.mute
* (audio.c), the pad map and the hotkeys (input.c: the table starts over, so the port's own actions and every
* enabled mod's bindings are registered again) and the mods (mods.c port_mods_apply). What waits for the next start,
* and the menu says so: the disc, the renderer and its internal scale, the rate, the memory cards, the watchdog, the
Expand All @@ -20,7 +20,7 @@ int port_live_apply(void) {
filter.scanlines = s->crt_scanlines;
filter.mask = s->crt_mask;
filter.curvature = s->crt_curvature;
port_video_apply(s->scale, s->fullscreen, &filter, s->subpixel);
port_video_apply(s->scale, s->fullscreen, s->scaling, &filter, s->subpixel);
port_audio_set_user_mute(s->mute);
port_input_settings(s->input);
port_mods_apply(s->mods);
Expand Down
Loading
Loading