diff --git a/docs-astro/src/data/commands.json b/docs-astro/src/data/commands.json index 9bbfb567..3d8e6ae6 100644 --- a/docs-astro/src/data/commands.json +++ b/docs-astro/src/data/commands.json @@ -201,6 +201,12 @@ "help": "Export buffer raw data.", "usage": "rdc buffer [-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", diff --git a/openspec/changes/2026-05-15-issue-224-cbuffer-export/proposal.md b/openspec/changes/2026-05-15-issue-224-cbuffer-export/proposal.md new file mode 100644 index 00000000..059dbb1b --- /dev/null +++ b/openspec/changes/2026-05-15-issue-224-cbuffer-export/proposal.md @@ -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___.bin`, and +returns `{"path", "size"}`. + +The handler is exposed as a VFS `leaf_bin` route at +`/draws//cbuffer///data` in `vfs/router.py`, mirroring the way `buf_raw` +is wired at `/buffers//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. diff --git a/openspec/changes/2026-05-15-issue-224-cbuffer-export/specs/daemon/spec.md b/openspec/changes/2026-05-15-issue-224-cbuffer-export/specs/daemon/spec.md new file mode 100644 index 00000000..c8cbae7a --- /dev/null +++ b/openspec/changes/2026-05-15-issue-224-cbuffer-export/specs/daemon/spec.md @@ -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___.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//cbuffer///data` via + `_export_vfs_path` (vfs_ls + resolve_path → _deliver_binary), exactly mirroring how + `rdc buffer --raw` uses `/buffers//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. diff --git a/openspec/changes/2026-05-15-issue-224-cbuffer-export/tasks.md b/openspec/changes/2026-05-15-issue-224-cbuffer-export/tasks.md new file mode 100644 index 00000000..52de90ae --- /dev/null +++ b/openspec/changes/2026-05-15-issue-224-cbuffer-export/tasks.md @@ -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//cbuffer///data` → + `cbuffer_raw` in `src/rdc/vfs/router.py`, mirroring + `/buffers//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 diff --git a/openspec/changes/2026-05-15-issue-224-cbuffer-export/test-plan.md b/openspec/changes/2026-05-15-issue-224-cbuffer-export/test-plan.md new file mode 100644 index 00000000..87a23ffc --- /dev/null +++ b/openspec/changes/2026-05-15-issue-224-cbuffer-export/test-plan.md @@ -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`. diff --git a/scripts/gen-commands.py b/scripts/gen-commands.py index 9783b2c4..6eb3135f 100644 --- a/scripts/gen-commands.py +++ b/scripts/gen-commands.py @@ -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", diff --git a/src/rdc/_skills/references/commands-quick-ref.md b/src/rdc/_skills/references/commands-quick-ref.md index dbac1c0d..ebf69199 100644 --- a/src/rdc/_skills/references/commands-quick-ref.md +++ b/src/rdc/_skills/references/commands-quick-ref.md @@ -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. diff --git a/src/rdc/cli.py b/src/rdc/cli.py index aa34952d..fcab73d6 100644 --- a/src/rdc/cli.py +++ b/src/rdc/cli.py @@ -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 @@ -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") diff --git a/src/rdc/commands/cbuffer.py b/src/rdc/commands/cbuffer.py new file mode 100644 index 00000000..7a845d7d --- /dev/null +++ b/src/rdc/commands/cbuffer.py @@ -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) diff --git a/src/rdc/handlers/buffer.py b/src/rdc/handlers/buffer.py index 22d59557..5ea1e5e2 100644 --- a/src/rdc/handlers/buffer.py +++ b/src/rdc/handlers/buffer.py @@ -196,6 +196,75 @@ def _flatten_vars( ), True +def _handle_cbuffer_raw( + request_id: int, params: dict[str, Any], state: DaemonState +) -> tuple[dict[str, Any], bool]: + cb_set = int(params.get("set", 0)) + cb_binding = int(params.get("binding", 0)) + stage_name = str(params.get("stage", "ps")) + stage_val = STAGE_MAP.get(stage_name, 4) + if state.temp_dir is None: + return _error_response(request_id, -32002, "temp directory not available"), True + try: + eid, pipe_state = require_pipe(params, state, request_id) + except PipeError as exc: + return exc.response, True + refl = pipe_state.GetShaderReflection(stage_val) + if refl is None: + return _error_response(request_id, -32001, f"no reflection for stage {stage_name}"), True + blocks = getattr(refl, "constantBlocks", []) + target_block = None + target_idx = 0 + for i, cb in enumerate(blocks): + s = getattr(cb, "fixedBindSetOrSpace", 0) + b = getattr(cb, "fixedBindNumber", 0) + if s == cb_set and b == cb_binding: + target_block = cb + target_idx = i + break + if target_block is None: + return _error_response( + request_id, + -32001, + f"no constant block at set={cb_set} binding={cb_binding}", + ), True + if not getattr(target_block, "bufferBacked", True): + return _error_response( + request_id, + -32602, + "cbuffer is not buffer-backed (push constant or root constant)", + ), True + if not hasattr(pipe_state, "GetConstantBlock"): + return _error_response( + request_id, + -32601, + "GetConstantBlock unavailable on this RenderDoc version", + ), True + controller = state.adapter.controller # type: ignore[union-attr] + cb_used = pipe_state.GetConstantBlock(stage_val, target_idx, 0) + cb_desc = cb_used.descriptor + cb_resource = cb_desc.resource + cb_offset = getattr(cb_desc, "byteOffset", 0) + cb_size = getattr(cb_desc, "byteSize", 0) + if cb_resource is None or int(cb_resource) == 0: + return _error_response(request_id, -32001, "cbuffer not bound at this draw"), True + if cb_size == 0: + cb_size = getattr(target_block, "byteSize", 0) + if cb_size == 0: + return _error_response( + request_id, + -32001, + "cbuffer size is unknown (descriptor and reflection both report 0)", + ), True + raw_data = controller.GetBufferData(cb_resource, cb_offset, cb_size) + temp_path = state.temp_dir / f"cbuffer_{eid}_{cb_set}_{cb_binding}.bin" + temp_path.write_bytes(raw_data) + return _result_response( + request_id, + {"path": str(temp_path), "size": len(raw_data)}, + ), True + + def _handle_vbuffer_decode( # noqa: PLR0912 request_id: int, params: dict[str, Any], state: DaemonState ) -> tuple[dict[str, Any], bool]: @@ -377,6 +446,7 @@ def _handle_ibuffer_decode( "buf_raw": _handle_buf_raw, "postvs": _handle_postvs, "cbuffer_decode": _handle_cbuffer_decode, + "cbuffer_raw": _handle_cbuffer_raw, "vbuffer_decode": _handle_vbuffer_decode, "ibuffer_decode": _handle_ibuffer_decode, "mesh_data": _handle_mesh_data, diff --git a/src/rdc/vfs/router.py b/src/rdc/vfs/router.py index 673c9a66..628d1d91 100644 --- a/src/rdc/vfs/router.py +++ b/src/rdc/vfs/router.py @@ -108,6 +108,12 @@ def _r( "cbuffer_decode", [("eid", int), ("set", int), ("binding", int)], ) +_r( + r"/draws/(?P\d+)/cbuffer/(?P\d+)/(?P\d+)/data", + "leaf_bin", + "cbuffer_raw", + [("eid", int), ("set", int), ("binding", int)], +) _r(r"/draws/(?P\d+)/cbuffer/(?P\d+)", "dir", None, [("eid", int), ("set", int)]) _r(r"/draws/(?P\d+)/vbuffer", "leaf", "vbuffer_decode", [("eid", int)]) _r(r"/draws/(?P\d+)/ibuffer", "leaf", "ibuffer_decode", [("eid", int)]) diff --git a/tests/integration/test_daemon_handlers_real.py b/tests/integration/test_daemon_handlers_real.py index d48a6e09..9214feb9 100644 --- a/tests/integration/test_daemon_handlers_real.py +++ b/tests/integration/test_daemon_handlers_real.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from pathlib import Path from typing import Any @@ -1980,6 +1981,25 @@ def test_cbuffer_decode_returns_data(self) -> None: result = _call(self.state, "cbuffer_decode", params) assert "variables" in result and "set" in result + def test_cbuffer_raw_returns_file(self) -> None: + """cbuffer_raw writes a temp file whose size matches the response.""" + eid = self._first_draw_eid() + params = {"eid": eid, "stage": "vs", "set": 0, "binding": 0} + result = _call(self.state, "cbuffer_raw", params) + assert "path" in result + assert result["size"] > 0 + assert os.path.exists(result["path"]) + assert os.path.getsize(result["path"]) == result["size"] + + def test_cbuffer_decode_and_raw_size_agree(self) -> None: + """Decoded variable footprint is consistent with raw byte size.""" + eid = self._first_draw_eid() + params = {"eid": eid, "stage": "vs", "set": 0, "binding": 0} + decoded = _call(self.state, "cbuffer_decode", params) + raw = _call(self.state, "cbuffer_raw", params) + assert len(decoded["variables"]) > 0 + assert raw["size"] > 0 + def test_vbuffer_decode_returns_vertex_data(self) -> None: """vbuffer_decode returns columns + vertices for a draw event.""" eid = self._first_draw_eid() diff --git a/tests/unit/test_buffer_decode.py b/tests/unit/test_buffer_decode.py index 150d0694..73d76dcf 100644 --- a/tests/unit/test_buffer_decode.py +++ b/tests/unit/test_buffer_decode.py @@ -91,6 +91,8 @@ def state(tmp_path: Path) -> DaemonState: # Set up cbuffer descriptor for GetConstantBlock pipe._cbuffer_descriptors[(ShaderStage.Pixel, 0)] = Descriptor( resource=ResourceId(50), + byteOffset=0, + byteSize=16, ) # Vertex inputs for vbuffer test @@ -133,6 +135,7 @@ def state(tmp_path: Path) -> DaemonState: vbuf_data = _make_vbuffer_data() ibuf_data = _make_ibuffer_data_u16() + cbuf_data = bytes(range(16)) light_val = ShaderValue(f32v=[0.5, 0.7, 0.0] + [0.0] * 13) intensity_val = ShaderValue(f32v=[1.0] + [0.0] * 15) cbuffer_vars = [ @@ -165,6 +168,8 @@ def _get_buffer_data( return vbuf_data if rid == 43: return ibuf_data + if rid == 50: + return cbuf_data[offset : offset + length] if length > 0 else cbuf_data[offset:] return b"" controller = SimpleNamespace( @@ -283,6 +288,157 @@ def test_nested_variables(self, state: DaemonState) -> None: assert r["variables"][1]["name"] == "light.color" +class TestCbufferRaw: + def test_happy_path(self, state: DaemonState, tmp_path: Path) -> None: + resp, _ = _handle_request( + rpc_request( + "cbuffer_raw", {"eid": 10, "set": 0, "binding": 0}, token="abcdef1234567890" + ), + state, + ) + r = resp["result"] + assert r["size"] == 16 + out = Path(r["path"]) + assert out.exists() + assert out.read_bytes() == bytes(range(16)) + assert out == tmp_path / "cbuffer_10_0_0.bin" + + def test_not_buffer_backed(self, state: DaemonState, tmp_path: Path) -> None: + pipe = state.adapter.controller.GetPipelineState() + pipe._reflections[ShaderStage.Pixel].constantBlocks[0].bufferBacked = False + resp, _ = _handle_request( + rpc_request( + "cbuffer_raw", {"eid": 10, "set": 0, "binding": 0}, token="abcdef1234567890" + ), + state, + ) + assert "error" in resp + assert "not buffer-backed" in resp["error"]["message"].lower() + assert not (tmp_path / "cbuffer_10_0_0.bin").exists() + + def test_no_adapter(self) -> None: + s = DaemonState( + capture="t.rdc", + current_eid=0, + token="abcdef1234567890", + ) + resp, _ = _handle_request( + rpc_request( + "cbuffer_raw", {"eid": 10, "set": 0, "binding": 0}, token="abcdef1234567890" + ), + s, + ) + assert resp["error"]["code"] == -32002 + + def test_invalid_binding(self, state: DaemonState) -> None: + resp, _ = _handle_request( + rpc_request( + "cbuffer_raw", {"eid": 10, "set": 0, "binding": 99}, token="abcdef1234567890" + ), + state, + ) + assert resp["error"]["code"] == -32001 + + def test_no_reflection(self, state: DaemonState) -> None: + resp, _ = _handle_request( + rpc_request( + "cbuffer_raw", + {"eid": 10, "set": 0, "binding": 0, "stage": "vs"}, + token="abcdef1234567890", + ), + state, + ) + assert resp["error"]["code"] == -32001 + + def test_constant_block_unavailable(self, state: DaemonState, monkeypatch: Any) -> None: + pipe = state.adapter.controller.GetPipelineState() + monkeypatch.delattr(type(pipe), "GetConstantBlock") + resp, _ = _handle_request( + rpc_request( + "cbuffer_raw", {"eid": 10, "set": 0, "binding": 0}, token="abcdef1234567890" + ), + state, + ) + assert "error" in resp + assert "unavailable" in resp["error"]["message"].lower() + + def test_no_temp_dir(self, state: DaemonState) -> None: + state.temp_dir = None + resp, _ = _handle_request( + rpc_request( + "cbuffer_raw", {"eid": 10, "set": 0, "binding": 0}, token="abcdef1234567890" + ), + state, + ) + assert resp["error"]["code"] == -32002 + + def test_null_resource_not_bound(self, state: DaemonState, tmp_path: Path) -> None: + """Null/zero cbuffer resource → clean -32001 error, no temp file.""" + pipe = state.adapter.controller.GetPipelineState() + pipe._cbuffer_descriptors[(ShaderStage.Pixel, 0)] = Descriptor( + resource=ResourceId(0), + byteOffset=0, + byteSize=16, + ) + resp, _ = _handle_request( + rpc_request( + "cbuffer_raw", {"eid": 10, "set": 0, "binding": 0}, token="abcdef1234567890" + ), + state, + ) + assert resp["error"]["code"] == -32001 + assert "not bound" in resp["error"]["message"].lower() + assert not (tmp_path / "cbuffer_10_0_0.bin").exists() + + def test_zero_byte_size_falls_back_to_reflected( + self, state: DaemonState, tmp_path: Path + ) -> None: + """byteSize==0 falls back to reflected block size; GetBufferData never gets size 0.""" + pipe = state.adapter.controller.GetPipelineState() + pipe._cbuffer_descriptors[(ShaderStage.Pixel, 0)] = Descriptor( + resource=ResourceId(50), + byteOffset=0, + byteSize=0, + ) + pipe._reflections[ShaderStage.Pixel].constantBlocks[0].byteSize = 12 + orig_get = state.adapter.controller.GetBufferData + seen_sizes: list[int] = [] + + def _get(rid: Any, offset: int, length: int) -> bytes: + seen_sizes.append(length) + return orig_get(rid, offset, length) + + state.adapter.controller.GetBufferData = _get + resp, _ = _handle_request( + rpc_request( + "cbuffer_raw", {"eid": 10, "set": 0, "binding": 0}, token="abcdef1234567890" + ), + state, + ) + r = resp["result"] + assert r["size"] == 12 + assert 0 not in seen_sizes + assert Path(r["path"]).read_bytes() == bytes(range(12)) + + def test_zero_byte_size_and_zero_reflected(self, state: DaemonState, tmp_path: Path) -> None: + """byteSize==0 and reflected size 0 → clean error, never dump whole buffer.""" + pipe = state.adapter.controller.GetPipelineState() + pipe._cbuffer_descriptors[(ShaderStage.Pixel, 0)] = Descriptor( + resource=ResourceId(50), + byteOffset=0, + byteSize=0, + ) + pipe._reflections[ShaderStage.Pixel].constantBlocks[0].byteSize = 0 + resp, _ = _handle_request( + rpc_request( + "cbuffer_raw", {"eid": 10, "set": 0, "binding": 0}, token="abcdef1234567890" + ), + state, + ) + assert "error" in resp + assert not (tmp_path / "cbuffer_10_0_0.bin").exists() + + class TestVbufferDecode: def test_happy_path(self, state: DaemonState) -> None: resp, _ = _handle_request( diff --git a/tests/unit/test_cbuffer_commands.py b/tests/unit/test_cbuffer_commands.py new file mode 100644 index 00000000..8efc8dd4 --- /dev/null +++ b/tests/unit/test_cbuffer_commands.py @@ -0,0 +1,132 @@ +"""Tests for rdc cbuffer CLI command.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from click.testing import CliRunner + +from rdc.cli import main +from rdc.commands.cbuffer import cbuffer_cmd + +_DECODE_RESPONSE: dict[str, Any] = { + "eid": 10, + "set": 0, + "binding": 0, + "variables": [{"name": "mvp", "type": "mat4", "value": [1.0, 0.0, 0.0, 0.0]}], +} + + +class TestCbufferCmd: + def test_json_default(self, monkeypatch: Any) -> None: + monkeypatch.setattr("rdc.commands.cbuffer.call", lambda m, p: dict(_DECODE_RESPONSE)) + runner = CliRunner() + result = runner.invoke(cbuffer_cmd, ["10", "--stage", "ps", "--set", "0", "--binding", "0"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["variables"] == _DECODE_RESPONSE["variables"] + assert data["set"] == 0 + + def test_json_explicit_flag(self, monkeypatch: Any) -> None: + monkeypatch.setattr("rdc.commands.cbuffer.call", lambda m, p: dict(_DECODE_RESPONSE)) + runner = CliRunner() + result = runner.invoke(cbuffer_cmd, ["10", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data == _DECODE_RESPONSE + + def test_decode_params_forwarded(self, monkeypatch: Any) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + def mock_call(method: str, params: dict[str, Any]) -> dict[str, Any]: + calls.append((method, params)) + return dict(_DECODE_RESPONSE) + + monkeypatch.setattr("rdc.commands.cbuffer.call", mock_call) + runner = CliRunner() + result = runner.invoke(cbuffer_cmd, ["7", "--stage", "vs", "--set", "1", "--binding", "2"]) + assert result.exit_code == 0 + assert calls[0][0] == "cbuffer_decode" + assert calls[0][1] == {"eid": 7, "stage": "vs", "set": 1, "binding": 2} + + def test_raw_output(self, monkeypatch: Any, tmp_path: Path) -> None: + out = tmp_path / "cb.bin" + captured: dict[str, Any] = {} + + def mock_export(vfs_path: str, output: str | None, raw: bool) -> None: + captured["vfs_path"] = vfs_path + captured["output"] = output + Path(output).write_bytes(bytes(range(16))) + + monkeypatch.setattr("rdc.commands.cbuffer._export_vfs_path", mock_export) + runner = CliRunner() + result = runner.invoke(cbuffer_cmd, ["10", "--raw", "-o", str(out)]) + assert result.exit_code == 0 + assert out.read_bytes() == bytes(range(16)) + assert captured["vfs_path"] == "/draws/10/cbuffer/0/0/data" + + def test_raw_without_output(self, monkeypatch: Any) -> None: + monkeypatch.setattr( + "rdc.commands.cbuffer._export_vfs_path", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not be called")), + ) + runner = CliRunner() + result = runner.invoke(cbuffer_cmd, ["10", "--raw"]) + assert result.exit_code != 0 + assert "-o" in result.output or "output" in result.output.lower() + + def test_raw_without_eid(self, monkeypatch: Any, tmp_path: Path) -> None: + monkeypatch.setattr( + "rdc.commands.cbuffer._export_vfs_path", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not be called")), + ) + runner = CliRunner() + result = runner.invoke(cbuffer_cmd, ["--raw", "-o", str(tmp_path / "cb.bin")]) + assert result.exit_code == 2 + assert "EID" in result.output + + def test_no_session(self, monkeypatch: Any) -> None: + def mock_call(method: str, params: dict[str, Any]) -> dict[str, Any]: + raise SystemExit(1) + + monkeypatch.setattr("rdc.commands.cbuffer.call", mock_call) + runner = CliRunner() + result = runner.invoke(cbuffer_cmd, ["10"]) + assert result.exit_code == 1 + + def test_eid_omitted_lets_daemon_default(self, monkeypatch: Any) -> None: + calls: list[dict[str, Any]] = [] + + def mock_call(method: str, params: dict[str, Any]) -> dict[str, Any]: + calls.append(params) + return dict(_DECODE_RESPONSE) + + monkeypatch.setattr("rdc.commands.cbuffer.call", mock_call) + runner = CliRunner() + result = runner.invoke(cbuffer_cmd, []) + assert result.exit_code == 0 + assert "eid" not in calls[0] + + def test_help(self) -> None: + runner = CliRunner() + result = runner.invoke(cbuffer_cmd, ["--help"]) + assert result.exit_code == 0 + assert "EID" in result.output + assert "--stage" in result.output + assert "--raw" in result.output + + def test_help_documents_d3d12_bindings(self) -> None: + """--set/--binding help must explain the D3D12 register-space framing.""" + runner = CliRunner() + result = runner.invoke(cbuffer_cmd, ["--help"]) + assert result.exit_code == 0 + out = result.output.lower() + assert "register space" in out + assert "bn" in out or "shader register" in out + + def test_in_main_help(self) -> None: + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert "cbuffer" in result.output diff --git a/tests/unit/test_vfs_router.py b/tests/unit/test_vfs_router.py index 59c02dc5..faa35343 100644 --- a/tests/unit/test_vfs_router.py +++ b/tests/unit/test_vfs_router.py @@ -264,6 +264,13 @@ def test_cbuffer_decode(self) -> None: assert m.handler == "cbuffer_decode" assert m.args == {"eid": 42, "set": 0, "binding": 3} + def test_cbuffer_raw(self) -> None: + m = resolve_path("/draws/42/cbuffer/0/3/data") + assert m is not None + assert m.kind == "leaf_bin" + assert m.handler == "cbuffer_raw" + assert m.args == {"eid": 42, "set": 0, "binding": 3} + def test_vbuffer_decode(self) -> None: m = resolve_path("/draws/42/vbuffer") assert m is not None