dcc-mcp-renderdoc 0.2.0__tar.gz → 0.3.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.3.0/.release-please-manifest.json +2 -0
  2. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/CHANGELOG.md +21 -0
  3. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/PKG-INFO +13 -3
  4. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/README.md +11 -1
  5. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/pyproject.toml +2 -2
  6. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/release-please-config.json +1 -2
  7. dcc_mcp_renderdoc-0.3.0/src/dcc_mcp_renderdoc/__version__.py +3 -0
  8. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/runtime.py +221 -9
  9. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/server.py +3 -8
  10. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/SKILL.md +6 -1
  11. dcc_mcp_renderdoc-0.3.0/src/dcc_mcp_renderdoc/skills/renderdoc-capture/scripts/capture_process.py +32 -0
  12. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/scripts/capture_program.py +6 -0
  13. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/tools.yaml +28 -1
  14. dcc_mcp_renderdoc-0.3.0/tests/test_package.py +65 -0
  15. dcc_mcp_renderdoc-0.3.0/tests/test_runtime.py +192 -0
  16. dcc_mcp_renderdoc-0.2.0/.release-please-manifest.json +0 -2
  17. dcc_mcp_renderdoc-0.2.0/src/dcc_mcp_renderdoc/__version__.py +0 -3
  18. dcc_mcp_renderdoc-0.2.0/tests/test_package.py +0 -22
  19. dcc_mcp_renderdoc-0.2.0/tests/test_runtime.py +0 -57
  20. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/.gitignore +0 -0
  21. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/LICENSE +0 -0
  22. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/__init__.py +0 -0
  23. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/SKILL.md +0 -0
  24. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/metadata/depends.md +0 -0
  25. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/scripts/export_thumbnail.py +0 -0
  26. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/scripts/export_timeline.py +0 -0
  27. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/scripts/inspect_capture.py +0 -0
  28. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/tools.yaml +0 -0
  29. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/metadata/depends.md +0 -0
  30. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/scripts/get_version.py +0 -0
  31. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/tests/live_renderdoc_smoke.py +0 -0
  32. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/tests/renderdoc_smoke.c +0 -0
  33. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/tests/test_download.py +0 -0
  34. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/tools/__init__.py +0 -0
  35. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/tools/download_latest_renderdoc.py +0 -0
  36. {dcc_mcp_renderdoc-0.2.0 → dcc_mcp_renderdoc-0.3.0}/tools/lint_skills.py +0 -0
