Headless: forward the program's stdout/stderr to ours by default

Running a homebrew and seeing what it prints is the most basic thing
PPSSPPHeadless does, but sceIoWrite() to fd 1 and 2 only ever went into
the log, so it took -l to see any of it - which turns on every log
channel at debug level and buries the output.

The debug-output listener now gets a channel, and headless writes StdOut
and StdErr straight through to the host's, unmodified. With no listener
(the normal app) the old sanitized Log::Printf line is unchanged.

pspautotests writes exclusively to the "emulator:" devctl channel, so
nothing there moves; --compare and --bench suppress the forwarding along
with the debug channel, keeping test console output as it was.

Also fixes a potential one-byte OOB read in the same path when an
unmapped address clamps validSize to 0 with size > 0.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Henrik RydgårdandClaude Opus 5 committed 2026-09-17 09:59:37 -06:00
1 parent d8a1830587
commit 1dbd1afc20
5 files changed
+77 -20

No files matched your search

+11 -3
View File
@@ -245,22 +245,30 @@ static MIPSExceptionInfo g_exceptionInfo;
// This is called on EmuThread before RunLoop.
static bool Core_ProcessStepping(MIPSDebugInterface *cpu);
static std::function<void(std::string_view)> g_debugOutputListener;
static std::function<void(DebugOutputChannel, std::string_view)> g_debugOutputListener;
static std::function<void(const DebugScreenshotDesc &)> g_debugScreenshotListener;
void Core_RegisterDebugOutputListeners(std::function<void(std::string_view)> listener, std::function<void(const DebugScreenshotDesc &)> screenshotListener) {
void Core_RegisterDebugOutputListeners(std::function<void(DebugOutputChannel, std::string_view)> listener, std::function<void(const DebugScreenshotDesc &)> screenshotListener) {
g_debugOutputListener = std::move(listener);
g_debugScreenshotListener = std::move(screenshotListener);
}
void Core_SendDebugOutput(LogLevel level, std::string_view string) {
if (g_debugOutputListener) {
g_debugOutputListener(string);
g_debugOutputListener(DebugOutputChannel::Debug, string);
} else {
GENERIC_LOG(Log::sceIo, level, "%.*s", STR_VIEW(string));
}
}
bool Core_SendHostOutput(DebugOutputChannel channel, std::string_view string) {
if (!g_debugOutputListener) {
return false;
}
g_debugOutputListener(channel, string);
return true;
}
void Core_SendDebugScreenshot(const DebugScreenshotDesc &desc) {
if (g_debugScreenshotListener) {
g_debugScreenshotListener(desc);
+11 -1
View File
@@ -301,9 +301,19 @@ struct DebugScreenshotDesc {
u32 height;
GEBufferFormat format;
};
// Which of the host's output streams a piece of emulated output belongs on.
enum class DebugOutputChannel {
Debug, // The "emulator:" devctl channel, plus our own messages about the run. This is what pspautotests uses.
StdOut, // sceIoWrite() to fd 1, and to a tty device.
StdErr, // sceIoWrite() to fd 2.
};
void Core_SendDebugOutput(LogLevel level, std::string_view string);
// Offers raw output from the emulated program to the listener, if any. Returns false if there was
// none, in which case the caller should log it instead - that's what the normal app does.
bool Core_SendHostOutput(DebugOutputChannel channel, std::string_view string);
void Core_SendDebugScreenshot(const DebugScreenshotDesc &desc);
void Core_RegisterDebugOutputListeners(std::function<void(std::string_view)> listener, std::function<void(const DebugScreenshotDesc &)> screenshotListener);
void Core_RegisterDebugOutputListeners(std::function<void(DebugOutputChannel, std::string_view)> listener, std::function<void(const DebugScreenshotDesc &)> screenshotListener);
class MIPSState;
// Shortcut, just calls Core_MemoryException with automatically determined parameters (function name, etc).
+17 -11
View File
@@ -1187,6 +1187,20 @@ void SanitizeControlChars(std::string &buf) {
}
}
// Output the emulated program wrote to stdout, stderr or a tty. A host that wants it (headless)
// gets the bytes exactly as written, so its stdout is a faithful copy of the program's. Otherwise
// it goes in the log, sanitized and with the trailing newline trimmed, since the log adds one.
static void __IoWriteToHostOutput(DebugOutputChannel channel, const char *str, u32 validSize, int size, const char *name) {
if (Core_SendHostOutput(channel, std::string_view(str, validSize))) {
return;
}
const int str_size = size <= 0 || validSize == 0 ? 0 : (str[validSize - 1] == '\n' ? validSize - 1 : validSize);
// buffer so we can edit the string.
std::string buf(str, str_size);
SanitizeControlChars(buf);
INFO_LOG(Log::Printf, "%s: %.*s", name, (int)buf.size(), buf.data());
}
static bool __IoWrite(int &result, int id, u32 data_addr, int size, int &us) {
PROFILE_THIS_SCOPE("io_rw");
// Low estimate, may be improved later from the WriteFile result.
@@ -1199,12 +1213,8 @@ static bool __IoWrite(int &result, int id, u32 data_addr, int size, int &us) {
const u32 validSize = Memory::ClampValidSizeAt(data_addr, size);
// Let's handle stdout/stderr specially.
if (id == PSP_STDOUT || id == PSP_STDERR) {
const char *str = (const char *) data_ptr;
const int str_size = size <= 0 ? 0 : (str[validSize - 1] == '\n' ? validSize - 1 : validSize);
// buffer so we can edit the string.
std::string buf(str, str_size);
SanitizeControlChars(buf);
INFO_LOG(Log::Printf, "%s: %.*s", id == 1 ? "stdout" : "stderr", (int)buf.size(), buf.data());
__IoWriteToHostOutput(id == PSP_STDERR ? DebugOutputChannel::StdErr : DebugOutputChannel::StdOut,
(const char *)data_ptr, validSize, size, id == PSP_STDOUT ? "stdout" : "stderr");
result = validSize;
return true;
}
@@ -1228,11 +1238,7 @@ static bool __IoWrite(int &result, int id, u32 data_addr, int size, int &us) {
NotifyMemInfo(MemBlockFlags::READ, data_addr, size, tag.c_str(), tag.size());
if (f->isTTY) {
const char *str = (const char *)data_ptr;
const int str_size = size <= 0 ? 0 : (str[validSize - 1] == '\n' ? validSize - 1 : validSize);
std::string buf(str, str_size);
SanitizeControlChars(buf);
INFO_LOG(Log::Printf, "%s: %.*s", "tty", (int)buf.size(), buf.data());
__IoWriteToHostOutput(DebugOutputChannel::StdOut, (const char *)data_ptr, validSize, size, "tty");
result = validSize;
return true;
}
+21 -3
View File
@@ -85,6 +85,10 @@ static bool g_screenshotFailed = false;
static std::string g_debugOutputBuffer;
static bool g_writeFailureScreenshot = true;
static bool g_writeDebugOutput = true;
// Whether the emulated program's stdout/stderr are forwarded to ours. On by default - just running
// a homebrew and seeing what it prints is the most basic thing headless does. Off for test runs,
// where the only output that should reach the console is what the comparison produces.
static bool g_forwardHostOutput = true;
// Set from the savestate callback on the emu thread, read after it has been joined.
static bool g_stateLoadFailed = false;
// Set by --save-state. Saving needs the game to actually be running, so it happens from the run
@@ -167,7 +171,20 @@ void SetWriteFailureScreenshot(bool flag) {
g_writeFailureScreenshot = flag;
}
void SendDebugOutput(std::string_view output) {
void SendDebugOutput(DebugOutputChannel channel, std::string_view output) {
if (channel != DebugOutputChannel::Debug) {
if (!g_forwardHostOutput)
return;
// Straight through, unmodified - and flushed, so it interleaves with the debug channel in
// the order the program actually wrote it.
FlushDebugOutput();
fflush(stdout);
FILE *stream = channel == DebugOutputChannel::StdErr ? stderr : stdout;
fwrite(output.data(), sizeof(char), output.length(), stream);
fflush(stream);
return;
}
if (!g_writeDebugOutput)
return;
#ifdef _WIN32
@@ -183,7 +200,7 @@ void SendDebugOutput(std::string_view output) {
}
void SendAndCollectOutput(std::string_view output) {
SendDebugOutput(output);
SendDebugOutput(DebugOutputChannel::Debug, output);
if (PSP_CoreParameter().collectDebugOutput) {
*PSP_CoreParameter().collectDebugOutput += output;
}
@@ -397,7 +414,7 @@ static bool RunAutoTest(GraphicsContext *graphicsContext, CoreParameter &corePar
if (!opt.bench) {
printf("%s", output.c_str());
SendDebugOutput("TIMEOUT\n");
SendDebugOutput(DebugOutputChannel::Debug, "TIMEOUT\n");
GitHubActionsPrint("error", "Test timeout for %s", currentTestName.c_str());
}
@@ -971,6 +988,7 @@ int main(int argc, const char* argv[]) {
SetWriteFailureScreenshot(!getenv("GITHUB_ACTIONS") && !testOptions.bench);
g_writeDebugOutput = !testOptions.compare && !testOptions.bench;
g_forwardHostOutput = !testOptions.compare && !testOptions.bench;
#if PPSSPP_PLATFORM(ANDROID)
// For some reason the debugger installs it with this name?
+17 -2
View File
@@ -1,6 +1,6 @@
# PPSSPPHeadless
Non-interactive, headless build of PPSSPP. It boots a PSP executable, PRX, or GE frame dump (`.ppdmp`) without a GUI, outputs emulated debug text to the console, optionally captures and compares text output or screenshots, and exits.
Non-interactive, headless build of PPSSPP. It boots a PSP executable, PRX, or GE frame dump (`.ppdmp`) without a GUI, forwards what the program prints to the console, optionally captures and compares text output or screenshots, and exits.
Primarily intended for:
- Automated regression testing (via [pspautotests](https://github.com/hrydgard/pspautotests/))
@@ -39,7 +39,7 @@ PPSSPPHeadless file.elf|file.prx|file.ppdmp [...] [options]
| `@file` | Read list of test filenames from a text file (`@-` for stdin). |
| `-m`, `--mount <file.cso>` | Mount an ISO/CSO on `umd1:`. |
| `-r`, `--root <path>` | Mount a path on `host0:` (ELF/PRX files must be under this). |
| `-l`, `--log` | Full emulator log output (not just emulated `printf`). |
| `-l`, `--log` | Full emulator log output, on top of the program's own output (see Program Output below). |
| `-o`, `--odslog` | Write log to `OutputDebugString` (Windows only). |
| `--graphics=<backend>` | GPU backend: `software`, `gles`, `directx11`, `vulkan`. |
| `--screenshot=<file>` | Compare the rendered output against a reference screenshot. |
@@ -60,6 +60,21 @@ PPSSPPHeadless file.elf|file.prx|file.ppdmp [...] [options]
| `--ignore <file>` | Skip the specified test file. |
| `--help` / `-h` | Show usage information. |
## Program Output
Running a homebrew with no options at all prints what it prints, and nothing else. Two separate
channels feed the console, both on by default:
- **The emulated program's `stdout` and `stderr`** - `sceIoWrite()` to fd 1 and 2 (which is where a
PSPSDK `printf()` ends up), and writes to a tty device. These go to *our* `stdout` and `stderr`
respectively, byte for byte, with no prefix or sanitization.
- **The `emulator:` devctl channel** - `sceIoDevctl("emulator:", SEND_OUTPUT, ...)`, a PPSSPP
extension. This goes to `stdout`. pspautotests uses this channel exclusively, and it is what
`--compare` compares against the `.expected` file.
`--compare` and `--bench` turn both off, so the only thing a test run prints is the comparison
result. Emulator log output is separate again, and stays off unless you pass `-l`.
## GPU Backends
The `--graphics` option selects the rendering backend: