Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs-astro/src/data/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@
"help": "Export buffer raw data.",
"usage": "rdc buffer <ID> [-o PATH] [--raw]"
},
{
"name": "cbuffer",
"id": "cbuffer",
"help": "Decode a constant buffer to JSON or export its raw bytes.",
"usage": "rdc cbuffer [EID] [--stage CHOICE] [--set INTEGER] [--binding INTEGER] [--json] [--raw] [-o PATH]"
},
{
"name": "rt",
"id": "rt",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# OpenSpec: issue-224-cbuffer-export

## Summary

Expose `rdc cbuffer` as a first-class CLI command with decoded JSON output and optional
raw binary export (`--raw`).

## Context and Motivation

OpenSpec phase2-buffer-decode (archived 2026-02-19) originally planned three CLI commands:
`rdc cbuffer`, `rdc vbuffer`, and `rdc ibuffer`. The daemon handler `cbuffer_decode` and its
VFS route shipped in that phase. The CLI half — `rdc cbuffer` — was never implemented.
This change completes that unshipped work.

GitHub issue #224 (part 2) tracks the gap. Users who rely on `rdc buffer --raw` for raw bytes
have no ergonomic path to decoded constant-buffer variables without constructing VFS paths
manually.

**Scope note:** The archived phase2-buffer-decode plan envisioned `rdc cbuffer` as a thin VFS
`cat` wrapper; this change implements a richer direct command with decoded JSON output, a new
`cbuffer_raw` handler, and a new VFS `leaf_bin` route — reviewers should not expect a pure VFS
wrapper.

## Design

### New command: `rdc cbuffer`

```
rdc cbuffer [EID] --stage [vs|hs|ds|gs|ps|cs] --set N --binding N [--json] [--raw -o file.bin]
```

- `EID`: optional; resolved via `complete_eid` if omitted (matches existing `rdc buffer` pattern).
- `--stage`: default `ps`.
- `--set`: default `0`.
- `--binding`: default `0`.
- `--json`: emit decoded variables as JSON (default output mode).
- `--raw -o file.bin`: export the raw constant-buffer bytes to a file.

### Decoded path

Calls the existing `cbuffer_decode` daemon handler unchanged. Returns
`{"eid", "set", "binding", "variables": [{name, type, value}, ...]}` and writes it via
`write_json`. No daemon changes required for this path.

### Raw path

Adds a new `cbuffer_raw` daemon handler in `handlers/buffer.py`. The handler repeats the
reflection lookup (`fixedBindSetOrSpace` / `fixedBindNumber`), obtains
`GetConstantBlock(...).descriptor`, calls `controller.GetBufferData(resource, byteOffset,
byteSize)`, writes the bytes to `state.temp_dir/cbuffer_<eid>_<set>_<binding>.bin`, and
returns `{"path", "size"}`.

The handler is exposed as a VFS `leaf_bin` route at
`/draws/<eid>/cbuffer/<set>/<binding>/data` in `vfs/router.py`, mirroring the way `buf_raw`
is wired at `/buffers/<id>/data`. The CLI calls
`_export_vfs_path(f"/draws/{eid}/cbuffer/{set}/{binding}/data", output, raw)` (from
`commands/export.py`), which follows the same `vfs_ls` + `resolve_path` → `_deliver_binary`
flow used by `rdc buffer --raw`. `_deliver_binary` (vfs.py:216) is the final delivery
step — it calls `call(match.handler, match.args)` through the VFS resolve layer; it is NOT
a standalone "call handler, get path, write bytes" helper invoked directly.

The `hasattr(pipe_state, "GetConstantBlock")` guard present in `cbuffer_decode` is preserved
in `cbuffer_raw` for RenderDoc version drift safety.

### New source file

`src/rdc/commands/cbuffer.py` — registered in `src/rdc/cli.py` adjacent to `buffer_cmd`
(~line 138).

## Risks

### Non-buffer-backed constant buffers

`ConstantBlock.bufferBacked == False` for push constants (Vulkan) and D3D12 root constants.
These have no backing buffer resource; `GetBufferData` would operate on a null resource.

Defined behavior: `--raw` MUST return a JSON-RPC error with a descriptive message
(e.g. `"cbuffer is not buffer-backed (push constant or root constant)"`) rather than crash
or silently return zero bytes. Decoded `--json` output is unaffected and continues to work
via `GetCBufferVariableContents`.

### D3D12 root constants / register spaces

The `fixedBindSetOrSpace` / `fixedBindNumber` mapping for D3D12 root constants cannot be
verified on this Linux development machine. The Vulkan (vkcube) integration test exercises
the buffer-backed path only. Behavior on D3D12 root-constant captures is verified by
reporter @Misaka-Mikoto-Tech on real D3D12 hardware after the PR ships.

### `_extract_value` type coverage (optional improvement)

The existing `_extract_value` helper (`buffer.py:163-173`) handles only `f32v` members;
integer and unsigned-integer shader variables degrade silently. `_flatten_shader_var`
in `handlers/_helpers.py` already handles `u32v`/`s32v` and is used by `shader_constants`.
Switching `cbuffer_decode` to use `_flatten_shader_var` is an optional polish step; it does
not block this change but is tracked as a separate optional task.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## ADDED Requirements

### Requirement: Constant Buffer Raw Export
The daemon SHALL expose a handler to export raw constant-buffer bytes to a temporary file.

#### Scenario: Export buffer-backed constant buffer
- **WHEN** client requests `cbuffer_raw` with `eid`, `set`, `binding`, and `stage`
- **THEN** daemon resolves the constant block via `fixedBindSetOrSpace` / `fixedBindNumber`,
calls `GetBufferData(resource, byteOffset, byteSize)`, writes bytes to
`state.temp_dir/cbuffer_<eid>_<set>_<binding>.bin`, and returns `{"path", "size"}`.
- **IF** `state.adapter` is None, return error -32002.
- **IF** `eid` is not a valid draw event, return error.

#### Scenario: Reject non-buffer-backed constant buffer
- **WHEN** client requests `cbuffer_raw` and `ConstantBlock.bufferBacked == False`
- **THEN** daemon returns a JSON-RPC error with a message indicating the cbuffer is not
buffer-backed (push constant or root constant) and does not write any file.

#### Scenario: RenderDoc version guard
- **WHEN** `GetConstantBlock` is not present on the pipeline state object
- **THEN** daemon returns an error indicating the API is unavailable on this RenderDoc version.

### Requirement: Constant Buffer CLI Command
The CLI SHALL expose `rdc cbuffer` as a first-class command for decoded and raw export.

#### Scenario: Decoded JSON output
- **WHEN** user runs `rdc cbuffer [EID] --stage STAGE --set N --binding N`
- **THEN** CLI calls `cbuffer_decode` handler and writes the JSON response to stdout.
- **IF** `EID` is omitted, CLI resolves it via `complete_eid`.

#### Scenario: Raw binary export
- **WHEN** user runs `rdc cbuffer [EID] --raw -o FILE`
- **THEN** CLI resolves the VFS path `/draws/<eid>/cbuffer/<set>/<binding>/data` via
`_export_vfs_path` (vfs_ls + resolve_path → _deliver_binary), exactly mirroring how
`rdc buffer --raw` uses `/buffers/<id>/data`, and writes bytes to `FILE`.
- **IF** `-o` is not specified alongside `--raw`, CLI exits with a usage error.

#### Scenario: No active session
- **WHEN** user runs `rdc cbuffer` and no daemon session is active
- **THEN** CLI exits with code 1 and prints an error message to stderr.
47 changes: 47 additions & 0 deletions openspec/changes/2026-05-15-issue-224-cbuffer-export/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Tasks: issue-224-cbuffer-export

## Phase A: Mock API additions

- [ ] Add `GetBufferData` stub to `mock_renderdoc.py` (anchor: ~line 1356 `GetConstantBlock`).
The happy-path test must give the mock `Descriptor` (returned via
`GetConstantBlock(...).descriptor`) a non-null `resource`, `byteOffset`, and `byteSize` —
these are the fields the raw-path handler keys off (`buffer.py:143-147`).
- [ ] Add `bufferBacked=False` variant to the mock `ConstantBlock` for the push-constant
clean-error test; this stubs the reflection-level field used to reject non-buffer-backed
cbuffers before `GetBufferData` is called.

## Phase B: `cbuffer_raw` daemon handler (tests first)

- [ ] Extend `tests/unit/test_buffer_decode.py` with `cbuffer_raw` cases (happy path,
`bufferBacked=False` error, no-adapter guard)
- [ ] Implement `cbuffer_raw` handler in `src/rdc/handlers/buffer.py` (~line 370,
adjacent to `HANDLERS` dict)
- [ ] Register `cbuffer_raw` in `HANDLERS`
- [ ] Add VFS `leaf_bin` route for `/draws/<eid>/cbuffer/<set>/<binding>/data` →
`cbuffer_raw` in `src/rdc/vfs/router.py`, mirroring
`/buffers/<id>/data` → `buf_raw` (router.py:184)
- [ ] Verify handler unit tests pass

## Phase C: `rdc cbuffer` CLI command (tests first)

- [ ] Write `tests/unit/test_cbuffer_commands.py` (JSON mode, `--raw -o`, no-session error,
`complete_eid` fallback, `--raw` without `-o` usage error)
- [ ] Implement `src/rdc/commands/cbuffer.py`; raw path calls
`_export_vfs_path(f"/draws/{eid}/cbuffer/{set}/{binding}/data", output, raw)` from
`commands/export.py` — no direct handler dispatch
- [ ] Register `cbuffer_cmd` in `src/rdc/cli.py` (~line 138, adjacent to `buffer_cmd`)
- [ ] Verify CLI unit tests pass

## Phase D: Integration + verification

- [ ] Extend `tests/integration/test_daemon_handlers_real.py` with `@pytest.mark.gpu`
tests for `cbuffer_raw` (Vulkan vkcube capture)
- [ ] Run `pixi run lint && pixi run test` — all pass, coverage ≥ 80% for new paths
- [ ] Run GPU integration tests against real capture — pass
- [ ] Code review

## Phase E: Optional polish (non-blocking)

- [ ] Switch `cbuffer_decode` to use `_flatten_shader_var` from `handlers/_helpers.py`
instead of `_extract_value` to fix int/uint member degradation
- [ ] Extend `test_buffer_decode.py` with int/uint value assertions
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Test Plan: issue-224-cbuffer-export

## Scope

### In scope
- New daemon handler `cbuffer_raw` (unit + integration)
- New CLI command `rdc cbuffer` (unit: JSON mode, raw mode, error paths)
- `bufferBacked == False` error path for `cbuffer_raw`
- Optional: `_extract_value` int/uint coverage in `test_buffer_decode.py`

### Out of scope
- Existing `cbuffer_decode` handler correctness (covered by prior test suite)
- `rdc vbuffer` / `rdc ibuffer` (separate issue)
- D3D12 root-constant register-space mapping (cannot run on this Linux box; deferred to reporter verification)

## Test Matrix

| Layer | Test Type | File |
|-------|-----------|------|
| Unit | `cbuffer_raw` handler (mock) | `tests/unit/test_buffer_decode.py` (extend) |
| Unit | `rdc cbuffer` CLI command | `tests/unit/test_cbuffer_commands.py` (new) |
| Integration | real capture decoded + raw | `tests/integration/test_daemon_handlers_real.py` (extend) |

## Cases

### `cbuffer_raw` handler (extend `test_buffer_decode.py`)

- **Happy path**: mock `GetConstantBlock` returns a buffer-backed descriptor (`bufferBacked=True`,
known `byteOffset`/`byteSize`); mock `GetBufferData` returns 16 known bytes.
Assert response contains `{"path": "...", "size": 16}` and the temp file exists with the
expected bytes.
- **bufferBacked=False**: mock `GetConstantBlock` returns `bufferBacked=False`.
Assert the handler returns a JSON-RPC error (code -32602 or equivalent) with a message
containing `"not buffer-backed"`. Assert no temp file is created.
- **No adapter**: `state.adapter is None` → assert error -32002 (standard no-adapter guard).
- **Missing eid**: invalid `eid` → assert error from `SetFrameEvent`.

Mock anchor: `mock_renderdoc.py` `GetConstantBlock` (~line 1356), `GetBufferData`.

### `rdc cbuffer` CLI command (new `test_cbuffer_commands.py`)

Mirror structure of `tests/unit/test_mesh_commands.py` (monkeypatch `rdc.commands.cbuffer.call`).

- **JSON mode (default)**: monkeypatch `call("cbuffer_decode", ...)` returns
`{"eid": 10, "set": 0, "binding": 0, "variables": [{"name": "mvp", "type": "mat4", "value": [...]}]}`.
Invoke `rdc cbuffer 10 --stage ps --set 0 --binding 0`.
Assert exit code 0, stdout is valid JSON matching the payload.
- **`--json` explicit flag**: same as above with `--json` flag; assert identical output.
- **`--raw -o file.bin`**: monkeypatch `call("cbuffer_raw", ...)` returns `{"path": "/tmp/cbuffer_10_0_0.bin", "size": 16}`;
monkeypatch binary delivery (`_deliver_binary` / `fetch_remote_file`).
Assert exit code 0, output file contains the expected bytes.
- **No session**: `call` raises connection error → assert exit code 1, message on stderr.
- **`--raw` without `-o`**: assert exit code non-zero with usage error on stderr.
- **EID omitted**: monkeypatch `complete_eid` returns `42`; assert the handler is called with `eid=42`.

### Integration (`test_daemon_handlers_real.py`, `@pytest.mark.gpu`)

Extend analogous to `test_cbuffer_decode_returns_data` (~line 1965), using a vkcube/Vulkan
capture with a known draw EID that has a buffer-backed cbuffer.

- **`cbuffer_raw` returns file**: call `cbuffer_raw` with valid `eid`/`set`/`binding`.
Assert response contains `path` and `size > 0`; assert temp file exists and `size` matches
`os.path.getsize(path)`.
- **`cbuffer_decode` + `cbuffer_raw` size agreement**: decoded `variables` total byte footprint
is consistent with the raw `size`.

## Assertions (all tests)

- Exit code 0 on success, non-zero on error.
- JSON output: valid JSON, `variables` array present for decoded mode.
- Raw output: file written at `-o` path, byte count matches `size` from handler.
- Error messages go to stderr; stdout is empty on error.
- `bufferBacked=False` produces an error message containing `"not buffer-backed"` (case-insensitive).

## Coverage Gate

CI enforces ≥ 80% line coverage for `src/rdc/commands/cbuffer.py` and the new
`cbuffer_raw` code path in `src/rdc/handlers/buffer.py`.
2 changes: 1 addition & 1 deletion scripts/gen-commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"search", "passes", "pass", "unused-targets",
]),
("Export", "export", None, [
"texture", "buffer", "rt", "mesh", "snapshot",
"texture", "buffer", "cbuffer", "rt", "mesh", "snapshot",
]),
("Pixel & Debug", "pixel-debug", None, [
"pixel", "pick-pixel", "tex-stats",
Expand Down
21 changes: 21 additions & 0 deletions src/rdc/_skills/references/commands-quick-ref.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,27 @@ Output VFS leaf node content.
| `--raw` | Force raw output even on TTY | flag | |
| `-o, --output` | Write binary output to file | path | |

## `rdc cbuffer`

Decode a constant buffer to JSON or export its raw bytes.

**Arguments:**

| Name | Type | Required |
|------|------|----------|
| `eid` | integer | no |

**Options:**

| Flag | Help | Type | Default |
|------|------|------|---------|
| `--stage` | Shader stage (default: ps) | choice | ps |
| `--set` | Vulkan: descriptor set. D3D12: register space. | integer | 0 |
| `--binding` | Vulkan: binding. D3D12: shader register (bN). | integer | 0 |
| `--json` | JSON output (default) | flag | |
| `--raw` | Export raw constant-buffer bytes | flag | |
| `-o, --output` | Write raw bytes to file | path | |

## `rdc close`

Close daemon-backed session.
Expand Down
2 changes: 2 additions & 0 deletions src/rdc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
sections_cmd,
thumbnail_cmd,
)
from rdc.commands.cbuffer import cbuffer_cmd
from rdc.commands.completion import completion_cmd
from rdc.commands.counters import counters_cmd
from rdc.commands.debug import debug_group
Expand Down Expand Up @@ -136,6 +137,7 @@ def entry() -> None:
main.add_command(texture_cmd, name="texture")
main.add_command(rt_cmd, name="rt")
main.add_command(buffer_cmd, name="buffer")
main.add_command(cbuffer_cmd, name="cbuffer")
main.add_command(mesh_cmd, name="mesh")
main.add_command(search_cmd, name="search")
main.add_command(usage_cmd, name="usage")
Expand Down
61 changes: 61 additions & 0 deletions src/rdc/commands/cbuffer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""rdc cbuffer -- decoded or raw constant-buffer export."""

from __future__ import annotations

from typing import Any

import click

from rdc.commands._helpers import call, complete_eid
from rdc.commands.export import _export_vfs_path
from rdc.formatters.json_fmt import write_json


@click.command("cbuffer")
@click.argument("eid", type=int, required=False, default=None, shell_complete=complete_eid)
@click.option(
"--stage",
type=click.Choice(["vs", "hs", "ds", "gs", "ps", "cs"]),
default="ps",
help="Shader stage (default: ps)",
)
@click.option(
"--set",
"cb_set",
type=int,
default=0,
help="Vulkan: descriptor set. D3D12: register space.",
)
@click.option(
"--binding",
type=int,
default=0,
help="Vulkan: binding. D3D12: shader register (bN).",
)
@click.option("--json", "use_json", is_flag=True, help="JSON output (default)")
@click.option("--raw", is_flag=True, help="Export raw constant-buffer bytes")
@click.option("-o", "--output", type=click.Path(), default=None, help="Write raw bytes to file")
def cbuffer_cmd(
eid: int | None,
stage: str,
cb_set: int,
binding: int,
use_json: bool,
raw: bool,
output: str | None,
) -> None:
"""Decode a constant buffer to JSON or export its raw bytes."""
if raw:
if output is None:
raise click.UsageError("-o/--output is required with --raw")
if eid is None:
raise click.UsageError("EID is required with --raw")
_export_vfs_path(f"/draws/{eid}/cbuffer/{cb_set}/{binding}/data", output, raw)
return

del use_json
params: dict[str, Any] = {"stage": stage, "set": cb_set, "binding": binding}
if eid is not None:
params["eid"] = eid
result = call("cbuffer_decode", params)
write_json(result)
Loading
Loading