@@ -0,0 +1,2 @@
1
+ {".":"0.3.0"}
2
+
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/dcc-mcp/dcc-mcp-renderdoc/compare/v0.2.1...v0.3.0) (2026-07-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * automate interactive frame capture ([3131ed3](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/3131ed35a5f252993c7a099a05071e73313126c3))
9
+ * default adapter instances to dynamic ports ([#6](https://github.com/dcc-mcp/dcc-mcp-renderdoc/issues/6)) ([b4bd77b](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/b4bd77bfc487755fc1aadc390997be638382ada1))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * keep capture diagnostics cross-platform ([a477bfd](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/a477bfd9f32d609f141d6d77d0576a861fd60bf1))
15
+ * report capture target diagnostics ([861c852](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/861c8528b35a13fdd8441a89ede80f967e811839))
16
+
17
+ ## [0.2.1](https://github.com/dcc-mcp/dcc-mcp-renderdoc/compare/v0.2.0...v0.2.1) (2026-07-15)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * keep runtime version aligned with package ([b3eb754](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/b3eb754c98416dd1d0b467691949e61bd679cb88))
23
+
3
24
  ## [0.2.0](https://github.com/dcc-mcp/dcc-mcp-renderdoc/compare/v0.1.0...v0.2.0) (2026-07-15)
4
25
 
5
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dcc-mcp-renderdoc
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Topic :: Games/Entertainment
20
20
  Requires-Python: >=3.9
21
- Requires-Dist: dcc-mcp-core<1.0.0,>=0.19.38
21
+ Requires-Dist: dcc-mcp-core<1.0.0,>=0.19.45
22
22
  Provides-Extra: dev
23
23
  Requires-Dist: build>=1.2; extra == 'dev'
24
24
  Requires-Dist: pytest>=8; extra == 'dev'
@@ -49,9 +49,15 @@ dcc-mcp-renderdoc
49
49
 
50
50
  On Windows, set the variable to `renderdoccmd.exe`.
51
51
 
52
+ Each adapter instance uses an OS-assigned MCP port and registers it for CLI discovery. Connect
53
+ through the stable gateway at `http://127.0.0.1:9765/mcp`; set
54
+ `DCC_MCP_RENDERDOC_PORT` only when a fixed direct endpoint is required.
55
+
52
56
  ## Agent workflows
53
57
 
54
58
  - Launch a game or test executable under RenderDoc and wait for a typed `.rdc` capture.
59
+ - Trigger F12 automatically after a configurable delay, with optional child-process window focus.
60
+ - Inject into a visible Windows process that had to be launched by a platform client, then trigger and collect a capture.
55
61
  - Inspect capture driver, machine identity, chunk version, API-call counts, and representative calls.
56
62
  - Export a capture thumbnail for visual review.
57
63
  - Export Chrome trace JSON for timeline tooling.
@@ -59,6 +65,11 @@ On Windows, set the variable to `renderdoccmd.exe`.
59
65
  The capture tool launches only the explicit executable and arguments supplied by the caller. It
60
66
  never invokes a shell. Analysis tools are read-only with respect to the `.rdc` input.
61
67
 
68
+ For interactive Windows programs, pass `trigger_after_secs` to `capture_program`. When a launcher
69
+ creates the rendered child process, also pass `trigger_process_name`. Use `capture_process` only
70
+ when the target is already running; late injection may not capture graphics devices created before
71
+ RenderDoc was attached.
72
+
62
73
  ## Real CI
63
74
 
64
75
  CI discovers the current stable RenderDoc build from the official downloads page. It compiles a
@@ -76,4 +87,3 @@ uv run python tools/lint_skills.py
76
87
 
77
88
  RenderDoc is an MIT-licensed graphics debugger maintained independently at
78
89
  [renderdoc.org](https://renderdoc.org/). This adapter is not affiliated with the RenderDoc project.
79
-
@@ -21,9 +21,15 @@ dcc-mcp-renderdoc
21
21
 
22
22
  On Windows, set the variable to `renderdoccmd.exe`.
23
23
 
24
+ Each adapter instance uses an OS-assigned MCP port and registers it for CLI discovery. Connect
25
+ through the stable gateway at `http://127.0.0.1:9765/mcp`; set
26
+ `DCC_MCP_RENDERDOC_PORT` only when a fixed direct endpoint is required.
27
+
24
28
  ## Agent workflows
25
29
 
26
30
  - Launch a game or test executable under RenderDoc and wait for a typed `.rdc` capture.
31
+ - Trigger F12 automatically after a configurable delay, with optional child-process window focus.
32
+ - Inject into a visible Windows process that had to be launched by a platform client, then trigger and collect a capture.
27
33
  - Inspect capture driver, machine identity, chunk version, API-call counts, and representative calls.
28
34
  - Export a capture thumbnail for visual review.
29
35
  - Export Chrome trace JSON for timeline tooling.
@@ -31,6 +37,11 @@ On Windows, set the variable to `renderdoccmd.exe`.
31
37
  The capture tool launches only the explicit executable and arguments supplied by the caller. It
32
38
  never invokes a shell. Analysis tools are read-only with respect to the `.rdc` input.
33
39
 
40
+ For interactive Windows programs, pass `trigger_after_secs` to `capture_program`. When a launcher
41
+ creates the rendered child process, also pass `trigger_process_name`. Use `capture_process` only
42
+ when the target is already running; late injection may not capture graphics devices created before
43
+ RenderDoc was attached.
44
+
34
45
  ## Real CI
35
46
 
36
47
  CI discovers the current stable RenderDoc build from the official downloads page. It compiles a
@@ -48,4 +59,3 @@ uv run python tools/lint_skills.py
48
59
 
49
60
  RenderDoc is an MIT-licensed graphics debugger maintained independently at
50
61
  [renderdoc.org](https://renderdoc.org/). This adapter is not affiliated with the RenderDoc project.
51
-
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dcc-mcp-renderdoc"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "RenderDoc capture and replay automation skills for DCC-MCP"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.12",
22
22
  "Topic :: Games/Entertainment",
23
23
  ]
24
- dependencies = ["dcc-mcp-core>=0.19.38,<1.0.0"]
24
+ dependencies = ["dcc-mcp-core>=0.19.45,<1.0.0"]
25
25
 
26
26
  [project.urls]
27
27
  Homepage = "https://github.com/dcc-mcp/dcc-mcp-renderdoc"
@@ -1,2 +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"}]}}}
2
-
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"}]}}}
@@ -0,0 +1,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.3.0" # x-release-please-version
@@ -3,9 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import re
6
7
  import shutil
7
8
  import subprocess
9
+ import sys
8
10
  import tempfile
11
+ import time
9
12
  import xml.etree.ElementTree as ET
10
13
  from collections import Counter
11
14
  from pathlib import Path
@@ -39,6 +42,7 @@ def _run(
39
42
  *,
40
43
  timeout_secs: int,
41
44
  command: Optional[str] = None,
45
+ accept_launched_id: bool = False,
42
46
  ) -> subprocess.CompletedProcess[str]:
43
47
  executable = resolve_renderdoccmd(command)
44
48
  try:
@@ -56,7 +60,13 @@ def _run(
56
60
  raise RenderDocError(f"Could not start RenderDoc: {exc}") from exc
57
61
  if result.returncode != 0:
58
62
  detail = (result.stderr or result.stdout or "unknown error").strip()[-2000:]
59
- raise RenderDocError(f"RenderDoc exited with code {result.returncode}: {detail}")
63
+ launched = re.search(r"Launched as ID (\d+)", detail)
64
+ if not (
65
+ accept_launched_id
66
+ and launched is not None
67
+ and int(launched.group(1)) == result.returncode
68
+ ):
69
+ raise RenderDocError(f"RenderDoc exited with code {result.returncode}: {detail}")
60
70
  return result
61
71
 
62
72
 
@@ -75,6 +85,9 @@ def capture_program(
75
85
  wait_for_exit: bool = True,
76
86
  api_validation: bool = False,
77
87
  hook_children: bool = False,
88
+ trigger_after_secs: Optional[float] = None,
89
+ trigger_process_name: Optional[str] = None,
90
+ capture_wait_secs: int = 30,
78
91
  timeout_secs: int = 300,
79
92
  command: Optional[str] = None,
80
93
  ) -> dict[str, Any]:
@@ -90,34 +103,233 @@ def capture_program(
90
103
 
91
104
  before = {path.resolve() for path in output.parent.glob("*.rdc")}
92
105
  cli_args = ["capture", "--working-dir", str(cwd), "--capture-file", str(output)]
93
- if wait_for_exit:
106
+ effective_wait_for_exit = wait_for_exit and trigger_after_secs is None
107
+ if effective_wait_for_exit:
94
108
  cli_args.append("--wait-for-exit")
95
109
  if api_validation:
96
110
  cli_args.append("--opt-api-validation")
97
111
  if hook_children:
98
112
  cli_args.append("--opt-hook-children")
99
113
  cli_args.extend([str(target), *(str(value) for value in arguments or [])])
100
- result = _run(cli_args, timeout_secs=timeout_secs, command=command)
101
-
102
- captures = sorted(
103
- (path.resolve() for path in output.parent.glob("*.rdc") if path.resolve() not in before),
104
- key=lambda path: path.stat().st_mtime_ns,
114
+ result = _run(
115
+ cli_args,
116
+ timeout_secs=timeout_secs,
117
+ command=command,
118
+ accept_launched_id=not effective_wait_for_exit,
105
119
  )
120
+ target_pid: Optional[int] = None
121
+ if trigger_after_secs is not None:
122
+ time.sleep(max(0.0, trigger_after_secs))
123
+ target_pid = _wait_for_visible_process(
124
+ trigger_process_name or target.name,
125
+ min(capture_wait_secs, 30),
126
+ )
127
+ focused = _trigger_capture_hotkey(target_pid)
128
+ captures = _wait_for_captures(output.parent, before, capture_wait_secs)
129
+ else:
130
+ focused = False
131
+ captures = _new_captures(output.parent, before)
106
132
  if not captures:
107
133
  output_detail = "\n".join(
108
134
  part.strip() for part in (result.stdout, result.stderr) if part.strip()
109
135
  )[-2000:]
136
+ diagnostics = _capture_failure_diagnostics(
137
+ process_id=target_pid,
138
+ process_name=trigger_process_name or target.name,
139
+ focused=focused,
140
+ )
110
141
  raise RenderDocError(
111
- "The target exited without creating a new .rdc capture; ensure it presents a frame "
112
- f"or uses RenderDoc's in-application capture API. RenderDoc output: {output_detail}"
142
+ "No new .rdc capture appeared; ensure the target presents a frame and was hooked "
143
+ f"before creating its graphics device. {diagnostics}. "
144
+ f"RenderDoc output: {output_detail}"
113
145
  )
114
146
  return {
115
147
  "target": str(target),
116
148
  "captures": [str(path) for path in captures],
149
+ "focused_target_window": focused,
150
+ "stdout": result.stdout.strip(),
151
+ }
152
+
153
+
154
+ def capture_process(
155
+ process_id: int,
156
+ output_template: str,
157
+ *,
158
+ working_directory: Optional[str] = None,
159
+ trigger_after_secs: float = 2.0,
160
+ capture_wait_secs: int = 30,
161
+ api_validation: bool = False,
162
+ timeout_secs: int = 60,
163
+ command: Optional[str] = None,
164
+ ) -> dict[str, Any]:
165
+ """Inject into one live process, trigger F12, and return the new capture."""
166
+ if process_id <= 0:
167
+ raise RenderDocError("Process ID must be a positive integer")
168
+ output = Path(output_template).expanduser().resolve()
169
+ output.parent.mkdir(parents=True, exist_ok=True)
170
+ before = {path.resolve() for path in output.parent.glob("*.rdc")}
171
+ cli_args = ["inject", f"--PID={process_id}", "--capture-file", str(output)]
172
+ if working_directory:
173
+ cwd = Path(working_directory).expanduser().resolve()
174
+ if not cwd.is_dir():
175
+ raise RenderDocError(f"Working directory does not exist: {cwd}")
176
+ cli_args.extend(["--working-dir", str(cwd)])
177
+ if api_validation:
178
+ cli_args.append("--opt-api-validation")
179
+ result = _run(
180
+ cli_args,
181
+ timeout_secs=timeout_secs,
182
+ command=command,
183
+ accept_launched_id=True,
184
+ )
185
+ time.sleep(max(0.0, trigger_after_secs))
186
+ focused = _trigger_capture_hotkey(process_id)
187
+ captures = _wait_for_captures(output.parent, before, capture_wait_secs)
188
+ if not captures:
189
+ output_detail = "\n".join(
190
+ part.strip() for part in (result.stdout, result.stderr) if part.strip()
191
+ )[-2000:]
192
+ diagnostics = _capture_failure_diagnostics(
193
+ process_id=process_id,
194
+ process_name=None,
195
+ focused=focused,
196
+ )
197
+ raise RenderDocError(
198
+ "No .rdc capture appeared after injection and F12; ensure the target window is visible "
199
+ f"and uses a graphics API supported by RenderDoc. {diagnostics}. "
200
+ f"RenderDoc output: {output_detail}"
201
+ )
202
+ return {
203
+ "process_id": process_id,
204
+ "captures": [str(path) for path in captures],
205
+ "focused_target_window": focused,
117
206
  "stdout": result.stdout.strip(),
118
207
  }
119
208
 
120
209
 
210
+ def _new_captures(directory: Path, before: set[Path]) -> list[Path]:
211
+ return sorted(
212
+ (path.resolve() for path in directory.glob("*.rdc") if path.resolve() not in before),
213
+ key=lambda path: path.stat().st_mtime_ns,
214
+ )
215
+
216
+
217
+ def _wait_for_captures(directory: Path, before: set[Path], timeout_secs: int) -> list[Path]:
218
+ deadline = time.monotonic() + timeout_secs
219
+ while time.monotonic() < deadline:
220
+ captures = _new_captures(directory, before)
221
+ if captures:
222
+ return captures
223
+ time.sleep(0.25)
224
+ return []
225
+
226
+
227
+ def _trigger_capture_hotkey(process_id: Optional[int] = None) -> bool:
228
+ if sys.platform != "win32":
229
+ raise RenderDocError("Automatic F12 capture triggering is currently supported on Windows")
230
+ import ctypes
231
+ from ctypes import wintypes
232
+
233
+ user32 = ctypes.windll.user32
234
+ target = wintypes.HWND()
235
+ if process_id is not None:
236
+ callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
237
+
238
+ @callback_type
239
+ def find_window(window, _extra):
240
+ nonlocal target
241
+ owner = wintypes.DWORD()
242
+ user32.GetWindowThreadProcessId(window, ctypes.byref(owner))
243
+ if owner.value == process_id and user32.IsWindowVisible(window):
244
+ target = window
245
+ return False
246
+ return True
247
+
248
+ user32.EnumWindows(find_window, 0)
249
+ if target:
250
+ user32.SetForegroundWindow(target)
251
+ time.sleep(0.15)
252
+ user32.keybd_event(0x7B, 0, 0, 0)
253
+ user32.keybd_event(0x7B, 0, 2, 0)
254
+ return bool(target)
255
+
256
+
257
+ def _wait_for_visible_process(process_name: str, timeout_secs: int) -> Optional[int]:
258
+ if sys.platform != "win32":
259
+ return None
260
+ deadline = time.monotonic() + timeout_secs
261
+ while time.monotonic() < deadline:
262
+ process_id = _visible_process_id(process_name)
263
+ if process_id is not None:
264
+ return process_id
265
+ time.sleep(0.25)
266
+ return None
267
+
268
+
269
+ def _visible_process_id(process_name: str) -> Optional[int]:
270
+ expected = Path(process_name).name.casefold()
271
+ for process in _visible_processes():
272
+ if process["name"].casefold() == expected:
273
+ return int(process["process_id"])
274
+ return None
275
+
276
+
277
+ def _visible_processes(limit: int = 64) -> list[dict[str, Any]]:
278
+ if sys.platform != "win32":
279
+ return []
280
+
281
+ import ctypes
282
+ from ctypes import wintypes
283
+
284
+ user32 = ctypes.windll.user32
285
+ kernel32 = ctypes.windll.kernel32
286
+ found: list[dict[str, Any]] = []
287
+ seen: set[int] = set()
288
+ callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
289
+
290
+ @callback_type
291
+ def find_window(window, _extra):
292
+ if not user32.IsWindowVisible(window) or len(found) >= limit:
293
+ return True
294
+ owner = wintypes.DWORD()
295
+ user32.GetWindowThreadProcessId(window, ctypes.byref(owner))
296
+ if not owner.value or owner.value in seen:
297
+ return True
298
+ process = kernel32.OpenProcess(0x1000, False, owner.value)
299
+ if not process:
300
+ return True
301
+ try:
302
+ size = wintypes.DWORD(32768)
303
+ path = ctypes.create_unicode_buffer(size.value)
304
+ if kernel32.QueryFullProcessImageNameW(process, 0, path, ctypes.byref(size)):
305
+ seen.add(owner.value)
306
+ found.append({"process_id": owner.value, "name": Path(path.value).name})
307
+ finally:
308
+ kernel32.CloseHandle(process)
309
+ return True
310
+
311
+ user32.EnumWindows(find_window, 0)
312
+ return found
313
+
314
+
315
+ def _capture_failure_diagnostics(
316
+ *, process_id: Optional[int], process_name: Optional[str], focused: bool
317
+ ) -> str:
318
+ visible = _visible_processes()
319
+ if process_id is not None:
320
+ match = next((process for process in visible if process["process_id"] == process_id), None)
321
+ target = f"{match['name']}(pid={process_id})" if match else f"pid={process_id}:not-visible"
322
+ else:
323
+ target = f"{Path(process_name).name}:not-found" if process_name else "not-found"
324
+ snapshot = ", ".join(
325
+ f"{process['name']}(pid={process['process_id']})" for process in visible[:20]
326
+ )
327
+ return (
328
+ f"target_process={target}; focused_target_window={focused}; "
329
+ f"visible_processes=[{snapshot or 'none'}]"
330
+ )
331
+
332
+
121
333
  def _require_capture(capture_file: str) -> Path:
122
334
  path = Path(capture_file).expanduser().resolve()
123
335
  if not path.is_file() or path.suffix.lower() != ".rdc":
@@ -15,14 +15,14 @@ from dcc_mcp_core.server_base import DccServerBase
15
15
  from .__version__ import __version__
16
16
  from .runtime import RenderDocError, get_version
17
17
 
18
- DEFAULT_PORT = 8765
18
+ DEFAULT_PORT = 0
19
19
  _server: Optional["RenderDocMcpServer"] = None
20
20
 
21
21
 
22
22
  class RenderDocMcpServer(DccServerBase):
23
23
  """Headless DCC-MCP adapter backed by renderdoccmd."""
24
24
 
25
- def __init__(self, port: int = DEFAULT_PORT) -> None:
25
+ def __init__(self, port: Optional[int] = None) -> None:
26
26
  os.environ.setdefault("DCC_MCP_PYTHON_EXECUTABLE", sys.executable)
27
27
  options = DccServerOptions.from_env(
28
28
  "renderdoc",
@@ -43,12 +43,7 @@ class RenderDocMcpServer(DccServerBase):
43
43
  def start_server(port: Optional[int] = None) -> RenderDocMcpServer:
44
44
  global _server
45
45
  if _server is None or not _server.is_running:
46
- selected_port = (
47
- port
48
- if port is not None
49
- else int(os.environ.get("DCC_MCP_RENDERDOC_PORT", DEFAULT_PORT))
50
- )
51
- _server = RenderDocMcpServer(selected_port)
46
+ _server = RenderDocMcpServer(port)
52
47
  _server.register_builtin_actions()
53
48
  _server.start()
54
49
  return _server
@@ -21,5 +21,10 @@ metadata:
21
21
  # RenderDoc Capture
22
22
 
23
23
  Call `get_version` before capture. `capture_program` starts only the explicit executable and
24
- arguments, never a shell. The target must trigger a RenderDoc frame capture.
24
+ arguments, never a shell. Set `trigger_after_secs` to trigger F12 automatically on Windows.
25
+ When using `hook_children`, set `trigger_process_name` to the child executable that should receive
26
+ focus before F12.
25
27
 
28
+ Use `capture_process` when Steam or another platform client must launch the target first. It
29
+ injects into the explicit PID, focuses that visible window, triggers F12 after the requested delay,
30
+ and waits for the resulting capture. Neither tool terminates the target process.
@@ -0,0 +1,32 @@
1
+ from dcc_mcp_core.skill import skill_entry, skill_success
2
+
3
+ from dcc_mcp_renderdoc.runtime import capture_process
4
+
5
+
6
+ @skill_entry
7
+ def main(
8
+ process_id: int,
9
+ output_template: str,
10
+ working_directory=None,
11
+ trigger_after_secs: float = 2.0,
12
+ capture_wait_secs: int = 30,
13
+ api_validation: bool = False,
14
+ timeout_secs: int = 60,
15
+ **_kwargs,
16
+ ):
17
+ result = capture_process(
18
+ process_id,
19
+ output_template,
20
+ working_directory=working_directory,
21
+ trigger_after_secs=trigger_after_secs,
22
+ capture_wait_secs=capture_wait_secs,
23
+ api_validation=api_validation,
24
+ timeout_secs=timeout_secs,
25
+ )
26
+ return skill_success(f"Created {len(result['captures'])} RenderDoc capture(s).", **result)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ from dcc_mcp_core.skill import run_main
31
+
32
+ run_main(main)
@@ -12,6 +12,9 @@ def main(
12
12
  wait_for_exit: bool = True,
13
13
  api_validation: bool = False,
14
14
  hook_children: bool = False,
15
+ trigger_after_secs=None,
16
+ trigger_process_name=None,
17
+ capture_wait_secs: int = 30,
15
18
  timeout_secs: int = 300,
16
19
  **_kwargs,
17
20
  ):
@@ -23,6 +26,9 @@ def main(
23
26
  wait_for_exit=wait_for_exit,
24
27
  api_validation=api_validation,
25
28
  hook_children=hook_children,
29
+ trigger_after_secs=trigger_after_secs,
30
+ trigger_process_name=trigger_process_name,
31
+ capture_wait_secs=capture_wait_secs,
26
32
  timeout_secs=timeout_secs,
27
33
  )
28
34
  return skill_success(f"Created {len(result['captures'])} RenderDoc capture(s).", **result)
@@ -29,6 +29,9 @@ tools:
29
29
  wait_for_exit: {type: boolean, default: true}
30
30
  api_validation: {type: boolean, default: false}
31
31
  hook_children: {type: boolean, default: false}
32
+ trigger_after_secs: {type: number, minimum: 0, maximum: 600}
33
+ trigger_process_name: {type: string, minLength: 1, maxLength: 260}
34
+ capture_wait_secs: {type: integer, minimum: 1, maximum: 600, default: 30}
32
35
  timeout_secs: {type: integer, minimum: 1, maximum: 3600, default: 300}
33
36
  additionalProperties: false
34
37
  output_schema: {type: object}
@@ -42,4 +45,28 @@ tools:
42
45
  next-tools:
43
46
  on-success: [renderdoc_analysis__inspect_capture, renderdoc_analysis__export_thumbnail]
44
47
  on-failure: [dcc_diagnostics__audit_log]
45
-
48
+ - name: capture_process
49
+ description: Inject RenderDoc into one visible Windows process, focus its window, trigger F12 after a delay, and return the new .rdc file. Use when a game must be launched by its platform client. Not for hidden services or process termination.
50
+ input_schema:
51
+ type: object
52
+ required: [process_id, output_template]
53
+ properties:
54
+ process_id: {type: integer, minimum: 1}
55
+ output_template: {type: string, minLength: 1, maxLength: 1000}
56
+ working_directory: {type: string, maxLength: 1000}
57
+ trigger_after_secs: {type: number, minimum: 0, maximum: 600, default: 2}
58
+ capture_wait_secs: {type: integer, minimum: 1, maximum: 600, default: 30}
59
+ api_validation: {type: boolean, default: false}
60
+ timeout_secs: {type: integer, minimum: 1, maximum: 600, default: 60}
61
+ additionalProperties: false
62
+ output_schema: {type: object}
63
+ read_only: false
64
+ destructive: false
65
+ idempotent: false
66
+ execution: async
67
+ affinity: any
68
+ timeout_hint_secs: 660
69
+ source_file: scripts/capture_process.py
70
+ next-tools:
71
+ on-success: [renderdoc_analysis__inspect_capture, renderdoc_analysis__export_thumbnail]
72
+ on-failure: [dcc_diagnostics__audit_log]
@@ -0,0 +1,65 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ from dcc_mcp_renderdoc import __version__
5
+ from dcc_mcp_renderdoc.server import RenderDocMcpServer
6
+
7
+
8
+ def test_server_constructs_with_headless_contract():
9
+ server = RenderDocMcpServer(port=0)
10
+ try:
11
+ assert server._options.server_name == "dcc-mcp-renderdoc"
12
+ assert server._options.dcc_name == "renderdoc"
13
+ finally:
14
+ server.stop()
15
+
16
+
17
+ def test_bundled_skills_and_release_workflow_exist():
18
+ root = Path(__file__).parents[1]
19
+ skills = root / "src" / "dcc_mcp_renderdoc" / "skills"
20
+ assert {path.name for path in skills.iterdir() if path.is_dir()} == {
21
+ "renderdoc-analysis",
22
+ "renderdoc-capture",
23
+ }
24
+ assert (root / ".github" / "workflows" / "release.yml").is_file()
25
+
26
+
27
+ def test_runtime_version_matches_distribution_metadata():
28
+ root = Path(__file__).parents[1]
29
+ pyproject = (root / "pyproject.toml").read_text(encoding="utf-8")
30
+ project_version = re.search(r'^version = "([^"]+)"$', pyproject, re.MULTILINE)
31
+ assert project_version is not None
32
+ assert __version__ == project_version.group(1)
33
+ lock = (root / "uv.lock").read_text(encoding="utf-8")
34
+ locked_project = re.search(
35
+ r'\[\[package\]\]\s+name = "dcc-mcp-renderdoc"\s+version = "([^"]+)"', lock
36
+ )
37
+ assert locked_project is not None
38
+ assert __version__ == locked_project.group(1)
39
+
40
+
41
+ def test_start_server_defers_port_resolution_to_core(monkeypatch):
42
+ from types import SimpleNamespace
43
+
44
+ from dcc_mcp_renderdoc import server as server_module
45
+
46
+ ports = []
47
+ stub = SimpleNamespace(
48
+ is_running=False,
49
+ register_builtin_actions=lambda: None,
50
+ start=lambda: None,
51
+ stop=lambda: None,
52
+ )
53
+
54
+ monkeypatch.setattr(server_module, "_server", None)
55
+ monkeypatch.setattr(
56
+ server_module, "RenderDocMcpServer", lambda port=None: ports.append(port) or stub
57
+ )
58
+ monkeypatch.setenv("DCC_MCP_RENDERDOC_PORT", "8765")
59
+
60
+ server_module.start_server(0)
61
+ server_module.stop_server()
62
+ server_module.start_server()
63
+ server_module.stop_server()
64
+
65
+ assert ports == [0, None]
@@ -0,0 +1,192 @@
1
+ from pathlib import Path
2
+ from subprocess import CompletedProcess
3
+
4
+ import pytest
5
+
6
+ from dcc_mcp_renderdoc import runtime
7
+
8
+
9
+ def test_parse_capture_xml_summarizes_stable_fields(tmp_path: Path):
10
+ xml_file = tmp_path / "capture.xml"
11
+ xml_file.write_text(
12
+ """<rdc><header><driver id="2">Vulkan</driver><machineIdent>123</machineIdent>
13
+ <thumbnail width="640" height="360" /></header><chunks version="17">
14
+ <chunk name="vkCmdDraw"/><chunk name="vkCmdDraw"/><chunk name="Present"/>
15
+ </chunks></rdc>""",
16
+ encoding="utf-8",
17
+ )
18
+
19
+ result = runtime.parse_capture_xml(str(xml_file), representative_limit=2)
20
+
21
+ assert result["driver"] == {"id": "2", "name": "Vulkan"}
22
+ assert result["thumbnail"] == {"width": 640, "height": 360}
23
+ assert result["chunk_count"] == 3
24
+ assert result["chunk_frequencies"][0] == {"name": "vkCmdDraw", "count": 2}
25
+ assert result["representative_chunks"] == ["vkCmdDraw", "vkCmdDraw"]
26
+
27
+
28
+ def test_resolve_renderdoccmd_prefers_explicit_file(tmp_path: Path):
29
+ executable = tmp_path / "renderdoccmd"
30
+ executable.touch()
31
+ assert runtime.resolve_renderdoccmd(str(executable)) == executable.resolve()
32
+
33
+
34
+ def test_capture_program_uses_argument_vector_and_reports_new_capture(tmp_path, monkeypatch):
35
+ target = tmp_path / "game.exe"
36
+ target.touch()
37
+ observed = {}
38
+
39
+ def fake_run(arguments, **_kwargs):
40
+ observed["arguments"] = arguments
41
+ (tmp_path / "capture_frame1.rdc").touch()
42
+ return CompletedProcess(arguments, 0, "captured", "")
43
+
44
+ monkeypatch.setattr(runtime, "_run", fake_run)
45
+ result = runtime.capture_program(
46
+ str(target),
47
+ str(tmp_path / "capture"),
48
+ arguments=["--scene", "demo"],
49
+ )
50
+
51
+ assert observed["arguments"][-3:] == [str(target.resolve()), "--scene", "demo"]
52
+ assert result["captures"] == [str((tmp_path / "capture_frame1.rdc").resolve())]
53
+
54
+
55
+ def test_non_waiting_launch_accepts_renderdoc_target_id(monkeypatch, tmp_path):
56
+ command = tmp_path / "renderdoccmd.exe"
57
+ command.touch()
58
+ monkeypatch.setattr(
59
+ runtime.subprocess,
60
+ "run",
61
+ lambda *args, **kwargs: CompletedProcess(args[0], 12345, "Launched as ID 12345", ""),
62
+ )
63
+
64
+ result = runtime._run(
65
+ ["capture", "game.exe"],
66
+ timeout_secs=10,
67
+ command=str(command),
68
+ accept_launched_id=True,
69
+ )
70
+
71
+ assert result.returncode == 12345
72
+
73
+
74
+ def test_capture_process_injects_triggers_and_reports_capture(tmp_path, monkeypatch):
75
+ observed = {}
76
+
77
+ def fake_run(arguments, **kwargs):
78
+ observed["arguments"] = arguments
79
+ observed["accept_launched_id"] = kwargs["accept_launched_id"]
80
+ return CompletedProcess(arguments, 456, "Launched as ID 456", "")
81
+
82
+ monkeypatch.setattr(runtime, "_run", fake_run)
83
+ monkeypatch.setattr(runtime, "_trigger_capture_hotkey", lambda pid: pid == 42)
84
+ monkeypatch.setattr(runtime.time, "sleep", lambda _seconds: None)
85
+ monkeypatch.setattr(
86
+ runtime,
87
+ "_wait_for_captures",
88
+ lambda directory, before, timeout: [tmp_path / "capture_frame1.rdc"],
89
+ )
90
+
91
+ result = runtime.capture_process(42, str(tmp_path / "capture"))
92
+
93
+ assert observed["arguments"][:2] == ["inject", "--PID=42"]
94
+ assert observed["accept_launched_id"] is True
95
+ assert result["focused_target_window"] is True
96
+
97
+
98
+ def test_capture_program_focuses_requested_child_before_trigger(tmp_path, monkeypatch):
99
+ target = tmp_path / "launcher.exe"
100
+ target.touch()
101
+ observed = {}
102
+ monkeypatch.setattr(
103
+ runtime,
104
+ "_run",
105
+ lambda arguments, **kwargs: CompletedProcess(arguments, 123, "Launched as ID 123", ""),
106
+ )
107
+ monkeypatch.setattr(runtime.time, "sleep", lambda _seconds: None)
108
+ monkeypatch.setattr(runtime, "_wait_for_visible_process", lambda name, timeout: 77)
109
+ monkeypatch.setattr(
110
+ runtime,
111
+ "_trigger_capture_hotkey",
112
+ lambda pid: observed.setdefault("pid", pid) == 77,
113
+ )
114
+ monkeypatch.setattr(
115
+ runtime,
116
+ "_wait_for_captures",
117
+ lambda directory, before, timeout: [tmp_path / "capture_frame1.rdc"],
118
+ )
119
+
120
+ result = runtime.capture_program(
121
+ str(target),
122
+ str(tmp_path / "capture"),
123
+ trigger_after_secs=1,
124
+ trigger_process_name="game.exe",
125
+ )
126
+
127
+ assert observed["pid"] == 77
128
+ assert result["focused_target_window"] is True
129
+
130
+
131
+ def test_capture_program_failure_reports_missing_child_diagnostics(tmp_path, monkeypatch):
132
+ target = tmp_path / "launcher.exe"
133
+ target.touch()
134
+ monkeypatch.setattr(
135
+ runtime,
136
+ "_run",
137
+ lambda arguments, **kwargs: CompletedProcess(arguments, 123, "Launched as ID 123", ""),
138
+ )
139
+ monkeypatch.setattr(runtime.time, "sleep", lambda _seconds: None)
140
+ monkeypatch.setattr(runtime, "_wait_for_visible_process", lambda name, timeout: None)
141
+ monkeypatch.setattr(runtime, "_trigger_capture_hotkey", lambda pid: False)
142
+ monkeypatch.setattr(runtime, "_wait_for_captures", lambda directory, before, timeout: [])
143
+ monkeypatch.setattr(
144
+ runtime,
145
+ "_visible_processes",
146
+ lambda: [{"process_id": 12, "name": "launcher.exe"}],
147
+ )
148
+
149
+ with pytest.raises(runtime.RenderDocError) as error:
150
+ runtime.capture_program(
151
+ str(target),
152
+ str(tmp_path / "capture"),
153
+ trigger_after_secs=1,
154
+ trigger_process_name="game.exe",
155
+ )
156
+
157
+ message = str(error.value)
158
+ assert "target_process=game.exe:not-found" in message
159
+ assert "focused_target_window=False" in message
160
+ assert "visible_processes=[launcher.exe(pid=12)]" in message
161
+ assert "RenderDoc output: Launched as ID 123" in message
162
+
163
+
164
+ def test_capture_process_failure_reports_injection_diagnostics(tmp_path, monkeypatch):
165
+ monkeypatch.setattr(
166
+ runtime,
167
+ "_run",
168
+ lambda arguments, **kwargs: CompletedProcess(
169
+ arguments, 42, "Launched as ID 42", "inject warning"
170
+ ),
171
+ )
172
+ monkeypatch.setattr(runtime.time, "sleep", lambda _seconds: None)
173
+ monkeypatch.setattr(runtime, "_trigger_capture_hotkey", lambda pid: False)
174
+ monkeypatch.setattr(runtime, "_wait_for_captures", lambda directory, before, timeout: [])
175
+ monkeypatch.setattr(
176
+ runtime,
177
+ "_visible_processes",
178
+ lambda: [{"process_id": 42, "name": "game.exe"}],
179
+ )
180
+
181
+ with pytest.raises(runtime.RenderDocError) as error:
182
+ runtime.capture_process(42, str(tmp_path / "capture"))
183
+
184
+ message = str(error.value)
185
+ assert "target_process=game.exe(pid=42)" in message
186
+ assert "focused_target_window=False" in message
187
+ assert "RenderDoc output: Launched as ID 42\ninject warning" in message
188
+
189
+
190
+ def test_invalid_capture_is_rejected(tmp_path: Path):
191
+ with pytest.raises(runtime.RenderDocError, match="not .rdc"):
192
+ runtime.inspect_capture(str(tmp_path / "missing.rdc"))
@@ -1,2 +0,0 @@
1
- {".":"0.2.0"}
2
-
@@ -1,3 +0,0 @@
1
- """Package version."""
2
-
3
- __version__ = "0.1.0"
@@ -1,22 +0,0 @@
1
- from pathlib import Path
2
-
3
- from dcc_mcp_renderdoc.server import RenderDocMcpServer
4
-
5
-
6
- def test_server_constructs_with_headless_contract():
7
- server = RenderDocMcpServer(port=0)
8
- try:
9
- assert server._options.server_name == "dcc-mcp-renderdoc"
10
- assert server._options.dcc_name == "renderdoc"
11
- finally:
12
- server.stop()
13
-
14
-
15
- def test_bundled_skills_and_release_workflow_exist():
16
- root = Path(__file__).parents[1]
17
- skills = root / "src" / "dcc_mcp_renderdoc" / "skills"
18
- assert {path.name for path in skills.iterdir() if path.is_dir()} == {
19
- "renderdoc-analysis",
20
- "renderdoc-capture",
21
- }
22
- assert (root / ".github" / "workflows" / "release.yml").is_file()
@@ -1,57 +0,0 @@
1
- from pathlib import Path
2
- from subprocess import CompletedProcess
3
-
4
- import pytest
5
-
6
- from dcc_mcp_renderdoc import runtime
7
-
8
-
9
- def test_parse_capture_xml_summarizes_stable_fields(tmp_path: Path):
10
- xml_file = tmp_path / "capture.xml"
11
- xml_file.write_text(
12
- """<rdc><header><driver id="2">Vulkan</driver><machineIdent>123</machineIdent>
13
- <thumbnail width="640" height="360" /></header><chunks version="17">
14
- <chunk name="vkCmdDraw"/><chunk name="vkCmdDraw"/><chunk name="Present"/>
15
- </chunks></rdc>""",
16
- encoding="utf-8",
17
- )
18
-
19
- result = runtime.parse_capture_xml(str(xml_file), representative_limit=2)
20
-
21
- assert result["driver"] == {"id": "2", "name": "Vulkan"}
22
- assert result["thumbnail"] == {"width": 640, "height": 360}
23
- assert result["chunk_count"] == 3
24
- assert result["chunk_frequencies"][0] == {"name": "vkCmdDraw", "count": 2}
25
- assert result["representative_chunks"] == ["vkCmdDraw", "vkCmdDraw"]
26
-
27
-
28
- def test_resolve_renderdoccmd_prefers_explicit_file(tmp_path: Path):
29
- executable = tmp_path / "renderdoccmd"
30
- executable.touch()
31
- assert runtime.resolve_renderdoccmd(str(executable)) == executable.resolve()
32
-
33
-
34
- def test_capture_program_uses_argument_vector_and_reports_new_capture(tmp_path, monkeypatch):
35
- target = tmp_path / "game.exe"
36
- target.touch()
37
- observed = {}
38
-
39
- def fake_run(arguments, **_kwargs):
40
- observed["arguments"] = arguments
41
- (tmp_path / "capture_frame1.rdc").touch()
42
- return CompletedProcess(arguments, 0, "captured", "")
43
-
44
- monkeypatch.setattr(runtime, "_run", fake_run)
45
- result = runtime.capture_program(
46
- str(target),
47
- str(tmp_path / "capture"),
48
- arguments=["--scene", "demo"],
49
- )
50
-
51
- assert observed["arguments"][-3:] == [str(target.resolve()), "--scene", "demo"]
52
- assert result["captures"] == [str((tmp_path / "capture_frame1.rdc").resolve())]
53
-
54
-
55
- def test_invalid_capture_is_rejected(tmp_path: Path):
56
- with pytest.raises(runtime.RenderDocError, match="not .rdc"):
57
- runtime.inspect_capture(str(tmp_path / "missing.rdc"))