Merge pull request #11197 from LeandroLP23/fix/agc-frame-recorder-settle-before-delta

fix(agc): read a frame capture's memory delta once the accepted submissions completed
This commit is contained in:
Dean authored and GitHub committed 2026-10-26 20:48:54 +13:00
commit 204c6747d0
7 files changed
+141 -44

No files matched your search

+7 -2
View File
@@ -3486,8 +3486,7 @@ if(BUILD_TESTING)
endif()
endforeach()
option(ANYPS5_RUN_QUARANTINED_FRAME_REPLAY "Build and register agc_driver_frame_replay on Windows" OFF)
if(WIN32 AND ANYPS5_RUN_QUARANTINED_FRAME_REPLAY)
if(WIN32)
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)
@@ -3500,6 +3499,12 @@ if(BUILD_TESTING)
TIMEOUT 120)
set_tests_properties(agc_driver_frame_replay PROPERTIES ENVIRONMENT_MODIFICATION
"PATH=path_list_prepend:$<TARGET_FILE_DIR:libc>")
add_test(NAME agc_driver_frame_replay_concurrent COMMAND agc_driver_frame_replay_tests $<TARGET_FILE:agc_frame_replay> concurrent)
set_tests_properties(agc_driver_frame_replay_concurrent PROPERTIES
ENVIRONMENT "ANYPS5_NO_SHADER_CACHE=1;APS5_FRAME_CAPTURE=${CMAKE_CURRENT_BINARY_DIR}/frame-replay-concurrent-capture;APS5_FRAME_CAPTURE_AT_FLIP=1;APS5_FRAME_CAPTURE_FRAMES=8"
TIMEOUT 120)
set_tests_properties(agc_driver_frame_replay_concurrent PROPERTIES ENVIRONMENT_MODIFICATION
"PATH=path_list_prepend:$<TARGET_FILE_DIR:libc>")
endif()
endif()
@@ -10,6 +10,7 @@
#include <cstdint>
#include <deque>
#include <filesystem>
#include <functional>
#include <map>
#include <memory>
#include <mutex>
@@ -35,10 +36,10 @@ public:
void ResolveGraphicsAbi(const Shader* vertex, const Shader* pixel, std::uint32_t primitiveType);
void RegisterVideoOutput(std::uint32_t handle);
void UnregisterVideoOutput(std::uint32_t handle);
void Submit(const Packet& packet, std::uint32_t queue);
void Flip(const FlipInfo& info);
void Submit(const Packet& packet, std::uint32_t queue, const std::function<void()>& accept);
void Flip(const FlipInfo& info, const std::function<void()>& accept);
void ReserveEopFlip(const FlipInfo& info, std::uint32_t token);
void Suspend();
void Suspend(const std::function<void()>& accept);
void PresentBuffer(const DisplayBuffer& buffer);
void FrameBoundary();
@@ -62,7 +63,8 @@ private:
};
FrameCall make(FrameCallKind kind);
void record(FrameCall call, bool prologueCall, std::span<const Shader* const> shaders = {});
void record(FrameCall call, bool prologueCall, std::span<const Shader* const> shaders = {}, const std::function<void()>& accept = {});
void recordLocked(FrameCall call, bool prologueCall, std::span<const Shader* const> shaders);
void emitDelta();
void emitShaderDelta(std::span<const Shader* const> shaders);
void writeDelta(std::span<const Span> spans, std::span<const std::uint64_t> hostPages, std::span<const Range> added = {});
@@ -72,6 +74,7 @@ private:
void snapshot(const Range& range);
void writeReferenceFrames();
bool abandonIfDiskLow();
void stop(const char* reason);
std::string header() const;
std::vector<Range> committedRanges();
std::vector<Range> newRanges();
@@ -19,9 +19,8 @@ void Submit(const Packet* packet, std::uint32_t queue) try {
return;
}
GuestMemory::CheckRange(packet, sizeof(Packet), alignof(Packet));
recorder->Submit(*packet, queue);
const auto flips = driver.FlipSubmissions();
driver.Submit(packet, queue);
recorder->Submit(*packet, queue, [&] { driver.Submit(packet, queue); });
if (driver.FlipSubmissions() != flips) recorder->FrameBoundary();
} catch (const ProcessShutdown&) {
LibcAwaitExit_nid_postfix();
@@ -41,8 +40,12 @@ void RegisterShader(const Shader* shader) {
}
void SuspendPoint() {
if (auto* const recorder = FrameRecorder::Active()) recorder->Suspend();
DriverDetail::Driver::Get().SuspendPoint();
auto* const recorder = FrameRecorder::Active();
if (recorder == nullptr) {
DriverDetail::Driver::Get().SuspendPoint();
return;
}
recorder->Suspend([] { DriverDetail::Driver::Get().SuspendPoint(); });
}
void RegisterVideoOutput(std::uint32_t handle, const std::shared_ptr<IVideoOutput>& output) {
@@ -146,9 +149,12 @@ extern "C" void AgcDriverSerialProgress_nid_postfix(std::uint64_t* accepted, std
extern "C" void AgcDriverSubmitFlip_nid_postfix(const AgcDriver::FlipInfo& info) try {
auto* const recorder = AgcDriver::FrameRecorder::Active();
if (recorder != nullptr) recorder->Flip(info);
AgcDriver::DriverDetail::Driver::Get().SubmitFlip(info);
if (recorder != nullptr) recorder->FrameBoundary();
if (recorder == nullptr) {
AgcDriver::DriverDetail::Driver::Get().SubmitFlip(info);
return;
}
recorder->Flip(info, [&] { AgcDriver::DriverDetail::Driver::Get().SubmitFlip(info); });
recorder->FrameBoundary();
} catch (const ProcessShutdown&) {
LibcAwaitExit_nid_postfix();
}
@@ -163,20 +163,20 @@ void FrameRecorder::UnregisterVideoOutput(std::uint32_t handle) {
record(std::move(call), true);
}
void FrameRecorder::Submit(const Packet& packet, std::uint32_t queue) {
void FrameRecorder::Submit(const Packet& packet, std::uint32_t queue, const std::function<void()>& accept) {
auto call = make(FrameCallKind::Submit);
call.queue = queue;
call.address = pointer(packet.addr);
call.size = packet.dw_num;
call.flags = packet.flags;
record(std::move(call), false);
record(std::move(call), false, {}, accept);
}
void FrameRecorder::Flip(const FlipInfo& info) {
void FrameRecorder::Flip(const FlipInfo& info, const std::function<void()>& accept) {
auto call = make(FrameCallKind::Flip);
call.cpuFlip = true;
call.values = {info.handle, flipValue(info.index), info.mode, flipValue(info.argument)};
record(std::move(call), false);
record(std::move(call), false, {}, accept);
}
void FrameRecorder::ReserveEopFlip(const FlipInfo& info, std::uint32_t token) {
@@ -198,8 +198,8 @@ void FrameRecorder::ReserveEopFlip(const FlipInfo& info, std::uint32_t token) {
++recordedCalls;
}
void FrameRecorder::Suspend() {
record(make(FrameCallKind::Suspend), false);
void FrameRecorder::Suspend(const std::function<void()>& accept) {
record(make(FrameCallKind::Suspend), false, {}, accept);
}
void FrameRecorder::PresentBuffer(const DisplayBuffer& buffer) {
@@ -236,8 +236,13 @@ void FrameRecorder::writeReferenceFrames() {
}
}
void FrameRecorder::record(FrameCall call, bool prologueCall, std::span<const Shader* const> shaders) {
void FrameRecorder::record(FrameCall call, bool prologueCall, std::span<const Shader* const> shaders, const std::function<void()>& accept) {
std::lock_guard lock(mutex);
recordLocked(std::move(call), prologueCall, shaders);
if (accept) accept();
}
void FrameRecorder::recordLocked(FrameCall call, bool prologueCall, std::span<const Shader* const> shaders) {
if (state == State::Waiting) {
if (!prologueCall) return;
call.ordinal = ++ordinal;
@@ -360,10 +365,23 @@ void FrameRecorder::emitShaderDelta(std::span<const Shader* const> shaders) {
void FrameRecorder::writeDelta(std::span<const Span> spans, std::span<const std::uint64_t> hostPages, std::span<const Range> added) {
if (abandonIfDiskLow()) return;
const auto [accepted, completed] = DriverDetail::Driver::Get().SerialProgress();
std::vector<std::pair<std::uint64_t, std::uint64_t>> blocks;
std::vector<std::pair<std::uint64_t, std::uint64_t>> written;
for (const auto& span : spans) {
GuestMemory::CollectWrittenBlocks(span.address, static_cast<std::size_t>(span.bytes), span.since, written);
blocks.insert(blocks.end(), written.begin(), written.end());
}
auto& driver = DriverDetail::Driver::Get();
auto [accepted, completed] = driver.SerialProgress();
if (completed < accepted && (!added.empty() || !blocks.empty())) {
if (!driver.WaitIdleFor(std::chrono::seconds(10))) {
stop("stopped: the GPU work submitted before a memory delta did not finish within 10 s");
return;
}
std::tie(accepted, completed) = driver.SerialProgress();
}
const auto group = deltaGroups + 1;
bool any = false;
std::vector<std::pair<std::uint64_t, std::uint64_t>> written;
std::vector<std::byte> bytes;
for (const auto& range : added) {
writer->WriteRange(range.address, range.bytes, range.kind, range.protection, range.access);
@@ -397,15 +415,12 @@ void FrameRecorder::writeDelta(std::span<const Span> spans, std::span<const std:
deltaBytes += bytes.size();
any = true;
}
for (const auto& span : spans) {
GuestMemory::CollectWrittenBlocks(span.address, static_cast<std::size_t>(span.bytes), span.since, written);
for (const auto& [begin, end] : written) {
bytes.resize(static_cast<std::size_t>(end - begin));
GuestMemory::ReadCommittedUnsynced(begin, bytes);
writer->WriteDelta(group, completed, begin, bytes);
deltaBytes += bytes.size();
any = true;
}
for (const auto& [begin, end] : blocks) {
bytes.resize(static_cast<std::size_t>(end - begin));
GuestMemory::ReadCommittedUnsynced(begin, bytes);
writer->WriteDelta(group, completed, begin, bytes);
deltaBytes += bytes.size();
any = true;
}
if (!any) return;
deltaGroups = group;
@@ -436,8 +451,13 @@ bool FrameRecorder::abandonIfDiskLow() {
std::error_code error;
const auto space = std::filesystem::space(directory, error);
if (error || space.available >= MinimumFreeBytes) return false;
warnings.push_back("stopped: less than 4 GiB free on the capture volume");
std::fprintf(stderr, "[frame-capture] stopped: less than 4 GiB free on the capture volume\n");
stop("stopped: less than 4 GiB free on the capture volume");
return true;
}
void FrameRecorder::stop(const char* reason) {
warnings.push_back(reason);
std::fprintf(stderr, "[frame-capture] %s\n", reason);
try {
writer->Finish(header());
} catch (const std::exception& failure) {
@@ -445,7 +465,6 @@ bool FrameRecorder::abandonIfDiskLow() {
}
writer.reset();
state = State::Done;
return true;
}
void FrameRecorder::start() {
@@ -17,6 +17,7 @@
#include <memory>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
#ifdef _WIN32
#include <windows.h>
@@ -100,14 +101,18 @@ std::vector<std::byte> pattern(std::uint8_t seed) {
return bytes;
}
void copyFrame(std::uint32_t* words, const Layout& layout, std::byte* target) {
CommandBuffer buffer{words, words + CommandBytes / sizeof(std::uint32_t), words, words + CommandBytes / sizeof(std::uint32_t), nullptr, nullptr, 0};
sceAgcDcbDmaData(&buffer, 0, DmaMemory, 0, at(target), DmaMemory, 0, at(layout.source), DataBytes, 0, 1, 0);
sceAgcDcbWriteData(&buffer, GraphicsMemory, 0, at(layout.label), &Done, 1, 0, 1);
void copy(std::uint32_t* words, std::size_t commandBytes, const std::byte* source, std::byte* target, std::uint32_t* label) {
CommandBuffer buffer{words, words + commandBytes / sizeof(std::uint32_t), words, words + commandBytes / sizeof(std::uint32_t), nullptr, nullptr, 0};
sceAgcDcbDmaData(&buffer, 0, DmaMemory, 0, at(target), DmaMemory, 0, at(source), DataBytes, 0, 1, 0);
sceAgcDcbWriteData(&buffer, GraphicsMemory, 0, at(label), &Done, 1, 0, 1);
Packet packet{words, static_cast<std::uint32_t>(buffer.cursor_up - words), 0, {}};
check(sceAgcDriverSubmitDcb(&packet) == 0, "submission failed");
}
void copyFrame(std::uint32_t* words, const Layout& layout, std::byte* target) {
copy(words, CommandBytes, layout.source, target, layout.label);
}
void flip() {
AgcDriverSubmitFlip_nid_postfix(AgcDriver::FlipInfo{Handle, -1, 1, 0});
}
@@ -187,15 +192,75 @@ void testCaptureAndReplay(const std::filesystem::path& tool) {
check(readFile(secondDump) == second, "the replay did not reproduce the second copy");
}
void testConcurrentCopies(const std::filesystem::path& tool) {
constexpr std::size_t Copies = 64;
constexpr std::size_t Frames = 8;
constexpr std::size_t CopyCommandBytes = 1024;
const char* directory = std::getenv("APS5_FRAME_CAPTURE");
check(directory != nullptr && *directory != '\0', "APS5_FRAME_CAPTURE is not set");
const std::filesystem::path capture(directory);
const auto layout = allocate();
const auto other = allocate();
auto* const otherSource = other.base + 128 * 1024;
auto* const targets = otherSource + DataBytes;
auto* const otherLabel = reinterpret_cast<std::uint32_t*>(other.base + 512 * 1024);
const auto copied = pattern(57);
std::memcpy(otherSource, copied.data(), copied.size());
auto output = std::make_shared<Output>();
AgcDriverRegisterVideoOutput_nid_postfix(Handle, output);
flip();
std::thread copier([&] {
for (std::size_t index = 0; index < Copies; ++index) {
copy(reinterpret_cast<std::uint32_t*>(other.base + index * CopyCommandBytes), CopyCommandBytes, otherSource, targets + index * DataBytes, otherLabel);
}
});
for (std::size_t frame = 0; frame + 1 < Frames; ++frame) {
const auto bytes = pattern(static_cast<std::uint8_t>(frame));
std::memcpy(layout.source, bytes.data(), bytes.size());
copyFrame(layout.firstCommands, layout, layout.firstTarget);
flip();
}
copier.join();
flip();
AgcDriverWaitIdle_nid_postfix();
const FrameCaptureReader reader(capture);
check(!reader.Truncated(), "the capture is truncated");
std::uint64_t base = 0;
std::size_t submits = 0, memory = 0;
for (const auto& call : reader.Calls()) {
if (call.kind == FrameCallKind::FrameStart) base = call.completed;
if (call.kind == FrameCallKind::Memory) {
++memory;
check(call.completed >= base + submits, "a memory delta was read with " + std::to_string(base + submits - call.completed) + " of the " + std::to_string(submits) + " submissions recorded before it unfinished");
}
submits += call.kind == FrameCallKind::Submit;
}
check(submits == Copies + Frames - 1, "the capture holds " + std::to_string(submits) + " submits, not " + std::to_string(Copies + Frames - 1));
check(memory != 0, "the capture holds no memory deltas");
const auto dump = capture / "targets.bin";
const auto command = "\"\"" + tool.string() + "\" \"" + capture.string() + "\" --strict --dump " + std::to_string(at(targets)) + ":" + std::to_string(Copies * DataBytes) + ":\"" + dump.string() + "\"\"";
const auto code = std::system(command.c_str());
check(code == 0, "agc_frame_replay exited with " + std::to_string(code));
const auto replayed = readFile(dump);
check(replayed.size() == Copies * DataBytes, "the replay dumped " + std::to_string(replayed.size()) + " bytes of the copy targets");
for (std::size_t index = 0; index < Copies; ++index) {
check(std::memcmp(replayed.data() + index * DataBytes, copied.data(), DataBytes) == 0, "the replay did not reproduce copy " + std::to_string(index) + " of the second thread");
}
}
}
int main(int argc, char** argv) {
if (argc != 2) {
std::fprintf(stderr, "usage: agc_driver_frame_replay_tests <agc_frame_replay>\n");
if (argc != 2 && !(argc == 3 && std::string(argv[2]) == "concurrent")) {
std::fprintf(stderr, "usage: agc_driver_frame_replay_tests <agc_frame_replay> [concurrent]\n");
return 2;
}
try {
testCaptureAndReplay(argv[1]);
if (argc == 3) testConcurrentCopies(argv[1]);
else testCaptureAndReplay(argv[1]);
} catch (const std::exception& error) {
std::fprintf(stderr, "FAILED: %s\n", error.what());
return 1;
+1 -1
View File
@@ -195,7 +195,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 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); an allocation the registry gained after the snapshot is recorded there as a new range with its nonzero 64 KiB blocks. A submission ends a frame only when it flips itself, not when another thread's submission flipped while it was being submitted. 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), read once the submissions already accepted have completed (a recorded submission, flip or suspend point reaches the driver before the recorder takes its next call, so that wait covers every call recorded before the delta); an allocation the registry gained after the snapshot is recorded there as a new range with its nonzero 64 KiB blocks. A submission ends a frame only when it flips itself, not when another thread's submission flipped while it was being submitted. 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.
+1 -2
View File
@@ -72,8 +72,7 @@
### Functional
- [Written staging window on discrete GPUs](../../../core/libs/prx/libSceAgcDriver/Graphics/src/GuestBufferMemory.cpp) (libSceAgcDriver) - a written buffer element of a dispatch that is not swept is staged device-local up to 8 MiB on a discrete GPU (`Context::discreteGpu`) and up to 2 MiB otherwise; `APS5_WRITTEN_SHADOW_MAX_KIB` replaces either bound. The 8 MiB figure comes from one title on one NVIDIA Windows system (PPSA09911: a 2.5 and a 3.3 MiB skin-cache output written in place cost 55 ms of GPU time per frame, 1.8 ms of extra staging copies once staged); the point where the two copies of a larger element cost more than its in-place stores, and whether AMD or Linux drivers move it, has not been measured. The window is per element and per use, so a kernel that streams once through an element inside the window pays the copy-in and the copy-back on every dispatch.
- [Frame capture](../../../core/libs/prx/libSceAgcDriver/Execution/src/FrameRecorder.cpp) (libSceAgcDriver) records guest memory on Windows only (a VirtualQuery walk of the registry allocations and the Windows write tracker); elsewhere a capture throws when it starts. Ranges are captured by allocation and protection, not by mapping: section views, aliases of one direct memory block and file views are snapshotted and replayed as separate private memory, so a store through one alias is not seen through another, and CPU stores to them during the captured frames are not recorded (only write-watched private memory is). Pages of the title's image are recorded during the frames only when a shader header or an array it points to lies in them and their content changed. A capture starts only at a frame boundary where the driver goes idle within 10 s, and gives up after five boundaries that do not.
- [agc_driver_frame_replay](../../../core/libs/prx/libSceAgcDriver/CMakeLists.txt) (libSceAgcDriver) is neither built nor registered on Windows until the recorder fix for [#10711](https://github.com/boykopovar/AnyPS5/issues/10711) lands: the recorder reads a delta block with `ReadCommittedUnsynced` while the second DMA copy into it is still in flight, so the capture can hold zeros for that block and the replay does not reproduce the second copy. Configure with `-DANYPS5_RUN_QUARANTINED_FRAME_REPLAY=ON` to build and register it; Linux is unaffected, as the test only exists on Windows.
- [Frame capture](../../../core/libs/prx/libSceAgcDriver/Execution/src/FrameRecorder.cpp) (libSceAgcDriver) records guest memory on Windows only (a VirtualQuery walk of the registry allocations and the Windows write tracker); elsewhere a capture throws when it starts. Ranges are captured by allocation and protection, not by mapping: section views, aliases of one direct memory block and file views are snapshotted and replayed as separate private memory, so a store through one alias is not seen through another, and CPU stores to them during the captured frames are not recorded (only write-watched private memory is). Pages of the title's image are recorded during the frames only when a shader header or an array it points to lies in them and their content changed. A capture starts only at a frame boundary where the driver goes idle within 10 s, and gives up after five boundaries that do not. A capture also stops when GPU work in flight at a delta waits on a later CPU store.
- [Frame replay](../../../core/libs/prx/libSceAgcDriver/tools/FrameReplay.cpp) (libSceAgcDriver) orders the calls of different captured threads only by their start (a call starts once every earlier call has started), so the driver sees a schedule the title could have produced, not necessarily the one it did. CPU stores a title made from threads that never call the driver are known only by the next recorded call, so the replay applies them early or late when a GPU wait needs them and derives a value from the wait's reference when no recorded store satisfies it (`of_them_synthesized`). Pacing compares the number of submissions in flight, not what they hold. Prologue registrations of shaders whose memory the title reused after registering them fail and are counted (`stale_registrations`). The title's image range must be free in the replaying process, so the tool is linked at a high image base on Windows.
- [Workgroup count split](../../../core/libs/prx/libSceAgcDriver/Execution/src/VulkanDevice.cpp) (libSceAgcDriver) - a direct dispatch over `maxComputeWorkGroupCount` runs as `vkCmdDispatch` chunks, each bound to a pipeline variant whose workgroup offset specialization constants add the chunk's base to `WorkgroupId` and `GlobalInvocationId`. A variant is built when its chunk base is first recorded, not when the dispatch is prepared. A split of a shader with device-memory LDS throws: its slot base reads the chunk's `NumWorkgroups`, not the dispatch's. Chunks are ordered by a compute barrier, which the console does not have between workgroups of one dispatch.
- [Experimental submission progress](../../../core/libs/prx/libSceAgcDriver/Execution/include/Driver/Queues/SubmissionProgress.hpp) (AGC driver) - the opt-in two-millisecond pacing budget is not derived from console hardware. Cross-title performance remains unvalidated; host scheduling can overshoot the timeout and pacing can reduce CPU/GPU overlap.