From ddfb31938e82f10652c0d2bb5069fafea4b227f0 Mon Sep 17 00:00:00 2001 From: Gabriel Scarcella Date: Sat, 24 Oct 2026 15:37:22 -0300 Subject: [PATCH] In-game menu: its picture on the hardware renderer; unmuting a game started muted opens the audio device (#74) The hardware renderer's swapchain cannot be read back (SDL creates its images to be drawn and blitted into only), so _PORT_MENU_SHOT was the software renderer's alone. The present that takes the picture now draws the game's picture and the menu into a texture of the swapchain's format, blits it into the swapchain and downloads it (menu.h port_menu_shot_due, port_menu_shot_save). tests/hello_test.py --menu --gpu takes the Settings page on the hardware renderer (Mesa's lavapipe, which presents to SDL's offscreen driver) and requires the software picture but for anti-aliased edges; CI's launcher job installs lavapipe and runs it. A game started with audio.mute had no audio device, so switching the mute off in the menu only took effect at the next start. The device now opens when audio.mute goes from on to off (paused while the menu's pause lasts); --mute alone keeps it shut, and a device that failed to open is not asked for again. The menu test starts muted and its live file unmutes it: the device must open. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 7 +- CLAUDE.md | 2 +- docs/LAUNCHER.md | 53 ++++++------ docs/RUNTIME.md | 5 +- include/psxstack/menu.h | 6 ++ include/psxstack/port_harness.h | 4 +- launcher/ui/menu.cpp | 48 ++++++++--- runtime/audio.c | 19 ++++- runtime/main.c | 1 + runtime/menu.c | 11 +++ runtime/render_gpu.c | 91 +++++++++++++++++++- tests/hello_test.py | 145 +++++++++++++++++++++++++++----- 12 files changed, 320 insertions(+), 72 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94d2018..1e432fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,8 +123,11 @@ jobs: run: python3 tests/fixtures/steam/make_fixtures.py --check - name: Self-test (offscreen) run: SDL_VIDEO_DRIVER=offscreen DW3_SELFTEST_TAR="$(command -v bsdtar)" build/launcher/dw2003-launcher --self-test build/selftest - - name: "hello with the window and the in-game menu: the input self-test offscreen, the menu's picture (tests/hello_test.py --menu)" - run: python3 tests/hello_test.py --menu + # Mesa's lavapipe: a Vulkan device that presents to SDL's offscreen driver, for the menu on the hardware renderer. + - name: lavapipe + run: test -f /usr/share/vulkan/icd.d/lvp_icd.x86_64.json || (sudo apt-get update -q && sudo apt-get install -y -q --no-install-recommends mesa-vulkan-drivers) + - name: "hello with the window and the in-game menu: the input self-test offscreen, the menu's pictures on both renderers (tests/hello_test.py --menu --gpu)" + run: python3 tests/hello_test.py --menu --gpu - name: "The launcher of a disc-free game with Steam artwork (examples/hello): the pictures are make_art.py's, it builds, its self-test" run: | python3 examples/hello/steam/make_art.py --check diff --git a/CLAUDE.md b/CLAUDE.md index 9acecbc..7944582 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ python3 tests/hello_test.py [--record] # the smoke test: examples/hel python3 tests/tasks_test.py [--sanitize|--m32] # the fibers: examples/tasks (three tasks, one preempted), its picture, its log twice, a state saved on a task's fiber and resumed python3 tests/shim_test.py [--sanitize|--m32] # the shim alone (psyq/*.c + tests/shim/shim_test.c): LIBAPI's events and root counter 3, the card's BIOS file calls and LIBCARD, CdSearchFile over a built ISO, CdRead, LIBSPU, LIBSND's SEQs python3 tests/hello_test.py --exe build/hello-win/hello.exe --wine --gpu # its Windows build (-DPSXSTACK_SDL=ON, cross-built) under Wine; --gpu: the hardware renderer's picture too (D3D12) -python3 tests/hello_test.py --menu # hello with the window and the in-game menu (-DPSXSTACK_MENU=ON): the input self-test offscreen, ending with the menu's round trip, and the menu's pictures (Settings; Mods at 1920x1080, 1280x800, 640x480), none wider than its window +python3 tests/hello_test.py --menu [--gpu] # hello with the window and the in-game menu (-DPSXSTACK_MENU=ON): the input self-test offscreen, ending with the menu's round trip (unmuted live: the device opens), and the menu's pictures (Settings; Mods at 1920x1080, 1280x800, 640x480), none wider than its window; --gpu: Settings on the hardware renderer too (lavapipe), the software one but for edge pixels cmake -S launcher -B build/launcher -G Ninja [-DPSXSTACK_GAME_JSON=path/to/game.json] && cmake --build build/launcher SDL_VIDEO_DRIVER=offscreen build/launcher/-launcher --self-test build/selftest # from game.json (dw2003 by default) cmake -S launcher -B build/launcher-win -G Ninja -DCMAKE_TOOLCHAIN_FILE="$PWD/cmake/windows-x86_64.cmake" -DCMAKE_BUILD_TYPE=Release # the toolchain path must be absolute diff --git a/docs/LAUNCHER.md b/docs/LAUNCHER.md index 889d20f..d49dfd1 100644 --- a/docs/LAUNCHER.md +++ b/docs/LAUNCHER.md @@ -102,7 +102,7 @@ and absolute paths and exits 0, or exits 64 naming the bad key. | `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.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.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 | +| `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 | | `watchdog` | integer 0-3600 | 0 | Seconds without a vsync before the game exits 4; 0 is off. The bare binary (no `--config`) keeps its 10 s | | `input` | object | the game's | "Input bindings" | @@ -474,23 +474,22 @@ at a time), where leaving the game for the launcher's window is a chore. submit). The window's events go to ImGui as well as to the runtime's keys (`runtime/input.c`): the menu navigates with the keyboard, the mouse and every gamepad; the pause, save and load hotkeys do not act while it is up. The hotkey again, or **Resume**, closes it and the game goes on; **Quit** ends the run with status 0. -- **The pages** (the navigation on the left; the shoulder buttons or Ctrl+PageUp/PageDown step through them): - **Game** (the title, Resume, Quit) and the launcher's own **Settings**, **Controls** and **Mods** screens +- **The pages** (the navigation on the left; the shoulder buttons or Ctrl+PageUp/PageDown step through them): **Game** + (the title, Resume, Quit) and the launcher's own **Settings**, **Controls** and **Mods** screens (`launcher/ui/screens.cpp`, the one implementation both draw; the update check, "Updates", is the window's own), - over the settings file the game was started with - (`--config`: the runtime's `settings.c` read it; the menu reads it 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, `audio.mute`, the pad map and every hotkey (the + over the settings file the game was started with (`--config`: the runtime's `settings.c` read it; the menu reads it + 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, + `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` - again, since a mod's `start` is a setter: "Mod runtime" rule 6). What waits for the - next start is marked **(restart)** on the page: the disc, the renderer and its internal scale, the rate, the memory - cards, the data mods, and a mod whose manifest says `"applies": "restart"` ("Mod manifest"). A file that does not - read back is logged and changes nothing. A game started without `--config` has the Game page alone; one the - launcher started (`_LAUNCHER` in its environment) says Quit *to launcher*. The disc stays the launcher's - window's. + again, since a mod's `start` is a setter: "Mod runtime" rule 6). What waits for the next start is marked + **(restart)** on the page: the disc, the renderer and its internal scale, the rate, the memory cards, the data mods, + and a mod whose manifest says `"applies": "restart"` ("Mod manifest"). A file that does not read back is logged and + changes nothing. A game started without `--config` has the Game page alone; one the launcher started + (`_LAUNCHER` in its environment) says Quit *to launcher*. The disc stays the launcher's window's. - **The style** (`launcher/ui/style.cpp`, shared with the launcher's window) is scaled to fit the launcher's canvas, its window at scale 1 (960x620, `launcher/ui/style.h` `CANVAS_W`/`CANVAS_H`), into the game's window, so a page the launcher fits fits the menu too: about 1.74 at 1920x1080, 1.29 at 1280x800, at least 1. `video.menu_size` steps @@ -503,19 +502,23 @@ at a time), where leaving the game for the launcher's window is a chore. the widget it reaches. - **The test:** `--input-test` ends with the menu's round trip (its hotkey at a vsync, the menu shown in the pause's polls, the hotkey again, the next vsync follows the paused one), and `_PORT_MENU_SHOT=PATH` writes the - window's output with the menu up as a PNG (the software renderer; the hardware renderer's present is not read - back with the menu in it), on the page `_PORT_MENU_SCREEN=game|settings|controls|mods` names (`mods:`: - with that mod chosen; `controls:keyboard|gamepad|hotkeys`: on that tab), with the window first sized to `_PORT_MENU_WINDOW=WxH` when it is set (SDL's - software renderer follows it: `SDL_RENDER_DRIVER=software`). The shot's log line names the windows whose content - reaches past their right edge ("nothing wider than its window" when none does). + window's output with the menu up as a PNG, on either renderer (the hardware renderer's swapchain cannot be read + back, so the present that takes it draws the picture and the menu into a texture of the swapchain's format, blits + that into the swapchain and reads it back: `runtime/render_gpu.c`), on the page + `_PORT_MENU_SCREEN=game|settings|controls|mods` names (`mods:`: with that mod chosen; + `controls:keyboard|gamepad|hotkeys`: on that tab), with the window first sized to `_PORT_MENU_WINDOW=WxH` + when it is set (SDL's software renderer follows it: `SDL_RENDER_DRIVER=software`). The shot's log line names the + windows whose content reaches past their right edge ("nothing wider than its window" when none does). The round trip also re-applies the settings live at its second poll, as the menu does after a save, and with `_PORT_MENU_LIVE_FILE=PATH` that file is put in place of the settings first (the hotkey pressed next is the file's binding, so a rebinding must take). `tests/hello_test.py --menu` builds `examples/hello` that way and runs - both on SDL's offscreen driver with a settings file of its own (the live file rebinds the menu hotkey and switches - fast-forward on), the picture its Settings page, then the Mods page's at 1920x1080, 1280x800 and 640x480 and at - 1280x800 "larger" (`tests/fixtures/mods/multipliers` chosen, a page larger than any a game ships so far), none of - them wider than its window; CI's `launcher` job runs it and keeps the pictures. The screens' logic is the launcher - self-test's. + both on SDL's offscreen driver with a settings file of its own, muted (the live file rebinds the menu hotkey, + switches fast-forward on and the mute off, so the audio device must open), the picture its Settings page (with + `--gpu` also on the hardware renderer, on Mesa's lavapipe, which presents to the offscreen driver: the software + picture but for the anti-aliased edges, at most 0.1 % of the pixels off by more than 8 levels), then the Mods page's + at 1920x1080, 1280x800 and 640x480 and at 1280x800 "larger" (`tests/fixtures/mods/multipliers` chosen, a page larger + than any a game ships so far), none of them wider than its window; CI's `launcher` job runs it and keeps the + pictures. The screens' logic is the launcher self-test's. - **The release:** a game that ships the menu links libstdc++ and libgcc statically with `-DPSXSTACK_MENU_STATIC_RUNTIME=ON`, as the launcher does (`launcher/README.md` "Releases"). diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index e10fb0b..a5aef5b 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -70,7 +70,7 @@ cmake -S port -B build/port-sdl -G Ninja -DPSXSTACK_SDL=ON -DPSXSTACK_MENU=ON `menu` hotkey (Escape, or Back+Start on a gamepad), in a pause; docs/LAUNCHER.md "In-game menu". The game then links as C++ (`launcher/ui/`, Dear ImGui from `tools/imgui`); without the option nothing of it is in the binary. A change made in the menu is written to the settings file and applied live where the runtime can (`runtime/live.c`). -`_PORT_MENU_SHOT=PATH` writes the window with the menu up as a PNG (the software renderer), on the page +`_PORT_MENU_SHOT=PATH` writes the window with the menu up as a PNG (either renderer), on the page `_PORT_MENU_SCREEN` names (`mods:` with a mod chosen, `controls:` on a tab of Controls), at the size `_PORT_MENU_WINDOW=WxH` gives the window (with `SDL_RENDER_DRIVER=software`). SDL's backends follow the `-dev` headers present when `setup.sh sdl3` runs: missing optional ones are turned off one by @@ -501,7 +501,8 @@ build/port-sdl/ --disc .cue --window --mute # no device SDL_VIDEO_DRIVER=offscreen SDL_AUDIO_DRIVER=disk SDL_AUDIO_DISK_OUTPUT_FILE=out.raw build/port-sdl/ --disc .cue --window --max-frames 3000 ``` `--wav FILE`: 44,100 Hz, stereo, signed 16-bit little-endian PCM; the header's sizes are written at exit -(`port_exit`), so the file is `44 + vsyncs × 882 × 4` bytes (every vsync of the run, the last one too). The window's device (unless `--mute`) is an SDL3 audio stream fed the +(`port_exit`), so the file is `44 + vsyncs × 882 × 4` bytes (every vsync of the run, the last one too). The window's device (unless `--mute` or `audio.mute`; a game started with +`audio.mute` opens it when the in-game menu switches the mute off) is an SDL3 audio stream fed the same samples. Its clock and the window's pace (`CLOCK_MONOTONIC`, `pump.c`) drift apart; the queue (frames put and not yet played, measured before each vsync's put) is held without touching the game's timing or the samples: a moving average above the band (target ± one vsync, target = the device's period + two vsyncs: 63 ms ± 20 ms at 1,024 frames) diff --git a/include/psxstack/menu.h b/include/psxstack/menu.h index 3c0fa7b..97d18de 100644 --- a/include/psxstack/menu.h +++ b/include/psxstack/menu.h @@ -43,6 +43,12 @@ int port_menu_frame(void); * swapchain texture into the present's command buffer, before its submit. */ void port_menu_render_renderer(struct SDL_Renderer *renderer); void port_menu_render_gpu(struct SDL_GPUCommandBuffer *cb, struct SDL_GPUTexture *swapchain); +/* The menu's picture from the hardware renderer (_PORT_MENU_SHOT), whose swapchain cannot be read back: + * port_menu_shot_due says this present is the one to take it, and render_gpu.c then draws the picture and the menu + * into a texture of the swapchain's format, blits that into the swapchain and reads it back; port_menu_shot_save + * takes its pixels (4 bytes a pixel, w * 4 a row, B, G, R in memory when bgra, else R, G, B; the fourth ignored). */ +int port_menu_shot_due(void); +void port_menu_shot_save(const void *pixels, int w, int h, int bgra); #ifdef __cplusplus } diff --git a/include/psxstack/port_harness.h b/include/psxstack/port_harness.h index 188f2c5..4a0a2bf 100644 --- a/include/psxstack/port_harness.h +++ b/include/psxstack/port_harness.h @@ -188,7 +188,9 @@ void port_audio_pause(int paused); /* the device paused (the pump's pause); noth /* The device muted (fast-forward): its queue cleared, then the samples rendered but not queued; unmuted, the queue * starts again from its target. The WAV and the SPU never change. */ void port_audio_set_mute(int mute); /* fast-forward's mute */ -void port_audio_set_user_mute(int mute); /* audio.mute changed in the menu (live.c): either source mutes */ +/* audio.mute: the settings' at the start (main.c), then as the menu changes it (live.c); either source mutes. A game + * started muted has no device: switched off, the mute opens it. */ +void port_audio_set_user_mute(int mute); /* ---- mods.c: the built-in mods (docs/LAUNCHER.md "Mod manifest", "Mod runtime") ---- * port_mods_settings: the settings' `mods` section (NULL: every mod off) read against the registry; a bad value fails diff --git a/launcher/ui/menu.cpp b/launcher/ui/menu.cpp index 01b543e..e8f733a 100644 --- a/launcher/ui/menu.cpp +++ b/launcher/ui/menu.cpp @@ -7,10 +7,10 @@ // (cmake/psxstack.cmake). // // _PORT_MENU_SHOT=PATH writes the window's output with the menu up, once, as a PNG: the second present after -// it is shown (the software renderer: the input self-test's picture; the hardware renderer's present cannot be read -// back with the menu in it, logged). _PORT_MENU_SCREEN=game|settings|controls|mods picks the page it opens on -// (the picture of a page; default game), `mods:` with that mod chosen, `controls:` on that tab (keyboard, -// gamepad, hotkeys). +// it is shown, on either present path (the hardware renderer's through a texture render_gpu.c reads back: menu.h +// port_menu_shot_due). _PORT_MENU_SCREEN=game|settings|controls|mods picks the page it opens on (the picture +// of a page; default game), `mods:` with that mod chosen, `controls:` on that tab (keyboard, gamepad, +// hotkeys). #include "menu.h" #include @@ -287,12 +287,14 @@ std::string menu_overflow() { return names; } -void menu_shot_renderer(SDL_Renderer *renderer) { - if (mn.shot.empty() || mn.shot_done || mn.drawn < 2) { - return; // the second present: ImGui draws an auto-sized window from its second frame on - } +// The shot is the second present with the menu: ImGui draws an auto-sized window from its second frame on. +bool menu_shot_wanted() { + return !mn.shot.empty() && !mn.shot_done && mn.drawn >= 2; +} + +// The window's output with the menu up (nullptr: it could not be read) as the shot's PNG, and the overflow line. +void menu_shot_write(SDL_Surface *s) { mn.shot_done = true; - SDL_Surface *s = SDL_RenderReadPixels(renderer, nullptr); if (s == nullptr || !SDL_SavePNG(s, mn.shot.c_str())) { port_log("menu: shot %s: %s", mn.shot.c_str(), SDL_GetError()); } else { @@ -300,6 +302,14 @@ void menu_shot_renderer(SDL_Renderer *renderer) { } const std::string over = menu_overflow(); port_log("menu: shot: %s", over.empty() ? "nothing wider than its window" : ("wider than its window: " + over).c_str()); +} + +void menu_shot_renderer(SDL_Renderer *renderer) { + if (!menu_shot_wanted()) { + return; + } + SDL_Surface *s = SDL_RenderReadPixels(renderer, nullptr); + menu_shot_write(s); SDL_DestroySurface(s); } @@ -333,10 +343,6 @@ void port_menu_open_gpu(SDL_Window *window, SDL_GPUDevice *device) { info.MSAASamples = SDL_GPU_SAMPLECOUNT_1; ImGui_ImplSDLGPU3_Init(&info); port_log("menu: Dear ImGui %s on SDL_GPU", IMGUI_VERSION); - if (!mn.shot.empty()) { - port_log("menu: shot: the software renderer's present only; none from the hardware renderer"); - mn.shot_done = true; - } } void port_menu_close(void) { @@ -435,4 +441,20 @@ void port_menu_render_gpu(SDL_GPUCommandBuffer *cb, SDL_GPUTexture *swapchain) { mn.drawn++; } +int port_menu_shot_due(void) { + // the draw this present makes is the one the shot wants (port_menu_render_gpu counts it) + return menu_ready_to_draw() && mn.device != nullptr && !mn.shot.empty() && !mn.shot_done && mn.drawn + 1 >= 2; +} + +void port_menu_shot_save(const void *pixels, int w, int h, int bgra) { + if (!menu_shot_wanted()) { + return; + } + SDL_Surface *s = pixels != nullptr ? SDL_CreateSurfaceFrom(w, h, bgra ? SDL_PIXELFORMAT_BGRX32 : SDL_PIXELFORMAT_RGBX32, + const_cast(pixels), w * 4) + : nullptr; + menu_shot_write(s); + SDL_DestroySurface(s); +} + } // extern "C" diff --git a/runtime/audio.c b/runtime/audio.c index f4f0a1d..09152aa 100644 --- a/runtime/audio.c +++ b/runtime/audio.c @@ -16,7 +16,8 @@ * - `--wav FILE` (any build, headless too): the samples as a RIFF WAV, 44,100 Hz, 2 channels, signed 16-bit * little-endian; the header's sizes are written at port_audio_close (port_exit): a run that is killed leaves them 0. * Its length is (vsyncs rendered) x 882 x 4 bytes + the 44-byte header at 50 Hz. - * - The device (a build with -DPSXSTACK_SDL=ON, window mode, unless --mute): an SDL3 audio stream (SDL resamples to the + * - The device (a build with -DPSXSTACK_SDL=ON, window mode, unless --mute or audio.mute; a game started with + * audio.mute opens it when the in-game menu switches the mute off): an SDL3 audio stream (SDL resamples to the * device's own format), fed the same samples. The window's pace (pump.c: vsync n due at n / fps seconds of * CLOCK_MONOTONIC) and the device's sample clock are two clocks: they drift apart (a few hundred ppm on real hardware; * a slow host or a pause makes it seconds). The queue (the frames put into the stream and not yet played, measured @@ -109,6 +110,8 @@ static void audio_wav_write(const int16_t *s, int frames) { #define AUDIO_REPORT_SEC 10 static SDL_AudioStream *audio_stream; +static int audio_device_tried; /* audio_device_open ran: a device that failed is not asked for again */ +static int audio_paused; /* the pump's pause (port_audio_pause): a device opened in it starts paused */ static int audio_vsync_frames; /* V: one vsync's frames, nominal */ static int audio_period; /* P: the frames the device pulls at once */ static int audio_target, audio_low, audio_high, audio_max; @@ -158,6 +161,7 @@ static void audio_report(const char *what, long long vsyncs, int min, int max, d static void audio_device_open(void) { SDL_AudioSpec spec, dev; int frames = 0; + audio_device_tried = 1; if (!SDL_InitSubSystem(SDL_INIT_AUDIO)) { port_log("audio: no audio (SDL_InitSubSystem: %s); the run goes on silent", SDL_GetError()); return; @@ -187,7 +191,9 @@ static void audio_device_open(void) { port_log("audio: SDL audio driver %s, device %d Hz %d channels, %d frames a period; queue target %.1f ms (band " "%.1f..%.1f, drop above %.1f)", SDL_GetCurrentAudioDriver(), dev.freq, dev.channels, frames, audio_ms(audio_target), audio_ms(audio_low), audio_ms(audio_high), audio_ms(audio_max)); - SDL_ResumeAudioStreamDevice(audio_stream); + if (!audio_paused) { + SDL_ResumeAudioStreamDevice(audio_stream); + } } static void audio_put(const void *data, int frames) { @@ -333,8 +339,12 @@ void port_audio_set_mute(int mute) { void port_audio_set_user_mute(int mute) { #ifdef PSXSTACK_SDL - if (!mute && audio_stream == NULL && port_window) { - port_log("audio: the game started muted (no audio device): sound at the next start"); + /* a game started muted has no device: unmuted in the menu, it opens now (in the menu's pause: paused until the + * game goes on); one that failed to open is not asked for again, and --mute alone keeps it shut (the settings' + * value at the start is main.c's first call here, so only a change of audio.mute opens it) */ + if (audio_mute_user && !mute && audio_stream == NULL && port_window && !audio_device_tried) { + port_log("audio: unmuted: the device opens"); + audio_device_open(); } #endif audio_mute_user = mute; @@ -343,6 +353,7 @@ void port_audio_set_user_mute(int mute) { void port_audio_pause(int paused) { #ifdef PSXSTACK_SDL + audio_paused = paused; if (audio_stream != NULL) { if (paused) { SDL_PauseAudioStreamDevice(audio_stream); diff --git a/runtime/main.c b/runtime/main.c index 5771d83..5fc8b6c 100644 --- a/runtime/main.c +++ b/runtime/main.c @@ -462,6 +462,7 @@ int main(int argc, char **argv) { port_spu_trace_open(spu_trace); } port_audio_open(window && !mute, wav); + port_audio_set_user_mute(port_settings.mute); /* the menu's audio.mute starts from the file's (live.c) */ if (script != NULL) { port_script_load(script); port_script_active = 1; diff --git a/runtime/menu.c b/runtime/menu.c index 2b03b9b..a08f088 100644 --- a/runtime/menu.c +++ b/runtime/menu.c @@ -47,6 +47,17 @@ void port_menu_render_gpu(struct SDL_GPUCommandBuffer *cb, struct SDL_GPUTexture (void)swapchain; } +int port_menu_shot_due(void) { + return 0; +} + +void port_menu_shot_save(const void *pixels, int w, int h, int bgra) { + (void)pixels; + (void)w; + (void)h; + (void)bgra; +} + #else typedef int psxstack_menu_c_is_empty; /* ISO C forbids an empty translation unit */ #endif diff --git a/runtime/render_gpu.c b/runtime/render_gpu.c index 5315095..7d073bf 100644 --- a/runtime/render_gpu.c +++ b/runtime/render_gpu.c @@ -1428,11 +1428,89 @@ static int render_source(SDL_GPUCommandBuffer *cb, const u32 *pixels, int w, int return render_upload(cb, pixels, w, h); } +/* The in-game menu's picture (menu.h port_menu_shot_due). SDL creates the swapchain's images to be drawn and blitted + * into, not read, so the present that takes it draws into a texture of the swapchain's format instead, blits that + * into the swapchain (the window shows what is saved) and downloads it; the menu gets the pixels once the frame is + * done. The texture and its buffer live for that one present. */ +typedef struct { + SDL_GPUTexture *texture; + SDL_GPUTransferBuffer *download; + Uint32 w, h; +} RenderMenuShot; + +static SDL_GPUTexture *render_menu_shot_begin(RenderMenuShot *shot, Uint32 w, Uint32 h) { + shot->w = w; + shot->h = h; + shot->texture = render_texture(r.swap_format, (int)w, (int)h, + SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER); + shot->download = render_buffer(SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, w * h * 4); + if (shot->texture == NULL || shot->download == NULL) { + port_log("renderer: gpu: no %ux%u target for the menu's picture: %s", w, h, SDL_GetError()); + port_menu_shot_save(NULL, (int)w, (int)h, 0); + SDL_ReleaseGPUTexture(r.device, shot->texture); + SDL_ReleaseGPUTransferBuffer(r.device, shot->download); + shot->texture = NULL; + shot->download = NULL; + return NULL; + } + return shot->texture; +} + +/* After the picture and the menu were drawn into the shot's texture: into the swapchain, and on its way back. */ +static void render_menu_shot_record(SDL_GPUCommandBuffer *cb, RenderMenuShot *shot, SDL_GPUTexture *swap) { + SDL_GPUBlitInfo blit; + SDL_GPUCopyPass *copy; + SDL_GPUTextureRegion region; + SDL_GPUTextureTransferInfo info; + memset(&blit, 0, sizeof(blit)); + blit.source.texture = shot->texture; + blit.source.w = shot->w; + blit.source.h = shot->h; + blit.destination.texture = swap; + blit.destination.w = shot->w; + blit.destination.h = shot->h; + blit.load_op = SDL_GPU_LOADOP_DONT_CARE; + blit.filter = SDL_GPU_FILTER_NEAREST; + SDL_BlitGPUTexture(cb, &blit); + memset(®ion, 0, sizeof(region)); + region.texture = shot->texture; + region.w = shot->w; + region.h = shot->h; + region.d = 1; + memset(&info, 0, sizeof(info)); + info.transfer_buffer = shot->download; + info.pixels_per_row = shot->w; + info.rows_per_layer = shot->h; + copy = SDL_BeginGPUCopyPass(cb); + SDL_DownloadFromGPUTexture(copy, ®ion, &info); + SDL_EndGPUCopyPass(copy); +} + +/* The submit of the shot's present: waits for the frame, hands the menu its pixels, releases the shot. 0 when the + * submit failed. */ +static int render_menu_shot_submit(SDL_GPUCommandBuffer *cb, RenderMenuShot *shot) { + SDL_GPUFence *fence = SDL_SubmitGPUCommandBufferAndAcquireFence(cb); + const void *map = NULL; + if (fence != NULL) { + SDL_WaitForGPUFences(r.device, true, &fence, 1); + SDL_ReleaseGPUFence(r.device, fence); + map = SDL_MapGPUTransferBuffer(r.device, shot->download, false); + } + port_menu_shot_save(map, (int)shot->w, (int)shot->h, r.swap_format == SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM); + if (map != NULL) { + SDL_UnmapGPUTransferBuffer(r.device, shot->download); + } + SDL_ReleaseGPUTexture(r.device, shot->texture); + SDL_ReleaseGPUTransferBuffer(r.device, shot->download); + return fence != NULL; +} + int render_gpu_present(const u32 *pixels, int w, int h, const int *vram_xy, RenderDestFn dest) { SDL_GPUCommandBuffer *cb; SDL_GPUTexture *swap = NULL; Uint32 sw = 0, sh = 0; int rect[4] = { 0, 0, 0, 0 }, wide[2]; + RenderMenuShot shot = { NULL, NULL, 0, 0 }; if (r.device == NULL || r.window == NULL) { return 0; @@ -1456,12 +1534,19 @@ int render_gpu_present(const u32 *pixels, int w, int h, const int *vram_xy, Rend if (swap != NULL) { /* NULL: the window is minimised or occluded; nothing to draw this frame */ SDL_GPUGraphicsPipeline *pipe = vram_xy != NULL ? (r.pipe_vram_swap ? r.pipe_vram_swap : r.pipe_vram_off) : (r.pipe_swap ? r.pipe_swap : r.pipe_off); + SDL_GPUTexture *into = port_menu_shot_due() ? render_menu_shot_begin(&shot, sw, sh) : NULL; + if (into == NULL) { + into = swap; + } dest((int)sw, (int)sh, rect); render_clip(rect, (int)sw, (int)sh); - render_draw(cb, swap, pipe, w, h, vram_xy, rect, 1); - port_menu_render_gpu(cb, swap); /* the menu over the picture, while it is shown */ + render_draw(cb, into, pipe, w, h, vram_xy, rect, 1); + port_menu_render_gpu(cb, into); /* the menu over the picture, while it is shown */ + if (into != swap) { + render_menu_shot_record(cb, &shot, swap); + } } - if (!SDL_SubmitGPUCommandBuffer(cb)) { + if (shot.texture != NULL ? !render_menu_shot_submit(cb, &shot) : !SDL_SubmitGPUCommandBuffer(cb)) { goto failed; } return 1; diff --git a/tests/hello_test.py b/tests/hello_test.py index 086c3dc..450d5df 100644 --- a/tests/hello_test.py +++ b/tests/hello_test.py @@ -3,7 +3,7 @@ python3 tests/hello_test.py [--build DIR] [--record] python3 tests/hello_test.py --exe build/hello-win/hello.exe --wine [--gpu] - python3 tests/hello_test.py --menu [--build DIR] + python3 tests/hello_test.py --menu [--gpu] [--build DIR] Configures and builds examples/hello (cmake, ninja, the host gcc and binutils; no SDL, no disc), checks `--version` names it, runs it for 40 vsyncs with a screenshot at vsync 30 (the software GPU: the same bytes on every machine) and @@ -18,6 +18,10 @@ (the prefix build/wine-prefix unless WINEPREFIX names one); --gpu also takes the hardware renderer's picture of the same vsync (an SDL build), which must be the software one byte for byte when a device opens (on Windows, Direct3D 12 first) and is skipped, with the reason, when none does (CI's Wine has none). +--menu builds hello with the window and the in-game menu instead and runs it on SDL's offscreen driver (menu_check); +--menu --gpu also takes the menu's picture on the hardware renderer (Mesa's lavapipe when it is installed: NVIDIA's +driver cannot present to the offscreen driver), which must be the software one but for a few edge pixels +(GPU_MENU_LEVELS, GPU_MENU_EDGES). """ import argparse import hashlib @@ -26,6 +30,7 @@ import shutil import subprocess import sys +import zlib from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -112,13 +117,14 @@ def script_check(runner, exe, build, env): return ok -def menu_check(build, env): +def menu_check(build, env, gpu): """--menu: hello with the window and the in-game menu (-DPSXSTACK_SDL=ON -DPSXSTACK_MENU=ON: tools/sdl3, dxc and imgui), its input self-test on SDL's offscreen driver, which ends with the menu's round trip (runtime/input.c), and the picture of the menu up on its Settings page (HELLO_PORT_MENU_SHOT and _SCREEN, launcher/ui/menu.cpp) at - build/menu.png; then the Mods page's at three window sizes and one menu size (MENU_SIZES), build/menu-mods-*.png; - then the input self-test again with each input.player2 that gives a second pad (PLAYER2: its players' part), the - first also with the picture of the Controls screen's Gamepad tab at 640x480, build/menu-controls-gamepad.png.""" + build/menu.png; with gpu the same on the hardware renderer, build/menu-gpu.png (menu_gpu); then the Mods page's at + three window sizes and one menu size (MENU_SIZES), build/menu-mods-*.png; then the input self-test again with each + input.player2 that gives a second pad (PLAYER2: its players' part), the first also with the picture of the Controls + screen's Gamepad tab at 640x480, build/menu-controls-gamepad.png.""" for tool in ("cmake", "ninja", "gcc", "g++", "objcopy", "objdump", "nm"): if not shutil.which(tool, path=env["PATH"]): print(f"hello: no {tool} on PATH (scripts/setup.sh cmake; gcc, g++ and binutils from the system)") @@ -128,28 +134,19 @@ def menu_check(build, env): "-DPSXSTACK_SDL=ON", "-DPSXSTACK_MENU=ON"], check=True, env=env, stdout=subprocess.DEVNULL) subprocess.run(["cmake", "--build", str(build)], check=True, env=env, stdout=subprocess.DEVNULL) exe, shot = build / "hello", build / "menu.png" - shot.unlink(missing_ok=True) - # A settings file for the run, so the menu's Settings, Controls and Mods pages have something to edit; the - # picture is the Settings page. cfg = build / "cfg" cfg.mkdir(exist_ok=True) - (cfg / "settings.json").write_text('{ "schema": 1 }\n') - # The live apply with a change (runtime/input.c HELLO_PORT_MENU_LIVE_FILE): a second file, put in place while the - # menu is up, rebinds the menu hotkey and switches fast-forward on; the round trip then closes the menu with the - # new key, and the mod's "on" line must follow. - (cfg / "live.json").write_text('{ "schema": 1, "input": { "hotkeys": { "menu": "F10" } }, ' - '"mods": { "fast_forward": { "enabled": true } } }\n') - run_env = dict(env, SDL_VIDEO_DRIVER="offscreen", SDL_AUDIO_DRIVER="dummy", HELLO_PORT_MENU_SHOT=str(shot), - HELLO_PORT_MENU_SCREEN="settings", HELLO_PORT_MENU_LIVE_FILE=str(cfg / "live.json")) - r = subprocess.run([str(exe), "--config", str(cfg / "settings.json"), "--mods-dir", str(cfg / "mods"), - "--input-test", "--fps", "0"], capture_output=True, text=True, env=run_env, timeout=600) + r = menu_run(exe, cfg, shot, env) lines = r.stderr.strip().splitlines() menu = next((l for l in lines if "input test: the menu:" in l), "(no menu line)") live = next((l for l in lines if "re-applied live in the menu:" in l), "(no live line)") ff_on = any(l.endswith("mods: fast_forward on") for l in lines) - ok = r.returncode == 0 and "the menu: passed" in menu and "live in the menu: passed" in live and ff_on + opened = next((i for i, l in enumerate(lines) if l.endswith("audio: unmuted: the device opens")), None) + sound = opened is not None and any("audio: SDL audio driver" in l for l in lines[opened:]) + ok = r.returncode == 0 and "the menu: passed" in menu and "live in the menu: passed" in live and ff_on and sound print(("ok " if ok else "FAIL ") + f"the input self-test with the menu: status {r.returncode}; {menu}; {live}; " - f"fast-forward {'switched on live' if ff_on else 'NOT switched on'}") + f"fast-forward {'switched on live' if ff_on else 'NOT switched on'}; " + f"{'unmuted live: the device opened' if sound else 'unmuted live: NO device opened'}") if not ok: print("\n".join(lines[-15:])) fit = shot_fit(lines) @@ -161,12 +158,118 @@ def menu_check(build, env): else: print("FAIL no PNG of the menu") ok = False + if gpu: + ok &= menu_gpu(exe, cfg, shot, env) ok &= menu_sizes(exe, cfg, env) ok &= players_check(exe, cfg, env) print("hello: " + ("pass" if ok else "FAIL")) return 0 if ok else 1 +def menu_run(exe, cfg, shot, env, *extra): + """The input self-test with the menu on its Settings page, its picture to `shot`. The game starts muted, from a + settings file of its own, so the menu's pages have something to edit. The live apply with a change + (runtime/input.c HELLO_PORT_MENU_LIVE_FILE): a second file, put in place while the menu is up, rebinds the menu + hotkey, switches fast-forward on and the mute off; the round trip then closes the menu with the new key, the mod's + "on" line must follow, and the audio device must open (runtime/audio.c: a game started muted has none).""" + shot.unlink(missing_ok=True) + (cfg / "settings.json").write_text('{ "schema": 1, "audio": { "mute": true } }\n') + (cfg / "live.json").write_text('{ "schema": 1, "input": { "hotkeys": { "menu": "F10" } }, ' + '"mods": { "fast_forward": { "enabled": true } }, "audio": { "mute": false } }\n') + run_env = dict(env, SDL_VIDEO_DRIVER="offscreen", SDL_AUDIO_DRIVER="dummy", HELLO_PORT_MENU_SHOT=str(shot), + HELLO_PORT_MENU_SCREEN="settings", HELLO_PORT_MENU_LIVE_FILE=str(cfg / "live.json")) + return subprocess.run([str(exe), "--config", str(cfg / "settings.json"), "--mods-dir", str(cfg / "mods"), + "--input-test", "--fps", "0", *extra], capture_output=True, text=True, env=run_env, + timeout=600) + + +# The hardware renderer's picture of the menu against the software one: ImGui's triangles rasterised by another API +# round their anti-aliased edges differently (lavapipe against OpenGL: 5 % of the pixels, by at most 2 levels a +# channel; NVIDIA's Direct3D 12: 67 pixels of a radio button's rim, by up to 33). At most GPU_MENU_EDGES of the +# pixels may differ by more than GPU_MENU_LEVELS; a missing, misplaced or other page differs by far more (another +# page: 18 % of the pixels). +GPU_MENU_LEVELS = 8 +GPU_MENU_EDGES = 0.001 +LAVAPIPE = Path("/usr/share/vulkan/icd.d/lvp_icd.x86_64.json") + + +def menu_gpu(exe, cfg, soft, env): + """--menu --gpu: the run of menu_run on the hardware renderer (render_gpu.c draws the present with the menu into a + texture it reads back), build/menu-gpu.png, on Mesa's lavapipe when it is installed (VK_DRIVER_FILES; NVIDIA's + driver cannot present to the offscreen driver and the window would fall back to SDL_Renderer).""" + shot = exe.parent / "menu-gpu.png" + run_env = dict(env) + if LAVAPIPE.exists() and "VK_DRIVER_FILES" not in env: + run_env["VK_DRIVER_FILES"] = str(LAVAPIPE) + r = menu_run(exe, cfg, shot, run_env, "--renderer", "gpu") + lines = r.stderr.strip().splitlines() + window = next((l.split("window: ", 1)[1] for l in lines if "window: SDL" in l), "(no window line)") + if "renderer gpu" not in window: + print(f"FAIL the menu on the hardware renderer: the window has none ({window}); install Mesa's lavapipe " + f"(mesa-vulkan-drivers), which presents to SDL's offscreen driver") + print("\n".join(l for l in lines if "renderer" in l)) + return False + menu = next((l for l in lines if "input test: the menu:" in l), "(no menu line)") + fit = shot_fit(lines) + good = r.returncode == 0 and "the menu: passed" in menu and fit == "nothing wider than its window" + if good and shot.exists() and soft.exists(): + (w, h, a), (sw, sh, b) = png_rgb(shot), png_rgb(soft) + if (w, h) != (sw, sh): + diff = f"{w}x{h}, the software one {sw}x{sh}" + good = False + else: + delta = [max(abs(a[i] - b[i]), abs(a[i + 1] - b[i + 1]), abs(a[i + 2] - b[i + 2])) + for i in range(0, len(a), 3)] + pixels, far = sum(1 for d in delta if d), sum(1 for d in delta if d > GPU_MENU_LEVELS) + diff = (f"{pixels} of {w * h} pixels differ from the software one, by at most {max(delta)}; " + f"{far} by more than {GPU_MENU_LEVELS} (at most {int(w * h * GPU_MENU_EDGES)})") + good = far <= w * h * GPU_MENU_EDGES + else: + diff = "no picture" if good else f"status {r.returncode}; {menu}; {fit}" + good = False + print(("ok " if good else "FAIL ") + f"the menu on the hardware renderer ({window}): {diff}; {shot}") + if not good and r.returncode != 0: + print("\n".join(lines[-15:])) + return good + + +def png_rgb(path): + """An 8-bit RGB or RGBA PNG, not interlaced (SDL_SavePNG's), as (width, height, its RGB bytes): no Pillow needed.""" + data, pos, idat = path.read_bytes(), 8, b"" + while pos < len(data): + n, kind = int.from_bytes(data[pos:pos + 4], "big"), data[pos + 4:pos + 8] + if kind == b"IHDR": + w, h = int.from_bytes(data[pos + 8:pos + 12], "big"), int.from_bytes(data[pos + 12:pos + 16], "big") + depth, ctype, interlace = data[pos + 16], data[pos + 17], data[pos + 20] + if depth != 8 or ctype not in (2, 6) or interlace: + raise ValueError(f"{path}: depth {depth}, colour type {ctype}, interlace {interlace}") + bpp = 3 if ctype == 2 else 4 + elif kind == b"IDAT": + idat += data[pos + 8:pos + 8 + n] + pos += 12 + n + raw, stride = zlib.decompress(idat), w * bpp + prev, out = bytearray(stride), bytearray() + for y in range(h): + f, line = raw[y * (stride + 1)], bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)]) + for i in range(stride): + a = line[i - bpp] if i >= bpp else 0 + b = prev[i] + c = prev[i - bpp] if i >= bpp else 0 + if f == 1: + line[i] = (line[i] + a) & 255 + elif f == 2: + line[i] = (line[i] + b) & 255 + elif f == 3: + line[i] = (line[i] + (a + b) // 2) & 255 + elif f == 4: + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255 + out += line if bpp == 3 else bytes(v for i, v in enumerate(line) if i % 4 != 3) + prev = line + return w, h, bytes(out) + + # The Mods page's pictures (#92): a desktop's 1920x1080, the Steam Deck's 1280x800 and the smallest window, 640x480, # at the fitted size, and the Deck's at video.menu_size "larger" (the narrow layout at a larger scale), with # tests/fixtures/mods/multipliers selected (a page at least as large as the widest a game ships), its values set so @@ -264,7 +367,7 @@ def main(): if venv.exists(): env["PATH"] = f"{venv}:{env['PATH']}" return menu_check(Path(args.build if args.build != str(ROOT / "build/hello") else ROOT / "build/hello-menu"), - env) + env, args.gpu) if args.exe: exe = Path(args.exe).resolve() build = exe.parent