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 +#include +#include + +namespace AgcDriver::Metal::AgcCapture { + +namespace { + +constexpr compression_algorithm Algorithm = COMPRESSION_LZ4; + +std::atomic active{false}; + +std::uint64_t ZeroHash(std::size_t bytes) { + static const std::vector zeros(ChunkBytes); + // Whole pages and chunks are the common sizes: hashed once, read without a lock. + static const std::uint64_t page = Hash(zeros.data(), PageBytes); + static const std::uint64_t chunk = Hash(zeros.data(), ChunkBytes); + if (bytes == PageBytes) return page; + if (bytes == ChunkBytes) return chunk; + return Hash(zeros.data(), bytes); +} + +void WriteRegisters(Writer& out, const Registers& registers) { + out.Put(static_cast(registers.size())); + registers.ForEach([&](std::uint32_t offset, std::uint32_t value) { + out.Put(offset); + out.Put(value); + }); +} + +Registers ReadRegisters(Reader& in) { + Registers registers; + const auto count = in.Get(); + for (std::uint32_t i = 0; i < count; ++i) { + const auto offset = in.Get(); + registers.insert_or_assign(offset, in.Get()); + } + return registers; +} + +// Residency per host page of [begin, begin + bytes): nonzero when the page may hold data. +std::vector Residency(const std::byte* begin, std::size_t bytes, std::uintptr_t& base, std::size_t& page) { + page = static_cast(getpagesize()); + base = reinterpret_cast(begin) & ~(static_cast(page) - 1); + const auto end = reinterpret_cast(begin) + bytes; + std::vector vec((end - base + page - 1) / page, 1); + static const bool readAll = [] { + const char* value = std::getenv("ANYPS5_AGC_CAPTURE_READ_ALL"); + return value != nullptr && *value == '1'; + }(); + if (readAll) return vec; + // The kernel walks every page of the range (about 1 s per 13 GiB on one thread): split it. + constexpr std::size_t SlicePages = 16384; + const auto pages = vec.size(); + auto* out = vec.data(); + const auto pageBytes = page; + __block bool failed = false; + dispatch_apply((pages + SlicePages - 1) / SlicePages, dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^(std::size_t slice) { + const auto first = slice * SlicePages; + const auto count = std::min(SlicePages, pages - first); + const auto from = base + first * pageBytes; + const auto bytes = std::min(count * pageBytes, end - from); + if (mincore(reinterpret_cast(from), bytes, out + first) != 0) failed = true; + }); + if (failed) std::fill(vec.begin(), vec.end(), 1); + return vec; +} + +bool AnyResident(const std::vector& vec, std::uintptr_t base, std::size_t page, const std::byte* begin, std::size_t bytes) { + const auto first = (reinterpret_cast(begin) - base) / page; + const auto last = (reinterpret_cast(begin) + bytes - 1 - base) / page; + for (auto i = first; i <= last && i < vec.size(); ++i) + if (vec[i] != 0) return true; + return false; +} + +} + +bool WriteBgraPng(const std::filesystem::path& path, const std::vector& pixels, std::uint32_t width, std::uint32_t height) { + if (pixels.size() != std::size_t{width} * height * 4) return false; + CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB(); + CFDataRef data = CFDataCreate(nullptr, reinterpret_cast(pixels.data()), static_cast(pixels.size())); + CGDataProviderRef provider = CGDataProviderCreateWithCFData(data); + // Scanout pixels are BGRA8 in memory (little-endian 32-bit ARGB, alpha ignored). + CGImageRef image = CGImageCreate(width, height, 8, 32, std::size_t{width} * 4, space, + static_cast(kCGBitmapByteOrder32Little) | static_cast(kCGImageAlphaNoneSkipFirst), provider, nullptr, false, kCGRenderingIntentDefault); + CFURLRef url = CFURLCreateFromFileSystemRepresentation(nullptr, reinterpret_cast(path.c_str()), + static_cast(path.string().size()), false); + CGImageDestinationRef destination = CGImageDestinationCreateWithURL(url, CFSTR("public.png"), 1, nullptr); + bool ok = false; + if (destination != nullptr && image != nullptr) { + CGImageDestinationAddImage(destination, image, nullptr); + ok = CGImageDestinationFinalize(destination); + } + if (destination != nullptr) CFRelease(destination); + CFRelease(url); + if (image != nullptr) CGImageRelease(image); + CGDataProviderRelease(provider); + CFRelease(data); + CGColorSpaceRelease(space); + return ok; +} + +namespace { + +class Capture { +public: + static Capture* Create() { + const char* dir = std::getenv("ANYPS5_AGC_CAPTURE"); + if (dir == nullptr || *dir == '\0') return nullptr; + auto* capture = new Capture(); + capture->dir = dir; + if (const char* flip = std::getenv("ANYPS5_AGC_CAPTURE_FLIP"); flip != nullptr && *flip != '\0') + capture->target = std::max(2, std::strtoull(flip, nullptr, 10)); + if (const char* after = std::getenv("ANYPS5_AGC_CAPTURE_AFTER_S"); after != nullptr && *after != '\0') + capture->afterSeconds = std::strtod(after, nullptr); + if (const char* frames = std::getenv("ANYPS5_AGC_CAPTURE_FRAMES"); frames != nullptr && *frames != '\0') + capture->frames = std::max(1, std::strtoull(frames, nullptr, 10)); + std::fprintf(stderr, "[agc-capture] armed: flip %llu (or the first after %.0f s), %llu frame(s), into %s\n", + static_cast(capture->target), capture->afterSeconds, static_cast(capture->frames), dir); + return capture; + } + + void Before(const DriverDetail::Submission& submission, const QueueState& state, const Context& context) { + if (!recording.load(std::memory_order_acquire)) return; + std::lock_guard gpu(context.gpuMutex); + std::lock_guard lock(mutex); + if (phase != Phase::Recording || submission.suspend) return; + context.drain(); + const auto started = std::chrono::steady_clock::now(); + if (queuesWithState.insert(submission.queue).second) { + Writer record; + record.Put(submission.queue); + WriteQueueState(record, state); + Emit(Tag::QueueState, record); + } + const auto registry = RegistryId(submission.shaders); + Writer record; + record.Put(submission.queue); + record.Put(registry); + record.Put(submission.commandAddress); + record.Put(static_cast(submission.commands.size())); + const auto pagesAt = record.bytes.size(); + record.Put(std::uint32_t{0}); + record.Put(static_cast(FrameOf())); + record.Put(std::uint32_t{0}); + record.PutBytes(submission.commands.data(), submission.commands.size() * sizeof(std::uint32_t)); + const auto pages = Deltas(context, record); + std::memcpy(record.bytes.data() + pagesAt, &pages, sizeof pages); + Emit(Tag::Submit, record); + ++submissions; + deltaPages += pages; + const auto ms = std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + stallMs += ms; + std::fprintf(stderr, "[agc-capture] submission %llu: queue %u, %zu words, %u changed pages, %.0f ms\n", + static_cast(submissions), submission.queue, submission.commands.size(), pages, ms); + } + + void After(const DriverDetail::Submission& submission, const Context& context) { + if (submission.flips.empty()) return; + std::lock_guard gpu(context.gpuMutex); + std::lock_guard lock(mutex); + flips += submission.flips.size(); + if (phase == Phase::Waiting && afterSeconds > 0) { + // Time trigger: the next flip after the delay is the captured frame. + const auto elapsed = std::chrono::duration(std::chrono::steady_clock::now() - loaded).count(); + if (elapsed < afterSeconds) return; + target = flips + 1; + afterSeconds = 0; + } + if (phase == Phase::Waiting && flips >= target - 1) { + context.drain(); + Begin(context); + } else if (phase == Phase::Recording && flips >= target - 1 + frames) { + phase = Phase::AwaitPresent; + recording.store(false, std::memory_order_release); + std::fprintf(stderr, "[agc-capture] recorded %llu submissions (%llu delta pages, %.0f ms stalled); waiting for the present\n", + static_cast(submissions), static_cast(deltaPages), stallMs); + Finish(); + } + } + + void Present(const DisplayBuffer* buffer, const std::vector* pixels, const Context& context) { + std::lock_guard lock(mutex); + ++presents; + if (phase == Phase::Done || phase == Phase::Waiting || buffer == nullptr) return; + // Present k scans out flip k: the presents of the recorded flips are target .. target + frames - 1. + if (presents < target || presents >= target + frames) return; + std::vector read; + if (pixels == nullptr || pixels->empty()) { + NativeGuestMemory::BorrowedRangesScope scope(context.ranges); + read = MetalPresentation::ReadPixels(*buffer); + pixels = &read; + } + const auto frame = static_cast(presents - target); + const auto hash = Hash(pixels->data(), pixels->size()); + Writer record; + record.Put(frame); + record.Put(std::uint32_t{0}); + record.Put(*buffer); + record.Put(hash); + const auto stored = Compress(pixels->data(), pixels->size()); + record.Put(static_cast(pixels->size())); + record.Put(static_cast(stored.size())); + record.PutBytes(stored.data(), stored.size()); + Emit(Tag::Display, record); + const auto png = dir / ("game-" + std::to_string(frame) + ".png"); + const bool wrote = WriteBgraPng(png, *pixels, buffer->width, buffer->height); + std::fprintf(stderr, "[agc-capture] frame %u shown by present %llu: %ux%u format 0x%llx tiling %u hash %016llx%s\n", frame, + static_cast(presents), buffer->width, buffer->height, static_cast(buffer->pixelFormat), + buffer->tilingMode, static_cast(hash), wrote ? "" : " (png not written)"); + if (++displays == frames) { + phase = Phase::Done; + Writer end; + end.Put(static_cast(frames)); + end.Put(static_cast(submissions)); + Emit(Tag::End, end); + std::fclose(file); + file = nullptr; + std::fprintf(stderr, "[agc-capture] done: %s (%.1f MiB)\n", (dir / "frame.agc").c_str(), static_cast(written) / (1 << 20)); + active.store(false); + } + } + +private: + enum class Phase { Waiting, Recording, AwaitPresent, Done }; + struct Table { + std::uint64_t guest; + std::byte* host; + std::size_t bytes; + std::vector pages; + }; + + std::uint64_t FrameOf() const { return flips - (target - 1); } + + void Emit(Tag tag, const Writer& record) { + if (file == nullptr) return; + const auto value = static_cast(tag); + const std::uint32_t reserved = 0; + const std::uint64_t bytes = record.bytes.size(); + std::fwrite(&value, sizeof value, 1, file); + std::fwrite(&reserved, sizeof reserved, 1, file); + std::fwrite(&bytes, sizeof bytes, 1, file); + if (bytes != 0) std::fwrite(record.bytes.data(), 1, bytes, file); + written += 16 + bytes; + } + + void Begin(const Context& context) { + const auto started = std::chrono::steady_clock::now(); + std::error_code error; + std::filesystem::create_directories(dir, error); + file = std::fopen((dir / "frame.agc").c_str(), "wb"); + if (file == nullptr) { + std::fprintf(stderr, "[agc-capture] cannot create %s; capture off\n", (dir / "frame.agc").c_str()); + phase = Phase::Done; + active.store(false); + return; + } + std::fwrite(Magic, 1, sizeof Magic, file); + const auto version = Version; + const auto algorithm = static_cast(Algorithm); + std::fwrite(&version, sizeof version, 1, file); + std::fwrite(&algorithm, sizeof algorithm, 1, file); + written = 16; + std::uint64_t residentBytes = 0; + std::uint64_t storedChunks = 0; + for (const auto& range : context.ranges) { + Writer record; + record.Put(range.guestAddress); + record.Put(static_cast(range.host.size())); + record.Put(static_cast(range.writable)); + record.Put(std::uint32_t{0}); + Emit(Tag::Range, record); + Table table{range.guestAddress, range.host.data(), range.host.size(), {}}; + table.pages.resize((range.host.size() + PageBytes - 1) / PageBytes); + std::uintptr_t base = 0; + std::size_t page = 0; + const auto resident = Residency(range.host.data(), range.host.size(), base, page); + const auto chunks = (range.host.size() + ChunkBytes - 1) / ChunkBytes; + // Batches keep at most 256 compressed chunks in memory at once. + constexpr std::size_t Batch = 256; + for (std::size_t first = 0; first < chunks; first += Batch) { + const auto count = std::min(Batch, chunks - first); + std::vector> stored(count); + std::vector hashes(count); + std::vector present(count, 0); + auto* storedOut = stored.data(); + auto* hashesOut = hashes.data(); + auto* presentOut = present.data(); + auto* pagesOut = table.pages.data(); + const auto* residentIn = &resident; + const auto* host = range.host.data(); + const auto hostBytes = range.host.size(); + dispatch_apply(count, dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^(std::size_t i) { + const auto offset = (first + i) * ChunkBytes; + const auto bytes = std::min(ChunkBytes, hostBytes - offset); + const auto* data = host + offset; + for (std::size_t p = 0; p < bytes; p += PageBytes) { + const auto pageBytes = std::min(PageBytes, bytes - p); + pagesOut[(offset + p) / PageBytes] = AnyResident(*residentIn, base, page, data + p, pageBytes) + ? Hash(data + p, pageBytes) : ZeroHash(pageBytes); + } + if (!AnyResident(*residentIn, base, page, data, bytes)) return; + hashesOut[i] = Hash(data, bytes); + if (hashesOut[i] == ZeroHash(bytes) && std::all_of(data, data + bytes, [](std::byte b) { return b == std::byte{0}; })) return; + presentOut[i] = 1; + storedOut[i] = Compress(data, bytes); + }); + for (std::size_t i = 0; i < count; ++i) { + if (!present[i]) continue; + const auto offset = (first + i) * ChunkBytes; + const auto bytes = std::min(ChunkBytes, range.host.size() - offset); + Writer chunk; + chunk.Put(range.guestAddress + offset); + chunk.Put(static_cast(bytes)); + chunk.Put(static_cast(stored[i].size())); + chunk.Put(hashes[i]); + chunk.PutBytes(stored[i].data(), stored[i].size()); + Emit(Tag::Chunk, chunk); + residentBytes += bytes; + ++storedChunks; + } + } + tables.push_back(std::move(table)); + } + phase = Phase::Recording; + recording.store(true, std::memory_order_release); + std::fprintf(stderr, "[agc-capture] flip %llu: snapshot of %zu ranges, %llu MiB in %llu chunks, %.1f MiB written in %.0f ms\n", + static_cast(flips), context.ranges.size(), static_cast(residentBytes >> 20), + static_cast(storedChunks), static_cast(written) / (1 << 20), + std::chrono::duration(std::chrono::steady_clock::now() - started).count()); + } + + // Appends the pages that changed since the previous checkpoint to `record`; returns their count. + std::uint32_t Deltas(const Context& context, Writer& record) { + std::uint32_t count = 0; + for (auto& table : tables) { + // A range that is no longer borrowed is not read by this submission. + const auto found = std::find_if(context.ranges.begin(), context.ranges.end(), [&](const auto& range) { + return range.guestAddress == table.guest && range.host.data() == table.host && range.host.size() == table.bytes; + }); + if (found == context.ranges.end()) continue; + std::uintptr_t base = 0; + std::size_t page = 0; + const auto resident = Residency(table.host, table.bytes, base, page); + const auto pages = table.pages.size(); + std::vector hashes(pages); + auto* hashOut = hashes.data(); + auto* tableRef = &table; + const auto* residentRef = &resident; + dispatch_apply((pages + 63) / 64, dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^(std::size_t group) { + for (std::size_t p = group * 64; p < std::min(pages, group * 64 + 64); ++p) { + const auto offset = p * PageBytes; + const auto bytes = std::min(PageBytes, tableRef->bytes - offset); + const auto* data = tableRef->host + offset; + hashOut[p] = AnyResident(*residentRef, base, page, data, bytes) ? Hash(data, bytes) : ZeroHash(bytes); + } + }); + for (std::size_t p = 0; p < pages; ++p) { + if (hashes[p] == table.pages[p]) continue; + const auto offset = p * PageBytes; + const auto bytes = std::min(PageBytes, table.bytes - offset); + const auto stored = Compress(table.host + offset, bytes); + record.Put(table.guest + offset); + record.Put(static_cast(bytes)); + record.Put(static_cast(stored.size())); + record.Put(table.pages[p]); + record.Put(hashes[p]); + record.PutBytes(stored.data(), stored.size()); + table.pages[p] = hashes[p]; + ++count; + } + } + return count; + } + + std::uint32_t RegistryId(const std::shared_ptr& registry) { + if (registry == nullptr) return 0xffffffffu; + if (const auto found = registryIds.find(registry.get()); found != registryIds.end()) return found->second; + static const auto nullPixel = DriverDetail::NullPixelProgramAddress(); + Writer record; + const auto id = static_cast(registryIds.size()); + std::vector> entries; + for (const auto& [key, snapshots] : *registry) { + if (key == nullPixel) continue; + for (const auto& snapshot : snapshots) entries.emplace_back(key, ShaderId(snapshot)); + } + record.Put(id); + record.Put(static_cast(entries.size())); + for (const auto& [key, shader] : entries) { + record.Put(key); + record.Put(shader); + } + Emit(Tag::Registry, record); + registryIds.emplace(registry.get(), id); + keepRegistries.push_back(registry); + return id; + } + + std::uint32_t ShaderId(const std::shared_ptr& snapshot) { + if (const auto found = shaderIds.find(snapshot.get()); found != shaderIds.end()) return found->second; + const auto id = static_cast(shaderIds.size()); + Writer record; + record.Put(id); + record.Put(static_cast(snapshot->type)); + record.Put(snapshot->codeAddress); + record.Put(snapshot->headerAddress); + record.Put(static_cast(snapshot->code.size())); + record.Put(static_cast(snapshot->header.size())); + record.PutBytes(snapshot->code.data(), snapshot->code.size() * sizeof(std::uint32_t)); + record.PutBytes(snapshot->header.data(), snapshot->header.size()); + Emit(Tag::Shader, record); + shaderIds.emplace(snapshot.get(), id); + keepShaders.push_back(snapshot); + return id; + } + + void Finish() { std::fflush(file); } + + std::mutex mutex; + std::filesystem::path dir; + std::FILE* file = nullptr; + std::uint64_t target = 600; + std::uint64_t frames = 1; + std::uint64_t flips = 0; + std::uint64_t presents = 0; + std::uint64_t displays = 0; + std::uint64_t submissions = 0; + std::uint64_t deltaPages = 0; + std::uint64_t written = 0; + double stallMs = 0; + double afterSeconds = 0; + std::chrono::steady_clock::time_point loaded = std::chrono::steady_clock::now(); + Phase phase = Phase::Waiting; + std::atomic recording{false}; + std::vector tables; + std::set queuesWithState; + std::map registryIds; + std::map shaderIds; + std::vector> keepRegistries; + std::vector> keepShaders; +}; + +Capture* SharedCapture() { + static Capture* capture = [] { + auto* created = Capture::Create(); + if (created != nullptr) active.store(true); + return created; + }(); + return capture; +} + +std::mutex replayMutex; +ReplayHook replayHook; + +// Arms the capture at load time, so Active() is accurate from the first submission. +__attribute__((constructor)) void ArmCapture() { static_cast(SharedCapture()); } + +} + +void WriteQueueState(Writer& out, const QueueState& state) { + WriteRegisters(out, state.shader); + WriteRegisters(out, state.context); + WriteRegisters(out, state.userConfig); + out.Put(static_cast(state.savedContext.has_value())); + if (state.savedContext) WriteRegisters(out, *state.savedContext); + out.Put(static_cast(state.constantRam.size())); + out.PutBytes(state.constantRam.data(), state.constantRam.size() * sizeof(std::uint32_t)); + out.Put(state.indexBase); + out.Put(state.drawIndirectBase); + out.Put(state.dispatchIndirectBase); + out.Put(state.indexBufferSize); + out.Put(state.indexType); + out.Put(state.instanceCount); + out.Put(state.predication.address); + out.Put(state.predication.operation); + out.Put(static_cast(state.predication.executeWhenSet)); +} + +QueueState ReadQueueState(Reader& in) { + QueueState state; + state.shader = ReadRegisters(in); + state.context = ReadRegisters(in); + state.userConfig = ReadRegisters(in); + if (in.Get() != 0) state.savedContext = ReadRegisters(in); + state.constantRam.resize(in.Get()); + std::memcpy(state.constantRam.data(), in.Take(state.constantRam.size() * sizeof(std::uint32_t)), state.constantRam.size() * sizeof(std::uint32_t)); + state.indexBase = in.Get(); + state.drawIndirectBase = in.Get(); + state.dispatchIndirectBase = in.Get(); + state.indexBufferSize = in.Get(); + state.indexType = in.Get(); + state.instanceCount = in.Get(); + state.predication.address = in.Get(); + state.predication.operation = in.Get(); + state.predication.executeWhenSet = in.Get() != 0; + return state; +} + +std::vector Compress(const void* data, std::size_t bytes) { + std::vector out(bytes); + const auto size = bytes == 0 ? 0 : compression_encode_buffer(reinterpret_cast(out.data()), out.size(), + static_cast(data), bytes, nullptr, Algorithm); + if (size == 0 || size >= bytes) { + std::memcpy(out.data(), data, bytes); + return out; + } + out.resize(size); + return out; +} + +void Decompress(const std::byte* stored, std::size_t storedBytes, void* target, std::size_t raw) { + if (storedBytes == raw) { + std::memcpy(target, stored, raw); + return; + } + const auto size = compression_decode_buffer(static_cast(target), raw, reinterpret_cast(stored), + storedBytes, nullptr, Algorithm); + if (size != raw) throw std::runtime_error("agc capture: a compressed block does not decode to its size"); +} + +bool Active() { return active.load(std::memory_order_relaxed); } + +void BeforeExecute(const DriverDetail::Submission& submission, const QueueState& state, const Context& context) { + if (!Active()) return; + if (auto* capture = SharedCapture()) return capture->Before(submission, state, context); + ReplayHook hook; + { + std::lock_guard lock(replayMutex); + hook = replayHook; + } + if (hook && !submission.suspend) hook(submission.queue, context); +} + +void AfterExecute(const DriverDetail::Submission& submission, const Context& context) { + if (!Active()) return; + if (auto* capture = SharedCapture()) capture->After(submission, context); +} + +void Presented(const DisplayBuffer* buffer, const std::vector* pixels, const Context& context) { + if (!Active()) return; + if (auto* capture = SharedCapture()) capture->Present(buffer, pixels, context); +} + +void InstallReplay(ReplayHook hook) { + if (SharedCapture() != nullptr) throw std::logic_error("agc replay: a capture is armed in this process"); + std::lock_guard lock(replayMutex); + replayHook = std::move(hook); + active.store(static_cast(replayHook)); +} + +void EnableGpuBusy(bool enabled) { GpuBusy::Shared().enabled.store(enabled); } +bool GpuBusyEnabled() { return GpuBusy::Enabled(); } +void RecordGpuBusy(double gpuStart, double gpuEnd) { GpuBusy::Record(gpuStart, gpuEnd); } +double TakeGpuBusyMs() { return GpuBusy::Take(); } + +} diff --git a/core/libs/prx/libSceAgcDriver/Execution/Metal/DirectDriver.hpp b/core/libs/prx/libSceAgcDriver/Execution/Metal/DirectDriver.hpp index 21f4928c7653..cdadb8f20581 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/Metal/DirectDriver.hpp +++ b/core/libs/prx/libSceAgcDriver/Execution/Metal/DirectDriver.hpp @@ -15,6 +15,9 @@ namespace AgcDriver::Metal { // and EOP interrupts delivered to the AGC event queues. Outside the driver's worker threads, each // call also hands the driver the current guest allocations when they changed since the last call. MetalDriver& DirectDriver(); +// Applies the process working-set mode DirectDriver configures with (ANYPS5_METAL_WORKING_SET_MIB, +// default 3 GiB in 4 MiB intervals); the frame replay calls it to bind memory as the game does. +void ApplyDirectWorkingSet(); // Submits the command words a descriptor names. The descriptor is read as a host pointer (it may be // on a thread stack), and words outside guest memory (host-built packets such as VideoOut's flip, diff --git a/core/libs/prx/libSceAgcDriver/Execution/Metal/DirectDriver.mm b/core/libs/prx/libSceAgcDriver/Execution/Metal/DirectDriver.mm index b5f9e3e73378..3ba3eec8b7be 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/Metal/DirectDriver.mm +++ b/core/libs/prx/libSceAgcDriver/Execution/Metal/DirectDriver.mm @@ -249,8 +249,7 @@ void SkipSubmission(const char* call, const Packet& packet, std::uint32_t queue, } void Configure(DirectState& state, MetalDriver& driver, const std::shared_ptr& ranges) { - const auto budget = WorkingSetBudget(); - MetalGuestHostAliases::SetProcessWorkingSet(budget == 0 ? 0 : WorkingSetGranule, budget); + ApplyDirectWorkingSet(); @autoreleasepool { id device = MTLCreateSystemDefaultDevice(); if (device == nil) throw std::runtime_error("Rosetta direct path: no Metal device"); @@ -309,6 +308,11 @@ bool RangesCover(const std::vector& ranges, st return result; } +void ApplyDirectWorkingSet() { + const auto budget = WorkingSetBudget(); + MetalGuestHostAliases::SetProcessWorkingSet(budget == 0 ? 0 : WorkingSetGranule, budget); +} + std::shared_ptr> DirectGuestRanges() { auto& state = State(); std::lock_guard lock(state.rangesMutex); diff --git a/core/libs/prx/libSceAgcDriver/Execution/Metal/MetalDriver.mm b/core/libs/prx/libSceAgcDriver/Execution/Metal/MetalDriver.mm index 1504cc234d40..93a4220a270a 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/Metal/MetalDriver.mm +++ b/core/libs/prx/libSceAgcDriver/Execution/Metal/MetalDriver.mm @@ -1,4 +1,5 @@ #include "MetalDriverInternal.hpp" +#include "AgcFrameCapture.hpp" #include #include "prx/libSceAgcDriver/Graphics/Metal/BindTrace.hpp" #include "prx/libSceAgcDriver/Graphics/Metal/MetalPresentation.hpp" @@ -503,6 +504,7 @@ void CaptureAndValidate(DriverDetail::Submission& submission, std::uint64_t addr require(descriptor.flags == 0, "nonzero submission flags are not implemented"); try { CaptureAndValidate(submission, descriptor.address, descriptor.words); + submission.commandAddress = descriptor.address; CaptureIndirectRegisterLists(submission); } catch (const DriverStopped&) { throw; @@ -807,7 +809,10 @@ void CaptureAndValidate(DriverDetail::Submission& submission, std::uint64_t addr CompletePriorGpuWorkAndCopyBack(); } else { NativeGuestMemory::BorrowedRangesScope scope(capturedRanges); + const AgcCapture::Context capture{capturedRanges, gpuMutex, [this] { CompletePriorGpuWorkAndCopyBack(); }}; + AgcCapture::BeforeExecute(submission, worker->state, capture); ExecuteSubmission(submission, worker->state); + AgcCapture::AfterExecute(submission, capture); // GPU work may still be running: retire the submission (and so let the guest // submit past it, MAX_QUEUED) when it completed, while this worker goes on. std::lock_guard gpuLock(gpuMutex); @@ -869,6 +874,24 @@ void CaptureAndValidate(DriverDetail::Submission& submission, std::uint64_t addr changed.notify_all(); } +void MetalDriver::ReplaceShaderRegistryForReplay(std::shared_ptr registry) { + require(registry != nullptr, "replay shader registry is null"); + auto merged = std::make_shared(*registry); + const auto nullPixelAddress = DriverDetail::NullPixelProgramAddress(); + std::lock_guard lock(impl->mutex); + // Keeps the driver's own null pixel program (and what it prepared) across replacements. + if (const auto found = impl->shaders->find(nullPixelAddress); found != impl->shaders->end()) (*merged)[nullPixelAddress] = found->second; + else (*merged)[nullPixelAddress] = {DriverDetail::CaptureNullPixelShader()}; + impl->shaders = std::move(merged); +} + +void MetalDriver::ReplaceQueueStateForReplay(std::uint32_t queue, const QueueState& state) { + std::lock_guard lock(impl->mutex); + auto& worker = impl->workers[queue]; + require(!worker.active && worker.pending.empty(), "replay queue state changes need an idle queue"); + worker.state = state; +} + void MetalDriver::WaitIdle() { rejectReadablePublicationReentry(impl.get()); require(!Impl::OnWorkerThread(), "worker cannot wait for itself"); @@ -1112,6 +1135,10 @@ void CaptureAndValidate(DriverDetail::Submission& submission, std::uint64_t addr pixels = MetalPresentation::ReadPixels(*buffer); } } + if (AgcCapture::Active()) { + const AgcCapture::Context capture{impl->ranges, impl->gpuMutex, [impl = impl.get()] { impl->CompletePriorGpuWorkAndCopyBack(); }}; + AgcCapture::Presented(buffer, decided && !pixels.empty() ? &pixels : nullptr, capture); + } } bool ready = false; impl->presentation->Present(window, buffer, opaque, gpuReady ? +[](void* value) { *static_cast(value) = true; } : nullptr, &ready, diff --git a/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Queues/Submission.hpp b/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Queues/Submission.hpp index f64ecfaa7ff0..0ca9786a556f 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Queues/Submission.hpp +++ b/core/libs/prx/libSceAgcDriver/Execution/include/Driver/Queues/Submission.hpp @@ -21,6 +21,8 @@ struct Submission { std::uint64_t serial; std::uint32_t queue; std::vector commands; + // Guest address the commands were captured from (frame capture, Metal/AgcFrameCapture.hpp). + std::uint64_t commandAddress = 0; std::shared_ptr shaders; std::map> flips; diff --git a/core/libs/prx/libSceAgcDriver/Execution/include/MetalDriver.hpp b/core/libs/prx/libSceAgcDriver/Execution/include/MetalDriver.hpp index 41d54a392cca..1cb2d1ac86fb 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/include/MetalDriver.hpp +++ b/core/libs/prx/libSceAgcDriver/Execution/include/MetalDriver.hpp @@ -5,6 +5,7 @@ #include "prx/libSceAgcDriver/Execution/include/NativeGuestMemory.hpp" #include "prx/libSceAgcDriver/Execution/include/VideoOutput.hpp" #include "prx/libSceAgcDriver/Execution/include/Presentation.hpp" +#include "prx/libSceAgcDriver/Execution/include/Driver/Shaders/ShaderRegistry.hpp" #include #include #include @@ -13,6 +14,8 @@ #include #include +namespace AgcDriver { struct QueueState; } + namespace AgcDriver::Metal { // Thrown to callers once the driver has been stopped. Entry points for guest threads @@ -78,6 +81,10 @@ class MetalDriver { // turns it on. void SetSkipInvalidCommandBuffers(bool skip); void WaitIdle(); + // Frame replay (tools/agc-replay) only: the shader registry the next submissions are captured + // with (the driver's null pixel program is added), and the state of an idle queue. + void ReplaceShaderRegistryForReplay(std::shared_ptr registry); + void ReplaceQueueStateForReplay(std::uint32_t queue, const QueueState& state); void Shutdown(); void SuspendPoint(); void RegisterShader(const Shader* shader); diff --git a/core/libs/prx/libSceAgcDriver/Graphics/Metal/GpuBusy.hpp b/core/libs/prx/libSceAgcDriver/Graphics/Metal/GpuBusy.hpp new file mode 100644 index 000000000000..89444b32b015 --- /dev/null +++ b/core/libs/prx/libSceAgcDriver/Graphics/Metal/GpuBusy.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +// GPU busy time for the frame replay (tools/agc-replay): while enabled, every command buffer +// MetalDevice creates adds its GPU interval, and overlapping intervals count once. Off by default. +// Callers outside the driver library use the AgcCapture wrappers (Execution/Metal/AgcFrameCapture.hpp), +// so the state below stays in the one image that records it. +namespace AgcDriver::Metal::GpuBusy { + +struct State { + std::atomic enabled{false}; + std::mutex mutex; + double busyMs = 0; + double busyUntil = 0; +}; + +inline State& Shared() { + static State state; + return state; +} + +inline bool Enabled() { return Shared().enabled.load(std::memory_order_relaxed); } + +inline void Record(double gpuStart, double gpuEnd) { + auto& state = Shared(); + if (!state.enabled.load(std::memory_order_relaxed) || gpuStart <= 0 || gpuEnd <= gpuStart) return; + std::lock_guard lock(state.mutex); + const auto from = std::max(gpuStart, state.busyUntil); + if (gpuEnd > from) state.busyMs += (gpuEnd - from) * 1000.0; + state.busyUntil = std::max(state.busyUntil, gpuEnd); +} + +inline double Take() { + auto& state = Shared(); + std::lock_guard lock(state.mutex); + const auto value = state.busyMs; + state.busyMs = 0; + return value; +} + +} diff --git a/core/libs/prx/libSceAgcDriver/Graphics/Metal/MetalDevice.mm b/core/libs/prx/libSceAgcDriver/Graphics/Metal/MetalDevice.mm index 467010b3bccb..cd3b2cbfedd2 100644 --- a/core/libs/prx/libSceAgcDriver/Graphics/Metal/MetalDevice.mm +++ b/core/libs/prx/libSceAgcDriver/Graphics/Metal/MetalDevice.mm @@ -2,6 +2,7 @@ #include "GpuStallWatch.hpp" #include "GpuTiming.hpp" #include "FrameProfile.hpp" +#include "GpuBusy.hpp" #include #include @@ -113,6 +114,9 @@ const auto tag = GpuTiming::Tag() != 0 ? GpuTiming::Tag() : GpuTiming::MakeTag(GpuTiming::Current(), 0); [commands addCompletedHandler:^(id done) { GpuTiming::RecordTag(tag, done.GPUEndTime - done.GPUStartTime); }]; } + if (GpuBusy::Enabled()) { + [commands addCompletedHandler:^(id done) { GpuBusy::Record(done.GPUStartTime, done.GPUEndTime); }]; + } if (FrameProfile::Enabled()) { const auto kind = GpuTiming::Current(); const auto tag = GpuTiming::Tag(); diff --git a/core/shader/recompiler/MetalReplay/CMakeLists.txt b/core/shader/recompiler/MetalReplay/CMakeLists.txt index af16c08cf28f..36e8d98835ad 100644 --- a/core/shader/recompiler/MetalReplay/CMakeLists.txt +++ b/core/shader/recompiler/MetalReplay/CMakeLists.txt @@ -33,6 +33,7 @@ add_library(anyps5_metal_native_execution STATIC "${agcDriverRoot}/Execution/src/DisplayFormat.cpp" "${agcDriverRoot}/Execution/Metal/MetalDriver.mm" "${agcDriverRoot}/Execution/Metal/MetalPacketExecution.mm" + "${agcDriverRoot}/Execution/Metal/AgcFrameCapture.mm" "${agcDriverRoot}/Graphics/src/ComputeStageInfo.cpp" "${agcDriverRoot}/Graphics/src/State.cpp" "${agcDriverRoot}/Graphics/src/SurfaceMetadata.cpp" diff --git a/tools/agc-replay/AgcReplay.mm b/tools/agc-replay/AgcReplay.mm new file mode 100644 index 000000000000..d9b61cc43634 --- /dev/null +++ b/tools/agc-replay/AgcReplay.mm @@ -0,0 +1,690 @@ +// anyps5_agc_replay: replays a frame captured with ANYPS5_AGC_CAPTURE through the Metal AGC driver +// (Execution/Metal/AgcFrameCapture.hpp has the capture and the file format). +// +// anyps5_agc_replay [--frames N] [--repeat K] [--dump-png out.png] +// [--expect-hash HEX] [--expect-png ref.png [--max-diff-pixels N]] [--metallib PATH] [--quiet] +// +// The captured guest ranges are mapped at their guest addresses (guest pointers are host pointers, +// as on the Rosetta direct path) and the driver is configured with them as the game's was. Each +// repeat restores the pre-frame memory and queue state, submits the recorded command buffers in +// order and waits for the GPU, then reads the scanout pixels of each frame's display buffer. +// Reported per frame: wall ms (first submit to idle), worker CPU ms (thread CPU time of the +// agc-queue-* workers, without the replay's own page writes), GPU busy ms (union of command +// buffer GPU intervals) and the image hash. Repeat 1 is a warm-up (pipelines compile there). +// The first repeat is a warm-up for timing and for the hash (cold pipelines and caches render a few +// hundred pixels 1-3 levels apart); the hash must be stable over the later repeats. +// --expect-hash checks the last frame's hash in every timed repeat (pixel-exact); --expect-png +// compares the last repeat against a PNG (e.g. one --dump-png wrote), allowing --max-diff-pixels. +// Exit status: 0, 1 on a mismatch or an unstable hash across timed repeats, 2 on errors. +#import +#import +#import +#include "prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.hpp" +#include "prx/libSceAgcDriver/Execution/Metal/DirectDriver.hpp" +#include "prx/libSceAgcDriver/Execution/include/MetalDriver.hpp" +#include "prx/libSceAgcDriver/Graphics/Metal/MetalPresentation.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef ANYPS5_AGC_REPLAY_METALLIB +#define ANYPS5_AGC_REPLAY_METALLIB "" +#endif + +using namespace AgcDriver; +namespace Capture = AgcDriver::Metal::AgcCapture; + +namespace { + +struct Block { + std::uint64_t guest; + std::uint32_t raw; + std::uint32_t stored; + std::uint64_t previous; + std::uint64_t hash; + const std::byte* data; +}; + +struct Range { + std::uint64_t guest; + std::uint64_t bytes; + bool writable; + std::vector chunks; // per 1 MiB chunk, null when the capture had none (zero) + std::byte* host = nullptr; +}; + +struct Submit { + std::uint32_t queue; + std::uint32_t registry; + std::uint64_t address; + const std::uint32_t* words; + std::uint32_t count; + std::uint32_t frame; + std::vector pages; +}; + +struct Display { + DisplayBuffer buffer; + std::uint64_t hash; + std::uint32_t raw; + std::uint32_t stored; + const std::byte* data; +}; + +struct CaptureFile { + std::vector ranges; + std::vector chunks; + std::map> shaders; + std::map> registries; + std::map states; + std::vector submits; + std::map displays; + std::uint64_t fileBytes = 0; +}; + +[[noreturn]] void Fail(const std::string& message) { + std::fprintf(stderr, "anyps5_agc_replay: %s\n", message.c_str()); + std::exit(2); +} + +CaptureFile Load(const std::string& path) { + const int fd = open(path.c_str(), O_RDONLY); + if (fd < 0) Fail("cannot open " + path); + const auto size = static_cast(lseek(fd, 0, SEEK_END)); + auto* base = static_cast(mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0)); + close(fd); + if (base == MAP_FAILED || size < 16) Fail("cannot map " + path); + if (std::memcmp(base, Capture::Magic, 8) != 0) Fail(path + " is not an AGC frame capture"); + std::uint32_t version = 0; + std::memcpy(&version, base + 8, 4); + if (version != Capture::Version) Fail(path + ": unsupported capture version " + std::to_string(version)); + CaptureFile file; + file.fileBytes = size; + std::size_t offset = 16; + std::vector> chunkGuests; + bool ended = false; + while (offset + 16 <= size && !ended) { + std::uint32_t tag = 0; + std::uint64_t bytes = 0; + std::memcpy(&tag, base + offset, 4); + std::memcpy(&bytes, base + offset + 8, 8); + offset += 16; + if (bytes > size - offset) Fail("truncated capture record"); + Capture::Reader in(base + offset, bytes); + switch (static_cast(tag)) { + case Capture::Tag::Range: { + Range range{in.Get(), in.Get(), in.Get() != 0, {}}; + range.chunks.assign((range.bytes + Capture::ChunkBytes - 1) / Capture::ChunkBytes, nullptr); + file.ranges.push_back(std::move(range)); + break; + } + case Capture::Tag::Chunk: { + Block block{}; + block.guest = in.Get(); + block.raw = in.Get(); + block.stored = in.Get(); + block.hash = in.Get(); + block.data = in.Take(block.stored); + file.chunks.push_back(block); + break; + } + case Capture::Tag::Shader: { + const auto id = in.Get(); + const auto type = static_cast(in.Get()); + const auto code = in.Get(); + const auto header = in.Get(); + const auto words = in.Get(); + const auto headerBytes = in.Get(); + DriverDetail::ShaderSnapshot snapshot{code, header, type, {}, {}}; + snapshot.code.resize(words); + std::memcpy(snapshot.code.data(), in.Take(std::size_t{words} * 4), std::size_t{words} * 4); + const auto* headerData = in.Take(headerBytes); + snapshot.header.assign(headerData, headerData + headerBytes); + file.shaders[id] = std::make_shared(std::move(snapshot)); + break; + } + case Capture::Tag::Registry: { + const auto id = in.Get(); + const auto entries = in.Get(); + auto registry = std::make_shared(); + for (std::uint32_t i = 0; i < entries; ++i) { + const auto key = in.Get(); + const auto shader = file.shaders.find(in.Get()); + if (shader == file.shaders.end()) Fail("registry names an unknown shader"); + (*registry)[key].push_back(shader->second); + } + file.registries[id] = std::move(registry); + break; + } + case Capture::Tag::QueueState: { + const auto queue = in.Get(); + file.states[queue] = Capture::ReadQueueState(in); + break; + } + case Capture::Tag::Submit: { + Submit submit{}; + submit.queue = in.Get(); + submit.registry = in.Get(); + submit.address = in.Get(); + submit.count = in.Get(); + const auto pages = in.Get(); + submit.frame = in.Get(); + in.Get(); + submit.words = reinterpret_cast(in.Take(std::size_t{submit.count} * 4)); + for (std::uint32_t i = 0; i < pages; ++i) { + Block page{}; + page.guest = in.Get(); + page.raw = in.Get(); + page.stored = in.Get(); + page.previous = in.Get(); + page.hash = in.Get(); + page.data = in.Take(page.stored); + submit.pages.push_back(page); + } + file.submits.push_back(std::move(submit)); + break; + } + case Capture::Tag::Display: { + const auto frame = in.Get(); + in.Get(); + Display display{}; + display.buffer = in.Get(); + display.hash = in.Get(); + display.raw = in.Get(); + display.stored = in.Get(); + display.data = in.Take(display.stored); + file.displays[frame] = display; + break; + } + case Capture::Tag::End: ended = true; break; + default: Fail("unknown capture record " + std::to_string(tag)); + } + offset += bytes; + } + if (!ended) Fail("capture has no end record (the game exited before the frame was presented?)"); + for (const auto& chunk : file.chunks) { + const auto range = std::find_if(file.ranges.begin(), file.ranges.end(), + [&](const Range& r) { return chunk.guest >= r.guest && chunk.guest < r.guest + r.bytes; }); + if (range == file.ranges.end()) Fail("chunk outside every range"); + range->chunks[(chunk.guest - range->guest) / Capture::ChunkBytes] = &chunk; + } + return file; +} + +// Per-thread counters by CPU performance level (xnu's PROC_PIDTHREADCOUNTS; not in the public SDK). +struct ThreadCountsData { + std::uint64_t instructions; + std::uint64_t cycles; + std::uint64_t userTimeMach; + std::uint64_t systemTimeMach; + std::uint64_t energyNj; +}; +struct ThreadCounts { + std::uint16_t length; + std::uint16_t reserved0; + std::uint32_t reserved1; + ThreadCountsData counts[4]; +}; +constexpr int ProcPidThreadCounts = 34; + +struct WorkerCounters { + std::uint64_t cpuNs = 0; + std::uint64_t systemNs = 0; + std::uint64_t instructions = 0; // 0 when the kernel does not report them +}; + +// Thread CPU time and retired instructions of the driver's queue workers (threads named agc-queue-*). +WorkerCounters Workers() { + WorkerCounters result; + thread_act_array_t threads = nullptr; + mach_msg_type_number_t count = 0; + if (task_threads(mach_task_self(), &threads, &count) != KERN_SUCCESS) return result; + for (mach_msg_type_number_t i = 0; i < count; ++i) { + thread_extended_info_data_t info{}; + mach_msg_type_number_t infoCount = THREAD_EXTENDED_INFO_COUNT; + if (thread_info(threads[i], THREAD_EXTENDED_INFO, reinterpret_cast(&info), &infoCount) == KERN_SUCCESS && + std::strncmp(info.pth_name, "agc-queue-", 10) == 0) { + result.cpuNs += info.pth_user_time + info.pth_system_time; + result.systemNs += info.pth_system_time; + thread_identifier_info_data_t identifier{}; + mach_msg_type_number_t identifierCount = THREAD_IDENTIFIER_INFO_COUNT; + ThreadCounts counts{}; + if (thread_info(threads[i], THREAD_IDENTIFIER_INFO, reinterpret_cast(&identifier), &identifierCount) == KERN_SUCCESS && + proc_pidinfo(getpid(), ProcPidThreadCounts, identifier.thread_id, &counts, sizeof counts) > 0) { + const auto levels = std::min(4, (counts.length - 8) / sizeof(ThreadCountsData)); + for (std::size_t level = 0; level < levels; ++level) result.instructions += counts.counts[level].instructions; + } + } + mach_port_deallocate(mach_task_self(), threads[i]); + } + vm_deallocate(mach_task_self(), reinterpret_cast(threads), count * sizeof(thread_act_t)); + return result; +} + +std::uint64_t ThreadCpuNs() { return clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID); } + +std::uint64_t ThreadInstructions() { + ThreadCounts counts{}; + std::uint64_t id = 0; + pthread_threadid_np(nullptr, &id); + if (proc_pidinfo(getpid(), ProcPidThreadCounts, id, &counts, sizeof counts) <= 0) return 0; + std::uint64_t total = 0; + for (std::size_t level = 0; level < std::min(4, (counts.length - 8) / sizeof(ThreadCountsData)); ++level) + total += counts.counts[level].instructions; + return total; +} + +class ReplayFlip final : public IFlipRequest { +public: + void GpuReady(const std::shared_ptr&) override {} + void Fail(std::exception_ptr) noexcept override {} +}; +class ReplayWait final : public IRenderingWait { +public: + void Wait() override {} +}; +class ReplayOutput final : public IVideoOutput { +public: + std::shared_ptr Reserve(const FlipInfo&) override { return std::make_shared(); } + std::shared_ptr CaptureRenderingWait(std::uint32_t) override { return std::make_shared(); } + void Fail(std::exception_ptr) noexcept override {} +}; + +struct DeltaStats { + std::atomic written{0}; + std::atomic kept{0}; + std::atomic cpuNs{0}; + std::atomic instructions{0}; +}; + +// Pixels that differ between two scanouts (colour channels and alpha separately), the largest colour delta. +struct PixelDiff { + std::size_t colour = 0; + std::size_t alpha = 0; + int maxDelta = 0; + std::uint32_t x0 = ~0u, x1 = 0, y0 = ~0u, y1 = 0; +}; +PixelDiff Compare(const std::vector& a, const std::vector& b, std::uint32_t width) { + PixelDiff diff; + for (std::size_t i = 0; i + 3 < std::min(a.size(), b.size()); i += 4) { + int delta = 0; + for (int c = 0; c < 3; ++c) delta = std::max(delta, std::abs(static_cast(a[i + c]) - static_cast(b[i + c]))); + if (delta != 0) { + ++diff.colour; + const auto x = static_cast(i / 4 % width); + const auto y = static_cast(i / 4 / width); + diff.x0 = std::min(diff.x0, x); + diff.x1 = std::max(diff.x1, x); + diff.y0 = std::min(diff.y0, y); + diff.y1 = std::max(diff.y1, y); + } + if (a[i + 3] != b[i + 3]) ++diff.alpha; + diff.maxDelta = std::max(diff.maxDelta, delta); + } + return diff; +} + +// A PNG decoded to BGRA8 (scanout layout), empty when it cannot be read. +std::vector ReadPng(const std::string& path, std::uint32_t& width, std::uint32_t& height) { + std::vector pixels; + CFURLRef url = CFURLCreateFromFileSystemRepresentation(nullptr, reinterpret_cast(path.c_str()), static_cast(path.size()), false); + CGImageSourceRef source = CGImageSourceCreateWithURL(url, nullptr); + CFRelease(url); + if (source == nullptr) return pixels; + CGImageRef image = CGImageSourceCreateImageAtIndex(source, 0, nullptr); + CFRelease(source); + if (image == nullptr) return pixels; + width = static_cast(CGImageGetWidth(image)); + height = static_cast(CGImageGetHeight(image)); + pixels.resize(std::size_t{width} * height * 4); + CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB(); + CGContextRef context = CGBitmapContextCreate(pixels.data(), width, height, 8, std::size_t{width} * 4, space, + static_cast(kCGBitmapByteOrder32Little) | static_cast(kCGImageAlphaNoneSkipFirst)); + CGContextDrawImage(context, CGRectMake(0, 0, width, height), image); + CGContextRelease(context); + CGColorSpaceRelease(space); + CGImageRelease(image); + return pixels; +} + +double Median(std::vector values) { + std::sort(values.begin(), values.end()); + return values.empty() ? 0 : values[values.size() / 2]; +} + +} + +int main(int argc, char** argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: %s [--frames N] [--repeat K] [--dump-png out.png] [--expect-hash HEX]\n" + " [--expect-png ref.png [--max-diff-pixels N]] [--metallib PATH] [--quiet]\n", argv[0]); + return 2; + } + std::string dir = argv[1]; + std::size_t frames = 0; + std::size_t repeat = 5; + std::string dumpPng; + std::string expect; + std::string expectPng; + std::size_t maxDiffPixels = 0; + std::string metallib = ANYPS5_AGC_REPLAY_METALLIB; + if (const char* value = std::getenv("ANYPS5_METAL_UTILITY_LIBRARY"); value != nullptr && *value != '\0') metallib = value; + bool quiet = false; + for (int i = 2; i < argc; ++i) { + const std::string arg = argv[i]; + const auto next = [&]() -> std::string { + if (i + 1 >= argc) Fail(arg + " needs a value"); + return argv[++i]; + }; + if (arg == "--frames") frames = std::stoul(next()); + else if (arg == "--repeat") repeat = std::max(1, std::stoul(next())); + else if (arg == "--dump-png") dumpPng = next(); + else if (arg == "--expect-hash") expect = next(); + else if (arg == "--expect-png") expectPng = next(); + else if (arg == "--max-diff-pixels") maxDiffPixels = std::stoul(next()); + else if (arg == "--metallib") metallib = next(); + else if (arg == "--quiet") quiet = true; + else Fail("unknown argument " + arg); + } + // The queue workers are created from this thread and inherit its QoS: user-interactive keeps + // them on performance cores, so thread CPU time does not jump with core type. + pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0); + // Compiled shaders persist next to the capture, never in the user's shared cache. + setenv("ANYPS5_SHADER_CACHE_DIR", (dir + "/shader_cache").c_str(), 0); + const auto loadStart = std::chrono::steady_clock::now(); + auto capture = Load(dir + "/frame.agc"); + std::size_t captured = 0; + for (const auto& submit : capture.submits) captured = std::max(captured, submit.frame + 1); + if (frames == 0 || frames > captured) frames = captured; + if (frames == 0) Fail("capture holds no submissions"); + std::vector submits; + for (const auto& submit : capture.submits) + if (submit.frame < frames) submits.push_back(&submit); + + // Guest memory at its guest addresses. + const auto pageSize = static_cast(getpagesize()); + std::vector ranges; + // A range the replay process already uses at that address (the driver's staging buffer is a host + // mapping, which can land where this process has its own) is mapped elsewhere: the driver + // translates guest addresses through the ranges, so only identity-mapped guest pointers need + // the guest address itself, and those point into the title's GPU allocations. + std::vector> moved; + for (auto& range : capture.ranges) { + const auto begin = range.guest & ~(pageSize - 1); + const auto end = (range.guest + range.bytes + pageSize - 1) & ~(pageSize - 1); + void* mapped = mmap(reinterpret_cast(begin), end - begin, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + if (mapped == MAP_FAILED) Fail("cannot map guest memory"); + if (mapped != reinterpret_cast(begin)) { + std::fprintf(stderr, "note: guest range 0x%llx (+0x%llx) is in use in this process; mapped at %p\n", + static_cast(range.guest), static_cast(range.bytes), mapped); + range.host = static_cast(mapped) + (range.guest - begin); + } else { + range.host = reinterpret_cast(range.guest); + } + ranges.push_back({range.guest, {range.host, range.bytes}, range.writable}); + } + const auto host = [&](std::uint64_t guest) { + for (const auto& range : capture.ranges) + if (guest >= range.guest && guest - range.guest < range.bytes) return range.host + (guest - range.guest); + Fail("guest address outside the captured ranges"); + }; + std::sort(ranges.begin(), ranges.end(), [](const auto& a, const auto& b) { return a.guestAddress < b.guestAddress; }); + std::uint64_t residentBytes = 0; + for (const auto& chunk : capture.chunks) { + Capture::Decompress(chunk.data, chunk.stored, host(chunk.guest), chunk.raw); + residentBytes += chunk.raw; + } + + id device = MTLCreateSystemDefaultDevice(); + if (device == nil) Fail("no Metal device"); + NSError* error = nil; + id library = [device newLibraryWithURL:[NSURL fileURLWithPath:[NSString stringWithUTF8String:metallib.c_str()]] error:&error]; + if (library == nil) Fail("cannot load the utility metallib " + metallib + " (--metallib)"); + Metal::ApplyDirectWorkingSet(); + auto& driver = Metal::MetalDriver::Get(); + driver.Configure((__bridge void*)device, (__bridge void*)library, ranges, {}, 1); + driver.SetSkipInvalidCommandBuffers(true); + std::set handles; + for (const auto* submit : submits) + for (std::uint32_t i = 0; i + 1 < submit->count; ++i) + if (submit->words[i] == FlipPacketHeader || submit->words[i] == RenderingWaitPacketHeader) handles.insert(submit->words[i + 1]); + std::vector> outputs; + for (const auto handle : handles) { + outputs.push_back(std::make_shared()); + driver.RegisterVideoOutput(handle, outputs.back()); + } + + // Each queue's submissions in order; the hook applies a submission's page delta just before it runs. + std::map> byQueue; + for (const auto* submit : submits) byQueue[submit->queue].push_back(submit); + std::map cursor; + std::mutex cursorMutex; + DeltaStats deltas; + Capture::InstallReplay([&](std::uint32_t queue, const Capture::Context& context) { + const Submit* submit = nullptr; + { + std::lock_guard lock(cursorMutex); + auto& list = byQueue[queue]; + auto& next = cursor[queue]; + if (next < list.size()) submit = list[next++]; + } + // Workers run at the QoS the game's guest threads give them only by inheritance; pin them to + // user-interactive so thread CPU time does not jump between core types. + thread_local bool raised = false; + if (!raised) { + pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0); + raised = true; + } + if (submit == nullptr || submit->pages.empty()) return; + const auto started = ThreadCpuNs(); + const auto startedInstructions = ThreadInstructions(); + std::lock_guard gpu(context.gpuMutex); + context.drain(); + for (const auto& page : submit->pages) { + auto* target = host(page.guest); + const auto current = Capture::Hash(target, page.raw); + if (current == page.hash) continue; + if (current != page.previous) { + ++deltas.kept; + continue; + } + Capture::Decompress(page.data, page.stored, target, page.raw); + ++deltas.written; + } + deltas.cpuNs += ThreadCpuNs() - started; + deltas.instructions += ThreadInstructions() - startedInstructions; + }); + Capture::EnableGpuBusy(true); + const auto loadMs = std::chrono::duration(std::chrono::steady_clock::now() - loadStart).count(); + std::fprintf(stderr, "capture: %.1f MiB file, %zu ranges, %.1f MiB resident, %zu submissions in %zu frame(s), %zu shaders; loaded in %.0f ms\n", + static_cast(capture.fileBytes) / (1 << 20), capture.ranges.size(), static_cast(residentBytes) / (1 << 20), + submits.size(), frames, capture.shaders.size(), loadMs); + + struct Sample { + double wallMs; + double cpuMs; + double gpuMs; + double minstr; + double systemMs; + std::uint64_t hash; + }; + std::vector> samples(frames); + std::vector lastPixels; + std::vector> previousPixels(frames); + const auto zeroChunk = std::vector(Capture::ChunkBytes); + for (std::size_t rep = 0; rep < repeat; ++rep) { + if (rep != 0) { + // Pre-frame memory: chunks that differ from the capture are written again. + for (const auto& range : capture.ranges) { + for (std::size_t c = 0; c < range.chunks.size(); ++c) { + const auto offset = c * Capture::ChunkBytes; + auto* data = range.host + offset; + const auto bytes = static_cast(std::min(Capture::ChunkBytes, range.bytes - offset)); + if (const auto* chunk = range.chunks[c]) { + if (Capture::Hash(data, bytes) != chunk->hash) Capture::Decompress(chunk->data, chunk->stored, data, chunk->raw); + continue; + } + std::vector resident((bytes + pageSize - 1) / pageSize, 1); + mincore(data, bytes, resident.data()); + if (std::none_of(resident.begin(), resident.end(), [](char r) { return r != 0; })) continue; + if (std::memcmp(data, zeroChunk.data(), bytes) != 0) std::memset(data, 0, bytes); + } + } + } + for (const auto& [queue, state] : capture.states) driver.ReplaceQueueStateForReplay(queue, state); + { + std::lock_guard lock(cursorMutex); + cursor.clear(); + } + std::uint32_t registry = 0xffffffffu; + std::size_t next = 0; + for (std::size_t frame = 0; frame < frames; ++frame) { + Capture::TakeGpuBusyMs(); + const auto deltaCpuBefore = deltas.cpuNs.load(); + const auto deltaInstrBefore = deltas.instructions.load(); + const auto workersBefore = Workers(); + const auto wallBefore = std::chrono::steady_clock::now(); + for (; next < submits.size() && submits[next]->frame == frame; ++next) { + const auto* submit = submits[next]; + if (submit->registry != registry && capture.registries.count(submit->registry) != 0) { + driver.ReplaceShaderRegistryForReplay(capture.registries.at(submit->registry)); + registry = submit->registry; + } + if (submit->count != 0) std::memcpy(host(submit->address), submit->words, std::size_t{submit->count} * 4); + driver.SubmitCommandBuffer(submit->address, submit->count, 0, submit->queue); + } + driver.WaitIdle(); + const auto wallMs = std::chrono::duration(std::chrono::steady_clock::now() - wallBefore).count(); + const auto workersAfter = Workers(); + const auto cpuMs = static_cast(workersAfter.cpuNs - workersBefore.cpuNs - (deltas.cpuNs.load() - deltaCpuBefore)) / 1e6; + const auto gpuMs = Capture::TakeGpuBusyMs(); + const auto minstr = static_cast(workersAfter.instructions - workersBefore.instructions - (deltas.instructions.load() - deltaInstrBefore)) / 1e6; + std::uint64_t hash = 0; + if (const auto display = capture.displays.find(static_cast(frame)); display != capture.displays.end()) { + NativeGuestMemory::BorrowedRangesScope scope(ranges); + lastPixels = Metal::MetalPresentation::ReadPixels(display->second.buffer); + hash = Capture::Hash(lastPixels.data(), lastPixels.size()); + } + const auto systemMs = static_cast(workersAfter.systemNs - workersBefore.systemNs) / 1e6; + samples[frame].push_back({wallMs, cpuMs, gpuMs, minstr, systemMs, hash}); + if (rep != 0 && hash != samples[frame][rep - 1].hash) { + const auto display = capture.displays.at(static_cast(frame)); + const auto diff = Compare(previousPixels[frame], lastPixels, display.buffer.width); + std::printf(" repeat %zu differs from repeat %zu: %zu pixels in colour (max delta %d) in x %u..%u y %u..%u, %zu in alpha\n", rep + 1, + rep, diff.colour, diff.maxDelta, diff.x0, diff.x1, diff.y0, diff.y1, diff.alpha); + } + previousPixels[frame] = lastPixels; + if (!quiet) + std::printf("repeat %zu frame %zu: wall %.2f ms worker cpu %.2f ms (%.2f system) gpu busy %.2f ms worker %.1f M instructions hash %016" PRIx64 "\n", + rep + 1, frame, wallMs, cpuMs, systemMs, gpuMs, minstr, hash); + } + } + + int status = 0; + std::printf("page deltas: %llu written, %llu kept (replayed GPU work wrote them), %.1f ms CPU (excluded)\n", + static_cast(deltas.written.load()), static_cast(deltas.kept.load()), + static_cast(deltas.cpuNs.load()) / 1e6 / static_cast(repeat)); + for (std::size_t frame = 0; frame < frames; ++frame) { + const auto& all = samples[frame]; + std::vector timed(all.begin() + (all.size() > 1 ? 1 : 0), all.end()); + std::vector wall, cpu, gpu, instr; + for (const auto& sample : timed) { + wall.push_back(sample.wallMs); + cpu.push_back(sample.cpuMs); + gpu.push_back(sample.gpuMs); + instr.push_back(sample.minstr); + } + const auto spread = [](const std::vector& v) { + const auto [lo, hi] = std::minmax_element(v.begin(), v.end()); + const auto median = Median(v); + return median > 0 ? 100.0 * (*hi - *lo) / median : 0.0; + }; + // Interquartile range over the median: the spread of the middle half of the repeats. + const auto iqr = [](std::vector v) { + std::sort(v.begin(), v.end()); + const auto median = v[v.size() / 2]; + return median > 0 ? 100.0 * (v[(v.size() * 3) / 4] - v[v.size() / 4]) / median : 0.0; + }; + const auto range = [](const std::vector& v) { + const auto [lo, hi] = std::minmax_element(v.begin(), v.end()); + return std::make_pair(*lo, *hi); + }; + const auto [wallLo, wallHi] = range(wall); + const auto [cpuLo, cpuHi] = range(cpu); + const auto [gpuLo, gpuHi] = range(gpu); + std::printf("frame %zu over %zu timed repeats (first is warm-up, %.1f ms wall):\n", frame, timed.size(), all.front().wallMs); + const auto [instrLo, instrHi] = range(instr); + std::printf(" worker cpu ms median %.2f min %.2f max %.2f spread %.1f%% iqr %.1f%%\n", Median(cpu), cpuLo, cpuHi, spread(cpu), iqr(cpu)); + std::printf(" wall ms median %.2f min %.2f max %.2f spread %.1f%% iqr %.1f%%\n", Median(wall), wallLo, wallHi, spread(wall), iqr(wall)); + std::printf(" gpu busy ms median %.2f min %.2f max %.2f spread %.1f%% iqr %.1f%%\n", Median(gpu), gpuLo, gpuHi, spread(gpu), iqr(gpu)); + std::printf(" worker M instr median %.1f min %.1f max %.1f spread %.1f%% iqr %.1f%%\n", Median(instr), instrLo, instrHi, + spread(instr), iqr(instr)); + std::set hashes; + for (const auto& sample : timed) hashes.insert(sample.hash); + const auto replayed = all.back().hash; + std::printf(" hash %016" PRIx64 " %s\n", replayed, hashes.size() == 1 ? "(stable over the timed repeats)" : "(UNSTABLE across timed repeats)"); + if (hashes.size() != 1) status = 1; + if (const auto display = capture.displays.find(static_cast(frame)); display != capture.displays.end()) { + const auto& shown = display->second; + std::printf(" game showed %016" PRIx64 ": %s\n", shown.hash, shown.hash == replayed ? "replay matches the game" : "replay differs from the game"); + if (shown.hash != replayed && frame + 1 == frames && lastPixels.size() == shown.raw) { + std::vector game(shown.raw); + Capture::Decompress(shown.data, shown.stored, game.data(), shown.raw); + const auto diff = Compare(game, lastPixels, shown.buffer.width); + std::printf(" vs game: %zu pixels differ in colour (%.4f%%, max delta %d) in x %u..%u y %u..%u, %zu in alpha\n", diff.colour, + 100.0 * static_cast(diff.colour) / static_cast(game.size() / 4), diff.maxDelta, diff.x0, diff.x1, diff.y0, diff.y1, + diff.alpha); + } + } + } + if (!expect.empty()) { + const auto wanted = std::strtoull(expect.c_str(), nullptr, 16); + bool all = true; + const auto& last = samples[frames - 1]; + for (std::size_t i = last.size() > 1 ? 1 : 0; i < last.size(); ++i) all = all && last[i].hash == wanted; + std::printf("expect-hash %016llx: %s\n", static_cast(wanted), all ? "match" : "MISMATCH"); + if (!all) status = 1; + } + if (!expectPng.empty()) { + std::uint32_t width = 0, height = 0; + const auto reference = ReadPng(expectPng, width, height); + const auto display = capture.displays.find(static_cast(frames - 1)); + if (reference.empty() || display == capture.displays.end() || width != display->second.buffer.width || height != display->second.buffer.height || + reference.size() != lastPixels.size()) { + std::printf("expect-png %s: cannot compare (unreadable or a different size)\n", expectPng.c_str()); + status = 1; + } else { + const auto diff = Compare(reference, lastPixels, width); + const bool ok = diff.colour <= maxDiffPixels; + std::printf("expect-png %s: %zu pixels differ (max delta %d), allowed %zu: %s\n", expectPng.c_str(), diff.colour, diff.maxDelta, + maxDiffPixels, ok ? "match" : "MISMATCH"); + if (!ok) status = 1; + } + } + if (!dumpPng.empty()) { + const auto display = capture.displays.find(static_cast(frames - 1)); + if (display == capture.displays.end() || !Capture::WriteBgraPng(dumpPng, lastPixels, display->second.buffer.width, display->second.buffer.height)) + std::fprintf(stderr, "anyps5_agc_replay: could not write %s\n", dumpPng.c_str()); + else std::printf("wrote %s\n", dumpPng.c_str()); + } + std::fflush(stdout); + // The driver's workers and Metal objects are not torn down: exit directly. + _exit(status); +} From 05f2f2a6d3d526976b4b554300dc7691d80be37d Mon Sep 17 00:00:00 2001 From: RINNECODER <93304526+RINNECODER@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:17:15 -0500 Subject: [PATCH 2/3] agc-replay: keep the captured cross-queue order, record ranges mapped mid-frame The replay hook now waits until a submission is the next one in the capture's global order before applying its page delta (5 s escape hatch, counted and reported). The capture records a range that appears after the snapshot with its contents before the submission that first sees it. The residency filter's reliance on MINCORE_PAGED_OUT is documented. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DgW4n3qYgi5jAAohEmXRQj --- .../Execution/Metal/AgcFrameCapture.mm | 138 ++++++++++-------- tools/agc-replay/AgcReplay.mm | 33 ++++- 2 files changed, 108 insertions(+), 63 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.mm b/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.mm index afe434e2db39..97cb43ef3329 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.mm +++ b/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.mm @@ -54,7 +54,10 @@ Registers ReadRegisters(Reader& in) { return registers; } -// Residency per host page of [begin, begin + bytes): nonzero when the page may hold data. +// Residency per host page of [begin, begin + bytes): nonzero when the page may hold data. Any flag +// counts, including MINCORE_PAGED_OUT, which xnu reports for pages held by the compressor or the +// swap pager; only a page the process never populated reads as 0, and its contents are zero. +// ANYPS5_AGC_CAPTURE_READ_ALL=1 reads every page instead. std::vector Residency(const std::byte* begin, std::size_t bytes, std::uintptr_t& base, std::size_t& page) { page = static_cast(getpagesize()); base = reinterpret_cast(begin) & ~(static_cast(page) - 1); @@ -280,65 +283,7 @@ void Begin(const Context& context) { written = 16; std::uint64_t residentBytes = 0; std::uint64_t storedChunks = 0; - for (const auto& range : context.ranges) { - Writer record; - record.Put(range.guestAddress); - record.Put(static_cast(range.host.size())); - record.Put(static_cast(range.writable)); - record.Put(std::uint32_t{0}); - Emit(Tag::Range, record); - Table table{range.guestAddress, range.host.data(), range.host.size(), {}}; - table.pages.resize((range.host.size() + PageBytes - 1) / PageBytes); - std::uintptr_t base = 0; - std::size_t page = 0; - const auto resident = Residency(range.host.data(), range.host.size(), base, page); - const auto chunks = (range.host.size() + ChunkBytes - 1) / ChunkBytes; - // Batches keep at most 256 compressed chunks in memory at once. - constexpr std::size_t Batch = 256; - for (std::size_t first = 0; first < chunks; first += Batch) { - const auto count = std::min(Batch, chunks - first); - std::vector> stored(count); - std::vector hashes(count); - std::vector present(count, 0); - auto* storedOut = stored.data(); - auto* hashesOut = hashes.data(); - auto* presentOut = present.data(); - auto* pagesOut = table.pages.data(); - const auto* residentIn = &resident; - const auto* host = range.host.data(); - const auto hostBytes = range.host.size(); - dispatch_apply(count, dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^(std::size_t i) { - const auto offset = (first + i) * ChunkBytes; - const auto bytes = std::min(ChunkBytes, hostBytes - offset); - const auto* data = host + offset; - for (std::size_t p = 0; p < bytes; p += PageBytes) { - const auto pageBytes = std::min(PageBytes, bytes - p); - pagesOut[(offset + p) / PageBytes] = AnyResident(*residentIn, base, page, data + p, pageBytes) - ? Hash(data + p, pageBytes) : ZeroHash(pageBytes); - } - if (!AnyResident(*residentIn, base, page, data, bytes)) return; - hashesOut[i] = Hash(data, bytes); - if (hashesOut[i] == ZeroHash(bytes) && std::all_of(data, data + bytes, [](std::byte b) { return b == std::byte{0}; })) return; - presentOut[i] = 1; - storedOut[i] = Compress(data, bytes); - }); - for (std::size_t i = 0; i < count; ++i) { - if (!present[i]) continue; - const auto offset = (first + i) * ChunkBytes; - const auto bytes = std::min(ChunkBytes, range.host.size() - offset); - Writer chunk; - chunk.Put(range.guestAddress + offset); - chunk.Put(static_cast(bytes)); - chunk.Put(static_cast(stored[i].size())); - chunk.Put(hashes[i]); - chunk.PutBytes(stored[i].data(), stored[i].size()); - Emit(Tag::Chunk, chunk); - residentBytes += bytes; - ++storedChunks; - } - } - tables.push_back(std::move(table)); - } + for (const auto& range : context.ranges) SnapshotRange(range, residentBytes, storedChunks); phase = Phase::Recording; recording.store(true, std::memory_order_release); std::fprintf(stderr, "[agc-capture] flip %llu: snapshot of %zu ranges, %llu MiB in %llu chunks, %.1f MiB written in %.0f ms\n", @@ -347,9 +292,82 @@ void Begin(const Context& context) { std::chrono::duration(std::chrono::steady_clock::now() - started).count()); } + // Records `range` and its resident, non-zero chunks, and starts its page hashes. + void SnapshotRange(const NativeGuestMemory::BorrowedRange& range, std::uint64_t& residentBytes, std::uint64_t& storedChunks) { + Writer record; + record.Put(range.guestAddress); + record.Put(static_cast(range.host.size())); + record.Put(static_cast(range.writable)); + record.Put(std::uint32_t{0}); + Emit(Tag::Range, record); + Table table{range.guestAddress, range.host.data(), range.host.size(), {}}; + table.pages.resize((range.host.size() + PageBytes - 1) / PageBytes); + std::uintptr_t base = 0; + std::size_t page = 0; + const auto resident = Residency(range.host.data(), range.host.size(), base, page); + const auto chunks = (range.host.size() + ChunkBytes - 1) / ChunkBytes; + // Batches keep at most 256 compressed chunks in memory at once. + constexpr std::size_t Batch = 256; + for (std::size_t first = 0; first < chunks; first += Batch) { + const auto count = std::min(Batch, chunks - first); + std::vector> stored(count); + std::vector hashes(count); + std::vector present(count, 0); + auto* storedOut = stored.data(); + auto* hashesOut = hashes.data(); + auto* presentOut = present.data(); + auto* pagesOut = table.pages.data(); + const auto* residentIn = &resident; + const auto* host = range.host.data(); + const auto hostBytes = range.host.size(); + dispatch_apply(count, dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^(std::size_t i) { + const auto offset = (first + i) * ChunkBytes; + const auto bytes = std::min(ChunkBytes, hostBytes - offset); + const auto* data = host + offset; + for (std::size_t p = 0; p < bytes; p += PageBytes) { + const auto pageBytes = std::min(PageBytes, bytes - p); + pagesOut[(offset + p) / PageBytes] = AnyResident(*residentIn, base, page, data + p, pageBytes) + ? Hash(data + p, pageBytes) : ZeroHash(pageBytes); + } + if (!AnyResident(*residentIn, base, page, data, bytes)) return; + hashesOut[i] = Hash(data, bytes); + if (hashesOut[i] == ZeroHash(bytes) && std::all_of(data, data + bytes, [](std::byte b) { return b == std::byte{0}; })) return; + presentOut[i] = 1; + storedOut[i] = Compress(data, bytes); + }); + for (std::size_t i = 0; i < count; ++i) { + if (!present[i]) continue; + const auto offset = (first + i) * ChunkBytes; + const auto bytes = std::min(ChunkBytes, range.host.size() - offset); + Writer chunk; + chunk.Put(range.guestAddress + offset); + chunk.Put(static_cast(bytes)); + chunk.Put(static_cast(stored[i].size())); + chunk.Put(hashes[i]); + chunk.PutBytes(stored[i].data(), stored[i].size()); + Emit(Tag::Chunk, chunk); + residentBytes += bytes; + ++storedChunks; + } + } + tables.push_back(std::move(table)); + } + // Appends the pages that changed since the previous checkpoint to `record`; returns their count. std::uint32_t Deltas(const Context& context, Writer& record) { std::uint32_t count = 0; + // A range the title mapped after the snapshot: its contents now are its initial state. + for (const auto& range : context.ranges) { + const bool known = std::any_of(tables.begin(), tables.end(), [&](const Table& table) { + return table.guest == range.guestAddress && table.host == range.host.data() && table.bytes == range.host.size(); + }); + if (known) continue; + std::uint64_t residentBytes = 0; + std::uint64_t storedChunks = 0; + SnapshotRange(range, residentBytes, storedChunks); + std::fprintf(stderr, "[agc-capture] range 0x%llx (+0x%zx) mapped during the frame: %llu MiB recorded\n", + static_cast(range.guestAddress), range.host.size(), static_cast(residentBytes >> 20)); + } for (auto& table : tables) { // A range that is no longer borrowed is not read by this submission. const auto found = std::find_if(context.ranges.begin(), context.ranges.end(), [&](const auto& range) { diff --git a/tools/agc-replay/AgcReplay.mm b/tools/agc-replay/AgcReplay.mm index d9b61cc43634..478e75d87bd2 100644 --- a/tools/agc-replay/AgcReplay.mm +++ b/tools/agc-replay/AgcReplay.mm @@ -35,6 +35,8 @@ #include #include #include +#include +#include #include #include #include @@ -77,6 +79,7 @@ const std::uint32_t* words; std::uint32_t count; std::uint32_t frame; + std::size_t index; // position in the capture's global execution order std::vector pages; }; @@ -415,8 +418,10 @@ int main(int argc, char** argv) { if (frames == 0 || frames > captured) frames = captured; if (frames == 0) Fail("capture holds no submissions"); std::vector submits; - for (const auto& submit : capture.submits) - if (submit.frame < frames) submits.push_back(&submit); + for (std::size_t i = 0; i < capture.submits.size(); ++i) { + capture.submits[i].index = i; + if (capture.submits[i].frame < frames) submits.push_back(&capture.submits[i]); + } // Guest memory at its guest addresses. const auto pageSize = static_cast(getpagesize()); @@ -476,6 +481,11 @@ int main(int argc, char** argv) { for (const auto* submit : submits) byQueue[submit->queue].push_back(submit); std::map cursor; std::mutex cursorMutex; + // The capture recorded submissions in one global order across queues, and each page delta's + // previous hash follows it: a worker waits until its submission is the next one in that order. + std::condition_variable turn; + std::size_t globalNext = 0; + std::atomic outOfOrder{0}; DeltaStats deltas; Capture::InstallReplay([&](std::uint32_t queue, const Capture::Context& context) { const Submit* submit = nullptr; @@ -492,7 +502,20 @@ int main(int argc, char** argv) { pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0); raised = true; } - if (submit == nullptr || submit->pages.empty()) return; + if (submit == nullptr) return; + const auto advance = [&] { + { + std::lock_guard lock(cursorMutex); + globalNext = std::max(globalNext, submit->index + 1); + } + turn.notify_all(); + }; + { + std::unique_lock lock(cursorMutex); + // A wait the capture's order cannot satisfy (a queue blocked on this one) gives up after 5 s. + if (!turn.wait_for(lock, std::chrono::seconds(5), [&] { return globalNext >= submit->index; })) ++outOfOrder; + } + if (submit->pages.empty()) return advance(); const auto started = ThreadCpuNs(); const auto startedInstructions = ThreadInstructions(); std::lock_guard gpu(context.gpuMutex); @@ -510,6 +533,7 @@ int main(int argc, char** argv) { } deltas.cpuNs += ThreadCpuNs() - started; deltas.instructions += ThreadInstructions() - startedInstructions; + advance(); }); Capture::EnableGpuBusy(true); const auto loadMs = std::chrono::duration(std::chrono::steady_clock::now() - loadStart).count(); @@ -552,6 +576,7 @@ int main(int argc, char** argv) { { std::lock_guard lock(cursorMutex); cursor.clear(); + globalNext = submits.empty() ? 0 : submits.front()->index; } std::uint32_t registry = 0xffffffffu; std::size_t next = 0; @@ -598,6 +623,8 @@ int main(int argc, char** argv) { } int status = 0; + if (outOfOrder.load() != 0) + std::printf("warning: %llu submissions ran out of the captured cross-queue order\n", static_cast(outOfOrder.load())); std::printf("page deltas: %llu written, %llu kept (replayed GPU work wrote them), %.1f ms CPU (excluded)\n", static_cast(deltas.written.load()), static_cast(deltas.kept.load()), static_cast(deltas.cpuNs.load()) / 1e6 / static_cast(repeat)); From f164a29c6d355935046ad011901bb433332ee879 Mon Sep 17 00:00:00 2001 From: RINNECODER <93304526+RINNECODER@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:33:29 -0500 Subject: [PATCH 3/3] agc-replay: activate ranges at their submission, finish captures with a blank present Replay: a guest range the capture recorded mid-frame was mapped and filled before the first submission. It now exists only from the submission that introduced it (the driver's borrowed ranges are replaced after a drain, its chunks are written then), a later range replaces an overlapping earlier one, and the next repeat starts from the pre-frame set again. Capture: a present in the recorded window without a buffer took a slot but never counted towards completion, so the End record was not written. It now counts and only has no Display record. The two rules live in AgcCaptureLogic.hpp and are covered by metal_utility_tests. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DgW4n3qYgi5jAAohEmXRQj --- .../Execution/Metal/AgcCaptureLogic.hpp | 90 +++++++++++++++++++ .../Execution/Metal/AgcFrameCapture.mm | 45 ++++++---- .../Graphics/Metal/CMakeLists.txt | 1 + .../tests/metal/CaptureLogic.mm | 52 +++++++++++ .../prx/libSceAgcDriver/tests/metal/Main.mm | 3 + tools/agc-replay/AgcReplay.mm | 82 ++++++++++++++--- 6 files changed, 242 insertions(+), 31 deletions(-) create mode 100644 core/libs/prx/libSceAgcDriver/Execution/Metal/AgcCaptureLogic.hpp create mode 100644 core/libs/prx/libSceAgcDriver/tests/metal/CaptureLogic.mm diff --git a/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcCaptureLogic.hpp b/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcCaptureLogic.hpp new file mode 100644 index 000000000000..7bbb6d3d27ef --- /dev/null +++ b/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcCaptureLogic.hpp @@ -0,0 +1,90 @@ +#pragma once + +// Pure bookkeeping shared by the AGC frame capture and the replay tool (no Metal, no I/O), so the +// two rules below can be tested on their own. + +#include +#include +#include +#include + +namespace AgcDriver::Metal::AgcCapture { + +// Which presents belong to the recorded frames, and when the capture is complete. Present k scans +// out flip k, so the presents of the recorded flips are target .. target + frames - 1. A present +// in that window without a buffer (a blanked output) still takes its slot: it has no Display +// record, but it counts towards completion, otherwise the End record would never be written. +class PresentTracker { +public: + struct Step { + bool inWindow = false; + bool recordDisplay = false; // the present has a buffer to record + std::uint32_t frame = 0; + bool complete = false; // every frame of the window has been presented + }; + + // `armed` is false while the capture is not recording: the present is only counted. + Step Observe(std::uint64_t target, std::uint64_t frames, bool armed, bool hasBuffer) { + ++presents_; + Step step; + if (!armed || presents_ < target || presents_ >= target + frames) return step; + step.inWindow = true; + step.recordDisplay = hasBuffer; + step.frame = static_cast(presents_ - target); + step.complete = ++seen_ == frames; + return step; + } + std::uint64_t Presents() const { return presents_; } + +private: + std::uint64_t presents_ = 0; + std::uint64_t seen_ = 0; +}; + +// The guest ranges of a capture over its submissions. A range recorded while the frame was running +// exists only from the submission that introduced it (`activation`, an index into the capture's +// global submission order; 0 for the pre-frame snapshot), and replaces an earlier range it overlaps. +class RangeTimeline { +public: + void Add(std::uint64_t guest, std::uint64_t bytes, std::size_t activation) { ranges_.push_back({guest, bytes, activation}); } + + // Indices (in Add order) of the ranges that exist when `submission` runs. + std::vector LiveAt(std::size_t submission) const { + std::vector live; + for (std::size_t i = 0; i < ranges_.size(); ++i) { + if (ranges_[i].activation > submission) continue; + bool replaced = false; + for (std::size_t j = 0; j < ranges_.size() && !replaced; ++j) + replaced = j != i && ranges_[j].activation <= submission && Replaces(j, i); + if (!replaced) live.push_back(i); + } + return live; + } + + // The first submission after `submission` at which the set of live ranges changes, or npos. + std::size_t NextChange(std::size_t submission) const { + std::size_t next = npos; + for (const auto& range : ranges_) + if (range.activation > submission) next = std::min(next, range.activation); + return next; + } + + static constexpr std::size_t npos = ~std::size_t{0}; + +private: + struct Entry { + std::uint64_t guest; + std::uint64_t bytes; + std::size_t activation; + }; + // Whether range j (activated no later than the submission asked about) takes the place of range i. + bool Replaces(std::size_t j, std::size_t i) const { + const auto& a = ranges_[j]; + const auto& b = ranges_[i]; + const bool overlap = a.guest < b.guest + b.bytes && b.guest < a.guest + a.bytes; + return overlap && (a.activation > b.activation || (a.activation == b.activation && j > i)); + } + std::vector ranges_; +}; + +} diff --git a/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.mm b/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.mm index 97cb43ef3329..04baa92cca5e 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.mm +++ b/core/libs/prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.mm @@ -1,4 +1,5 @@ #include "AgcFrameCapture.hpp" +#include "AgcCaptureLogic.hpp" #include "prx/libSceAgcDriver/Execution/include/ShaderCapture.hpp" #include "prx/libSceAgcDriver/Graphics/Metal/MetalPresentation.hpp" #include "prx/libSceAgcDriver/Graphics/Metal/GpuBusy.hpp" @@ -201,17 +202,23 @@ void After(const DriverDetail::Submission& submission, const Context& context) { void Present(const DisplayBuffer* buffer, const std::vector* pixels, const Context& context) { std::lock_guard lock(mutex); - ++presents; - if (phase == Phase::Done || phase == Phase::Waiting || buffer == nullptr) return; - // Present k scans out flip k: the presents of the recorded flips are target .. target + frames - 1. - if (presents < target || presents >= target + frames) return; + // A present in the window without a buffer (a blanked output) keeps its slot and counts + // towards completion; it only has no Display record. + const auto step = presentTracker.Observe(target, frames, phase != Phase::Done && phase != Phase::Waiting, buffer != nullptr); + if (!step.inWindow) return; + const auto presents = presentTracker.Presents(); + if (!step.recordDisplay) { + std::fprintf(stderr, "[agc-capture] present %llu showed no buffer: frame %u has no display\n", + static_cast(presents), step.frame); + return Complete(step); + } std::vector read; if (pixels == nullptr || pixels->empty()) { NativeGuestMemory::BorrowedRangesScope scope(context.ranges); read = MetalPresentation::ReadPixels(*buffer); pixels = &read; } - const auto frame = static_cast(presents - target); + const auto frame = step.frame; const auto hash = Hash(pixels->data(), pixels->size()); Writer record; record.Put(frame); @@ -228,20 +235,23 @@ void Present(const DisplayBuffer* buffer, const std::vector* pixels, std::fprintf(stderr, "[agc-capture] frame %u shown by present %llu: %ux%u format 0x%llx tiling %u hash %016llx%s\n", frame, static_cast(presents), buffer->width, buffer->height, static_cast(buffer->pixelFormat), buffer->tilingMode, static_cast(hash), wrote ? "" : " (png not written)"); - if (++displays == frames) { - phase = Phase::Done; - Writer end; - end.Put(static_cast(frames)); - end.Put(static_cast(submissions)); - Emit(Tag::End, end); - std::fclose(file); - file = nullptr; - std::fprintf(stderr, "[agc-capture] done: %s (%.1f MiB)\n", (dir / "frame.agc").c_str(), static_cast(written) / (1 << 20)); - active.store(false); - } + Complete(step); } private: + void Complete(const AgcCapture::PresentTracker::Step& step) { + if (!step.complete) return; + phase = Phase::Done; + Writer end; + end.Put(static_cast(frames)); + end.Put(static_cast(submissions)); + Emit(Tag::End, end); + std::fclose(file); + file = nullptr; + std::fprintf(stderr, "[agc-capture] done: %s (%.1f MiB)\n", (dir / "frame.agc").c_str(), static_cast(written) / (1 << 20)); + active.store(false); + } + enum class Phase { Waiting, Recording, AwaitPresent, Done }; struct Table { std::uint64_t guest; @@ -457,8 +467,7 @@ void SnapshotRange(const NativeGuestMemory::BorrowedRange& range, std::uint64_t& std::uint64_t target = 600; std::uint64_t frames = 1; std::uint64_t flips = 0; - std::uint64_t presents = 0; - std::uint64_t displays = 0; + PresentTracker presentTracker; std::uint64_t submissions = 0; std::uint64_t deltaPages = 0; std::uint64_t written = 0; diff --git a/core/libs/prx/libSceAgcDriver/Graphics/Metal/CMakeLists.txt b/core/libs/prx/libSceAgcDriver/Graphics/Metal/CMakeLists.txt index 237d2b4b3a7c..50663a1000c2 100644 --- a/core/libs/prx/libSceAgcDriver/Graphics/Metal/CMakeLists.txt +++ b/core/libs/prx/libSceAgcDriver/Graphics/Metal/CMakeLists.txt @@ -80,6 +80,7 @@ if(BUILD_TESTING) "${metalTestDirectory}/Presentation.mm" "${metalTestDirectory}/GuestMemory.mm" "${metalTestDirectory}/CommandBatching.mm" + "${metalTestDirectory}/CaptureLogic.mm" ) target_compile_options(metal_utility_tests PRIVATE "$<$:-fobjc-arc>") target_link_libraries(metal_utility_tests PRIVATE anyps5_metal_utilities) diff --git a/core/libs/prx/libSceAgcDriver/tests/metal/CaptureLogic.mm b/core/libs/prx/libSceAgcDriver/tests/metal/CaptureLogic.mm new file mode 100644 index 000000000000..df179900deb7 --- /dev/null +++ b/core/libs/prx/libSceAgcDriver/tests/metal/CaptureLogic.mm @@ -0,0 +1,52 @@ +#include "MetalTestSupport.hpp" +#include "prx/libSceAgcDriver/Execution/Metal/AgcCaptureLogic.hpp" + +// The capture's present bookkeeping and the replay's range timeline. + +namespace { + +using AgcDriver::Metal::AgcCapture::PresentTracker; +using AgcDriver::Metal::AgcCapture::RangeTimeline; +using MetalTests::Require; + +void PresentWithoutBufferStillCompletes() { + PresentTracker tracker; + // Presents 1..2 precede the window; the window is presents 3..5 and the middle one is blank. + Require(!tracker.Observe(3, 3, true, true).inWindow, "present before the window was taken"); + Require(!tracker.Observe(3, 3, true, true).inWindow, "present before the window was taken"); + const auto first = tracker.Observe(3, 3, true, true); + Require(first.inWindow && first.recordDisplay && first.frame == 0 && !first.complete, "first frame of the window"); + const auto blank = tracker.Observe(3, 3, true, false); + Require(blank.inWindow && !blank.recordDisplay && blank.frame == 1 && !blank.complete, "a blank present must keep its slot without a Display"); + const auto last = tracker.Observe(3, 3, true, true); + Require(last.inWindow && last.recordDisplay && last.frame == 2 && last.complete, "capture with a blank present never completed"); + Require(!tracker.Observe(3, 3, true, true).inWindow, "present after the window was taken"); +} + +void LateRangeExistsOnlyFromItsSubmission() { + RangeTimeline timeline; + timeline.Add(0x1000, 0x1000, 0); // pre-frame + timeline.Add(0x9000, 0x1000, 3); // introduced by submission 3 + timeline.Add(0x20000, 0x1000, 5); // introduced by submission 5 + Require(timeline.LiveAt(0) == std::vector{0}, "a range mapped mid-frame exists before its submission"); + Require(timeline.LiveAt(2) == std::vector{0}, "a range mapped mid-frame exists before its submission"); + Require((timeline.LiveAt(3) == std::vector{0, 1}), "a range is missing at the submission that introduced it"); + Require((timeline.LiveAt(7) == std::vector{0, 1, 2}), "a range disappeared after its submission"); + Require(timeline.NextChange(0) == 3 && timeline.NextChange(3) == 5 && timeline.NextChange(5) == RangeTimeline::npos, "next change"); +} + +void ReusedAddressReplacesTheEarlierRange() { + RangeTimeline timeline; + timeline.Add(0x1000, 0x2000, 0); + timeline.Add(0x2000, 0x2000, 4); // overlaps the first from submission 4 + Require(timeline.LiveAt(3) == std::vector{0}, "replacement applied early"); + Require(timeline.LiveAt(4) == std::vector{1}, "the old range survived its replacement"); +} + +} + +void RunCaptureLogicTests(const MetalTests::Context&) { + PresentWithoutBufferStillCompletes(); + LateRangeExistsOnlyFromItsSubmission(); + ReusedAddressReplacesTheEarlierRange(); +} diff --git a/core/libs/prx/libSceAgcDriver/tests/metal/Main.mm b/core/libs/prx/libSceAgcDriver/tests/metal/Main.mm index 2a27f0f10fa1..3a78cc1f16f1 100644 --- a/core/libs/prx/libSceAgcDriver/tests/metal/Main.mm +++ b/core/libs/prx/libSceAgcDriver/tests/metal/Main.mm @@ -8,6 +8,7 @@ void RunPresentationTests(const MetalTests::Context& context); void RunGuestMemoryTests(const MetalTests::Context& context); void RunCommandBatchingTests(const MetalTests::Context& context); +void RunCaptureLogicTests(const MetalTests::Context& context); #if ANYPS5_METAL_SHADER_BRIDGE void RunShaderPipelineTests(const MetalTests::Context& context); void RunShaderAlignmentTests(const MetalTests::Context& context); @@ -44,6 +45,8 @@ int main(int argc, char** argv) { std::cout << "Guest memory GPU output passed\n"; RunCommandBatchingTests(context); std::cout << "Command buffer batching GPU output passed\n"; + RunCaptureLogicTests(context); + std::cout << "Frame capture bookkeeping passed\n"; #if ANYPS5_METAL_SHADER_BRIDGE RunShaderPipelineTests(context); std::cout << "Converted shader pipeline GPU output passed\n"; diff --git a/tools/agc-replay/AgcReplay.mm b/tools/agc-replay/AgcReplay.mm index 478e75d87bd2..01068fc0f3d6 100644 --- a/tools/agc-replay/AgcReplay.mm +++ b/tools/agc-replay/AgcReplay.mm @@ -19,6 +19,7 @@ #import #import #import +#include "prx/libSceAgcDriver/Execution/Metal/AgcCaptureLogic.hpp" #include "prx/libSceAgcDriver/Execution/Metal/AgcFrameCapture.hpp" #include "prx/libSceAgcDriver/Execution/Metal/DirectDriver.hpp" #include "prx/libSceAgcDriver/Execution/include/MetalDriver.hpp" @@ -70,6 +71,9 @@ bool writable; std::vector chunks; // per 1 MiB chunk, null when the capture had none (zero) std::byte* host = nullptr; + std::size_t activation = 0; // global submission index it appeared at (0: part of the pre-frame snapshot) + bool live = false; + bool filled = false; }; struct Submit { @@ -94,6 +98,7 @@ struct CaptureFile { std::vector ranges; std::vector chunks; + std::vector chunkRange; // the range record each chunk belongs to std::map> shaders; std::map> registries; std::map states; @@ -135,6 +140,8 @@ CaptureFile Load(const std::string& path) { case Capture::Tag::Range: { Range range{in.Get(), in.Get(), in.Get() != 0, {}}; range.chunks.assign((range.bytes + Capture::ChunkBytes - 1) / Capture::ChunkBytes, nullptr); + // A range recorded while the frame ran appears just before the submission that introduced it. + range.activation = file.submits.size(); file.ranges.push_back(std::move(range)); break; } @@ -145,6 +152,15 @@ CaptureFile Load(const std::string& path) { block.stored = in.Get(); block.hash = in.Get(); block.data = in.Take(block.stored); + // The chunk belongs to the latest range record that holds it (an address can be reused). + std::size_t owner = file.ranges.size(); + bool found = false; + while (owner > 0 && !found) { + const auto& candidate = file.ranges[--owner]; + found = block.guest >= candidate.guest && block.guest < candidate.guest + candidate.bytes; + } + if (!found) Fail("chunk outside every range"); + file.chunkRange.push_back(owner); file.chunks.push_back(block); break; } @@ -222,11 +238,9 @@ CaptureFile Load(const std::string& path) { offset += bytes; } if (!ended) Fail("capture has no end record (the game exited before the frame was presented?)"); - for (const auto& chunk : file.chunks) { - const auto range = std::find_if(file.ranges.begin(), file.ranges.end(), - [&](const Range& r) { return chunk.guest >= r.guest && chunk.guest < r.guest + r.bytes; }); - if (range == file.ranges.end()) Fail("chunk outside every range"); - range->chunks[(chunk.guest - range->guest) / Capture::ChunkBytes] = &chunk; + for (std::size_t i = 0; i < file.chunks.size(); ++i) { + auto& range = file.ranges[file.chunkRange[i]]; + range.chunks[(file.chunks[i].guest - range.guest) / Capture::ChunkBytes] = &file.chunks[i]; } return file; } @@ -426,6 +440,19 @@ int main(int argc, char** argv) { // Guest memory at its guest addresses. const auto pageSize = static_cast(getpagesize()); std::vector ranges; + // Ranges the title mapped during the frame exist only from the submission that introduced them. + Capture::RangeTimeline timeline; + for (const auto& range : capture.ranges) timeline.Add(range.guest, range.bytes, range.activation); + const auto setLive = [&](std::size_t submission) { + for (auto& range : capture.ranges) range.live = false; + ranges.clear(); + for (const auto index : timeline.LiveAt(submission)) { + auto& range = capture.ranges[index]; + range.live = true; + ranges.push_back({range.guest, {range.host, range.bytes}, range.writable}); + } + std::sort(ranges.begin(), ranges.end(), [](const auto& a, const auto& b) { return a.guestAddress < b.guestAddress; }); + }; // A range the replay process already uses at that address (the driver's staging buffer is a host // mapping, which can land where this process has its own) is mapped elsewhere: the driver // translates guest addresses through the ranges, so only identity-mapped guest pointers need @@ -443,18 +470,22 @@ int main(int argc, char** argv) { } else { range.host = reinterpret_cast(range.guest); } - ranges.push_back({range.guest, {range.host, range.bytes}, range.writable}); } + // Only live ranges are looked up, newest first (an address reused by a later range wins). const auto host = [&](std::uint64_t guest) { - for (const auto& range : capture.ranges) - if (guest >= range.guest && guest - range.guest < range.bytes) return range.host + (guest - range.guest); + for (auto it = capture.ranges.rbegin(); it != capture.ranges.rend(); ++it) + if (it->live && guest >= it->guest && guest - it->guest < it->bytes) return it->host + (guest - it->guest); Fail("guest address outside the captured ranges"); }; - std::sort(ranges.begin(), ranges.end(), [](const auto& a, const auto& b) { return a.guestAddress < b.guestAddress; }); + setLive(0); std::uint64_t residentBytes = 0; - for (const auto& chunk : capture.chunks) { - Capture::Decompress(chunk.data, chunk.stored, host(chunk.guest), chunk.raw); + for (std::size_t i = 0; i < capture.chunks.size(); ++i) { + auto& range = capture.ranges[capture.chunkRange[i]]; + if (range.activation != 0) continue; // filled when its submission is reached + const auto& chunk = capture.chunks[i]; + Capture::Decompress(chunk.data, chunk.stored, range.host + (chunk.guest - range.guest), chunk.raw); residentBytes += chunk.raw; + range.filled = true; } id device = MTLCreateSystemDefaultDevice(); @@ -553,15 +584,24 @@ int main(int argc, char** argv) { std::vector lastPixels; std::vector> previousPixels(frames); const auto zeroChunk = std::vector(Capture::ChunkBytes); + std::size_t liveAt = 0; + std::uint64_t generation = 1; for (std::size_t rep = 0; rep < repeat; ++rep) { if (rep != 0) { + // Back to the pre-frame set of ranges; ranges the frame introduced do not exist yet and read as zero. + if (liveAt != 0 || std::any_of(capture.ranges.begin(), capture.ranges.end(), [](const Range& r) { return r.activation != 0 && r.filled; })) { + liveAt = 0; + setLive(0); + driver.ReplaceBorrowedRanges(ranges, ++generation); + } // Pre-frame memory: chunks that differ from the capture are written again. - for (const auto& range : capture.ranges) { + for (auto& range : capture.ranges) { + if (range.activation != 0) range.filled = false; for (std::size_t c = 0; c < range.chunks.size(); ++c) { const auto offset = c * Capture::ChunkBytes; auto* data = range.host + offset; const auto bytes = static_cast(std::min(Capture::ChunkBytes, range.bytes - offset)); - if (const auto* chunk = range.chunks[c]) { + if (const auto* chunk = range.activation == 0 ? range.chunks[c] : nullptr) { if (Capture::Hash(data, bytes) != chunk->hash) Capture::Decompress(chunk->data, chunk->stored, data, chunk->raw); continue; } @@ -588,6 +628,22 @@ int main(int argc, char** argv) { const auto wallBefore = std::chrono::steady_clock::now(); for (; next < submits.size() && submits[next]->frame == frame; ++next) { const auto* submit = submits[next]; + if (submit->index >= timeline.NextChange(liveAt)) { + // A range the capture recorded just before this submission: it exists from here on, + // with its contents at this point, and not for the submissions before. + driver.WaitIdle(); + liveAt = submit->index; + setLive(liveAt); + for (std::size_t r = 0; r < capture.ranges.size(); ++r) { + auto& range = capture.ranges[r]; + if (!range.live || range.activation == 0 || range.filled) continue; + for (const auto* chunk : range.chunks) + if (chunk != nullptr) Capture::Decompress(chunk->data, chunk->stored, range.host + (chunk->guest - range.guest), chunk->raw); + range.filled = true; + } + driver.ReplaceBorrowedRanges(ranges, ++generation); + registry = 0xffffffffu; + } if (submit->registry != registry && capture.registries.count(submit->registry) != 0) { driver.ReplaceShaderRegistryForReplay(capture.registries.at(submit->registry)); registry = submit->registry;