diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 8e7e503..1840dbd 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 @@ -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, diff --git a/docs/GAME_CONTRACT.md b/docs/GAME_CONTRACT.md index 42f2892..f992683 100644 --- a/docs/GAME_CONTRACT.md +++ b/docs/GAME_CONTRACT.md @@ -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//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 diff --git a/docs/LAUNCHER.md b/docs/LAUNCHER.md index d49dfd1..6c22d39 100644 --- a/docs/LAUNCHER.md +++ b/docs/LAUNCHER.md @@ -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 | diff --git a/docs/PORT.md b/docs/PORT.md index f641692..9cd2285 100644 --- a/docs/PORT.md +++ b/docs/PORT.md @@ -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. diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index a5aef5b..e40f521 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -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 diff --git a/include/psxstack/port_harness.h b/include/psxstack/port_harness.h index 4a0a2bf..9ba41db 100644 --- a/include/psxstack/port_harness.h +++ b/include/psxstack/port_harness.h @@ -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). */ diff --git a/include/psxstack/settings.h b/include/psxstack/settings.h index 96c751e..e212129 100644 --- a/include/psxstack/settings.h +++ b/include/psxstack/settings.h @@ -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 */ diff --git a/launcher/src/selftest.cpp b/launcher/src/selftest.cpp index 516f612..bd3d829 100644 --- a/launcher/src/selftest.cpp +++ b/launcher/src/selftest.cpp @@ -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. { diff --git a/launcher/src/settings.cpp b/launcher/src/settings.cpp index b62e97a..ce7bae1 100644 --- a/launcher/src/settings.cpp +++ b/launcher/src/settings.cpp @@ -295,6 +295,11 @@ Settings settings_from_json(const Json &doc, std::vector *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\""); @@ -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)); } diff --git a/launcher/src/settings.h b/launcher/src/settings.h index 9201a0e..300bf8a 100644 --- a/launcher/src/settings.h +++ b/launcher/src/settings.h @@ -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. @@ -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 diff --git a/launcher/ui/screens.cpp b/launcher/ui/screens.cpp index 381d53e..3658d33 100644 --- a/launcher/ui/screens.cpp +++ b/launcher/ui/screens.cpp @@ -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" }; diff --git a/runtime/live.c b/runtime/live.c index f3ebc81..da92d01 100644 --- a/runtime/live.c +++ b/runtime/live.c @@ -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 @@ -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); diff --git a/runtime/main.c b/runtime/main.c index 5fc8b6c..d8cef6e 100644 --- a/runtime/main.c +++ b/runtime/main.c @@ -51,7 +51,8 @@ static void usage(const char *argv0) { " [--disc CUE|BIN] [--no-disc-check] [--cd-speed instant|realistic] [--memcard1|2 MCD|none]\n" " [--script JSON]\n" " [--log FILE] [--record FILE] [--max-frames N] [--watchdog SEC] [--trace]\n" - " [--window] [--scale N] [--fullscreen] [--fps N] [--input-test] [--screenshot FRAME:PATH]\n" + " [--window] [--scale N] [--fullscreen] [--scaling fit|integer|stretch] [--fps N] [--input-test]\n" + " [--screenshot FRAME:PATH]\n" " [--renderer software|gpu] [--internal-scale N] [--gpu-screenshot FRAME[@WxH]:PATH]\n" " [--subpixel off|on|perspective]\n" " [--filter NAME[:KEY=V,...]]\n" @@ -94,6 +95,9 @@ static void usage(const char *argv0) { " script owns the pad\n" " --scale N the window's size: 320*N x 240*N (default 2; implies --window)\n" " --fullscreen a fullscreen window (implies --window)\n" + " --scaling S the picture's size in the window: fit (default: as large as it fits at its aspect),\n" + " integer (a whole multiple of its lines: every line the same height, bars around it)\n" + " or stretch (the whole window); --gpu-screenshot's @WxH too; overrides video.scaling\n" " --fps N the window's pace and the nominal rate: N vsyncs per second (default " PSXSTACK_STR(PSXSTACK_GAME_RATE) ", the game's video.rate;\n" " 0: unthrottled at that rate)\n" @@ -168,7 +172,7 @@ int main(int argc, char **argv) { int memcard_given[2] = { 0, 0 }; int disc_check = 1, max_frames_given = 0; int window = 0, scale = 2, fullscreen = 0, input_test = 0, gpu = 0, gpu_shots = 0, internal_scale = 1; - int subpixel = 1, no_game = 0; + int subpixel = 1, no_game = 0, scaling = PORT_SCALING_FIT; const char *config = NULL; const char *quick_state = NULL; PortFilter filter = PORT_FILTER_DEFAULTS; @@ -188,6 +192,7 @@ int main(int argc, char **argv) { window = port_settings.window; scale = port_settings.scale; fullscreen = port_settings.fullscreen; + scaling = port_settings.scaling; gpu = port_settings.gpu; internal_scale = port_settings.internal_scale; subpixel = port_settings.subpixel; @@ -257,6 +262,11 @@ int main(int argc, char **argv) { } else if (strcmp(argv[i], "--fullscreen") == 0) { fullscreen = 1; window = 1; + } else if (strcmp(argv[i], "--scaling") == 0 && i + 1 < argc) { + if ((scaling = port_scaling_from_name(argv[++i])) < 0) { + fprintf(stderr, "port: --scaling: fit, integer or stretch\n"); + return 64; + } } else if (strcmp(argv[i], "--fps") == 0 && i + 1 < argc) { fps = number(argv[i + 1], argv[i]); i++; @@ -383,6 +393,7 @@ int main(int argc, char **argv) { eff.window = window; eff.scale = scale; eff.fullscreen = fullscreen; + eff.scaling = scaling; eff.mute = mute; eff.watchdog = port_watchdog_sec; eff.refresh = refresh; @@ -424,6 +435,7 @@ int main(int argc, char **argv) { port_video_set_renderer(gpu ? "gpu" : "software"); port_video_set_internal_scale(internal_scale); port_video_set_subpixel(subpixel); + port_video_set_scaling(scaling); port_video_set_filter(&filter); if (window && !port_video_available()) { fprintf(stderr, "port: --window: this build has no window: configure with -DPSXSTACK_SDL=ON " diff --git a/runtime/render_gpu.h b/runtime/render_gpu.h index 3464983..732fe52 100644 --- a/runtime/render_gpu.h +++ b/runtime/render_gpu.h @@ -57,10 +57,12 @@ void render_gpu_raster_resync(void); void render_gpu_set_filter(const PortFilter *f); /* Widescreen (render_gpu_wide.c; video.c port_video_widescreen): enable before render_gpu_raster_start (the target gets * the wide canvas strip, 1.5 times as wide); on or off every vsync (the scenes that widen; off without the - * rasteriser); the width a 15-bit display of w x h at vram_xy is presented with: its canvas's (16:9 at the display's - * pixel aspect), or 0 when it stays 4:3. render_gpu_present and render_gpu_readback show the canvas for that width. */ + * rasteriser); the canvas's aspect a:b (from 4:3 to 16:9, the window's: video.c video_wide_follow; 16:9 until it is + * set); the width a 15-bit display of w x h at vram_xy is presented with: its canvas's (a:b at the display's pixel + * aspect), or 0 when it stays 4:3. render_gpu_present and render_gpu_readback show the canvas for that width. */ void render_gpu_wide_enable(void); void render_gpu_wide_set(int on); +void render_gpu_wide_set_aspect(int a, int b); int render_gpu_wide_width(const int vram_xy[2], int w, int h); /* Releases everything, the window's claim, then the device (before SDL_Quit: video.c port_video_quit). */ void render_gpu_close(void); diff --git a/runtime/render_gpu_wide.c b/runtime/render_gpu_wide.c index 3002ec2..2b60c6d 100644 --- a/runtime/render_gpu_wide.c +++ b/runtime/render_gpu_wide.c @@ -6,9 +6,12 @@ * for a wider picture (textures sit beside the display buffers), so the rasteriser keeps a canvas per display buffer * in a strip of its target right of the VRAM (render_gpu_wide.h; the target is 1024 + 512 VRAM pixels wide while * widescreen is enabled) and draws every unit it draws into a display buffer a second time there: - * - translated by (dx, dy), so that the buffer's left edge lands P pixels into the canvas: P = ceil(w / 6), which - * makes the canvas w + 2P wide, 16:9 at the 4:3 display's pixel aspect (320 -> 428). dx and dy keep the 4x4 - * dither's phase; the canvas's rows are a 256-line slot, so two buffers share the strip; + * - translated by (dx, dy), so that the buffer's left edge lands P pixels into the canvas: P makes the canvas + * w + 2P wide, the aspect a:b video.c asks for (render_gpu_wide_set_aspect: the window's, 4:3 to 16:9) at the 4:3 + * display's pixel aspect, rounded down so that the canvas is never wider than a:b (a whole multiple of it fits a + * screen of that aspect): P = floor(w (3a - 4b) / 8b), 53 at 16:9 (320 -> 426), 32 at 16:10 (320 -> 384), 0 at + * 4:3 (no wide picture). dx and dy keep the 4x4 dither's phase; the canvas's rows are a 256-line slot, so two + * buffers share the strip; * - with the drawing area widened by P on each side where it reaches the buffer's edge (the layers that draw the * whole screen); an area inside the buffer (a window's clip) keeps its size; * - an untextured triangle whose vertices all lie on the buffer's left and right edges, or an untextured rectangle @@ -53,6 +56,7 @@ static struct { int on; /* the scenes that widen: port_video_widescreen */ int w, h; /* the display's size the slots are for */ int pad; /* P: the columns added on each side */ + int aspect[2]; /* the canvas's aspect, a:b (render_gpu_wide_set_aspect; 0:0 until then: 16:9) */ unsigned long vsync; WideSlot slot[WIDE_SLOTS]; } wide; @@ -65,6 +69,11 @@ int render_gpu_wide_enabled(void) { return wide.enabled; } +void render_gpu_wide_set_aspect(int a, int b) { + wide.aspect[0] = a; + wide.aspect[1] = b; +} + /* The slot of the display buffer at (x, y), or NULL. */ static WideSlot *wide_find(int x, int y) { int k; @@ -127,18 +136,18 @@ static WideSlot *wide_add(int x, int y) { return s; } -/* The display's size: the slots start over when it changes; 0 when it cannot be shown wide. */ +/* The display's size: the slots start over when it or the aspect's P changes; 0 when it cannot be shown wide. */ static int wide_display(PsyqDisplay *d) { - int pad; + int pad, a = wide.aspect[0] > 0 ? wide.aspect[0] : 16, b = wide.aspect[0] > 0 ? wide.aspect[1] : 9; psyq_gpu_display(d); if (!d->enabled || d->rgb24 || d->w <= 0 || d->h <= 0) { return 0; } - pad = (d->w + 5) / 6; - if (d->w + 2 * pad + 3 > RENDER_WIDE_COLS || d->h > WIDE_SLOT_H) { + pad = d->w * (3 * a - 4 * b) / (8 * b); + if (pad <= 0 || d->w + 2 * pad + 3 > RENDER_WIDE_COLS || d->h > WIDE_SLOT_H) { return 0; } - if (d->w != wide.w || d->h != wide.h) { + if (d->w != wide.w || d->h != wide.h || pad != wide.pad) { memset(wide.slot, 0, sizeof(wide.slot)); wide.w = d->w; wide.h = d->h; diff --git a/runtime/settings.c b/runtime/settings.c index b939930..569a5e0 100644 --- a/runtime/settings.c +++ b/runtime/settings.c @@ -5,6 +5,7 @@ * "disc": { "path": "dw2003.cue", "sha1": "457cb233..." }, none / none * "video": { "window": true, "scale": 2, "fullscreen": false, "refresh": 50, "renderer": "software", * "internal_scale": 1, + * "scaling": "fit", * "subpixel": "on", * "filter": "none", * "crt": { "scanlines": 50, "mask": 30, "curvature": 0 }, @@ -159,6 +160,7 @@ static void settings_parse(PortSettings *s, const char *path) { "input", "mods", "mod_order", "launcher", NULL }; static const char *const disc_keys[] = { "path", "sha1", NULL }; static const char *const video_keys[] = { "window", "scale", "fullscreen", "refresh", "renderer", "internal_scale", + "scaling", "subpixel", "filter", "crt", @@ -243,6 +245,10 @@ static void settings_parse(PortSettings *s, const char *path) { s->gpu = v->string[0] == 'g'; } settings_int(obj, "video", "internal_scale", 1, 8, &s->internal_scale); + if ((v = settings_member(obj, "video", "scaling", PORT_JSON_STRING)) != NULL && + (s->scaling = port_scaling_from_name(v->string)) < 0) { + port_settings_fail("video.scaling", "\"fit\", \"integer\" or \"stretch\", not \"%s\"", v->string); + } if ((v = settings_member(obj, "video", "subpixel", PORT_JSON_STRING)) != NULL) { if (strcmp(v->string, "off") != 0 && strcmp(v->string, "on") != 0 && strcmp(v->string, "perspective") != 0) { @@ -344,6 +350,7 @@ void port_settings_print(FILE *f, const PortSettings *s) { " \"refresh\": %d,\n \"renderer\": \"%s\",\n \"internal_scale\": %d", s->window ? "true" : "false", s->scale, s->fullscreen ? "true" : "false", s->refresh, s->gpu ? "gpu" : "software", s->internal_scale); + fprintf(f, ",\n \"scaling\": \"%s\"", port_scaling_names[s->scaling]); fprintf(f, ",\n \"subpixel\": \"%s\"", s->subpixel == 2 ? "perspective" : s->subpixel ? "on" : "off"); fprintf(f, ",\n \"filter\": \"%s\"", port_filter_names[s->filter]); fprintf(f, ",\n \"crt\": { \"scanlines\": %d, \"mask\": %d, \"curvature\": %d }", s->crt_scanlines, s->crt_mask, diff --git a/runtime/video.c b/runtime/video.c index d0bb470..9b9cdc2 100644 --- a/runtime/video.c +++ b/runtime/video.c @@ -3,10 +3,11 @@ * per vsync into 32-bit pixels at the display's own size. Two consumers: * * - The window (`--window`; only in a build configured with -DPSXSTACK_SDL=ON, which defines PSXSTACK_SDL): an SDL3 - * streaming texture updated and presented every vsync, drawn at 4:3 with nearest-neighbour filtering, as large as - * an integer multiple of the image's lines fits (`--scale N`: a 320*N x 240*N window; `--fullscreen`; F11 toggles): - * with an even N a 240-line and a 480-line display come out the same size. input.c reads the window's events; - * pump.c paces the vsyncs to real time (50 Hz) in window mode. + * streaming texture updated and presented every vsync, drawn at 4:3 with nearest-neighbour filtering (`--scale N`: + * a 320*N x 240*N window; `--fullscreen`; F11 toggles), as large as `--scaling` says (video_dest): fit (the + * default) as large as the window allows at 4:3, integer as large as a whole multiple of the image's lines fits + * (with an even N a 240-line and a 480-line display come out the same size), stretch the whole window. input.c reads + * the window's events; pump.c paces the vsyncs to real time (50 Hz) in window mode. * - Screenshots (`--screenshot FRAME:PATH`, any build): the image of vsync FRAME as a binary PPM (P6), exactly the * pixels the window's texture gets. They depend on nothing but the VRAM and the display area (no host state), so * two runs, or a window and a headless run, give the same bytes. The debug channel's screenshot is the same image. @@ -23,8 +24,9 @@ * (port_video_gpu_headless), and skips the shot with a log line when there is none. * Widescreen (render_gpu_wide.c): a game mod calls port_video_widescreen_enable before the window opens (with the GPU * renderer chosen, a new window opens 16:9) and port_video_widescreen(on) every vsync; while it is on, a 15-bit display - * that has a wide canvas is presented 16:9 inside the window (the window's size is kept: the picture switches between - * 4:3 and 16:9 in it), and `--gpu-screenshot` and the debug channel's GPU screenshot give the wide image. The + * that has a wide canvas is presented at the window's aspect, from 4:3 to 16:9 (video_wide_follow: a 16:10 screen + * gets a 16:10 picture; the window's size is kept: the picture switches between 4:3 and the wide one in it; 16:9 + * without a window), and `--gpu-screenshot` and the debug channel's GPU screenshot give the wide image. The * software renderer, `--screenshot` and the frame hash stay 4:3 (logged once while it is asked for). * `_PORT_PRESENT_READBACK=FRAME:PATH` reads SDL_Renderer's output of vsync FRAME back into a PPM (the comparison * of the two present paths: tests/port/render_gpu.py); `_PORT_GPU_VRAM_CHECK=N` compares the rasteriser's whole @@ -82,6 +84,7 @@ static long video_check_every = -1, video_checks, video_checks_failed; /* > 5) & 31, b = (c >> 10) & 31; @@ -197,6 +200,10 @@ void port_video_set_filter(const PortFilter *f) { video_filter = *f; } +void port_video_set_scaling(int mode) { + video_scaling = mode >= 0 && mode < PORT_SCALING_COUNT ? mode : PORT_SCALING_FIT; +} + int port_video_gpu_screenshot_add(const char *spec) { char *end; long frame = strtol(spec, &end, 0); @@ -248,7 +255,8 @@ static int video_tex_w, video_tex_h; static Uint64 video_start_ns; static long video_presents; static int video_gpu; /* the window presents through the hardware renderer */ -static int video_aspect[2] = { 4, 3 }; /* the presented picture's aspect: 4:3, or 16:9 at the display's pixel aspect */ +static int video_aspect[2] = { 4, 3 }; /* the presented picture's aspect: 4:3, or video_wide_aspect's */ +static int video_wide_aspect[2] = { 16, 9 }; /* a wide picture's: the window's, 4:3 to 16:9 (video_wide_follow) */ static int video_scale_set; /* the scale the window was opened or last sized at (port_video_apply) */ int port_video_available(void) { @@ -311,11 +319,16 @@ void port_video_toggle_fullscreen(void) { /* video.scale and video.fullscreen changed live (the menu, live.c): the fullscreen state follows; the window is * sized to the scale when it changed and the window is not fullscreen (a fullscreen window keeps the size for when - * it leaves). The filter (video.filter, video.crt) and the sub-pixel mode follow through their setters. */ -void port_video_apply(int scale, int fullscreen, const PortFilter *filter, int subpixel) { + * it leaves). The scaling (video_dest, from the next present), the filter (video.filter, video.crt) and the sub-pixel + * mode follow through their setters. */ +void port_video_apply(int scale, int fullscreen, int scaling, const PortFilter *filter, int subpixel) { if (video_window == NULL) { return; } + if (scaling != video_scaling) { + port_video_set_scaling(scaling); + port_log("window: scaling %s", port_scaling_names[video_scaling]); + } if (fullscreen != ((SDL_GetWindowFlags(video_window) & SDL_WINDOW_FULLSCREEN) != 0)) { SDL_SetWindowFullscreen(video_window, fullscreen != 0); } @@ -339,27 +352,33 @@ void port_video_apply(int scale, int fullscreen, const PortFilter *filter, int s port_video_set_subpixel(subpixel); } -/* The image's place in the renderer's output (ow x oh pixels): 4:3 (video_aspect; a wide picture's 16:9), as tall as - * an integer multiple of its lines allows (every line the same height), centred; scaled to fit when the output has - * fewer pixel rows than it has lines. The width is not an integer multiple for every mode (only 320 and 640 wide - * displays are 4:3 pixel for pixel). */ +/* The image's place in the renderer's output (ow x oh pixels), centred, in whole pixels, by video_scaling: fit, as + * large as the output allows at its aspect (video_aspect: 4:3, a wide picture's video_wide_aspect), as tall or as wide + * as the output; integer, as tall as an integer multiple of its lines allows (every line the same height), as fit + * when the output has fewer pixel rows than it has lines; stretch, the whole output. The width is not an integer + * multiple for every mode (only 320 and 640 wide displays are 4:3 pixel for pixel). */ static void video_dest(int ow, int oh, SDL_FRect *dst) { - int f = oh / video_h, an = video_aspect[0], ad = video_aspect[1]; - float dw, dh; - while (f > 0 && (video_h * f * an + ad - 1) / ad > ow) { + int f = oh / video_h, an = video_aspect[0], ad = video_aspect[1], dw, dh; + while (video_scaling == PORT_SCALING_INTEGER && f > 0 && (video_h * f * an + ad - 1) / ad > ow) { f--; } - if (f > 0) { - dh = (float)(video_h * f); - dw = (float)((video_h * f * an + ad - 1) / ad); /* rounded up: 4:3's (h f 4 + 2) / 3 */ + if (video_scaling == PORT_SCALING_STRETCH) { + dw = ow; + dh = oh; + } else if (video_scaling == PORT_SCALING_INTEGER && f > 0) { + dh = video_h * f; + dw = (dh * an + ad - 1) / ad; /* rounded up: 4:3's (h f 4 + 2) / 3 */ + } else if (ow * ad >= oh * an) { + dh = oh; /* the output is wider than the picture: as tall */ + dw = (oh * an + ad / 2) / ad; } else { - dh = (float)oh < ow * (float)ad / (float)an ? (float)oh : ow * (float)ad / (float)an; - dw = dh * (float)an / (float)ad; + dw = ow; + dh = (ow * ad + an / 2) / an; } - dst->w = dw; - dst->h = dh; - dst->x = (float)(int)((ow - dw) / 2); - dst->y = (float)(int)((oh - dh) / 2); + dst->w = (float)dw; + dst->h = (float)dh; + dst->x = (float)((ow - dw) / 2); + dst->y = (float)((oh - dh) / 2); } /* video_dest in whole pixels (the hardware renderer's rectangle: its present maps pixels with integers). */ @@ -400,15 +419,48 @@ static void video_readback(void) { SDL_DestroySurface(shot); } -/* The width the hardware renderer shows the converted display with: its wide canvas's while widescreen is on (and - * video_aspect 16:9 at the display's pixel aspect), else the display's (4:3). */ +/* The width the hardware renderer shows the converted display with: its wide canvas's while widescreen is on, else + * the display's (4:3). A wide picture's video_aspect: integer scaling shows the canvas at its own aspect, pixel for + * pixel (4 W : 3 w; render_gpu_wide.c rounds it down, so a whole multiple fits a screen of video_wide_aspect's); + * fit and stretch at video_wide_aspect's, which the canvas was made for (less than a column wider than it). */ static int video_gpu_width(void) { int w = video_vram ? render_gpu_wide_width(video_vram_xy, video_w, video_h) : 0; - video_aspect[0] = w > 0 ? 4 * w : 4; - video_aspect[1] = w > 0 ? 3 * video_w : 3; + int exact = video_scaling == PORT_SCALING_INTEGER; + video_aspect[0] = w <= 0 ? 4 : exact ? 4 * w : video_wide_aspect[0]; + video_aspect[1] = w <= 0 ? 3 : exact ? 3 * video_w : video_wide_aspect[1]; return w > 0 ? w : video_w; } +/* A wide picture's aspect, the window's (in pixels: what video_dest gets) from 4:3 to 16:9, the widest the games' + * wide scenes were checked at (a wider window shows the 16:9 picture with bars at the sides, a narrower one 4:3); + * 16:9 without a window. render_gpu_wide.c makes the canvas for it; a change starts the canvases over. */ +static void video_wide_follow(void) { + int w, h, a, b; + if (video_window == NULL || !SDL_GetWindowSizeInPixels(video_window, &w, &h) || w <= 0 || h <= 0) { + return; + } + if (w * 9 > h * 16 || (h * 16 + 4) / 9 - w <= 1) { /* within a pixel of 16:9: the window opened 16:9 */ + w = 16; + h = 9; + } else if (w * 3 < h * 4) { + w = 4; + h = 3; + } + for (a = w, b = h; b != 0;) { /* the aspect in lowest terms: 1280x800 is 16:10 */ + int r = a % b; + a = b; + b = r; + } + w /= a; + h /= a; + if (w != video_wide_aspect[0] || h != video_wide_aspect[1]) { + port_log("widescreen: %d:%d, the window's", w, h); + } + video_wide_aspect[0] = w; + video_wide_aspect[1] = h; + render_gpu_wide_set_aspect(w, h); +} + static void video_present(void) { SDL_FRect dst; int ow, oh; @@ -650,9 +702,10 @@ void port_video_open(int scale, int fullscreen) { void port_video_toggle_fullscreen(void) { } -void port_video_apply(int scale, int fullscreen, const PortFilter *filter, int subpixel) { +void port_video_apply(int scale, int fullscreen, int scaling, const PortFilter *filter, int subpixel) { (void)scale; (void)fullscreen; + (void)scaling; (void)filter; (void)subpixel; } @@ -688,6 +741,9 @@ void port_video_widescreen_enable(void) { void port_video_widescreen(int on) { #ifdef PSXSTACK_SDL int gpu = render_gpu_rasterising(); + if (on) { + video_wide_follow(); + } render_gpu_wide_set(on); #else int gpu = 0; diff --git a/runtime/video_filter.c b/runtime/video_filter.c index 02c4206..76bfe94 100644 --- a/runtime/video_filter.c +++ b/runtime/video_filter.c @@ -1,5 +1,5 @@ /* The present's filters by name (port_harness.h "PortFilter"; docs/RUNTIME.md "The window"): `--filter - * NAME[:KEY=V,...]` and video.filter. In every build: the headless build reads the same settings and options, and + * NAME[:KEY=V,...]` and video.filter; the scaling modes too (`--scaling`, video.scaling). In every build: the headless build reads the same settings and options, and * only the hardware renderer (render_gpu_present.c) draws them. */ #include #include @@ -9,6 +9,18 @@ const char *const port_filter_names[PORT_FILTER_COUNT] = { "none", "sharp", "scanlines", "crt", "smooth" }; +const char *const port_scaling_names[PORT_SCALING_COUNT] = { "fit", "integer", "stretch" }; + +int port_scaling_from_name(const char *name) { + int i; + for (i = 0; i < PORT_SCALING_COUNT; i++) { + if (strcmp(name, port_scaling_names[i]) == 0) { + return i; + } + } + return -1; +} + /* The parameters a filter takes (`--filter NAME:KEY=V`): a bit per entry of keys[]. */ static const char *const keys[] = { "scanlines", "mask", "curvature" }; static const unsigned takes[PORT_FILTER_COUNT] = { 0, 0, 1, 7, 0 };