dcc-mcp-renderdoc 0.3.3__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. dcc_mcp_renderdoc-0.4.0/.release-please-manifest.json +2 -0
  2. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/CHANGELOG.md +13 -0
  3. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/PKG-INFO +3 -2
  4. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/README.md +2 -1
  5. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/pyproject.toml +1 -1
  6. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/release-please-config.json +1 -1
  7. dcc_mcp_renderdoc-0.4.0/src/dcc_mcp_renderdoc/__version__.py +3 -0
  8. dcc_mcp_renderdoc-0.4.0/src/dcc_mcp_renderdoc/downloader.py +88 -0
  9. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/runtime.py +136 -0
  10. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/SKILL.md +3 -0
  11. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/tools.yaml +1 -1
  12. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/SKILL.md +5 -0
  13. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/tools.yaml +2 -2
  14. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/tests/test_runtime.py +154 -7
  15. dcc_mcp_renderdoc-0.3.3/.release-please-manifest.json +0 -2
  16. dcc_mcp_renderdoc-0.3.3/src/dcc_mcp_renderdoc/__version__.py +0 -3
  17. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/.gitignore +0 -0
  18. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/LICENSE +0 -0
  19. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/__init__.py +0 -0
  20. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/_target_control.py +0 -0
  21. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/server.py +0 -0
  22. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/metadata/depends.md +0 -0
  23. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/scripts/export_thumbnail.py +0 -0
  24. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/scripts/export_timeline.py +0 -0
  25. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/scripts/inspect_capture.py +0 -0
  26. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/metadata/depends.md +0 -0
  27. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/scripts/capture_process.py +0 -0
  28. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/scripts/capture_program.py +0 -0
  29. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/scripts/get_version.py +0 -0
  30. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/tests/live_renderdoc_smoke.py +0 -0
  31. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/tests/renderdoc_smoke.c +0 -0
  32. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/tests/test_download.py +0 -0
  33. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/tests/test_package.py +0 -0
  34. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/tools/__init__.py +0 -0
  35. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/tools/download_latest_renderdoc.py +0 -0
  36. {dcc_mcp_renderdoc-0.3.3 → dcc_mcp_renderdoc-0.4.0}/tools/lint_skills.py +0 -0
@@ -0,0 +1,2 @@
1
+ {".":"0.4.0"}
2
+
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/dcc-mcp/dcc-mcp-renderdoc/compare/v0.3.3...v0.4.0) (2026-07-23)
4
+
5
+
6
+ ### Features
7
+
8
+ * auto-download RenderDoc runtime ([e380b95](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/e380b95c9cd7f86a031e4634b39740111af507ec))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * classify render work by API operation ([f5cf5a6](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/f5cf5a69b31cd4066d47d28c6701262d3c3225ac))
14
+ * reject captures without rendering work ([5799193](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/57991932e74c57e2826ffba10cbb3f433e55dca7))
15
+
3
16
  ## [0.3.3](https://github.com/dcc-mcp/dcc-mcp-renderdoc/compare/v0.3.2...v0.3.3) (2026-07-22)
4
17
 
5
18
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dcc-mcp-renderdoc
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: RenderDoc capture and replay automation skills for DCC-MCP
5
5
  Project-URL: Homepage, https://github.com/dcc-mcp/dcc-mcp-renderdoc
6
6
  Project-URL: Repository, https://github.com/dcc-mcp/dcc-mcp-renderdoc
@@ -105,7 +105,8 @@ through the stable gateway at `http://127.0.0.1:9765/mcp`; set
105
105
  - Launch a game or test executable under RenderDoc and wait for a typed `.rdc` capture.
106
106
  - Trigger a capture through official Target Control after a configurable delay.
107
107
  - Inject into a process that had to be launched by a platform client, then trigger and collect a capture.
108
- - Inspect capture driver, machine identity, chunk version, API-call counts, and representative calls.
108
+ - Reject no-work captures with actionable diagnostics while preserving the `.rdc` artifact.
109
+ - Inspect capture driver, machine identity, chunk version, frame-work and Present counts, and representative calls.
109
110
  - Export a capture thumbnail for visual review.
110
111
  - Export Chrome trace JSON for timeline tooling.
111
112
 
@@ -77,7 +77,8 @@ through the stable gateway at `http://127.0.0.1:9765/mcp`; set
77
77
  - Launch a game or test executable under RenderDoc and wait for a typed `.rdc` capture.
78
78
  - Trigger a capture through official Target Control after a configurable delay.
