diff --git a/docs-astro/src/data/commands.json b/docs-astro/src/data/commands.json index 28e38dc..3908c93 100644 --- a/docs-astro/src/data/commands.json +++ b/docs-astro/src/data/commands.json @@ -211,7 +211,7 @@ "name": "rt", "id": "rt", "help": "Export render target as PNG.", - "usage": "rdc rt [EID] [-o PATH] [--target INTEGER] [--raw] [--overlay CHOICE] [--width INTEGER] [--height INTEGER]" + "usage": "rdc rt [EID] [-o PATH] [--target INTEGER] [--depth] [--raw] [--overlay CHOICE] [--width INTEGER] [--height INTEGER]" }, { "name": "mesh", diff --git a/openspec/changes/2026-06-09-rt-depth-flag/proposal.md b/openspec/changes/2026-06-09-rt-depth-flag/proposal.md new file mode 100644 index 0000000..57cd5ea --- /dev/null +++ b/openspec/changes/2026-06-09-rt-depth-flag/proposal.md @@ -0,0 +1,81 @@ +# OpenSpec: 2026-06-09-rt-depth-flag + +## Summary + +Add `--depth` flag to `rdc rt` so `rdc rt --depth -o depth.png` exports the raw +depth attachment via the existing `rt_depth` VFS route. + +## Context and Motivation + +Issue #236's verify section literally used `rdc rt --eid 500 --depth` as the canonical +test command. The backend was shipped in PR #237: VFS route +`/draws//targets/depth.png` → handler `rt_depth` is registered in `vfs/router.py` +line 152 and works in both local and remote (pid==0) modes. The CLI plumbing was never +added, leaving users with no ergonomic path to the depth attachment. + +## Design + +### New flag: `--depth` + +``` +rdc rt [--depth] [-o output.png] [--raw] +``` + +When `--depth` is passed, `rt_cmd` routes to +`/draws/{eid}/targets/depth.png` instead of `/draws/{eid}/targets/color{target}.png`. + +### `--target` sentinel change + +`--target` currently defaults to `0`. Click cannot distinguish "user passed `--target 0`" +from "user did not pass `--target`" when the default is `0`. To detect mutual exclusion: + +- Change `--target` default in the Click decorator to `None` (sentinel). +- In the help text, document the default as `0` explicitly. +- In `rt_cmd` logic: if `--depth` is set and `--target` is not `None`, raise + `click.UsageError("--depth and --target are mutually exclusive")`. +- If `--depth` is not set and `--target is None`, use `0` as the effective target. + +This is a breaking change to the `--target` parameter type annotation (from `int` to +`int | None`) but not to user-visible defaults. + +### Interaction with `--overlay` + +`--overlay` takes priority: if `--overlay` is provided, the existing overlay path runs +unchanged and `--depth` is silently ignored (no interaction needed; depth overlay is a +separate concept). Document in help text. + +### Distinction: `--overlay depth` vs `--depth` + +These are fundamentally different operations: + +- `--overlay depth` calls `rt_overlay` with `overlay="depth"`, which asks RenderDoc to + render the depth *overlay visualization* on top of the colour buffer. Output is a + colour-mapped image rendered by RenderDoc's overlay engine. +- `--depth` exports the raw depth/stencil attachment texture at the draw's framebuffer + bind point, via the `rt_depth` handler. Output is the raw depth buffer as PNG. + +The `rdc rt` help text must call this distinction out explicitly. + +### Shell completion: `_complete_rt_target` + +`_complete_rt_target` filters `color\d+\.png` filenames and therefore already excludes +`depth.png`. No change needed; `--depth` is a boolean flag without a completeable value. + +### Docs surface + +`scripts/gen-commands.py` introspects Click command objects to produce +`docs-astro/src/data/commands.json`, which feeds the Astro-based command reference. +`scripts/gen-skill-ref.py` generates `src/rdc/_skills/references/commands-quick-ref.md`. +Both are regenerated via `pixi run gen-commands` and `pixi run gen-skill-ref`. The new +flag will appear automatically once regenerated; no manual doc edits required. + +## Risks + +- **CLI surface change**: `--target` default changes from `int(0)` to `None` internally. + Any caller that inspects `rt_cmd`'s Click parameter objects programmatically (e.g. + test introspection) may need updating. +- **Shell completion**: no impact; `_complete_rt_target` is not invoked for `--depth`. +- **Docs regen**: both `check-commands` and `check-skill-ref` CI checks will fail if + regenerated artifacts are not committed alongside the implementation. +- **Remote mode**: `_export_vfs_path` already handles `pid==0` (remote daemon) via + `_deliver_binary`; no special casing needed for `--depth`. diff --git a/openspec/changes/2026-06-09-rt-depth-flag/tasks.md b/openspec/changes/2026-06-09-rt-depth-flag/tasks.md new file mode 100644 index 0000000..bf465a1 --- /dev/null +++ b/openspec/changes/2026-06-09-rt-depth-flag/tasks.md @@ -0,0 +1,38 @@ +# Tasks: 2026-06-09-rt-depth-flag + +## T1: Implementation — `rt_cmd` CLI plumbing + +- [ ] In `src/rdc/commands/export.py`, change `--target` default from `0` to `None`; + update help text to read `"Color target index (default 0)"` (no user-visible change). +- [ ] Add `--depth` boolean flag (`is_flag=True`) to the `@click.command("rt")` decorator. +- [ ] In `rt_cmd` body (after the `--overlay` early-return, before `_export_vfs_path`): + - Raise `click.UsageError("--depth and --target are mutually exclusive")` if + `depth` is `True` and `target is not None`. + - If `depth` is `True`: call + `_export_vfs_path(f"/draws/{eid}/targets/depth.png", output, raw)`. + - Otherwise: use `target if target is not None else 0` as the color index. + +## T2: Unit tests + +- [ ] Extend `TestRtCmd` in `tests/unit/test_export_commands.py` with four cases from + test-plan.md: `test_rt_depth_routes_to_depth_png`, + `test_rt_depth_with_explicit_target_raises_usage_error`, + `test_rt_depth_target_none_defaults_to_color0`, + `test_rt_depth_remote_pid0_writes_output`, + `test_rt_depth_with_overlay_ignores_depth`. +- [ ] Run `pixi run test` — all pass. + +## T3: Docs + completion regen + +- [ ] Run `pixi run gen-commands` to regenerate `docs-astro/src/data/commands.json`. +- [ ] Run `pixi run gen-skill-ref` to regenerate + `src/rdc/_skills/references/commands-quick-ref.md`. +- [ ] Verify `pixi run check-commands` and `pixi run check-skill-ref` pass (CI gate). +- [ ] Commit regenerated artifacts alongside the implementation. + +## T4: Manual real-GPU verify + +- [ ] Run the four manual steps from test-plan.md against a vkcube capture: + local depth export, remote-proxy depth export, mutual-exclusion error, overlay + depth regression. +- [ ] Confirm output PNG is non-empty and visually plausible depth greyscale. diff --git a/openspec/changes/2026-06-09-rt-depth-flag/test-plan.md b/openspec/changes/2026-06-09-rt-depth-flag/test-plan.md new file mode 100644 index 0000000..afe279a --- /dev/null +++ b/openspec/changes/2026-06-09-rt-depth-flag/test-plan.md @@ -0,0 +1,109 @@ +# Test Plan: 2026-06-09-rt-depth-flag + +## Scope + +### In scope +- `--depth` routes to `/draws/{eid}/targets/depth.png` +- `--depth` + explicit `--target` raises `UsageError` +- `--depth` + `-o` writes output file +- `--depth` without `--overlay` reaches `_export_vfs_path` +- Remote daemon path (pid==0) with `--depth` + +### Out of scope +- `rt_depth` daemon handler correctness (shipped in PR #237, covered by prior tests) +- `--overlay` behaviour (no change) + +## Test Matrix + +| Layer | Test Type | File | +|-------|-----------|------| +| Unit | `--depth` flag routing | `tests/unit/test_export_commands.py` (extend `TestRtCmd`) | +| Unit | `--depth` + `--target` mutual exclusion | `tests/unit/test_export_commands.py` | +| Unit | `--depth` + `-o` output file written | `tests/unit/test_export_commands.py` | +| Unit | remote pid==0 path with `--depth` | `tests/unit/test_export_commands.py` | +| Unit | `--depth` + `--overlay` ignores depth (no error) | `tests/unit/test_export_commands.py` | +| Manual | real GPU: local + remote | see below | + +## Unit Cases (extend `TestRtCmd` in `test_export_commands.py`) + +### `test_rt_depth_routes_to_depth_png` + +Monkeypatch `call` and `_deliver_binary`. Invoke `rt_cmd` with `["100", "--depth", "-o", +str(out_file)]`. Assert exit code 0. Assert `vfs_ls` was called with path +`/draws/100/targets/depth.png`. + +### `test_rt_depth_with_explicit_target_raises_usage_error` + +Invoke `rt_cmd` with `["100", "--depth", "--target", "1", "-o", str(out_file)]`. Assert +exit code non-zero. Assert output/error contains `"mutually exclusive"`. + +### `test_rt_depth_target_none_defaults_to_color0` + +Invoke `rt_cmd` with `["100", "-o", str(out_file)]` (no `--depth`, no `--target`). Assert +`vfs_ls` was called with path `/draws/100/targets/color0.png`. Confirms sentinel default +behaviour is preserved. + +### `test_rt_depth_remote_pid0_writes_output` + +Monkeypatch `rdc.commands.vfs._load_session` to return a session with `pid=0`, +`rdc.commands.vfs.fetch_remote_file` to return PNG bytes, and +`rdc.commands.vfs._stdout_is_tty` to `lambda: False`. Invoke `rt_cmd` with +`["100", "--depth", "-o", str(out_file)]`. + +NOTE (corrected against actual code): `_export_vfs_path` → `_deliver_binary` does NOT +emit any "--output required" error for the VFS path. For `pid==0` it calls +`fetch_remote_file(temp_path)` and, when `output` is set, writes the bytes to the output +file (vfs.py lines 234-244). There is no remote "--output is required" guard on this +path; that guard exists only in the `--overlay` branch of `rt_cmd`. So: + +- Assert exit code 0. +- Assert `vfs_ls` was called with `/draws/100/targets/depth.png`. +- Assert `out_file` was written with the mocked PNG bytes. + +(If the remote no-`-o` behaviour is also worth covering: under `CliRunner`, +`_stdout_is_tty()` is False, so `--depth` with no `-o` and `pid==0` writes bytes to +stdout and exits 0 — it does NOT error. Per the settled main-agent ruling, `--depth` +without `-o` behaves exactly like color export, i.e. no special TTY/remote guard.) + +### `test_rt_depth_with_overlay_ignores_depth` + +Per the design, `--overlay` takes priority and `--depth` is silently ignored (the +`--overlay` branch early-returns before the depth/target logic, so no mutual-exclusion +error is raised for `--depth + --overlay`). Monkeypatch `call` so `rt_overlay` returns a +`path` and `fetch_remote_file` returns bytes. Invoke `rt_cmd` with +`["100", "--overlay", "depth", "--depth", "-o", str(out_file)]`. Assert exit code 0, +assert `rt_overlay` was called (not `vfs_ls` for `depth.png`), and `vfs_ls` was NOT +called with `/draws/100/targets/depth.png`. Confirms `--depth` is inert under `--overlay` +and does not trip the mutual-exclusion guard. + +## Manual Real-GPU Verify + +Prerequisite: vkcube capture with a known draw EID that has a depth attachment (any 3D +draw should qualify). + +1. **Local mode**: + ``` + rdc open capture.rdc + rdc rt --depth -o /tmp/depth_local.png + ``` + Assert exit code 0, `/tmp/depth_local.png` is a valid PNG, non-empty. + +2. **Remote proxy mode**: + ``` + rdc open capture.rdc --proxy host:port + rdc rt --depth -o /tmp/depth_remote.png + ``` + Assert exit code 0, file written, pixel values plausible (non-constant, depth values + visible as greyscale gradient). + +3. **Mutual exclusion error**: + ``` + rdc rt --depth --target 0 -o /tmp/d.png + ``` + Assert non-zero exit with `"mutually exclusive"` in output. + +4. **`--overlay depth` still works** (regression): + ``` + rdc rt --overlay depth -o /tmp/depth_overlay.png + ``` + Assert exit code 0. File is a colour-mapped overlay PNG (distinct from raw depth). diff --git a/src/rdc/_skills/references/commands-quick-ref.md b/src/rdc/_skills/references/commands-quick-ref.md index 242d781..463c0d8 100644 --- a/src/rdc/_skills/references/commands-quick-ref.md +++ b/src/rdc/_skills/references/commands-quick-ref.md @@ -877,7 +877,8 @@ Export render target as PNG. | Flag | Help | Type | Default | |------|------|------|---------| | `-o, --output` | Write to file | path | | -| `--target` | Color target index (default 0) | integer | 0 | +| `--target` | Color target index (default 0); mutually exclusive with --depth | integer | | +| `--depth` | Export the raw depth attachment texture (/draws//targets/depth.png); distinct from --overlay depth, which renders RenderDoc's depth overlay visualization. Ignored when --overlay is set. | flag | | | `--raw` | Force raw output even on TTY | flag | | | `--overlay` | Render with debug overlay | choice | | | `--width` | Overlay render width | integer | 256 | diff --git a/src/rdc/commands/export.py b/src/rdc/commands/export.py index 1e73d2c..6852437 100644 --- a/src/rdc/commands/export.py +++ b/src/rdc/commands/export.py @@ -131,10 +131,19 @@ def texture_cmd(id: int, output: str | None, mip: int, raw: bool) -> None: @click.option("-o", "--output", type=click.Path(), default=None, help="Write to file") @click.option( "--target", - default=0, + default=None, type=int, shell_complete=_complete_rt_target, - help="Color target index (default 0)", + help="Color target index (default 0); mutually exclusive with --depth", +) +@click.option( + "--depth", + is_flag=True, + help=( + "Export the raw depth attachment texture (/draws//targets/depth.png); " + "distinct from --overlay depth, which renders RenderDoc's depth overlay " + "visualization. Ignored when --overlay is set." + ), ) @click.option("--raw", is_flag=True, help="Force raw output even on TTY") @click.option( @@ -160,7 +169,8 @@ def texture_cmd(id: int, output: str | None, mip: int, raw: bool) -> None: def rt_cmd( eid: int | None, output: str | None, - target: int, + target: int | None, + depth: bool, raw: bool, overlay: str | None, width: int, @@ -194,7 +204,15 @@ def rt_cmd( if eid is None: raise click.UsageError("EID is required when --overlay is not used") - _export_vfs_path(f"/draws/{eid}/targets/color{target}.png", output, raw) + + if depth: + if target is not None: + raise click.UsageError("--depth and --target are mutually exclusive") + _export_vfs_path(f"/draws/{eid}/targets/depth.png", output, raw) + return + + color = target if target is not None else 0 + _export_vfs_path(f"/draws/{eid}/targets/color{color}.png", output, raw) @click.command("buffer") diff --git a/tests/unit/test_export_commands.py b/tests/unit/test_export_commands.py index cada754..be54b4e 100644 --- a/tests/unit/test_export_commands.py +++ b/tests/unit/test_export_commands.py @@ -111,6 +111,112 @@ def mock_call(method: str, params: dict[str, Any] | None = None) -> dict[str, An vfs_calls = [c for c in calls if c[0] == "vfs_ls"] assert any("/draws/100/targets/color2.png" in str(c) for c in vfs_calls) + def test_rt_depth_routes_to_depth_png(self, monkeypatch: Any, tmp_path: Path) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + def mock_call(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + calls.append((method, dict(params) if params else {})) + if method == "vfs_ls": + return {"kind": "leaf_bin", "path": params.get("path", "/") if params else "/"} + temp = tmp_path / "export.bin" + temp.write_bytes(b"\x89PNG" + b"\x00" * 50) + return {"path": str(temp), "size": 54} + + monkeypatch.setattr("rdc.commands.export.call", mock_call) + monkeypatch.setattr("rdc.commands.vfs.call", mock_call) + monkeypatch.setattr("rdc.commands.vfs._stdout_is_tty", lambda: False) + out_file = tmp_path / "depth.png" + runner = click.testing.CliRunner() + result = runner.invoke(rt_cmd, ["100", "--depth", "-o", str(out_file)]) + assert result.exit_code == 0 + vfs_calls = [c for c in calls if c[0] == "vfs_ls"] + assert any("/draws/100/targets/depth.png" in str(c) for c in vfs_calls) + + def test_rt_depth_with_explicit_target_raises_usage_error( + self, monkeypatch: Any, tmp_path: Path + ) -> None: + mock = _make_mockcall(tmp_path) + monkeypatch.setattr("rdc.commands.export.call", mock) + monkeypatch.setattr("rdc.commands.vfs.call", mock) + monkeypatch.setattr("rdc.commands.vfs._stdout_is_tty", lambda: False) + out_file = tmp_path / "depth.png" + runner = click.testing.CliRunner() + result = runner.invoke(rt_cmd, ["100", "--depth", "--target", "1", "-o", str(out_file)]) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output + + def test_rt_depth_target_none_defaults_to_color0( + self, monkeypatch: Any, tmp_path: Path + ) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + def mock_call(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + calls.append((method, dict(params) if params else {})) + if method == "vfs_ls": + return {"kind": "leaf_bin", "path": params.get("path", "/") if params else "/"} + temp = tmp_path / "export.bin" + temp.write_bytes(b"\x89PNG" + b"\x00" * 50) + return {"path": str(temp), "size": 54} + + monkeypatch.setattr("rdc.commands.export.call", mock_call) + monkeypatch.setattr("rdc.commands.vfs.call", mock_call) + monkeypatch.setattr("rdc.commands.vfs._stdout_is_tty", lambda: False) + out_file = tmp_path / "rt.png" + runner = click.testing.CliRunner() + result = runner.invoke(rt_cmd, ["100", "-o", str(out_file)]) + assert result.exit_code == 0 + vfs_calls = [c for c in calls if c[0] == "vfs_ls"] + assert any("/draws/100/targets/color0.png" in str(c) for c in vfs_calls) + + def test_rt_depth_remote_pid0_writes_output(self, monkeypatch: Any, tmp_path: Path) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\xde\xad\xbe\xef" * 25 + + def mock_call(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + calls.append((method, dict(params) if params else {})) + if method == "vfs_ls": + return {"kind": "leaf_bin", "path": params.get("path", "/") if params else "/"} + return {"path": "/remote/depth.png", "size": len(png_bytes)} + + class FakeSession: + pid = 0 + + monkeypatch.setattr("rdc.commands.export.call", mock_call) + monkeypatch.setattr("rdc.commands.vfs.call", mock_call) + monkeypatch.setattr("rdc.commands.vfs._load_session", lambda: FakeSession()) + monkeypatch.setattr("rdc.commands.vfs.fetch_remote_file", lambda path: png_bytes) + monkeypatch.setattr("rdc.commands.vfs._stdout_is_tty", lambda: False) + out_file = tmp_path / "depth.png" + runner = click.testing.CliRunner() + result = runner.invoke(rt_cmd, ["100", "--depth", "-o", str(out_file)]) + assert result.exit_code == 0 + vfs_calls = [c for c in calls if c[0] == "vfs_ls"] + assert any("/draws/100/targets/depth.png" in str(c) for c in vfs_calls) + assert out_file.read_bytes() == png_bytes + + def test_rt_depth_with_overlay_ignores_depth(self, monkeypatch: Any, tmp_path: Path) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + def mock_call(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + calls.append((method, dict(params) if params else {})) + if method == "rt_overlay": + return {"path": "/remote/overlay.png", "overlay": "depth", "size": 64} + if method == "vfs_ls": + return {"kind": "leaf_bin", "path": params.get("path", "/") if params else "/"} + return {"path": "/remote/overlay.png", "size": 64} + + monkeypatch.setattr("rdc.commands.export.call", mock_call) + monkeypatch.setattr("rdc.commands.export.fetch_remote_file", lambda path: b"\x89PNG") + out_file = tmp_path / "overlay.png" + runner = click.testing.CliRunner() + result = runner.invoke( + rt_cmd, ["100", "--overlay", "depth", "--depth", "-o", str(out_file)] + ) + assert result.exit_code == 0 + assert any(c[0] == "rt_overlay" for c in calls) + vfs_calls = [c for c in calls if c[0] == "vfs_ls"] + assert not any("/draws/100/targets/depth.png" in str(c) for c in vfs_calls) + class TestBufferCmd: def test_buffer_output(self, monkeypatch: Any, tmp_path: Path) -> None: