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