chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:55:23 +08:00
commit bc7eac8151
1701 changed files with 376767 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Bundled RenderDoc native bindings must not be committed (use system install).
cli_anything/renderdoc/native/
# Python
__pycache__/
*.pyc
*.egg-info/
.pytest_cache/
+83
View File
@@ -0,0 +1,83 @@
# HARNESS.md RenderDoc CLI Harness Specification
## Overview
This harness wraps the **RenderDoc** graphics debugger Python API into a Click-based
CLI tool called `cli-anything-renderdoc`. It enables headless, scriptable analysis
of GPU frame captures (`.rdc` files) without requiring the RenderDoc GUI.
## Architecture
```
agent-harness/
├── HARNESS.md # This file
├── RENDERDOC.md # Software-specific SOP
├── setup.py # PEP 420 namespace package
└── cli_anything/ # NO __init__.py (namespace package)
└── renderdoc/ # HAS __init__.py
├── renderdoc_cli.py # Main CLI entry point (Click)
├── core/
│ ├── capture.py # Capture file open/close/metadata/convert
│ ├── actions.py # Draw call / action tree navigation
│ ├── textures.py # Texture listing, pixel picking, export
│ ├── pipeline.py # Pipeline state, shader export, diff, cbuffers
│ ├── resources.py # Buffer/resource enumeration and reading
│ ├── mesh.py # Vertex input/output decoding
│ └── counters.py # GPU performance counters
├── utils/
│ ├── output.py # JSON/table output formatting
│ └── errors.py # Error handling
├── skills/
│ └── SKILL.md # AI-discoverable skill definition
└── tests/
├── TEST.md # Test plan and results
├── test_core.py # Unit tests (mock-based, no renderdoc dep)
└── test_full_e2e.py # E2E tests (requires renderdoc + .rdc files)
```
## Command Groups
| Group | Commands |
|-------------|--------------------------------------------------|
| `capture` | `info`, `thumb`, `convert` |
| `actions` | `list`, `summary`, `find`, `get` |
| `textures` | `list`, `get`, `save`, `save-outputs`, `pick` |
| `pipeline` | `state`, `shader-export`, `cbuffer`, `diff` |
| `resources` | `list`, `buffers`, `read-buffer` |
| `mesh` | `inputs`, `outputs` |
| `counters` | `list`, `fetch` |
## Global Options
- `--capture / -c <path>`: Path to the `.rdc` capture file (or `$RENDERDOC_CAPTURE`)
- `--json`: Output in JSON format (machine-readable)
- `--debug`: Show tracebacks on errors
- `--version`: Show version
## Patterns
1. **Lazy loading**: `renderdoc` module is only imported when a command runs, not at
CLI parse time. This allows `--help` to work without renderdoc installed.
2. **CaptureHandle**: Single context manager that owns the CaptureFile and
ReplayController lifecycle.
3. **Dict-based returns**: Every core function returns plain `dict`/`list` for
direct JSON serialisation.
4. **Dual output**: `_output()` helper picks JSON or human-readable based on `--json`.
5. **Error dicts**: Errors returned as `{"error": "message"}` rather than exceptions
at the boundary layer.
## Testing Strategy
- **Unit tests** (`test_core.py`): Test all core module functions using mocks.
No dependency on `renderdoc` module. Uses synthetic data.
- **E2E tests** (`test_full_e2e.py`): Require a real RenderDoc installation and
at least one `.rdc` capture file. Test full CLI invocation via subprocess.
- **Subprocess tests**: Invoke the CLI with
`python -m cli_anything.renderdoc.renderdoc_cli` from `agent-harness/` (see
`test_core.py` / `test_full_e2e.py`).
## Dependencies
- **Required**: `click>=8.0`, `prompt-toolkit>=3.0`, `python>=3.10`
- **Optional** (runtime): `renderdoc` Python module (from RenderDoc installation)
- **Test**: `pytest>=7.0`
+81
View File
@@ -0,0 +1,81 @@
# RENDERDOC.md Software-Specific SOP
## About RenderDoc
RenderDoc is a free, open-source GPU frame capture and analysis tool for
Vulkan, D3D11, D3D12, OpenGL, and OpenGL ES. It captures a single frame
of GPU commands and allows detailed inspection of every draw call, resource,
shader, and pipeline state.
## Key Concepts
### Capture File (.rdc)
A binary file containing all GPU state and commands for a single frame.
Contains embedded sections (textures, shaders, structured data, thumbnails).
### Actions
GPU operations recorded in the capture. Hierarchical tree structure:
- **PushMarker / PopMarker**: Debug groups (like `RenderPass: ForwardOpaque`)
- **Drawcall**: Actual draw calls with triangle/vertex counts
- **Clear**: Clear render target/depth
- **Dispatch**: Compute shader dispatch
- **Copy/Resolve**: Resource copy operations
- **Present**: Frame present
### Resources
GPU resources tracked by unique `ResourceId`:
- **Textures**: 2D, 3D, cube, array textures with mips
- **Buffers**: Vertex, index, constant, structured buffers
- **Shaders**: Compiled shader programs
### Pipeline State
At any event, the complete GPU pipeline is inspectable:
- Bound shaders (VS, HS, DS, GS, PS, CS)
- Vertex inputs (attribute layout)
- Render targets and depth target
- Viewports and scissors
- Blend, depth/stencil, rasterizer state
- Shader resources (textures, buffers, samplers)
- Constant buffer contents
### Replay
Captures can be replayed on the local GPU. The ReplayController allows:
- Setting the current event (seeking to any draw call)
- Reading back textures, buffers, mesh data
- Picking pixels
- Fetching GPU counters
- Disassembling shaders
## CLI Coverage Map
| RenderDoc Feature | CLI Command | Status |
|-----------------------------|---------------------------|-----------|
| Open/close capture | `capture info` | ✅ Done |
| Capture metadata | `capture info` | ✅ Done |
| List sections | `capture info` | ✅ Done |
| Extract thumbnail | `capture thumb` | ✅ Done |
| Convert capture | `capture convert` | ✅ Done |
| List all actions | `actions list` | ✅ Done |
| Action summary | `actions summary` | ✅ Done |
| Find actions by name | `actions find` | ✅ Done |
| Get single action | `actions get` | ✅ Done |
| Filter draw calls only | `actions list --draws-only` | ✅ Done |
| List textures | `textures list` | ✅ Done |
| Get texture details | `textures get` | ✅ Done |
| Save texture to file | `textures save` | ✅ Done |
| Save render target outputs | `textures save-outputs` | ✅ Done |
| Pick pixel value | `textures pick` | ✅ Done |
| Pipeline state | `pipeline state` | ✅ Done |
| Shader disassembly | `pipeline shader-export` | ✅ Done |
| Constant buffer contents | `pipeline cbuffer` | ✅ Done |
| Pipeline diff | `pipeline diff` | ✅ Done |
| List resources | `resources list` | ✅ Done |
| List buffers | `resources buffers` | ✅ Done |
| Read buffer data | `resources read-buffer` | ✅ Done |
| Vertex inputs | `mesh inputs` | ✅ Done |
| Post-VS outputs | `mesh outputs` | ✅ Done |
| GPU counters list | `counters list` | ✅ Done |
| Fetch counter results | `counters fetch` | ✅ Done |
| Remote capture | — | ❌ Future |
| Shader debugging | — | ❌ Future |
| Resource diff | — | ❌ Future |
@@ -0,0 +1,129 @@
# cli-anything-renderdoc
Command-line interface for [RenderDoc](https://renderdoc.org/) graphics debugger.
Provides headless, scriptable analysis of GPU frame captures (`.rdc` files)
without requiring the RenderDoc GUI.
## Installation
```bash
cd renderdoc/agent-harness
pip install -e .
```
**Prerequisites**: RenderDoc must be installed and its Python module must be
importable. Add the RenderDoc Python directory to `PYTHONPATH`:
```bash
# Windows (typical)
set PYTHONPATH=C:\Program Files\RenderDoc
# Linux (typical)
export PYTHONPATH=/opt/renderdoc/lib
```
## Quick Start
```bash
# Show capture info
cli-anything-renderdoc --capture frame.rdc capture info
# List all draw calls
cli-anything-renderdoc -c frame.rdc actions list --draws-only
# Get action summary
cli-anything-renderdoc -c frame.rdc actions summary
# Save a texture as PNG
cli-anything-renderdoc -c frame.rdc textures save <resourceId> -o output.png
# Pick a pixel
cli-anything-renderdoc -c frame.rdc textures pick <resourceId> 100 200
# Get pipeline state at a draw call
cli-anything-renderdoc -c frame.rdc pipeline state 42
# Get shader disassembly
cli-anything-renderdoc -c frame.rdc pipeline shader-export 42 --stage Fragment
# List GPU counters
cli-anything-renderdoc -c frame.rdc counters list
# Read buffer data
cli-anything-renderdoc -c frame.rdc resources read-buffer <resourceId> --format float32
# JSON output for all commands
cli-anything-renderdoc -c frame.rdc --json actions list
```
## Preview Bundles
RenderDoc exposes preview bundles for honest capture inspection and diffing.
```bash
# Capture a preview bundle
cli-anything-renderdoc -c frame.rdc --json preview capture --recipe quick --event-id 42
# Capture a diff preview bundle
cli-anything-renderdoc -c frame.rdc --json preview diff 100 200
# Return the latest existing bundle
cli-anything-renderdoc -c frame.rdc --json preview latest --recipe quick
```
Preview bundles typically contain thumbnails, output-target images, and
pipeline/action JSON. Diff bundles also contain `pipeline_diff.json`.
Inspect or open them with:
```bash
cli-hub previews inspect /path/to/bundle
cli-hub previews html /path/to/bundle -o page.html
cli-hub previews open /path/to/bundle
```
## Command Reference
### Global Options
| Option | Description |
|-------------|--------------------------------------|
| `--capture` | Path to `.rdc` capture file |
| `--json` | JSON output mode |
| `--debug` | Show error tracebacks |
| `--version` | Show version |
### Commands
| Group | Command | Description |
|-------------|----------------|--------------------------------------------|
| `capture` | `info` | Show metadata and sections |
| `capture` | `thumb` | Extract thumbnail image |
| `capture` | `convert` | Convert capture format |
| `actions` | `list` | List all actions / draw calls |
| `actions` | `summary` | Count actions by type |
| `actions` | `find` | Search actions by name |
| `actions` | `get` | Get single action details |
| `textures` | `list` | List all textures |
| `textures` | `get` | Get texture details |
| `textures` | `save` | Export texture to image file |
| `textures` | `save-outputs` | Save all render targets at an event |
| `textures` | `pick` | Read pixel value |
| `pipeline` | `state` | Full pipeline state at event |
| `pipeline` | `shader-export` | Export shader source / disassembly |
| `pipeline` | `cbuffer` | Constant buffer contents |
| `pipeline` | `diff` | Compare pipeline state between events |
| `resources` | `list` | List all resources |
| `resources` | `buffers` | List buffer resources |
| `resources` | `read-buffer` | Read raw buffer data |
| `mesh` | `inputs` | Vertex shader input data |
| `mesh` | `outputs` | Post-VS output data |
| `counters` | `list` | Available GPU counters |
| `counters` | `fetch` | Fetch counter results |
## Environment Variables
| Variable | Description |
|-------------------|--------------------------------|
| `RENDERDOC_CAPTURE` | Default capture file path |
| `PYTHONPATH` | Must include RenderDoc Python |
@@ -0,0 +1,6 @@
"""RenderDoc CLI harness - command-line interface for RenderDoc graphics debugger."""
__version__ = "0.1.0"
# The ``renderdoc`` module is loaded lazily by core/CLI code when needed.
# Install RenderDoc and add its Python bindings directory to PYTHONPATH.
@@ -0,0 +1,3 @@
"""Allow running as python -m cli_anything.renderdoc"""
from cli_anything.renderdoc.renderdoc_cli import main
main()
@@ -0,0 +1 @@
"""Core modules for RenderDoc CLI harness."""
@@ -0,0 +1,168 @@
"""
Action (draw call) inspection: list, search, navigate the action tree.
Works with a CaptureHandle's ReplayController to enumerate draw calls,
clears, dispatches, and other GPU actions recorded in a frame capture.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
try:
import renderdoc as rd
HAS_RD = True
except ImportError:
rd = None # type: ignore[assignment]
HAS_RD = False
# ---------------------------------------------------------------------------
# Action helpers
# ---------------------------------------------------------------------------
def _action_to_dict(action, structured_file=None) -> Dict[str, Any]:
"""Serialise one ActionDescription to a plain dict."""
name = action.customName
if not name and structured_file is not None:
name = action.GetName(structured_file)
flags_list = _decode_flags(action.flags)
return {
"eventId": action.eventId,
"actionId": action.actionId,
"name": name or "",
"flags": flags_list,
"numIndices": action.numIndices,
"numInstances": action.numInstances,
"indexOffset": action.indexOffset,
"baseVertex": action.baseVertex,
"vertexOffset": action.vertexOffset,
"instanceOffset": action.instanceOffset,
"outputs": [str(o) for o in action.outputs],
"depthOut": str(action.depthOut),
"children_count": len(action.children),
}
def _decode_flags(flags) -> List[str]:
"""Decode ActionFlags bitmask into list of human-readable names."""
if rd is None:
return []
names = []
flag_map = {
rd.ActionFlags.Clear: "Clear",
rd.ActionFlags.Drawcall: "Drawcall",
rd.ActionFlags.Dispatch: "Dispatch",
rd.ActionFlags.CmdList: "CmdList",
rd.ActionFlags.SetMarker: "SetMarker",
rd.ActionFlags.PushMarker: "PushMarker",
rd.ActionFlags.PopMarker: "PopMarker",
rd.ActionFlags.Present: "Present",
rd.ActionFlags.MultiAction: "MultiAction",
rd.ActionFlags.Copy: "Copy",
rd.ActionFlags.Resolve: "Resolve",
rd.ActionFlags.GenMips: "GenMips",
rd.ActionFlags.PassBoundary: "PassBoundary",
rd.ActionFlags.Indexed: "Indexed",
rd.ActionFlags.Instanced: "Instanced",
rd.ActionFlags.Auto: "Auto",
rd.ActionFlags.Indirect: "Indirect",
rd.ActionFlags.ClearColor: "ClearColor",
rd.ActionFlags.ClearDepthStencil: "ClearDepthStencil",
rd.ActionFlags.BeginPass: "BeginPass",
rd.ActionFlags.EndPass: "EndPass",
}
for flag_val, flag_name in flag_map.items():
if flags & flag_val:
names.append(flag_name)
return names
# ---------------------------------------------------------------------------
# Flat enumeration
# ---------------------------------------------------------------------------
def _flatten_actions(actions, out: list, structured_file=None, depth: int = 0):
"""Recursively flatten action tree."""
for a in actions:
d = _action_to_dict(a, structured_file)
d["depth"] = depth
out.append(d)
if len(a.children) > 0:
_flatten_actions(a.children, out, structured_file, depth + 1)
def list_actions(controller, flat: bool = True) -> List[Dict[str, Any]]:
"""Return all actions in the capture.
Parameters
----------
controller : rd.ReplayController
flat : bool
If True (default), return a flat list with a ``depth`` key.
If False, return only root-level actions.
"""
sf = controller.GetStructuredFile()
root_actions = controller.GetRootActions()
if flat:
result: List[Dict[str, Any]] = []
_flatten_actions(root_actions, result, sf)
return result
return [_action_to_dict(a, sf) for a in root_actions]
def find_action_by_event(controller, event_id: int) -> Optional[Dict[str, Any]]:
"""Find a single action by its eventId."""
sf = controller.GetStructuredFile()
# Use the flat list and filter
all_actions: List[Dict[str, Any]] = []
_flatten_actions(controller.GetRootActions(), all_actions, sf)
for a in all_actions:
if a["eventId"] == event_id:
return a
return None
def find_actions_by_name(controller, pattern: str) -> List[Dict[str, Any]]:
"""Find actions whose name contains *pattern* (case-insensitive)."""
sf = controller.GetStructuredFile()
all_actions: List[Dict[str, Any]] = []
_flatten_actions(controller.GetRootActions(), all_actions, sf)
pat = pattern.lower()
return [a for a in all_actions if pat in a["name"].lower()]
def get_drawcalls_only(controller) -> List[Dict[str, Any]]:
"""Return only actual draw calls (not markers, clears, etc.)."""
all_actions = list_actions(controller, flat=True)
return [a for a in all_actions if "Drawcall" in a["flags"]]
def action_summary(controller) -> Dict[str, Any]:
"""High-level summary: counts of different action types."""
all_actions = list_actions(controller, flat=True)
summary = {
"total_actions": len(all_actions),
"drawcalls": 0,
"clears": 0,
"dispatches": 0,
"copies": 0,
"markers": 0,
"presents": 0,
}
for a in all_actions:
flags = a["flags"]
if "Drawcall" in flags:
summary["drawcalls"] += 1
if "Clear" in flags:
summary["clears"] += 1
if "Dispatch" in flags:
summary["dispatches"] += 1
if "Copy" in flags:
summary["copies"] += 1
if "PushMarker" in flags or "SetMarker" in flags:
summary["markers"] += 1
if "Present" in flags:
summary["presents"] += 1
return summary
@@ -0,0 +1,207 @@
"""
Capture management: open, inspect metadata, list sections, convert captures.
This module wraps the renderdoc Python API for capture file operations.
It works in two modes:
1. LIVE mode: when `renderdoc` is importable (RenderDoc installed)
2. MOCK mode: when `renderdoc` is NOT importable (unit-test / offline)
Every public function returns plain Python dicts/lists for JSON serialisation.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
# ---------------------------------------------------------------------------
# Import renderdoc gracefully degrade if unavailable
# ---------------------------------------------------------------------------
try:
import renderdoc as rd
HAS_RD = True
except ImportError:
rd = None # type: ignore[assignment]
HAS_RD = False
def _require_rd():
if not HAS_RD:
raise RuntimeError(
"renderdoc Python module not available. "
"Ensure RenderDoc is installed and its Python bindings are on PYTHONPATH."
)
def _api_properties_summary(props: Any) -> Dict[str, Any]:
"""JSON-friendly subset of CaptureFile.APIProperties / GetAPIProperties."""
out: Dict[str, Any] = {"api": str(props.pipelineType)}
if hasattr(props, "degraded"):
out["degraded"] = bool(props.degraded)
driver = None
for attr in ("localRenderer", "vendor"):
if hasattr(props, attr):
val = getattr(props, attr)
if val is not None and str(val):
driver = str(val)
break
out["driver"] = driver if driver is not None else str(props.pipelineType)
return out
# ---------------------------------------------------------------------------
# Global RenderDoc replay API (InitialiseReplay / ShutdownReplay) — refcounted
# ---------------------------------------------------------------------------
_replay_refcount = 0
def _ensure_replay_api():
"""Initialise RenderDoc replay once; pair with ``_release_replay_api`` per handle."""
global _replay_refcount
_require_rd()
if _replay_refcount == 0:
rd.InitialiseReplay(rd.GlobalEnvironment(), [])
_replay_refcount += 1
def _release_replay_api():
"""Shut down replay when the last CaptureHandle in the process closes."""
global _replay_refcount
if not HAS_RD or _replay_refcount <= 0:
return
_replay_refcount -= 1
if _replay_refcount == 0:
rd.ShutdownReplay()
# ---------------------------------------------------------------------------
# Capture file handle wrapper
# ---------------------------------------------------------------------------
class CaptureHandle:
"""Wraps an open renderdoc CaptureFile + optional ReplayController."""
def __init__(self, path: str):
_require_rd()
self.path = os.path.abspath(path)
if not os.path.isfile(self.path):
raise FileNotFoundError(f"Capture file not found: {self.path}")
_ensure_replay_api()
try:
self._cap = rd.OpenCaptureFile()
result = self._cap.OpenFile(self.path, "", None)
if result != rd.ResultCode.Succeeded:
raise RuntimeError(f"Failed to open capture: {result}")
except Exception:
_release_replay_api()
raise
self._controller: Any = None
self._closed = False
# -- lazy replay init ---------------------------------------------------
def _ensure_replay(self):
if self._controller is not None:
return
if not self._cap.LocalReplaySupport():
raise RuntimeError("Capture cannot be replayed locally")
result, ctrl = self._cap.OpenCapture(rd.ReplayOptions(), None)
if result != rd.ResultCode.Succeeded:
raise RuntimeError(f"Failed to initialise replay: {result}")
self._controller = ctrl
@property
def controller(self):
self._ensure_replay()
return self._controller
# -- metadata -----------------------------------------------------------
def metadata(self) -> Dict[str, Any]:
"""Return capture-level metadata."""
result: Dict[str, Any] = {"path": self.path}
try:
props = self._cap.APIProperties()
result.update(_api_properties_summary(props))
except AttributeError:
self._ensure_replay()
api_props = self._controller.GetAPIProperties()
result.update(_api_properties_summary(api_props))
try:
result["replay_supported"] = self._cap.LocalReplaySupport()
except AttributeError:
result["replay_supported"] = self._controller is not None
return result
# -- embedded sections --------------------------------------------------
def list_sections(self) -> List[Dict[str, Any]]:
"""List embedded sections in the capture."""
count = self._cap.GetSectionCount()
sections = []
for i in range(count):
props = self._cap.GetSectionProperties(i)
sections.append({
"index": i,
"name": props.name,
"type": str(props.type),
"flags": int(props.flags),
"uncompressed_size": props.uncompressedSize,
"compressed_size": props.compressedSize,
})
return sections
# -- thumbnail ----------------------------------------------------------
def thumbnail(self, output_path: str, max_dim: int = 0) -> Dict[str, Any]:
"""Extract thumbnail from capture to output_path (PNG)."""
thumb = self._cap.GetThumbnail(rd.FileType.PNG, max_dim)
if thumb.type == rd.FileType.PNG and len(thumb.data) > 0:
with open(output_path, "wb") as f:
f.write(bytes(thumb.data))
return {"path": output_path, "size": len(thumb.data), "format": "PNG"}
return {"error": "No thumbnail available"}
# -- convert capture format ---------------------------------------------
def convert(self, output_path: str, export_format: str = "") -> Dict[str, Any]:
"""Convert / re-save the capture."""
result = self._cap.Convert(
output_path, export_format, None, None
)
if result != rd.ResultCode.Succeeded:
return {"error": f"Conversion failed: {result}"}
return {"path": output_path, "format": export_format or "rdc"}
# -- cleanup ------------------------------------------------------------
def close(self):
if getattr(self, "_closed", False):
return
self._closed = True
if self._controller is not None:
self._controller.Shutdown()
self._controller = None
if self._cap is not None:
self._cap.Shutdown()
self._cap = None
_release_replay_api()
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
# ---------------------------------------------------------------------------
# High-level convenience functions (stateless)
# ---------------------------------------------------------------------------
def open_capture(path: str) -> CaptureHandle:
"""Open a capture file and return a CaptureHandle."""
return CaptureHandle(path)
def capture_info(path: str) -> Dict[str, Any]:
"""Return metadata dict for a capture without starting replay."""
with CaptureHandle(path) as cap:
meta = cap.metadata()
meta["sections"] = cap.list_sections()
return meta
@@ -0,0 +1,85 @@
"""
GPU performance counters: enumerate, fetch, and describe counters.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
try:
import renderdoc as rd
HAS_RD = True
except ImportError:
rd = None # type: ignore[assignment]
HAS_RD = False
def list_counters(controller) -> List[Dict[str, Any]]:
"""Enumerate all available GPU counters and their descriptions."""
counters = controller.EnumerateCounters()
result = []
for c in counters:
desc = controller.DescribeCounter(c)
result.append({
"counter": int(c),
"name": str(desc.name),
"description": str(desc.description),
"resultByteWidth": desc.resultByteWidth,
"resultType": str(desc.resultType),
"unit": str(desc.unit),
})
return result
def fetch_counters(
controller,
counter_ids: Optional[List[int]] = None,
) -> Dict[str, Any]:
"""Fetch counter results for specified counters (or SamplesPassed by default).
Returns per-event counter values.
"""
available = controller.EnumerateCounters()
if counter_ids is None:
# Default to SamplesPassed if available
if rd.GPUCounter.SamplesPassed in available:
counter_ids = [int(rd.GPUCounter.SamplesPassed)]
else:
# Use first available counter
if available:
counter_ids = [int(available[0])]
else:
return {"error": "No counters available"}
valid_ids = [int(c) for c in available]
try:
rd_counters = [rd.GPUCounter(c) for c in counter_ids]
except (ValueError, TypeError) as exc:
return {
"error": "Invalid counter id(s) %s: %s" % (counter_ids, exc),
"valid_counter_ids": valid_ids,
}
results = controller.FetchCounters(rd_counters)
# Group by counter
output: Dict[str, List[Dict[str, Any]]] = {}
for r in results:
counter_name = str(rd.GPUCounter(r.counter))
if counter_name not in output:
output[counter_name] = []
desc = controller.DescribeCounter(r.counter)
if desc.resultByteWidth == 4:
val = r.value.u32
else:
val = r.value.u64
output[counter_name].append({
"eventId": r.eventId,
"value": val,
})
return {"counters": output, "total_results": len(results)}
@@ -0,0 +1,459 @@
"""
Pipeline diff -- compare two pipeline snapshots and output only differences.
Usage:
from core.diff import diff_pipeline
result = diff_pipeline(controller_a, event_a, controller_b, event_b)
The result dict only contains sections that have at least one difference.
Sections that are completely identical are either omitted or marked "SAME".
The snapshot format matches the output of ``dump_pipeline_for_diff``:
{
"eventId": ...,
"PipelineState": {
"pipelineType": ...,
"vertexInputs": [...],
"outputTargets": [...],
"depthTarget": {...},
"viewport": {...},
"rasterizer": {...},
"blend": {...},
"depthStencil": {...},
"stages": {
"Vertex": {
"shader": "ResourceId::...",
"entryPoint": "...",
"ShaderReflection": { ... },
"bindings": {
"constantBlocks": [ { ..., "variables": [...] } ],
"readOnlyResources": [...],
"readWriteResources": [...],
"samplers": [...]
}
},
...
}
}
}
"""
from __future__ import annotations
import math
from typing import Any, Dict, List, Optional
from cli_anything.renderdoc.core.pipeline import dump_pipeline_for_diff
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_FLOAT_TOL = 1e-6
def _floats_equal(a, b) -> bool:
"""Compare two floats with tolerance; handle NaN/Inf."""
if isinstance(a, float) and isinstance(b, float):
if math.isnan(a) and math.isnan(b):
return True
if math.isinf(a) and math.isinf(b):
return a == b
return abs(a - b) <= _FLOAT_TOL
return a == b
def _values_equal(a, b) -> bool:
"""Deep equality check for plain JSON-like values."""
if type(a) != type(b):
return False
if isinstance(a, dict):
if set(a.keys()) != set(b.keys()):
return False
return all(_values_equal(a[k], b[k]) for k in a)
if isinstance(a, list):
if len(a) != len(b):
return False
return all(_values_equal(x, y) for x, y in zip(a, b))
if isinstance(a, float):
return _floats_equal(a, b)
return a == b
def _diff_dicts(
a: Optional[Dict], b: Optional[Dict], label: str = "",
) -> Optional[Dict[str, Any]]:
"""Compare two flat/nested dicts, return only differing keys.
Returns None if both are equal (or both None).
"""
if a is None and b is None:
return None
if a is None or b is None:
return {"A": a, "B": b}
diffs: Dict[str, Any] = {}
all_keys = sorted(set(list(a.keys()) + list(b.keys())))
for k in all_keys:
va = a.get(k)
vb = b.get(k)
if not _values_equal(va, vb):
diffs[k] = {"A": va, "B": vb}
return diffs if diffs else None
def _diff_lists(
a: Optional[List], b: Optional[List], key_field: str = "name",
) -> Optional[List[Dict[str, Any]]]:
"""Compare two lists of dicts by a key field, return only diffs.
Items present in A but not B get status "only_in_A", vice versa.
Items present in both get per-field diff.
Returns None if identical.
"""
if a is None and b is None:
return None
a = a or []
b = b or []
a_map = {str(item.get(key_field, i)): item for i, item in enumerate(a)}
b_map = {str(item.get(key_field, i)): item for i, item in enumerate(b)}
all_keys = sorted(set(list(a_map.keys()) + list(b_map.keys())))
diffs = []
for k in all_keys:
va = a_map.get(k)
vb = b_map.get(k)
if va is None:
diffs.append({"key": k, "status": "only_in_B", "B": vb})
elif vb is None:
diffs.append({"key": k, "status": "only_in_A", "A": va})
else:
d = _diff_dicts(va, vb)
if d:
diffs.append({"key": k, "status": "changed", "fields": d})
return diffs if diffs else None
# ---------------------------------------------------------------------------
# CBuffer variable diff (recursive, handles struct members)
# ---------------------------------------------------------------------------
def _diff_cbuffer_vars(
vars_a: List[Dict], vars_b: List[Dict],
) -> Optional[List[Dict]]:
"""Compare two lists of cbuffer variables; return only diffs."""
a_map = {v["name"]: v for v in vars_a}
b_map = {v["name"]: v for v in vars_b}
all_names = sorted(set(list(a_map.keys()) + list(b_map.keys())))
diffs = []
for name in all_names:
va = a_map.get(name)
vb = b_map.get(name)
if va is None:
diffs.append({"name": name, "status": "only_in_B", "B": vb})
elif vb is None:
diffs.append({"name": name, "status": "only_in_A", "A": va})
else:
if "members" in va or "members" in vb:
sub = _diff_cbuffer_vars(
va.get("members", []),
vb.get("members", []),
)
if sub:
diffs.append({"name": name, "status": "changed", "members": sub})
else:
vals_a = va.get("values", [])
vals_b = vb.get("values", [])
if not _values_equal(vals_a, vals_b):
diffs.append({
"name": name,
"status": "changed",
"A": vals_a,
"B": vals_b,
})
return diffs if diffs else None
# ---------------------------------------------------------------------------
# Stage-level diff (new structure)
# ---------------------------------------------------------------------------
def _diff_bindings(bindings_a: Dict, bindings_b: Dict) -> Optional[Dict[str, Any]]:
"""Diff the bindings sub-dict of a stage.
Handles: constantBlocks (with nested variable values),
readOnlyResources, readWriteResources, samplers.
"""
result: Dict[str, Any] = {}
has_diff = False
# --- constantBlocks ---
cbs_a = bindings_a.get("constantBlocks", [])
cbs_b = bindings_b.get("constantBlocks", [])
# Build maps keyed by index
a_map = {cb.get("index", i): cb for i, cb in enumerate(cbs_a)}
b_map = {cb.get("index", i): cb for i, cb in enumerate(cbs_b)}
all_indices = sorted(set(list(a_map.keys()) + list(b_map.keys())))
cb_diffs = []
var_diffs_all = []
for idx in all_indices:
ca = a_map.get(idx)
cb_ = b_map.get(idx)
if ca is None:
cb_diffs.append({"index": idx, "status": "only_in_B", "B": cb_})
elif cb_ is None:
cb_diffs.append({"index": idx, "status": "only_in_A", "A": ca})
else:
# Compare binding metadata (resource, byteOffset, byteSize)
ca_meta = {k: v for k, v in ca.items() if k not in ("variables",)}
cb_meta = {k: v for k, v in cb_.items() if k not in ("variables",)}
meta_diff = _diff_dicts(ca_meta, cb_meta)
if meta_diff:
cb_diffs.append({"index": idx, "status": "changed", "fields": meta_diff})
# Compare runtime variable values
va_vars = ca.get("variables", [])
vb_vars = cb_.get("variables", [])
vdiff = _diff_cbuffer_vars(va_vars, vb_vars)
if vdiff:
var_diffs_all.append({"index": idx, "variables": vdiff})
cb_result: Dict[str, Any] = {}
if cb_diffs:
cb_result["metadata"] = cb_diffs
if var_diffs_all:
cb_result["variables"] = var_diffs_all
if cb_result:
result["constantBlocks"] = cb_result
has_diff = True
else:
result["constantBlocks"] = "SAME"
# --- readOnlyResources, readWriteResources, samplers ---
for section in ("readOnlyResources", "readWriteResources", "samplers"):
d = _diff_lists(
bindings_a.get(section, []),
bindings_b.get(section, []),
key_field="index",
)
if d:
result[section] = d
has_diff = True
else:
result[section] = "SAME"
return result if has_diff else None
def _diff_stages(stages_a: Dict, stages_b: Dict) -> Optional[Dict[str, Any]]:
"""Diff the stages sub-dict of PipelineState.
For each stage present in either snapshot, compare:
- shader / entryPoint (as a dict)
- ShaderReflection (deep dict diff)
- bindings (structured diff with variable values)
"""
all_names = sorted(set(list(stages_a.keys()) + list(stages_b.keys())))
result: Dict[str, Any] = {}
has_diff = False
for name in all_names:
sa = stages_a.get(name)
sb = stages_b.get(name)
if sa is None:
result[name] = {"status": "only_in_B", "B": sb}
has_diff = True
continue
if sb is None:
result[name] = {"status": "only_in_A", "A": sa}
has_diff = True
continue
stage_result: Dict[str, Any] = {}
stage_has_diff = False
# shader + entryPoint
shader_dict_a = {"shader": sa.get("shader"), "entryPoint": sa.get("entryPoint")}
shader_dict_b = {"shader": sb.get("shader"), "entryPoint": sb.get("entryPoint")}
shader_diff = _diff_dicts(shader_dict_a, shader_dict_b)
if shader_diff:
stage_result["shader"] = shader_diff
stage_has_diff = True
else:
stage_result["shader"] = "SAME"
# ShaderReflection
refl_diff = _diff_dicts(
sa.get("ShaderReflection"),
sb.get("ShaderReflection"),
)
if refl_diff:
stage_result["ShaderReflection"] = refl_diff
stage_has_diff = True
else:
stage_result["ShaderReflection"] = "SAME"
# bindings
bindings_diff = _diff_bindings(
sa.get("bindings", {}),
sb.get("bindings", {}),
)
if bindings_diff:
stage_result["bindings"] = bindings_diff
stage_has_diff = True
else:
stage_result["bindings"] = "SAME"
if stage_has_diff:
result[name] = stage_result
has_diff = True
else:
result[name] = "SAME"
return result if has_diff else None
# ---------------------------------------------------------------------------
# Core diff from two snapshot dicts
# ---------------------------------------------------------------------------
def _diff_from_snapshots(snap_a: Dict[str, Any], snap_b: Dict[str, Any]) -> Dict[str, Any]:
"""Shared implementation: diff two dump_pipeline_for_diff snapshots."""
ps_a = snap_a.get("PipelineState", {})
ps_b = snap_b.get("PipelineState", {})
result: Dict[str, Any] = {
"eventA": snap_a.get("eventId"),
"eventB": snap_b.get("eventId"),
}
has_diff = False
# pipelineType
pt_a = ps_a.get("pipelineType")
pt_b = ps_b.get("pipelineType")
if pt_a != pt_b:
result["pipelineType"] = {"A": pt_a, "B": pt_b}
has_diff = True
else:
result["pipelineType"] = pt_a
# Simple sections: vertexInputs, outputTargets, depthTarget, viewport,
# rasterizer, depthStencil
for section, key_field in [
("vertexInputs", "name"),
("outputTargets", "index"),
]:
d = _diff_lists(
ps_a.get(section, []),
ps_b.get(section, []),
key_field=key_field,
)
if d:
result[section] = d
has_diff = True
else:
result[section] = "SAME"
for section in ("depthTarget", "viewport", "rasterizer", "depthStencil"):
d = _diff_dicts(ps_a.get(section), ps_b.get(section))
if d:
result[section] = d
has_diff = True
else:
result[section] = "SAME"
# blend — nested: top-level dict keys + blends list
blend_a = ps_a.get("blend")
blend_b = ps_b.get("blend")
if blend_a is None and blend_b is None:
result["blend"] = "SAME"
elif blend_a is None or blend_b is None:
result["blend"] = {"A": blend_a, "B": blend_b}
has_diff = True
else:
blend_diff: Dict[str, Any] = {}
blend_has_diff = False
# Top-level scalar keys
for k in sorted(set(list(blend_a.keys()) + list(blend_b.keys()))):
if k == "blends":
continue
va = blend_a.get(k)
vb = blend_b.get(k)
if not _values_equal(va, vb):
blend_diff[k] = {"A": va, "B": vb}
blend_has_diff = True
# blends list
blends_diff = _diff_lists(
blend_a.get("blends", []),
blend_b.get("blends", []),
key_field="index",
)
if blends_diff:
blend_diff["blends"] = blends_diff
blend_has_diff = True
if blend_has_diff:
result["blend"] = blend_diff
has_diff = True
else:
result["blend"] = "SAME"
# stages
stages_diff = _diff_stages(
ps_a.get("stages", {}),
ps_b.get("stages", {}),
)
if stages_diff:
result["stages"] = stages_diff
has_diff = True
else:
result["stages"] = "SAME"
result["identical"] = not has_diff
return result
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def diff_pipeline(
controller_a,
event_a: int,
controller_b,
event_b: int,
) -> Dict[str, Any]:
"""Compare full pipeline state at two events (possibly from different captures).
Returns a dict containing only the dimensions that differ.
Each section is either omitted (identical) or marked "SAME".
"""
snap_a = dump_pipeline_for_diff(controller_a, event_a)
snap_b = dump_pipeline_for_diff(controller_b, event_b)
return _diff_from_snapshots(snap_a, snap_b)
def diff_pipeline_from_snapshots(
snap_a: Dict[str, Any],
snap_b: Dict[str, Any],
) -> Dict[str, Any]:
"""Compare two pre-built pipeline snapshots (for testing or offline use).
Expects snapshots in the ``dump_pipeline_for_diff`` format::
{"eventId": ..., "PipelineState": { ... }}
Same logic as diff_pipeline but without needing live controllers.
"""
return _diff_from_snapshots(snap_a, snap_b)
@@ -0,0 +1,176 @@
"""
Mesh data decoding: vertex inputs, post-VS outputs, index buffers.
"""
from __future__ import annotations
import struct as _struct
from typing import Any, Dict, List, Optional
import click
try:
import renderdoc as rd
HAS_RD = True
except ImportError:
rd = None # type: ignore[assignment]
HAS_RD = False
def _unpack_data(fmt, data):
"""Unpack vertex data according to resource format."""
if fmt.Special():
return None # packed formats not supported
format_chars = {}
format_chars[rd.CompType.UInt] = "xBHxIxxxL"
format_chars[rd.CompType.SInt] = "xbhxixxxl"
format_chars[rd.CompType.Float] = "xxexfxxxd"
format_chars[rd.CompType.UNorm] = format_chars[rd.CompType.UInt]
format_chars[rd.CompType.UScaled] = format_chars[rd.CompType.UInt]
format_chars[rd.CompType.SNorm] = format_chars[rd.CompType.SInt]
format_chars[rd.CompType.SScaled] = format_chars[rd.CompType.SInt]
vertex_format = str(fmt.compCount) + format_chars[fmt.compType][fmt.compByteWidth]
value = _struct.unpack_from(vertex_format, data, 0)
if fmt.compType == rd.CompType.UNorm:
divisor = float((2 ** (fmt.compByteWidth * 8)) - 1)
value = tuple(float(i) / divisor for i in value)
elif fmt.compType == rd.CompType.SNorm:
max_neg = -float(2 ** (fmt.compByteWidth * 8)) / 2
divisor = float(-(max_neg - 1))
value = tuple(
(float(i) if (i == max_neg) else (float(i) / divisor)) for i in value
)
if fmt.BGRAOrder():
value = tuple(value[i] for i in [2, 1, 0, 3])
return value
def get_mesh_inputs(controller, event_id: int, max_vertices: int = 100) -> Dict[str, Any]:
"""Get vertex shader input data at a draw call.
Returns decoded vertex data for up to *max_vertices* vertices.
"""
controller.SetFrameEvent(event_id, True)
state = controller.GetPipelineState()
ib = state.GetIBuffer()
vbs = state.GetVBuffers()
attrs = state.GetVertexInputs()
# get draw info
action = None
for a in _flatten(controller.GetRootActions()):
if a.eventId == event_id:
action = a
break
if action is None:
return {"error": f"No action at event {event_id}"}
# Decode indices
indices = _get_indices(controller, ib, action)
num = min(len(indices), max_vertices)
attributes = []
for attr in attrs:
attr_data = {
"name": str(attr.name),
"format": str(attr.format),
"vertices": [],
}
if attr.perInstance:
attr_data["perInstance"] = True
attributes.append(attr_data)
continue
vb = vbs[attr.vertexBuffer]
for i in range(num):
idx = indices[i]
offset = (
attr.byteOffset
+ vb.byteOffset
+ (idx + action.vertexOffset) * vb.byteStride
)
data = controller.GetBufferData(vb.resourceId, offset, 64)
try:
val = _unpack_data(attr.format, bytes(data))
attr_data["vertices"].append({"index": idx, "value": list(val) if val else None})
except Exception as e:
attr_data["vertices"].append({"index": idx, "error": str(e)})
attributes.append(attr_data)
return {
"eventId": event_id,
"numIndices": action.numIndices,
"decoded_count": num,
"attributes": attributes,
}
def get_mesh_outputs(controller, event_id: int, max_vertices: int = 100) -> Dict[str, Any]:
"""Get post-vertex-shader output data at a draw call."""
controller.SetFrameEvent(event_id, True)
postvs = controller.GetPostVSData(0, 0, rd.MeshDataStage.VSOut)
if postvs.vertexResourceId == rd.ResourceId.Null():
return {"eventId": event_id, "error": "No post-VS data available"}
vs = controller.GetPipelineState().GetShaderReflection(rd.ShaderStage.Vertex)
if vs is None:
return {"eventId": event_id, "error": "No vertex shader bound"}
outputs = []
for attr in vs.outputSignature:
outputs.append({
"name": attr.semanticIdxName if attr.varName == "" else str(attr.varName),
"compCount": attr.compCount,
"systemValue": str(attr.systemValue),
})
return {
"eventId": event_id,
"numIndices": postvs.numIndices,
"outputs": outputs,
}
def _flatten(actions, out=None):
if out is None:
out = []
for a in actions:
out.append(a)
if len(a.children) > 0:
_flatten(a.children, out)
return out
def _get_indices(controller, ib, action):
"""Decode index buffer."""
if action.flags & rd.ActionFlags.Indexed and ib.resourceId != rd.ResourceId.Null():
if action.numIndices <= 0:
return []
idx_fmt = "B"
if ib.byteStride == 2:
idx_fmt = "H"
elif ib.byteStride == 4:
idx_fmt = "I"
start = ib.byteOffset + action.indexOffset * ib.byteStride
length = action.numIndices * ib.byteStride
ibdata = controller.GetBufferData(ib.resourceId, start, length)
fmt_str = str(action.numIndices) + idx_fmt
try:
indices = _struct.unpack(fmt_str, bytes(ibdata))
return [i + action.baseVertex for i in indices]
except Exception as e:
click.echo(f"Warning: failed to unpack indices: {e}", err=True)
return list(range(action.numIndices))
else:
return list(range(action.numIndices))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,516 @@
"""Preview bundle generation for the RenderDoc harness."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..utils.preview_bundle import (
append_live_trajectory,
artifact_record,
bundle_root,
finalize_bundle,
find_latest_manifest,
fingerprint_data,
fingerprint_file,
prepare_bundle,
write_json,
)
from . import actions as actions_mod
from . import diff as diff_mod
from . import pipeline as pipeline_mod
from . import textures as textures_mod
HARNESS_VERSION = "0.1.0"
RECIPES: Dict[str, Dict[str, Any]] = {
"quick": {
"description": "Capture thumbnail, output targets, and pipeline snapshot",
"max_thumb_dim": 768,
},
}
def list_recipes() -> List[Dict[str, Any]]:
"""Return available preview recipes."""
recipes = [
{
"name": name,
"description": config["description"],
"bundle_kind": "capture",
"artifacts": ["hero", "gallery", "metadata"],
}
for name, config in RECIPES.items()
]
recipes.append(
{
"name": "diff",
"description": "Compare two pipeline states and their output targets",
"bundle_kind": "diff",
"artifacts": ["gallery", "diff", "metadata"],
}
)
return recipes
def _default_event_id(handle) -> Optional[int]:
drawcalls = actions_mod.get_drawcalls_only(handle.controller)
if not drawcalls:
return None
return int(drawcalls[-1]["eventId"])
def _compact_diff(obj: Any) -> Any:
if isinstance(obj, dict):
pruned = {}
for key, value in obj.items():
if value == "SAME":
continue
compacted = _compact_diff(value)
if compacted is not None:
pruned[key] = compacted
return pruned or None
if isinstance(obj, list):
values = [_compact_diff(item) for item in obj if item != "SAME"]
values = [item for item in values if item is not None]
return values or None
return obj
def _count_differences(obj: Any) -> int:
if isinstance(obj, dict):
return sum(_count_differences(value) for value in obj.values()) or len(obj)
if isinstance(obj, list):
return sum(_count_differences(item) for item in obj) or len(obj)
return 1
def _trajectory_dir(capture_path: str, recipe: str, root_dir: Optional[str] = None) -> str:
return str(
bundle_root(
"renderdoc",
recipe,
project_path=capture_path,
root_dir=root_dir,
).resolve()
)
def _attach_trajectory_ref(manifest: Dict[str, Any]) -> Dict[str, Any]:
bundle_dir = manifest.get("_bundle_dir")
context = manifest.get("context") or {}
trajectory_ref = context.get("trajectory_path")
if bundle_dir and trajectory_ref:
trajectory_path = (Path(str(bundle_dir)) / str(trajectory_ref)).resolve()
if trajectory_path.is_file():
manifest["_trajectory_path"] = str(trajectory_path)
return manifest
def capture(
handle,
capture_path: str,
recipe: str = "quick",
*,
event_id: Optional[int] = None,
root_dir: Optional[str] = None,
force: bool = False,
command: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a capture preview bundle for a RenderDoc capture."""
if recipe not in RECIPES:
raise ValueError(
f"Unknown preview recipe: {recipe!r}. Available: {', '.join(sorted(RECIPES))}"
)
config = RECIPES[recipe]
source_fingerprint = fingerprint_file(capture_path)
prepared = prepare_bundle(
software="renderdoc",
recipe=recipe,
bundle_kind="capture",
source_fingerprint=source_fingerprint,
options={"event_id": event_id or "auto", **config},
harness_version=HARNESS_VERSION,
project_path=capture_path,
root_dir=root_dir,
force=force,
)
if prepared["cached"]:
manifest = dict(prepared["manifest"])
manifest["cached"] = True
return _attach_trajectory_ref(manifest)
bundle_dir = prepared["bundle_dir"]
artifacts_dir = prepared["artifacts_dir"]
trajectory_dir = _trajectory_dir(capture_path, recipe, root_dir=root_dir)
trajectory_rel = os.path.relpath(Path(trajectory_dir) / "trajectory.json", Path(bundle_dir))
warnings: List[str] = []
artifacts: List[Dict[str, Any]] = []
metadata = handle.metadata()
action_summary = actions_mod.action_summary(handle.controller)
hero_path = os.path.join(artifacts_dir, "hero.png")
thumb_result = handle.thumbnail(hero_path, config["max_thumb_dim"])
if thumb_result.get("error"):
warnings.append(str(thumb_result["error"]))
elif os.path.isfile(hero_path):
artifacts.append(
artifact_record(
bundle_dir,
hero_path,
artifact_id="hero",
role="hero",
kind="image",
label="Capture thumbnail",
renderdoc_format=thumb_result.get("format"),
)
)
chosen_event = event_id or _default_event_id(handle)
output_count = 0
if chosen_event is not None:
outputs_dir = os.path.join(artifacts_dir, "outputs")
output_results = textures_mod.save_action_outputs(handle.controller, chosen_event, outputs_dir, file_format="png")
for index, item in enumerate(output_results):
if item.get("error") or not item.get("path") or not os.path.isfile(item["path"]):
warnings.append(item.get("error", f"missing output target {index}"))
continue
output_count += 1
artifacts.append(
artifact_record(
bundle_dir,
item["path"],
artifact_id=f"output_{index:02d}",
role="gallery",
kind="image",
label=item.get("label", f"Output {index}"),
)
)
pipeline_state = pipeline_mod.get_pipeline_state(handle.controller, chosen_event)
pipeline_path = os.path.join(artifacts_dir, "pipeline_state.json")
write_json(pipeline_path, pipeline_state)
artifacts.append(
artifact_record(
bundle_dir,
pipeline_path,
artifact_id="pipeline_state",
role="metadata",
kind="json",
label=f"Pipeline state at event {chosen_event}",
media_type="application/json",
)
)
else:
warnings.append("No drawcall event found; skipped output-target and pipeline capture.")
summary_path = os.path.join(artifacts_dir, "action_summary.json")
write_json(summary_path, action_summary)
artifacts.append(
artifact_record(
bundle_dir,
summary_path,
artifact_id="action_summary",
role="metadata",
kind="json",
label="Action summary",
media_type="application/json",
)
)
summary = {
"headline": f"RenderDoc preview captured from {os.path.basename(capture_path)}",
"facts": {
"recipe": recipe,
"api": metadata.get("api"),
"event_id": chosen_event,
"drawcalls": action_summary.get("drawcalls", 0),
"output_targets": output_count,
},
"warnings": warnings,
"next_actions": [
"Inspect the hero thumbnail for a quick capture-level sanity check.",
"Inspect output targets and pipeline_state.json for the selected event.",
],
}
manifest = finalize_bundle(
bundle_dir=bundle_dir,
bundle_id=prepared["bundle_id"],
bundle_kind="capture",
software="renderdoc",
recipe=recipe,
source={
"capture_path": os.path.abspath(capture_path),
"capture_name": os.path.basename(capture_path),
"capture_fingerprint": source_fingerprint,
},
artifacts=artifacts,
summary=summary,
cache_key=prepared["cache_key"],
generator={
"entry_point": "cli-anything-renderdoc",
"harness_version": HARNESS_VERSION,
"command": command or f"cli-anything-renderdoc -c {capture_path} preview capture --recipe {recipe}",
},
status="partial" if warnings else "ok",
warnings=warnings or None,
context={"event_id": chosen_event, "trajectory_path": trajectory_rel},
metrics={
"drawcalls": action_summary.get("drawcalls", 0),
"output_targets": output_count,
},
labels=["gpu", "capture", "preview"],
)
trajectory = append_live_trajectory(
trajectory_dir,
software="renderdoc",
recipe=recipe,
bundle_manifest=manifest,
publish_reason="capture",
project_path=os.path.abspath(capture_path),
project_name=os.path.basename(capture_path),
session_name=f"{os.path.splitext(os.path.basename(capture_path))[0]}-{recipe}",
command=command,
stage_label=f"event-{chosen_event}" if chosen_event is not None else "capture",
note=f"RenderDoc capture preview for event {chosen_event}" if chosen_event is not None else None,
)
manifest["_trajectory_path"] = trajectory["_trajectory_path"]
manifest["cached"] = False
return manifest
def diff(
handle_a,
capture_path_a: str,
event_a: int,
handle_b,
capture_path_b: str,
event_b: int,
*,
compact: bool = True,
root_dir: Optional[str] = None,
force: bool = False,
command: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a diff preview bundle for two RenderDoc events."""
source_fingerprint = fingerprint_data(
{
"capture_a": fingerprint_file(capture_path_a),
"event_a": event_a,
"capture_b": fingerprint_file(capture_path_b),
"event_b": event_b,
}
)
prepared = prepare_bundle(
software="renderdoc",
recipe="diff",
bundle_kind="diff",
source_fingerprint=source_fingerprint,
options={"compact": compact, "event_a": event_a, "event_b": event_b},
harness_version=HARNESS_VERSION,
project_path=capture_path_a,
root_dir=root_dir,
force=force,
)
if prepared["cached"]:
manifest = dict(prepared["manifest"])
manifest["cached"] = True
return _attach_trajectory_ref(manifest)
bundle_dir = prepared["bundle_dir"]
artifacts_dir = prepared["artifacts_dir"]
trajectory_dir = _trajectory_dir(capture_path_a, "diff", root_dir=root_dir)
trajectory_rel = os.path.relpath(Path(trajectory_dir) / "trajectory.json", Path(bundle_dir))
warnings: List[str] = []
artifacts: List[Dict[str, Any]] = []
thumb_a = os.path.join(artifacts_dir, "capture_a_thumb.png")
thumb_a_result = handle_a.thumbnail(thumb_a, 512)
if not thumb_a_result.get("error") and os.path.isfile(thumb_a):
artifacts.append(
artifact_record(
bundle_dir,
thumb_a,
artifact_id="capture_a_thumb",
role="gallery",
kind="image",
label=f"{os.path.basename(capture_path_a)} thumbnail",
)
)
thumb_b = os.path.join(artifacts_dir, "capture_b_thumb.png")
thumb_b_result = handle_b.thumbnail(thumb_b, 512)
if not thumb_b_result.get("error") and os.path.isfile(thumb_b):
artifacts.append(
artifact_record(
bundle_dir,
thumb_b,
artifact_id="capture_b_thumb",
role="gallery",
kind="image",
label=f"{os.path.basename(capture_path_b)} thumbnail",
)
)
outputs_a = textures_mod.save_action_outputs(
handle_a.controller,
event_a,
os.path.join(artifacts_dir, "outputs_a"),
file_format="png",
)
outputs_b = textures_mod.save_action_outputs(
handle_b.controller,
event_b,
os.path.join(artifacts_dir, "outputs_b"),
file_format="png",
)
for side, output_results in (("A", outputs_a), ("B", outputs_b)):
for index, item in enumerate(output_results):
if item.get("error") or not item.get("path") or not os.path.isfile(item["path"]):
warnings.append(item.get("error", f"missing output target {side}{index}"))
continue
artifacts.append(
artifact_record(
bundle_dir,
item["path"],
artifact_id=f"{side.lower()}_output_{index:02d}",
role="gallery",
kind="image",
label=f"{side} {item.get('label', f'Output {index}')}",
)
)
diff_data = diff_mod.diff_pipeline(handle_a.controller, event_a, handle_b.controller, event_b)
if compact:
diff_data = _compact_diff(diff_data) or {}
diff_path = os.path.join(artifacts_dir, "pipeline_diff.json")
write_json(diff_path, diff_data)
artifacts.append(
artifact_record(
bundle_dir,
diff_path,
artifact_id="pipeline_diff",
role="diff",
kind="json",
label="Pipeline diff",
media_type="application/json",
)
)
pipeline_a = pipeline_mod.get_pipeline_state(handle_a.controller, event_a)
pipeline_a_path = os.path.join(artifacts_dir, "pipeline_a.json")
write_json(pipeline_a_path, pipeline_a)
artifacts.append(
artifact_record(
bundle_dir,
pipeline_a_path,
artifact_id="pipeline_a",
role="metadata",
kind="json",
label=f"Pipeline state A at event {event_a}",
media_type="application/json",
)
)
pipeline_b = pipeline_mod.get_pipeline_state(handle_b.controller, event_b)
pipeline_b_path = os.path.join(artifacts_dir, "pipeline_b.json")
write_json(pipeline_b_path, pipeline_b)
artifacts.append(
artifact_record(
bundle_dir,
pipeline_b_path,
artifact_id="pipeline_b",
role="metadata",
kind="json",
label=f"Pipeline state B at event {event_b}",
media_type="application/json",
)
)
diff_count = _count_differences(diff_data)
summary = {
"headline": f"RenderDoc diff bundle created for events {event_a} vs {event_b}",
"facts": {
"event_a": event_a,
"event_b": event_b,
"capture_a": os.path.basename(capture_path_a),
"capture_b": os.path.basename(capture_path_b),
"difference_count": diff_count,
},
"warnings": warnings,
"next_actions": [
"Inspect pipeline_diff.json for resource, shader, and state changes.",
"Compare A/B output-target images for visible regressions.",
],
}
manifest = finalize_bundle(
bundle_dir=bundle_dir,
bundle_id=prepared["bundle_id"],
bundle_kind="diff",
software="renderdoc",
recipe="diff",
source={
"capture_path": os.path.abspath(capture_path_a),
"capture_name": os.path.basename(capture_path_a),
"capture_fingerprint": fingerprint_file(capture_path_a),
},
artifacts=artifacts,
summary=summary,
cache_key=prepared["cache_key"],
generator={
"entry_point": "cli-anything-renderdoc",
"harness_version": HARNESS_VERSION,
"command": command
or f"cli-anything-renderdoc -c {capture_path_a} preview diff {event_a} {event_b} --capture-b {capture_path_b}",
},
status="partial" if warnings else "ok",
warnings=warnings or None,
context={"event_a": event_a, "event_b": event_b, "trajectory_path": trajectory_rel},
metrics={"difference_count": diff_count},
labels=["gpu", "capture", "diff", "preview"],
source_bundles=[
{"capture_path": os.path.abspath(capture_path_a), "event_id": event_a},
{"capture_path": os.path.abspath(capture_path_b), "event_id": event_b},
],
)
trajectory = append_live_trajectory(
trajectory_dir,
software="renderdoc",
recipe="diff",
bundle_manifest=manifest,
publish_reason="diff",
project_path=os.path.abspath(capture_path_a),
project_name=os.path.basename(capture_path_a),
session_name=f"{os.path.splitext(os.path.basename(capture_path_a))[0]}-diff",
command=command,
stage_label=f"diff-{event_a}-vs-{event_b}",
note=f"Pipeline diff for events {event_a} vs {event_b}",
)
manifest["_trajectory_path"] = trajectory["_trajectory_path"]
manifest["cached"] = False
return manifest
def latest(
*,
project_path: Optional[str] = None,
recipe: Optional[str] = None,
bundle_kind: Optional[str] = None,
root_dir: Optional[str] = None,
) -> Dict[str, Any]:
"""Return the latest preview bundle manifest for RenderDoc."""
manifest = find_latest_manifest(
software="renderdoc",
recipe=recipe,
bundle_kind=bundle_kind,
project_path=project_path,
root_dir=root_dir,
)
if manifest is None:
raise FileNotFoundError("No RenderDoc preview bundle found")
return _attach_trajectory_ref(manifest)
@@ -0,0 +1,90 @@
"""
Resource inspection: list buffers, get buffer data, enumerate all resources.
"""
from __future__ import annotations
import struct as _struct
from typing import Any, Dict, List, Optional
try:
import renderdoc as rd
HAS_RD = True
except ImportError:
rd = None # type: ignore[assignment]
HAS_RD = False
def list_resources(controller) -> List[Dict[str, Any]]:
"""List all resources in the capture."""
resources = controller.GetResources()
return [
{
"resourceId": str(r.resourceId),
"name": str(r.name),
"type": str(r.type),
}
for r in resources
]
def list_buffers(controller) -> List[Dict[str, Any]]:
"""List all buffer resources."""
buffers = controller.GetBuffers()
return [
{
"resourceId": str(b.resourceId),
"length": b.length,
"creationFlags": int(b.creationFlags),
}
for b in buffers
]
def get_buffer_data(
controller,
resource_id_str: str,
offset: int = 0,
length: int = 0,
fmt: str = "hex",
) -> Dict[str, Any]:
"""Read raw buffer data.
Parameters
----------
fmt : str
'hex' returns hex string, 'float32' unpacks as floats,
'uint32' unpacks as unsigned ints, 'raw' returns byte list.
"""
buf_id = None
for b in controller.GetBuffers():
if str(b.resourceId) == resource_id_str:
buf_id = b.resourceId
break
if buf_id is None:
return {"error": f"Buffer {resource_id_str} not found"}
data = controller.GetBufferData(buf_id, offset, length)
raw_bytes = bytes(data)
result: Dict[str, Any] = {
"resourceId": resource_id_str,
"offset": offset,
"length": len(raw_bytes),
}
if fmt == "hex":
result["data"] = raw_bytes.hex()
elif fmt == "float32":
count = len(raw_bytes) // 4
result["data"] = list(_struct.unpack(f"<{count}f", raw_bytes[: count * 4]))
elif fmt == "uint32":
count = len(raw_bytes) // 4
result["data"] = list(_struct.unpack(f"<{count}I", raw_bytes[: count * 4]))
elif fmt == "raw":
result["data"] = list(raw_bytes)
else:
result["data"] = raw_bytes.hex()
return result
@@ -0,0 +1,222 @@
"""
Texture inspection and export.
List all textures in a capture, inspect individual texture metadata,
pick pixel values, and save textures to disk in various formats.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
try:
import renderdoc as rd
HAS_RD = True
except ImportError:
rd = None # type: ignore[assignment]
HAS_RD = False
# ---------------------------------------------------------------------------
# Texture enumeration
# ---------------------------------------------------------------------------
def _tex_to_dict(tex) -> Dict[str, Any]:
"""Serialise TextureDescription to a plain dict."""
return {
"resourceId": str(tex.resourceId),
"name": str(getattr(tex, "name", "")),
"width": tex.width,
"height": tex.height,
"depth": tex.depth,
"mips": tex.mips,
"arraysize": tex.arraysize,
"msQual": tex.msQual,
"msSamp": tex.msSamp,
"format": str(tex.format),
"dimension": tex.dimension,
"type": str(tex.type) if hasattr(tex, "type") else str(tex.dimension),
"cubemap": getattr(tex, "cubemap", False),
"byteSize": getattr(tex, "byteSize", 0),
"creationFlags": int(getattr(tex, "creationFlags", 0)),
}
def list_textures(controller) -> List[Dict[str, Any]]:
"""Return all textures in the capture."""
textures = controller.GetTextures()
return [_tex_to_dict(t) for t in textures]
def get_texture(controller, resource_id_str: str) -> Optional[Dict[str, Any]]:
"""Get a single texture by resource ID string."""
for tex in controller.GetTextures():
if str(tex.resourceId) == resource_id_str:
return _tex_to_dict(tex)
return None
# ---------------------------------------------------------------------------
# Pixel picking
# ---------------------------------------------------------------------------
def pick_pixel(
controller,
resource_id_str: str,
x: int,
y: int,
mip: int = 0,
slice_idx: int = 0,
sample: int = 0,
) -> Dict[str, Any]:
"""Pick a pixel value from a texture.
Returns dict with float, uint, and int value representations.
"""
# Find the resource ID
tex_id = None
for tex in controller.GetTextures():
if str(tex.resourceId) == resource_id_str:
tex_id = tex.resourceId
break
if tex_id is None:
return {"error": f"Texture {resource_id_str} not found"}
sub = rd.Subresource(mip, slice_idx, sample)
pix = controller.PickPixel(tex_id, x, y, sub, rd.CompType.Typeless)
return {
"x": x,
"y": y,
"resourceId": resource_id_str,
"float": list(pix.floatValue),
"uint": list(pix.uintValue),
"int": list(pix.intValue),
}
# ---------------------------------------------------------------------------
# Texture export
# ---------------------------------------------------------------------------
_FORMAT_MAP = {}
if HAS_RD and hasattr(rd, "FileType"):
_FORMAT_MAP = {
"png": rd.FileType.PNG,
"jpg": rd.FileType.JPG,
"jpeg": rd.FileType.JPG,
"bmp": rd.FileType.BMP,
"tga": rd.FileType.TGA,
"hdr": rd.FileType.HDR,
"exr": rd.FileType.EXR,
"dds": rd.FileType.DDS,
}
def save_texture(
controller,
resource_id_str: str,
output_path: str,
file_format: str = "png",
mip: int = 0,
slice_idx: int = 0,
alpha: str = "preserve",
) -> Dict[str, Any]:
"""Save a texture to disk.
Parameters
----------
controller : ReplayController
resource_id_str : str
The resource ID as a string.
output_path : str
Destination file path.
file_format : str
One of: png, jpg, bmp, tga, hdr, exr, dds
mip : int
Mip level to save (-1 for all, DDS only).
slice_idx : int
Array slice to save (-1 for all, DDS only).
alpha : str
Alpha handling: 'preserve', 'discard', 'blend_checkerboard'
"""
fmt_lower = file_format.lower()
if fmt_lower not in _FORMAT_MAP:
return {"error": f"Unsupported format: {file_format}. Use: {list(_FORMAT_MAP.keys())}"}
tex_id = None
for tex in controller.GetTextures():
if str(tex.resourceId) == resource_id_str:
tex_id = tex.resourceId
break
if tex_id is None:
return {"error": f"Texture {resource_id_str} not found"}
save = rd.TextureSave()
save.resourceId = tex_id
save.destType = _FORMAT_MAP[fmt_lower]
save.mip = mip
save.slice.sliceIndex = slice_idx
alpha_lower = alpha.lower()
if alpha_lower == "preserve":
save.alpha = rd.AlphaMapping.Preserve
elif alpha_lower == "discard":
save.alpha = rd.AlphaMapping.Discard
elif alpha_lower in ("blend", "blend_checkerboard", "checkerboard"):
save.alpha = rd.AlphaMapping.BlendToCheckerboard
else:
save.alpha = rd.AlphaMapping.Preserve
output_path = os.path.abspath(output_path)
controller.SaveTexture(save, output_path)
if os.path.isfile(output_path):
return {
"path": output_path,
"format": fmt_lower,
"size": os.path.getsize(output_path),
}
return {"error": "Failed to save texture (file not created)"}
def save_action_outputs(
controller,
event_id: int,
output_dir: str,
file_format: str = "png",
) -> List[Dict[str, Any]]:
"""Save all render target outputs at a specific event.
Moves the replay to *event_id*, then saves each colour output and
the depth output (if any) to *output_dir*.
"""
controller.SetFrameEvent(event_id, True)
state = controller.GetPipelineState()
targets = state.GetOutputTargets()
depth = state.GetDepthTarget()
results = []
os.makedirs(output_dir, exist_ok=True)
for i, t in enumerate(targets):
if t.resourceId == rd.ResourceId.Null():
continue
rid = str(t.resourceId)
fname = f"event{event_id}_rt{i}.{file_format}"
path = os.path.join(output_dir, fname)
r = save_texture(controller, rid, path, file_format)
r["label"] = f"RT{i}"
results.append(r)
if depth.resourceId != rd.ResourceId.Null():
rid = str(depth.resourceId)
fname = f"event{event_id}_depth.{file_format}"
path = os.path.join(output_dir, fname)
r = save_texture(controller, rid, path, file_format)
r["label"] = "Depth"
results.append(r)
return results
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
---
name: cli-anything-renderdoc
description: CLI harness for RenderDoc graphics debugger capture analysis
version: 0.1.0
command: cli-anything-renderdoc
install: pip install cli-anything-renderdoc
requires:
- renderdoc (Python bindings from RenderDoc installation)
- click>=8.0
- prompt-toolkit>=3.0
categories:
- graphics
- debugging
- gpu
- rendering
---
# RenderDoc CLI Skill
Headless command-line analysis of RenderDoc GPU frame captures (`.rdc` files).
## Capabilities
- **Capture inspection**: metadata, sections, thumbnails, format conversion
- **Action tree**: list/search/filter draw calls, clears, dispatches, markers
- **Texture operations**: list, inspect, export (PNG/JPG/DDS/HDR/EXR), pixel picking
- **Pipeline state**: full shader/RT/viewport state at any event
- **Shader analysis**: export shader in human-readable form (HLSL/GLSL/disasm), constant buffer readback
- **Resource inspection**: buffer/texture enumeration, raw data reading
- **Mesh data**: vertex shader input/output decoding
- **GPU counters**: enumerate and fetch hardware performance counters
## Command Groups
### capture
```bash
cli-anything-renderdoc -c frame.rdc capture info # Metadata + sections
cli-anything-renderdoc -c frame.rdc capture thumb -o t.png # Extract thumbnail
cli-anything-renderdoc -c frame.rdc capture convert -o out.rdc --format rdc
```
### actions
```bash
cli-anything-renderdoc -c frame.rdc actions list # All actions
cli-anything-renderdoc -c frame.rdc actions list --draws-only # Draw calls only
cli-anything-renderdoc -c frame.rdc actions summary # Counts by type
cli-anything-renderdoc -c frame.rdc actions find "Shadow" # Search by name
cli-anything-renderdoc -c frame.rdc actions get 42 # Single action
```
### textures
```bash
cli-anything-renderdoc -c frame.rdc textures list
cli-anything-renderdoc -c frame.rdc textures get <id>
cli-anything-renderdoc -c frame.rdc textures save <id> -o out.png --format png
cli-anything-renderdoc -c frame.rdc textures save-outputs 42 -o ./renders/
cli-anything-renderdoc -c frame.rdc textures pick <id> 100 200
```
### pipeline
```bash
cli-anything-renderdoc -c frame.rdc pipeline state 42
# Export shader in human-readable form
# Text shaders (GLSL/HLSL) → saved directly
# Binary shaders (DXBC/SPIR-V) → embedded source (HLSL/GLSL) or disassembly
cli-anything-renderdoc -c frame.rdc pipeline shader-export 42 --stage Fragment
cli-anything-renderdoc -c frame.rdc pipeline shader-export 42 --stage Vertex -o ./shaders/
cli-anything-renderdoc -c frame.rdc pipeline cbuffer 42 --stage Vertex --index 0
# Compare pipeline state between two events
# Default output: same directory as the capture file ; use -o to override
cli-anything-renderdoc -c a.rdc pipeline diff 100 200 -b b.rdc
cli-anything-renderdoc -c frame.rdc pipeline diff 100 200 # same capture
cli-anything-renderdoc -c a.rdc pipeline diff 100 200 -b b.rdc -o result.json
cli-anything-renderdoc -c a.rdc pipeline diff 100 200 -b b.rdc --no-compact
```
### resources
```bash
cli-anything-renderdoc -c frame.rdc resources list
cli-anything-renderdoc -c frame.rdc resources buffers
cli-anything-renderdoc -c frame.rdc resources read-buffer <id> --format float32
```
### mesh
```bash
cli-anything-renderdoc -c frame.rdc mesh inputs 42 --max-vertices 10
cli-anything-renderdoc -c frame.rdc mesh outputs 42
```
### counters
```bash
cli-anything-renderdoc -c frame.rdc counters list
cli-anything-renderdoc -c frame.rdc counters fetch --ids 1,2,3
```
## Preview Bundles
RenderDoc preview support is for truthful capture inspection rather than live
creative preview.
```bash
# List preview recipes
cli-anything-renderdoc -c frame.rdc --json preview recipes
# Capture a preview bundle for the active capture or a specific event
cli-anything-renderdoc -c frame.rdc --json preview capture --recipe quick --event-id 42
# Capture a diff preview bundle for two events or captures
cli-anything-renderdoc -c frame.rdc --json preview diff 100 200
cli-anything-renderdoc -c a.rdc --json preview diff 100 200 --capture-b b.rdc
# Return the latest existing bundle for the capture
cli-anything-renderdoc -c frame.rdc --json preview latest --recipe quick
```
Typical preview bundle contents:
- capture thumbnail
- output-target images
- `pipeline_state.json`
- `action_summary.json`
Diff bundles additionally include:
- A/B thumbnails
- A/B output-target images
- `pipeline_diff.json`
RenderDoc does not currently expose live preview sessions. Instead, each
capture/diff bundle appends to a stable recipe-level `trajectory.json`.
Viewer commands:
```bash
cli-hub previews inspect /path/to/bundle
cli-hub previews html /path/to/bundle -o page.html
cli-hub previews open /path/to/bundle
```
## JSON Mode
All commands support `--json` for machine-readable output:
```bash
cli-anything-renderdoc -c frame.rdc --json actions summary
```
## Environment Variables
| Variable | Description |
|----------------------|------------------------------|
| `RENDERDOC_CAPTURE` | Default capture file path |
| `PYTHONPATH` | Must include RenderDoc path |
## Agent Usage Notes
- **Use `pipeline shader-export` to extract shaders** — for binary shaders (DXBC/SPIR-V) it auto-exports embedded HLSL/GLSL source or falls back to disassembly; for text shaders (GLSL/HLSL) it saves the raw source directly
- **Shader formats by capture API**:
- D3D11 → DXBC binary, exported as embedded HLSL source (`.hlsl`) or bytecode asm (`.dxbc.asm`)
- OpenGL/GLES → GLSL source text (`.glsl`), already human-readable
- Vulkan → SPIR-V binary, exported as embedded GLSL source (`.glsl`) or SPIR-V asm (`.spv.asm`)
- **Use `pipeline diff` to compare two events** — it writes a JSON file and prints only the path; use `-b` for a second capture
- Always specify `--json` for programmatic consumption
- Use `actions summary` first to understand capture complexity
- Use `actions list --draws-only` to focus on actual rendering
- Use `preview capture` or `preview diff` when the agent needs a portable,
honest inspection bundle rather than raw replay calls alone
- Read `_trajectory_path` from preview JSON if you need persistent capture/diff history
- Use `cli-hub previews ...` only to inspect/open already-generated bundles
- Pipeline state requires an event ID from the action list
- Texture save supports: png, jpg, bmp, tga, hdr, exr, dds
- Buffer data can be decoded as hex, float32, uint32, or raw bytes
@@ -0,0 +1,109 @@
# TEST.md RenderDoc CLI Test Plan & Results
## Test Strategy
### Unit Tests (`test_core.py`)
Mock-based tests for all core modules. No dependency on the `renderdoc` Python
module or actual `.rdc` capture files. Tests synthetic data paths.
### E2E Tests (`test_full_e2e.py`)
Full integration tests requiring:
1. RenderDoc installed with Python bindings accessible
2. A `.rdc` capture file (set via `RENDERDOC_TEST_CAPTURE` env var)
Skips gracefully if either prerequisite is missing.
## Running Tests
```bash
# Unit tests (always runnable)
cd renderdoc/agent-harness
pytest cli_anything/renderdoc/tests/test_core.py -v
# E2E tests (requires RenderDoc + capture file)
RENDERDOC_TEST_CAPTURE=/path/to/capture.rdc pytest cli_anything/renderdoc/tests/test_full_e2e.py -v
# All tests
pytest cli_anything/renderdoc/tests/ -v
```
## Test Coverage
| Module | Tests | Status |
|------------------------|-------|--------|
| utils/output.py | 4 | ✅ Pass |
| utils/errors.py | 2 | ✅ Pass |
| core/actions.py | 9 | ✅ Pass |
| core/textures.py | 4 | ✅ Pass |
| core/resources.py | 5 | ✅ Pass |
| core/diff.py | 12 | ✅ Pass |
| CLI help (all groups) | 8 | ✅ Pass |
| CLI subprocess | 1 | ✅ Pass |
| **Total Unit** | **45**| **✅ All Pass** |
| E2E (capture info) | 2 | ⏭️ Skip (no RD) |
| E2E (actions) | 4 | ⏭️ Skip (no RD) |
| E2E (textures) | 2 | ⏭️ Skip (no RD) |
| E2E (resources) | 2 | ⏭️ Skip (no RD) |
| E2E (pipeline) | 2 | ⏭️ Skip (no RD) |
| E2E (counters) | 1 | ⏭️ Skip (no RD) |
| E2E (workflow) | 1 | ⏭️ Skip (no RD) |
| **Total E2E** | **14**| **⏭️ All Skip** |
## Test Results
```
============================= test session starts =============================
platform win32 -- Python 3.10.2, pytest-9.0.2
cli_anything/renderdoc/tests/test_core.py::TestOutputUtils::test_output_json PASSED
cli_anything/renderdoc/tests/test_core.py::TestOutputUtils::test_output_table PASSED
cli_anything/renderdoc/tests/test_core.py::TestOutputUtils::test_output_table_empty PASSED
cli_anything/renderdoc/tests/test_core.py::TestOutputUtils::test_format_size PASSED
cli_anything/renderdoc/tests/test_core.py::TestErrorUtils::test_handle_error PASSED
cli_anything/renderdoc/tests/test_core.py::TestErrorUtils::test_handle_error_debug PASSED
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_decode_flags PASSED
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_decode_flags_multiple PASSED
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_action_to_dict PASSED
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_list_actions_flat PASSED
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_list_actions_root_only PASSED
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_find_actions_by_name PASSED
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_find_action_by_event PASSED
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_get_drawcalls_only PASSED
cli_anything/renderdoc/tests/test_core.py::TestActionsModule::test_action_summary PASSED
cli_anything/renderdoc/tests/test_core.py::TestTexturesModule::test_tex_to_dict PASSED
cli_anything/renderdoc/tests/test_core.py::TestTexturesModule::test_list_textures PASSED
cli_anything/renderdoc/tests/test_core.py::TestTexturesModule::test_get_texture_found PASSED
cli_anything/renderdoc/tests/test_core.py::TestTexturesModule::test_get_texture_not_found PASSED
cli_anything/renderdoc/tests/test_core.py::TestResourcesModule::test_list_resources PASSED
cli_anything/renderdoc/tests/test_core.py::TestResourcesModule::test_list_buffers PASSED
cli_anything/renderdoc/tests/test_core.py::TestResourcesModule::test_get_buffer_data_hex PASSED
cli_anything/renderdoc/tests/test_core.py::TestResourcesModule::test_get_buffer_data_float32 PASSED
cli_anything/renderdoc/tests/test_core.py::TestResourcesModule::test_get_buffer_data_not_found PASSED
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_main_help PASSED
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_capture_help PASSED
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_actions_help PASSED
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_textures_help PASSED
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_pipeline_help PASSED
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_resources_help PASSED
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_mesh_help PASSED
cli_anything/renderdoc/tests/test_core.py::TestCLIHelp::test_counters_help PASSED
cli_anything/renderdoc/tests/test_core.py::TestCLISubprocess::test_cli_help_subprocess PASSED
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_identical_snapshots PASSED
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_different_viewport PASSED
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_float_tolerance PASSED
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_float_nan_equal PASSED
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_diff_lists_only_in_one_side PASSED
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_diff_dicts_missing_key PASSED
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_diff_dicts_identical PASSED
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_diff_dicts_none_inputs PASSED
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_stage_diff_shader_changed PASSED
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_cbuffer_variable_diff PASSED
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_cbuffer_variable_identical PASSED
cli_anything/renderdoc/tests/test_core.py::TestDiffModule::test_output_table_extra_columns PASSED
============================= 45 passed in 0.15s ==============================
cli_anything/renderdoc/tests/test_full_e2e.py - 14 skipped (no RenderDoc)
============================= 59 total, 45 passed, 14 skipped ================
```
@@ -0,0 +1,797 @@
"""
Unit tests for RenderDoc CLI core modules.
These tests use mocks and synthetic data — no renderdoc dependency needed.
Run with: pytest test_core.py -v
"""
from __future__ import annotations
import json
import os
import struct
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch, PropertyMock
import pytest
# ===========================================================================
# Test utils/output.py
# ===========================================================================
class TestOutputUtils:
def test_output_json(self):
from cli_anything.renderdoc.utils.output import output_json
import io
buf = io.StringIO()
output_json({"key": "value", "num": 42}, file=buf)
result = json.loads(buf.getvalue())
assert result["key"] == "value"
assert result["num"] == 42
def test_output_table(self):
from cli_anything.renderdoc.utils.output import output_table
import io
buf = io.StringIO()
output_table(
[["Alice", 30], ["Bob", 25]],
["Name", "Age"],
file=buf,
)
text = buf.getvalue()
assert "Alice" in text
assert "Bob" in text
assert "Name" in text
def test_output_table_empty(self):
from cli_anything.renderdoc.utils.output import output_table
import io
buf = io.StringIO()
output_table([], ["Name"], file=buf)
assert "(no data)" in buf.getvalue()
def test_format_size(self):
from cli_anything.renderdoc.utils.output import format_size
assert format_size(512) == "512 B"
assert "KB" in format_size(2048)
assert "MB" in format_size(2 * 1024 * 1024)
assert "GB" in format_size(3 * 1024 * 1024 * 1024)
# ===========================================================================
# Test utils/errors.py
# ===========================================================================
class TestErrorUtils:
def test_handle_error(self):
from cli_anything.renderdoc.utils.errors import handle_error
result = handle_error(ValueError("test error"))
assert result["error"] == "test error"
assert result["type"] == "ValueError"
assert "traceback" not in result
def test_handle_error_debug(self):
from cli_anything.renderdoc.utils.errors import handle_error
try:
raise RuntimeError("boom")
except RuntimeError as e:
result = handle_error(e, debug=True)
assert "traceback" in result
assert "boom" in result["traceback"]
# ===========================================================================
# Test core/actions.py (with mock rd)
# ===========================================================================
class MockActionFlags:
Clear = 0x0001
Drawcall = 0x0002
Dispatch = 0x0004
CmdList = 0x0008
SetMarker = 0x0010
PushMarker = 0x0020
PopMarker = 0x0040
Present = 0x0080
MultiAction = 0x0100
Copy = 0x0200
Resolve = 0x0400
GenMips = 0x0800
PassBoundary = 0x1000
Indexed = 0x2000
Instanced = 0x4000
Auto = 0x8000
Indirect = 0x10000
ClearColor = 0x20000
ClearDepthStencil = 0x40000
BeginPass = 0x80000
EndPass = 0x100000
def _make_mock_action(event_id, name, flags=0x0002, num_indices=100, children=None):
action = MagicMock()
action.eventId = event_id
action.actionId = event_id
action.customName = name
action.GetName = MagicMock(return_value=name)
action.flags = flags
action.numIndices = num_indices
action.numInstances = 1
action.indexOffset = 0
action.baseVertex = 0
action.vertexOffset = 0
action.instanceOffset = 0
action.outputs = []
action.depthOut = MagicMock()
action.depthOut.__str__ = lambda s: "0"
action.children = children or []
action.next = None
return action
class TestActionsModule:
@patch("cli_anything.renderdoc.core.actions.rd")
def test_decode_flags(self, mock_rd):
# Patch the flag values
mock_rd.ActionFlags = MockActionFlags
from cli_anything.renderdoc.core.actions import _decode_flags
result = _decode_flags(0x0002) # Drawcall
assert "Drawcall" in result
@patch("cli_anything.renderdoc.core.actions.rd")
def test_decode_flags_multiple(self, mock_rd):
mock_rd.ActionFlags = MockActionFlags
from cli_anything.renderdoc.core.actions import _decode_flags
result = _decode_flags(0x0002 | 0x2000) # Drawcall + Indexed
assert "Drawcall" in result
assert "Indexed" in result
@patch("cli_anything.renderdoc.core.actions.rd")
def test_action_to_dict(self, mock_rd):
mock_rd.ActionFlags = MockActionFlags
from cli_anything.renderdoc.core.actions import _action_to_dict
action = _make_mock_action(1, "Draw Triangle", 0x0002)
d = _action_to_dict(action, None)
assert d["eventId"] == 1
assert d["name"] == "Draw Triangle"
assert "Drawcall" in d["flags"]
@patch("cli_anything.renderdoc.core.actions.rd")
def test_list_actions_flat(self, mock_rd):
mock_rd.ActionFlags = MockActionFlags
from cli_anything.renderdoc.core.actions import list_actions
child = _make_mock_action(2, "DrawIndexed", 0x0002)
root = _make_mock_action(1, "RenderPass", 0x0020, children=[child])
controller = MagicMock()
controller.GetRootActions.return_value = [root]
controller.GetStructuredFile.return_value = MagicMock()
result = list_actions(controller, flat=True)
assert len(result) == 2
assert result[0]["eventId"] == 1
assert result[1]["eventId"] == 2
assert result[1]["depth"] == 1
@patch("cli_anything.renderdoc.core.actions.rd")
def test_list_actions_root_only(self, mock_rd):
mock_rd.ActionFlags = MockActionFlags
from cli_anything.renderdoc.core.actions import list_actions
child = _make_mock_action(2, "DrawIndexed")
root = _make_mock_action(1, "RenderPass", 0x0020, children=[child])
controller = MagicMock()
controller.GetRootActions.return_value = [root]
controller.GetStructuredFile.return_value = MagicMock()
result = list_actions(controller, flat=False)
assert len(result) == 1
@patch("cli_anything.renderdoc.core.actions.rd")
def test_find_actions_by_name(self, mock_rd):
mock_rd.ActionFlags = MockActionFlags
from cli_anything.renderdoc.core.actions import find_actions_by_name
a1 = _make_mock_action(1, "Clear RenderTarget", 0x0001)
a2 = _make_mock_action(2, "DrawIndexed(100)", 0x0002)
a3 = _make_mock_action(3, "DrawIndexed(200)", 0x0002)
controller = MagicMock()
controller.GetRootActions.return_value = [a1, a2, a3]
controller.GetStructuredFile.return_value = MagicMock()
result = find_actions_by_name(controller, "drawindex")
assert len(result) == 2
@patch("cli_anything.renderdoc.core.actions.rd")
def test_find_action_by_event(self, mock_rd):
mock_rd.ActionFlags = MockActionFlags
from cli_anything.renderdoc.core.actions import find_action_by_event
a1 = _make_mock_action(10, "Draw", 0x0002)
controller = MagicMock()
controller.GetRootActions.return_value = [a1]
controller.GetStructuredFile.return_value = MagicMock()
result = find_action_by_event(controller, 10)
assert result is not None
assert result["eventId"] == 10
result = find_action_by_event(controller, 999)
assert result is None
@patch("cli_anything.renderdoc.core.actions.rd")
def test_get_drawcalls_only(self, mock_rd):
mock_rd.ActionFlags = MockActionFlags
from cli_anything.renderdoc.core.actions import get_drawcalls_only
a1 = _make_mock_action(1, "Clear", 0x0001) # Clear
a2 = _make_mock_action(2, "Draw", 0x0002) # Drawcall
a3 = _make_mock_action(3, "Marker", 0x0020) # PushMarker
controller = MagicMock()
controller.GetRootActions.return_value = [a1, a2, a3]
controller.GetStructuredFile.return_value = MagicMock()
result = get_drawcalls_only(controller)
assert len(result) == 1
assert result[0]["name"] == "Draw"
@patch("cli_anything.renderdoc.core.actions.rd")
def test_action_summary(self, mock_rd):
mock_rd.ActionFlags = MockActionFlags
from cli_anything.renderdoc.core.actions import action_summary
actions = [
_make_mock_action(1, "Clear", 0x0001),
_make_mock_action(2, "Draw1", 0x0002),
_make_mock_action(3, "Draw2", 0x0002),
_make_mock_action(4, "Dispatch", 0x0004),
_make_mock_action(5, "Copy", 0x0200),
_make_mock_action(6, "Marker", 0x0020),
_make_mock_action(7, "Present", 0x0080),
]
controller = MagicMock()
controller.GetRootActions.return_value = actions
controller.GetStructuredFile.return_value = MagicMock()
result = action_summary(controller)
assert result["total_actions"] == 7
assert result["drawcalls"] == 2
assert result["clears"] == 1
assert result["dispatches"] == 1
assert result["copies"] == 1
assert result["markers"] == 1
assert result["presents"] == 1
# ===========================================================================
# Test core/textures.py (mock-based)
# ===========================================================================
class TestTexturesModule:
def _make_mock_tex(self, rid="123", w=512, h=512, mips=1, fmt="R8G8B8A8_UNORM"):
tex = MagicMock()
tex.resourceId = MagicMock()
tex.resourceId.__str__ = lambda s: rid
tex.name = f"Texture_{rid}"
tex.width = w
tex.height = h
tex.depth = 1
tex.mips = mips
tex.arraysize = 1
tex.msQual = 0
tex.msSamp = 1
tex.format = MagicMock()
tex.format.__str__ = lambda s: fmt
tex.dimension = 2
tex.type = MagicMock()
tex.type.__str__ = lambda s: "Texture2D"
tex.cubemap = False
tex.byteSize = w * h * 4
tex.creationFlags = 0
return tex
def test_tex_to_dict(self):
from cli_anything.renderdoc.core.textures import _tex_to_dict
tex = self._make_mock_tex()
d = _tex_to_dict(tex)
assert d["resourceId"] == "123"
assert d["width"] == 512
assert d["height"] == 512
assert d["mips"] == 1
def test_list_textures(self):
from cli_anything.renderdoc.core.textures import list_textures
controller = MagicMock()
controller.GetTextures.return_value = [
self._make_mock_tex("1", 256, 256),
self._make_mock_tex("2", 1024, 1024),
]
result = list_textures(controller)
assert len(result) == 2
assert result[0]["width"] == 256
assert result[1]["width"] == 1024
def test_get_texture_found(self):
from cli_anything.renderdoc.core.textures import get_texture
controller = MagicMock()
controller.GetTextures.return_value = [
self._make_mock_tex("42", 800, 600),
]
result = get_texture(controller, "42")
assert result is not None
assert result["width"] == 800
def test_get_texture_not_found(self):
from cli_anything.renderdoc.core.textures import get_texture
controller = MagicMock()
controller.GetTextures.return_value = []
result = get_texture(controller, "999")
assert result is None
# ===========================================================================
# Test core/resources.py (mock-based)
# ===========================================================================
class TestResourcesModule:
def test_list_resources(self):
from cli_anything.renderdoc.core.resources import list_resources
r1 = MagicMock()
r1.resourceId = MagicMock()
r1.resourceId.__str__ = lambda s: "1"
r1.name = "Backbuffer"
r1.type = MagicMock()
r1.type.__str__ = lambda s: "Texture"
controller = MagicMock()
controller.GetResources.return_value = [r1]
result = list_resources(controller)
assert len(result) == 1
assert result[0]["name"] == "Backbuffer"
def test_list_buffers(self):
from cli_anything.renderdoc.core.resources import list_buffers
b1 = MagicMock()
b1.resourceId = MagicMock()
b1.resourceId.__str__ = lambda s: "5"
b1.length = 4096
b1.creationFlags = 0
controller = MagicMock()
controller.GetBuffers.return_value = [b1]
result = list_buffers(controller)
assert len(result) == 1
assert result[0]["length"] == 4096
def test_get_buffer_data_hex(self):
from cli_anything.renderdoc.core.resources import get_buffer_data
b1 = MagicMock()
b1.resourceId = MagicMock()
b1.resourceId.__str__ = lambda s: "5"
controller = MagicMock()
controller.GetBuffers.return_value = [b1]
controller.GetBufferData.return_value = b"\x01\x02\x03\x04"
result = get_buffer_data(controller, "5", 0, 4, "hex")
assert result["data"] == "01020304"
assert result["length"] == 4
def test_get_buffer_data_float32(self):
from cli_anything.renderdoc.core.resources import get_buffer_data
b1 = MagicMock()
b1.resourceId = MagicMock()
b1.resourceId.__str__ = lambda s: "5"
controller = MagicMock()
controller.GetBuffers.return_value = [b1]
test_data = struct.pack("<2f", 1.0, 2.5)
controller.GetBufferData.return_value = test_data
result = get_buffer_data(controller, "5", 0, 8, "float32")
assert len(result["data"]) == 2
assert abs(result["data"][0] - 1.0) < 0.001
assert abs(result["data"][1] - 2.5) < 0.001
def test_get_buffer_data_not_found(self):
from cli_anything.renderdoc.core.resources import get_buffer_data
controller = MagicMock()
controller.GetBuffers.return_value = []
result = get_buffer_data(controller, "999", 0, 4, "hex")
assert "error" in result
# ===========================================================================
# Test CLI entry point (Click testing)
# ===========================================================================
class TestCLIHelp:
"""Test that CLI help works without renderdoc installed."""
def test_main_help(self):
from click.testing import CliRunner
from cli_anything.renderdoc.renderdoc_cli import cli
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "RenderDoc CLI" in result.output
def test_capture_help(self):
from click.testing import CliRunner
from cli_anything.renderdoc.renderdoc_cli import cli
runner = CliRunner()
result = runner.invoke(cli, ["capture", "--help"])
assert result.exit_code == 0
assert "info" in result.output
def test_actions_help(self):
from click.testing import CliRunner
from cli_anything.renderdoc.renderdoc_cli import cli
runner = CliRunner()
result = runner.invoke(cli, ["actions", "--help"])
assert result.exit_code == 0
assert "list" in result.output
def test_textures_help(self):
from click.testing import CliRunner
from cli_anything.renderdoc.renderdoc_cli import cli
runner = CliRunner()
result = runner.invoke(cli, ["textures", "--help"])
assert result.exit_code == 0
assert "save" in result.output
def test_pipeline_help(self):
from click.testing import CliRunner
from cli_anything.renderdoc.renderdoc_cli import cli
runner = CliRunner()
result = runner.invoke(cli, ["pipeline", "--help"])
assert result.exit_code == 0
assert "state" in result.output
def test_resources_help(self):
from click.testing import CliRunner
from cli_anything.renderdoc.renderdoc_cli import cli
runner = CliRunner()
result = runner.invoke(cli, ["resources", "--help"])
assert result.exit_code == 0
assert "buffers" in result.output
def test_mesh_help(self):
from click.testing import CliRunner
from cli_anything.renderdoc.renderdoc_cli import cli
runner = CliRunner()
result = runner.invoke(cli, ["mesh", "--help"])
assert result.exit_code == 0
assert "inputs" in result.output
def test_counters_help(self):
from click.testing import CliRunner
from cli_anything.renderdoc.renderdoc_cli import cli
runner = CliRunner()
result = runner.invoke(cli, ["counters", "--help"])
assert result.exit_code == 0
assert "fetch" in result.output
# ===========================================================================
# Test subprocess invocation pattern
# ===========================================================================
class TestCLISubprocess:
"""Test CLI via subprocess from agent-harness root (namespace on cwd)."""
def test_cli_help_subprocess(self):
import subprocess
harness_root = Path(__file__).resolve().parents[3]
try:
result = subprocess.run(
[sys.executable, "-m", "cli_anything.renderdoc.renderdoc_cli", "--help"],
capture_output=True,
text=True,
timeout=10,
cwd=str(harness_root),
)
assert result.returncode == 0
assert "RenderDoc CLI" in result.stdout
except FileNotFoundError:
pytest.skip("CLI not installed")
# ===========================================================================
# Test core/diff.py (snapshot-based, no renderdoc needed)
# ===========================================================================
class TestDiffModule:
"""Unit tests for diff_pipeline_from_snapshots and helpers."""
@staticmethod
def _make_snapshot(event_id, pipeline_state=None):
"""Build a minimal snapshot dict."""
return {
"eventId": event_id,
"PipelineState": pipeline_state or {},
}
def test_identical_snapshots(self):
from cli_anything.renderdoc.core.diff import diff_pipeline_from_snapshots
ps = {
"pipelineType": "Graphics",
"viewport": {"x": 0, "y": 0, "width": 1920, "height": 1080},
"rasterizer": {"fillMode": "Solid"},
"depthStencil": {"depthEnable": True},
"stages": {},
}
snap = self._make_snapshot(100, ps)
result = diff_pipeline_from_snapshots(snap, snap)
assert result["identical"] is True
def test_different_viewport(self):
from cli_anything.renderdoc.core.diff import diff_pipeline_from_snapshots
ps_a = {"viewport": {"x": 0, "y": 0, "width": 1920, "height": 1080}}
ps_b = {"viewport": {"x": 0, "y": 0, "width": 1280, "height": 720}}
result = diff_pipeline_from_snapshots(
self._make_snapshot(1, ps_a),
self._make_snapshot(2, ps_b),
)
assert result["identical"] is False
assert "viewport" in result
assert result["viewport"]["width"]["A"] == 1920
assert result["viewport"]["width"]["B"] == 1280
def test_float_tolerance(self):
from cli_anything.renderdoc.core.diff import _values_equal
assert _values_equal(1.0, 1.0 + 1e-9) is True
assert _values_equal(1.0, 1.1) is False
def test_float_nan_equal(self):
import math
from cli_anything.renderdoc.core.diff import _values_equal
assert _values_equal(float("nan"), float("nan")) is True
assert _values_equal(float("inf"), float("inf")) is True
assert _values_equal(float("inf"), float("-inf")) is False
def test_diff_lists_only_in_one_side(self):
from cli_anything.renderdoc.core.diff import diff_pipeline_from_snapshots
ps_a = {
"vertexInputs": [
{"name": "POSITION", "format": "R32G32B32_FLOAT"},
],
}
ps_b = {
"vertexInputs": [
{"name": "POSITION", "format": "R32G32B32_FLOAT"},
{"name": "TEXCOORD", "format": "R32G32_FLOAT"},
],
}
result = diff_pipeline_from_snapshots(
self._make_snapshot(1, ps_a),
self._make_snapshot(2, ps_b),
)
assert result["identical"] is False
assert isinstance(result["vertexInputs"], list)
statuses = [d["status"] for d in result["vertexInputs"]]
assert "only_in_B" in statuses
def test_diff_dicts_missing_key(self):
from cli_anything.renderdoc.core.diff import _diff_dicts
a = {"x": 1, "y": 2}
b = {"x": 1, "z": 3}
result = _diff_dicts(a, b)
assert result is not None
assert "y" in result
assert "z" in result
def test_diff_dicts_identical(self):
from cli_anything.renderdoc.core.diff import _diff_dicts
a = {"x": 1, "y": 2}
result = _diff_dicts(a, a)
assert result is None
def test_diff_dicts_none_inputs(self):
from cli_anything.renderdoc.core.diff import _diff_dicts
assert _diff_dicts(None, None) is None
result = _diff_dicts(None, {"x": 1})
assert result is not None
assert result["A"] is None
def test_stage_diff_shader_changed(self):
from cli_anything.renderdoc.core.diff import diff_pipeline_from_snapshots
ps_a = {
"stages": {
"Vertex": {
"shader": "ResourceId::100",
"entryPoint": "main",
"ShaderReflection": {},
"bindings": {"constantBlocks": [], "readOnlyResources": [],
"readWriteResources": [], "samplers": []},
},
},
}
ps_b = {
"stages": {
"Vertex": {
"shader": "ResourceId::200",
"entryPoint": "main",
"ShaderReflection": {},
"bindings": {"constantBlocks": [], "readOnlyResources": [],
"readWriteResources": [], "samplers": []},
},
},
}
result = diff_pipeline_from_snapshots(
self._make_snapshot(1, ps_a),
self._make_snapshot(2, ps_b),
)
assert result["identical"] is False
assert result["stages"]["Vertex"]["shader"]["shader"]["A"] == "ResourceId::100"
assert result["stages"]["Vertex"]["shader"]["shader"]["B"] == "ResourceId::200"
def test_cbuffer_variable_diff(self):
from cli_anything.renderdoc.core.diff import _diff_cbuffer_vars
vars_a = [
{"name": "color", "values": [1.0, 0.0, 0.0, 1.0]},
{"name": "intensity", "values": [0.5]},
]
vars_b = [
{"name": "color", "values": [0.0, 1.0, 0.0, 1.0]},
{"name": "intensity", "values": [0.5]},
]
result = _diff_cbuffer_vars(vars_a, vars_b)
assert result is not None
assert len(result) == 1
assert result[0]["name"] == "color"
assert result[0]["status"] == "changed"
def test_cbuffer_variable_identical(self):
from cli_anything.renderdoc.core.diff import _diff_cbuffer_vars
vars_a = [{"name": "x", "values": [1.0]}]
result = _diff_cbuffer_vars(vars_a, vars_a)
assert result is None
def test_output_table_extra_columns(self):
"""Verify output_table truncates rows longer than headers."""
from cli_anything.renderdoc.utils.output import output_table
import io
buf = io.StringIO()
output_table(
[["Alice", 30, "extra_col"]],
["Name", "Age"],
file=buf,
)
text = buf.getvalue()
assert "Alice" in text
assert "extra_col" not in text
class TestPreviewModule:
def _make_handle(self, tmp_path: Path, name: str = "frame") -> tuple[MagicMock, Path]:
capture_path = tmp_path / f"{name}.rdc"
capture_path.write_bytes(b"RDC")
handle = MagicMock()
handle.controller = MagicMock()
handle.metadata.return_value = {"api": "Vulkan", "replay_supported": True}
def _thumb(output_path, max_dim=0):
Path(output_path).write_bytes(b"\x89PNG\r\n\x1a\nthumb")
return {"path": output_path, "format": "PNG"}
handle.thumbnail.side_effect = _thumb
return handle, capture_path
def test_capture_bundle(self, tmp_path, monkeypatch):
from cli_anything.renderdoc.core import preview as preview_mod
from cli_anything.renderdoc.core import actions as actions_mod
from cli_anything.renderdoc.core import textures as textures_mod
from cli_anything.renderdoc.core import pipeline as pipeline_mod
handle, capture_path = self._make_handle(tmp_path, "capture_a")
monkeypatch.setattr(actions_mod, "get_drawcalls_only", lambda controller: [{"eventId": 42}])
monkeypatch.setattr(actions_mod, "action_summary", lambda controller: {"drawcalls": 5})
def fake_save_outputs(controller, event_id, output_dir, file_format="png"):
os.makedirs(output_dir, exist_ok=True)
output_path = Path(output_dir) / f"event{event_id}_rt0.png"
output_path.write_bytes(b"\x89PNG\r\n\x1a\nout")
return [{"path": str(output_path), "label": "RT0"}]
monkeypatch.setattr(textures_mod, "save_action_outputs", fake_save_outputs)
monkeypatch.setattr(pipeline_mod, "get_pipeline_state", lambda controller, event_id: {"eventId": event_id})
manifest = preview_mod.capture(handle, str(capture_path), root_dir=str(tmp_path))
assert manifest["software"] == "renderdoc"
assert manifest["recipe"] == "quick"
assert manifest["status"] == "ok"
assert any(item["role"] == "hero" for item in manifest["artifacts"])
assert any(item["artifact_id"] == "pipeline_state" for item in manifest["artifacts"])
assert os.path.isfile(manifest["_trajectory_path"])
trajectory = json.loads(Path(manifest["_trajectory_path"]).read_text(encoding="utf-8"))
assert trajectory["step_count"] == 1
assert trajectory["steps"][0]["publish_reason"] == "capture"
def test_diff_bundle(self, tmp_path, monkeypatch):
from cli_anything.renderdoc.core import preview as preview_mod
from cli_anything.renderdoc.core import textures as textures_mod
from cli_anything.renderdoc.core import pipeline as pipeline_mod
from cli_anything.renderdoc.core import diff as diff_mod
handle_a, capture_a = self._make_handle(tmp_path, "capture_a")
handle_b, capture_b = self._make_handle(tmp_path, "capture_b")
def fake_save_outputs(controller, event_id, output_dir, file_format="png"):
os.makedirs(output_dir, exist_ok=True)
output_path = Path(output_dir) / f"event{event_id}_rt0.png"
output_path.write_bytes(b"\x89PNG\r\n\x1a\nout")
return [{"path": str(output_path), "label": "RT0"}]
monkeypatch.setattr(textures_mod, "save_action_outputs", fake_save_outputs)
monkeypatch.setattr(pipeline_mod, "get_pipeline_state", lambda controller, event_id: {"eventId": event_id})
monkeypatch.setattr(diff_mod, "diff_pipeline", lambda *args, **kwargs: {"identical": False, "blend": {"enabled": {"A": True, "B": False}}})
manifest = preview_mod.diff(
handle_a,
str(capture_a),
100,
handle_b,
str(capture_b),
200,
root_dir=str(tmp_path),
)
assert manifest["software"] == "renderdoc"
assert manifest["bundle_kind"] == "diff"
assert any(item["artifact_id"] == "pipeline_diff" for item in manifest["artifacts"])
assert os.path.isfile(manifest["_trajectory_path"])
trajectory = json.loads(Path(manifest["_trajectory_path"]).read_text(encoding="utf-8"))
assert trajectory["step_count"] == 1
assert trajectory["steps"][0]["publish_reason"] == "diff"
def test_latest_bundle(self, tmp_path, monkeypatch):
from cli_anything.renderdoc.core import preview as preview_mod
from cli_anything.renderdoc.core import actions as actions_mod
from cli_anything.renderdoc.core import textures as textures_mod
from cli_anything.renderdoc.core import pipeline as pipeline_mod
handle, capture_path = self._make_handle(tmp_path, "capture_latest")
monkeypatch.setattr(actions_mod, "get_drawcalls_only", lambda controller: [{"eventId": 7}])
monkeypatch.setattr(actions_mod, "action_summary", lambda controller: {"drawcalls": 1})
def fake_save_outputs(controller, event_id, output_dir, file_format="png"):
os.makedirs(output_dir, exist_ok=True)
output_path = Path(output_dir) / f"event{event_id}_rt0.png"
output_path.write_bytes(b"\x89PNG\r\n\x1a\nout")
return [{"path": str(output_path), "label": "RT0"}]
monkeypatch.setattr(textures_mod, "save_action_outputs", fake_save_outputs)
monkeypatch.setattr(pipeline_mod, "get_pipeline_state", lambda controller, event_id: {"eventId": event_id})
created = preview_mod.capture(handle, str(capture_path), root_dir=str(tmp_path))
latest = preview_mod.latest(root_dir=str(tmp_path))
assert latest["bundle_id"] == created["bundle_id"]
assert os.path.isfile(latest["_trajectory_path"])
@@ -0,0 +1,374 @@
"""
End-to-end tests for RenderDoc CLI.
These tests require:
1. RenderDoc installed with Python bindings accessible
2. A .rdc capture file (set via RENDERDOC_TEST_CAPTURE env var)
Skip gracefully if either is unavailable.
Run with: pytest test_full_e2e.py -v
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
from cli_anything.renderdoc.core.capture import open_capture
from cli_anything.renderdoc.core import preview as preview_mod
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
HARNESS_ROOT = str(Path(__file__).resolve().parents[3])
TEST_CAPTURE = os.environ.get("RENDERDOC_TEST_CAPTURE", "")
HAS_CAPTURE = os.path.isfile(TEST_CAPTURE) if TEST_CAPTURE else False
try:
import renderdoc as rd
HAS_RD = True
except ImportError:
HAS_RD = False
skip_no_rd = pytest.mark.skipif(not HAS_RD, reason="renderdoc module not available")
skip_no_cap = pytest.mark.skipif(not HAS_CAPTURE, reason="RENDERDOC_TEST_CAPTURE not set or file missing")
def _run_cli(*args, json_mode=True) -> dict | list | str:
"""Run CLI via module invocation and parse output."""
cmd = [sys.executable, "-m", "cli_anything.renderdoc.renderdoc_cli"]
if TEST_CAPTURE:
cmd.extend(["--capture", TEST_CAPTURE])
if json_mode:
cmd.append("--json")
cmd.extend(args)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, cwd=HARNESS_ROOT)
if result.returncode != 0:
raise RuntimeError(f"CLI failed: {result.stderr}\n{result.stdout}")
if json_mode:
return json.loads(result.stdout)
return result.stdout
def _artifact_path(manifest, artifact_id: str) -> str:
for artifact in manifest["artifacts"]:
if artifact["artifact_id"] == artifact_id:
return os.path.join(manifest["_bundle_dir"], artifact["path"])
raise KeyError(f"Artifact not found: {artifact_id}")
# ===========================================================================
# E2E: Capture info
# ===========================================================================
@skip_no_rd
@skip_no_cap
class TestCaptureE2E:
def test_capture_info(self):
data = _run_cli("capture", "info")
assert "path" in data
assert "api" in data
assert "sections" in data
assert isinstance(data["sections"], list)
def test_capture_thumb(self):
with tempfile.TemporaryDirectory() as tmpdir:
output = os.path.join(tmpdir, "thumb.png")
data = _run_cli("capture", "thumb", "--output", output)
# May fail if no thumbnail - that's ok
if "error" not in data:
assert os.path.isfile(output)
# ===========================================================================
# E2E: Actions
# ===========================================================================
@skip_no_rd
@skip_no_cap
class TestActionsE2E:
def test_actions_list(self):
data = _run_cli("actions", "list")
assert isinstance(data, list)
assert len(data) > 0
assert "eventId" in data[0]
def test_actions_summary(self):
data = _run_cli("actions", "summary")
assert "total_actions" in data
assert data["total_actions"] > 0
def test_actions_draws_only(self):
data = _run_cli("actions", "list", "--draws-only")
assert isinstance(data, list)
for a in data:
assert "Drawcall" in a["flags"]
def test_actions_get(self):
# First get list to find a valid eventId
actions = _run_cli("actions", "list")
if actions:
eid = actions[0]["eventId"]
data = _run_cli("actions", "get", str(eid))
assert data["eventId"] == eid
# ===========================================================================
# E2E: Textures
# ===========================================================================
@skip_no_rd
@skip_no_cap
class TestTexturesE2E:
def test_textures_list(self):
data = _run_cli("textures", "list")
assert isinstance(data, list)
if len(data) > 0:
assert "resourceId" in data[0]
assert "width" in data[0]
def test_textures_save(self):
textures = _run_cli("textures", "list")
if not textures:
pytest.skip("No textures in capture")
rid = textures[0]["resourceId"]
with tempfile.TemporaryDirectory() as tmpdir:
output = os.path.join(tmpdir, "tex.png")
data = _run_cli("textures", "save", rid, "--output", output)
if "error" not in data:
assert os.path.isfile(output)
# ===========================================================================
# E2E: Resources
# ===========================================================================
@skip_no_rd
@skip_no_cap
class TestResourcesE2E:
def test_resources_list(self):
data = _run_cli("resources", "list")
assert isinstance(data, list)
def test_resources_buffers(self):
data = _run_cli("resources", "buffers")
assert isinstance(data, list)
# ===========================================================================
# E2E: Pipeline
# ===========================================================================
@skip_no_rd
@skip_no_cap
class TestPipelineE2E:
def test_pipeline_state(self):
# Get first draw call
draws = _run_cli("actions", "list", "--draws-only")
if not draws:
pytest.skip("No draw calls in capture")
eid = draws[0]["eventId"]
data = _run_cli("pipeline", "state", str(eid))
assert "shaders" in data
assert "eventId" in data
def test_pipeline_shader_export(self):
draws = _run_cli("actions", "list", "--draws-only")
if not draws:
pytest.skip("No draw calls")
eid = draws[0]["eventId"]
data = _run_cli("pipeline", "shader-export", str(eid), "--stage", "Fragment")
# May have error if no pixel shader - acceptable
assert "eventId" in data or "error" in data
# ===========================================================================
# E2E: Counters
# ===========================================================================
@skip_no_rd
@skip_no_cap
class TestCountersE2E:
def test_counters_list(self):
data = _run_cli("counters", "list")
assert isinstance(data, list)
# ===========================================================================
# Workflow: Full analysis pipeline
# ===========================================================================
@skip_no_rd
@skip_no_cap
class TestWorkflowE2E:
def test_full_analysis_workflow(self):
"""Simulate a typical analysis: info → list draws → inspect → export."""
# Step 1: Capture info
info = _run_cli("capture", "info")
assert "api" in info
# Step 2: Action summary
summary = _run_cli("actions", "summary")
assert summary["total_actions"] > 0
# Step 3: Find draw calls
draws = _run_cli("actions", "list", "--draws-only")
if not draws:
return # No draws to inspect
# Step 4: Inspect pipeline at first draw
eid = draws[0]["eventId"]
pipeline = _run_cli("pipeline", "state", str(eid))
assert "shaders" in pipeline
# Step 5: List textures
textures = _run_cli("textures", "list")
assert isinstance(textures, list)
@skip_no_rd
@skip_no_cap
class TestPreviewAPIE2E:
def test_preview_capture_bundle(self):
with tempfile.TemporaryDirectory() as tmpdir:
with open_capture(TEST_CAPTURE) as handle:
manifest = preview_mod.capture(
handle,
TEST_CAPTURE,
root_dir=tmpdir,
force=True,
)
assert manifest["software"] == "renderdoc"
assert manifest["bundle_kind"] == "capture"
assert manifest["status"] in ("ok", "partial")
artifact_ids = {artifact["artifact_id"] for artifact in manifest["artifacts"]}
assert "action_summary" in artifact_ids
summary_path = _artifact_path(manifest, "action_summary")
assert os.path.isfile(summary_path)
latest = preview_mod.latest(
project_path=TEST_CAPTURE,
recipe="quick",
bundle_kind="capture",
root_dir=tmpdir,
)
assert latest["bundle_id"] == manifest["bundle_id"]
print(f"\n RenderDoc preview bundle: {manifest['_bundle_dir']}")
print(f" RenderDoc action summary: {summary_path}")
def test_preview_diff_bundle(self):
draws = _run_cli("actions", "list", "--draws-only")
if not draws:
pytest.skip("No draw calls in capture")
event_a = int(draws[0]["eventId"])
event_b = int(draws[-1]["eventId"])
with tempfile.TemporaryDirectory() as tmpdir:
with open_capture(TEST_CAPTURE) as handle_a, open_capture(TEST_CAPTURE) as handle_b:
manifest = preview_mod.diff(
handle_a,
TEST_CAPTURE,
event_a,
handle_b,
TEST_CAPTURE,
event_b,
root_dir=tmpdir,
force=True,
)
assert manifest["software"] == "renderdoc"
assert manifest["bundle_kind"] == "diff"
diff_path = _artifact_path(manifest, "pipeline_diff")
assert os.path.isfile(diff_path)
with open(diff_path, "r", encoding="utf-8") as fh:
diff_data = json.load(fh)
assert isinstance(diff_data, dict)
latest = preview_mod.latest(
project_path=TEST_CAPTURE,
recipe="diff",
bundle_kind="diff",
root_dir=tmpdir,
)
assert latest["bundle_id"] == manifest["bundle_id"]
print(f"\n RenderDoc diff bundle: {manifest['_bundle_dir']}")
print(f" RenderDoc pipeline diff: {diff_path}")
@skip_no_rd
@skip_no_cap
class TestPreviewCLIE2E:
def test_cli_preview_capture(self):
with tempfile.TemporaryDirectory() as tmpdir:
manifest = _run_cli("preview", "capture", "--root-dir", tmpdir)
assert manifest["software"] == "renderdoc"
summary_path = _artifact_path(manifest, "action_summary")
assert os.path.isfile(summary_path)
latest = _run_cli(
"preview",
"latest",
"--recipe",
"quick",
"--bundle-kind",
"capture",
"--root-dir",
tmpdir,
)
assert latest["bundle_id"] == manifest["bundle_id"]
print(f"\n RenderDoc preview bundle: {manifest['_bundle_dir']}")
print(f" RenderDoc action summary: {summary_path}")
def test_cli_preview_diff(self):
draws = _run_cli("actions", "list", "--draws-only")
if not draws:
pytest.skip("No draw calls in capture")
event_a = int(draws[0]["eventId"])
event_b = int(draws[-1]["eventId"])
with tempfile.TemporaryDirectory() as tmpdir:
manifest = _run_cli(
"preview",
"diff",
str(event_a),
str(event_b),
"--root-dir",
tmpdir,
)
assert manifest["software"] == "renderdoc"
assert manifest["bundle_kind"] == "diff"
diff_path = _artifact_path(manifest, "pipeline_diff")
assert os.path.isfile(diff_path)
latest = _run_cli(
"preview",
"latest",
"--recipe",
"diff",
"--bundle-kind",
"diff",
"--root-dir",
tmpdir,
)
assert latest["bundle_id"] == manifest["bundle_id"]
print(f"\n RenderDoc diff bundle: {manifest['_bundle_dir']}")
print(f" RenderDoc pipeline diff: {diff_path}")
@@ -0,0 +1 @@
"""Utility modules for RenderDoc CLI harness."""
@@ -0,0 +1,29 @@
"""
Error handling utilities.
"""
from __future__ import annotations
import sys
import traceback
from typing import Any, Dict
def handle_error(e: Exception, debug: bool = False) -> Dict[str, Any]:
"""Convert an exception into an error dict.
If *debug* is True, includes the full traceback.
"""
result = {
"error": str(e),
"type": type(e).__name__,
}
if debug:
result["traceback"] = traceback.format_exc()
return result
def die(message: str, code: int = 1):
"""Print error message and exit."""
sys.stderr.write(f"Error: {message}\n")
sys.exit(code)
@@ -0,0 +1,56 @@
"""
Output formatting: JSON and human-readable output helpers.
"""
from __future__ import annotations
import json
import sys
from typing import Any
def output_json(data: Any, indent: int = 2, file=None):
"""Write data as JSON to stdout or a file."""
if file is None:
file = sys.stdout
json.dump(data, file, indent=indent, default=str)
file.write("\n")
def output_table(rows: list, headers: list, file=None):
"""Print a simple ASCII table."""
if file is None:
file = sys.stdout
if not rows:
file.write("(no data)\n")
return
# Calculate column widths
col_widths = [len(h) for h in headers]
for row in rows:
for i, val in enumerate(row):
if i < len(col_widths):
col_widths[i] = max(col_widths[i], len(str(val)))
# Header
header_line = " ".join(str(h).ljust(col_widths[i]) for i, h in enumerate(headers))
file.write(header_line + "\n")
file.write(" ".join("-" * w for w in col_widths) + "\n")
# Rows
for row in rows:
truncated = row[:len(headers)]
line = " ".join(str(v).ljust(col_widths[i]) for i, v in enumerate(truncated))
file.write(line + "\n")
def format_size(size_bytes: int) -> str:
"""Format byte count as human-readable string."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
@@ -0,0 +1,468 @@
"""Shared helpers for CLI-Anything preview bundles."""
from __future__ import annotations
import hashlib
import json
import mimetypes
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, Optional
PROTOCOL_VERSION = "preview-bundle/v1"
TRAJECTORY_PROTOCOL_VERSION = "preview-trajectory/v1"
def _slug(value: str) -> str:
text = (value or "preview").strip().lower()
text = re.sub(r"[^a-z0-9]+", "-", text)
text = re.sub(r"-{2,}", "-", text).strip("-")
return text or "preview"
def _json_dumps(data: Any) -> str:
return json.dumps(
data,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
def hash_data(data: Any) -> str:
return hashlib.sha256(_json_dumps(data).encode("utf-8")).hexdigest()
def fingerprint_data(data: Any) -> str:
return f"sha256:{hash_data(data)}"
def fingerprint_file(path: str) -> str:
resolved = os.path.abspath(path)
stat = os.stat(resolved)
return fingerprint_data(
{
"path": resolved,
"size": stat.st_size,
"mtime_ns": stat.st_mtime_ns,
}
)
def bundle_root(
software: str,
recipe: str,
project_path: Optional[str] = None,
root_dir: Optional[str] = None,
) -> Path:
if root_dir:
base = Path(root_dir).expanduser().resolve()
elif project_path:
base = Path(project_path).expanduser().resolve().parent / ".cli-anything" / "previews"
else:
base = Path.home() / ".cli-anything" / "previews"
return base / _slug(software) / _slug(recipe)
def build_cache_key(
software: str,
recipe: str,
bundle_kind: str,
source_fingerprint: str,
options: Optional[Dict[str, Any]] = None,
harness_version: Optional[str] = None,
protocol_version: str = PROTOCOL_VERSION,
) -> str:
return fingerprint_data(
{
"protocol_version": protocol_version,
"software": software,
"recipe": recipe,
"bundle_kind": bundle_kind,
"source_fingerprint": source_fingerprint,
"options": options or {},
"harness_version": harness_version or "",
}
)
def _iter_manifests(search_root: Path) -> Iterable[Path]:
if not search_root.exists():
return []
return sorted(search_root.rglob("manifest.json"), reverse=True)
def _load_json(path: Path) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def find_cached_manifest(
software: str,
recipe: str,
bundle_kind: str,
cache_key: str,
project_path: Optional[str] = None,
root_dir: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
root = bundle_root(software, recipe, project_path=project_path, root_dir=root_dir)
for manifest_path in _iter_manifests(root):
try:
manifest = _load_json(manifest_path)
except (OSError, json.JSONDecodeError):
continue
if (
manifest.get("protocol_version") == PROTOCOL_VERSION
and manifest.get("software") == software
and manifest.get("recipe") == recipe
and manifest.get("bundle_kind") == bundle_kind
and manifest.get("status") in {"ok", "partial"}
and manifest.get("cache_key") == cache_key
):
manifest["_manifest_path"] = str(manifest_path.resolve())
manifest["_bundle_dir"] = str(manifest_path.parent.resolve())
manifest["_summary_path"] = str(
(manifest_path.parent / manifest.get("summary_path", "summary.json")).resolve()
)
return manifest
return None
def find_latest_manifest(
software: str,
recipe: Optional[str] = None,
bundle_kind: Optional[str] = None,
project_path: Optional[str] = None,
root_dir: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if root_dir:
search_root = Path(root_dir).expanduser().resolve() / _slug(software)
elif project_path:
search_root = Path(project_path).expanduser().resolve().parent / ".cli-anything" / "previews" / _slug(software)
else:
search_root = Path.home() / ".cli-anything" / "previews" / _slug(software)
if recipe:
search_root = search_root / _slug(recipe)
for manifest_path in _iter_manifests(search_root):
try:
manifest = _load_json(manifest_path)
except (OSError, json.JSONDecodeError):
continue
if manifest.get("software") != software:
continue
if recipe and manifest.get("recipe") != recipe:
continue
if bundle_kind and manifest.get("bundle_kind") != bundle_kind:
continue
if manifest.get("status") not in {"ok", "partial"}:
continue
manifest["_manifest_path"] = str(manifest_path.resolve())
manifest["_bundle_dir"] = str(manifest_path.parent.resolve())
manifest["_summary_path"] = str(
(manifest_path.parent / manifest.get("summary_path", "summary.json")).resolve()
)
return manifest
return None
def prepare_bundle(
software: str,
recipe: str,
bundle_kind: str,
source_fingerprint: str,
options: Optional[Dict[str, Any]] = None,
harness_version: Optional[str] = None,
project_path: Optional[str] = None,
root_dir: Optional[str] = None,
force: bool = False,
) -> Dict[str, Any]:
cache_key = build_cache_key(
software=software,
recipe=recipe,
bundle_kind=bundle_kind,
source_fingerprint=source_fingerprint,
options=options or {},
harness_version=harness_version,
)
if not force:
cached = find_cached_manifest(
software=software,
recipe=recipe,
bundle_kind=bundle_kind,
cache_key=cache_key,
project_path=project_path,
root_dir=root_dir,
)
if cached:
return {
"cached": True,
"cache_key": cache_key,
"bundle_id": cached.get("bundle_id"),
"bundle_dir": cached["_bundle_dir"],
"manifest_path": cached["_manifest_path"],
"summary_path": os.path.join(cached["_bundle_dir"], cached.get("summary_path", "summary.json")),
"manifest": cached,
}
now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
bundle_id = f"{now}_{cache_key.split(':', 1)[-1][:8]}_{_slug(recipe)}"
out_dir = bundle_root(software, recipe, project_path=project_path, root_dir=root_dir) / bundle_id
artifacts_dir = out_dir / "artifacts"
artifacts_dir.mkdir(parents=True, exist_ok=False)
return {
"cached": False,
"cache_key": cache_key,
"bundle_id": bundle_id,
"bundle_dir": str(out_dir.resolve()),
"artifacts_dir": str(artifacts_dir.resolve()),
"manifest_path": str((out_dir / "manifest.json").resolve()),
"summary_path": str((out_dir / "summary.json").resolve()),
}
def artifact_record(
bundle_dir: str,
path: str,
artifact_id: str,
role: str,
kind: str,
label: str,
media_type: Optional[str] = None,
**extra: Any,
) -> Dict[str, Any]:
bundle_path = Path(bundle_dir).resolve()
file_path = Path(path).resolve()
rel_path = file_path.relative_to(bundle_path).as_posix()
record: Dict[str, Any] = {
"artifact_id": artifact_id,
"role": role,
"kind": kind,
"label": label,
"media_type": media_type or mimetypes.guess_type(str(file_path))[0] or "application/octet-stream",
"path": rel_path,
}
if file_path.exists():
record["bytes"] = file_path.stat().st_size
record.update({k: v for k, v in extra.items() if v is not None})
return record
def write_json(path: str, data: Any) -> str:
output_path = Path(path).resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False, default=str)
fh.write("\n")
return str(output_path)
def finalize_bundle(
bundle_dir: str,
bundle_id: str,
bundle_kind: str,
software: str,
recipe: str,
source: Dict[str, Any],
artifacts: list[Dict[str, Any]],
summary: Dict[str, Any],
cache_key: str,
generator: Dict[str, Any],
status: str = "ok",
warnings: Optional[list[str]] = None,
context: Optional[Dict[str, Any]] = None,
metrics: Optional[Dict[str, Any]] = None,
labels: Optional[list[str]] = None,
source_bundles: Optional[list[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
bundle_path = Path(bundle_dir).resolve()
summary_rel = "summary.json"
summary_path = write_json(str(bundle_path / summary_rel), summary)
manifest = {
"protocol_version": PROTOCOL_VERSION,
"bundle_id": bundle_id,
"bundle_kind": bundle_kind,
"software": software,
"recipe": recipe,
"status": status,
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"cache_key": cache_key,
"generator": generator,
"source": source,
"summary_path": summary_rel,
"artifacts": artifacts,
}
if warnings:
manifest["warnings"] = warnings
if context:
manifest["context"] = context
if metrics:
manifest["metrics"] = metrics
if labels:
manifest["labels"] = labels
if source_bundles:
manifest["source_bundles"] = source_bundles
manifest_path = write_json(str(bundle_path / "manifest.json"), manifest)
manifest["_manifest_path"] = manifest_path
manifest["_bundle_dir"] = str(bundle_path)
manifest["_summary_path"] = summary_path
return manifest
def _clean_none_fields(data: Dict[str, Any]) -> Dict[str, Any]:
return {key: value for key, value in data.items() if value is not None}
def live_trajectory_path(session_dir: str | Path) -> Path:
return Path(session_dir).expanduser().resolve() / "trajectory.json"
def load_live_trajectory(session_dir: str | Path) -> Dict[str, Any]:
trajectory_path = live_trajectory_path(session_dir)
if not trajectory_path.is_file():
return {}
return _load_json(trajectory_path)
def summarize_trajectory(trajectory: Dict[str, Any], *, recent_steps: int = 3) -> Dict[str, Any]:
steps = list(trajectory.get("steps") or [])
latest = steps[-1] if steps else {}
recent = steps[-max(1, int(recent_steps)):] if steps else []
return _clean_none_fields(
{
"protocol_version": trajectory.get("protocol_version"),
"software": trajectory.get("software"),
"recipe": trajectory.get("recipe"),
"step_count": trajectory.get("step_count", len(steps)),
"current_step_id": trajectory.get("current_step_id"),
"latest_command": latest.get("command"),
"latest_publish_reason": latest.get("publish_reason"),
"latest_bundle_id": latest.get("bundle_id"),
"recent_steps": [
_clean_none_fields(
{
"step_id": item.get("step_id"),
"step_index": item.get("step_index"),
"bundle_id": item.get("bundle_id"),
"publish_reason": item.get("publish_reason"),
"command": item.get("command"),
"command_finished_at": item.get("command_finished_at"),
"status": item.get("status"),
"cached": item.get("cached"),
}
)
for item in recent
],
}
)
def build_live_history_item(
bundle_manifest: Dict[str, Any],
*,
step_id: Optional[str] = None,
step_index: Optional[int] = None,
publish_reason: Optional[str] = None,
command: Optional[str] = None,
command_started_at: Optional[str] = None,
command_finished_at: Optional[str] = None,
source_fingerprint: Optional[str] = None,
stage_label: Optional[str] = None,
note: Optional[str] = None,
) -> Dict[str, Any]:
source = bundle_manifest.get("source") or {}
resolved_command = command or (bundle_manifest.get("generator") or {}).get("command")
resolved_fingerprint = (
source_fingerprint
or source.get("project_fingerprint")
or source.get("capture_fingerprint")
)
created_at = bundle_manifest.get("created_at")
return _clean_none_fields(
{
"step_id": step_id,
"step_index": step_index,
"bundle_id": bundle_manifest.get("bundle_id"),
"bundle_dir": bundle_manifest.get("_bundle_dir"),
"manifest_path": bundle_manifest.get("_manifest_path"),
"summary_path": bundle_manifest.get("_summary_path"),
"created_at": created_at,
"status": bundle_manifest.get("status"),
"cached": bool(bundle_manifest.get("cached")),
"publish_reason": publish_reason,
"command": resolved_command,
"command_started_at": command_started_at or created_at,
"command_finished_at": command_finished_at or created_at,
"source_fingerprint": resolved_fingerprint,
"stage_label": stage_label,
"note": note,
}
)
def append_live_trajectory(
session_dir: str | Path,
*,
software: str,
recipe: str,
bundle_manifest: Dict[str, Any],
publish_reason: str,
project_path: Optional[str] = None,
project_name: Optional[str] = None,
session_name: Optional[str] = None,
command: Optional[str] = None,
command_started_at: Optional[str] = None,
command_finished_at: Optional[str] = None,
source_fingerprint: Optional[str] = None,
stage_label: Optional[str] = None,
note: Optional[str] = None,
) -> Dict[str, Any]:
session_path = Path(session_dir).expanduser().resolve()
existing = load_live_trajectory(session_path)
steps = list(existing.get("steps") or [])
finished_at = (
command_finished_at
or bundle_manifest.get("created_at")
or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
)
started_at = command_started_at or finished_at
step_index = len(steps) + 1
step_id = f"step-{step_index:04d}"
step = build_live_history_item(
bundle_manifest,
step_id=step_id,
step_index=step_index,
publish_reason=publish_reason,
command=command,
command_started_at=started_at,
command_finished_at=finished_at,
source_fingerprint=source_fingerprint,
stage_label=stage_label,
note=note,
)
steps.append(step)
trajectory: Dict[str, Any] = dict(existing)
trajectory.update(
_clean_none_fields(
{
"protocol_version": TRAJECTORY_PROTOCOL_VERSION,
"software": software,
"recipe": recipe,
"session_name": session_name or session_path.name,
"project_path": project_path,
"project_name": project_name,
"created_at": existing.get("created_at", finished_at),
"updated_at": finished_at,
"step_count": len(steps),
"current_step_id": step_id,
}
)
)
trajectory["steps"] = steps
trajectory_path = write_json(str(live_trajectory_path(session_path)), trajectory)
trajectory["_trajectory_path"] = trajectory_path
trajectory["latest_step"] = step
return trajectory
@@ -0,0 +1,567 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
from pathlib import Path
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
def _display_home_path(path: str) -> str:
"""Display a path relative to the home directory when possible."""
expanded = Path(path).expanduser().resolve()
home = Path.home().resolve()
try:
relative = expanded.relative_to(home)
return f"~/{relative.as_posix()}"
except ValueError:
return str(expanded)
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None, skill_path: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the repo-root skills/ tree when present,
otherwise from the package's skills/ directory.
Displayed in banner for AI agents to know where to read skill info.
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
software_aliases = {"iterm2_ctl": "iterm2"}
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
self.skill_id = f"cli-anything-{self.skill_slug}"
self.skill_install_cmd = (
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
)
global_skill_root = Path(
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
).expanduser()
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
# inside the CLI-Anything monorepo. Fall back to the packaged
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
if skill_path is None:
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
repo_skill = None
for parent in Path(__file__).resolve().parents:
candidate = parent / "skills" / self.skill_id / "SKILL.md"
if candidate.is_file():
repo_skill = candidate
break
if repo_skill and repo_skill.is_file():
skill_path = str(repo_skill)
elif package_skill.is_file():
skill_path = str(package_skill)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
import textwrap
inner = 72
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
def _meta_lines(label: str, value: str) -> list[str]:
"""Wrap a metadata line for the banner box."""
icon = self._c(_MAGENTA, "")
label_text = self._c(_DARK_GRAY, label)
prefix = f" {icon} {label_text} "
available = max(12, inner - _visible_len(prefix))
wrapped = textwrap.wrap(
value,
width=available,
break_long_words=True,
break_on_hyphens=False,
) or [""]
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
continuation_prefix = " " * _visible_len(prefix)
for chunk in wrapped[1:]:
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
return lines
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
meta_lines: list[str] = []
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
print(top)
print(_box_line(title))
print(_box_line(ver))
for line in meta_lines:
print(_box_line(line))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
tokens = []
tokens.append(("class:icon", ""))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "" * filled + "" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import FormattedText
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", ""))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}
+41
View File
@@ -0,0 +1,41 @@
"""Setup for cli-anything-renderdoc package."""
from pathlib import Path
from setuptools import setup, find_namespace_packages
_README = Path(__file__).parent / "cli_anything" / "renderdoc" / "README.md"
_long_desc = _README.read_text(encoding="utf-8") if _README.is_file() else ""
setup(
name="cli-anything-renderdoc",
version="0.1.0",
description="CLI harness for RenderDoc graphics debugger",
long_description=_long_desc,
long_description_content_type="text/markdown",
author="cli-anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
python_requires=">=3.10",
install_requires=[
"click>=8.0",
"prompt-toolkit>=3.0",
],
extras_require={
"test": ["pytest>=7.0"],
},
entry_points={
"console_scripts": [
"cli-anything-renderdoc=cli_anything.renderdoc.renderdoc_cli:main",
],
},
package_data={
"cli_anything.renderdoc": ["skills/*.md", "README.md"],
},
include_package_data=True,
zip_safe=False,
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Topic :: Software Development :: Debuggers",
],
)