dcc-mcp-renderdoc 0.3.0__tar.gz → 0.3.2__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 (35) hide show
  1. dcc_mcp_renderdoc-0.3.2/.release-please-manifest.json +2 -0
  2. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/CHANGELOG.md +21 -0
  3. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/PKG-INFO +48 -1
  4. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/README.md +47 -0
  5. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/pyproject.toml +1 -1
  6. dcc_mcp_renderdoc-0.3.2/src/dcc_mcp_renderdoc/__version__.py +3 -0
  7. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/runtime.py +171 -42
  8. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/SKILL.md +3 -1
  9. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/tools.yaml +2 -2
  10. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tests/test_package.py +16 -0
  11. dcc_mcp_renderdoc-0.3.2/tests/test_runtime.py +429 -0
  12. dcc_mcp_renderdoc-0.3.0/.release-please-manifest.json +0 -2
  13. dcc_mcp_renderdoc-0.3.0/src/dcc_mcp_renderdoc/__version__.py +0 -3
  14. dcc_mcp_renderdoc-0.3.0/tests/test_runtime.py +0 -192
  15. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/.gitignore +0 -0
  16. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/LICENSE +0 -0
  17. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/release-please-config.json +0 -0
  18. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/__init__.py +0 -0
  19. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/server.py +0 -0
  20. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/SKILL.md +0 -0
  21. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/metadata/depends.md +0 -0
  22. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/scripts/export_thumbnail.py +0 -0
  23. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/scripts/export_timeline.py +0 -0
  24. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/scripts/inspect_capture.py +0 -0
  25. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/tools.yaml +0 -0
  26. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/metadata/depends.md +0 -0
  27. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/scripts/capture_process.py +0 -0
  28. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/scripts/capture_program.py +0 -0
  29. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/scripts/get_version.py +0 -0
  30. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tests/live_renderdoc_smoke.py +0 -0
  31. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tests/renderdoc_smoke.c +0 -0
  32. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tests/test_download.py +0 -0
  33. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tools/__init__.py +0 -0
  34. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tools/download_latest_renderdoc.py +0 -0
  35. {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tools/lint_skills.py +0 -0
@@ -0,0 +1,2 @@
1
+ {".":"0.3.2"}
2
+
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.2](https://github.com/dcc-mcp/dcc-mcp-renderdoc/compare/v0.3.1...v0.3.2) (2026-07-22)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * keep capture controller alive through trigger ([#11](https://github.com/dcc-mcp/dcc-mcp-renderdoc/issues/11)) ([1fb7bed](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/1fb7bed40dfb027b148a14cefd01c53028891d56))
9
+
10
+ ## [0.3.1](https://github.com/dcc-mcp/dcc-mcp-renderdoc/compare/v0.3.0...v0.3.1) (2026-07-22)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * allow long-running frame trigger delays ([#10](https://github.com/dcc-mcp/dcc-mcp-renderdoc/issues/10)) ([3212cf7](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/3212cf7c9f236b28b48bf0d28705e2b1939fbe21))
16
+ * capture relaunched child processes ([39e5603](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/39e5603112c8cad98807da3fee86248dd03fb824))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * align agent workflow and branding ([ca8b2cf](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/ca8b2cf955a470a87efde24c12e9ebbdb284633c))
22
+ * document CLI install and updates ([599cc70](https://github.com/dcc-mcp/dcc-mcp-renderdoc/commit/599cc70445e11c2fc53d2626c24f9912365b87e0))
23
+
3
24
  ## [0.3.0](https://github.com/dcc-mcp/dcc-mcp-renderdoc/compare/v0.2.1...v0.3.0) (2026-07-16)
4
25
 
5
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dcc-mcp-renderdoc
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -28,6 +28,53 @@ Description-Content-Type: text/markdown
28
28
 
29
29
  # dcc-mcp-renderdoc
30
30
 
31
+ <p align="center">
32
+ <img src="docs/assets/dcc-mcp-renderdoc.svg" alt="DCC-MCP · RENDERDOC" width="600">
33
+ </p>
34
+
35
+ ## Agent workflow
36
+
37
+ AI agents should use the shared gateway through `dcc-mcp-cli`; IDE users may
38
+ continue to use the MCP endpoint. Prefer typed skills and tools over raw scripts.
39
+
40
+ ### Install or update the CLI
41
+
42
+ `dcc-mcp-cli` is the preferred control path for every shell-capable agent. If
43
+ it is missing, ask the user before installing the latest official release:
44
+
45
+ ```bash
46
+ # Linux/macOS
47
+ curl -fsSL https://raw.githubusercontent.com/dcc-mcp/dcc-mcp-core/main/scripts/install-cli.sh | sh
48
+
49
+ # Windows PowerShell
50
+ powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/dcc-mcp/dcc-mcp-core/main/scripts/install-cli.ps1 | iex"
51
+ ```
52
+
53
+ Keep an official build current through the release manifest:
54
+
55
+ ```bash
56
+ dcc-mcp-cli update check
57
+ dcc-mcp-cli update apply
58
+ ```
59
+
60
+ `update apply` downloads and stages the latest CLI for the next launch. It
61
+ does not update a running `dcc-mcp-server`; update that server in its own
62
+ environment.
63
+
64
+ ```bash
65
+ dcc-mcp-cli dcc-types
66
+ dcc-mcp-cli list
67
+ dcc-mcp-cli search --query "<task>" --dcc-type renderdoc
68
+ dcc-mcp-cli describe <tool-slug>
69
+ dcc-mcp-cli call <tool-slug> --json '{"key":"value"}'
70
+ ```
71
+
72
+ `dcc-types` reports release-catalog support; `list` reports live sessions. If a
73
+ tool belongs to an inactive progressive skill, call `dcc-mcp-cli load-skill <skill-name> --dcc-type renderdoc` before retrying. For post-task improvement,
74
+ attach a stable session id with `--meta-json`, query `dcc-mcp-cli stats --range 24h --session-id <task-id>`, then pass the bounded evidence to the
75
+ `review_skill_improvement` prompt from `dcc-mcp-skills-creator`.
76
+
77
+
31
78
  RenderDoc capture and replay automation for the DCC Model Context Protocol ecosystem.
32
79
 
33
80
  The adapter is headless-first: it reuses the official `renderdoccmd` executable for capture and
@@ -1,5 +1,52 @@
1
1
  # dcc-mcp-renderdoc
2
2
 
3
+ <p align="center">
4
+ <img src="docs/assets/dcc-mcp-renderdoc.svg" alt="DCC-MCP · RENDERDOC" width="600">
5
+ </p>
6
+
7
+ ## Agent workflow
8
+
9
+ AI agents should use the shared gateway through `dcc-mcp-cli`; IDE users may
10
+ continue to use the MCP endpoint. Prefer typed skills and tools over raw scripts.
11
+
12
+ ### Install or update the CLI
13
+
14
+ `dcc-mcp-cli` is the preferred control path for every shell-capable agent. If
15
+ it is missing, ask the user before installing the latest official release:
16
+
17
+ ```bash
18
+ # Linux/macOS
19
+ curl -fsSL https://raw.githubusercontent.com/dcc-mcp/dcc-mcp-core/main/scripts/install-cli.sh | sh
20
+
21
+ # Windows PowerShell
22
+ powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/dcc-mcp/dcc-mcp-core/main/scripts/install-cli.ps1 | iex"
23
+ ```
24
+
25
+ Keep an official build current through the release manifest:
26
+
27
+ ```bash
28
+ dcc-mcp-cli update check
29
+ dcc-mcp-cli update apply
30
+ ```
31
+
32
+ `update apply` downloads and stages the latest CLI for the next launch. It
33
+ does not update a running `dcc-mcp-server`; update that server in its own
34
+ environment.
35
+
36
+ ```bash
37
+ dcc-mcp-cli dcc-types
38
+ dcc-mcp-cli list
39
+ dcc-mcp-cli search --query "<task>" --dcc-type renderdoc
40
+ dcc-mcp-cli describe <tool-slug>
41
+ dcc-mcp-cli call <tool-slug> --json '{"key":"value"}'
42
+ ```
43
+
44
+ `dcc-types` reports release-catalog support; `list` reports live sessions. If a
45
+ tool belongs to an inactive progressive skill, call `dcc-mcp-cli load-skill <skill-name> --dcc-type renderdoc` before retrying. For post-task improvement,
46
+ attach a stable session id with `--meta-json`, query `dcc-mcp-cli stats --range 24h --session-id <task-id>`, then pass the bounded evidence to the
47
+ `review_skill_improvement` prompt from `dcc-mcp-skills-creator`.
48
+
49
+
3
50
  RenderDoc capture and replay automation for the DCC Model Context Protocol ecosystem.
4
51
 
5
52
  The adapter is headless-first: it reuses the official `renderdoccmd` executable for capture and
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dcc-mcp-renderdoc"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "RenderDoc capture and replay automation skills for DCC-MCP"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.3.2" # x-release-please-version
@@ -8,6 +8,7 @@ import shutil
8
8
  import subprocess
9
9
  import sys
10
10
  import tempfile
11
+ import threading
11
12
  import time
12
13
  import xml.etree.ElementTree as ET
13
14
  from collections import Counter
@@ -19,6 +20,81 @@ class RenderDocError(RuntimeError):
19
20
  """Raised when a RenderDoc operation cannot satisfy its contract."""
20
21
 
21
22
 
23
+ class _CaptureController:
24
+ def __init__(self, process: Any, stdout: list[str], stderr: list[str], readers: list[Any]):
25
+ self.process = process
26
+ self.stdout = stdout
27
+ self.stderr = stderr
28
+ self.readers = readers
29
+
30
+ def output(self) -> tuple[str, str]:
31
+ return "".join(self.stdout), "".join(self.stderr)
32
+
33
+ def close(self) -> None:
34
+ if self.process.poll() is None:
35
+ self.process.terminate()
36
+ try:
37
+ self.process.wait(timeout=5)
38
+ except subprocess.TimeoutExpired:
39
+ self.process.kill()
40
+ self.process.wait(timeout=5)
41
+ for reader in self.readers:
42
+ reader.join(timeout=1)
43
+
44
+
45
+ def _start_capture_controller(
46
+ arguments: Sequence[str], *, timeout_secs: int, command: Optional[str]
47
+ ) -> _CaptureController:
48
+ executable = resolve_renderdoccmd(command)
49
+ try:
50
+ process = subprocess.Popen(
51
+ [str(executable), *arguments],
52
+ stdout=subprocess.PIPE,
53
+ stderr=subprocess.PIPE,
54
+ text=True,
55
+ shell=False,
56
+ )
57
+ except OSError as exc:
58
+ raise RenderDocError(f"Could not start RenderDoc: {exc}") from exc
59
+
60
+ stdout: list[str] = []
61
+ stderr: list[str] = []
62
+
63
+ def read_stream(stream: Any, output: list[str]) -> None:
64
+ for chunk in iter(lambda: stream.read(1), ""):
65
+ output.append(chunk)
66
+
67
+ readers = [
68
+ threading.Thread(target=read_stream, args=(process.stdout, stdout), daemon=True),
69
+ threading.Thread(target=read_stream, args=(process.stderr, stderr), daemon=True),
70
+ ]
71
+ for reader in readers:
72
+ reader.start()
73
+ controller = _CaptureController(process, stdout, stderr, readers)
74
+ deadline = time.monotonic() + timeout_secs
75
+ while True:
76
+ combined = "".join(stdout + stderr)
77
+ launched = re.search(r"Launched as ID (\d+)", combined)
78
+ returncode = process.poll()
79
+ if launched is not None:
80
+ launched_id = int(launched.group(1))
81
+ if returncode is None or returncode in (0, launched_id):
82
+ return controller
83
+ if returncode is not None:
84
+ controller.close()
85
+ detail = "".join(controller.output()).strip()[-2000:] or "unknown error"
86
+ raise RenderDocError(f"RenderDoc exited with code {returncode}: {detail}")
87
+ if time.monotonic() >= deadline:
88
+ controller.close()
89
+ detail = "".join(controller.output()).strip()[-2000:]
90
+ suffix = f": {detail}" if detail else ""
91
+ raise RenderDocError(
92
+ f"RenderDoc command timed out waiting for {arguments[0]} readiness after "
93
+ f"{timeout_secs}s{suffix}"
94
+ )
95
+ time.sleep(0.01)
96
+
97
+
22
98
  def resolve_renderdoccmd(explicit: Optional[str] = None) -> Path:
23
99
  """Resolve an executable RenderDoc CLI without invoking a shell."""
24
100
  candidate = explicit or os.environ.get("DCC_MCP_RENDERDOC_CMD")
@@ -102,6 +178,10 @@ def capture_program(
102
178
  raise RenderDocError(f"Working directory does not exist: {cwd}")
103
179
 
104
180
  before = {path.resolve() for path in output.parent.glob("*.rdc")}
181
+ process_name = trigger_process_name or target.name
182
+ visible_before_launch = (
183
+ set(_visible_process_ids(process_name)) if trigger_after_secs is not None else set()
184
+ )
105
185
  cli_args = ["capture", "--working-dir", str(cwd), "--capture-file", str(output)]
106
186
  effective_wait_for_exit = wait_for_exit and trigger_after_secs is None
107
187
  if effective_wait_for_exit:
@@ -111,28 +191,59 @@ def capture_program(
111
191
  if hook_children:
112
192
  cli_args.append("--opt-hook-children")
113
193
  cli_args.extend([str(target), *(str(value) for value in arguments or [])])
114
- result = _run(
115
- cli_args,
116
- timeout_secs=timeout_secs,
117
- command=command,
118
- accept_launched_id=not effective_wait_for_exit,
119
- )
120
194
  target_pid: Optional[int] = None
121
195
  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),
196
+ controller = _start_capture_controller(
197
+ cli_args,
198
+ timeout_secs=timeout_secs,
199
+ command=command,
126
200
  )
127
- focused = _trigger_capture_hotkey(target_pid)
128
- captures = _wait_for_captures(output.parent, before, capture_wait_secs)
201
+ try:
202
+ time.sleep(max(0.0, trigger_after_secs))
203
+ target_pid, focused, captures = _wait_for_triggered_capture(
204
+ output.parent,
205
+ before,
206
+ process_name,
207
+ visible_before_launch,
208
+ capture_wait_secs,
209
+ )
210
+ finally:
211
+ controller.close()
212
+ stdout, stderr = controller.output()
129
213
  else:
214
+ result = _run(
215
+ cli_args,
216
+ timeout_secs=timeout_secs,
217
+ command=command,
218
+ accept_launched_id=not effective_wait_for_exit,
219
+ )
220
+ stdout, stderr = result.stdout, result.stderr
130
221
  focused = False
131
222
  captures = _new_captures(output.parent, before)
223
+ injection_error: Optional[str] = None
224
+ if (
225
+ not captures
226
+ and target_pid is not None
227
+ and hook_children
228
+ and trigger_process_name is not None
229
+ ):
230
+ try:
231
+ fallback = capture_process(
232
+ target_pid,
233
+ output_template,
234
+ working_directory=str(cwd),
235
+ trigger_after_secs=0.25,
236
+ capture_wait_secs=capture_wait_secs,
237
+ api_validation=api_validation,
238
+ timeout_secs=min(timeout_secs, 60),
239
+ command=command,
240
+ )
241
+ captures = [Path(path).resolve() for path in fallback["captures"]]
242
+ focused = bool(fallback["focused_target_window"]) or focused
243
+ except RenderDocError as exc:
244
+ injection_error = str(exc)
132
245
  if not captures:
133
- output_detail = "\n".join(
134
- part.strip() for part in (result.stdout, result.stderr) if part.strip()
135
- )[-2000:]
246
+ output_detail = "\n".join(part.strip() for part in (stdout, stderr) if part.strip())[-2000:]
136
247
  diagnostics = _capture_failure_diagnostics(
137
248
  process_id=target_pid,
138
249
  process_name=trigger_process_name or target.name,
@@ -142,12 +253,13 @@ def capture_program(
142
253
  "No new .rdc capture appeared; ensure the target presents a frame and was hooked "
143
254
  f"before creating its graphics device. {diagnostics}. "
144
255
  f"RenderDoc output: {output_detail}"
256
+ + (f". Injection fallback: {injection_error}" if injection_error else "")
145
257
  )
146
258
  return {
147
259
  "target": str(target),
148
260
  "captures": [str(path) for path in captures],
149
261
  "focused_target_window": focused,
150
- "stdout": result.stdout.strip(),
262
+ "stdout": stdout.strip(),
151
263
  }
152
264
 
153
265
 
@@ -176,34 +288,36 @@ def capture_process(
176
288
  cli_args.extend(["--working-dir", str(cwd)])
177
289
  if api_validation:
178
290
  cli_args.append("--opt-api-validation")
179
- result = _run(
291
+ controller = _start_capture_controller(
180
292
  cli_args,
181
293
  timeout_secs=timeout_secs,
182
294
  command=command,
183
- accept_launched_id=True,
184
295
  )
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)
296
+ try:
297
+ time.sleep(max(0.0, trigger_after_secs))
298
+ focused = _trigger_capture_hotkey(process_id)
299
+ captures = _wait_for_captures(output.parent, before, capture_wait_secs)
300
+ finally:
301
+ controller.close()
302
+ stdout, stderr = controller.output()
188
303
  if not captures:
189
- output_detail = "\n".join(
190
- part.strip() for part in (result.stdout, result.stderr) if part.strip()
191
- )[-2000:]
304
+ output_detail = "\n".join(part.strip() for part in (stdout, stderr) if part.strip())[-2000:]
192
305
  diagnostics = _capture_failure_diagnostics(
193
306
  process_id=process_id,
194
307
  process_name=None,
195
308
  focused=focused,
196
309
  )
197
310
  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}. "
311
+ "No .rdc capture appeared after injection and F12. Inject before the target creates "
312
+ "its graphics device; for an already initialized game, relaunch it with "
313
+ f"capture_program. {diagnostics}. "
200
314
  f"RenderDoc output: {output_detail}"
201
315
  )
202
316
  return {
203
317
  "process_id": process_id,
204
318
  "captures": [str(path) for path in captures],
205
319
  "focused_target_window": focused,
206
- "stdout": result.stdout.strip(),
320
+ "stdout": stdout.strip(),
207
321
  }
208
322
 
209
323
 
@@ -246,32 +360,47 @@ def _trigger_capture_hotkey(process_id: Optional[int] = None) -> bool:
246
360
  return True
247
361
 
248
362
  user32.EnumWindows(find_window, 0)
249
- if target:
250
- user32.SetForegroundWindow(target)
251
- time.sleep(0.15)
363
+ if not target:
364
+ return False
365
+ user32.SetForegroundWindow(target)
366
+ time.sleep(0.15)
252
367
  user32.keybd_event(0x7B, 0, 0, 0)
253
368
  user32.keybd_event(0x7B, 0, 2, 0)
254
369
  return bool(target)
255
370
 
256
371
 
257
- def _wait_for_visible_process(process_name: str, timeout_secs: int) -> Optional[int]:
258
- if sys.platform != "win32":
259
- return None
372
+ def _wait_for_triggered_capture(
373
+ directory: Path,
374
+ before: set[Path],
375
+ process_name: str,
376
+ ignored_process_ids: set[int],
377
+ timeout_secs: int,
378
+ ) -> tuple[Optional[int], bool, list[Path]]:
260
379
  deadline = time.monotonic() + timeout_secs
380
+ handled_process_ids = set(ignored_process_ids)
381
+ target_pid: Optional[int] = None
382
+ focused = False
261
383
  while time.monotonic() < deadline:
262
- process_id = _visible_process_id(process_name)
263
- if process_id is not None:
264
- return process_id
384
+ captures = _new_captures(directory, before)
385
+ if captures:
386
+ return target_pid, focused, captures
387
+ for process_id in _visible_process_ids(process_name):
388
+ if process_id in handled_process_ids:
389
+ continue
390
+ handled_process_ids.add(process_id)
391
+ target_pid = process_id
392
+ focused = _trigger_capture_hotkey(process_id) or focused
265
393
  time.sleep(0.25)
266
- return None
394
+ return target_pid, focused, _new_captures(directory, before)
267
395
 
268
396
 
269
- def _visible_process_id(process_name: str) -> Optional[int]:
397
+ def _visible_process_ids(process_name: str) -> list[int]:
270
398
  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
399
+ return [
400
+ int(process["process_id"])
401
+ for process in _visible_processes()
402
+ if process["name"].casefold() == expected
403
+ ]
275
404
 
276
405
 
277
406
  def _visible_processes(limit: int = 64) -> list[dict[str, Any]]:
@@ -27,4 +27,6 @@ focus before F12.
27
27
 
28
28
  Use `capture_process` when Steam or another platform client must launch the target first. It
29
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.
30
+ and waits for the resulting capture. Inject before the target creates its graphics device; late
31
+ injection cannot recover resources that RenderDoc did not observe. Prefer `capture_program` when
32
+ the executable can be launched directly. Neither tool terminates the target process.
@@ -29,7 +29,7 @@ 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}
32
+ trigger_after_secs: {type: number, minimum: 0, maximum: 900}
33
33
  trigger_process_name: {type: string, minLength: 1, maxLength: 260}
34
34
  capture_wait_secs: {type: integer, minimum: 1, maximum: 600, default: 30}
35
35
  timeout_secs: {type: integer, minimum: 1, maximum: 3600, default: 300}
@@ -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 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.
49
+ description: Inject RenderDoc into one Windows process before it creates its graphics device, focus its visible window, trigger F12 after a delay, and return the new .rdc file. Use when a game must be launched by its platform client. Prefer capture_program for an already initialized game; not for hidden services or process termination.
50
50
  input_schema:
51
51
  type: object
52
52
  required: [process_id, output_template]
@@ -24,6 +24,22 @@ def test_bundled_skills_and_release_workflow_exist():
24
24
  assert (root / ".github" / "workflows" / "release.yml").is_file()
25
25
 
26
26
 
27
+ def test_capture_program_accepts_ten_minute_boss_trigger():
28
+ root = Path(__file__).parents[1]
29
+ tools = (
30
+ root / "src" / "dcc_mcp_renderdoc" / "skills" / "renderdoc-capture" / "tools.yaml"
31
+ ).read_text(encoding="utf-8")
32
+ capture_program = re.search(
33
+ r" - name: capture_program(?P<body>.*?)(?=\n - name:)", tools, re.DOTALL
34
+ )
35
+ assert capture_program is not None
36
+ maximum = re.search(
37
+ r"trigger_after_secs: \{[^}]*maximum: (?P<seconds>\d+)", capture_program.group("body")
38
+ )
39
+ assert maximum is not None
40
+ assert int(maximum.group("seconds")) >= 612
41
+
42
+
27
43
  def test_runtime_version_matches_distribution_metadata():
28
44
  root = Path(__file__).parents[1]
29
45
  pyproject = (root / "pyproject.toml").read_text(encoding="utf-8")
@@ -0,0 +1,429 @@
1
+ import io
2
+ from pathlib import Path
3
+ from subprocess import CompletedProcess, TimeoutExpired
4
+
5
+ import pytest
6
+
7
+ from dcc_mcp_renderdoc import runtime
8
+
9
+
10
+ class StubController:
11
+ def __init__(self, stdout="Launched as ID 123", stderr=""):
12
+ self.stdout = stdout
13
+ self.stderr = stderr
14
+
15
+ def output(self):
16
+ return self.stdout, self.stderr
17
+
18
+ def close(self):
19
+ return None
20
+
21
+
22
+ def test_parse_capture_xml_summarizes_stable_fields(tmp_path: Path):
23
+ xml_file = tmp_path / "capture.xml"
24
+ xml_file.write_text(
25
+ """<rdc><header><driver id="2">Vulkan</driver><machineIdent>123</machineIdent>
26
+ <thumbnail width="640" height="360" /></header><chunks version="17">
27
+ <chunk name="vkCmdDraw"/><chunk name="vkCmdDraw"/><chunk name="Present"/>
28
+ </chunks></rdc>""",
29
+ encoding="utf-8",
30
+ )
31
+
32
+ result = runtime.parse_capture_xml(str(xml_file), representative_limit=2)
33
+
34
+ assert result["driver"] == {"id": "2", "name": "Vulkan"}
35
+ assert result["thumbnail"] == {"width": 640, "height": 360}
36
+ assert result["chunk_count"] == 3
37
+ assert result["chunk_frequencies"][0] == {"name": "vkCmdDraw", "count": 2}
38
+ assert result["representative_chunks"] == ["vkCmdDraw", "vkCmdDraw"]
39
+
40
+
41
+ def test_resolve_renderdoccmd_prefers_explicit_file(tmp_path: Path):
42
+ executable = tmp_path / "renderdoccmd"
43
+ executable.touch()
44
+ assert runtime.resolve_renderdoccmd(str(executable)) == executable.resolve()
45
+
46
+
47
+ def test_capture_program_uses_argument_vector_and_reports_new_capture(tmp_path, monkeypatch):
48
+ target = tmp_path / "game.exe"
49
+ target.touch()
50
+ observed = {}
51
+
52
+ def fake_run(arguments, **_kwargs):
53
+ observed["arguments"] = arguments
54
+ (tmp_path / "capture_frame1.rdc").touch()
55
+ return CompletedProcess(arguments, 0, "captured", "")
56
+
57
+ monkeypatch.setattr(runtime, "_run", fake_run)
58
+ result = runtime.capture_program(
59
+ str(target),
60
+ str(tmp_path / "capture"),
61
+ arguments=["--scene", "demo"],
62
+ )
63
+
64
+ assert observed["arguments"][-3:] == [str(target.resolve()), "--scene", "demo"]
65
+ assert result["captures"] == [str((tmp_path / "capture_frame1.rdc").resolve())]
66
+
67
+
68
+ def test_non_waiting_launch_accepts_renderdoc_target_id(monkeypatch, tmp_path):
69
+ command = tmp_path / "renderdoccmd.exe"
70
+ command.touch()
71
+ monkeypatch.setattr(
72
+ runtime.subprocess,
73
+ "run",
74
+ lambda *args, **kwargs: CompletedProcess(args[0], 12345, "Launched as ID 12345", ""),
75
+ )
76
+
77
+ result = runtime._run(
78
+ ["capture", "game.exe"],
79
+ timeout_secs=10,
80
+ command=str(command),
81
+ accept_launched_id=True,
82
+ )
83
+
84
+ assert result.returncode == 12345
85
+
86
+
87
+ def test_capture_process_injects_triggers_and_reports_capture(tmp_path, monkeypatch):
88
+ observed = {}
89
+
90
+ def fake_run(arguments, **kwargs):
91
+ observed["arguments"] = arguments
92
+ return StubController("Launched as ID 456")
93
+
94
+ monkeypatch.setattr(runtime, "_start_capture_controller", fake_run)
95
+ monkeypatch.setattr(runtime, "_trigger_capture_hotkey", lambda pid: pid == 42)
96
+ monkeypatch.setattr(runtime.time, "sleep", lambda _seconds: None)
97
+ monkeypatch.setattr(
98
+ runtime,
99
+ "_wait_for_captures",
100
+ lambda directory, before, timeout: [tmp_path / "capture_frame1.rdc"],
101
+ )
102
+
103
+ result = runtime.capture_process(42, str(tmp_path / "capture"))
104
+
105
+ assert observed["arguments"][:2] == ["inject", "--PID=42"]
106
+ assert result["focused_target_window"] is True
107
+
108
+
109
+ def test_capture_process_triggers_while_injector_is_running_and_only_stops_injector(
110
+ tmp_path, monkeypatch
111
+ ):
112
+ started = {}
113
+ target = {"running": True}
114
+ command = tmp_path / "renderdoccmd.exe"
115
+ command.touch()
116
+
117
+ class Injector:
118
+ def __init__(self, arguments, **kwargs):
119
+ started.update(arguments=arguments, kwargs=kwargs, process=self)
120
+ self.stdout = io.StringIO("Launched as ID 42\n")
121
+ self.stderr = io.StringIO("")
122
+ self.returncode = None
123
+ self.terminated = False
124
+ self.killed = False
125
+
126
+ def poll(self):
127
+ return self.returncode
128
+
129
+ def terminate(self):
130
+ self.terminated = True
131
+ self.returncode = 0
132
+
133
+ def kill(self):
134
+ self.killed = True
135
+ self.returncode = -9
136
+
137
+ def wait(self, timeout=None):
138
+ if self.returncode is None:
139
+ raise TimeoutExpired(self.args, timeout)
140
+ return self.returncode
141
+
142
+ monkeypatch.setattr(runtime.subprocess, "Popen", Injector)
143
+ monkeypatch.setattr(runtime, "_trigger_capture_hotkey", lambda process_id: process_id == 42)
144
+ monkeypatch.setattr(
145
+ runtime,
146
+ "_wait_for_captures",
147
+ lambda directory, before, timeout: [tmp_path / "capture_frame1.rdc"],
148
+ )
149
+
150
+ result = runtime.capture_process(
151
+ 42,
152
+ str(tmp_path / "capture;still-one-argument"),
153
+ trigger_after_secs=0,
154
+ timeout_secs=1,
155
+ command=str(command),
156
+ )
157
+
158
+ assert result["focused_target_window"] is True
159
+ assert started["arguments"][1:3] == ["inject", "--PID=42"]
160
+ assert started["arguments"][4] == str((tmp_path / "capture;still-one-argument").resolve())
161
+ assert started["kwargs"]["shell"] is False
162
+ assert started["process"].terminated is True
163
+ assert started["process"].killed is False
164
+ assert target["running"] is True
165
+
166
+
167
+ def test_capture_process_readiness_timeout_force_stops_only_injector(tmp_path, monkeypatch):
168
+ command = tmp_path / "renderdoccmd.exe"
169
+ command.touch()
170
+ target = {"running": True}
171
+ started = {}
172
+
173
+ class StuckInjector:
174
+ def __init__(self, arguments, **kwargs):
175
+ started["process"] = self
176
+ self.stdout = io.StringIO("")
177
+ self.stderr = io.StringIO("")
178
+ self.killed = False
179
+ self.terminated = False
180
+
181
+ def poll(self):
182
+ return -9 if self.killed else None
183
+
184
+ def terminate(self):
185
+ self.terminated = True
186
+
187
+ def kill(self):
188
+ self.killed = True
189
+
190
+ def wait(self, timeout=None):
191
+ if not self.killed:
192
+ raise TimeoutExpired("renderdoccmd", timeout)
193
+ return -9
194
+
195
+ monkeypatch.setattr(runtime.subprocess, "Popen", StuckInjector)
196
+
197
+ with pytest.raises(runtime.RenderDocError, match="waiting for inject readiness"):
198
+ runtime.capture_process(
199
+ 42,
200
+ str(tmp_path / "capture"),
201
+ trigger_after_secs=0,
202
+ timeout_secs=0,
203
+ command=str(command),
204
+ )
205
+
206
+ assert started["process"].terminated is True
207
+ assert started["process"].killed is True
208
+ assert target["running"] is True
209
+
210
+
211
+ def test_triggered_program_capture_keeps_controller_alive_until_capture(tmp_path, monkeypatch):
212
+ target = tmp_path / "game.exe"
213
+ target.touch()
214
+ command = tmp_path / "renderdoccmd.exe"
215
+ command.touch()
216
+ events = []
217
+
218
+ class Controller:
219
+ def __init__(self, arguments, **kwargs):
220
+ events.append(("started", arguments, kwargs))
221
+ self.stdout = io.StringIO("Launched as ID 77\n")
222
+ self.stderr = io.StringIO("")
223
+ self.returncode = None
224
+
225
+ def poll(self):
226
+ return self.returncode
227
+
228
+ def terminate(self):
229
+ events.append(("stopped",))
230
+ self.returncode = 0
231
+
232
+ def kill(self):
233
+ raise AssertionError("responsive controller should not be killed")
234
+
235
+ def wait(self, timeout=None):
236
+ return self.returncode
237
+
238
+ monkeypatch.setattr(runtime.subprocess, "Popen", Controller)
239
+ monkeypatch.setattr(runtime, "_visible_process_ids", lambda _name: [])
240
+
241
+ def captured_while_running(directory, before, name, ignored, timeout):
242
+ assert [event[0] for event in events] == ["started"]
243
+ return 77, True, [tmp_path / "capture_frame1.rdc"]
244
+
245
+ monkeypatch.setattr(runtime, "_wait_for_triggered_capture", captured_while_running)
246
+
247
+ result = runtime.capture_program(
248
+ str(target),
249
+ str(tmp_path / "capture"),
250
+ trigger_after_secs=0,
251
+ command=str(command),
252
+ )
253
+
254
+ assert result["captures"] == [str((tmp_path / "capture_frame1.rdc").resolve())]
255
+ assert [event[0] for event in events] == ["started", "stopped"]
256
+ assert events[0][1][-1] == str(target.resolve())
257
+ assert events[0][2]["shell"] is False
258
+
259
+
260
+ def test_capture_program_focuses_requested_child_before_trigger(tmp_path, monkeypatch):
261
+ target = tmp_path / "launcher.exe"
262
+ target.touch()
263
+ observed = {}
264
+ monkeypatch.setattr(
265
+ runtime,
266
+ "_start_capture_controller",
267
+ lambda arguments, **kwargs: StubController(),
268
+ )
269
+ monkeypatch.setattr(runtime.time, "sleep", lambda _seconds: None)
270
+ monkeypatch.setattr(
271
+ runtime,
272
+ "_wait_for_triggered_capture",
273
+ lambda directory, before, name, ignored, timeout: (
274
+ observed.setdefault("pid", 77),
275
+ True,
276
+ [tmp_path / "capture_frame1.rdc"],
277
+ ),
278
+ )
279
+
280
+ result = runtime.capture_program(
281
+ str(target),
282
+ str(tmp_path / "capture"),
283
+ trigger_after_secs=1,
284
+ trigger_process_name="game.exe",
285
+ )
286
+
287
+ assert observed["pid"] == 77
288
+ assert result["focused_target_window"] is True
289
+
290
+
291
+ def test_capture_program_failure_reports_missing_child_diagnostics(tmp_path, monkeypatch):
292
+ target = tmp_path / "launcher.exe"
293
+ target.touch()
294
+ monkeypatch.setattr(
295
+ runtime,
296
+ "_start_capture_controller",
297
+ lambda arguments, **kwargs: StubController(),
298
+ )
299
+ monkeypatch.setattr(runtime.time, "sleep", lambda _seconds: None)
300
+ monkeypatch.setattr(
301
+ runtime,
302
+ "_wait_for_triggered_capture",
303
+ lambda directory, before, name, ignored, timeout: (None, False, []),
304
+ )
305
+ monkeypatch.setattr(
306
+ runtime,
307
+ "_visible_processes",
308
+ lambda: [{"process_id": 12, "name": "launcher.exe"}],
309
+ )
310
+
311
+ with pytest.raises(runtime.RenderDocError) as error:
312
+ runtime.capture_program(
313
+ str(target),
314
+ str(tmp_path / "capture"),
315
+ trigger_after_secs=1,
316
+ trigger_process_name="game.exe",
317
+ )
318
+
319
+ message = str(error.value)
320
+ assert "target_process=game.exe:not-found" in message
321
+ assert "focused_target_window=False" in message
322
+ assert "visible_processes=[launcher.exe(pid=12)]" in message
323
+ assert "RenderDoc output: Launched as ID 123" in message
324
+
325
+
326
+ def test_triggered_capture_retargets_replacement_process(tmp_path, monkeypatch):
327
+ capture = tmp_path / "capture_frame1.rdc"
328
+ process_snapshots = iter([[77], [77, 88]])
329
+ triggered = []
330
+ monkeypatch.setattr(
331
+ runtime,
332
+ "_visible_process_ids",
333
+ lambda _name: next(process_snapshots, [88]),
334
+ )
335
+ monkeypatch.setattr(
336
+ runtime,
337
+ "_trigger_capture_hotkey",
338
+ lambda process_id: not triggered.append(process_id),
339
+ )
340
+ monkeypatch.setattr(
341
+ runtime,
342
+ "_new_captures",
343
+ lambda _directory, _before: [capture] if triggered == [77, 88] else [],
344
+ )
345
+ monkeypatch.setattr(runtime.time, "sleep", lambda _seconds: None)
346
+
347
+ target_pid, focused, captures = runtime._wait_for_triggered_capture(
348
+ tmp_path,
349
+ set(),
350
+ "game.exe",
351
+ set(),
352
+ 1,
353
+ )
354
+
355
+ assert triggered == [77, 88]
356
+ assert target_pid == 88
357
+ assert focused is True
358
+ assert captures == [capture]
359
+
360
+
361
+ def test_capture_program_injects_visible_unhooked_child(tmp_path, monkeypatch):
362
+ target = tmp_path / "launcher.exe"
363
+ target.touch()
364
+ capture = tmp_path / "capture_frame1.rdc"
365
+ observed = {}
366
+ monkeypatch.setattr(
367
+ runtime,
368
+ "_start_capture_controller",
369
+ lambda arguments, **kwargs: StubController(),
370
+ )
371
+ monkeypatch.setattr(runtime.time, "sleep", lambda _seconds: None)
372
+ monkeypatch.setattr(
373
+ runtime,
374
+ "_wait_for_triggered_capture",
375
+ lambda directory, before, name, ignored, timeout: (88, True, []),
376
+ )
377
+
378
+ def fake_capture_process(process_id, output_template, **kwargs):
379
+ observed.update(process_id=process_id, output_template=output_template, **kwargs)
380
+ return {
381
+ "captures": [str(capture)],
382
+ "focused_target_window": True,
383
+ "stdout": "injected",
384
+ }
385
+
386
+ monkeypatch.setattr(runtime, "capture_process", fake_capture_process)
387
+
388
+ result = runtime.capture_program(
389
+ str(target),
390
+ str(tmp_path / "capture"),
391
+ hook_children=True,
392
+ trigger_after_secs=1,
393
+ trigger_process_name="game.exe",
394
+ )
395
+
396
+ assert observed["process_id"] == 88
397
+ assert observed["trigger_after_secs"] == 0.25
398
+ assert result["captures"] == [str(capture.resolve())]
399
+
400
+
401
+ def test_capture_process_failure_reports_injection_diagnostics(tmp_path, monkeypatch):
402
+ monkeypatch.setattr(
403
+ runtime,
404
+ "_start_capture_controller",
405
+ lambda arguments, **kwargs: StubController("Launched as ID 42", "inject warning"),
406
+ )
407
+ monkeypatch.setattr(runtime.time, "sleep", lambda _seconds: None)
408
+ monkeypatch.setattr(runtime, "_trigger_capture_hotkey", lambda pid: False)
409
+ monkeypatch.setattr(runtime, "_wait_for_captures", lambda directory, before, timeout: [])
410
+ monkeypatch.setattr(
411
+ runtime,
412
+ "_visible_processes",
413
+ lambda: [{"process_id": 42, "name": "game.exe"}],
414
+ )
415
+
416
+ with pytest.raises(runtime.RenderDocError) as error:
417
+ runtime.capture_process(42, str(tmp_path / "capture"))
418
+
419
+ message = str(error.value)
420
+ assert "before the target creates its graphics device" in message
421
+ assert "capture_program" in message
422
+ assert "target_process=game.exe(pid=42)" in message
423
+ assert "focused_target_window=False" in message
424
+ assert "RenderDoc output: Launched as ID 42\ninject warning" in message
425
+
426
+
427
+ def test_invalid_capture_is_rejected(tmp_path: Path):
428
+ with pytest.raises(runtime.RenderDocError, match="not .rdc"):
429
+ runtime.inspect_capture(str(tmp_path / "missing.rdc"))
@@ -1,2 +0,0 @@
1
- {".":"0.3.0"}
2
-
@@ -1,3 +0,0 @@
1
- """Package version."""
2
-
3
- __version__ = "0.3.0" # x-release-please-version
@@ -1,192 +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_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"))