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.
- dcc_mcp_renderdoc-0.3.2/.release-please-manifest.json +2 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/CHANGELOG.md +21 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/PKG-INFO +48 -1
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/README.md +47 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/pyproject.toml +1 -1
- dcc_mcp_renderdoc-0.3.2/src/dcc_mcp_renderdoc/__version__.py +3 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/runtime.py +171 -42
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/SKILL.md +3 -1
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/tools.yaml +2 -2
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tests/test_package.py +16 -0
- dcc_mcp_renderdoc-0.3.2/tests/test_runtime.py +429 -0
- dcc_mcp_renderdoc-0.3.0/.release-please-manifest.json +0 -2
- dcc_mcp_renderdoc-0.3.0/src/dcc_mcp_renderdoc/__version__.py +0 -3
- dcc_mcp_renderdoc-0.3.0/tests/test_runtime.py +0 -192
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/.gitignore +0 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/LICENSE +0 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/release-please-config.json +0 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/__init__.py +0 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/server.py +0 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/SKILL.md +0 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/metadata/depends.md +0 -0
- {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
- {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
- {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
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-analysis/tools.yaml +0 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/src/dcc_mcp_renderdoc/skills/renderdoc-capture/metadata/depends.md +0 -0
- {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
- {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
- {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
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tests/live_renderdoc_smoke.py +0 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tests/renderdoc_smoke.c +0 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tests/test_download.py +0 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tools/__init__.py +0 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tools/download_latest_renderdoc.py +0 -0
- {dcc_mcp_renderdoc-0.3.0 → dcc_mcp_renderdoc-0.3.2}/tools/lint_skills.py +0 -0
|
@@ -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.
|
|
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
|
|
@@ -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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
196
|
+
controller = _start_capture_controller(
|
|
197
|
+
cli_args,
|
|
198
|
+
timeout_secs=timeout_secs,
|
|
199
|
+
command=command,
|
|
126
200
|
)
|
|
127
|
-
|
|
128
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
199
|
-
|
|
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":
|
|
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
|
-
|
|
251
|
-
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
263
|
-
if
|
|
264
|
-
return
|
|
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
|
|
394
|
+
return target_pid, focused, _new_captures(directory, before)
|
|
267
395
|
|
|
268
396
|
|
|
269
|
-
def
|
|
397
|
+
def _visible_process_ids(process_name: str) -> list[int]:
|
|
270
398
|
expected = Path(process_name).name.casefold()
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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,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"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|