`apps/nescapture/Cargo.toml` carried `[profile.release]` with `opt-level = 3` and `lto = "thin"`. Cargo only reads `[profile.*]` from the workspace root and warns about a member that writes one, so `cargo build --release --workspace` — which is what build/Dockerfile runs — ignored it. The Vulkan capture layer that ends up in the guest image was built at the default release profile, and the warning saying so scrolled past on every build. `opt-level = 3` is already the release default, so `lto = "thin"` is the only part that was actually lost. It moves to the workspace root, where Cargo reads it, and now applies to all five members — the same rule as `[workspace.dependencies]` right above it: what must not differ between members is stated once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nescapture
A Vulkan implicit layer that captures frames from inside the workload's own
process, encodes them with Vulkan Video on the GPU that drew them, and
sends them to neshub over a Unix socket — no copy out to the
CPU and back.
Being a layer rather than a screen-scraper is the whole point: the frame is already on the GPU when we get it, and it never leaves.
Where it sits
Game process
│ Vulkan calls
▼
┌──────────────────────────────────────────────┐
│ nescapture implicit layer │
│ │
│ vkCreateShaderModule → SHA-256 hash │
│ vkCreateGraphicsPipelines → track hashes │
│ vkCmdBindPipeline → detect HUD │
│ vkQueuePresentKHR → capture+encode │
└──────────────────────────────────────────────┘
│ GPU blit, same device
▼
final_image (DMA-BUF exportable)
│ get_dmabuf_fd(final_memory)
▼
DmaBufImporter (pixelforge VkDevice)
│ import_or_reuse() → vk::Image
▼
ColorConverter (GPU compute shader)
│ BGRA/RGB10/FP16 → NV12/P010/YUV444
▼
Encoder (Vulkan Video: H.264 / H.265 / AV1)
│ Annex-B packets
▼
Unix datagram → neshub → the client
CPU fallback exists only for driver configurations without DMA-BUF external memory export.
Sockets
| path | direction | carries |
|---|---|---|
/tmp/nestri-video.sock |
nescapture → neshub | encoded frames |
/tmp/nestri-stats.sock |
nescapture → neshub | capture fps, encode ms, drops |
/tmp/nescapture-cmd.sock |
neshub → nescapture | IDR requests, encode settings |
nescapture binds the command socket and connects to the other two. The stats
path is derived from NESCAPTURE_IPC_PATH's directory, so moving the video
socket moves both.
Quick start
# 1. Build
cargo build --release -p nescapture
# 2. Install the layer manifest
sudo cp apps/nescapture/manifest/VK_LAYER_nescapture.json /usr/share/vulkan/implicit_layer.d/
# Point the manifest's `library_path` at target/release/libnescapture_layer.so
# 3. Run something that draws
export NESCAPTURE_ENABLE=1
export NESCAPTURE_CODEC=h265
export NESCAPTURE_BITRATE=10000
export RUST_LOG=info
./my-vulkan-app
The layer is inert unless NESCAPTURE_ENABLE=1. That is deliberate — an
implicit layer is loaded into every Vulkan process on the system.
Environment variables
| Variable | Default | Description |
|---|---|---|
NESCAPTURE_ENABLE |
(unset) | Set to 1 to activate the layer. Nothing happens otherwise |
NESCAPTURE_IPC_PATH |
/tmp/nestri-video.sock |
Where to send encoded frames |
NESCAPTURE_CODEC |
best available | h264, h265 or av1; probes if unset |
NESCAPTURE_FORMAT |
yuv420 |
yuv420 or yuv444 |
NESCAPTURE_DEPTH |
auto | 8 or 10; inferred from the swapchain VkFormat if unset |
NESCAPTURE_BITRATE |
10000 |
CBR target in kbps. Ignored when NESCAPTURE_QP is set |
NESCAPTURE_QP |
(unset) | Constant QP instead of CBR |
NESCAPTURE_FPS |
60 |
Target frame rate |
NESCAPTURE_IDR_INTERVAL |
4 |
Force an IDR every N seconds |
NESCAPTURE_TUNE |
(unset) | highquality, lowlatency, ultralowlatency, lossless |
NESCAPTURE_CONFIG |
(unset) | Path to the per-app shader-hash TOML |
NESCAPTURE_GAME_NAME |
exe basename | Override app identification for that config |
NESCAPTURE_DISCOVER |
(unset) | Set to 1 to log every draw, for finding HUD shaders |
RUST_LOG |
error |
Standard env_logger filter, e.g. nescapture_layer=debug |
Everything else is decided at runtime: the client asks neshub for a codec or
bitrate change and it arrives on the command socket, so the encoder is
reconfigured without a restart.
Per-app shader-hash config
HUD detection needs to know which pipelines draw the HUD, and that is
per-application. Run once with NESCAPTURE_DISCOVER=1 to log every shader,
then pick out the HUD pipelines by the [SUSPECT] marker — blend on, depth
off, six vertices or fewer.
# ~/.config/nescapture/apps.toml
[game."MyApp.exe"]
hud_fragment_shaders = ["0xaabbccddeeff0011"]
hud_vertex_shaders = ["0x1a2b3c4d5e6f7890"]
skip_fragment_shaders = ["0x1122334455667788"]
export NESCAPTURE_CONFIG=~/.config/nescapture/apps.toml
export NESCAPTURE_GAME_NAME=MyApp.exe
Modules
src/
├── lib.rs entry points, dispatch routing
├── dispatch.rs Vulkan function-pointer types and tables
├── state.rs global DashMaps, DeviceState, CbState
├── instance.rs vkCreateInstance / vkDestroyInstance
├── device.rs vkCreateDevice / vkDestroyDevice
├── shader.rs SPIR-V hashing
├── pipeline.rs graphics pipeline tracking
├── framebuffer.rs image view and framebuffer tracking
├── commands.rs vkCmdBind*, vkCmdDraw*, vkCmdBeginRenderPass
├── swapchain.rs vkCreateSwapchainKHR, image enumeration
├── capture.rs GPU blit to the capture image, DMA-BUF export
├── present.rs vkQueuePresentKHR, encode dispatch
├── encode.rs pixelforge pipeline, codec probing, IPC send
├── dmabuf_import.rs cross-device zero-copy import
├── config.rs per-app TOML shader-hash config
└── discovery.rs draw-call logging for shader discovery
Dependencies
| Crate | Purpose |
|---|---|
pixelforge |
Vulkan Video hardware encode |
ash |
Vulkan bindings |
nesprotocol |
The IPC frame format neshub reads |
sha2, bytemuck |
SPIR-V fingerprinting |
dashmap, once_cell |
Lock-free concurrent state |
serde, toml |
Per-app shader config |
ash and pixelforge are pinned to git revisions — both track Vulkan Video
support that has not landed in a release.
Licence
Apache 2.0. See LICENSE.