mirror of
https://fastgit.cc/github.com/HKUDS/CLI-Anything
synced 2026-04-21 05:10:42 +08:00
- pipeline: use GetComputePipelineObject for ShaderStage.Compute in disasm, cbuffer, reflection export, dump/diff - cli: invalidate cached CaptureHandle when capture_path changes (REPL -c) - capture: remove unused struct/json/Tuple imports Made-with: Cursor
924 lines
31 KiB
Python
924 lines
31 KiB
Python
#!/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()
|