diff --git a/README.md b/README.md index 7f1a3e5..bb6b3e7 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ consumer is [dw2003recomp](https://github.com/gascarcella/dw2003recomp), the Dig - **The hardware renderer** on SDL_GPU (Vulkan; Direct3D 12 on Windows): internal resolution up to 8x, sub-pixel precision from the GTE's fractions, optional perspective-correct texturing (docs/PORT.md "Rendering", "Sub-pixel precision"). -- **Present filters:** sharp bilinear, scanlines, CRT, xBR (docs/RUNTIME.md "The window"). +- **Present filters:** sharp bilinear, scanlines, CRT, xBR (docs/RUNTIME.md "The window"); a game with 2D and 3D scenes + may give each its own (game.json `video.scenes`). - **Screen fit:** fit, integer or stretch scaling; a wide 16:9 canvas a game's mod can turn on for the scenes that widen. - **Texture dumps and texture packs**, keyed by content (docs/RUNTIME.md "Texture dump", "Texture packs"). diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d30a7eb..b744a56 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -317,3 +317,15 @@ the gamepad map and never reaches a hotkey: the host's player owns pause, the me the pad (the window, a script) sets both ports every vsync, since the shim's "connected" is part of a save state. The runtime's players are arrays of `PORT_PADS` and the setting is `player`, so a multitap can add players without a new format. + +## Two filter sets: the game names its scene, both pipelines stay built (2026-10-26) + +A game may want one present filter for its 2D scenes and another for its 3D ones (dw2003recomp #122: smooth for the +field's pixel art, another for the battles). The sets are fixed, `2d` and `3d` (a filter and its crt values; +everything else stays global), opted in by game.json's `video.scenes`. The scene comes from the adapter, +`game_state_scene()`, pulled once per vsync like the other probes (the adapter has no per-vsync hook, and its mods +can be off), not detected by the runtime: a game draws rotated 2D sprites with the GTE, and counting polygons or +textures flickers between frames. A switch must not stall a frame, so both sets' pipelines are built when the +filters are set and a scene change only picks the slot the present binds; the cost is a second filter's shader and +pipelines, and smooth's 640x576 16-bit texture (720 KB) when either set is smooth. The scene is read fresh each +vsync and only chooses the present, so replays, checkpoints and save states do not hold it. diff --git a/docs/GAME_CONTRACT.md b/docs/GAME_CONTRACT.md index 5ad76c8..29c8d04 100644 --- a/docs/GAME_CONTRACT.md +++ b/docs/GAME_CONTRACT.md @@ -60,6 +60,14 @@ the port and the launcher stay single binaries. Contents: `--input-test` then runs the window's input self-test on the pump alone, without the game. - **Timing:** `video.rate` (the nominal vsyncs per second: 50 PAL, 60 NTSC), the rates the launcher offers and the text that explains the alternative. +- **Scenes** (optional): `video.scenes`, `{ "2d": { "label", "filter" }, "3d": { "label" } }`: the game has two present + filter sets, one for its 2D scenes and one for its 3D scenes, and the runtime switches between them at the vsync + where the adapter's `game_state_scene()` changes ("4. The adapter units"). A set is a filter and its crt values; + the sub-pixel mode, the scale, fullscreen, the scaling and the internal scale stay global. `label` names each set in + the launcher's Filter rows (`"the field and the menus"`, `"battles"`); `2d.filter` (optional) is the 2D set's + default filter, with the default crt values (absent: the 2D set starts as a copy of the 3D set). The settings' + `video.filter` and `video.crt` are the 3D set, `video.scene_2d` the 2D set (docs/LAUNCHER.md "Settings file"). + The sets are fixed, `2d` and `3d`. Absent: one set, and the probe is never read. - **Memory:** `memory.ram` (base, size), `memory.slots` (name, base, size, in address order, contiguous), `memory.heap` (PS1 start and end, the host heap size; optional: a game whose heap is its own data, an array in its `.bss`, has none, and the arena is then the slots alone), `memory.bios_standin` (addresses the game reads from the BIOS ROM and what @@ -84,6 +92,7 @@ launcher and the game's adapter use by these exact names: | `PsxstackGameBiosStandin` (`address`, `text`), `PSXSTACK_GAME_BIOS_STANDIN_COUNT`, `PSXSTACK_GAME_BIOS_STANDINS` | `memory.bios_standin`; with none the count is 0 and the initializer holds one empty entry | | `PSXSTACK_GAME_ABOUT`, `PSXSTACK_GAME_DISC_HINT`, `PSXSTACK_GAME_WEBSITE` | `launcher.*`; `about` defaults to the title, the others to `""` | | `PSXSTACK_GAME_UPDATES_GITHUB` | `launcher.updates.github` (`"owner/repo"`), `""` when absent (no update check) | +| `PSXSTACK_GAME_SCENES`, `PSXSTACK_GAME_SCENE_2D_LABEL`, `PSXSTACK_GAME_SCENE_3D_LABEL`, `PSXSTACK_GAME_SCENE_2D_FILTER` | `video.scenes`: 1 or 0; the labels; the 2D set's default filter by name, `""` when `2d.filter` is absent (a copy of the 3D set); all three strings `""` without scenes | `--cmake` writes the CMake variables: `PSXSTACK_GAME_ID`, `PSXSTACK_GAME_TITLE`, `PSXSTACK_GAME_ENV_PREFIX`, `PSXSTACK_GAME_RATE`, and `PSXSTACK_GAME_STEAM_ART_CAPSULE`, `_WIDE_CAPSULE`, `_HERO`, `_LOGO`, `_ICON` (`launcher.steam_art`'s @@ -144,6 +153,14 @@ C the game compiles into the port alongside the runtime. It implements: writes, a script's `write_mem` and `poke_ps1`). The stack asks the adapter first and maps the arena itself for what the adapter does not. A game whose random index is LIBC2's `rand` state (the PS1's generator, the shim's: `psyq/libc2.c`) returns `port_rand_seed()` (`hooks.h`) from `game_state_random_index()`. +- **The scene** (a game with `video.scenes`): `int32_t game_state_scene(void)`, `PORT_SCENE_3D` (0) or + `PORT_SCENE_2D` (1): which present-filter set the window shows now. The runtime reads it once per vsync, before the + present, and never for a game without scenes; the switch snaps at that vsync, with nothing built (both sets' + pipelines are kept). It is a probe the runtime pulls, like the others, because the adapter has no per-vsync hook and + its mods can be off. The game decides; the runtime does not detect a scene from what is drawn (a game draws rotated + 2D sprites with the GTE, and counting polygons flickers). dw2003: 3D while its battle overlay is in slot 1, 2D + otherwise. Default: `PORT_SCENE_3D`. It reads the game's state and changes none, so replays, checkpoint hashes and + save states do not depend on it. Additive: no `PSXSTACK_API` bump. - **The mods:** `game_mod_count()` and `game_mods()` return the game's `PortMod` records (``): id, version, options, `start`/`frame`/`stop` callbacks (`start` a setter the in-game menu may run again, `stop` its undo: docs/LAUNCHER.md "Mod runtime" rule 6), an optional `status` (text for the window title) and `applies` (`"restart"` @@ -206,7 +223,8 @@ the id as a bare token), `_TITLE`, `_ENV_PREFIX`, `_RATE`, `_RATE_COUNT`, `_RATE `_RAM_SIZE`; `PORT_SLOT_COUNT`, `PORT_SLOT_BASE/SIZE/NAME/OFS`, `port_slot`, `PORT_HEAP_START_ADDR`, `PORT_HEAP_END_ADDR`, `PORT_HEAP_OFS`, `PORT_HEAP_SIZE`, `PORT_ARENA_SIZE`; `PsxstackGameDisc`, `PSXSTACK_GAME_DISC_COUNT`, `PSXSTACK_GAME_DISCS`; `PsxstackGameBiosStandin`, `PSXSTACK_GAME_BIOS_STANDIN_COUNT`, -`PSXSTACK_GAME_BIOS_STANDINS`; `PSXSTACK_GAME_ABOUT`, `_DISC_HINT`, `_WEBSITE`, `_UPDATES_GITHUB`. The runtime's +`PSXSTACK_GAME_BIOS_STANDINS`; `PSXSTACK_GAME_ABOUT`, `_DISC_HINT`, `_WEBSITE`, `_UPDATES_GITHUB`; +`PSXSTACK_GAME_SCENES`, `_SCENE_2D_LABEL`, `_SCENE_3D_LABEL`, `_SCENE_2D_FILTER`. The runtime's environment variables are `_PORT_`. ### 6. Tests diff --git a/docs/PORT.md b/docs/PORT.md index 36d76ae..c5f88a7 100644 --- a/docs/PORT.md +++ b/docs/PORT.md @@ -415,6 +415,14 @@ against the PS1 or an emulator) are listed in `psyq/README.md` "Behaviour assume 4:3) is smoothed from its canvas in the target read at 1x (every N-th pixel, exact for the 2D layers). The window and `--gpu-screenshot`'s `@WxH` pictures are filtered; the picture itself (no `@WxH`, the debug channel's) is not. The software renderer shows the picture unfiltered and logs it. + **Two sets** (a game with `video.scenes`: GAME_CONTRACT.md "1. game.json"): a filter and its parameters for the + 2D scenes and another for the 3D scenes, in two slots that are both built at the start and at a live apply + (pipelines per filter kind, so two slots of one kind share them; smooth's edges texture when either slot is + smooth). video.c reads `game_state_scene()` once per vsync before the present and passes the slot to every + present call: the switch snaps at that vsync and creates or frees nothing (DECISIONS "Two filter sets: the game + names its scene, both pipelines stay built"). A game without scenes has one set; the probe is never called. The in-game menu, over + whichever scene, is drawn after the filtered picture, which covers the whole frame; `port_video_scene()` tells it + the set in use. - **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 diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index fb09b3e..6de09a8 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -115,7 +115,13 @@ default: the picture's pixels as whole blocks), `sharp` (sharp bilinear), `scanl `crt[:scanlines=,mask=,curvature=]` (a parameter not named keeps `video.crt`'s value; defaults 50, 30, 0) or `smooth` (xBR on the 1x software image, whatever the internal scale); it applies to the window and to `--gpu-screenshot`'s `@WxH` pictures, and a software-renderer run logs that its picture stays -unfiltered (docs/PORT.md "Rendering"). +unfiltered (docs/PORT.md "Rendering"). A game with 2D and 3D scenes (game.json `video.scenes`, GAME_CONTRACT.md +"1. game.json") has two such sets: `video.filter` and `video.crt` are the 3D scenes', `video.scene_2d` (`{ "filter", +"crt" }`, read member by member over its default: game.json's `2d.filter` with the default crt values, else a copy of +the 3D set) the 2D scenes'. `--filter` sets both, `--filter-2d NAME[:KEY=V,...]` the 2D set alone (over `--filter`; +refused, status 64, for a game without scenes, where `video.scene_2d` is an unknown key). The window shows the set of +the scene the adapter's `game_state_scene()` names, read every vsync; the log says `renderer: gpu: filter crt (3d), +smooth (2d)` once both are built and `renderer: gpu: scene 2d (filter smooth), vsync N` at each change. **Widescreen** (docs/PORT.md "Rendering"): when a game mod calls `port_video_widescreen_enable` (from its start) and `port_video_widescreen(1)` (every vsync of the scenes that widen), the hardware renderer presents those scenes 16:9 inside the window, the rest 4:3; the window opens 16:9 when the mod is on and `video.renderer` is `gpu`. With the diff --git a/examples/dw2003.game.json b/examples/dw2003.game.json index 3930c2a..9c19999 100644 --- a/examples/dw2003.game.json +++ b/examples/dw2003.game.json @@ -16,7 +16,9 @@ "video": { "rate": 50, "rates": [50, 60], - "rate_note": "The game's own 60 Hz mode, as the NTSC patch gives it: time, walking and music keep their speed, other things run 20% faster. A game built before its 60 Hz mode runs at 50 Hz." + "rate_note": "The game's own 60 Hz mode, as the NTSC patch gives it: time, walking and music keep their speed, other things run 20% faster. A game built before its 60 Hz mode runs at 50 Hz.", + "scenes": { "2d": { "label": "the field and the menus", "filter": "smooth" }, + "3d": { "label": "battles" } } }, "memory": { "ram": { "base": "0x80000000", "size": "0x200000" }, diff --git a/include/psxstack/game.h b/include/psxstack/game.h index f019354..5e59b77 100644 --- a/include/psxstack/game.h +++ b/include/psxstack/game.h @@ -31,6 +31,14 @@ int32_t game_state_random_index(void); /* the pad RNG's index (the record's rand /* The field player's position in pixels: 1 and the values in x and y, or 0 when there is no player (default). */ int game_state_player_pos(double *x, double *y); +/* ---- The scene (a game with `video.scenes`, GAME_CONTRACT.md "1. game.json"): whether the game shows a 3D scene or a + * 2D one now, which picks the present-filter set the window uses. The runtime reads it once per vsync, before the + * present, and only when the description declares scenes (PSXSTACK_GAME_SCENES); the game decides, the runtime does + * not detect. Default: PORT_SCENE_3D (the one set of a game without scenes). */ +#define PORT_SCENE_3D 0 +#define PORT_SCENE_2D 1 +int32_t game_state_scene(void); + /* ---- The checkpoint image: the game-state bytes a checkpoint hashes, as the emulator dumps them from PS1 RAM: * game_state_image_size() bytes (0 by default: nothing to hash), written by game_state_image (pointers as the PS1 * addresses of the host functions they hold). game_state_volatile: the [lo, hi) byte ranges of the image zeroed for diff --git a/include/psxstack/port_harness.h b/include/psxstack/port_harness.h index 86b9268..0b62c5a 100644 --- a/include/psxstack/port_harness.h +++ b/include/psxstack/port_harness.h @@ -8,6 +8,7 @@ #define PORT_HARNESS_H #include +#include #include #include "psxstack/types.h" @@ -129,7 +130,15 @@ const char *port_filter_choices(int quoted); /* `--filter`'s argument into *f: the name, and the parameters it names (KEY=V, V 0..100; the others keep their value); * 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 two sets, indexed by PORT_SCENE_* (game.h): f[PORT_SCENE_3D] is video.filter and video.crt (`--filter`), the + * only set of a game without video.scenes; f[PORT_SCENE_2D] video.scene_2d (`--filter-2d`), ignored without scenes. + * The present uses the set of the scene game_state_scene() last said (read once per vsync; video.c); port_video_scene + * is that scene (PORT_SCENE_3D for a game without scenes): the in-game menu marks its set. */ +void port_video_set_filters(const PortFilter f[2]); +int32_t port_video_scene(void); +/* The 2D set when nothing names it (no video.scene_2d, no --config; settings.c): game.json's video.scenes.2d.filter + * with the default parameters, or a copy of the 3D set `f3d` when the description names none. */ +void port_filter_scene_2d_default(const PortFilter *f3d, PortFilter *f2d); /* 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 @@ -138,9 +147,9 @@ enum { PORT_SCALING_FIT, PORT_SCALING_INTEGER, PORT_SCALING_STRETCH, PORT_SCALIN 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); +/* The window's settings changed live (the menu; runtime/live.c): the scale, fullscreen, the scaling, the filters (both + * sets, as port_video_set_filters) and the sub-pixel mode; nothing without a window. */ +void port_video_apply(int scale, int fullscreen, int scaling, const PortFilter filters[2], 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 e212129..af3a473 100644 --- a/include/psxstack/settings.h +++ b/include/psxstack/settings.h @@ -28,6 +28,10 @@ typedef struct PortSettings { * internal scale 1 (2: textured perspective-correct too) */ int filter; /* video.filter: PORT_FILTER_* (default 0, "none"): the hardware renderer's present filter */ int crt_scanlines, crt_mask, crt_curvature; /* video.crt: the filters' parameters, 0..100 (default 50, 30, 0) */ + /* video.scene_2d { filter, crt } (a game with video.scenes; the fields above are then the 3D set): the 2D set. + * Absent: port_filter_scene_2d_default's (game.json's 2d.filter with the default crt, or a copy of the 3D set). */ + int filter_2d; + int crt_2d_scanlines, crt_2d_mask, crt_2d_curvature; int mute; /* audio.mute (default 0) */ int watchdog; /* watchdog: seconds, 0 off (default 0: a player's game is never killed by the debugging aid) */ char *memcard[2]; /* memcard1/2, absolute; NULL: no card (null in the file); default card1.mcd/card2.mcd */ diff --git a/runtime/game_defaults.c b/runtime/game_defaults.c index 3a242a7..3d1342d 100644 --- a/runtime/game_defaults.c +++ b/runtime/game_defaults.c @@ -35,6 +35,10 @@ WEAK int game_state_player_pos(double *x, double *y) { return 0; } +WEAK int32_t game_state_scene(void) { + return PORT_SCENE_3D; +} + WEAK uint32_t game_state_image_size(void) { return 0; } diff --git a/runtime/live.c b/runtime/live.c index da92d01..a4c520f 100644 --- a/runtime/live.c +++ b/runtime/live.c @@ -1,6 +1,7 @@ /* 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, fullscreen and scaling, the hardware renderer's filter and sub-pixel mode (video.c), audio.mute + * the window's scale, fullscreen and scaling, the hardware renderer's filters (both scenes' sets: video.c builds both + * again) 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 @@ -12,15 +13,19 @@ int port_live_apply(void) { const PortSettings *s = &port_settings; - PortFilter filter; + PortFilter filters[2]; /* PORT_SCENE_3D's (video.filter, video.crt), PORT_SCENE_2D's (video.scene_2d) */ if (!port_settings_reload()) { return 0; } - filter.kind = s->filter; - filter.scanlines = s->crt_scanlines; - filter.mask = s->crt_mask; - filter.curvature = s->crt_curvature; - port_video_apply(s->scale, s->fullscreen, s->scaling, &filter, s->subpixel); + filters[PORT_SCENE_3D].kind = s->filter; + filters[PORT_SCENE_3D].scanlines = s->crt_scanlines; + filters[PORT_SCENE_3D].mask = s->crt_mask; + filters[PORT_SCENE_3D].curvature = s->crt_curvature; + filters[PORT_SCENE_2D].kind = s->filter_2d; + filters[PORT_SCENE_2D].scanlines = s->crt_2d_scanlines; + filters[PORT_SCENE_2D].mask = s->crt_2d_mask; + filters[PORT_SCENE_2D].curvature = s->crt_2d_curvature; + port_video_apply(s->scale, s->fullscreen, s->scaling, filters, 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 d8cef6e..1b36e3a 100644 --- a/runtime/main.c +++ b/runtime/main.c @@ -55,7 +55,7 @@ static void usage(const char *argv0) { " [--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" + " [--filter NAME[:KEY=V,...]] [--filter-2d NAME[:KEY=V,...]]\n" " [--spu-trace FILE] [--wav FILE] [--mute] [--debug SOCKET] [--debug-hold] [--crash-dir DIR]\n" " [--save-state WHEN:FILE] [--save-state-exit] [--load-state FILE] [--quick-state FILE]\n" " [--version]\n" @@ -120,7 +120,10 @@ static void usage(const char *argv0) { " crt[:scanlines=,mask=,curvature=] (video.crt's values otherwise; default 50, 30, 0)\n" " or smooth (xBR on the 1x software image, at any internal scale);\n" " the window and --gpu-screenshot's @WxH pictures; the software renderer shows the\n" - " picture unfiltered (overrides video.filter and video.crt)\n" + " picture unfiltered (overrides video.filter and video.crt; for a game with 2D and\n" + " 3D scenes, both sets: video.scene_2d too)\n" + " --filter-2d NAME[:KEY=V,...] a game with 2D and 3D scenes (game.json video.scenes): the 2D scenes'\n" + " filter alone, as --filter (overrides video.scene_2d, and --filter for the 2D set)\n" " --dump-textures DIR every texture the game samples, the first time, as a PNG named by its key, and\n" " DIR/index.json (a build with -DPSXSTACK_SDL=ON; either renderer)\n" " --texture-pack DIR a texture pack (DIR/mod.json, PNGs named by key) for the hardware renderer;\n" @@ -175,7 +178,10 @@ int main(int argc, char **argv) { int subpixel = 1, no_game = 0, scaling = PORT_SCALING_FIT; const char *config = NULL; const char *quick_state = NULL; - PortFilter filter = PORT_FILTER_DEFAULTS; + PortFilter filter = PORT_FILTER_DEFAULTS, filter_2d; /* the 3D set (a game without scenes: the one), the 2D set */ + PortFilter filters[2]; + const char *filter_2d_spec = NULL; + int filter_given = 0; int print_settings = 0, print_mods = 0, script_mods = 0; long fps = -1; int refresh = 0; /* --refresh, else the settings' video.refresh; 0: neither (the description's video.rate) */ @@ -200,12 +206,18 @@ int main(int argc, char **argv) { filter.scanlines = port_settings.crt_scanlines; filter.mask = port_settings.crt_mask; filter.curvature = port_settings.crt_curvature; + filter_2d.kind = port_settings.filter_2d; + filter_2d.scanlines = port_settings.crt_2d_scanlines; + filter_2d.mask = port_settings.crt_2d_mask; + filter_2d.curvature = port_settings.crt_2d_curvature; mute = port_settings.mute; port_watchdog_sec = port_settings.watchdog; for (i = 0; i < 2; i++) { memcard[i] = port_settings.memcard[i]; memcard_given[i] = memcard[i] != NULL ? 1 : -1; } + } else { + port_filter_scene_2d_default(&filter, &filter_2d); } for (i = 1; i < argc; i++) { if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) { @@ -331,6 +343,14 @@ int main(int argc, char **argv) { fprintf(stderr, "port: --filter: %s\n", err); return 64; } + filter_given = 1; + } else if (strcmp(argv[i], "--filter-2d") == 0 && i + 1 < argc) { + if (!PSXSTACK_GAME_SCENES) { + fprintf(stderr, "port: --filter-2d: %s has one filter for every scene (no video.scenes): --filter\n", + PSXSTACK_GAME_TITLE); + return 64; + } + filter_2d_spec = argv[++i]; /* after the loop: over --filter's, wherever it is */ } else if (strcmp(argv[i], "--gpu-screenshot") == 0 && i + 1 < argc) { if (!port_video_gpu_screenshot_add(argv[++i])) { fprintf(stderr, "port: --gpu-screenshot: FRAME[@WxH]:PATH (FRAME >= 1; at most 64): %s\n", argv[i]); @@ -383,6 +403,19 @@ int main(int argc, char **argv) { port_mods_print_registry(stdout); return 0; } + /* --filter is both sets; --filter-2d then the 2D set alone, over it */ + if (filter_given) { + filter_2d = filter; + } + if (filter_2d_spec != NULL) { + char err[160]; + if (!port_filter_parse(filter_2d_spec, &filter_2d, err, sizeof(err))) { + fprintf(stderr, "port: --filter-2d: %s\n", err); + return 64; + } + } + filters[PORT_SCENE_3D] = filter; + filters[PORT_SCENE_2D] = filter_2d; if (print_settings) { PortSettings eff = port_settings; if (config == NULL) { @@ -404,6 +437,10 @@ int main(int argc, char **argv) { eff.crt_scanlines = filter.scanlines; eff.crt_mask = filter.mask; eff.crt_curvature = filter.curvature; + eff.filter_2d = filter_2d.kind; + eff.crt_2d_scanlines = filter_2d.scanlines; + eff.crt_2d_mask = filter_2d.mask; + eff.crt_2d_curvature = filter_2d.curvature; for (i = 0; i < 2; i++) { eff.memcard[i] = memcard_given[i] > 0 && memcard[i] != NULL ? port_settings_abspath(memcard[i]) : NULL; } @@ -436,7 +473,7 @@ int main(int argc, char **argv) { port_video_set_internal_scale(internal_scale); port_video_set_subpixel(subpixel); port_video_set_scaling(scaling); - port_video_set_filter(&filter); + port_video_set_filters(filters); if (window && !port_video_available()) { fprintf(stderr, "port: --window: this build has no window: configure with -DPSXSTACK_SDL=ON " "(port/README.md \"The window\")\n"); diff --git a/runtime/render_gpu.c b/runtime/render_gpu.c index 63a5855..5328fff 100644 --- a/runtime/render_gpu.c +++ b/runtime/render_gpu.c @@ -130,6 +130,7 @@ static struct { int target_w, target_h; int submit_failed; char describe[160]; + int scene; /* the present filter's slot the presents show (PORT_SCENE_*'s value) */ /* The rasteriser. */ int raster; @@ -1346,7 +1347,7 @@ static void render_draw(SDL_GPUCommandBuffer *cb, SDL_GPUTexture *target, SDL_GP SDL_GPUTextureSamplerBinding bind; SDL_Rect scissor; RenderPresentView u; /* dst, src, disp (cut) as present*.frag.hlsl read them; param for a filter */ - SDL_GPUGraphicsPipeline *filter = filtered ? render_present_pipeline(target != r.target) : NULL; + SDL_GPUGraphicsPipeline *filter = filtered ? render_present_pipeline(r.scene, target != r.target) : NULL; memset(&u, 0, sizeof(u)); u.dst[0] = rect[0]; @@ -1372,8 +1373,8 @@ static void render_draw(SDL_GPUCommandBuffer *cb, SDL_GPUTexture *target, SDL_GP } if (filter != NULL) { u.cut[3] = h; /* the display's lines */ - render_present_params(&u); - if (vram_xy != NULL && render_present_image()) { + render_present_params(r.scene, &u); + if (vram_xy != NULL && render_present_image(r.scene)) { /* a wide picture for a filter of the 1x image (render_gpu_present: the software image is 4:3): its canvas * at 1x, every N-th target pixel (a 1x pixel's N x N block's corner) */ u.src[0] = w; @@ -1381,7 +1382,7 @@ static void render_draw(SDL_GPUCommandBuffer *cb, SDL_GPUTexture *target, SDL_GP u.param[3] = (float)r.scale; } if (rect[2] > 0 && rect[3] > 0) { /* the filter's first pass, if any */ - render_present_prepare(cb, vram_xy != NULL ? r.vram_target : r.image, r.sampler, &u); + render_present_prepare(r.scene, cb, vram_xy != NULL ? r.vram_target : r.image, r.sampler, &u); } } memset(&ct, 0, sizeof(ct)); @@ -1401,7 +1402,7 @@ static void render_draw(SDL_GPUCommandBuffer *cb, SDL_GPUTexture *target, SDL_GP bind.sampler = r.sampler; SDL_BindGPUFragmentSamplers(pass, 0, &bind, 1); if (filter != NULL) { - render_present_bind(pass, r.sampler); + render_present_bind(r.scene, pass, r.sampler); } SDL_PushGPUFragmentUniformData(cb, 0, &u, filter != NULL ? sizeof(u) @@ -1528,7 +1529,7 @@ int render_gpu_present(const u32 *pixels, int w, int h, const int *vram_xy, Rend if (r.device == NULL || r.window == NULL) { return 0; } - if (render_present_image() && vram_xy != NULL && !render_gpu_wide_cut(vram_xy, w, h, wide)) { + if (render_present_image(r.scene) && vram_xy != NULL && !render_gpu_wide_cut(vram_xy, w, h, wide)) { vram_xy = NULL; /* the filter takes the 1x image (the target's units still run every vsync); a wide picture keeps its canvas (render_draw) */ } @@ -1599,7 +1600,7 @@ int render_gpu_readback(const u32 *pixels, int w, int h, const int *vram_xy, int return 0; } } - if (dest != NULL && render_present_image() && vram_xy != NULL && !render_gpu_wide_cut(vram_xy, w, h, wide)) { + if (dest != NULL && render_present_image(r.scene) && vram_xy != NULL && !render_gpu_wide_cut(vram_xy, w, h, wide)) { vram_xy = NULL; /* a filtered picture from the 1x image, as the window's */ } cb = SDL_AcquireGPUCommandBuffer(r.device); @@ -1642,19 +1643,33 @@ int render_gpu_readback(const u32 *pixels, int w, int h, const int *vram_xy, int return 1; } -void render_gpu_set_filter(const PortFilter *f) { +void render_gpu_set_filters(const PortFilter f[2], int sets) { + static const char *const slot_names[2] = { "3d", "2d" }; char why[256]; + int ok[2], i; if (r.device == NULL) { return; } - if (!render_present_set(r.device, r.vert, r.window != NULL ? r.swap_format : SDL_GPU_TEXTUREFORMAT_INVALID, f, why, - sizeof(why))) { - port_log("renderer: gpu: filter %s unavailable (%s); none", port_filter_names[f->kind], why); - } else if (f->kind != PORT_FILTER_NONE) { - port_log("renderer: gpu: filter %s", port_filter_names[f->kind]); + render_present_set(r.device, r.vert, r.window != NULL ? r.swap_format : SDL_GPU_TEXTUREFORMAT_INVALID, f, ok, why, + sizeof(why)); + for (i = 0; i < sets; i++) { + if (!ok[i]) { + port_log("renderer: gpu: filter %s%s%s unavailable (%s); none", port_filter_names[f[i].kind], + sets > 1 ? " for " : "", sets > 1 ? slot_names[i] : "", why); + } + } + if (sets == 1 && ok[0] && f[0].kind != PORT_FILTER_NONE) { + port_log("renderer: gpu: filter %s", port_filter_names[f[0].kind]); + } else if (sets > 1 && (f[0].kind != PORT_FILTER_NONE || f[1].kind != PORT_FILTER_NONE)) { + port_log("renderer: gpu: filter %s (3d), %s (2d)", port_filter_names[ok[0] ? f[0].kind : PORT_FILTER_NONE], + port_filter_names[ok[1] ? f[1].kind : PORT_FILTER_NONE]); } } +void render_gpu_set_scene(int slot) { + r.scene = slot == 1; +} + void render_gpu_close(void) { if (r.device != NULL) { SDL_WaitForGPUIdle(r.device); diff --git a/runtime/render_gpu.h b/runtime/render_gpu.h index 2f8bf29..94388b8 100644 --- a/runtime/render_gpu.h +++ b/runtime/render_gpu.h @@ -51,10 +51,14 @@ int render_gpu_readback(const u32 *pixels, int w, int h, const int *vram_xy, int int render_gpu_read_vram(u16 *out); /* The target set to the VRAM as it is now (tests/host/gpu_hw_replay.py, after a case that differed). */ void render_gpu_raster_resync(void); -/* The present's filter (port_harness.h PortFilter; render_gpu_present.c) for the window and for --gpu-screenshot's - * @WxH pictures; the picture itself (no @WxH, the debug channel's) stays unfiltered. A filter whose pipelines cannot - * be created is logged and the present stays nearest. */ -void render_gpu_set_filter(const PortFilter *f); +/* The present's filters (port_harness.h PortFilter; render_gpu_present.c) for the window and for --gpu-screenshot's + * @WxH pictures; the picture itself (no @WxH, the debug channel's) stays unfiltered. Two sets, the slots 0 and 1 + * (PORT_SCENE_3D's and PORT_SCENE_2D's values), both built now; `sets` 1 for a game without scenes (f[1], the same as + * f[0], shares its pipelines, and the log names one filter). render_gpu_set_scene picks the slot the presents use + * (once per vsync: it builds nothing). A filter whose pipelines cannot be created is logged and that slot's present + * stays nearest. */ +void render_gpu_set_filters(const PortFilter f[2], int sets); +void render_gpu_set_scene(int slot); /* 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 canvas's aspect a:b (from 4:3 to 16:9, the window's: video.c video_wide_follow; 16:9 until it is diff --git a/runtime/render_gpu_internal.h b/runtime/render_gpu_internal.h index 15161f8..1577008 100644 --- a/runtime/render_gpu_internal.h +++ b/runtime/render_gpu_internal.h @@ -32,23 +32,26 @@ typedef struct { float param[4]; /* the filter's parameters */ } RenderPresentView; -/* The filter's pipelines (for B8G8R8A8, the readback's format, and for `swap_format` unless it is INVALID), created - * now; PORT_FILTER_NONE releases them. 0 on failure, with the reason in `why` (the caller then presents unfiltered). */ -int render_present_set(SDL_GPUDevice *device, SDL_GPUShader *vert, SDL_GPUTextureFormat swap_format, - const PortFilter *f, char *why, size_t why_size); +/* The present's two filter slots (0: PORT_SCENE_3D's set, the only one of a game without scenes; 1: PORT_SCENE_2D's): + * both slots' pipelines (for B8G8R8A8, the readback's format, and for `swap_format` unless it is INVALID), created now, + * once per kind (smooth's edges texture when either slot is smooth); PORT_FILTER_NONE builds nothing. ok[i] 0 when + * slot i's could not be created, with the reason in `why` (that slot then presents unfiltered). The calls below take + * the slot the present shows: a scene change builds nothing. */ +void render_present_set(SDL_GPUDevice *device, SDL_GPUShader *vert, SDL_GPUTextureFormat swap_format, + const PortFilter f[2], int ok[2], char *why, size_t why_size); /* The filter's first pass, if it has one (`smooth`: its decisions per source pixel), from `source` (the w x h - * picture) on cb before the present's render pass; and its texture bound for the present's pass (slot 1). */ -void render_present_prepare(SDL_GPUCommandBuffer *cb, SDL_GPUTexture *source, SDL_GPUSampler *sampler, + * picture) on cb before the present's render pass; and its texture bound for the present's pass (sampler 1). */ +void render_present_prepare(int slot, SDL_GPUCommandBuffer *cb, SDL_GPUTexture *source, SDL_GPUSampler *sampler, const RenderPresentView *view); -void render_present_bind(SDL_GPURenderPass *pass, SDL_GPUSampler *sampler); +void render_present_bind(int slot, SDL_GPURenderPass *pass, SDL_GPUSampler *sampler); /* Whether the filter takes the 32-bit image (video.c's 1x software picture) in place of the display cut from the * VRAM target: `smooth`, a pixel-art filter, needs the 1x picture at every internal scale. A wide picture (widescreen, * render_gpu_wide.c: the image is 4:3) keeps its canvas in the target, read at 1x (param[3]: every N-th pixel). */ -int render_present_image(void); +int render_present_image(int slot); /* The filter's pipeline for the swapchain (swap 1) or the readback target; NULL: none (the present's own). */ -SDL_GPUGraphicsPipeline *render_present_pipeline(int swap); +SDL_GPUGraphicsPipeline *render_present_pipeline(int slot, int swap); /* The filter's parameters into the view. */ -void render_present_params(RenderPresentView *v); +void render_present_params(int slot, RenderPresentView *v); void render_present_release(SDL_GPUDevice *device); #endif diff --git a/runtime/render_gpu_present.c b/runtime/render_gpu_present.c index 8da25bb..8b7d252 100644 --- a/runtime/render_gpu_present.c +++ b/runtime/render_gpu_present.c @@ -7,7 +7,8 @@ * image into a 16-bit texture, which its present reads (render_present_bind), and it always takes the image * (render_present_image). It reads both sources (the 32-bit image, a 15-bit display cut from the VRAM target), so it applies to the * 24-bit displays too. PORT_FILTER_NONE leaves render_gpu.c's own present untouched: the default picture does not - * change. Every filter is continuous in its sampling position, so that a pixel centre on a tie (a boundary between + * change. Two slots, the scenes' two sets (GAME_CONTRACT.md "1. game.json" video.scenes), both kept built: a scene + * change picks the slot render_gpu.c passes, and builds nothing. Every filter is continuous in its sampling position, so that a pixel centre on a tie (a boundary between * source pixels or lines) comes out the same on every device. */ #ifdef PSXSTACK_SDL #include @@ -31,60 +32,82 @@ #define EDGES_W 640 /* smooth's decisions: the largest 32-bit image (render_gpu.c RENDER_IMAGE_W, RENDER_IMAGE_H) */ #define EDGES_H 576 +/* Two slots (the scenes' sets: PORT_SCENE_3D, PORT_SCENE_2D; a game without scenes uses slot 0), both built by + * render_present_set: a scene change only picks the slot the present binds, nothing is created or freed. A slot is a + * kind and its parameters; the pipelines are per kind, so two slots of the same kind share them. */ static struct { int kind; float param[4]; /* the shader's: scanlines, mask, curvature (0..1) */ +} f[2]; +static struct { SDL_GPUShader *frag; SDL_GPUGraphicsPipeline *off, *swap; - /* smooth's first pass: its decisions per source pixel (present_smooth_edges.frag.hlsl) into an R16_UINT texture */ - SDL_GPUShader *edges_frag; - SDL_GPUGraphicsPipeline *edges_pipe; - SDL_GPUTexture *edges; -} f; +} kinds[PORT_FILTER_COUNT]; +/* smooth's first pass: its decisions per source pixel (present_smooth_edges.frag.hlsl) into an R16_UINT texture, one + * for both slots (one present at a time) */ +static SDL_GPUShader *edges_frag; +static SDL_GPUGraphicsPipeline *edges_pipe; +static SDL_GPUTexture *edges; -void render_present_release(SDL_GPUDevice *device) { - if (f.off != NULL) { - SDL_ReleaseGPUGraphicsPipeline(device, f.off); - } - if (f.swap != NULL) { - SDL_ReleaseGPUGraphicsPipeline(device, f.swap); +static void present_release_kind(SDL_GPUDevice *device, int kind) { + if (kinds[kind].off != NULL) { + SDL_ReleaseGPUGraphicsPipeline(device, kinds[kind].off); } - if (f.frag != NULL) { - SDL_ReleaseGPUShader(device, f.frag); + if (kinds[kind].swap != NULL) { + SDL_ReleaseGPUGraphicsPipeline(device, kinds[kind].swap); } - if (f.edges_pipe != NULL) { - SDL_ReleaseGPUGraphicsPipeline(device, f.edges_pipe); + if (kinds[kind].frag != NULL) { + SDL_ReleaseGPUShader(device, kinds[kind].frag); } - if (f.edges_frag != NULL) { - SDL_ReleaseGPUShader(device, f.edges_frag); + memset(&kinds[kind], 0, sizeof(kinds[kind])); + if (kind == PORT_FILTER_SMOOTH) { + if (edges_pipe != NULL) { + SDL_ReleaseGPUGraphicsPipeline(device, edges_pipe); + } + if (edges_frag != NULL) { + SDL_ReleaseGPUShader(device, edges_frag); + } + if (edges != NULL) { + SDL_ReleaseGPUTexture(device, edges); + } + edges_pipe = NULL; + edges_frag = NULL; + edges = NULL; } - if (f.edges != NULL) { - SDL_ReleaseGPUTexture(device, f.edges); +} + +void render_present_release(SDL_GPUDevice *device) { + int k; + for (k = 0; k < PORT_FILTER_COUNT; k++) { + present_release_kind(device, k); } - memset(&f, 0, sizeof(f)); + memset(f, 0, sizeof(f)); } -int render_present_set(SDL_GPUDevice *device, SDL_GPUShader *vert, SDL_GPUTextureFormat swap_format, - const PortFilter *pf, char *why, size_t why_size) { - render_present_release(device); - switch (pf->kind) { +/* The pipelines of `kind` (built once: a second slot of the kind shares them). 0 on failure, with the reason in why. */ +static int present_build(SDL_GPUDevice *device, SDL_GPUShader *vert, SDL_GPUTextureFormat swap_format, int kind, + char *why, size_t why_size) { + if (kinds[kind].off != NULL) { + return 1; + } + switch (kind) { case PORT_FILTER_SHARP: - f.frag = render_shader(RENDER_SHADER(present_sharp_frag), SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); + kinds[kind].frag = render_shader(RENDER_SHADER(present_sharp_frag), SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); break; case PORT_FILTER_SCANLINES: - f.frag = render_shader(RENDER_SHADER(present_scanlines_frag), SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); + kinds[kind].frag = render_shader(RENDER_SHADER(present_scanlines_frag), SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); break; case PORT_FILTER_CRT: - f.frag = render_shader(RENDER_SHADER(present_crt_frag), SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); + kinds[kind].frag = render_shader(RENDER_SHADER(present_crt_frag), SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); break; case PORT_FILTER_SMOOTH: /* 2 samplers: the image and the first pass's decisions */ - f.frag = render_shader(RENDER_SHADER(present_smooth_frag), SDL_GPU_SHADERSTAGE_FRAGMENT, 2, 1); + kinds[kind].frag = render_shader(RENDER_SHADER(present_smooth_frag), SDL_GPU_SHADERSTAGE_FRAGMENT, 2, 1); break; default: return 1; } - if (pf->kind == PORT_FILTER_SMOOTH) { + if (kind == PORT_FILTER_SMOOTH) { SDL_GPUTextureCreateInfo ti; memset(&ti, 0, sizeof(ti)); ti.type = SDL_GPU_TEXTURETYPE_2D; @@ -94,35 +117,47 @@ int render_present_set(SDL_GPUDevice *device, SDL_GPUShader *vert, SDL_GPUTextur ti.height = EDGES_H; ti.layer_count_or_depth = 1; ti.num_levels = 1; - f.edges = SDL_CreateGPUTexture(device, &ti); - f.edges_frag = render_shader(RENDER_SHADER(present_smooth_edges_frag), SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); - f.edges_pipe = - f.edges_frag != NULL ? render_pipeline(vert, f.edges_frag, SDL_GPU_TEXTUREFORMAT_R16_UINT) : NULL; - if (f.edges == NULL || f.edges_pipe == NULL) { + edges = SDL_CreateGPUTexture(device, &ti); + edges_frag = render_shader(RENDER_SHADER(present_smooth_edges_frag), SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); + edges_pipe = edges_frag != NULL ? render_pipeline(vert, edges_frag, SDL_GPU_TEXTUREFORMAT_R16_UINT) : NULL; + if (edges == NULL || edges_pipe == NULL) { snprintf(why, why_size, "%s", SDL_GetError()); - render_present_release(device); + present_release_kind(device, kind); return 0; } } - f.off = f.frag != NULL ? render_pipeline(vert, f.frag, SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM) : NULL; - if (f.off != NULL && swap_format != SDL_GPU_TEXTUREFORMAT_INVALID && + kinds[kind].off = + kinds[kind].frag != NULL ? render_pipeline(vert, kinds[kind].frag, SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM) : NULL; + if (kinds[kind].off != NULL && swap_format != SDL_GPU_TEXTUREFORMAT_INVALID && swap_format != SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM) { - f.swap = render_pipeline(vert, f.frag, swap_format); + kinds[kind].swap = render_pipeline(vert, kinds[kind].frag, swap_format); } - if (f.off == NULL || (swap_format != SDL_GPU_TEXTUREFORMAT_INVALID && - swap_format != SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM && f.swap == NULL)) { + if (kinds[kind].off == NULL || (swap_format != SDL_GPU_TEXTUREFORMAT_INVALID && + swap_format != SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM && kinds[kind].swap == NULL)) { snprintf(why, why_size, "%s", SDL_GetError()); - render_present_release(device); + present_release_kind(device, kind); return 0; } - f.kind = pf->kind; - f.param[0] = (float)pf->scanlines / 100.0f; - f.param[1] = (float)pf->mask / 100.0f; - f.param[2] = (float)pf->curvature / 100.0f; return 1; } -void render_present_prepare(SDL_GPUCommandBuffer *cb, SDL_GPUTexture *source, SDL_GPUSampler *sampler, +void render_present_set(SDL_GPUDevice *device, SDL_GPUShader *vert, SDL_GPUTextureFormat swap_format, + const PortFilter pf[2], int ok[2], char *why, size_t why_size) { + int i; + render_present_release(device); + for (i = 0; i < 2; i++) { + ok[i] = present_build(device, vert, swap_format, pf[i].kind, why, why_size); + if (!ok[i]) { + continue; /* this slot presents unfiltered */ + } + f[i].kind = pf[i].kind; + f[i].param[0] = (float)pf[i].scanlines / 100.0f; + f[i].param[1] = (float)pf[i].mask / 100.0f; + f[i].param[2] = (float)pf[i].curvature / 100.0f; + } +} + +void render_present_prepare(int slot, SDL_GPUCommandBuffer *cb, SDL_GPUTexture *source, SDL_GPUSampler *sampler, const RenderPresentView *view) { int w = view->src[0], h = view->src[1]; SDL_GPUColorTargetInfo ct; @@ -130,15 +165,15 @@ void render_present_prepare(SDL_GPUCommandBuffer *cb, SDL_GPUTexture *source, SD SDL_GPUTextureSamplerBinding bind; SDL_Rect scissor; RenderPresentView v; - if (f.kind != PORT_FILTER_SMOOTH || w <= 0 || h <= 0 || w > EDGES_W || h > EDGES_H) { + if (f[slot].kind != PORT_FILTER_SMOOTH || w <= 0 || h <= 0 || w > EDGES_W || h > EDGES_H) { return; } memset(&ct, 0, sizeof(ct)); - ct.texture = f.edges; + ct.texture = edges; ct.load_op = SDL_GPU_LOADOP_DONT_CARE; ct.store_op = SDL_GPU_STOREOP_STORE; pass = SDL_BeginGPURenderPass(cb, &ct, 1, NULL); - SDL_BindGPUGraphicsPipeline(pass, f.edges_pipe); + SDL_BindGPUGraphicsPipeline(pass, edges_pipe); scissor.x = 0; scissor.y = 0; scissor.w = w; @@ -157,24 +192,25 @@ void render_present_prepare(SDL_GPUCommandBuffer *cb, SDL_GPUTexture *source, SD SDL_EndGPURenderPass(pass); } -void render_present_bind(SDL_GPURenderPass *pass, SDL_GPUSampler *sampler) { +void render_present_bind(int slot, SDL_GPURenderPass *pass, SDL_GPUSampler *sampler) { SDL_GPUTextureSamplerBinding bind; - if (f.kind == PORT_FILTER_SMOOTH) { - bind.texture = f.edges; + if (f[slot].kind == PORT_FILTER_SMOOTH) { + bind.texture = edges; bind.sampler = sampler; SDL_BindGPUFragmentSamplers(pass, 1, &bind, 1); } } -int render_present_image(void) { - return f.kind == PORT_FILTER_SMOOTH; +int render_present_image(int slot) { + return f[slot].kind == PORT_FILTER_SMOOTH; } -SDL_GPUGraphicsPipeline *render_present_pipeline(int swap) { - return swap && f.swap != NULL ? f.swap : f.off; +SDL_GPUGraphicsPipeline *render_present_pipeline(int slot, int swap) { + int k = f[slot].kind; + return swap && kinds[k].swap != NULL ? kinds[k].swap : kinds[k].off; } -void render_present_params(RenderPresentView *v) { - memcpy(v->param, f.param, sizeof(v->param)); +void render_present_params(int slot, RenderPresentView *v) { + memcpy(v->param, f[slot].param, sizeof(v->param)); } #endif diff --git a/runtime/settings.c b/runtime/settings.c index 569a5e0..9dbc006 100644 --- a/runtime/settings.c +++ b/runtime/settings.c @@ -8,8 +8,11 @@ * "scaling": "fit", * "subpixel": "on", * "filter": "none", - * "crt": { "scanlines": 50, "mask": 30, "curvature": 0 }, - * "menu_size": "auto" }, the in-game menu's own; not read here + * "crt": { "scanlines": 50, "mask": 30, "curvature": 0 }, the 3D set (without scenes: the one) + * "scene_2d": { "filter": "smooth", a game with video.scenes: the 2D set; absent: + * "crt": { "scanlines": 50, ... } }, game.json's 2d.filter and the default crt, + * else a copy of the 3D set; unknown otherwise + * "menu_size": "auto" }, the in-game menu's own; not read here * "audio": { "mute": false }, * "memcard1": "card1.mcd", "memcard2": "card2.mcd", null: no card in that slot * "watchdog": 0, seconds; 0 off @@ -164,10 +167,15 @@ static void settings_parse(PortSettings *s, const char *path) { "subpixel", "filter", "crt", +#if PSXSTACK_GAME_SCENES + "scene_2d", /* the 2D set; a game without scenes logs it unknown */ +#endif "menu_size", /* the in-game menu's (launcher/ui/menu.cpp reads it) */ NULL }; static const char *const audio_keys[] = { "mute", NULL }; static const char *const crt_keys[] = { "scanlines", "mask", "curvature", NULL }; + static const char *const scene_keys[] = { "filter", "crt", NULL }; + PortFilter f3d, f2d; const PortJson *root, *v, *obj; char err[256], *slash; FILE *f; @@ -267,6 +275,30 @@ static void settings_parse(PortSettings *s, const char *path) { settings_int(v, "video.crt", "curvature", 0, 100, &s->crt_curvature); } } + /* the 2D set: the default from the 3D set (or game.json's), then what video.scene_2d names */ + f3d.kind = s->filter; + f3d.scanlines = s->crt_scanlines; + f3d.mask = s->crt_mask; + f3d.curvature = s->crt_curvature; + port_filter_scene_2d_default(&f3d, &f2d); + s->filter_2d = f2d.kind; + s->crt_2d_scanlines = f2d.scanlines; + s->crt_2d_mask = f2d.mask; + s->crt_2d_curvature = f2d.curvature; + if (PSXSTACK_GAME_SCENES && obj != NULL && + (obj = settings_member(obj, "video", "scene_2d", PORT_JSON_OBJECT)) != NULL) { + settings_unknown(obj, "video.scene_2d", scene_keys); + if ((v = settings_member(obj, "video.scene_2d", "filter", PORT_JSON_STRING)) != NULL && + (s->filter_2d = port_filter_from_name(v->string)) < 0) { + port_settings_fail("video.scene_2d.filter", "%s, not \"%s\"", port_filter_choices(1), v->string); + } + if ((v = settings_member(obj, "video.scene_2d", "crt", PORT_JSON_OBJECT)) != NULL) { + settings_unknown(v, "video.scene_2d.crt", crt_keys); + settings_int(v, "video.scene_2d.crt", "scanlines", 0, 100, &s->crt_2d_scanlines); + settings_int(v, "video.scene_2d.crt", "mask", 0, 100, &s->crt_2d_mask); + settings_int(v, "video.scene_2d.crt", "curvature", 0, 100, &s->crt_2d_curvature); + } + } if ((obj = settings_member(root, "", "audio", PORT_JSON_OBJECT)) != NULL) { settings_unknown(obj, "audio", audio_keys); settings_bool(obj, "audio", "mute", &s->mute); @@ -288,6 +320,17 @@ static void settings_parse(PortSettings *s, const char *path) { s->launcher = settings_member(root, "", "launcher", PORT_JSON_OBJECT); } +void port_filter_scene_2d_default(const PortFilter *f3d, PortFilter *f2d) { + static const PortFilter defaults = PORT_FILTER_DEFAULTS; + int kind = port_filter_from_name(PSXSTACK_GAME_SCENE_2D_FILTER); + if (kind < 0) { /* "": the description names none */ + *f2d = *f3d; + return; + } + *f2d = defaults; + f2d->kind = kind; +} + void port_settings_load(const char *path) { memset(&port_settings, 0, sizeof(port_settings)); settings_parse(&port_settings, path); @@ -355,6 +398,11 @@ void port_settings_print(FILE *f, const PortSettings *s) { 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, s->crt_curvature); + if (PSXSTACK_GAME_SCENES) { + fprintf(f, ",\n \"scene_2d\": { \"filter\": \"%s\", \"crt\": { \"scanlines\": %d, \"mask\": %d, " + "\"curvature\": %d } }", + port_filter_names[s->filter_2d], s->crt_2d_scanlines, s->crt_2d_mask, s->crt_2d_curvature); + } fprintf(f, "\n },\n \"audio\": {\n \"mute\": %s\n },\n \"memcard1\": ", s->mute ? "true" : "false"); settings_print_path(f, s->memcard[0]); fputs(",\n \"memcard2\": ", f); diff --git a/runtime/video.c b/runtime/video.c index b3a2cb3..11573e9 100644 --- a/runtime/video.c +++ b/runtime/video.c @@ -71,7 +71,11 @@ static int video_shot_count; static long video_converted_frame = -1; /* the frame video_pixels was converted at (the debug channel's screenshot) */ static int video_gpu_wanted; /* --renderer gpu / video.renderer "gpu" */ static int video_internal_scale = 1; /* --internal-scale / video.internal_scale: the rasteriser's, 1..8 */ -static PortFilter video_filter; /* --filter / video.filter: the hardware renderer's present (render_gpu_present.c) */ +/* --filter, --filter-2d / video.filter, video.scene_2d: the hardware renderer's present (render_gpu_present.c), a set + * per scene (PORT_SCENE_3D, PORT_SCENE_2D; a game without video.scenes uses the first, the second its copy) */ +static PortFilter video_filters[2]; +#define VIDEO_SETS (PSXSTACK_GAME_SCENES ? 2 : 1) /* the sets the game has */ +static int video_scene = PORT_SCENE_3D; /* the scene the present shows (game_state_scene, read every vsync) */ static struct { long frame; int w, h; /* the output's size; 0: the image's own */ @@ -197,10 +201,28 @@ void port_video_set_subpixel(int mode) { #endif } -void port_video_set_filter(const PortFilter *f) { - video_filter = *f; +void port_video_set_filters(const PortFilter f[2]) { + video_filters[0] = f[0]; + video_filters[1] = PSXSTACK_GAME_SCENES ? f[1] : f[0]; } +int32_t port_video_scene(void) { + return video_scene; +} + +#ifdef PSXSTACK_SDL +/* "filter: X needs the GPU renderer": once per set that has a filter (the software renderer shows none). */ +static void video_filters_unfiltered(void) { + int i; + for (i = 0; i < VIDEO_SETS; i++) { + if (video_filters[i].kind != PORT_FILTER_NONE && (i == 0 || video_filters[1].kind != video_filters[0].kind)) { + port_log("filter: %s needs the GPU renderer (--renderer gpu); the picture is unfiltered", + port_filter_names[video_filters[i].kind]); + } + } +} +#endif + void port_video_set_scaling(int mode) { video_scaling = mode >= 0 && mode < PORT_SCALING_COUNT ? mode : PORT_SCALING_FIT; } @@ -287,10 +309,9 @@ void port_video_open(int scale, int fullscreen) { } } if (video_gpu) { - render_gpu_set_filter(&video_filter); - } else if (video_filter.kind != PORT_FILTER_NONE) { - port_log("filter: %s needs the GPU renderer (--renderer gpu); the picture is unfiltered", - port_filter_names[video_filter.kind]); + render_gpu_set_filters(video_filters, VIDEO_SETS); + } else { + video_filters_unfiltered(); } if (!video_gpu) { video_renderer = SDL_CreateRenderer(video_window, NULL); @@ -320,9 +341,10 @@ 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 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) { + * it leaves). The scaling (video_dest, from the next present), the filters (video.filter, video.crt, video.scene_2d: + * both sets built again) and the sub-pixel mode follow through their setters. */ +void port_video_apply(int scale, int fullscreen, int scaling, const PortFilter filters[2], int subpixel) { + PortFilter old[2]; if (video_window == NULL) { return; } @@ -341,13 +363,13 @@ void port_video_apply(int scale, int fullscreen, int scaling, const PortFilter * } port_log("window: scale %d (%dx%d)", scale, 320 * scale, 240 * scale); } - if (memcmp(filter, &video_filter, sizeof(video_filter)) != 0) { - video_filter = *filter; + memcpy(old, video_filters, sizeof(old)); + port_video_set_filters(filters); + if (memcmp(old, video_filters, sizeof(old)) != 0) { if (video_gpu) { - render_gpu_set_filter(&video_filter); - } else if (video_filter.kind != PORT_FILTER_NONE) { - port_log("filter: %s needs the GPU renderer (--renderer gpu); the picture is unfiltered", - port_filter_names[video_filter.kind]); + render_gpu_set_filters(video_filters, VIDEO_SETS); + } else { + video_filters_unfiltered(); } } port_video_set_subpixel(subpixel); @@ -634,7 +656,7 @@ void port_video_gpu_headless(void) { port_log("renderer: gpu: no rasteriser (%s); the software image through SDL_GPU", why); } port_log("renderer: gpu for the screenshots (%s)", render_gpu_describe()); - render_gpu_set_filter(&video_filter); + render_gpu_set_filters(video_filters, VIDEO_SETS); } int port_video_dump_textures(const char *dir) { @@ -703,11 +725,11 @@ void port_video_open(int scale, int fullscreen) { void port_video_toggle_fullscreen(void) { } -void port_video_apply(int scale, int fullscreen, int scaling, const PortFilter *filter, int subpixel) { +void port_video_apply(int scale, int fullscreen, int scaling, const PortFilter filters[2], int subpixel) { (void)scale; (void)fullscreen; (void)scaling; - (void)filter; + (void)filters; (void)subpixel; } @@ -792,8 +814,35 @@ static int video_due(void) { return 1; } +/* The scene (game.h game_state_scene; a game with video.scenes): read once per vsync, before the present. A change + * picks the other set's slot for the presents from this vsync on (render_gpu_set_scene: nothing is built) and is + * logged. A game without scenes never calls the probe. */ +static void video_scene_follow(void) { + int s; + if (!PSXSTACK_GAME_SCENES) { + return; + } + s = game_state_scene() == PORT_SCENE_2D ? PORT_SCENE_2D : PORT_SCENE_3D; + if (s != video_scene) { +#ifdef PSXSTACK_SDL + if (render_gpu_active()) { + port_log("renderer: gpu: scene %s (filter %s), vsync %ld", s == PORT_SCENE_2D ? "2d" : "3d", + port_filter_names[video_filters[s].kind], port_frames); + } else +#endif + { + port_log("video: scene %s, vsync %ld", s == PORT_SCENE_2D ? "2d" : "3d", port_frames); + } + } + video_scene = s; +#ifdef PSXSTACK_SDL + render_gpu_set_scene(s); +#endif +} + void port_video_frame(void) { int i, shot = 0, present; + video_scene_follow(); #ifdef PSXSTACK_SDL render_gpu_frame(); /* the rasteriser's units of this vsync, presented or not: its target is state */ render_gpu_tex_frame(); diff --git a/schema/game.schema.json b/schema/game.schema.json index c428bdb..8943886 100644 --- a/schema/game.schema.json +++ b/schema/game.schema.json @@ -52,7 +52,26 @@ "type": "array", "items": { "enum": [50, 60] }, "uniqueItems": true, "description": "The rates the launcher offers (default: [rate]). A rate other than `rate` is passed to the game's game_apply_rate()." }, - "rate_note": { "type": "string", "description": "The launcher's explanation of the alternative rate (dw2003: the NTSC patch's 60 Hz mode)." } + "rate_note": { "type": "string", "description": "The launcher's explanation of the alternative rate (dw2003: the NTSC patch's 60 Hz mode)." }, + "scenes": { + "type": "object", "additionalProperties": false, "required": ["2d", "3d"], + "description": "Opt-in: two present-filter sets (a filter and its crt values), one for the game's 2D scenes and one for its 3D scenes, which the runtime switches between as the adapter's game_state_scene() says. Absent: one set, as before. The settings' video.filter and video.crt are the 3D set, video.scene_2d the 2D set.", + "properties": { + "2d": { + "type": "object", "additionalProperties": false, "required": ["label"], + "properties": { + "label": { "type": "string", "minLength": 1, "description": "What the 2D scenes are, for the launcher's Filter row: \"the field and the menus\"." }, + "filter": { "enum": ["none", "sharp", "scanlines", "crt", "smooth"], "description": "The 2D set's default filter (with the default crt values). Absent: a copy of the 3D set." } + } + }, + "3d": { + "type": "object", "additionalProperties": false, "required": ["label"], + "properties": { + "label": { "type": "string", "minLength": 1, "description": "What the 3D scenes are, for the launcher's Filter row: \"battles\"." } + } + } + } + } } }, "memory": { diff --git a/tests/game_gen_test.py b/tests/game_gen_test.py index be4ca7a..a1780b5 100644 --- a/tests/game_gen_test.py +++ b/tests/game_gen_test.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """tools/game_gen.py on examples/dw2003.game.json: the header compiles as C and as C++, a few values are right, the -descriptions that must fail do, the update check's repository (and its absence), the Steam artwork's files, and a one-slot game and a game without a stack heap generate. Run: python3 tests/game_gen_test.py (needs cc and c++; ~1 s).""" +descriptions that must fail do, the update check's repository (and its absence), the Steam artwork's files, the scenes (and +their absence), and a one-slot game and a game without a stack heap generate. Run: python3 tests/game_gen_test.py (needs cc and c++; ~1 s).""" import copy import json import os @@ -28,10 +29,12 @@ def compile_header(header, lang, cc, tmp): static const PsxstackGameBiosStandin bios[] = PSXSTACK_GAME_BIOS_STANDINS; static const int rates[PSXSTACK_GAME_RATE_COUNT] = PSXSTACK_GAME_RATES; int main(void) {{ - printf("%s|%s|%s|%s|%d|%d|%d|%u|%u|%u|%u|%s|%llu|%s|%s|%u|%s|%s\\n", PSXSTACK_GAME_ID, PSXSTACK_GAME_ID_UPPER, - PSXSTACK_GAME_TITLE, PSXSTACK_GAME_ENV_PREFIX, PSXSTACK_GAME_RATE, rates[PSXSTACK_GAME_RATE_COUNT - 1], - PORT_SLOT_COUNT, PORT_SLOT1_BASE, PORT_SLOT2_BASE, PORT_HEAP_START_ADDR, PORT_HEAP_SIZE, discs[0].sha1, - discs[0].size, discs[0].cue, PORT_SLOT2_NAME, bios[0].address, bios[0].text, PSXSTACK_GAME_WEBSITE); + printf("%s|%s|%s|%s|%d|%d|%d|%u|%u|%u|%u|%s|%llu|%s|%s|%u|%s|%s|%d|%s|%s|%s\\n", PSXSTACK_GAME_ID, + PSXSTACK_GAME_ID_UPPER, PSXSTACK_GAME_TITLE, PSXSTACK_GAME_ENV_PREFIX, PSXSTACK_GAME_RATE, + rates[PSXSTACK_GAME_RATE_COUNT - 1], PORT_SLOT_COUNT, PORT_SLOT1_BASE, PORT_SLOT2_BASE, PORT_HEAP_START_ADDR, + PORT_HEAP_SIZE, discs[0].sha1, discs[0].size, discs[0].cue, PORT_SLOT2_NAME, bios[0].address, bios[0].text, + PSXSTACK_GAME_WEBSITE, PSXSTACK_GAME_SCENES, PSXSTACK_GAME_SCENE_2D_LABEL, PSXSTACK_GAME_SCENE_3D_LABEL, + PSXSTACK_GAME_SCENE_2D_FILTER); return 0; }} ''') @@ -74,7 +77,7 @@ def check(ok, what): "the CMake variables") expect = ("dw2003|DW2003|Digimon World 2003|DW3|50|60|2|2148019376|2148163040|2148186112|4194304|" "457cb233349ba841e03b33d8060f8fbcadd45cb3|692146560|dw2003.cue|file|532676908|PC-PORT-M1|" - "https://github.com/gascarcella/dw2003recomp") + "https://github.com/gascarcella/dw2003recomp|1|the field and the menus|battles|smooth") for lang, cc in (("c", "cc"), ("c++", "c++")): if shutil.which(cc) is None: print(f"skipped: no {cc}") @@ -204,6 +207,37 @@ def f(g): r = run(str(tmp / "art.json"), "--check") check(r.returncode == 0, f"a PNG path relative to the description is taken: {r.stderr}") + # video.scenes: the bad shapes refused; without it the macros say one set; without 2d.filter its default is "". + def scenes(mutate): + def f(g): + mutate(g["video"]["scenes"]) + return f + failing(scenes(lambda s: s.pop("3d")), "scenes without 3d", "scenes") + failing(scenes(lambda s: s["2d"].update(filter="blur")), "an unknown 2d filter", "filter") + failing(scenes(lambda s: s["3d"].update(filter="none")), "a 3d filter (the 3D default is the stack's)", "3d") + failing(scenes(lambda s: s["3d"].update(label="")), "an empty label", "label") + failing(scenes(lambda s: s.update(menu={"label": "menus"})), "a third scene", "scenes") + + def video_unknown(g): + g["video"]["scene"] = {} + failing(video_unknown, "an unknown key in video", "scene") + nosc = copy.deepcopy(base) + del nosc["video"]["scenes"] + (tmp / "nosc.json").write_text(json.dumps(nosc)) + r = run(str(tmp / "nosc.json"), "--out", str(tmp / "nosc.h")) + t = (tmp / "nosc.h").read_text() if r.returncode == 0 else "" + check(r.returncode == 0 and "#define PSXSTACK_GAME_SCENES 0" in t and + "#define PSXSTACK_GAME_SCENE_2D_LABEL \"\"" in t and "#define PSXSTACK_GAME_SCENE_2D_FILTER \"\"" in t, + f"no video.scenes generates PSXSTACK_GAME_SCENES 0 and empty strings: {r.stderr}") + nof = copy.deepcopy(base) + del nof["video"]["scenes"]["2d"]["filter"] + (tmp / "nof.json").write_text(json.dumps(nof)) + r = run(str(tmp / "nof.json"), "--out", str(tmp / "nof.h")) + t = (tmp / "nof.h").read_text() if r.returncode == 0 else "" + check(r.returncode == 0 and "#define PSXSTACK_GAME_SCENES 1" in t and + "#define PSXSTACK_GAME_SCENE_2D_FILTER \"\"" in t, + f"scenes without 2d.filter: PSXSTACK_GAME_SCENE_2D_FILTER \"\": {r.stderr}") + def bad_addr(g): g["memory"]["ram"]["base"] = "80000000" failing(bad_addr, "an address without 0x", "") diff --git a/tools/game_gen.py b/tools/game_gen.py index d87a83f..7f82214 100755 --- a/tools/game_gen.py +++ b/tools/game_gen.py @@ -96,6 +96,20 @@ def validate_schema(game, schema): for r in video.get("rates", []): if r not in (50, 60): fail("video.rates: 50 or 60 each") + for k in video: + if k not in schema["properties"]["video"]["properties"]: + fail(f"video: unknown key: {k}") + if "scenes" in video: + sc = video["scenes"] + if not isinstance(sc, dict) or set(sc) != {"2d", "3d"}: + fail("video/scenes: an object with `2d` and `3d`") + for k, allowed in (("2d", {"label", "filter"}), ("3d", {"label"})): + if not isinstance(sc[k], dict) or not set(sc[k]) <= allowed: + fail(f"video/scenes/{k}: an object with " + " and ".join(f"`{a}`" for a in sorted(allowed))) + if not isinstance(sc[k].get("label"), str) or not sc[k]["label"]: + fail(f"video/scenes/{k}/label: a non-empty string") + if "filter" in sc["2d"] and sc["2d"]["filter"] not in FILTERS: + fail("video/scenes/2d/filter: one of " + ", ".join(FILTERS)) mem = game["memory"] if not isinstance(mem, dict): fail("memory: an object") @@ -128,6 +142,9 @@ def validate_schema(game, schema): fail("launcher/updates/github: owner/repo") +# The present filters' names (runtime/video_filter.c port_filter_names; the schema's video.scenes.2d.filter). +FILTERS = ("none", "sharp", "scanlines", "crt", "smooth") + # launcher.steam_art's pictures, in the order the launcher writes them: key, the file's name after the appid. STEAM_ART = (("capsule", "p.png"), ("wide_capsule", ".png"), ("hero", "_hero.png"), ("logo", "_logo.png"), ("icon", "_icon.png")) @@ -181,6 +198,7 @@ def resolve(game): "rate": game["video"]["rate"], "rates": game["video"].get("rates") or [game["video"]["rate"]], "rate_note": game["video"].get("rate_note", ""), + "scenes": game["video"].get("scenes"), "about": game.get("launcher", {}).get("about", game["title"]), "disc_hint": game.get("launcher", {}).get("disc_hint", ""), "website": game.get("launcher", {}).get("website", ""), @@ -277,6 +295,15 @@ def header(g, source): w(f"#define PSXSTACK_GAME_RATES {{ {', '.join(str(r) for r in g['rates'])} }}") w(f"#define PSXSTACK_GAME_RATE_NOTE {cstr(g['rate_note'])}") w("") + w("/* The scenes (video.scenes): 1 when the game has a present-filter set for its 2D scenes and one for its 3D scenes,") + w(" * switched by game_state_scene(); 0: one set. The labels name them in the launcher; the 2D set's default filter,") + w(" * \"\" for a copy of the 3D set's. */") + sc = g["scenes"] + w(f"#define PSXSTACK_GAME_SCENES {int(sc is not None)}") + w(f"#define PSXSTACK_GAME_SCENE_2D_LABEL {cstr(sc['2d']['label'] if sc else '')}") + w(f"#define PSXSTACK_GAME_SCENE_3D_LABEL {cstr(sc['3d']['label'] if sc else '')}") + w(f"#define PSXSTACK_GAME_SCENE_2D_FILTER {cstr(sc['2d'].get('filter', '') if sc else '')}") + w("") w("/* Memory: the PS1 RAM as the game addresses it, the overlay slots (1-based: the hook macros' `tier`), the heap */") w(f"#define PSXSTACK_GAME_RAM_BASE {g['ram_base']:#010x}u") w(f"#define PSXSTACK_GAME_RAM_SIZE {g['ram_size']:#x}u")