ue-prism 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. ue_prism-1.0.0/LICENSE +21 -0
  2. ue_prism-1.0.0/PKG-INFO +225 -0
  3. ue_prism-1.0.0/README.md +198 -0
  4. ue_prism-1.0.0/prism/__init__.py +3 -0
  5. ue_prism-1.0.0/prism/__main__.py +7 -0
  6. ue_prism-1.0.0/prism/attributelog.py +191 -0
  7. ue_prism-1.0.0/prism/autostart.py +48 -0
  8. ue_prism-1.0.0/prism/bridge.py +119 -0
  9. ue_prism-1.0.0/prism/bus.py +162 -0
  10. ue_prism-1.0.0/prism/cli.py +287 -0
  11. ue_prism-1.0.0/prism/domain/__init__.py +22 -0
  12. ue_prism-1.0.0/prism/domain/actors.py +98 -0
  13. ue_prism-1.0.0/prism/domain/assets.py +467 -0
  14. ue_prism-1.0.0/prism/domain/metrics.py +259 -0
  15. ue_prism-1.0.0/prism/domain/migrate.py +303 -0
  16. ue_prism-1.0.0/prism/domain/ping.py +71 -0
  17. ue_prism-1.0.0/prism/envelope.py +28 -0
  18. ue_prism-1.0.0/prism/folderscan.py +85 -0
  19. ue_prism-1.0.0/prism/logscan.py +96 -0
  20. ue_prism-1.0.0/prism/registry.py +146 -0
  21. ue_prism-1.0.0/prism/rules.py +345 -0
  22. ue_prism-1.0.0/prism/server.py +534 -0
  23. ue_prism-1.0.0/prism/tasks.py +271 -0
  24. ue_prism-1.0.0/prism/uat.py +240 -0
  25. ue_prism-1.0.0/pyproject.toml +42 -0
  26. ue_prism-1.0.0/setup.cfg +4 -0
  27. ue_prism-1.0.0/tests/test_assets.py +40 -0
  28. ue_prism-1.0.0/tests/test_attribute.py +137 -0
  29. ue_prism-1.0.0/tests/test_bus.py +49 -0
  30. ue_prism-1.0.0/tests/test_buscall.py +30 -0
  31. ue_prism-1.0.0/tests/test_cli.py +142 -0
  32. ue_prism-1.0.0/tests/test_cook.py +160 -0
  33. ue_prism-1.0.0/tests/test_folderscan.py +42 -0
  34. ue_prism-1.0.0/tests/test_logscan.py +49 -0
  35. ue_prism-1.0.0/tests/test_metrics_degrade.py +27 -0
  36. ue_prism-1.0.0/tests/test_migrate.py +227 -0
  37. ue_prism-1.0.0/tests/test_migration_preview.py +138 -0
  38. ue_prism-1.0.0/tests/test_pr05_contract.py +96 -0
  39. ue_prism-1.0.0/tests/test_registry.py +71 -0
  40. ue_prism-1.0.0/tests/test_resolve.py +99 -0
  41. ue_prism-1.0.0/tests/test_rules.py +245 -0
  42. ue_prism-1.0.0/tests/test_tasks.py +144 -0
  43. ue_prism-1.0.0/ue_prism.egg-info/PKG-INFO +225 -0
  44. ue_prism-1.0.0/ue_prism.egg-info/SOURCES.txt +46 -0
  45. ue_prism-1.0.0/ue_prism.egg-info/dependency_links.txt +1 -0
  46. ue_prism-1.0.0/ue_prism.egg-info/entry_points.txt +3 -0
  47. ue_prism-1.0.0/ue_prism.egg-info/requires.txt +4 -0
  48. ue_prism-1.0.0/ue_prism.egg-info/top_level.txt +1 -0
