Skip to content

Commit f23143a

Browse files
authored
feat(cbuffer): add rdc cbuffer command and cbuffer_raw export (#224) (#228)
* feat(cbuffer): add rdc cbuffer command and cbuffer_raw export (#224) Complete the unshipped CLI half of phase2-buffer-decode. - New daemon handler cbuffer_raw: reflection lookup, GetConstantBlock descriptor, GetBufferData -> temp file {path,size}. Rejects non-buffer-backed cbuffers (push/root constants) with a clean JSON-RPC error; preserves GetConstantBlock version guard. - New VFS leaf_bin route /draws/<eid>/cbuffer/<set>/<binding>/data. - New CLI command rdc cbuffer: decoded JSON (cbuffer_decode) by default, raw export via _export_vfs_path mirroring rdc buffer. - Unit tests for handler + CLI, GPU integration tests, regenerated command/skill references. * fix(cbuffer): require explicit EID for --raw export (#224) Passing --raw without an EID silently targeted draw 0 (capture-start), which never has a bound cbuffer and produced a confusing error. Now raises UsageError matching the precedent in export.py rt_cmd. Rename test_eid_omitted_uses_completion_fallback → test_eid_omitted_lets_daemon_default to reflect what the test actually asserts (no eid key in params, daemon uses current event). Add test_raw_without_eid: asserts exit code 2 and "EID" in output. * fix(cbuffer): guard null resource and byteSize==0; clarify D3D12 bindings (#224) _handle_cbuffer_raw returned a 0-byte file as success when the cbuffer resource was null/unbound, and dumped the entire backing upload heap when the descriptor reported byteSize==0 (D3D12 root CBV). Now: - null/zero cb_resource -> JSON-RPC -32001 'cbuffer not bound at this draw' - byteSize==0 falls back to the reflected ConstantBlock byteSize - both zero -> clean -32001 error; never pass size 0 to GetBufferData --set/--binding help now states Vulkan (descriptor set / binding) vs D3D12 (register space / shader register bN). Regenerated commands-quick-ref.
1 parent 072cc49 commit f23143a

15 files changed

Lines changed: 742 additions & 1 deletion

File tree

docs-astro/src/data/commands.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,12 @@
201201
"help": "Export buffer raw data.",
202202
"usage": "rdc buffer <ID> [-o PATH] [--raw]"
203203
},
204+
{
205+
"name": "cbuffer",
206+
"id": "cbuffer",
207+
"help": "Decode a constant buffer to JSON or export its raw bytes.",
208+
"usage": "rdc cbuffer [EID] [--stage CHOICE] [--set INTEGER] [--binding INTEGER] [--json] [--raw] [-o PATH]"
209+
},
204210
{
205211
"name": "rt",
206212
"id": "rt",
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# OpenSpec: issue-224-cbuffer-export
2+
3+
## Summary
4+
5+
Expose `rdc cbuffer` as a first-class CLI command with decoded JSON output and optional
6+
raw binary export (`--raw`).
7+
8+
## Context and Motivation
9+
10+
OpenSpec phase2-buffer-decode (archived 2026-02-19) originally planned three CLI commands:
11+
`rdc cbuffer`, `rdc vbuffer`, and `rdc ibuffer`. The daemon handler `cbuffer_decode` and its
12+
VFS route shipped in that phase. The CLI half — `rdc cbuffer` — was never implemented.
13+
This change completes that unshipped work.
14+
15+
GitHub issue #224 (part 2) tracks the gap. Users who rely on `rdc buffer --raw` for raw bytes
16+
have no ergonomic path to decoded constant-buffer variables without constructing VFS paths
17+
manually.
18+
19+
**Scope note:** The archived phase2-buffer-decode plan envisioned `rdc cbuffer` as a thin VFS
20+
`cat` wrapper; this change implements a richer direct command with decoded JSON output, a new
21+
`cbuffer_raw` handler, and a new VFS `leaf_bin` route — reviewers should not expect a pure VFS
22+
wrapper.
23+
24+
## Design
25+
26+
### New command: `rdc cbuffer`
27+
28+
```
29+
rdc cbuffer [EID] --stage [vs|hs|ds|gs|ps|cs] --set N --binding N [--json] [--raw -o file.bin]
30+
```
31+
32+
- `EID`: optional; resolved via `complete_eid` if omitted (matches existing `rdc buffer` pattern).
33+
- `--stage`: default `ps`.
34+
- `--set`: default `0`.
35+
- `--binding`: default `0`.
36+
- `--json`: emit decoded variables as JSON (default output mode).
37+
- `--raw -o file.bin`: export the raw constant-buffer bytes to a file.
38+
39+
### Decoded path
40+
41+
Calls the existing `cbuffer_decode` daemon handler unchanged. Returns
42+
`{"eid", "set", "binding", "variables": [{name, type, value}, ...]}` and writes it via
43+
`write_json`. No daemon changes required for this path.
44+
45+
### Raw path
46+
47+
Adds a new `cbuffer_raw` daemon handler in `handlers/buffer.py`. The handler repeats the
48+
reflection lookup (`fixedBindSetOrSpace` / `fixedBindNumber`), obtains
49+
`GetConstantBlock(...).descriptor`, calls `controller.GetBufferData(resource, byteOffset,
50+
byteSize)`, writes the bytes to `state.temp_dir/cbuffer_<eid>_<set>_<binding>.bin`, and
51+
returns `{"path", "size"}`.
52+
53+
The handler is exposed as a VFS `leaf_bin` route at
54+
`/draws/<eid>/cbuffer/<set>/<binding>/data` in `vfs/router.py`, mirroring the way `buf_raw`
55+
is wired at `/buffers/<id>/data`. The CLI calls
56+
`_export_vfs_path(f"/draws/{eid}/cbuffer/{set}/{binding}/data", output, raw)` (from
57+
`commands/export.py`), which follows the same `vfs_ls` + `resolve_path``_deliver_binary`
58+
flow used by `rdc buffer --raw`. `_deliver_binary` (vfs.py:216) is the final delivery
59+
step — it calls `call(match.handler, match.args)` through the VFS resolve layer; it is NOT
60+
a standalone "call handler, get path, write bytes" helper invoked directly.
61+
62+
The `hasattr(pipe_state, "GetConstantBlock")` guard present in `cbuffer_decode` is preserved
63+
in `cbuffer_raw` for RenderDoc version drift safety.
64+
65+
### New source file
66+
67+
`src/rdc/commands/cbuffer.py` — registered in `src/rdc/cli.py` adjacent to `buffer_cmd`
68+
(~line 138).
69+
70+
## Risks
71+
72+
### Non-buffer-backed constant buffers
73+
74+
`ConstantBlock.bufferBacked == False` for push constants (Vulkan) and D3D12 root constants.
75+
These have no backing buffer resource; `GetBufferData` would operate on a null resource.
76+
77+
Defined behavior: `--raw` MUST return a JSON-RPC error with a descriptive message
78+
(e.g. `"cbuffer is not buffer-backed (push constant or root constant)"`) rather than crash
79+
or silently return zero bytes. Decoded `--json` output is unaffected and continues to work
80+
via `GetCBufferVariableContents`.
81+
82+
### D3D12 root constants / register spaces
83+
84+
The `fixedBindSetOrSpace` / `fixedBindNumber` mapping for D3D12 root constants cannot be
85+
verified on this Linux development machine. The Vulkan (vkcube) integration test exercises
86+
the buffer-backed path only. Behavior on D3D12 root-constant captures is verified by
87+
reporter @Misaka-Mikoto-Tech on real D3D12 hardware after the PR ships.
88+
89+
### `_extract_value` type coverage (optional improvement)
90+
91+
The existing `_extract_value` helper (`buffer.py:163-173`) handles only `f32v` members;
92+
integer and unsigned-integer shader variables degrade silently. `_flatten_shader_var`
93+
in `handlers/_helpers.py` already handles `u32v`/`s32v` and is used by `shader_constants`.
94+
Switching `cbuffer_decode` to use `_flatten_shader_var` is an optional polish step; it does
95+
not block this change but is tracked as a separate optional task.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Constant Buffer Raw Export
4+
The daemon SHALL expose a handler to export raw constant-buffer bytes to a temporary file.
5+
6+
#### Scenario: Export buffer-backed constant buffer
7+
- **WHEN** client requests `cbuffer_raw` with `eid`, `set`, `binding`, and `stage`
8+
- **THEN** daemon resolves the constant block via `fixedBindSetOrSpace` / `fixedBindNumber`,
9+
calls `GetBufferData(resource, byteOffset, byteSize)`, writes bytes to
10+
`state.temp_dir/cbuffer_<eid>_<set>_<binding>.bin`, and returns `{"path", "size"}`.
11+
- **IF** `state.adapter` is None, return error -32002.
12+
- **IF** `eid` is not a valid draw event, return error.
13+
14+
#### Scenario: Reject non-buffer-backed constant buffer
15+
- **WHEN** client requests `cbuffer_raw` and `ConstantBlock.bufferBacked == False`
16+
- **THEN** daemon returns a JSON-RPC error with a message indicating the cbuffer is not
17+
buffer-backed (push constant or root constant) and does not write any file.
18+
19+
#### Scenario: RenderDoc version guard
20+
- **WHEN** `GetConstantBlock` is not present on the pipeline state object
21+
- **THEN** daemon returns an error indicating the API is unavailable on this RenderDoc version.
22+
23+
### Requirement: Constant Buffer CLI Command
24+
The CLI SHALL expose `rdc cbuffer` as a first-class command for decoded and raw export.
25+
26+
#### Scenario: Decoded JSON output
27+
- **WHEN** user runs `rdc cbuffer [EID] --stage STAGE --set N --binding N`
28+
- **THEN** CLI calls `cbuffer_decode` handler and writes the JSON response to stdout.
29+
- **IF** `EID` is omitted, CLI resolves it via `complete_eid`.
30+
31+
#### Scenario: Raw binary export
32+
- **WHEN** user runs `rdc cbuffer [EID] --raw -o FILE`
33+
- **THEN** CLI resolves the VFS path `/draws/<eid>/cbuffer/<set>/<binding>/data` via
34+
`_export_vfs_path` (vfs_ls + resolve_path → _deliver_binary), exactly mirroring how
35+
`rdc buffer --raw` uses `/buffers/<id>/data`, and writes bytes to `FILE`.
36+
- **IF** `-o` is not specified alongside `--raw`, CLI exits with a usage error.
37+
38+
#### Scenario: No active session
39+
- **WHEN** user runs `rdc cbuffer` and no daemon session is active
40+
- **THEN** CLI exits with code 1 and prints an error message to stderr.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Tasks: issue-224-cbuffer-export
2+
3+
## Phase A: Mock API additions
4+
5+
- [ ] Add `GetBufferData` stub to `mock_renderdoc.py` (anchor: ~line 1356 `GetConstantBlock`).
6+
The happy-path test must give the mock `Descriptor` (returned via
7+
`GetConstantBlock(...).descriptor`) a non-null `resource`, `byteOffset`, and `byteSize`
8+
these are the fields the raw-path handler keys off (`buffer.py:143-147`).
9+
- [ ] Add `bufferBacked=False` variant to the mock `ConstantBlock` for the push-constant
10+
clean-error test; this stubs the reflection-level field used to reject non-buffer-backed
11+
cbuffers before `GetBufferData` is called.
12+
13+
## Phase B: `cbuffer_raw` daemon handler (tests first)
14+
15+
- [ ] Extend `tests/unit/test_buffer_decode.py` with `cbuffer_raw` cases (happy path,
16+
`bufferBacked=False` error, no-adapter guard)
17+
- [ ] Implement `cbuffer_raw` handler in `src/rdc/handlers/buffer.py` (~line 370,
18+
adjacent to `HANDLERS` dict)
19+
- [ ] Register `cbuffer_raw` in `HANDLERS`
20+
- [ ] Add VFS `leaf_bin` route for `/draws/<eid>/cbuffer/<set>/<binding>/data`
21+
`cbuffer_raw` in `src/rdc/vfs/router.py`, mirroring
22+
`/buffers/<id>/data``buf_raw` (router.py:184)
23+
- [ ] Verify handler unit tests pass
24+
25+
## Phase C: `rdc cbuffer` CLI command (tests first)
26+
27+
- [ ] Write `tests/unit/test_cbuffer_commands.py` (JSON mode, `--raw -o`, no-session error,
28+
`complete_eid` fallback, `--raw` without `-o` usage error)
29+
- [ ] Implement `src/rdc/commands/cbuffer.py`; raw path calls
30+
`_export_vfs_path(f"/draws/{eid}/cbuffer/{set}/{binding}/data", output, raw)` from
31+
`commands/export.py` — no direct handler dispatch
32+
- [ ] Register `cbuffer_cmd` in `src/rdc/cli.py` (~line 138, adjacent to `buffer_cmd`)
33+
- [ ] Verify CLI unit tests pass
34+
35+
## Phase D: Integration + verification
36+
37+
- [ ] Extend `tests/integration/test_daemon_handlers_real.py` with `@pytest.mark.gpu`
38+
tests for `cbuffer_raw` (Vulkan vkcube capture)
39+
- [ ] Run `pixi run lint && pixi run test` — all pass, coverage ≥ 80% for new paths
40+
- [ ] Run GPU integration tests against real capture — pass
41+
- [ ] Code review
42+
43+
## Phase E: Optional polish (non-blocking)
44+
45+
- [ ] Switch `cbuffer_decode` to use `_flatten_shader_var` from `handlers/_helpers.py`
46+
instead of `_extract_value` to fix int/uint member degradation
47+
- [ ] Extend `test_buffer_decode.py` with int/uint value assertions
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Test Plan: issue-224-cbuffer-export
2+
3+
## Scope
4+
5+
### In scope
6+
- New daemon handler `cbuffer_raw` (unit + integration)
7+
- New CLI command `rdc cbuffer` (unit: JSON mode, raw mode, error paths)
8+
- `bufferBacked == False` error path for `cbuffer_raw`
9+
- Optional: `_extract_value` int/uint coverage in `test_buffer_decode.py`
10+
11+
### Out of scope
12+
- Existing `cbuffer_decode` handler correctness (covered by prior test suite)
13+
- `rdc vbuffer` / `rdc ibuffer` (separate issue)
14+
- D3D12 root-constant register-space mapping (cannot run on this Linux box; deferred to reporter verification)
15+
16+
## Test Matrix
17+
18+
| Layer | Test Type | File |
19+
|-------|-----------|------|
20+
| Unit | `cbuffer_raw` handler (mock) | `tests/unit/test_buffer_decode.py` (extend) |
21+
| Unit | `rdc cbuffer` CLI command | `tests/unit/test_cbuffer_commands.py` (new) |
22+
| Integration | real capture decoded + raw | `tests/integration/test_daemon_handlers_real.py` (extend) |
23+
24+
## Cases
25+
26+
### `cbuffer_raw` handler (extend `test_buffer_decode.py`)
27+
28+
- **Happy path**: mock `GetConstantBlock` returns a buffer-backed descriptor (`bufferBacked=True`,
29+
known `byteOffset`/`byteSize`); mock `GetBufferData` returns 16 known bytes.
30+
Assert response contains `{"path": "...", "size": 16}` and the temp file exists with the
31+
expected bytes.
32+
- **bufferBacked=False**: mock `GetConstantBlock` returns `bufferBacked=False`.
33+
Assert the handler returns a JSON-RPC error (code -32602 or equivalent) with a message
34+
containing `"not buffer-backed"`. Assert no temp file is created.
35+
- **No adapter**: `state.adapter is None` → assert error -32002 (standard no-adapter guard).
36+
- **Missing eid**: invalid `eid` → assert error from `SetFrameEvent`.
37+
38+
Mock anchor: `mock_renderdoc.py` `GetConstantBlock` (~line 1356), `GetBufferData`.
39+
40+
### `rdc cbuffer` CLI command (new `test_cbuffer_commands.py`)
41+
42+
Mirror structure of `tests/unit/test_mesh_commands.py` (monkeypatch `rdc.commands.cbuffer.call`).
43+
44+
- **JSON mode (default)**: monkeypatch `call("cbuffer_decode", ...)` returns
45+
`{"eid": 10, "set": 0, "binding": 0, "variables": [{"name": "mvp", "type": "mat4", "value": [...]}]}`.
46+
Invoke `rdc cbuffer 10 --stage ps --set 0 --binding 0`.
47+
Assert exit code 0, stdout is valid JSON matching the payload.
48+
- **`--json` explicit flag**: same as above with `--json` flag; assert identical output.
49+
- **`--raw -o file.bin`**: monkeypatch `call("cbuffer_raw", ...)` returns `{"path": "/tmp/cbuffer_10_0_0.bin", "size": 16}`;
50+
monkeypatch binary delivery (`_deliver_binary` / `fetch_remote_file`).
51+
Assert exit code 0, output file contains the expected bytes.
52+
- **No session**: `call` raises connection error → assert exit code 1, message on stderr.
53+
- **`--raw` without `-o`**: assert exit code non-zero with usage error on stderr.
54+
- **EID omitted**: monkeypatch `complete_eid` returns `42`; assert the handler is called with `eid=42`.
55+
56+
### Integration (`test_daemon_handlers_real.py`, `@pytest.mark.gpu`)
57+
58+
Extend analogous to `test_cbuffer_decode_returns_data` (~line 1965), using a vkcube/Vulkan
59+
capture with a known draw EID that has a buffer-backed cbuffer.
60+
61+
- **`cbuffer_raw` returns file**: call `cbuffer_raw` with valid `eid`/`set`/`binding`.
62+
Assert response contains `path` and `size > 0`; assert temp file exists and `size` matches
63+
`os.path.getsize(path)`.
64+
- **`cbuffer_decode` + `cbuffer_raw` size agreement**: decoded `variables` total byte footprint
65+
is consistent with the raw `size`.
66+
67+
## Assertions (all tests)
68+
69+
- Exit code 0 on success, non-zero on error.
70+
- JSON output: valid JSON, `variables` array present for decoded mode.
71+
- Raw output: file written at `-o` path, byte count matches `size` from handler.
72+
- Error messages go to stderr; stdout is empty on error.
73+
- `bufferBacked=False` produces an error message containing `"not buffer-backed"` (case-insensitive).
74+
75+
## Coverage Gate
76+
77+
CI enforces ≥ 80% line coverage for `src/rdc/commands/cbuffer.py` and the new
78+
`cbuffer_raw` code path in `src/rdc/handlers/buffer.py`.

scripts/gen-commands.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"search", "passes", "pass", "unused-targets",
3636
]),
3737
("Export", "export", None, [
38-
"texture", "buffer", "rt", "mesh", "snapshot",
38+
"texture", "buffer", "cbuffer", "rt", "mesh", "snapshot",
3939
]),
4040
("Pixel & Debug", "pixel-debug", None, [
4141
"pixel", "pick-pixel", "tex-stats",

src/rdc/_skills/references/commands-quick-ref.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,27 @@ Output VFS leaf node content.
282282
| `--raw` | Force raw output even on TTY | flag | |
283283
| `-o, --output` | Write binary output to file | path | |
284284

285+
## `rdc cbuffer`
286+
287+
Decode a constant buffer to JSON or export its raw bytes.
288+
289+
**Arguments:**
290+
291+
| Name | Type | Required |
292+
|------|------|----------|
293+
| `eid` | integer | no |
294+
295+
**Options:**
296+
297+
| Flag | Help | Type | Default |
298+
|------|------|------|---------|
299+
| `--stage` | Shader stage (default: ps) | choice | ps |
300+
| `--set` | Vulkan: descriptor set. D3D12: register space. | integer | 0 |
301+
| `--binding` | Vulkan: binding. D3D12: shader register (bN). | integer | 0 |
302+
| `--json` | JSON output (default) | flag | |
303+
| `--raw` | Export raw constant-buffer bytes | flag | |
304+
| `-o, --output` | Write raw bytes to file | path | |
305+
285306
## `rdc close`
286307

287308
Close daemon-backed session.

src/rdc/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
sections_cmd,
2929
thumbnail_cmd,
3030
)
31+
from rdc.commands.cbuffer import cbuffer_cmd
3132
from rdc.commands.completion import completion_cmd
3233
from rdc.commands.counters import counters_cmd
3334
from rdc.commands.debug import debug_group
@@ -136,6 +137,7 @@ def entry() -> None:
136137
main.add_command(texture_cmd, name="texture")
137138
main.add_command(rt_cmd, name="rt")
138139
main.add_command(buffer_cmd, name="buffer")
140+
main.add_command(cbuffer_cmd, name="cbuffer")
139141
main.add_command(mesh_cmd, name="mesh")
140142
main.add_command(search_cmd, name="search")
141143
main.add_command(usage_cmd, name="usage")

src/rdc/commands/cbuffer.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""rdc cbuffer -- decoded or raw constant-buffer export."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
import click
8+
9+
from rdc.commands._helpers import call, complete_eid
10+
from rdc.commands.export import _export_vfs_path
11+
from rdc.formatters.json_fmt import write_json
12+
13+
14+
@click.command("cbuffer")
15+
@click.argument("eid", type=int, required=False, default=None, shell_complete=complete_eid)
16+
@click.option(
17+
"--stage",
18+
type=click.Choice(["vs", "hs", "ds", "gs", "ps", "cs"]),
19+
default="ps",
20+
help="Shader stage (default: ps)",
21+
)
22+
@click.option(
23+
"--set",
24+
"cb_set",
25+
type=int,
26+
default=0,
27+
help="Vulkan: descriptor set. D3D12: register space.",
28+
)
29+
@click.option(
30+
"--binding",
31+
type=int,
32+
default=0,
33+
help="Vulkan: binding. D3D12: shader register (bN).",
34+
)
35+
@click.option("--json", "use_json", is_flag=True, help="JSON output (default)")
36+
@click.option("--raw", is_flag=True, help="Export raw constant-buffer bytes")
37+
@click.option("-o", "--output", type=click.Path(), default=None, help="Write raw bytes to file")
38+
def cbuffer_cmd(
39+
eid: int | None,
40+
stage: str,
41+
cb_set: int,
42+
binding: int,
43+
use_json: bool,
44+
raw: bool,
45+
output: str | None,
46+
) -> None:
47+
"""Decode a constant buffer to JSON or export its raw bytes."""
48+
if raw:
49+
if output is None:
50+
raise click.UsageError("-o/--output is required with --raw")
51+
if eid is None:
52+
raise click.UsageError("EID is required with --raw")
53+
_export_vfs_path(f"/draws/{eid}/cbuffer/{cb_set}/{binding}/data", output, raw)
54+
return
55+
56+
del use_json
57+
params: dict[str, Any] = {"stage": stage, "set": cb_set, "binding": binding}
58+
if eid is not None:
59+
params["eid"] = eid
60+
result = call("cbuffer_decode", params)
61+
write_json(result)

0 commit comments

Comments
 (0)