mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-31 01:42:25 +08:00
Merge pull request #147 from levishilf/feat/renderdoc-cli-harness
Feat/renderdoc cli harness
This commit is contained in:
@@ -57,6 +57,7 @@
|
||||
!/krita/
|
||||
!/freecad/
|
||||
!/iterm2/
|
||||
!/renderdoc/
|
||||
|
||||
# Step 5: Inside each software dir, ignore everything (including dotfiles)
|
||||
/gimp/*
|
||||
@@ -103,6 +104,8 @@
|
||||
/freecad/.*
|
||||
/iterm2/*
|
||||
/iterm2/.*
|
||||
/renderdoc/*
|
||||
/renderdoc/.*
|
||||
|
||||
# Step 6: ...except agent-harness/
|
||||
!/gimp/agent-harness/
|
||||
@@ -128,6 +131,7 @@
|
||||
!/krita/agent-harness/
|
||||
!/freecad/agent-harness/
|
||||
!/iterm2/agent-harness/
|
||||
!/renderdoc/agent-harness/
|
||||
|
||||
# Step 7: Ignore build artifacts within allowed dirs
|
||||
**/__pycache__/
|
||||
|
||||
@@ -456,6 +456,7 @@ The catalog auto-updates whenever `registry.json` changes — new community CLIs
|
||||
| **📞 Communication & Collaboration** | Automate meeting scheduling, participant management, recording retrieval, and reporting through structured CLI | Zoom, Jitsi Meet, BigBlueButton, Mattermost |
|
||||
| **📐 Diagramming & Visualization** | Create and manipulate diagrams, flowcharts, architecture diagrams, and visual documentation programmatically | Draw.io (diagrams.net), Mermaid, PlantUML, Excalidraw, yEd |
|
||||
| **🌐 Network & Infrastructure** | Manage network services, DNS, ad-blocking, and infrastructure through structured CLI commands | AdGuardHome |
|
||||
| **🔬 Graphics & GPU Debugging** | Analyze GPU frame captures, inspect pipeline state, export shaders, and diff rendering state | RenderDoc |
|
||||
| **✨ AI Content Generation** | Generate professional deliverables (slides, docs, diagrams, websites, research reports) through AI-powered cloud APIs | [AnyGen](https://www.anygen.io), Gamma, Beautiful.ai, Tome |
|
||||
|
||||
---
|
||||
@@ -771,8 +772,9 @@ comfyui 70 passed ✅ (60 unit + 10 e2e)
|
||||
adguardhome 36 passed ✅ (24 unit + 12 e2e)
|
||||
ollama 98 passed ✅ (87 unit + 11 e2e)
|
||||
sketch 19 passed ✅ (19 jest, Node.js)
|
||||
renderdoc 59 passed ✅ (45 unit + 14 e2e)
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
TOTAL 1,858 passed ✅ 100% pass rate
|
||||
TOTAL 1,917 passed ✅ 100% pass rate
|
||||
```
|
||||
|
||||
---
|
||||
@@ -840,7 +842,8 @@ cli-anything/
|
||||
├── 🧠 notebooklm/agent-harness/ # NotebookLM CLI (experimental, 21 tests)
|
||||
├── 🛡️ adguardhome/agent-harness/ # AdGuard Home CLI (36 tests)
|
||||
├── 🦙 ollama/agent-harness/ # Ollama CLI (98 tests)
|
||||
└── 🎨 sketch/agent-harness/ # Sketch CLI (19 tests, Node.js)
|
||||
├── 🎨 sketch/agent-harness/ # Sketch CLI (19 tests, Node.js)
|
||||
└── 🔬 renderdoc/agent-harness/ # RenderDoc CLI (59 tests)
|
||||
```
|
||||
|
||||
Each `agent-harness/` contains an installable Python package under `cli_anything.<software>/` with Click CLI, core modules, utils (including `repl_skin.py` and backend wrapper), and comprehensive tests.
|
||||
|
||||
@@ -340,6 +340,20 @@
|
||||
"category": "devops",
|
||||
"contributor": "voidfreud",
|
||||
"contributor_url": "https://github.com/voidfreud"
|
||||
},
|
||||
{
|
||||
"name": "renderdoc",
|
||||
"display_name": "RenderDoc",
|
||||
"version": "0.1.0",
|
||||
"description": "GPU frame capture analysis: pipeline state, shader export, texture inspection, draw call browsing",
|
||||
"requires": "renderdoc (Python bindings from RenderDoc installation)",
|
||||
"homepage": "https://renderdoc.org",
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=renderdoc/agent-harness",
|
||||
"entry_point": "cli-anything-renderdoc",
|
||||
"skill_md": "renderdoc/agent-harness/cli_anything/renderdoc/skills/SKILL.md",
|
||||
"category": "graphics",
|
||||
"contributor": "levishilf",
|
||||
"contributor_url": "https://github.com/levishilf"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
@@ -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`
|
||||
@@ -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,103 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## 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,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
|
||||
@@ -0,0 +1,923 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RenderDoc CLI - Command-line interface for RenderDoc graphics debugger.
|
||||
|
||||
Provides headless access to RenderDoc capture analysis:
|
||||
- Inspect capture metadata and sections
|
||||
- List and search draw calls / actions
|
||||
- Inspect pipeline state at any event
|
||||
- List, inspect, and export textures
|
||||
- Read buffer and mesh data
|
||||
- Query GPU performance counters
|
||||
- Pick pixel values
|
||||
|
||||
Usage:
|
||||
renderdoc-cli [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
All commands support --json for machine-readable output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy import helpers – we don't want to import renderdoc at CLI parse time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_capture_handle = None # type: ignore
|
||||
_capture_handle_path = None # type: ignore
|
||||
_repl_mode = False
|
||||
|
||||
|
||||
def _close_all_captures():
|
||||
global _capture_handle, _capture_handle_b, _capture_handle_path, _capture_handle_b_path
|
||||
if _capture_handle is not None:
|
||||
_capture_handle.close()
|
||||
_capture_handle = None
|
||||
_capture_handle_path = None
|
||||
if _capture_handle_b is not None:
|
||||
_capture_handle_b.close()
|
||||
_capture_handle_b = None
|
||||
_capture_handle_b_path = None
|
||||
|
||||
|
||||
def _get_export_dir(ctx: click.Context, subfolder: str = "") -> str:
|
||||
"""Return the default export directory for the current capture.
|
||||
|
||||
Layout: <capture_dir>/<stem>_exported/<subfolder>/
|
||||
e.g. tests/pc_exported/shaders/
|
||||
"""
|
||||
capture_path = ctx.obj.get("capture_path", "capture")
|
||||
capture_dir = os.path.dirname(os.path.abspath(capture_path))
|
||||
stem = os.path.splitext(os.path.basename(capture_path))[0]
|
||||
export_dir = os.path.join(capture_dir, "%s_exported" % stem)
|
||||
if subfolder:
|
||||
export_dir = os.path.join(export_dir, subfolder)
|
||||
os.makedirs(export_dir, exist_ok=True)
|
||||
return export_dir
|
||||
|
||||
|
||||
def _get_handle(ctx: click.Context):
|
||||
"""Return the active CaptureHandle, opening it if needed."""
|
||||
global _capture_handle, _capture_handle_path
|
||||
path = ctx.obj.get("capture_path")
|
||||
if not path:
|
||||
click.echo("Error: No capture file specified. Use --capture <path>", err=True)
|
||||
ctx.exit(1)
|
||||
path_abs = os.path.abspath(path)
|
||||
if _capture_handle is not None:
|
||||
if _capture_handle_path == path_abs:
|
||||
return _capture_handle
|
||||
_capture_handle.close()
|
||||
_capture_handle = None
|
||||
_capture_handle_path = None
|
||||
from cli_anything.renderdoc.core.capture import CaptureHandle
|
||||
from cli_anything.renderdoc.utils.errors import handle_error
|
||||
|
||||
try:
|
||||
_capture_handle = CaptureHandle(path)
|
||||
_capture_handle_path = path_abs
|
||||
except Exception as e:
|
||||
debug = ctx.obj.get("debug", False)
|
||||
err = handle_error(e, debug=debug)
|
||||
if ctx.obj.get("json_mode"):
|
||||
from cli_anything.renderdoc.utils.output import output_json
|
||||
output_json(err)
|
||||
ctx.exit(1)
|
||||
else:
|
||||
msg = "Failed to open capture: %s" % err["error"]
|
||||
if debug and "traceback" in err:
|
||||
msg += "\n" + err["traceback"]
|
||||
raise click.ClickException(msg)
|
||||
return _capture_handle
|
||||
|
||||
|
||||
def _output(ctx: click.Context, data, human_fn=None):
|
||||
"""Output data as JSON or human-readable."""
|
||||
if ctx.obj.get("json_mode"):
|
||||
from cli_anything.renderdoc.utils.output import output_json
|
||||
|
||||
output_json(data)
|
||||
elif human_fn:
|
||||
human_fn(data)
|
||||
else:
|
||||
from cli_anything.renderdoc.utils.output import output_json
|
||||
|
||||
output_json(data)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Root group
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option(
|
||||
"--capture", "-c",
|
||||
type=click.Path(exists=False),
|
||||
envvar="RENDERDOC_CAPTURE",
|
||||
help="Path to .rdc capture file.",
|
||||
)
|
||||
@click.option("--json", "json_mode", is_flag=True, help="Output in JSON format.")
|
||||
@click.option("--debug", is_flag=True, help="Show debug tracebacks on errors.")
|
||||
@click.version_option(package_name="cli-anything-renderdoc")
|
||||
@click.pass_context
|
||||
def cli(ctx, capture, json_mode, debug):
|
||||
"""RenderDoc CLI – headless capture analysis tool.
|
||||
|
||||
Run without a subcommand to enter interactive REPL mode.
|
||||
"""
|
||||
ctx.ensure_object(dict)
|
||||
# Preserve REPL session state: nested `cli.main(...)` omits global options, so
|
||||
# only overwrite capture when the user passed `-c` on that invocation.
|
||||
if capture is not None:
|
||||
ctx.obj["capture_path"] = capture
|
||||
ctx.obj["json_mode"] = json_mode
|
||||
ctx.obj["debug"] = debug
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
ctx.invoke(repl)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# capture commands
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@cli.group("capture")
|
||||
def capture_group():
|
||||
"""Capture file operations."""
|
||||
pass
|
||||
|
||||
|
||||
@capture_group.command("info")
|
||||
@click.pass_context
|
||||
def capture_info(ctx):
|
||||
"""Show capture file metadata and sections."""
|
||||
handle = _get_handle(ctx)
|
||||
meta = handle.metadata()
|
||||
meta["sections"] = handle.list_sections()
|
||||
|
||||
def _human(data):
|
||||
click.echo(f"Capture: {data['path']}")
|
||||
click.echo(f"API: {data['api']}")
|
||||
click.echo(f"Replay: {'yes' if data['replay_supported'] else 'no'}")
|
||||
click.echo(f"\nSections ({len(data['sections'])}):")
|
||||
for s in data["sections"]:
|
||||
click.echo(f" [{s['index']}] {s['name']} ({s['type']}) - {s['uncompressed_size']} bytes")
|
||||
|
||||
_output(ctx, meta, _human)
|
||||
|
||||
|
||||
@capture_group.command("thumb")
|
||||
@click.option("--output", "-o", required=True, type=click.Path(), help="Output image path.")
|
||||
@click.option("--max-dim", default=0, type=int, help="Max thumbnail dimension (0 = original).")
|
||||
@click.pass_context
|
||||
def capture_thumb(ctx, output, max_dim):
|
||||
"""Extract capture thumbnail to an image file."""
|
||||
handle = _get_handle(ctx)
|
||||
result = handle.thumbnail(output, max_dim)
|
||||
_output(ctx, result)
|
||||
|
||||
|
||||
@capture_group.command("convert")
|
||||
@click.option("--output", "-o", required=True, type=click.Path(), help="Output file path.")
|
||||
@click.option("--format", "fmt", default="", help="Target format (default: rdc).")
|
||||
@click.pass_context
|
||||
def capture_convert(ctx, output, fmt):
|
||||
"""Convert capture to a different format."""
|
||||
handle = _get_handle(ctx)
|
||||
result = handle.convert(output, fmt)
|
||||
_output(ctx, result)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# action commands
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@cli.group("actions")
|
||||
def actions_group():
|
||||
"""Draw call / action inspection."""
|
||||
pass
|
||||
|
||||
|
||||
@actions_group.command("list")
|
||||
@click.option("--flat/--no-flat", default=True, help="Flat list vs root-only.")
|
||||
@click.option("--draws-only", is_flag=True, help="Only show actual draw calls.")
|
||||
@click.pass_context
|
||||
def actions_list(ctx, flat, draws_only):
|
||||
"""List all actions (draw calls, clears, etc.) in the capture."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.actions import list_actions, get_drawcalls_only
|
||||
|
||||
if draws_only:
|
||||
data = get_drawcalls_only(handle.controller)
|
||||
else:
|
||||
data = list_actions(handle.controller, flat=flat)
|
||||
|
||||
def _human(actions):
|
||||
click.echo(f"Total actions: {len(actions)}")
|
||||
for a in actions:
|
||||
indent = " " * a.get("depth", 0)
|
||||
flags = ",".join(a["flags"]) if a["flags"] else ""
|
||||
click.echo(f"{indent}[{a['eventId']:>5}] {a['name']:<50} {flags}")
|
||||
|
||||
_output(ctx, data, _human)
|
||||
|
||||
|
||||
@actions_group.command("summary")
|
||||
@click.pass_context
|
||||
def actions_summary(ctx):
|
||||
"""Show action count summary by type."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.actions import action_summary
|
||||
|
||||
data = action_summary(handle.controller)
|
||||
|
||||
def _human(d):
|
||||
click.echo("Action Summary:")
|
||||
for k, v in d.items():
|
||||
click.echo(f" {k}: {v}")
|
||||
|
||||
_output(ctx, data, _human)
|
||||
|
||||
|
||||
@actions_group.command("find")
|
||||
@click.argument("pattern")
|
||||
@click.pass_context
|
||||
def actions_find(ctx, pattern):
|
||||
"""Find actions by name pattern (case-insensitive)."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.actions import find_actions_by_name
|
||||
|
||||
data = find_actions_by_name(handle.controller, pattern)
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
@actions_group.command("get")
|
||||
@click.argument("event_id", type=int)
|
||||
@click.pass_context
|
||||
def actions_get(ctx, event_id):
|
||||
"""Get details of a single action by eventId."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.actions import find_action_by_event
|
||||
|
||||
data = find_action_by_event(handle.controller, event_id)
|
||||
if data is None:
|
||||
data = {"error": f"No action found with eventId={event_id}"}
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# texture commands
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@cli.group("textures")
|
||||
def textures_group():
|
||||
"""Texture inspection and export."""
|
||||
pass
|
||||
|
||||
|
||||
@textures_group.command("list")
|
||||
@click.pass_context
|
||||
def textures_list(ctx):
|
||||
"""List all textures in the capture."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.textures import list_textures
|
||||
|
||||
data = list_textures(handle.controller)
|
||||
|
||||
def _human(textures):
|
||||
click.echo(f"Total textures: {len(textures)}")
|
||||
for t in textures:
|
||||
click.echo(
|
||||
f" [{t['resourceId']}] {t['width']}x{t['height']} "
|
||||
f"mips={t['mips']} fmt={t['format']}"
|
||||
)
|
||||
|
||||
_output(ctx, data, _human)
|
||||
|
||||
|
||||
@textures_group.command("get")
|
||||
@click.argument("resource_id")
|
||||
@click.pass_context
|
||||
def textures_get(ctx, resource_id):
|
||||
"""Get details of a single texture by resource ID."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.textures import get_texture
|
||||
|
||||
data = get_texture(handle.controller, resource_id)
|
||||
if data is None:
|
||||
data = {"error": f"Texture {resource_id} not found"}
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
@textures_group.command("save")
|
||||
@click.argument("resource_id")
|
||||
@click.option("--output", "-o", required=True, type=click.Path(), help="Output file path.")
|
||||
@click.option("--format", "fmt", default="png", help="Image format: png, jpg, bmp, tga, hdr, exr, dds.")
|
||||
@click.option("--mip", default=0, type=int, help="Mip level (-1 for all, DDS only).")
|
||||
@click.option("--slice", "slice_idx", default=0, type=int, help="Array slice (-1 for all, DDS only).")
|
||||
@click.option("--alpha", default="preserve", help="Alpha: preserve, discard, blend_checkerboard.")
|
||||
@click.pass_context
|
||||
def textures_save(ctx, resource_id, output, fmt, mip, slice_idx, alpha):
|
||||
"""Save a texture to an image file."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.textures import save_texture
|
||||
|
||||
data = save_texture(handle.controller, resource_id, output, fmt, mip, slice_idx, alpha)
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
@textures_group.command("save-outputs")
|
||||
@click.argument("event_id", type=int)
|
||||
@click.option("--output-dir", "-o", required=True, type=click.Path(), help="Output directory.")
|
||||
@click.option("--format", "fmt", default="png", help="Image format.")
|
||||
@click.pass_context
|
||||
def textures_save_outputs(ctx, event_id, output_dir, fmt):
|
||||
"""Save all render target outputs at a specific event."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.textures import save_action_outputs
|
||||
|
||||
data = save_action_outputs(handle.controller, event_id, output_dir, fmt)
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
@textures_group.command("pick")
|
||||
@click.argument("resource_id")
|
||||
@click.argument("x", type=int)
|
||||
@click.argument("y", type=int)
|
||||
@click.option("--mip", default=0, type=int)
|
||||
@click.option("--slice", "slice_idx", default=0, type=int)
|
||||
@click.pass_context
|
||||
def textures_pick(ctx, resource_id, x, y, mip, slice_idx):
|
||||
"""Pick a pixel value from a texture."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.textures import pick_pixel
|
||||
|
||||
data = pick_pixel(handle.controller, resource_id, x, y, mip, slice_idx)
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# pipeline commands
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@cli.group("pipeline")
|
||||
def pipeline_group():
|
||||
"""Pipeline state inspection."""
|
||||
pass
|
||||
|
||||
|
||||
@pipeline_group.command("state")
|
||||
@click.argument("event_id", type=int)
|
||||
@click.pass_context
|
||||
def pipeline_state(ctx, event_id):
|
||||
"""Show full pipeline state at a specific event."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.pipeline import get_pipeline_state
|
||||
|
||||
data = get_pipeline_state(handle.controller, event_id)
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
@pipeline_group.command("shader-export")
|
||||
@click.argument("event_id", type=int)
|
||||
@click.option("--stage", default="Fragment", help="Shader stage: Vertex, Fragment, Compute, etc.")
|
||||
@click.option("-o", "--output", "output_dir", default=None,
|
||||
help="Output directory. Default: <capture>_exported/shaders/")
|
||||
@click.pass_context
|
||||
def pipeline_shader_export(ctx, event_id, stage, output_dir):
|
||||
"""Export shader source in human-readable form.
|
||||
|
||||
For text shaders (GLSL, HLSL, Slang) the raw bytes are already
|
||||
readable — they are saved directly.
|
||||
|
||||
For binary shaders (DXBC, SPIR-V, DXIL) the tool tries, in order:
|
||||
|
||||
\b
|
||||
1. Embedded debug source (HLSL/GLSL compiled with /Zi)
|
||||
2. RenderDoc disassembly (bytecode asm)
|
||||
|
||||
The raw binary is always saved alongside for completeness.
|
||||
|
||||
\b
|
||||
Default output: <capture>_exported/shaders/
|
||||
"""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.pipeline import export_shader
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = _get_export_dir(ctx, "shaders")
|
||||
|
||||
data = export_shader(handle.controller, event_id, stage, output_dir=output_dir)
|
||||
|
||||
def _human(d):
|
||||
if "error" in d:
|
||||
click.echo("Error: %s" % d["error"])
|
||||
return
|
||||
click.echo(" Encoding: %s" % d["encoding"])
|
||||
click.echo(" Raw: %s" % d["raw_path"])
|
||||
rp = d.get("readable_path")
|
||||
if rp and rp != d["raw_path"]:
|
||||
label = "Source" if d.get("readable_kind") == "source" else "Disassembly"
|
||||
click.echo(" %s: %s" % (label.ljust(12), rp))
|
||||
|
||||
_output(ctx, data, _human)
|
||||
|
||||
|
||||
@pipeline_group.command("dump-shader-reflection", hidden=True)
|
||||
@click.argument("event_id", type=int)
|
||||
@click.option("--stage", default="Fragment", help="Shader stage: Vertex, Fragment, Compute, etc.")
|
||||
@click.option("-o", "--output", "output_dir", default=None, help="Output directory path.")
|
||||
@click.pass_context
|
||||
def pipeline_dump_shader_reflection(ctx, event_id, stage, output_dir):
|
||||
"""Export complete ShaderReflection for a shader stage to a folder.
|
||||
|
||||
Creates a directory containing:
|
||||
|
||||
\b
|
||||
reflection.json Full ShaderReflection (signatures, cbuffer layouts,
|
||||
resource declarations, debug info with source)
|
||||
bindings.json Runtime GPU bindings (bound resource IDs, offsets)
|
||||
cbuffer_values.json Runtime constant buffer variable values
|
||||
shader_raw.* Raw shader bytes (e.g. .dxbc, .glsl)
|
||||
sources/ Debug source files (if compiled with debug info)
|
||||
|
||||
\b
|
||||
Default output: <capture>_exported/shaders/<shader>_reflection/
|
||||
"""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.pipeline import export_shader_reflection
|
||||
|
||||
if output_dir is None:
|
||||
# Build default output_dir under the capture's export directory.
|
||||
# We need the resourceId to name the folder, so do a quick probe first.
|
||||
import renderdoc as rd
|
||||
from cli_anything.renderdoc.core.pipeline import STAGE_MAP
|
||||
stage_enum = STAGE_MAP.get(stage.lower())
|
||||
if stage_enum is None:
|
||||
_output(ctx, {"error": "Unknown stage: %s" % stage})
|
||||
return
|
||||
handle.controller.SetFrameEvent(event_id, True)
|
||||
pipe = handle.controller.GetPipelineState()
|
||||
refl = pipe.GetShaderReflection(stage_enum)
|
||||
if refl is None:
|
||||
_output(ctx, {"error": "No shader bound at stage %s for event %d" % (stage, event_id)})
|
||||
return
|
||||
rid_str = str(refl.resourceId).replace("::", "_")
|
||||
shader_dir = _get_export_dir(ctx, "shaders")
|
||||
output_dir = os.path.join(
|
||||
shader_dir,
|
||||
"shader_%s_%s_eid%d_reflection" % (rid_str, stage, event_id),
|
||||
)
|
||||
|
||||
data = export_shader_reflection(
|
||||
handle.controller, event_id, stage,
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
def _human(d):
|
||||
if "error" in d:
|
||||
click.echo("Error: %s" % d["error"])
|
||||
return
|
||||
click.echo("Exported: %s" % d["output_dir"])
|
||||
click.echo(" Stage: %s" % d["stage"])
|
||||
click.echo(" ResourceId: %s" % d["resourceId"])
|
||||
click.echo(" EntryPoint: %s" % d["entryPoint"])
|
||||
click.echo(" Encoding: %s" % d["encoding"])
|
||||
click.echo("")
|
||||
click.echo(" Files:")
|
||||
for f in d.get("files", []):
|
||||
click.echo(" %s" % f)
|
||||
src_files = d.get("source_files", [])
|
||||
if src_files:
|
||||
click.echo("")
|
||||
click.echo(" Debug sources: %d files" % len(src_files))
|
||||
for sf in src_files:
|
||||
click.echo(" %s (%d bytes)" % (sf["original_path"], sf["size"]))
|
||||
click.echo("")
|
||||
click.echo(" CBuffers: %d, ReadOnly: %d, ReadWrite: %d, Samplers: %d" % (
|
||||
d["constantBlocks_count"],
|
||||
d["readOnlyResources_count"],
|
||||
d["readWriteResources_count"],
|
||||
d["samplers_count"],
|
||||
))
|
||||
|
||||
_output(ctx, data, _human)
|
||||
|
||||
|
||||
@pipeline_group.command("dump", hidden=True)
|
||||
@click.argument("event_id", type=int)
|
||||
@click.option("-o", "--output", "output_path", default=None, help="Output JSON file path.")
|
||||
@click.pass_context
|
||||
def pipeline_dump(ctx, event_id, output_path):
|
||||
"""Dump full PipelineState + ShaderReflection at EVENT_ID to JSON.
|
||||
|
||||
Exports the complete pipeline state, shader reflection metadata for all
|
||||
bound stages, and GPU runtime bindings. Intended for human debugging.
|
||||
|
||||
\b
|
||||
Default output: <capture>_exported/pipeline_eid<EID>_dump.json
|
||||
"""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.pipeline import dump_pipeline
|
||||
|
||||
data = dump_pipeline(handle.controller, event_id)
|
||||
|
||||
if output_path is None:
|
||||
export_dir = _get_export_dir(ctx)
|
||||
output_path = os.path.join(export_dir, "pipeline_eid%d_dump.json" % event_id)
|
||||
|
||||
output_path = os.path.abspath(output_path)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
|
||||
|
||||
if ctx.obj.get("json_mode"):
|
||||
_output(ctx, {"path": output_path})
|
||||
else:
|
||||
click.echo(output_path)
|
||||
|
||||
|
||||
@pipeline_group.command("cbuffer")
|
||||
@click.argument("event_id", type=int)
|
||||
@click.option("--stage", default="Fragment", help="Shader stage.")
|
||||
@click.option("--index", "cbuffer_index", default=0, type=int, help="CBuffer index.")
|
||||
@click.pass_context
|
||||
def pipeline_cbuffer(ctx, event_id, stage, cbuffer_index):
|
||||
"""Get constant buffer contents at a specific event."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.pipeline import get_cbuffer_contents
|
||||
|
||||
data = get_cbuffer_contents(handle.controller, event_id, stage, cbuffer_index)
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# resource commands
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@cli.group("resources")
|
||||
def resources_group():
|
||||
"""Resource (buffer/texture) listing and data reading."""
|
||||
pass
|
||||
|
||||
|
||||
@resources_group.command("list")
|
||||
@click.pass_context
|
||||
def resources_list(ctx):
|
||||
"""List all resources in the capture."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.resources import list_resources
|
||||
|
||||
data = list_resources(handle.controller)
|
||||
|
||||
def _human(resources):
|
||||
click.echo(f"Total resources: {len(resources)}")
|
||||
for r in resources:
|
||||
click.echo(f" [{r['resourceId']}] {r['type']}: {r['name']}")
|
||||
|
||||
_output(ctx, data, _human)
|
||||
|
||||
|
||||
@resources_group.command("buffers")
|
||||
@click.pass_context
|
||||
def resources_buffers(ctx):
|
||||
"""List all buffer resources."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.resources import list_buffers
|
||||
|
||||
data = list_buffers(handle.controller)
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
@resources_group.command("read-buffer")
|
||||
@click.argument("resource_id")
|
||||
@click.option("--offset", default=0, type=int, help="Byte offset.")
|
||||
@click.option("--length", default=256, type=int, help="Number of bytes to read.")
|
||||
@click.option("--format", "fmt", default="hex", help="Output format: hex, float32, uint32, raw.")
|
||||
@click.pass_context
|
||||
def resources_read_buffer(ctx, resource_id, offset, length, fmt):
|
||||
"""Read raw buffer data."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.resources import get_buffer_data
|
||||
|
||||
data = get_buffer_data(handle.controller, resource_id, offset, length, fmt)
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# mesh commands
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@cli.group("mesh")
|
||||
def mesh_group():
|
||||
"""Mesh data (vertex inputs/outputs) inspection."""
|
||||
pass
|
||||
|
||||
|
||||
@mesh_group.command("inputs")
|
||||
@click.argument("event_id", type=int)
|
||||
@click.option("--max-vertices", default=100, type=int, help="Max vertices to decode.")
|
||||
@click.pass_context
|
||||
def mesh_inputs(ctx, event_id, max_vertices):
|
||||
"""Get vertex shader inputs at a draw call."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.mesh import get_mesh_inputs
|
||||
|
||||
data = get_mesh_inputs(handle.controller, event_id, max_vertices)
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
@mesh_group.command("outputs")
|
||||
@click.argument("event_id", type=int)
|
||||
@click.option("--max-vertices", default=100, type=int, help="Max vertices to decode.")
|
||||
@click.pass_context
|
||||
def mesh_outputs(ctx, event_id, max_vertices):
|
||||
"""Get post-vertex-shader outputs at a draw call."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.mesh import get_mesh_outputs
|
||||
|
||||
data = get_mesh_outputs(handle.controller, event_id, max_vertices)
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# counter commands
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@cli.group("counters")
|
||||
def counters_group():
|
||||
"""GPU performance counters."""
|
||||
pass
|
||||
|
||||
|
||||
@counters_group.command("list")
|
||||
@click.pass_context
|
||||
def counters_list(ctx):
|
||||
"""List all available GPU counters."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.counters import list_counters
|
||||
|
||||
data = list_counters(handle.controller)
|
||||
|
||||
def _human(counters):
|
||||
click.echo(f"Available GPU counters: {len(counters)}")
|
||||
for c in counters:
|
||||
click.echo(f" [{c['counter']}] {c['name']}: {c['description']}")
|
||||
|
||||
_output(ctx, data, _human)
|
||||
|
||||
|
||||
@counters_group.command("fetch")
|
||||
@click.option("--ids", default=None, help="Comma-separated counter IDs (default: SamplesPassed).")
|
||||
@click.pass_context
|
||||
def counters_fetch(ctx, ids):
|
||||
"""Fetch GPU counter results."""
|
||||
handle = _get_handle(ctx)
|
||||
from cli_anything.renderdoc.core.counters import fetch_counters
|
||||
|
||||
counter_ids = None
|
||||
if ids:
|
||||
counter_ids = [int(i.strip()) for i in ids.split(",")]
|
||||
|
||||
data = fetch_counters(handle.controller, counter_ids)
|
||||
_output(ctx, data)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# pipeline diff (compare two events)
|
||||
# ===========================================================================
|
||||
|
||||
# Secondary capture handle for diff B-side
|
||||
_capture_handle_b = None # type: ignore
|
||||
_capture_handle_b_path = None # type: ignore
|
||||
|
||||
|
||||
def _get_handle_b(ctx: click.Context, path: str):
|
||||
"""Open a second capture file for the B-side of a diff."""
|
||||
global _capture_handle_b, _capture_handle_b_path
|
||||
path_abs = os.path.abspath(path)
|
||||
if _capture_handle_b is not None:
|
||||
if _capture_handle_b_path == path_abs:
|
||||
return _capture_handle_b
|
||||
_capture_handle_b.close()
|
||||
_capture_handle_b = None
|
||||
_capture_handle_b_path = None
|
||||
from cli_anything.renderdoc.core.capture import CaptureHandle
|
||||
from cli_anything.renderdoc.utils.errors import handle_error
|
||||
|
||||
try:
|
||||
_capture_handle_b = CaptureHandle(path)
|
||||
_capture_handle_b_path = path_abs
|
||||
except Exception as e:
|
||||
debug = ctx.obj.get("debug", False)
|
||||
err = handle_error(e, debug=debug)
|
||||
if ctx.obj.get("json_mode"):
|
||||
from cli_anything.renderdoc.utils.output import output_json
|
||||
output_json(err)
|
||||
ctx.exit(1)
|
||||
else:
|
||||
msg = "Failed to open capture-b: %s" % err["error"]
|
||||
if debug and "traceback" in err:
|
||||
msg += "\n" + err["traceback"]
|
||||
raise click.ClickException(msg)
|
||||
return _capture_handle_b
|
||||
|
||||
|
||||
@pipeline_group.command("diff")
|
||||
@click.argument("event_a", type=int)
|
||||
@click.argument("event_b", type=int)
|
||||
@click.option(
|
||||
"--capture-b", "-b",
|
||||
type=click.Path(exists=False),
|
||||
default=None,
|
||||
help="Path to second .rdc capture (default: same as --capture).",
|
||||
)
|
||||
@click.option(
|
||||
"--compact/--no-compact",
|
||||
default=True,
|
||||
help="Omit identical sections (default: compact).",
|
||||
)
|
||||
@click.option(
|
||||
"--output", "-o",
|
||||
type=click.Path(),
|
||||
default=None,
|
||||
help="Output JSON path. Default: auto-generated next to capture.",
|
||||
)
|
||||
@click.pass_context
|
||||
def pipeline_diff_cmd(ctx, event_a, event_b, capture_b, compact, output):
|
||||
"""Compare pipeline state at EVENT_A vs EVENT_B.
|
||||
|
||||
By default both events come from the same capture (--capture).
|
||||
Use --capture-b / -b to specify a second capture file.
|
||||
|
||||
Results are written to a JSON file; only the path is printed to stdout.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
# Two events in different captures
|
||||
cli-anything-renderdoc -c a.rdc pipeline diff 100 200 -b b.rdc
|
||||
# Two events in the same capture
|
||||
cli-anything-renderdoc -c frame.rdc pipeline diff 100 200
|
||||
# Custom output path
|
||||
cli-anything-renderdoc -c a.rdc pipeline diff 100 200 -b b.rdc -o result.json
|
||||
"""
|
||||
handle_a = _get_handle(ctx)
|
||||
if capture_b:
|
||||
handle_b = _get_handle_b(ctx, capture_b)
|
||||
else:
|
||||
handle_b = handle_a
|
||||
|
||||
from cli_anything.renderdoc.core.diff import diff_pipeline
|
||||
|
||||
data = diff_pipeline(
|
||||
handle_a.controller, event_a,
|
||||
handle_b.controller, event_b,
|
||||
)
|
||||
|
||||
if compact:
|
||||
def _prune_same(obj):
|
||||
"""Recursively remove 'SAME' markers and empty containers."""
|
||||
if isinstance(obj, dict):
|
||||
pruned = {}
|
||||
for k, v in obj.items():
|
||||
if v == "SAME":
|
||||
continue
|
||||
cleaned = _prune_same(v)
|
||||
if cleaned is not None:
|
||||
pruned[k] = cleaned
|
||||
return pruned if pruned else None
|
||||
if isinstance(obj, list):
|
||||
pruned = [_prune_same(item) for item in obj if item != "SAME"]
|
||||
pruned = [item for item in pruned if item is not None]
|
||||
return pruned if pruned else None
|
||||
return obj
|
||||
|
||||
data = _prune_same(data) or {}
|
||||
|
||||
# Determine output file path
|
||||
if output is None:
|
||||
capture_a_path = ctx.obj.get("capture_path", "capture")
|
||||
base_dir = os.path.dirname(os.path.abspath(capture_a_path))
|
||||
stem_a = os.path.splitext(os.path.basename(capture_a_path))[0]
|
||||
if capture_b:
|
||||
stem_b = os.path.splitext(os.path.basename(capture_b))[0]
|
||||
output = os.path.join(
|
||||
base_dir,
|
||||
"diff_%s_eid%d_vs_%s_eid%d.json" % (stem_a, event_a, stem_b, event_b),
|
||||
)
|
||||
else:
|
||||
output = os.path.join(
|
||||
base_dir,
|
||||
"diff_%s_eid%d_vs_eid%d.json" % (stem_a, event_a, event_b),
|
||||
)
|
||||
|
||||
output = os.path.abspath(output)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
|
||||
|
||||
if ctx.obj.get("json_mode"):
|
||||
_output(ctx, {"path": output})
|
||||
else:
|
||||
click.echo(output)
|
||||
# Cleanup hook
|
||||
# ===========================================================================
|
||||
|
||||
@cli.result_callback()
|
||||
@click.pass_context
|
||||
def cleanup(ctx, *args, **kwargs):
|
||||
global _repl_mode
|
||||
# REPL invokes cli.main() per line; keep captures open until repl() exits.
|
||||
if _repl_mode:
|
||||
return
|
||||
_close_all_captures()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# REPL
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_context
|
||||
def repl(ctx):
|
||||
"""Start interactive REPL session."""
|
||||
from cli_anything.renderdoc.utils.repl_skin import ReplSkin
|
||||
|
||||
global _repl_mode
|
||||
_repl_mode = True
|
||||
|
||||
skin = ReplSkin("renderdoc", version="0.1.0")
|
||||
skin.print_banner()
|
||||
|
||||
pt_session = skin.create_prompt_session()
|
||||
|
||||
_repl_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",
|
||||
"help": "Show this help",
|
||||
"quit": "Exit REPL",
|
||||
}
|
||||
|
||||
capture_path = ctx.obj.get("capture_path", "")
|
||||
context = os.path.basename(capture_path) if capture_path else ""
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
line = skin.get_input(pt_session, project_name=context, modified=False)
|
||||
if not line:
|
||||
continue
|
||||
if line.lower() in ("quit", "exit", "q"):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
if line.lower() == "help":
|
||||
skin.help(_repl_commands)
|
||||
continue
|
||||
|
||||
args = line.split()
|
||||
try:
|
||||
cli.main(args, standalone_mode=False, obj=ctx.obj)
|
||||
except SystemExit:
|
||||
pass
|
||||
except click.exceptions.UsageError as e:
|
||||
skin.warning("Usage error: %s" % e)
|
||||
except Exception as e:
|
||||
skin.error("%s" % e)
|
||||
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
finally:
|
||||
_close_all_captures()
|
||||
_repl_mode = False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Entry point
|
||||
# ===========================================================================
|
||||
|
||||
def main():
|
||||
cli(obj={})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
- 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,688 @@
|
||||
"""
|
||||
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 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
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 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)
|
||||
@@ -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,521 @@
|
||||
"""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 skills/SKILL.md inside the package
|
||||
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
|
||||
|
||||
# ── 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"
|
||||
|
||||
# ── 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))
|
||||
|
||||
|
||||
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 package's skills/ directory if not provided.
|
||||
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
|
||||
|
||||
# Auto-detect skill path from package layout:
|
||||
# cli_anything/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
if skill_path is None:
|
||||
from pathlib import Path
|
||||
_auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
|
||||
if _auto.is_file():
|
||||
skill_path = str(_auto)
|
||||
self.skill_path = skill_path
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
from pathlib import Path
|
||||
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."""
|
||||
inner = 54
|
||||
|
||||
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}"
|
||||
|
||||
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 = ""
|
||||
|
||||
# Skill path for agent discovery
|
||||
skill_line = None
|
||||
if self.skill_path:
|
||||
skill_icon = self._c(_MAGENTA, "◇")
|
||||
skill_label = self._c(_DARK_GRAY, " Skill:")
|
||||
skill_path_display = self._c(_LIGHT_GRAY, self.skill_path)
|
||||
skill_line = f" {skill_icon} {skill_label} {skill_path_display}"
|
||||
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
if skill_line:
|
||||
print(_box_line(skill_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
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user