ue_prism-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Za0Shu1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,225 @@
1
+ Metadata-Version: 2.4
2
+ Name: ue-prism
3
+ Version: 1.0.0
4
+ Summary: 面向 Unreal Engine 项目的 MCP 诊断服务器(日志 / 性能 / 引用,结构化只读)
5
+ Author: Za0Shu1
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Za0Shu1/ue-prism
8
+ Project-URL: Repository, https://github.com/Za0Shu1/ue-prism
9
+ Project-URL: Issues, https://github.com/Za0Shu1/ue-prism/issues
10
+ Project-URL: Documentation, https://github.com/Za0Shu1/ue-prism/blob/main/docs/SETUP.md
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Classifier: Topic :: Software Development :: Testing
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: mcp<3,>=2
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # ue-prism
29
+
30
+ > 把 Unreal 项目像棱镜一样分光解析——日志、性能开销、资产引用,交给 AI agent 逐条检验。
31
+
32
+ **ue-prism** 是一个面向 Unreal Engine 项目的 MCP(Model Context Protocol)诊断服务器:让 AI agent 读取并分析**编辑器/cook/package 日志**(报错归因)、**场景性能与资产开销**(优化建议)、**资产引用链**(迁移决策),所有结果结构化返回、机器可读。
33
+
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
35
+ ![UE](https://img.shields.io/badge/UE-5.0%20%E2%80%93%205.8%2B-blue)
36
+ ![状态](https://img.shields.io/badge/状态-v1.0-brightgreen)
37
+
38
+ > 当前进度:**v1.0 已交付**。v0.1 连通链路 → v0.2 cook/package → v0.3 性能静态规则引擎(`get_perf_report` 7 规则、`get_asset_metrics` 度量,5.4/5.8 校准一致)→ **v1.0**:连接发现(零配置 · bridge 自注册 · 全工具 `project=` 选择器 · `list_projects`)、引用链分析(`get_asset_chain` 递归 `uses`/`used_by` 闭包,按类聚合数量/体量)、迁移套件(写操作 `migrate_asset_rename`/`_move`/`_asset`〔A·同工程复制依赖闭包〕,双钥 dry_run + 成功后自动清理 redirector 桩)、发布(GitHub Releases 已就绪 · `release.yml`;PyPI 走 Trusted Publishing 待注册免费账号 · `publish.yml` 由 `PYPI_PUBLISH` 变量开关))、一条命令安装器(`prism setup --client`,幂等/可卸载/`--dry-run`)+ `server.json` 注册清单。UE 5.4.4 真机实证完毕。共 **18 工具、123 测试**。详细需求与验收见 [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md)。
39
+
40
+ ## 为什么要做这个
41
+
42
+ UE 5.8 随引擎发布了实验性官方 MCP 插件(ModelContextProtocol + AllToolsets),但实测与社区反馈暴露了系统性缺陷。ue-prism 的每一条设计都是它的反面:
43
+
44
+ | 官方插件的问题 | ue-prism 的对策 |
45
+ |---|---|
46
+ | 失败以 `Ok` 文本返回,agent 无法判别成败 | 统一结构化错误信封,错误绝不伪装成成功 |
47
+ | 无就绪握手,早期拿到残缺工具表 | `ping` 握手,先确认编辑器在线 |
48
+ | 52+ toolset 数百函数,lazy 发现、schema 巨大 | ≤20 个扁平工具,签名全在文档里 |
49
+ | 静默截断(列表限 20 条不提示) | 所有列表携带 `total / truncated / cap` |
50
+ | 绑定 5.8 Experimental API,随版本漂移 | 只依赖 `unreal` Python API + 纯标准库传输,5.0–5.8+ 通吃 |
51
+
52
+ **默认只读。** 写操作(cook、迁移改名/移动)一律 dry-run 预览 + 双钥确认(`dry_run=False` 且 `confirm=True`)才落盘。
53
+
54
+ ## 架构
55
+
56
+ ```
57
+ Claude Code / Cursor / opencode
58
+ │ stdio (MCP)
59
+
60
+ prism server (Python 3.10+,不碰任何引擎 API)
61
+ │ 文件总线:cmd_*.json ⇄ res_*.json
62
+
63
+ bridge (UE 编辑器内 Python,主线程 tick 轮询)
64
+ │ 白名单函数分发
65
+
66
+ domain/*.py ── 纯 `unreal` API + 标准库
67
+ (日志分析 / 资产扫描 / 场景清单 / 引用查询)
68
+ ```
69
+
70
+ 文件总线刻意做得"无聊":原子 rename、无 socket、无线程,编辑器崩溃不拖累外部进程,任何带 Python 插件的引擎版本都能用。轮询放在**主线程 tick**(UE 5.6+ 禁止在非 game thread 调用 `unreal`)。bridge 顺带限频写一个仅含时间戳的心跳标记,客户端据此对"编辑器已关/主线程停摆"立即快速失败,不再空等 30s 超时;超时崩溃残留的孤儿命令/响应文件会被自动清扫。将来可无痛替换为 socket/HTTP。
71
+
72
+ ## 仓库结构
73
+
74
+ ```text
75
+ prism/
76
+ ├── server.py # 协议层:MCP 工具(18 个),绝不 import unreal
77
+ ├── bridge.py # 传输层:编辑器内白名单分发 + 信封透传 + v1.0 自注册指针
78
+ ├── registry.py # v1.0 连接发现:固定目录指针写/读 + 现场心跳新鲜度
79
+ ├── autostart.py # v1.0 pip 安装后 UE 侧一行自启(随包发布,无需 sys.path)
80
+ ├── bus.py # 文件总线:原子写 / 心跳标记 / 孤儿清扫
81
+ ├── envelope.py # 统一信封与错误码(三层通用,纯标准库)
82
+ ├── tasks.py # v0.2 任务登记表:惰性状态推导 / BUSY 互斥
83
+ ├── uat.py # v0.2 UAT 定位与提交:引擎路由 / 命令模板 / 跨重启合成
84
+ ├── attributelog.py # v0.2 日志→资产归因:指纹分组 / 磁盘解析 / 批量富化
85
+ ├── rules.py # v0.3 规则引擎:profile/TOML 阈值 + 7 规则 + 报告聚合
86
+ ├── logscan.py # read_editor_log 离线聚合
87
+ ├── folderscan.py # scan_folder_assets 离线审计
88
+ └── domain/ # 领域层:ping / actors / assets / metrics(含 describe_many、
89
+ # referencers_many 批量函数),纯 unreal + 标准库,守 3.7 子集
90
+ scripts/ # autostart_bridge.py + verify_connection.py / verify_migration.py + CLI 手测
91
+ tests/ # 123 项无引擎测试(总线/任务/cook/归因/规则引擎/扫描 + 连接发现 / 引用链 / 迁移)
92
+ docs/ # SETUP.md(操作)· REQUIREMENTS.md(契约单一事实源)· DESIGN_v0.2.md
93
+ .github/workflows/ # CI:push/PR 跑 pytest(3.10 / 3.12 矩阵)
94
+ ```
95
+
96
+ ## 工具(18 个:只读诊断/引用链 + 双钥确认的 cook / 迁移 + 规则报告 + 连接发现)
97
+
98
+ 经 bridge(需编辑器在线):
99
+
100
+ | 工具 | 说明 |
101
+ |---|---|
102
+ | `ping` | bridge 心跳、UE 版本、工程路径、已加载地图 |
103
+ | `list_level_actors` | 场景清单:类名/标签过滤、transform、组件数 |
104
+ | `describe_asset` | 单资产元数据:类、包路径、磁盘大小、脏标记 |
105
+ | `get_asset_references` | 双向引用:`uses`(依赖) / `used_by`(被引用),可递归、限量 |
106
+
107
+ 离线(不依赖编辑器,读磁盘):
108
+
109
+ | 工具 | 说明 |
110
+ |---|---|
111
+ | `read_editor_log` | 日志聚合:Error/Warning 分组、计数、首现时间、样本 |
112
+ | `scan_folder_assets` | 目录资产审计:按大小排序、类型汇总、Top 开销元凶 |
113
+
114
+ 外部进程(server 侧调 UAT,编辑器须关闭;首个非只读工具族):
115
+
116
+ | 工具 | 说明 |
117
+ |---|---|
118
+ | `cook_package` | 触发 cook/package;**默认 dry-run,`dry_run=False`+`confirm=True` 双钥才执行**;同工程 BUSY 互斥 |
119
+ | `get_cook_status` | 任务状态**读时惰性推导**(done 标记/硬时限/pid/日志收束行),无守护进程 |
120
+ | `attribute_cook_errors` | 日志→资产归因:指纹分组 + 磁盘解析;桥在线时 `describe_many`/`referencers_many` 批量富化 |
121
+
122
+ 性能规则(v0.3 · 混合通道:桥在线出全量、离线自动半份并逐条注明 skip):
123
+
124
+ | 工具 | 说明 |
125
+ |---|---|
126
+ | `get_perf_report` | 静态性能健康报告:7 规则(资产大小/纹理尺寸/网格三角+单LOD/光源重复/WP 外部 actor 数/cook 静默丢弃/cook 空煮),top-80 大资产抽样度量 |
127
+ | `list_perf_rules` | 当前 profile 生效规则与阈值(可解释性入口;pc/console/mobile 三档 + 工程 prism.toml 覆盖) |
128
+ | `get_asset_metrics` | 批量原语度量:纹理 px / 网格 LOD 三角,带 `tried` API 证据链(5.4/5.8 双版本校准) |
129
+
130
+ 连接发现 · 引用链 · 迁移(v1.0 · 去硬编码路径 + 依赖闭包分析 + 写操作):
131
+
132
+ | 工具 | 说明 |
133
+ |---|---|
134
+ | `list_projects` | 零配置枚举已注册工程 + 活跃态(`live`/`heartbeat_age`);供 agent 先探后用 |
135
+ | `get_asset_chain` | 递归依赖闭包(迁移分析):`uses`=要带走的全部 `/Game` 依赖(带类别/磁盘大小,`by_class` 聚合体量);`used_by`=谁依赖它;`scope=game` 默认滤引擎噪声 |
136
+ | `preview_asset_migration` | 只读迁移预览:源/目标冲突检测 + `used_by` 影响面 → dry-run 计划(目标仅剩改名遗留的 `ObjectRedirector` 桩时不算冲突) |
137
+ | `migrate_asset_rename` | 真改名(写 · 双钥):走 `rename_asset(pkg,pkg)` 自动修引用,成功后默认清一次重定向桩 |
138
+ | `migrate_asset_move` | 真移动(写 · 双钥):移动到目录,可带 new_name 同时改名,默认清桩 |
139
+ | `migrate_asset` | 迁移 A·同工程复制(写 · 双钥):把资产 + 其 `/Game` `uses` 闭包镜像复制到目标目录;默认 dry-run 出复制计划(按类数量/体量/冲突);原件不动 |
140
+
141
+ 所有桥 / 离线 / cook / 迁移工具接受可选 `project=` 选择器;未配置工程路径时按判定阶梯自动定位(详见 REQUIREMENTS §3.5)。
142
+
143
+ ## 快速开始
144
+
145
+ > v0.1/v0.2 均已跑通。完整操作(`pip` 安装 / UE 开机自启 / Codex·opencode 配置 / cook 双钥)见 [docs/SETUP.md](docs/SETUP.md);设计契约见 [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md)。下面是最小三步速览(`<REPO>`=仓库绝对路径、`<PROJ>`=UE 工程根,均用正斜杠)。
146
+
147
+ ```bash
148
+ # 1. 安装 server
149
+ pip install git+https://github.com/Za0Shu1/ue-prism.git # 现在即可用(GitHub Releases 也已就绪)
150
+ # pip install ue-prism # PyPI:注册免费账号 + 配 Trusted Publishing 后启用(见 docs/SETUP.md §7)
151
+
152
+ # 2. 编辑器自启(推荐):在工程 Config/DefaultEngine.ini 注册一次后,重开编辑器即自动起 bridge:
153
+ # [/Script/PythonScriptPlugin.PythonScriptPluginSettings]
154
+ # +StartupScripts="<REPO>/scripts/autostart_bridge.py"
155
+ # 手动兜底(Output Log 的 [PY] 控制台):
156
+ import sys; sys.path.insert(0, r"<REPO>")
157
+ import prism.bridge; prism.bridge.start(r"<PROJ>/Saved/Prism")
158
+
159
+ # 3. 在 MCP 客户端注册(一条命令幂等写入、零工程路径;v1.0 bridge 自注册后 server 自动发现):
160
+ prism setup --client codex # 或 opencode / cursor / all;--dry-run 预览、--uninstall 移除
161
+ # 等价手写:command: python -m prism.server (或装包后 ue-prism)
162
+ # 多工程时 agent 先 list_projects 探测、或调用时传 project=<工程名>
163
+ # (仍可钉死单工程:加 env PRISM_BUS_DIR=<PROJ>/Saved/Prism PRISM_PROJECT_DIR=<PROJ>)
164
+ ```
165
+
166
+ 先问 agent 一句验证连通:*"你现在连上的是哪个 UE 项目?里面加载了哪些地图?"*
167
+ 接入日志工具后,可试:*"把最近编辑器日志里的 Error/Warning 归类,指出可疑原因。"*
168
+ cook 回路(关编辑器、双钥)可试:*"先 dry-run 看看 cook 会跑什么命令"* → *"确认执行"* → *"把这次 cook 的报错归因到资产"*
169
+
170
+ ## 路线图
171
+
172
+ - **v0.1**(已发布 · v0.1.0):三层骨架 + 文件总线 + `ping` 端到端连通 + 结构化错误契约 + UE 5.4 实测 + 6 只读工具 + bridge 编辑器开机自启
173
+ - **v0.2**(已发布 · v0.2.0,v0.2.1 已清尾):cook/package 全回路真机实证完毕 —— cook 容错三坑、package 25s/786MB 一次通过、5.8 版式(`PRISM_ENGINE_ROOT`)同工程通过、失败注入还原闭环
174
+ - **v0.3**(已发布 · v0.3.0):性能静态规则引擎(7 规则全来自真机案例;纹理/网格度量 5.4 与 5.8 校准一致;桥离线自动半份报告)
175
+ - **v1.0**(已交付 · 版本 1.0.0,待打 tag 发 PyPI):连接发现(零配置/自注册/`project=` 选择器)+ 引用链分析(`get_asset_chain` 传递闭包)+ 迁移套件(rename/move/copy 写操作,双钥 + redirector 自动清理);UE 5.4.4 真机实证完毕
176
+ - 之后:bridge 打包成正式 UE 插件、socket 传输、Epic 官方 5.8 toolset 可选后端、截图验证工具
177
+
178
+ ## 运行要求
179
+
180
+ - Unreal Engine 5.0+,启用 Python Editor Scripting(launcher 安装自带)
181
+ - 编辑器侧:引擎内嵌 Python 随 UE 版本 3.7→3.11(domain 层守 3.7 语法子集)
182
+ - server 侧:Python ≥3.10 + MCP Python SDK(锁 2.x),无引擎依赖
183
+
184
+ ## 本机真机测试(UE 5.4)
185
+
186
+ - 工程目录:`F:/UE_Projects/TestProject/UE_PRISM_Project`(已在 DefaultEngine.ini 注册 bridge 自启;5.4.4 与 5.8.2 双版本真机验证过)
187
+ - 文件总线目录:`<工程>/Saved/Prism`(含 `heartbeat.json` 活性标记与 `tasks/` 任务档案)
188
+ - ue-prism 仓库:`F:/VibeCoding/ue-prism`;server 侧解释器:`F:/venv/Scripts/python.exe`
189
+
190
+ **1) 双击打开工程(EngineAssociation=5.4)**——bridge 已由 StartupScripts 自启,Output Log 出现 `[prism] bridge started` 即在线;`<工程>/Saved/Prism/heartbeat.json` 应每秒级更新。
191
+
192
+ **2) 另开系统终端,从总线外部验证连通与快速失败:**
193
+ ```powershell
194
+ F:\venv\Scripts\python.exe F:\VibeCoding\ue-prism\scripts\ping_cli.py "F:/UE_Projects/TestProject/UE_PRISM_Project/Saved/Prism"
195
+ ```
196
+ 期望:`ok:true`;关编辑器再戳 → 约 1s 内 `BRIDGE_UNREACHABLE`(心跳陈旧),不再空等 30s。
197
+
198
+ **3) cook 回路(关闭编辑器后;细则见 docs/SETUP.md §4):**
199
+ ```powershell
200
+ F:\venv\Scripts\python.exe -m prism.server --project-dir F:\UE_Projects\TestProject\UE_PRISM_Project
201
+ # 或在 agent 里:cook_package() 干跑 -> cook_package(dry_run=False, confirm=True) -> get_cook_status(task_id)
202
+ ```
203
+
204
+ **4) 可选——无头探针(unreal API 名跨版本校核):**
205
+ ```powershell
206
+ & "E:\UnrealEngine\UE_5.4\Engine\Binaries\Win64\UnrealEditor-Cmd.exe" `
207
+ "F:\UE_Projects\TestProject\UE_PRISM_Project\UE_PRISM_Project.uproject" `
208
+ -run=pythonscript -script="F:\VibeCoding\ue-prism\scripts\probe_unreal.py"
209
+ ```
210
+ ## 相关项目
211
+
212
+ - [chongdashu/unreal-mcp](https://github.com/chongdashu/unreal-mcp)、[IvanMurzak/Unreal-MCP](https://github.com/IvanMurzak/Unreal-MCP) —— 侧重编辑器**操控**;ue-prism 侧重**诊断与分析**
213
+ - Epic 官方 `ModelContextProtocol` 插件(UE 5.8)—— 原生但实验性;ue-prism 覆盖 5.0–5.8 行为一致
214
+
215
+ ## 设计文档
216
+
217
+ 需求、契约与决策记录见 [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md);设计草案:[v0.2 cook 全回路](docs/DESIGN_v0.2.md)(已实现并真机实证)· [v0.3 性能静态规则引擎](docs/DESIGN_v0.3.md)(评审中)。
218
+
219
+ ## License
220
+
221
+ MIT © [Za0Shu1](https://github.com/Za0Shu1)
222
+
223
+ ---
224
+
225
+ Unreal® 与 Unreal Engine® 为 Epic Games, Inc. 注册商标。本项目与 Epic Games 无关联,亦未获其背书。
@@ -0,0 +1,198 @@
1
+ # ue-prism
2
+
3
+ > 把 Unreal 项目像棱镜一样分光解析——日志、性能开销、资产引用,交给 AI agent 逐条检验。
4
+
5
+ **ue-prism** 是一个面向 Unreal Engine 项目的 MCP(Model Context Protocol)诊断服务器:让 AI agent 读取并分析**编辑器/cook/package 日志**(报错归因)、**场景性能与资产开销**(优化建议)、**资产引用链**(迁移决策),所有结果结构化返回、机器可读。
6
+
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ ![UE](https://img.shields.io/badge/UE-5.0%20%E2%80%93%205.8%2B-blue)
9
+ ![状态](https://img.shields.io/badge/状态-v1.0-brightgreen)
10
+
11
+ > 当前进度:**v1.0 已交付**。v0.1 连通链路 → v0.2 cook/package → v0.3 性能静态规则引擎(`get_perf_report` 7 规则、`get_asset_metrics` 度量,5.4/5.8 校准一致)→ **v1.0**:连接发现(零配置 · bridge 自注册 · 全工具 `project=` 选择器 · `list_projects`)、引用链分析(`get_asset_chain` 递归 `uses`/`used_by` 闭包,按类聚合数量/体量)、迁移套件(写操作 `migrate_asset_rename`/`_move`/`_asset`〔A·同工程复制依赖闭包〕,双钥 dry_run + 成功后自动清理 redirector 桩)、发布(GitHub Releases 已就绪 · `release.yml`;PyPI 走 Trusted Publishing 待注册免费账号 · `publish.yml` 由 `PYPI_PUBLISH` 变量开关))、一条命令安装器(`prism setup --client`,幂等/可卸载/`--dry-run`)+ `server.json` 注册清单。UE 5.4.4 真机实证完毕。共 **18 工具、123 测试**。详细需求与验收见 [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md)。
12
+
13
+ ## 为什么要做这个
14
+
15
+ UE 5.8 随引擎发布了实验性官方 MCP 插件(ModelContextProtocol + AllToolsets),但实测与社区反馈暴露了系统性缺陷。ue-prism 的每一条设计都是它的反面:
16
+
17
+ | 官方插件的问题 | ue-prism 的对策 |
18
+ |---|---|
19
+ | 失败以 `Ok` 文本返回,agent 无法判别成败 | 统一结构化错误信封,错误绝不伪装成成功 |
20
+ | 无就绪握手,早期拿到残缺工具表 | `ping` 握手,先确认编辑器在线 |
21
+ | 52+ toolset 数百函数,lazy 发现、schema 巨大 | ≤20 个扁平工具,签名全在文档里 |
22
+ | 静默截断(列表限 20 条不提示) | 所有列表携带 `total / truncated / cap` |
23
+ | 绑定 5.8 Experimental API,随版本漂移 | 只依赖 `unreal` Python API + 纯标准库传输,5.0–5.8+ 通吃 |
24
+
25
+ **默认只读。** 写操作(cook、迁移改名/移动)一律 dry-run 预览 + 双钥确认(`dry_run=False` 且 `confirm=True`)才落盘。
26
+
27
+ ## 架构
28
+
29
+ ```
30
+ Claude Code / Cursor / opencode
31
+ │ stdio (MCP)
32
+
33
+ prism server (Python 3.10+,不碰任何引擎 API)
34
+ │ 文件总线:cmd_*.json ⇄ res_*.json
35
+
36
+ bridge (UE 编辑器内 Python,主线程 tick 轮询)
37
+ │ 白名单函数分发
38
+
39
+ domain/*.py ── 纯 `unreal` API + 标准库
40
+ (日志分析 / 资产扫描 / 场景清单 / 引用查询)
41
+ ```
42
+
43
+ 文件总线刻意做得"无聊":原子 rename、无 socket、无线程,编辑器崩溃不拖累外部进程,任何带 Python 插件的引擎版本都能用。轮询放在**主线程 tick**(UE 5.6+ 禁止在非 game thread 调用 `unreal`)。bridge 顺带限频写一个仅含时间戳的心跳标记,客户端据此对"编辑器已关/主线程停摆"立即快速失败,不再空等 30s 超时;超时崩溃残留的孤儿命令/响应文件会被自动清扫。将来可无痛替换为 socket/HTTP。
44
+
45
+ ## 仓库结构
46
+
47
+ ```text
48
+ prism/
49
+ ├── server.py # 协议层:MCP 工具(18 个),绝不 import unreal
50
+ ├── bridge.py # 传输层:编辑器内白名单分发 + 信封透传 + v1.0 自注册指针
51
+ ├── registry.py # v1.0 连接发现:固定目录指针写/读 + 现场心跳新鲜度
52
+ ├── autostart.py # v1.0 pip 安装后 UE 侧一行自启(随包发布,无需 sys.path)
53
+ ├── bus.py # 文件总线:原子写 / 心跳标记 / 孤儿清扫
54
+ ├── envelope.py # 统一信封与错误码(三层通用,纯标准库)
55
+ ├── tasks.py # v0.2 任务登记表:惰性状态推导 / BUSY 互斥
56
+ ├── uat.py # v0.2 UAT 定位与提交:引擎路由 / 命令模板 / 跨重启合成
57
+ ├── attributelog.py # v0.2 日志→资产归因:指纹分组 / 磁盘解析 / 批量富化
58
+ ├── rules.py # v0.3 规则引擎:profile/TOML 阈值 + 7 规则 + 报告聚合
59
+ ├── logscan.py # read_editor_log 离线聚合
60
+ ├── folderscan.py # scan_folder_assets 离线审计
61
+ └── domain/ # 领域层:ping / actors / assets / metrics(含 describe_many、
62
+ # referencers_many 批量函数),纯 unreal + 标准库,守 3.7 子集
63
+ scripts/ # autostart_bridge.py + verify_connection.py / verify_migration.py + CLI 手测
64
+ tests/ # 123 项无引擎测试(总线/任务/cook/归因/规则引擎/扫描 + 连接发现 / 引用链 / 迁移)
65
+ docs/ # SETUP.md(操作)· REQUIREMENTS.md(契约单一事实源)· DESIGN_v0.2.md
66
+ .github/workflows/ # CI:push/PR 跑 pytest(3.10 / 3.12 矩阵)
67
+ ```
68
+
69
+ ## 工具(18 个:只读诊断/引用链 + 双钥确认的 cook / 迁移 + 规则报告 + 连接发现)
70
+
71
+ 经 bridge(需编辑器在线):
72
+
73
+ | 工具 | 说明 |
74
+ |---|---|
75
+ | `ping` | bridge 心跳、UE 版本、工程路径、已加载地图 |
76
+ | `list_level_actors` | 场景清单:类名/标签过滤、transform、组件数 |
77
+ | `describe_asset` | 单资产元数据:类、包路径、磁盘大小、脏标记 |
78
+ | `get_asset_references` | 双向引用:`uses`(依赖) / `used_by`(被引用),可递归、限量 |
79
+
80
+ 离线(不依赖编辑器,读磁盘):
81
+
82
+ | 工具 | 说明 |
83
+ |---|---|
84
+ | `read_editor_log` | 日志聚合:Error/Warning 分组、计数、首现时间、样本 |
85
+ | `scan_folder_assets` | 目录资产审计:按大小排序、类型汇总、Top 开销元凶 |
86
+
87
+ 外部进程(server 侧调 UAT,编辑器须关闭;首个非只读工具族):
88
+
89
+ | 工具 | 说明 |
90
+ |---|---|
91
+ | `cook_package` | 触发 cook/package;**默认 dry-run,`dry_run=False`+`confirm=True` 双钥才执行**;同工程 BUSY 互斥 |
92
+ | `get_cook_status` | 任务状态**读时惰性推导**(done 标记/硬时限/pid/日志收束行),无守护进程 |
93
+ | `attribute_cook_errors` | 日志→资产归因:指纹分组 + 磁盘解析;桥在线时 `describe_many`/`referencers_many` 批量富化 |
94
+
95
+ 性能规则(v0.3 · 混合通道:桥在线出全量、离线自动半份并逐条注明 skip):
96
+
97
+ | 工具 | 说明 |
98
+ |---|---|
99
+ | `get_perf_report` | 静态性能健康报告:7 规则(资产大小/纹理尺寸/网格三角+单LOD/光源重复/WP 外部 actor 数/cook 静默丢弃/cook 空煮),top-80 大资产抽样度量 |
100
+ | `list_perf_rules` | 当前 profile 生效规则与阈值(可解释性入口;pc/console/mobile 三档 + 工程 prism.toml 覆盖) |
101
+ | `get_asset_metrics` | 批量原语度量:纹理 px / 网格 LOD 三角,带 `tried` API 证据链(5.4/5.8 双版本校准) |
102
+
103
+ 连接发现 · 引用链 · 迁移(v1.0 · 去硬编码路径 + 依赖闭包分析 + 写操作):
104
+
105
+ | 工具 | 说明 |
106
+ |---|---|
107
+ | `list_projects` | 零配置枚举已注册工程 + 活跃态(`live`/`heartbeat_age`);供 agent 先探后用 |
108
+ | `get_asset_chain` | 递归依赖闭包(迁移分析):`uses`=要带走的全部 `/Game` 依赖(带类别/磁盘大小,`by_class` 聚合体量);`used_by`=谁依赖它;`scope=game` 默认滤引擎噪声 |
109
+ | `preview_asset_migration` | 只读迁移预览:源/目标冲突检测 + `used_by` 影响面 → dry-run 计划(目标仅剩改名遗留的 `ObjectRedirector` 桩时不算冲突) |
110
+ | `migrate_asset_rename` | 真改名(写 · 双钥):走 `rename_asset(pkg,pkg)` 自动修引用,成功后默认清一次重定向桩 |
111
+ | `migrate_asset_move` | 真移动(写 · 双钥):移动到目录,可带 new_name 同时改名,默认清桩 |
112
+ | `migrate_asset` | 迁移 A·同工程复制(写 · 双钥):把资产 + 其 `/Game` `uses` 闭包镜像复制到目标目录;默认 dry-run 出复制计划(按类数量/体量/冲突);原件不动 |
113
+
114
+ 所有桥 / 离线 / cook / 迁移工具接受可选 `project=` 选择器;未配置工程路径时按判定阶梯自动定位(详见 REQUIREMENTS §3.5)。
115
+
116
+ ## 快速开始
117
+
118
+ > v0.1/v0.2 均已跑通。完整操作(`pip` 安装 / UE 开机自启 / Codex·opencode 配置 / cook 双钥)见 [docs/SETUP.md](docs/SETUP.md);设计契约见 [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md)。下面是最小三步速览(`<REPO>`=仓库绝对路径、`<PROJ>`=UE 工程根,均用正斜杠)。
119
+
120
+ ```bash
121
+ # 1. 安装 server
122
+ pip install git+https://github.com/Za0Shu1/ue-prism.git # 现在即可用(GitHub Releases 也已就绪)
123
+ # pip install ue-prism # PyPI:注册免费账号 + 配 Trusted Publishing 后启用(见 docs/SETUP.md §7)
124
+
125
+ # 2. 编辑器自启(推荐):在工程 Config/DefaultEngine.ini 注册一次后,重开编辑器即自动起 bridge:
126
+ # [/Script/PythonScriptPlugin.PythonScriptPluginSettings]
127
+ # +StartupScripts="<REPO>/scripts/autostart_bridge.py"
128
+ # 手动兜底(Output Log 的 [PY] 控制台):
129
+ import sys; sys.path.insert(0, r"<REPO>")
130
+ import prism.bridge; prism.bridge.start(r"<PROJ>/Saved/Prism")
131
+
132
+ # 3. 在 MCP 客户端注册(一条命令幂等写入、零工程路径;v1.0 bridge 自注册后 server 自动发现):
133
+ prism setup --client codex # 或 opencode / cursor / all;--dry-run 预览、--uninstall 移除
134
+ # 等价手写:command: python -m prism.server (或装包后 ue-prism)
135
+ # 多工程时 agent 先 list_projects 探测、或调用时传 project=<工程名>
136
+ # (仍可钉死单工程:加 env PRISM_BUS_DIR=<PROJ>/Saved/Prism PRISM_PROJECT_DIR=<PROJ>)
137
+ ```
138
+
139
+ 先问 agent 一句验证连通:*"你现在连上的是哪个 UE 项目?里面加载了哪些地图?"*
140
+ 接入日志工具后,可试:*"把最近编辑器日志里的 Error/Warning 归类,指出可疑原因。"*
141
+ cook 回路(关编辑器、双钥)可试:*"先 dry-run 看看 cook 会跑什么命令"* → *"确认执行"* → *"把这次 cook 的报错归因到资产"*
142
+
143
+ ## 路线图
144
+
145
+ - **v0.1**(已发布 · v0.1.0):三层骨架 + 文件总线 + `ping` 端到端连通 + 结构化错误契约 + UE 5.4 实测 + 6 只读工具 + bridge 编辑器开机自启
146
+ - **v0.2**(已发布 · v0.2.0,v0.2.1 已清尾):cook/package 全回路真机实证完毕 —— cook 容错三坑、package 25s/786MB 一次通过、5.8 版式(`PRISM_ENGINE_ROOT`)同工程通过、失败注入还原闭环
147
+ - **v0.3**(已发布 · v0.3.0):性能静态规则引擎(7 规则全来自真机案例;纹理/网格度量 5.4 与 5.8 校准一致;桥离线自动半份报告)
148
+ - **v1.0**(已交付 · 版本 1.0.0,待打 tag 发 PyPI):连接发现(零配置/自注册/`project=` 选择器)+ 引用链分析(`get_asset_chain` 传递闭包)+ 迁移套件(rename/move/copy 写操作,双钥 + redirector 自动清理);UE 5.4.4 真机实证完毕
149
+ - 之后:bridge 打包成正式 UE 插件、socket 传输、Epic 官方 5.8 toolset 可选后端、截图验证工具
150
+
151
+ ## 运行要求
152
+
153
+ - Unreal Engine 5.0+,启用 Python Editor Scripting(launcher 安装自带)
154
+ - 编辑器侧:引擎内嵌 Python 随 UE 版本 3.7→3.11(domain 层守 3.7 语法子集)
155
+ - server 侧:Python ≥3.10 + MCP Python SDK(锁 2.x),无引擎依赖
156
+
157
+ ## 本机真机测试(UE 5.4)
158
+
159
+ - 工程目录:`F:/UE_Projects/TestProject/UE_PRISM_Project`(已在 DefaultEngine.ini 注册 bridge 自启;5.4.4 与 5.8.2 双版本真机验证过)
160
+ - 文件总线目录:`<工程>/Saved/Prism`(含 `heartbeat.json` 活性标记与 `tasks/` 任务档案)
161
+ - ue-prism 仓库:`F:/VibeCoding/ue-prism`;server 侧解释器:`F:/venv/Scripts/python.exe`
162
+
163
+ **1) 双击打开工程(EngineAssociation=5.4)**——bridge 已由 StartupScripts 自启,Output Log 出现 `[prism] bridge started` 即在线;`<工程>/Saved/Prism/heartbeat.json` 应每秒级更新。
164
+
165
+ **2) 另开系统终端,从总线外部验证连通与快速失败:**
166
+ ```powershell
167
+ F:\venv\Scripts\python.exe F:\VibeCoding\ue-prism\scripts\ping_cli.py "F:/UE_Projects/TestProject/UE_PRISM_Project/Saved/Prism"
168
+ ```
169
+ 期望:`ok:true`;关编辑器再戳 → 约 1s 内 `BRIDGE_UNREACHABLE`(心跳陈旧),不再空等 30s。
170
+
171
+ **3) cook 回路(关闭编辑器后;细则见 docs/SETUP.md §4):**
172
+ ```powershell
173
+ F:\venv\Scripts\python.exe -m prism.server --project-dir F:\UE_Projects\TestProject\UE_PRISM_Project
174
+ # 或在 agent 里:cook_package() 干跑 -> cook_package(dry_run=False, confirm=True) -> get_cook_status(task_id)
175
+ ```
176
+
177
+ **4) 可选——无头探针(unreal API 名跨版本校核):**
178
+ ```powershell
179
+ & "E:\UnrealEngine\UE_5.4\Engine\Binaries\Win64\UnrealEditor-Cmd.exe" `
180
+ "F:\UE_Projects\TestProject\UE_PRISM_Project\UE_PRISM_Project.uproject" `
181
+ -run=pythonscript -script="F:\VibeCoding\ue-prism\scripts\probe_unreal.py"
182
+ ```
183
+ ## 相关项目
184
+
185
+ - [chongdashu/unreal-mcp](https://github.com/chongdashu/unreal-mcp)、[IvanMurzak/Unreal-MCP](https://github.com/IvanMurzak/Unreal-MCP) —— 侧重编辑器**操控**;ue-prism 侧重**诊断与分析**
186
+ - Epic 官方 `ModelContextProtocol` 插件(UE 5.8)—— 原生但实验性;ue-prism 覆盖 5.0–5.8 行为一致
187
+
188
+ ## 设计文档
189
+
190
+ 需求、契约与决策记录见 [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md);设计草案:[v0.2 cook 全回路](docs/DESIGN_v0.2.md)(已实现并真机实证)· [v0.3 性能静态规则引擎](docs/DESIGN_v0.3.md)(评审中)。
191
+
192
+ ## License
193
+
194
+ MIT © [Za0Shu1](https://github.com/Za0Shu1)
195
+
196
+ ---
197
+
198
+ Unreal® 与 Unreal Engine® 为 Epic Games, Inc. 注册商标。本项目与 Epic Games 无关联,亦未获其背书。
@@ -0,0 +1,3 @@
1
+ """ue-prism: 面向 Unreal Engine 项目的 MCP 诊断服务器(骨架 / MVP)。"""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,7 @@
1
+ """Allow `python -m prism` to run the CLI (serve/setup)."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -0,0 +1,191 @@
1
+ """v0.2 PR-3 日志→资产归因(docs/DESIGN_v0.2.md §5):纯标准库离线解析;富化单次经总线。
2
+
3
+ 流水线:
4
+ 1) 错误行识别(LogXxx: Error / Fatal / UAT "ERROR:" 三形态)
5
+ 2) 指纹分组:路径与数字归一化,同类错误聚成 pattern
6
+ 3) 行内提取 /Game 包路径(含 Content\\...uasset 相对形态)
7
+ 4) 磁盘解析(存在性/大小;离线可用)
8
+ 5) bridge 在线(心跳新鲜,PR-0.5 基建)时批量富化:describe_many + referencers_many
9
+ 各一次总线往返(绝不逐资产 N 次 —— 设计稿洞②)
10
+ 列表纪律:pattern ≤ top(默认 30),组内资产 ≤ 10;total/truncated/cap 全程携带。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+
17
+ from . import bus, envelope
18
+
19
+ GAME_RE = re.compile(r"/Game/[A-Za-z0-9_]+(?:/[A-Za-z0-9_]+)*(?:\.[A-Za-z0-9_]+)?")
20
+ CONTENT_REL_RE = re.compile(r"Content[/\\]+([^\s\"']+?\.(?:uasset|umap))")
21
+ _TS_RE = re.compile(r"^\[[^\]]*\](\[\s*\d*\])?")
22
+ _LOG_ERR_RE = re.compile(r"^([A-Za-z0-9_]+):\s*(Error|Fatal error|Fatal|ERROR)\s*:?\s*(.*)$")
23
+ _UAT_ERR_RE = re.compile(r"^ERROR:\s*(.*)$")
24
+ _FATAL_ERR_RE = re.compile(r"^Fatal\s*[Ee]rror\s*[:!]\s*(.*)$")
25
+ _NUM_RE = re.compile(r"\d+")
26
+
27
+ ENRICH_CAP = 60 # 单次总线往返最多带走的资产数
28
+ PER_GROUP_CAP = 10 # 每 pattern 展示资产上限
29
+
30
+
31
+ def _line_category_message(raw):
32
+ """返回 (category, message) 或 None。时间戳剥离后按三形态匹配。"""
33
+ line = _TS_RE.sub("", raw.strip())
34
+ m = _UAT_ERR_RE.match(line)
35
+ if m:
36
+ return ("UAT", m.group(1))
37
+ m = _LOG_ERR_RE.match(line)
38
+ if m:
39
+ return (m.group(1), m.group(3))
40
+ m = _FATAL_ERR_RE.match(line)
41
+ if m:
42
+ return ("FatalError", m.group(1))
43
+ return None
44
+
45
+
46
+ def _fingerprint(category, message):
47
+ s = GAME_RE.sub("<ASSET>", message)
48
+ s = CONTENT_REL_RE.sub("<ASSET>", s)
49
+ s = _NUM_RE.sub("#", s)
50
+ return (category + ": " + s.strip())[:200]
51
+
52
+
53
+ def _asset_candidates(line):
54
+ out = []
55
+ for m in GAME_RE.finditer(line):
56
+ pkg = m.group(0).split(".", 1)[0] if "." in m.group(0).split("/")[-1] else m.group(0)
57
+ if pkg not in out:
58
+ out.append(pkg)
59
+ for m in CONTENT_REL_RE.finditer(line):
60
+ rel = m.group(1).replace("\\", "/")
61
+ rel = rel[: rel.rfind(".")] # 去扩展名
62
+ pkg = "/Game/" + rel if not rel.startswith("Game/") else rel
63
+ if pkg not in out:
64
+ out.append(pkg)
65
+ return out
66
+
67
+
68
+ def _disk_info(content_root, pkg):
69
+ rel = pkg[len("/Game/"):].replace("/", os.sep) if pkg.startswith("/Game/") else None
70
+ if rel is None:
71
+ return {"path": pkg, "exists": False, "size_bytes": None}
72
+ for ext in (".uasset", ".umap"):
73
+ p = os.path.join(content_root, rel + ext)
74
+ if os.path.isfile(p):
75
+ try:
76
+ return {"path": pkg, "exists": True, "size_bytes": os.path.getsize(p)}
77
+ except OSError:
78
+ pass
79
+ return {"path": pkg, "exists": False, "size_bytes": None}
80
+
81
+
82
+ def parse_log(log_path, project_dir=None, top=30):
83
+ """解析 cook/编辑器日志 -> 归因报告骨架(未富化)。编码容错 utf-8-sig + replace。"""
84
+ top = max(1, int(top))
85
+ content_root = os.path.join(project_dir, "Content") if project_dir else None
86
+ groups = {}
87
+ order = []
88
+ scanned = 0
89
+ error_lines = 0
90
+ with open(log_path, "r", encoding="utf-8-sig", errors="replace") as f:
91
+ for i, raw in enumerate(f):
92
+ scanned += 1
93
+ cm = _line_category_message(raw)
94
+ if cm is None:
95
+ continue
96
+ category, message = cm
97
+ error_lines += 1
98
+ fp = _fingerprint(category, message)
99
+ g = groups.get(fp)
100
+ if g is None:
101
+ g = groups[fp] = {"pattern": fp, "count": 0, "first_line_no": i + 1,
102
+ "sample_line": raw.strip()[:500], "assets": {}}
103
+ order.append(fp)
104
+ g["count"] += 1
105
+ for pkg in _asset_candidates(raw):
106
+ if pkg not in g["assets"]:
107
+ info = _disk_info(content_root, pkg) if content_root else {"path": pkg}
108
+ g["assets"][pkg] = info
109
+ patterns = []
110
+ for fp in sorted(order, key=lambda k: -groups[k]["count"]):
111
+ g = groups[fp]
112
+ assets = sorted(g["assets"].values(), key=lambda a: (-int(a.get("size_bytes") or 0), a["path"]))
113
+ patterns.append({
114
+ "pattern": g["pattern"],
115
+ "count": g["count"],
116
+ "first_line_no": g["first_line_no"],
117
+ "sample_line": g["sample_line"],
118
+ "assets": assets[:PER_GROUP_CAP],
119
+ "assets_total": len(assets),
120
+ "assets_truncated": len(assets) > PER_GROUP_CAP,
121
+ })
122
+ total = len(patterns)
123
+ return {
124
+ "log_path": os.path.abspath(log_path),
125
+ "scanned_lines": scanned,
126
+ "error_lines": error_lines,
127
+ "patterns": patterns[:top],
128
+ "total": total,
129
+ "truncated": total > top,
130
+ "cap": top,
131
+ "enriched": False,
132
+ "enrich_note": "offline",
133
+ }
134
+
135
+
136
+ def all_assets(report):
137
+ out = []
138
+ for g in report["patterns"]:
139
+ for a in g["assets"]:
140
+ if a["path"] not in out:
141
+ out.append(a["path"])
142
+ return out
143
+
144
+
145
+ def enrich_via_bus(bus_dir, paths):
146
+ """心跳新鲜才发起(不付 30s 探测税);返回 (map|None, note)。"""
147
+ if not paths:
148
+ return None, "no_assets"
149
+ age = bus.heartbeat_age(bus_dir)
150
+ if age is None or age > bus.HB_STALE_SECONDS:
151
+ return None, "bridge offline or heartbeat missing/stale"
152
+ client = bus.BusClient(bus_dir, timeout=10)
153
+ want = list(paths[:ENRICH_CAP])
154
+ d = client.call("describe_many", {"paths": want})
155
+ r = client.call("referencers_many", {"paths": want, "cap": 10})
156
+ if not d.get("ok"):
157
+ return None, "describe_many failed: %s" % (d.get("error") or {}).get("code", "?")
158
+ emap = {}
159
+ for item in d["result"]["items"]:
160
+ if item.get("ok") is False: # 领域函数自返信封(如 UE_API_MISMATCH)
161
+ continue
162
+ key = item.get("package_name") or item.get("query")
163
+ if key:
164
+ emap.setdefault(key, {}).update(
165
+ {"class": item.get("class"), "found": item.get("found"),
166
+ "size_bytes": item.get("size_bytes")})
167
+ if r.get("ok"):
168
+ for item in r["result"]["items"]:
169
+ key = item.get("package_name") or item.get("query")
170
+ if key:
171
+ emap.setdefault(key, {})["used_by_count"] = item.get("used_by_count")
172
+ note = "enriched %d/%d assets" % (len(emap), len(want))
173
+ if len(paths) > ENRICH_CAP:
174
+ note += " (capped at %d)" % ENRICH_CAP
175
+ return emap, note
176
+
177
+
178
+ def apply_enrichment(report, emap, note):
179
+ if not emap:
180
+ report["enrich_note"] = note
181
+ return
182
+ for g in report["patterns"]:
183
+ for a in g["assets"]:
184
+ e = emap.get(a["path"]) or {}
185
+ for k in ("class", "used_by_count", "found"):
186
+ if k in e and e[k] is not None:
187
+ a[k] = e[k]
188
+ g["assets"].sort(key=lambda a: (-(int(a.get("used_by_count") or 0)),
189
+ -int(a.get("size_bytes") or 0), a["path"]))
190
+ report["enriched"] = True
191
+ report["enrich_note"] = note