79
79
  - Inject into a process that had to be launched by a platform client, then trigger and collect a capture.
80
- - Inspect capture driver, machine identity, chunk version, API-call counts, and representative calls.
80
+ - Reject no-work captures with actionable diagnostics while preserving the `.rdc` artifact.
81
+ - Inspect capture driver, machine identity, chunk version, frame-work and Present counts, and representative calls.
81
82
  - Export a capture thumbnail for visual review.
82
83
  - Export Chrome trace JSON for timeline tooling.
83
84
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dcc-mcp-renderdoc"
7
- version = "0.3.3"
7
+ version = "0.4.0"
8
8
  description = "RenderDoc capture and replay automation skills for DCC-MCP"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1 +1 @@
1
- {"packages":{".":{"release-type":"python","package-name":"dcc-mcp-renderdoc","changelog-path":"CHANGELOG.md","include-component-in-tag":false,"extra-files":[{"type":"toml","path":"pyproject.toml","jsonpath":"$.project.version"},{"type":"generic","path":"src/dcc_mcp_renderdoc/__version__.py"},{"type":"toml","path":"uv.lock","jsonpath":"$.package[?(@.name=='dcc-mcp-renderdoc')].version"}]}}}
1
+ {"packages":{".":{"release-type":"python","package-name":"dcc-mcp-renderdoc","changelog-path":"CHANGELOG.md","include-component-in-tag":false,"extra-files":[{"type":"toml","path":"pyproject.toml","jsonpath":"$.project.version"},{"type":"generic","path":"src/dcc_mcp_renderdoc/__version__.py"},{"type":"generic","path":"uv.lock"}]}}}
@@ -0,0 +1,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.4.0" # x-release-please-version
@@ -0,0 +1,88 @@
1
+ """Download and cache the official RenderDoc command-line bundle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import sys
9
+ import tarfile
10
+ import urllib.request
11
+ import zipfile
12
+ from pathlib import Path
13
+
14
+
15
+ def _cache_root() -> Path:
16
+ configured = os.environ.get("DCC_MCP_RUNTIME_CACHE")
17
+ if configured:
18
+ return Path(configured).expanduser().resolve() / "renderdoc"
19
+ if os.name == "nt":
20
+ root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local"))
21
+ return root / "dcc-mcp/renderdoc"
22
+ return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "dcc-mcp/renderdoc"
23
+
24
+
25
+ def _safe_destination(root: Path, member_name: str) -> Path:
26
+ destination = (root / member_name).resolve()
27
+ try:
28
+ destination.relative_to(root.resolve())
29
+ except ValueError as exc:
30
+ raise RuntimeError(f"Archive member escapes destination: {member_name}") from exc
31
+ return destination
32
+
33
+
34
+ def _extract(archive: Path, destination: Path) -> None:
35
+ destination.mkdir(parents=True, exist_ok=True)
36
+ if zipfile.is_zipfile(archive):
37
+ with zipfile.ZipFile(archive) as bundle:
38
+ for member in bundle.infolist():
39
+ _safe_destination(destination, member.filename)
40
+ bundle.extractall(destination)
41
+ return
42
+ with tarfile.open(archive, "r:gz") as bundle:
43
+ for member in bundle.getmembers():
44
+ _safe_destination(destination, member.name)
45
+ if member.issym() or member.islnk():
46
+ raise RuntimeError(f"Archive links are not accepted: {member.name}")
47
+ bundle.extractall(destination)
48
+
49
+
50
+ def download_latest() -> Path:
51
+ request = urllib.request.Request(
52
+ "https://renderdoc.org/builds", headers={"User-Agent": "dcc-mcp-renderdoc"}
53
+ )
54
+ with urllib.request.urlopen(request, timeout=30) as response:
55
+ html = response.read().decode("utf-8")
56
+ versions = set(re.findall(r"/stable/([0-9]+\.[0-9]+)/", html))
57
+ if not versions:
58
+ raise RuntimeError("No stable RenderDoc versions found on the official builds page")
59
+ version = max(versions, key=lambda value: tuple(int(part) for part in value.split(".")))
60
+ if sys.platform == "win32":
61
+ archive_name, command_name = f"RenderDoc_{version}_64.zip", "renderdoccmd.exe"
62
+ elif sys.platform.startswith("linux"):
63
+ archive_name, command_name = f"renderdoc_{version}.tar.gz", "renderdoccmd"
64
+ else:
65
+ raise RuntimeError(f"RenderDoc has no supported desktop bundle for {sys.platform}")
66
+ destination = _cache_root() / version
67
+ existing = next(destination.rglob(command_name), None) if destination.exists() else None
68
+ if existing:
69
+ return existing.resolve()
70
+ archive = _cache_root() / archive_name
71
+ _cache_root().mkdir(parents=True, exist_ok=True)
72
+ try:
73
+ with (
74
+ urllib.request.urlopen(
75
+ f"https://renderdoc.org/stable/{version}/{archive_name}", timeout=120
76
+ ) as response,
77
+ archive.open("wb") as stream,
78
+ ):
79
+ shutil.copyfileobj(response, stream)
80
+ _extract(archive, destination)
81
+ finally:
82
+ archive.unlink(missing_ok=True)
83
+ command = next(destination.rglob(command_name), None)
84
+ if command is None:
85
+ raise RuntimeError(f"Downloaded bundle did not contain {command_name}")
86
+ if sys.platform.startswith("linux"):
87
+ command.chmod(command.stat().st_mode | 0o111)
88
+ return command.resolve()
@@ -16,6 +16,8 @@ from collections import Counter
16
16
  from pathlib import Path
17
17
  from typing import Any, Iterable, Optional, Sequence
18
18
 
19
+ from .downloader import download_latest
20
+
19
21
 
20
22
  class RenderDocError(RuntimeError):
21
23
  """Raised when a RenderDoc operation cannot satisfy its contract."""
@@ -120,6 +122,17 @@ def resolve_renderdoccmd(explicit: Optional[str] = None) -> Path:
120
122
  found = shutil.which(name)
121
123
  if found:
122
124
  return Path(found).resolve()
125
+ if os.environ.get("DCC_MCP_RENDERDOC_AUTO_DOWNLOAD", "1").lower() not in {
126
+ "0",
127
+ "false",
128
+ "no",
129
+ }:
130
+ try:
131
+ return download_latest()
132
+ except (OSError, RuntimeError) as exc:
133
+ raise RenderDocError(
134
+ f"RenderDoc was not found and automatic download failed: {exc}"
135
+ ) from exc
123
136
  raise RenderDocError(
124
137
  "renderdoccmd was not found; install RenderDoc or set DCC_MCP_RENDERDOC_CMD"
125
138
  )
@@ -472,9 +485,11 @@ def capture_program(
472
485
  f"before creating its graphics device. {diagnostics}. "
473
486
  f"RenderDoc output: {output_detail}"
474
487
  )
488
+ capture_validations = _validate_frame_captures(captures, command=command)
475
489
  return {
476
490
  "target": str(target),
477
491
  "captures": [str(path) for path in captures],
492
+ "capture_validations": capture_validations,
478
493
  "focused_target_window": False,
479
494
  "trigger_mode": trigger_mode,
480
495
  "stdout": stdout.strip(),
@@ -542,9 +557,11 @@ def capture_process(
542
557
  f"capture_program. {diagnostics}. "
543
558
  f"RenderDoc output: {output_detail}"
544
559
  )
560
+ capture_validations = _validate_frame_captures(captures, command=command)
545
561
  return {
546
562
  "process_id": process_id,
547
563
  "captures": [str(path) for path in captures],
564
+ "capture_validations": capture_validations,
548
565
  "focused_target_window": False,
549
566
  "trigger_mode": "target_control",
550
567
  "stdout": stdout.strip(),
@@ -626,6 +643,75 @@ def _child_text(parent: ET.Element, name: str) -> Optional[str]:
626
643
  return child.text.strip() if child is not None and child.text else None
627
644
 
628
645
 
646
+ def _chunk_operation(name: str) -> str:
647
+ if name.casefold().startswith("internal::"):
648
+ return ""
649
+ return name.rsplit("::", 1)[-1].casefold()
650
+
651
+
652
+ def _is_draw_or_dispatch_chunk(name: str) -> bool:
653
+ operation = _chunk_operation(name)
654
+ if operation.startswith("gldrawbuffer"):
655
+ return False
656
+ return operation.startswith(
657
+ (
658
+ "draw",
659
+ "dispatch",
660
+ "tracerays",
661
+ "executeindirect",
662
+ "vkcmddraw",
663
+ "vkcmddispatch",
664
+ "vkcmdtracerays",
665
+ "gldraw",
666
+ "glmultidraw",
667
+ "gldispatch",
668
+ )
669
+ )
670
+
671
+
672
+ def _is_frame_work_chunk(name: str) -> bool:
673
+ if _is_draw_or_dispatch_chunk(name):
674
+ return True
675
+ operation = _chunk_operation(name)
676
+ if operation.startswith("gl"):
677
+ clear = operation == "glclear" or operation.startswith(
678
+ ("glclearbuffer", "glclearnamedframebuffer", "glcleartex")
679
+ )
680
+ else:
681
+ clear = operation != "clearstate" and operation.startswith(("clear", "vkcmdclear"))
682
+ return clear or operation.startswith(
683
+ (
684
+ "copy",
685
+ "resolve",
686
+ "blit",
687
+ "vkcmdcopy",
688
+ "vkcmdresolve",
689
+ "vkcmdblit",
690
+ "glcopy",
691
+ "glblit",
692
+ "vkcmdbeginrenderpass",
693
+ "vkcmdbeginrendering",
694
+ "vkcmdexecutecommands",
695
+ "executecommandlist",
696
+ )
697
+ )
698
+
699
+
700
+ def _is_present_chunk(name: str) -> bool:
701
+ operation = _chunk_operation(name)
702
+ return operation.startswith(
703
+ (
704
+ "present",
705
+ "vkqueuepresent",
706
+ "swapbuffers",
707
+ "glxswapbuffers",
708
+ "eglswapbuffers",
709
+ "wglswapbuffers",
710
+ "wglswaplayerbuffers",
711
+ )
712
+ )
713
+
714
+
629
715
  def parse_capture_xml(xml_file: str, *, representative_limit: int = 20) -> dict[str, Any]:
630
716
  """Parse the stable high-level fields emitted by RenderDoc's XML converter."""
