From 45569a7aff1445baed03deb7053fc4042df66c00 Mon Sep 17 00:00:00 2001
From: RINNECODER <93304526+RINNECODER@users.noreply.github.com>
Date: Wed, 14 Oct 2026 18:09:57 -0500
Subject: [PATCH 1/3] tools: AGC frame capture and replay benchmark
ANYPS5_AGC_CAPTURE=
records one frame the Metal AGC driver executes:
the pre-frame guest memory (resident GPU-visible ranges, LZ4 chunks), each
queue's state, every submission with its shader registry and the guest
pages that changed before it ran, and the scanout pixels the game showed.
Hooks sit at the submission boundary (queue worker loop) and in Present.
anyps5_agc_replay maps the capture at its guest addresses, feeds the
recorded command buffers through the same driver K times and reports worker
CPU ms, worker instructions, wall ms, GPU busy ms and the image hash, with
--expect-hash / --expect-png regression checks and --dump-png.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01DgW4n3qYgi5jAAohEmXRQj
---
cmake/RosettaGraphics.cmake | 16 +
.../Execution/Metal/AgcFrameCapture.hpp | 157 ++++
.../Execution/Metal/AgcFrameCapture.mm | 573 +++++++++++++++
.../Execution/Metal/DirectDriver.hpp | 3 +
.../Execution/Metal/DirectDriver.mm | 8 +-
.../Execution/Metal/MetalDriver.mm | 27 +
.../include/Driver/Queues/Submission.hpp | 2 +
.../Execution/include/MetalDriver.hpp | 7 +
.../Graphics/Metal/GpuBusy.hpp | 44 ++
.../Graphics/Metal/MetalDevice.mm | 4 +
.../recompiler/MetalReplay/CMakeLists.txt | 1 +
tools/agc-replay/AgcReplay.mm | 690 ++++++++++++++++++
12 files changed, 1530 insertions(+), 2 deletions(-)
create mode 100644 core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.hpp
create mode 100644 core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.mm
create mode 100644 core/libs/prx/libSceAgcDriver/Graphics/Metal/GpuBusy.hpp
create mode 100644 tools/agc-replay/AgcReplay.mm
diff --git a/cmake/RosettaGraphics.cmake b/cmake/RosettaGraphics.cmake
index 64d54774f9a4..d44eda21d832 100644
--- a/cmake/RosettaGraphics.cmake
+++ b/cmake/RosettaGraphics.cmake
@@ -63,6 +63,7 @@ add_library(libSceAgcDriver SHARED EXCLUDE_FROM_ALL
${rosettaAgcDriver}/Execution/Metal/MetalDriver.mm
${rosettaAgcDriver}/Execution/Metal/MetalPacketExecution.mm
${rosettaAgcDriver}/Execution/Metal/DirectDriver.mm
+ ${rosettaAgcDriver}/Execution/Metal/AgcFrameCapture.mm
${rosettaAgcDriver}/Graphics/src/ComputeStageInfo.cpp
${rosettaAgcDriver}/Graphics/src/State.cpp
${rosettaAgcDriver}/Graphics/src/SurfaceMetadata.cpp
@@ -132,6 +133,21 @@ add_custom_command(
VERBATIM)
add_custom_target(rosetta_metallib DEPENDS ${PATCHED_DIR}/AnyPS5Utilities.metallib)
+# Frame-capture replay benchmark (tools/agc-replay): replays a frame captured with
+# ANYPS5_AGC_CAPTURE through this libSceAgcDriver, under Rosetta like the game.
+add_executable(anyps5_agc_replay ${CMAKE_SOURCE_DIR}/tools/agc-replay/AgcReplay.mm)
+target_compile_options(anyps5_agc_replay PRIVATE -fobjc-arc)
+target_compile_features(anyps5_agc_replay PRIVATE cxx_std_20)
+target_compile_definitions(anyps5_agc_replay PRIVATE ANYPS5_METAL_GUEST_REPLAY=1 ANYPS5_METAL_BACKEND=1 ANYPS5_ROSETTA_DIRECT=1
+ ANYPS5_AGC_REPLAY_METALLIB="${PATCHED_DIR}/AnyPS5Utilities.metallib")
+target_include_directories(anyps5_agc_replay PRIVATE ${ANYPS5_ROSETTA_LIBS_ROOT} ${ANYPS5_VULKAN_HEADERS_INCLUDE_DIR}
+ $)
+target_link_libraries(anyps5_agc_replay PRIVATE libSceAgcDriver libkernel libc "-framework Metal" "-framework Foundation"
+ "-framework CoreGraphics" "-framework ImageIO")
+set_target_properties(anyps5_agc_replay PROPERTIES CXX_EXTENSIONS OFF BUILD_RPATH ${UNPATCHED_DIR}
+ RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/rosetta/bin)
+add_dependencies(anyps5_agc_replay rosetta_metallib)
+
function(anyps5_rosetta_graphics_tests)
if(NOT ANYPS5_ROSETTA_BUILD_TESTING)
return()
diff --git a/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.hpp b/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.hpp
new file mode 100644
index 000000000000..29aa5a8f88cf
--- /dev/null
+++ b/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.hpp
@@ -0,0 +1,157 @@
+#pragma once
+
+// Frame capture and replay for the Metal AGC driver (fork-only; tools/agc-replay).
+//
+// Capture (off unless ANYPS5_AGC_CAPTURE= is set): after the queue workers have executed
+// flip ANYPS5_AGC_CAPTURE_FLIP - 1 (default 600; ANYPS5_AGC_CAPTURE_AFTER_S= captures the
+// first flip executed after that long since the driver loaded instead), the worker that ran that flip drains the GPU and
+// writes /frame.agc:
+// - every borrowed guest range the driver sees (GPU-visible guest memory plus its staging range)
+// and the contents of its resident, non-zero 1 MiB chunks, compressed: the pre-frame state;
+// - the queue state (registers, constant RAM, index and indirect bases, predication) of each
+// queue at its first recorded submission;
+// - every submission the workers execute until flip N + ANYPS5_AGC_CAPTURE_FRAMES - 1, in
+// execution order: queue, command address and words, the shader registry it was submitted
+// with, and the 64 KiB guest pages that changed since the previous submission (taken just
+// before it executes, with the GPU drained), each with the hash of its previous contents;
+// - for each captured flip, the display buffer the next present scanned out and its pixels
+// (the frame the game showed), also written as /game-.png.
+// The hooks sit at the submission boundary (queue worker loop) and in Present; they never touch
+// the per-draw path. Capturing stalls the game for the snapshot (seconds) once.
+//
+// Replay (anyps5_agc_replay) maps the ranges at their guest addresses, submits the recorded
+// command buffers through the same driver, and applies each recorded page delta from the worker
+// just before that submission executes: a page is written only while the replay's copy still
+// equals the game's previous contents, so pages the replayed GPU work wrote keep the replay's
+// bytes (the delta carries CPU writes the guest made between submissions).
+//
+// File format (little-endian, version 1): "AGCFRAME", u32 version, u32 compression algorithm
+// (libcompression constant), then records {u32 tag, u32 reserved, u64 payload bytes, payload}.
+
+#include "prx/libSceAgcDriver/Execution/include/Driver/Queues/Submission.hpp"
+#include "prx/libSceAgcDriver/Execution/include/DisplayBuffer.hpp"
+#include "prx/libSceAgcDriver/Execution/include/NativeGuestMemory.hpp"
+#include "prx/libSceAgcDriver/Execution/include/QueueState.hpp"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace AgcDriver::Metal::AgcCapture {
+
+inline constexpr char Magic[8] = {'A', 'G', 'C', 'F', 'R', 'A', 'M', 'E'};
+inline constexpr std::uint32_t Version = 1;
+inline constexpr std::uint64_t ChunkBytes = std::uint64_t{1} << 20;
+inline constexpr std::uint64_t PageBytes = std::uint64_t{64} << 10;
+
+enum class Tag : std::uint32_t {
+ Range = 1, // u64 guest, u64 bytes, u32 writable, u32 0
+ Chunk = 2, // u64 guest, u32 raw bytes, u32 stored bytes (== raw: uncompressed), u64 hash, data
+ Shader = 3, // u32 id, u32 type, u64 code address, u64 header address, u32 code words, u32 header bytes, code, header
+ Registry = 4, // u32 id, u32 entries, entries x {u64 key, u32 shader id}
+ QueueState = 5, // u32 queue, serialized QueueState
+ Submit = 6, // u32 queue, u32 registry id, u64 command address, u32 words, u32 pages, u32 frame, u32 0,
+ // words x u32, pages x {u64 guest, u32 raw, u32 stored, u64 previous hash, u64 hash, data}
+ Display = 7, // u32 frame, u32 0, DisplayBuffer fields, u64 pixel hash, u32 raw, u32 stored, data
+ End = 8, // u32 frames, u32 submissions
+};
+
+// FNV-style 4-lane hash; the same function hashes chunks, pages and display pixels.
+inline std::uint64_t Hash(const void* data, std::size_t bytes) {
+ const auto* p = static_cast(data);
+ std::uint64_t lanes[4] = {0x9e3779b97f4a7c15ull, 0xc2b2ae3d27d4eb4full, 0x165667b19e3779f9ull, 0x27d4eb2f165667c5ull};
+ std::size_t i = 0;
+ for (; i + 32 <= bytes; i += 32) {
+ for (int lane = 0; lane < 4; ++lane) {
+ std::uint64_t word;
+ std::memcpy(&word, p + i + 8 * lane, 8);
+ lanes[lane] = (lanes[lane] ^ word) * 0x100000001b3ull;
+ }
+ }
+ std::uint64_t h = lanes[0] ^ (lanes[1] << 1) ^ (lanes[2] << 2) ^ (lanes[3] << 3) ^ bytes;
+ for (; i < bytes; ++i) h = (h ^ p[i]) * 0x100000001b3ull;
+ h ^= h >> 33;
+ h *= 0xff51afd7ed558ccdull;
+ h ^= h >> 33;
+ return h;
+}
+
+class Writer {
+public:
+ std::vector bytes;
+ template void Put(const T& value) {
+ const auto* p = reinterpret_cast(&value);
+ bytes.insert(bytes.end(), p, p + sizeof(T));
+ }
+ void PutBytes(const void* data, std::size_t size) {
+ const auto* p = static_cast(data);
+ bytes.insert(bytes.end(), p, p + size);
+ }
+};
+
+class Reader {
+public:
+ Reader(const std::byte* data, std::size_t size) : data(data), size(size) {}
+ template T Get() {
+ T value;
+ std::memcpy(&value, Take(sizeof(T)), sizeof(T));
+ return value;
+ }
+ const std::byte* Take(std::size_t count) {
+ if (count > size - offset) throw std::runtime_error("agc capture: truncated record");
+ const auto* p = data + offset;
+ offset += count;
+ return p;
+ }
+ [[nodiscard]] bool Done() const { return offset == size; }
+
+private:
+ const std::byte* data;
+ std::size_t size;
+ std::size_t offset = 0;
+};
+
+void WriteQueueState(Writer& out, const QueueState& state);
+QueueState ReadQueueState(Reader& in);
+
+// Compresses with the capture's algorithm; returns raw bytes when compression does not help.
+std::vector Compress(const void* data, std::size_t bytes);
+// Decompresses `stored` bytes into exactly `raw` bytes at `target` (a copy when stored == raw).
+void Decompress(const std::byte* stored, std::size_t storedBytes, void* target, std::size_t raw);
+
+// Writes width x height BGRA8 pixels (scanout layout) as a PNG.
+bool WriteBgraPng(const std::filesystem::path& path, const std::vector& pixels, std::uint32_t width, std::uint32_t height);
+
+// What a hook needs from the driver: the worker's ranges, the GPU lock and the drain
+// (CompletePriorGpuWorkAndCopyBack, to call with the GPU lock held).
+struct Context {
+ std::span ranges;
+ std::recursive_mutex& gpuMutex;
+ std::function drain;
+};
+
+// Whether a capture or a replay is installed (cheap; the hooks return at once otherwise).
+bool Active();
+// Queue worker, before and after ExecuteSubmission (not holding the GPU lock).
+void BeforeExecute(const DriverDetail::Submission& submission, const QueueState& state, const Context& context);
+void AfterExecute(const DriverDetail::Submission& submission, const Context& context);
+// MetalDriver::Present with the GPU lock held; `pixels` are the decoded display pixels when read.
+void Presented(const DisplayBuffer* buffer, const std::vector* pixels, const Context& context);
+
+// Replay: called on the worker before each submission of `queue` executes, in queue order.
+using ReplayHook = std::function;
+void InstallReplay(ReplayHook hook);
+
+// GPU busy time (union of command buffer GPU intervals) while enabled; MetalDevice records it.
+void EnableGpuBusy(bool enabled);
+bool GpuBusyEnabled();
+void RecordGpuBusy(double gpuStart, double gpuEnd);
+double TakeGpuBusyMs();
+
+}
diff --git a/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.mm b/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.mm
new file mode 100644
index 000000000000..afe434e2db39
--- /dev/null
+++ b/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.mm
@@ -0,0 +1,573 @@
+#include "AgcFrameCapture.hpp"
+#include "prx/libSceAgcDriver/Execution/include/ShaderCapture.hpp"
+#include "prx/libSceAgcDriver/Graphics/Metal/MetalPresentation.hpp"
+#include "prx/libSceAgcDriver/Graphics/Metal/GpuBusy.hpp"
+#include
+#include
+#include
+#include
+#import
+#import
+#include
+#include
+#include
+#include
+#include
+#include
+#include