From 5d838d824bcfd8a264b2e717953e10995cef2aab Mon Sep 17 00:00:00 2001 From: Gabriel Scarcella Date: Mon, 26 Oct 2026 14:30:54 -0300 Subject: [PATCH] Launcher: a Filter row for a game's 2D scenes and one for its 3D scenes A game with video.scenes has two present-filter sets (dw2003recomp #122). The launcher's settings layer holds them as FilterSets: video.filter and video.crt (the 3D set, the only one of a game without scenes) and video.scene_2d (the 2D set), read member by member over what an absent scene_2d means (game.json's 2d.filter with the default crt values, else a copy of the 3D set), written whole once it differs from that or the file has it, so a file that never chose a 2D set stays as it was. A game without scenes neither reads nor writes the key. The Settings screen draws, for such a game, two Filter rows, "Filter: <3D label>" and "Filter: <2D label>", each with its combo, sliders and help line (a colon: the UI font has no dash); the screen of a game without scenes is unchanged. The in-game menu marks the set on screen through the runtime's port_video_scene(); a change to either set is saved and applied live as before (port_live_apply rebuilds both). brand.h carries the labels and the 2D default from the generated PSXSTACK_GAME_SCENE* macros. The self-test covers the scene_2d round trip, the absent, partial and bad cases, the key left alone by a game without scenes, and a picture of the two rows with the 2D one marked. Co-Authored-By: Claude Opus 5.5 --- docs/LAUNCHER.md | 6 +- launcher/README.md | 12 +-- launcher/src/brand.h | 6 ++ launcher/src/selftest.cpp | 151 +++++++++++++++++++++++++++++--------- launcher/src/settings.cpp | 90 ++++++++++++++++------- launcher/src/settings.h | 22 +++++- launcher/ui/menu.cpp | 7 ++ launcher/ui/screens.cpp | 48 ++++++++---- launcher/ui/screens.h | 7 +- 9 files changed, 267 insertions(+), 82 deletions(-) diff --git a/docs/LAUNCHER.md b/docs/LAUNCHER.md index 6c22d39..342122d 100644 --- a/docs/LAUNCHER.md +++ b/docs/LAUNCHER.md @@ -100,8 +100,9 @@ and absolute paths and exits 0, or exits 64 naming the bad key. | `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 | | `video.subpixel` | `"off"`, `"on"` or `"perspective"` | `"on"` | Above internal scale 1, the hardware renderer draws the 3D at the GTE's sub-pixel positions (`docs/PORT.md` "Sub-pixel precision") instead of the PS1's whole pixels; `"perspective"` also textures it perspective-correct (not the PS1's look: its affine textures swim on large polygons). The launcher's "3D vertices" choice, shown for the GPU renderer above x1. A game built before it logs the key and ignores it | -| `video.filter` | `"none"`, `"sharp"`, `"scanlines"`, `"crt"` or `"smooth"` | `"none"` | The hardware renderer's present filter (docs/PORT.md "Rendering"): `none` shows the picture's pixels as whole blocks, `sharp` is sharp bilinear, `scanlines` sharp with dark lines, `crt` a TV, `smooth` xBR on the 1x picture (the internal scale does not apply to it). The software renderer shows the picture unfiltered and logs it. The launcher's "Filter" combo, shown for the GPU renderer; it writes the member only once it is set | +| `video.filter` | `"none"`, `"sharp"`, `"scanlines"`, `"crt"` or `"smooth"` | `"none"` | The hardware renderer's present filter (docs/PORT.md "Rendering"): `none` shows the picture's pixels as whole blocks, `sharp` is sharp bilinear, `scanlines` sharp with dark lines, `crt` a TV, `smooth` xBR on the 1x picture (the internal scale does not apply to it). The software renderer shows the picture unfiltered and logs it. The launcher's "Filter" combo, shown for the GPU renderer; it writes the member only once it is set. A game with scenes (GAME_CONTRACT.md "1. game.json" Scenes): the set of its 3D scenes, the row "Filter: " | | `video.crt` | `{ "scanlines", "mask", "curvature" }`, integers 0-100 | 50, 30, 0 | The filters' parameters: `scanlines` (the lines' strength) for `scanlines` and `crt`, `mask` (the aperture grille) and `curvature` for `crt`. The launcher's sliders under the Filter combo; written once set, as `video.filter` | +| `video.scene_2d` | `{ "filter", "crt" }`, as `video.filter` and `video.crt` | game.json's `video.scenes.2d.filter` with the default `crt`; without one, `video.filter` and `video.crt` | A game with scenes: the present-filter set of its 2D scenes (`video.filter` and `video.crt` are then its 3D scenes'); the window switches between the two at the vsync where the game's scene changes. A member it lacks is the default's. The launcher's second Filter row, "Filter: "; it writes the member whole once the set differs from the default, and keeps it once the file has it. A game without scenes logs the key and ignores it (the launcher leaves it as it is) | | `video.menu_size` | `"smaller"`, `"auto"` or `"larger"` | `"auto"` | The in-game menu's size ("In-game menu"): its fit to the window, a step smaller or larger. The Settings screen's "Menu size"; written once set. The menu reads it, the runtime does not (`--print-settings` leaves it out); a game built before it logs the key and ignores it | | `audio.mute` | bool | false | No audio device (switched off in the in-game menu: the device opens then) | | `memcard1`, `memcard2` | string or `null` | `"card1.mcd"`, `"card2.mcd"` | A `.mcd` image, created formatted when missing; `null`: no card in that slot. An empty string is an error | @@ -482,7 +483,8 @@ at a time), where leaving the game for the launcher's window is a chore. again through the launcher's `SettingsFile` and writes it as the launcher would, keeping every member it does not edit) and the mods beside the executable plus the user's data mods (`--mods-dir`). A change is saved when no widget is in use (and when the menu closes), and the runtime then re-reads the file and applies it (`runtime/live.c` - `port_live_apply`): the window's scale and fullscreen, the hardware renderer's filter and sub-pixel mode, + `port_live_apply`): the window's scale and fullscreen, the hardware renderer's filter (both sets, for a game with + scenes: the Settings page marks the one on screen, `port_video_scene()`) and sub-pixel mode, `audio.mute` (a game started muted opens its audio device when the mute goes off), the pad map and every hotkey (the table starts over, the enabled mods' bindings registered again) and the mods (`port_mods_apply`: one switched on gets its hotkeys and its `start`, one switched off its `stop`, one that stays on with other values its `start` diff --git a/launcher/README.md b/launcher/README.md index e3450ed..0233cfe 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -197,16 +197,18 @@ drops `SDL_VIDEO_DRIVER` from the program's environment.) No disc and no display needed (CI runs it). It replaces `DIR/launcher-self-test/` and checks: the path helpers (Windows forms too), the JSON writer, the lookup order, the settings file's round trips (the documented example with its unknown -members kept, invalid values, a broken file, a newer schema), the binding grammar, `input.player2`, and the input prompt +members kept, invalid values, a broken file, a newer schema; `video.scene_2d` for a game with scenes, left as it is by +one without), the binding grammar, `input.player2`, and the input prompt (keys, gamepad buttons, stick directions, chords, Escape), the `.cue` reader and the SHA-1 check (against the described discs), the launch path with **the launcher itself as the game's stand-in** (`_LAUNCHER_FAKE_GAME=mode`: a game with and without `--config`, one that rejects the file, one that crashes, one that fails after 250 lines), then opens the window, walks every screen with injected key events and a virtual gamepad, plays with the stand-in (a file of the first described disc's size stands for the verified disc), rebinds a key through the prompt and checks the file, shows -the Gamepad tab with player 2 set and two virtual gamepads connected, drops a wrong file on the window, renders the mods -screen over the fixture manifests (`tests/fixtures/mods/`: the first game's `fast_forward` as shipped, the documented -`skip_dialogues` example, one with every option type, one with presets and a slider whose toggle makes it a typed -number; three unusable ones written at test time) and rebinds a mod's key through the prompt, and saves a picture of +the Gamepad tab with player 2 set and two virtual gamepads connected, the GPU renderer's rows (for a game with scenes +the two Filter rows, the 2D one marked as the in-game menu marks the set on screen), drops a wrong file on the window, +renders the mods screen over the fixture manifests (`tests/fixtures/mods/`: the first game's `fast_forward` as shipped, +the documented `skip_dialogues` example, one with every option type, one with presets and a slider whose toggle makes +it a typed number; three unusable ones written at test time) and rebinds a mod's key through the prompt, and saves a picture of each screen in `DIR/launcher-self-test/screens/`. The update check never touches the network: `_LAUNCHER_FAKE_CURL` points at **the launcher itself as curl's stand-in** (it answers with a file, in pieces for a download, or with curl's errors; `_SELFTEST_CURL_REPLY` says which), and every check of the self-test goes diff --git a/launcher/src/brand.h b/launcher/src/brand.h index 8419852..37ebf26 100644 --- a/launcher/src/brand.h +++ b/launcher/src/brand.h @@ -49,6 +49,12 @@ constexpr int GAME_RATE = PSXSTACK_GAME_RATE; constexpr int GAME_RATE_COUNT = PSXSTACK_GAME_RATE_COUNT; inline constexpr int GAME_RATES[PSXSTACK_GAME_RATE_COUNT] = PSXSTACK_GAME_RATES; constexpr const char *GAME_RATE_NOTE = PSXSTACK_GAME_RATE_NOTE; +// The scenes (the description's video.scenes): a present-filter set for the 2D scenes and one for the 3D scenes, +// named by their labels in the Filter rows; the 2D set's default filter ("" = a copy of the 3D set's). +constexpr bool GAME_SCENES = PSXSTACK_GAME_SCENES != 0; +constexpr const char *GAME_SCENE_2D_LABEL = PSXSTACK_GAME_SCENE_2D_LABEL; +constexpr const char *GAME_SCENE_3D_LABEL = PSXSTACK_GAME_SCENE_3D_LABEL; +constexpr const char *GAME_SCENE_2D_FILTER = PSXSTACK_GAME_SCENE_2D_FILTER; // The discs the game accepts (disc.h: disc_by_sha1, disc_known, disc_labels). A disc-free game (examples/hello) // describes none: the generated list then holds one empty entry, which the count keeps out of reach. constexpr int GAME_DISC_COUNT = PSXSTACK_GAME_DISC_COUNT; diff --git a/launcher/src/selftest.cpp b/launcher/src/selftest.cpp index bd3d829..556de2f 100644 --- a/launcher/src/selftest.cpp +++ b/launcher/src/selftest.cpp @@ -302,23 +302,23 @@ static void test_settings_file(const std::string &root) { write(path_join(filtered, SETTINGS_FILE), R"({"schema":1,"video":{"renderer":"gpu","filter":"sharp"}})"); SettingsFile fl; fl.load(filtered); - check(fl.messages().empty() && fl.values.filter == "sharp", "video.filter is read"); - fl.values.filter = "none"; + check(fl.messages().empty() && fl.values.present.filter == "sharp", "video.filter is read"); + fl.values.present.filter = "none"; check(fl.save(&err), "saving the filter: " + err); const Json written = Json::parse(read(fl.path()), nullptr); const Json *saved = written.find("video") != nullptr ? written.find("video")->find("filter") : nullptr; check(saved != nullptr && saved->as_string() == "none", "a filter the file had is written back, \"none\" too"); write(path_join(filtered, SETTINGS_FILE), R"({"schema":1,"video":{"filter":"blur"}})"); fl.load(filtered); - check(fl.messages().size() == 1 && fl.values.filter == "none", "a bad filter: a warning, \"none\""); + check(fl.messages().size() == 1 && fl.values.present.filter == "none", "a bad filter: a warning, \"none\""); // video.crt (the scanlines and crt filters' parameters): read, written once set, bad values warned about. write(path_join(filtered, SETTINGS_FILE), R"({"schema":1,"video":{"filter":"crt","crt":{"scanlines":80,"mask":10,"curvature":25}}})"); fl.load(filtered); - check(fl.messages().empty() && fl.values.filter == "crt" && fl.values.crt_scanlines == 80 && - fl.values.crt_mask == 10 && fl.values.crt_curvature == 25, + check(fl.messages().empty() && fl.values.present.filter == "crt" && fl.values.present.scanlines == 80 && + fl.values.present.mask == 10 && fl.values.present.curvature == 25, "video.crt is read"); - fl.values.crt_mask = 60; + fl.values.present.mask = 60; check(fl.save(&err), "saving video.crt: " + err); const Json crt_written = Json::parse(read(fl.path()), nullptr); const Json *crt = crt_written.find("video") != nullptr ? crt_written.find("video")->find("crt") : nullptr; @@ -327,8 +327,80 @@ static void test_settings_file(const std::string &root) { "video.crt is written back"); write(path_join(filtered, SETTINGS_FILE), R"({"schema":1,"video":{"crt":{"scanlines":101,"mask":-1}}})"); fl.load(filtered); - check(fl.messages().size() == 2 && fl.values.crt_scanlines == 50 && fl.values.crt_mask == 30, + check(fl.messages().size() == 2 && fl.values.present.scanlines == 50 && fl.values.present.mask == 30, "bad video.crt values: a warning each, the defaults"); + // video.scene_2d (the 2D scenes' set, a game with scenes): absent, what the game's 2d.filter or the 3D set means, + // and not added; read member by member over that; written whole once it differs, and kept once the file has it; + // bad members warned about. A game without scenes leaves it as it is, unread. + { + const std::string sc = path_join(root, "scene_2d"); + path_make_dir(sc, nullptr); + auto scene_2d = [&](const SettingsFile &f) { + const Json doc = Json::parse(read(f.path()), nullptr); + const Json *video = doc.find("video"); + const Json *m = video != nullptr ? video->find("scene_2d") : nullptr; + return m != nullptr ? m->dump() : std::string(); + }; + write(path_join(sc, SETTINGS_FILE), R"({"schema":1,"video":{"filter":"crt","crt":{"scanlines":70}}})"); + SettingsFile f; + f.load(sc); + FilterSet want; + if (GAME_SCENE_2D_FILTER[0] != '\0') { + want.filter = GAME_SCENE_2D_FILTER; + } else { + want.filter = "crt"; + want.scanlines = 70; + } + check(f.messages().empty() && f.values.present.filter == "crt" && f.values.present_2d == want, + "a file without video.scene_2d: the 2D set is the game's 2d.filter, else the 3D set"); + f.values.mute = true; + check(f.save(&err) && scene_2d(f).empty(), "a file that never chose a 2D set gets no video.scene_2d"); + if (GAME_SCENES) { + f.values.present_2d.filter = "scanlines"; + f.values.present_2d.scanlines = 90; + check(f.save(&err), "saving video.scene_2d: " + err); + SettingsFile g; + g.load(sc); + check(g.messages().empty() && g.values.present_2d == f.values.present_2d && + g.values.present == f.values.present, + "video.scene_2d is written and read back, the 3D set as it was"); + const Json whole = Json::parse(scene_2d(g), nullptr); + const Json *crt2 = whole.find("crt"); + check(crt2 != nullptr && crt2->find("mask") != nullptr && crt2->find("curvature") != nullptr, + "video.scene_2d is written whole: " + scene_2d(g)); + const std::string kept = scene_2d(g); + g.values.mute = false; + check(g.save(&err) && scene_2d(g) == kept, "an untouched video.scene_2d stays as it was"); + g.values.present_2d = Settings::scene_2d_default(g.values.present); + check(g.save(&err) && !scene_2d(g).empty(), + "a video.scene_2d the file had is written back, the default too"); + write(path_join(sc, SETTINGS_FILE), + R"({"schema":1,"video":{"filter":"sharp","scene_2d":{"filter":"crt"}}})"); + g.load(sc); + FilterSet partial = Settings::scene_2d_default(g.values.present); + partial.filter = "crt"; + check(g.messages().empty() && g.values.present_2d == partial, + "a partial video.scene_2d: its missing members are what an absent one means"); + write(path_join(sc, SETTINGS_FILE), + R"({"schema":1,"video":{"scene_2d":{"filter":"blur","crt":{"mask":101}}}})"); + g.load(sc); + check(g.messages().size() == 2 && g.values.present_2d == Settings::scene_2d_default(g.values.present), + "bad video.scene_2d members: a warning each, the defaults"); + write(path_join(sc, SETTINGS_FILE), R"({"schema":1,"video":{"scene_2d":"crt"}})"); + g.load(sc); + check(g.messages().size() == 1 && g.values.present_2d == Settings::scene_2d_default(g.values.present), + "a video.scene_2d that is not an object: a warning, the default"); + } else { + write(path_join(sc, SETTINGS_FILE), R"({"schema":1,"video":{"scene_2d":{"filter":"blur"}}})"); + SettingsFile g; + g.load(sc); + const std::string kept = scene_2d(g); + g.values.mute = true; + g.values.present.filter = "sharp"; + check(g.messages().empty() && g.save(&err) && scene_2d(g) == kept, + "a game without scenes neither reads nor writes video.scene_2d"); + } + } // Not JSON: the defaults; the first save keeps the old file aside. const std::string broken = path_join(root, "broken"); @@ -1187,6 +1259,32 @@ static void drop_file(SDL_Window *w, const std::string &path) { } // The play path with the game's stand-in and a verified disc (a file of the BIN's size and the right disc.sha1). +// The mouse over the page, its wheel turned down: the Settings screen scrolled to its end (the Updates part), or by +// `notches` of the wheel. +static void scroll_page_down(SDL_Window *w, App &app, float notches = 40) { + int width = 0, height = 0; + SDL_GetWindowSize(w, &width, &height); + SDL_Event e; + SDL_zero(e); + e.type = SDL_EVENT_MOUSE_MOTION; + e.motion.windowID = SDL_GetWindowID(w); + e.motion.x = (float)width * 0.6f; + e.motion.y = (float)height * 0.5f; + SDL_PushEvent(&e); + pump(app, 2); + for (int i = 0; notches > 0 && i < 4; i++) { + SDL_zero(e); + e.type = SDL_EVENT_MOUSE_WHEEL; + e.wheel.windowID = SDL_GetWindowID(w); + e.wheel.y = -std::min(notches, 10.0f); + notches -= 10; + e.wheel.mouse_x = (float)width * 0.6f; + e.wheel.mouse_y = (float)height * 0.5f; + SDL_PushEvent(&e); + pump(app, 2); + } +} + static void test_play(SDL_Window *window, const std::string &root) { const std::string dir = path_join(root, "play"), shots = path_join(root, "screens"); if (GAME_DISC_COUNT == 0) { @@ -1344,11 +1442,22 @@ static void test_play(SDL_Window *window, const std::string &root) { png = path_join(shots, "9b-Settings-gpu.png"); app.frame(png.c_str()); // The CRT filter shows its sliders (a picture). - app.settings().values.filter = "crt"; + app.settings().values.present.filter = "crt"; pump(app, 2); png = path_join(shots, "9c-Settings-crt.png"); app.frame(png.c_str()); - app.settings().values.filter = "none"; + // A game with scenes: the Filter row for its 3D scenes and the one for its 2D scenes, the latter marked as the one + // on screen as the in-game menu marks it (a picture; the launcher marks none). + if (GAME_SCENES) { + app.settings().values.present_2d.filter = "scanlines"; + app.screens().set_scene(Screens::SCENE_2D); + scroll_page_down(window, app, 4); + png = path_join(shots, "9d-Settings-scenes.png"); + app.frame(png.c_str()); + app.screens().set_scene(Screens::SCENE_NONE); + app.settings().values.present_2d = Settings::scene_2d_default(app.settings().values.present); + } + app.settings().values.present.filter = "none"; app.settings().values.renderer = "software"; app.settings().values.internal_scale = 1; pump(app, 2); @@ -1448,30 +1557,6 @@ static void test_mods_window(SDL_Window *window, const std::string &root) { // ---- the update check (docs/LAUNCHER.md "Updates"): the version rules, GitHub's answer, curl through its stand-in, // the prompt and the Settings screen. Never the network: the stand-in answers. -// The mouse over the page, its wheel turned down: the Settings screen scrolled to its end (the Updates part). -static void scroll_page_down(SDL_Window *w, App &app) { - int width = 0, height = 0; - SDL_GetWindowSize(w, &width, &height); - SDL_Event e; - SDL_zero(e); - e.type = SDL_EVENT_MOUSE_MOTION; - e.motion.windowID = SDL_GetWindowID(w); - e.motion.x = (float)width * 0.6f; - e.motion.y = (float)height * 0.5f; - SDL_PushEvent(&e); - pump(app, 2); - for (int i = 0; i < 4; i++) { - SDL_zero(e); - e.type = SDL_EVENT_MOUSE_WHEEL; - e.wheel.windowID = SDL_GetWindowID(w); - e.wheel.y = -10.0f; - e.wheel.mouse_x = (float)width * 0.6f; - e.wheel.mouse_y = (float)height * 0.5f; - SDL_PushEvent(&e); - pump(app, 2); - } -} - static void set_env(const char *name, const std::string &value) { SDL_SetEnvironmentVariable(SDL_GetEnvironment(), name, value.c_str(), true); } diff --git a/launcher/src/settings.cpp b/launcher/src/settings.cpp index ce7bae1..a24e813 100644 --- a/launcher/src/settings.cpp +++ b/launcher/src/settings.cpp @@ -265,6 +265,56 @@ static void write_input(const Settings &s, Json *doc) { } } +FilterSet Settings::scene_2d_default(const FilterSet &present_3d) { + if (GAME_SCENE_2D_FILTER[0] == '\0') { + return present_3d; + } + FilterSet set; + set.filter = GAME_SCENE_2D_FILTER; + return set; +} + +// A present-filter set's members in `obj` (video, video.scene_2d): its filter and its crt; a bad one keeps the value +// `set` has, with a warning. +static void read_filter_set(const Json *obj, const char *where, FilterSet *set, std::vector *w) { + const std::string at = where; + const std::string was = set->filter; + read_string(obj, where, "filter", &set->filter, w); + if (std::find(std::begin(FILTER_NAMES), std::end(FILTER_NAMES), set->filter) == std::end(FILTER_NAMES)) { + warn(w, at + ".filter: expected a filter's name (docs/LAUNCHER.md \"Members\"); using \"" + was + "\""); + set->filter = was; + } + if (const Json *crt = obj->find("crt")) { + if (!crt->is_object()) { + warn(w, at + ".crt: expected an object; using the defaults"); + } else { + read_int(crt, (at + ".crt").c_str(), "scanlines", 0, 100, &set->scanlines, w); + read_int(crt, (at + ".crt").c_str(), "mask", 0, 100, &set->mask, w); + read_int(crt, (at + ".crt").c_str(), "curvature", 0, 100, &set->curvature, w); + } + } +} + +// A present-filter set into `obj`, its other members kept: all of it when `whole`, else the filter once it is not +// "none" or `obj` has one and the crt once it differs from the defaults or `obj` has one (a file that never chose +// them stays as it was). +static void write_filter_set(const FilterSet &set, bool whole, Json *obj) { + if (whole || set.filter != "none" || obj->find("filter") != nullptr) { + obj->set("filter", Json::string(set.filter)); + } + const FilterSet defaults; + if (whole || set.scanlines != defaults.scanlines || set.mask != defaults.mask || + set.curvature != defaults.curvature || obj->find("crt") != nullptr) { + if (const Json *crt = obj->find("crt"); crt != nullptr && !crt->is_object()) { + obj->erase("crt"); + } + Json &crt = obj->member("crt"); + crt.set("scanlines", Json::number(set.scanlines)); + crt.set("mask", Json::number(set.mask)); + crt.set("curvature", Json::number(set.curvature)); + } +} + Settings settings_from_json(const Json &doc, std::vector *w) { Settings s; if (const Json *disc = section(doc, "disc", w)) { @@ -290,10 +340,14 @@ Settings settings_from_json(const Json &doc, std::vector *w) { warn(w, "video.subpixel: expected \"off\", \"on\" or \"perspective\"; using \"on\""); s.subpixel = "on"; } - read_string(video, "video", "filter", &s.filter, w); - if (std::find(std::begin(FILTER_NAMES), std::end(FILTER_NAMES), s.filter) == std::end(FILTER_NAMES)) { - warn(w, "video.filter: expected a filter's name (docs/LAUNCHER.md \"Members\"); using \"none\""); - s.filter = "none"; + read_filter_set(video, "video", &s.present, w); + s.present_2d = Settings::scene_2d_default(s.present); + if (const Json *scene = GAME_SCENES ? video->find("scene_2d") : nullptr) { // unknown to a game without scenes + if (!scene->is_object()) { + warn(w, "video.scene_2d: expected an object; using the defaults"); + } else { + read_filter_set(scene, "video.scene_2d", &s.present_2d, w); + } } 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)) { @@ -305,15 +359,6 @@ Settings settings_from_json(const Json &doc, std::vector *w) { warn(w, "video.menu_size: expected \"smaller\", \"auto\" or \"larger\"; using \"auto\""); s.menu_size = "auto"; } - if (const Json *crt = video->find("crt")) { - if (!crt->is_object()) { - warn(w, "video.crt: expected an object; using the defaults"); - } else { - read_int(crt, "video.crt", "scanlines", 0, 100, &s.crt_scanlines, w); - read_int(crt, "video.crt", "mask", 0, 100, &s.crt_mask, w); - read_int(crt, "video.crt", "curvature", 0, 100, &s.crt_curvature, w); - } - } } if (const Json *audio = section(doc, "audio", w)) { read_bool(audio, "audio", "mute", &s.mute, w); @@ -390,25 +435,20 @@ void settings_to_json(const Settings &s, Json *doc) { video.set("renderer", Json::string(s.renderer)); video.set("internal_scale", Json::number(s.internal_scale)); video.set("subpixel", Json::string(s.subpixel)); - 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)); - } + write_filter_set(s.present, false, &video); 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)); } - const Settings defaults; - if (s.crt_scanlines != defaults.crt_scanlines || s.crt_mask != defaults.crt_mask || - s.crt_curvature != defaults.crt_curvature || video.find("crt") != nullptr) { // as video.filter - if (const Json *crt = video.find("crt"); crt != nullptr && !crt->is_object()) { - video.erase("crt"); + // video.scene_2d (a game with scenes): written once it differs from what a file without one means, or the file has + // it; then whole, so the file says the set exactly (an absent member would read as the default, not the 3D set's). + if (GAME_SCENES && (s.present_2d != Settings::scene_2d_default(s.present) || video.find("scene_2d") != nullptr)) { + if (const Json *scene = video.find("scene_2d"); scene != nullptr && !scene->is_object()) { + video.erase("scene_2d"); } - Json &crt = video.member("crt"); - crt.set("scanlines", Json::number(s.crt_scanlines)); - crt.set("mask", Json::number(s.crt_mask)); - crt.set("curvature", Json::number(s.crt_curvature)); + write_filter_set(s.present_2d, true, &video.member("scene_2d")); } doc->member("audio").set("mute", Json::boolean(s.mute)); for (int i = 0; i < 2; i++) { diff --git a/launcher/src/settings.h b/launcher/src/settings.h index 300bf8a..28acff9 100644 --- a/launcher/src/settings.h +++ b/launcher/src/settings.h @@ -62,6 +62,16 @@ 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" }; +// A present-filter set (docs/LAUNCHER.md "Members"): video.filter and video.crt; a game with scenes (GAME_SCENES) +// has a second one for its 2D scenes, video.scene_2d. +struct FilterSet { + std::string filter = "none"; // one of FILTER_NAMES + int scanlines = 50, mask = 30, curvature = 0; // crt: the scanlines and crt filters', 0..100 + bool operator==(const FilterSet &o) const { + return filter == o.filter && scanlines == o.scanlines && mask == o.mask && curvature == o.curvature; + } + bool operator!=(const FilterSet &o) const { return !(*this == o); } +}; // 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. @@ -92,8 +102,12 @@ struct Settings { 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 std::string subpixel = "on"; // "on", "perspective" or "off": the 3D at the GTE's sub-pixel positions above x1 - std::string filter = "none"; // the hardware renderer's present filter (video.filter): one of FILTER_NAMES - int crt_scanlines = 50, crt_mask = 30, crt_curvature = 0; // video.crt: the scanlines and crt filters', 0..100 + // The hardware renderer's present filter (video.filter, video.crt); a game with scenes: its 3D scenes' set. + FilterSet present; + // video.scene_2d (a game with scenes only): its 2D scenes' set, scene_2d_default(present) when the file has none. + // Written whole once it differs from that, or once the file has it. It is held as values: where the default is a + // copy of the 3D set, a change to the 3D set leaves it as it was (as the Filter rows show it), and it is written. + FilterSet present_2d = scene_2d_default(FilterSet()); std::string menu_size = "auto"; // the in-game menu's size (video.menu_size): one of MENU_SIZE_NAMES bool mute = false; MemoryCard memcard[2] = { { true, "card1.mcd" }, { true, "card2.mcd" } }; @@ -109,6 +123,10 @@ struct Settings { std::map hotkeys; // input.hotkeys: action -> binding std::string player2 = "off"; // input.player2: one of PLAYER2_NAMES + // What a file without video.scene_2d means: the game's 2d.filter with the default crt values, else a copy of the + // 3D set (GAME_CONTRACT.md "1. game.json" Scenes). + static FilterSet scene_2d_default(const FilterSet &present_3d); + // The effective values (the file's, else the defaults). std::vector keys_for(const std::string &button) const; std::vector pad_for(const std::string &button) const; diff --git a/launcher/ui/menu.cpp b/launcher/ui/menu.cpp index e8f733a..22f229d 100644 --- a/launcher/ui/menu.cpp +++ b/launcher/ui/menu.cpp @@ -34,9 +34,13 @@ extern "C" { #include "port_harness.h" #include "port_runtime.h" +#include "psxstack/game.h" // PORT_SCENE_* #include "psxstack/settings.h" // the runtime's: the settings file the game was started with } +static_assert(psxstack::Screens::SCENE_3D == PORT_SCENE_3D && psxstack::Screens::SCENE_2D == PORT_SCENE_2D, + "the screens' scenes are the adapter's"); + namespace { enum Page { PAGE_GAME, PAGE_SETTINGS, PAGE_CONTROLS, PAGE_MODS, PAGE_COUNT }; @@ -397,6 +401,9 @@ int port_menu_frame(void) { return PORT_MENU_NONE; } menu_style_for_window(); + // The scenes' set the present uses now, marked on the Settings page (a game with scenes; the game is paused, so it + // holds while the menu is up). + mn.screens.set_scene(PSXSTACK_GAME_SCENES ? (int)port_video_scene() : psxstack::Screens::SCENE_NONE); if (mn.renderer != nullptr) { ImGui_ImplSDLRenderer3_NewFrame(); } else { diff --git a/launcher/ui/screens.cpp b/launcher/ui/screens.cpp index 3658d33..d679934 100644 --- a/launcher/ui/screens.cpp +++ b/launcher/ui/screens.cpp @@ -57,9 +57,11 @@ static float item_width(float label_w) { ImGui::GetContentRegionAvail().x - label_w - ImGui::GetStyle().ItemSpacing.x); } -// The hardware renderer's present filter (video.filter; under the GPU renderer only: the Software one shows the -// picture unfiltered): a combo in FILTER_NAMES' order, the filter's sliders (video.crt), a line of help for the choice. -void Screens::draw_filter(float label_w) { +// A present-filter set (video.filter and video.crt, or a scene's set: video.scene_2d; under the GPU renderer only: the +// Software one shows the picture unfiltered): a combo in FILTER_NAMES' order, the filter's sliders (its crt), a line +// of help for the choice. `scene` names the set's scenes ("Filter: battles", on a line of its own above its rows), +// nullptr for a game with one set; `in_use` marks the set the game shows now (the in-game menu). +void Screens::draw_filter(float label_w, FilterSet &set, const char *id, const char *scene, bool in_use) { static const char *const labels[] = { "None (the PS1's pixels)", "Sharp", "Scanlines", "CRT", "Smooth (2D)" }; static const char *const help[] = { "Each pixel of the picture as a block of whole window pixels, as it is.", @@ -74,34 +76,46 @@ void Screens::draw_filter(float label_w) { }; static_assert(SDL_arraysize(labels) == SDL_arraysize(FILTER_NAMES) && SDL_arraysize(help) == SDL_arraysize(labels), "a label and a help line per filter"); - Settings &s = settings_.values; int current = 0; for (int i = 0; i < (int)SDL_arraysize(FILTER_NAMES); i++) { - if (s.filter == FILTER_NAMES[i]) { + if (set.filter == FILTER_NAMES[i]) { current = i; } } - ImGui::TextDisabled("Filter"); + ImGui::PushID(id); + if (scene != nullptr) { + ImGui::PushTextWrapPos(0); + ImGui::TextDisabled("Filter: %s", scene); + ImGui::PopTextWrapPos(); + if (in_use) { + ImGui::SameLine(); + wrap_for(ImGui::CalcTextSize("(on screen now)").x); + ImGui::TextColored(rgb(ACCENT), "(on screen now)"); + } + ImGui::Dummy(ImVec2(0, 0)); + } else { + ImGui::TextDisabled("Filter"); + } ImGui::SameLine(label_w); ImGui::SetNextItemWidth(item_width(label_w)); if (ImGui::BeginCombo("##filter", labels[current])) { for (int i = 0; i < (int)SDL_arraysize(FILTER_NAMES); i++) { if (ImGui::Selectable(labels[i], i == current)) { - s.filter = FILTER_NAMES[i]; + set.filter = FILTER_NAMES[i]; dirty_ = true; } } ImGui::EndCombo(); } - // video.crt's sliders, for the filters that read them - const bool lines = s.filter == "scanlines" || s.filter == "crt", crt = s.filter == "crt"; + // the crt's sliders, for the filters that read them + const bool lines = set.filter == "scanlines" || set.filter == "crt", crt = set.filter == "crt"; struct { bool shown; const char *label, *id; int *value; - } sliders[] = { { lines, "Scanlines", "##crt_scanlines", &s.crt_scanlines }, - { crt, "Mask", "##crt_mask", &s.crt_mask }, - { crt, "Curvature", "##crt_curvature", &s.crt_curvature } }; + } sliders[] = { { lines, "Scanlines", "##crt_scanlines", &set.scanlines }, + { crt, "Mask", "##crt_mask", &set.mask }, + { crt, "Curvature", "##crt_curvature", &set.curvature } }; for (auto &sl : sliders) { if (sl.shown) { ImGui::TextDisabled("%s", sl.label); @@ -115,6 +129,7 @@ void Screens::draw_filter(float label_w) { ImGui::TextDisabled("%s", help[current]); ImGui::PopTextWrapPos(); ImGui::Unindent(label_w); + ImGui::PopID(); } void Screens::draw_settings() { @@ -256,8 +271,13 @@ void Screens::draw_settings() { ImGui::PopTextWrapPos(); ImGui::Unindent(label_w); } - if (s.renderer == "gpu") { - draw_filter(label_w); + if (s.renderer == "gpu" && !GAME_SCENES) { + draw_filter(label_w, s.present, "filter", nullptr, false); + } else if (s.renderer == "gpu") { + // A game with scenes (docs/LAUNCHER.md "Members"): a set for its 3D scenes (video.filter, video.crt) and one + // for its 2D scenes (video.scene_2d), each under its scenes' label; the game switches between them. + draw_filter(label_w, s.present, "filter_3d", GAME_SCENE_3D_LABEL, scene_ == SCENE_3D); + draw_filter(label_w, s.present_2d, "filter_2d", GAME_SCENE_2D_LABEL, scene_ == SCENE_2D); } if (s.refresh != GAME_RATE && GAME_RATE_NOTE[0] != '\0') { ImGui::Indent(label_w); diff --git a/launcher/ui/screens.h b/launcher/ui/screens.h index 59e327a..3d8c070 100644 --- a/launcher/ui/screens.h +++ b/launcher/ui/screens.h @@ -32,6 +32,10 @@ class Screens { bool game_found); // In the game's menu: every screen says that changes take effect at the next start. void set_in_game(bool on) { in_game_ = on; } + // The scenes' present-filter set the game shows now, which the Settings screen marks (a game with scenes; the + // in-game menu, every frame, from the runtime): SCENE_3D or SCENE_2D (game.h PORT_SCENE_*), SCENE_NONE unmarked. + static constexpr int SCENE_NONE = -1, SCENE_3D = 0, SCENE_2D = 1; + void set_scene(int scene) { scene_ = scene; } // A part of the Settings screen its host draws itself, after "Memory cards" (the launcher's Updates); given the // label column's width. The in-game menu sets none. void set_settings_extra(std::function draw) { settings_extra_ = std::move(draw); } @@ -74,7 +78,7 @@ class Screens { private: void draw_restart_note(); void restart_mark(float indent = 0); // "(restart)" after the last item, in the game's menu - void draw_filter(float label_w); + void draw_filter(float label_w, FilterSet &set, const char *id, const char *scene, bool in_use); void draw_names_table(bool pad); void draw_players(); void draw_hotkeys(); @@ -97,6 +101,7 @@ class Screens { bool dirty_ = false; bool saved_ = false; bool in_game_ = false; + int scene_ = SCENE_NONE; std::string controls_tab_; // show_controls_tab's, until the Controls screen is drawn std::function settings_extra_; std::string error_; // the last save error