631
717
  path = Path(xml_file)
@@ -644,6 +730,9 @@ def parse_capture_xml(xml_file: str, *, representative_limit: int = 20) -> dict[
644
730
  thumbnail = header.find("thumbnail")
645
731
  names = [chunk.get("name") or "unnamed" for chunk in chunks.findall("chunk")]
646
732
  counts = Counter(names)
733
+ draw_dispatch_count = sum(_is_draw_or_dispatch_chunk(name) for name in names)
734
+ frame_work_count = sum(_is_frame_work_chunk(name) for name in names)
735
+ present_count = sum(_is_present_chunk(name) for name in names)
647
736
  return {
648
737
  "driver": {
649
738
  "id": driver.get("id") if driver is not None else None,
@@ -656,6 +745,12 @@ def parse_capture_xml(xml_file: str, *, representative_limit: int = 20) -> dict[
656
745
  },
657
746
  "chunk_version": chunks.get("version"),
658
747
  "chunk_count": len(names),
748
+ "draw_dispatch_count": draw_dispatch_count,
749
+ "frame_work_count": frame_work_count,
750
+ "present_count": present_count,
751
+ "frame_content_status": (
752
+ "rendering_commands_present" if frame_work_count else "no_rendering_work"
753
+ ),
659
754
  "chunk_frequencies": [
660
755
  {"name": name, "count": count} for name, count in counts.most_common(20)
661
756
  ],
@@ -689,6 +784,47 @@ def inspect_capture(
689
784
  return {"capture_file": str(capture), "size_bytes": capture.stat().st_size, **details}
690
785
 
691
786
 
787
+ def _validate_frame_captures(
788
+ captures: Sequence[Path], *, command: Optional[str]
789
+ ) -> list[dict[str, Any]]:
790
+ validations = []
791
+ for capture in captures:
792
+ details = inspect_capture(str(capture), command=command)
793
+ if details["frame_work_count"] == 0:
794
+ diagnostics = {
795
+ "capture_file": details["capture_file"],
796
+ "driver": details["driver"],
797
+ "chunk_count": details["chunk_count"],
798
+ "draw_dispatch_count": details["draw_dispatch_count"],
799
+ "present_count": details["present_count"],
800
+ "representative_chunks": details["representative_chunks"],
801
+ }
802
+ raise RenderDocError(
803
+ "RenderDoc capture contains no rendering work (Draw, Dispatch, Clear, Copy, "
804
+ "Resolve, Blit, render-pass work, or command-list execution) and is not a usable "
805
+ "frame capture. The .rdc was preserved; retry while the target is actively "
806
+ "rendering and verify the selected process or child target. "
807
+ f"diagnostics={json.dumps(diagnostics, sort_keys=True)}"
808
+ )
809
+ validations.append(
810
+ {
811
+ key: details[key]
812
+ for key in (
813
+ "capture_file",
814
+ "size_bytes",
815
+ "driver",
816
+ "thumbnail",
817
+ "chunk_count",
818
+ "draw_dispatch_count",
819
+ "frame_work_count",
820
+ "present_count",
821
+ "frame_content_status",
822
+ )
823
+ }
824
+ )
825
+ return validations
826
+
827
+
692
828
  def _prepare_output(output_file: str, expected_suffixes: Iterable[str]) -> Path:
693
829
  path = Path(output_file).expanduser().resolve()
694
830
  if path.suffix.lower() not in set(expected_suffixes):
@@ -23,3 +23,6 @@ metadata:
23
23
  Inspect before exporting. These tools never modify the input `.rdc`; exports require an explicit
24
24
  destination path and create its parent directory when needed.
25
25
 
26
+ Inspection reports `draw_dispatch_count`, `frame_work_count`, `present_count`, and
27
+ `frame_content_status` so a structurally readable capture with no rendering work is not mistaken
28
+ for a usable frame.
@@ -1,6 +1,6 @@
1
1
  tools:
2
2
  - name: inspect_capture
3
- description: Convert one .rdc capture to temporary XML and summarize stable header and chunk metadata. Use for offline triage. Not for GPU performance conclusions or high-level drawcall reconstruction.
3
+ description: Convert one .rdc capture to temporary XML and summarize stable header, chunk metadata, Draw/Dispatch count, frame-work count, Present count, and frame-content status. Use for offline triage and identifying readable but unusable no-work captures. Not for GPU performance conclusions or high-level drawcall reconstruction.
4
4
  input_schema:
5
5
  type: object
6
6
  required: [capture_file]
@@ -32,6 +32,11 @@ The official RenderDoc runtime supports Windows and Linux, not macOS (where this
32
32
  Python unit tests only). On headless Linux, run the adapter under Xvfb or provide another working
33
33
  X/Wayland display; the official archive does not include Qt's `offscreen` platform plugin.
34
34
 
35
+ Capture tools inspect each new `.rdc` before reporting success. A capture with no frame work such
36
+ as Draw, Dispatch, Clear, Copy, Resolve, Blit, render-pass work, or command-list execution is
37
+ rejected with chunk diagnostics while the `.rdc` remains at the requested output path; retry while
38
+ the intended target is actively rendering or correct the selected child process.
39
+
35
40
  Use `capture_process` when Steam or another platform client must launch the target first. It
36
41
  injects into the explicit PID, connects to the returned Target Control ident, requests one capture
37
42
  after the configured delay, and waits for the resulting file. It never takes foreground focus or
@@ -14,7 +14,7 @@ tools:
14
14
  on-success: [renderdoc_capture__capture_program]
15
15
  on-failure: [dcc_diagnostics__audit_log]
16
16
  - name: capture_program
17
- description: Launch one explicit executable under RenderDoc and return newly created .rdc files. With trigger_after_secs, request the frame through official local Target Control without foreground or keyboard input. With hook_children enabled, trigger_process_name first checks the launched target, then follows only its official NewChild messages to select a unique matching child. Use for controlled game or graphics test capture. Not for shell commands or attaching to an existing process.
17
+ description: Launch one explicit executable under RenderDoc and return newly created .rdc files only when they contain frame work such as Draw, Dispatch, Clear, Copy, Resolve, Blit, render-pass work, or command-list execution. A no-work capture is preserved and reported as a failure with chunk diagnostics. With trigger_after_secs, request the frame through official local Target Control without foreground or keyboard input. With hook_children enabled, trigger_process_name first checks the launched target, then follows only its official NewChild messages to select a unique matching child. Use for controlled game or graphics test capture. Not for shell commands or attaching to an existing process.
18
18
  input_schema:
19
19
  type: object
20
20
  required: [executable, output_template]
@@ -46,7 +46,7 @@ tools:
46
46
  on-success: [renderdoc_analysis__inspect_capture, renderdoc_analysis__export_thumbnail]
47
47
  on-failure: [dcc_diagnostics__audit_log]
48
48
  - name: capture_process
49
- description: Inject RenderDoc into one process before it creates its graphics device, request a capture through official local Target Control after a delay, and return the new .rdc file without foreground or keyboard input. Use when a game must be launched by its platform client. Prefer capture_program for an already initialized game; not for process termination.
49
+ description: Inject RenderDoc into one process before it creates its graphics device, request a capture through official local Target Control after a delay, and return the new .rdc file only when it contains frame work such as Draw, Dispatch, Clear, Copy, Resolve, Blit, render-pass work, or command-list execution. A no-work capture is preserved and reported as a failure with chunk diagnostics. Use when a game must be launched by its platform client. Prefer capture_program for an already initialized game; not for process termination.
50
50
  input_schema:
51
51
  type: object
52
52
  required: [process_id, output_template]
@@ -32,6 +32,23 @@ def _default_target_control_delay(monkeypatch):
32
32
  monkeypatch.setattr(runtime.sys, "platform", "win32")
33
33
 
34
34
 
35
+ @pytest.fixture
36
+ def valid_frame_captures(monkeypatch):
37
+ monkeypatch.setattr(
38
+ runtime,
39
+ "_validate_frame_captures",
40
+ lambda captures, **_kwargs: [
41
+ {
42
+ "capture_file": str(capture),
43
+ "draw_dispatch_count": 1,
44
+ "frame_work_count": 1,
45
+ "frame_content_status": "rendering_commands_present",
46
+ }
47
+ for capture in captures
48
+ ],
49
+ )
50
+
51
+
35
52
  def test_parse_capture_xml_summarizes_stable_fields(tmp_path: Path):
36
53
  xml_file = tmp_path / "capture.xml"
37
54
  xml_file.write_text(
@@ -47,10 +64,90 @@ def test_parse_capture_xml_summarizes_stable_fields(tmp_path: Path):
47
64
  assert result["driver"] == {"id": "2", "name": "Vulkan"}
48
65
  assert result["thumbnail"] == {"width": 640, "height": 360}
49
66
  assert result["chunk_count"] == 3
67
+ assert result["draw_dispatch_count"] == 2
68
+ assert result["frame_work_count"] == 2
69
+ assert result["present_count"] == 1
70
+ assert result["frame_content_status"] == "rendering_commands_present"
50
71
  assert result["chunk_frequencies"][0] == {"name": "vkCmdDraw", "count": 2}
51
72
  assert result["representative_chunks"] == ["vkCmdDraw", "vkCmdDraw"]
52
73
 
53
74
 
75
+ def test_parse_capture_xml_accepts_clear_and_copy_only_frame_work(tmp_path: Path):
76
+ xml_file = tmp_path / "capture.xml"
77
+ xml_file.write_text(
78
+ """<rdc><header><driver id="1">D3D11</driver></header><chunks version="1">
79
+ <chunk name="ID3D11DeviceContext::ClearRenderTargetView"/>
80
+ <chunk name="ID3D11DeviceContext::CopyResource"/>
81
+ <chunk name="IDXGISwapChain::Present"/>
82
+ </chunks></rdc>""",
83
+ encoding="utf-8",
84
+ )
85
+
86
+ result = runtime.parse_capture_xml(str(xml_file))
87
+
88
+ assert result["draw_dispatch_count"] == 0
89
+ assert result["frame_work_count"] == 2
90
+ assert result["frame_content_status"] == "rendering_commands_present"
91
+
92
+
93
+ def test_parse_capture_xml_rejects_opengl_state_only_frame(tmp_path: Path):
94
+ xml_file = tmp_path / "capture.xml"
95
+ xml_file.write_text(
96
+ """<rdc><header><driver id="3">OpenGL</driver></header><chunks version="1">
97
+ <chunk name="glDrawBuffer"/><chunk name="glDrawBuffers"/>
98
+ <chunk name="glNamedFramebufferDrawBuffer"/>
99
+ <chunk name="glNamedFramebufferDrawBuffers"/>
100
+ <chunk name="glClearColor"/><chunk name="glClearDepth"/>
101
+ <chunk name="glClearStencil"/><chunk name="SwapBuffers"/>
102
+ </chunks></rdc>""",
103
+ encoding="utf-8",
104
+ )
105
+
106
+ result = runtime.parse_capture_xml(str(xml_file))
107
+
108
+ assert result["draw_dispatch_count"] == 0
109
+ assert result["frame_work_count"] == 0
110
+ assert result["present_count"] == 1
111
+ assert result["frame_content_status"] == "no_rendering_work"
112
+
113
+
114
+ def test_parse_capture_xml_accepts_vulkan_render_pass_work(tmp_path: Path):
115
+ xml_file = tmp_path / "capture.xml"
116
+ xml_file.write_text(
117
+ """<rdc><header><driver id="2">Vulkan</driver></header><chunks version="1">
118
+ <chunk name="vkCmdBeginRenderPass"/><chunk name="vkCmdBeginRendering"/>
119
+ <chunk name="vkQueuePresentKHR"/>
120
+ </chunks></rdc>""",
121
+ encoding="utf-8",
122
+ )
123
+
124
+ result = runtime.parse_capture_xml(str(xml_file))
125
+
126
+ assert result["draw_dispatch_count"] == 0
127
+ assert result["frame_work_count"] == 2
128
+ assert result["present_count"] == 1
129
+ assert result["frame_content_status"] == "rendering_commands_present"
130
+
131
+
132
+ def test_parse_capture_xml_accepts_explicit_command_list_execution(tmp_path: Path):
133
+ xml_file = tmp_path / "capture.xml"
134
+ xml_file.write_text(
135
+ """<rdc><header><driver id="2">Vulkan</driver></header><chunks version="1">
136
+ <chunk name="vkCmdExecuteCommands"/>
137
+ <chunk name="ID3D11DeviceContext::ExecuteCommandList"/>
138
+ <chunk name="ID3D12CommandQueue::ExecuteCommandLists"/>
139
+ <chunk name="vkQueuePresentKHR"/>
140
+ </chunks></rdc>""",
141
+ encoding="utf-8",
142
+ )
143
+
144
+ result = runtime.parse_capture_xml(str(xml_file))
145
+
146
+ assert result["draw_dispatch_count"] == 0
147
+ assert result["frame_work_count"] == 3
148
+ assert result["present_count"] == 1
149
+
150
+
54
151
  def test_resolve_renderdoccmd_prefers_explicit_file(tmp_path: Path):
55
152
  executable = tmp_path / "renderdoccmd"
56
153
  executable.touch()
@@ -63,8 +160,17 @@ def test_capture_program_uses_argument_vector_and_reports_new_capture(tmp_path,
63
160
  observed = {}
64
161
 
65
162
  def fake_run(arguments, **_kwargs):
66
- observed["arguments"] = arguments
67
- (tmp_path / "capture_frame1.rdc").write_bytes(b"rdc")
163
+ if arguments[0] == "capture":
164
+ observed["arguments"] = arguments
165
+ (tmp_path / "capture_frame1.rdc").write_bytes(b"rdc")
166
+ elif arguments[0] == "convert":
167
+ xml_file = Path(arguments[arguments.index("--output") + 1])
168
+ xml_file.write_text(
169
+ """<rdc><header><driver id="2">Vulkan</driver></header><chunks version="1">
170
+ <chunk name="vkCmdDrawIndexed"/><chunk name="Present"/>
171
+ </chunks></rdc>""",
172
+ encoding="utf-8",
173
+ )
68
174
  return CompletedProcess(arguments, 0, "captured", "")
69
175
 
70
176
  monkeypatch.setattr(runtime, "_run", fake_run)
@@ -76,6 +182,36 @@ def test_capture_program_uses_argument_vector_and_reports_new_capture(tmp_path,
76
182
 
77
183
  assert observed["arguments"][-3:] == [str(target.resolve()), "--scene", "demo"]
78
184
  assert result["captures"] == [str((tmp_path / "capture_frame1.rdc").resolve())]
185
+ assert result["capture_validations"][0]["draw_dispatch_count"] == 1
186
+
187
+
188
+ def test_capture_program_rejects_present_only_capture_and_preserves_rdc(tmp_path, monkeypatch):
189
+ target = tmp_path / "game.exe"
190
+ target.touch()
191
+ capture = tmp_path / "capture_frame1.rdc"
192
+
193
+ def fake_run(arguments, **_kwargs):
194
+ if arguments[0] == "capture":
195
+ capture.write_bytes(b"rdc")
196
+ elif arguments[0] == "convert":
197
+ xml_file = Path(arguments[arguments.index("--output") + 1])
198
+ xml_file.write_text(
199
+ """<rdc><header><driver id="1">D3D11</driver></header><chunks version="1">
200
+ <chunk name="Internal::Drawcall Metadata"/>
201
+ <chunk name="ID3D11DeviceContext::RSSetViewports"/>
202
+ <chunk name="ID3D11DeviceContext::ClearState"/>
203
+ <chunk name="IDXGISwapChain::Present"/>
204
+ </chunks></rdc>""",
205
+ encoding="utf-8",
206
+ )
207
+ return CompletedProcess(arguments, 0, "", "")
208
+
209
+ monkeypatch.setattr(runtime, "_run", fake_run)
210
+
211
+ with pytest.raises(runtime.RenderDocError, match="no rendering work"):
212
+ runtime.capture_program(str(target), str(tmp_path / "capture"))
213
+
214
+ assert capture.is_file()
79
215
 
80
216
 
81
217
  def test_non_waiting_launch_accepts_renderdoc_target_id(monkeypatch, tmp_path):
@@ -1395,7 +1531,7 @@ def test_bundled_target_control_is_python36_compatible():
1395
1531
  ast.parse(source, feature_version=(3, 6))
1396
1532
 
1397
1533
 
1398
- def test_capture_program_reuses_target_control_trigger(tmp_path, monkeypatch):
1534
+ def test_capture_program_reuses_target_control_trigger(tmp_path, monkeypatch, valid_frame_captures):
1399
1535
  target = tmp_path / "game.exe"
1400
1536
  target.touch()
1401
1537
  capture = tmp_path / "capture_frame1.rdc"
@@ -1453,7 +1589,7 @@ def test_capture_program_rejects_child_name_without_child_hook(tmp_path):
1453
1589
  )
1454
1590
 
1455
1591
 
1456
- def test_capture_process_reuses_target_control_trigger(tmp_path, monkeypatch):
1592
+ def test_capture_process_reuses_target_control_trigger(tmp_path, monkeypatch, valid_frame_captures):
1457
1593
  capture = tmp_path / "capture_frame1.rdc"
1458
1594
  observed = {}
1459
1595
  monkeypatch.setattr(
@@ -1494,7 +1630,9 @@ def test_capture_process_reuses_target_control_trigger(tmp_path, monkeypatch):
1494
1630
  assert result["trigger_mode"] == "target_control"
1495
1631
 
1496
1632
 
1497
- def test_capture_process_injects_triggers_and_reports_capture(tmp_path, monkeypatch):
1633
+ def test_capture_process_injects_triggers_and_reports_capture(
1634
+ tmp_path, monkeypatch, valid_frame_captures
1635
+ ):
1498
1636
  observed = {}
1499
1637
  capture = tmp_path / "capture_frame1.rdc"
1500
1638
 
@@ -1522,7 +1660,7 @@ def test_capture_process_injects_triggers_and_reports_capture(tmp_path, monkeypa
1522
1660
 
1523
1661
 
1524
1662
  def test_capture_process_triggers_while_injector_is_running_and_only_stops_injector(
1525
- tmp_path, monkeypatch
1663
+ tmp_path, monkeypatch, valid_frame_captures
1526
1664
  ):
1527
1665
  started = {}
1528
1666
  target = {"running": True}
@@ -1628,7 +1766,9 @@ def test_capture_process_readiness_timeout_force_stops_only_injector(tmp_path, m
1628
1766
  assert target["running"] is True
1629
1767
 
1630
1768
 
1631
- def test_triggered_program_capture_keeps_controller_alive_until_capture(tmp_path, monkeypatch):
1769
+ def test_triggered_program_capture_keeps_controller_alive_until_capture(
1770
+ tmp_path, monkeypatch, valid_frame_captures
1771
+ ):
1632
1772
  target = tmp_path / "game.exe"
1633
1773
  target.touch()
1634
1774
  command = tmp_path / "renderdoccmd.exe"
@@ -1684,3 +1824,10 @@ def test_triggered_program_capture_keeps_controller_alive_until_capture(tmp_path
1684
1824
  def test_invalid_capture_is_rejected(tmp_path: Path):
1685
1825
  with pytest.raises(runtime.RenderDocError, match="not .rdc"):
1686
1826
  runtime.inspect_capture(str(tmp_path / "missing.rdc"))
1827
+
1828
+
1829
+ def test_missing_runtime_can_disable_auto_download(monkeypatch, tmp_path: Path):
1830
+ monkeypatch.setenv("DCC_MCP_RENDERDOC_CMD", str(tmp_path / "missing"))
1831
+ monkeypatch.setenv("DCC_MCP_RENDERDOC_AUTO_DOWNLOAD", "0")
1832
+ with pytest.raises(runtime.RenderDocError, match="does not exist"):
1833
+ runtime.resolve_renderdoccmd()
@@ -1,2 +0,0 @@
1
- {".":"0.3.3"}
2
-
@@ -1,3 +0,0 @@
1
- """Package version."""
2
-
3
- __version__ = "0.3.3" # x-release-please-version