From 4ed0bfd08a09ed1d329b45c55be614c1f973d7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Jos=C3=A9=20Garc=C3=ADa=20Garc=C3=ADa?= Date: Tue, 20 Oct 2026 17:40:41 +0200 Subject: [PATCH] Headless: Scripted button presses and timed screenshots --press=SECONDS:MASK[:DURATION],... holds sceCtrl buttons at emulated times, and --screenshot-at=SECONDS:FILE,... saves screenshots along the way, to drive a game past its menus in a run. With presses, the run loop steps 1/60 of a second at a time so short ones land. --- Core/CmdLine.cpp | 2 + Core/CmdLine.h | 2 + docs/debugging.md | 4 ++ headless/Headless.cpp | 95 ++++++++++++++++++++++++++++++++++++++----- 4 files changed, 93 insertions(+), 10 deletions(-) diff --git a/Core/CmdLine.cpp b/Core/CmdLine.cpp index 44cf315d16..87c6be4287 100644 --- a/Core/CmdLine.cpp +++ b/Core/CmdLine.cpp @@ -218,6 +218,8 @@ static const CommandLineParam g_autoParams[] = { {POFF(screenshotFilenameDiff), CmdParamType::String, "screenshot-diff", '\0', "Save a visual comparison image to FILE when comparing screenshots", CmdLineMode::Headless}, {POFF(screenshotSaveKeepAlpha), CmdParamType::Bool, "screenshot-keep-alpha", '\0', "Preserve the alpha channel when saving PNG screenshots (default: alpha is forced to 255)", CmdLineMode::Headless}, {POFF(dumpAudioFilename), CmdParamType::String, "dump-audio", '\0', "Record the mixed audio output to a WAV file", CmdLineMode::Headless}, + {POFF(pressScript), CmdParamType::String, "press", '\0', "Hold buttons at emulated times: SECONDS:MASK[:DURATION],...", CmdLineMode::Headless}, + {POFF(screenshotAt), CmdParamType::String, "screenshot-at", '\0', "Save screenshots at emulated times: SECONDS:FILE,...", CmdLineMode::Headless}, {POFF(timeoutWall), CmdParamType::Double, "timeout-wall", '\0', "Stop the run after this many real seconds", CmdLineMode::Headless}, {POFF(timeoutEmulated), CmdParamType::Double, "timeout-emulated", '\0', "Stop the run after this many emulated seconds", CmdLineMode::Headless}, // The old name for --timeout-wall, kept working because it's in a lot of scripts. diff --git a/Core/CmdLine.h b/Core/CmdLine.h index b80bfa0651..2982435ad7 100644 --- a/Core/CmdLine.h +++ b/Core/CmdLine.h @@ -199,6 +199,8 @@ struct CommandLineOptions { std::optional screenshotDir; std::optional depthFilenameSave; std::optional dumpAudioFilename; + std::optional pressScript; + std::optional screenshotAt; std::optional replayEnd; // Headless: apply the game's compatibility settings when replaying a GE frame dump. std::optional dumpCompat; diff --git a/docs/debugging.md b/docs/debugging.md index c9c6f20fea..b3c93c2fa3 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -226,6 +226,10 @@ To check that a game keeps running after `--state=` loads, pass `--frame-stats`: the flips in each emulated second since the load. A stuck or crashed game shows as zeros or a short list, and a steady game as a flat 30 or 60. The state's load result is printed too, and a failed load exits 1. +To get past menus, `--press=SECONDS:MASK[:DURATION],...` holds buttons at emulated times (`MASK` is a sceCtrl +mask, `0x8` START and `0x4000` CROSS, held 0.15 seconds by default), and `--screenshot-at=SECONDS:FILE,...` saves +screenshots along the way. + For audio, `--dump-audio=FILE.wav` records the mixed output (before the game volume setting) for the whole run. An audio glitch you can hear usually shows in the waveform: a click is a jump between neighbouring samples, and a repeated or dropped mixer block is two identical 64-sample runs in a row. diff --git a/headless/Headless.cpp b/headless/Headless.cpp index 46bee0dd8b..4a44e2e51e 100644 --- a/headless/Headless.cpp +++ b/headless/Headless.cpp @@ -61,6 +61,7 @@ #include "Core/EmuThread.h" #include "Core/HLE/HLE.h" #include "Core/HLE/__sceAudio.h" +#include "Core/HLE/sceCtrl.h" #include "Core/MIPS/MIPSTables.h" #include "Core/System.h" #include "Core/Util/PSARUnpack.h" @@ -281,6 +282,35 @@ static void SaveDepthBuffer(const GPUDebugBuffer &depth, const Path &path) { fclose(f); } +// At the render scale. pixels is the 1x readback, used when there's no scaling. +static bool SaveDisplayScreenshot(const Path &path, const std::vector &pixels) { + const static u32 FRAME_STRIDE = 512; + const static u32 FRAME_WIDTH = 480; + const static u32 FRAME_HEIGHT = 272; + const GPUDebugFramebufferType type = g_screenshotRenderTarget ? GPU_DBG_FRAMEBUF_RENDER : GPU_DBG_FRAMEBUF_DISPLAY; + GPUDebugBuffer full; + const int scale = gpu->GetCurrentFramebuffer(full, type, -1) ? std::max(full.GetScaleFactor(), 1) : 1; + const u32 w = FRAME_WIDTH * scale; + const u32 h = FRAME_HEIGHT * scale; + const std::vector fullPixels = scale > 1 ? TranslateDebugBufferToCompare(&full, w, h) : pixels; + ScreenshotComparer saver(fullPixels, scale > 1 ? w : FRAME_STRIDE, w, h); + bool saved = path.GetFileExtension() == ".png" ? saver.SaveActualPNG(path, g_screenshotSaveKeepAlpha) : saver.SaveActualBitmap(path); + if (saved) { + SendAndCollectOutput("Screenshot saved to: " + path.ToVisualString() + "\n"); + } + return saved; +} + +static void SaveScreenshotNow(const Path &path) { + if (!gpu) { + return; + } + const GPUDebugFramebufferType type = g_screenshotRenderTarget ? GPU_DBG_FRAMEBUF_RENDER : GPU_DBG_FRAMEBUF_DISPLAY; + GPUDebugBuffer buffer; + gpu->GetCurrentFramebuffer(buffer, type, 1); + SaveDisplayScreenshot(path, TranslateDebugBufferToCompare(&buffer, 512, 272)); +} + void SendDebugScreenshot(const DebugScreenshotDesc &desc) { const u8 *pixbuf = (const u8 *)desc.data; u32 w = desc.stride; @@ -300,16 +330,8 @@ void SendDebugScreenshot(const DebugScreenshotDesc &desc) { // If a screenshot save path is set, save unconditionally, at the render scale. if (!g_screenshotSavePath.empty()) { - GPUDebugBuffer full; - const int scale = gpu->GetCurrentFramebuffer(full, type, -1) ? std::max(full.GetScaleFactor(), 1) : 1; - const u32 w = FRAME_WIDTH * scale; - const u32 h = FRAME_HEIGHT * scale; - const std::vector fullPixels = scale > 1 ? TranslateDebugBufferToCompare(&full, w, h) : pixels; - ScreenshotComparer saver(fullPixels, scale > 1 ? w : FRAME_STRIDE, w, h); - bool saved = g_screenshotSavePath.GetFileExtension() == ".png" ? saver.SaveActualPNG(g_screenshotSavePath, g_screenshotSaveKeepAlpha) : saver.SaveActualBitmap(g_screenshotSavePath); + bool saved = SaveDisplayScreenshot(g_screenshotSavePath, pixels); g_screenshotSaved = g_screenshotSaved || saved; - if (saved) - SendAndCollectOutput("Screenshot saved to: " + g_screenshotSavePath.ToVisualString() + "\n"); } if (!g_depthSavePath.empty()) { @@ -416,6 +438,14 @@ struct AutoTestOptions { int requiredDisableHLE; // The WebSocket debugger is on (--debugger or --debugger-run), so a stop is its to resume. bool debugger; + // --press, in emulated seconds. + struct Press { + double start; + double duration; + u32 buttons; + }; + std::vector presses; + std::vector> screenshotsAt; }; // Ends a frame of the draw context the way the app does, presenting it. Unpresented frames never @@ -528,6 +558,7 @@ static bool RunAutoTest(GraphicsContext *graphicsContext, CoreParameter &corePar std::vector flipsPerSecond; double statsStart = -1.0; int statsLastFlips = 0; + std::vector> screenshotsAt = opt.screenshotsAt; coreState = coreParameter.startBreak ? CORE_STEPPING_CPU : CORE_RUNNING_CPU; while (coreState == CORE_RUNNING_CPU || coreState == CORE_STEPPING_CPU) { // Savestate loads/saves are queued and applied here, same as EmuScreen::render does in the @@ -561,7 +592,27 @@ static bool RunAutoTest(GraphicsContext *graphicsContext, CoreParameter &corePar }); } - int blockTicks = (int)usToCycles(1000000 / 10); + // Overlapping presses don't release each other's buttons. + if (!opt.presses.empty()) { + u32 held = 0, released = 0; + for (const auto &press : opt.presses) { + if (emulatedElapsed >= press.start && emulatedElapsed < press.start + press.duration) { + held |= press.buttons; + } else { + released |= press.buttons; + } + } + __CtrlUpdateButtons(held, released & ~held); + } + for (auto &shot : screenshotsAt) { + if (!shot.second.empty() && emulatedElapsed >= shot.first) { + SaveScreenshotNow(shot.second); + shot.second.clear(); + } + } + + // Finer steps when input is scripted, so short presses land. + int blockTicks = (int)usToCycles(1000000 / (opt.presses.empty() ? 10 : 60)); PSP_RunLoopFor(blockTicks); // If we were rendering, this might be a nice time to do something about it. @@ -854,6 +905,30 @@ int main(int argc, const char* argv[]) { testOptions.verbose = cmdLineOptions.verbose.value_or(false); testOptions.printEqualLines = cmdLineOptions.printEqualLines.value_or(false); testOptions.frameStats = cmdLineOptions.frameStats.value_or(false); + // BUTTONS is a sceCtrl mask (hex with 0x), DURATION defaults to 0.15s. + if (cmdLineOptions.pressScript.has_value()) { + std::vector items; + SplitString(cmdLineOptions.pressScript.value(), ',', items); + for (const std::string &item : items) { + std::vector parts; + SplitString(item, ':', parts); + if (parts.size() < 2) { + continue; + } + AutoTestOptions::Press press{ atof(parts[0].c_str()), parts.size() > 2 ? atof(parts[2].c_str()) : 0.15, (u32)strtoul(parts[1].c_str(), nullptr, 0) }; + testOptions.presses.push_back(press); + } + } + if (cmdLineOptions.screenshotAt.has_value()) { + std::vector items; + SplitString(cmdLineOptions.screenshotAt.value(), ',', items); + for (const std::string &item : items) { + const size_t colon = item.find(':'); + if (colon != std::string::npos) { + testOptions.screenshotsAt.emplace_back(atof(item.substr(0, colon).c_str()), Path(item.substr(colon + 1))); + } + } + } testOptions.maxScreenshotError = cmdLineOptions.maxScreenshotError.value_or(0.0); testOptions.requiredDisableHLE = cmdLineOptions.disableHLE.value_or(0); testOptions.debugger = cmdLineOptions.DebuggerPort().has_value();