Pure-v2 Capture Pipeline
Pure-v2 is the current recording path. It captures frames, encodes video, muxes audio and video into MP4, writes CSV events, and emits JSON metadata.
Pipeline
Verified invariants
These properties were established by reading the production code path and hand-verifying them during a reliability review (EC-282) and the dead-code removal that followed (EC-283). They are documented here because each one is a non-obvious fact that a casual read of the source — or an automated scan — tends to get wrong, flagging a bug that does not exist. Treat them as checked axioms: if you think you've found a defect that contradicts one of these, re-read the cited code before filing it.
- WGC always runs in queue mode in production.
DesktopCapturecallsenableQueueMode(true)unconditionally before starting capture (desktop_capture.cpp). Consequences:- The only frame producer is the polling thread (
WindowsGraphicsCapture::capturePollingThreadFunc→enqueueFrame). The old event-drivenonFrameArrivedpath was never enabled in production and has been removed (EC-283), along with the superseded single-textureprocessFrameWithZeroCopy/zeroCopyTexture_mechanism and the CPUprocessFrameWithStagingreadback. Zero-copy GPU→NVENC lives exclusively in the pooled path: theD3D11TexturePoolBGRA slot is fed directly to NVENC as anNV_ENC_INPUT_RESOURCE_TYPE_DIRECTXinput resource, with no per-frame CUDA-context copy (EC-312). The legacy CUDA path (register the texture as a CUDA resource andcuMemcpy2DAsyncinto a NVENC input buffer) is retained only as a runtime fallback — used when DirectX input is opted out (PLAYROLL_NVENC_DIRECTX=0), no D3D11 device is bound, or the DirectX session fails to open. See NVENC input path (EC-312) below. - Because there is exactly one producer and one consumer, the lock-free CaptureQueue is a genuine SPSC ring — the single-producer/single-consumer assumption is satisfied, not violated.
- The only frame producer is the polling thread (
- WGC records a window, never a screen. Playroll always captures the detected game window (
CaptureManager::startCapture(HWND)→startWindowStreaming→startWindowCapture). The full-monitor display-capture path (startDisplayCapture/setMonitor/startDisplayStreaming) had no callers and was removed (EC-283).WGC::initializeno longer pre-creates any capture item;startWindowCaptureis the sole capture-item creator (the old monitor item built at init was always discarded). - Video timing is wall-clock-anchored CFR, not "encoded-frame-count" CFR. The muxed video PTS is
frame_index * (90000 / fps), butframe_indexis advanced to match elapsed wall-clock (target frame count derived from elapsed microseconds), andpadPureV2CfrTailpads the tail to keep it aligned. Dropped/decimated frames therefore do not shorten the video relative to the audio sample clock — A/V stays in sync. (This is robust but depends on the CFR padding always running; that path is load-bearing.) open_flagimpliesheader_writtenin the muxer. InHybridMp4Muxer,header_writtenis set immediately beforeopen_flag, andclose_clean()early-returns unlessopen_flagis set. Soav_write_traileris never reached with the header unwritten, and the abrupt-close path skips the trailer entirely. The two flags are logically coupled, not independent.- The NVENC RAII chain is balanced across mid-session recovery.
NvidiaNvsdkV2→ (encoder session, registered resources, bitstream buffers) are released in the correct dependency order, including across a destroy+reinit recovery cycle. The bitstream lock/unlock and resource map/unmap are paired on all paths. On the primary DirectX-input path NVENC registers each pool texture as a DirectX resource (NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX); on the CUDA fallback path the chain is additionally rooted in aCudaContextand the texture is registered as a CUDA resource. Both paths are synchronous — the encoder session is opened withenableEncodeAsync=0and frames are retired in submit order via a blockingnvEncLockBitstream, which provides natural backpressure. (An async completion-event ring was prototyped under EC-312 and reverted: with a single encode/poll thread its non-blocking drain lapped NVENC and starved the capture pool to ~5 FPS. The async scaffolding remains compiled but is dead at runtime.) The read-after-write ordering that prevents black frames is not owned by the encoder: it lives upstream as a GPU fence after the captureCopyResource, and is preserved unchanged on the DirectX path — see EC-284. - Encoded packets are moved, not copied, through the whole pipeline — encoder →
EncodeStage→PacketQueue→ mux stage all usestd::move, and the final mux hop hands the buffer to FFmpeg viaav_buffer_create(zero-copy) rather thanav_new_packet+ memcpy (EC-282).
GPU adapter pinning (hybrid laptops)
On hybrid laptops D3D11CreateDevice(nullptr) may bind the capturer to the Intel iGPU while NVENC encodes on the NVIDIA dGPU, forcing a cross-adapter copy on every frame and silently destroying capture throughput. Pure-v2 enumerates DXGI adapters and binds D3D11 explicitly to the NVIDIA adapter, then selects the matching CUDA device by PCI ID. The enumerated adapters, the active D3D11 adapter, and the selected CUDA device are logged at start so the capture/encode GPU is verifiable from logs.
With the DirectX NVENC input path (EC-312), the same ID3D11Device is used to create the capture textures and to open the NVENC DirectX session, so the D3D11/NVENC device-match is now the load-bearing constraint for the primary path; the CUDA-device selection still matters for the CUDA fallback and for on-device transcription.
Game/Input write synchronization
Pure-v2 does not block the video encoder while writing input rows to disk. Synchronization is logical and happens through shared session anchors:
| Anchor | Source | Purpose |
|---|---|---|
frame_number | CaptureManager::currentInputFrameNumber() | Links lifecycle and input rows to the current encoded-frame position. In pure-v2 this comes from framesEncodedAtomic_. |
timestamp | std::chrono::system_clock::now() at event observation time | Provides absolute ordering and duration math across CSV lifecycle rows. |
| Lifecycle rows | START, VIDEO_START, VIDEO_PAUSE, VIDEO_RESUME, VIDEO_END, END | Bounds the effective video interval and pause intervals used by validation. |
| Async queue | InputEventRecorder writer thread | Decouples input persistence from capture/encode throughput. |
The important property is that capture writes and input writes do not need to be physically synchronous. They need to share enough anchors to reconstruct the same recording interval during validation.
Operationally:
- The MP4 path owns video sample ordering and cadence. The CSV writer never sits on the encode/mux hot path.
- Input and lifecycle events are timestamped before they enter the async queue, so writer delay does not change their event time.
VIDEO_STARTis emitted after CSV recording starts, andVIDEO_ENDis emitted beforestopRecording()performs the final CSV flush.- The validator parses CSV lifecycle timestamps, requires monotonic timestamps and valid JSON
event_args, subtracts pause intervals, then compares the resulting CSV duration with MP4/JSON duration.
Under queue pressure, lifecycle rows are preserved preferentially and non-lifecycle input rows are the first candidates for dropping. This keeps the CSV usable for upload validation and tester troubleshooting even if detailed input fidelity is degraded.
Target quality
The product target is 60 FPS. The validator-compatible effective minimum is currently 58 FPS for a 60 FPS target. This gives tolerance for timing noise while still rejecting recordings that materially miss the quality target.
Active-duration accounting and first-frame gating
The validator computes muxed_fps = frame_count_muxed / duration_s, where duration_s is the recording's active duration (wall-clock minus pause intervals). Two rules keep the denominator honest:
- Pause intervals are excluded. Every
pauseCapture/resumeCapturepair is subtracted fromactive_duration_s(written into_meta.jsonasduration_s, with the raw wall-clock kept aswall_duration_sfor reference). - First-frame gating. The active segment does not start at
capture.v2.start— it starts when the first frame is actually enqueued into the capture queue. This shields the recording from time spent waiting for WGC to start producing frames (window minimized at record start, slow WGC source initialization, etc.) that would otherwise inflate the denominator while producing no frames. The transition is logged ascapture.v2.first_framewith asetup_msfield; sustained highsetup_msindicates a window-readiness or WGC source problem at start.
A practical consequence: a recording that takes 3 seconds for WGC to start producing frames will report active_duration_s = wall_duration_s - 3s and muxed_fps computed on the smaller denominator. This is what we want — the validator should reject a session for low quality, not for a slow start.
Source-health → pause policy
The WGC layer detects two health transitions while polling:
window_invalid— the captured window is minimized, hidden, or destroyed. The WGC layer signalsonSourceTurnedUnhealthy("window_invalid")and stops producing frames upstream until the window is valid again. There is no fallback texture path: producing frames here would not reach the encoder anyway becauseCaptureManagerpauses the encode pipeline onwgc-source-unhealthy.window_restored— the source becomes valid again.
Both transitions invoke a latched callback into CaptureManager, which forwards them to the pause policy as the reason wgc-source-unhealthy. The pause policy aggregates this with other reasons (e.g. input-idle, focus_lost) via PauseCoordinator and applies a physical pause/resume if the aggregate state changes. This closes the ~2-3s gap between the WGC poll noticing degradation and the periodic WindowWatchdog poll picking it up — without bypassing the coordination layer responsible for UI overlay state, audio cues, and recording phase transitions.
The reason wgc-source-unhealthy is whitelisted in RecordingPausePolicyReason (see recording_pause_policy_reason.hpp), required for the message-window command boundary to forward the request.
FPS profiling
Core includes an FPSProfiler module that observes periodic pure-v2 QoS snapshots.
In addition to the telemetry event below, the most recent snapshot (plus the
profiler's severity/reason classification) is retained on CaptureManager while
recording and surfaced live via the recorder.perf block of /api/status. This
feeds the performance overlay — now the Electron overlay's stats/GPU widgets
(the native bottom-left D2D HUD is retired; PLAYROLL_LEGACY_OVERLAY=1 restores
it) — and any in-UI perf readout.
It computes:
- Window source FPS.
- Window encoded FPS.
- Window muxed FPS.
- Cumulative muxed FPS.
- Drop ratio.
- Worst window muxed FPS while a warning is active.
It emits events when quality degrades, escalates, or recovers.
Encoder frame lifecycle
NVENC input path (EC-312)
The encode stage hands the captured D3D11TexturePool BGRA texture directly to NVENC, registering it as an NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX input resource. There is no per-frame CUDA copy on this path: the map texture and encode H.264 step in the diagram above is a register/map of the existing D3D11 texture, not a cuMemcpy. This removes a per-frame cuMemcpy2DAsync + blocking cuEventSynchronize that previously stalled the encode thread whenever the game saturated the GPU (the copy_sync cost in EC-309 / EC-312). On the DirectX path the copy_sync_us metric is 0; it is only non-zero on the CUDA fallback.
This is purely an encoder-input change. WGC capture is unchanged — Playroll still captures via Windows Graphics Capture (non-injecting, anti-cheat-safe); it does not hook the game's swapchain.
Path selection (decided once at encoder init, then stamped on telemetry):
| Path | When | NVENC input | Per-frame CUDA copy |
|---|---|---|---|
| DirectX (default) | D3D11 device bound and the DirectX NVENC session opens | NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX (the pool texture) | none |
| CUDA (fallback) | PLAYROLL_NVENC_DIRECTX=0, no D3D11 device, or DirectX session-open failure | CUDA-registered resource | cuMemcpy2DAsync + sync |
The active path is reported at init via capture.v2.nvenc.input_path and stamped as input_path (directx | cuda) on every capture.v2.encode_stage.frame_metrics event, alongside copy_sync_us / map_us / submit_us / nvenc_lock_wait_ms, so the path in use — and the absence of copy-sync cost on DirectX — is verifiable from telemetry.
Ordering and lifetime notes:
- EC-284 fence is preserved. The capture-side
CopyResourceinto the pool slot is fenced to GPU completion (fenceGpuCopy) before the slot is enqueued, so NVENC always reads an already-written texture. Removing the CUDA copy did not remove this fence — it lives upstream inCaptureManager/ WGC, not in the encoder. - In-flight slots are not overwritten. On the DirectX path the encoder defers the capture-pool slot release (
defers_slot_release()) until NVENC has retired the frame, so capture cannot recycle a texture NVENC is still reading.
Backpressure ownership
Warning reasons
| Reason | Meaning |
|---|---|
low_source_fps | The source/capture side is not producing enough frames. |
capture_pool_pressure | Texture pool pressure or pool-slot drops are causing frame loss. |
encode_backpressure | Encoder latency or NVENC lock waits are too high. |
mux_backpressure | Packet queue, mux, or disk I/O is slowing the pipeline. |
low_muxed_fps | Muxed FPS is low and no more specific cause was identified. |
Current event
capture.v2.fps_profiler.event
This event is logged by core today. UI exposure is a separate integration step.
Live stats surface
CaptureManager::getStats() snapshots into Stats so callers (HTTP status, dashboards, overlay) can read live values without taking the capture-thread mutex. Beyond frame counters, it includes:
| Field | Source | Meaning |
|---|---|---|
qosState | atomic mirror of qos.keep_every_n / saturation | Normal, Escalating, or Saturated — distinguishes "low FPS because the machine is slow" from "low FPS because Playroll is throttling capture under encoder pressure". |
qosKeepEveryN | atomic mirror | Current decimation factor. Under the hard-60 policy (kQosMaxKeepEveryN = 1 in capture_manager.cpp) this is capped at 1 for the whole session — adaptive capture decimation is disabled by design, so quality degradation surfaces as qosState = Escalating/Saturated and dropped frames, never as a lower kept-frame ratio. |
qosDropsTotal | atomic mirror, updated on the 5s QoS snapshot tick | Cumulative all-cause drops since recording start. |
qosDropsLastWindow | computed from total + previous snapshot | Drops since the previous getStats() call — useful for live "drops in the last second" overlays. |
recoveryInProgress | set during zero_video_output_watchdog recovery attempts | UI can render a "Reconnecting…" banner instead of leaving the FPS counter mysteriously at zero. |
recoveryAttempt | attempt counter while recoveryInProgress=true | Reset to 0 when the recovery succeeds. |
Related monitored failures
Pure-v2 also emits telemetry for conditions that can explain failed or degraded recordings:
| Event family | Meaning |
|---|---|
capture.v2.qos* | Queue pressure, dropped frames, and capture backpressure. |
capture.v2.pool_pressure | Texture pool pressure and GPU resource contention. |
capture.v2.encode_stage.frame_metrics | Encode throughput and latency. |
capture.v2.packet_queue.depth | Packet queue growth and enqueue blocks. |
capture.v2.mux_stage.write_metrics | MP4 write latency and disk I/O. |
capture.v2.zero_video_output_watchdog | Active pipeline with no muxed video output. |
capture.v2.nvenc.* | NVIDIA encoder warnings and failures. |
See Core Signals for the full user-facing and operational signal catalog.