fix(agc): end a captured frame once per EOP flip

An EOP flip reservation also ended a frame, and so did the submission
whose EOP release carries the reserved token, so a title flipping with
EOP releases counted each displayed frame twice: a capture of N frames
stopped after about N/2 and could start between the reservation and
the submission that flips. The reservation is now only recorded; a
frame ends at a CPU flip or at a submission that flips.

agc_driver_frame_capture_eop reserves and submits six EOP flips with
APS5_FRAME_CAPTURE_FRAMES=3 and checks that the capture starts after
the first flipping submission and holds three frames of one reservation
and one submission each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
SamandClaude Opus 5.5 authored and Adrià Franch committed 2026-10-24 02:09:44 +02:00
1 parent 95d2a76583
commit dbe034fdf8
5 files changed
+140 -7

No files matched your search

@@ -3284,6 +3284,20 @@ if(BUILD_TESTING)
"PATH=path_list_prepend:$<TARGET_FILE_DIR:libc>")
endif()
add_test_executable(agc_driver_frame_capture_eop_tests tests/FrameCaptureEop.cpp Execution/src/FrameCapture.cpp)
target_include_directories(agc_driver_frame_capture_eop_tests PRIVATE ${LIBS_INCLUDE_DIR})
target_link_libraries(agc_driver_frame_capture_eop_tests PRIVATE libSceAgcDriver libkernel libc libzstd_static)
set_target_properties(agc_driver_frame_capture_eop_tests PROPERTIES CXX_EXTENSIONS OFF)
configure_windows_unwind(agc_driver_frame_capture_eop_tests)
add_test(NAME agc_driver_frame_capture_eop COMMAND agc_driver_frame_capture_eop_tests)
set_tests_properties(agc_driver_frame_capture_eop PROPERTIES
ENVIRONMENT "ANYPS5_NO_SHADER_CACHE=1;APS5_FRAME_CAPTURE=${CMAKE_CURRENT_BINARY_DIR}/frame-capture-eop;APS5_FRAME_CAPTURE_AT_FLIP=1;APS5_FRAME_CAPTURE_FRAMES=3"
TIMEOUT 120)
if(WIN32)
set_tests_properties(agc_driver_frame_capture_eop PROPERTIES ENVIRONMENT_MODIFICATION
"PATH=path_list_prepend:$<TARGET_FILE_DIR:libc>")
endif()
add_test_executable(agc_driver_frame_replay_tests tests/FrameReplay.cpp Execution/src/FrameCapture.cpp)
target_include_directories(agc_driver_frame_replay_tests PRIVATE ${LIBS_INCLUDE_DIR})
target_link_libraries(agc_driver_frame_replay_tests PRIVATE libSceAgc libSceAgcDriver libkernel libc libzstd_static)
@@ -122,10 +122,7 @@ extern "C" void AgcDriverSuspendPoint_nid_postfix() try {
extern "C" std::uint32_t AgcDriverReserveEopFlip_nid_postfix(const AgcDriver::FlipInfo& info) {
const auto token = AgcDriver::DriverDetail::Driver::Get().ReserveEopFlip(info);
if (auto* const recorder = AgcDriver::FrameRecorder::Active(); recorder != nullptr && token != 0) {
recorder->ReserveEopFlip(info, token);
recorder->FrameBoundary();
}
if (auto* const recorder = AgcDriver::FrameRecorder::Active(); recorder != nullptr && token != 0) recorder->ReserveEopFlip(info, token);
return token;
}
@@ -0,0 +1,122 @@
#include "prx/libSceAgcDriver/Execution/include/Driver.hpp"
#include "prx/libSceAgcDriver/Execution/include/FrameCapture.hpp"
#include "prx/libSceAgcDriver/Execution/include/VideoOutput.hpp"
#include "prx/libSceAgcDriver/Submit/include/Dcb.hpp"
#include "prx/libc/include/GuestAllocations.hpp"
#include "prx/libc/include/GuestArena.hpp"
#include "SceTypes.hpp"
#include <algorithm>
#include <array>
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <memory>
#include <stdexcept>
#include <string>
#ifdef _WIN32
#include <windows.h>
#endif
namespace {
using AgcDriver::FrameCallKind;
using AgcDriver::FrameCaptureReader;
constexpr std::uint32_t Handle = 7;
constexpr std::size_t BlockBytes = 1u << 20;
constexpr std::size_t Frames = 6;
constexpr std::size_t CapturedFrames = 3;
void check(bool condition, const std::string& reason) {
if (!condition) throw std::runtime_error(reason);
}
class Request final : public AgcDriver::IFlipRequest {
public:
explicit Request(std::atomic<int>& ready) : ready(ready) {}
void GpuReady(const std::shared_ptr<AgcDriver::FrameTiming>&) override { ++ready; }
void Fail(std::exception_ptr error) noexcept override {
if (!error) std::terminate();
}
private:
std::atomic<int>& ready;
};
class Output final : public AgcDriver::IVideoOutput {
public:
std::shared_ptr<AgcDriver::IFlipRequest> Reserve(const AgcDriver::FlipInfo&) override { return std::make_shared<Request>(ready); }
void Fail(std::exception_ptr error) noexcept override {
if (!error) std::terminate();
}
std::atomic<int> ready = 0;
};
std::byte* allocate() {
auto* const block = static_cast<std::byte*>(GuestArena::GuestArenaAllocate_nid_postfix(BlockBytes, 65536));
check(block != nullptr, "cannot allocate guest memory");
#ifdef _WIN32
GuestArena::GuestArenaCommit_nid_postfix(block, BlockBytes, PAGE_READWRITE, BlockBytes);
#else
GuestArena::GuestArenaCommit_nid_postfix(block, BlockBytes, 3, BlockBytes);
#endif
GuestAllocations::Mutation mutation;
mutation.Add(block, BlockBytes, true, true, true);
return block;
}
void submitEopFrame(std::byte* block, std::size_t frame) {
const auto token = AgcDriverReserveEopFlip_nid_postfix(AgcDriver::FlipInfo{Handle, 0, 1, static_cast<std::int64_t>(frame)});
check(token != 0, "the EOP flip of frame " + std::to_string(frame) + " was not reserved");
auto* const words = reinterpret_cast<std::uint32_t*>(block + frame * 256);
const auto label = reinterpret_cast<std::uintptr_t>(block + BlockBytes / 2 + frame * 8);
const std::array<std::uint32_t, 8> release{0xc0064900u, 0x06200504u, 0x42010000u, static_cast<std::uint32_t>(label), static_cast<std::uint32_t>(label >> 32u), 1u, 0u, token};
std::memcpy(words, release.data(), sizeof(release));
Packet packet{words, static_cast<std::uint32_t>(release.size()), 0, {}};
check(sceAgcDriverSubmitDcb(&packet) == 0, "the EOP submission of frame " + std::to_string(frame) + " failed");
}
void testEopFramesCountOnce() {
const char* directory = std::getenv("APS5_FRAME_CAPTURE");
check(directory != nullptr && *directory != '\0', "APS5_FRAME_CAPTURE is not set");
auto* const block = allocate();
auto output = std::make_shared<Output>();
AgcDriverRegisterVideoOutput_nid_postfix(Handle, output);
for (std::size_t frame = 0; frame < Frames; ++frame) submitEopFrame(block, frame);
AgcDriverWaitIdle_nid_postfix();
check(output->ready.load() == static_cast<int>(Frames), "the driver presented " + std::to_string(output->ready.load()) + " of the EOP flips");
const FrameCaptureReader reader{std::filesystem::path(directory)};
check(!reader.Truncated(), "the capture is truncated");
check(reader.Header().find("\"frames\":" + std::to_string(CapturedFrames)) != std::string::npos, "the capture header does not record " + std::to_string(CapturedFrames) + " frames: " + reader.Header());
const auto& calls = reader.Calls();
const auto start = std::find_if(calls.begin(), calls.end(), [](const auto& call) { return call.kind == FrameCallKind::FrameStart; });
check(start != calls.end(), "the capture has no frame start");
std::size_t submits = 0, reservations = 0;
FrameCallKind first = FrameCallKind::End;
for (auto call = start + 1; call != calls.end(); ++call) {
if (call->kind == FrameCallKind::Submit || call->kind == FrameCallKind::ReserveEopFlip) {
if (first == FrameCallKind::End) first = call->kind;
submits += call->kind == FrameCallKind::Submit;
reservations += call->kind == FrameCallKind::ReserveEopFlip;
}
}
check(first == FrameCallKind::ReserveEopFlip, "the capture started between a reservation and the submission that flips it");
check(submits == CapturedFrames && reservations == CapturedFrames,
"the captured frames hold " + std::to_string(submits) + " submissions and " + std::to_string(reservations) + " reservations, expected " + std::to_string(CapturedFrames) + " of each");
}
}
int main() {
try {
testEopFramesCountOnce();
} catch (const std::exception& error) {
std::fprintf(stderr, "FAILED: %s\n", error.what());
return 1;
}
std::puts("frame capture EOP tests passed");
return 0;
}
+2 -2
View File
@@ -188,7 +188,7 @@ On Windows use `agc_pm4_replay.exe` and prepend `build/core/libs/libs/unpatched`
## Frame capture and replay
Set `APS5_FRAME_CAPTURE` to a directory to record a few frames of a title at the AGC driver boundary, then replay them with `agc_frame_replay` through any build of the driver with no title running. From the start of the process the driver keeps every shader registration and ABI resolution, video output registration and EOP flip reservation. At the frame boundary picked by `APS5_FRAME_CAPTURE_AT_FLIP` (default 1), or by the appearance of the file `APS5_FRAME_CAPTURE_TRIGGER` names (a number in it replaces the frame count), it waits for the driver to go idle and writes every committed, readable allocation of the guest allocation registry, the title's image included, as zstd chunks without their all-zero 64 KiB blocks. A frame ends at a CPU flip, an EOP flip reservation or a submission that holds a flip packet. For the next `APS5_FRAME_CAPTURE_FRAMES` (10) frames it records each submission (queue, command buffer address and size), flip and suspend point with the thread that made it and the accepted and completed serials, each preceded by the guest pages the write tracker saw the CPU write since the previous call that handed work to the GPU (`GuestMemory::CollectWrittenBlocks`, per 4 KiB where the tracker kept page stamps). Shader registrations and ABI resolutions only collect the blocks of the shader header and the arrays it points to, and the pages of the title's image only when their content changed. At the end it stores each display buffer the title presented, decoded, as a reference image. A capture is `calls.bin`, `memory.bin` and `capture.json` (commit, switches, sizes, warnings), and the recorder stops with a warning when its volume has less than 4 GiB free. A 20-frame capture in the Demon's Souls Nexus is 5.3 GiB of snapshot and 11.5 GiB of deltas before compression, 3.9 GiB on disk.
Set `APS5_FRAME_CAPTURE` to a directory to record a few frames of a title at the AGC driver boundary, then replay them with `agc_frame_replay` through any build of the driver with no title running. From the start of the process the driver keeps every shader registration and ABI resolution, video output registration and EOP flip reservation. At the frame boundary picked by `APS5_FRAME_CAPTURE_AT_FLIP` (default 1), or by the appearance of the file `APS5_FRAME_CAPTURE_TRIGGER` names (a number in it replaces the frame count), it waits for the driver to go idle and writes every committed, readable allocation of the guest allocation registry, the title's image included, as zstd chunks without their all-zero 64 KiB blocks. A frame ends at a CPU flip or at a submission that flips (a flip packet, or the EOP release of a reserved flip token); the reservation itself only records its token. For the next `APS5_FRAME_CAPTURE_FRAMES` (10) frames it records each submission (queue, command buffer address and size), flip and suspend point with the thread that made it and the accepted and completed serials, each preceded by the guest pages the write tracker saw the CPU write since the previous call that handed work to the GPU (`GuestMemory::CollectWrittenBlocks`, per 4 KiB where the tracker kept page stamps). Shader registrations and ABI resolutions only collect the blocks of the shader header and the arrays it points to, and the pages of the title's image only when their content changed. At the end it stores each display buffer the title presented, decoded, as a reference image. A capture is `calls.bin`, `memory.bin` and `capture.json` (commit, switches, sizes, warnings), and the recorder stops with a warning when its volume has less than 4 GiB free. A 20-frame capture in the Demon's Souls Nexus is 5.3 GiB of snapshot and 11.5 GiB of deltas before compression, 3.9 GiB on disk.
`agc_frame_replay <capture> [--no-pace] [--no-release] [--release-after-ms <n>] [--no-synthesize] [--strict] [--dump <address>:<bytes>:<file>]... [--frames <dir>]` maps the captured ranges at their addresses and registers them with their access (a range the replaying process already maps writable, such as the driver's fixed memory, is only refilled, and a non-GPU range it uses itself is skipped and counted), repeats the registrations, then replays the calls of each captured thread on a thread of its own, started in captured order. Before a call that hands work to the GPU it waits until no more submissions are in flight than when the title made it (`--no-pace` turns this off). Titles release GPU waits from threads that never call the driver, so when the driver completes nothing for 3 ms the replay reads the waits its queues are blocked on (`AgcDriverAwaitedWaits`) and writes the next recorded CPU value of the awaited dword that satisfies the compare, else the last earlier one, else a value derived from the reference (`--no-synthesize` turns this last step off). It prints one JSON line with the frame count and times, the submissions, the pacing wait, the released waits and how many were derived, the submissions that never completed and, for captures with reference images, the share of identical pixels and the largest channel difference of the replayed display buffers (`--frames` writes both images). Effects that depend on timing (temporal antialiasing, particles) make pixels differ, so expect a visual match rather than identical frames. Replay times follow the GPU clock state: lock the clocks (`nvidia-smi -lgc`, `-lmc`) when comparing two drivers.
@@ -197,7 +197,7 @@ cmake --build build --target agc_frame_replay
build/core/libs/prx/libSceAgcDriver/agc_frame_replay capture
```
On Windows use `agc_frame_replay.exe` and prepend `build/core/libs/libs/unpatched` to the process `PATH`. With `BUILD_TESTING=ON`, `agc_frame_capture` checks the file format (round trip, zero-block elision, truncated and corrupt files) and `agc_driver_frame_replay` captures two frames of CPU writes followed by DMA copies, replays them in `agc_frame_replay` and compares the copies.
On Windows use `agc_frame_replay.exe` and prepend `build/core/libs/libs/unpatched` to the process `PATH`. With `BUILD_TESTING=ON`, `agc_frame_capture` checks the file format (round trip, zero-block elision, truncated and corrupt files) `agc_driver_frame_capture_eop` reserves and submits six EOP flips with `APS5_FRAME_CAPTURE_FRAMES=3` and checks that the capture starts after the first flipping submission and holds three frames of one reservation and one submission each, and `agc_driver_frame_replay` captures two frames of CPU writes followed by DMA copies, replays them in `agc_frame_replay` and compares the copies.
## Pipeline statistics
+1 -1
View File
@@ -148,7 +148,7 @@ The code reads environment names through `getenv`, `IsEnvironmentSet`, `kibSetti
| `APS5_FORCE_SDR` | presence | `core/libs/prx/libSceAgcDriver/Execution/src/HostHdr.cpp` | unset | suppresses advertised HDR availability while retaining detected host capabilities; uses the existing SDR presentation path |
| `APS5_FORCE_STRUCTURIZER_FALLBACK` | presence | `core/libs/prx/libSceAgcDriver/tests/ControlFlow.cpp` | | makes the control-flow test expect the reach-flag fallback instead of no cloning |
| `APS5_FRAME_CAPTURE` | string | `core/libs/prx/libSceAgcDriver/Execution/src/FrameRecorder.cpp` | | directory in which the driver records a few frames of the title's driver calls and guest memory for `agc_frame_replay` |
| `APS5_FRAME_CAPTURE_AT_FLIP` | integer | `core/libs/prx/libSceAgcDriver/Execution/src/FrameRecorder.cpp` | 1 | frame boundary (CPU flip or EOP flip reservation) at which the frame capture starts when no trigger file is set |
| `APS5_FRAME_CAPTURE_AT_FLIP` | integer | `core/libs/prx/libSceAgcDriver/Execution/src/FrameRecorder.cpp` | 1 | frame boundary (CPU flip or submission that flips) at which the frame capture starts when no trigger file is set |
| `APS5_FRAME_CAPTURE_FRAMES` | integer | `core/libs/prx/libSceAgcDriver/Execution/src/FrameRecorder.cpp` | 10 | number of frame boundaries the frame capture records |
| `APS5_FRAME_CAPTURE_TRIGGER` | string | `core/libs/prx/libSceAgcDriver/Execution/src/FrameRecorder.cpp` | | file whose appearance starts the frame capture at the next frame boundary; a number in it replaces the frame count |
| `APS5_FULL_BARRIERS` | presence | `core/libs/prx/libSceAgcDriver/Graphics/src/Recorder.cpp` | | records every barrier instead of merging redundant ones |