asecli 0.6.2__py3-none-any.whl
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.
- asecli/__init__.py +3 -0
- asecli/bridge/__init__.py +40 -0
- asecli/bridge/_editor_port_contract.py +131 -0
- asecli/bridge/_editor_primitives.py +205 -0
- asecli/bridge/_editor_spec_grammar.py +245 -0
- asecli/bridge/_editor_spec_io.py +26 -0
- asecli/bridge/_editor_spec_validation.py +218 -0
- asecli/bridge/_gui_handoff_store.py +78 -0
- asecli/bridge/_gui_project.py +35 -0
- asecli/bridge/_gui_resource_store.py +159 -0
- asecli/bridge/_gui_resource_upgrade.py +122 -0
- asecli/bridge/editor_create.py +173 -0
- asecli/bridge/editor_spec.py +250 -0
- asecli/bridge/graph_geometry.py +249 -0
- asecli/bridge/graph_geometry_parser.py +57 -0
- asecli/bridge/graph_inspect.py +136 -0
- asecli/bridge/gui_handoff.py +100 -0
- asecli/bridge/gui_presentation.py +70 -0
- asecli/bridge/gui_provider_detection.py +197 -0
- asecli/bridge/gui_runtime_probe.py +25 -0
- asecli/bridge/gui_support.py +240 -0
- asecli/bridge/gui_support_resource.py +58 -0
- asecli/bridge/mcp_client.py +231 -0
- asecli/bridge/recompile.py +107 -0
- asecli/bridge/resource_text.py +12 -0
- asecli/bridge/resources/asecli_material_gui.authoring.part00.cs.txt +238 -0
- asecli/bridge/resources/asecli_material_gui.authoring.part01.cs.txt +235 -0
- asecli/bridge/resources/asecli_material_gui.authoring.store.cs.txt +125 -0
- asecli/bridge/resources/asecli_material_gui.condition.cs.txt +99 -0
- asecli/bridge/resources/asecli_material_gui.hydration.cs.txt +105 -0
- asecli/bridge/resources/asecli_material_gui.part00.cs.txt +300 -0
- asecli/bridge/resources/asecli_material_gui.part01.cs.txt +290 -0
- asecli/bridge/resources/asecli_material_gui.reconciliation.cs.txt +174 -0
- asecli/bridge/resources/asecli_material_gui.transaction.cs.txt +108 -0
- asecli/bridge/resources/editor_create.part00.cs.txt +176 -0
- asecli/bridge/resources/editor_create.part01.cs.txt +145 -0
- asecli/bridge/resources/editor_create.part02.cs.txt +139 -0
- asecli/bridge/resources/editor_create.part03.cs.txt +161 -0
- asecli/bridge/resources/editor_create.part04.cs.txt +114 -0
- asecli/bridge/resources/wire_route.transaction.cs.txt +140 -0
- asecli/bridge/wire_route.py +127 -0
- asecli/checks/__init__.py +10 -0
- asecli/checks/checksum.py +53 -0
- asecli/checks/local_vars.py +135 -0
- asecli/checks/usage.py +141 -0
- asecli/checks/validate.py +129 -0
- asecli/cli/__init__.py +1 -0
- asecli/cli/commands.py +237 -0
- asecli/cli/commentary_command.py +158 -0
- asecli/cli/create_command.py +250 -0
- asecli/cli/custom_gui_command.py +170 -0
- asecli/cli/gui_provider.py +30 -0
- asecli/cli/gui_support_command.py +35 -0
- asecli/cli/io.py +214 -0
- asecli/cli/layout_command.py +213 -0
- asecli/cli/main.py +245 -0
- asecli/cli/recompile_metadata.py +53 -0
- asecli/cli/skill_command.py +121 -0
- asecli/cli/usage_command.py +42 -0
- asecli/core/__init__.py +84 -0
- asecli/core/comment_bounds.py +166 -0
- asecli/core/comment_layout.py +167 -0
- asecli/core/comment_metrics.py +132 -0
- asecli/core/comment_purpose.py +140 -0
- asecli/core/commentary.py +225 -0
- asecli/core/compiled_metadata.py +135 -0
- asecli/core/compiled_properties.py +190 -0
- asecli/core/custom_gui.py +250 -0
- asecli/core/custom_gui_versions.py +35 -0
- asecli/core/fishbone_placement.py +159 -0
- asecli/core/fishbone_topology.py +186 -0
- asecli/core/graph_ops.py +122 -0
- asecli/core/layout.py +174 -0
- asecli/core/layout_audit.py +237 -0
- asecli/core/layout_audit_geometry.py +247 -0
- asecli/core/layout_audit_repeated.py +44 -0
- asecli/core/layout_graph.py +113 -0
- asecli/core/local_vars.py +54 -0
- asecli/core/material_gui_condition.py +68 -0
- asecli/core/material_gui_protocol.py +35 -0
- asecli/core/material_gui_spec.py +191 -0
- asecli/core/meticulous_layout.py +114 -0
- asecli/core/model.py +237 -0
- asecli/core/property_presentation.py +233 -0
- asecli/core/wire_geometry.py +83 -0
- asecli/core/wire_router.py +248 -0
- asecli/schema/__init__.py +49 -0
- asecli/schema/data/observed.json +16 -0
- asecli/schema/data/schemas.json +14106 -0
- asecli/skills/asecli/SKILL.md +275 -0
- asecli/skills/asecli/references/layout-standard.md +99 -0
- asecli/skills/asecli/references/master-output-settings-standard.md +51 -0
- asecli/skills/asecli/references/material-property-standard.md +108 -0
- asecli-0.6.2.dist-info/METADATA +594 -0
- asecli-0.6.2.dist-info/RECORD +98 -0
- asecli-0.6.2.dist-info/WHEEL +4 -0
- asecli-0.6.2.dist-info/entry_points.txt +2 -0
- asecli-0.6.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Inspect and install ASECLI's clean-room Unity material GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..core import ASECLI_GUI_EDITOR, MZGUI_EDITOR
|
|
9
|
+
from ._gui_project import unity_project
|
|
10
|
+
from ._gui_resource_store import install_gui_resource
|
|
11
|
+
from ._gui_resource_upgrade import upgrade_backup_path, upgrade_gui_resource
|
|
12
|
+
from .gui_provider_detection import (
|
|
13
|
+
runtime_providers,
|
|
14
|
+
scan_native_mzgui,
|
|
15
|
+
validate_runtime_probe,
|
|
16
|
+
)
|
|
17
|
+
from .gui_presentation import inspect_inline_help_presentation, require_inline_help_presentation
|
|
18
|
+
from .gui_support_resource import (
|
|
19
|
+
GUI_AUTHORING_SHA256,
|
|
20
|
+
GUI_AUTHORING_SOURCE,
|
|
21
|
+
GUI_SUPPORT_ASSET_PATH,
|
|
22
|
+
GUI_SUPPORT_KNOWN_PREVIOUS,
|
|
23
|
+
GUI_SUPPORT_RESOURCE_PARTS,
|
|
24
|
+
GUI_SUPPORT_SHA256,
|
|
25
|
+
GUI_SUPPORT_SOURCE,
|
|
26
|
+
gui_target_state,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def inspect_gui_support(
|
|
31
|
+
project_root: str | Path, *, runtime_probe: dict | None = None
|
|
32
|
+
) -> dict:
|
|
33
|
+
"""Prefer native MZGUI; otherwise select ASECLI's Editor fallback."""
|
|
34
|
+
project = unity_project(project_root, GUI_SUPPORT_ASSET_PATH)
|
|
35
|
+
native_evidence, native_candidates = scan_native_mzgui(
|
|
36
|
+
project.root, skip_source_name=Path(GUI_SUPPORT_ASSET_PATH).name
|
|
37
|
+
)
|
|
38
|
+
runtime_provider_details: list[dict] = []
|
|
39
|
+
if runtime_probe is not None:
|
|
40
|
+
validate_runtime_probe(runtime_probe, project.assets)
|
|
41
|
+
runtime_provider_details = runtime_providers(runtime_probe)
|
|
42
|
+
runtime_native = [
|
|
43
|
+
item
|
|
44
|
+
for item in runtime_provider_details
|
|
45
|
+
if item["shader_gui"] and not item["fallback"]
|
|
46
|
+
]
|
|
47
|
+
if len(runtime_provider_details) == 1 and runtime_native:
|
|
48
|
+
native_evidence = [
|
|
49
|
+
*native_evidence,
|
|
50
|
+
"runtime:" + runtime_native[0]["assembly"],
|
|
51
|
+
]
|
|
52
|
+
elif not runtime_provider_details and native_evidence:
|
|
53
|
+
native_candidates = [*native_candidates, *native_evidence]
|
|
54
|
+
native_evidence = []
|
|
55
|
+
target_state, actual_sha256 = gui_target_state(project.target)
|
|
56
|
+
providers = []
|
|
57
|
+
if native_evidence:
|
|
58
|
+
providers.append("native_mzgui")
|
|
59
|
+
if target_state in {"installed", "upgrade_available"}:
|
|
60
|
+
providers.append("asecli_compat")
|
|
61
|
+
elif target_state == "native_extension":
|
|
62
|
+
providers.append("asecli_authoring_extension")
|
|
63
|
+
runtime_fallback = [item for item in runtime_provider_details if item["fallback"]]
|
|
64
|
+
runtime_conflict = len(runtime_provider_details) > 1
|
|
65
|
+
if runtime_conflict:
|
|
66
|
+
provider = "multiple"
|
|
67
|
+
recommended_editor = None
|
|
68
|
+
elif native_evidence:
|
|
69
|
+
if target_state in {"installed", "upgrade_available"}:
|
|
70
|
+
provider = "multiple"
|
|
71
|
+
recommended_editor = None
|
|
72
|
+
elif target_state == "conflict":
|
|
73
|
+
provider = "target_conflict"
|
|
74
|
+
recommended_editor = None
|
|
75
|
+
else:
|
|
76
|
+
provider = "native_mzgui"
|
|
77
|
+
recommended_editor = MZGUI_EDITOR
|
|
78
|
+
elif runtime_fallback and target_state == "absent":
|
|
79
|
+
provider = "fallback_external"
|
|
80
|
+
recommended_editor = MZGUI_EDITOR
|
|
81
|
+
elif target_state in {"conflict", "native_extension"}:
|
|
82
|
+
provider = "target_conflict"
|
|
83
|
+
recommended_editor = None
|
|
84
|
+
elif native_candidates:
|
|
85
|
+
provider = "unknown"
|
|
86
|
+
recommended_editor = None
|
|
87
|
+
elif target_state == "installed":
|
|
88
|
+
provider = "asecli_compat"
|
|
89
|
+
recommended_editor = MZGUI_EDITOR
|
|
90
|
+
elif target_state == "upgrade_available":
|
|
91
|
+
provider = "asecli_upgrade_available"
|
|
92
|
+
recommended_editor = MZGUI_EDITOR
|
|
93
|
+
else:
|
|
94
|
+
provider = "missing"
|
|
95
|
+
recommended_editor = MZGUI_EDITOR
|
|
96
|
+
upgrade_from = GUI_SUPPORT_KNOWN_PREVIOUS.get(actual_sha256)
|
|
97
|
+
backup = (
|
|
98
|
+
upgrade_backup_path(project.target, actual_sha256, GUI_SUPPORT_KNOWN_PREVIOUS)
|
|
99
|
+
if upgrade_from
|
|
100
|
+
else None
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"project_root": str(project.root),
|
|
105
|
+
"provider": provider,
|
|
106
|
+
"providers": providers,
|
|
107
|
+
"recommended_editor": recommended_editor,
|
|
108
|
+
"native_mzgui": {
|
|
109
|
+
"detected": bool(native_evidence),
|
|
110
|
+
"status": "detected" if native_evidence else ("unknown" if native_candidates else "not_detected"),
|
|
111
|
+
"editor": MZGUI_EDITOR,
|
|
112
|
+
"evidence": sorted(set(native_evidence)),
|
|
113
|
+
"candidates": sorted(set(native_candidates)),
|
|
114
|
+
"runtime_probe": runtime_probe,
|
|
115
|
+
"runtime_providers": runtime_provider_details,
|
|
116
|
+
},
|
|
117
|
+
"asecli_material_gui": {
|
|
118
|
+
"editor": MZGUI_EDITOR,
|
|
119
|
+
"legacy_editor_alias": ASECLI_GUI_EDITOR,
|
|
120
|
+
"target": str(project.target),
|
|
121
|
+
"asset_path": GUI_SUPPORT_ASSET_PATH,
|
|
122
|
+
"state": target_state,
|
|
123
|
+
"expected_sha256": GUI_SUPPORT_SHA256,
|
|
124
|
+
"actual_sha256": actual_sha256,
|
|
125
|
+
"upgrade_available": upgrade_from is not None,
|
|
126
|
+
"upgrade_from": upgrade_from,
|
|
127
|
+
"backup": str(backup) if backup else None,
|
|
128
|
+
},
|
|
129
|
+
"capabilities": {
|
|
130
|
+
"foldout": "FoldoutMzgui",
|
|
131
|
+
"tooltip": "TooltipMzgui",
|
|
132
|
+
"help_box": "HelpBoxMzgui",
|
|
133
|
+
"enabled_if": "EnableIfMzgui",
|
|
134
|
+
"inline_help_presentation": inspect_inline_help_presentation(GUI_SUPPORT_SOURCE),
|
|
135
|
+
"fallback_editor": MZGUI_EDITOR,
|
|
136
|
+
"legacy_fallback_editor": ASECLI_GUI_EDITOR,
|
|
137
|
+
"authoring": {
|
|
138
|
+
"surface": "Window/Amplify Shader Editor/MZGUI Attributes (ASECLI)",
|
|
139
|
+
"storage": "ase_custom_attributes",
|
|
140
|
+
"version_strategy": "runtime_capability_probe",
|
|
141
|
+
"patches_ase_source": False,
|
|
142
|
+
},
|
|
143
|
+
"automatic_technical_tooltip": ["property_name", "shader_default_value"],
|
|
144
|
+
"ase_version_dependency": False,
|
|
145
|
+
},
|
|
146
|
+
"would_write": provider in {"missing", "asecli_upgrade_available"}
|
|
147
|
+
or (provider == "native_mzgui" and target_state == "absent"),
|
|
148
|
+
"written": False,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def install_gui_support(
|
|
153
|
+
project_root: str | Path,
|
|
154
|
+
*,
|
|
155
|
+
write: bool = False,
|
|
156
|
+
runtime_probe: dict | None = None,
|
|
157
|
+
) -> dict:
|
|
158
|
+
"""Inject the Editor fallback only after native MZGUI was ruled out."""
|
|
159
|
+
state = inspect_gui_support(project_root, runtime_probe=runtime_probe)
|
|
160
|
+
if state["provider"] == "multiple":
|
|
161
|
+
raise RuntimeError(
|
|
162
|
+
"native MZGUI and ASECLI fallback are both installed; remove the ASECLI fallback "
|
|
163
|
+
"before selecting MZGUI.MZGUI"
|
|
164
|
+
)
|
|
165
|
+
if state["provider"] == "native_mzgui":
|
|
166
|
+
target_state = state["asecli_material_gui"]["state"]
|
|
167
|
+
if target_state == "native_extension":
|
|
168
|
+
state["action"] = "use_native_mzgui"
|
|
169
|
+
state["would_write"] = False
|
|
170
|
+
return state
|
|
171
|
+
state["action"] = "install_native_mzgui_extension"
|
|
172
|
+
if not write:
|
|
173
|
+
return state
|
|
174
|
+
project = unity_project(project_root, GUI_SUPPORT_ASSET_PATH)
|
|
175
|
+
install_gui_resource(project, GUI_AUTHORING_SOURCE.encode("utf-8"))
|
|
176
|
+
installed = inspect_gui_support(project_root, runtime_probe=runtime_probe)
|
|
177
|
+
if installed["provider"] != "native_mzgui" or installed[
|
|
178
|
+
"asecli_material_gui"
|
|
179
|
+
]["state"] != "native_extension":
|
|
180
|
+
raise RuntimeError("ASECLI native MZGUI extension failed digest verification")
|
|
181
|
+
installed.update(
|
|
182
|
+
action="install_native_mzgui_extension",
|
|
183
|
+
would_write=False,
|
|
184
|
+
written=True,
|
|
185
|
+
requires_editor_recompile=True,
|
|
186
|
+
)
|
|
187
|
+
return installed
|
|
188
|
+
if state["provider"] == "fallback_external":
|
|
189
|
+
state["action"] = "use_external_fallback"
|
|
190
|
+
state["would_write"] = False
|
|
191
|
+
return state
|
|
192
|
+
if state["provider"] == "unknown":
|
|
193
|
+
raise RuntimeError(
|
|
194
|
+
"native MZGUI may exist in an unverified source or assembly; run gui-support "
|
|
195
|
+
"with --runtime-probe while the target Editor is connected before installing"
|
|
196
|
+
)
|
|
197
|
+
if write:
|
|
198
|
+
require_inline_help_presentation(GUI_SUPPORT_SOURCE)
|
|
199
|
+
target_state = state["asecli_material_gui"]["state"]
|
|
200
|
+
if target_state == "conflict":
|
|
201
|
+
raise FileExistsError(
|
|
202
|
+
"ASECLI GUI support target already exists with different content; refusing to overwrite: "
|
|
203
|
+
+ state["asecli_material_gui"]["target"]
|
|
204
|
+
)
|
|
205
|
+
if target_state == "installed":
|
|
206
|
+
state["action"] = "already_installed"
|
|
207
|
+
state["would_write"] = False
|
|
208
|
+
return state
|
|
209
|
+
|
|
210
|
+
upgrading = target_state == "upgrade_available"
|
|
211
|
+
state["action"] = (
|
|
212
|
+
"upgrade_asecli_material_gui" if upgrading else "install_asecli_material_gui"
|
|
213
|
+
)
|
|
214
|
+
if not write:
|
|
215
|
+
return state
|
|
216
|
+
|
|
217
|
+
project = unity_project(project_root, GUI_SUPPORT_ASSET_PATH)
|
|
218
|
+
backup_path = None
|
|
219
|
+
if upgrading:
|
|
220
|
+
backup_path = upgrade_gui_resource(
|
|
221
|
+
project,
|
|
222
|
+
GUI_SUPPORT_SOURCE.encode("utf-8"),
|
|
223
|
+
state["asecli_material_gui"]["actual_sha256"],
|
|
224
|
+
GUI_SUPPORT_KNOWN_PREVIOUS,
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
install_gui_resource(project, GUI_SUPPORT_SOURCE.encode("utf-8"))
|
|
228
|
+
|
|
229
|
+
installed = inspect_gui_support(project_root, runtime_probe=runtime_probe)
|
|
230
|
+
if installed["asecli_material_gui"]["state"] != "installed":
|
|
231
|
+
raise RuntimeError("ASECLI GUI support was written but failed digest verification")
|
|
232
|
+
installed["action"] = (
|
|
233
|
+
"upgrade_asecli_material_gui" if upgrading else "install_asecli_material_gui"
|
|
234
|
+
)
|
|
235
|
+
installed["would_write"] = False
|
|
236
|
+
installed["written"] = True
|
|
237
|
+
installed["requires_editor_recompile"] = True
|
|
238
|
+
installed["backed_up"] = backup_path is not None
|
|
239
|
+
installed["backup"] = str(backup_path) if backup_path else None
|
|
240
|
+
return installed
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Packaged GUI resource composition and known upgrade identities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .resource_text import compose_resource_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
GUI_SUPPORT_ASSET_PATH = "Assets/Editor/ASECLI/ASECLIMaterialGUI.cs"
|
|
12
|
+
GUI_SUPPORT_RESOURCE_PARTS = (
|
|
13
|
+
"asecli_material_gui.part00.cs.txt",
|
|
14
|
+
"asecli_material_gui.part01.cs.txt",
|
|
15
|
+
"asecli_material_gui.authoring.part00.cs.txt",
|
|
16
|
+
"asecli_material_gui.authoring.store.cs.txt",
|
|
17
|
+
"asecli_material_gui.authoring.part01.cs.txt",
|
|
18
|
+
"asecli_material_gui.reconciliation.cs.txt",
|
|
19
|
+
"asecli_material_gui.transaction.cs.txt",
|
|
20
|
+
"asecli_material_gui.hydration.cs.txt",
|
|
21
|
+
"asecli_material_gui.condition.cs.txt",
|
|
22
|
+
)
|
|
23
|
+
GUI_SUPPORT_SOURCE = compose_resource_text(
|
|
24
|
+
"asecli.bridge", "resources", GUI_SUPPORT_RESOURCE_PARTS
|
|
25
|
+
)
|
|
26
|
+
GUI_SUPPORT_SHA256 = hashlib.sha256(GUI_SUPPORT_SOURCE.encode("utf-8")).hexdigest()
|
|
27
|
+
GUI_AUTHORING_RESOURCE_PARTS = GUI_SUPPORT_RESOURCE_PARTS[2:]
|
|
28
|
+
GUI_AUTHORING_SOURCE = compose_resource_text(
|
|
29
|
+
"asecli.bridge", "resources", GUI_AUTHORING_RESOURCE_PARTS
|
|
30
|
+
)
|
|
31
|
+
GUI_AUTHORING_SHA256 = hashlib.sha256(GUI_AUTHORING_SOURCE.encode("utf-8")).hexdigest()
|
|
32
|
+
GUI_SUPPORT_KNOWN_PREVIOUS = {
|
|
33
|
+
"cf45c7d6ad6aa79880205d73f7a6db45e20239cb312f91743056c5efa00b41b8": "0.2.0-original",
|
|
34
|
+
"9541c541628b8404c66ca2c36e80af25f69960d6e1a07deabad53fd6233c5b7a": "0.3.1-material-only",
|
|
35
|
+
"04a618f5b95ce564fe70e72f5ef8fe7b9c59394c53e9ec682efe27310dc4a493": "0.3.1-material-only",
|
|
36
|
+
"76a092825c44ea43fa10bde7cd4185c3af1d3c26a900d90da2d8fadde150967f": "0.3.1-authoring-preview",
|
|
37
|
+
"77ccadf84e3c2c1eddff343ae535c09af15402b92172e772efdf481d06c3433e": "0.3.1-authoring-preview",
|
|
38
|
+
"9249b3245c01052e27bb8cf82b9e0c05e8f20ac2adfe21796a1777c4857a25ca": "0.3.1-authoring-preview",
|
|
39
|
+
"738a79e7e9198dd6b21879d7d42dfad4dae6b74ef6e50e7cf17c4b3372d0e92c": "0.3.2-portable-mzgui",
|
|
40
|
+
"e0fa59a9fa3ca2840f8ee2d76e03fbbb862919d2476aefec0fe7f4e64150f034": "tooltip-contract",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def gui_target_state(target: Path) -> tuple[str, str | None]:
|
|
45
|
+
if target.is_symlink():
|
|
46
|
+
return "conflict", None
|
|
47
|
+
if not target.exists():
|
|
48
|
+
return "absent", None
|
|
49
|
+
if not target.is_file():
|
|
50
|
+
return "conflict", None
|
|
51
|
+
actual = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
52
|
+
if actual == GUI_SUPPORT_SHA256:
|
|
53
|
+
return "installed", actual
|
|
54
|
+
if actual == GUI_AUTHORING_SHA256:
|
|
55
|
+
return "native_extension", actual
|
|
56
|
+
if actual in GUI_SUPPORT_KNOWN_PREVIOUS:
|
|
57
|
+
return "upgrade_available", actual
|
|
58
|
+
return "conflict", actual
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Minimal MCP (Model Context Protocol) streamable-HTTP client.
|
|
2
|
+
|
|
3
|
+
Speaks just enough JSON-RPC to reach an ``mcp-for-unity`` server:
|
|
4
|
+
initialize -> notifications/initialized -> tools/call.
|
|
5
|
+
Handles both plain-JSON and SSE-framed responses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
from .. import __version__
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class McpError(RuntimeError):
|
|
20
|
+
def __init__(self, message: str, *, data: dict | None = None):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.data = data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def redact(text: str, *secrets: str | None) -> str:
|
|
26
|
+
for secret in secrets:
|
|
27
|
+
if secret:
|
|
28
|
+
text = text.replace(secret, "<redacted>")
|
|
29
|
+
return text
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _timeout_error(
|
|
33
|
+
phase: str,
|
|
34
|
+
started: float,
|
|
35
|
+
budget: float,
|
|
36
|
+
*,
|
|
37
|
+
now: float | None = None,
|
|
38
|
+
) -> McpError:
|
|
39
|
+
elapsed_ms = max(0, round(((time.monotonic() if now is None else now) - started) * 1000))
|
|
40
|
+
budget_ms = max(0, round(budget * 1000))
|
|
41
|
+
return McpError(
|
|
42
|
+
f"MCP {phase} timed out after {elapsed_ms} ms (budget {budget_ms} ms)",
|
|
43
|
+
data={
|
|
44
|
+
"phase": phase,
|
|
45
|
+
"elapsed_ms": elapsed_ms,
|
|
46
|
+
"budget_ms": budget_ms,
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_mcp_url(url: str, allow_remote: bool = False) -> str:
|
|
52
|
+
"""Validate the MCP endpoint without resolving or contacting it."""
|
|
53
|
+
try:
|
|
54
|
+
parsed = urllib.parse.urlsplit(url)
|
|
55
|
+
host = parsed.hostname
|
|
56
|
+
parsed.port
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
raise McpError("invalid MCP URL") from exc
|
|
59
|
+
if parsed.scheme not in {"http", "https"} or not host:
|
|
60
|
+
raise McpError("MCP URL must use http or https")
|
|
61
|
+
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
|
|
62
|
+
raise McpError("MCP URL must not contain credentials, query parameters, or fragments")
|
|
63
|
+
if host.lower() not in {"127.0.0.1", "localhost", "::1"} and not allow_remote:
|
|
64
|
+
raise McpError("remote MCP URL requires --allow-remote-mcp")
|
|
65
|
+
return url
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
69
|
+
"""Never forward MCP headers or tokens to a redirect target."""
|
|
70
|
+
|
|
71
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def tool_text(result: dict, *secrets: str | None) -> str:
|
|
76
|
+
"""Return textual MCP tool output, rejecting tool-level error envelopes."""
|
|
77
|
+
content = result.get("content")
|
|
78
|
+
texts = []
|
|
79
|
+
if isinstance(content, list):
|
|
80
|
+
texts = [item.get("text", "") for item in content if isinstance(item, dict) and item.get("type") == "text"]
|
|
81
|
+
summary = "\n".join(text for text in texts if text).strip()
|
|
82
|
+
if result.get("isError"):
|
|
83
|
+
detail = redact(summary[:300] or "no error details", *secrets)
|
|
84
|
+
raise McpError(f"MCP tool failed: {detail}")
|
|
85
|
+
if not summary:
|
|
86
|
+
raise McpError("MCP tool returned no textual result")
|
|
87
|
+
return summary
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class McpClient:
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
url: str,
|
|
94
|
+
instance_token: str | None = None,
|
|
95
|
+
timeout: float = 120.0,
|
|
96
|
+
allow_remote: bool = False,
|
|
97
|
+
*,
|
|
98
|
+
connect_timeout: float = 20.0,
|
|
99
|
+
):
|
|
100
|
+
self.url = validate_mcp_url(url, allow_remote=allow_remote)
|
|
101
|
+
self.instance_token = instance_token
|
|
102
|
+
self.timeout = timeout
|
|
103
|
+
self.connect_timeout = connect_timeout
|
|
104
|
+
self.session_id: str | None = None
|
|
105
|
+
self._next_id = 0
|
|
106
|
+
self._opener = urllib.request.build_opener(NoRedirectHandler())
|
|
107
|
+
|
|
108
|
+
def _post(self, payload: dict, *, timeout: float | None = None) -> dict | None:
|
|
109
|
+
expected_id = payload.get("id")
|
|
110
|
+
phase = str(payload.get("method", "request"))
|
|
111
|
+
effective_timeout = self.timeout if timeout is None else timeout
|
|
112
|
+
started = time.monotonic()
|
|
113
|
+
body = json.dumps(payload).encode("utf-8")
|
|
114
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|
|
115
|
+
if self.session_id:
|
|
116
|
+
headers["Mcp-Session-Id"] = self.session_id
|
|
117
|
+
if self.instance_token:
|
|
118
|
+
headers["X-Unity-Instance-Token"] = self.instance_token
|
|
119
|
+
req = urllib.request.Request(self.url, data=body, headers=headers, method="POST")
|
|
120
|
+
try:
|
|
121
|
+
with self._opener.open(req, timeout=effective_timeout) as resp:
|
|
122
|
+
sid = resp.headers.get("Mcp-Session-Id")
|
|
123
|
+
if sid:
|
|
124
|
+
self.session_id = sid
|
|
125
|
+
ctype = resp.headers.get("Content-Type", "")
|
|
126
|
+
raw = resp.read().decode("utf-8")
|
|
127
|
+
except urllib.error.HTTPError as e:
|
|
128
|
+
detail = redact(e.read().decode("utf-8", "replace")[:300], self.instance_token)
|
|
129
|
+
raise McpError(f"HTTP {e.code}: {detail}") from e
|
|
130
|
+
except urllib.error.URLError as e:
|
|
131
|
+
if isinstance(e.reason, TimeoutError):
|
|
132
|
+
raise _timeout_error(phase, started, effective_timeout) from e
|
|
133
|
+
detail = redact(str(e.reason), self.instance_token)
|
|
134
|
+
raise McpError(f"cannot reach MCP server: {detail}") from e
|
|
135
|
+
except TimeoutError as e:
|
|
136
|
+
raise _timeout_error(phase, started, effective_timeout) from e
|
|
137
|
+
if "text/event-stream" in ctype:
|
|
138
|
+
collected: list[dict] = []
|
|
139
|
+
for line in raw.splitlines():
|
|
140
|
+
if line.startswith("data:"):
|
|
141
|
+
chunk = line[5:].strip()
|
|
142
|
+
if chunk and chunk != "[DONE]":
|
|
143
|
+
try:
|
|
144
|
+
frame = json.loads(chunk)
|
|
145
|
+
except json.JSONDecodeError as exc:
|
|
146
|
+
raise McpError("MCP SSE response contained malformed JSON") from exc
|
|
147
|
+
if isinstance(frame, dict):
|
|
148
|
+
collected.append(frame)
|
|
149
|
+
if not collected:
|
|
150
|
+
return None
|
|
151
|
+
if expected_id is None:
|
|
152
|
+
return None
|
|
153
|
+
return _matching_response(collected, expected_id)
|
|
154
|
+
if not raw.strip():
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
response = json.loads(raw)
|
|
158
|
+
except json.JSONDecodeError as exc:
|
|
159
|
+
raise McpError("MCP response contained malformed JSON") from exc
|
|
160
|
+
if not isinstance(response, dict):
|
|
161
|
+
raise McpError("MCP JSON-RPC response must be an object")
|
|
162
|
+
if expected_id is None:
|
|
163
|
+
return response
|
|
164
|
+
return _matching_response([response], expected_id)
|
|
165
|
+
|
|
166
|
+
def _rpc(
|
|
167
|
+
self,
|
|
168
|
+
method: str,
|
|
169
|
+
params: dict | None = None,
|
|
170
|
+
notify: bool = False,
|
|
171
|
+
timeout: float | None = None,
|
|
172
|
+
) -> dict | None:
|
|
173
|
+
self._next_id += 1
|
|
174
|
+
payload: dict = {"jsonrpc": "2.0", "method": method}
|
|
175
|
+
if params is not None:
|
|
176
|
+
payload["params"] = params
|
|
177
|
+
if not notify:
|
|
178
|
+
payload["id"] = self._next_id
|
|
179
|
+
resp = self._post(payload) if timeout is None else self._post(payload, timeout=timeout)
|
|
180
|
+
if notify:
|
|
181
|
+
return None
|
|
182
|
+
if resp is None:
|
|
183
|
+
raise McpError(f"empty response for {method}")
|
|
184
|
+
if "error" in resp:
|
|
185
|
+
detail = redact(str(resp["error"]), self.instance_token)
|
|
186
|
+
raise McpError(f"{method}: {detail}")
|
|
187
|
+
return resp
|
|
188
|
+
|
|
189
|
+
def connect(self) -> dict:
|
|
190
|
+
started = time.monotonic()
|
|
191
|
+
deadline = started + self.connect_timeout
|
|
192
|
+
|
|
193
|
+
def remaining(phase: str) -> float:
|
|
194
|
+
now = time.monotonic()
|
|
195
|
+
value = deadline - now
|
|
196
|
+
if value > 0:
|
|
197
|
+
return value
|
|
198
|
+
raise _timeout_error(phase, started, self.connect_timeout, now=now)
|
|
199
|
+
|
|
200
|
+
resp = self._rpc(
|
|
201
|
+
"initialize",
|
|
202
|
+
{
|
|
203
|
+
"protocolVersion": "2024-11-05",
|
|
204
|
+
"capabilities": {},
|
|
205
|
+
"clientInfo": {"name": "asecli", "version": __version__},
|
|
206
|
+
},
|
|
207
|
+
timeout=remaining("initialize"),
|
|
208
|
+
)
|
|
209
|
+
self._rpc(
|
|
210
|
+
"notifications/initialized",
|
|
211
|
+
{},
|
|
212
|
+
notify=True,
|
|
213
|
+
timeout=remaining("notifications/initialized"),
|
|
214
|
+
)
|
|
215
|
+
return resp.get("result", {}).get("serverInfo", {})
|
|
216
|
+
|
|
217
|
+
def call_tool(self, name: str, arguments: dict) -> dict:
|
|
218
|
+
resp = self._rpc("tools/call", {"name": name, "arguments": arguments})
|
|
219
|
+
result = resp.get("result", {})
|
|
220
|
+
tool_text(result, self.instance_token)
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _matching_response(frames: list[dict], expected_id: object) -> dict:
|
|
225
|
+
matches = [frame for frame in frames if frame.get("id") == expected_id]
|
|
226
|
+
if not matches:
|
|
227
|
+
observed = [frame.get("id") for frame in frames if "id" in frame]
|
|
228
|
+
raise McpError(f"JSON-RPC response id mismatch: expected {expected_id!r}, observed {observed!r}")
|
|
229
|
+
if len(matches) > 1:
|
|
230
|
+
raise McpError(f"duplicate JSON-RPC responses for request id {expected_id!r}")
|
|
231
|
+
return matches[0]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Trigger ASE regeneration for a shader inside a running Unity/Tuanjie editor (TASK-0011)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .mcp_client import McpClient, McpError, tool_text
|
|
11
|
+
|
|
12
|
+
RECOMPILE_SNIPPET = '''
|
|
13
|
+
string assetPath = {asset_path};
|
|
14
|
+
var shader = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.Shader>(assetPath);
|
|
15
|
+
if (shader == null) throw new System.Exception("shader not found: " + assetPath);
|
|
16
|
+
var previousWindow = AmplifyShaderEditor.UIUtils.CurrentWindow;
|
|
17
|
+
var previousSelection = UnityEditor.Selection.activeObject;
|
|
18
|
+
AmplifyShaderEditor.AmplifyShaderEditorWindow win = null;
|
|
19
|
+
try
|
|
20
|
+
{
|
|
21
|
+
win = UnityEditor.EditorWindow.CreateInstance<AmplifyShaderEditor.AmplifyShaderEditorWindow>();
|
|
22
|
+
AmplifyShaderEditor.UIUtils.CurrentWindow = win;
|
|
23
|
+
var uiTextInfo = typeof(AmplifyShaderEditor.UIUtils).GetField(
|
|
24
|
+
"m_textInfo",
|
|
25
|
+
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
|
|
26
|
+
if (uiTextInfo != null && uiTextInfo.GetValue(null) == null)
|
|
27
|
+
uiTextInfo.SetValue(null, new System.Globalization.CultureInfo("en-US", false).TextInfo);
|
|
28
|
+
|
|
29
|
+
// CommentaryNode.Position reads Event.current. A direct hidden-window load
|
|
30
|
+
// therefore fails outside OnGUI. Defer the asset and dispatch real GUI
|
|
31
|
+
// events so ASE loads comment groups in its normal editor lifecycle.
|
|
32
|
+
var delayedLoad = typeof(AmplifyShaderEditor.AmplifyShaderEditorWindow).GetField(
|
|
33
|
+
"m_delayedLoadObject",
|
|
34
|
+
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
|
35
|
+
if (delayedLoad == null)
|
|
36
|
+
throw new System.Exception("ASE delayed-load field not found");
|
|
37
|
+
delayedLoad.SetValue(win, shader);
|
|
38
|
+
win.Show();
|
|
39
|
+
|
|
40
|
+
var layoutEvent = new UnityEngine.Event();
|
|
41
|
+
layoutEvent.type = UnityEngine.EventType.Layout;
|
|
42
|
+
win.SendEvent(layoutEvent);
|
|
43
|
+
var repaintEvent = new UnityEngine.Event();
|
|
44
|
+
repaintEvent.type = UnityEngine.EventType.Repaint;
|
|
45
|
+
win.SendEvent(repaintEvent);
|
|
46
|
+
|
|
47
|
+
if (win.CurrentGraph == null || win.CurrentGraph.CurrentMasterNode == null)
|
|
48
|
+
throw new System.Exception("ASE graph did not load in GUI event");
|
|
49
|
+
bool saved = win.SaveToDisk(false);
|
|
50
|
+
return "recompiled, saved=" + saved;
|
|
51
|
+
}
|
|
52
|
+
finally
|
|
53
|
+
{
|
|
54
|
+
UnityEditor.Selection.activeObject = previousSelection;
|
|
55
|
+
AmplifyShaderEditor.UIUtils.CurrentWindow = previousWindow;
|
|
56
|
+
if (win != null)
|
|
57
|
+
{
|
|
58
|
+
win.Close();
|
|
59
|
+
UnityEngine.Object.DestroyImmediate(win);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
'''
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def recompile_via_mcp(
|
|
66
|
+
shader_path: str,
|
|
67
|
+
mcp_url: str = "http://127.0.0.1:8080/mcp",
|
|
68
|
+
instance_token: str | None = None,
|
|
69
|
+
allow_remote_mcp: bool = False,
|
|
70
|
+
) -> dict:
|
|
71
|
+
"""Open the shader in ASE inside the running editor and force save (regenerate HLSL)."""
|
|
72
|
+
p = Path(shader_path).resolve()
|
|
73
|
+
if not p.exists():
|
|
74
|
+
raise FileNotFoundError(shader_path)
|
|
75
|
+
before = hashlib.sha1(p.read_bytes()).hexdigest()
|
|
76
|
+
project_root = _detect_project_root(p)
|
|
77
|
+
asset_path = p.as_posix().removeprefix(project_root.as_posix() + "/")
|
|
78
|
+
client = McpClient(mcp_url, instance_token=instance_token, allow_remote=allow_remote_mcp)
|
|
79
|
+
client.connect()
|
|
80
|
+
result = client.call_tool(
|
|
81
|
+
"execute_code", {"action": "execute", "code": RECOMPILE_SNIPPET.replace("{asset_path}", json.dumps(asset_path))}
|
|
82
|
+
)
|
|
83
|
+
result_text = tool_text(result, instance_token)
|
|
84
|
+
saved_match = re.search(r"\brecompiled,\s*saved=(true|false)\b", result_text, re.IGNORECASE)
|
|
85
|
+
if not saved_match:
|
|
86
|
+
raise McpError("MCP tool result did not confirm saved state")
|
|
87
|
+
saved = saved_match.group(1).lower() == "true"
|
|
88
|
+
if not saved:
|
|
89
|
+
raise McpError("MCP tool did not confirm saved=True")
|
|
90
|
+
after = hashlib.sha1(p.read_bytes()).hexdigest()
|
|
91
|
+
return {
|
|
92
|
+
"transport": "mcp",
|
|
93
|
+
"server": mcp_url,
|
|
94
|
+
"asset_path": asset_path,
|
|
95
|
+
"saved": saved,
|
|
96
|
+
"changed": before != after,
|
|
97
|
+
"tool_result": "recompiled, saved=True",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _detect_project_root(shader_path: Path) -> Path:
|
|
102
|
+
cur = shader_path.parent
|
|
103
|
+
while cur != cur.parent:
|
|
104
|
+
if (cur / "Assets").is_dir() and (cur / "ProjectSettings").is_dir():
|
|
105
|
+
return cur
|
|
106
|
+
cur = cur.parent
|
|
107
|
+
raise ValueError(f"cannot locate Unity project root for {shader_path}")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Deterministically compose packaged text resources from ordered fragments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compose_resource_text(package: str, directory: str, fragments: Iterable[str]) -> str:
|
|
10
|
+
"""Return the byte-stable UTF-8 concatenation of explicitly ordered fragments."""
|
|
11
|
+
root = resources.files(package).joinpath(directory)
|
|
12
|
+
return "".join(root.joinpath(name).read_text(encoding="utf-8") for name in fragments)
|