From 280ae098568ee3fa7fcff81b2cc97eb770d45c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 28 Sep 2026 18:43:14 -0600 Subject: [PATCH 1/7] sceUsbCam: Make sceUsbCamReadVideoFrameBlocking wait for the next frame It returned at once. Go!Edit reads frames in a loop on a high-priority thread (bhCameraGetJpeg) that only yields to its own priority level, so after the "Loading complete" dialog it spun and starved the rest of the game. Return at the camera's next frame tick, at the rate from the setup params. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/HLE/sceUsbCam.cpp | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Core/HLE/sceUsbCam.cpp b/Core/HLE/sceUsbCam.cpp index 608a0edf60..3ec2270250 100644 --- a/Core/HLE/sceUsbCam.cpp +++ b/Core/HLE/sceUsbCam.cpp @@ -27,6 +27,7 @@ #include "Core/HLE/HLE.h" #include "Core/HLE/sceUsbCam.h" #include "Core/HLE/sceUsbMic.h" +#include "Core/CoreTiming.h" #include "Core/HW/Camera.h" #include "Core/MemMapHelpers.h" @@ -43,6 +44,8 @@ Camera::Config *config; unsigned int videoBufferLength = 0; unsigned int nextVideoFrame = 0; +// When video capture started, which the frame clock counts from. Not saved in states, it only sets the phase. +static u64 videoStartUs = 0; uint8_t *videoBuffer; std::mutex videoBufferMutex; @@ -206,6 +209,7 @@ static int sceUsbCamStartVideo() { jpegData = nullptr; } + videoStartUs = CoreTiming::GetGlobalTimeUs(); Camera::startCapture(); return 0; } @@ -215,13 +219,29 @@ static int sceUsbCamStopVideo() { return 0; } +// How often the camera delivers a frame, from the framerate in the setup params +// (PSPSDK's PSP_USBCAM_FRAMERATE_*: 3.75, 5, 7.5, 10, 15, 20, 30 and 60 fps). +static int getFrameIntervalUs() { + static const int intervalsUs[] = { 266667, 200000, 133333, 100000, 66667, 50000, 33333, 16667 }; + int framerate = config->type == Camera::ConfigType::CfVideoEx ? config->videoExParam.framerate : config->videoParam.framerate; + if (framerate < 0 || framerate >= (int)ARRAY_SIZE(intervalsUs)) { + framerate = 6; // 30 fps + } + return intervalsUs[framerate]; +} + static int sceUsbCamReadVideoFrameBlocking(u32 bufAddr, u32 size) { std::lock_guard lock(videoBufferMutex); u32 transferSize = std::min(videoBufferLength, size); if (Memory::IsValidRange(bufAddr, size)) { Memory::Memcpy(bufAddr, videoBuffer, transferSize); } - return transferSize; + // This blocks until the camera's next frame. Returning at once lets a high-priority capture thread + // (Go!Edit's bhCameraGetJpeg) spin in its read loop and starve the rest of the game. + const int intervalUs = getFrameIntervalUs(); + const u64 sinceStartUs = CoreTiming::GetGlobalTimeUs() - videoStartUs; + const int waitUs = intervalUs - (int)(sinceStartUs % intervalUs); + return hleDelayResult(hleLogDebug(Log::HLE, transferSize), "camera frame", waitUs); } static int sceUsbCamReadVideoFrame(u32 bufAddr, u32 size) { From 4504092d3da28e6b636c9fba8d0dcd5061a90f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 28 Sep 2026 18:43:16 -0600 Subject: [PATCH 2/7] sceUsbMic: Don't crash when there's no Windows capture device object Only the app creates winMic, so a blocking mic read crashed headless. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/HLE/sceUsbMic.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Core/HLE/sceUsbMic.cpp b/Core/HLE/sceUsbMic.cpp index 33c838aee9..00acebf3de 100644 --- a/Core/HLE/sceUsbMic.cpp +++ b/Core/HLE/sceUsbMic.cpp @@ -346,7 +346,8 @@ int Microphone::stopMic() { bool Microphone::isHaveDevice() { #ifdef HAVE_WIN32_MICROPHONE - return winMic->getDeviceCounts() >= 1; + // Only the app creates winMic, headless doesn't. + return winMic && winMic->getDeviceCounts() >= 1; #elif PPSSPP_PLATFORM(ANDROID) return System_AudioRecordingIsAvailable(); #endif From 2a5febd63484d660eafd2c95562665763583c761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 28 Sep 2026 18:43:17 -0600 Subject: [PATCH 3/7] wsdbg: Add :screenshot, and document nested key=value params :screenshot saves gpu.buffer.screenshot as a PNG without dumping the data URI into the output. The docs claimed nested parameters need a raw JSON line, which gets no ticket; a single-quoted JSON value in key=value form works and keeps it. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tools/wsdbg/README.md | 7 +++-- Tools/wsdbg/src/main.rs | 69 +++++++++++++++++++++++++++++++++++++---- docs/debugging.md | 15 +++++---- 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/Tools/wsdbg/README.md b/Tools/wsdbg/README.md index ebc2f29739..e51eaf9486 100644 --- a/Tools/wsdbg/README.md +++ b/Tools/wsdbg/README.md @@ -90,8 +90,10 @@ trips would otherwise hang the script forever, so it gives up after `--sync-time (default 30), reports it, and makes the run exit non-zero. Matching is by ticket, always - `--sync` never waits for "whatever message arrives next", which is -what used to quietly desynchronise a script. A raw JSON line (the only way to send nested -parameters) is sent exactly as written, so it's waited for only if *you* gave it a `ticket`; +what used to quietly desynchronise a script. So prefer the `key=value` form for nested parameters +too: values are parsed as JSON, and single quotes keep the inner double quotes intact, as in +`input.buttons.send buttons='{"cross":true}'`. A raw JSON line is sent exactly as written, so it's +waited for only if *you* gave it a `ticket`; without one there is nothing to match and `--sync` moves straight on to the next line. Raw lines are rejected up front, rather than sent and left to fail somewhere downstream, if they aren't valid JSON, aren't an object, have no string `event`, or carry a `ticket` that isn't an integer. @@ -115,6 +117,7 @@ way took minutes per run. These run inside the one session instead: |---|---| | `:sleep ` | Wall-clock pause. Keeps draining and printing messages while it waits. | | `:wait [timeout]` | Blocks until a message with that event name arrives. Exits non-zero if it never does. | +| `:screenshot ` | Saves `gpu.buffer.screenshot` as a PNG, without printing the image data. Needs the CPU stopped. Use a native path (`C:/...`) on Windows. With headless, use `--graphics=software`: the Vulkan backend has no output image to read back there. | | `:echo ` | Prints text, for marking up a script's output. | | `# comment` | Ignored. | diff --git a/Tools/wsdbg/src/main.rs b/Tools/wsdbg/src/main.rs index 17ac1bc1e6..9ab7da7416 100644 --- a/Tools/wsdbg/src/main.rs +++ b/Tools/wsdbg/src/main.rs @@ -528,6 +528,8 @@ fn print_help() { println!("wsdbg - connected. Type an event name and optional key=value params, e.g.:"); println!(" game.status"); println!(" cpu.setReg thread=0 name=4 value=1000"); + println!("Values are parsed as JSON where possible. Single-quote nested ones, which keeps the ticket:"); + println!(" input.buttons.send buttons='{{\"cross\":true}}'"); println!("Or paste a full JSON message starting with '{{' to send it verbatim."); println!("A numeric 'ticket' is auto-assigned to shorthand commands so you can match up responses."); println!(":help show this message"); @@ -535,6 +537,7 @@ fn print_help() { println!(":snapshot memory.read into a locally-named byte buffer"); println!(":snapshots list saved snapshots"); println!(":diff byte-compare two snapshots"); + println!(":screenshot save gpu.buffer.screenshot to a PNG file"); println!(":sleep pause, still printing anything that arrives"); println!(":wait [timeout] block until that event arrives (e.g. cpu.stepping)"); println!(":echo print text, for marking up a script's output"); @@ -678,6 +681,7 @@ fn send_and_wait( event: &str, params: &[String], timeout_secs: f64, + print_response: bool, ) -> Result { let ticket = next_ticket(); let json_text = build_event_json(event, params, Some(ticket))?; @@ -688,11 +692,13 @@ fn send_and_wait( while Instant::now() < deadline { match socket.read() { Ok(Message::Text(text)) => { - print_incoming(&text); - if let Ok(v) = serde_json::from_str::(&text) { - if v.get("ticket").and_then(|t| t.as_u64()) == Some(ticket) { - return Ok(v); - } + let v = serde_json::from_str::(&text).ok(); + let is_ours = v.as_ref().and_then(|v| v.get("ticket")).and_then(|t| t.as_u64()) == Some(ticket); + if !is_ours || print_response { + print_incoming(&text); + } + if is_ours { + return Ok(v.unwrap()); } } Ok(Message::Close(frame)) => return Err(anyhow!("connection closed by PPSSPP: {frame:?}")), @@ -724,7 +730,7 @@ fn cmd_snapshot(socket: &mut WebSocket, snapshots: &mut Snapshots, ar } let name = args[0]; let params = vec![format!("address={}", args[1]), format!("size={}", args[2])]; - match send_and_wait(socket, "memory.read", ¶ms, timeout_secs) { + match send_and_wait(socket, "memory.read", ¶ms, timeout_secs, true) { Ok(resp) => { if resp.get("event").and_then(|e| e.as_str()) == Some("error") { let msg = resp.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error"); @@ -751,6 +757,51 @@ fn cmd_snapshot(socket: &mut WebSocket, snapshots: &mut Snapshots, ar } } +// :screenshot - saves what gpu.buffer.screenshot returns as a PNG file. The response +// (a data: URI of the whole image) isn't printed, it would bury everything else in the output. +fn cmd_screenshot(socket: &mut WebSocket, args: &[&str], timeout_secs: f64) -> bool { + if args.len() != 1 { + eprintln!("! Usage: :screenshot "); + return false; + } + let path = args[0]; + let resp = match send_and_wait(socket, "gpu.buffer.screenshot", &["type=uri".to_string()], timeout_secs, false) { + Ok(resp) => resp, + Err(e) => { + eprintln!("! {e}"); + return false; + } + }; + if resp.get("event").and_then(|e| e.as_str()) == Some("error") { + let msg = resp.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error"); + eprintln!("! gpu.buffer.screenshot failed: {msg}"); + return false; + } + let uri = resp.get("uri").and_then(|u| u.as_str()).unwrap_or(""); + let b64 = match uri.split_once(";base64,") { + Some((_, b)) => b, + None => { + eprintln!("! Response had no base64 data: URI"); + return false; + } + }; + let bytes = match base64::engine::general_purpose::STANDARD.decode(b64) { + Ok(b) => b, + Err(e) => { + eprintln!("! Could not decode the image: {e}"); + return false; + } + }; + if let Err(e) = std::fs::write(path, &bytes) { + eprintln!("! Could not write {path}: {e}"); + return false; + } + let w = resp.get("width").and_then(|w| w.as_u64()).unwrap_or(0); + let h = resp.get("height").and_then(|h| h.as_u64()).unwrap_or(0); + println!("screenshot saved: {path} ({w}x{h})"); + true +} + // :snapshots - list what's been captured so far in this session. fn cmd_list_snapshots(snapshots: &Snapshots) { if snapshots.is_empty() { @@ -914,6 +965,12 @@ fn run_repl(mut socket: WebSocket, sync: bool, sync_timeout: f64) -> cmd_snapshot(&mut socket, &mut snapshots, &args, sync_timeout); } Some(":snapshots") => cmd_list_snapshots(&snapshots), + Some(":screenshot") => { + let args: Vec<&str> = words.collect(); + if !cmd_screenshot(&mut socket, &args, sync_timeout) { + failed = true; + } + } Some(":diff") => { let args: Vec<&str> = words.collect(); cmd_diff(&snapshots, &args); diff --git a/docs/debugging.md b/docs/debugging.md index 81168851ff..f18806a7b0 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -96,16 +96,19 @@ A working invocation, and the traps around it: - Some events deliberately never respond while the CPU is stepping, so `--sync` will burn its full timeout on them: `gpu.stats.get` and `gpu.stats.feed` (documented - they answer after the next flip), `gpu.record.dump`, and `input.buttons.press` (waits for N frames). Resume the CPU first, or skip them in scripted runs. -- Log broadcasts drown scripted output. Send this first: - `{"event":"broadcast.config.set","disallowed":{"logger":true,"input":true}}`. Note `wsdbg`'s `key=value` shorthand - can't build nested objects - paste raw JSON lines (any line starting with `{` is sent verbatim) for those. +- Log broadcasts drown scripted output. Pass `--quiet` to wsdbg, which turns them off. +- **Nested parameters work in wsdbg's `key=value` shorthand**: values are parsed as JSON, and single quotes keep + the inner double quotes, e.g. `input.buttons.send buttons='{"cross":true}'`. Prefer that over a raw JSON line, + which gets no ticket (see below). - Keep wsdbg scripts in files and pipe them in, rather than building JSON inline in a shell command - inline `{"event":...}` in a bash heredoc trips Claude Code's command analyzer ("brace with quote character") and forces a manual approval prompt for every single invocation. - **`--sync` can only match a response to a request that carries a ticket**, and wsdbg only assigns tickets to its - `key=value` shorthand. A raw JSON line (needed for nested params) gets no ticket, so `--sync` just waits for the - next message and treats whatever broadcast arrives first as the answer, silently desynchronising the rest of the - script. Use the shorthand wherever the parameters are flat. Hex works there: `memory.disasm address=0x08804000`. + `key=value` shorthand. A raw JSON line without a `ticket` isn't waited for at all, so use the shorthand (nested + values included, see above). Hex works there: `memory.disasm address=0x08804000`. +- **To see the screen from a script, use wsdbg's `:screenshot `** while the CPU is stopped, e.g. after a + `cpu.runUntilTime`. With headless, run `--graphics=software` for it: headless Vulkan has no output image to read + back and asserts. Give a native path on Windows (`C:/...`, not `/c/...`). - **Headless reports `SYSPROP_HAS_DEBUGGER` as false** (only `Windows/main.cpp` implements it), so anything gated on it does nothing there - `LoadSymbolsIfSupported()` in `Core/System.cpp`, for instance, doesn't load `.ppmap`/`.sym` at all under headless. Gate new debugger-adjacent features on their own config flag, not on that property. From 948062907b16dc3305da1b4f8f4c00ec786d7586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 29 Sep 2026 11:44:10 -0600 Subject: [PATCH 4/7] sceUsbMic: Complete blocking reads when the samples are due With a host microphone present, a blocking read waited until the host had delivered all the data. If it never did, the thread waited forever: Go!Edit's sound thread stalled that way and its video recording never advanced. The PSP mic streams in real time, so wake at the scheduled time and fill what the host didn't deliver with silence. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/HLE/sceUsbMic.cpp | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/Core/HLE/sceUsbMic.cpp b/Core/HLE/sceUsbMic.cpp index 00acebf3de..3890f48822 100644 --- a/Core/HLE/sceUsbMic.cpp +++ b/Core/HLE/sceUsbMic.cpp @@ -69,16 +69,22 @@ static void __MicBlockingResume(u64 userdata, int cyclesLate) { } if (Microphone::isHaveDevice()) { - if (Microphone::getReadMicDataLength() >= iter->needSize) { - u32 ret = __KernelGetWaitValue(threadID, error); - DEBUG_LOG(Log::HLE, "sceUsbMic: Waking up thread(%d)", (int)iter->threadID); - __KernelResumeThreadFromWait(threadID, ret); - iter = waitingThreads.erase(iter); - } else { - u64 waitTimeus = (iter->needSize - Microphone::getReadMicDataLength()) * 1000000 / 2 / iter->sampleRate; - CoreTiming::ScheduleEvent(usToCycles(waitTimeus), eventMicBlockingResume, userdata); - iter++; + // The PSP's mic delivers in real time, so the read completes when the samples are due in + // emulated time. Waiting for the host instead hangs the game if its mic never delivers + // (Go!Edit's recording stalled that way), so fill what's missing with silence. + const u32 needSize = (u32)iter->needSize; + const u32 have = std::min((u32)Microphone::getReadMicDataLength(), needSize); + if (have < needSize) { + DEBUG_LOG(Log::HLE, "sceUsbMic: host mic only delivered %d of %d bytes, padding with silence", have, needSize); + if (Memory::IsValidRange(iter->addr + have, needSize - have)) { + Memory::Memset(iter->addr + have, 0, needSize - have, "MicSilence"); + } + readMicDataLength = needSize; } + u32 ret = __KernelGetWaitValue(threadID, error); + DEBUG_LOG(Log::HLE, "sceUsbMic: Waking up thread(%d)", (int)iter->threadID); + __KernelResumeThreadFromWait(threadID, ret); + iter = waitingThreads.erase(iter); } else { for (int i = 0; i < iter->needSize; i++) { if (Memory::IsValidAddress(iter->addr + i)) { From 9d33e828ab2c36f21132678d3416d0bf685825b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 29 Sep 2026 11:44:10 -0600 Subject: [PATCH 5/7] Camera: Compress webcam frames to fit the game's framesize The PSP camera keeps JPEG frames within the framesize from the video setup. Go!Edit stores frames in 15KB slots and only takes frames that fit, so our uncompressed-quality webcam frames were dropped and one frame got repeated for the whole clip. Lower the JPEG quality until a frame fits. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/HLE/sceUsbCam.cpp | 15 +++++++++++++++ Core/HLE/sceUsbCam.h | 3 +++ Windows/CaptureDevice.cpp | 24 +++++++++++++++++------- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Core/HLE/sceUsbCam.cpp b/Core/HLE/sceUsbCam.cpp index 3ec2270250..e506d21549 100644 --- a/Core/HLE/sceUsbCam.cpp +++ b/Core/HLE/sceUsbCam.cpp @@ -418,6 +418,21 @@ void Camera::onCameraDeviceChange() { } } +int Camera::getMaxFrameSize() { + int framesize = 0; + if (config) { + if (config->type == Camera::ConfigType::CfVideoEx) { + framesize = config->videoExParam.framesize; + } else if (config->type == Camera::ConfigType::CfVideo) { + framesize = config->videoParam.framesize; + } + } + if (framesize <= 0 || framesize > VIDEO_BUFFER_SIZE) { + return VIDEO_BUFFER_SIZE; + } + return framesize; +} + void Camera::pushCameraImage(long long length, unsigned char* image) { std::lock_guard lock(videoBufferMutex); if (!videoBuffer) { diff --git a/Core/HLE/sceUsbCam.h b/Core/HLE/sceUsbCam.h index 4d4e4f0831..d356dbc8a4 100644 --- a/Core/HLE/sceUsbCam.h +++ b/Core/HLE/sceUsbCam.h @@ -121,4 +121,7 @@ namespace Camera { int startCapture(); int stopCapture(); void pushCameraImage(long long length, unsigned char *image); + // The largest JPEG frame the game accepts (framesize in the video setup). The PSP camera compresses + // to fit it, so capture code should too. + int getMaxFrameSize(); } diff --git a/Windows/CaptureDevice.cpp b/Windows/CaptureDevice.cpp index 666cb6542d..c2b2d069a0 100644 --- a/Windows/CaptureDevice.cpp +++ b/Windows/CaptureDevice.cpp @@ -247,13 +247,23 @@ HRESULT ReaderCallback::OnReadSample( } } - // Compress image to jpeg from RGB24. - jpge::compress_image_to_jpeg_file_in_memory( - device->imageJpeg, imgJpegSize, - dstW, - dstH, - 3, - device->imageRGB); + // Compress image to jpeg from RGB24. Like the PSP camera, lower the quality until the frame + // fits the size the game asked for: Go!Edit stores frames in fixed 15KB slots. + const int maxFrameSize = Camera::getMaxFrameSize(); + jpge::params params; + for (int quality = 85; ; quality -= 10) { + imgJpegSize = device->imgJpegSize; + params.m_quality = quality; + jpge::compress_image_to_jpeg_file_in_memory( + device->imageJpeg, imgJpegSize, + dstW, + dstH, + 3, + device->imageRGB, params); + if (imgJpegSize <= maxFrameSize || quality <= 15) { + break; + } + } } #endif Camera::pushCameraImage(imgJpegSize, device->imageJpeg); From 07556d064edc6bcef2f6b82f6bd36d2f3935e951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 29 Sep 2026 12:24:40 -0600 Subject: [PATCH 6/7] Camera: Recompress oversized frames on every platform Android, iOS, macOS and Linux encode camera frames at a fixed quality, so a detailed frame can exceed the game's framesize just as on Windows before. pushCameraImage now decodes such a frame and re-encodes it at lower quality until it fits. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/HLE/sceUsbCam.cpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Core/HLE/sceUsbCam.cpp b/Core/HLE/sceUsbCam.cpp index e506d21549..87f91ddbc0 100644 --- a/Core/HLE/sceUsbCam.cpp +++ b/Core/HLE/sceUsbCam.cpp @@ -17,6 +17,7 @@ #include #include +#include #include "ppsspp_config.h" @@ -30,6 +31,8 @@ #include "Core/CoreTiming.h" #include "Core/HW/Camera.h" #include "Core/MemMapHelpers.h" +#include "ext/jpge/jpgd.h" +#include "ext/jpge/jpge.h" #if defined(_WIN32) && !PPSSPP_PLATFORM(UWP) && !defined(__LIBRETRO__) #define HAVE_WIN32_CAMERA @@ -433,7 +436,38 @@ int Camera::getMaxFrameSize() { return framesize; } +// Re-encodes a frame at lower quality until it fits maxSize, like the PSP camera compresses to the +// game's framesize. Most platforms' capture code encodes at a fixed quality, so this is the common fallback. +static bool RecompressToFit(const unsigned char *image, long long length, int maxSize, std::vector *out) { + int width = 0, height = 0, comps = 0; + unsigned char *rgb = jpgd::decompress_jpeg_image_from_memory(image, (int)length, &width, &height, &comps, 3); + if (!rgb) { + return false; + } + bool fits = false; + out->resize(width * height * 3 + 1024); + jpge::params params; + for (int quality = 70; quality >= 10; quality -= 15) { + int size = (int)out->size(); + params.m_quality = quality; + if (jpge::compress_image_to_jpeg_file_in_memory(out->data(), size, width, height, 3, rgb, params) && size <= maxSize) { + out->resize(size); + fits = true; + break; + } + } + free(rgb); + return fits; +} + void Camera::pushCameraImage(long long length, unsigned char* image) { + std::vector recompressed; + const int maxSize = getMaxFrameSize(); + if (length > maxSize && RecompressToFit(image, length, maxSize, &recompressed)) { + image = recompressed.data(); + length = (long long)recompressed.size(); + } + std::lock_guard lock(videoBufferMutex); if (!videoBuffer) { return; From 9e54cebcc21a5c0630bbcd9d507e5ba4fb6259ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 29 Sep 2026 12:29:04 -0600 Subject: [PATCH 7/7] Camera: Remember the JPEG quality that fit the last frame Start from the quality that fit the previous frame instead of the top, so most frames encode once. Step down while a frame is too big, and step back up when one comes out under half the limit. Windows and the recompression fallback share the logic. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/HLE/sceUsbCam.cpp | 45 ++++++++++++++++++++++++++++----------- Core/HLE/sceUsbCam.h | 4 ++++ Windows/CaptureDevice.cpp | 25 +++++++++------------- 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/Core/HLE/sceUsbCam.cpp b/Core/HLE/sceUsbCam.cpp index 87f91ddbc0..4ba4a09326 100644 --- a/Core/HLE/sceUsbCam.cpp +++ b/Core/HLE/sceUsbCam.cpp @@ -16,6 +16,7 @@ // https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. #include +#include #include #include @@ -436,28 +437,46 @@ int Camera::getMaxFrameSize() { return framesize; } -// Re-encodes a frame at lower quality until it fits maxSize, like the PSP camera compresses to the -// game's framesize. Most platforms' capture code encodes at a fixed quality, so this is the common fallback. +// The JPEG quality that fit the last frame. Frames of one scene are similar in size, so it's usually +// right first time. Only the capture thread uses it, but atomic in case a platform has several. +static std::atomic g_jpegQuality{ 80 }; + +int Camera::encodeToFit(int maxSize, const std::function &encode) { + int quality = g_jpegQuality; + int size = encode(quality); + while ((size < 0 || size > maxSize) && quality > 10) { + quality = std::max(10, quality - 10); + size = encode(quality); + } + // Way under the limit: try a better quality next time. + if (size >= 0 && size < maxSize / 2 && quality < 90) { + quality += 10; + } + g_jpegQuality = quality; + return size; +} + +// Re-encodes a frame until it fits maxSize, like the PSP camera compresses to the game's framesize. +// Most platforms' capture code encodes at a fixed quality, so this is the common fallback. static bool RecompressToFit(const unsigned char *image, long long length, int maxSize, std::vector *out) { int width = 0, height = 0, comps = 0; unsigned char *rgb = jpgd::decompress_jpeg_image_from_memory(image, (int)length, &width, &height, &comps, 3); if (!rgb) { return false; } - bool fits = false; out->resize(width * height * 3 + 1024); - jpge::params params; - for (int quality = 70; quality >= 10; quality -= 15) { - int size = (int)out->size(); + int size = Camera::encodeToFit(maxSize, [&](int quality) { + jpge::params params; params.m_quality = quality; - if (jpge::compress_image_to_jpeg_file_in_memory(out->data(), size, width, height, 3, rgb, params) && size <= maxSize) { - out->resize(size); - fits = true; - break; - } - } + int outSize = (int)out->size(); + return jpge::compress_image_to_jpeg_file_in_memory(out->data(), outSize, width, height, 3, rgb, params) ? outSize : -1; + }); free(rgb); - return fits; + if (size < 0 || size > maxSize) { + return false; + } + out->resize(size); + return true; } void Camera::pushCameraImage(long long length, unsigned char* image) { diff --git a/Core/HLE/sceUsbCam.h b/Core/HLE/sceUsbCam.h index d356dbc8a4..a5db932bc6 100644 --- a/Core/HLE/sceUsbCam.h +++ b/Core/HLE/sceUsbCam.h @@ -18,6 +18,7 @@ #pragma once #include +#include #include #include #include "Core/HLE/FunctionWrappers.h" @@ -124,4 +125,7 @@ namespace Camera { // The largest JPEG frame the game accepts (framesize in the video setup). The PSP camera compresses // to fit it, so capture code should too. int getMaxFrameSize(); + // Calls encode(quality), which returns the JPEG size or -1, starting from the quality that fit the + // previous frame and going down until the result fits maxSize. Returns the final size (or -1). + int encodeToFit(int maxSize, const std::function &encode); } diff --git a/Windows/CaptureDevice.cpp b/Windows/CaptureDevice.cpp index c2b2d069a0..64f73bdbd1 100644 --- a/Windows/CaptureDevice.cpp +++ b/Windows/CaptureDevice.cpp @@ -247,22 +247,17 @@ HRESULT ReaderCallback::OnReadSample( } } - // Compress image to jpeg from RGB24. Like the PSP camera, lower the quality until the frame - // fits the size the game asked for: Go!Edit stores frames in fixed 15KB slots. - const int maxFrameSize = Camera::getMaxFrameSize(); - jpge::params params; - for (int quality = 85; ; quality -= 10) { - imgJpegSize = device->imgJpegSize; + // Compress image to jpeg from RGB24. Like the PSP camera, fit the size the game asked for: + // Go!Edit stores frames in fixed 15KB slots. + imgJpegSize = Camera::encodeToFit(Camera::getMaxFrameSize(), [&](int quality) { + jpge::params params; params.m_quality = quality; - jpge::compress_image_to_jpeg_file_in_memory( - device->imageJpeg, imgJpegSize, - dstW, - dstH, - 3, - device->imageRGB, params); - if (imgJpegSize <= maxFrameSize || quality <= 15) { - break; - } + int size = device->imgJpegSize; + return jpge::compress_image_to_jpeg_file_in_memory( + device->imageJpeg, size, dstW, dstH, 3, device->imageRGB, params) ? size : -1; + }); + if (imgJpegSize < 0) { + imgJpegSize = 0; } } #endif