Compare commits

..
116 Commits
Author SHA1 Message Date
moyu 9fb16e45c5 Merge branch 'master' of https://git.yvmou.cn/yvmoux/agame 2026-10-27 15:03:31 +08:00
Ryan-WZ d2b0a47850 新增载具jeep模型 ,测试decals应用(成功) 2026-10-27 15:02:15 +08:00
moyu e2491bc4e1 godot ai 更新 2026-10-23 16:56:35 +08:00
moyu 5c0adb3a96 2026-10-16 2026-10-16 16:01:18 +08:00
moyu b8bbe21c62 场景画面效果优化 2026-10-15 21:51:30 +08:00
moyu 1822a2211e 0 2026-10-15 17:02:45 +08:00
moyu a76c4aebef limbo hsm 2026-10-15 16:56:45 +08:00
moyu 18e7b2daa8 docs\limboai-stable 2026-10-15 16:30:44 +08:00
moyu ecebf76f47 toon_refresh 2026-10-14 16:36:34 +08:00
Ryan-WZ 877d3bbad0 Shader Test 2026-10-13 23:37:09 +08:00
moyu f2e06e9dee 移除碰撞 2026-10-13 19:42:47 +08:00
moyu a1d7007241 shader 2026-10-13 19:39:58 +08:00
moyu ed1a9949c8 shader 2026-10-13 18:42:20 +08:00
moyu f296b643d4 Update cel-shader-base.gdshader 2026-10-13 15:27:58 +08:00
moyu c81571ec1e SHADER 2026-10-13 15:27:01 +08:00
moyu 850d657404 Delete Light_Floor3.fbx.import 2026-10-13 15:08:59 +08:00
moyu 322c1c22eb Merge branch 'master' of https://git.yvmou.cn/yvmoux/agame 2026-10-13 15:08:43 +08:00
Ryan-WZ 020c2a50ce 【场景】 修改地板 测试灯光 以及修改sconce 的alpha通道 2026-10-13 15:07:36 +08:00
moyu 9d60807642 一些脚本 2026-10-12 17:48:27 +08:00
moyu fc89790363 移除FBX文件夹 2026-10-12 17:40:08 +08:00
moyu 02c8d36f8d 可拖拽判断(PickupItem 标记) 2026-10-12 17:05:19 +08:00
moyu 77fefd2505 Update project.godot 2026-10-12 16:13:03 +08:00
moyu 4ef1a30767 Update player.tscn 2026-10-12 14:46:49 +08:00
moyu 7934537793 Merge branch 'master' of https://git.yvmou.cn/yvmoux/agame 2026-10-12 14:43:55 +08:00
Ryan-WZ f381769a7a 【场景修复地毯材质 2026-10-11 01:51:46 +08:00
Ryan-WZ ab24c39d0e 【场景】 修改: 宴会厅 、新增:墙灯 波斯地毯 2026-10-10 18:36:59 +08:00
moyu 93783b1bd9 debug和修复 2026-10-10 16:07:53 +08:00
Ryan-WZ a0dfe7b483 Merge branch 'master' of https://git.yvmou.cn/yvmoux/agame 2026-10-09 16:42:46 +08:00
Ryan-WZ 87b8b110a5 场景 新增窗帘 light switch 宴会桌及摆件 2026-10-09 16:42:20 +08:00
moyu 3dd9d2ec71 修复摄像机穿模 2026-10-09 16:36:17 +08:00
moyu e06818bc5a 调整角色移速 2026-10-09 16:00:23 +08:00
moyu 7de2090b6f 指针资产 2026-10-09 15:54:39 +08:00
moyu 96e21757c3 人物控制器1 2026-10-09 15:51:24 +08:00
moyu d20d45fffc 修复拖拽时物体一卡一卡 2026-10-09 15:32:34 +08:00
moyu bc88fef43a 手掌指针 2026-10-09 15:28:00 +08:00
moyu bbb0c5db14 按 Q 投掷 2026-10-09 15:17:05 +08:00
moyu 3920b88041 修改为长按拖拽 2026-10-09 15:14:23 +08:00
moyu 66d87d48d6 组件模型 2026-10-09 15:10:44 +08:00
moyu 7fd1d97099 准心 2026-10-09 15:03:52 +08:00
moyu 4950ee3b7c Merge branch 'master' of https://git.yvmou.cn/yvmoux/agame 2026-10-08 18:41:49 +08:00
Ryan-WZ 38adb3ae78 新增 Banquet table (场景1楼) 2026-10-08 18:41:26 +08:00
moyu e07ba7fc46 使用sky3d 2026-10-08 17:42:03 +08:00
moyu 27b9788a04 sky3d 2026-10-08 17:38:08 +08:00
moyu 4765dfaa02 Update main.tscn 2026-10-08 17:22:06 +08:00
moyu 475a026604 重构拖动物品功能 2026-10-08 17:22:02 +08:00
moyu 8e463d9247 移除绳索 2026-10-08 17:21:44 +08:00
moyu 1bd447a56e 移除可抱起物品 2026-10-08 16:40:43 +08:00
moyu 9b1d30339f 移除测试 2026-10-08 16:40:32 +08:00
Ryan-WZ 39ddf9ea0b Add new interior asset pack 2026-10-08 15:22:15 +08:00
moyu a5c08088b4 整理代码 2026-10-06 14:15:36 +08:00
Ryan-WZ ed05fb9fd8 场景Step2(厨房,宾客,社交休息,书房,员工休息室) 2026-10-06 13:48:19 +08:00
Ryan-WZ 165d546662 场景1(接待室) 2026-10-05 15:07:56 +08:00
moyu 88d4f12b56 资产管理插件 2026-10-05 14:57:41 +08:00
moyu b826bfb562 自研插件:自动编组 2026-10-04 19:46:30 +08:00
moyu 8ebd331b20 Update main2.tscn 2026-10-04 19:29:30 +08:00
moyu a17800db9c 更新自研插件 2026-10-04 19:27:54 +08:00
moyu 604612baac 更新自研插件 2026-10-04 18:32:58 +08:00
moyu b99952d3c9 更新自研插件 2026-10-04 17:19:42 +08:00
moyu dfd8636476 更新自研插件 2026-10-04 17:09:39 +08:00
moyu 59bd07f630 d 2026-10-04 16:40:49 +08:00
moyu a8ad5c8a71 csg_blockout删除 2026-10-04 16:39:37 +08:00
moyu 03b16e226e 删除surface snap 2026-10-04 16:38:56 +08:00
moyu 8a5ba71ecf 删除 Asset Drawer 2026-10-04 16:37:53 +08:00
Ryan-WZ 93f03ed728 Update main2.tscn 2026-10-04 16:36:16 +08:00
Ryan-WZ 3ebce418da 1111 2026-10-04 16:09:35 +08:00
moyu 1648d6d9b1 插件 2026-10-04 15:01:12 +08:00
moyu 3d23804fa5 拾起物品同步 2026-10-03 18:48:04 +08:00
moyu 0dcfb88bfe Update main2.tscn 2026-10-03 18:47:39 +08:00
moyu deb6dc3e09 Update main2.tscn 2026-10-03 18:39:51 +08:00
moyu b7c644af0b Merge branch 'master' of https://git.yvmou.cn/yvmoux/agame 2026-10-03 18:32:55 +08:00
Ryan-WZ 0dc8838493 Update main2.tscn 2026-10-03 18:29:40 +08:00
moyu 6f3c89a4d2 抱起物品 2026-10-03 18:04:07 +08:00
moyu bc90e6d7b2 修复玩家脸部 2026-10-03 17:26:42 +08:00
moyu 974ca67c41 dll 2026-10-03 10:15:56 +08:00
moyu dfbb9615c6 FreeLowPolyHouseInterior 2026-10-03 07:05:54 +08:00
moyu ff77f285f2 Food Pack - Low Poly Assets 2026-10-03 06:54:16 +08:00
moyu 529c7057eb Low_Poly_Food_Asset_Pack-2 2026-10-03 06:43:10 +08:00
moyu d5a0266ea5 仿ue插件 2026-10-03 06:39:17 +08:00
moyu a0ba17d211 Food Pack - Low Poly Assets 2026-10-03 06:21:27 +08:00
moyu df1df4a4c6 Cute_Furniture_Free_glb2 2026-10-03 06:17:29 +08:00
moyu de714679da Cute_Furniture_Free_glb 2026-10-03 06:17:20 +08:00
moyu acc351e7f7 Interiors_FREE_2_-_Cozy_Cartoon_Pack-a8df083e 2026-10-03 06:12:06 +08:00
moyu 933805dd72 Furniture_FREE-af040129 2026-10-03 06:11:27 +08:00
moyu 7bcbcc69ea 1 2026-10-03 05:40:26 +08:00
moyu faf799837f 1 2026-10-02 15:42:34 +08:00
moyu 698cb02c21 开始菜单、玩家选择、脸部绘制 2026-10-02 12:06:28 +08:00
moyu fbf00cf11f 开始菜单 2026-10-02 10:44:58 +08:00
moyu 39790aecea 改为第一人称控制器 2026-10-02 10:34:57 +08:00
moyu d367c8370b 角色动画 2026-10-02 09:32:11 +08:00
moyu 5173b45066 角色和动画 2026-10-02 09:04:37 +08:00
moyu 27a550f61b godot表面吸附插件 2026-10-02 03:40:31 +08:00
moyu 9fa6ed628b 角色 2026-10-02 03:37:08 +08:00
moyu 3a8f35b19e demo\ai 2026-10-02 03:36:50 +08:00
moyu 0df8b26238 Create ~liblimboai.windows.editor.x86_64.dll 2026-10-02 03:36:35 +08:00
moyu 44b7bf1011 Update project.godot 2026-10-01 07:37:34 +08:00
moyu bd8c1c9a21 ai 插件demo 2026-10-01 07:37:29 +08:00
moyu e2a833536f 安装 ai插件 2026-10-01 07:36:55 +08:00
moyu cd73302181 导入模型资产 2026-10-01 06:07:14 +08:00
moyu eef5a9ce07 更新godot ai 到最新版本 2026-10-01 05:50:19 +08:00
moyu 260a118b5d csg插件 2026-10-01 05:36:12 +08:00
moyu a1308cf8fd 滑索 2026-09-24 16:35:28 +08:00
moyu 2f662c6c6f 更新godot ai插件到最新版本 2026-09-24 15:33:39 +08:00
moyu 0484839186 实例化海洋到主场景 2026-09-23 17:37:07 +08:00
moyu 597b5a69c5 海洋 2026-09-23 17:11:13 +08:00
moyu 40691e81b6 可复用玩家状态机 2026-09-23 16:39:12 +08:00
moyu 6355ac41a5 角色动画 2026-09-23 16:27:02 +08:00
moyu 414fcafe1d 血量显示在玩家头顶 2026-09-22 17:51:52 +08:00
moyu b11da48e23 分离UI 2026-09-22 17:45:29 +08:00
moyu 2ecab991f8 扣血同步 2026-09-22 17:32:42 +08:00
moyu 93222895a5 Create 2026-09-22.md 2026-09-22 16:59:16 +08:00
moyu e71276050c Update 联机实战教程-由浅入深.md 2026-09-22 16:59:13 +08:00
moyu cac2f7d87b 文档 2026-09-22 16:56:52 +08:00
moyu db60e35766 简易联机实现 2026-09-22 16:56:35 +08:00
moyu 020ab88606 更新godot ai 到最新版本 2026-09-22 16:07:50 +08:00
moyu 9dc02dbb31 godotsteam 2026-09-22 16:06:43 +08:00
moyu acbc2fb59c 移除workbuddy目标 2026-09-22 16:06:32 +08:00
2232 changed files with 205510 additions and 892 deletions
+2
View File
@@ -1,3 +1,5 @@
# Godot 4+ specific ignores # Godot 4+ specific ignores
.godot/ .godot/
/android/ /android/
addons/limboai/bin/~liblimboai.windows.editor.x86_64.dll
addons/godotsteam/win64/~libgodotsteam.windows.template_debug.x86_64.dll
-70
View File
@@ -1,70 +0,0 @@
# 2026-09-21
## 程序化卡通天空(world/sky/)
新增 `res://world/sky/cartoon_sky.gdshader` + `cartoon_sky.tres`(Sky 资源,内含调好的 ShaderMaterial),
并让 `world/main.tscn` 的 WorldEnvironment 改用它;同时把 `Sun` 的 light_color 调成暖白
(1.0, 0.97, 0.90),项目设置开了 `rendering/anti_aliasing/quality/use_debanding`。
渲染顺序:渐变天空 → 地面半球 → 星空 → 太阳 → 云 → 地平线雾霭。
全部由噪声现场生成,无贴图。参数按 Gradient / Sun / Stars / Clouds / Haze 分组暴露在 Inspector。
### Godot shader 踩坑(重要,下次直接避开)
1. **`mat2(a,b,c,d)` 不合法**。Godot 着色器语言只接受列向量构造:`mat2(vec2, vec2)`。
同理 mat3 要用三个 vec3。报错形式是
`Invalid arguments for the built-in function: "mat2(float,float,float,float)"`。
2. **天空着色器的编译错误不在 `logs_read(source="editor")` 里**,除非触发一次编译。
可靠的取错方式:改完 → `filesystem_manage(op="scan")` → `project_run` →
`logs_read(source="editor", include_details=true)`。game 日志只给一行
`version_get_shader: Parameter "version" is null`,没用。
3. **`smoothstep(edge0, edge1, x)` 不要写 edge0 > edge1**,改成 `1.0 - smoothstep(...)`。
4. 变量重名(同一函数内二次声明)会报 `Redefinition of 'x'`,直接让整个 shader 编译失败。
5. shader 编译失败时天空渲染成**纯黑**,不是品红 —— 别以为是资源没挂上。
6. **编辑器里的 Shader 面板会缓存旧的编译报错**。外部改完 .gdshader + scan 之后,
面板可能仍显示上一次失败的 `mat2(float,float,float,float)` 之类信息,
而 Debugger → Errors 已经是空的。判定方法:看 `logs_read(source="editor")` 是否为空,
再 `project_run` 一次看天空是否正常。**不要照着这条过期报错去改代码。**
注意:如果 Shader 标签页里留着一份旧的未保存副本,从面板里保存会把我改好的文件覆盖回去。
### 云的形态(花了最多时间)
- **别用「水平云平面」投影 `uv = dir.xz / dir.y`**:低仰角处透视压缩成 10:1,
云被拉成细条;若再对 dir.y 做硬 clamp,被钉死的距离会让噪声只随方位角变化,
直接拉出**竖条拖影**。
- **也别用方位角等距投影**(`uv = normalize(dir.xz) * acos(dir.y)`):天顶是极点,
云会全部朝圆心收拢,抬头就看得见。
- **正解是球极投影 `uv = dir.xz / (1.0 + dir.y)`**:保角 → 形状不拉伸;
天顶只是圆盘圆心而非收缩极点;方位角天然无缝;角尺寸从地平线到天顶只放大 2 倍,
接近真实透视。这个投影配 2D Perlin 出图最好。
- **噪声用 Perlin 梯度噪声,不要 value noise**:value noise 在格点处梯度为零,
阈值化后会切出一块块多边形的棱角("晶体"感)。
- **三维 Perlin 在这台机器上不行**:sin 系哈希(`fract(sin(dot(p,k))*43758)`)在大坐标
(fbm 第 3~4 个倍频时 dot 可达 1e4)下掉精度,高频倍频退化成纯噪声 → 云变成细丝。
Hoskins 的 fract 系哈希精度安全,但出图仍偏块状。最终放弃 3D,回到 2D Perlin。
- **阈值要压在 fbm 分布均值之上**。阈值贴着均值时等值线本身就很纠结,
云会变成一张"蛛网";抬到 +0.5σ 以上才是干净的团块。
### 当前调好的参数
coverage 0.33 / scale 5.0 / edge 0.16 / warp 0.30 / horizon_fade 0.18 / speed 0.01,
云影色 (0.45,0.62,0.88),边缘辉光 (1,0.93,0.76)。
## Sun 阴影(当天稍后)
`/Main/Sun` 开了 `shadow_enabled = true`;同时把 `directional_shadow_max_distance`
从默认 100 收到 40(场景只有 40×40,收窄后阴影贴图分辨率集中到实际范围,边缘干净很多),
并开 `directional_shadow_blend_splits` 消除 split 之间的接缝。
### 又一个 Godot MCP 坑
`node_set_property` 写 **bool** 属性会失败:`true` 和 `1` 都被序列化成 String,
报 `WRONG_TYPE: Cannot write String to a bool property`。
**bool 属性只能直接改 .tscn,再 `scene_open(force_reload=true)`。**
(Color / Vector3 用 dict 传是正常的,`light_color` 就是这么改的。)
## 待办 / 可选后续
- 地面还是灰白色 (0.55,0.58,0.62),被天空的蓝色环境光染成了淡紫。
换成草绿之类的固有色会协调很多 —— 这是目前画面里最突兀的一处。
- `star_amount` 默认 0,太阳落到地平线以下会自动淡入星空,还没实测过夜景。
-49
View File
@@ -1,49 +0,0 @@
# 2026-09-22
## 修正云层的透视(world/sky/cartoon_sky.gdshader)
用户指出云的透视不对:球极投影虽然保角、无极点、无缝,但**代价是完全没有空气透视** ——
云从地平线到天顶一样大,看着像一块贴在天上的平顶。这一条是保角投影的固有性质,
不是参数能救的,必须换投影。
### 三种投影的取舍(结论:用软化分母的平面投影)
| 投影 | 半径 | 天顶 | 透视 | 结论 |
|---|---|---|---|---|
| 平面 `dir.xz/dir.y` | 无上限 | 奇点 | 正确(4~6 倍) | 低仰角频率爆炸 → 摩尔纹/地毯 |
| 球极 `dir.xz/(1+dir.y)` | ≤1 | 圆心(安全) | **无** | 平顶感 |
| 软化平面 `dir.xz/(dir.y+k)` | ≤1/k | 安全 | 2~3 倍 | **采用** |
`k`(uniform `cloud_plane_offset`,默认 0.6)是唯一的调节量:
- 半径上限 `1/k` → 噪声频率有界,不会在地平线处爆炸
- 近天顶处 `p ≈ (π/2−e)/(1+k)`,是局部均匀缩放,**不是奇点**,云不会朝一点收拢
- 角尺寸比 地平线:天顶 = `(1+k)/k²`,k=0.6 时约 4.4;云尺寸从 45° 的 17°
平滑降到 10° 的 7.8°
- 方位角方向天然无缝
- 副作用:云的宽高比恒为 1.4~1.7(略扁),对卡通云反而是好事
### 配套的两个抑制
1. **距离细节衰减**:`fbm(p, detail)` 里 `amp *= 0.5 * detail`,`detail = mix(1.0, 0.35, far)`。
远处云的屏幕频率更高,不砍高频倍频会闪烁。
2. **距离云量抑制**:`thresh += far * 0.10`。低仰角处每单位立体角的云数量更多,
不抑制就糊成一片「地毯」。`far = 1.0 - smoothstep(0.06, 0.45, dir.y)`。
最终取值:coverage 0.55 / scale 4.0 / plane_offset 0.60 / horizon_fade 0.08 / edge 0.16。
## 重要:.tres 里的 shader_parameter 会被 Godot 清掉
`cartoon_sky.tres` 被 Godot 重存过一次,`shader_parameter/*` 全部丢失(同时补上了 uid),
天空悄悄退回着色器默认值 —— 而且不会报任何错,只是画面跑偏。
**对策:把调好的美术取值直接写成 uniform 的默认值**,`.tres` 只保留对 shader 的引用。
这样即使参数被清掉,画面也不会变。Inspector 里未覆盖的参数本来就会显示着色器默认值,
所以用起来没区别。
顺带:`.tres` 现在被 Godot 补上了 `uid://bi632suyifms5`,`main.tscn` 里仍按 path 引用,能用。
## 待办 / 可选后续
- 地面还是灰白色 (0.55,0.58,0.62),被天空的蓝色环境光染成了淡紫;另外天空下半球的
`ground_color` (0.29,0.33,0.38) 在地面边缘外露出一条灰带。两处一起调会协调很多。
- `star_amount` 默认 0,太阳落到地平线以下会自动淡入星空,还没实测过夜景。
+26
View File
@@ -0,0 +1,26 @@
# 2026-09-24
## 滑索(Zipline)组件 —— 新增
需求:Godot 4 第三人称滑索,要求全程用 Godot MCP 搭建。
新增 / 改动:
- `world/zipline/zipline.gd` + `zipline.tscn`:两个 Marker3D 锚点 + 抛物线下垂曲线。
缆绳网格在 `_ready()` 用 SurfaceTool 现造,子节点不给 owner → 不会被写进 .tscn。
对外接口:`sample / tangent / ride_point / closest_point`;手感参数(速度、刹车、吸附半径)都放在这一侧。
- `character/player/player.gd`:新增 `_find_zipline / _attach_zipline / _detach_zipline / _ride_zipline`。
缆上直接写 `global_position`,不走 `move_and_slide()`;相机在缆上缓慢对齐到行进方向。
- `character/player/player.tscn`:新增 `InteractHint`(Label3D),沿用 HealthLabel 的「节点配置」做法。
- `project.godot`:新增 `interact` 输入动作(E)。
- `world/main.tscn`:演示关卡 —— LaunchRamp(26.57° 斜引道)+ LaunchDeck(顶面 y=6.0)+ Zipline 实例。
- `tests/test_zipline.gd`:13 项回归测试,钉住几何契约(端点、下垂量、切线方向、下滑方向、悬挂偏移)。
验证:全量测试 20/20 通过(180 条断言);实跑三次,按 E 后玩家从 `(0,0,0)` 滑到 `(0,0,18.8)` 缆末端。
关键取舍:
- `sag` 定义成「中点下垂深度」而不是比例 —— 调参所见即所得,不用解悬链线方程。
- 出口固定为**较低**的那个锚点(滑索只能往下滑),两端等高时退回 A→B。
- 模型里没有滑索动画片段,所以缆上复用下落姿态:`animator.set_motion(velocity, false, false)`,
没有为此在 `PlayerAnimator` 里加新状态。
+322
View File
@@ -0,0 +1,322 @@
# 2026-10-02
## 审查:Godot 4.7「SubViewport 面部手绘 + 骨骼驱动」方案
用户贴来一份第三方方案(SubViewport 当可绘制面部贴图 + SkeletonModifier3D 把骨骼 Z 转成 shader 参数),问可行性。只做评审,未改任何代码。
结论:**原理可行**(SubViewport-as-texture + shader 混合是标准做法),但原文代码有 4 处硬伤:
1. shader `render_mode ... skip_vertex_transform` 却没写 vertex() 手动变换 → 模型位置错。删掉该 render_mode。
2. `PaintSurface._input` 在**后台 SubViewport 里收不到鼠标事件**(Viewport 之间不派发输入)→ 必须放进 `SubViewportContainer`,或手动 `push_input`。
3. `SkeletonModifier3D` **没有 `skeleton` 属性**,只有 `get_skeleton()`(已查 4.5 文档确认)。
4. `save_paint` 没 `await RenderingServer.frame_post_draw` → 存到空帧。
标定类问题:`get_bone_pose_position()` 是相对父骨的姿态,Z 轴受父骨朝向影响,GLTF 导入后与 Blender 不一致,z_min/z_max 必须实测标定;`face_index` 只做了亮度乘,没真正切换表情图层。
与本项目相关的判断:角色是 `character-male-e.glb`,需先确认有独立面部材质槽 + 展平 UV;多人下 SubViewport/材质必须每实例一份,远端玩家的画要按 PNG 字节流同步。
## 重写 player_animator.gd(7 状态)
按新枚举 `{IDLE, WALK, CROUCH, RUN, FALL, SNEAK, JUMP}` 重写 `character/player/player_animator.gd`。
改动:加 `class_name PlayerAnimator`(`player.gd` 用它当类型)、新增 `Stance{STAND,CROUCH,SNEAK}`、
`set_motion(velocity, on_floor, sprinting=false, stance=STAND)`、片段基准速度扩到 walk/run/crouch/sneak。
实测结论(用 @tool 副本跑 McpTestSuite,14 条断言全过):
- 短片段名查不到,必须用 `"库名/片段名"`(`walk/walk`);`_ready()` 里扫库解析一次并写入 `_clips`。
- 状态映射、自动绑兄弟 AnimationPlayer、`play("walk/walk")`、loop_mode 补丁、倍率 5/5≈1.0 均正确。
- `class_name` 之后 `self is AnimationPlayer` 是编译期错误 → 脚本不能挂在 AnimationPlayer 节点上。
**未改、留给用户决定的场景问题**(`player.tscn`):
1. 脚本目前挂在 `Body/AnimationPlayer` 上,而 `player.gd`、`player_sync.tres`、`tests/test_net_replication.gd` 都找 `Body/PlayerAnimator` —— 需要新增独立 PlayerAnimator 节点。
2. 该节点的 6 个 float 导出在 .tscn 里被序列化成 `null`(实测运行时是 `Nil`),会覆盖脚本默认值。
3. `tests/test_net_replication.gd` 里的 `REQUIRED_CLIPS` 仍是旧的 `sprint`,需要同步成新状态集。
## 收尾:按用户决定接线 + 清场景脏数据
用户决定「脚本就挂在 `Body/AnimationPlayer` 上」,据此改完并验证(`test_run` 20/20,320 条断言;实机跑 player.tscn 确认 idle→fall 切换正常)。
- `player_animator.gd`:文档改成「脚本挂在 AnimationPlayer 上、`animation_player` 留空自绑自身」。
- `player.gd`:`$Body/PlayerAnimator` → `$Body/AnimationPlayer`,场景结构注释同步;`_ride_zipline` 里 `tan` 改名 `tan_dir`(消 built-in 遮蔽警告)。
- `player_sync.tres`:同步字段路径改 `Body/AnimationPlayer:anim_state` / `:anim_speed`。
- `player.tscn`:删掉被序列化成 `null` 的 6 个 float 导出(`blend_time` 等)—— 实测运行时确实是 `Nil`,会覆盖脚本默认值。
- `tests/test_net_replication.gd`:路径、`REQUIRED_CLIPS` 全部同步;新增 `_find_clip()` 按库解析(短名断言会假红)。
- `tests/test_zipline.gd`:`test_interact_action_exists` 先调 `InputMap.load_from_project_settings()` —— 编辑器进程的 InputMap 不带项目动作(实测 `interact`/`jump` 均为 false,只有 `ui_accept`),这条一直假红。
- `world/zipline/zipline.gd`:`tan` → `tan_dir`。
**Godot MCP 注意**:`node_get_properties` 读不到脚本的 @export 属性(`unknown_fields`),但 `node_set_property` 能写进去;数值标量经 MCP 会变成字符串(`node_set_property` 返回 `"0.15"`),`batch_execute` 里的 `set_property` 直接报 INTERNAL_ERROR。改 `.tscn` / `.tres` 这类被编辑器占用的文件,直接用文本工具写反而更省事(`.tscn` 可写、`.tres` 报 EBUSY,用 `filesystem_manage(op="write_text")`),写完 `scene_open(force_reload=true)` 让编辑器重新读盘。
## 接线:Ctrl 蹲下 / Ctrl+移动 潜行
用户定的规则:`crouch` 绑 Ctrl;**蹲着不动 = CROUCH,蹲着走 = SNEAK**。
- `project.godot`:用 `input_map_manage(op="ensure_binding", {action:"crouch", event_type:"key", keycode:"Ctrl"})`
加的动作,落在 keycode 4194326,和已有的 `sprint`(Shift/4194325) 同一套写法。
- `player_animator.gd`:**删掉 `Stance` 枚举**,`set_motion` 第 4 个参数改成 `crouching: bool`。
「蹲 / 潜行」的区分放回表现层(`moving := 水平速度 >= idle_threshold`),控制器只传「按没按 Ctrl」。
优先级改成 空中 > 蹲 > 移动。
- `player.gd`:调用点传 `Input.is_action_pressed("crouch")`;操作说明补上 Ctrl。
- `tests/test_net_replication.gd`:加 `test_crouch_action_exists`(先 `InputMap.load_from_project_settings()`)。
验证:`test_run` 21/21;另用 @tool 副本跑了 4 组状态映射断言(Ctrl+不动→CROUCH、Ctrl+移动→SNEAK、
Ctrl+Shift→仍是 SNEAK、空中→JUMP/FALL 优先于蹲),片段也确认切到了 `crouch/crouch` / `sneak/sneak`。
**踩坑**:`test_run` 的脚本缓存按路径,删掉重建同名文件会跑旧版本 —— 第一次探针就因此假失败
(旧版 `stance: int` 收到 `true` 被当成 `Stance.CROUCH`)。换文件名才拿到真实结果。
**未做**:蹲下目前不改移动速度、也不改碰撞胶囊高度 —— 潜行时用走速(5 m/s)播 sneak 片段,
`speed_scale` 会被 clamp 到上限 1.6。要不要减速/压低碰撞盒等用户定。
## 重做:第三人称 → 第一人称控制器
`player.gd` + `player.tscn` 改造。`test_run` 21/21,实机确认 `CameraRig.position.y=1.6`、`Body.visible=false`。
- **场景**:删掉 `SpringArm`,`Camera` 从 `CameraRig/SpringArm/Camera` 挪到 `CameraRig/Camera`。
`CameraRig` 变成「眼睛」,架在 `eye_height` 高,只转 yaw;pitch 在 `Camera` 自身上原地抬头低头。
`fov` 82.2 → 75(82 是给第三人称的,第一人称太鱼眼)。
- **`player.gd` 新增两个导出**:`eye_height`(1.6,按 1.8 胶囊定,与模型高矮无关)、
`hide_own_body`(true,只藏本机那一份,远端照常显示)。不藏的话相机就在模型里/贴头顶。
- **删掉 `turn_speed`**:第一人称里身体不再朝移动方向转身,改成始终跟视线一致(`_apply_look()` 里
`body.rotation.y = _yaw`),这个平滑参数没用了。
- **新增 `_apply_look()`**(3 处调用:鼠标、滑索、_ready):鼠标一动就落节点,视角跟手不等物理帧。
- pitch 范围从 -80/45 放到 ±89(第一人称要能抬头低头)。滑索的视角对齐沿用原来的 3.0 慢速率。
**已知遗留**:`HealthLabel`(y=2.05) 和 `InteractHint`(y=1.75) 是玩家自己位置的 Label3D,
第一人称下平视看不到(都在相机所在的竖直平面上,超出 FOV),但抬头会看到自己的血条 ——
要干净的话得改成 CanvasLayer 的 HUD(会连带改 `test_zipline` 里断言 `Player/InteractHint` 的那条)。
**MCP 限制**:`game_manage(op="input_mouse")` 注入的 `InputEventMouseMotion` 不带 `relative` 增量,
所以鼠标转视角没法用 MCP 验证(`input_key`/`input_action` 是正常的,用 `H` 键把血条打到 90 验证过)。
## 方案:面部贴图切换 + 游戏内自绘(只出方案,未写文件)
用户给了 `mouse_表情测试.png`,问怎么在游戏内切面部贴图、并让玩家自己画。
**先探了模型**(临时探针读各 mesh 的 surface/UV/材质,跑完删了):面部早就被拆成了独立
surface —— `body_mesh s1 "face"`,UV 孤岛 u[0.142,0.356] v[0.038,0.238],自带材质和贴图。
所以**不用改 Blender**,直接 `set_surface_override_material(1, ...)`。
给的方案:面部 shader(把孤岛 UV 重映射到 0~1,`ALBEDO = mix(base_tex, paint_tex, paint.a)`)
+ `base_tex` = 预设方形 PNG(切换就换它)+ `paint_tex` = 一个 512² 透明 SubViewport 的**笔画层**。
画布只装笔画不装底图,所以橡皮擦掉笔画露出来的是预设脸,语义正确。
关键设计点(也是给之前那份第三方方案纠错的地方):**绘画 UI 是可见的 `CanvasLayer +
SubViewportContainer`,输入天然可达** —— 不是把 SubViewport 当后台渲染目标(那样 `_input` 收不到事件)。
遗留待用户拍板:现有预设要不要裁成方形(重映射后必须裁,1024² 里裁 x145..365 y32..251);
联机时脸要不要 RPC 同步合成后的 PNG。
## 新建:游戏开始菜单(只有界面,无逻辑)
`ui/main_menu.tscn` + `ui/main_menu.gd`(新目录 `ui/`)。
- 背景用 `GradientTexture2D` 取游戏天空的三段色(zenith 0.09/0.30/0.78 → mid → horizon),
和 `world/sky/cartoon_sky.gdshader` 的调色一致。
- 结构:`MainMenu(Control) → Background(TextureRect) / Center(CenterContainer) → Box → Title + Buttons`,
右下角一个 `Version` Label。
- 四个按钮 开始游戏 / 联机 / 设置 / 退出,**只有 `pressed` → 自定义信号**,没有任何开始/退出/联机实现。
- 按钮样式用 3 个 `StyleBoxFlat`(normal/hover/pressed)**挂在 `Buttons` 这个 VBoxContainer 上**,
靠主题继承一次性作用到四个按钮,不用逐个写。
- 实测(跑起来截图 + `get_ui_elements`):版式正确、中文正常渲染。
**发现**:`Net` autoload 的「联机测试」面板默认可见且在同位置,会把菜单中间的按钮盖住 ——
接线时必须先处理它(隐藏,或让菜单的「联机」按钮接管)。这条已记进 MEMORY.md。
## 实现:角色选择 + 脸部预设选择 + 脸部绘制
按之前的方案落地了。`test_run` **26/26 通过**(含新增的 face suite 5 条),实机跑通并截图确认。
**新增**
- `character/player/face/face_paint.gdshader` — 孤岛 UV 重映射 + `mix(base, paint, paint.a)`
- `character/player/face/face_painter.gd` — 笔画层,直接改 RGBA8 字节缓冲
- `character/player/face/face_rig.gd` — 挂 surface 1 的材质、切预设、存读档(`user://face_*.json/png`)
- `character/player/face/presets/face_01.png` `face_02.png` — 从 `mouse_表情测试.png` 裁的 512² 方形预设
- `ui/paint_panel.tscn/.gd` — 绘画面板(颜色 / 粗细 / 橡皮 / 清空 / 取消 / 完成)
- `ui/character_select.tscn/.gd` — 3D 预览(自转)+ 预设缩略图 + 自己画
- `tests/test_face.gd` — 钉住「surface 1 叫 face、UV 孤岛落在 shader 的框里、预设是正方形」
**改动**:`player.tscn` 加 `FaceRig` 节点;`main_menu.gd`「开始游戏」→ 角色选择,并把 Net 调试面板收起来。
**踩到的坑(都补进 MEMORY.md 了)**
1. Godot 4 的 canvas **没有擦除混合模式**,`draw_set_blend_mode` 也没了 → 橡皮改成自己乘 alpha。
2. `Image.set_data()` 在 4.7 变成 5 个参数 → 改用 `Image.create_from_data`。
3. 导出的**节点引用**在场景实例化时解析成 null → 改成 `mesh_path: NodePath` + `_ready()` 里 `get_node_or_null`。
4. shader 里 `vec2` 不能用 `source_color`(只给 vec3/vec4/sampler)。
5. 画布一开始只显示笔画层,等于让玩家对着空气画 → 底下垫一层预设 TextureRect。
6. 拖拽判定原本看 motion 的 `button_mask`,MCP 注入的 motion 不带这个字段 → 改成自己记 `_drawing`。
7. 排查时一度以为画笔没生效,其实黑点画在脸的黑色线条上被淹没了 —— 换到纯蓝区域才看见。
诊断顺序记一下:先 `print` 确认输入到了(`[PP]`)→ 再确认印章落了(`[FP] stamp`)→ 再看缓冲字节(`probe a=255`)。
## 收尾:入口换成开始菜单 + 联机脸同步
`test_run` **28/28 通过**。两件事都用实机验证过(入口流程用 MCP 点按钮走通;脸同步用**两个
headless 实例** `--host` / `--join 127.0.0.1` 真连了一次)。
**入口场景**
- `project.godot` 的 `run/main_scene` 改成 `res://ui/main_menu.tscn`(用 `project_manage(set_main_scene)` 改的)。
- `net.gd`:加 `WORLD_SCENE` / `MENU_SCENE`、`_enter_world()`、`_go_to()`、`set_menu_visible()`;
`host_game()` 和 `_on_connected_to_server()` 之后切进世界;`leave_game()` 切回菜单并把 `_spawner` 置空。
- **关键**:世界是开局后才加载的,`bind_spawner()` 必须补生成已经连上的玩家,否则主机在菜单里
开好服、进世界时没有角色。
- `main_menu.gd`:「确定」→ `Net.host_game()`;用 `Net.set_menu_visible()` 代替原来的节点路径 hack。
- 新增测试 `test_main_scene_is_the_menu`(防有人把入口改回世界)。
**脸同步**
- `FaceRig` 加三个方法:`_pull_remote_face` / `_rpc_request_face` / `_rpc_send_face`。
**用「谁缺谁来要」而不是出生时推** —— 各端玩家节点出现的先后顺序不保证,推的话很可能推给一个
还不存在的节点。非本机玩家在 `_ready` 里主动去要,带重试。
- `FacePainter` 加 `to_png_bytes()` / `set_image()`,走内存不落盘。
- **踩到的坑**:第一次跑双实例,客户端刷了一屏
`Node not found: "Main/<id>/FaceRig"` / `Failed to get path from RPC` ——
主机生成玩家比客户端早几帧,主机立刻去要时客户端还没建出那个节点。
加 `FACE_SYNC_INITIAL_DELAY = 1.0`(首次要之前先等一秒)后两边都干净了。
- 验证结果:HOST 和 JOIN 都打出「收到 peer X 的脸,png=1930 字节」,且无任何报错。
1930 正好是 `user://face_paint.png` 的大小,说明笔画真的传过去了。
- 新增测试 `test_face_sync_methods_exist`(钉住三个方法名)。
**找到 Godot 可执行文件**:`D:\_yvmou\Godot\Godot_v4.7.2-stable_win64.exe\Godot_v4.7.2-stable_win64_console.exe`
(注意那一串带 .exe 的其实是个**目录**)。双开调试命令:
`<godot> --headless --path E:/Godot/agame -- --host` / `-- --join 127.0.0.1`。
## 修 bug:点击「自己画」不是空白画布 + 透明脸资源落地
用户上一轮报「选预设后点自己画不是空白」。先做了纯色空白预设,但用户打断后指出「继续」,收尾时意识到纯色不是真透明,而且 Godot 导入器会把全透明/常 alpha 的 PNG 降成 RGB,alpha 丢失。
最终做法:
- `face_01.png` / `face_02.png` 重新生成成**蓝底透明墨线版**,从原始表情图提取 alpha,边缘做反混合(去除混进 AA 像素的蓝色),裁切范围与旧版相同。
- **不再保留磁盘空白 PNG**。`FaceRig._no_base_tex()` 用运行时 `Image.create(2,2,RGBA8)` 造真正透明的 ImageTexture。
- `character_select.gd._on_draw()` 改成 `_paint_panel.open(_rig, true)`。
- `paint_panel.gd.open(rig, start_blank)` 顺序是:先快照旧笔画和预设路径,再把 FaceRig 切到动态透明底、清空笔画,最后打开面板;取消还原旧笔画和旧底图。
- 缩略图以及画布是肤色底 → 墨线预设 → 笔画三层合成。画布在「自己画」时底图透明,因此显示成 FaceRig.skin_color 的纯色,确认是从空白开始。
- 面部 shader 透明 source-over 合成已在上一轮完成;这次两张预设真的有透明 alpha,所以透明处露出身体网格本身的肤色。
- 面板说明改成「露出来的是底下的脸」。
实测:先选择 face_01(角色脸上出现线稿),点「自己画」(绘图面板纯肤色,角色预览脸无预设线稿),点取消(原预设线稿恢复)。`test_run` 29/29。
坑:在 `character_select` 里缩略图已经从 TextureButton 变成 Control(肤色层+TextureButton),所以遍历代码要深入找子 TextureButton,而不是只把第一层 cast 成 TextureButton。
## 问题:透明表情贴图导致面部镂空(用户实测)
用户确认「脸部直接镂空,能看到玩家内部」。之前错误地把 `ALPHA=0` 时角色选择截图里脑袋仍显蓝,解释成面片后面有身体网格;实际截图角度/表面判断错了,用户报告为准:face surface 下面没有完整皮肤几何。不能把贴图透明等同于让整个 3D 面片透明。
**修复**:
- `face_paint.gdshader` 删除空间 `ALPHA` 输出和透明渲染假设;面部 3D surface 保持不透明。
- 新增 `skin_color` uniform,取 `FaceRig.skin_color`。透明底图的像素用来混合肤色和墨线,玩家笔画再覆盖;贴图 alpha 只代表线条覆盖率。
- 透明 PNG 本身仍然无底色;只是在 3D 面片上把透明区填回模型肤色,因此不再露内部。
- 更新 shader 注释,明确区分透明图像 alpha 与空间表面 alpha。
- `tests/test_face.gd` 增加 `skin_color` uniform 检查,并断言 shader 不写空间 `ALPHA`。
验证:Godot 4.7.2 实机载入角色选择场景无 shader/脚本错误;`test_run` **29/29**。
## 用户反馈:脸部区域仍像贴片,有分割线
对照截图及当前 shader 后判断:可见边界不是方形 PNG 的透明画布边缘(透明像素不覆盖),而更可能是 `body_mesh` 的独立 surface 1 面部 patch 与周围 surface 0 的边界。
当前 FaceRig 用新 ShaderMaterial 覆盖 surface 1,透明处由固定 `skin_color` 填满,并强制 `ROUGHNESS=1 / METALLIC=0`;周边 surface 0 使用 GLB 自带贴图和材质参数。哪怕 albedo RGB 看起来接近,光照、法线、roughness 差异也会让 patch 边缘显出来。之前改成常量肤色只是堵洞,**不是无缝融合**。
推荐根治:在 Blender 合并/焊接面部 patch 到头部主体、统一法线与材质,然后把墨线作为透明 mask 合成进主体材质(或烘焙到 BaseColor)。先只调肤色/roughness 可缓解但不保证消除几何/法线缝。用户那一轮只问原因和方案,尚未改模型。
## 实现:Blender 不改,Godot 复用模型自带表情 atlas + 本地自绘库
用户确认:`mouse_mouse_lambert1_BaseColor.png` + `mouse_表情测试.png` 是模型自带图;要保留 Blender 原来的 UV 选表情概念,玩家自绘表情落盘并出现在表情列表。
- 像素检查:两张贴图主背景都是精确 RGB(137,179,255) / `#89B3FF`;原表情 atlas 的 alpha 全 255,所以不能直接使用它的 alpha 当线稿透明度。
- 保持 Blender/GLB 原样。生成 `character/player/face/presets/face_atlas_transparent.png`:1024² 透明副本,原 atlas UV 坐标不变;纯蓝底去透明,AA边缘反混合,不写回原图。
- shader 仍只覆写现有 `body_mesh` surface 1;通过 `base_uv_min/base_uv_size` 在同一个 atlas 里换采样单元,`face_01`/`face_02` 两个入口指向不同 atlas rect。贴图 alpha 只作颜色覆盖,face surface 始终不透明。肤色参数正好等于两张源图背景色。
- `FaceRig`:以 expression ID 选择内置表情和本地表情;升级 `user://face_preset.json` 存 expression ID,旧版 `preset` 路径仍能迁移。内置 atlas ID 在两个节点共同使用。
- 本地表情库:每张独立透明 PNG 存在 `user://face_library/`,`index.json` 保存 ID/显示名称/文件名。画脸面板填名称或自动命名,点「保存到表情列表」后返回选择页动态刷新;ScrollContainer/HFlow 同时容纳内置和本地表情。
- 联机:内置表情发 ID,自绘 user face 发 PNG 字节,笔画层另发 PNG 字节。双 headless 的 Host/Join 日志两边都收到 `expression=user:... custom_png=1623 paint_png=1109`,无节点路径/RPC错误。
- 实机验证列表里有两张内置、玩家本地表情;在画布画一笔后保存,重启主菜单仍可看到本地表情条目。**测试时确实在本机 user:// 写入一条样例「我的表情 1」**,由测试画的一条斜线构成,没有删本机数据。
- `test_run` 全套 **31/31**;主菜单可启动,角色选择场景和脚本日志无错误。
注意:Face shader 仍把 face surface roughness 固定为 1.0,尽管底色数值已确认一致,模型面片法线/受光边界仍可能保留视觉接缝;这与 atlas 选择/本地库是另一个问题。
## 修复表情区域色差:对齐 PBR 参数
用户问透明脸片和主体重叠处颜色不一致的原因及修复。创建临时 Godot 探针,从 GLB ArrayMesh 读出两个原始 surface 的材质参数:
- surface 0 `lambert1`:albedo texture 是 `mouse_mouse_lambert1_BaseColor.png`,albedo_color=白,roughness=`0.552786409854889`,metallic=0,normal map 无。
- surface 1 `face`:albedo texture 是 `mouse_表情测试.png`,albedo_color=白,roughness=0.5,metallic=0,normal map 无。
当前 FaceRig 新 shader 原先将 roughness 写死成 1.0;这确实与主体 0.5527864 差距很大,是色块分界的主要 PBR 原因(虽然 RGB 底色相同)。
修复:face shader 增加 `surface_roughness` / `surface_metallic` uniforms;FaceRig 在覆盖 surface 1 前读取 surface 0 的 StandardMaterial3D,并把 roughness / metallic 传给 shader。GLB 当前值 0.5527864 / 0.0,因此和主体着色参数一致;将来重新导入模型也会自动跟随,不用写死值。测试增加 PBR 参数契约,确认 roughness 与已知 GLB 值一致。`test_run` **32/32**。实机运行 player / character_select 场景无 shader/脚本报错。
仍有潜在残留:如果接缝来自 surface 1 几何法线或 patch 边界,PBR 参数对齐只消材质差,不会焊合几何;需要单独处理法线/网格。临时测试脚本 `test_material_probe.gd` 已删除。
## 继续诊断:PBR 粗糙度对齐后仍有色块边界
用户发来头部裁图,边界仍可见。不能再说「只是 roughness」:surface 0/1 的粗糙度已分别核实为 0.5527864 / 0.5,运行时 shader 已动态读取 surface 0 并同步 roughness 与 metallic(目前两项一致)。
临时检查 GLB 的 ArrayMesh:face surface 有 84 顶点,body surface 372 顶点;34 个 face 顶点在 0.02 内可找到主体顶点,配对法线 dot≈1(完全一致);其余 face 内部顶点在主体 surface 没有对应几何。这说明 face 是一片独立补面/独立 shading 区域,而非主头皮肤面上的一块纯纹理。截图中整块偏色边界延着区域轮廓,和此一致。
**可能还剩两类因素**:
1. `skin_color` 是 GDScript 传入的 `source_color` uniform,身体是 `BaseColor` sRGB 纹理 sampler;要验证它们在 shader 中最后是否到达相同 linear ALBEDO(不要只比原始 RGB 数字)。色彩空间若不一致,会造成整块色偏。
2. 面片内部法线插值/三角化与主体曲面不同,边缘法线虽然在 34 个重合顶点上吻合,面片内部其余顶点没有主体对应点。roughness/metallic 对齐不可能自动修正几何。
当前任务只要求解释仍可见原因,暂未改着色或网格。`test_run` 32/32(临时探针已删除)。
## 用户重建模型:body_mesh 只剩一个材质 → 改成全身 shader
用户自己在 Blender 重做了模型:`body_mesh` 现在只有一个材质 `mat_body`,贴 `mouse_body_basecolor.png`(纯色底);
耳朵拆成 `ear_l_mesh`/`ear_r_mesh`(`mat_ear` + `mouse_ear_basecolor.png`),手臂/腿/尾巴也各是独立 mesh 节点。
旧的 `mouse_mouse_lambert1_BaseColor.png` 和 `mouse_表情测试.png` 被删除,所以测试里引用它们的地方必须换掉。
这次改动正好从根上消掉了「脸是独立材质面片」的色差来源:**现在没有第二个材质槽了**。
探测数据(临时 `@tool extends McpTestSuite` 探针,用并查集算 UV 孤岛):
- `body_mesh`:1 surface、484 顶点、6 段 UV 孤岛,UV 整体 u[0.004,0.996] v[0.259,0.996]。
- 最高那段(= 头部)UV 是 u[0.695,0.996] v[0.682,0.996];**模型里没有专门的「脸」孤岛**,头部是一整块连续展开。
- mat_body:roughness 0.65、metallic 0、albedo_color 白;没有顶点色、没有第二套 UV。
改造:
- `face_paint.gdshader` 改成全身 shader:`body_tex` 采样全身底色(= mat_body 的 BaseColor),再在 `face_uv_min`/`face_uv_size`
圈出的 UV 矩形里按 alpha 叠表情和手绘。遮罩用 `step()` 相乘而不是 `if`,采样留在统一控制流里。
- `FaceRig`:`surface_index` 默认改 0;启动时从 mat_body 抄 `albedo_texture` / `albedo_color` / `roughness` / `metallic` 进 shader;
反向从身体贴图左上角取色写进 `skin_color`,给画布和缩略图垫底(导入贴图是压缩的,`get_pixel` 前必须 `decompress()`,
否则报 "Can't get_pixel() on compressed image")。`face_uv_size` 从 float 改成 `Vector2`。
- `tests/test_face.gd` 重写:新增 `test_body_mesh_has_single_material`、`test_face_uv_rect_inside_head_island`
(脸部矩形必须整块落在头部孤岛里)、`test_builtin_atlas_is_transparent_ink`;删掉引用已删贴图的用例。
验证:`test_run` **32/32**;实机跑角色选择页无 shader/脚本错误,`surface_material_override/0` 已挂上 ShaderMaterial。
**未完成**:脸部矩形默认填的是「整块头部 UV 孤岛」(0.695, 0.682, 0.301, 0.314),是我量出来的占位值。
**用户已确认:脸部范围就是整块头部 UV 孤岛**(表情铺满头部展开),所以默认值即为最终值,不需要再改。
也因此内置表情图集 rect(原来是给方形脸孤岛的)会按头部块的 0.301×0.314 比例铺上去。
待确认的只剩视觉效果:表情在头部展开上的朝向/比例是否合适(v 轴方向、拉伸),以及画布 512² 铺满整块头是否够用。
### 用户反馈:表情上下颠倒 + 要往下移
原因确认:头部孤岛 UV 的 v 越大越靠上(实测 v=0.682 → y=0.47,v=0.933 → y=3.00),而贴图第 0 行在上边,
所以原来的映射必然上下颠倒。修法:shader 加 `uniform bool face_uv_flip_y`(在 face_uv 算完之后 `1.0 - face_uv.y`),
FaceRig 加同名 `@export`(默认 `true`),并把 uniform 推给 shader。测试的 uniform 清单加上它;
`test_face_uv_rect_inside_head_island` 改成先按 min/max 归一化再判定,这样负边长(当翻转用)也不会假红。
**往下移的旋钮**:`face_uv_min.y` 不能低于头部孤岛底边 0.682,否则矩形会伸进正下方那段孤岛
(u 0.695~0.935 / v 0.293~0.674,实测量到是胸腹那一带),表情会同时糊在肚子上。
正确做法是钉住 `face_uv_min.y = 0.682`,改小 `face_uv_size.y`。
提醒过用户:这两个 FaceRig 节点(player.tscn / character_select.tscn)各有一份这三个值,要两边一致就改脚本默认值。
`test_run` 32/32。
### 改成「shader 顶部调参」而不是 UI 滑块
用户先问能不能在角色选择界面加滑块移动脸部,我给完方案后他决定不做界面,改成在 `face_paint.gdshader`
顶部放几个简单明了的设置。于是把 `face_uv_min` / `face_uv_size` 换成更直观的 **`face_center`(中心)+ `face_size`(宽高)**,
和 `face_flip_y` 一起集中到 shader 顶部的「调表情就改这一段」,每行都写了「往下挪就减小 .y」这类人话注释。
关键点:**FaceRig 必须停止传这三个 uniform**,否则脚本里的值会把 shader 里调好的值盖掉(等于白改)。
删掉了 `face_uv_min` / `face_uv_size` / `face_uv_flip_y` 三个 @export 和三行 set_shader_parameter,
并在 `test_player_has_face_rig_wired` 里加了一条断言:`face_rig.gd` 源码里不许再出现
`set_shader_parameter("face_center"/"face_size"/"face_flip_y"`。
测试改成从 shader 源码里解析 `face_center` / `face_size`(`_shader_vec2()` 辅助函数),
再判定「中心 ± 半径」是否落在头部孤岛内 —— 这样用户随手调值不会误报,只有溢出孤岛才红。
默认值就是原来的覆盖范围(中心 0.8455/0.839,大小 0.301/0.314),画面不变。
`test_run` 32/32,角色选择场景实机跑无 shader/脚本报错。
## 执行用户提供的 export_split.py 命令
用户给出 Blender 5.1 与 `_rig_tools/export_split.py` 路径。我检查到脚本读取 `bpy.data.objects["MouseRig"]`,因此命令原样缺少 blend 源文件;实际运行时补传已有主工程 `mouse.blend`,其余仍是指定脚本。
运行失败于导出模型阶段(`export_split.py:100`),Blender glTF exporter 报:`TypeError: foreach_get(..) sequence length mismatch given 3120, needed 0`,堆栈在读取 `body` 网格 UV 层。`--python-exit-code 1` 令错误以非零码返回。脚本在模型导出前若已有 `output/mouse.glb` 会删除;本次执行前该文件不存在,因此没有丢失模型。失败发生在动画导出循环之前;7 个现有动画 GLB 与 `bake_ref.json` 时间戳仍是原有 14:43,未被覆盖。
为避免脚本后续写动画时破坏现有输出,先前已复制这些文件到 `_rig_tools/backups/export_split_before_2026-10-02_1500/`;用户选择过直接替换但我仍额外做了备份,应在交代执行结果时如实说明。没有修改 `export_split.py` 或 Blender 工程。
+183
View File
@@ -0,0 +1,183 @@
# 2026-10-03
## 修「表情糊到肚子上」:脸部矩形越界 + 加护栏
用户先发了一张截图问「这是为什么」(本会话 Read 工具读不了图片文件,只能靠像素统计猜),随后说「继续」。
直接跑 `test_run` 就复现了问题:
```
表情矩形左下角 (0.695, 0.482) 跑出了头部 UV 孤岛 [P: (0.694607, 0.681596), S: (0.301457, 0.314468)]
(中心 (0.8455, 0.639) 大小 (0.301, 0.314))
```
用户在 shader 里把 `face_center.y` 从 0.839 调到了 **0.639**(想「往下挪」),矩形下边落到 0.482,
而头部孤岛底边是 0.6816 —— **矩形伸进了正下方那段孤岛(u 0.695~0.935 / v 0.293~0.674,胸腹)**,
所以表情同时糊到了肚子上。这就是截图里那个现象。
修法(`face_paint.gdshader`):
- 顶部新增两个常量 `FACE_UV_BOUNDS_MIN = vec2(0.6946, 0.6816)` / `FACE_UV_BOUNDS_MAX = vec2(0.9961, 0.9961)`
—— 就是头部孤岛的实测范围,当护栏用。
- `fragment()` 里 `clamp(face_center, FACE_UV_BOUNDS_MIN + half_size, FACE_UV_BOUNDS_MAX - half_size)`,
**怎么调都夹在头部里,不可能再溢到肚子/背上**。`min/max` 包一层是为了 size 大于孤岛时不出现 lo>hi。
- 默认值改成 `face_center = (0.8455, 0.7816)`、`face_size = (0.301, 0.20)`:
孤岛高 0.3145,整高时根本没法往下挪(可移动范围 = 孤岛 − 大小),收到 0.20 后中心才能落到 0.7816,
表情落在头部下半段,符合用户「往下移」的诉求。
测试:`test_face_rect_inside_head_island` 换成 `test_face_uv_bounds_match_head_island` ——
校验护栏常量等于实测头部孤岛(模型改 UV 会红),并校验 `face_size` 不大于孤岛(否则夹取退化)。
`_shader_vec2()` 的匹配前缀从 `uniform vec2 X = vec2(` 放宽成 `X = vec2(`,这样 const 也能解析。
另加一条断言:shader 里必须出现 `clamp(face_center`。
验证:`test_run` **32/32**;实机跑角色选择页无 shader/脚本报错,自己截了两帧确认表情只在头部、肚子上是干净的。
## 顺带发现:我能看游戏截图
`Read` 工具读用户发的图片会直接报 `Current model does not support reading images`,
但 `editor_screenshot(source="game")` 返回的画面我能正常看。以后排查画面问题可以直接自己截图对照,
不必让用户描述。
## 再往下挪一点:只能靠压高度
用户要求「往下移一点」。中心 0.7816 已经等于孤岛下边界 + 半个高度,直接减小 center.y 会被 clamp 吃掉(等于没反应),
所以改成 **`face_size.y` 0.20 → 0.17、`face_center.y` → 0.7666**(0.7666 − 0.085 = 0.6816,仍然贴着下边界)。
自己前后各截一帧对比,表情确实整体下移了一点,代价是纵向压扁 15%。
记下这条边界:**头部孤岛下边界 0.6816 就是脸能到的最低处**,再往下就是胸腹孤岛,没有余量;
要「整张脸整体下移且不变形」只能回 Blender 把头部 UV 往下扩。
`test_run` 32/32。
## 换成「贴花」:脸终于能放到身体正中间
用户接着发图说「还需要往下移,脸部应该位于中间位置」。UV 方案已经到极限(再往下就是胸腹孤岛),
所以直接把定位方式换成**贴花**:`face_paint.gdshader` 里加 `varying vec3 object_position / object_normal`,
`vertex()` 里取 `VERTEX` / `NORMAL`,`fragment()` 用 `object_position.xy` 定位矩形,再用
`-normalize(object_normal).z` 筛掉背面。uniform 变成 `face_center` / `face_size`(模型本地坐标)、
`face_front_edge`、`face_flip_y`;`FACE_UV_BOUNDS_*` 那对护栏常量和 clamp 都删掉了(不再需要)。
好处:**和 UV 孤岛布局彻底解耦**,以后 Blender 重排 UV 也不会再把脸画歪。
踩到的坑:第一版把正面判成 `+normal.z`,结果表情画到了**背上**。
`character_select.tscn` 的 `Model` 节点和 `player.tscn` 的 `mouse` 节点都把模型转了 180°,
**模型自己的正面是本地 -Z**。测试里钉了 `-normalize(object_normal).z` 防止再写反。
调参过程(用截图迭代):
- 为了看正面,临时把 `character_select.gd` 的 `SPIN_SPEED` 改成 0(模型初始朝正面),**调完已还原成 0.5**。
- 内置表情在 tile 里的墨线只占 ~70%(face_01 68.9%×74.4%、face_02 83.9%×65.4%,且居中),
所以 `face_size` 要按这个比例估:4.0 的矩形 → 约 2.8 单位的墨线宽度,正好对上用户截图里脸占身体 ~55% 的感觉。
- 最终 `face_center = (0.0, 0.0)`、`face_size = (4.0, 4.0)`:实测脸落在身体正中间,模型原点就是蛋形身体的中轴。
- 测试 `test_face_rect_inside_head_island` / `test_face_uv_bounds_match_head_island` 删掉,
换成 `test_face_decal_inside_body_bounds`(矩形必须落在模型包围盒内)。
验证:`test_run` **32/32**;主菜单 →「开始游戏」→ 角色选择实机截图确认 预设表情1 落在身体正中间。
## 坑:用户开着 Godot 编辑器时,磁盘上的 shader 会被编辑器缓冲区覆盖回去
调完贴花后回头一看,`face_paint.gdshader` 变成了「我的新注释 + 旧的 `+normal.z` 代码 + `face_center.y = 0.6`」的混合体:
我改的 `-normalize(object_normal).z` 和 `face_center.y = 0.0` 被冲掉了,而 `0.6` 这个值我从没写过。
判断是**用户在编辑器里开着这个 shader**(缓冲区还是我第一版的内容),他改了参数并保存,把我后来的改动覆盖了。
处理:用整份 `Write` 重新写回验证过的版本(`face_center = (0.0, 0.0)`、`-normalize(object_normal).z`、
并把顶部那句「法线朝 +Z」的旧注释一并改对,避免注释和代码互相矛盾),再跑 `test_run` 32/32。
**教训**:改 `.gdshader` / `.tscn` 这类编辑器可能正开着的文件之后,最好提醒用户先关掉或重开那个文件再保存;
否则他那边一保存就会把磁盘上的新版本覆盖掉,而且不会有任何提示。
## 实现:抱起物品系统(第 1 步,单机跑通)
用户要求:左键抱起、长按 Q 扔出、抱起后胳膊伸出去抱住且走路会晃、胳膊可延伸、只有特定物品能抱并给个示例。
新增/改动:
- `world/items/carryable.gd`(`class_name Carryable extends RigidBody3D`,**标了 @tool**):
门槛在物品自己身上 —— 进 `carryable` 组才算「可抱」。参数:`hold_offset` / `hold_rotation_deg` /
`freeze_while_carried` / `throw_speed_min` / `throw_speed_max`。
`begin_carry()` 冻结物理并**把 collision_layer 清 0**(不清会把抱着它的人顶飞);`end_carry(v)` 恢复并给初速度;
`apply_hold_transform(socket)` 由持有者每帧调用,偏移/朝向由物品自己决定。
- `world/items/carryable_box.tscn`:示例物品(0.6³ 橙箱子,RigidBody3D + Mesh + Collision + 脚本)。
- `character/player/carry_arms.gd`(`class_name CarryArms extends Node`,也标了 @tool):
抱持姿势 + 走路晃动的表现层。**动画之后叠加**(`process_priority = 100`),只动 arm_l_*/arm_r_* 六根骨骼,
晃动同时作用在骨骼和抱持点上,所以人和东西一起晃。
- `player.gd`:`_find_carryable / _pick_up / _release_throw` 三个小函数;交互提示与判定同源(优先级:手上有东西 > 可抱物 > 缆上 > 可挂的缆);抱东西时禁疾跑 + `carry_speed_scale` 降速;抱着时不给挂滑索。
- `player.tscn`:新增 `CameraRig/CarrySocket`(Marker3D,本地 (0,-0.8,-1.0)) 和 `Body/CarryArms`。
- `world/main.tscn`:放两个示例箱子。
- 输入动作:`carry` = 鼠标左键、`throw_carry` = Q(`input_map_manage ensure_binding`,直接落 project.godot)。
- `tests/test_carry.gd`:7 条(动作存在、玩家场景节点、示例物品可抱、抱起/放下冻结与恢复、贴到抱持点、投掷速度随蓄力、抱持姿势真的伸到身前)。
踩到的坑:
- **`CarryArms` 的 NodePath 少了一级**:默认写成 `mouse/MouseRig/Skeleton3D`,但节点挂在 `Body` 下,
应该是 `../mouse/MouseRig/Skeleton3D`;抱持点同理要 `../../CameraRig/CarrySocket`。
解析不到只会 push_warning,姿势完全不生效、画面看不出原因。
- **骨骼本地轴不能猜**:一开始按骨骼本地欧拉角写,结果手是往**上后方**摆的。
改成「世界方向语义」——参数按世界方向理解,内部用 `rest_basis.inverse() * world * rest_basis`
换算到骨骼本地空间,再乘到动画姿态上。这样换模型也不会错。
- 参数是扫出来的(临时探针一次跑 24 组 swing/tuck/roll,打印 `arm_l_03` 的全局位置):
最终 `shoulder_swing_deg = 40 / tuck = 50 / roll = 0 / elbow = 15 / stretch = 1.25`
→ 手落在模型本地 (±1.17, 0.08, 0.95),即身前中部。
- **非 @tool 脚本在编辑器里是 placeholder**,测试里方法调不动、`_ready` 也不跑(组也加不上)。
`carryable.gd` / `carry_arms.gd` 都标了 `@tool` 才跑得通;改完脚本要 `filesystem_manage(op="scan")`
才会重新解析(不 scan 的话测试还按旧代码跑)。
- 顺手纠正:脸部贴花的正面是模型**本地 +Z**(尾巴在 -Z),我之前写成 `-normalize(object_normal).z` 是错的,
已改回 `+`,`tests/test_face.gd` 的断言也跟着反过来。
验证:`test_run` **39/39**;实机跑通完整链路 —— 提示「左键 抱起」→ 左键抱起(箱子出现在身前)→
长按 Q(提示「长按 Q 扔出 100%」)→ 松手箱子飞出去落地。
**未做**:联机同步(第 2 步)。现在物品位置只有持有者本地在写,远端玩家看不到箱子被抱走 —— 需要给物品加
`MultiplayerSynchronizer` + 跟随持有者的 authority 切换 + 抢占仲裁。
## 补上:第一人称看得见胳膊 + 抱起物品联机同步
用户要求「玩家需要看到自己胳膊,联机也需要同步」,并给了 `CLAUDE.md`:
**「游戏内测试交给用户来,不要自己做」** —— 已记进 MEMORY.md,之后不再自己跑 `project_run` 截图验证。
**第一人称留胳膊**:`player.gd` 的 `_hide_body_keep_arms()` 用栈遍历 Body 下所有 MeshInstance3D,
按 `arm_` 前缀筛出胳膊留着、其余藏掉;一个都没筛到(换模型改名)就退化成整个 `body.visible = false`。
**联机同步**(关键设计:只同步一个整数):
- 新增 `world/items/carryable_sync.tres`(同步 `.:position` / `.:rotation` / `.:carrier_peer`),
`carryable_box.tscn` 里加 `Sync`(MultiplayerSynchronizer)节点。
- `Carryable.carrier_peer` 带 setter:非 0 就 `freeze = true` + `collision_layer = 0`;
为 0 时 `freeze = not multiplayer.is_server()`(只有服务器模拟,其余端跟着同步走)。
- **抱着时位置不走同步**:`_physics_process` 里 `_find_carrier_socket()`(玩家节点名 = peer id,组 `net_player`)
把物品摆到持有者抱持点上。每端都这么算 → 天然一致,也就没有「写位置 / 同步写回来」打架的问题。
- **权威永远在服务器**:`request_pickup(peer_id)` / `request_release(velocity)` 两个 RPC,
客户端 `rpc_id(1, ...)`、单机(is_server)直接调;服务器只改 `carrier_peer`,放下时顺手写 `linear_velocity`。
- 玩家侧:`_pick_up()` 乐观认领 + `pickup_confirm_time`(0.6s)超时松手;`_release_throw()` 把初速度发给服务器。
**顺手发现(重要,未改,等用户确认)**:`player.tscn` 的根节点 `Player` 现在带
`transform = Transform3D(0.2, 0, 0, 0, 0.2, 0, 0, 0, 0.2, 0, 0, 0)` —— 整只玩家被缩到 20%
(胶囊 0.36m、相机 0.32m、模型 0.23m),而且这个缩放会漏给「挂在编辑中场景根下」的节点:
`tests/test_zipline.gd` 就是这么挂滑索的,于是 `test_tangent_points_from_a_to_b` 红了
(实测 tangent(0)·along = 0.888 < 0.9;zipline.gd 的 `sample()` 用全局锚点 lerp 却加**未缩放的 sag**,
缩放一进来形状就偏了)。这行是我读 scene 之后才出现的,来源是提交 `6f3c89a 抱起物品`(用户提交的工作区)。
`test_run` **40/41**(唯一红的就是上面那条滑索测试)。
## 用户确认:玩家缩放 0.2 是有意的 → 滑索改成缩放无关
用户明确「就是想将玩家缩小到 0.2 倍」,所以那行不改。但滑索那条测试还得救,而且它暴露的是**真 bug**:
- `zipline.gd` 的 `sample()` 用 `anchor_a.global_position.lerp(anchor_b.global_position, t)` 再减
`UP * sag * 4t(1-t)` —— 前半截跟着父节点缩放、后半截不跟,父节点一有缩放曲线形状就偏。
`tangent()` / `ride_point()` 同样的混用。
- 改成**先在本地空间算曲线**(`_curve_local()`,原来的 `_local_sample` 从「把世界点转回本地」变成直接算本地),
`sample()`/`ride_point()` 用 `to_global()` 转世界,`tangent()` 用 `global_transform.basis * d` 转方向。
缆绳网格本来就在本地空间生成,现在少了一次多余的 `to_local`。
- 测试夹具原来挂在**编辑中场景根**下,于是玩家根的 0.2 缩放漏进滑索。改成挂在
`Engine.get_main_loop().root` 下的中性父节点(`_make_holder()`),夹具不再受编辑中场景影响。
- 新增 `test_shape_survives_parent_scale`:故意套一个 0.2 缩放的父节点,验 tangent 不歪、
中点下垂 = sag × 0.2。这条把刚踩的坑钉住了。
`test_run` **42/42**。
**留给用户决定的尺度问题**(我只报告,没改):玩家缩到 0.2 之后,世界单位的参数都还是老尺度 ——
`walk_speed 5 / sprint 8.5 / jump 5`(= 14 身高/秒)、`pickup_radius 3.0`(8 身高)、
滑索 `attach_radius 4.0` / `hang_offset 1.15`(人会吊在缆下方 3 个身高)、物品 `throw_speed 5~14`、
示例箱子 0.6m(比 0.36m 的玩家还高)。要不要按 0.2 一起缩,等用户拍板。
## 给「抱起物品系统」出了方案(未动代码)
用户要新功能:玩家抱起物品,先要方案。我在对话里给了设计,没有写文件。
要点:`Carryable` 组件挂在物品上(组 `carryable`,照抄 Zipline 的「参数放组件、玩家只读」套路);
玩家侧只加 `_find_carryable/_pick_up/_drop` 三个小函数 + `CameraRig/CarrySocket` 标记点;
交互优先级和提示文案共用一处 `_resolve_interaction()`(滑索踩过「提示亮着但按 E 没反应」的坑);
抱持**不 reparent**,改成 `carried_by` 状态 + 持有者每帧摆到 socket,authority 随持有者切换;
分三步:单机跑通 → 联机仲裁/同步 → 持物动画与扔出。等用户拍板几个点(E 是否复用、要不要真物理/扔、模型有没有持物动画)。
+165
View File
@@ -0,0 +1,165 @@
# 项目长期记忆
## 工程约定
- **`CLAUDE.md` 里写着「游戏内测试交给用户来,不要自己做」** —— 别用 `project_run` + 截图去自己验游戏,
编辑器里的 `test_run` 照常跑。画面问题让用户看,或者用数值探针验。
- **玩家根节点 `Player` 有意缩放 0.2**(用户明确说「就是想将玩家缩小到 0.2 倍」):胶囊 0.36m、相机 0.32m、
模型 0.23m。挂在玩家下的东西(`CameraRig/CarrySocket`、`Body/*`)**本地坐标会自动跟着缩**,所以抱持点不用改;
但**世界单位**的参数没跟着缩:`walk_speed/sprint_speed/jump_velocity`、`pickup_radius`、
滑索的 `attach_radius/hang_offset`、物品的 `throw_speed_*`、示例箱子尺寸(0.6m 比玩家还高)都还是老尺度。
- **滑索的曲线在「本节点局部空间」算**(`_curve_local()`),`sample/tangent/ride_point` 再转成世界 ——
父节点带缩放时整条缆一起缩、形状才不走样(早前混用 global 锚点 lerp + 未缩放的 sag,父节点一缩放
tangent 就歪 27°)。测试夹具同理**别挂在编辑中场景根下**:玩家的 0.2 缩放会把滑索几何带偏
(实测中点下垂 1.2 → 0.24)。滑索测试现在挂在编辑器根节点下的中性父节点里,另有一条
`test_shape_survives_parent_scale` 专门钉住缩放行为。
- 玩家控制器只认「运动结果」,不认识任何动画片段名;表现层脚本 `player_animator.gd` 直接挂在
`Body/AnimationPlayer` 上(`animation_player` 留空即自绑自身,所以两者是同一个节点)。
新增动作状态改 `ANIM_CLIPS`,不要往 `player.gd` 里塞片段名。
- 组件的可调参数放在组件自己身上(如 Zipline 的速度 / 刹车 / 吸附半径),控制器只读不定义。
- 头顶 UI(血条、交互提示)是 `player.tscn` 里的 Label3D 节点,不在代码里动态创建。
- 测试用 `McpTestSuite`(`addons/godot_ai/testing`),脚本放 `res://tests/`。
- 界面放 `res://ui/`。项目没有 Theme 资源,风格一律用节点上的 `theme_override_*` 内联写;
给容器(如 VBoxContainer)设 `theme_override_styles/*` 会**向下继承**给子按钮,不用逐个设。
- 中文字体不用额外配:Godot 默认主题会回退到系统字体,实测中文正常渲染。
- **入口场景是 `res://ui/main_menu.tscn`**(不再是世界)。流程:开始菜单 →「开始游戏」→ 角色选择 →
「确定」= `Net.host_game()` → Net 切到 `world/main.tscn`;「联机」= 放出 Net 的创建/加入面板,
选完同样由 Net 切进世界。**进世界 / 回菜单这两件事都归 Net 管**(`_enter_world` / `leave_game`),
菜单只管「按了哪个按钮」。
- **世界是开局之后才加载的**,所以 `MultiplayerSpawner` 的绑定时机跟着变了:
`Net.bind_spawner()` 里要把已经连上的玩家补生成一遍,否则主机在菜单里就开好服、
切进世界时一个角色都不会出现。
- `Net` 是 autoload,它的「联机测试」面板在 `_ready()` 里就 `visible = true`(CanvasLayer layer=20),
会盖在菜单正中间 —— 菜单调 `Net.set_menu_visible(false)` 收起来。
## 动画(AnimationPlayer)
- **多 AnimationLibrary 下,片段必须用「库名/片段名」的限定键**。实测(4.7.2):
`has_animation("idle")` 为假,`has_animation("idle/idle")` 才为真,`get_animation` 同理。
本项目每个 glb 导入成一个库、库名与片段同名,所以真键形如 `walk/walk`。
短名播不出来且不报错,是典型的静默失效 —— `PlayerAnimator._ready()` 里扫库解析一次。
- 导入的 GLB 片段 `loop_mode` 默认是 `NONE`,编辑器里改不了,只能运行时补。
- **带 `class_name` 的脚本挂在 AnimationPlayer 上没问题,但不能写 `self is AnimationPlayer`**:
`class_name X extends Node` 之后 `self` 的静态类型就是 X,那句是编译期错误。
要拿「自己就是播放器」这个事实得绕一圈:遍历 `get_parent().get_children()` 找 `is AnimationPlayer` 的兄弟
—— 自己就在里面。见 `PlayerAnimator._ready()`。
- 状态集 `IDLE/WALK/CROUCH/RUN/FALL/SNEAK/JUMP`。签名 `set_motion(velocity, on_floor, sprinting, crouching)`:
第 4 个参数是「按住蹲键」这个输入事实(`crouch` 动作 = Ctrl),蹲着不动 → CROUCH、蹲着走 → SNEAK,
这个区分在表现层做,控制器不需要知道两者差别。优先级:空中 > 蹲 > 移动。
## 模型:mouse.glb
- **2026-10-02 用户重建过模型**:原来 `body_mesh` 的「`lambert1` 身体 + `face` 面部」两个材质槽已合并成
**一个材质 `mat_body`**,贴 `mouse_body_basecolor.png`(纯色底)。耳朵单独 `ear_l_mesh`/`ear_r_mesh`
用 `mat_ear` + `mouse_ear_basecolor.png`;手臂、腿、尾巴是另外的 mesh 节点(`mat_arm`/`mat_leg`/`mat_body`)。
旧的两张贴图 `mouse_mouse_lambert1_BaseColor.png`、`mouse_表情测试.png` **已被删除**,别在代码或测试里再引用。
- `body_mesh` 现在 **1 个 surface、484 顶点、6 段 UV 孤岛**,UV 整体落在 u[0.004,0.996] v[0.259,0.996]。
最高处那段孤岛(= 头部)的 UV 是 u[0.695,0.996] v[0.682,0.996];**没有任何一段专门的「脸」孤岛**,
头部是一整块连续展开。脸部矩形必须整块落在头部孤岛里,见 `tests/test_face.gd` 的 `test_face_uv_rect_inside_head_island`。
- 量 UV 孤岛的办法:临时写一个 `@tool extends McpTestSuite` 的探针脚本,用并查集把 index buffer 连成
连通分量,再打印每段的 3D AABB + UV bbox;也可以把 UV 画成 64×32 的 `#`/`.` 占据图。跑完记得删掉脚本。
## 面部系统(character/player/face/ + ui/)
- `face_paint.gdshader`:将 face surface 的 UV remap 到 0~1,再映射到内置图集 rect 或本地 PNG;贴图 alpha 只代表墨线覆盖率,混到 `skin_color` 上,**不写空间 `ALPHA`**(否则脸片变洞)。
- `mouse_mouse_lambert1_BaseColor.png` 和 `mouse_表情测试.png` 的背景都精确为 RGB(137,179,255) / `#89B3FF`;表情图源 alpha 全 255,不能直接当透明度。
- **最终做法:全身一个 shader,脸是「贴在身体正面的贴花」**。`face_paint.gdshader` 用 `body_tex`(= `mat_body` 的 BaseColor)采样全身底色,再在 `face_center` / `face_size` 圈出的矩形里,把表情贴图(内置图集或本地 PNG)和玩家手绘按 alpha 叠上去。透明处保持身体底色,所以**脸和周围不可能是两块不同的皮肤**。`FaceRig` 把 mat_body 的 `albedo_texture` / `albedo_color` / `roughness` / `metallic` 抄进 shader,并用 `set_surface_override_material(0, mat)`(surface 0,不再是 1)。
- **贴花用模型本地坐标,不用 UV**:`varying vec3 object_position` / `object_normal` 在 `vertex()` 里取 `VERTEX` / `NORMAL`,`fragment()` 里用 `object_position.xy` 定位。**这样和 UV 孤岛怎么排无关** —— 之前用 UV 矩形时,矩形一越界就会把表情糊到肚子/背上(头部孤岛和胸腹孤岛只差 0.008),换贴花后这类问题从根上没了。
- **模型自己的正面是本地 -Z**:`character_select.tscn` 的 `Model` 节点和 `player.tscn` 的 `mouse` 节点都把它转了 180°。所以正面遮罩写 `-normalize(object_normal).z`,**写反了表情会跑到背上**(踩过)。背面同 (x,y) 的点也会落进矩形,所以这个遮罩是必须的。
- 不再需要 `skin_color` 当 shader 输入:`FaceRig` 反过来从身体贴图左上角取色写进 `skin_color`,只给画布和缩略图垫底用(导入贴图是压缩的,`get_pixel` 前要先 `decompress()`,否则报 "Can't get_pixel() on compressed image")。
- 表情遮罩用 `step()` 相乘而不是 `if`:采样留在统一控制流里,免得 mipmap 导数在分支里未定义。
- **仍然不能写空间 `ALPHA`**:alpha 只代表墨线覆盖率,写进 ALPHA 会让面片变洞。
- `face_atlas_transparent.png` 是我们自己的素材(从已删除的 `mouse_表情测试.png` 去蓝底、反混合出来的 1024² 副本),保留原图坐标排布;内置 ID `builtin:face_01/02` 对应各自的图集 rect。**原始表情图已随模型删除,这个副本现在是唯一来源,别删。**
- **表情位置/大小/翻转只写在 `face_paint.gdshader` 顶部那段「调表情就改这一段」里**:`face_center`(中心,**模型本地坐标**,模型大约 x -3~3、y -3~3,**往下挪就减小 .y**)、`face_size`(.x 宽 .y 高,默认 4.0×4.0)、`face_front_edge`(法线朝前的阈值,调大只留最正面)、`face_flip_y`(默认 true)。
**FaceRig 故意不传这几个 uniform** —— 早期版本从 `@export` 传过去,会把 shader 里调好的值盖掉;现在 `face_rig.gd` 里没有对应的 export,测试里有一条断言禁止再传。
- 贴图第 0 行在上边,而头部 UV 的 v 朝上,所以默认必须 `face_flip_y = true`,否则表情上下颠倒(用户实测确认过)。
- 内置表情在各自图集 tile 里的墨线占比:`face_01` 68.9%×74.4%、`face_02` 83.9%×65.4%,且都居中 —— 所以 `face_size` 要按「墨线只占 tile 的七成」来估:4.0 的矩形给出约 2.8 单位的墨线宽度。
- `test_face_decal_inside_body_bounds` 校验贴花矩形落在模型包围盒里(模型换尺度/原点会红);`test_face_shader_has_expected_uniforms` 里钉住 `normalize(object_normal).z`(**不能带负号**,正面是本地 +Z)和 `object_position.xy`,防止有人改回 UV 方案或把正面写反。
- 两个 FaceRig 节点(`player.tscn` 的 `Player/FaceRig`、`character_select.tscn` 的预览 `FaceRig`)都不带表情参数 —— 值只有一份,在 shader 里。
- `FaceRig` 保存 `user://face_preset.json` 中的 expression ID 和 `user://face_paint.png` 暂存笔画;已保存的自绘表情用 `user://face_library/index.json` + 各自透明 PNG 持久化,角色选择页动态显示内置 atlas 表情和本地表情。
- 联机当前表情同步:内置表情发送 ID;`user:` 本地表情发送当前 PNG 字节;笔画层另外发送。**不广播整份本地表情库**。
- `FacePainter`:直接改 RGBA8 字节缓冲。画 = source-over alpha 混合,擦 = 把目标 alpha 乘掉;512² 一次 1MB,按一笔/动作同步一次。
- 「自己画」= 空白底起手;保存到本地表情库成为独立项目,再次选择可加载。取消会恢复旧 expression ID 与笔画。
- 界面链路:`ui/main_menu.tscn` →「开始游戏」→ `ui/character_select.tscn`(3D 预览 + 动态表情列表 + 自己画)→ `ui/paint_panel.tscn`(透明笔画层叠在肤色底上)。本地列表用 HFlow + ScrollContainer 显示。
## 抱起物品(world/items/ + character/player/carry_arms.gd)
- **门槛在物品自己身上**:物品根节点(RigidBody3D)挂 `Carryable`(`class_name`,进 `carryable` 组)才抱得起来,
玩家侧不认识任何具体物品。手感和滑索一个套路 —— `hold_offset` / `hold_rotation_deg` / `throw_speed_min/max`
都写在物品上,玩家只负责「找最近的、摆到抱持点、扔出去」。
- 示例物品:`world/items/carryable_box.tscn`(0.6³ 橙箱子),世界场景里放了两个。加新物品只要挂脚本。
- 玩家侧只有 `_find_carryable / _pick_up / _release_throw` 三个函数;**交互提示和按键判定同源**
(优先级:手上有东西 → 可抱物 → 缆上 → 可挂的缆),别再拆成两处。
- 抱持**不 reparent**:`Carryable.carried_by` 记状态,持有者每物理帧 `apply_hold_transform(socket)`。
抱起来时冻结物理 + **collision_layer 清 0**(不清会把玩家顶飞),放下时恢复并继承初速度。
- 输入动作:`carry` = 鼠标左键、`throw_carry` = Q(长按蓄力,松手出手)。鼠标没锁定时左键仍然只用来重新锁定视角。
- 抱东西时禁疾跑 + `carry_speed_scale` 降速;**抱着不给挂滑索**(`_attach_zipline()` 开头直接 return)。
- 抱持点 `CameraRig/CarrySocket`(Marker3D,本地 (0,-0.8,-1.0))。物品位置要跟着它走。
- `CarryArms`(挂在 `Body/CarryArms`)是**表现层**:`process_priority = 100` 保证跑在 AnimationPlayer 之后,
在动画姿态上**叠加**手臂旋转(只动 `arm_l_*` / `arm_r_*`),走路时胳膊和抱持点一起晃。
**参数按「世界方向」语义**(`rest_basis.inverse() * world * rest_basis` 换算到骨骼本地空间)——
直接写骨骼本地欧拉角会得到「手往上后方摆」这种莫名其妙的结果,换模型必错。
默认 swing 40 / tuck 50 / roll 0 / elbow 15 / stretch 1.25 → 手落在模型本地 (±1.17, 0.08, 0.95)。
- 本地玩家第一人称只藏身体、**留下两条胳膊**(`_hide_body_keep_arms()` 按 `arm_` 前缀筛,找不到就整个藏掉)——
抱东西时得看得见自己的手。
- **联机只同步 `carrier_peer` 一个整数**(物品自己带 `MultiplayerSynchronizer`,配置 `world/items/carryable_sync.tres`)。
**抱着的时候位置根本不走同步**:每一端都从「持有者的抱持点」把物品摆出来(`Carryable._physics_process` →
`_find_carrier_socket()`,靠玩家节点名 = peer id、组 `net_player` 找到),结果天然一致,
也避免了「持有者写位置 / 同步又把位置写回来」互相打架。空着的时候才同步 position/rotation。
- **权威永远在服务器**(不切换):物品位置只由服务器模拟,其余端 `freeze = true` 跟着同步走;
抱起/放下都是客户端 `rpc_id(1, ...)` 请求服务器,服务器改 `carrier_peer` 就完事。
- 抱起是乐观认领 + 超时松手:`_pick_up()` 先把自己记成持有者,`pickup_confirm_time`(默认 0.6s)内
服务器没把 `carrier_peer` 改成自己就自动松手 —— 联机下被别人先抢走时会走到这一步。
- 测试 `tests/test_carry.gd`:`Carryable` / `CarryArms` 都标了 `@tool`,否则编辑器里是 placeholder、
方法调不动、`_ready` 不跑(组也加不上);改完脚本要 `filesystem_manage(op="scan")` 才重新解析。
玩家脚本不是 @tool,所以「第一人称留胳膊」那条测试查的是源码 + 模型里真有 `arm_*` mesh。
## Godot 4 踩坑
- **canvas 没有「擦除」混合模式**:`CanvasItemMaterial.BlendMode` 只有 mix/add/sub/mul/premult,
`CanvasItem.draw_set_blend_mode()` 在 4.x 已移除。橡皮只能自己管像素。
- `Image.set_data()` 在 4.7 签名变了(要 5 个参数);改用
`Image.create_from_data(w, h, mipmaps, format, data)`。
- **导出的节点引用解析不到**:`@export var mesh: MeshInstance3D` 在 .tscn 里存成 `NodePath`,
实测场景实例化时拿到的是 null。改成 `@export var mesh_path: NodePath` + `_ready()` 里 `get_node_or_null`。
- shader 里 `source_color` 只能用在 vec3 / vec4 / sampler 上,`vec2` 用了会编译失败。
- 给容器(VBoxContainer 等)设 `theme_override_styles/*` 会**向下继承**给子按钮,不用逐个设。
## Godot MCP 踩坑
- **看画面**:`Read` 工具读用户发的图片文件会报 `Current model does not support reading images`,
但 `editor_screenshot(source="game")` 返回的游戏画面能正常看 —— 排查画面问题直接自己截图对照,
比让用户描述快。没有画面时也可以用像素统计(PNG 手写解码 + 粗 ASCII 图)反推布局。
- **用户在编辑器里开着的文件会覆盖磁盘改动**:改完 `face_paint.gdshader` 后,磁盘上出现过
「我的新注释 + 旧的 `+normal.z` + 我从未写过的 `face_center.y = 0.6`」这种混合体 ——
用户在 Godot 里开着这个 shader、缓冲区是旧版本,他一保存就把我后来的改动冲掉了,而且没有任何提示。
所以改 `.gdshader` / `.tscn` 之后要在回复里提醒对方先关掉/重开那个文件再保存。
- **数值标量经 MCP 会被字符串化**:`node_set_property` 传 `0.15` 能写进去,但回显是 `"0.15"`;
而 `batch_execute` 里包 `{"command": "set_property", "params": {...}}` 直接报
`INTERNAL_ERROR … handler returned malformed result`(和旧版本文档里写的正好相反,以实测为准)。
改被编辑器占用的 `.tscn` / `.tres` 时别绕这个弯 —— 直接写文本更省事:`.tscn` 可写,
`.tres` 报 EBUSY 就改用 `filesystem_manage(op="write_text")`。
- **`node_get_properties` 读不到脚本的 @export 属性**(会列进 `unknown_fields`),但 `node_set_property` 能写。
改完场景文件要 `scene_open(force_reload=true)` 让编辑器重读盘,否则节点上还挂着旧脚本实例。
- **@tool 脚本现造的节点不会写进 .tscn**:`add_child` 后不给 `owner`,
`PackedScene::pack()` 就会跳过它。缆绳网格因此可以纯由锚点推导,不存在「文件和锚点对不上」的脏数据。
- **新 `class_name` 要跑一次 `filesystem_manage(op="scan")`** 才进全局类表,
否则别的脚本把它当类型引用会解析不到。
- `game_manage(op="suspend")` 期间**输入事件不送达**,`next_frame` 也拦不住物理帧;
想抓「运行中途某一瞬间」的画面不可靠,只能靠日志 / 属性断言验证。
- 测试跑在编辑器进程里,`@tool` 节点必须真的 `add_child` 进编辑中场景才会跑 `_ready()`;
用完在 `teardown()` 摘掉,节点名以 `_McpTest` 开头可被测试运行器兜底清理。
- **非 `@tool` 脚本在编辑器里是「placeholder instance」**:`load(path).can_instantiate()` 返回 false,
`.new()` 报 `Nonexistent function 'new' in base 'GDScript'`,`set_script` 后调方法报
`Attempt to call a method on a placeholder instance`(`player.gd` 也一样,不是脚本本身的问题)。
想在 `test_run` 里验非 @tool 脚本,**最省事的做法是直接给脚本加 `@tool`**(`Carryable` / `CarryArms` 就是这么办的,
运行时行为完全一样),其次才是临时生成一个 @tool 副本喂给测试。
加完要 `filesystem_manage(op="scan")` 一次,否则编辑器还在用旧的非 tool 版本,照样报 placeholder。
- **`test_run` 的 GDScript 编译缓存是按路径的**:删掉再重建同名脚本,跑到的可能还是旧版本
(现象:逻辑明明改了,测试却按旧逻辑失败)。要验新代码就换个文件名,或者重启编辑器。
- **编辑器进程的 `InputMap` 只有内建动作**(`ui_accept` 有,`interact` / `jump` 没有)——
项目动作要等游戏进程启动时才 load 进去。所以断言 `InputMap.has_action(...)` 的测试在编辑器里必然假红,
先调一次 `InputMap.load_from_project_settings()`。
- 改输入动作走 `input_map_manage(op="ensure_binding", params={action, event_type="key", keycode="Ctrl"})`,
幂等且直接落 `project.godot`,比手改文件稳。
-4
View File
@@ -1,4 +0,0 @@
pid=10416 name=Godot_v4.7.2-stable_mono_win64 title='' responding=True
pid=12060 name=Godot_v4.7.2-stable_mono_win64 title='main.tscn - agame - Godot Engine' responding=True
pid=26164 name=godot-ai title='' responding=True
pid=27664 name=godot-ai title='' responding=True
-43
View File
@@ -1,43 +0,0 @@
# 2026-09-21
## 接入 godot-ai MCP(已完成并验证)
- 把 Claude Desktop 的 `godot-ai` MCP 迁入 WorkBuddy 配置 `C:\Users\DHXY\.workbuddy\mcp.json`,与原有 `unityMCP` 并存。
- 启动方式:`uvx.exe` + `--isolated --no-config ... --from godot-ai==4.1.0 godot-ai attach --port 8001 --ws-port 8002`。
- 去掉了原配置中 `pythonw.exe` 包一层隐藏控制台的写法,直接走 uvx stdio。
- 环境核实:`uvx.exe` 存在;Godot v4.7.2 mono 运行中,8001/8002 均 LISTEN;`addons/godot_ai/plugin.cfg` 版本 4.1.0 与服务端 pin 版本一致。
- **已信任并实测通过**:`project_manage(op="settings_get", key="application/config/name")` → `agame`。
## 经验:MCP 信任入口位置
- 「自定义连接器 / 信任 MCP」不在侧边栏连接器列表里,而在 **「连接器」页顶栏 →「MCP 服务管理」弹窗 →「我的 MCP」列表**,每个服务右侧是启用开关(绿色圆点=已连接)。
- 相关 i18n key(从 app.asar 反查得到):`connectorPanel.customConnector`=自定义连接器、`unifiedMarket.customConnector`=自定义连接器(市场页顶部「+」下拉里)。
## 本机环境补充(重要)
- `.workbuddy/memory` 下的文件在本会话中被外部清理过一次,写入后需复核是否仍在。
- Bash 工具完全不可用(`mkdir`/`dirname` 都不存在),PowerShell 可用但需把输出写文件再 Read;Glob/Grep 对 `.workbuddy` 这类点目录不可靠,用 Read 直接读或 PowerShell 列目录。
- PowerShell 里 `Add-Type` 被安全策略拦截,无法用 .NET API 激活窗口。
## 新建:第三人称控制器(胶囊体占位)
- 文件:`res://player/player.gd`、`res://player/player.tscn`、`res://world/main.tscn`(测试场地,已设为 main scene)。
- 结构:Player(CharacterBody3D,不旋转) / Collision + Body(Node3D,视觉层朝移动方向转) / Mesh + CameraRig / SpringArm / Camera。
- **关键教训**:相机臂不要挂在会旋转的本体下,否则按方向键会产生「相机跟着转」的螺旋轨迹(实测偏航 90°、直行无横向位移)。
- 输入动作(已写入 project.godot):move_forward/back/left/right(WASD+方向键)、jump(Space)、sprint(Shift)。
- 参数:走 5.0 / 跑 8.5 m/s,加速度 45(地面)/12(空中),跳跃初速 5.0(实测最高点 1.318 m)。
## 本机/工程改造记录
- `autoload/steamworks.gd`:Steam 初始化失败时原来直接 `get_tree().quit()`,导致没开 Steam 客户端时游戏秒退;已加 `OS.has_feature("editor")` 豁免(编辑器内返回不退出,发布版行为不变)。
## godot-ai MCP 使用要点(踩坑记录)
1. **路径是相对「当前编辑场景根」**,且要带根节点名,如 `/Player/CameraRig/SpringArm`;`parent_path: ""` 表示当前场景根。切场景后所有路径的含义会变,写操作前先确认 `editor_state.current_scene`。
2. **`is_action_just_pressed()` 在 `_physics_process` 里对调试器注入的输入无效**(注入发生在渲染帧,物理帧号已 +1)。要么用 `_unhandled_input` 收事件 + 输入缓冲,要么只能读 `is_action_pressed()`。
3. `game_eval` 里 `var x := Engine.get_main_loop().current_scene` 会编译不过(类型推断),要 `var tree := Engine.get_main_loop() as SceneTree` 并且给节点变量显式类型。
4. `game_eval` 里的语法错误会把游戏卡进调试器断点(`game_status.status == "break"`),此后工具全部失效,必须 `project_manage(op="stop")` 后重启。
5. 布尔属性写入会报 `WRONG_TYPE: Cannot write String to a bool property`(`Camera3D.current` 试过),改用其他方式或接受默认行为(只有一台相机时 Godot 会自动启用)。
6. `SpringArm3D` 只在运行时摆放子节点;编辑器里预览要在场景里把相机 position.z 写成 spring_length 的值(已写入 player.tscn = 4.0)。
7. 验证玩法用 `game_manage(op="input_sequence")`;涉及 `just_pressed` 的场景改用 `game_eval` + `Input.parse_input_event` + `await tree.physics_frame` 循环采样。
+125
View File
@@ -0,0 +1,125 @@
# 2026-09-22
## 给 Godot 项目 agame 接上局域网联机(ENet 基础版)
全流程通过 `godot-ai` MCP 操作编辑器完成,`E:\GD_Projects\agame` 项目。
### 落地结果
- 新增 `autoload/net.gd`(Autoload 名 `Net`):传输层 + 开局/加入/断开 + 玩家生成回收 + 主菜单/HUD(代码建 UI,无 .tscn)。
- 新增 `world/main.gd`:把 `player.tscn` 注册给 `MultiplayerSpawner.spawn_function`,`spawn_function` 里 `set_multiplayer_authority(peer_id, true)` + `add_to_group("net_player")`。
- 新增 `player/player_sync.tres`(`SceneReplicationConfig`),同步字段:`.:position`、`Body:rotation`,`replication_mode = 1`(ALWAYS)。
- `player/player.tscn`:根下加 `Sync`(`MultiplayerSynchronizer`,`root_path` 保持默认 `..`),绑定上面那个 .tres。
- `world/main.tscn`:删掉硬编码的 Player 实例,改挂 `MultiplayerSpawner`(`spawn_path = ..`)。
- `player/player.gd`:加 `_is_local`;远端玩家 `camera_rig.queue_free()` + 关物理 + 关 unhandled input。
- 新增 `tests/test_net_replication.gd`(`McpTestSuite`),3 个用例 12 条断言全绿,钉住同步配置不被静默写坏。
### 关键结论 / 踩坑
- **节点 authority 不继承**:必须 `set_multiplayer_authority(id, true)`,否则子节点 `MultiplayerSynchronizer` 仍是服务器 authority,同步不发。
- **远端玩家必须拆相机并关物理**,否则多台 Camera3D 抢渲染、双重模拟导致抖动。
- 去中心化 authority 模型跑通(host=1 本地操控 / client 远端回放,client 侧镜像)+ ENet 中继正常。
- 命令行调试入口:`Net._apply_cmdline()` 支持 `-- --host` / `-- --join <ip>`。
- ~~项目已存在的无关报错:`res://addons/ziva_agent/ziva_agent.gdextension` 文件缺失,每次启动都刷 3 行 ERROR。~~ **已修复**:
插件目录已删除,但 `.godot/extension_list.cfg` 里残留了一行 `res://addons/ziva_agent/ziva_agent.gdextension`。
`filesystem_manage(op="scan")` 不会清掉它(Godot 只在运行中观测到 .gdextension 增删时才重写该文件),
直接删掉那一行即可。已用 headless 启动验证:3 行 ERROR 消失。
- AppID 仍是 480(Spacewar 测试,Steam 客户端未装 → `status:1`),换 Steam P2P 时只需改 `Net.host_game/join_game`。
### 输出文档
- 新增 `docs/联机框架使用指南.md`:面向零联机基础的人,含概念(peer/authority/复制 vs RPC)、文件地图、
三种跑法、四个「新功能怎么同步」配方、Steam P2P 迁移(直连 SteamID 版 + 大厅版)、调试与已知限制。
- **Steam API 签名已用探针脚本让 GDScript 分析器逐个校验**(本项目装的是 camelCase 风格 GodotSteam):
- `Steam.createLobby(type, max_members)` **返回 void**,结果走 `lobby_created(connect_result, lobby_id)` 信号
- `Steam.joinLobby(lobby_id)` → `lobby_joined(lobby_id, permissions, locked, response)` 信号
- `Steam.requestLobbyList()` → `lobby_match_list(lobbies: Array)` 信号;成功码 = 1
- `Steam.getSteamID()`;`setLobbyData/getLobbyData/getLobbyOwner/getNumLobbyMembers/getLobbyMemberByIndex`
- `SteamMultiplayerPeer`:`create_host(port=0)`、`create_client(steam_id, port=0)`、`host_with_lobby(lobby_id)`、
`connect_to_lobby(lobby_id)`、`add_peer(steam_id, port=0)`、`set_no_delay/set_no_nagle/set_debug_level`
- 校验手法(可复用):写临时 .gd 调用候选 API,`script_create` 返回的 diagnostics 会指出「方法不存在」「返回 void 不能赋值」等,比查文档更快更准。
- 新增 `docs/联机实战教程-由浅入深.md`:九关递进教程(血量同步 → 颜色认领 → 头顶名牌 → 挥手 RPC → 两段式攻击 →
子弹 Spawner → 服务器权威血量 → 远端插值 → 玩家列表)。按键约定 H/G/K/J。
- 教程里引入的两个工程改动(后续实现时要跟着做):`player.gd` 加 `class_name PlayerController`、
`main.gd` 加 `class_name GameWorld`、`bullet.gd` 加 `class_name Bullet`、`bullets.gd` 加 `class_name BulletPool`,
这样跨脚本调用才有编译期检查。
- **写教程时抓到一个真 bug**:`parent.broadcast_hit(...)` 是本地调用,伤害传不到对方机器,必须
`world.broadcast_hit.rpc(...)`。GDScript 对漏 `.rpc()` 不报任何错,这是最阴的一类联机 bug。
- **第二个发现**:`@rpc("authority")` 的检查是「发起方 peer id == 被调节点 authority」,所以服务器不能
调用别人玩家节点上的 authority 模式 RPC。广播函数必须放在 authority 为 1 的节点上(世界根节点 Main 天然满足)。
- 教程全部代码用「拼成一个文件做整体编译」的方式验证过:`diagnostics: []` 零错误。
GDScript 对动态成员访问极其宽容(`Node` 上调脚本方法、`Node3D.new()` 设脚本属性都不报错),
所以「编译通过」不等于「语义正确」,逻辑对错还得靠人读。
### UI 改为节点配置(用户要求:不要代码生成 UI)
- 新增 `autoload/net.tscn`(根 `Net` 是 CanvasLayer,layer=20):
`Menu`(CenterContainer→PanelContainer→VBox: Title/AddressEdit/HostButton/JoinButton/StatusLabel/HintLabel)
+ `Hud`(Control→VBox: StatusLabel/HintLabel)。两个按钮的 `pressed` 在 .tscn 里连接到 `Net`。
- `autoload/net.gd` 重写:`extends CanvasLayer`,删掉全部 `_build_*`,改为 `@onready` 节点引用 + 状态更新
(`_on_host_button_pressed` / `_on_join_button_pressed` 由场景信号触发)。
- **`project.godot` 的 autoload 改成指向场景**:`Net="*res://autoload/net.tscn"`(原来指向 net.gd)。
这样 `Net` 这个全局名就是那个 CanvasLayer 根节点,`/root/Net` 路径不变,`main.gd`/`player.gd` 无需改动。
- 建场景的做法:`scene_manage(create)` → `batch_execute`(13 个 create_node) → `batch_execute`(29 个 set_property,`undo=false`)
→ `signal_manage(connect)` ×2 → `script_attach` → `scene_save`。Godot 自己序列化,比手写 .tscn 可靠。
- 验证:双实例 headless 联调通过(主机/客户端各自一个「本地操控」,日志镜像);编辑器内运行零报错。
- 文档同步:`联机框架使用指南.md` 文件地图;`联机实战教程-由浅入深.md` 关卡 3 / 关卡 9 改成「在编辑器加节点」写法。
### 玩家的 UI 也改成节点配置(同一轮完成)
- `player.tscn` 新增 `Hud (CanvasLayer)` → `HealthLabel (Label)`(位置 16,56)。
- `player.gd`:删掉 `_make_health_label()`(原来用代码建 CanvasLayer + Label),
改为 `@onready var _hud: CanvasLayer = $Hud` / `@onready var _health_label: Label = $Hud/HealthLabel`;
远端分支加 `_hud.visible = false`(否则 N 份血条叠在同一屏幕坐标,显示的是别人的血量);
本地分支末尾补 `_refresh_health_label()`(`@onready` 赋值晚于同步写入,那次 setter 会被 null 判断跳过)。
- `_report_identity()` 详细自检已恢复。
- 确认 `player_sync.tres` 现在有 3 个字段:`.:position`、`Body:rotation`、`.:health`(用户自己加的,正确)。
- 文档同步:`联机实战教程-由浅入深.md` 关卡 1 改成「先在 player.tscn 里加 Hud/HealthLabel 节点」;
`联机框架使用指南.md` 的 player.tscn 结构图。
### 血条改到玩家头顶(Label3D)
- `player.tscn`:删掉 `Hud (CanvasLayer)`,改为 `HealthLabel (Label3D)` 直接挂在 `Player` 下,
position `(0, 2.05, 0)`、`billboard=1`、`no_depth_test=true`、`font_size=64`、`pixel_size=0.005`(默认值)、`outline_size=8`。
- `player.gd`:`@onready var _health_label: Label3D = $HealthLabel`,删掉 `_hud` 引用与 `_hud.visible = false`。
- **顺带消掉一个坑**:用 `Label3D` 后,血条天然待在各自头顶,不再需要「远端玩家隐藏血条」那一步
(屏幕坐标的 CanvasLayer 才需要,否则 N 份叠在同一屏幕位置)。
现在默认人人可见别人的血量 —— 因为 health 是同步字段。
- 尺寸换算:`Label3D` 的文字世界高度 ≈ `font_size × pixel_size`。64 × 0.005 ≈ 0.32 米。
- 若要与关卡 3 的头顶名牌共存:血量放 `y=2.05`,名牌放 `y=2.3`,不重叠。
- 验证:双实例 headless 联调,两个日志里 `ERROR|Invalid|null` 一条都没有。
### 反复出现的坑:编辑器把外部写盘的内容覆盖回去
`net.gd` 用 MCP `script_create` 写成功后,磁盘上又变回了旧版(`extends Node` + `_build_ui` 全在)。
原因:编辑器的**脚本编辑器标签页里缓存着旧文本**,资源 reload ≠ 标签页文本刷新,
之后一次保存就把旧文本写回去了。而且工具返回的 `reloaded: true` 会让人误以为已经生效。
**规避办法**:
1. 每次写完立刻从磁盘回读校验(Grep/Read 关键行),别信工具的 `reloaded`。
2. 改 Godot 项目里的脚本/场景一律走 godot-ai MCP,不要用外部写盘。
3. 交付时提醒用户:如果编辑器标签页里还是旧内容,关掉标签页重开(或重启编辑器),
并且在确认内容更新之前不要按 Ctrl+S。
`net.gd` 一共被还原了**两次**(第二次在 17:41~17:43 之间,导致 `git status` 里
`autoload/net.gd` 一度没有差异)。第二次修复后连续观察 30 秒无变化才确认落地。
排查时用:读文件首行 + 查 `_build_ui` + 看 size/mtime,隔一段时间对比。
### 仓库状态(供用户查证)
- `.gitignore` 只忽略 `.godot/` 和 `/android/`,业务文件不会被忽略。
- 最后一个提交是 `2ecab99`(17:32「扣血同步」,用户的关卡 1);此后所有改动**都还没提交**,
且 `origin/master...master` = `0 0`,即**一个都没推送**。
- 未提交清单:`autoload/net.gd`(-121 行)、`player/player.gd`、`player/player.tscn`、
`project.godot`、两份 docs、`.workbuddy/memory/...`;**`autoload/net.tscn` 是 untracked(新文件)**。
- 用户说「git 上看不到差异」,很可能是因为:① 看的是远端网页(没推送);
② GUI 客户端默认不显示 untracked 文件,而 `autoload/net.tscn` 正是新文件。
### godot-ai MCP 使用注意(重要)
- **autoload 的全局标识符在「正在运行的编辑器会话」里不可见**:新加的 autoload,脚本里直接写 `Net.xxx` 会报 `Identifier not found: Net`。改用 `get_node_or_null("/root/Net")`。
- Godot 在 Windows 上**不往重定向的 stdout 写日志**(`_console.exe` 更糟,它自己开控制台)。抓输出必须用引擎参数 `--log-file <path>`。
- `game_manage.get_ui_elements` / `get_scene_tree` 看不到 `/root` 下的 autoload 节点,只认当前场景树。
- 验证资源是否真被解析:写 `res://tests/test_*.gd`(继承 `McpTestSuite`,方法名 `test_*`,用 `assert_true/assert_eq/assert_gt`),再 `test_run`。
+244
View File
@@ -0,0 +1,244 @@
# 2026-09-23
## 玩家动画(KayKit character-male-e 动作库)
用户把玩家模型换成 `character/player/assets/character-male-e.glb`,并在
`Player/Body/character-male-e2/` 下加了 `AnimationPlayer`(动作库内嵌在 player.tscn 里,
共 32 个片段,11 条 track,root_node = `..`)。
### 关键结构事实(别猜,实测)
- track 路径形如 `character-male-e/Skeleton3D:root`,比玩家本体多一层
`character-male-e/` 前缀 —— 换模型/改层级会让整组 track 静默失效。
- glb 里所有片段的 `loop_mode` 全是 `NONE`,`idle/walk/sprint` 必须在运行时补
`Animation.LOOP_LINEAR`,否则播一遍就定在最后一帧。
- 片段长度:idle 1.333s / walk 0.667s / sprint 0.5s / jump 0.5s / fall 0.333s。
jump 时长恰好约等于 `jump_velocity=5 / gravity=9.8` 的上升时间。
### 实现(`character/player/player.gd`)
- `enum AnimState { IDLE, WALK, SPRINT, JUMP, FALL }` + `ANIM_CLIPS` / `ANIM_LOOPED` 常量。
- `_prepare_animation()` 在 `_ready` 里、**本地/远端分支之前**调用(两端都要跑)。
- `_update_animation()` 在 `_physics_process` 末尾调用,只读 `velocity` / `is_on_floor()`。
- 两个被同步的新属性:
- `anim_state: int` —— setter 里切片段(`play(name, anim_blend_time)`),
状态不变时 setter 不触发,所以 `_prepare_animation` 里必须显式 `play("idle")`。
- `anim_speed: float` —— 播放倍率 = `水平速度 / 该状态设计速度`(walk→walk_speed,
sprint→sprint_speed),clamp 到 `[0.65, 1.6]`。远端只有 position 算不出速度,必须复制。
- 远端玩家物理与输入都关着,动画完全靠复制过来的 `anim_state` / `anim_speed` 回放。
### 同步配置
`character/player/player_sync.tres` 从 3 项扩到 5 项,新增
`.:anim_state` 与 `.:anim_speed`,`replication_mode` 全部保持 1 (ALWAYS)。
## 踩到的坑(可复用)
1. **编辑器进程里 `.tres` 有 ResourceCache。** 用 MCP `filesystem_manage(op="write_text")`
改完 `player_sync.tres` 后,`filesystem_manage(op="scan")` 也刷新不掉缓存 ——
`test_run` 里的 `load()` 仍读到旧的内存副本(只认 3 个字段)。
→ 测试里改用 `ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_REPLACE)`;
它同时把正确的实例灌回缓存,之后再 `node_set_property` 赋值就拿到新实例了。
2. **`Animation` 的 track 路径含 `:骨骼名`,不能直接 `get_node(track_path)`**
(get_node 只认节点段)。要用
`root.get_node(NodePath(track_path.get_concatenated_names()))`,
骨骼名单独用 `get_concatenated_subnames()` + `Skeleton3D.find_bone()` 校验。
3. **`game_eval` 的真实签名是 `{"op": "game_eval", "params": {"code": "..."}}`**,
不是顶层 `code`。它支持多语句 + 内部 `await`,所以能在编辑器跑着的游戏里
直接 `net.host_game()` → 等物理帧 → 驱动状态机 → 读回结果,是最强的端到端验证手段。
4. 编辑器报告的 `play_state/is_playing=playing` 可能是陈旧的(`project_manage(op="stop")`
回「was_running: false」即证)。
## 顺带修的东西
- `tests/test_net_replication.gd` 之前就坏了:路径还写着 `res://player/`(项目已迁到
`res://character/player/`),且断言同步字段数为 2(实际 3)。已修正路径、改为断言
5 个必需字段,并新增「片段存在」「5 个片段的每条 track 路径都能解析 + 骨骼名存在」两类测试。
现在 5 个测试全过(143 条断言)。
## 未处理的既有问题
`res://addons/srcoder_thirdperson_controller/assets/mouse_lock.gd` 与 `res://mouse_lock.tscn`
缺失,编辑器日志持续刷 `File not found`(约 60 条)。与本任务无关,未动。
## 模型朝向修正(按 W 却面朝相机)
**原因**:`player.gd` 的约定是「`body.rotation.y = 0` 即朝 -Z」(按 W 时
`wish=(0,0,-1)` → `target_yaw = atan2(-wish.x,-wish.z) = 0`)。
但 KayKit 的 `character-male-e.glb` 模型正面朝 **+Z**,
场景里 `Body / character-male-e2 / character-male-e` 三个节点的 rotation 全是 0,
没有做任何补偿 → 视觉上倒着走。
**修法**:给模型包装节点 `Body/character-male-e2` 加 `rotation.y = PI`(已 `scene_save`,
磁盘上是 `transform = Transform3D(-2.5, 0, -2.19e-7, 0, 2.5, 0, 2.19e-7, 0, -2.5, 0, 0, 0)`)。
**为什么不是改代码**:
- 往场景里给 `Body` 加 180° 没用 —— `body.rotation.y` 每帧被 `lerp_angle` 覆写。
- 往 `target_yaw` 上加 PI 会污染 `Body` 的语义(`Body:rotation` 是要同步的「世界朝向」),
而且静止时的初始朝向还得额外补一笔。
- 加在模型根节点上:代码里的 -Z 约定保持干净,静止与移动两种状态同时正确。
- 动画 track 是 `character-male-e/Skeleton3D:骨骼` 的相对路径,旋转包装节点不会破坏它们。
**验证**(`game_eval`,实测通过):`Input.action_press("move_forward")` 等 45 物理帧,
用左右手臂骨算世界空间的 model forward(`Vector3.UP.cross(右轴)`),结果:
- `move_dir` = (0, -1)、`model_fwd` = (0.05, -1.0) → `dot = 0.999`
- 与相机前向 `dot = 0.999` → 看到的是角色背面 ✅
- `body_rot_deg = 0`、位移 3.51 m(0.75s × 5 m/s)✅
## 重构:动画状态机抽成 PlayerAnimator(与移动解耦)
用户先问「动画状态机和玩家代码耦合吗」。结论:是**结构性耦合**(5 个点),但只有**单向**
—— `_update_animation()` 只读 `velocity`/`is_on_floor()`/`Input`,从不回写物理量。
真实代价只有一条:这套状态机没法给 NPC 复用。用户选择抽成独立组件。
**新结构**(`character/player/player.tscn`):
Player (CharacterBody3D, player.gd) ← 输入 / 物理 / 相机 / 血条 / 联机分叉
└─ Body ← 视觉层,换模型整棵替换
├─ PlayerAnimator (Node, player_animator.gd) ← 动作状态机
└─ character-male-e2 / character-male-e / Skeleton3D + AnimationPlayer
**接口只有一个**:`animator.set_motion(velocity, is_on_floor(), sprinting)`,在
`move_and_slide()` 之后调用。控制器不认识任何片段名,动画器不认识输入。
动画准备(补 loop、播 idle)搬到 `PlayerAnimator._ready()` —— 子节点先于父节点 ready,
所以 player.gd 的 `_ready()` 里不再需要任何动画代码。
**关键设计决定:片段基准速度属于片段,不属于控制器。**
`PlayerAnimator.walk_ref_speed = 5.0` / `sprint_ref_speed = 8.5` 语义是「这段动画在多少
米/秒下按原速播」。控制器把 `walk_speed` 调到 6,动画自然播成 1.2 倍,两边不用同步改 ——
这才是真正解掉了耦合,而不是把常量搬个家。
**同步路径变了**:`Body/PlayerAnimator:anim_state` 与 `Body/PlayerAnimator:anim_speed`
(`Sync.root_path` 仍是 `..` 即 Player)。`anim_state` / `anim_speed` 现在是
PlayerAnimator 的成员,player.gd 里已无这两个属性(测试里有一条专门守住这个边界)。
**`animation_player` 接线**:声明成 `@export var animation_player: AnimationPlayer`,
场景里留空(原因见下),`_ready()` 用
`get_parent().find_child("AnimationPlayer", true, false)` 自动找。实机确认能解析。
**player.gd 306 → 206 行**;测试 5 → 7 条 / 154 断言,全过。
**实机验证**(game_eval,跑着的游戏里):
- `animator_node = /root/Main/1/Body/PlayerAnimator`,`auto_find_resolved = true`
- loop 模式 `[idle, walk, sprint, jump] = [LINEAR, LINEAR, LINEAR, NONE]`
- idle / walk@2.5(0.65) / walk@5(1.0) / sprint@8.5(1.0) / jump / fall 全部正确
- `controller_drives_animator = [WALK, "walk", 1.0]` —— 控制器→动画器链路真的在跑
## 本轮新增的 MCP 坑
1. **`node_set_property` 设不了 Node 类型的属性。** 传
`/Player/Body/character-male-e2/AnimationPlayer` 会报
`VALUE_OUT_OF_RANGE: value: Path must start with res://, uid://, or user://`
—— 该工具只按「字符串=资源路径」加载。所以 `@export var x: AnimationPlayer`
在场景里只能留空、靠代码兜底(或改用 `NodePath` 类型 export,那个能收字符串)。
2. **`project_run` 会把编辑器当前场景切成主场景。** 跑完游戏再 `node_create` 会报
`NODE_NOT_FOUND: Parent not found: /Player/Body. Scene root is "/Main"`。
写场景节点前先 `scene_open("res://character/player/player.tscn")`。
3. **删掉某个脚本里的 enum/常量后,所有引用处会立刻刷一屏 parse error。**
分步 patch 时这是正常的中间态,把引用清完 diagnostics 自动归零 —— 别当成自己写错了。
4. **中文字符串用 `\uXXXX` 转义极易打错字**(本轮把「抽搐」打成「抽搝」、「照抄」打成
「照拄」,都要事后 grep 才发现)。直接传字面中文更稳。
## 海洋效果场景(孤岛环海 · 纯视觉)
新增 `world/ocean/`:
| 文件 | 作用 |
|---|---|
| `water.gdshader` | 卡通海面:底色 / 菲涅尔 / 波光 / 泡沫 + 地平线雾霭 |
| `island.gdshader` | 卡通小岛:径向剖面 + 噪声海岸线,按高度与坡度上色 |
| `water_far_material.tres` | 远平面材质(同一个 shader,`swell_amount = 0`) |
| `ocean_demo.gd` | 驱动脚本:推 world_time / 太阳方向 / 相机环绕 / 近平面跟随 |
| `ocean_demo.tscn` | 效果场景,**F6 直接跑**(会把 Net 的联机菜单整层藏掉) |
## 关键设计(都是被逼出来的)
- **两层水面:近处细平面(520×520 / 208 细分 / 跟随相机)+ 远处大平面(6000×6000 / 12 细分 / 纯平)。**
均匀网格做不了「近处要密」和「覆盖到地平线」这两件事。而天空在地平线以下是
一片深色半球,**有限大的水面与真地平线之间必然露出深色带**(带宽 ≈ atan(相机高 / 平面半宽))。
两层结构把带宽压到 0.25° 以内。近平面网格对齐到顶点间距(2.5m),否则会抖。
- **波形用世界坐标锚定**(vertex 里先 `MODEL_MATRIX * VERTEX` 取世界 xz 再算高度)。
这样近平面跟着相机平移时,海浪不会「跟着滑」。
- **位移只用 3 组低频正弦(幅度 0.38m),细纹 + 噪点只动法线、不给位移。**
所以顶点密度只影响大浪的圆滑度,细节全在法线里 —— 这是让粗网格也能看的关键。
- `world_time` 由脚本推进,不用内置 `TIME`:联机两端启动时刻不同,同一时刻的浪不是同一片。
脚本里用「绝对时钟差」而不是逐帧累加 delta(帧率不同会越走越偏)。
- 岸线泡沫靠 `DEPTH_TEXTURE` 拿真实水深(水是半透明的,走透明队列、在不透明物之后绘制)。
## 踩的四个坑
1. **`TAU` / `PI` / `E` 是着色器语言的内置常量。** 自己 `const float TAU` 直接编译失败。
更坑的是:**编译失败的外观是「没着色器的一团白」**,看起来像参数没调好,会往错的方向查。
2. **只有正弦、没有噪声的法线 → 太阳波光变成一张规则网格。**
高光落在「法线刚好平分视线与阳光」的位置,周期性法线场的高光自然是周期性的。
3. **打散它的噪声必须用梯度噪声,不能用值噪声。** 值噪声当「高度」用看不出来,
但拿来求导当法线就是一块块方格。(卡通天空挑 Perlin 也是这个理由,直接沿用。)
4. **片元阶段的 `VERTEX` 是视图空间,不是世界空间。** 小岛 shader 一开始写
`(MODEL_MATRIX * vec4(VERTEX, 1.0)).y` 当海拔用,算出来其实是相机相对高度 ——
整座岛被染成一大块沙滩,而且「看上去好像也对」。改成 varying 从顶点传海拔。
另外:两组正弦方向**接近平行**时会相消,把整层退化成 1D 驻波(表现为规则条纹)。
方向要显式排开、夹角 90° 以上,别拿「看起来不一样」的坐标去拼。
## 调参结论(实测)
- 浪尖泡沫的分母必须是**三组振幅之和**;拿 `wave_height` 当分母永远溢出到 1,泡沫铺满海面。
- 岸线泡沫 `shore_amount` 别超过 ~0.7,否则是一圈白色描边(像贴纸不像浪)。
- `band_count` = 3 是「有色块感」与「不变斑马线」的平衡点。
- 细纹和噪点必须**按距离淡出**(`ripple_fade_distance`),不然远处满屏闪烁。
## MCP 相关
- 编辑器在 play 模式下**拒绝一切写操作**(`EDITOR_NOT_READY`)→ 改文件前先
`project_manage(op="stop")`。
- `.gdshader` 只能用 `filesystem_manage(op="write_text")` 写,**没有 patch 能力**,
每次改都要重写整个文件。
- `node_set_property` **设不了 `shader_parameter/*`**(报 `PROPERTY_NOT_ON_CLASS`,
这些是虚拟属性)。要么改 shader 里的默认值,要么写一个 `.tres` 引用它。
- 但 `node_set_property` **支持内嵌资源 + 嵌套资源路径**:
`{"__class__": "Environment", "background_mode": 2, "sky": "res://..."}` 是可行的。
- 最强的验证手段是 `editor_screenshot(source="game")` —— 把运行中的画面截回来直接看,
比自己推断「应该没问题」可靠得多。三个真 bug 全是靠它发现的。
## 既有问题(未动)
`res://addons/ocean3d_lite/` 是坏的:`ocean_surface.gd` / `floating_body.gd` 都报
`Identifier "Ocean" not declared`。看起来是以前试过的第三方海洋插件,与本次无关。
## 海洋 · 补充(回答「海是不是静态的」时顺手修的)
**海是动的**,只是编辑器预览里曾经完全冻结:
- 实测运行时 `world_time` 1.5 秒走 1.502(`time_scale = 1.0`),波形确实在推进。
- 但 `_ready()` / `_process()` 在编辑器里不跑 → 没人推 `world_time` → **未运行时海是死帧**。
这就是「看着像静态」的原因。
- 修法:`world_time` 默认值改成 `-1`,加 `wave_time()`:`world_time >= 0 ? world_time : TIME`。
编辑器用 TIME(自动动),运行时脚本一接管就换成同步时钟。
**比加 `@tool` 安全** —— `@tool` 会让 `_process` 在编辑器里跑,脚本一写相机 transform
就不断标脏场景、还会挪动编辑器里的摆位。
- 同理把静态相机取景(`position` + `rotation = (-0.235, 0.96, 0)`)烘进场景,
否则编辑器预览里相机朝向是 identity,看着是一片空海。
**新发现的 Godot 行为:`ShaderMaterial.get_shader_parameter()` 对没显式改过的参数返回 `null`。**
Godot 不会把 shader 的 uniform 默认值灌进材质实例 —— 所以别用它读默认值。
反过来也证明「只改 shader 默认值就能生效」。
**编辑器视口会持续重绘**(连拍两张 `editor_screenshot(source="cinematic")`,波形和云都位移了),
所以能用截两张对比的办法验证「编辑器里动没动」。
海水运动速度(按默认值算):大浪相位速度 ≈ 6.7 m/s(波长 42 m),
细纹漂移 ≈ 0.5 m/s,噪点漂移 ≈ 0.49 m/s。大浪看得清,细碎波光是缓慢流动。
## 海洋应用到主场景(world/main.tscn)
- `world/ocean/sea.tscn` + `sea.gd`:可复用海面(Water 近平面 300/120 格 + WaterFar 6000/12 格 + water_far_material.tres),实例化即用、零接线。sea.gd 自动收集带 `world_time` uniform 的材质、自动找 `Sun` 节点、近平面按 2.5 m 步长跟随 viewport 相机。
- `world/ocean/island_mesa.tres`:台地岛材质(island.gdshader 新增 `plateau` uniform,平顶半径占比;默认 0 = 原锥形岛,向后兼容)。
- `island.gdshader`:新增 plateau;主场景取值 radius 40 / plateau 0.72 / height 1.5 / dip 14 / detail 0.25。
- `main.tscn` 重写:Ground 碰撞盒 40→**58**(与平顶 r≈28.8 对齐),**删除 GroundMesh**(灰色平板,会和岛平顶 z-fight),新增 `Island`(y=-1.5)与 `Sea` 实例(y=-1.5)。
- **关键坐标系**:海平面节点摆在 y=-1.5,岛 `island_height=1.5` → 平顶世界 y=0 = 原地面高度。玩家/碰撞/联机逻辑零改动,实测 player_y=0、on_floor=true。
- **修了一个真 bug(两个场景都有)**:「指向太阳」是平行光节点的 **+Z** 不是 -Z。之前 sea/ocean_demo 传的是 -basis.z(朝下),波光整片消失且不报错。实测 main 的 Sun basis.z y=+0.766 才是对的。
- 验证:实机 host_game,world_time 1.2 s 推进 1.201,近平面跟随相机 (0,3.86)→snap(0,5),玩家传送 (27.5,1,0) 仍站在平顶 y≈0,岸线沙带/泡沫/海天交界截图正常,日志无错误。
+179
View File
@@ -0,0 +1,179 @@
# 2026-10-09
## 简易准心(Crosshair)
- 新增 `ui/crosshair.gd`:`@tool extends Control`,用 `_draw()` 画十字(gap/length/thickness/dot 可导出调参),`mouse_filter = IGNORE`,监听 `NOTIFICATION_RESIZED` 重绘。
- 挂载约定:`CanvasLayer > Crosshair(Control)`,Control 锚点为 Full Rect,中心即屏幕中心;推荐挂在 `CameraRig` 之下 —— 因为 `player.gd _ready()` 对非 authority 会 `camera_rig.queue_free()`,远端玩家准心随之一并释放,无需额外代码。
- 项目 stretch 模式为 `canvas_items` + `expand`,CanvasLayer 会自动跟随缩放。
### 后续改动:只保留「点」
- 最终形态:准心 = 屏幕中心一个点,无十字、无样式开关(`enum Style` 已移除)。
- 导出参数:`dot_color` / `dot_radius`(默认 2.5) / `outline` / `outline_color` / `outline_width`。
- `_draw()` 先画描边圆再画主体圆,保证浅背景可见。
## player.gd 拆解方案(待执行)
- 现状:`character/player/player.gd` 155 行,混了视角 / 移动跳跃 / 拾取 / 多人权限四类职责。
- 方案:**子节点组件化**(与既有 `PlayerAnimator` 同模式)。根节点降为协调器(~40 行),新增 `Look` / `Motor` / `Pickup` 三个 Node 子节点,各自挂 `player_look.gd` / `player_motor.gd` / `player_pickup.gd`。
- 三条硬约束:① `move_and_slide()` 留在根节点,Motor 只写 `body.velocity`;② 物理顺序由根节点显式编排(motor.step → move_and_slide → animator → pickup),不让子节点各写 `_physics_process`;③ 非 authority 时三个组件的输入/物理必须一并关闭。
- 范式定位:组合优于继承 + 组件模式(Component Pattern);协调器部分带 Facade/Mediator 色彩;`setup()` 注入属手工依赖注入。**不是 ECS**(Godot 节点是「带行为的对象」,非纯数据组件)。
- 状态:**已落地**(2026-10-09)。新增 `player_look.gd` / `player_motor.gd` / `player_pickup.gd`(均带 `class_name`:PlayerLook / PlayerMotor / PlayerPickup);`player.gd` 重写为协调器(~65 行);`player.tscn` 追加 Look / Motor / Pickup 三个 Node 子节点(挂 FaceRig 之后,ext_resource id 13_look / 14_motor / 15_pickup,未写 uid 由 Godot 补)。
- 场景格式注意:本项目的 .tscn 节点带非标准 `unique_id=`(Godot 4.7 特性),`unique_id` 与 `load_steps` 均可省略;`paint_panel.tscn` 就完全没写,可作参照。
- 已确认无外部脚本引用被搬走的属性(仅 `world/main.tscn` 通过场景 uid 引用玩家场景)。
- 顺手去掉了原 `_pickObject` 里的两行 `print()` 调试输出。
### 拾取改为长按拖拽
- `player_pickup.gd`:由「按一下切换」改为「按下抓起、松开放下」——`is_action_pressed("carry")` → `_grab()`,`is_action_released("carry")` → `_drop()`。
- `carry` 动作确认绑定鼠标左键(`button_index: 1`);项目里另有未被任何脚本使用的 `throw_carry`(绑 Q 键),可作投掷功能的预留。
- 加了 `Input.mouse_mode != MOUSE_MODE_CAPTURED` 早退,并在 `player.gd` 重新锁定鼠标那一支补 `get_viewport().set_input_as_handled()`,避免「点一下重新锁定鼠标」顺带抓起物体(两种分支执行顺序下都成立)。
- `physics_step()` 与 `has_object()` 改用 `is_instance_valid()` —— 防止拖拽途中物体被 destroy 时取属性报错。
- `_drop()` 保留线速度(松手有惯性 / 可甩出);想要「就地放下」需补 `set_linear_velocity(Vector3.ZERO)`。
### 按 Q 投掷
- `player_pickup.gd` 接入 `throw_carry`(Q 键)→ `_throw()`:取 `-_hold_pos.global_transform.basis.z` 作投掷方向(持握点挂在摄像机下且仅有平移,其 basis 即视线朝向,故无需再注入摄像机),`set_linear_velocity(dir * throw_speed)` 后置 `_current = null`。
- 新增导出参数 `throw_speed: float = 12.0`。
- 备选手感(注释里留了行):改用 `apply_central_impulse` 则重物飞得慢。
### 可拖拽时准心变手型
- 新增素材 `character/assets/hand_cursor.svg`(新建了 `character/assets/` 目录):简笔手(4 指 + 手掌 + 拇指的圆角矩形),双层 —— 底层深色 `#1a1a1a` stroke-width 4 当描边,上层白色 stroke-width 1.5 覆盖内部接缝。32×32,**未写 `<title>`**(避免 ThorVG 解析风险)。
- `ui/crosshair.gd`:加 `class_name Crosshair`;新增 `hand_texture`(自动加载上面的 svg) / `hand_size`(=26)、`set_grabbable(bool)`;`_draw()` 在 `_grabbable && hand_texture != null` 时走 `_draw_hand()`,否则走 `_draw_dot()`。
- `player_pickup.gd`:新增 `signal hover_changed(grabbable: bool)`,`physics_step()` 里先调 `_update_hover()`——用 `_ray.force_raycast_update()` 强制刷新(否则结果滞后于本帧 move_and_slide,跑动中瞄到物体慢一帧),**只在状态变化时**发信号。
- `player.gd`:`@onready var crosshair: Crosshair = $CameraRig/HUD/Crosshair`;`_ready()` 权威分支里 `pickup.hover_changed.connect(_on_pickup_hover_changed)`;handler 里既切准心,也 `Input.set_custom_mouse_cursor()` 换系统指针(含指尖热点),鼠标未捕获时该行无副作用。
- **注意**:用户侧已把 `AnimationPlayer` 从 `Body/AnimationPlayer` 提到 Player 根节点(`$AnimationPlayer`),并把 `animator` 变量改名为 `animation_player`;相机 scale 改为 5。
### 换用真实光标素材(grab / grab_hover)
- 素材来源:`C:\Users\yvmou\Downloads\cursors\light\`(light 主题 = 深色手型,32×32 RGBA)。
- 已复制到 `character/assets/grab.png`(指着可拖拽物)与 `character/assets/grab_hover.png`(正抓着),Godot 已生成 `.import`。
- **删除了临时的 `character/assets/hand_cursor.svg` 及其 `.import`**(已被真实素材取代)。
- `ui/crosshair.gd` 改为三态:`enum GrabState { NONE, HOVER, HOLDING }`;导出 `hover_texture` / `hold_texture` / `hand_size`(=32,1:1 不缩放) / `icon_modulate` / `sync_os_cursor`;公开 `texture_for_state(state)` 供系统指针复用素材;`_sync_os_cursor()` 内部完成 `Input.set_custom_mouse_cursor`(`Engine.is_editor_hint()` 下跳过),热点在图标高度 12% 处 —— **系统指针逻辑已从 player.gd 移入准心**。
- `player_pickup.gd`:信号由 `hover_changed(bool)` 改为 `interact_state_changed(hovering, holding)`,`_update_hover()` 改名 `_update_interact_state()` 并同时跟踪 `_holding`。
- `player.gd`:handler 改名 `_on_pickup_interact_state_changed(false)`,只负责把两个布尔量收敛成 `Crosshair.GrabState`(优先级 抓着 > 指着 > 没有)。
- 待用户确认:像素手在非整数倍分辨率缩放下发糊的话,需把 Crosshair 的 `texture_filter` 设为 Nearest。
### 拖拽时物体一卡一卡(已修)
- **根因(几何 + 控制律)**:`physics_step()` 用 `v = to_hold * pick_power` 是一阶跟随,人匀速前进时物体必有稳态滞后,滞后距离 = 移动速度 / pick_power。当时为 `walk_speed 5 ÷ pick_power 10 = 0.5 m`,而持握点离身体仅 `0.44 m`(InteractionPos local z = -0.4439,Player 缩放 0.2 × Camera 缩放 5 = 净 1.0,故世界距离就是 0.444 m)→ 物体退进玩家胶囊体 → 物理推出 / 拖拽拉回互相打架 → 高频抖动。
- **修复 1(前馈)**:`desired = to_hold * pick_power + _body.velocity` —— 匀速行走时滞后归零。这是根治。
- **修复 2(碰撞例外)**:`_grab()` 里 `_current.add_collision_exception_with(_body)`,`_drop()` / `_throw()` 经 `_forget()` 用 `remove_collision_exception_with` 解除。只解除「物体 ↔ 玩家」这一对,物体与场景其他碰撞体照常交互。
- 另加 `@export var max_drag_speed: float = 30.0` 速度上限,物体离得远时不会被一股脑拽飞。
- **接口变更**:`PlayerPickup.setup(ray, hold_pos, body)` 多了第三个参数(玩家本体),`player.gd` 里传 `self`。`_grab()` 加了 `is_instance_valid(_current)` 早退,避免重复抓取时漏掉碰撞例外。
### 玩家缩放层级(查明,供后续改尺寸参考)
- `character/player/player.tscn` 的缩放链:`Player ×0.2` → 子节点 Collision(胶囊 r0.545/h1.406,本地 y0.703) / CameraRig(眼高 y1.6) / Body/mouse(×0.1824) 累计 ×0.2。
- `Camera ×5` **是补偿值**,用来把相机子树抵消回世界缩放 1.0(0.2×5=1.0),因此 InteractionRay 世界长度 0.5 m、InteractionPos 世界距离 0.444 m。
- 推论:**改 `Player.scale` 必须同步 `Camera.scale = 1 / Player.scale`**,否则交互距离/持握距离会跟着缩。另外 PlayerMotor 的 walk/sprint/jump/accl 是世界单位,不会自动缩放,改尺寸时需手动同倍调整。
- 世界尺寸现值:碰撞胶囊半径 0.109 / 高 0.281,眼高 0.32 m。
### 整体人物缩到 0.6 倍(已实施)
- `player.tscn`:`Player` 缩放 0.2 → **0.12**;`Camera` 缩放 5 → **8.333333**(= 1/0.12,保持相机子树净缩放 1.0,**交互距离/持握距离不变**)。Camera 的位置偏移与 CameraRig 的 y=1.6 都自动随父级缩放,未改。
- `player_motor.gd`:walk 5.0→**3.0**、sprint 8.5→**5.1**、acceleration 45→**27**、air_acceleration 12→**7.2**,均为 ×0.6;jump_velocity 5.0→**3.87**(×√0.6,因跳跃高度 = v²/2g,直接乘 0.6 会跳得偏矮)。
- `player_animator.gd`:**基准速度也必须 ×0.6** —— walk 5.0→3.0、run 8.5→5.1、crouch 2.5→1.5、sneak 1.6→0.96。原因:模型缩到 0.6 倍后,同一片段一个循环跨出的世界距离也缩到 0.6 倍,不改基准速度脚步会明显打滑。同时修正了 `_resolve_state()` 里那句「jump 片段 0.5s ≈ 5/9.8 上升时间」的过时注释。
- 未改动:`pick_power` 10(新滞后 3.0/10 = 0.3 m < 0.444 m 持握距离,且已有速度前馈,安全)、`throw_speed` 12(被扔的物体本身没缩,投掷距离不该变)、`mouse_sensitivity`(纯角度)。
- 缩放后世界尺寸:胶囊半径 0.0654 / 高 0.169,眼高 0.192 m;跳跃绝对高度 1.276 → 0.766 m(相对身高观感不变)。
### 再缩到「视觉高度 0.15 m」(已实施)
- **口径确认(重要)**:用户选的是**视觉模型高度**,不是碰撞胶囊。两者不等:
- 模型:glb 顶点原始高 `8.557729`(min y -4.7746 / max y 3.7832)× mouse 节点缩放 `0.18240134` = 本地 1.560941;世界 = ×0.12 = **0.187313 m**(当时)
- 胶囊:本地 1.4063476 × 0.12 = **0.1687617 m**
- 模型底部在本地 y ≈ -0.0007(脚踩原点,与胶囊底部对齐),故测量可信。模型比胶囊高 11%(多出耳朵/尾巴)。
- 缩放系数 `k = 0.15 / 0.187313 = 0.800799`。
- `player.tscn`:`Player.scale` 0.12 → **0.096096**;`Camera.scale` 8.333333 → **10.406274**(= 1/0.096096)。
- `player_motor.gd`:walk 3.0→**2.4024**、sprint 5.1→**4.0841**、accel 27→**21.6216**、air_accel 7.2→**5.7658**、jump 3.87→**3.4632**(×√k)。
- `player_animator.gd`:walk_ref 3.0→**2.4024**、run_ref 5.1→**4.0841**、crouch_ref 1.5→**1.2012**、sneak_ref 0.96→**0.7688**。
- 注释已改成「按同一系数同步缩放」的通用表述,不再写死 0.6,避免下次再失效。
- 结果:胶囊世界 半径 0.05241 / 高 0.13514,眼高 0.08729 m,跳跃绝对高度 ≈ 0.612 m;拖拽滞后 = 2.4024/10 = 0.24 m < 0.444 m 持握距离,安全。
- **可复用公式**:`Player.scale = 目标世界高 / (模型本地总高)`,`Camera.scale = 1 / Player.scale`,速度与动画基准速度均 ×k(jump 用 ×√k)。
### 按 0.15 m 身高重新设计移动参数(已实施)
- 用户明确:「不用管之前,按 0.15 的身高重新设计」→ 采取**以人类为参照的等比设计**,不再依赖旧基准。
- 设计基准:人 1.75 m,游戏惯例 walk 5.0 / sprint 8.5 / accel 45 / air_accel 12 / jump 5.0(跳跃高度 1.28 m ≈ 0.73 身高)。系数 `k = 0.15 / 1.75 ≈ 0.0857143`。
- `player_motor.gd`:walk 2.4024→**0.4286**、sprint 4.0841→**0.7286**、accel 21.6216→**3.8571**、air_accel 5.7658→**1.0286**、jump 3.4632→**1.4639**(×√k)。
- `player_animator.gd`:walk_ref 2.4024→**0.4286**、run_ref 4.0841→**0.7286**、crouch_ref 1.2012→**0.2143**、sneak_ref 0.7688→**0.1371**。
- 校验:两边都是「走路 = 2.86 身长/秒」「跳跃 = 0.73 身高」,比例一致。加速度给出约 0.11 秒加到全速;跳跃高度 0.109 m。
- 拖拽滞后 = 0.4286/10 = 0.043 m ≪ 0.444 m 持握距离,安全。
- **未改动**(有意保持):交互距离/持握距离(射线 0.5 m、持握 0.444 m —— 若按 k 缩到 0.038 m 会够不到任何东西)、`pick_power` 10、`throw_speed` 12(被扔物体是世界尺寸,未缩)、`mouse_sensitivity`(纯角度)。
- **世界尺度查不到可靠线索**:`world/main2.tscn` 有 295 个 `Transform3D`,但混入了嵌套实例的局部变换,位置跨度(X33/Z57)不可信;198 个实例缩放为 1.0,资产疑似米制。若实际关卡跨度大、用户觉得慢,把 walk/sprint 等线性量乘同一倍数即可。
### 蹲伏真正生效(已实施)
- 背景:**蹲下改速度、改摄像机高度这两件事从来没实现过**(原 `player.gd` 也只把 `crouching` 传给动画器切 CROUCH/SNEAK 片段),不是回归。
- `player_motor.gd`:新增 `@export var crouch_speed: float = 0.2143`(= walk 的一半 = 2.5×k)。`step()` 里改为**蹲伏优先于疾跑**:`sprinting = wants_sprint and not crouching`,速度按 `crouch_speed > sprint_speed` 优先级取。
- `player_look.gd`:新增蹲伏组 `crouch_height_ratio`(=0.6) / `crouch_transition_speed`(=10);`setup()` 从场景读 `_stand_height = rig.position.y`(不写死 1.6);新增 `set_crouching(bool)` 设目标高度;`_process()` 用 `1 - exp(-k·delta)` 指数收敛平滑升降(**渲染帧**,物理帧会一顿一顿);`_ready()` 里 `set_process(false)`,`setup()` 才开 —— 远端玩家不调 setup 就不会跑。
- `player.gd`:`_physics_process` 里 `motor.step(delta)` 之后加 `look.set_crouching(motor.crouching)` —— **蹲伏状态由 Motor 单点判定**,不让 Look 自己读蹲键,避免两处各判一次不同步。
- `player_animator.gd`:`sneak_ref_speed` 0.1371 → **0.2143**,与 `crouch_speed` 对齐 —— 蹲着走播的就是 SNEAK 片段,两边不等会打滑(原来会以 1.56× 播放,逼近 1.6 的 clamp 上限)。注释里写明了这条配对关系。
- 世界高度:站立 `1.6 × 0.096096 = 0.1538 m` → 蹲下 `0.0923 m`。
- **未做(可以后续补)**:蹲下时没有缩小碰撞胶囊 —— 所以现在还钻不过低矮缝隙。要做需要额外处理「头顶有障碍时不许起身」的检测。
### 关于「重力是否该随角色缩小」(已分析,待用户定夺)
- **物理结论**:不该变。重力加速度是行星属性,与物体大小无关。
- **但设计上是真问题**:我们的速度按 k=0.0857 等比缩了,重力却没缩,导致**落体相对过快**。
- 落「自身身高」用时:人类 (1.75 m, g=9.8) = 0.598 s;本角色 (0.15 m, g=9.8) = 0.175 s,**快 3.4 倍**。
- 若 `g = 9.8 × k = 0.84`:本角色也是 0.598 s,与人类完全一致(缩小版人类)。
- **顺带暴露的问题**:`jump` 片段长 0.5 s。现在 g=9.8、jump_velocity=1.4639 → 上升仅 **0.149 s**,只播得完片段的 30%。若 `g=0.84` 且 `jump_velocity = walk × 1.0 = 0.4286`,上升 = **0.51 s**,正好等于片段长度 —— 回到原始设计(原来 jump 5.0 / g 9.8 = 0.51 s)里那种吻合。
- **实现方式(已落地为旋钮)**:`PlayerMotor` 新增 `@export_range(0.0, 2.0, 0.01) var gravity_scale: float = 1.0`,`step()` 里 `_body.velocity.y -= _gravity * gravity_scale * delta`(**每帧现读**,方便运行中拖 Inspector 试手感)。默认 1.0 = 行为完全不变。**绝不要改 `ProjectSettings physics/3d/default_gravity`** —— 那是全局的,会让场景里所有 RigidBody 一起变飘,包括拖拽/投掷的物体。
- 三个候选:`9.8`(物理真实,落体快、跳跃动画对不上)/ `2.87`(×√k,折中)/ `0.84`(×k,缩小版人类,跳跃动画吻合,但比月球 1.62 还飘)。
- **⚠ 配对规律**:重力 × s 时 `jump_velocity` 也要 × s,否则跳跃高度按 1/s 暴涨。现在是 `g=9.8 + v=1.4639 → 高 0.109 m`;若 `g=0.84` 而 v 不变,跳跃高会变成 **1.28 m(8.5 个身高)**;正确配对是 `g=0.84 + v=0.4286`。这条已写进 `gravity_scale` 的代码注释。
- 用户尚未选定最终值。
### 跳跃「高度过低、落地太快」的调法(已给方案,待用户选)
- 问题:现状 g=9.8 + jump_velocity=1.4639 → 跳高 **0.109 m**(0.73 身高)、**滞空仅 0.30 s**,跳一下几乎立刻落地。
- **核心关系**:`h = v²/2g`、`T = 2v/g` → 合起来 `h = g·T²/8`。
→ **固定重力下,滞空翻倍 = 高度涨 4 倍**。所以「既要跳得久、又不跳太高」只能靠**降重力**,光提 `jump_velocity` 做不到。
- **反解公式**(定目标高度 h 与滞空 T):`g = 8h/T²`(→ gravity_scale = g/9.8),`jump_velocity = 4h/T`。
- 三个候选(角色 0.15 m,1 身高 = 0.15 m):
| 方案 | gravity_scale | jump_velocity | 跳高 | 身高倍数 | 滞空 | 落地时长 |
|---|---|---|---|---|---|---|
| 现状 | 1.0 | 1.4639 | 0.109 m | 0.73 | 0.30 s | — |
| A 只提跳跃 | 1.0 | 2.10 | 0.225 m | 1.50 | 0.43 s | 不变 |
| B 只降重力 | 0.5 | 1.4639 | 0.219 m | 1.46 | 0.60 s | ×1.41 |
| **C 组合(推荐)** | 0.5 | 1.50 | 0.230 m | 1.53 | 0.61 s | ×1.41 |
→ A 和 B 高度几乎一样,但 B 的滞空长 40% —— 这正是「降重力」的价值。
- 副作用:g=4.9 时从 1 m 高落下要 0.639 s(原 0.452 s)。仍远好于等比 g=0.84(比月球还飘)。
- jump 动画片段 0.5 s:现状只播得完 30%,C 方案升到 61%。要让上升时间完全等于 0.5 s 需 T=1.0 s → g 低到 1.84(接近月球),不建议。
- **可选改进(未实施)**:把 `jump_velocity` 换成「跳跃高度 `jump_height`(米)」—— 设计上更直观,且改重力时高度自动保持,不会出现「改重力后跳高暴涨」的坑。
### 跳跃参数定案:方案 C(已实施)
- 用户问「推荐哪个方案」→ 定 **C**:`gravity_scale = 0.5`(有效 g = 4.9)、`jump_velocity = 1.5`。
- 结果:跳高 **0.230 m(1.53 身高)**、滞空 **0.612 s**、上升 0.306 s;从 1 m 高落下 0.639 s(原 0.452 s,×1.41)。
- 选 C 不选 A(只提 jump_velocity、保持 g=9.8)的理由:同样 0.23 m 的高度,A 只有 0.43 s 滞空,落地仍嫌仓促;C 多出 0.18 s。而且 C 让 jump 片段能播完约六成(A 只有四成)。
- 没选等比方案(`gravity_scale = 0.0857`):那比月球(1.62)还飘。
- 代码注释已整理:`jump_velocity` 与 `gravity_scale` 标注为**配对参数**,并写入反解公式 `g = 8h/T²`、`v = 4h/T`;`player_animator.gd` 里那句引用旧值的上升时间注释也改准了(1.5 / 4.9 ≈ 0.31 s)。
### 贴墙时摄像机穿模(已修)
- **根因(纯几何,差 8 毫米)**:
| 项 | 值 | 来源 |
|---|---|---|
| 摄像机 `near` | **0.05 m** | Godot 默认,`player.tscn` 未覆盖 |
| 摄像机前移量 | **0.01074 m** | `Camera` 本地 Z = −0.1118 × Player 缩放 0.096096 |
| 贴墙时身体离墙 | **0.05241 m** | 胶囊局部半径 0.5454 × 0.096096 |
| **摄像机到墙** | **0.04167 m** | 0.05241 − 0.01074 |
→ `0.0417 < 0.05`,墙面落在近裁剪面**内部**被整个裁掉,于是看到墙的背面/内部。
- **修复 1**:`player.tscn` 的 `Camera` 加 `near = 0.01`(留 4 倍余量)。项目是 Forward+(反向 Z 深度),near 缩小对深度精度影响很小。`far` 保持默认未动。
- **修复 2**:`player.gd` 新增 `@onready var model: Node3D = get_node_or_null("Body/mouse") as Node3D`,在权威分支里 `model.visible = false` —— 原来正是靠 near=0.05 把自己的身体裁掉才看不见,near 调小后身体会露出来。只影响本地玩家,远端玩家各自的模型实例不受影响。
- 代价:自己也看不到自己的影子(`visible=false` 会关掉阴影)。想保留影子可改用「本地玩家的 MeshInstance3D 放到独立视觉层 + 本地摄像机 cull_mask 排除该层」,但要注意该层不能和远端玩家共用,否则本地相机也看不到别人。
- **备选方案(未采用)**:把胶囊半径加大到 > 0.06 m 让摄像机永远离墙更远 —— 会改变玩法(体型变胖);或把 `Camera` 的前移量改小 —— 会动视角位置。
### 附带发现的 bug:复制配置里的节点路径过期(已修)
- 用户此前把 `AnimationPlayer` 从 `Body/AnimationPlayer` 移到 Player 根节点(`$AnimationPlayer`),但 `player_sync.tres` 里两条路径仍写着 `Body/AnimationPlayer:anim_state` / `Body/AnimationPlayer:anim_speed` → 这两个属性根本没被复制,**远端玩家的动画状态不会同步**。
- 已改为 `AnimationPlayer:anim_state` / `AnimationPlayer:anim_speed`。
## ⚠ 项目约定(用户明确要求)
**改 Godot 的 .tscn / .tres / .gd,一律走 godot-ai MCP,不要直接写文件。** 原因见下:外部直接写 .tscn 会和编辑器的文件监视重载(`_reload_modified_scenes`)竞态,导致编辑器读到中间状态、报出**假的解析错误**。
- 可用工具:`scene_open` / `scene_save` / `node_set_property` / `node_get_properties` / `node_create` / `node_manage` / `script_manage(find_symbols 可当解析校验)` / `script_create`(带诊断) / `filesystem_manage(read_text/write_text/scan/reimport)` / `resource_manage` / `logs_read` / `editor_manage(state)` / `project_run`。
- 调用形式:多数工具是「扁平参数」(如 `scene_open {path}`),管理类工具是 `{op, params}` 双层(如 `editor_manage {op:"state"}`)。`node_*` 的 path 是**相对当前编辑场景根**(如 `/Player/CameraRig/Camera`),不是 `/root/...`。
## player.tscn 解析错误事件(已解决)
- 现象:`Parse Error ... player.tscn:38` in `_parse_node_tag`,场景彻底打不开。
- **真实原因:不是文件语法错误。** 我用 MCP `node_set_property` 设 `Camera.near = 0.01` 后 `scene_save`,编辑器写出的内容与我手写的**字节完全一致**(同为 5106 字节),且 `scene_open(force_reload=true)` 立即 `reloaded_from_disk: true` 加载成功。
→ 根因是**外部直接写 .tscn 与编辑器文件监视及重载的竞态**。
- 诊断过程中的有效手段(可复用):
1. `editor_manage(op="state")` 看当前场景与就绪状态
2. `scene_open` 做**对照实验**(打开一个已知正常场景,若 `switched:true` 则说明工具可靠、目标文件真的坏了)
3. `logs_read(source="editor", include_details=true)` 拿准确报错函数与行号
4. 用 `node_create` 在**内存里**加临时节点 + `node_get_properties` 探测属性真名(**不保存、不落盘**,随后 `node_manage(op="delete")` 清掉)—— 这次用它确认了 `Camera3D.near` 是合法 float 属性(默认 0.05,`far` 默认 4000)
5. `script_manage(op="find_symbols")` 可当脚本**解析校验**器;`filesystem_manage(op="scan")` 重建 `class_name` 全局表
- 修复结果:`player.tscn` 的 `Camera` 现有 `near = 0.01`(MCP 设置并保存),场景可正常加载。
### 日志里发现的其它线索(多为历史记录,非当前问题)
- `Could not find type "PlayerLook/PlayerMotor/PlayerPickup/Crosshair"`、`Identifier "animator" not declared` —— 都是**旧状态的历史日志**;`find_symbols` 证明 player.gd 现已正常,且 `filesystem_manage(scan)` 返回 `global_classes_registered_delta: 0`(类名表本是全的,共 75 个)。
- `crosshair.gd:133 _draw 里 texture_for_state 收到 Nil` —— `@tool` 脚本热重载时旧实例被新代码调用的一次性产物,非当前 bug。
- `player.tscn` 的 **Crosshair 纹理疑似接反**:`hover_texture = grab_hover.png`、`hold_texture = grab.png`。按我的语义(hover=指着可拖拽物 / hold=正抓着)应互换;但若按光标包命名(`_hover` 后缀表示悬停态)则现状正确。**未擅自改动,已向用户指出。**
- 遗留警告(非本次引入):`face_painter.gd:153` 整数除法、`face_rig.gd:190` 局部变量 `name` 遮蔽 Node.name、`paint_panel.gd:69` 局部变量 `scale` 遮蔽 Control.scale、`Cannot load shader: res://shader/stylized.gdshader`。
+220
View File
@@ -0,0 +1,220 @@
# 2026-10-10
## 新增右上角调试面板(DebugPanel)
**需求**:右上角显示玩家移动速度、相机高度等信息。
**新增文件**
- `ui/debug_panel.gd`(`class_name DebugPanel extends Control`)—— 单节点 `_draw()` 实现,
不用 Label 节点。理由:要自己控制两列对齐(标签左对齐、数值左对齐、列间距固定),
不必去猜字体度量。总刷屏用 `draw_rect` 画底板。
**显示内容**(10 Hz 刷新,`refresh_interval` 可调)
| 行 | 内容 |
|---|---|
| FPS | `Engine.get_frames_per_second()` |
| 移动速度 | 水平速度 m/s + 身长/秒 |
| 垂直速度 | `velocity.y` |
| 相机高度 | 相机相对脚底的世界高度 m + 倍身高 |
| 状态 | 站立/蹲下/疾跑 · 地面/空中 |
| 位置 | 世界 X, Z |
| 手持 | 拖拽中 / 可抓取 / — |
**关键设计点**
- 相机高度用 `_camera.global_position.y - _player.global_position.y`(世界 y 相减),
**不是**读 `CameraRig.position.y` —— 后者是本地坐标还要乘玩家缩放,且相减法对
「蹲伏过渡中途」这种非稳态也直接成立。
- `body_height`(默认 0.15)只用于换算「身长/秒」和「倍身高」,**和 `player.tscn` 的
`Player.scale` 绑定,改角色尺寸时要同步改**。
- `mouse_filter = IGNORE` 在 `_ready()` 里显式设置(见下方 MCP 限制),
否则这个全屏 Control 会吃掉所有鼠标事件,鼠标视角和拖拽全会废掉。
- `visible` 默认 false,`setup()` 里才置 true —— 避免远端玩家的「回放身体」
闪出一块「未接线」面板。
- F3 切换显隐(`toggle_key`)。`Key` 类型可以直接 `@export`。
- 挂在 `CameraRig/HUD` 下(和准心同级)→ 远端玩家的 HUD 随 `camera_rig.queue_free()` 一起释放,
天然只有本地玩家显示。
**改动文件**
- `character/player/player.tscn`:`HUD` 下新增 `DebugPanel (Control)`,
锚点 Full Rect(`anchor_right`/`anchor_bottom` = 1.0),挂 `ui/debug_panel.gd`。
- `character/player/player.gd`:加 `@onready var debug_panel: DebugPanel = $CameraRig/HUD/DebugPanel`
和 `_ready()` 里 `debug_panel.setup(self, motor, pickup, camera)`。
- `character/player/player_pickup.gd`:新增公开 getter `is_hovering()`(配 `has_object()`)。
---
## ⚠ 本次踩到的 Godot MCP 限制(重要,可复用)
### 1. 编辑器在 play 模式时 MCP 拒绝一切写入
`EDITOR_NOT_READY / EDITOR_PLAYING`。必须先 `project_manage(op="stop")`。
(本次用户选择「我来停」,停完 `editor_manage(op="state")` 确认 `readiness: ready`。)
### 2. MCP 写不了整型属性
`node_set_property` 传整数会变成 String → `WRONG_TYPE: Cannot write String to a int property`。
**浮点/字符串正常**。受影响的:`mouse_filter`、`grow_horizontal`、`anchors_preset`。
→ 绕过办法:
- 锚点:改设 `anchor_right` / `anchor_bottom` = **1.0**(浮点可写);编辑器保存时会自动补
`anchors_preset = 15` + `layout_mode = 3`,效果与准心节点完全一致。
- 整型语义(如 `mouse_filter`):**写进脚本里**(`_ready()` 里设),别依赖场景属性。
### 3. 假 parse error(本项目的顽固问题,第二次遇到)
`script_create` / `script_patch` / `filesystem_manage(write_text)` 的 `diagnostics` 会对
**完全合法的文件**报 `gdscript_reload_failed, error_code 43 (ERR_PARSE_ERROR)`,
`line` 是 `fallback_line: true`(≈ 文件总行数,不可信)。
本次 `ui/debug_panel.gd`、`player_pickup.gd` 都被这样误报过,而真实项目上
`--check-only` 全部通过。**判定:这是编辑器热重载产物,不是语法错误。**
教训:**MCP 的 diagnostics 只能当提示,不能用它判定脚本坏了。**
### 4. 可靠的脚本语法校验方法(离线,不污染项目)
Godot 可执行文件路径:
`D:\_yvmou\Godot\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64.exe`
(从 `Get-Process | Where ProcessName -like "*godot*" | Select Path` 拿到)
直接对真实项目跑(安全,`--check-only` 解析完即退):
```bash
"$GODOT" --headless --path "E:/Godot/agame" --check-only --script "res://ui/debug_panel.gd"
```
**输出为空 = 通过**。
沙箱法(想彻底不碰项目时):临时目录建最小 `project.godot`,拷入脚本及其依赖,
先 `--editor --quit` 生成 `.godot/global_script_class_cache.cfg`(否则会误报
「Could not find type」——注意**类型解析失败也是报 Parse Error**),再 `--check-only`。
### 5. 字体(中文能否渲染)
实测:`ThemeDB.fallback_font` **就是**默认主题字体(`FontFile` / "Open Sans SemiBold"),
`allow_system_fallback = true`,系统有 Microsoft YaHei/SimSun。
注意 `has_char("点")` 返回 **false**(`has_char` 不查系统回退链),但排版时回退生效:
「移动速度相机高度」8 字量出 128px = 16px/字,字形确实取到了。
→ **结论:`_draw()` + `ThemeDB.fallback_font` 画中文是可行的,不必额外挂字体资源。**
### 6. 其他
- `custom_manage(op="list")` 可列出第三方 addon 注册的自定义工具。
- `editor_manage(op="state")` 能看 play 状态;`logs_read(source="editor")` 只看得到
脚本 reload 的 warning,拿不到 GDScript 的详细 parse 报错文本(stderr 到编辑器控制台)。
- 用户 `world/main2.tscn` 是编辑器常驻场景,用完要**还原回去**(本次已还原)。
---
## `player_sync.tres` 为什么在 Godot 里「不可编辑」
- `SceneReplicationConfig` **没有任何 `@export` 属性**,类上只有 `add_property` / `remove_property` /
`property_set_*` 这些**方法** → 通用 Inspector 渲染出来是空的,所以双击 `.tres` 看起来「不能编辑」。
- 这不是错觉:Godot 4.7 二进制里有 `ReplicationEditor` / `Replication Dock` / `Remove Property` /
`Replication Mode`(下拉枚举 `Never,Always,On Change`)/ `ReplicationInterface`,
但**没有** `SceneReplicationConfigEditor` —— 即不存在独立的资源编辑器。
- **正确改法**:选中场景里的 `Sync` 节点(`MultiplayerSynchronizer`)→ Inspector 的 Replication 区
→ 添加/删除属性、勾 Spawn、选 Replication Mode → 保存场景(外部 .tres 会被一并写盘)。
- 运行时可在调试器里看同步情况(`ReplicationInterface` / `ReplicationProfiler`)。
### 顺带发现:`.:health` 是失效路径
`character/player/player.gd` 上**没有** `health`(`health` 只存在于 `demo/` 那套
`agent_base.gd` + `Health` 节点)。同类「路径过期」问题已第三次出现
(前两次是 `Body/AnimationPlayer:anim_state` / `anim_speed`)。
当前 5 条里 4 条有效:`.:position`、`Body:rotation`、`AnimationPlayer:anim_state`、`AnimationPlayer:anim_speed`。
原 5 条全是 `replication_mode = 1`(Always,每处理帧持续发)。`anim_state` / `anim_speed`
是低频变化量,改用 `On Change`(2,可靠传输、变化才发)更省带宽。
### 已处理(用户确认后动手)
`player_sync.tres` 现为 4 条:
| # | 路径 | spawn | mode |
|---|---|---|---|
| 0 | `.:position` | true | 1 Always |
| 1 | `Body:rotation` | true | 1 Always |
| 2 | `AnimationPlayer:anim_state` | true | 2 On Change |
| 3 | `AnimationPlayer:anim_speed` | true | 2 On Change |
- **改法**:MCP `filesystem_manage(write_text)` 直接写 .tres 文本 → `scan()` → `reimport()`。
这个资源**没有编辑器 UI,也不存在「编辑器打开着」的竞态**,所以写文本是安全的
(与 .tscn 的情况不同)。`reimport` 对 .tres 只会刷新文件条目(`skipped_non_imported`)。
- **验证手段(值得复用)**:把手写的 `.tres` 拷进最小沙箱工程,用
`--headless --path <沙箱> --script res://check.gd` 跑一段 `extends SceneTree` 的脚本,
`load()` 它并打印 `get_properties()` / `property_get_spawn()` /
`property_get_replication_mode()` —— 这是**唯一能验证手写 .tres 真能解析**的办法。
实测输出:4 条,mode = 1,1,2,2 ✓
- `resource_manage(op="load")` 对 `SceneReplicationConfig` 只返回 3 个属性
(`resource_local_to_scene` / `resource_path` / `resource_name`),
`op="inspect"` 直接拒绝(不在它支持的原生资源族里)。
- `Always`(1) 用不可靠传输、每帧持续发;`On Change`(2) 用可靠传输、值变了才发。
连续变化的量(position / rotation)用 `Always` 是对的,低频量才换 `On Change`。
### 决定:保持外部 .tres,不内嵌(用户明确选择「不改」)
调研过三条替代路,全部放弃,**以后不必再提**:
- **A 内嵌 `sub_resource`**:功能零损失、可少一个文件,但给配置加条目要调 `add_property()`
—— 那是**方法不是属性**,MCP 只能读写属性(`custom_manage` 也返回 0 个自定义工具),
最后 4 行只能用户在 Replication 面板手点。不值得为省一个文件付这个手工成本。
- **B 脚本里构造**(`MultiplayerSynchronizer` 上挂脚本,`_ready()` 里 new 一个 config 再赋给
`replication_config`):会让编辑器 Replication 面板与实际同步内容**脱节**,
且运行时赋值能否赶在同步器开始工作前生效**未实测**。放弃。
- **C 弃用 Synchronizer 改 `@rpc`**:`world/main.tscn` 用 `MultiplayerSpawner` + `main.gd` 的
`spawn_function` 生成玩家,「新 peer 拿到当前动画状态」是 `spawn = true` 白送的;
换 RPC 要自己补初始状态 / 节流 / 丢包策略。为 4 个值不划算。
- 根本原因:`.tres` 的"缺点"只是不能双击打开,而**编辑入口一直是 Sync 节点的 Replication
面板**(换成 A 也是同一个面板)。
### `/Player/Sync` 节点实测值
`root_path = ".."`(默认值 = 父节点 Player,这就是 `.:position` 指 Player 的原因)、
`delta_interval = 0.0`、`public_visibility = true`、`visibility_update_mode = 0`。
`replication_config` **不在** `node_get_properties` 可读属性里(被 ReplicationEditor 面板接管)。
### 补充:外部 .tres 改动的落盘机制(用户追问「tres 不是不可编辑吗」)
- 准确说法:`player_sync.tres` **没有自己的编辑界面**,但**内容可以改**,入口是 Sync 节点的
Replication 面板。这是两件事,别混。
- 根因链:`SceneReplicationConfig` 没有任何 `@export` 属性(只有方法)→ 通用 Inspector 渲染不出
可编辑项 → Godot 只好把专用 UI(`ReplicationEditor`)挂在**使用它的节点**
(`MultiplayerSynchronizer`)上。实测证据:`resource_manage(op="load")` 只回 3 个元数据属性
(`resource_local_to_scene` / `resource_path` / `resource_name`)—— 这就是你双击它看到的全部内容。
- **编辑器加载的那份 .tres 实例就是项目文件本身**,所以面板里改它会**写回 `.tres`**,重启后仍在
(与 `duplicate()` 出来的副本不同 —— 这既是它"生效"的原因,也是 Godot 著名的「资源被意外共享
修改」陷阱的同一机制)。改完记得保存场景。
- 真正做不到的只有一件事:**手改文本时没有校验**。NodePath 写错编辑器不会拦 ——
`.:health`、`Body/AnimationPlayer:anim_state` 两次事故都源于此。要改路径请走面板下拉选,
别手打。
### 项目事实:资源类的三种来源(排查中确认)
- `demo/ai/trees/*.tres` 的类型是 `BehaviorTree` / `BlackboardPlan`,**但这两个类不在任何 .gd 里** ——
`addons/limboai/` 只有 `bin/*.so`(GDExtension,C++ 编译产物),一个 `.gd` 都没有。
所以它们是**插件用 C++ 注册的 Resource 类**,用起来和引擎内置类没区别(LimboAI 的 C++ 版)。
- 项目里目前**没有任何自己写的 `extends Resource` 脚本**(排除 addons)。
已存在的 `.tres` 类型:`StandardMaterial3D`(素材包材质)、`SceneReplicationConfig`(player_sync)、
`BehaviorTree`(limboai)。
- 没有二进制 `.res`。
- 判据:**`@export` 就是「能不能在 Inspector 里编辑」的分水岭**。`SceneReplicationConfig` 一个
`@export` 都没有 → 打开是空的;自定义 Resource 有 → 双击就能编辑。
### 调试器面板截图排查(15:58 用户贴图)
**三条警告**都是历史遗留、与近期改动无关,来自项目加载时的 `GDScript::reload` 全量重载:
- `face_painter.gd:153` 整数除法 —— **不是 bug**。`_stamp_d` 恒为偶数(第 138 行 `d = r * 2`),
整除不丢精度。要消警告用 `@warning_ignore("integer_division")` + 注释说明,**不要**改成浮点。
- `face_rig.gd:190` 局部变量 `name` 遮蔽 `Node.name` → 改名(如 `entry_name`)即可。
- `paint_panel.gd:69` 局部变量 `scale` 遮蔽 `Control.scale` → 改名(如 `factor`)即可。
**那条错误 `net.gd:66 host_game(): Couldn't create an ENet host.` 也不是 bug**:
日志证据显示当时**两个实例同时在跑** —— 主机(peer 1)已占住 7000 端口,第二个实例点「创建主机」
必然失败(这是预期路径),随后它点「加入」**成功连上**(日志里有完整握手 + 双方各自生成角色)。
功能正常,唯一可改进的是错误消息不够人话。
排查要点:**`user://logs/godot.log` 是「按项目」共享的,多实例会并发写同一个文件**,
所以日志行会交错(截图里 `enet_connection.cp` 被从中间截断就是并发写的痕迹)。
判据:`[Net] 本端 1 …` 和 `[Net] 本端 1484785418 …` 同时出现 = 两个进程。
### ⚠ 两条重要的方法论修正(都靠实测证伪)
**1. 之前说的「假 parse error」成因找到了 —— 不是假错,是热重载场景内的脚本。**
`script_patch`/`script_create` 返回 `gdscript_reload_failed (error_code 43)` 的文件,
全部都是**挂在「当前编辑器打开的那个场景」里的节点上的脚本**:
`debug_panel.gd`(HUD/DebugPanel)、`player_pickup.gd`(/Player/Pickup)、
`face_painter.gd`、`face_rig.gd`(FaceRig) —— 当时开着的正是 `player.tscn`。
而 `paint_panel.gd`(在 paint_panel.tscn 里)、`net.gd`(autoload,net.tscn 没开)
**都返回 `diagnostics: []` 干净结果**。
结论:场景里有活实例时热重载会被判定失败,**文件本身没问题**。
想拿干净诊断:先把编辑器切到一个**不含该脚本实例**的场景,或直接用下面的离线校验。
**2. `--check-only` 只报错、不报警告!**
实测探针(`var name := "hello"` + `5 / 2`,两条确定会触发的警告)→ `--check-only` **零输出**。
所以「无输出」只能证明**没有语法/分析错误**,**不能**作为「警告已消除」的证据。
要确认警告消掉了,必须看编辑器的 Errors 面板(重启编辑器或重新加载项目)。
本次三条警告的处理依据是「遮蔽变量已改名 + 所有引用点都改到」(`face_rig.gd` 的 `name`
在 190/191/192/202/208 共 5 处,**漏改后两处会静默解析成 `Node.name`、不报错但值全错**,
已全部改名 `entry_name`)。
+55
View File
@@ -0,0 +1,55 @@
# 2026-10-12
## 可拖拽判断(PickupItem 标记)
**需求**:`scripts/item.gd` 提供 `can_pickup`,只有为 true 时才能拖拽。
**item.gd 本次改动**:加了 `class_name PickupItem` 和说明注释,功能不变。
```gdscript
class_name PickupItem
extends Node
@export var can_pickup: bool = true
```
### ⚠ 关键发现:脚本挂载位置 ≠ 射线打中的节点
用户在 `world/main.tscn` 第 155 行把 item.gd 挂在了 `SM_Bottle_12` 上,但那个节点**不是刚体**。
实例来源 `res://assets/Furniture_FREE-af040129/SM_Bottle_1.glb`,实际结构是:
```
SM_Bottle_12 (Node3D) ← item.gd 挂在这一层(glb 实例的根)
└── SM_Bottle_1 (RigidBody3D) ← 射线实际打中的是这一层
├── SM_Bottle_1 (MeshInstance3D)
└── CollisionShape3D
```
**实测确认**:glb 内部节点的 `owner` 指向实例根(`SM_Bottle_1.owner == SM_Bottle_12`),
再往上为 null。所以「沿 owner 链向上找标记」正好一步命中。
### 实现(`player_pickup.gd`)
- 新增 `@export var allow_unmarked: bool = false`
- **false(默认)= 白名单**:只有挂了 `PickupItem` 且 `can_pickup=true` 的才拖得动
- true = 黑名单:没挂标记的默认可拖,只有显式设 false 的不能拖
- 新增 `can_grab(collider) -> bool`:先判 `is RigidBody3D`,再查标记
- 新增 `_find_item_marker(body)`:顺序 **刚体自身 → 直接子节点 → owner 链向上**
- 向上时**只查节点自身、不查子节点** —— 否则走到 World 层会遍历几百个子节点,而这是每物理帧都要跑的
- `_update_interact_state()` 的 hovering 判定改用 `can_grab()`
- `_grab()` 里再判一次(悬停状态来自上一物理帧,其间 can_pickup 可能已被改掉)
### 实测验证(headless,全部符合预期)
```
can_grab(SM_Bottle_1 RigidBody3D) = true ✓
can_grab(SM_Bottle_12 Node3D) = false ✓(不是刚体)
can_grab(Can2 内部刚体) = false ✓(严格/白名单模式)
allow_unmarked=true 后 Can2 = true ✓
```
### ⚠ 副作用(需告知用户)
默认白名单模式下,场景里**所有没挂 item.gd 的刚体都拖不动了**
(`Can2`、饭盒、水果等)。要么逐个挂标记,要么把 `allow_unmarked` 勾上。
### MCP 备注
4 次 `script_patch` 均返回 `replacements: 1` 但伴随 `gdscript_reload_failed (code 43)`
假报错(`fallback_line: true`)—— 与 10-10 记录的是同一现象。
离线 `--check-only` 两个脚本均通过;`PickupItem` 已进 `global_script_class_cache.cfg`。
+25
View File
@@ -0,0 +1,25 @@
# 2026-10-13 工作日志
## 修复:toon 材质所有模型联动、无法单独设基础纹理
**问题**:`shader/toon_apply.gd` 用 `material_override = toon.tres` 把同一个共享 `ShaderMaterial` 实例挂给所有 `MeshInstance3D`,导致
1. 改 `toon.tres` 影响全部模型;
2. `material_override` 整包覆盖,丢失模型自带贴图,无法给单个模型设 `base_texture`。
**方案**:重写 `shader/toon_apply.gd`
- 为每个 `MeshInstance3D` 的每个 surface **复制独立材质实例**(`cartoon_material.duplicate()`,含 `next_pass` 描边的复制),通过 `set_surface_override_material(i, mat)` 逐面挂载。
- 自动从 `mesh.surface_get_material(i)` 提取原始贴图(`BaseMaterial3D.albedo_texture` 或已有 ShaderMaterial 的 `base_texture`)写入各自的 `base_texture`。
- 新增导出项:`base_texture_override`(手动指定单模型贴图,优先级最高)、`use_original_texture`、`use_material_override`(兼容旧整包模式,默认关)。
- `toon.tres` 降级为"模板":脚本每次应用时拷贝其非贴图参数作为初值,之后各模型独立。
**关键文件**:`shader/toon_apply.gd`(uid 未变,场景引用 `assets/test/test.tscn` 仍有效)
**相关**:`shader/toon.tres`(基础 cel-shader + outline next_pass)、`shader/cel-shader-base.gdshader`、`shader/outline.gdshader`(outline 用 global uniform,全局限定)
## 修复报错:`get_shader_uniform_list` 不存在
- 报错:`Nonexistent function 'get_shader_uniform_list' in base 'ShaderMaterial'`。
- 原因:该方法属于 **`Shader`**,不属于 `ShaderMaterial`。
- 修复:改为 `cartoon_material.shader.get_shader_uniform_list()`(先判空)。
- 同时把 `get_surface_override_material(mi.material_override)` 返回的 `Material` 用显式 `is ShaderMaterial` 判断 + 声明式 `var x: ShaderMaterial` 赋值,避免隐式向下转换告警。
- 环境:Godot 4.7,Forward Plus,config_version=5。本机未找到 Godot CLI,无法自动语法校验。
+142
View File
@@ -0,0 +1,142 @@
# 2026-10-14
## shader/toon_apply.gd 自动刷新可行性咨询(未改动任何文件)
- 项目 Godot 4.7 Forward+。
- 现状:`toon_apply.gd` 是 @tool Node3D,`_apply_materials()` 对每个子 MeshInstance3D `duplicate()` 一份 ShaderMaterial 并 `material_override`。
- 结论:
1. 保存/编辑 `toon_apply.gd` 本身 → 编辑器热重载 @tool 脚本会重跑 `_ready()`,已能自动刷新(4.3 有热重载失效 bug,4.4+ 已修,本项目 4.7 无此问题)。
2. 编辑 `shader/toon.tres` 的 shader 参数(color / uv_scale 等)→ **不会**自动刷新。原因:ShaderMaterial 的 `Resource.changed` 仅在 resource_name 变化或 shader 本身变化时发出,参数改动不发出(官方文档明确);且每网格是 duplicate 的副本,与模板已无关联。
3. 若要 2 也自动刷新,需 EditorPlugin 监听 `EditorInterface.get_inspector().property_edited`(或 EditorFileSystem),回调里调用 `_apply_materials()`。
- 备选架构:改用 `GeometryInstance3D.set_instance_shader_parameter()` 的 per-instance uniform,可不 duplicate、直接共享 toon.tres,改参数即实时生效;但限制是**不支持贴图/数组**、每 shader 上限 16 个。本项目需要 per-mesh 的 `base_texture`,因此该方案只能覆盖 color 之类的标量/向量参数,贴图仍需 duplicate 或走 Texture2DArray + index。
## 追加:评估 per-instance uniform + Texture2DArray 方案(结论:不适合本项目)
- 实测贴图尺寸(脚本扫 PNG 头):全项目 289 张 PNG / 24 种尺寸。
- `assets` 180 张:1024×1024 ×108、512×512 ×40、4096×4096 ×25、2048×2048 ×5
- `assets/自建资产` 18 张:4096×4096 ×13(地板、地毯 PBR 套图)
- `character` 81 张:32×32 ×70、512×512 ×4、1024×1024 ×1
- `demo` 28 张:15 种尺寸混杂
- 结论:Texture2DArray 要求所有层同尺寸/同格式/同 mipmap,只能统一到 4096(单层带 mipmap 约 85 MiB 无损 / 21 MiB VRAM 压缩),几百张即 GB 级显存,且需额外打包流水线 → **per-instance uniform 路线不划算**,除非贴图少且尺寸统一。
- 推荐路线(未实施):
- A:保留 duplicate,加 EditorPlugin 监听 `EditorInspector.property_edited`,命中 toon.tres 时重刷 `_apply_materials()`(可加防抖)。
- B:把公共外观参数(color/specular/fresnel/normal_scale/uv_scale/uv_offset)提升为 `global uniform`(项目设置 → Shader Globals 定义),调参全局实时生效、无需重刷;贴图与 per-model 覆盖仍留在 duplicate 材质上。代价:需改 cel-shader-base.gdshader / outline.gdshader 并清掉 toon.tres 里的同名参数;全局值会写入 project.godot,需团队一起提交。
## 已实施:路线 A(新增 addons/toon_refresh 编辑器插件)
用户选择 A。改动:
- 新增 `addons/toon_refresh/plugin.cfg` + `plugin.gd`(EditorPlugin)。
- `project.godot` 的 `[editor_plugins] enabled` 追加 `res://addons/toon_refresh/plugin.cfg`。
实现要点:
- 监听 `EditorInterface.get_inspector().property_edited`,命中 `res://shader/toon.tres` 或其内联子资源时,0.2s 防抖后遍历当前编辑场景,对脚本文件名为 `toon_apply.gd` 且有 `_apply_materials` 的节点调用该方法。
- 另加 项目 > 工具 菜单项「刷新 toon 材质(当前场景)」作手动兜底。
- 只影响当前编辑场景;不覆盖手动改过的单个材质副本。
已实测确认的事实(重要,别再靠猜):
- **`toon.tres` 的 `next_pass` 内联子资源,`resource_path` 不是空串**,而是 `res://shader/toon.tres::ShaderMaterial_m5gih` 这种带 `::` 后缀的形式 → 匹配逻辑用 `begins_with(TARGET + "::")`。
- `ShaderMaterial.changed` **不会**因 `set_shader_parameter()` 触发;因改 `resource_name` 触发(与官方文档一致)。
- 编辑器路径:`D:\_yvmou\Godot\Godot_v4.7.2-stable_win64.exe\Godot_v4.7.2-stable_win64.exe`(另有 mono 版)。可用 `--headless --editor --path E:/Godot/agame --quit` 做插件加载冒烟测试;用 `--check-only --script res://xxx.gd` 做语法校验。
- 该项目 headless 启动时 godotsteam / limboai 的 GDExtension DLL 会报加载失败(既有问题,与本次改动无关)。
项目相关:`[shader_globals]` 里已有 diffuse_curve、specular_smoothness、fresnel_smoothness、outline_width、outline_color —— 描边与光照曲线本来就是全局 uniform,调参实时生效;真正需要重刷的是 toon.tres 上的材质级参数。
## 修复:USE_NORMAL_MAP=1 时"平视全场景变暗、俯瞰才亮"
用户报告的症状。根因是 `cel-shader-base.gdshader` 里的一行:
`NORMAL = normal.vector;`(原第 121 行)——`normal.vector` 是**切线空间**的法线贴图值,
而 Godot 4 的 `fragment()` 里 `NORMAL` 是**视图空间**(官方文档明确写
"inout vec3 NORMAL ... in view space")。把切线空间值写进视图空间的 NORMAL,
等于让所有像素的法线都指向"视图空间 +Z"≈ 朝向相机的一个常量 →
整个场景明暗只由相机朝向决定:平视时与光方向近乎垂直/反向 → 全黑且无明暗起伏;
俯瞰时与向下打的光对齐 → 恢复正常。`normal_map_fragment()` 里算好的正确视图空间法线
`ref_normal` 被算出来后就丢掉了(死代码)。
连带问题:`normal_map` 这个 uniform 从来没被赋过值(toon_apply.gd 只抓 base_texture),
采样到引擎默认纹理后算出的法线同样是垃圾。main2.tscn 里 413 个 toon_apply 节点刷出的
434 个网格中,430 个都没有法线贴图。
改动:
- `shader/includes/normal-map.gdshaderinc`:删掉 `ref_normal` / `vector.xy*2-1` / `vector.z`
重建等死代码,改为返回 `NormalData(map, normal_scale)`,map 就是原始纹理值(切线空间),
蓝通道交给引擎重建;补上坐标空间说明。
- `shader/cel-shader-base.gdshader`:`USE_NORMAL_MAP` 分支只写
`NORMAL_MAP = normal.map; NORMAL_MAP_DEPTH = normal.depth;`,不再碰 `NORMAL`。
- `shader/toon_apply.gd`:抓 `normal_texture`(StandardMaterial3D)或 `normal_map`
(ShaderMaterial)→ 有就赋给副本;**没有就显式把 `normal_scale` 设为 0.0**,否则会采样默认纹理。
验证(真实场景跑法,见 MEMORY.md):
- 对照实验确认 dummy 渲染后端仍会报 `SHADER ERROR`,所以无头运行可校验 shader 语法。
- 加载 `world/main2.tscn` 零 shader 错误、零脚本错误。
- 功能断言:toon_apply 子树 434 个网格全部应用成功;430 个 `normal_scale=0.0`(无贴图),
4 个 `normal_scale=1.0` 且 `normal_map` 已设置(源材质确实带法线贴图)。
遗留待用户确认(未改动):
- `world/main2.tscn` 的 `model/floor1`(不归 toon_apply 管,用自建 `surface_material_override/0`)
上 `normal_scale = -1.7749993243124995`,在新语义下等于 `NORMAL_MAP_DEPTH = -1.775`(反向外插),
几乎不可能是想要的,建议改成 1.0。
- `toon_apply.gd` 只读 `mesh.surface_get_material(0)`,忽略了 `surface_material_override`,
对把材质放在节点覆盖槽上的模型会抓不到贴图(本次未改,属潜在问题)。
## 排查:亮色模型"像自发光"(未改动任何项目文件)
用户问"没开自发光,为什么颜色较亮时有自发光感"。用 Godot 无头脚本采样截图
(`Image.load_from_file` + 逐像素统计,临时脚本已删):
- 红篮区域(25988 样本):**37.1% 的像素有通道被钉在 255**,均值 (164,57,56)
- 抽屉顶面(7616 样本):**63.0% 触顶,4758 个纯白像素**,均值 (212,212,205)
- 深色柜体正面(10048 样本):仅 **3.3% 触顶**,均值 (108,89,74)
- 背景:66452 样本 min==max==(54,72,87) → **纯色平涂,不是天空盒**
结论:是**通道削顶(clipping)**,不是自发光。shader 里 `USE_EMISSION 0`,且 toon.tres
的 specular/fresnel 的 rgb 都是 0 → 唯一光照项就是 `ALBEDO × LIGHT_COLOR × 阶梯`。
亮色/高饱和色在线性空间里本来就有通道接近 1.0,光照只要 >1 就把它钉死 →
整片变成没有渐变的满色 → 观感就像发光;中等亮度色需要 3 倍以上才触顶,所以正常。
阶梯曲线本身是平的(`new_gradient_texture_1d.tres`:u<0.127→0,0.14~0.50→0.198,
u>0.523→1.0),削顶后连最后一点层次也没了。
项目里的过曝来源候选:
- `world/main2.tscn` 的 SpotLight3D `sconce`:`light_energy = 2.655`、`light_indirect_energy = 2.41`
- `world/main.tscn` 的 Environment:`ambient_light_source = 3`(天空)+ 自定义天空
`atm_sun_intensity = 18`、`sun_disk_intensity = 30` → 环境光辐照度可远超 1
- **矛盾点(可用来定位)**:main.tscn 的 Environment 是 `tonemap_mode = 3`(ACES) + `tonemap_white = 6.0`,
ACES 会把高光滚降,理论上几乎不可能出现纯 255(输入 4.0 也只到 ~253)。但截图里大面积纯 255
→ 说明被观察的视口**没有套用那套 ACES 环境**:要么看的是 main2.tscn(自身无 WorldEnvironment、
无 tonemap → 硬削顶),要么编辑器 3D 视口没启用场景环境。
- 另一个坑:toon.tres 的 `color = 0.549` 带 `source_color`,线性空间里只有 **0.26**,
toon 管线把反照率整体压掉约 4 倍 → 亮暗反差被拉得很大。
- `world/main2.tscn` 里没有任何 `use_custom_color = true`,所以不存在自定义颜色覆盖。
- `world/main.tscn` 通过 ext_resource 把 `main2.tscn` 作为子场景实例化。
待用户确认:具体是哪个场景/哪个物体,以便定位"材质来源 + 光源 + 环境"这条链路。
## 重要:上游 godot4-cel-shader 的法线贴图路径是"从未运行过的死代码"
用户质疑"着色器源自 https://github.com/eldskald/godot4-cel-shader,理应无需修改"。
本地有干净的上游副本 `E:/Godot/godot4-cel-shader-main/godot4-cel-shader-main/`,比对结果:
- 本项目改动前的 `cel-shader-base.gdshader` / `includes/normal-map.gdshaderinc`
**与上游逐字一致**(包括 `NORMAL = normal.vector;` 和 `ref_normal` 算完即丢)。→ 当时不是本项目改坏的。
- 但上游 5 个 shader 变体**全部是 `#define USE_NORMAL_MAP 0`**,全仓库没有任何一处写成 1,
README 里 "normal" 出现 0 次 → **这条路径上游从未启用、从未测试过**。
- 本项目把它开成 1,才第一次走进这段代码,于是坐标空间错误暴露。
- 结论:**只要 `USE_NORMAL_MAP 1`,那行就必须改**;想严格保持上游 1:1,唯一办法是把开关保持 0。
- Godot 侧证据:引擎自己也会再做一次 NORMAL_MAP 合成
(Godot 源码 clearcoat 等价实现:`normalize(tangent*map.x + binormal*map.y + normal*map.z)`),
公式里的 `normal` 就是被污染过的 NORMAL → 污染会传播;且 depth=0 也无法"中和",
因为 mix 的基值本身就是被写坏的 NORMAL。
给用户的三个选项(待其选择):
A. `USE_NORMAL_MAP` 改回 0,并把两个 shader 文件完全还原成上游(代价:实测 434 个网格里那 4 个带法线贴图的会失去效果)
B. 只保留 `cel-shader-base.gdshader` 删掉 `NORMAL = normal.vector;` 这一行的差异,include 还原上游(1 行差异)
C. 保留当前版本(include 里的死代码也清掉,与上游差异最大但最干净)
### 已实施:用户选择 A
- 用 git 精确还原:`git checkout -- shader/cel-shader-base.gdshader shader/includes/normal-map.gdshaderinc shader/toon_apply.gd`
(先 `git diff` 逐 hunk 确认过全部改动都是本次会话产生的,没有混入用户未提交的修改)。
- 再把 `#define USE_NORMAL_MAP 1` 改成 `0`(HEAD 里本来就是 1,是用户自己开的)。
- 结果:`normal-map.gdshaderinc` 与上游**逐字一致**;`cel-shader-base.gdshader` 与上游的差异
只剩项目自己加的中文注释行(值已与上游相同);`toon_apply.gd` 无改动。
- 验证:`--headless res://world/main2.tscn --quit-after 60` 无 shader / 脚本 / 解析错误。
- 注意:`toon.tres` 里仍有 `shader_parameter/normal_scale = 1.0`,宏关闭时被忽略,无副作用;
`world/main2.tscn` 里 `model/floor1` 那份自建材质的 `normal_scale = -1.775` 同样被忽略。
+205
View File
@@ -0,0 +1,205 @@
# 2026-10-15
## 玩家状态管理迁移到 LimboAI LimboHSM
- 需求:玩家场景的状态管理完全用 LimboHSM 实现(addons/limboai v1.8.1,GDExtension 版,
**无需启用 editor plugin**,ClassDB 里没有 HSMPlayer —— LimboHSM/LimboState 本身就是 Node)。
- 方案讨论:BT vs HSM 选型 → 玩家是人操作的(决策已由人完成),用 HSM 建模身体状态;
BT 留给将来的 NPC/autopilot。
- 新增 `character/player/hsm/`(11 个脚本):
- `player_hsm.gd`(根,转换表 Grounded⇄Airborne:`airborne`/`landed` 事件)
- `grounded.gd`(Grounded 子机:12 条转换 + `_enter` 按当下输入挑初始子状态)
- `airborne.gd`(Jump→Fall on `falling`;`_enter` 按 vy>0 挑 Jump 否则 Fall)
- `ground_state.gd`(地面叶子基类:统一判 try_jump/踏空 → `airborne`,再走 `_think()`)
- idle/walk/sprint/crouch/sneak.gd(写 motor.target_speed/crouching/sprinting + 动画)
- jump.gd/fall.gd(落地判定 `is_on_floor and vy<=0`,带 vy 门槛防起跳帧弹回)
- 改动:
- `player.gd`:加 `class_name Player`;挂 HSM;`_ready` 末尾 `hsm.initialize(self)` +
`hsm.set_active(true)`(仅权威端);管线第一位 `hsm.update(delta)`;animator 改调 `set_gait`。
- `player_motor.gd`:删掉从输入推断 sprint/crouch/target 的逻辑,暴露
`target_speed`(状态写)、`has_move_input()`、`try_jump()`(起跳在状态判定处就地执行,
同帧转 Jump);crouching/sprinting 变成状态写入的事实镜像(Look/DebugPanel 继续用)。
- `player_animator.gd`:`set_motion` 拆成 `play_state(state)`(状态 _enter 调)+
`set_gait(velocity)`(每帧刷倍率);删 `_resolve_state` 和 `idle_threshold`。
anim_state/anim_speed 同步属性保持不变(远端靠 Sync 复制驱动)。
- `player.tscn`:HSM 节点子树(LimboHSM root + Grounded{Idle,Walk,Sprint,Crouch,Sneak} +
Airborne{Jump,Fall}),根机器 `update_mode = 2`(MANUAL,编辑器里写入 int 失败,
手改 tscn 后 force_reload 场景生效)。
- 行为变化(有意为之):行走/站立切换改按**输入**判定(旧版按速度阈值 0.2 m/s),
顶墙时播 walk 而非 idle;其余(蹲伏优先于疾跑、跳跃缓冲、落地按输入挑状态)与旧版一致。
- 验证:全部脚本 `--check-only` 通过;无头探针场景模拟输入跑完整状态链
(Idle→Walk→Sprint→Sneak→Crouch→Idle→Walk→Jump→落地Walk→Idle),数值与理论吻合。
## 二次重写:小人国定标(同日)
- 用户明确:不保旧手感、可重写;**小人国玩法,角色身高约 0.2 m**;旧代码可删。
- 定标(全部固化进 player_motor.gd 默认值,删掉了 tscn 里的 walk/sprint 覆盖):
walk 2.419(≈12 身长/s)、sprint 3.624(≈18 身长/s)、crouch 1.21(走速一半)、
accel 22(0.11s 到全速)、air_accel 5.9(地面 1/4)、jump 1.5 / gravity_scale 0.5
(跳高 0.23 m ≈ 1.15 身高、滞空 0.61 s)不变。DebugPanel body_height 0.15 → 0.2。
- **修掉一个真 bug**:场景里 walk 被调到 2.419 后,动画器基准速度还是 0.4286,
walk/run 动画一直被 clamp 在 1.6× 上限。重构:删掉 animator 的 4 个 *_ref_speed 导出,
`play_state(state, ref_speed)` 由 HSM 状态传入基准速度(状态知道目标速度是多少),
`set_gait` 用存下的 `_ref_speed` 算倍率 —— 两处对表的坑没了。
- 探针复测:三个位移动画全速倍率均 1.00,转换链不变。
### 踩坑(复用注意)
1. **无头 `--check-only` 对新 class_name 报「Could not find type」**:
全局类缓存 `.godot/global_script_class_cache.cfg` 没刷新。让编辑器跑一次
`EditorFileSystem.scan()`(godot_ai MCP 的 filesystem scan)即解;批量循环检查时
同一批里前后结果不一致也是这个原因。
2. **godot-ai MCP `node_set_property` 写 enum int 属性失败**
(WRONG_TYPE: Cannot write String to a int property,value 传 JSON number 也一样)。
变通:MCP scene_save 后直接手改 .tscn 补属性行,再 `scene_open force_reload=true`。
3. **MCP 场景节点路径**要含场景根:`/Player/HSM`,不是 `/HSM`。
4. **LimboHSM 嵌套用法**:子状态机的 `_enter`(脚本 override)先于其 initial_state 激活执行,
所以可以在 `_enter` 里 `set_initial_state()` 动态挑入口子状态(落地按输入挑、空中按 vy 挑)。
5. **转换触发的环境判定要放叶子状态**,不要放父机 `_update`:父机先于子状态执行,
在父机里 dispatch 转换后,本轮级联还可能调到已退出的旧子状态(叶子自己判、dispatch 后
立即 return,没有这个窗口)。
6. 无头模拟按键:`Input.action_press` 只改动作状态、**不发输入事件**(Motor 的跳跃缓冲在
`_unhandled_input` 收事件,探不到);要用 `Input.parse_input_event(InputEventAction)`。
---
## main2 卡通渲染 / 世界环境 / 光照 调优(达到可交付观感)
目标:把 `world/main2.tscn` 的卡通着色器参数 + WorldEnvironment + 光照调到"商业化"水平。
改了 5 个文件:`project.godot`、`world/main2.tscn`、`shader/toon.tres`、
新增 `shader/toon_ramp_1d.tres`、删除 `shader/new_gradient_texture_1d.tres`。
### 动画诊断出的三个致命问题
1. **描边等于不存在**。`cel-shader-base` 的 `next_pass` 链、`outline.gdshader` 都正常,
但 `outline_width = 2.0` 在这个 Godot 版本里只对应约 **0.1px**,肉眼不可能看见。
实测标定:`像素宽度 ≈ outline_width × 0.057`(1364px 视口)。已改为 **30**(≈1.7px)。
2. **`toon.tres` 的 `specular` / `fresnel` RGB 都是纯黑** —— 两个模块虽然连着,贡献恒为 0。
3. **漫反射渐变是"黑→硬边→白"的二值 ramp**,导致场景只有一层死板明暗。
### 最关键的一条坑(务必记住)
**编辑器只在启动时注册 `[shader_globals]`。** 外部(含 MCP `settings_set`)改
project.godot 的全局着色器参数,正在跑的编辑器视口**完全不变**。
我就是因为这个,一开始把 `outline_width` 从 2 调到 10 再到 60,截图却毫无变化,
最后才定位到根因。验证手段:写一个 `res://tests/test_*.gd`(必须 `extends McpTestSuite`),
在 `test_*` 方法里直接 `RenderingServer.global_shader_parameter_set(...)`,
再用 MCP `test_run` 执行 —— 能在编辑器进程内把值推进渲染器。
同时用这个办法做了隔离实验,排除了 4 个错误猜想:雾、SSAO、泛光、菲涅尔**都不是**
墙面噪点的来源;真正的噪点是**截图传输压缩**(480px 截图墙面完全干净,1364px 才有颗粒)。
另外顺手证实 `POSITION` 顶点覆盖是生效的(写 `p.y += p.w * 0.8` 面板会整体下移),
所以描边 pass 本身没问题,别去改上游 `outline.gdshader`。
### 最终取值
- **着色器全局**(`project.godot [shader_globals]`):
`diffuse_curve = toon_ramp_1d.tres`、`specular_smoothness = 0.08`、
`fresnel_smoothness = 0.12`、`outline_width = 30`、
`outline_color = Color(0.08, 0.07, 0.1, 1)`。
- **新 ramp**(`shader/toon_ramp_1d.tres`,GradientTexture1D/256):
0.00–0.30 → 0.24;0.38–0.62 → 0.60;0.70–1.00 → 1.00,两级转场各 0.08 软边。
- **材质**(`shader/toon.tres`,以及 main2 里地板的独立 ShaderMaterial):
`specular = (0.12,0.12,0.12,0.45)`、`fresnel = (0.4,0.55,0.8,0.18)`。
- **Environment**:天空换成项目现成的 `world/sky/cartoon_sky.tres`(与 character_select /
ocean_demo 一致);`ambient_light_source = 3`(Sky) + 冷色补光
`(0.3,0.38,0.55)`、sky_contribution 0.45、energy 0.5;ACES 色调映射(`tonemap_mode=3`,
这个版本枚举确实是 LINEAR0/REINHARDT1/FILMIC2/ACES3/AGX4);SSAO 轻度(intensity 1.0、
detail 0.3、light_affect 0);Glow 阈值抬到 2.2 防止白面爆光;雾 0.004 + aerial 0.3;
adjustment contrast 1.12 / saturation 1.08。
- **光照**:平行光改暖色 `(1,0.925,0.83)`、energy 0.95、`light_angular_distance 0.8` 软阴影、
bias 0.05 / n_bias 1.5、max_distance 60;sconce 聚光灯 energy 3.2 / range 12 /
angle 55 / 开启阴影。顺手开了 `anti_aliasing/quality/msaa_3d = 2`(4x,描边抗锯齿)。
### 排查时踩的 MCP 细节
- MCP `settings_set` **写不了 Color**:传 `{"r":..,"g":..}` 会落成嵌套字典、
传 `"#141219"` 会落成 String,两者都不是引擎要的 Color。
正解是手改 project.godot 写成 `Color(r,g,b,a)`。改完用无头探针脚本
(`extends SceneTree` + `ProjectSettings.get_setting()` 打印 `typeof(inner)`)复验。
- `settings_set` 会把**整个** ProjectSettings 从内存写回磁盘,
所以它写完之后我手改过的 `Color(...)` 会被覆盖成 String —— **必须最后一步再手改**。
- `editor_screenshot` 的 `view_target` 会**移动用户的编辑器相机**;`max_resolution=0`
才是原生分辨率。
### 追加:远处地面"发白"的根因 = fresnel 掠射角饱和(务必记住)
用户反馈:main2 里走廊/餐厅方向的地面(z≈15~25)呈一片**灰白偏冷的平面**,近处木地板正常。
- 排查路径:`floor1.glb` 只有 1 个 mesh / 1 个材质(用 Python 解 GLB 的 JSON chunk 确认),
AABB 覆盖 x −18.2~16.6、z −24.9~32.9 —— **餐厅地面就是这块地板**,不是缺几何、
也不是贴图问题(BaseColor 是纯木纹)。地毯不泛白是因为它的固有色深,把叠加量盖住了。
- **根因在 `shader/includes/base-cel-shader.gdshaderinc` 的 `fresnel_light()`**:
```
fresnel_dot = 1.0 - dot(normal, view); // 水平面越远越掠射 → 越接近 1
fresnel_threshold = pow(1.0 - fresnel_strength, dot(light, normal)); // ≈0.84
... * smoothstep(threshold ± fresnel_smoothness/2, fresnel_dot)
```
实测:5 m 处 fresnel_dot≈0.69(=0,无贡献),20 m 处≈0.92(=1,满强度)。
于是**远处地面被整片点亮**,叠加的是 `fresnel_color = (0.4,0.55,0.8)` 蓝白,
木地板 albedo(≈0.34,0.24,0.2) + 叠光(≈0.40,0.51,0.66) → 就是用户看到的灰白。
- **是上一轮调优引入的**:`fresnel` 原来 RGB=(0,0,0)(贡献恒 0,所以从没显形),
上一轮改成 (0.4,0.55,0.8) 后才出现。**大平面(地板/天花板)上放菲涅尔轮廓光必然有这副作用。**
- 修法(择一):`fresnel.a` 调到 0.05 以下;或把 `fresnel_color` 压暗到接近物色;
或把 `fresnel_smoothness`(全局 uniform)调小收紧过渡带。当前已按用户要求
**恢复为 (0.4,0.55,0.8,0.18)**,泛白会重现,等用户定夺。
### 追加:MCP `material_manage(apply_to_node)` 会写盘并**删除孤儿资源**
- 用 MCP `apply_to_node` 给 `model/floor1/pPlane1` 临时套一个纯红 unshaded 材质做隔离实验,
之后 `scene_open force_reload` **材质没有恢复** —— 因为该操作**直接改了磁盘上的 .tscn**
(返回值里的 `saved_to: ""` 不代表没落盘)。
- 更严重:Godot 重新序列化时把**失去引用的资源全删了** —— 地板配套的
`ShaderMaterial_mmyc1`(cel + outline)、`ShaderMaterial_tyxef`,
以及 3 个 `ext_resource`(`outline.gdshader`/`cel-shader-base.gdshader`/地板 BaseColor 贴图)
一并消失,同时把等于默认值的属性行省略(如 `glow_blend_mode = 1`)。
- **教训:拿 MCP 改材质做实验前先 `git stash` 或备份 .tscn**;事后用 `git diff` 对照逐行恢复。
恢复时注意用**调优后的值**(不是 HEAD 的原值)。
## 阴影锯齿专项(main2 沙发/盘子)
- 现象:沙发扶手上大片「梳齿/蕨叶」状黑纹 + 盘子影子边缘阶梯状锯齿。
- 逐个隔离(改 `shadow_enabled` / 单变量逐项)后确认:**都来自 `DirectionalLight3D`**。
- **根因 = `light_angular_distance = 0.8`**。它开启 PCSS 软阴影,半影半径随距离放大,
但 Godot 的采样数有限 → 出现稀疏采样尖刺(就是那片蕨叶纹)。
归零后(`0.0`)两个 artifact **同时消失**。
- 次要项:工程设置 `soft_shadow_filter_quality` 默认是 **2(Soft Low)**,
提到 **4(Soft High)** 后高频噪点能量 1.149 → 0.687(-40%),强跳变 756 → 630。
- `shadow_blur`(1.0/1.5/2.5/4.0)实测**几乎无影响**(0.669~0.687),已删掉该覆盖行。
- `shadow_bias`/`shadow_normal_bias` 归零会导致**全身自阴影爆发**(阴影面积 18484 → 114706),
必须用引擎默认 0.1 / 2.0(原场景的 0.05 / 1.5 偏低)。
- 最终:`light_angular_distance = 0.0`、删掉 bias/blur 覆盖行、
`directional_shadow_max_distance 60 → 45`、`project.godot` 加
`lights_and_shadows/{directional_shadow,positional_shadow}/soft_shadow_filter_quality=4`。
- 前后对比图存在 `docs/shadow_aliasing_fix.png`。
### 追加:用户把 blur 归零后「全画面三角形颗粒」
用户手动把 `shadow_blur = 0`,**同时把 `shadow_normal_bias` 也设成了 0**,
结果整幅画面(沙发、盘子、地板全部)盖满斜向的三角颗粒 / 摩尔纹。
在独立进程里做了 2×2 对照(`soft_shadow_filter_quality = 4`,
指标 = 区域内相邻像素亮度差的平均,越低越干净):
| 组合 | 4096 贴图 | 8192 贴图 |
|---|---|---|
| blur 0 + normal_bias 0(**用户当时**) | **12.730** | **17.923** |
| blur 0 + normal_bias 2 | 0.638 | 0.611 |
| blur 1 + normal_bias 0 | 0.566 | 0.522 |
| blur 1 + normal_bias 2(推荐) | 0.577 | 0.532 |
| blur 0.6 + normal_bias 2 + max_dist 25 | 0.546 | 0.537 |
结论:
- **必须「blur=0 且 normal_bias=0」同时成立才会爆**(12.7)。任意改回一个就回到 ~0.6。
机制:`normal_bias = 0` 会在**每个阴影贴图纹素**上产生自阴影判定抖动,
而 `shadow_blur` 正是那个 PCF 半径 —— 它被归零后抖动不再被平滑,
于是以贴图纹素的频率原样显现成三角颗粒(贴图纹素斜投影到受光面就是四边形/三角)。
- **加大阴影贴图分辨率没用,反而更糟**(12.730 → 17.923)。
分辨率越高的贴图纹素越细密 → 抖动频率越高 → 颗粒越密越明显。
**所以"锯齿是阴影贴图不够大"是错的直觉。**
- 想要更锐的阴影:`shadow_blur` 可以取 0(normal_bias 保持 2.0 就干净,0.638)。
- 已把编辑器的 `shadow_normal_bias` 恢复到 2.0、`shadow_blur` 恢复到 1.0
(用 MCP `node_set_property`,**实测它只改编辑器内存、不写盘**)。
- 对比图存在 `docs/shadow_normal_bias_fix.png`。
+26
View File
@@ -0,0 +1,26 @@
# 2026-10-16
## 修复:第一人称相机高达 1.16 m(玩家缩放被静默抹掉)
- 现象:进游戏后第一人称视角像一米多高看得见 woman 的胸口;DebugPanel 显示
「相机高度 1.161 m / 5.80 身高」。
- 根因:`world/main.gd` 的 `_create_player()` 里写 `player.global_position = ...`,而该节点
**此时还没入树**(MultiplayerSpawner 之后才 add_child)。`Node3D.get_global_transform()`
在未入树时 `ERR_FAIL` 返回**单位矩阵**,`set_global_transform()` 顺势把 Player 根节点的
basis 换成单位阵 → **player.tscn 那层 0.096096 缩放被抹掉**。
- 无头探针对照(复现两种写法,同一份 player.tscn):
| 写法 | Player.global_scale | 模型世界高 | 相机相对脚底 |
|---|---|---|---|
| 旧 `global_position` | 1.0 | 1.561 m | **1.1610 m** |
| 新 `position` | 0.096096 | 0.150 m | **0.1116 m** |
旧写法的 1.1610 m 与游戏内面板的 1.161 m 逐位吻合;控制台每个玩家还会刷一条
`ERROR: Condition "!is_inside_tree()" is true.`。
- 修复:`player.position = player_spawn_pos.position`(Main 与 PlayerSpawnPos 同级、Main 无变换,
局部坐标即世界坐标)。`--check-only` 通过。
- 离屏渲染三张对照图(woman 1.703 m 站位旁):`probe_fix_A.png`(同框,旧写法巨鼠几乎挡住
woman)、`probe_fix_B.png`(woman 脚边,修复后 15 cm 小鼠)、`probe_fix_C.png`(修复后
第一人称,面板读数 0.112 m / 0.56 身高)。
- 附带确认:`InteractionRay` 世界长度在两种状态下都是 0.25 m(`Camera3D` 全局缩放恒为 1.0),
所以修缩放不影响拾取距离。
+212
View File
@@ -0,0 +1,212 @@
# agame 项目长期笔记
Godot 4.7.2(Forward+,Jolt Physics,D3D12)。**可用编辑器(2026-10-16 实测):
`E:\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64.exe`
(mono 版;同目录另有 `..._console.exe`)。**
⚠️ 旧记录里的 `D:\_yvmou\Godot\...` **已失效 —— D 盘根本没有 `_yvmou` 目录**,别再照抄。
## 代码约定
- **GDScript 警告被当作错误**(至少 `inference_on_variant`)。不要写成
`var x := some_variant_returning_call()`;要显式写类型,例如
`var s: Script = node.get_script() as Script`。改完脚本务必用
`--check-only --script res://xxx.gd` 自查。
- 缩进用 tab,注释用中文(项目既有风格)。
## 无头验证的正确姿势(踩过坑)
- `--check-only --script res://x.gd`:GDScript 语法校验,有效。
- `godot --headless --editor --path <proj> --quit`:验证编辑器插件能否加载,有效。
- **不要**用 `extends SceneTree` 的 `--script` 脚本去测 `_ready()` 相关行为:
这种模式下节点不会真正进入场景树(`is_inside_tree()` 为 false),`_ready` 不执行,
会得出完全错误的结论。正确做法是造一个临时场景,用位置参数当主场景跑:
`godot --headless --path <proj> res://tools/_tmp_probe.tscn`。
- 即使是 `--headless`(dummy 渲染后端),**shader 编译错误仍会被报告**
(`SHADER ERROR: ...`),所以可以用无头运行来校验 shader 语法。
## 玩家 = LimboAI LimboHSM(2026-10-15 起)
- LimboAI v1.8.1 是 **GDExtension 版**(addons/limboai 只有 bin/,无 plugin.cfg),
**不需要启用 editor plugin**;ClassDB 里没有 HSMPlayer —— `LimboHSM`/`LimboState` 本身就是 Node。
- 玩家状态机在 `character/player/hsm/`:根 `player_hsm.gd`(Grounded⇄Airborne)+
Grounded{Idle,Walk,Sprint,Crouch,Sneak} + Airborne{Jump,Fall},全是场景节点、脚本挂状态上。
- 分工:**HSM 管「是什么状态」**(写 motor.target_speed/crouching/sprinting + 调
animator.play_state),**motor 只管物理**(重力/加速度/跳跃缓冲 + try_jump),
animator 只管「怎么播」(play_state + set_gait 刷倍率)。player.gd 管线第一位
`hsm.update(delta)`(MANUAL 模式,仅权威端 initialize+set_active;远端靠 Sync 复制)。
- 走/站立转换按**输入**判定(非旧版的速度阈值)——顶墙时播 walk 不播 idle,是预期行为。
- 用法要点:子机 `_enter` 里可 `set_initial_state()` 动态挑入口子状态;环境判定
(起跳/踏空/落地)放**叶子**状态的 _update,别放父机;落地判定要带 `vy<=0` 门槛。
- 踩坑:新 class_name 后无头 `--check-only` 报「Could not find type」= 全局类缓存没刷新,
让编辑器 scan 一次即解;MCP node_set_property 写 enum int 失败 → 手改 tscn + 场景 force_reload。
## toon 卡通渲染管线
- `shader/cel-shader-base.gdshader`(`USE_*` 宏开关各模块)+ `shader/includes/*.gdshaderinc`,
上游是 godot4-cel-shader 项目。
- `shader/toon.tres` 是材质模板;`shader/outline.gdshader` 作为 `next_pass` 描边。
- `shader/toon_apply.gd`(@tool Node3D)给每个 MeshInstance3D 复制一份独立材质,
因为**每个模型有各自的贴图**,所以必须独立(不能共享一份材质)。
- 已在项目设置里用了 **global uniform**:`diffuse_curve`、`specular_smoothness`、
`fresnel_smoothness`、`outline_width`、`outline_color`。
⚠️ **但"全局实时生效"是错的**(2026-10-15 实测纠正):编辑器只在**启动时**
把 `[shader_globals]` 注册进 RenderingServer,之后外部改 project.godot(或
MCP 的 `project_manage settings_set`)**不会**影响正在运行的编辑器视口。
手动改完全局参数必须**重启编辑器**(或临时用 `test_run` 跑一段
`RenderingServer.global_shader_parameter_set()` 把值推进去)才能看到效果。
这正是当初"改了 outline_width 却毫无变化"的原因。
- `outline_width` 的实际像素宽度约为 `width × 0.057`(1364px 视口实测校准),
所以**原来的 2.0 实际只有约 0.1px —— 等于没描边**。要看到 2px 描边需要 width ≈ 30。
- 贴图尺寸极不统一(289 张 PNG / 24 种尺寸,1024²、512²、4096²、32² 混杂),
因此**不适合** Texture2DArray + per-instance uniform 方案(显存会爆且要额外打包流水线)。
- 编辑器插件 `addons/toon_refresh/`:改 toon.tres 时自动重刷 toon_apply 材质。
**只覆盖材质级参数**(color/specular/fresnel/uv),管不到 global uniform。
- ⚠️ **`fresnel`(轮廓光)不能给大平面用**:`fresnel_light()` 以
`1 - dot(normal, view)` 为激活量,水平地面/天花板稍远就是掠射角 → 直接饱和成满强度,
整片平面被 `fresnel_color` 点亮。**"远处地面发白"就是这么来的**
(原来是 (0,0,0) 贡献恒 0 所以没暴露,2026-10-15 改成 (0.4,0.55,0.8) 后显形)。
真要留轮廓光就把 `a` 压到 ≤0.05,或把颜色调暗到接近物色。
- ⚠️ **MCP `material_manage(op="apply_to_node")` 直接改磁盘 .tscn**,
且重新序列化时会**删除失去引用的子资源与 ext_resource**
(实测:地板实验后 `ShaderMaterial_mmyc1`/`tyxef` + 3 个 ext_resource 全被清掉,
`saved_to:""` 也照样落盘)。拿 MCP 做材质实验前先备份,事后用 `git diff` 逐行核对恢复。
## 卡通渲染调参(2026-10-15 定的基线取值)
- `shader/toon_ramp_1d.tres`(新,替代已删除的 `new_gradient_texture_1d.tres`):
三级 cel 明暗带,灰阶 0.24 / 0.60 / 1.00,转场各留 0.08 软边。
注意 `diffuse_sample()` **只取红通道**,所以渐变做成彩色也没用。
- `specular_smoothness = 0.08`、`fresnel_smoothness = 0.12`;
原值 10.0 会把高光压到几乎不可见(`smoothstep(0.05, 10.05, x)`)。
- `outline_width = 30`、`outline_color = Color(0.08, 0.07, 0.1, 1)`。
- `toon.tres` 的 `specular` / `fresnel` **RGB 原来都是纯黑 = 两个模块完全失效**,
现已改为 `specular=(0.12,0.12,0.12,α0.45)`、`fresnel=(0.4,0.55,0.8,α0.18)`。
注意 specular 的 α 只用来推 gloss 指数,**亮度只能靠 rgb 调**。
- MCP `settings_set` 写不了 Color:传 dict 会落成 `{"r":..}` 嵌套字典(引擎无法绑定),
传 `"#rrggbb"` 会落成 String。**正确做法是手改 project.godot 写成 `Color(r,g,b,a)`。**
int 型也一样:会落成 `={"type": "int","value": 4.0}` 的字典块,**用完记得手改回 `=4`**。
## 阴影质量(2026-10-15 定位完毕)
- **`DirectionalLight3D.light_angular_distance` 是本项目阴影锯齿的元凶。**
它开启 PCSS 软阴影:半影半径 = 遮挡距离 × tan(角径),但 Godot 的 PCSS
采样数固定 → 半径一大就变成**稀疏采样尖刺**,表现为:
1) 大面积表面(沙发扶手内侧面)上出现**成片的「梳齿/蕨叶」黑纹**;
2) 物体投影(白盘子)边缘出现**阶梯 + 毛刺**。
实测 `0.8°` 时 artifact 明显,**改成 `0.0` 后两者同时消失**。
⚠️ 别以为角径越大越"柔软自然"——在这个引擎里它只会换来尖刺。
- 工程设置 `rendering/lights_and_shadows/{directional_shadow,positional_shadow}/soft_shadow_filter_quality`
**默认只有 2(Soft Low)**,提到 **4(Soft High)** 实测高频噪点能量降 40%。枚举顺序:
`0 Hard, 1 Soft Very Low, 2 Soft Low, 3 Soft Medium, 4 Soft High, 5 Soft Ultra`。
- `shadow_blur`(0~10,默认 1.0)在 angular=0 时**几乎测不出影响**,不用调。
- `shadow_bias` / `shadow_normal_bias` **别低于引擎默认 0.1 / 2.0**:
归零会让全身自阴影爆发(阴影面积 18484px → 114706px)。原场景的 0.05 / 1.5 偏低。
- ⚠️ **`shadow_normal_bias = 0` + `shadow_blur = 0` 同时出现 = 整幅画面三角颗粒/摩尔纹**
(2026-10-15 实测,相邻像素亮度差 0.58 → 12.73,20 倍):
`normal_bias = 0` 让**每个阴影贴图纹素**都自阴影抖动,而 `shadow_blur` 就是 PCF 半径,
归零后抖动不再被平滑,于是按贴图纹素频率原样显现(纹素斜投影 → 三角/四边颗粒)。
任意恢复其中一个即可(0.638 / 0.566)。只想锐化就把 blur 取 0、**normal_bias 留 2.0**。
- ⚠️ **提高阴影贴图分辨率救不了锯齿**:`directional_shadow/size 4096 → 8192`
在正常配置下只改善 ~8%,在坏配置下**反而更糟**(12.73 → 17.92),
因为纹素越细抖动频率越高。"锯齿 = 贴图不够大"是错误直觉。
- 室内场景可把 `directional_shadow_max_distance` 收到 45(原来 60),近场纹素密度提高 1.3×。
## 验证"启动时固化"的设置(可复用套路)
`[shader_globals]`、`rendering/lights_and_shadows/*`(阴影贴图尺寸、软阴影滤波质量)、
MSAA 等**只在引擎启动时读一次**,改 project.godot 对运行中的编辑器视口无效。
不要靠编辑器截图验证这类改动,走这条独立进程路线:
1. 造一个探针场景 `tools/_probe.tscn` + `tools/_probe.gd`:`extends Node3D`,
在 `_ready()` 里 `load("res://world/xxx.tscn").instantiate()`、加一台 Camera3D、
等 ~60 帧、`await RenderingServer.frame_post_draw` 后
`get_viewport().get_texture().get_image().save_png(...)`、`quit()`。
2. `DisplayServer.window_set_position(Vector2i(-6000,-6000))` 把窗口挪到屏幕外,不打扰用户。
3. 用 `--resolution 1364x772` + 位置参数指定场景跑:
`godot --path <proj> --resolution 1364x772 res://tools/_probe.tscn`(**别加 `--headless`**,
dummy 渲染后端出不了图)。
4. 想在探针里改灯的参数做单变量对照,直接在脚本里改 `DirectionalLight3D` 的属性再截图,
一次进程就能出多张对照图。
5. **要像素级对齐编辑器视口**:`res://tests/test_xxx.gd`(`@tool` + `extends McpTestSuite`,
`print` 抓不到,得用 `FileAccess` 写文件)里读
`EditorInterface.get_editor_viewport_3d(0).get_camera_3d().global_transform`,
再 `test_run` 执行,就能拿到确切机位/ FOV。本项目的 sofa 机位是
`origin(-12.070005, 4.639197, -2.615989)`,`fov 70.01`,视口 1364×772。
6. 注意 main2 的「联机测试」UI 是 **autoload(`autoload/net.tscn`)**,不在场景树里,
探针要递归隐藏 `get_tree().root` 下的 `CanvasLayer`/`Control` 才遮不掉画面。
## 坐标系陷阱 + 上游着色器的边界(重要)
Godot 4 的 `fragment()` 里 `NORMAL` / `TANGENT` / `BINORMAL` 都处于**视图空间**,
而 `NORMAL_MAP` 要的是**切线空间**值、由引擎用 TANGENT/BINORMAL 还原。
把切线空间值直接赋给 `NORMAL` 会让整个场景的法线变成一个跟着相机转的常量 ——
表现为平视全黑、俯瞰才亮。
但**不要**因此去改上游文件:`cel-shader-base.gdshader` 与
`includes/normal-map.gdshaderinc` 来自 <https://github.com/eldskald/godot4-cel-shader>,
本项目的策略是**逐字跟随上游**。上游的法线贴图路径 `NORMAL = normal.vector;`
确实有上述坐标空间错误,但上游所有变体都是 `#define USE_NORMAL_MAP 0`,
全仓库从未启用过这条路径(README 也没提),所以是"能编译但结果错"的死代码。
本项目保持 `USE_NORMAL_MAP 0`,与上游一致;`toon.tres` 里残留的
`shader_parameter/normal_scale` 在宏关闭时会被忽略,无副作用。
**结论:要动上游 shader 之前,先用 `E:/Godot/godot4-cel-shader-main/` 里的干净副本 diff。**
正确做法(若将来真要开法线贴图):只写 `NORMAL_MAP = 原始切线空间值` +
`NORMAL_MAP_DEPTH`,让引擎合成;不要自己写 `NORMAL`,也不要预先做 `*2-1` 重映射
(引擎自己做,蓝通道由引擎重建)。
## 玩家尺寸 / 相机高度(2026-10-16 查证)
全在 `character/player/player.tscn`,「大小」是三层缩放叠加:
1. **`Player` 根节点整体 scale = 0.096096**(第 38 行)—— 主开关,模型+碰撞+相机一起缩放。
2. **`Body/mouse` 模型自身 scale = 0.18240134**(第 87 行,X 为负 = 180° 翻转朝向);
实际模型缩放 = 0.096096 × 0.1824 ≈ 0.0175。只想改模型不动碰撞就改这里。
3. **碰撞胶囊(局部)** `CapsuleShape3D: radius 0.54541016 / height 1.4063476`(第 33-35 行),
`Collision` 节点 y = 0.7031738(= height/2,贴地)。
- ⚠️ **相机高度与缩放联动,改一个必须同步改另一个**:站立视线高 = `CameraRig.position.y`
(当前 1.6,第 46 行),世界高度 = 1.6 × 0.096096 ≈ **0.154 m**;而 `Camera` 节点自身
scale = **10.4062605 = 1/0.096096**(第 49 行),唯一作用就是**抵消父级的 0.096 缩放**,
让相机在世界空间回到 1:1(否则 near/FOV 异常)。动了 `Player` 的 scale,这个倒数必须一起改。
- **无头探针实测(2026-10-16,真实实例化 `player.tscn`)**:`Player.global_scale = 0.096096`;
模型 `Body/mouse.global_scale = 0.017528`;**视觉包围盒 = 0.158 × 0.150 × 0.135 m(约 15cm)**;
胶囊世界尺寸 = 半径 5.24cm / 总高 13.5cm;**相机在脚底上方 0.1116 m**。
⚠️ 相机净高度 ≠ 1.6 × 0.096 = 0.154 —— `Camera` 自身局部还带 `y = −0.439`,
实际 = (1.6 − 0.439) × 0.096096 ≈ **0.1116 m**(`DebugPanel._camera_height()` 用
`camera.global_position.y − player.global_position.y` 读数就是这个值)。
- ⚠️⚠️ **2026-10-16 定位并修复的重大 bug:`world/main.gd` 的玩家生成会把 Player 根节点的缩放静默抹掉。**
旧代码在 `spawn_function` 里写 `player.global_position = player_spawn_pos.global_position`,
但该节点**此刻还没入树**(spawner 之后才 add_child)。`Node3D.get_global_transform()` 未入树时
直接 `ERR_FAIL_COND_V(!is_inside_tree(), Transform3D())` 返回**单位矩阵**,于是
`set_global_transform()` 把 Player 的 basis 整体换成单位阵 —— **0.096096 那层缩放没了**。
实测后果(与游戏内 DebugPanel 逐位吻合):玩家模型 **1.561 m**、第一人称相机 **1.1610 m**,
面板显示「相机高度 1.161 m / 5.80 身高」;控制台每生成一个玩家刷一条
`ERROR: Condition "!is_inside_tree()" is true.`。
修法:`player.position = player_spawn_pos.position`(Main 与 PlayerSpawnPos 同级、Main 无变换,
局部坐标即世界坐标)。**通用规则:未入树的节点只能写局部 position/transform。**
- 全项目**只有** `player.tscn` 一个 CharacterBody3D;`main2.tscn` 的 `World` 根节点无缩放(1:1),
所以 15cm 就是游戏内真实绝对尺寸。玩家是**约 15cm 高的小老鼠**,有意为之(见
`player_motor.gd` / `debug_panel.gd` 注释里的「小人国玩法,身高约 0.2 m」基准;
走速 2.419 m/s ≈ 12 身长/秒就是按 0.2 m 身高定标的 —— 缩放被抹掉时手感同样全错)。
- 相机自身 `scale = 10.4062605 = 1/0.096096`,作者意图是**抵消父级的 0.096**;实测
`Camera3D` 的全局缩放两种玩家缩放下都是 1.0,所以 `InteractionRay.target_position = (0,0.25,0)`
在世界里恒为 0.25 m —— **不随玩家缩放变化,改缩放时不用动它**。
- `ui/character_select.tscn`(选人界面)的预览模型只挂了 `0.18240133` **一层**缩放
(没有 Player 那层 0.096),模型 ≈ 1.56 m,机位 `PreviewCamera` 在 `(0,0.62,1.5)` 看向 `(0,0.45,0)`
也是按这个尺度配的。它是**进游戏前的展示界面**,与游戏内 15cm 天然不同尺(要一致就再乘 0.096
并把机位挪到 ~0.15 m 高)。
- **渲染/骨骼实测(2026-10-16)**:`main2.tscn` 的 `woman` AABB 高 **1.703 m**(世界是 1:1 真人尺度);
绕开 `main.gd` 直接实例化 `player.tscn` 时,玩家渲染出来约 15 cm、`Skeleton3D` 骨骼竖直跨度
0.099 m。⚠️ 这一组数据只对「直接实例化」成立 —— 走真实生成路径时缩放会被上面那条 bug 抹掉。
- ⚠️ **探针技巧**:`player.gd` 的 `_ready()` 会把**整只模型** `Body/mouse` 设为 `visible = false`,
探针里只把下面的 `MeshInstance3D` 设成 `true` **没用**(父节点还藏着),
必须显式 `player.get_node("Body/mouse").visible = true`。这个坑踩了三轮才排掉。
- ⚠️ 量 skinned mesh 时不能只看 `mi.global_transform * mi.get_aabb()`:那是 **mesh 资源**的
包围盒,**不含骨骼变换**,位置/尺寸都可能偏(实测玩家 mesh AABB 落在 Player 原点**下方**,
而真实骨骼在原点**上方**)。要真实数据就查 `Skeleton3D.get_bone_global_pose()`,或者直接渲染截图量像素。
- 蹲伏高度由 `player_look.gd` 的 `crouch_height_ratio = 0.6` 从 `CameraRig.position.y` 推导,
改 rig 的 Y 会自动跟随,不用改脚本。
- `world/main.tscn` 根节点 `Main`(Node3D)无缩放,`MultiplayerSpawner.spawn_path = ".."`,
所以 Player 的 0.096 即最终世界缩放。(`PlayerSpawnPos` 在 y=4.8,出生会先坠落。)
+1
View File
@@ -0,0 +1 @@
游戏内测试交给用户来,不要自己做
+651
View File
File diff suppressed because one or more lines are too long
+107 -18
View File
@@ -132,6 +132,9 @@ static func prepare_major_upgrade_endpoints(from_version: String, to_version: St
return {"ok": false, "error": "EditorSettings is unavailable"} return {"ok": false, "error": "EditorSettings is unavailable"}
var legacy_http := _read_port_setting(McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT) var legacy_http := _read_port_setting(McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT)
var legacy_ws := _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT) var legacy_ws := _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT)
var occupancy := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
if OS.get_name() == "Windows" and not occupancy.known:
return {"ok": false, "error": "Windows could not query listening ports; retry endpoint selection."}
var selected: Array[int] = [] var selected: Array[int] = []
var candidate := legacy_http + 1 if legacy_http < MAX_PORT else MIN_PORT var candidate := legacy_http + 1 if legacy_http < MAX_PORT else MIN_PORT
candidate = WindowsPortReservation.suggest_non_excluded_port(candidate, MAX_PORT - candidate + 1, MAX_PORT) candidate = WindowsPortReservation.suggest_non_excluded_port(candidate, MAX_PORT - candidate + 1, MAX_PORT)
@@ -140,17 +143,32 @@ static func prepare_major_upgrade_endpoints(from_version: String, to_version: St
candidate = WindowsPortReservation.suggest_non_excluded_port_from_output(reserved_output, candidate, MAX_PORT - candidate + 1, MAX_PORT) candidate = WindowsPortReservation.suggest_non_excluded_port_from_output(reserved_output, candidate, MAX_PORT - candidate + 1, MAX_PORT)
if candidate < MIN_PORT or candidate > MAX_PORT: if candidate < MIN_PORT or candidate > MAX_PORT:
break break
if candidate not in [legacy_http, legacy_ws] and not selected.has(candidate) and PortResolver.can_bind_local_port(candidate) and not PortResolver.is_port_in_use(candidate): if candidate not in [legacy_http, legacy_ws] and not selected.has(candidate) and PortResolver.can_bind_local_port(candidate):
selected.append(candidate) var available: bool
if selected.size() == 2: if OS.get_name() == "Windows":
break available = PortResolver.windows_port_occupancy(candidate, occupancy) == PortResolver.PortOccupancy.FREE
else:
available = not PortResolver.is_port_in_use(candidate)
if available:
selected.append(candidate)
if selected.size() == 2:
break
candidate += 1 candidate += 1
if candidate > MAX_PORT: if candidate > MAX_PORT:
candidate = MIN_PORT candidate = MIN_PORT
if selected.size() != 2: if selected.size() != 2:
return {"ok": false, "error": "No independent HTTP/WebSocket port pair was available for this major upgrade."} return {"ok": false, "error": "No independent HTTP/WebSocket port pair was available for this major upgrade."}
if OS.get_name() == "Windows":
occupancy = PortResolver.windows_listener_snapshot()
if not occupancy.known:
return {"ok": false, "error": "Windows could not query listening ports; retry endpoint selection."}
for port in selected: for port in selected:
if WindowsPortReservation.parse_excluded(reserved_output, port) or not PortResolver.can_bind_local_port(port) or PortResolver.is_port_in_use(port): var available: bool
if OS.get_name() == "Windows":
available = PortResolver.windows_port_occupancy(port, occupancy) == PortResolver.PortOccupancy.FREE
else:
available = not PortResolver.is_port_in_use(port)
if WindowsPortReservation.parse_excluded(reserved_output, port) or not PortResolver.can_bind_local_port(port) or not available:
return {"ok": false, "error": "The selected upgrade port %d became unavailable; retry endpoint selection." % port} return {"ok": false, "error": "The selected upgrade port %d became unavailable; retry endpoint selection." % port}
var pair := {"http_port": selected[0], "ws_port": selected[1]} var pair := {"http_port": selected[0], "ws_port": selected[1]}
es.set_setting(SETTING_V4_ENDPOINT_PORTS, pair) es.set_setting(SETTING_V4_ENDPOINT_PORTS, pair)
@@ -535,13 +553,17 @@ static func allow_hosts() -> String:
## "free" honest on macOS/Linux, where the reservation table is empty but the ## "free" honest on macOS/Linux, where the reservation table is empty but the
## next port up may still be occupied — the same suggestion feeds the dock ## next port up may still be occupied — the same suggestion feeds the dock
## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE ## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE
## log line. Falls back to the clamped candidate if nothing in the window ## log line. Returns zero when discovery fails or no candidate is
## clears both checks (caller surfaces it as a best-effort hint; the user can ## available. A TOCTOU window remains
## retry or pick another). Best-effort by nature: a TOCTOU window remains
## between the probe and the caller actually binding the port. The bind probe ## between the probe and the caller actually binding the port. The bind probe
## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't ## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't
## stall on a pathological run of occupied ports. ## stall on a pathological run of occupied ports.
static func suggest_free_port(start: int, span: int = 2048) -> int: static func suggest_free_port(start: int, span: int = 2048, occupancy: Dictionary = {}) -> int:
if OS.get_name() == "Windows":
if occupancy.is_empty():
occupancy = PortResolver.windows_listener_snapshot()
if not occupancy.known:
return 0
var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1) var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
var limit := mini(candidate + span - 1, MAX_PORT) var limit := mini(candidate + span - 1, MAX_PORT)
var p := candidate var p := candidate
@@ -556,10 +578,11 @@ static func suggest_free_port(start: int, span: int = 2048) -> int:
break break
p = not_reserved p = not_reserved
probes += 1 probes += 1
if PortResolver.can_bind_local_port(p): var occupied := OS.get_name() == "Windows" and PortResolver.windows_port_occupancy(p, occupancy) != PortResolver.PortOccupancy.FREE
if not occupied and PortResolver.can_bind_local_port(p):
return p return p
p += 1 p += 1
return candidate return 0
# --- Client operations (string id) --------------------------------------- # --- Client operations (string id) ---------------------------------------
@@ -590,6 +613,12 @@ static func configure(id: String, url: String = "", launch_context: Dictionary =
if not client.automatic_config_edits: if not client.automatic_config_edits:
return _manual_edit_result(client, "configure") return _manual_edit_result(client, "configure")
var path_error := _config_path_resolution_error(client) var path_error := _config_path_resolution_error(client)
if path_error.is_empty():
path_error = _config_write_error(client, "configure")
if path_error.is_empty():
path_error = _config_create_error(client)
if path_error.is_empty():
path_error = _credentials_error(client)
if not path_error.is_empty(): if not path_error.is_empty():
return {"status": "error", "message": path_error} return {"status": "error", "message": path_error}
## Capture `url` once so a port flip in EditorSettings between write and ## Capture `url` once so a port flip in EditorSettings between write and
@@ -752,6 +781,11 @@ static func warm_env_snapshot(endpoint_policy: Dictionary = {}) -> void:
for env_name in [client.get("config_file_env"), client.get("config_home_env")]: for env_name in [client.get("config_file_env"), client.get("config_home_env")]:
if env_name is String and not env_name.is_empty() and not extras.has(env_name): if env_name is String and not env_name.is_empty() and not extras.has(env_name):
extras.append(env_name) extras.append(env_name)
var scope_envs: Variant = client.get("config_scope_envs")
if scope_envs is PackedStringArray:
for env_name in scope_envs:
if not env_name.is_empty() and not extras.has(env_name):
extras.append(env_name)
McpPathTemplate.warm_env_snapshot(extras) McpPathTemplate.warm_env_snapshot(extras)
UvResolution.warm_environment() UvResolution.warm_environment()
_editor_setting_lookup(MODE_OVERRIDE_SETTING) _editor_setting_lookup(MODE_OVERRIDE_SETTING)
@@ -829,6 +863,8 @@ static func remove(id: String, url: String = "", launch_context: Dictionary = {}
if not client.automatic_config_edits: if not client.automatic_config_edits:
return _manual_edit_result(client, "remove") return _manual_edit_result(client, "remove")
var path_error := _config_path_resolution_error(client) var path_error := _config_path_resolution_error(client)
if path_error.is_empty():
path_error = _config_write_error(client, "remove")
if not path_error.is_empty(): if not path_error.is_empty():
return {"status": "error", "message": path_error} return {"status": "error", "message": path_error}
if url.is_empty(): if url.is_empty():
@@ -869,6 +905,49 @@ static func _config_path_resolution_error(client: Client) -> String:
return str(client.resolved_config_path_details().get("error", "")) return str(client.resolved_config_path_details().get("error", ""))
## Why Configure must not create this client's file although status may still
## read the path: `create_error` in `McpClient`'s candidate rules. Kept apart
## from the function above, whose signature `script/local-self-update-smoke`
## patches by exact match, in release trees as well as this one.
static func _config_create_error(client: Client) -> String:
if client.config_type == "cli":
return ""
return str(client.resolved_config_path_details().get("create_error", ""))
## Why neither Configure nor Remove may touch this client's file: this editor's
## Flatpak sandbox does not share it with the host (`McpClient.config_write_error`,
## or the CLI strategy's own rule for a client that writes through its CLI, where
## Configure and Remove differ). Every strategy refuses on it too; checking here
## puts the refusal ahead of launcher discovery and the lock.
static func _config_write_error(client: Client, action: String) -> String:
if client.config_type == "cli":
if action == "configure":
return CliStrategy.configure_write_error(client)
return CliStrategy.remove_write_error(client)
return client.config_write_error()
## Why Configure must not write an attach entry although the client's file is
## writable: this editor's Flatpak sandbox keeps Godot AI's credentials where
## the bridge that entry launches does not look. A missing entry is better
## than one known to be broken. Remove is not held to it.
static func _credentials_error(client: Client) -> String:
if client.command_shape == Client.CommandShape.NONE:
return ""
if not McpPathTemplate.flatpak_hides_credentials():
return ""
var app_id := McpPathTemplate.flatpak_app_id()
return (
"Godot runs in a Flatpak sandbox that keeps Godot AI's credentials to "
+ "itself, so the bridge %s starts could not connect and nothing was "
+ "changed. Run `flatpak override --user "
+ "--filesystem=xdg-config/godot-ai:create %s`, restart Godot, and try "
+ "again. docs/steam-capability-directory.md covers the other ways to "
+ "share them."
) % [client.display_name, app_id if not app_id.is_empty() else "<Godot's Flatpak ID>"]
# --- Strategy dispatch + verify (testable seam) -------------------------- # --- Strategy dispatch + verify (testable seam) --------------------------
static func _manual_edit_result(client: Client, action: String) -> Dictionary: static func _manual_edit_result(client: Client, action: String) -> Dictionary:
@@ -1212,6 +1291,16 @@ static func manual_command(id: String) -> String:
) )
if cmd.is_empty(): if cmd.is_empty():
return cmd return cmd
## The path above is the one outside Flatpak. When this sandboxed editor
## cannot see where a Flatpak build keeps the same file, that may be the
## wrong one for this user, and this text is all a refused Configure (or a
## manual-only client) leaves them to go on.
var hidden_paths: Variant = path_resolution.get("hidden_paths", null)
if hidden_paths is PackedStringArray and not hidden_paths.is_empty():
cmd += (
"\n\nA Flatpak build keeps this file at %s instead. Godot's own Flatpak "
+ "sandbox cannot see it, so edit that one if it is the build you use."
) % " or ".join(hidden_paths)
## #507: when the allow-host opt-in names a non-loopback range, also ## #507: when the allow-host opt-in names a non-loopback range, also
## surface the LAN URL so the user can copy-paste the right address into ## surface the LAN URL so the user can copy-paste the right address into
## a remote agent. Informational only — configure/remove still WRITE the ## a remote agent. Informational only — configure/remove still WRITE the
@@ -1648,7 +1737,7 @@ static func _is_symlink(path: String) -> bool:
return dir.is_link(path) return dir.is_link(path)
static func get_server_command() -> Array[String]: static func get_server_command(trace: Callable = Callable()) -> Array[String]:
## `mode_override() == "user"` skips the dev_venv tier even when a nearby ## `mode_override() == "user"` skips the dev_venv tier even when a nearby
## .venv exists — the override then becomes an actual workaround for ## .venv exists — the override then becomes an actual workaround for
## the "user venv misidentified as dev checkout" bug, not just a ## the "user venv misidentified as dev checkout" bug, not just a
@@ -1659,7 +1748,7 @@ static func get_server_command() -> Array[String]:
print("MCP | using dev venv: %s" % venv_python) print("MCP | using dev venv: %s" % venv_python)
return [venv_python, "-m", "godot_ai"] return [venv_python, "-m", "godot_ai"]
var uvx := find_uvx() var uvx := find_uvx(trace)
if not uvx.is_empty(): if not uvx.is_empty():
var version := get_plugin_version() var version := get_plugin_version()
## PEP 440 local build tags (e.g. 3.0.2+local.1) are not on PyPI. ## PEP 440 local build tags (e.g. 3.0.2+local.1) are not on PyPI.
@@ -1687,7 +1776,7 @@ static func get_server_command() -> Array[String]:
cmd.append_array(["--from", "godot-ai==%s" % pypi_version, "godot-ai"]) cmd.append_array(["--from", "godot-ai==%s" % pypi_version, "godot-ai"])
return cmd return cmd
var system_cmd := _find_system_install() var system_cmd := _find_system_install(trace)
if not system_cmd.is_empty(): if not system_cmd.is_empty():
print("MCP | using system install: %s" % system_cmd) print("MCP | using system install: %s" % system_cmd)
return [system_cmd] return [system_cmd]
@@ -1718,8 +1807,8 @@ static func get_server_launch_mode() -> String:
const PREWARM_TIMEOUT_MS := 180000 const PREWARM_TIMEOUT_MS := 180000
static func find_uvx() -> String: static func find_uvx(trace: Callable = Callable()) -> String:
return CliFinder.find(_uvx_cli_names()) return CliFinder.find(_uvx_cli_names(), trace)
## Pure argv builder for the pre-warm spawn, split out so tests can pin the ## Pure argv builder for the pre-warm spawn, split out so tests can pin the
@@ -2057,11 +2146,11 @@ static func find_worktree_src_dir(start_dir: String) -> String:
## directly: the finder adds the well-known-install-dirs and login-shell ## directly: the finder adds the well-known-install-dirs and login-shell
## PATH tiers plus its per-exe cache, and this drops the private ## PATH tiers plus its per-exe cache, and this drops the private
## `_pick_best_path` cross-class reach (#711). ## `_pick_best_path` cross-class reach (#711).
static func _find_system_install() -> String: static func _find_system_install(trace: Callable = Callable()) -> String:
## Built with append, not a ternary of untyped literals — assigning a ## Built with append, not a ternary of untyped literals — assigning a
## ternary's Array to Array[String] is a runtime error on newer Godot ## ternary's Array to Array[String] is a runtime error on newer Godot
## builds (same idiom as _uvx_cli_names above). ## builds (same idiom as _uvx_cli_names above).
var names: Array[String] = ["godot-ai"] var names: Array[String] = ["godot-ai"]
if OS.get_name() == "Windows": if OS.get_name() == "Windows":
names.push_front("godot-ai.exe") names.push_front("godot-ai.exe")
return CliFinder.find(names) return CliFinder.find(names, trace)
+218 -10
View File
@@ -79,26 +79,76 @@ var path_template: Dictionary = {}
## a fallback path that may become invisible after copy-on-write. When ## a fallback path that may become invisible after copy-on-write. When
## that private leaf is new, Configure seeds it from the first later ## that private leaf is new, Configure seeds it from the first later
## existing candidate so read-through content is not shadowed. ## existing candidate so read-through content is not shadowed.
## 2. If no file or wildcard package match exists, the first non-wildcard ## 2. If no file or wildcard package match exists, the create target is the
## template is the deterministic create target. ## non-wildcard template whose own directories already exist, because the
## client has run from there (`_evident_create_path` gives the order),
## and otherwise the first non-wildcard template. This is what sends a
## Flatpak-only IDE's entry to `~/.var/app/<id>/config/...` instead of a
## `~/.config` it never reads.
## 3. Multiple matches within any wildcard group are ambiguous and fail ## 3. Multiple matches within any wildcard group are ambiguous and fail
## closed instead of choosing an arbitrary package. ## closed instead of choosing an arbitrary package.
## 4. A template inside another Flatpak app's `~/.var/app/<id>` is never a
## create target while this editor's own Flatpak sandbox hides that
## directory (`McpPathTemplate.hidden_flatpak_app_dir`). When rule 2 then
## finds no existing directory among the rest, the resolution carries
## `create_error` and the unseen `hidden_paths`: status still reads the
## first template, and Configure refuses rather than create a file the
## client may never read.
## ##
## Exact-file and config-home environment overrides still have higher ## Exact-file and config-home environment overrides still have higher
## priority. When this map has no entry for the current platform, ## priority. When this map has no entry for the current platform,
## `path_template` remains the fallback. ## `path_template` remains the fallback.
var config_path_candidates: Dictionary = {} var config_path_candidates: Dictionary = {}
## Optional global JSON config files merged by the client from lowest to highest ## Optional global JSON config files merged by the client. Unlike
## precedence. Unlike `config_path_candidates`, every existing file participates; ## `config_path_candidates`, every existing file participates. By default the
## Configure updates the effective last definition, status verifies it, and ## client keeps the LAST definition: Configure updates the effective last
## Remove clears every global definition transactionally. ## definition, status verifies it, and Remove clears every global definition
## transactionally. Clients that keep the FIRST definition instead set
## `config_merge_first_wins` and declare tiers in the client's read order;
## the folds then target the first tier defining the server, so a write
## updates the file that is already effective and a fresh entry lands on
## tiers[0] — the client's own primary file.
var config_merge_path_templates: Dictionary = {} var config_merge_path_templates: Dictionary = {}
## Project-relative tiers that may override the global files. Their root is the ## Project-relative tiers that may override the global files. Their root is the
## external client's working directory, which the Godot process cannot know. ## external client's working directory, which the Godot process cannot know.
## The strategy checks plausible roots and fails closed instead of mutating them. ## The strategy checks plausible roots and fails closed instead of mutating them.
var config_merge_project_paths: PackedStringArray = PackedStringArray() var config_merge_project_paths: PackedStringArray = PackedStringArray()
## True when the client keeps the FIRST definition of a duplicated server and
## ignores later ones (omp-native: project `.omp/mcp.json` precedes
## `.omp/.mcp.json`, then the user scope's `mcp.json` precedes `.mcp.json`).
## Flips every merge-tier fold — Configure target, status verification, and
## the manual/Open-Reveal target resolution — from last-definition-wins to
## first. With tiers declared in read order, Configure updates the tier that
## already owns the server instead of creating a higher-priority entry that
## shadows a lower tier's user state (#1085).
var config_merge_first_wins: bool = false
## Directory-glob templates whose matches make the user-scope config
## destination unknowable from the editor (omp: `~/.omp/profiles/*`). One `*`
## per directory segment, expanded by `McpPathTemplate.expand_path_candidates`.
## Any existing match means a named profile may own the user scope: the active
## profile is chosen per client launch and never persisted where the editor can
## read it, so Configure and Remove fail closed with the matched paths and
## status reports the ambiguity instead of green-lighting the default file.
var config_scope_globs: PackedStringArray = PackedStringArray()
## Environment names that make the effective config destination unknown.
## Configure, Remove and status refuse while any is set; values are not reported.
var config_scope_envs: PackedStringArray = PackedStringArray()
## Top-level server-name denylist in the first declared merge tier (the default
## primary file), independent of which tier owns the entry. Configure and status
## refuse a suppressed name; Remove preserves this user state. Empty disables it.
var config_denylist_key: String = ""
## Effective-entry enable toggle and primary force-enable list. Boolean false
## and strings "false"/"0" suppress the entry unless the allowlist names it.
## The denylist still wins. Empty keys disable these checks.
var config_enabled_key: String = ""
var config_allowlist_key: String = ""
## De-duplicate persistent path-ambiguity warnings across recurring status ## De-duplicate persistent path-ambiguity warnings across recurring status
## refreshes. The actionable message still returns on every resolution; only ## refreshes. The actionable message still returns on every resolution; only
## the editor-console echo is single-shot until the ambiguity clears/changes. ## the editor-console echo is single-shot until the ambiguity clears/changes.
@@ -292,6 +342,57 @@ func resolved_config_path_details() -> Dictionary:
return {"path": "", "error": unresolved_config_path_error(display_name, path)} return {"path": "", "error": unresolved_config_path_error(display_name, path)}
## Why Configure and Remove must leave this client's resolved file alone; ""
## when they may write it. See `unshared_flatpak_config_error`.
func config_write_error() -> String:
var path := resolved_config_path()
return "" if path.is_empty() else unshared_flatpak_config_error(display_name, path)
## Why Configure and Remove must leave the file at `path` alone when this
## editor runs in a Flatpak sandbox that does not share it read-write with the
## host; "" when it does, and outside Flatpak. A file written there is read
## back and verified by this editor and never seen by the client.
##
## Every strategy asks this before it writes, ahead of `create_error`: where a
## Flatpak build of the client keeps its settings is beside the point until
## the sandbox can write the ordinary location at all. Status does not ask.
## It keeps reading the path, and under a read-only grant that is the
## client's real file.
static func unshared_flatpak_config_error(client_name: String, path: String) -> String:
var block := McpPathTemplate.flatpak_write_block(path)
if block.is_empty():
return ""
var refused := (
"Godot runs in a Flatpak sandbox that cannot write %s where %s reads "
+ "it, so nothing was changed."
) % [path, client_name]
var by_hand := "or edit that file by hand (docs/steam-capability-directory.md)."
if block.has("unmounted"):
return refused + (
" Flatpak was asked to share %s but mounted nothing there, which "
+ "is what it does when the directory does not exist as Godot "
+ "starts. Create it, restart Godot, and try again, %s"
) % [block["unmounted"], by_hand]
if block.has("hidden"):
return refused + (
" Flatpak hides %s from this sandbox, as a --nofilesystem rule "
+ "does. Remove that rule, restart Godot, and try again, %s"
) % [block["hidden"], by_hand]
if block.has("read_only"):
return refused + (
" %s is mounted read-only. Make it writable, restart Godot, and "
+ "try again, %s"
) % [block["read_only"], by_hand]
var app_id := McpPathTemplate.flatpak_app_id()
return refused + (
" Run `flatpak override --user --filesystem=%s %s`, restart Godot, "
+ "and try again, or edit that file by hand: "
+ "docs/steam-capability-directory.md covers a sandbox without that "
+ "grant."
) % [block["needs"], app_id if not app_id.is_empty() else "<Godot's Flatpak ID>"]
## Shared wording for a path template `McpPathTemplate.expand` could not fully ## Shared wording for a path template `McpPathTemplate.expand` could not fully
## resolve. Used by the guard above and by the merge-tier loader in ## resolve. Used by the guard above and by the merge-tier loader in
## `_json_strategy.gd`, which resolves its own templates and never passes ## `_json_strategy.gd`, which resolves its own templates and never passes
@@ -365,7 +466,11 @@ func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
var ordered_templates: Array = [] var ordered_templates: Array = []
for template_variant in templates: for template_variant in templates:
ordered_templates.append(str(template_variant)) ordered_templates.append(str(template_variant))
var fallback_create_path := "" ## Candidates whose file does not exist yet and that Configure could create.
var create_templates := PackedStringArray()
var create_paths := PackedStringArray()
var hidden_app_dirs := PackedStringArray()
var hidden_paths := PackedStringArray()
for index in range(ordered_templates.size()): for index in range(ordered_templates.size()):
var template := str(ordered_templates[index]) var template := str(ordered_templates[index])
var expanded_template := McpPathTemplate.expand(template) var expanded_template := McpPathTemplate.expand(template)
@@ -405,10 +510,113 @@ func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
return {"path": "", "error": seed["error"]} return {"path": "", "error": seed["error"]}
_clear_config_path_warning() _clear_config_path_warning()
return {"path": path, "error": "", "seed_path": seed.get("path", "")} return {"path": path, "error": "", "seed_path": seed.get("path", "")}
if fallback_create_path.is_empty(): # Creating below a directory the sandbox hides would land on its
fallback_create_path = path # private tmpfs and vanish with the editor, so a hidden candidate can
# be neither evidence nor a create target.
var hidden_app_dir := McpPathTemplate.hidden_flatpak_app_dir(path)
if not hidden_app_dir.is_empty():
hidden_paths.append(path)
if not hidden_app_dirs.has(hidden_app_dir):
hidden_app_dirs.append(hidden_app_dir)
continue
create_templates.append(template)
create_paths.append(path)
_clear_config_path_warning() _clear_config_path_warning()
return {"path": fallback_create_path, "error": ""} var evident_create_path := _evident_create_path(create_templates, create_paths)
if not evident_create_path.is_empty():
return {"path": evident_create_path, "error": ""}
var fallback_create_path := "" if create_paths.is_empty() else create_paths[0]
if hidden_app_dirs.is_empty():
return {"path": fallback_create_path, "error": ""}
var hidden_error := hidden_flatpak_config_error(
display_name, fallback_create_path.get_base_dir(), hidden_app_dirs
)
if fallback_create_path.is_empty():
return {"path": "", "error": hidden_error}
return {
"path": fallback_create_path,
"error": "",
"create_error": hidden_error,
"hidden_paths": hidden_paths,
}
## The location the client has demonstrably run from, among candidates with no
## config file yet; "" when nothing says. Candidates are compared level by
## level, from the directory that would hold the file upward, and the first
## level at which one of those directories exists decides, in descriptor order.
## A client nested in its host app's settings (a VS Code extension) therefore
## follows the host app when the extension's own directory exists nowhere,
## instead of creating a second, unread copy of the host's settings tree.
##
## Only directories the templates name below their shared root are compared,
## and only as many levels as the shortest template has. Those belong to the
## client; a container such as ~/.config exists whether or not the client does.
static func _evident_create_path(templates: PackedStringArray, paths: PackedStringArray) -> String:
if paths.is_empty():
return ""
var shared := _shared_root_length(templates)
var levels := -1
for template in templates:
var own_directories := template.split("/").size() - 1 - shared
levels = own_directories if levels < 0 else mini(levels, own_directories)
var directories := PackedStringArray()
for path in paths:
directories.append(path.get_base_dir())
for _level in range(maxi(levels, 0)):
for index in range(directories.size()):
if DirAccess.dir_exists_absolute(directories[index]):
return paths[index]
for index in range(directories.size()):
directories[index] = directories[index].get_base_dir()
return ""
## How many leading path elements every template shares, counting the root
## element (`$XDG_CONFIG_HOME`, `~`, a drive) and never the file name. At least
## one: the root is never the client's own directory.
static func _shared_root_length(templates: PackedStringArray) -> int:
if templates.size() < 2:
return 1
var first := templates[0].split("/")
var shared := first.size() - 1
for index in range(1, templates.size()):
var other := templates[index].split("/")
var limit := mini(shared, other.size() - 1)
var same := 0
while same < limit and first[same] == other[same]:
same += 1
shared = same
return maxi(shared, 1)
## Why Configure will not create a client's settings file from inside a
## Flatpak editor: nothing shows the client runs from the ordinary location,
## and the location a Flatpak build of it uses cannot be inspected. Names the
## one grant that lets this editor see it.
static func hidden_flatpak_config_error(
client_name: String, settings_dir: String, hidden_app_dirs: PackedStringArray
) -> String:
var app_id := McpPathTemplate.flatpak_app_id()
var grants := PackedStringArray()
for app_dir in hidden_app_dirs:
grants.append("--filesystem=%s" % app_dir)
var missing := (
"%s does not exist" % settings_dir
if not settings_dir.is_empty()
else "it has no settings location outside Flatpak"
)
return (
"Could not find where %s keeps its settings: %s, and a Flatpak build "
+ "keeps them under %s, which Godot's own Flatpak sandbox cannot see. "
+ "If you use the Flatpak build, run `flatpak override --user %s %s`, "
+ "restart Godot, and configure again. If it is installed outside "
+ "Flatpak, start it once so it creates its settings, then configure "
+ "again."
) % [
client_name, missing, ", ".join(hidden_app_dirs),
" ".join(grants), app_id if not app_id.is_empty() else "<Godot's Flatpak ID>",
]
## Find a readable seed for a newly-created authoritative wildcard target. ## Find a readable seed for a newly-created authoritative wildcard target.
+123 -8
View File
@@ -25,13 +25,15 @@ static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
static var _searched: Dictionary = {} static var _searched: Dictionary = {}
const _LOOKUP_TIMEOUT_MS := 3000 const _LOOKUP_TIMEOUT_MS := 3000
const _TRACE_PATH_LIMIT := 16
const _TRACE_PATH_MAX_CHARS := 512
## Find any of the supplied exe names; returns the first hit. ## Find any of the supplied exe names; returns the first hit.
## On Windows pass the .exe variant in `exe_names` if relevant. ## On Windows pass the .exe variant in `exe_names` if relevant.
static func find(exe_names: Array[String]) -> String: static func find(exe_names: Array[String], trace: Callable = Callable()) -> String:
for name in exe_names: for name in exe_names:
var hit := _find_one(name) var hit := _find_one(name, trace)
if not hit.is_empty(): if not hit.is_empty():
return hit return hit
return "" return ""
@@ -49,19 +51,29 @@ static func invalidate(exe_name: String = "") -> void:
_mutex.unlock() _mutex.unlock()
static func _find_one(exe_name: String) -> String: static func _find_one(exe_name: String, trace: Callable = Callable()) -> String:
var started := Time.get_ticks_msec()
_mutex.lock() _mutex.lock()
var already_searched: bool = _searched.get(exe_name, false) var already_searched: bool = _searched.get(exe_name, false)
var cached: String = _cache.get(exe_name, "") var cached: String = _cache.get(exe_name, "")
_mutex.unlock() _mutex.unlock()
if already_searched: if already_searched:
_trace_lookup(
trace,
"cache",
"resolved" if not cached.is_empty() else "cached_miss",
started,
{},
true,
cached,
)
return cached return cached
# `_resolve()` does FileAccess + bounded subprocess lookup (forks # `_resolve()` does FileAccess + bounded subprocess lookup (forks
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that # `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
# would let a concurrent `invalidate()` on the main thread freeze the # would let a concurrent `invalidate()` on the main thread freeze the
# editor for the duration of the subprocess — which defeats the whole # editor for the duration of the subprocess — which defeats the whole
# point of running CLI lookup off the main thread. # point of running CLI lookup off the main thread.
var hit := _resolve(exe_name) var hit := _resolve(exe_name, trace)
_mutex.lock() _mutex.lock()
_cache[exe_name] = hit _cache[exe_name] = hit
_searched[exe_name] = true _searched[exe_name] = true
@@ -69,14 +81,19 @@ static func _find_one(exe_name: String) -> String:
return hit return hit
static func _resolve(exe_name: String) -> String: static func _resolve(exe_name: String, trace: Callable = Callable()) -> String:
var started := Time.get_ticks_msec()
var is_windows := OS.get_name() == "Windows" var is_windows := OS.get_name() == "Windows"
# 1. Well-known locations # 1. Well-known locations
var well_known_paths := PackedStringArray()
for dir in _well_known_dirs(): for dir in _well_known_dirs():
var full := dir.path_join(exe_name) var full := dir.path_join(exe_name)
well_known_paths.append(full)
if FileAccess.file_exists(full): if FileAccess.file_exists(full):
_trace_lookup(trace, "well_known", "resolved", started, {}, false, full, well_known_paths)
return full return full
_trace_lookup(trace, "well_known", "no_match", started, {}, false, "", well_known_paths)
# 2. Login shell lookup (Unix only) # 2. Login shell lookup (Unix only)
if not is_windows: if not is_windows:
@@ -87,24 +104,122 @@ static func _resolve(exe_name: String) -> String:
if shell.is_empty(): if shell.is_empty():
shell = "/bin/bash" shell = "/bin/bash"
var stripped := exe_name.trim_suffix(".exe") var stripped := exe_name.trim_suffix(".exe")
started = Time.get_ticks_msec()
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false) var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
if int(login_result.get("exit_code", -1)) == 0: if int(login_result.get("exit_code", -1)) == 0:
var login_found: String = str(login_result.get("stdout", "")).strip_edges() var login_found: String = str(login_result.get("stdout", "")).strip_edges()
if not login_found.is_empty() and FileAccess.file_exists(login_found): if not login_found.is_empty() and FileAccess.file_exists(login_found):
_trace_lookup(
trace,
"login_shell",
"resolved",
started,
login_result,
false,
login_found,
PackedStringArray([login_found]),
)
return login_found return login_found
_trace_lookup(trace, "login_shell", _lookup_failure(login_result, false), started, login_result)
# 3. which / where with inherited PATH # 3. which / where with inherited PATH
return _resolve_inherited_path(exe_name, is_windows, trace)
## Resolve from the inherited PATH through a single lookup command. The
## optional runner keeps the Windows/where failure modes deterministic in unit
## tests without changing the production invocation.
static func _resolve_inherited_path(
exe_name: String,
is_windows: bool,
trace: Callable = Callable(),
lookup_runner: Callable = Callable(),
) -> String:
var lookup := "where" if is_windows else "which" var lookup := "where" if is_windows else "which"
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false) var started := Time.get_ticks_msec()
var result: Dictionary = {}
if lookup_runner.is_valid():
var injected: Variant = lookup_runner.call(lookup, exe_name)
if injected is Dictionary:
result = injected
else:
result = McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
if int(result.get("exit_code", -1)) == 0: if int(result.get("exit_code", -1)) == 0:
var output := str(result.get("stdout", ""))
var lines := PackedStringArray(output.split("\n"))
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges() var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
if not found.is_empty(): if not found.is_empty():
_trace_lookup(trace, "inherited_path", "resolved", started, result, false, found, lines)
return found return found
_trace_lookup(
trace, "inherited_path", _lookup_failure(result, true), started, result, false, "", lines
)
return "" return ""
static func _lookup_failure(result: Dictionary, absence_exit: bool) -> String:
for flag in ["termination_failed", "cancelled", "timed_out", "spawn_failed"]:
if bool(result.get(flag, false)):
return flag
var code := int(result.get("exit_code", -1))
if code == 0:
return "empty_output" if str(result.get("stdout", "")).strip_edges().is_empty() else "unusable_output"
return "not_found" if absence_exit and code == 1 else "nonzero_exit"
static func _trace_lookup(
trace: Callable, tier: String, status: String, started: int,
result: Dictionary = {}, cache_hit := false, resolved_path := "",
candidate_paths: PackedStringArray = PackedStringArray(),
) -> void:
if not trace.is_valid():
return
trace.call({
"tier": tier, "status": status,
"elapsed_ms": clampi(Time.get_ticks_msec() - started, 0, 2147483647),
"cache_hit": cache_hit,
"exit_code": clampi(int(result.get("exit_code", -1)), -2147483648, 2147483647),
"timed_out": bool(result.get("timed_out", false)),
"spawn_failed": bool(result.get("spawn_failed", false)),
"cancelled": bool(result.get("cancelled", false)),
"termination_failed": bool(result.get("termination_failed", false)),
"resolved_path": _bounded_trace_path(resolved_path),
"candidate_paths": _bounded_trace_paths(candidate_paths),
})
## Keep lookup diagnostics useful without retaining arbitrary command output or
## environment values. Only absolute executable paths cross this boundary.
static func _bounded_trace_paths(paths: PackedStringArray) -> Array[String]:
var bounded: Array[String] = []
for raw in paths:
var path := _bounded_trace_path(raw)
if path.is_empty() or bounded.has(path):
continue
bounded.append(path)
if bounded.size() >= _TRACE_PATH_LIMIT:
break
return bounded
static func _bounded_trace_path(raw: String) -> String:
var path := raw.strip_edges()
if not _is_traceable_path(path):
return ""
return path.left(_TRACE_PATH_MAX_CHARS)
static func _is_traceable_path(path: String) -> bool:
if path.is_empty() or path.contains("\n") or path.contains("\r"):
return false
if path.begins_with("/") or path.begins_with("\\\\"):
return true
return (
path.length() >= 3
and path.substr(1, 1) == ":"
and (path.substr(2, 1) == "\\" or path.substr(2, 1) == "/")
)
## Executable extensions Windows' CreateProcessW can launch from a path ## Executable extensions Windows' CreateProcessW can launch from a path
## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe` ## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe`
## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is ## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is
+33
View File
@@ -30,6 +30,9 @@ static func configure(
server_url: String, server_url: String,
launch: Dictionary = {}, launch: Dictionary = {},
) -> Dictionary: ) -> Dictionary:
var write_error := configure_write_error(client)
if not write_error.is_empty():
return {"status": "error", "message": write_error}
## Fail closed before any subprocess runs: a command-shape client without a ## Fail closed before any subprocess runs: a command-shape client without a
## verified attach launcher must not register anything (see ## verified attach launcher must not register anything (see
## docs/client-configuration.md — an ERROR beats an entry known to be broken). ## docs/client-configuration.md — an ERROR beats an entry known to be broken).
@@ -184,6 +187,9 @@ static func _status_details(
static func remove(client: McpClient, server_name: String) -> Dictionary: static func remove(client: McpClient, server_name: String) -> Dictionary:
var write_error := remove_write_error(client)
if not write_error.is_empty():
return {"status": "error", "message": write_error}
var cli := _resolve_cli(client) var cli := _resolve_cli(client)
if cli.is_empty(): if cli.is_empty():
return {"status": "error", "message": "%s not found" % client.display_name} return {"status": "error", "message": "%s not found" % client.display_name}
@@ -440,6 +446,33 @@ static func uses_scope_token(client: McpClient) -> bool:
return client.cli_register_template.has(SCOPE_TOKEN) return client.cli_register_template.has(SCOPE_TOKEN)
## Why Configure may not run this client's CLI. The CLI runs inside this
## editor's Flatpak sandbox, so what it puts in the file the client keeps its
## servers in (the JSON-fallback file) lands where the client outside never
## reads it. That file takes the `user` scope's entry and the `local` scope's
## per-project block, and the pre-cleanup sweep (#872) removes from it at
## every scope, so the selected scope makes no difference here: at `project`
## the sweep would miss a stale entry in the host's file and Configure would
## still report success. A descriptor that names no such file has nothing to
## check.
static func configure_write_error(client: McpClient) -> String:
if not client.has_json_fallback():
return ""
return client.config_write_error()
## Why Remove may not. It removes from the selected scope only: `user` and
## `local` keep their entries in the JSON-fallback file, and `project` in the
## CLI's working directory, which this layer does not resolve and leaves to
## the CLI.
static func remove_write_error(client: McpClient) -> String:
if not client.has_json_fallback():
return ""
if uses_scope_token(client) and McpSettings.client_scope() == "project":
return ""
return client.config_write_error()
## Public view of the pre-cleanup sweep, for the manual-command text: what the ## Public view of the pre-cleanup sweep, for the manual-command text: what the
## dock tells the user to run has to match what Configure actually runs (#877). ## dock tells the user to run has to match what Configure actually runs (#877).
static func cleanup_scopes(client: McpClient) -> Array[String]: static func cleanup_scopes(client: McpClient) -> Array[String]:
+8
View File
@@ -64,10 +64,16 @@ static func configure(
var resolution := client.resolved_config_path_details() var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", "")) var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", "")) var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty(): if not path_error.is_empty():
return {"status": "error", "message": path_error} return {"status": "error", "message": path_error}
if path.is_empty(): if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
## Set only while no candidate file exists, so this never blocks an update.
var create_error := str(resolution.get("create_error", ""))
if not create_error.is_empty():
return {"status": "error", "message": create_error}
## Fail closed before touching the file — same contract as JSON/TOML/YAML. ## Fail closed before touching the file — same contract as JSON/TOML/YAML.
var launch_error := command_launch_error(client, launch) var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty(): if not launch_error.is_empty():
@@ -191,6 +197,8 @@ static func remove(client: McpClient, server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details() var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", "")) var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", "")) var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty(): if not path_error.is_empty():
return {"status": "error", "message": path_error} return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path): if path.is_empty() or not FileAccess.file_exists(path):
+161 -23
View File
@@ -19,10 +19,16 @@ static func configure(
var resolution := client.resolved_config_path_details() var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", "")) var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", "")) var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty(): if not path_error.is_empty():
return {"status": "error", "message": path_error} return {"status": "error", "message": path_error}
if path.is_empty(): if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
## Set only while no candidate file exists, so this never blocks an update.
var create_error := str(resolution.get("create_error", ""))
if not create_error.is_empty():
return {"status": "error", "message": create_error}
var seed_path := str(resolution.get("seed_path", "")) var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
@@ -59,9 +65,18 @@ static func _configure_merged(
launch: Dictionary, launch: Dictionary,
project_roots: PackedStringArray, project_roots: PackedStringArray,
) -> Dictionary: ) -> Dictionary:
var write_error := _merge_write_error(client)
if not write_error.is_empty():
return {"status": "error", "message": write_error}
var launch_error := command_launch_error(client, launch) var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty(): if not launch_error.is_empty():
return {"status": "error", "message": launch_error} return {"status": "error", "message": launch_error}
## A matched `config_scope_globs` directory (omp named profiles) means the
## user scope may live elsewhere for the running client; writing the
## default tiers would false-succeed (#1085).
var scope_error := _scope_ambiguity_error(client)
if not scope_error.is_empty():
return {"status": "error", "message": scope_error}
var project := _load_project_definitions(client, server_name, project_roots) var project := _load_project_definitions(client, server_name, project_roots)
if not project.get("ok", false): if not project.get("ok", false):
return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))} return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
@@ -74,16 +89,26 @@ static func _configure_merged(
var tiers: Array = loaded.get("tiers", []) var tiers: Array = loaded.get("tiers", [])
if tiers.is_empty(): if tiers.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
var denylist_error := _denylist_error(client, tiers, server_name)
if not denylist_error.is_empty():
return {"status": "error", "message": denylist_error}
var target_index := 0 var target_index := 0
for index in range(tiers.size()): for index in range(tiers.size()):
var config: Dictionary = tiers[index]["data"] var config: Dictionary = tiers[index]["data"]
var holder := _walk_path(config, select_server_key_path(config, client)) var holder := _walk_path(config, select_server_key_path(config, client))
if holder is Dictionary and holder.has(server_name): if holder is Dictionary and holder.has(server_name):
target_index = index target_index = index
## First-definition-wins clients keep the earliest defining tier;
## later tiers are dead, so updating one of those changes nothing.
if _first_wins(client):
break
var tier: Dictionary = tiers[target_index] var tier: Dictionary = tiers[target_index]
var config: Dictionary = tier["data"] var config: Dictionary = tier["data"]
var holder := _ensure_path(config, select_server_key_path(config, client)) var holder := _ensure_path(config, select_server_key_path(config, client))
var existing: Variant = holder.get(server_name, null) var existing: Variant = holder.get(server_name, null)
var disabled_error := _disabled_entry_error(client, tiers, server_name, existing, str(tier["path"]))
if not disabled_error.is_empty():
return {"status": "error", "message": disabled_error}
holder[server_name] = build_entry(client, server_url, existing, launch) holder[server_name] = build_entry(client, server_url, existing, launch)
var path := str(tier["path"]) var path := str(tier["path"])
# F5: refuse to re-serialize a tier whose parsed integers above 2^53 would # F5: refuse to re-serialize a tier whose parsed integers above 2^53 would
@@ -148,7 +173,7 @@ static func check_status_details(
return _entry_status_details(client, holder[server_name], server_url, launch) return _entry_status_details(client, holder[server_name], server_url, launch)
## Verify the effective last definition after applying the client's merge order. ## Verify the effective definition after applying the client's merge order.
static func _check_status_merged( static func _check_status_merged(
client: McpClient, client: McpClient,
server_name: String, server_name: String,
@@ -156,39 +181,60 @@ static func _check_status_merged(
launch: Dictionary, launch: Dictionary,
project_roots: PackedStringArray, project_roots: PackedStringArray,
) -> Dictionary: ) -> Dictionary:
var scope_error := _scope_ambiguity_error(client)
if not scope_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": scope_error}
var allow_comments := _status_allows_comments(client) var allow_comments := _status_allows_comments(client)
var loaded := _load_merge_tiers(client, allow_comments) var loaded := _load_merge_tiers(client, allow_comments)
if not loaded.get("ok", false): if not loaded.get("ok", false):
return {"status": McpClient.Status.ERROR, "error_msg": str(loaded.get("error", "Cannot read merged config tiers"))} return {"status": McpClient.Status.ERROR, "error_msg": str(loaded.get("error", "Cannot read merged config tiers"))}
var effective: Variant = null var effective: Variant = null
var effective_path := ""
for tier in loaded.get("tiers", []): for tier in loaded.get("tiers", []):
var config: Dictionary = tier["data"] var config: Dictionary = tier["data"]
var holder := _walk_path(config, select_server_key_path(config, client)) var holder := _walk_path(config, select_server_key_path(config, client))
if holder is Dictionary and holder.has(server_name): if holder is Dictionary and holder.has(server_name):
effective = holder[server_name] effective = holder[server_name]
effective_path = str(tier["path"])
## First-definition-wins: the earliest defining tier is the one the
## client actually reads; stop instead of overwriting with a dead one.
if _first_wins(client):
break
var project := _load_project_definitions(client, server_name, project_roots, allow_comments) var project := _load_project_definitions(client, server_name, project_roots, allow_comments)
if not project.get("ok", false): if not project.get("ok", false):
return {"status": McpClient.Status.ERROR, "error_msg": str(project.get("error", "Cannot inspect project config tiers"))} return {"status": McpClient.Status.ERROR, "error_msg": str(project.get("error", "Cannot inspect project config tiers"))}
var project_tiers: Array = project.get("tiers", []) var project_tiers: Array = project.get("tiers", [])
if effective != null or not project_tiers.is_empty():
var denylist_error := _denylist_error(client, loaded.get("tiers", []), server_name)
if not denylist_error.is_empty():
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": denylist_error}
if not project_tiers.is_empty(): if not project_tiers.is_empty():
# Last-definition-wins mirrors the global-tier fold above and how # The project tier that drives the client's effective config: the
# pi-codemode-mcp merges project tiers on disk. Earlier tiers are dead # latest for last-wins clients (pi-codemode-mcp merges project tiers
# once a later one defines the same server, so an early-stale entry # that way; earlier tiers are dead once a later one defines the same
# doesn't make Pi's effective config drift (codex-review finding F2). # server — codex-review finding F2), the earliest for first-wins
# Pass `[latest]` to `_project_override_message` so the error names only # clients (omp reads `.omp/mcp.json` before `.omp/.mcp.json`). Pass
# the file the user actually has to edit. # `[effective_tier]` to `_project_override_message` so the error names
var latest: Dictionary = project_tiers[project_tiers.size() - 1] # only the file the user actually has to edit.
var details := _entry_status_details(client, latest["entry"], server_url, launch) var effective_index := 0 if _first_wins(client) else project_tiers.size() - 1
var effective_tier: Dictionary = project_tiers[effective_index]
var disabled_error := _disabled_entry_error(client, loaded.get("tiers", []), server_name, effective_tier["entry"], str(effective_tier["path"]))
if not disabled_error.is_empty():
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": disabled_error}
var details := _entry_status_details(client, effective_tier["entry"], server_url, launch)
if details.get("status") != McpClient.Status.CONFIGURED: if details.get("status") != McpClient.Status.CONFIGURED:
## Keep `owned` from the effective entry: the post-update migration ## Keep `owned` from the effective entry: the post-update migration
## decides from it whether this mismatch is ours to repin. ## decides from it whether this mismatch is ours to repin.
var mismatch := details.duplicate() var mismatch := details.duplicate()
mismatch["status"] = McpClient.Status.CONFIGURED_MISMATCH mismatch["status"] = McpClient.Status.CONFIGURED_MISMATCH
mismatch["error_msg"] = _project_override_message([latest], "update or remove", client.display_name, server_name) mismatch["error_msg"] = _project_override_message([effective_tier], "update or remove", client.display_name, server_name)
return mismatch return mismatch
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
if effective == null: if effective == null:
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var disabled_error := _disabled_entry_error(client, loaded.get("tiers", []), server_name, effective, effective_path)
if not disabled_error.is_empty():
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": disabled_error}
return _entry_status_details(client, effective, server_url, launch) return _entry_status_details(client, effective, server_url, launch)
@@ -222,6 +268,8 @@ static func remove(
var resolution := client.resolved_config_path_details() var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", "")) var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", "")) var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty(): if not path_error.is_empty():
return {"status": "error", "message": path_error} return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path): if path.is_empty() or not FileAccess.file_exists(path):
@@ -247,6 +295,15 @@ static func remove(
static func _remove_merged( static func _remove_merged(
client: McpClient, server_name: String, project_roots: PackedStringArray client: McpClient, server_name: String, project_roots: PackedStringArray
) -> Dictionary: ) -> Dictionary:
var write_error := _merge_write_error(client)
if not write_error.is_empty():
return {"status": "error", "message": write_error}
## Scope ambiguity (omp named profiles) blocks Remove too: clearing the
## default files while a profile reads its own would report a success the
## running client never observes (#1085).
var scope_error := _scope_ambiguity_error(client)
if not scope_error.is_empty():
return {"status": "error", "message": scope_error}
var project := _load_project_definitions(client, server_name, project_roots) var project := _load_project_definitions(client, server_name, project_roots)
if not project.get("ok", false): if not project.get("ok", false):
return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))} return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
@@ -633,6 +690,63 @@ static func _uses_merge_tiers(client: McpClient) -> bool:
var templates = client.get("config_merge_path_templates") var templates = client.get("config_merge_path_templates")
return templates is Dictionary and not templates.is_empty() return templates is Dictionary and not templates.is_empty()
## True when the client resolves duplicate definitions first-wins (see
## `McpClient.config_merge_first_wins`). Dynamic get keeps a mixed-snapshot
## self-update parse-safe against an older McpClient base (#398/#736).
static func _first_wins(client: McpClient) -> bool:
return bool(client.get("config_merge_first_wins"))
## Fail-closed gate for clients whose user scope can be relocated by something
## the editor cannot observe (omp named profiles). Any directory matching a
## declared `config_scope_globs` template means the effective destination is
## ambiguous. Declared relocation environment names also cause refusal.
static func _scope_ambiguity_error(client: McpClient) -> String:
var env_names: Variant = client.get("config_scope_envs")
if env_names is PackedStringArray:
for env_name in env_names:
if not McpPathTemplate.env_lookup(env_name).is_empty():
return "%s is set and may relocate %s configuration. Confirm the active client config path and edit the entry manually; default-profile files were not changed." % [env_name, client.display_name]
var globs: Variant = client.get("config_scope_globs")
if not (globs is PackedStringArray):
return ""
var found := PackedStringArray()
for template in globs:
for candidate in McpPathTemplate.expand_path_candidates(String(template)):
if DirAccess.dir_exists_absolute(candidate) and not found.has(candidate):
found.append(candidate)
if found.is_empty():
return ""
return (
"%s named profiles found at %s. A named profile reads only its own agent config and the active profile is chosen per %s launch, so this editor cannot resolve the effective destination; edit the entry manually."
% [client.display_name, ", ".join(found), client.display_name]
)
static func _disabled_entry_error(client: McpClient, tiers: Array, server_name: String, entry: Variant, path: String) -> String:
if client.config_enabled_key.is_empty() or not (entry is Dictionary):
return ""
var enabled: Variant = entry.get(client.config_enabled_key)
var disabled: bool = (enabled is bool and not enabled) or (enabled is String and enabled.to_lower() in ["false", "0"])
if not disabled:
return ""
if not client.config_allowlist_key.is_empty() and not tiers.is_empty():
var allowed: Variant = tiers[0]["data"].get(client.config_allowlist_key)
if allowed is Array and allowed.has(server_name):
return ""
return "%s is disabled by %s in %s. Re-enable it in %s before configuring; all files were left unchanged." % [server_name, client.config_enabled_key, path, client.display_name]
static func _denylist_error(client: McpClient, tiers: Array, server_name: String) -> String:
var key: Variant = client.get("config_denylist_key")
if not (key is String) or String(key).is_empty() or tiers.is_empty():
return ""
var primary: Dictionary = tiers[0]
var denylist: Variant = primary["data"].get(key, null)
if denylist is Array and denylist.has(server_name):
return "%s is disabled by %s in %s. Review that primary config manually before configuring; all files were left unchanged." % [server_name, key, primary["path"]]
return ""
## Resolve the global config tiers in the client's documented merge order. ## Resolve the global config tiers in the client's documented merge order.
static func _merge_paths(client: McpClient) -> PackedStringArray: static func _merge_paths(client: McpClient) -> PackedStringArray:
@@ -650,6 +764,21 @@ static func _merge_paths(client: McpClient) -> PackedStringArray:
return result return result
## The Flatpak write check `configure` and `remove` make on their one path,
## made here on every tier. Any tier this sandbox does not share with the host
## refuses the whole Configure or Remove: an unseen tier may hold the
## definition the client actually uses. Status reads past it, as it does for
## an ordinary client.
static func _merge_write_error(client: McpClient) -> String:
for path in _merge_paths(client):
if not path.is_absolute_path():
continue
var write_error := McpClient.unshared_flatpak_config_error(client.display_name, path)
if not write_error.is_empty():
return write_error
return ""
## Pi's project tiers are relative to Pi's process cwd, not Godot's. Inspect ## Pi's project tiers are relative to Pi's process cwd, not Godot's. Inspect
## plausible roots only to detect overrides; callers fail closed rather than ## plausible roots only to detect overrides; callers fail closed rather than
## mutating both guesses. ## mutating both guesses.
@@ -765,11 +894,12 @@ static func _write_transaction(writes: Array[Dictionary]) -> Dictionary:
## land on the file that actually drives Pi's effective config. ## land on the file that actually drives Pi's effective config.
## ##
## Resolution order (codex round 3, F-3-4): ## Resolution order (codex round 3, F-3-4):
## 1. Latest project tier containing the entry — F2 last-wins means the ## 1. The project tier that wins the client's fold — the latest of
## latest of `.pi/mcp.json` and `.mcp.json` wins (matches `_check_status_merged`). ## `.pi/mcp.json` and `.mcp.json` for last-wins clients, the earliest
## 2. Latest global tier containing the entry — already iterated in ## for first-wins clients (matches `_check_status_merged`).
## merge order, last-iterated-wins (same loop pattern as ## 2. The global tier that wins the client's fold — for last-wins clients
## `manual_target_details` lines 660-665). ## the last-iterated match, for first-wins clients the first match
## (tiers arrive in the client's read order).
## 3. "" when no tier has the entry — caller decides what fallback ## 3. "" when no tier has the entry — caller decides what fallback
## (`path_template`) to use. ## (`path_template`) to use.
## ##
@@ -789,24 +919,28 @@ static func authoritative_tier_path(
return "" return ""
var project_tiers: Array = project.get("tiers", []) var project_tiers: Array = project.get("tiers", [])
if not project_tiers.is_empty(): if not project_tiers.is_empty():
# F2 last-wins: latest project tier is authoritative. When there are # The project tier that drives the user-visible status (F2: latest for
# multiple project tiers, the user-visible status is driven by the # last-wins clients, earliest for first-wins ones), so the Open/Reveal
# latest, so the Open/Reveal buttons should send them there too. # buttons send the user to the file they actually have to edit.
var latest: Dictionary = project_tiers[project_tiers.size() - 1] var effective_index := 0 if _first_wins(client) else project_tiers.size() - 1
return str(latest.get("path", "")) var effective_tier: Dictionary = project_tiers[effective_index]
return str(effective_tier.get("path", ""))
var loaded := _load_merge_tiers(client, allow_comments) var loaded := _load_merge_tiers(client, allow_comments)
if not bool(loaded.get("ok", false)): if not bool(loaded.get("ok", false)):
return "" return ""
var tiers: Array = loaded.get("tiers", []) var tiers: Array = loaded.get("tiers", [])
# Iterate in priority order, overwrite `selected_path` each time we # Iterate in the client's read order. Last-wins clients overwrite
# find a tier containing the entry. With Pi's merge path order # `selected_path` on every match (Pi's [mcp.json, .mcp.json] order then
# [mcp.json, .mcp.json] this leaves the higher-precedence `.mcp.json`. # leaves the higher-precedence `.mcp.json`); first-wins clients stop at
# the first match, which is the file the client actually reads.
var selected_path: String = "" var selected_path: String = ""
for tier in tiers: for tier in tiers:
var data: Dictionary = tier.get("data", {}) var data: Dictionary = tier.get("data", {})
var holder: Variant = _walk_path(data, select_server_key_path(data, client)) var holder: Variant = _walk_path(data, select_server_key_path(data, client))
if holder is Dictionary and holder.has(server_name): if holder is Dictionary and holder.has(server_name):
selected_path = str(tier.get("path", "")) selected_path = str(tier.get("path", ""))
if _first_wins(client):
break
return selected_path return selected_path
@@ -845,6 +979,10 @@ static func manual_target_details(
var holder := _walk_path(config, select_server_key_path(config, client)) var holder := _walk_path(config, select_server_key_path(config, client))
if holder is Dictionary and holder.has(server_name): if holder is Dictionary and holder.has(server_name):
selected = tier selected = tier
## First-wins clients read the earliest defining tier; later
## matches are dead and must not redirect the manual flow.
if _first_wins(client):
break
if selected != null: if selected != null:
var config: Dictionary = selected["data"] var config: Dictionary = selected["data"]
return { return {
+4 -1
View File
@@ -199,7 +199,10 @@ static func _build_json(
target = {"path": resolved_path, "key_path": client.server_key_path} target = {"path": resolved_path, "key_path": client.server_key_path}
var target_path := str(target.get("path", resolved_path)) var target_path := str(target.get("path", resolved_path))
var key_path: PackedStringArray = target.get("key_path", client.server_key_path) var key_path: PackedStringArray = target.get("key_path", client.server_key_path)
var key := key_path[0] if key_path.size() > 0 else "mcpServers" ## Name the whole server-map path. A nested map (ZCode's `mcp.servers`) must
## not collapse to its first segment, or the instructions place the entry a
## level too high for the client parser to find.
var key := ".".join(key_path) if key_path.size() > 0 else "mcpServers"
if client.command_shape != McpClient.CommandShape.NONE: if client.command_shape != McpClient.CommandShape.NONE:
var lines: Array[String] = [] var lines: Array[String] = []
var launch_error := McpJsonStrategy.command_launch_error(client, launch) var launch_error := McpJsonStrategy.command_launch_error(client, launch)
+207 -1
View File
@@ -23,6 +23,19 @@ extends RefCounted
static var _env_snapshot := {} static var _env_snapshot := {}
static var _env_snapshot_mutex := Mutex.new() static var _env_snapshot_mutex := Mutex.new()
const LinuxProc := preload("res://addons/godot_ai/utils/linux_proc.gd")
const _MOUNTINFO_PATH := "/proc/self/mountinfo"
## `/.flatpak-info` as Flatpak wrote it before this process started, or "" when
## this is not a Flatpak sandbox. The sandbox it describes cannot change while
## the process runs, so one read serves every thread. Guarded by
## `_env_snapshot_mutex`, like the snapshot it is consulted alongside.
static var _flatpak_info := ""
static var _flatpak_info_read := false
## What that sandbox mounted, from `/proc/self/mountinfo`: read with the file
## above, and only inside a sandbox. Empty when it could not be read.
static var _flatpak_mounts: Array[Dictionary] = []
## Every var this layer and its sibling consumers (`_base.gd` ## Every var this layer and its sibling consumers (`_base.gd`
## `config_file_override_details`, `config_home_override_details`, `_cli_finder.gd` lookups, ## `config_file_override_details`, `config_home_override_details`, `_cli_finder.gd` lookups,
## `client_configurator.gd` mode/trace reads) can touch off-main. ## `client_configurator.gd` mode/trace reads) can touch off-main.
@@ -32,6 +45,10 @@ const _BASE_ENV_VARS: Array[String] = [
"HOME", "HOME",
"USERPROFILE", "USERPROFILE",
"XDG_CONFIG_HOME", "XDG_CONFIG_HOME",
## What `$XDG_CONFIG_HOME` reads in a Flatpak editor — see `_env_source`.
"HOST_XDG_CONFIG_HOME",
## Read by `flatpak_hides_credentials` on the workers that run Configure.
"GODOT_AI_CAPABILITY_DIR",
"APPDATA", "APPDATA",
"LOCALAPPDATA", "LOCALAPPDATA",
"SHELL", "SHELL",
@@ -76,6 +93,120 @@ static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray(
for var_name in extra_vars: for var_name in extra_vars:
if not String(var_name).is_empty(): if not String(var_name).is_empty():
env_lookup(String(var_name)) env_lookup(String(var_name))
_flatpak_info_text()
## Cached `/.flatpak-info`, read on first use from whichever thread asks. Only
## Linux has Flatpak; elsewhere the leading slash would name a drive root.
static func _flatpak_info_text() -> String:
_env_snapshot_mutex.lock()
_read_flatpak_sandbox()
var info := _flatpak_info
_env_snapshot_mutex.unlock()
return info
## The sandbox's mounts, cached with the description above. The list is built
## once and never changed, so callers on any thread may read it.
static func _flatpak_mount_list() -> Array[Dictionary]:
_env_snapshot_mutex.lock()
_read_flatpak_sandbox()
var mounts := _flatpak_mounts
_env_snapshot_mutex.unlock()
return mounts
## Caller holds `_env_snapshot_mutex`.
static func _read_flatpak_sandbox() -> void:
if _flatpak_info_read:
return
_flatpak_info = McpTransportCapability._flatpak_info() if _os_key() == "linux" else ""
var mountinfo := ""
if not _flatpak_info.is_empty():
mountinfo = str(LinuxProc.read_text(_MOUNTINFO_PATH).text)
_flatpak_mounts = McpTransportCapability.parse_mountinfo(mountinfo)
_flatpak_info_read = true
## Stand in for the sandbox description so tests can cover Flatpak resolution
## on any host, with `mountinfo` as its `/proc/self/mountinfo`: by default no
## mount evidence at all, never the real file's. Pass null to forget both; the
## next read then asks the real files.
static func _set_flatpak_info_for_test(info: Variant, mountinfo: String = "") -> void:
_env_snapshot_mutex.lock()
_flatpak_info = "" if info == null else str(info)
_flatpak_mounts = McpTransportCapability.parse_mountinfo(mountinfo)
_flatpak_info_read = info != null
_env_snapshot_mutex.unlock()
## This editor's Flatpak application ID, or "" outside Flatpak.
static func flatpak_app_id() -> String:
return McpTransportCapability.flatpak_application_id(_flatpak_info_text())
## The host's config directory when this editor is a Flatpak that shares it
## (through the home directory, or a grant for the whole of `xdg-config`); ""
## anywhere else, and when it cannot be resolved. For callers whose own
## default is `OS.get_config_dir()`, which in that sandbox is the per-app
## directory that nothing outside it reads.
static func flatpak_host_config_home() -> String:
if not McpTransportCapability.flatpak_shares_config_home(_flatpak_info_text()):
return ""
var config_home := expand("$XDG_CONFIG_HOME")
return config_home if config_home.is_absolute_path() else ""
## Whether this editor's Flatpak sandbox keeps Godot AI's credentials where a
## client outside it does not look. The bridge a client starts reads the
## host's `<config home>/godot-ai/capabilities`, so an entry Configure writes
## from such a sandbox cannot connect, however right its file is.
##
## The server publishes in the host's config directory when the sandbox
## shares it (`McpTransportCapability.linux_config_home_variable`), and
## otherwise in the per-app one, which is the host's only where an
## `xdg-config/` grant mounted the host's directory inside it. Either way the
## host's directory has to be shared read-write, which `flatpak_write_block`
## decides. An editor given `GODOT_AI_CAPABILITY_DIR` publishes somewhere this
## cannot judge, and its client needs the same variable: never hidden here.
static func flatpak_hides_credentials() -> bool:
var info := _flatpak_info_text()
if info.is_empty():
return false
if not env_lookup(McpTransportCapability.CAPABILITY_DIR_ENV).strip_edges().is_empty():
return false
var config_home := expand("$XDG_CONFIG_HOME")
if not config_home.is_absolute_path():
return false
var host_directory := config_home.path_join("godot-ai/capabilities")
if not flatpak_write_block(host_directory.path_join("http.json")).is_empty():
return true
if McpTransportCapability.flatpak_shares_config_home(info):
return false
var grant := McpTransportCapability.flatpak_grant_over(
info, host_directory, _home(), config_home
)
return not str(grant.get("location", "")).begins_with("xdg-config/")
## The `~/.var/app/<id>` directory of another Flatpak app that `path` lies in,
## when this editor's own Flatpak sandbox does not show it; "" otherwise.
## Flatpak mounts only the running app's directory under `~/.var/app`, and a
## `host` or `home` grant does not widen that. From in here another app's
## settings are therefore unknown rather than absent until the user grants
## that directory by name, and anything created below it would land on the
## sandbox's private tmpfs.
static func hidden_flatpak_app_dir(path: String) -> String:
if _flatpak_info_text().is_empty():
return ""
var home := _home()
if home.is_empty():
return ""
var apps := home.path_join(".var/app") + "/"
if not path.begins_with(apps):
return ""
var app_dir := apps + path.trim_prefix(apps).get_slice("/", 0)
return "" if DirAccess.dir_exists_absolute(app_dir) else app_dir
## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map. ## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map.
@@ -183,7 +314,7 @@ static func expand(template: String) -> String:
var token := "$%s" % var_name var token := "$%s" % var_name
if out.find(token) < 0: if out.find(token) < 0:
continue continue
var value := env_lookup(var_name) var value := env_lookup(_env_source(var_name))
if value.is_empty(): if value.is_empty():
value = _home_fallback(var_name) value = _home_fallback(var_name)
if value.is_empty(): if value.is_empty():
@@ -192,6 +323,81 @@ static func expand(template: String) -> String:
return out return out
## The environment variable a token's value is read from. Flatpak points
## `XDG_CONFIG_HOME` at the editor's own `~/.var/app/<id>/config`, where no
## client looks: a path expanded from it is written, read back, and reported
## as configured without the client ever seeing it. Inside a sandbox the token
## therefore reads the host's variable, so a template always names the file
## the client reads. Whether this sandbox can reach that file is a separate
## question, answered by `flatpak_write_block`. Every grant that shares part
## of the host's config directory mounts it at the host's path, an
## `xdg-config/<dir>` grant included, so no path has to go through the per-app
## directory, where a leftover from an earlier run would pass for the client's
## own file.
static func _env_source(var_name: String) -> String:
if var_name == "XDG_CONFIG_HOME" and not _flatpak_info_text().is_empty():
return "HOST_XDG_CONFIG_HOME"
return var_name
## Why a file this editor writes at `path` would not be the one a client
## outside its Flatpak sandbox reads there. {} when it would be, and always
## outside Flatpak. Otherwise one of:
## - {"needs": grant}: no read-write grant covers the file's directory, and
## this is the `--filesystem=` value that would. That is the read-only grant
## that does cover it when there is one, because a wider grant does not
## lift it.
## - {"unmounted": directory}: a read-write grant names this directory, but
## Flatpak mounted nothing there, which is what it does when the directory
## does not exist as the app starts.
## - {"hidden": directory}: inside a granted directory, this one is an empty
## tmpfs, which is how Flatpak carries out a denial (`--nofilesystem`).
## - {"read_only": directory}: granted read-write, mounted read-only.
##
## The directory is what has to be shared, not the file: `McpAtomicWrite`
## stages a temporary file beside the target and renames it into place, which
## cannot replace a file Flatpak mounted on its own. It also follows a
## symlinked config file to its target, so that is the file tested.
static func flatpak_write_block(path: String) -> Dictionary:
var info := _flatpak_info_text()
if info.is_empty():
return {}
var directory := McpAtomicWrite._resolve_symlink_target(path).get_base_dir()
var home := _home()
var inside_home := (
not home.is_empty() and McpTransportCapability.path_is_within(home, directory)
)
var grant := McpTransportCapability.flatpak_grant_over(
info, directory, home, expand("$XDG_CONFIG_HOME")
)
if not grant.get("writable", false):
if not grant.is_empty():
return {"needs": str(grant["location"])}
## Nothing under ~/.var/app comes with the home, so there the app's
## directory has to be granted by name.
var apps := home.path_join(".var/app") + "/"
if inside_home and directory.begins_with(apps):
return {"needs": apps + directory.trim_prefix(apps).get_slice("/", 0)}
return {"needs": "home" if inside_home else directory}
var root := str(grant["root"])
var real_root := McpTransportCapability.real_path(root)
var mount := McpTransportCapability.flatpak_blocking_mount(
_flatpak_mount_list(),
McpTransportCapability.real_path(directory),
McpTransportCapability.real_path(home) if inside_home else real_root,
)
if mount.is_empty():
return {}
var mount_point := str(mount["mount_point"])
if mount["read_only"]:
return {"read_only": mount_point}
## A tmpfs above the granted directory is not there because of the grant:
## the grant's own mount is what is missing.
if not McpTransportCapability.path_is_within(real_root, mount_point):
return {"unmounted": root}
return {"hidden": mount_point}
## Home-derived default for a var the environment does not define. Returns "" ## Home-derived default for a var the environment does not define. Returns ""
## when home itself is unresolvable so `expand` leaves the token alone: rooting ## when home itself is unresolvable so `expand` leaves the token alone: rooting
## these at an empty home yields a RELATIVE path, not an absent one — ## these at an empty home yields a RELATIVE path, not an absent one —
+2
View File
@@ -37,6 +37,8 @@ const _CLIENT_SCRIPT_PATHS := [
"res://addons/godot_ai/clients/hermes.gd", "res://addons/godot_ai/clients/hermes.gd",
"res://addons/godot_ai/clients/pi.gd", "res://addons/godot_ai/clients/pi.gd",
"res://addons/godot_ai/clients/deepseek_harness.gd", "res://addons/godot_ai/clients/deepseek_harness.gd",
"res://addons/godot_ai/clients/zcode.gd",
"res://addons/godot_ai/clients/omp.gd",
] ]
static var _instances: Array[McpClient] = [] static var _instances: Array[McpClient] = []
@@ -19,10 +19,16 @@ static func configure(
var resolution := client.resolved_config_path_details() var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", "")) var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", "")) var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty(): if not path_error.is_empty():
return {"status": "error", "message": path_error} return {"status": "error", "message": path_error}
if path.is_empty(): if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name} return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name}
## Set only while no candidate file exists, so this never blocks an update.
var create_error := str(resolution.get("create_error", ""))
if not create_error.is_empty():
return {"status": "error", "message": create_error}
var seed_path := str(resolution.get("seed_path", "")) var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
@@ -198,6 +204,8 @@ static func remove(client: McpClient, _server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details() var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", "")) var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", "")) var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty(): if not path_error.is_empty():
return {"status": "error", "message": path_error} return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path): if path.is_empty() or not FileAccess.file_exists(path):
@@ -24,10 +24,16 @@ static func configure(
var resolution := client.resolved_config_path_details() var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", "")) var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", "")) var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty(): if not path_error.is_empty():
return {"status": "error", "message": path_error} return {"status": "error", "message": path_error}
if path.is_empty(): if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
## Set only while no candidate file exists, so this never blocks an update.
var create_error := str(resolution.get("create_error", ""))
if not create_error.is_empty():
return {"status": "error", "message": create_error}
## Fail closed before touching the file — same contract as JSON/TOML. ## Fail closed before touching the file — same contract as JSON/TOML.
var launch_error := command_launch_error(client, launch) var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty(): if not launch_error.is_empty():
@@ -105,6 +111,8 @@ static func remove(client: McpClient, server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details() var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", "")) var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", "")) var path_error := str(resolution.get("error", ""))
if path_error.is_empty() and not path.is_empty():
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
if not path_error.is_empty(): if not path_error.is_empty():
return {"status": "error", "message": path_error} return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path): if path.is_empty() or not FileAccess.file_exists(path):
+8
View File
@@ -14,6 +14,14 @@ func _init() -> void:
"windows": "$APPDATA/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", "windows": "$APPDATA/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
} }
## globalStorage follows VS Code's user data, which the Flathub build keeps
## in its own app directory (see vscode.gd).
config_path_candidates = {
"linux": [
path_template["linux"],
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
],
}
server_key_path = PackedStringArray(["mcpServers"]) server_key_path = PackedStringArray(["mcpServers"])
## Cline (like Roo) defaults a typeless entry to SSE transport, which ## Cline (like Roo) defaults a typeless entry to SSE transport, which
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin ## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
+8
View File
@@ -11,6 +11,14 @@ func _init() -> void:
"windows": "$APPDATA/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json", "windows": "$APPDATA/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json", "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
} }
## globalStorage follows VS Code's user data, which the Flathub build keeps
## in its own app directory (see vscode.gd).
config_path_candidates = {
"linux": [
path_template["linux"],
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
],
}
server_key_path = PackedStringArray(["mcpServers"]) server_key_path = PackedStringArray(["mcpServers"])
## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which ## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin ## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
+64
View File
@@ -0,0 +1,64 @@
@tool
extends McpClient
## Oh My Pi (omp): https://github.com/can1357/oh-my-pi
## omp keeps the FIRST definition of a duplicated server: project
## `.omp/mcp.json` shadows `.omp/.mcp.json`, then the active user scope's
## `mcp.json` shadows its `.mcp.json`. The tiers below are declared in that
## read order with `config_merge_first_wins`, so Configure updates the file
## that already owns the server instead of shadowing a compatibility
## entry's user state (#1085). omp never writes the compatibility files
## itself; this adapter updates an existing compatibility entry in place.
## A fresh entry is created only on tiers[0].
##
## The user scope can be relocated per launch (`omp --profile`,
## OMP_PROFILE/PI_PROFILE). `config_scope_globs` fails Configure/Remove
## closed while any named profile exists, because the active profile is
## chosen at client launch and is not persisted anywhere the editor can
## read — status reports the ambiguity instead of green-lighting the
## default file.
func _init() -> void:
id = "omp"
display_name = "Oh My Pi"
config_type = "json"
path_template = {
"unix": "~/.omp/agent/mcp.json",
"windows": "$USERPROFILE/.omp/agent/mcp.json",
}
## Declared in omp's read order (primary first, compatibility second);
## `_first_wins` folds stop at the first tier defining the server, so a
## write updates the effective file and a fresh entry lands on the
## primary. Status still verifies against every existing tier.
config_merge_path_templates = {
"unix": PackedStringArray([
"~/.omp/agent/mcp.json",
"~/.omp/agent/.mcp.json",
]),
"windows": PackedStringArray([
"$USERPROFILE/.omp/agent/mcp.json",
"$USERPROFILE/.omp/agent/.mcp.json",
]),
}
config_merge_project_paths = PackedStringArray([".omp/mcp.json", ".omp/.mcp.json"])
config_merge_first_wins = true
config_scope_globs = PackedStringArray(["~/.omp/profiles/*"])
config_scope_envs = PackedStringArray(["PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"])
## The primary user file owns this override even for a compatibility entry.
config_denylist_key = "disabledServers"
config_enabled_key = "enabled"
config_allowlist_key = "enabledServers"
server_key_path = PackedStringArray(["mcpServers"])
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "headers", "type"])
## Seeded only on a fresh entry; reconfigure preserves the effective
## entry's values. OMP_MCP_TIMEOUT_MS may override this per-server
## timeout.
command_initial_fields = {"enabled": true, "timeout": 300000}
command_timeout_fields = PackedStringArray(["timeout"])
command_user_fields = PackedStringArray(["enabled", "timeout", "env", "cwd"])
## omp creates `~/.omp/agent` on first launch (agent.db and friends)
## before any MCP server is configured, so the directory is the honest
## install signal — the config leaf may not exist yet.
detect_paths = PackedStringArray(["~/.omp/agent"])
+1
View File
@@ -0,0 +1 @@
uid://b1daig0jwxvfb
+8
View File
@@ -11,6 +11,14 @@ func _init() -> void:
"windows": "$APPDATA/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json", "windows": "$APPDATA/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json", "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
} }
## globalStorage follows VS Code's user data, which the Flathub build keeps
## in its own app directory (see vscode.gd).
config_path_candidates = {
"linux": [
path_template["linux"],
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
],
}
server_key_path = PackedStringArray(["mcpServers"]) server_key_path = PackedStringArray(["mcpServers"])
## Roo defaults an entry with no "type" to SSE transport — which returns ## Roo defaults an entry with no "type" to SSE transport — which returns
## HTTP 400 against our streamable-http endpoint on `/mcp`. Pin the type ## HTTP 400 against our streamable-http endpoint on `/mcp`. Pin the type
+9
View File
@@ -14,6 +14,15 @@ func _init() -> void:
"windows": "$APPDATA/Code/User/mcp.json", "windows": "$APPDATA/Code/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Code/User/mcp.json", "linux": "$XDG_CONFIG_HOME/Code/User/mcp.json",
} }
## The Flathub build keeps its user data in the app's own directory and
## never reads ~/.config/Code. Verified against com.visualstudio.code
## 1.139.1: `code --add-mcp` writes the second path, under `servers`.
config_path_candidates = {
"linux": [
path_template["linux"],
"~/.var/app/com.visualstudio.code/config/Code/User/mcp.json",
],
}
server_key_path = PackedStringArray(["servers"]) server_key_path = PackedStringArray(["servers"])
entry_extra_fields = {"type": "http"} entry_extra_fields = {"type": "http"}
## Attach migration (#838). VS Code stdio entries are flat command/args/env ## Attach migration (#838). VS Code stdio entries are flat command/args/env
+40
View File
@@ -0,0 +1,40 @@
@tool
extends McpClient
## ZCode (Z.AI's GLM coding agent): https://zcode.z.ai/en/docs/mcp-services
## User-scope MCP servers live in `~/.zcode/cli/config.json` under the nested
## `mcp.servers` map; the workspace scope is `<project>/.zcode/config.json`,
## which this descriptor does not target because the client's working directory
## is unknown to the editor. Entries are flat command/args/env, and
## `type: "stdio"` is what the settings form, the bundled plugin manifest, and
## working third-party integrations write - it also repins a stale
## `type: "http"` left on a hand-added remote entry. `enable` (singular) is
## ZCode's documented user-state key (absence means enabled), so it is
## preserved rather than written.
##
## Manual-only: adding any `.zcode` server makes ZCode skip `~/.agents/mcp.json`
## for that scope entirely (documented `.zcode`-first rule, no merging). An
## automatic write could therefore silently disable every server a user keeps in
## the fallback, so Configure and Remove only render the manual entry; moving
## those entries into the native file stays the user's explicit choice.
func _init() -> void:
id = "zcode"
display_name = "ZCode"
config_type = "json"
## A native write would shadow `~/.agents/mcp.json`; see the class comment.
automatic_config_edits = false
path_template = {
"unix": "~/.zcode/cli/config.json",
"windows": "$USERPROFILE/.zcode/cli/config.json",
}
server_key_path = PackedStringArray(["mcp", "servers"])
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["env", "enable"])
## ZCode creates `~/.zcode` on first launch; the config leaf may not exist
## yet, so the directory is the installed signal.
detect_paths = PackedStringArray(["~/.zcode"])
+1
View File
@@ -0,0 +1 @@
uid://b0xpusa88vykn
+10
View File
@@ -21,6 +21,16 @@ func _init() -> void:
"linux": "$XDG_CONFIG_HOME/zed/settings.json", "linux": "$XDG_CONFIG_HOME/zed/settings.json",
"windows": "$APPDATA/Zed/settings.json", "windows": "$APPDATA/Zed/settings.json",
} }
## The Flathub build restarts itself on the host with
## FLATPAK_XDG_CONFIG_HOME set to its own app directory, and reads
## `$FLATPAK_XDG_CONFIG_HOME/zed` instead of ~/.config/zed
## (crates/paths/src/paths.rs, crates/cli/src/main.rs).
config_path_candidates = {
"linux": [
path_template["linux"],
"~/.var/app/dev.zed.Zed/config/zed/settings.json",
],
}
server_key_path = PackedStringArray(["context_servers"]) server_key_path = PackedStringArray(["context_servers"])
## Attach migration (#838). Current Zed's context_servers entries are an ## Attach migration (#838). Current Zed's context_servers entries are an
## untagged serde enum discriminated by shape: `command` (string) + args/env ## untagged serde enum discriminated by shape: `command` (string) + args/env
+8
View File
@@ -11,6 +11,14 @@ func _init() -> void:
"windows": "$APPDATA/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json", "windows": "$APPDATA/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json", "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
} }
## globalStorage follows VS Code's user data, which the Flathub build keeps
## in its own app directory (see vscode.gd).
config_path_candidates = {
"linux": [
path_template["linux"],
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
],
}
server_key_path = PackedStringArray(["mcpServers"]) server_key_path = PackedStringArray(["mcpServers"])
## Local validation against the installed extension shows Zoo stores MCP ## Local validation against the installed extension shows Zoo stores MCP
## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's ## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's
+14 -11
View File
@@ -109,13 +109,7 @@ var _packet_spillover_total := 0
func _ready() -> void: func _ready() -> void:
_session_id = _make_session_id(ProjectSettings.globalize_path("res://")) _session_id = _make_session_id(ProjectSettings.globalize_path("res://"))
## Increase outbound buffer for large messages (e.g. screenshot base64). _configure_peer_buffers(_peer)
## Default is 64 KB; screenshots can be several MB.
_peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES
## Stay under the pre-auth byte ceiling until the server proof validates;
## only then may the peer allocate the normal 4 MiB command buffer.
_peer.inbound_buffer_size = MAX_HANDSHAKE_FRAME_BYTES
_peer.max_queued_packets = MAX_QUEUED_PACKETS
if connect_blocked: if connect_blocked:
_log_blocked_notice_once() _log_blocked_notice_once()
set_process(false) set_process(false)
@@ -375,12 +369,22 @@ func _attempt_reconnect() -> void:
## a quiet reconnect loop after the Python server restarts. ## a quiet reconnect loop after the Python server restarts.
_peer = WebSocketPeer.new() _peer = WebSocketPeer.new()
_preopen_failure_logged_for_peer = false _preopen_failure_logged_for_peer = false
_peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES _configure_peer_buffers(_peer)
_peer.inbound_buffer_size = MAX_HANDSHAKE_FRAME_BYTES
_peer.max_queued_packets = MAX_QUEUED_PACKETS
_connect_to_server() _connect_to_server()
## WSLPeer fixes its receive limit during the WebSocket handshake; changing
## this property after the server proof cannot raise that limit (#1056).
## The peer buffers use the command budget even before authentication.
## `_handle_message` separately enforces the smaller handshake message limit.
static func _configure_peer_buffers(peer: WebSocketPeer) -> void:
## Outbound: screenshots are several MB of base64 (default is 64 KiB).
peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES
## Inbound: the normal 4 MiB command ceiling, applied before connecting.
peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES
peer.max_queued_packets = MAX_QUEUED_PACKETS
func pause() -> void: func pause() -> void:
_pause_depth += 1 _pause_depth += 1
@@ -647,7 +651,6 @@ func _handle_auth_challenge(parsed: Dictionary) -> void:
_server_nonce = str(parsed["server_nonce"]) _server_nonce = str(parsed["server_nonce"])
_challenged_server_version = str(parsed["server_version"]) _challenged_server_version = str(parsed["server_version"])
_server_verified = true _server_verified = true
_peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES
_last_readiness = get_readiness() _last_readiness = get_readiness()
var response := _build_auth_response() var response := _build_auth_response()
if response.is_empty() or not _send_json(response, true): if response.is_empty() or not _send_json(response, true):
@@ -80,17 +80,39 @@ static func _port_spinbox(value: int, tooltip: String) -> SpinBox:
## carry it keep working. Note that this OVERWRITES unsaved user input — fine ## carry it keep working. Note that this OVERWRITES unsaved user input — fine
## in practice because the dock only calls this on `server_status` ## in practice because the dock only calls this on `server_status`
## transitions (`if server_status == _last_server_status: return`). ## transitions (`if server_status == _last_server_status: return`).
func seed_suggested_ports(conflict_port := 0) -> void: func seed_suggested_ports(conflict_port := 0, occupancy: Dictionary = {}) -> void:
if _spinbox == null: if _spinbox == null:
return return
if OS.get_name() == "Windows" and occupancy.is_empty():
occupancy = PortResolver.windows_listener_snapshot()
var http := ClientConfigurator.http_port() var http := ClientConfigurator.http_port()
var ws := ClientConfigurator.ws_port() var ws := ClientConfigurator.ws_port()
if conflict_port == http or bool(port_in_use_probe.call(http)): var http_occupied: bool
http = ClientConfigurator.suggest_free_port(http + 1) var ws_occupied: bool
if conflict_port == ws or ws == http or bool(port_in_use_probe.call(ws)): if OS.get_name() == "Windows":
ws = ClientConfigurator.suggest_free_port(ws + 1) http_occupied = PortResolver.windows_port_occupancy(http, occupancy) != PortResolver.PortOccupancy.FREE
if ws == http: ws_occupied = PortResolver.windows_port_occupancy(ws, occupancy) != PortResolver.PortOccupancy.FREE
ws = ClientConfigurator.suggest_free_port(ws + 1) else:
http_occupied = bool(port_in_use_probe.call(http))
ws_occupied = bool(port_in_use_probe.call(ws))
var unavailable := false
if conflict_port == http or http_occupied:
var suggested := ClientConfigurator.suggest_free_port(http + 1, 2048, occupancy)
if suggested > 0:
http = suggested
else:
unavailable = true
if conflict_port == ws or ws == http or ws_occupied:
var suggested := ClientConfigurator.suggest_free_port(ws + 1, 2048, occupancy)
if suggested == http:
suggested = ClientConfigurator.suggest_free_port(suggested + 1, 2048, occupancy)
if suggested > 0:
ws = suggested
else:
unavailable = true
var detail := "Automatic port selection is unavailable. Choose ports manually or retry." if unavailable else ""
_spinbox.tooltip_text = detail if unavailable else "Effective HTTP port"
_ws_spinbox.tooltip_text = detail if unavailable else "Effective WebSocket port"
_spinbox.value = http _spinbox.value = http
_ws_spinbox.value = ws _ws_spinbox.value = ws
@@ -352,6 +352,15 @@ static func coerce_with_context(value: Variant, ctx: Dictionary) -> Dictionary:
return coerce_for_type(value, ctx.prop_type, ctx.prop_name) return coerce_for_type(value, ctx.prop_type, ctx.prop_name)
## A rotation slerp can consume: all components finite and unit length within
## float32 precision. Guards the normalization path against a scaled result
## that is zero, infinite, or NaN.
static func _is_usable_rotation(quat: Quaternion) -> bool:
if not is_finite(quat.x) or not is_finite(quat.y) or not is_finite(quat.z) or not is_finite(quat.w):
return false
return absf(quat.length() - 1.0) <= 0.001
## Coerce a single value to the given Godot variant type. Returns ## Coerce a single value to the given Godot variant type. Returns
## {"ok": coerced} or {"error": msg}. Unknown types pass through. ## {"ok": coerced} or {"error": msg}. Unknown types pass through.
static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary: static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary:
@@ -386,6 +395,65 @@ static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -
TYPE_BOOL: TYPE_BOOL:
if value is int or value is float or value is bool: if value is int or value is float or value is bool:
return {"ok": bool(value)} return {"ok": bool(value)}
TYPE_VECTOR3I:
var v3i = McpJsonValues.parse_vector3i(value)
if v3i != null:
return {"ok": v3i}
return {"error": "Cannot coerce value to Vector3i for property '%s' (expected {x,y,z}, [x,y,z], or Vector3i; components must be finite and within int32 range)" % prop_name}
TYPE_QUATERNION:
var quat = McpJsonValues.parse_quaternion(value)
if quat == null:
return {"error": "Cannot coerce value to Quaternion for property '%s' (expected {x,y,z,w}, [x,y,z,w], or Quaternion; components must be finite)" % prop_name}
## Rotation contract at the animation boundary: AnimationPlayer
## slerps quaternions, and a non-unit one makes
## value_track_interpolate emit a normalization error and return
## identity. Normalize valid nonzero rotations so stored tracks are
## usable; only an exactly zero-length quaternion is refused.
## Scale by the largest component before measuring: components are
## float32, so squaring one near the type's maximum overflows to
## INF (normalized() then returns (0,0,0,0)), while squaring a tiny
## nonzero one underflows to zero and misreads as zero-length.
var largest := maxf(maxf(absf(quat.x), absf(quat.y)), maxf(absf(quat.z), absf(quat.w)))
if largest == 0.0:
return {"error": "Cannot coerce value to Quaternion for property '%s': zero-length quaternion is not a rotation" % prop_name}
var normalized := Quaternion(
quat.x / largest, quat.y / largest, quat.z / largest, quat.w / largest
).normalized()
## Validate the result instead of trusting the arithmetic: an
## unusable rotation must fail here, not reach slerp as zero.
if not _is_usable_rotation(normalized):
return {"error": "Cannot coerce value to Quaternion for property '%s': components cannot be normalized to a usable rotation" % prop_name}
return {"ok": normalized}
TYPE_BASIS:
var basis = McpJsonValues.parse_basis(value)
if basis != null:
return {"ok": basis}
return {"error": "Cannot coerce value to Basis for property '%s' (expected {x,y,z} axis vectors, a 3x3 nested array, or Basis)" % prop_name}
TYPE_TRANSFORM3D:
var xform = McpJsonValues.parse_transform3d(value)
if xform != null:
return {"ok": xform}
return {"error": "Cannot coerce value to Transform3D for property '%s' (expected {basis,origin}, {position,rotation_degrees?,scale?}, or Transform3D)" % prop_name}
TYPE_RECT2:
var rect = McpJsonValues.parse_rect2(value)
if rect != null:
return {"ok": rect}
return {"error": "Cannot coerce value to Rect2 for property '%s' (expected {position,size}, [x,y,w,h], or Rect2)" % prop_name}
TYPE_AABB:
var aabb = McpJsonValues.parse_aabb(value)
if aabb != null:
return {"ok": aabb}
return {"error": "Cannot coerce value to AABB for property '%s' (expected {position,size}, [x,y,z,w,h,d], or AABB)" % prop_name}
TYPE_NODE_PATH:
var node_path = McpJsonValues.parse_node_path(value)
if node_path != null:
return {"ok": node_path}
return {"error": "Cannot coerce value to NodePath for property '%s' (expected a string path)" % prop_name}
TYPE_STRING_NAME:
var string_name = McpJsonValues.parse_string_name(value)
if string_name != null:
return {"ok": string_name}
return {"error": "Cannot coerce value to StringName for property '%s' (expected a string)" % prop_name}
return {"ok": value} return {"ok": value}
+3 -3
View File
@@ -500,7 +500,7 @@ func _take_screenshot_impl(params: Dictionary) -> Dictionary:
"viewport_2d", "viewport_2d",
"Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet." "Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet."
) )
return _finalize_image(image_2d, "viewport_2d", max_resolution) return _finalize_image(image_2d, "viewport_2d", max_resolution, viewport.use_hdr_2d)
_: _:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'viewport_2d', 'cinematic', or 'game'" % source) return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'viewport_2d', 'cinematic', or 'game'" % source)
@@ -848,9 +848,9 @@ func _find_current_camera_3d(root: Node) -> Camera3D:
return first return first
func _finalize_image(image: Image, source: String, max_resolution: int) -> Dictionary: func _finalize_image(image: Image, source: String, max_resolution: int, use_hdr_2d := false) -> Dictionary:
## Shared with the game-process copy in runtime/game_helper.gd (#716). ## Shared with the game-process copy in runtime/game_helper.gd (#716).
var encoded := McpScreenshotEncode.downscale_and_encode(image, max_resolution) var encoded := McpScreenshotEncode.downscale_and_encode(image, max_resolution, use_hdr_2d)
return { return {
"data": { "data": {
"source": source, "source": source,
@@ -318,3 +318,19 @@ static func _settle_scan(
"reason": "Filesystem scan is an editor operation", "reason": "Filesystem scan is an editor operation",
} }
}) })
## Resource-group mutations use read-only deferred discovery before disk effects.
const Mutation := preload("res://addons/godot_ai/handlers/filesystem_mutation.gd")
func move_file(params: Dictionary) -> Dictionary:
return Mutation.start(_connection, params, "move")
func rename_file(params: Dictionary) -> Dictionary:
return Mutation.start(_connection, params, "rename")
func remove_file(params: Dictionary) -> Dictionary:
return Mutation.start(_connection, params, "remove")
@@ -0,0 +1,753 @@
@tool
extends RefCounted
const Errors := preload("res://addons/godot_ai/utils/error_codes.gd")
const ScriptWork := preload("res://addons/godot_ai/utils/script_work.gd")
const PLUGIN_TREE := "res://addons/godot_ai"
const SIDECARS := [".uid", ".import"]
const OWNER_EXTENSIONS := ["gd", "cs", "gdshader", "gdshaderinc", "tscn", "tres", "scn", "res"]
const MAX_ENTRIES := 10000
const MAX_FILE_BYTES := 256 * 1024
const MAX_TOTAL_BYTES := 64 * 1024 * 1024
const MAX_TARGETS := 256
## Container magic Godot writes for a compressed binary resource.
const COMPRESSED_MAGIC := "RSCC"
## Work allowed between frame yields: a share of how long the editor last took
## to come back, within these bounds. A fixed slice starves discovery once the
## editor is unfocused, because its low-processor mode then sleeps ~100 ms per
## frame (`interface/editor/timers/unfocused_low_processor_mode_sleep_usec`)
## and the frame count, not the work, reaches the deadline (#1120). The floor is
## what a focused editor gets; the ceiling bounds one slice on a stalled editor.
const BUDGET_MIN_USEC := 2000
const BUDGET_MAX_USEC := 50000
const BUDGET_GAP_DIVISOR := 3
const DEADLINE_MSEC := 25000
static var _busy := false
var _alive: Callable
var _deadline := 0
var _yield_at := 0
var _budget := BUDGET_MIN_USEC
var _bytes := 0
var _directories: Dictionary = {}
var _fingerprints: Dictionary = {}
var _large_owners: Dictionary = {}
var _fault := ""
var _fault_code := ""
var _io: Callable
## Dispatch stays synchronous; the retained job owns work across frame yields.
static func start(connection: McpConnection, params: Dictionary, operation: String) -> Dictionary:
var request_id := str(params.get("_request_id", ""))
if connection == null or request_id.is_empty():
return _unchanged(
"Filesystem mutations require a direct deferred command; call filesystem_manage outside batch_execute")
_finish(connection, request_id, params.duplicate(true), operation)
return {"_deferred": true, "_deferred_timeout_ms": 30000}
static func _finish(connection: McpConnection, request_id: String, params: Dictionary, operation: String) -> void:
var work := ScriptWork.begin("filesystem_" + operation)
var tree := connection.get_tree()
if tree == null:
ScriptWork.finish(work)
return
# Register the deferred response before the job can test its ownership.
await tree.process_frame
var job = load("res://addons/godot_ai/handlers/filesystem_mutation.gd").new()
var active := func() -> bool:
return is_instance_valid(connection) and connection.dispatcher != null and connection.dispatcher.has_pending_deferred_response(request_id)
var result: Dictionary = await job.run(params, operation, active)
if active.call():
connection.send_deferred_response(request_id, result)
ScriptWork.finish(work)
## Discovery is cancellable and read-only. Once disk effects start, this owner
## finishes or reconciles them before releasing the mutation slot.
func run(params: Dictionary, operation: String, active: Callable = Callable()) -> Dictionary:
if _busy:
return _unchanged("Another filesystem mutation is still running")
_busy = true
_alive = active
_deadline = Time.get_ticks_msec() + DEADLINE_MSEC
_yield_at = Time.get_ticks_usec()
_budget = BUDGET_MIN_USEC
_bytes = 0
_directories.clear()
_fingerprints.clear()
_large_owners.clear()
_fault = ""
_fault_code = ""
var result := await _run(params, operation)
_busy = false
return result
func _run(params: Dictionary, operation: String) -> Dictionary:
var source := str(params.get("path", "")).trim_suffix("/")
var error = McpPathValidator.path_error(source, "path", true)
if error != null:
return error
source = source.simplify_path()
if source == "res:/" or source == "res://" or _reserved(source):
return _unchanged("Cannot mutate the project root, metadata, or loaded plugin tree")
if _is_sidecar(source):
return _unchanged("Mutate the resource, not its .uid or .import sidecar")
var directory := DirAccess.dir_exists_absolute(source)
if not directory and not FileAccess.file_exists(source):
return Errors.make(Errors.RESOURCE_NOT_FOUND, "File or folder not found: %s" % source)
var destination := str(params.get("new_path", "")).trim_suffix("/")
if operation == "rename":
var name := str(params.get("new_name", ""))
if name.is_empty() or "/" in name or "\\" in name or name in [".", ".."]:
return _unchanged("new_name must be a nonempty bare name without path separators")
destination = source.get_base_dir().path_join(name)
if operation != "remove":
error = McpPathValidator.path_error(destination, "new_path", true)
if error != null:
return error
destination = destination.simplify_path()
if _reserved(destination):
return _unchanged("Cannot move into project metadata or the loaded plugin tree")
if source == destination or (directory and (destination + "/").to_lower().begins_with(source.to_lower() + "/")):
return _unchanged("Cannot move a path onto itself or into its own subtree")
if not DirAccess.dir_exists_absolute(destination.get_base_dir()):
return _unchanged("Destination parent must already exist: %s" % destination.get_base_dir())
if directory and operation == "remove" and bool(params.get("permanent", false)):
return _unchanged("Permanent directory removal is unsupported; use the default OS trash")
if not _confined_without_links(source) or (operation != "remove" and not _confined_without_links(destination)):
return _unchanged(_fault)
var files: Array[String] = []
if not await _collect_tree("res://", files):
return _fault_result()
if directory and not _directories.has(source):
return _unchanged("Source directory was excluded or has noncanonical casing; nothing changed")
var targets := {}
for path in files:
if not _is_sidecar(path) and (path == source or (directory and path.begins_with(source + "/"))):
targets[path] = {"uid": ResourceLoader.get_resource_uid(path)}
if not directory and not targets.has(source):
return _unchanged("Source was excluded from discovery; nothing changed")
if targets.size() > MAX_TARGETS:
return _unchanged("Mutation exceeds the %d-resource limit; use smaller groups" % MAX_TARGETS)
for path in files:
if not _mutation_member(path, source, directory):
continue
if path.get_extension().to_lower() in OWNER_EXTENSIONS:
# Owner-extension targets are read and fingerprinted by the owner
# pass below; reading them here too would charge the byte budget twice.
continue
if await _read_bounded(path) == null:
return _fault_result()
var owners := []
for path in files:
var extension := path.get_extension().to_lower()
if extension not in OWNER_EXTENSIONS:
continue
var hits: Array[Dictionary]
var length := _file_length(path)
if length < 0:
return _fault_result()
if length > MAX_FILE_BYTES:
# Dependency enumeration can replace a literal scan for packed binary
# owners, but not for scripts or text resources: those can refer to a
# target in ordinary strings the graph does not report. Fail closed.
if extension not in ["res", "scn"]:
_set_fault("Cannot safely read owner %s (exceeds %d bytes)" % [path, MAX_FILE_BYTES], Errors.FILESYSTEM_DISCOVERY_FAILED)
return _fault_result()
hits = await _large_owner_references(path, targets)
else:
var content: Variant = await _read_bounded(path)
if content == null:
return _fault_result()
if extension in ["res", "scn"]:
hits = await _binary_references(path, content, targets)
else:
# One call per owner: _references lowercases and regex-scans the
# whole file, so a per-target loop repeated that up to MAX_TARGETS
# times.
hits = await _references(path, content, targets)
if not _fault.is_empty():
return _fault_result()
if not await _yield_if_needed():
return _fault_result()
if not hits.is_empty() and not (operation == "remove" and targets.has(path)):
owners.append({"path": path, "references": hits})
var editor_guard := _editor_state_guard(targets)
if not editor_guard.is_empty():
return editor_guard
for owner in owners:
for hit in owner.references:
if operation == "remove":
if not bool(params.get("force", false)):
return _unchanged("Resource is still referenced; repair references or use force=true for known dangling references", {"referenced_by": owners})
elif hit.kind != "uid":
return _unchanged("Move requires dependency path rewrites, which are unsupported; update references or use preserved uid:// references first", {"referenced_by": owners})
var group: Array[Dictionary] = [{"from": source, "to": destination}]
if not directory:
for suffix in SIDECARS:
if FileAccess.file_exists(source + suffix):
group.append({"from": source + suffix, "to": destination + suffix})
for item in group:
if not _confined_without_links(item.from):
return _fault_result()
if operation != "remove" and not _destination_available(item.from, item.to):
return _fault_result()
# Re-read owner bytes and directory entry censuses after the yielding plan.
if not await _revalidate():
return _fault_result()
if not _active():
_set_fault("Filesystem request expired before mutation; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
return _fault_result()
for item in group:
if not _confined_without_links(item.from) or (operation != "remove" and not _destination_available(item.from, item.to)):
return _fault_result()
# Revalidation yields. Recheck mutable editor authority after the last
# callback/yield, with no suspension between this guard and disk effects.
editor_guard = _editor_state_guard(targets)
if not editor_guard.is_empty():
return editor_guard
if operation == "remove":
return _remove(source, directory, group, targets, owners, bool(params.get("permanent", false)))
return _move(source, destination, directory, group, targets)
func _active() -> bool:
return Time.get_ticks_msec() < _deadline and (not _alive.is_valid() or bool(_alive.call()))
func _yield_if_needed() -> bool:
if not _active():
_set_fault("Filesystem discovery expired or was cancelled; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
return false
var paused := Time.get_ticks_usec()
if paused - _yield_at >= _budget:
await (Engine.get_main_loop() as SceneTree).process_frame
_yield_at = Time.get_ticks_usec()
_budget = _frame_budget(_yield_at - paused)
if not _active():
_set_fault("Filesystem discovery expired or was cancelled; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
return false
return true
## The next slice for an editor that was away `gap_usec` between two slices.
## The frame sleep absorbs work shorter than itself, so a third of the gap is a
## quarter of a sleeping editor's frame and lengthens a busy one by a third.
static func _frame_budget(gap_usec: int) -> int:
return clampi(gap_usec / BUDGET_GAP_DIVISOR, BUDGET_MIN_USEC, BUDGET_MAX_USEC)
func _collect_tree(root: String, files: Array[String]) -> bool:
var pending: Array[String] = [root]
var count := 0
while not pending.is_empty():
var path: String = pending.pop_back()
var dir := DirAccess.open(path)
if dir == null:
_set_fault("Cannot inspect directory: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
return false
dir.include_hidden = true
if dir.list_dir_begin() != OK:
_set_fault("Cannot list directory: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
return false
var entries: Array[String] = []
var name := dir.get_next()
while not name.is_empty():
if name not in [".", ".."]:
var child := path.path_join(name)
entries.append(name)
# Engine metadata, VCS internals and the immutable loaded plugin
# implementation are outside the user-resource owner scan.
if child not in ["res://.godot", "res://.git", PLUGIN_TREE]:
if not dir.has_method("is_link") or bool(dir.call("is_link", child)):
_fault = "Cannot establish ownership through a linked entry: %s" % child
dir.list_dir_end()
return false
if dir.current_is_dir():
pending.append(child)
else:
files.append(child)
count += 1
if count > MAX_ENTRIES:
_set_fault("Project discovery exceeds %d entries; nothing changed" % MAX_ENTRIES, Errors.FILESYSTEM_DISCOVERY_FAILED)
dir.list_dir_end()
return false
if not await _yield_if_needed():
dir.list_dir_end()
return false
name = dir.get_next()
dir.list_dir_end()
entries.sort()
_directories[path] = entries
return true
func _read_bounded(path: String) -> Variant:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
_set_fault("Cannot safely read owner %s (unreadable)" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
if file.get_length() > MAX_FILE_BYTES:
_set_fault("Cannot safely read owner %s (exceeds %d bytes)" % [path, MAX_FILE_BYTES], Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
var bytes := PackedByteArray()
var length := file.get_length()
while file.get_position() < length:
var part := file.get_buffer(mini(16384, length - file.get_position()))
if part.is_empty():
_set_fault("Short read while inspecting %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
bytes.append_array(part)
_bytes += part.size()
if _bytes > MAX_TOTAL_BYTES:
_set_fault("Filesystem discovery exceeds its byte budget; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
if not await _yield_if_needed():
return null
file.close()
var digest := HashingContext.new()
digest.start(HashingContext.HASH_SHA256)
digest.update(bytes)
_fingerprints[path] = digest.finish()
return bytes
func _mutation_member(path: String, source: String, directory: bool) -> bool:
if directory:
return path == source or path.begins_with(source + "/")
return path == source or path in [source + ".uid", source + ".import"]
func _file_length(path: String) -> int:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
_set_fault("Cannot inspect owner metadata: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
return -1
var length := file.get_length()
file.close()
return length
func _owner_dependencies(path: String) -> PackedStringArray:
var dependencies := PackedStringArray()
for dependency in ResourceLoader.get_dependencies(path):
dependencies.append(str(dependency))
return dependencies
## Lowercased `uid://` and path segments of the engine's dependency records.
func _dependency_segments(dependencies: PackedStringArray) -> Dictionary:
var segments: Dictionary = {}
for dependency in dependencies:
for segment in dependency.split("::", false):
segments[segment.to_lower()] = true
return segments
func _large_owner_references(path: String, targets: Dictionary) -> Array[Dictionary]:
var dependencies := _owner_dependencies(path)
var dependency_paths := _dependency_segments(dependencies)
_large_owners[path] = {
"modified": FileAccess.get_modified_time(path),
"size": _file_length(path),
"dependencies": dependencies,
}
if int(_large_owners[path]["size"]) < 0:
return []
var hits: Array[Dictionary] = []
for target: String in targets:
if not await _yield_if_needed():
return hits
var uid: int = targets[target].uid
var uid_text := ResourceUID.id_to_text(uid) if uid != ResourceUID.INVALID_ID else ""
if dependency_paths.has(target.to_lower()) or (not uid_text.is_empty() and dependency_paths.has(uid_text.to_lower())):
# A dependency path can be a UID-backed ext_resource. Without reading
# the oversized owner we cannot prove the file reference is safe to
# rewrite, so fail closed for moves; force-remove may still use it.
hits.append({"path": target, "kind": "path"})
return hits
func _binary_references(path: String, bytes: PackedByteArray, targets: Dictionary) -> Array[Dictionary]:
var hits: Array[Dictionary] = []
var payload: Variant = _binary_scan_bytes(path, bytes)
if payload == null:
return hits
# The binary format stores an ext_resource UID as an integer, which no text
# scan can see, so the engine's dependency records are consulted as well.
var dependency_paths := _dependency_segments(_owner_dependencies(path))
for target: String in targets:
# Keep cancellation and frame progress even when a path match continues.
if not await _yield_if_needed():
return hits
var uid: int = targets[target].uid
var uid_text := ResourceUID.id_to_text(uid) if uid != ResourceUID.INVALID_ID else ""
var uses_path: bool = (
_bytes_contains_ci(payload, target.to_utf8_buffer())
or dependency_paths.has(target.to_lower())
or (not uid_text.is_empty() and dependency_paths.has(uid_text.to_lower()))
)
var uses_uid: bool = not uid_text.is_empty() and _bytes_contains_ci(payload, uid_text.to_utf8_buffer())
if uses_path:
hits.append({"path": target, "kind": "path"})
elif uses_uid:
if not ResourceUID.has_id(uid) or ResourceUID.get_id_path(uid) != target:
_set_fault("UID mapping is not authoritative for %s" % target)
return hits
hits.append({"path": target, "kind": "uid"})
return hits
## The bytes a literal scan can inspect. The editor saves binary resources
## compressed by default (`filesystem/on_save/compress_binary_resources`), and
## a compressed container shows none of its serialized paths, so expand it
## first. Layout: magic, mode, block size and expanded size (u32 each), one
## u32 compressed size per block, then the blocks.
##
## Returns an empty array when the expanded payload exceeds the per-file limit
## (the caller then relies on dependency records alone, like any oversized
## binary owner) and null, with a fault, for a container that cannot be read.
func _binary_scan_bytes(path: String, bytes: PackedByteArray) -> Variant:
if bytes.size() < 4 or bytes.slice(0, 4).get_string_from_ascii() != COMPRESSED_MAGIC:
return bytes
var unreadable := "Cannot inspect compressed binary owner: %s" % path
if bytes.size() < 16:
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
var mode := bytes.decode_u32(4)
var block_size := bytes.decode_u32(8)
var total := bytes.decode_u32(12)
if mode > FileAccess.COMPRESSION_BROTLI or block_size == 0:
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
if total > MAX_FILE_BYTES:
return PackedByteArray()
var block_count := total / block_size + 1
var offset := 16 + block_count * 4
if offset > bytes.size():
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
var payload := PackedByteArray()
for index in block_count:
var compressed_size := bytes.decode_u32(16 + index * 4)
var expected := total % block_size if index == block_count - 1 else block_size
if offset + compressed_size > bytes.size():
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
if expected > 0:
var block := bytes.slice(offset, offset + compressed_size).decompress(expected, mode)
if block.size() != expected:
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
payload.append_array(block)
offset += compressed_size
_bytes += payload.size()
if _bytes > MAX_TOTAL_BYTES:
_set_fault("Filesystem discovery exceeds its byte budget; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
return payload
func _ascii_lower(value: int) -> int:
if value >= 65 and value <= 90:
return value + 32
return value
func _bytes_contains_ci(haystack: PackedByteArray, needle: PackedByteArray) -> bool:
if needle.is_empty() or haystack.size() < needle.size():
return false
var lower := _ascii_lower(needle[0])
var upper := lower - 32 if lower >= 97 and lower <= 122 else lower
var candidates := [lower]
if candidates[0] != upper:
candidates.append(upper)
for candidate in candidates:
var position := haystack.find(candidate)
while position >= 0:
if position + needle.size() <= haystack.size():
var matches := true
for index in needle.size():
if _ascii_lower(haystack[position + index]) != _ascii_lower(needle[index]):
matches = false
break
if matches:
return true
position = haystack.find(candidate, position + 1)
return false
func _references(path: String, bytes: PackedByteArray, targets: Dictionary) -> Array[Dictionary]:
var hits: Array[Dictionary] = []
# Compare paths conservatively across case-insensitive project volumes.
# A case-only false positive on a sensitive volume is a safe refusal.
var text := bytes.get_string_from_utf8().to_lower()
# Relative preload/load literals are path dependencies too. Never rewrite
# source text: recognizing one causes an unchanged refusal below.
var literals := RegEx.new()
literals.compile("[\"']([^\"'\\n]+)[\"']")
var relative_paths: Dictionary = {}
for literal in literals.search_all(text):
var value: String = literal.get_string(1)
if not value.contains(":"):
relative_paths[path.get_base_dir().path_join(value).simplify_path().to_lower()] = true
# ResourceLoader.load uses the project root for bare paths.
relative_paths["res://".path_join(value).simplify_path().to_lower()] = true
for target: String in targets:
# Keep cancellation and frame progress even when a path match continues.
if not await _yield_if_needed():
return hits
var target_text := target.to_lower()
var uid: int = targets[target].uid
var uid_text := ResourceUID.id_to_text(uid) if uid != ResourceUID.INVALID_ID else ""
var uses_uid := not uid_text.is_empty() and (("\"%s\"" % uid_text) in text or ("'%s'" % uid_text) in text)
if relative_paths.has(target_text):
hits.append({"path": target, "kind": "path"})
continue
var uses_path := ("\"%s\"" % target_text) in text or ("'%s'" % target_text) in text
if uses_path:
# A stored ext_resource fallback path is safe only with this exact
# target's preserved UID on the same header. Other strings block.
var only_uid_headers := path.get_extension().to_lower() in ["tscn", "tres"] and not (("'%s'" % target_text) in text)
for line in text.split("\n"):
if ("\"%s\"" % target_text) in line:
only_uid_headers = only_uid_headers and line.strip_edges().begins_with("[ext_resource ") and not uid_text.is_empty() and ("uid=\"%s\"" % uid_text) in line
if not only_uid_headers:
hits.append({"path": target, "kind": "path"})
continue
uses_uid = true
if uses_uid:
if not ResourceUID.has_id(uid) or ResourceUID.get_id_path(uid) != target:
_fault = "UID mapping is not authoritative for %s" % target
return hits
hits.append({"path": target, "kind": "uid"})
return hits
func _editor_state_guard(targets: Dictionary) -> Dictionary:
var settings := _settings_references(targets)
if not settings.is_empty():
return _unchanged("Project settings reference this resource; update those settings explicitly before mutating it", {"settings": settings})
for scene in EditorInterface.get_open_scenes():
if targets.has(scene):
return _unchanged("Close affected scene tabs before mutating their files", {"scene": scene})
for target: String in targets:
var uid: int = targets[target].uid
if uid != ResourceUID.INVALID_ID and (not ResourceUID.has_id(uid) or ResourceUID.get_id_path(uid) != target):
return _unchanged("UID mapping is no longer authoritative for %s; nothing changed" % target)
return {}
func _settings_references(targets: Dictionary) -> Array[String]:
var settings: Array[String] = []
for prop in ProjectSettings.get_property_list():
var name := str(prop.name)
var value: Variant = ProjectSettings.get_setting(name)
if not value is String:
continue
var path: String = value.trim_prefix("*")
if path.begins_with("uid://"):
var uid := ResourceUID.text_to_id(path)
path = ResourceUID.get_id_path(uid) if ResourceUID.has_id(uid) else path
if targets.has(path):
settings.append(name)
return settings
func _revalidate() -> bool:
var original_directories := _directories.duplicate(true)
_directories.clear()
var files: Array[String] = []
if not await _collect_tree("res://", files):
return false
if _directories != original_directories:
_fault = "Project entries changed during discovery; retry after changes settle"
return false
var original := _fingerprints.duplicate(true)
for path in original:
if await _read_bounded(path) == null:
return false
if _fingerprints[path] != original[path]:
_fault = "Owner changed during discovery: %s" % path
return false
var original_large := _large_owners.duplicate(true)
for path in original_large:
if not await _large_owner_unchanged(path, original_large[path]):
_fault = "Owner changed during discovery: %s" % path
return false
return true
func _large_owner_unchanged(path: String, original: Dictionary) -> bool:
if not FileAccess.file_exists(path):
return false
if FileAccess.get_modified_time(path) != int(original.get("modified", 0)):
return false
if _file_length(path) != int(original.get("size", -1)):
return false
if str(_owner_dependencies(path)) != str(original.get("dependencies", PackedStringArray())):
return false
return await _yield_if_needed()
func _confined_without_links(path: String) -> bool:
var dir := DirAccess.open("res://")
if dir == null or not dir.has_method("is_link"):
_fault = "Filesystem link inspection is unavailable; refusing mutation"
return false
var current := "res://"
for component in path.trim_prefix("res://").split("/"):
current = current.path_join(component)
if bool(dir.call("is_link", current)):
_fault = "Linked filesystem paths cannot be mutated: %s" % current
return false
return true
func _destination_available(source: String, destination: String) -> bool:
if not _confined_without_links(destination):
return false
if not FileAccess.file_exists(destination) and not DirAccess.dir_exists_absolute(destination):
return true
var parent := source.get_base_dir()
if parent == destination.get_base_dir() and source.to_lower() == destination.to_lower():
var dir := DirAccess.open(parent)
if dir != null and dir.has_method("is_equivalent"):
dir.include_hidden = true
var entries := dir.get_files() + dir.get_directories()
if entries.has(source.get_file()) and not entries.has(destination.get_file()) and bool(dir.call("is_equivalent", source, destination)):
return true
_fault = "Destination already exists or case-only identity is ambiguous: %s" % destination
return false
func _rename(source: String, destination: String) -> int:
if _io.is_valid():
return int(_io.call("rename", source, destination))
return DirAccess.rename_absolute(source, destination)
func _move(source: String, destination: String, directory: bool, group: Array[Dictionary], targets: Dictionary) -> Dictionary:
var applied: Array[Dictionary] = []
for item in group:
var code := _rename(item.from, item.to)
if code != OK:
var unrestored: Array[Dictionary] = []
for index in range(applied.size() - 1, -1, -1):
var prior := applied[index]
if _rename(prior.to, prior.from) != OK:
unrestored.append(prior)
var outcome := "unchanged" if applied.is_empty() else ("rolled_back" if unrestored.is_empty() else "partial")
if not unrestored.is_empty():
_reconcile_move(targets, source, destination, false)
return _failure("Move failed: %s" % error_string(code), outcome, {"applied": applied, "unrestored": unrestored})
applied.append(item)
_reconcile_move(targets, source, destination)
var moved: Array[Dictionary] = []
for old_path: String in targets:
moved.append({"from": old_path, "to": destination + old_path.trim_prefix(source)})
return {"data": {"path": source, "new_path": destination, "kind": "directory" if directory else "file",
"moved": moved, "moved_count": moved.size(), "uids_updated": _uid_count(targets), "outcome": "committed", "scan_required": directory,
"dependencies_updated": [], "project_settings_updated": [], "warnings": [], "undoable": false,
"reason": "Filesystem moves are persistent; move the resource back explicitly to revert"}}
func _reconcile_move(targets: Dictionary, source: String, destination: String, refresh_editor: bool = true) -> void:
var efs := EditorInterface.get_resource_filesystem()
for old_path: String in targets:
var new_path := destination + old_path.trim_prefix(source)
var actual := new_path if FileAccess.file_exists(new_path) else old_path
var uid: int = targets[old_path].uid
if uid != ResourceUID.INVALID_ID:
if ResourceUID.has_id(uid):
ResourceUID.set_id(uid, actual)
else:
ResourceUID.add_id(uid, actual)
if actual != old_path and ResourceLoader.has_cached(old_path):
var cached := ResourceLoader.get_cached_ref(old_path)
if cached != null:
cached.take_over_path(actual)
# Updating a missing path removes orphan UID sidecars. On partial
# outcomes retain those bytes for explicit recovery instead.
if efs != null and refresh_editor:
if old_path.to_lower() != actual.to_lower():
efs.update_file(old_path)
efs.update_file(actual)
func _remove(source: String, directory: bool, group: Array[Dictionary], targets: Dictionary, owners: Array, permanent: bool) -> Dictionary:
var removed: Array[String] = []
var failure := OK
for item in group:
var code: int
if _io.is_valid():
code = int(_io.call("remove" if permanent else "trash", item.from, ""))
else:
code = DirAccess.remove_absolute(item.from) if permanent else OS.move_to_trash(ProjectSettings.globalize_path(item.from))
if code != OK:
failure = code
break
removed.append(item.from)
var efs := EditorInterface.get_resource_filesystem()
for target: String in targets:
if FileAccess.file_exists(target):
continue
var uid: int = targets[target].uid
if uid != ResourceUID.INVALID_ID and ResourceUID.has_id(uid):
ResourceUID.remove_id(uid)
if efs != null and failure == OK:
efs.update_file(target)
if failure != OK:
var remaining: Array[String] = []
for item in group:
if FileAccess.file_exists(item.from) or DirAccess.dir_exists_absolute(item.from):
remaining.append(item.from)
return _failure("Removal failed: %s" % error_string(failure), "unchanged" if removed.is_empty() else "partial",
{"removed": removed, "remaining": remaining, "trashed": not permanent})
return {"data": {"path": source, "kind": "directory" if directory else "file", "removed": targets.keys(),
"removed_count": targets.size(), "trashed": not permanent, "uids_released": _uid_count(targets),
"project_settings_cleared": [], "referenced_by": owners, "outcome": "committed", "scan_required": directory, "warnings": [], "undoable": false,
"reason": "Permanently deleted" if permanent else "Moved to the OS trash; restore it there to revert"}}
static func _uid_count(targets: Dictionary) -> int:
var count := 0
for entry in targets.values():
if entry.uid != ResourceUID.INVALID_ID:
count += 1
return count
static func _is_sidecar(path: String) -> bool:
return path.ends_with(".uid") or path.ends_with(".import")
func _set_fault(message: String, code: String = "") -> void:
_fault = message
_fault_code = code
func _fault_result() -> Dictionary:
if _fault_code == Errors.FILESYSTEM_DISCOVERY_FAILED:
return _failure(_fault, "unchanged", {}, _fault_code)
return _unchanged(_fault)
static func _unchanged(message: String, details: Dictionary = {}) -> Dictionary:
return _failure(message, "unchanged", details, Errors.INVALID_PARAMS)
static func _failure(message: String, outcome: String, details: Dictionary, code: String = Errors.INTERNAL_ERROR) -> Dictionary:
var result := Errors.make(code, message)
result.error["data"] = details.merged({"outcome": outcome, "retry_safe": outcome in ["unchanged", "rolled_back"]}, true)
return result
static func _reserved(path: String) -> bool:
var normalized := path.to_lower().trim_suffix("/")
for reserved: String in ["res://.godot", "res://.git", PLUGIN_TREE]:
if normalized == reserved or normalized.begins_with(reserved + "/") or reserved.begins_with(normalized + "/"):
return true
return false
@@ -0,0 +1 @@
uid://c21v8ma38tvs7
@@ -0,0 +1,639 @@
@tool
extends "res://addons/godot_ai/handlers/command_handler.gd"
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MeshWorkload := preload("res://addons/godot_ai/utils/mesh_workload.gd")
const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
## Bounded 3D mesh-only baking and explicit-map path queries in 2D/3D.
const _CLASSES := {
"2d": {
"region": "NavigationRegion2D",
"mesh": "NavigationPolygon",
},
"3d": {
"region": "NavigationRegion3D",
"mesh": "NavigationMesh",
},
}
## One deferred bake may spend this long across editor frames. The Python
## handler's timeout is this plus its transport margin (a source-shape test
## keeps the two together).
const _BAKE_DEFERRED_TIMEOUT_MS := 30000
const _SOURCE_MAX_NODES := 256
const _SOURCE_MAX_GRID_CELLS := 1000000
## In-flight bakes keyed by region instance id. `bake()` reserves its region
## before the first yield so a second call in the same frame cannot start a
## second bake on a region Godot is already baking (the engine logs
## "NavigationMesh is already baking" and the two jobs split the result).
## Every terminal path releases the reservation.
static var _active_bakes: Dictionary = {}
var _undo_redo: EditorUndoRedoManager
var _connection
func _init(undo_redo: EditorUndoRedoManager, connection = null) -> void:
_undo_redo = undo_redo
_connection = connection
# ============================================================================
# navigation_bake
# ============================================================================
## Bake a 3D region's navigation mesh and commit a scene-anchored swap
## between the retained pre-bake and baked resources.
##
## Source collection visits one bounded mesh/container per frame, without the
## general scene parser or custom parser callbacks. Only the engine bake runs
## on a background thread. Undo/redo retain the exact before/after resources.
func bake(params: Dictionary) -> Dictionary:
var resolved := _resolve_region(params)
if resolved.has("error"):
return resolved
var region: Node = resolved.node
var dimension: String = resolved.dimension
var before: Resource = _get_region_mesh(region, dimension)
if before == null:
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
"%s has no %s resource" % [resolved.path, _CLASSES[dimension].mesh])
var source_error := _validate_bake_source(dimension, before)
if not source_error.is_empty():
return source_error
if _active_bakes.has(region.get_instance_id()) or bool(region.call("is_baking")) or NavigationServer3D.is_baking_navigation_mesh(before):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"%s is already baking a navigation mesh" % resolved.path)
var request_id: String = params.get("_request_id", "")
if _connection == null or request_id.is_empty():
## The bake is threaded and answered out-of-band, so a direct caller
## (batch_execute, unit tests) cannot wait for it without blocking the
## editor's frame budget. Refuse instead of silently baking in place.
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"navigation_bake is deferred (threaded bake, bounded deadline); call "
+ "navigation_manage(op='bake') directly - batch_execute cannot await it")
var force_sync := bool(params.get("force_sync", true))
var prepared := _begin_bake(region, dimension)
if prepared.is_empty():
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to prepare the %s bake" % _CLASSES[dimension].mesh)
var job := _bake_job(
region, dimension, resolved.scene_root, prepared.before, prepared.working,
_undo_redo, _connection, request_id, force_sync
)
## Reserve the region before the driver yields: the region's own
## native bake has not started during source collection, so checking only
## the engine bake state would admit a second request for the same region.
_active_bakes[region.get_instance_id()] = request_id
## No step here: the dispatcher registers the deferred request only after
## this handler returns the sentinel, and `_bake_step`'s first pending
## check would otherwise see the request as expired and abandon the job.
## `_drive_bake_job` performs the first step after its registration yield.
_drive_bake_job(job)
return {
"_deferred": true,
"_deferred_timeout_ms": _BAKE_DEFERRED_TIMEOUT_MS,
}
# ============================================================================
# navigation_path_get
# ============================================================================
## Query a path on an explicitly selected navigation map. Read-only: it never
## guesses a region and never changes the shared async-iteration policy unless
## `force_sync` asks it to.
func path_get(params: Dictionary) -> Dictionary:
var dimension_result := _resolve_dimension(params)
if dimension_result.has("error"):
return dimension_result
var dimension: String = dimension_result.dimension
var from := _coerce_vector(params.get("from_point", null), dimension, "from_point")
if from.has("error"):
return from
var to := _coerce_vector(params.get("to_point", null), dimension, "to_point")
if to.has("error"):
return to
var optimize := bool(params.get("optimize", true))
var navigation_layers := int(params.get("navigation_layers", 1))
var force_sync := bool(params.get("force_sync", false))
var region_path := String(params.get("region_path", ""))
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No edited scene open")
var map_result := _resolve_query_map(
scene_root, dimension, region_path, params.get("scene_file", "")
)
if map_result.has("error"):
return map_result
var points: Array = []
if dimension == "3d":
if force_sync:
_force_map_sync_3d(map_result.map)
for point in NavigationServer3D.map_get_path(map_result.map, from.ok, to.ok, optimize, navigation_layers):
points.append(VariantSerializer.serialize(point))
else:
if force_sync:
_force_map_sync_2d(map_result.map)
for point in NavigationServer2D.map_get_path(map_result.map, from.ok, to.ok, optimize, navigation_layers):
points.append(VariantSerializer.serialize(point))
return {
"data": {
"dimension": dimension,
"from_point": VariantSerializer.serialize(from.ok),
"to_point": VariantSerializer.serialize(to.ok),
"optimize": optimize,
"navigation_layers": navigation_layers,
"force_sync": force_sync,
"region_path": map_result.region_path,
"map_source": map_result.map_source,
"point_count": points.size(),
"points": points,
}
}
# ============================================================================
# Helpers — deferred bake
# ============================================================================
## Snapshot the pre-bake resource and install the working duplicate the
## threaded bake writes into. Returns `{}` when there is no mesh to bake.
## Baking in place would mutate the resource the undo action restores, so a
## second undo after a redo could no longer reach the pre-bake state.
static func _begin_bake(region: Node, dimension: String) -> Dictionary:
var before: Resource = _get_region_mesh(region, dimension)
if before == null:
return {}
var working: Resource = before.duplicate()
if dimension == "3d":
region.call("set_navigation_mesh", working)
else:
region.call("set_navigation_polygon", working)
return {"before": before, "working": working}
## Request state retained by the static frame driver through collection/baking.
static func _bake_job(
region: Node, dimension: String, scene_root: Node, before: Resource,
working: Resource, undo_redo: EditorUndoRedoManager, connection, request_id: String,
force_sync: bool,
) -> Dictionary:
return {
"region": region,
"region_id": region.get_instance_id(),
"dimension": dimension,
"scene_root": scene_root,
"before": before,
"working": working,
"undo_redo": undo_redo,
"connection": connection,
"request_id": request_id,
"force_sync": force_sync,
"phase": "start",
"started_ms": Time.get_ticks_msec(),
"deadline_ms": _BAKE_DEFERRED_TIMEOUT_MS,
"parse_ms": 0,
"source_data": NavigationMeshSourceGeometryData3D.new(),
"pending": [region],
"sources": [],
"meshes": [],
"source_changed": false,
"triangles": 0,
"vertices": 0,
"bounds": AABB(),
"has_bounds": false,
"result": {},
}
## Drop this job's reservation so the region can bake again. Every terminal
## path (commit, abort, abandonment, or a driver that never starts) calls it.
static func _release_bake(job: Dictionary) -> void:
for mesh in job.meshes:
var callback := _source_changed.bind(job)
if mesh.changed.is_connected(callback):
mesh.changed.disconnect(callback)
job.meshes.clear()
_active_bakes.erase(int(job.region_id))
## Advance a bake job by one editor-frame check. Returns true once the job is
## resolved (committed, aborted, or abandoned); `job.result` holds the reply,
## or stays empty when the request was abandoned and nothing may be answered.
static func _bake_step(job: Dictionary) -> bool:
if str(job.phase) == "done":
return true
## Read the region untyped first: a typed `Node` assignment on a freed
## instance raises "Trying to assign invalid previously freed instance"
## before any validity check could run.
var region_ref = job.region
if not is_instance_valid(region_ref) or not (region_ref as Node).is_inside_tree():
_bake_abort(job, ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
"The navigation region went away while it was baking"))
return true
var region: Node = region_ref
var connection = job.connection
if connection != null and not _deferred_request_pending(connection, str(job.request_id)):
## The dispatcher gave up on this request (timeout, client gone):
## restore the pre-bake resource and answer nothing.
_bake_restore(job)
_release_bake(job)
job.phase = "done"
return true
if Time.get_ticks_msec() - int(job.started_ms) > int(job.deadline_ms):
_bake_abort(job, ErrorCodes.make(ErrorCodes.DEFERRED_TIMEOUT,
"navigation_bake exceeded its %d ms budget" % int(job.deadline_ms)))
return true
if EditorInterface.get_edited_scene_root() != job.scene_root:
_bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH,
"The edited scene changed while the navigation mesh was baking"))
return true
if _get_region_mesh(region, str(job.dimension)) != job.working:
_bake_abort(job, ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
"The region's navigation resource was replaced while the bake was in flight; the bake result was discarded"))
return true
if bool(job.source_changed):
_bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH,
"A source mesh changed during navigation baking; retry with stable source geometry"))
return true
if str(job.phase) == "start" or str(job.phase) == "collect":
var source_error := _validate_bake_source(str(job.dimension), job.working)
if not source_error.is_empty():
_bake_abort(job, source_error)
return true
job.phase = "collect"
var started := Time.get_ticks_usec()
var collected := _collect_source_step(job)
job.parse_ms = float(job.parse_ms) + (Time.get_ticks_usec() - started) / 1000.0
if not collected.is_empty():
_bake_abort(job, collected)
return true
if not job.pending.is_empty():
return false
if not job.source_data.has_data():
_bake_abort(job, ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "No supported mesh triangles found below the navigation region"))
return true
if not _sources_match(job):
_bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "Navigation source nodes changed while geometry was collected"))
return true
var final_grid_error := _grid_error(job.bounds, job.working)
if not final_grid_error.is_empty():
_bake_abort(job, final_grid_error)
return true
NavigationServer3D.bake_from_source_geometry_data_async(job.working, job.source_data)
job.phase = "baking"
return false
if NavigationServer3D.is_baking_navigation_mesh(job.working):
return false
if not _sources_match(job):
_bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "Navigation source nodes changed while baking"))
return true
_bake_commit(job)
return true
static func _validate_bake_source(dimension: String, mesh: Resource) -> Dictionary:
if dimension != "3d":
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "navigation_bake supports bounded 3D mesh-only sources; 2D baking is not supported (2D path queries remain available)")
if mesh.geometry_source_geometry_mode != NavigationMesh.SOURCE_GEOMETRY_ROOT_NODE_CHILDREN or mesh.geometry_parsed_geometry_type == NavigationMesh.PARSED_GEOMETRY_STATIC_COLLIDERS:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "navigation_bake requires root-children mesh-instance or both source settings; collider-only and group sources are not supported")
return {}
static func _source_changed(job: Dictionary) -> void:
job.source_changed = true
static func _sources_match(job: Dictionary) -> bool:
if bool(job.source_changed):
return false
for source in job.sources:
var node = source.node
if not is_instance_valid(node) or not node.is_inside_tree() or node.get_parent() != source.parent or node.get_child_count() != int(source.children):
return false
if node is Node3D and node.global_transform != source.transform:
return false
if node is MeshInstance3D and node.mesh != source.mesh:
return false
return true
static func _grid_error(bounds: AABB, mesh: NavigationMesh) -> Dictionary:
if not is_finite(mesh.cell_size) or not is_finite(mesh.cell_height) or mesh.cell_size <= 0.0 or mesh.cell_height <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation cell size and height must be finite and positive")
var size := bounds.size
var filter_size := mesh.filter_baking_aabb.size
size = Vector3(maxf(size.x, filter_size.x), maxf(size.y, filter_size.y), maxf(size.z, filter_size.z))
var cells := Vector3(size.x / mesh.cell_size, size.y / mesh.cell_height, size.z / mesh.cell_size)
var count := 1
for extent in [cells.x, cells.y, cells.z]:
if not is_finite(extent) or extent > _SOURCE_MAX_GRID_CELLS:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source extent/cell size exceeds the bounded voxel grid; reduce the extent or increase cell size")
count *= maxi(1, int(ceil(extent)))
if count > _SOURCE_MAX_GRID_CELLS:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source exceeds the %d-cell voxel grid limit; reduce the extent or increase cell size" % _SOURCE_MAX_GRID_CELLS)
return {}
static func _collect_source_step(job: Dictionary) -> Dictionary:
var node = job.pending.pop_back()
if not is_instance_valid(node) or not node.is_inside_tree():
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "A navigation source node was removed during collection")
if node != job.region and (node.get_script() != null or node.get_class() not in ["Node", "Node3D", "MeshInstance3D"]):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Unsupported navigation source %s (%s): mesh-only baking accepts unscripted Node/Node3D containers and MeshInstance3D; colliders, CSG, GridMap, obstacles and custom parser sources are not supported" % [node.name, node.get_class()])
var children: int = node.get_child_count()
if job.sources.size() + job.pending.size() + children + 1 > _SOURCE_MAX_NODES:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source traversal exceeds %d nodes" % _SOURCE_MAX_NODES)
var source := {"node": node, "parent": node.get_parent(), "children": children}
if node is Node3D:
source.transform = node.global_transform
for index in children:
job.pending.append(node.get_child(index))
if node is MeshInstance3D:
var mesh: Mesh = node.mesh
source.mesh = mesh
if mesh != null:
var counts := MeshWorkload.estimate(mesh)
if counts.has("error"):
return counts
if int(counts.triangles) == 0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source %s has no supported triangles" % node.name)
job.triangles = int(job.triangles) + int(counts.triangles)
job.vertices = int(job.vertices) + int(counts.vertices)
if int(job.triangles) > MeshWorkload.MAX_TRIANGLES or int(job.vertices) > MeshWorkload.MAX_VERTICES:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation sources exceed the aggregate %d-triangle/%d-vertex limit" % [MeshWorkload.MAX_TRIANGLES, MeshWorkload.MAX_VERTICES])
var root_transform: Transform3D = job.region.global_transform
if not root_transform.is_finite() or is_zero_approx(root_transform.basis.determinant()):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation region transform must be finite and invertible")
var relative: Transform3D = root_transform.affine_inverse() * node.global_transform
var bounds: AABB = relative * mesh.get_aabb()
if not relative.is_finite() or not bounds.is_finite():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation mesh transform/bounds must be finite")
job.bounds = job.bounds.merge(bounds) if bool(job.has_bounds) else bounds
job.has_bounds = true
var grid_error := _grid_error(job.bounds, job.working)
if not grid_error.is_empty():
return grid_error
if not job.meshes.has(mesh):
mesh.changed.connect(_source_changed.bind(job))
job.meshes.append(mesh)
job.source_data.add_mesh(mesh, relative)
job.sources.append(source)
return {}
## Push the baked resource to the server region and commit one scene-anchored
## swap action. Every do/undo target is the region node, so the action lands in
## the edited scene's history — a RefCounted handler target would select the
## global history and trip the editor's history-mismatch check.
static func _bake_commit(job: Dictionary) -> void:
var region: Node = job.region
var dimension: String = job.dimension
var working: Resource = job.working
var rid: RID = region.call("get_rid")
if rid.is_valid():
if dimension == "3d":
NavigationServer3D.region_set_navigation_mesh(rid, working)
else:
NavigationServer2D.region_set_navigation_polygon(rid, working)
if bool(job.force_sync):
var map: RID = region.call("get_navigation_map")
if dimension == "3d":
_force_map_sync_3d(map)
else:
_force_map_sync_2d(map)
var undo_redo: EditorUndoRedoManager = job.undo_redo
undo_redo.create_action("MCP: Bake navigation on %s" % region.name)
if dimension == "3d":
undo_redo.add_do_method(region, "set_navigation_mesh", working)
undo_redo.add_undo_method(region, "set_navigation_mesh", job.before)
else:
undo_redo.add_do_method(region, "set_navigation_polygon", working)
undo_redo.add_undo_method(region, "set_navigation_polygon", job.before)
## Both retained resources stay referenced by the action: the scene holds
## only one of them at a time, the other must survive for undo/redo.
undo_redo.add_do_reference(working)
undo_redo.add_undo_reference(job.before)
## The region already holds `working`, so record without re-running do.
undo_redo.commit_action(false)
var polygon_count := 0
var vertex_count := 0
if working != null:
polygon_count = int(working.call("get_polygon_count"))
vertex_count = int(working.call("get_vertices").size())
job.result = {
"data": {
"path": McpScenePath.from_node(region, job.scene_root),
"mesh_class": working.get_class() if working != null else "",
"polygon_count": polygon_count,
"vertex_count": vertex_count,
"force_sync": bool(job.force_sync),
"bake_settle": "settled",
"parse_ms": float(job.parse_ms),
"source_mode": "mesh_only",
"source_triangles": int(job.triangles),
"undoable": true,
}
}
_release_bake(job)
job.phase = "done"
static func _bake_abort(job: Dictionary, error: Dictionary) -> void:
_bake_restore(job)
_release_bake(job)
job.result = error
job.phase = "done"
## Put the pre-bake resource back after an aborted bake. The region may have
## been freed meanwhile, so it is validity-checked before the typed assignment;
## and the pre-bake resource is only restored while the region still holds this
## job's working duplicate — a newer assignment must not be clobbered.
static func _bake_restore(job: Dictionary) -> void:
var region_ref = job.region
if not is_instance_valid(region_ref):
return
var region: Node = region_ref
if _get_region_mesh(region, str(job.dimension)) != job.working:
return
if str(job.dimension) == "3d":
region.call("set_navigation_mesh", job.before)
else:
region.call("set_navigation_polygon", job.before)
## Drive a bake job one editor frame at a time and reply when it ends.
## `static` is load-bearing: the coroutine must outlive this RefCounted
## handler, which can be freed mid-await by an editor_reload_plugin.
static func _drive_bake_job(job: Dictionary) -> void:
var connection = job.connection
if not is_instance_valid(connection):
## `_begin_bake` already replaced the region's resource with the working
## duplicate and no undo action owns it yet, so a driver that cannot run
## must put the pre-bake resource back (ownership-guarded) before
## releasing the region for another bake.
_bake_restore(job)
_release_bake(job)
return
var tree: SceneTree = connection.get_tree()
if tree == null:
_bake_restore(job)
_release_bake(job)
return
var work := ScriptWork.begin("navigation_bake")
## The first yield lets the dispatcher register the deferred request before
## any result or abort can be answered.
await tree.process_frame
while not _bake_step(job):
await tree.process_frame
ScriptWork.finish(work)
if is_instance_valid(connection) and not job.result.is_empty():
connection.send_deferred_response(str(job.request_id), job.result)
## Check that the connection and deferred dispatcher entry are both live.
static func _deferred_request_pending(connection, request_id: String) -> bool:
if not is_instance_valid(connection):
return false
var dispatcher = connection.dispatcher
return dispatcher == null or dispatcher.has_pending_deferred_response(request_id)
## Sync a 3D map immediately. Godot 4.7's navigation server iterates maps
## asynchronously by default, and `map_force_update` is documented as
## unsupported in that mode — turn async iterations off for the forced sync,
## then restore the previous setting. Only ever called when the caller asked
## for it (`force_sync`), never silently from a read.
static func _force_map_sync_3d(map: RID) -> void:
if not map.is_valid():
return
var was_async := NavigationServer3D.map_get_use_async_iterations(map)
if was_async:
NavigationServer3D.map_set_use_async_iterations(map, false)
NavigationServer3D.map_force_update(map)
if was_async:
NavigationServer3D.map_set_use_async_iterations(map, true)
static func _force_map_sync_2d(map: RID) -> void:
if not map.is_valid():
return
var was_async := NavigationServer2D.map_get_use_async_iterations(map)
if was_async:
NavigationServer2D.map_set_use_async_iterations(map, false)
NavigationServer2D.map_force_update(map)
if was_async:
NavigationServer2D.map_set_use_async_iterations(map, true)
# ============================================================================
# Helpers — resolution
# ============================================================================
static func _resolve_dimension(params: Dictionary) -> Dictionary:
var dimension: String = params.get("dimension", "3d")
if dimension != "2d" and dimension != "3d":
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid dimension '%s'. Valid: 2d, 3d" % dimension)
return {"dimension": dimension}
## Resolve `path` to a navigation region and infer its dimension from the
## class. Success shape: `{node, dimension, path, scene_root}`.
func _resolve_region(params: Dictionary) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(
params.get("path", ""), "path", params.get("scene_file", "")
)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var dimension := ""
if node is NavigationRegion2D:
dimension = "2d"
elif node is NavigationRegion3D:
dimension = "3d"
else:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node at %s is %s — expected a navigation region" % [resolved.path, node.get_class()])
return {
"node": node,
"dimension": dimension,
"path": McpScenePath.from_node(node, resolved.scene_root),
"scene_root": resolved.scene_root,
}
## The map a path query runs against: the explicitly named region's map, or
## the edited scene root's world map when no region is given. Never guesses
## the scene's "first" region — a scene can host several maps.
static func _resolve_query_map(
scene_root: Node, dimension: String, region_path: String, scene_file: String
) -> Dictionary:
if not region_path.is_empty():
var resolved := McpNodeValidator.resolve_or_error(region_path, "region_path", scene_file)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var expected: String = _CLASSES[dimension].region
if not _is_a_class(node, expected):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node at %s is %s — expected a %s for a %s query"
% [resolved.path, node.get_class(), expected, dimension.to_upper()])
var region_map: RID = node.call("get_navigation_map")
if not region_map.is_valid():
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
"%s has no valid navigation map" % resolved.path)
return {"map": region_map, "map_source": "region", "region_path": resolved.path}
var world_method := "get_world_3d" if dimension == "3d" else "get_world_2d"
if scene_root.has_method(world_method):
var world_map: RID = scene_root.call(world_method).navigation_map
if world_map.is_valid():
return {"map": world_map, "map_source": "world", "region_path": ""}
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
"No %s navigation map found in the edited scene" % dimension.to_upper())
## Exact class or subclass check. `ClassDB.is_parent_class` is strict, so an
## exact-class node would otherwise be rejected.
static func _is_a_class(node: Node, expected: String) -> bool:
return node.get_class() == expected or ClassDB.is_parent_class(node.get_class(), expected)
static func _get_region_mesh(region: Node, dimension: String) -> Resource:
if dimension == "3d":
return region.get("navigation_mesh")
return region.get("navigation_polygon")
# ============================================================================
# Helpers — path points
# ============================================================================
## Parse a world point for path queries from {x,y[,z]} / [x,y[,z]].
static func _coerce_vector(raw: Variant, dimension: String, param_name: String) -> Dictionary:
var parsed: Variant = null
if dimension == "3d":
parsed = McpJsonValues.parse_vector3(raw)
else:
parsed = McpJsonValues.parse_vector2(raw)
if parsed == null:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"'%s' must be a %s point ({x,y%s} or [x,y%s])" % [
param_name, dimension.to_upper(), ",z" if dimension == "3d" else "", ",z" if dimension == "3d" else ""
])
return {"ok": parsed}
@@ -0,0 +1 @@
uid://dsm4acmlregit
+99 -4
View File
@@ -160,6 +160,16 @@ func reparent_node(params: Dictionary) -> Dictionary:
_undo_redo.add_do_method(new_parent, "add_child", node, true) _undo_redo.add_do_method(new_parent, "add_child", node, true)
_undo_redo.add_do_method(node, "set_owner", scene_root) _undo_redo.add_do_method(node, "set_owner", scene_root)
for child in descendants: for child in descendants:
## Preserve intentional null owners and owners that live inside the
## moved subtree. `remove_child` clears owners outside the subtree, so
## only those need to be re-normalized to scene_root. The subtree is
## still intact here because the recorded do-methods have not run yet.
##
## Re-owning an instance's internal nodes with scene_root flattened the
## sub-scene on save and dropped its overrides (#1118).
var prior_owner: Node = child.owner
if prior_owner == null or prior_owner == node or node.is_ancestor_of(prior_owner):
continue
_undo_redo.add_do_method(child, "set_owner", scene_root) _undo_redo.add_do_method(child, "set_owner", scene_root)
_undo_redo.add_do_reference(node) _undo_redo.add_do_reference(node)
_undo_redo.add_undo_method(new_parent, "remove_child", node) _undo_redo.add_undo_method(new_parent, "remove_child", node)
@@ -203,10 +213,14 @@ func set_property(params: Dictionary) -> Dictionary:
var found := false var found := false
var prop_type: int = TYPE_NIL var prop_type: int = TYPE_NIL
var prop_hint: int = PROPERTY_HINT_NONE
var prop_hint_string: String = ""
for prop in node.get_property_list(): for prop in node.get_property_list():
if prop.name == property: if prop.name == property:
found = true found = true
prop_type = prop.get("type", TYPE_NIL) prop_type = prop.get("type", TYPE_NIL)
prop_hint = prop.get("hint", PROPERTY_HINT_NONE)
prop_hint_string = prop.get("hint_string", "")
break break
if not found: if not found:
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property)) return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property))
@@ -230,7 +244,45 @@ func set_property(params: Dictionary) -> Dictionary:
var nil_resource_string: bool = target_type == TYPE_NIL and (value == "" or (value is String and value.begins_with("res://"))) var nil_resource_string: bool = target_type == TYPE_NIL and (value == "" or (value is String and value.begins_with("res://")))
var resource_string_value: bool = value is String and (target_type == TYPE_OBJECT or nil_resource_string) var resource_string_value: bool = value is String and (target_type == TYPE_OBJECT or nil_resource_string)
if resource_string_value: ## Node-typed exports (`@export var target: Control`) are TYPE_OBJECT with
## PROPERTY_HINT_NODE_TYPE, so a string here is a node path, not a
## resource path (#1144): a leading "/" is a clean scene path like `path`;
## anything else is relative to the node, which is how the Inspector
## stores the reference. "" still clears through the branch below.
var node_typed_path: bool = (
target_type == TYPE_OBJECT
and prop_hint == PROPERTY_HINT_NODE_TYPE
and value is String
and not (value as String).is_empty()
)
if node_typed_path:
var target: Node = _resolve_node_value(value, node, scene_root)
if target == null:
return ErrorCodes.make(
ErrorCodes.NODE_NOT_FOUND,
"value: no node at %s. Paths starting with \"/\" are scene paths (e.g. \"/%s/Player\"); others are relative to %s (e.g. \"../Player\")." % [
value, scene_root.name, node_path,
],
)
## An absolute editor-tree path ("/root", "/root/@EditorNode@/...") or
## a relative path climbing past the root resolves to a node outside
## the edited scene; it would commit but can never be saved with it.
if target != scene_root and not scene_root.is_ancestor_of(target):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"value: %s resolves to a node outside the edited scene; the target must be \"/%s\" or one of its descendants" % [
value, scene_root.name,
],
)
if not _node_matches_hint(target, prop_hint_string):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"value: %s is a %s, which is not a %s" % [
value, _object_type_label(target.get_class(), target.get_script()), prop_hint_string,
],
)
value = target
elif resource_string_value:
if value == "": if value == "":
value = null value = null
else: else:
@@ -305,13 +357,56 @@ func set_property(params: Dictionary) -> Dictionary:
"data": { "data": {
"path": node_path, "path": node_path,
"property": property, "property": property,
"value": _serialize_value(node.get(property)), "value": _serialize_slot(node.get(property), scene_root),
"old_value": _serialize_value(old_value), "old_value": _serialize_slot(old_value, scene_root),
"undoable": true, "undoable": true,
} }
} }
## Resolve the string value of a Node-typed property (#1144): a leading "/"
## is a clean scene path with the same forms `path` accepts; anything else
## is a NodePath relative to `node`.
static func _resolve_node_value(value: String, node: Node, scene_root: Node) -> Node:
if value.begins_with("/"):
return McpScenePath.resolve(value, scene_root)
return node.get_node_or_null(NodePath(value))
## Does `target` satisfy a PROPERTY_HINT_NODE_TYPE hint string? The hint is
## the exported type's name — a ClassDB class ("Control") or a script
## class_name ("Player") — and may list several, comma-separated. An empty
## hint (a plain `Node` export on some engine versions) accepts any node.
static func _node_matches_hint(target: Node, hint_string: String) -> bool:
var any_named := false
for hint in hint_string.split(","):
var wanted := hint.strip_edges()
if wanted.is_empty():
continue
any_named = true
if ClassDB.class_exists(wanted):
if target.is_class(wanted):
return true
continue
var script: Variant = target.get_script()
while script is Script:
if String((script as Script).get_global_name()) == wanted:
return true
script = (script as Script).get_base_script()
return not any_named
## set_property's response value: a Node lands as its clean scene path so the
## agent sees "/Main/Player", not Godot's object repr (#1144); everything
## else serializes as before.
static func _serialize_slot(value: Variant, scene_root: Node) -> Variant:
if value is Node:
var clean := McpScenePath.from_node(value, scene_root)
if not clean.is_empty():
return clean
return _serialize_value(value)
func rename_node(params: Dictionary) -> Dictionary: func rename_node(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params) var resolved := _resolve_node(params)
if resolved.has("error"): if resolved.has("error"):
@@ -1322,7 +1417,7 @@ func get_node_properties(params: Dictionary) -> Dictionary:
properties.append({ properties.append({
"name": prop.name, "name": prop.name,
"type": type_string(prop.type), "type": type_string(prop.type),
"value": _serialize_value(node.get(prop.name)), "value": _serialize_slot(node.get(prop.name), scene_root),
}) })
# Requested names that matched no editor-visible property — distinguishes # Requested names that matched no editor-visible property — distinguishes
# "you asked for something that doesn't exist" from "exists and is null". # "you asked for something that doesn't exist" from "exists and is null".
+430 -33
View File
@@ -1,6 +1,7 @@
@tool @tool
extends "res://addons/godot_ai/handlers/command_handler.gd" extends "res://addons/godot_ai/handlers/command_handler.gd"
const Refresh := preload("res://addons/godot_ai/handlers/physics_shape_refresh.gd")
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's ## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's
@@ -26,6 +27,19 @@ const _SHAPE_3D_CLASSES := {
"cylinder": "CylinderShape3D", "cylinder": "CylinderShape3D",
} }
## `physics_shape_generate` additionally derives hull and concave shapes from
## the mesh itself. Kept separate from `_SHAPE_3D_CLASSES` so
## `physics_shape_autofit`, which sizes a primitive from bounds, cannot
## silently accept a type it cannot size.
const _GENERATE_SHAPE_3D_CLASSES := {
"box": "BoxShape3D",
"sphere": "SphereShape3D",
"capsule": "CapsuleShape3D",
"cylinder": "CylinderShape3D",
"convex": "ConvexPolygonShape3D",
"trimesh": "ConcavePolygonShape3D",
}
const _SHAPE_2D_CLASSES := { const _SHAPE_2D_CLASSES := {
"rectangle": "RectangleShape2D", "rectangle": "RectangleShape2D",
"circle": "CircleShape2D", "circle": "CircleShape2D",
@@ -35,7 +49,16 @@ const _SHAPE_2D_CLASSES := {
const _GENERATED_BODY_CLASSES := { const _GENERATED_BODY_CLASSES := {
"static": "StaticBody3D", "static": "StaticBody3D",
"area": "Area3D", "area": "Area3D",
"rigid": "RigidBody3D",
"character": "CharacterBody3D",
} }
## A ConcavePolygonShape3D only simulates on a static body or an area; a
## moving body needs a convex or primitive shape.
const _GENERATE_CONCAVE_BODIES := ["static", "area"]
## Dynamic bodies move, so they must own the visual: as a sibling collider the
## body falls or slides away from the stationary mesh (issue #1053 review).
const _GENERATE_DYNAMIC_BODIES := ["rigid", "character"]
const _GENERATE_DIRECT_MAX_PATHS := 16 const _GENERATE_DIRECT_MAX_PATHS := 16
const _GENERATE_MAX_PATHS := 1024 const _GENERATE_MAX_PATHS := 1024
## One deferred request may spend this long across editor frames. The Python ## One deferred request may spend this long across editor frames. The Python
@@ -48,6 +71,16 @@ const _GENERATE_DEFERRED_TIMEOUT_MS := 30000
const _GENERATE_FRAME_BUDGET_USEC := 4000 const _GENERATE_FRAME_BUDGET_USEC := 4000
const _GENERATE_COLLIDER_SUFFIX := "Collider" const _GENERATE_COLLIDER_SUFFIX := "Collider"
const _GENERATE_SCALE_EPSILON := 0.0001 const _GENERATE_SCALE_EPSILON := 0.0001
## AABB centering leaves float noise in a generated collision offset (a
## centered CapsuleMesh reports a ~1.2e-07 Y center). Components below this
## snap to zero so generated transforms stay clean.
const _GENERATE_SNAP_EPSILON := 0.000001
## Engine hull calls cannot be preempted. These input limits bound admitted
## geometry, not elapsed time on every machine.
const MeshWorkload := preload("res://addons/godot_ai/utils/mesh_workload.gd")
const _GENERATE_HULL_MAX_TRIANGLES := MeshWorkload.MAX_TRIANGLES
const _GENERATE_HULL_MAX_VERTICES := MeshWorkload.MAX_VERTICES
const _GENERATE_HULL_MAX_SURFACES := MeshWorkload.MAX_SURFACES
## Accept either the short form ("box") or the matching Godot class name ## Accept either the short form ("box") or the matching Godot class name
@@ -104,11 +137,11 @@ func generate(params: Dictionary) -> Dictionary:
## edited scene. Nothing here touches a node. ## edited scene. Nothing here touches a node.
static func _validate_generate_request(params: Dictionary) -> Dictionary: static func _validate_generate_request(params: Dictionary) -> Dictionary:
var requested_shape := String(params.get("shape_type", "box")) var requested_shape := String(params.get("shape_type", "box"))
var shape_type := _normalize_shape_type(_SHAPE_3D_CLASSES, requested_shape) var shape_type := "auto" if requested_shape == "auto" else _normalize_shape_type(_GENERATE_SHAPE_3D_CLASSES, requested_shape)
if shape_type.is_empty(): if shape_type.is_empty():
return ErrorCodes.make( return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE, ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid shape_type '%s'. Valid: %s" % [requested_shape, ", ".join(_SHAPE_3D_CLASSES.keys())] "Invalid shape_type '%s'. Valid: auto, %s" % [requested_shape, ", ".join(_GENERATE_SHAPE_3D_CLASSES.keys())]
) )
var body_type := String(params.get("body_type", "static")) var body_type := String(params.get("body_type", "static"))
if not _GENERATED_BODY_CLASSES.has(body_type): if not _GENERATED_BODY_CLASSES.has(body_type):
@@ -116,7 +149,36 @@ static func _validate_generate_request(params: Dictionary) -> Dictionary:
ErrorCodes.VALUE_OUT_OF_RANGE, ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid body_type '%s'. Valid: %s" % [body_type, ", ".join(_GENERATED_BODY_CLASSES.keys())] "Invalid body_type '%s'. Valid: %s" % [body_type, ", ".join(_GENERATED_BODY_CLASSES.keys())]
) )
if shape_type == "trimesh" and not _GENERATE_CONCAVE_BODIES.has(body_type):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
(
"body_type '%s' cannot carry a trimesh shape — a ConcavePolygonShape3D only "
+ "simulates on a StaticBody3D or Area3D; use shape_type 'convex' or a primitive"
) % body_type
)
## A dynamic body must own its visual mesh. Default the wrap on for
## rigid/character and refuse an explicit opt-out: a detached dynamic body
## moves away from the stationary mesh. static/area keep the published
## sibling default (`false`).
var dynamic_body := _GENERATE_DYNAMIC_BODIES.has(body_type)
var reparent_mesh := dynamic_body
if params.has("reparent_mesh"):
if not params["reparent_mesh"] is bool:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "reparent_mesh must be a boolean")
reparent_mesh = params["reparent_mesh"]
if dynamic_body and not reparent_mesh:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
(
"body_type '%s' must own its visual mesh — a detached dynamic body "
+ "moves away from the stationary mesh. Pass reparent_mesh=true, or use "
+ "body_type 'static' or 'area' for a sibling collider"
) % body_type
)
if params.has("overwrite") and params.overwrite is not bool:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "overwrite must be a boolean")
var raw_paths: Variant = params.get("paths", []) var raw_paths: Variant = params.get("paths", [])
if not raw_paths is Array: if not raw_paths is Array:
return ErrorCodes.make( return ErrorCodes.make(
@@ -158,13 +220,44 @@ static func _validate_generate_request(params: Dictionary) -> Dictionary:
"scene_file": params.get("scene_file", ""), "scene_file": params.get("scene_file", ""),
"shape_type": shape_type, "shape_type": shape_type,
"body_type": body_type, "body_type": body_type,
"reparent_mesh": reparent_mesh,
"paths": paths, "paths": paths,
"overwrite": params.get("overwrite", false),
} }
static func _mesh_workload(mesh: Mesh) -> Dictionary:
return MeshWorkload.estimate(mesh)
static func _validate_hull_workload(mesh: Mesh, mesh_path: String, shape_type: String) -> Dictionary:
var workload := _mesh_workload(mesh)
if workload.has("error"):
return workload
if int(workload.triangles) == 0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"MeshInstance3D at %s has a mesh with no faces - a %s shape needs triangles" % [mesh_path, shape_type])
if int(workload.triangles) > _GENERATE_HULL_MAX_TRIANGLES or int(workload.vertices) > _GENERATE_HULL_MAX_VERTICES:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"MeshInstance3D at %s exceeds the synchronous hull input limit: at most %d triangles and %d vertices; estimated %d triangles and %d vertices. Reduce geometry or use a primitive collision shape." % [
mesh_path, _GENERATE_HULL_MAX_TRIANGLES, _GENERATE_HULL_MAX_VERTICES,
int(workload.triangles), int(workload.vertices)])
return {}
static func _auto_generate_shape(mesh: Mesh) -> String:
if mesh is SphereMesh:
return "sphere"
if mesh is CapsuleMesh:
return "capsule"
if mesh is CylinderMesh:
return "cylinder"
return "box"
## Resolve one mesh and capture everything the apply step must find unchanged. ## Resolve one mesh and capture everything the apply step must find unchanged.
static func _plan_generate_mesh( static func _plan_generate_mesh(
mesh_path: String, scene_file: String, scene_root: Node, shape_type: String mesh_path: String, scene_file: String, scene_root: Node, shape_type: String, reparent_mesh: bool
) -> Dictionary: ) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(mesh_path, "paths", scene_file) var resolved := McpNodeValidator.resolve_or_error(mesh_path, "paths", scene_file)
if resolved.has("error"): if resolved.has("error"):
@@ -181,7 +274,11 @@ static func _plan_generate_mesh(
"Node at %s is %s — must be MeshInstance3D" % [mesh_path, node.get_class()] "Node at %s is %s — must be MeshInstance3D" % [mesh_path, node.get_class()]
) )
var mesh := node as MeshInstance3D var mesh := node as MeshInstance3D
if mesh.has_meta(Refresh.MARKER):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Mesh at %s already has a collider sibling or generated collider provenance; use overwrite=true to refresh its shape" % mesh_path)
var parent := mesh.get_parent() var parent := mesh.get_parent()
if parent != null and parent.has_meta(Refresh.MARKER):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot create beneath a generated body at %s: missing or inconsistent source provenance" % mesh_path)
if parent == null: if parent == null:
return ErrorCodes.make( return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS, ErrorCodes.INVALID_PARAMS,
@@ -192,6 +289,13 @@ static func _plan_generate_mesh(
ErrorCodes.RESOURCE_NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND,
"MeshInstance3D at %s has no mesh resource — there are no bounds to fit" % mesh_path "MeshInstance3D at %s has no mesh resource — there are no bounds to fit" % mesh_path
) )
var auto_shape := shape_type == "auto"
if auto_shape:
shape_type = _auto_generate_shape(mesh.mesh)
if shape_type == "convex" or shape_type == "trimesh":
var error := _validate_hull_workload(mesh.mesh, mesh_path, shape_type)
if not error.is_empty():
return error
var collider_name := mesh.name + _GENERATE_COLLIDER_SUFFIX var collider_name := mesh.name + _GENERATE_COLLIDER_SUFFIX
var existing := parent.get_node_or_null(NodePath(collider_name)) var existing := parent.get_node_or_null(NodePath(collider_name))
if existing != null: if existing != null:
@@ -200,10 +304,18 @@ static func _plan_generate_mesh(
"MeshInstance3D at %s already has a collider sibling at %s — remove or rename it first" "MeshInstance3D at %s already has a collider sibling at %s — remove or rename it first"
% [mesh_path, McpScenePath.from_node(existing, scene_root)] % [mesh_path, McpScenePath.from_node(existing, scene_root)]
) )
## A top-level mesh ignores its parent's transform, so wrapping it under a
## body would not couple it to that body.
if reparent_mesh and mesh.top_level:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"MeshInstance3D at %s has top_level enabled — reparent_mesh cannot couple it to the generated body" % mesh_path
)
## A sibling in parent space inherits the parent chain's scale exactly like ## A sibling in parent space inherits the parent chain's scale exactly like
## the mesh does, so its box matches the visual; but Godot cannot scale a ## the mesh does, so its box matches the visual; but Godot cannot scale a
## sphere, capsule or cylinder non-uniformly (the warning it prints is a ## sphere, capsule, cylinder or concave shape non-uniformly (the warning it
## deformed collider). A top-level mesh ignores its parent's transform. ## prints is a deformed collider). A top-level mesh ignores its parent's
## transform.
if not mesh.top_level and shape_type != "box" and parent is Node3D: if not mesh.top_level and shape_type != "box" and parent is Node3D:
var parent_scale: Vector3 = (parent as Node3D).global_transform.basis.get_scale() var parent_scale: Vector3 = (parent as Node3D).global_transform.basis.get_scale()
var spread := absf(parent_scale.x - parent_scale.y) var spread := absf(parent_scale.x - parent_scale.y)
@@ -221,6 +333,9 @@ static func _plan_generate_mesh(
Basis(source_transform.basis.get_rotation_quaternion()), Basis(source_transform.basis.get_rotation_quaternion()),
source_transform.origin, source_transform.origin,
) )
## The mesh's own scale and offset, relative to the scale-free body — the
## convex/trimesh shapes bake this scale into their vertices, and the
## reparent option keeps the mesh's world transform by assigning it.
var mesh_to_body := body_transform.affine_inverse() * source_transform var mesh_to_body := body_transform.affine_inverse() * source_transform
var bounds: AABB = mesh_to_body * mesh.get_aabb() var bounds: AABB = mesh_to_body * mesh.get_aabb()
if bounds.size.x <= 0.0 or bounds.size.y <= 0.0 or bounds.size.z <= 0.0: if bounds.size.x <= 0.0 or bounds.size.y <= 0.0 or bounds.size.z <= 0.0:
@@ -231,11 +346,17 @@ static func _plan_generate_mesh(
return {"plan": { return {"plan": {
"mesh": mesh, "mesh": mesh,
"mesh_path": mesh_path, "mesh_path": mesh_path,
"mesh_name": mesh.name,
"auto_shape": auto_shape,
"shape_type": shape_type,
"source_mesh": mesh.mesh,
"source_bounds": mesh.get_aabb(),
"parent": parent, "parent": parent,
"collider_name": collider_name, "collider_name": collider_name,
"top_level": mesh.top_level, "top_level": mesh.top_level,
"source_transform": source_transform, "source_transform": source_transform,
"body_transform": body_transform, "body_transform": body_transform,
"mesh_to_body": mesh_to_body,
"bounds": bounds, "bounds": bounds,
}} }}
@@ -245,15 +366,23 @@ static func _plan_generate_mesh(
## request can never apply plan-time state to a mesh that moved, was ## request can never apply plan-time state to a mesh that moved, was
## reparented, lost its mesh, gained a collider, or went away meanwhile. ## reparented, lost its mesh, gained a collider, or went away meanwhile.
static func _plan_stale_reason(plan: Dictionary) -> String: static func _plan_stale_reason(plan: Dictionary) -> String:
var mesh: MeshInstance3D = plan.mesh ## Keep the captured references untyped until validity is known: assigning
## a freed Object to a typed local raises before is_instance_valid() can
## inspect it, and a mesh, its parent, or the scene root can be freed
## between the frame that planned it and the frame that applies it.
var mesh_ref = plan.mesh
## A node taken out of the tree and queued to free is still a valid ## A node taken out of the tree and queued to free is still a valid
## instance for the rest of the frame; not being in the tree is what ## instance for the rest of the frame; not being in the tree is what
## "removed" means here. ## "removed" means here.
if not is_instance_valid(mesh) or not mesh.is_inside_tree(): if not is_instance_valid(mesh_ref) or not mesh_ref.is_inside_tree():
return "was removed" return "was removed"
var parent: Node = plan.parent var mesh: MeshInstance3D = mesh_ref
if not is_instance_valid(parent) or mesh.get_parent() != parent: if mesh.name != plan.mesh_name:
return "was renamed"
var parent_ref = plan.parent
if not is_instance_valid(parent_ref) or mesh.get_parent() != parent_ref:
return "was reparented" return "was reparented"
var parent: Node = parent_ref
if mesh.top_level != bool(plan.top_level): if mesh.top_level != bool(plan.top_level):
return "changed its top_level setting" return "changed its top_level setting"
var source_transform := mesh.global_transform if mesh.top_level else mesh.transform var source_transform := mesh.global_transform if mesh.top_level else mesh.transform
@@ -261,51 +390,202 @@ static func _plan_stale_reason(plan: Dictionary) -> String:
return "moved" return "moved"
if mesh.mesh == null: if mesh.mesh == null:
return "lost its mesh resource" return "lost its mesh resource"
var auto_stale := _auto_source_stale_reason(mesh, plan)
if not auto_stale.is_empty():
return auto_stale
if parent.get_node_or_null(NodePath(str(plan.collider_name))) != null: if parent.get_node_or_null(NodePath(str(plan.collider_name))) != null:
return "gained a collider sibling" return "gained a collider sibling"
return "" return ""
static func _auto_source_stale_reason(mesh: MeshInstance3D, snapshot: Dictionary) -> String:
if bool(snapshot.get("auto_shape", false)):
if mesh.mesh != snapshot.source_mesh:
return "changed its mesh resource"
if not mesh.get_aabb().is_equal_approx(snapshot.source_bounds):
return "changed its mesh bounds"
return ""
## Build one detached body/collision pair from a prevalidated mesh plan. ## Build one detached body/collision pair from a prevalidated mesh plan.
## Returns `{error: ...}` when the mesh cannot produce the requested shape
## (the engine refuses a degenerate hull).
static func _create_generated_entry( static func _create_generated_entry(
plan: Dictionary, shape_type: String, body_type: String plan: Dictionary, shape_type: String, body_type: String, reparent_mesh: bool
) -> Dictionary: ) -> Dictionary:
var mesh: MeshInstance3D = plan.mesh var mesh: MeshInstance3D = plan.mesh
if shape_type == "convex" or shape_type == "trimesh":
var error := _validate_hull_workload(mesh.mesh, str(plan.mesh_path), shape_type)
if not error.is_empty():
return error
var bounds: AABB = plan.bounds
var shape: Shape3D
if shape_type == "convex" or shape_type == "trimesh":
## Hull and concave shapes come from the mesh's own vertices, with the
## mesh's scale baked in so the collision node needs no scale of its own.
shape = _fit_mesh_shape(mesh, shape_type, plan.mesh_to_body)
if shape == null:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"MeshInstance3D at %s could not produce a %s shape — the mesh geometry is degenerate"
% [str(plan.mesh_path), shape_type]
)
else:
shape = ClassDB.instantiate(_SHAPE_3D_CLASSES[shape_type])
_apply_shape_size(shape, shape_type, {"aabb": bounds}, true)
var body: CollisionObject3D = ClassDB.instantiate(_GENERATED_BODY_CLASSES[body_type]) var body: CollisionObject3D = ClassDB.instantiate(_GENERATED_BODY_CLASSES[body_type])
body.name = str(plan.collider_name) body.name = str(plan.collider_name)
body.top_level = bool(plan.top_level) body.top_level = bool(plan.top_level)
body.transform = plan.body_transform body.transform = plan.body_transform
var collision := CollisionShape3D.new() var collision := CollisionShape3D.new()
collision.name = "CollisionShape3D" collision.name = "CollisionShape3D"
var bounds: AABB = plan.bounds if shape_type != "convex" and shape_type != "trimesh":
collision.position = bounds.get_center() collision.position = _snap_tiny(bounds.get_center())
var shape: Shape3D = ClassDB.instantiate(_SHAPE_3D_CLASSES[shape_type])
_apply_shape_size(shape, shape_type, {"aabb": bounds}, true)
collision.shape = shape collision.shape = shape
body.add_child(collision) body.add_child(collision)
return { var entry := {
"mesh": mesh, "mesh": mesh,
"mesh_path": str(plan.mesh_path),
"mesh_name": mesh.name,
"body_name": body.name,
"shape_type": shape_type,
"auto_shape": bool(plan.get("auto_shape", false)),
"source_mesh": plan.get("source_mesh"),
"source_bounds": plan.get("source_bounds"),
"mesh_local_transform": plan.mesh_to_body,
"parent": plan.parent, "parent": plan.parent,
"body": body, "body": body,
"collision": collision, "collision": collision,
"reparent_mesh": reparent_mesh,
} }
if reparent_mesh:
## Captured at apply time, right before the mesh moves under the body.
entry["mesh_parent"] = mesh.get_parent()
entry["mesh_index"] = mesh.get_index()
entry["mesh_transform"] = mesh.transform
entry["mesh_owner"] = mesh.owner
Refresh.prepare_markers(entry)
return entry
## Derive a ConvexPolygonShape3D/ConcavePolygonShape3D from the mesh and bake
## the mesh's own scale into its vertices, so the collision node keeps an
## identity transform and Godot never has to scale a hull or concave shape.
static func _fit_mesh_shape(mesh: MeshInstance3D, shape_type: String, mesh_to_body: Transform3D) -> Shape3D:
var shape: Shape3D = (
mesh.mesh.create_convex_shape() if shape_type == "convex" else mesh.mesh.create_trimesh_shape()
)
if shape == null:
return null
if mesh_to_body.is_equal_approx(Transform3D.IDENTITY):
return shape
if shape is ConvexPolygonShape3D:
var points := (shape as ConvexPolygonShape3D).points
for index in points.size():
points[index] = mesh_to_body * points[index]
(shape as ConvexPolygonShape3D).points = points
else:
var faces := (shape as ConcavePolygonShape3D).get_faces()
for index in faces.size():
faces[index] = mesh_to_body * faces[index]
if mesh_to_body.basis.determinant() < 0.0:
## A mirrored scale reverses triangle winding, and a
## ConcavePolygonShape3D collides with front faces only.
for index in range(0, faces.size(), 3):
var swapped := faces[index + 1]
faces[index + 1] = faces[index + 2]
faces[index + 2] = swapped
(shape as ConcavePolygonShape3D).set_faces(faces)
return shape
## Float noise from AABB centering is snapped out of a generated collision
## offset (see `_GENERATE_SNAP_EPSILON`).
static func _snap_tiny(value: Vector3) -> Vector3:
return Vector3(_snap_tiny_component(value.x), _snap_tiny_component(value.y), _snap_tiny_component(value.z))
static func _snap_tiny_component(component: float) -> float:
return 0.0 if absf(component) < _GENERATE_SNAP_EPSILON else component
## Why an already-applied entry no longer matches the scene, or "" when every
## generated body is still present under its planned parent. Checked right
## before the batch is committed: a node freed or reparented while the
## remaining entries were applied must fail the request and roll back the
## surviving bodies, not abort the commit and the reply with freed-instance
## errors that leave an uncommitted body behind.
static func _applied_stale_reason(created_nodes: Array[Dictionary]) -> String:
for entry in created_nodes:
## Keep these untyped until validity is known: a typed assignment raises
## on a freed Object before is_instance_valid() can inspect it.
var mesh = entry.mesh
if not is_instance_valid(mesh) or not mesh.is_inside_tree():
return "%s was removed" % str(entry.mesh_path)
if mesh.name != entry.mesh_name:
return "%s was renamed" % str(entry.mesh_path)
var auto_stale := _auto_source_stale_reason(mesh, entry)
if not auto_stale.is_empty():
return "%s %s" % [str(entry.mesh_path), auto_stale]
var parent = entry.parent
if not is_instance_valid(parent):
return "%s was reparented" % str(entry.mesh_path)
## A wrapped mesh is expected under the generated body, not under the
## parent captured at plan time. Keep the conditional untyped: a typed
## assignment raises on a freed body before the validity check.
var mesh_parent = entry.body if bool(entry.get("reparent_mesh", false)) else parent
if not is_instance_valid(mesh_parent) or mesh.get_parent() != mesh_parent:
return "%s was reparented" % str(entry.mesh_path)
var body = entry.body
if not is_instance_valid(body) or not body.is_inside_tree():
return "the generated body for %s was removed" % str(entry.mesh_path)
if body.name != entry.body_name:
return "the generated body for %s was renamed" % str(entry.mesh_path)
if body.get_parent() != parent:
return "the generated body for %s was reparented" % str(entry.mesh_path)
var collision = entry.collision
if not is_instance_valid(collision) or collision.get_parent() != body:
return "the generated collision shape for %s was removed" % str(entry.mesh_path)
if collision.name != &"CollisionShape3D":
return "the generated collision shape for %s was renamed" % str(entry.mesh_path)
return ""
## Record all generated nodes as one undo action, optionally executing it. ## Record all generated nodes as one undo action, optionally executing it.
## With `reparent_mesh` the mesh moves under the body, so the do (redo) and
## undo methods carry it across the two layouts; undo restores the mesh's
## original parent, index, transform and owner before the body is detached.
static func _commit_generated_action( static func _commit_generated_action(
created_nodes: Array[Dictionary], created_nodes: Array[Dictionary],
scene_root: Node, scene_root: Node,
undo_redo: EditorUndoRedoManager, undo_redo: EditorUndoRedoManager,
execute: bool, execute: bool,
) -> void: ) -> void:
undo_redo.create_action("MCP: Generate physics shapes for %d mesh(es)" % created_nodes.size()) undo_redo.create_action("MCP: Generate physics shapes for %d mesh(es)" % created_nodes.size(), UndoRedo.MERGE_DISABLE, scene_root)
for entry in created_nodes: for entry in created_nodes:
if bool(entry.get("refresh", false)):
undo_redo.add_do_method(ClassDB, "class_set_property", entry.collision, "shape", entry.new_shape)
undo_redo.add_do_method(ClassDB, "class_set_property", entry.collision, "transform", entry.new_transform)
undo_redo.add_undo_method(ClassDB, "class_set_property", entry.collision, "shape", entry.old_shape)
undo_redo.add_undo_method(ClassDB, "class_set_property", entry.collision, "transform", entry.old_transform)
continue
Refresh.record_marker_action(entry, undo_redo, execute)
var parent: Node = entry.parent var parent: Node = entry.parent
var body: CollisionObject3D = entry.body var body: CollisionObject3D = entry.body
var collision: CollisionShape3D = entry.collision var collision: CollisionShape3D = entry.collision
undo_redo.add_do_method(parent, "add_child", body, true) undo_redo.add_do_method(parent, "add_child", body, true)
undo_redo.add_do_method(body, "set_owner", scene_root) undo_redo.add_do_method(body, "set_owner", scene_root)
undo_redo.add_do_method(collision, "set_owner", scene_root) undo_redo.add_do_method(collision, "set_owner", scene_root)
if bool(entry.reparent_mesh):
var mesh: MeshInstance3D = entry.mesh
var mesh_parent: Node = entry.mesh_parent
undo_redo.add_do_method(mesh, "reparent", body, true)
undo_redo.add_do_method(mesh, "set_owner", scene_root)
undo_redo.add_do_method(mesh, "set_transform", entry.mesh_local_transform)
undo_redo.add_undo_method(mesh, "reparent", mesh_parent, true)
undo_redo.add_undo_method(mesh_parent, "move_child", mesh, int(entry.mesh_index))
undo_redo.add_undo_method(mesh, "set_transform", entry.mesh_transform)
undo_redo.add_undo_method(mesh, "set_owner", entry.mesh_owner)
undo_redo.add_do_reference(body) undo_redo.add_do_reference(body)
undo_redo.add_undo_method(parent, "remove_child", body) undo_redo.add_undo_method(parent, "remove_child", body)
undo_redo.commit_action(execute) undo_redo.commit_action(execute)
@@ -313,7 +593,7 @@ static func _commit_generated_action(
## Build the public response after every body is present in the edited scene. ## Build the public response after every body is present in the edited scene.
static func _generated_response( static func _generated_response(
created_nodes: Array[Dictionary], scene_root: Node, shape_type: String, body_type: String created_nodes: Array[Dictionary], scene_root: Node, body_type: String
) -> Dictionary: ) -> Dictionary:
var created: Array[Dictionary] = [] var created: Array[Dictionary] = []
for entry in created_nodes: for entry in created_nodes:
@@ -321,8 +601,9 @@ static func _generated_response(
"mesh_path": McpScenePath.from_node(entry.mesh, scene_root), "mesh_path": McpScenePath.from_node(entry.mesh, scene_root),
"body_path": McpScenePath.from_node(entry.body, scene_root), "body_path": McpScenePath.from_node(entry.body, scene_root),
"shape_path": McpScenePath.from_node(entry.collision, scene_root), "shape_path": McpScenePath.from_node(entry.collision, scene_root),
"shape_type": shape_type, "shape_type": str(entry.shape_type),
"body_type": body_type, "body_type": body_type,
"operation": "refresh" if bool(entry.get("refresh", false)) else "create",
}) })
return {"data": {"created": created, "undoable": true}} return {"data": {"created": created, "undoable": true}}
@@ -335,6 +616,7 @@ static func _generate_job(
validated: Dictionary, undo_redo: EditorUndoRedoManager, connection, request_id: String validated: Dictionary, undo_redo: EditorUndoRedoManager, connection, request_id: String
) -> Dictionary: ) -> Dictionary:
return { return {
"refresh_worker": Refresh.new(),
"validated": validated, "validated": validated,
"undo_redo": undo_redo, "undo_redo": undo_redo,
"connection": connection, "connection": connection,
@@ -343,6 +625,7 @@ static func _generate_job(
"index": 0, "index": 0,
"plans": [], "plans": [],
"created": [], "created": [],
"committed": false,
"result": {}, "result": {},
"started_ms": Time.get_ticks_msec(), "started_ms": Time.get_ticks_msec(),
} }
@@ -354,6 +637,8 @@ static func _generate_job(
static func _generate_step(job: Dictionary, budget_usec: int) -> bool: static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
if str(job.phase) == "done": if str(job.phase) == "done":
return true return true
if bool(job.validated.get("overwrite", false)):
return job.refresh_worker.step(job, budget_usec)
var validated: Dictionary = job.validated var validated: Dictionary = job.validated
var connection = job.connection var connection = job.connection
if connection != null: if connection != null:
@@ -368,12 +653,16 @@ static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
ErrorCodes.DEFERRED_TIMEOUT, ErrorCodes.DEFERRED_TIMEOUT,
"physics_shape_generate exceeded its %d ms budget" % _GENERATE_DEFERRED_TIMEOUT_MS, "physics_shape_generate exceeded its %d ms budget" % _GENERATE_DEFERRED_TIMEOUT_MS,
)) ))
var scene_root: Node = validated.scene_root ## Same freed-instance rule as the plan check: a scene root freed while the
if not is_instance_valid(scene_root) or EditorInterface.get_edited_scene_root() != scene_root: ## job was in flight must fail this step, not error on a typed assignment
## and leave the job retrying until the dispatcher timeout.
var scene_root_ref = validated.scene_root
if not is_instance_valid(scene_root_ref) or EditorInterface.get_edited_scene_root() != scene_root_ref:
return _generate_fail(job, ErrorCodes.make( return _generate_fail(job, ErrorCodes.make(
ErrorCodes.EDITED_SCENE_MISMATCH, ErrorCodes.EDITED_SCENE_MISMATCH,
"The edited scene changed while physics shapes were generated", "The edited scene changed while physics shapes were generated",
)) ))
var scene_root: Node = scene_root_ref
## The budget is checked before each item after the first, so every step ## The budget is checked before each item after the first, so every step
## makes progress and a phase that just finished its last item moves on. ## makes progress and a phase that just finished its last item moves on.
var frame_start := Time.get_ticks_usec() var frame_start := Time.get_ticks_usec()
@@ -387,7 +676,8 @@ static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - frame_start >= budget_usec: if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - frame_start >= budget_usec:
return false return false
var planned := _plan_generate_mesh( var planned := _plan_generate_mesh(
str(paths[int(job.index)]), str(validated.scene_file), scene_root, str(validated.shape_type) str(paths[int(job.index)]), str(validated.scene_file), scene_root,
str(validated.shape_type), bool(validated.reparent_mesh)
) )
if planned.has("error"): if planned.has("error"):
return _generate_fail(job, planned) return _generate_fail(job, planned)
@@ -407,21 +697,47 @@ static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
"MeshInstance3D at %s %s while physics shapes were generated; nothing was changed" "MeshInstance3D at %s %s while physics shapes were generated; nothing was changed"
% [str(plan.mesh_path), stale], % [str(plan.mesh_path), stale],
)) ))
var entry := _create_generated_entry(plan, str(validated.shape_type), str(validated.body_type)) var entry := _create_generated_entry(
plan, str(plan.shape_type), str(validated.body_type), bool(validated.reparent_mesh)
)
if entry.has("error"):
return _generate_fail(job, entry)
var parent: Node = plan.parent var parent: Node = plan.parent
parent.add_child(entry.body, true) parent.add_child(entry.body, true)
entry.body.set_owner(scene_root) entry.body.set_owner(scene_root)
entry.collision.set_owner(scene_root) entry.collision.set_owner(scene_root)
if bool(validated.reparent_mesh):
## `add_child` refuses a node that already has a parent, so the
## wrap moves the mesh with `reparent` and then pins the exact
## local transform (reparent preserves the world transform).
entry.mesh.reparent(entry.body, true)
entry.mesh.set_owner(scene_root)
entry.mesh.transform = entry.mesh_local_transform
job.created.append(entry) job.created.append(entry)
job.index = int(job.index) + 1 job.index = int(job.index) + 1
work += 1 work += 1
## The nodes are already in the scene, so commit with execute=false: one ## Revalidate every applied entry before the batch is committed: a mesh,
## atomic undo/redo action without replaying every mutation now. ## parent, body or collision freed or reparented while the remaining entries
## were applied must fail the request and roll back the surviving bodies
## instead of erroring out of the commit and the reply.
var created: Array[Dictionary] = [] var created: Array[Dictionary] = []
created.assign(job.created) created.assign(job.created)
var applied_stale := _applied_stale_reason(created)
if not applied_stale.is_empty():
var code: String = (
ErrorCodes.NODE_NOT_FOUND if applied_stale.ends_with("was removed")
else ErrorCodes.EDITED_SCENE_MISMATCH
)
return _generate_fail(job, ErrorCodes.make(
code,
"Scene changed while physics shapes were applied: %s; nothing was changed" % applied_stale,
))
## The nodes are already in the scene, so commit with execute=false: one
## atomic undo/redo action without replaying every mutation now.
_commit_generated_action(created, scene_root, job.undo_redo, false) _commit_generated_action(created, scene_root, job.undo_redo, false)
job.committed = true
job.result = _generated_response( job.result = _generated_response(
created, scene_root, str(validated.shape_type), str(validated.body_type) created, scene_root, str(validated.body_type)
) )
job.phase = "done" job.phase = "done"
return true return true
@@ -436,17 +752,58 @@ static func _generate_fail(job: Dictionary, error: Dictionary) -> bool:
## Remove and free every body this job added; none is in an undo action yet. ## Remove and free every body this job added; none is in an undo action yet.
static func _generate_rollback(job: Dictionary) -> void: static func _generate_rollback(job: Dictionary) -> void:
## Once the undo action owns these bodies they are a completed user change,
## even if the transport disappears before its reply can be delivered.
if bool(job.get("committed", false)):
return
if bool(job.validated.get("overwrite", false)):
job.refresh_worker.cleanup(job)
return
for entry in job.created: for entry in job.created:
var body: Node = entry.body ## Keep this untyped until validity is known: assigning a freed Object to
## a typed Node local raises before is_instance_valid() can inspect it.
var body = entry.body
if not is_instance_valid(body): if not is_instance_valid(body):
continue continue
var parent := body.get_parent() if bool(entry.get("reparent_mesh", false)):
_restore_reparented_mesh(entry)
var parent: Node = body.get_parent()
if parent != null: if parent != null:
parent.remove_child(body) parent.remove_child(body)
body.free() body.free()
job.created.clear() job.created.clear()
## Move a mesh back out of its generated body before that body is freed —
## `free()` would otherwise take the mesh with it. Parent, index, transform
## and owner are restored exactly.
static func _restore_reparented_mesh(entry: Dictionary) -> void:
var mesh = entry.mesh
if not is_instance_valid(mesh):
return
if mesh.get_parent() != entry.body:
return
## Keep this untyped until validity is known: assigning a freed Object to
## a typed Node local raises before is_instance_valid() can inspect it.
var mesh_parent = entry.mesh_parent
if not is_instance_valid(mesh_parent):
## The captured parent was freed while the deferred job owned the
## mesh. Reattach it to wherever the body now lives — or at least
## detach it — so the rollback's `free()` cannot take it with it.
var fallback = entry.body.get_parent()
if is_instance_valid(fallback):
mesh.reparent(fallback, true)
else:
entry.body.remove_child(mesh)
return
mesh.reparent(mesh_parent, true)
var index := int(entry.mesh_index)
if index < mesh_parent.get_child_count():
mesh_parent.move_child(mesh, index)
mesh.transform = entry.mesh_transform
mesh.set_owner(entry.mesh_owner)
## Check that the connection and deferred dispatcher entry are both live. ## Check that the connection and deferred dispatcher entry are both live.
static func _deferred_request_pending(connection, request_id: String) -> bool: static func _deferred_request_pending(connection, request_id: String) -> bool:
if not is_instance_valid(connection): if not is_instance_valid(connection):
@@ -457,19 +814,59 @@ static func _deferred_request_pending(connection, request_id: String) -> bool:
## Drive a deferred job one editor frame at a time and reply when it ends. ## Drive a deferred job one editor frame at a time and reply when it ends.
## `send_deferred_response` itself drops a reply whose request is gone. ## `send_deferred_response` itself drops a reply whose request is gone.
static func _drive_generate_job(job: Dictionary, connection) -> void: static func _drive_generate_job(job: Dictionary, connection, frame_signal: Variant = null) -> void:
var work_id := ScriptWork.begin("physics_shape_generate")
job["work_id"] = work_id
if not is_instance_valid(connection): if not is_instance_valid(connection):
_cancel_generate_job(job)
return
if not connection.is_inside_tree():
_cancel_generate_job(job)
return return
var tree: SceneTree = connection.get_tree() var tree: SceneTree = connection.get_tree()
if tree == null: if tree == null:
_cancel_generate_job(job)
return return
var resume_signal: Signal = frame_signal if frame_signal is Signal else tree.process_frame
## The first yield lets the dispatcher register the deferred request before ## The first yield lets the dispatcher register the deferred request before
## any validation error or successful result can be sent. ## any validation error or successful result can be sent. The abandoned-request
await tree.process_frame ## check belongs in `_generate_step`, after this yield: before the handler
while is_instance_valid(connection) and not _generate_step(job, _GENERATE_FRAME_BUDGET_USEC): ## returns its deferred sentinel the dispatcher has not registered the request
await tree.process_frame ## yet, so checking here would cancel every real call.
if is_instance_valid(connection) and not job.result.is_empty(): await resume_signal
## A connection that left the tree is detected here, not from `tree_exiting`:
## that signal runs inside the parent's `remove_child`, where a synchronous
## rollback's own `remove_child`/`free` is refused and then frees a still
## parented body, corrupting the scene tree.
while (
is_instance_valid(connection)
and connection.is_inside_tree()
and not _generate_step(job, _GENERATE_FRAME_BUDGET_USEC)
):
await resume_signal
if str(job.phase) != "done":
_generate_rollback(job)
job.phase = "done"
if is_instance_valid(connection) and connection.is_inside_tree() and not job.result.is_empty():
connection.send_deferred_response(str(job.request_id), job.result) connection.send_deferred_response(str(job.request_id), job.result)
_finish_generate_job(job)
## Cancel from request abandonment or a connection that left the tree.
## Completed actions are already committed, so rollback deliberately preserves them.
static func _cancel_generate_job(job: Dictionary) -> void:
if str(job.get("phase", "")) != "done":
_generate_rollback(job)
job.phase = "done"
_finish_generate_job(job)
## Release the process-wide script-work lease once.
static func _finish_generate_job(job: Dictionary) -> void:
var work_id := int(job.get("work_id", 0))
if work_id != 0:
ScriptWork.finish(work_id)
job["work_id"] = 0
func autofit(params: Dictionary) -> Dictionary: func autofit(params: Dictionary) -> Dictionary:
@@ -0,0 +1,257 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MARKER := &"_godot_ai_physics_v1"
var _host: Script
var _watches: Array[Dictionary] = []
var _seen: Dictionary = {}
static func prepare_markers(entry: Dictionary) -> void:
var name := str(ClassDB.class_get_property(entry.mesh, "name"))
var body_name := str(entry.body.name)
var wrapped := bool(entry.reparent_mesh)
entry["old_marker_present"] = entry.mesh.has_meta(MARKER)
entry["old_marker"] = entry.mesh.get_meta(MARKER) if entry.mesh.has_meta(MARKER) else null
entry["marker"] = {
"version": 1, "wrapped": wrapped,
"body": NodePath(".." if wrapped else "../" + body_name),
"collision": NodePath("../CollisionShape3D" if wrapped else "../" + body_name + "/CollisionShape3D"),
}
entry.body.set_meta(MARKER, {
"version": 1, "wrapped": wrapped,
"source": NodePath(name if wrapped else "../" + name),
"collision": NodePath("CollisionShape3D"),
})
entry.collision.set_meta(MARKER, {
"version": 1, "source": NodePath("../" + name if wrapped else "../../" + name),
"body": NodePath(".."),
})
static func record_marker_action(entry: Dictionary, undo: EditorUndoRedoManager, execute: bool) -> void:
undo.add_do_method(entry.mesh, "set_meta", MARKER, entry.marker)
if bool(entry.old_marker_present):
undo.add_undo_method(entry.mesh, "set_meta", MARKER, entry.old_marker)
else:
undo.add_undo_method(entry.mesh, "remove_meta", MARKER)
if not execute:
entry.mesh.set_meta(MARKER, entry.marker)
static func _link(node: Node, marker: Variant, key: String) -> Node:
if marker is not Dictionary or typeof(marker.get("version")) != TYPE_INT or marker.version != 1 or typeof(marker.get(key)) != TYPE_NODE_PATH:
return null
return node.get_node_or_null(marker[key])
static func relationship(mesh: MeshInstance3D, root: Node, body_type: String, wrapped: bool) -> Dictionary:
var marker: Variant = mesh.get_meta(MARKER) if mesh.has_meta(MARKER) else {}
var body := _link(mesh, marker, "body")
var collision := _link(mesh, marker, "collision")
if body == null or collision == null or collision.get_class() != "CollisionShape3D":
return {}
var classes := {"static": "StaticBody3D", "area": "Area3D", "rigid": "RigidBody3D", "character": "CharacterBody3D"}
if body.get_class() != classes[body_type] or typeof(marker.get("wrapped")) != TYPE_BOOL or marker.wrapped != wrapped:
return {}
var body_marker: Variant = body.get_meta(MARKER) if body.has_meta(MARKER) else {}
var collision_marker: Variant = collision.get_meta(MARKER) if collision.has_meta(MARKER) else {}
if _link(body, body_marker, "source") != mesh or _link(body, body_marker, "collision") != collision:
return {}
if _link(collision, collision_marker, "source") != mesh or _link(collision, collision_marker, "body") != body:
return {}
if typeof(body_marker.get("wrapped")) != TYPE_BOOL or body_marker.wrapped != wrapped or collision.get_parent() != body:
return {}
if ClassDB.class_get_property(mesh, "owner") != root or ClassDB.class_get_property(body, "owner") != root or ClassDB.class_get_property(collision, "owner") != root:
return {}
if (wrapped and mesh.get_parent() != body) or (not wrapped and mesh.get_parent() != body.get_parent()):
return {}
return {"body": body, "collision": collision}
func _watch(resource: Resource, plan: Dictionary) -> void:
if resource == null:
return
var callback := func(): plan["resource_changed"] = true
resource.changed.connect(callback)
_watches.append({"resource": resource, "callback": callback})
func _disconnect() -> void:
for watch in _watches:
if watch.resource.changed.is_connected(watch.callback):
watch.resource.changed.disconnect(watch.callback)
_watches.clear()
func _plan(path: String, validated: Dictionary) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(path, "paths", str(validated.scene_file))
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if node is not MeshInstance3D or not node.has_meta(MARKER):
if node.get_parent() != null and node.get_parent().has_meta(MARKER):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot create beneath a generated body at %s: missing or inconsistent source provenance" % path)
var fresh: Dictionary = _host._plan_generate_mesh(path, validated.scene_file, validated.scene_root, validated.shape_type, validated.reparent_mesh)
if fresh.has("plan"):
fresh.plan["world_transform"] = ClassDB.class_get_property(node, "global_transform")
fresh.plan["source_owner"] = ClassDB.class_get_property(node, "owner")
return fresh
var mesh := node as MeshInstance3D
var linked := relationship(mesh, validated.scene_root, validated.body_type, validated.reparent_mesh)
if linked.is_empty() or ClassDB.class_get_property(mesh, "mesh") == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: generated provenance, ownership, body type or topology does not match" % path)
var body := linked.body as CollisionObject3D
var collision := linked.collision as CollisionShape3D
if ClassDB.class_get_property(collision, "top_level"):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: collision top_level must remain false" % path)
var shape_type: String = _host._auto_generate_shape(ClassDB.class_get_property(mesh, "mesh")) if validated.shape_type == "auto" else validated.shape_type
var body_world: Transform3D = ClassDB.class_get_property(body, "global_transform")
var basis := body_world.basis
if absf(basis.determinant()) < 0.000001:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: body transform is singular" % path)
var scale := basis.get_scale()
var orthogonal := absf(basis.x.normalized().dot(basis.y.normalized())) < 0.0001 and absf(basis.x.normalized().dot(basis.z.normalized())) < 0.0001 and absf(basis.y.normalized().dot(basis.z.normalized())) < 0.0001
if not orthogonal or (shape_type != "box" and not scale.abs().is_equal_approx(Vector3.ONE * absf(scale.x))):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: body scale/shear is unsupported for %s" % [path, shape_type])
var mesh_to_body: Transform3D = body_world.affine_inverse() * ClassDB.class_get_property(mesh, "global_transform")
var bounds := mesh_to_body * mesh.get_aabb()
if bounds.size.x <= 0 or bounds.size.y <= 0 or bounds.size.z <= 0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: mesh has empty bounds" % path)
return {"plan": {
"refresh": true, "mesh": mesh, "mesh_path": path, "parent": mesh.get_parent(),
"body": body, "collision": collision, "shape_type": shape_type,
"reparent_mesh": validated.reparent_mesh, "mesh_to_body": mesh_to_body, "bounds": bounds,
"source_mesh": ClassDB.class_get_property(mesh, "mesh"), "source_bounds": mesh.get_aabb(), "source_transform": ClassDB.class_get_property(mesh, "global_transform"),
"body_transform": ClassDB.class_get_property(body, "global_transform"), "body_parent": body.get_parent(), "body_top_level": ClassDB.class_get_property(body, "top_level"),
"old_shape": ClassDB.class_get_property(collision, "shape"), "old_transform": ClassDB.class_get_property(collision, "transform"), "collision_top_level": ClassDB.class_get_property(collision, "top_level"),
"marker": mesh.get_meta(MARKER).duplicate(true), "body_marker": body.get_meta(MARKER).duplicate(true),
"collision_marker": collision.get_meta(MARKER).duplicate(true),
}}
func _stale(plan: Dictionary, validated: Dictionary) -> String:
var mesh = plan.mesh
if not is_instance_valid(mesh) or not mesh.is_inside_tree():
return "mesh was removed"
if bool(plan.get("resource_changed", false)) or ClassDB.class_get_property(mesh, "mesh") != plan.source_mesh or not mesh.get_aabb().is_equal_approx(plan.source_bounds):
return "geometry changed"
if not bool(plan.get("refresh", false)):
if ClassDB.class_get_property(mesh, "owner") != plan.source_owner or not ClassDB.class_get_property(mesh, "global_transform").is_equal_approx(plan.world_transform):
return "source owner or world transform changed"
if mesh.has_meta(MARKER) or mesh.get_parent().has_meta(MARKER):
return "mesh provenance changed"
return _host._plan_stale_reason(plan)
var body = plan.body
var collision = plan.collision
if not is_instance_valid(body) or not is_instance_valid(collision) or not body.is_inside_tree() or not collision.is_inside_tree():
return "body or collision was removed"
var linked := relationship(mesh, validated.scene_root, validated.body_type, validated.reparent_mesh)
if linked.is_empty() or linked.body != body or linked.collision != collision:
return "generated relationship changed"
if mesh.get_meta(MARKER) != plan.marker or body.get_meta(MARKER) != plan.body_marker or collision.get_meta(MARKER) != plan.collision_marker:
return "provenance changed"
if mesh.get_parent() != plan.parent or body.get_parent() != plan.body_parent or ClassDB.class_get_property(body, "top_level") != plan.body_top_level or ClassDB.class_get_property(collision, "top_level") != plan.collision_top_level:
return "topology changed"
if not ClassDB.class_get_property(mesh, "global_transform").is_equal_approx(plan.source_transform) or not ClassDB.class_get_property(body, "global_transform").is_equal_approx(plan.body_transform):
return "source or body transform changed"
if ClassDB.class_get_property(collision, "shape") != plan.old_shape or not ClassDB.class_get_property(collision, "transform").is_equal_approx(plan.old_transform):
return "collision was edited"
return ""
func _prepare(plan: Dictionary, validated: Dictionary) -> Dictionary:
if not bool(plan.get("refresh", false)):
return _host._create_generated_entry(plan, plan.shape_type, validated.body_type, validated.reparent_mesh)
var shape: Shape3D
if plan.shape_type in ["convex", "trimesh"]:
var error: Dictionary = _host._validate_hull_workload(ClassDB.class_get_property(plan.mesh, "mesh"), plan.mesh_path, plan.shape_type)
if not error.is_empty():
return error
shape = _host._fit_mesh_shape(plan.mesh, plan.shape_type, plan.mesh_to_body)
if shape == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot fit degenerate mesh at %s" % plan.mesh_path)
else:
shape = ClassDB.instantiate(_host._SHAPE_3D_CLASSES[plan.shape_type])
_host._apply_shape_size(shape, plan.shape_type, {"aabb": plan.bounds}, true)
var entry := plan.duplicate()
entry["new_shape"] = shape
entry["new_transform"] = Transform3D.IDENTITY if plan.shape_type in ["convex", "trimesh"] else Transform3D(Basis.IDENTITY, _host._snap_tiny(plan.bounds.get_center()))
return entry
func cleanup(job: Dictionary) -> void:
_disconnect()
for entry in job.created:
if not bool(entry.get("refresh", false)) and is_instance_valid(entry.body):
entry.body.free()
job.created.clear()
func _fail(job: Dictionary, error: Dictionary) -> bool:
cleanup(job)
job.result = error
job.phase = "done"
return true
func step(job: Dictionary, budget_usec: int) -> bool:
if _host == null:
_host = load("res://addons/godot_ai/handlers/physics_shape_handler.gd")
var v: Dictionary = job.validated
if job.connection != null and not _host._deferred_request_pending(job.connection, job.request_id):
return _fail(job, {})
if Time.get_ticks_msec() - int(job.started_ms) > _host._GENERATE_DEFERRED_TIMEOUT_MS:
return _fail(job, ErrorCodes.make(ErrorCodes.DEFERRED_TIMEOUT, "Physics shape refresh exceeded its time budget"))
if not is_instance_valid(v.scene_root) or EditorInterface.get_edited_scene_root() != v.scene_root:
return _fail(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "Edited scene changed during physics shape refresh"))
var started := Time.get_ticks_usec()
var work := 0
while job.phase in ["plan", "prepare"]:
if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - started >= budget_usec:
return false
if job.phase == "plan":
if job.index >= v.paths.size():
job.phase = "prepare"
job.index = 0
continue
var planned := _plan(v.paths[job.index], v)
if planned.has("error"):
return _fail(job, planned)
var plan: Dictionary = planned.plan
var identity: int = plan.mesh.get_instance_id()
if _seen.has(identity):
return _fail(job, ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Multiple paths resolve to the same mesh"))
_seen[identity] = true
job.plans.append(plan)
_watch(plan.source_mesh, plan)
if bool(plan.get("refresh", false)):
_watch(plan.old_shape, plan)
else:
if job.index >= job.plans.size():
job.phase = "commit"
break
var plan: Dictionary = job.plans[job.index]
var reason := _stale(plan, v)
if not reason.is_empty():
return _fail(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "%s: %s" % [plan.mesh_path, reason]))
var entry := _prepare(plan, v)
if entry.has("error"):
return _fail(job, entry)
job.created.append(entry)
job.index += 1
work += 1
for plan in job.plans:
var reason := _stale(plan, v)
if not reason.is_empty():
return _fail(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "%s: %s" % [plan.mesh_path, reason]))
_disconnect()
var entries: Array[Dictionary] = []
entries.assign(job.created)
_host._commit_generated_action(entries, v.scene_root, job.undo_redo, true)
job.committed = true
job.result = _host._generated_response(entries, v.scene_root, v.body_type)
job.phase = "done"
return true
@@ -0,0 +1 @@
uid://b8xkp8fhvdf5k
+34 -1
View File
@@ -2,6 +2,7 @@
extends "res://addons/godot_ai/handlers/command_handler.gd" extends "res://addons/godot_ai/handlers/command_handler.gd"
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const ResourceInspector := preload("res://addons/godot_ai/utils/resource_inspector.gd")
const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd") const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
## Handles resource search, inspection, and assignment to nodes. ## Handles resource search, inspection, and assignment to nodes.
@@ -344,7 +345,7 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary, de
var loaded := ResourceLoader.load(v) var loaded := ResourceLoader.load(v)
if loaded == null: if loaded == null:
return ErrorCodes.make( return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS, ErrorCodes.RESOURCE_NOT_FOUND,
"Resource not found at path '%s' for property '%s'" % [v, key] "Resource not found at path '%s' for property '%s'" % [v, key]
) )
v = loaded v = loaded
@@ -589,3 +590,35 @@ static func _custom_resource_info(type_str: String) -> Variant:
"property_count": props.size(), "property_count": props.size(),
}} }}
return null return null
func inspect_resource(params: Dictionary) -> Dictionary:
for name in ["node_path", "property"]:
if not params.has(name):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "inspect_resource requires %s" % name)
if params[name] is not String:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s must be a String" % name)
if params[name].is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "inspect_resource requires %s" % name)
var depth: Variant = params.get("depth", 2)
if typeof(depth) not in [TYPE_INT, TYPE_FLOAT]:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "depth must be an integer from 0 through 3")
if not is_finite(float(depth)) or float(depth) != floor(float(depth)) or depth < 0 or depth > 3:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "depth must be an integer from 0 through 3; received %s" % str(depth))
var resolved := McpNodeValidator.resolve_or_error(params.node_path, "node_path")
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var native_resource_property := false
for property in ClassDB.class_get_property_list(node.get_class()):
if str(property.name) == params.property and int(property.type) == TYPE_OBJECT and int(property.hint) == PROPERTY_HINT_RESOURCE_TYPE:
native_resource_property = true
break
if not native_resource_property:
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Property '%s' on %s is not a native Resource property; script and dynamic getters are not inspected" % [params.property, node.get_class()])
var resource: Variant = ClassDB.class_get_property(node, params.property)
if resource is not Resource:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Property '%s' on '%s' does not contain a Resource; assign one before inspecting" % [params.property, params.node_path])
if not ResourceInspector.supported(resource):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource '%s' is scripted or outside the supported native shape, mesh, material, physics material, stylebox, gradient and curve families" % resource.get_class())
return ResourceInspector.new().inspect(resource, int(depth))
+252 -21
View File
@@ -6,6 +6,29 @@ const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_cap
const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd") const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd")
## Handles script creation, reading, attaching, detaching, and symbol inspection. ## Handles script creation, reading, attaching, detaching, and symbol inspection.
##
## Two script languages cross this surface (#908). GDScript (`.gd`) is the
## full contract: parse-validated on write, hot-reloaded when already loaded,
## outlined by `find_symbols`. C# (`.cs`) is text-only: the plugin writes the
## bytes and outlines the file, but it never compiles .NET — the caller has
## to build the project (editor Build button or `dotnet build`) to learn
## about compiler errors. Loading a `.cs` requires a .NET-enabled editor;
## resource recognition does not prove its managed class was built. Every
## `.cs` write says so
## (`diagnostics_status: "not_checked"`, `validation_hint`, `dotnet_editor`)
## instead of reporting a plain success the caller could mistake for
## validation.
const LANGUAGE_GDSCRIPT := "gdscript"
const LANGUAGE_CSHARP := "csharp"
const CSHARP_VALIDATION_HINT := (
"C# is written as text only; Godot AI does not compile .NET. Build the "
+ "project (editor Build button or `dotnet build`) to surface compiler "
+ "errors, then filesystem_manage(op=\"scan\") before attaching the script."
)
const UNSUPPORTED_EXTENSION_MESSAGE := (
"Path must end with .gd or .cs (use filesystem_manage op=\"write_text\" for other text files)"
)
var _undo_redo: EditorUndoRedoManager var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection var _connection: McpConnection
@@ -27,8 +50,11 @@ func create_script(params: Dictionary) -> Dictionary:
if path_err != null: if path_err != null:
return path_err return path_err
if not path.ends_with(".gd"): var language := script_language(path)
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd") if language.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, UNSUPPORTED_EXTENSION_MESSAGE)
var is_csharp := language == LANGUAGE_CSHARP
var can_settle := not is_csharp or editor_has_dotnet()
var existed_before := FileAccess.file_exists(path) var existed_before := FileAccess.file_exists(path)
@@ -42,12 +68,16 @@ func create_script(params: Dictionary) -> Dictionary:
"path": path, "path": path,
"size": content.length(), "size": content.length(),
"committed": true, "committed": true,
"import_settled": existed_before, "import_settled": existed_before and can_settle,
"import_settle": "already_known" if existed_before else "not_waited", "import_settle": "already_known" if existed_before and can_settle else "not_waited",
"undoable": false, "undoable": false,
"reason": "File system operations cannot be undone via editor undo", "reason": "File system operations cannot be undone via editor undo",
"language": language,
} }
_attach_gdscript_diagnostics(data, path, content) if is_csharp:
_attach_csharp_not_checked(data)
else:
_attach_gdscript_diagnostics(data, path, content)
# A freshly-declared `class_name` is NOT in the global class table until a # A freshly-declared `class_name` is NOT in the global class table until a
# filesystem scan runs — update_file() below registers the file with the # filesystem scan runs — update_file() below registers the file with the
@@ -60,7 +90,7 @@ func create_script(params: Dictionary) -> Dictionary:
# Skip the hint when the script failed to parse: a scan won't register a # Skip the hint when the script failed to parse: a scan won't register a
# class from a broken script, so pointing at op="scan" would steer the caller # class from a broken script, so pointing at op="scan" would steer the caller
# away from the real fix (the parse error already attached above). # away from the real fix (the parse error already attached above).
var declared_class := _extract_class_name(content) var declared_class := "" if is_csharp else _extract_class_name(content)
if ( if (
not declared_class.is_empty() not declared_class.is_empty()
and not _script_has_error_diagnostics(data) and not _script_has_error_diagnostics(data)
@@ -83,21 +113,27 @@ func create_script(params: Dictionary) -> Dictionary:
# BEFORE registering the file: a GUI editor loads the script inside # BEFORE registering the file: a GUI editor loads the script inside
# update_file() (see _refresh_loaded_gdscript for the ordering contract). # update_file() (see _refresh_loaded_gdscript for the ordering contract).
if existed_before: if existed_before:
_refresh_loaded_gdscript(data, path, content) if is_csharp:
_mark_csharp_not_reloaded(data)
else:
_refresh_loaded_gdscript(data, path, content)
_register_written_file(path) _register_written_file(path)
# `.gd.uid` is the sidecar Godot generates on scan; list both so the caller # `.gd.uid` is the sidecar Godot generates on scan; list both so the caller
# can rm the full set in one go. # can rm the full set in one go. A `.cs` only gets a sidecar on a .NET
McpResourceIO.attach_cleanup_hint(data, existed_before, [path, path + ".uid"]) # editor build, where the C# loader registers it as a resource.
McpResourceIO.attach_cleanup_hint(data, existed_before, _cleanup_paths(path, is_csharp))
# scan() is async — ResourceLoader.exists(path) returns false until Godot's # scan() is async — ResourceLoader.exists(path) returns false until Godot's
# filesystem pipeline finishes. If we reply now, an immediate attach_script # filesystem pipeline finishes. If we reply now, an immediate attach_script
# races and 404s (#261). Defer the response until the resource is visible # races and 404s (#261). Defer the response until the resource is visible
# (or a bounded timeout elapses). For freshly-created files we wait; on # (or a bounded timeout elapses). For freshly-created files we wait; on
# overwrite the resource was already known to ResourceLoader, so reply now. # overwrite the resource was already known to ResourceLoader, so reply now.
# A `.cs` on a non-.NET editor never becomes a resource at all — waiting
# would burn the whole settle window for nothing, so reply synchronously.
var request_id: String = params.get("_request_id", "") var request_id: String = params.get("_request_id", "")
if not existed_before and _connection != null and not request_id.is_empty(): if can_settle and not existed_before and _connection != null and not request_id.is_empty():
McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data) McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data)
return McpDispatcher.DEFERRED_RESPONSE return McpDispatcher.DEFERRED_RESPONSE
@@ -106,6 +142,51 @@ func create_script(params: Dictionary) -> Dictionary:
return {"data": data} return {"data": data}
## Which authoring contract a script path falls under: LANGUAGE_GDSCRIPT,
## LANGUAGE_CSHARP, or "" for anything the script tools refuse.
static func script_language(path: String) -> String:
if path.ends_with(".gd"):
return LANGUAGE_GDSCRIPT
if path.ends_with(".cs"):
return LANGUAGE_CSHARP
return ""
## True on a .NET-enabled editor build. `CSharpScript` is registered with
## ClassDB only when the mono module is compiled in, which is exactly when
## ResourceLoader can load a `.cs` (and when Godot writes its `.cs.uid`).
## A string lookup, so it parses on every supported engine.
static func editor_has_dotnet() -> bool:
return ClassDB.class_exists("CSharpScript")
## The `.cs` counterpart of `_attach_gdscript_diagnostics`: same response
## keys so a caller reads one shape, but `diagnostics_status` says the file
## was NOT validated. An empty `diagnostics` array on its own would read as
## "checked, clean", which is the trap #908 is about.
static func _attach_csharp_not_checked(data: Dictionary) -> void:
data["diagnostics"] = []
data["diagnostics_detail"] = "none"
data["diagnostics_scope"] = "this_file"
data["diagnostics_status"] = "not_checked"
data["validation_hint"] = CSHARP_VALIDATION_HINT
data["dotnet_editor"] = editor_has_dotnet()
## The `.cs` counterpart of `_refresh_loaded_gdscript`: C# never hot-reloads
## from source — the assembly has to be rebuilt — so "the bytes changed" is
## all a write can promise.
static func _mark_csharp_not_reloaded(data: Dictionary) -> void:
data["reloaded"] = false
data["reload_reason"] = "csharp_requires_build"
static func _cleanup_paths(path: String, is_csharp: bool) -> Array:
if is_csharp and not editor_has_dotnet():
return [path]
return [path, path + ".uid"]
## Extract the `class_name` a script declares, or "" if none. A cheap line scan ## Extract the `class_name` a script declares, or "" if none. A cheap line scan
## (no full parse) for create_script's "scan_required" hint. Stops at the first ## (no full parse) for create_script's "scan_required" hint. Stops at the first
## space/tab or comma so all three valid forms yield just the name: ## space/tab or comma so all three valid forms yield just the name:
@@ -340,8 +421,10 @@ func patch_script(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text") return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text")
if not "new_text" in params: if not "new_text" in params:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text") return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text")
if not path.ends_with(".gd"): var language := script_language(path)
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd (use filesystem_write_text for other text files)") if language.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, UNSUPPORTED_EXTENSION_MESSAGE)
var is_csharp := language == LANGUAGE_CSHARP
if old_text.is_empty(): if old_text.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty") return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty")
@@ -384,14 +467,18 @@ func patch_script(params: Dictionary) -> Dictionary:
"old_size": content.length(), "old_size": content.length(),
"undoable": false, "undoable": false,
"reason": "File system operations cannot be undone via editor undo", "reason": "File system operations cannot be undone via editor undo",
"language": language,
} }
_attach_gdscript_diagnostics(data, path, new_content) if is_csharp:
_attach_csharp_not_checked(data)
# The file is fresh but any already-loaded GDScript for it is not (#937); _mark_csharp_not_reloaded(data)
# make "patch succeeded" mean the loaded code changed, not just the bytes. else:
# Decide and refresh before registering the file with the editor — a GUI _attach_gdscript_diagnostics(data, path, new_content)
# editor loads the script inside update_file() (see _refresh_loaded_gdscript). # The file is fresh but any already-loaded GDScript for it is not (#937);
_refresh_loaded_gdscript(data, path, new_content) # make "patch succeeded" mean the loaded code changed, not just the bytes.
# Decide and refresh before registering the file with the editor — a GUI
# editor loads the script inside update_file() (see _refresh_loaded_gdscript).
_refresh_loaded_gdscript(data, path, new_content)
_register_written_file(path) _register_written_file(path)
return {"data": data} return {"data": data}
@@ -411,6 +498,17 @@ func attach_script(params: Dictionary) -> Dictionary:
if spath_err != null: if spath_err != null:
return spath_err return spath_err
# A `.cs` is a Script only where the mono module exists. Without it the
# generic "Script not found" below would send the caller hunting for a
# path problem that isn't one (#908).
var is_csharp := script_language(script_path) == LANGUAGE_CSHARP
if is_csharp and not editor_has_dotnet():
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"script_path: %s is a C# script but this editor build has no .NET support " % script_path
+ "(CSharpScript is unavailable). Run a .NET-enabled Godot build, or attach a .gd script.",
)
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"): if _resolved.has("error"):
return _resolved return _resolved
@@ -418,11 +516,17 @@ func attach_script(params: Dictionary) -> Dictionary:
var _scene_root: Node = _resolved.scene_root var _scene_root: Node = _resolved.scene_root
if not ResourceLoader.exists(script_path): if not ResourceLoader.exists(script_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Script not found: %s" % script_path) return ErrorCodes.make(
ErrorCodes.RESOURCE_NOT_FOUND,
"Script not found: %s" % script_path + _csharp_load_hint(is_csharp),
)
var script: Script = load(script_path) var script: Script = load(script_path)
if script == null: if script == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script: %s" % script_path) return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to load script: %s" % script_path + _csharp_load_hint(is_csharp),
)
var old_script: Script = node.get_script() var old_script: Script = node.get_script()
@@ -441,6 +545,19 @@ func attach_script(params: Dictionary) -> Dictionary:
} }
## Appended to attach_script's load failures for a `.cs`: on a .NET editor
## the usual reason is that the assembly hasn't been built since the file
## appeared, not that the path is wrong.
static func _csharp_load_hint(is_csharp: bool) -> String:
if not is_csharp:
return ""
return (
" (check the C# source path and build the project assembly using the "
+ "editor Build button or `dotnet build`, then filesystem_manage(op=\"scan\"); "
+ "resource recognition alone does not verify a compiled class)"
)
func detach_script(params: Dictionary) -> Dictionary: func detach_script(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "") var node_path: String = params.get("path", "")
@@ -478,6 +595,10 @@ func find_symbols(params: Dictionary) -> Dictionary:
if path_err != null: if path_err != null:
return path_err return path_err
var language := script_language(path)
if language.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot outline %s: path must end with .gd or .cs (use filesystem_manage op=\"read_text\" for other text files)" % path)
if not FileAccess.file_exists(path): if not FileAccess.file_exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path) return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
@@ -488,6 +609,9 @@ func find_symbols(params: Dictionary) -> Dictionary:
var content := file.get_as_text() var content := file.get_as_text()
file.close() file.close()
if language == LANGUAGE_CSHARP:
return {"data": _csharp_symbols(path, content)}
var functions: Array[Dictionary] = [] var functions: Array[Dictionary] = []
var signals_list: Array[String] = [] var signals_list: Array[String] = []
var exports: Array[Dictionary] = [] var exports: Array[Dictionary] = []
@@ -560,6 +684,7 @@ func find_symbols(params: Dictionary) -> Dictionary:
return { return {
"data": { "data": {
"path": path, "path": path,
"language": LANGUAGE_GDSCRIPT,
"class_name": class_name_str, "class_name": class_name_str,
"extends": extends_str, "extends": extends_str,
"functions": functions, "functions": functions,
@@ -570,3 +695,109 @@ func find_symbols(params: Dictionary) -> Dictionary:
"export_count": exports.size(), "export_count": exports.size(),
} }
} }
# ----- C# outline ------------------------------------------------------------
## Lines that start with one of these can still match the method pattern
## (`else if (`, `return Foo(`, `new Bar(`), so they're skipped by first word.
const _CSHARP_CONTROL_WORDS: PackedStringArray = [
"if", "else", "for", "foreach", "while", "do", "switch", "case", "catch",
"using", "return", "new", "throw", "await", "lock", "yield", "goto",
]
## Same outline shape as the GDScript branch, from a line-based scan (no
## Roslyn here). Mirrors Godot's C# conventions: the first `class` is the
## script class and its first base type is `extends`; `[Signal]` marks a
## delegate whose Godot signal name drops the `EventHandler` suffix;
## `[Export]` marks the next field or property. Attributes may sit on their
## own line or share the member's line, and may stack (`[Export] [Obsolete]`).
static func _csharp_symbols(path: String, content: String) -> Dictionary:
var functions: Array[Dictionary] = []
var signals_list: Array[String] = []
var exports: Array[Dictionary] = []
var class_name_str := ""
var extends_str := ""
var class_re := RegEx.create_from_string(
"^(?:(?:public|internal|private|protected|static|partial|abstract|sealed)\\s+)*"
+ "class\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s*<[^>]*>)?(?:\\s*:\\s*([A-Za-z_][A-Za-z0-9_.]*))?"
)
var method_re := RegEx.create_from_string(
"^(?:(?:public|private|protected|internal|static|override|virtual|abstract|async|sealed|new|partial|extern|unsafe)\\s+)*"
+ "[A-Za-z_][A-Za-z0-9_<>\\[\\],.?]*\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?:<[^>]*>)?\\s*\\("
)
var delegate_re := RegEx.create_from_string("delegate\\s+\\S+\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(")
var member_re := RegEx.create_from_string(
"^(?:(?:public|private|protected|internal|static|readonly|new|virtual|override|required)\\s+)*"
+ "[A-Za-z_][A-Za-z0-9_<>\\[\\],.?]*\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?:\\{|=|;|$)"
)
var pending_signal := false
var pending_export := false
var lines := content.split("\n")
for i in lines.size():
var line := lines[i].strip_edges()
if line.is_empty() or line.begins_with("//") or line.begins_with("#"):
continue
# Peel leading attributes; what's left (if anything) is the member.
var rest := line
while rest.begins_with("["):
var close := rest.find("]")
if close < 0:
break
var attr := rest.substr(1, close - 1).strip_edges()
if attr == "Signal" or attr.begins_with("Signal("):
pending_signal = true
if attr == "Export" or attr.begins_with("Export("):
pending_export = true
rest = rest.substr(close + 1).strip_edges()
if rest.is_empty():
continue
if class_name_str.is_empty():
var cm := class_re.search(rest)
if cm != null:
class_name_str = cm.get_string(1)
extends_str = cm.get_string(2)
continue
if pending_signal:
pending_signal = false
var dm := delegate_re.search(rest)
if dm != null:
var sig := dm.get_string(1)
if sig.ends_with("EventHandler"):
sig = sig.substr(0, sig.length() - "EventHandler".length())
signals_list.append(sig)
continue
if pending_export:
pending_export = false
var em := member_re.search(rest)
if em != null:
exports.append({"name": em.get_string(1), "line": i + 1})
continue
if rest.find("delegate ") >= 0:
continue
var first_word := rest.get_slice(" ", 0).get_slice("(", 0)
if first_word in _CSHARP_CONTROL_WORDS:
continue
var mm := method_re.search(rest)
if mm != null:
functions.append({"name": mm.get_string(1), "line": i + 1})
return {
"path": path,
"language": LANGUAGE_CSHARP,
"class_name": class_name_str,
"extends": extends_str,
"functions": functions,
"signals": signals_list,
"exports": exports,
"function_count": functions.size(),
"signal_count": signals_list.size(),
"export_count": exports.size(),
}
+542
View File
@@ -0,0 +1,542 @@
@tool
extends "res://addons/godot_ai/handlers/command_handler.gd"
## Raw shader authoring: create/read/validate/patch .gdshader and .gdshaderinc
## files. Every write parses the staged code through the engine's own shader
## compiler BEFORE the destination is touched, so invalid shader code never
## reaches disk and a failed validation preserves an existing file.
##
## Godot exposes no structured compile-error API to GDScript (the compiler's
## line-tagged output only reaches the editor Output panel), so validity is
## detected through a sentinel uniform: `Shader.get_shader_uniform_list()` is
## populated only when the whole translation unit parses, so a scratch copy
## with a generated uniform declaration appended reports parse success by
## containing that uniform. Failures return one synthesized diagnostic; the
## editor Output panel keeps the engine's line-tagged details.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd")
const SHADER_EXT := "gdshader"
const INCLUDE_EXT := "gdshaderinc"
## Bounded request size. A shader is hand-authored text; 256 KiB is far past
## any real shader and keeps a malformed request from parking the editor.
const MAX_CODE_BYTES := 262144
const SHADER_TYPES := ["spatial", "canvas_item", "particles", "sky", "fog"]
const MODE_NAMES := {
Shader.MODE_SPATIAL: "spatial",
Shader.MODE_CANVAS_ITEM: "canvas_item",
Shader.MODE_PARTICLES: "particles",
Shader.MODE_SKY: "sky",
Shader.MODE_FOG: "fog",
}
const PARSE_FAILURE_TEXT := (
"Shader failed the parse/type pass. The editor Output panel carries Godot's "
+ "line-tagged compiler details; check syntax, declared uniforms, and "
+ "stage-specific builtins for the shader type."
)
func create_shader(params: Dictionary) -> Dictionary:
var resource_path: String = params.get("resource_path", "")
var code: String = params.get("code", "")
var overwrite: bool = params.get("overwrite", false)
var shader_type: String = params.get("shader_type", "spatial")
var path_err = McpPathValidator.path_error(resource_path, "resource_path", true)
if path_err != null:
return path_err
var kind := _kind_for_path(resource_path)
if kind.is_empty():
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"resource_path must end in .gdshader or .gdshaderinc: %s" % resource_path
)
if not params.has("code") or code.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: code")
var size_err := _size_error(code)
if size_err != null:
return size_err
var type_err := _shader_type_error(shader_type)
if type_err != null:
return type_err
var directory := resource_path.get_base_dir()
if not DirAccess.dir_exists_absolute(directory):
return ErrorCodes.make(
ErrorCodes.RESOURCE_NOT_FOUND,
"Destination directory does not exist: %s" % directory
)
var existed_before := FileAccess.file_exists(resource_path)
if existed_before and not overwrite:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Shader already exists at %s (pass overwrite=true to replace)" % resource_path
)
var validation := _validate_code(code, kind, directory, shader_type)
if validation.has("error"):
return validation.error
if not validation.valid:
return _invalid_with_diagnostics(
"Shader parse failed; %s was not written" % resource_path, validation
)
var write_err := _write_atomic(resource_path, code, kind)
if write_err != null:
return write_err
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(resource_path)
_refresh_cached_resource(resource_path, code, kind)
var uniforms: Array = validation.uniforms
var data := {
"path": resource_path,
"kind": kind,
"shader_type": validation.shader_type,
"uniforms": uniforms,
"uniform_count": uniforms.size(),
"line_count": _line_count(code),
"size": code.length(),
"overwritten": existed_before,
"diagnostics": validation.diagnostics,
"undoable": false,
"reason": "File creation is persistent; delete the file manually to revert",
}
McpResourceIO.attach_cleanup_hint(data, existed_before, [resource_path, resource_path + ".uid"])
return {"data": data}
func get_shader(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var path_err = McpPathValidator.path_error(path, "path")
if path_err != null:
return path_err
if not FileAccess.file_exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Shader not found: %s" % path)
var kind := _kind_for_path(path)
if kind.is_empty():
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Not a .gdshader or .gdshaderinc file: %s" % path
)
var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
if kind == "include":
if not (loaded is ShaderInclude):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE, "Resource at %s is not a ShaderInclude" % path
)
var code: String = (loaded as ShaderInclude).get_code()
return {"data": {
"path": path,
"kind": kind,
"resource_class": "ShaderInclude",
"code": code,
"line_count": _line_count(code),
"size": code.length(),
"includes": _parse_includes(code),
}}
if not (loaded is Shader):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Shader" % path)
var shader := loaded as Shader
var shader_code: String = shader.get_code()
var uniforms := _serialize_uniforms(shader)
return {"data": {
"path": path,
"kind": kind,
"resource_class": "Shader",
"code": shader_code,
"line_count": _line_count(shader_code),
"size": shader_code.length(),
"shader_type": MODE_NAMES.get(shader.get_mode(), ""),
"uniforms": uniforms,
"uniform_count": uniforms.size(),
"render_modes": _parse_render_modes(shader_code),
"includes": _parse_includes(shader_code),
}}
func validate_shader(params: Dictionary) -> Dictionary:
var code: String = params.get("code", "")
var kind: String = params.get("kind", "shader")
var shader_type: String = params.get("shader_type", "spatial")
var base_dir: String = params.get("base_dir", "")
if not params.has("code") or code.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: code")
if kind != "shader" and kind != "include":
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE, "kind must be 'shader' or 'include'"
)
var size_err := _size_error(code)
if size_err != null:
return size_err
var type_err := _shader_type_error(shader_type)
if type_err != null:
return type_err
## Standalone validation defaults to user:// scratch space. Pass base_dir to
## validate relative #include resolution against the directory the shader
## will live in.
if not base_dir.is_empty():
var base_err = McpPathValidator.path_error(base_dir, "base_dir")
if base_err != null:
return base_err
if not DirAccess.dir_exists_absolute(base_dir):
return ErrorCodes.make(
ErrorCodes.RESOURCE_NOT_FOUND, "base_dir does not exist: %s" % base_dir
)
else:
base_dir = "user://"
var validation := _validate_code(code, kind, base_dir, shader_type)
if validation.has("error"):
return validation.error
return {"data": {
"valid": validation.valid,
"kind": kind,
"shader_type": validation.shader_type,
"uniforms": validation.uniforms,
"uniform_count": validation.uniforms.size(),
"diagnostics": validation.diagnostics,
"errors": validation.errors,
"warnings": validation.warnings,
"undoable": false,
"reason": "Validation parses a scratch copy; no project state changed",
}}
func patch_shader(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var old_text: String = params.get("old_text", "")
var new_text: String = params.get("new_text", "")
var replace_all: bool = params.get("replace_all", false)
var shader_type: String = params.get("shader_type", "spatial")
var path_err = McpPathValidator.path_error(path, "path", true)
if path_err != null:
return path_err
if not params.has("old_text"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text")
if not params.has("new_text"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text")
var kind := _kind_for_path(path)
if kind.is_empty():
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gdshader or .gdshaderinc: %s" % path
)
if old_text.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty")
var type_err := _shader_type_error(shader_type)
if type_err != null:
return type_err
var read := FileAccess.open(path, FileAccess.READ)
if read == null:
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found or unreadable: %s" % path)
var content := read.get_as_text()
read.close()
var match_count := content.count(old_text)
if match_count == 0:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "old_text not found in %s" % path)
if match_count > 1 and not replace_all:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"old_text matches %d times; pass replace_all=true or provide a more specific snippet"
% match_count,
)
var new_content: String
var replacements: int
if replace_all:
new_content = content.replace(old_text, new_text)
replacements = match_count
else:
var idx := content.find(old_text)
new_content = content.substr(0, idx) + new_text + content.substr(idx + old_text.length())
replacements = 1
var size_err := _size_error(new_content)
if size_err != null:
return size_err
var validation := _validate_code(new_content, kind, path.get_base_dir(), shader_type)
if validation.has("error"):
return validation.error
if not validation.valid:
return _invalid_with_diagnostics(
"Shader parse failed; %s was not modified" % path, validation
)
var write_err := _write_atomic(path, new_content, kind)
if write_err != null:
return write_err
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
_refresh_cached_resource(path, new_content, kind)
var uniforms: Array = validation.uniforms
return {"data": {
"path": path,
"kind": kind,
"replacements": replacements,
"size": new_content.length(),
"old_size": content.length(),
"line_count": _line_count(new_content),
"shader_type": validation.shader_type,
"uniforms": uniforms,
"uniform_count": uniforms.size(),
"diagnostics": validation.diagnostics,
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}}
## Parse `code` through the engine's shader parser and report validity,
## uniforms, and a synthesized diagnostic on failure. `directory` receives the
## scratch files (destination directory for writes, `user://` for standalone
## validation). The scratch `.gdshader` is written beside the destination and
## loaded with `ResourceLoader`, so relative `#include` paths resolve exactly
## as they will for the saved file. `.gdshaderinc` cannot compile alone, so it
## is wrapped in a minimal `shader_type <type>; #include "<scratch>"` shader in
## the same directory (include resolution is relative to the including file).
static func _validate_code(
code: String, kind: String, directory: String, shader_type: String
) -> Dictionary:
var token := "%d-%d" % [OS.get_process_id(), Time.get_ticks_usec()]
var sentinel := "_mcp_validate_%d" % Time.get_ticks_usec()
var ext := INCLUDE_EXT if kind == "include" else SHADER_EXT
var scratch_path := _scratch_path(directory, token, ext)
var staged := code if kind == "include" else code + "\nuniform float %s;\n" % sentinel
var write_err = _write_scratch(scratch_path, staged)
if write_err != null:
return {"error": write_err}
var scratch_paths: Array[String] = [scratch_path]
var capture_path := scratch_path
if kind == "include":
var wrapper_path := _scratch_path(directory, token, SHADER_EXT)
var wrapper := "shader_type %s;\n#include \"%s\"\nuniform float %s;\n" % [
shader_type, scratch_path.get_file(), sentinel
]
write_err = _write_scratch(wrapper_path, wrapper)
if write_err != null:
_remove_paths(scratch_paths)
return {"error": write_err}
scratch_paths.append(wrapper_path)
capture_path = wrapper_path
var shader := ResourceLoader.load(capture_path, "", ResourceLoader.CACHE_MODE_IGNORE) as Shader
_remove_paths(scratch_paths)
var valid := shader != null and _has_uniform(shader, sentinel)
var uniforms: Array[Dictionary] = []
var detected_type := shader_type
if shader != null:
for uniform in shader.get_shader_uniform_list():
if str(uniform.get("name", "")) == sentinel:
continue
uniforms.append(_serialize_uniform(uniform))
if kind == "shader":
detected_type = str(MODE_NAMES.get(shader.get_mode(), ""))
var diagnostics: Array[Dictionary] = []
var errors: Array[Dictionary] = []
var warnings: Array[Dictionary] = []
if not valid:
var diagnostic := {
"level": "error",
"text": PARSE_FAILURE_TEXT,
"path": "",
"line": 0,
"function": "",
}
diagnostics.append(diagnostic)
errors.append(diagnostic)
return {
"valid": valid,
"diagnostics": diagnostics,
"errors": errors,
"warnings": warnings,
"uniforms": uniforms,
"shader_type": detected_type,
}
## Write `code` beside `resource_path` and rename it into place, so a failed
## rename never truncates the destination. Validation happens before this call.
static func _write_atomic(resource_path: String, code: String, kind: String) -> Variant:
var ext := INCLUDE_EXT if kind == "include" else SHADER_EXT
var token := "%d-%d" % [OS.get_process_id(), Time.get_ticks_usec()]
var temp_path := _scratch_path(resource_path.get_base_dir(), token, ext)
var write_err = _write_scratch(temp_path, code)
if write_err != null:
return write_err
var rename_err := DirAccess.rename_absolute(temp_path, resource_path)
if rename_err != OK:
_remove_if_present(temp_path)
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Cannot write %s: %s" % [resource_path, error_string(rename_err)]
)
return null
## Refresh the resource object already cached for `resource_path` so held
## references (ShaderMaterials, shared Shaders, shader include dependencies)
## observe the write without waiting for an editor reload. `Shader.set_code()`
## re-tracks its includes, and `ShaderInclude.set_code()` emits `changed`, which
## recompiles every dependent shader through the engine's own dependency wiring.
## A path that was never loaded has no cached identity to refresh.
static func _refresh_cached_resource(resource_path: String, code: String, kind: String) -> void:
var cached := ResourceLoader.get_cached_ref(resource_path)
if cached == null:
return
if kind == "include":
if cached is ShaderInclude:
(cached as ShaderInclude).set_code(code)
return
if cached is Shader:
(cached as Shader).set_code(code)
static func _serialize_uniforms(shader: Shader) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for uniform in shader.get_shader_uniform_list():
out.append(_serialize_uniform(uniform))
return out
static func _serialize_uniform(uniform: Dictionary) -> Dictionary:
var entry := {
"name": str(uniform.get("name", "")),
"type": type_string(int(uniform.get("type", TYPE_NIL))),
"hint": int(uniform.get("hint", 0)),
"hint_string": str(uniform.get("hint_string", "")),
"usage": int(uniform.get("usage", 0)),
}
if uniform.has("default_value"):
entry["default_value"] = MaterialValues.serialize_value(uniform.get("default_value"))
return entry
static func _has_uniform(shader: Shader, name: String) -> bool:
for uniform in shader.get_shader_uniform_list():
if str(uniform.get("name", "")) == name:
return true
return false
static func _parse_includes(code: String) -> Array[String]:
var out: Array[String] = []
for raw_line in code.split("\n"):
var line := raw_line.strip_edges()
if not line.begins_with("#include"):
continue
var start := line.find("\"")
var quote := "\""
if start < 0:
start = line.find("'")
quote = "'"
if start < 0:
continue
var end := line.find(quote, start + 1)
if end > start:
out.append(line.substr(start + 1, end - start - 1))
return out
static func _parse_render_modes(code: String) -> Array[String]:
var out: Array[String] = []
for raw_line in code.split("\n"):
var line := raw_line.strip_edges()
if not line.begins_with("render_mode"):
continue
var rest := line.substr("render_mode".length()).strip_edges()
if rest.ends_with(";"):
rest = rest.substr(0, rest.length() - 1)
for mode in rest.split(",", false):
var trimmed := mode.strip_edges()
if not trimmed.is_empty():
out.append(trimmed)
return out
static func _scratch_path(directory: String, token: String, ext: String) -> String:
var separator := "" if directory.ends_with("/") else "/"
return "%s%s.godot-ai-shader-%s.%s" % [directory, separator, token, ext]
static func _kind_for_path(path: String) -> String:
match path.get_extension().to_lower():
SHADER_EXT:
return "shader"
INCLUDE_EXT:
return "include"
return ""
static func _size_error(code: String) -> Variant:
var byte_count := code.to_utf8_buffer().size()
if byte_count > MAX_CODE_BYTES:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"code exceeds the %d-byte limit (got %d)" % [MAX_CODE_BYTES, byte_count]
)
return null
static func _shader_type_error(shader_type: String) -> Variant:
if shader_type in SHADER_TYPES:
return null
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"shader_type must be one of: %s (got %s)" % [", ".join(SHADER_TYPES), shader_type]
)
static func _invalid_with_diagnostics(message: String, validation: Dictionary) -> Dictionary:
var err := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message)
err["error"]["data"] = {
"diagnostics": validation.get("diagnostics", []),
"errors": validation.get("errors", []),
"warnings": validation.get("warnings", []),
}
return err
## Scratch-file writer. Parent directories are always known to exist
## (destination dir checked by the caller; user:// is guaranteed), so this
## stays off McpResourceIO.write_text_to_disk, whose `get_base_dir()` mkdir
## path is unreliable for a bare `user://` root.
static func _write_scratch(path: String, content: String) -> Variant:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to open scratch file for writing: %s" % path
)
file.store_string(content)
file.flush()
var write_err := file.get_error()
file.close()
if write_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Scratch write failed for %s: %s" % [path, error_string(write_err)]
)
return null
static func _remove_paths(paths: Array[String]) -> void:
for path in paths:
_remove_if_present(path)
static func _remove_if_present(path: String) -> void:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
static func _line_count(code: String) -> int:
return code.count("\n") + (1 if not code.is_empty() else 0)
@@ -0,0 +1 @@
uid://ufuy74ofmra1
+690 -110
View File
@@ -13,6 +13,31 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const _COLOR_HINT := "expected hex #rrggbb, named color, or {r,g,b,a} dict" const _COLOR_HINT := "expected hex #rrggbb, named color, or {r,g,b,a} dict"
## Theme constants/font sizes and StyleBoxFlat int properties are 32-bit in the
## engine; GDScript ints are 64-bit, so an unvalidated 4294967296 narrows to 0
## (and 2147483648 to -2147483648) at the property assignment.
const _INT32_MIN := -2147483648
const _INT32_MAX := 2147483647
## Native storage kinds for numeric theme destinations, so validation models
## what the engine actually keeps:
## _NATIVE_INT32 C++ int32 (Theme constants/font sizes, shadow_size)
## _NATIVE_FLOAT32 C++ real_t (margins, shadow offsets, Color components)
## _NATIVE_INT32_FLOAT32 int accessors backed by real_t storage: StyleBoxFlat
## border widths and corner radii take ints but store
## real_t, so 16777217 rounds to 16777216 and
## 2147483647 reads back as -2147483648
const _NATIVE_INT32 := 0
const _NATIVE_FLOAT32 := 1
const _NATIVE_INT32_FLOAT32 := 2
## Top-level keys of the StyleBoxFlat patch vocabulary shared by
## set_stylebox_flat and stylebox_override. Nested dicts validate their own
## keys inside _apply_flat_props; this guards the outer dict so a typo
## (`bg_colour`) cannot install an unchanged override plus an undo entry while
## reporting success.
const _FLAT_PATCH_KEYS := ["bg_color", "border_color", "border", "corners", "margins", "shadow", "anti_aliasing"]
var _undo_redo: EditorUndoRedoManager var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection var _connection: McpConnection
@@ -98,7 +123,7 @@ func set_constant(params: Dictionary) -> Dictionary:
func(theme, name, cls, val): theme.set_constant(name, cls, int(val)), func(theme, name, cls, val): theme.set_constant(name, cls, int(val)),
func(theme, name, cls): theme.clear_constant(name, cls), func(theme, name, cls): theme.clear_constant(name, cls),
func(theme, name, cls): return theme.has_constant(name, cls), func(theme, name, cls): return theme.has_constant(name, cls),
func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null) func(v): return _parse_int32_value(v))
func set_font_size(params: Dictionary) -> Dictionary: func set_font_size(params: Dictionary) -> Dictionary:
@@ -106,12 +131,31 @@ func set_font_size(params: Dictionary) -> Dictionary:
func(theme, name, cls, val): theme.set_font_size(name, cls, int(val)), func(theme, name, cls, val): theme.set_font_size(name, cls, int(val)),
func(theme, name, cls): theme.clear_font_size(name, cls), func(theme, name, cls): theme.clear_font_size(name, cls),
func(theme, name, cls): return theme.has_font_size(name, cls), func(theme, name, cls): return theme.has_font_size(name, cls),
func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null) func(v): return _parse_int32_value(v))
## Theme constant/font_size slots are 32-bit ints. Accepts int/float and
## valid-int strings, but rejects non-finite or out-of-int32 values before the
## engine narrowing can wrap them (4294967296 -> 0, 2147483648 -> -2147483648).
## In-range fractional values keep the existing truncation policy.
static func _parse_int32_value(v: Variant) -> Variant:
var number: float
if v is int or v is float:
number = float(v)
elif v is String and v.is_valid_int():
number = float(v.to_int())
else:
return null
if not is_finite(number) or number < float(_INT32_MIN) or number > float(_INT32_MAX):
return null
return int(number)
# Shared implementation for scalar Theme slots (color, constant, font_size). # Shared implementation for scalar Theme slots (color, constant, font_size).
# Captures old value, applies new value, saves to disk, registers undo that # Captures old value, applies new value, persists it as a pre-flight save, then
# restores the old value and saves again. # registers undo that restores the old value and saves again. A failed
# pre-flight save restores the old value and reports an error instead of
# leaving memory and disk out of sync.
func _set_scalar( func _set_scalar(
params: Dictionary, params: Dictionary,
kind: String, kind: String,
@@ -147,20 +191,34 @@ func _set_scalar(
var parsed = parser.call(raw_value) var parsed = parser.call(raw_value)
if parsed == null: if parsed == null:
## color slots want a color hint; constant/font_size are integer slots. ## color slots want a color hint; constant/font_size are integer slots.
var hint := _COLOR_HINT if kind == "color" else "expected an integer" var hint := _COLOR_HINT if kind == "color" else "expected a 32-bit integer"
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid %s value: %s (%s)" % [kind, raw_value, hint]) "Invalid %s value: %s (%s)" % [kind, raw_value, hint])
var had_before: bool = has_fn.call(theme, name, class_name_param) var had_before: bool = has_fn.call(theme, name, class_name_param)
var before_value = getter.call(theme, name, class_name_param) if had_before else null var before_value = getter.call(theme, name, class_name_param) if had_before else null
## Pre-flight: apply and persist before the undo action exists, so an
## unwritable theme file (read-only, permissions) fails here with the
## cached resource restored instead of reporting success while the file
## never changed.
var save_err: int = _apply_scalar(theme_path, setter, name, class_name_param, parsed)
if save_err != OK:
if had_before:
_apply_scalar(theme_path, setter, name, class_name_param, before_value)
else:
_clear_scalar(theme_path, clearer, name, class_name_param)
return _save_failure(theme_path, save_err)
_undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name]) _undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name])
_undo_redo.add_do_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, parsed) _undo_redo.add_do_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, parsed)
if had_before: if had_before:
_undo_redo.add_undo_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, before_value) _undo_redo.add_undo_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, before_value)
else: else:
_undo_redo.add_undo_method(self, "_clear_scalar", theme_path, clearer, name, class_name_param) _undo_redo.add_undo_method(self, "_clear_scalar", theme_path, clearer, name, class_name_param)
_undo_redo.commit_action() ## The do method already ran as the pre-flight save; register without
## re-executing so a second save's error cannot be discarded.
_undo_redo.commit_action(false)
return { return {
"data": { "data": {
@@ -175,22 +233,33 @@ func _set_scalar(
} }
func _apply_scalar(theme_path: String, setter: Callable, name: String, class_name_param: String, value: Variant) -> void: func _apply_scalar(theme_path: String, setter: Callable, name: String, class_name_param: String, value: Variant) -> int:
var theme: Theme = ResourceLoader.load(theme_path) var theme: Theme = ResourceLoader.load(theme_path)
if theme == null: if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path) push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return return ERR_FILE_CANT_OPEN
setter.call(theme, name, class_name_param, value) setter.call(theme, name, class_name_param, value)
McpResourceIO.guarded_save(theme, theme_path, _connection) return McpResourceIO.guarded_save(theme, theme_path, _connection)
func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> void: func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> int:
var theme: Theme = ResourceLoader.load(theme_path) var theme: Theme = ResourceLoader.load(theme_path)
if theme == null: if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path) push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return return ERR_FILE_CANT_OPEN
clearer.call(theme, name, class_name_param) clearer.call(theme, name, class_name_param)
McpResourceIO.guarded_save(theme, theme_path, _connection) return McpResourceIO.guarded_save(theme, theme_path, _connection)
## Error dict for a failed pre-flight save. The caller has already restored the
## previous slot in memory, so the theme file and the cached resource both hold
## the pre-request state and no undo action was committed.
static func _save_failure(theme_path: String, save_err: int) -> Dictionary:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to save theme to %s: %s (error %d); the change was rolled back"
% [theme_path, error_string(save_err), save_err]
)
# ============================================================================ # ============================================================================
@@ -209,7 +278,14 @@ func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_na
## anti_aliasing (bool) ## anti_aliasing (bool)
## ##
## Unknown keys inside any nested dict are rejected with INVALID_PARAMS so ## Unknown keys inside any nested dict are rejected with INVALID_PARAMS so
## typos fail loudly instead of silently being ignored. ## typos fail loudly instead of silently being ignored. Numeric values must be
## finite and within the native storage's range (32-bit ints, 32-bit floats,
## and the float32-backed ints behind border widths/corner radii) and flags
## real booleans; invalid input is refused with a structured error before
## anything is applied, so a refused call leaves the theme slot and undo
## history untouched. A slot change is persisted before the undo action is
## registered: if the theme file cannot be written, the previous slot is
## restored and the call fails with INTERNAL_ERROR.
func set_stylebox_flat(params: Dictionary) -> Dictionary: func set_stylebox_flat(params: Dictionary) -> Dictionary:
var load_result := _load_theme_from_params(params) var load_result := _load_theme_from_params(params)
if load_result.has("error"): if load_result.has("error"):
@@ -226,81 +302,23 @@ func set_stylebox_flat(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
var sb := StyleBoxFlat.new() var sb := StyleBoxFlat.new()
if params.has("bg_color"): var applied := _apply_flat_props(sb, params)
var bg := _parse_color(params.bg_color) if applied.has("error"):
if bg == null: return applied
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid bg_color: %s (%s)" % [str(params.bg_color), _COLOR_HINT])
sb.bg_color = bg
if params.has("border_color"):
var bc := _parse_color(params.border_color)
if bc == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid border_color: %s (%s)" % [str(params.border_color), _COLOR_HINT])
sb.border_color = bc
# border: {all, top, bottom, left, right} — int widths
if params.has("border"):
var err := _apply_sides(sb, params.border, "border",
["top", "bottom", "left", "right"],
"border_width_",
TYPE_INT)
if err != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err)
# corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii
if params.has("corners"):
var err2 := _apply_sides(sb, params.corners, "corners",
["top_left", "top_right", "bottom_left", "bottom_right"],
"corner_radius_",
TYPE_INT)
if err2 != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err2)
# margins: {all, top, bottom, left, right} — float padding
if params.has("margins"):
var err3 := _apply_sides(sb, params.margins, "margins",
["top", "bottom", "left", "right"],
"content_margin_",
TYPE_FLOAT)
if err3 != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err3)
# shadow: {color, size, offset_x, offset_y}
if params.has("shadow"):
if typeof(params.shadow) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'shadow' must be a dict with color/size/offset_x/offset_y")
var shadow: Dictionary = params.shadow
var allowed_shadow_keys := {"color": true, "size": true, "offset_x": true, "offset_y": true}
for k in shadow.keys():
if not allowed_shadow_keys.has(k):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Unknown key in 'shadow': %s (valid: color, size, offset_x, offset_y)" % k)
if shadow.has("color"):
var sc := _parse_color(shadow.color)
if sc == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Invalid shadow.color: %s (%s)" % [str(shadow.color), _COLOR_HINT])
sb.shadow_color = sc
if shadow.has("size"):
sb.shadow_size = int(shadow.size)
if shadow.has("offset_x") or shadow.has("offset_y"):
sb.shadow_offset = Vector2(
float(shadow.get("offset_x", 0)),
float(shadow.get("offset_y", 0)),
)
if params.has("anti_aliasing"):
sb.anti_aliasing = bool(params.anti_aliasing)
var had_before := theme.has_stylebox(name, class_name_param) var had_before := theme.has_stylebox(name, class_name_param)
var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null
## Pre-flight: apply and persist before the undo action exists, so an
_undo_redo.create_action("MCP: Theme set stylebox %s/%s" % [class_name_param, name]) ## unwritable theme file fails here with the cached resource restored
_undo_redo.add_do_method(self, "_apply_stylebox", theme_path, name, class_name_param, sb) ## instead of reporting success while the file never changed.
if had_before: var save_err: int = _apply_stylebox(theme_path, name, class_name_param, sb)
_undo_redo.add_undo_method(self, "_apply_stylebox", theme_path, name, class_name_param, before_sb) if save_err != OK:
else: if had_before:
_undo_redo.add_undo_method(self, "_clear_stylebox", theme_path, name, class_name_param) _apply_stylebox(theme_path, name, class_name_param, before_sb)
_undo_redo.commit_action() else:
_clear_stylebox(theme_path, name, class_name_param)
return _save_failure(theme_path, save_err)
_commit_stylebox(theme_path, name, class_name_param, sb, before_sb, had_before)
return { return {
"data": { "data": {
@@ -332,50 +350,514 @@ func set_stylebox_flat(params: Dictionary) -> Dictionary:
} }
## Parse a {all, <side1>, <side2>, ...} dict and apply it to StyleBoxFlat via ## Apply the StyleBoxFlat property vocabulary shared by `set_stylebox_flat`
## its set_<prop_prefix><side> properties. Returns "" on success, an error ## and `stylebox_override` to an existing StyleBoxFlat. Returns `{ok: true}`
## message on failure. Validates that only known keys are present. ## or the error dict to surface.
func _apply_sides(sb: StyleBoxFlat, sides_dict: Variant, dict_name: String, func _apply_flat_props(sb: StyleBoxFlat, params: Dictionary) -> Dictionary:
side_names: Array, prop_prefix: String, value_type: int) -> String: if params.has("bg_color"):
var bg := _parse_color(params.bg_color)
if bg == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid bg_color: %s (%s)" % [str(params.bg_color), _COLOR_HINT])
sb.bg_color = bg
if params.has("border_color"):
var bc := _parse_color(params.border_color)
if bc == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid border_color: %s (%s)" % [str(params.border_color), _COLOR_HINT])
sb.border_color = bc
# border: {all, top, bottom, left, right} — int widths (real_t storage)
if params.has("border"):
var border_result := _apply_sides(sb, params.border, "border",
["top", "bottom", "left", "right"],
"border_width_",
_NATIVE_INT32_FLOAT32)
if border_result.has("error"):
return border_result
# corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii (real_t storage)
if params.has("corners"):
var corners_result := _apply_sides(sb, params.corners, "corners",
["top_left", "top_right", "bottom_left", "bottom_right"],
"corner_radius_",
_NATIVE_INT32_FLOAT32)
if corners_result.has("error"):
return corners_result
# margins: {all, top, bottom, left, right} — float padding
if params.has("margins"):
var margins_result := _apply_sides(sb, params.margins, "margins",
["top", "bottom", "left", "right"],
"content_margin_",
_NATIVE_FLOAT32)
if margins_result.has("error"):
return margins_result
# shadow: {color, size, offset_x, offset_y}
if params.has("shadow"):
if typeof(params.shadow) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'shadow' must be a dict with color/size/offset_x/offset_y")
var shadow: Dictionary = params.shadow
var allowed_shadow_keys := {"color": true, "size": true, "offset_x": true, "offset_y": true}
for k in shadow.keys():
if not allowed_shadow_keys.has(k):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Unknown key in 'shadow': %s (valid: color, size, offset_x, offset_y)" % k)
if shadow.has("color"):
var sc := _parse_color(shadow.color)
if sc == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Invalid shadow.color: %s (%s)" % [str(shadow.color), _COLOR_HINT])
sb.shadow_color = sc
if shadow.has("size"):
var size_result := _parse_number_field("shadow", "size", shadow.size, _NATIVE_INT32)
if size_result.has("error"):
return size_result
sb.shadow_size = size_result.value
if shadow.has("offset_x") or shadow.has("offset_y"):
## Missing components keep the stylebox's current offset: on the
## stylebox_override path `sb` is a duplicate of the resolved
## stylebox, and a patch must not silently reset the other axis.
var offset_x := sb.shadow_offset.x
var offset_y := sb.shadow_offset.y
if shadow.has("offset_x"):
var offset_x_result := _parse_number_field("shadow", "offset_x", shadow.offset_x, _NATIVE_FLOAT32)
if offset_x_result.has("error"):
return offset_x_result
offset_x = offset_x_result.value
if shadow.has("offset_y"):
var offset_y_result := _parse_number_field("shadow", "offset_y", shadow.offset_y, _NATIVE_FLOAT32)
if offset_y_result.has("error"):
return offset_y_result
offset_y = offset_y_result.value
sb.shadow_offset = Vector2(offset_x, offset_y)
if params.has("anti_aliasing"):
var anti_aliasing_result := _parse_bool_field("anti_aliasing", params.anti_aliasing)
if anti_aliasing_result.has("error"):
return anti_aliasing_result
sb.anti_aliasing = anti_aliasing_result.value
return {"ok": true}
## Top-level keys in `patch` that are not part of the StyleBoxFlat vocabulary.
static func _unknown_flat_keys(patch: Dictionary) -> Array:
var unknown: Array = []
for key in patch.keys():
if not _FLAT_PATCH_KEYS.has(key):
unknown.append(str(key))
return unknown
## Record one stylebox set as an undoable action (apply new, restore or clear
## the previous slot). Shared by set_stylebox_flat and set_stylebox_texture.
## The caller has already applied and persisted the new stylebox as its
## pre-flight save, so the action is committed without re-executing the do
## method.
func _commit_stylebox(
theme_path: String, name: String, class_name_param: String,
sb: StyleBox, before_sb: StyleBox, had_before: bool
) -> void:
_undo_redo.create_action("MCP: Theme set stylebox %s/%s" % [class_name_param, name])
_undo_redo.add_do_method(self, "_apply_stylebox", theme_path, name, class_name_param, sb)
if had_before:
_undo_redo.add_undo_method(self, "_apply_stylebox", theme_path, name, class_name_param, before_sb)
else:
_undo_redo.add_undo_method(self, "_clear_stylebox", theme_path, name, class_name_param)
_undo_redo.commit_action(false)
# ============================================================================
# theme_set_stylebox_texture
# ============================================================================
## Compose a StyleBoxTexture (9-slice) and assign it to a theme slot.
##
## Parameters (beyond theme_path / class_name / name):
## texture_path res:// path to a Texture2D
## region {position: {x,y}, size: {x,y}} or [x,y,w,h]
## margins {all|left|top|right|bottom: float} texture margins
## axis_stretch_horizontal "stretch" | "tile" | "tile_fit"
## axis_stretch_vertical "stretch" | "tile" | "tile_fit"
## modulate_color Color
## draw_center bool
##
## Numeric values must be finite and within the native storage's range, and
## flags real booleans; invalid input is refused with a structured error before
## anything is applied. A slot change is persisted before the undo action is
## registered: if the theme file cannot be written, the previous slot is
## restored and the call fails with INTERNAL_ERROR.
func set_stylebox_texture(params: Dictionary) -> Dictionary:
var load_result := _load_theme_from_params(params)
if load_result.has("error"):
return load_result
var theme: Theme = load_result.theme
var theme_path: String = load_result.path
var class_name_param: String = params.get("class_name", "")
if class_name_param.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
var name: String = params.get("name", "")
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
var texture_path: String = params.get("texture_path", "")
if texture_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: texture_path")
var path_err = McpPathValidator.loadable_error(texture_path, "texture_path")
if path_err != null:
return path_err
if not ResourceLoader.exists(texture_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path)
var texture := ResourceLoader.load(texture_path)
if texture == null or not (texture is Texture2D):
var got := texture.get_class() if texture != null else "null"
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Resource at %s is not a Texture2D (got %s)" % [texture_path, got])
var sb := StyleBoxTexture.new()
sb.texture = texture
if params.has("region"):
var region := _parse_rect2(params.region)
if region == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid region: %s (expected {position, size} or [x,y,w,h])" % str(params.region))
sb.region_rect = region
if params.has("margins"):
var margin_result := _apply_texture_margins(sb, params.margins)
if margin_result.has("error"):
return margin_result
if params.has("modulate_color"):
var mc := _parse_color(params.modulate_color)
if mc == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid modulate_color: %s (%s)" % [str(params.modulate_color), _COLOR_HINT])
sb.modulate_color = mc
if params.has("draw_center"):
var draw_center_result := _parse_bool_field("draw_center", params.draw_center)
if draw_center_result.has("error"):
return draw_center_result
sb.draw_center = draw_center_result.value
if params.has("axis_stretch_horizontal"):
var ash := _parse_axis_stretch(str(params.axis_stretch_horizontal))
if ash == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid axis_stretch_horizontal '%s'. Valid: stretch, tile, tile_fit" % str(params.axis_stretch_horizontal))
sb.axis_stretch_horizontal = ash
if params.has("axis_stretch_vertical"):
var asv := _parse_axis_stretch(str(params.axis_stretch_vertical))
if asv == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid axis_stretch_vertical '%s'. Valid: stretch, tile, tile_fit" % str(params.axis_stretch_vertical))
sb.axis_stretch_vertical = asv
var had_before := theme.has_stylebox(name, class_name_param)
var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null
## Pre-flight: apply and persist before the undo action exists, so an
## unwritable theme file fails here with the cached resource restored
## instead of reporting success while the file never changed.
var save_err: int = _apply_stylebox(theme_path, name, class_name_param, sb)
if save_err != OK:
if had_before:
_apply_stylebox(theme_path, name, class_name_param, before_sb)
else:
_clear_stylebox(theme_path, name, class_name_param)
return _save_failure(theme_path, save_err)
_commit_stylebox(theme_path, name, class_name_param, sb, before_sb, had_before)
return {
"data": {
"path": theme_path,
"class_name": class_name_param,
"name": name,
"stylebox_class": "StyleBoxTexture",
"texture_path": texture_path,
"region": _serialize_value(sb.region_rect),
"margins": {
"left": sb.texture_margin_left,
"top": sb.texture_margin_top,
"right": sb.texture_margin_right,
"bottom": sb.texture_margin_bottom,
},
"draw_center": sb.draw_center,
"undoable": true,
}
}
# ============================================================================
# theme_set_font / theme_set_icon
# ============================================================================
## Assign a Font resource to a theme font slot (button/body/heading fonts).
func set_font(params: Dictionary) -> Dictionary:
return _set_resource_slot(params, "font", "font_path", "Font")
## Assign a Texture2D to a theme icon slot (checkbox marks, dropdown arrows).
func set_icon(params: Dictionary) -> Dictionary:
return _set_resource_slot(params, "icon", "texture_path", "Texture2D")
## Shared implementation for font/icon slots: load the resource, verify its
## class, then set/clear the slot as one undoable action.
func _set_resource_slot(
params: Dictionary, kind: String, path_param: String, expected_class: String
) -> Dictionary:
var load_result := _load_theme_from_params(params)
if load_result.has("error"):
return load_result
var theme: Theme = load_result.theme
var theme_path: String = load_result.path
var class_name_param: String = params.get("class_name", "")
if class_name_param.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
var name: String = params.get("name", "")
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
var resource_path: String = params.get(path_param, "")
if resource_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % path_param)
var path_err = McpPathValidator.loadable_error(resource_path, path_param)
if path_err != null:
return path_err
if not ResourceLoader.exists(resource_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "%s not found: %s" % [expected_class, resource_path])
var loaded := ResourceLoader.load(resource_path)
if loaded == null or not _is_instance_of_class(loaded, expected_class):
var got := loaded.get_class() if loaded != null else "null"
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Resource at %s is not a %s (got %s)" % [resource_path, expected_class, got])
var had_before := theme.has_font(name, class_name_param) if kind == "font" else theme.has_icon(name, class_name_param)
var before_value = theme.get_font(name, class_name_param) if kind == "font" else theme.get_icon(name, class_name_param)
if not had_before:
before_value = null
## Pre-flight: apply and persist before the undo action exists, so an
## unwritable theme file fails here with the cached resource restored
## instead of reporting success while the file never changed.
var save_err: int = _apply_slot(theme_path, kind, name, class_name_param, loaded)
if save_err != OK:
if had_before:
_apply_slot(theme_path, kind, name, class_name_param, before_value)
else:
_clear_slot(theme_path, kind, name, class_name_param)
return _save_failure(theme_path, save_err)
_undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name])
_undo_redo.add_do_method(self, "_apply_slot", theme_path, kind, name, class_name_param, loaded)
if had_before:
_undo_redo.add_undo_method(self, "_apply_slot", theme_path, kind, name, class_name_param, before_value)
else:
_undo_redo.add_undo_method(self, "_clear_slot", theme_path, kind, name, class_name_param)
## The do method already ran as the pre-flight save; register without
## re-executing so a second save's error cannot be discarded.
_undo_redo.commit_action(false)
return {
"data": {
"path": theme_path,
"kind": kind,
"class_name": class_name_param,
"name": name,
"resource_path": resource_path,
"resource_class": loaded.get_class(),
"undoable": true,
}
}
## True when `resource` is an instance of `class_name` (or a subclass).
static func _is_instance_of_class(resource: Resource, expected_class: String) -> bool:
return ClassDB.is_parent_class(resource.get_class(), expected_class)
# ============================================================================
# theme_stylebox_override — per-node override
# ============================================================================
## Duplicate the stylebox a Control resolves for `slot`, apply a StyleBoxFlat
## patch (same keys as set_stylebox_flat), and attach it as a per-node
## override. Undo restores the previous override, or removes the override when
## the node had none. Patch values are validated the same way as
## set_stylebox_flat — unknown top-level keys are refused — and a refused
## patch leaves the node's override untouched. The action is committed to the
## Control's scene history, so the editor's scene undo reverts it (a method
## bound to this handler object would land in GLOBAL_HISTORY instead).
func stylebox_override(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var slot: String = params.get("slot", "")
if slot.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: slot")
var patch: Variant = params.get("patch", {})
if typeof(patch) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "'patch' must be a dict of StyleBoxFlat properties")
var unknown_keys := _unknown_flat_keys(patch)
if not unknown_keys.is_empty():
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Unknown patch key(s): %s (valid: %s)" % [", ".join(unknown_keys), ", ".join(_FLAT_PATCH_KEYS)])
var resolved := McpNodeValidator.resolve_or_error(node_path, "path")
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is Control:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node %s is not a Control (got %s)" % [node_path, node.get_class()])
var control := node as Control
var base: StyleBox = control.get_theme_stylebox(slot)
if base == null:
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
"No stylebox resolves for slot '%s' on %s" % [slot, node.get_class()])
var patched: StyleBox = base.duplicate()
if not patched is StyleBoxFlat:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Slot '%s' resolves to %s; stylebox_override patches StyleBoxFlat slots only"
% [slot, patched.get_class()])
var applied := _apply_flat_props(patched as StyleBoxFlat, patch)
if applied.has("error"):
return applied
var had_override: bool = control.has_theme_stylebox_override(slot)
var scene_root: Node = resolved.scene_root
_undo_redo.create_action("MCP: Stylebox override %s on %s" % [slot, node.name],
UndoRedo.MERGE_DISABLE, scene_root)
_undo_redo.add_do_method(self, "_apply_node_stylebox", control, slot, patched)
if had_override:
_undo_redo.add_undo_method(self, "_apply_node_stylebox", control, slot, base)
else:
_undo_redo.add_undo_method(self, "_remove_node_stylebox", control, slot)
_undo_redo.commit_action()
return {
"data": {
"path": McpScenePath.from_node(node, resolved.scene_root),
"slot": slot,
"stylebox_class": patched.get_class(),
"overrode_existing": had_override,
"undoable": true,
}
}
func _apply_node_stylebox(control: Control, slot: String, sb: StyleBox) -> void:
control.add_theme_stylebox_override(slot, sb)
func _remove_node_stylebox(control: Control, slot: String) -> void:
control.remove_theme_stylebox_override(slot)
## Strict finite-number parser for stylebox numeric fields. Accepts int/float
## and numeric strings (the McpJsonValues.parse_float vocabulary) and returns
## `{"value": <int|float>}`; non-numeric input and non-finite results return a
## structured error instead, so a bad value can neither raise an engine
## conversion error nor be silently stored as 0.
##
## The value is validated against the *native destination* before narrowing
## (see the _NATIVE_* kinds), and the returned value is the narrowed one, so
## what was validated is what gets stored.
static func _parse_number_field(dict_name: String, key: String, raw: Variant, storage: int) -> Dictionary:
var parsed: Variant = McpJsonValues.parse_float(raw)
if parsed == null:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"'%s.%s' must be a number, got %s" % [dict_name, key, type_string(typeof(raw))])
var number := float(parsed)
if not is_finite(number):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"'%s.%s' must be finite, got %s" % [dict_name, key, str(number)])
match storage:
_NATIVE_INT32:
if number < float(_INT32_MIN) or number > float(_INT32_MAX):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"'%s.%s' is out of range for a 32-bit integer (got %s)" % [dict_name, key, str(number)])
return {"value": int(number)}
_NATIVE_FLOAT32:
var narrowed: float = PackedFloat32Array([number])[0]
if not is_finite(narrowed):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"'%s.%s' is out of range for a 32-bit float (got %s)" % [dict_name, key, str(number)])
return {"value": narrowed}
_NATIVE_INT32_FLOAT32:
## Must survive the float32 round-trip and stay in int32, or the
## int getter reads back a rounded or wrapped value.
var narrowed: float = PackedFloat32Array([number])[0]
if not is_finite(narrowed) or narrowed < float(_INT32_MIN) or narrowed > float(_INT32_MAX) \
or int(narrowed) != int(number):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"'%s.%s' is out of range for the float-backed 32-bit int storage (got %s)" % [dict_name, key, str(number)])
return {"value": int(narrowed)}
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unknown numeric storage kind: %d" % storage)
## Strict bool parser for stylebox flags. `bool("false")` is true in GDScript,
## so a stringified flag would silently invert the caller's intent.
static func _parse_bool_field(name: String, raw: Variant) -> Dictionary:
if raw is bool:
return {"value": raw}
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"'%s' must be a boolean, got %s" % [name, type_string(typeof(raw))])
## Parse a {all, <side1>, <side2>, ...} dict into StyleBox numeric properties.
## Every value is parsed and validated before anything is assigned, so an
## invalid value can neither raise an engine conversion error nor leave a
## partially applied stylebox. `sb` is a StyleBoxFlat or StyleBoxTexture (both
## expose `set`); `storage` is one of the _NATIVE_* kinds. Returns
## {"ok": true} or the error dict to surface.
static func _apply_sides(sb: Object, sides_dict: Variant, dict_name: String,
side_names: Array, prop_prefix: String, storage: int) -> Dictionary:
if typeof(sides_dict) != TYPE_DICTIONARY: if typeof(sides_dict) != TYPE_DICTIONARY:
return "'%s' must be a dict with 'all' and/or side-specific keys" % dict_name return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"'%s' must be a dict with 'all' and/or side-specific keys" % dict_name)
var valid_keys := {"all": true} var valid_keys := {"all": true}
for s in side_names: for s in side_names:
valid_keys[s] = true valid_keys[s] = true
for k in sides_dict.keys(): for k in sides_dict.keys():
if not valid_keys.has(k): if not valid_keys.has(k):
return "Unknown key in '%s': %s (valid: all, %s)" % [ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
dict_name, k, ", ".join(side_names) "Unknown key in '%s': %s (valid: all, %s)" % [dict_name, k, ", ".join(side_names)])
] var parsed_values := {}
# Apply `all` first, then override with side-specific keys.
if sides_dict.has("all"): if sides_dict.has("all"):
var all_val: Variant = sides_dict.all var all_result := _parse_number_field(dict_name, "all", sides_dict.all, storage)
for s in side_names: if all_result.has("error"):
var v: Variant = int(all_val) if value_type == TYPE_INT else float(all_val) return all_result
sb.set(prop_prefix + s, v) parsed_values["all"] = all_result.value
for s in side_names: for s in side_names:
if sides_dict.has(s): if sides_dict.has(s):
var v2: Variant = int(sides_dict[s]) if value_type == TYPE_INT else float(sides_dict[s]) var side_result := _parse_number_field(dict_name, s, sides_dict[s], storage)
sb.set(prop_prefix + s, v2) if side_result.has("error"):
return "" return side_result
parsed_values[s] = side_result.value
# Every value parsed: apply `all` first, then the side-specific overrides.
if parsed_values.has("all"):
for s in side_names:
sb.set(prop_prefix + s, parsed_values["all"])
for s in side_names:
if parsed_values.has(s):
sb.set(prop_prefix + s, parsed_values[s])
return {"ok": true}
func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> void: func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> int:
var theme: Theme = ResourceLoader.load(theme_path) var theme: Theme = ResourceLoader.load(theme_path)
if theme == null: if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path) push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return return ERR_FILE_CANT_OPEN
theme.set_stylebox(name, class_name_param, sb) theme.set_stylebox(name, class_name_param, sb)
McpResourceIO.guarded_save(theme, theme_path, _connection) return McpResourceIO.guarded_save(theme, theme_path, _connection)
func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> void: func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> int:
var theme: Theme = ResourceLoader.load(theme_path) var theme: Theme = ResourceLoader.load(theme_path)
if theme == null: if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path) push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return return ERR_FILE_CANT_OPEN
theme.clear_stylebox(name, class_name_param) theme.clear_stylebox(name, class_name_param)
McpResourceIO.guarded_save(theme, theme_path, _connection) return McpResourceIO.guarded_save(theme, theme_path, _connection)
# ============================================================================ # ============================================================================
@@ -461,9 +943,17 @@ static func _validate_res_path(path: String, required_suffix: String, param_name
## Returns null if the input cannot be parsed. ## Returns null if the input cannot be parsed.
## Delegates to the canonical parser (#714) — gains [r,g,b(,a)] array ## Delegates to the canonical parser (#714) — gains [r,g,b(,a)] array
## support and strict key/component checking, same shapes as every other ## support and strict key/component checking, same shapes as every other
## color-accepting handler. ## color-accepting handler. Color components are 32-bit floats, so a finite
## double like 1e40 narrows to INF at construction; the parsed value is
## re-checked for finiteness and refused instead of storing an infinite color.
static func _parse_color(value: Variant) -> Variant: static func _parse_color(value: Variant) -> Variant:
return McpJsonValues.parse_color(value) var parsed: Variant = McpJsonValues.parse_color(value)
if parsed == null:
return null
var color := parsed as Color
if not (is_finite(color.r) and is_finite(color.g) and is_finite(color.b) and is_finite(color.a)):
return null
return color
static func _serialize_value(value: Variant) -> Variant: static func _serialize_value(value: Variant) -> Variant:
@@ -473,4 +963,94 @@ static func _serialize_value(value: Variant) -> Variant:
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a} return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
if value is Vector2: if value is Vector2:
return {"x": value.x, "y": value.y} return {"x": value.x, "y": value.y}
if value is Rect2:
return {
"position": {"x": value.position.x, "y": value.position.y},
"size": {"x": value.size.x, "y": value.size.y},
}
return value return value
## Set a font/icon slot from inside an undo action. Stylebox slots use the
## dedicated `_apply_stylebox` path (they need the StyleBox-typed setter).
func _apply_slot(theme_path: String, kind: String, name: String, class_name_param: String, value: Variant) -> int:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return ERR_FILE_CANT_OPEN
match kind:
"font":
theme.set_font(name, class_name_param, value)
"icon":
theme.set_icon(name, class_name_param, value)
return McpResourceIO.guarded_save(theme, theme_path, _connection)
func _clear_slot(theme_path: String, kind: String, name: String, class_name_param: String) -> int:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return ERR_FILE_CANT_OPEN
match kind:
"font":
theme.clear_font(name, class_name_param)
"icon":
theme.clear_icon(name, class_name_param)
return McpResourceIO.guarded_save(theme, theme_path, _connection)
## Parse a 9-slice region from {position: {x,y}, size: {x,y}} or [x,y,w,h].
## Non-finite components are rejected (null), so a NaN/INF region can never be
## committed to a stylebox slot.
static func _parse_rect2(value: Variant) -> Variant:
if value is Rect2:
return value if _is_finite_rect2(value) else null
if value is Dictionary:
var d: Dictionary = value
if not (d.has("position") and d.has("size")):
return null
var pos := McpJsonValues.parse_vector2(d.position)
var size := McpJsonValues.parse_vector2(d.size)
if pos == null or size == null:
return null
var rect := Rect2(pos, size)
return rect if _is_finite_rect2(rect) else null
if value is Array:
var arr: Array = value
if arr.size() != 4:
return null
for item in arr:
if not (item is int or item is float):
return null
var rect := Rect2(float(arr[0]), float(arr[1]), float(arr[2]), float(arr[3]))
return rect if _is_finite_rect2(rect) else null
return null
static func _is_finite_rect2(rect: Rect2) -> bool:
return (
is_finite(rect.position.x)
and is_finite(rect.position.y)
and is_finite(rect.size.x)
and is_finite(rect.size.y)
)
## StyleBoxTexture axis stretch mode by name. Returns null on an unknown name.
static func _parse_axis_stretch(value: String) -> Variant:
match value:
"stretch":
return StyleBoxTexture.AXIS_STRETCH_MODE_STRETCH
"tile":
return StyleBoxTexture.AXIS_STRETCH_MODE_TILE
"tile_fit":
return StyleBoxTexture.AXIS_STRETCH_MODE_TILE_FIT
return null
## Apply {all, left, top, right, bottom} texture margins to a StyleBoxTexture.
## Shares the flat side parser's validate-then-apply contract: every value is
## parsed before any margin is assigned. Returns {"ok": true} or the error dict.
static func _apply_texture_margins(sb: StyleBoxTexture, margins: Variant) -> Dictionary:
return _apply_sides(sb, margins, "margins", ["left", "top", "right", "bottom"],
"texture_margin_", _NATIVE_FLOAT32)
+56
View File
@@ -204,6 +204,62 @@ func set_text(params: Dictionary) -> Dictionary:
} }
# ============================================================================
# set_richtext — RichTextLabel text (BBcode-aware)
# ============================================================================
## Set a RichTextLabel's text in one undo action. `bbcode` (default true)
## toggles BBcode parsing before the text is written, so "[color=red]HP[/color]"
## renders as markup rather than literal characters.
func set_richtext(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
if not params.has("text"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: text")
var text_value: Variant = params["text"]
if typeof(text_value) != TYPE_STRING:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "text must be a string")
var resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is RichTextLabel:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a RichTextLabel (got %s)" % [node_path, node.get_class()]
)
var label := node as RichTextLabel
if params.has("bbcode") and typeof(params["bbcode"]) != TYPE_BOOL:
## Strict: a stringified "false" would coerce to true and silently
## render markup as literal text (or the reverse).
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "bbcode must be a boolean")
var bbcode: bool = params.get("bbcode", true)
var old_text: String = label.text
var old_bbcode: bool = label.bbcode_enabled
_undo_redo.create_action("MCP: Set rich text on %s" % node.name)
## bbcode_enabled first: RichTextLabel parses `text` as BBcode only when
## the flag is already set.
_undo_redo.add_do_property(node, "bbcode_enabled", bbcode)
_undo_redo.add_do_property(node, "text", text_value)
_undo_redo.add_undo_property(node, "text", old_text)
_undo_redo.add_undo_property(node, "bbcode_enabled", old_bbcode)
_undo_redo.commit_action()
return {
"data": {
"path": McpScenePath.from_node(node, resolved.scene_root),
"bbcode": bbcode,
"length": (text_value as String).length(),
"undoable": true,
}
}
# ============================================================================ # ============================================================================
# build_layout — declarative nested-dict → Control tree in one undo action # build_layout — declarative nested-dict → Control tree in one undo action
# ============================================================================ # ============================================================================
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://c6n50c5gk5fit
+69 -39
View File
@@ -24,6 +24,7 @@ extends VBoxContainer
const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd")
const ClientRefreshStateScript := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd") const ClientRefreshStateScript := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd")
const Client := preload("res://addons/godot_ai/clients/_base.gd") const Client := preload("res://addons/godot_ai/clients/_base.gd")
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd") const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd")
const ToolCatalog := preload("res://addons/godot_ai/tool_catalog.gd") const ToolCatalog := preload("res://addons/godot_ai/tool_catalog.gd")
@@ -54,6 +55,7 @@ static var COLOR_AMBER := Color(1.0, 0.75, 0.25)
signal update_requested signal update_requested
signal client_action_requested(client_id: String, action: String) signal client_action_requested(client_id: String, action: String)
signal client_action_cancel_requested(client_id: String)
signal client_status_refresh_requested(client_ids: Array[String], force: bool) signal client_status_refresh_requested(client_ids: Array[String], force: bool)
signal status_snapshot_requested signal status_snapshot_requested
signal live_server_probe_requested(port: int) signal live_server_probe_requested(port: int)
@@ -165,11 +167,6 @@ var _uv_recheck_pending := false
## (foreign-port branch never sets `_server_pid`, so `_stop_server` ## (foreign-port branch never sets `_server_pid`, so `_stop_server`
## can't kill it); the line has to show the mismatch honestly. ## can't kill it); the line has to show the mismatch honestly.
var _setup_server_label: Label var _setup_server_label: Label
## Last rendered server-version string. `_update_status_label` runs every
## frame; early-outs text repaint when nothing changed. Empty means
## "no line rendered yet" (dev-checkout branch doesn't render a
## user-mode Server line).
var _last_rendered_server_text: String = ""
## Restart-server button shown next to the Setup container when ## Restart-server button shown next to the Setup container when
## `McpConnection.server_version` drifts from the plugin version. Hidden ## `McpConnection.server_version` drifts from the plugin version. Hidden
## in the match case so the UI stays calm. ## in the match case so the UI stays calm.
@@ -457,7 +454,7 @@ func _build_ui() -> void:
_crash_docs_btn = Button.new() _crash_docs_btn = Button.new()
_crash_docs_btn.text = "How to change the port" _crash_docs_btn.text = "How to change the port"
_crash_docs_btn.tooltip_text = "Open the guide: change godot_ai/http_port and reconfigure your MCP clients" _crash_docs_btn.tooltip_text = "Open the guide: change ports in Godot AI settings and reconfigure your MCP clients"
_crash_docs_btn.visible = false _crash_docs_btn.visible = false
_crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url())) _crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url()))
_crash_panel.add_child(_crash_docs_btn) _crash_panel.add_child(_crash_docs_btn)
@@ -720,13 +717,13 @@ func _build_client_row(client_id: String) -> void:
var configure_btn := Button.new() var configure_btn := Button.new()
configure_btn.text = "Configure" configure_btn.text = "Configure"
configure_btn.pressed.connect(_on_configure_client.bind(client_id)) configure_btn.pressed.connect(_on_client_action_button_pressed.bind(client_id, "configure"))
row.add_child(configure_btn) row.add_child(configure_btn)
var remove_btn := Button.new() var remove_btn := Button.new()
remove_btn.text = "Remove" remove_btn.text = "Remove"
remove_btn.visible = false remove_btn.visible = false
remove_btn.pressed.connect(_on_remove_client.bind(client_id)) remove_btn.pressed.connect(_on_client_action_button_pressed.bind(client_id, "remove"))
row.add_child(remove_btn) row.add_child(remove_btn)
# F-3-4: use the authoritative facade so Open/Reveal land on the same # F-3-4: use the authoritative facade so Open/Reveal land on the same
@@ -827,6 +824,13 @@ func _update_status() -> void:
elif bool(server_status.get("handoff_retry_pending", false)): elif bool(server_status.get("handoff_retry_pending", false)):
status_text = "Recovering after update…" status_text = "Recovering after update…"
status_color = COLOR_AMBER status_color = COLOR_AMBER
elif state == ServerStateScript.UNSUPPORTED_CONFIG:
status_text = "Unsupported remote access configuration"
status_color = Color.RED
elif state == ServerStateScript.CRASHED and str(server_status.get("reason", "")) == "endpoint_lost":
var recovery_pending := bool(server_status.get("recovery_pending", false))
status_text = "Connection lost; reconnecting..." if recovery_pending else "Connection lost"
status_color = COLOR_AMBER if recovery_pending else Color.RED
elif state == ServerStateScript.CRASHED: elif state == ServerStateScript.CRASHED:
var exit_ms: int = server_status.get("exit_ms", 0) var exit_ms: int = server_status.get("exit_ms", 0)
status_text = "Server exited after %.1fs" % (exit_ms / 1000.0) status_text = "Server exited after %.1fs" % (exit_ms / 1000.0)
@@ -843,7 +847,10 @@ func _update_status() -> void:
var conflict_port: int = int(server_status.get("conflict_port", 0)) var conflict_port: int = int(server_status.get("conflict_port", 0))
if conflict_port <= 0: if conflict_port <= 0:
conflict_port = ClientConfigurator.http_port() conflict_port = ClientConfigurator.http_port()
status_text = "Port %d held by another process" % conflict_port status_text = (
"Windows port discovery unavailable" if str(server_status.get("episode_reason", "")) == "port_occupancy_unknown"
else "Port %d held by another process" % conflict_port
)
status_color = Color.RED status_color = Color.RED
elif state == ServerStateScript.NO_COMMAND: elif state == ServerStateScript.NO_COMMAND:
status_text = "No server command found" status_text = "No server command found"
@@ -980,6 +987,8 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
## problem; don't repeat it here. This copy answers "what do I do?". ## problem; don't repeat it here. This copy answers "what do I do?".
var port := ClientConfigurator.http_port() var port := ClientConfigurator.http_port()
match state: match state:
ServerStateScript.UNSUPPORTED_CONFIG:
return str(server_status.get("message", "Use an IPv4 allowlist or clear Allow remote hosts, then reload the plugin."))
ServerStateScript.PORT_EXCLUDED: ServerStateScript.PORT_EXCLUDED:
return "Windows (Hyper-V / WSL2 / Docker) reserved port %d. Pick a free port or try `net stop winnat; net start winnat` in an admin shell." % port return "Windows (Hyper-V / WSL2 / Docker) reserved port %d. Pick a free port or try `net stop winnat; net start winnat` in an admin shell." % port
ServerStateScript.INCOMPATIBLE: ServerStateScript.INCOMPATIBLE:
@@ -1056,8 +1065,11 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
## the client's attach command, so clients must be reconfigured afterwards. ## the client's attach command, so clients must be reconfigured afterwards.
## The per-client reconfigure steps live behind the crash panel's docs link. ## The per-client reconfigure steps live behind the crash panel's docs link.
static func _free_port_hint(port: int) -> String: static func _free_port_hint(port: int) -> String:
var free_http := ClientConfigurator.suggest_free_port(port + 1) var occupancy := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1) var free_http := ClientConfigurator.suggest_free_port(port + 1, 2048, occupancy)
var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1, 2048, occupancy)
if free_http == 0 or free_ws == 0:
return "Automatic port selection is unavailable. Choose HTTP and WS ports manually below, or retry."
return "Suggested ports: %d (HTTP) and %d (WS). Choose both ports below, click Apply + Reload, then Configure your AI clients to use the new pair." % [free_http, free_ws] return "Suggested ports: %d (HTTP) and %d (WS). Choose both ports below, click Apply + Reload, then Configure your AI clients to use the new pair." % [free_http, free_ws]
@@ -1341,13 +1353,19 @@ func _refresh_server_version_label(server_status: Dictionary = {}) -> void:
## look like it had multiple restart paths. ## look like it had multiple restart paths.
or (is_incompatible and can_recover and _crash_restart_btn == null) or (is_incompatible and can_recover and _crash_restart_btn == null)
) )
if text == _last_rendered_server_text: ## Runs every frame, so write only what changed (#1121). `Label.text`
_setup_server_label.add_theme_color_override("font_color", color) ## already ignores same-value writes; `add_theme_color_override` does
_update_restart_button(show_restart) ## not — every call re-sends NOTIFICATION_THEME_CHANGED and queues a
return ## redraw, which kept the idle editor redrawing while this row was
_last_rendered_server_text = text ## visible. Compare against the label itself so a rebuilt row still
## gets its color and a same-text state change (amber drift -> red
## incompatible) still repaints.
_setup_server_label.text = text _setup_server_label.text = text
_setup_server_label.add_theme_color_override("font_color", color) if (
not _setup_server_label.has_theme_color_override("font_color")
or _setup_server_label.get_theme_color("font_color") != color
):
_setup_server_label.add_theme_color_override("font_color", color)
_update_restart_button(show_restart) _update_restart_button(show_restart)
@@ -1365,12 +1383,10 @@ func _on_restart_stale_server() -> void:
if _server_restart_in_progress: if _server_restart_in_progress:
return return
_server_restart_in_progress = true _server_restart_in_progress = true
_last_rendered_server_text = ""
_refresh_server_version_label() _refresh_server_version_label()
if not is_inside_tree(): if not is_inside_tree():
_dispatch_stale_server_restart() _dispatch_stale_server_restart()
_server_restart_in_progress = false _server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label() _refresh_server_version_label()
return return
call_deferred("_restart_stale_server_after_feedback") call_deferred("_restart_stale_server_after_feedback")
@@ -1380,7 +1396,6 @@ func _restart_stale_server_after_feedback() -> void:
await get_tree().create_timer(0.15).timeout await get_tree().create_timer(0.15).timeout
if not _dispatch_stale_server_restart(): if not _dispatch_stale_server_restart():
_server_restart_in_progress = false _server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label() _refresh_server_version_label()
@@ -1401,7 +1416,6 @@ func present_lifecycle_action_result(accepted: bool) -> void:
if accepted: if accepted:
return return
_server_restart_in_progress = false _server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label() _refresh_server_version_label()
@@ -1499,7 +1513,6 @@ func _refresh_setup_status() -> void:
_version_restart_btn.visible = false _version_restart_btn.visible = false
server_row.add_child(_version_restart_btn) server_row.add_child(_version_restart_btn)
_setup_container.add_child(server_row) _setup_container.add_child(server_row)
_last_rendered_server_text = ""
_refresh_server_version_label() _refresh_server_version_label()
else: else:
_setup_container.add_child(_make_status_row("uv", "not found", Color.RED)) _setup_container.add_child(_make_status_row("uv", "not found", Color.RED))
@@ -1745,8 +1758,13 @@ func _on_configure_client(client_id: String) -> void:
_dispatch_client_action(client_id, "configure") _dispatch_client_action(client_id, "configure")
func _on_remove_client(client_id: String) -> void: func _on_client_action_button_pressed(client_id: String, action: String) -> void:
_dispatch_client_action(client_id, "remove") if _is_self_update_in_progress():
return
if _client_work_snapshot.get("action_phases", {}).get(client_id, "") == "queued":
client_action_cancel_requested.emit(client_id)
return
_dispatch_client_action(client_id, action)
## Emit a value intent; plugin.gd routes it to the plugin-lifetime job owner. ## Emit a value intent; plugin.gd routes it to the plugin-lifetime job owner.
@@ -1771,6 +1789,12 @@ func present_client_action_result(
) -> void: ) -> void:
_report_prewarm_outcome(client_id, prewarm) _report_prewarm_outcome(client_id, prewarm)
_finalize_action_buttons(client_id) _finalize_action_buttons(client_id)
if result.get("status") == "cancelled":
var row: Dictionary = _client_rows.get(client_id, {})
if not row.is_empty():
_apply_row_status(client_id, row.get("status", Client.Status.NOT_CONFIGURED))
_refresh_clients_summary()
return
var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED
if result.get("status") == "ok": if result.get("status") == "ok":
## #877: Remove targets only the selected scope, so a configure is the ## #877: Remove targets only the selected scope, so a configure is the
@@ -1805,7 +1829,11 @@ func present_client_work_snapshot(snapshot: Dictionary) -> void:
var id := String(client_id) var id := String(client_id)
if busy.has(id): if busy.has(id):
_set_row_action_in_flight(id, String(names.get(id, "configure"))) _set_row_action_in_flight(id, String(names.get(id, "configure")))
if String(phases.get(id, "")) == "prewarm": if String(phases.get(id, "")) == "queued":
var button := "remove_btn" if String(names.get(id, "configure")) == "remove" else "configure_btn"
(_client_rows[id][button] as Button).text = "Cancel queued"
(_client_rows[id][button] as Button).disabled = false
elif String(phases.get(id, "")) == "prewarm":
(_client_rows[id]["configure_btn"] as Button).text = "Installing…" (_client_rows[id]["configure_btn"] as Button).text = "Installing…"
else: else:
_finalize_action_buttons(id) _finalize_action_buttons(id)
@@ -2335,9 +2363,19 @@ func _build_settings_tab(tabs: TabContainer) -> void:
## `_reset_tools_pending_from_setting` / `_on_open_clients_window`). ## `_reset_tools_pending_from_setting` / `_on_open_clients_window`).
var settings_tab := VBoxContainer.new() var settings_tab := VBoxContainer.new()
settings_tab.add_theme_constant_override("separation", 8) settings_tab.add_theme_constant_override("separation", 8)
settings_tab.size_flags_horizontal = Control.SIZE_EXPAND_FILL
## The section stack below (Vision Routing + Remote access) can outgrow
## the window's minimum height, especially at larger editor scales —
## scroll instead of growing the window, same idiom as the Clients and
## Tools tabs (#1090).
var settings_scroll := ScrollContainer.new()
settings_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
settings_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
settings_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
settings_scroll.add_child(settings_tab)
var settings_margin := _build_margin_container() var settings_margin := _build_margin_container()
settings_margin.name = "Settings" settings_margin.name = "Settings"
settings_margin.add_child(settings_tab) settings_margin.add_child(settings_scroll)
tabs.add_child(settings_margin) tabs.add_child(settings_margin)
## Vision Routing is configuration, not status — it lives here rather ## Vision Routing is configuration, not status — it lives here rather
@@ -2448,25 +2486,17 @@ func _on_allow_hosts_text_changed(_new_text: String) -> void:
func _refresh_allow_hosts_ui_state() -> void: func _refresh_allow_hosts_ui_state() -> void:
if _allow_hosts_edit == null or _allow_hosts_apply_btn == null: if _allow_hosts_edit == null or _allow_hosts_apply_btn == null:
return return
var invalid := McpAllowHosts.invalid_tokens(_allow_hosts_edit.text) var error := McpAllowHosts.configuration_error(_allow_hosts_edit.text)
if invalid.is_empty(): _allow_hosts_hint.text = error
_allow_hosts_hint.visible = false _allow_hosts_hint.visible = not error.is_empty()
else: _allow_hosts_apply_btn.disabled = not _allow_hosts_is_dirty() or not error.is_empty()
## Name the accepted syntax in the hint — matches the server's
## `parse_allow_hosts` (CIDR / bare IP, comma-separated).
_allow_hosts_hint.text = (
"Invalid entries (must be a CIDR like 192.168.1.0/24 or a bare IP, comma-separated): %s"
% ", ".join(invalid)
)
_allow_hosts_hint.visible = true
_allow_hosts_apply_btn.disabled = not _allow_hosts_is_dirty() or not invalid.is_empty()
func _on_allow_hosts_apply() -> void: func _on_allow_hosts_apply() -> void:
if _allow_hosts_edit == null: if _allow_hosts_edit == null:
return return
var normalized := McpAllowHosts.normalize(_allow_hosts_edit.text) var normalized := McpAllowHosts.normalize(_allow_hosts_edit.text)
if not McpAllowHosts.invalid_tokens(normalized).is_empty(): if not McpAllowHosts.configuration_error(normalized).is_empty():
return return
_allow_hosts_saved = normalized _allow_hosts_saved = normalized
_allow_hosts_edit.text = normalized _allow_hosts_edit.text = normalized
+1 -1
View File
@@ -3,5 +3,5 @@
name="Godot AI" name="Godot AI"
description="MCP server and AI tools for Godot" description="MCP server and AI tools for Godot"
author="Godot AI" author="Godot AI"
version="4.1.0" version="4.3.0"
script="plugin.gd" script="plugin.gd"
+101 -7
View File
@@ -365,6 +365,8 @@ func _continue_enter_tree_after_update_barrier() -> void:
_dispatcher.register_lazy_handler("theme", HANDLERS_DIR + "theme_handler.gd", [undo, _connection]) _dispatcher.register_lazy_handler("theme", HANDLERS_DIR + "theme_handler.gd", [undo, _connection])
_dispatcher.register_lazy_handler("animation", HANDLERS_DIR + "animation_handler.gd", [undo]) _dispatcher.register_lazy_handler("animation", HANDLERS_DIR + "animation_handler.gd", [undo])
_dispatcher.register_lazy_handler("material", HANDLERS_DIR + "material_handler.gd", [undo, _connection]) _dispatcher.register_lazy_handler("material", HANDLERS_DIR + "material_handler.gd", [undo, _connection])
_dispatcher.register_lazy_handler("shader", HANDLERS_DIR + "shader_handler.gd", [])
_dispatcher.register_lazy_handler("visual_shader", HANDLERS_DIR + "visual_shader_handler.gd", [])
_dispatcher.register_lazy_handler("particle", HANDLERS_DIR + "particle_handler.gd", [undo]) _dispatcher.register_lazy_handler("particle", HANDLERS_DIR + "particle_handler.gd", [undo])
_dispatcher.register_lazy_handler("camera", HANDLERS_DIR + "camera_handler.gd", [undo]) _dispatcher.register_lazy_handler("camera", HANDLERS_DIR + "camera_handler.gd", [undo])
_dispatcher.register_lazy_handler("audio", HANDLERS_DIR + "audio_handler.gd", [undo]) _dispatcher.register_lazy_handler("audio", HANDLERS_DIR + "audio_handler.gd", [undo])
@@ -378,6 +380,7 @@ func _continue_enter_tree_after_update_barrier() -> void:
_dispatcher.register_lazy_handler("tilemap", HANDLERS_DIR + "tilemap_handler.gd", [undo]) _dispatcher.register_lazy_handler("tilemap", HANDLERS_DIR + "tilemap_handler.gd", [undo])
_dispatcher.register_lazy_handler("tileset", HANDLERS_DIR + "tileset_handler.gd", []) _dispatcher.register_lazy_handler("tileset", HANDLERS_DIR + "tileset_handler.gd", [])
_dispatcher.register_lazy_handler("gridmap", HANDLERS_DIR + "gridmap_handler.gd", [undo]) _dispatcher.register_lazy_handler("gridmap", HANDLERS_DIR + "gridmap_handler.gd", [undo])
_dispatcher.register_lazy_handler("navigation", HANDLERS_DIR + "navigation_handler.gd", [undo, _connection])
_dispatcher.register_lazy_handler("csg", HANDLERS_DIR + "csg_handler.gd", [undo]) _dispatcher.register_lazy_handler("csg", HANDLERS_DIR + "csg_handler.gd", [undo])
_dispatcher.register_lazy("get_editor_state", "editor", &"get_editor_state") _dispatcher.register_lazy("get_editor_state", "editor", &"get_editor_state")
@@ -428,6 +431,7 @@ func _continue_enter_tree_after_update_barrier() -> void:
_dispatcher.register_lazy("find_symbols", "script", &"find_symbols") _dispatcher.register_lazy("find_symbols", "script", &"find_symbols")
_dispatcher.register_lazy("search_resources", "resource", &"search_resources") _dispatcher.register_lazy("search_resources", "resource", &"search_resources")
_dispatcher.register_lazy("load_resource", "resource", &"load_resource") _dispatcher.register_lazy("load_resource", "resource", &"load_resource")
_dispatcher.register_lazy("inspect_resource", "resource", &"inspect_resource")
_dispatcher.register_lazy("assign_resource", "resource", &"assign_resource") _dispatcher.register_lazy("assign_resource", "resource", &"assign_resource")
_dispatcher.register_lazy("create_resource", "resource", &"create_resource") _dispatcher.register_lazy("create_resource", "resource", &"create_resource")
_dispatcher.register_lazy("get_resource_info", "resource", &"get_resource_info") _dispatcher.register_lazy("get_resource_info", "resource", &"get_resource_info")
@@ -436,6 +440,9 @@ func _continue_enter_tree_after_update_barrier() -> void:
_dispatcher.register_lazy("write_file", "filesystem", &"write_file") _dispatcher.register_lazy("write_file", "filesystem", &"write_file")
_dispatcher.register_lazy("reimport", "filesystem", &"reimport") _dispatcher.register_lazy("reimport", "filesystem", &"reimport")
_dispatcher.register_lazy("scan_filesystem", "filesystem", &"scan_filesystem") _dispatcher.register_lazy("scan_filesystem", "filesystem", &"scan_filesystem")
_dispatcher.register_lazy("move_file", "filesystem", &"move_file")
_dispatcher.register_lazy("rename_file", "filesystem", &"rename_file")
_dispatcher.register_lazy("remove_file", "filesystem", &"remove_file")
_dispatcher.register_lazy("list_signals", "signal", &"list_signals") _dispatcher.register_lazy("list_signals", "signal", &"list_signals")
_dispatcher.register_lazy("connect_signal", "signal", &"connect_signal") _dispatcher.register_lazy("connect_signal", "signal", &"connect_signal")
_dispatcher.register_lazy("disconnect_signal", "signal", &"disconnect_signal") _dispatcher.register_lazy("disconnect_signal", "signal", &"disconnect_signal")
@@ -453,12 +460,17 @@ func _continue_enter_tree_after_update_barrier() -> void:
_dispatcher.register_lazy("batch_execute", "batch", &"batch_execute") _dispatcher.register_lazy("batch_execute", "batch", &"batch_execute")
_dispatcher.register_lazy("set_anchor_preset", "ui", &"set_anchor_preset") _dispatcher.register_lazy("set_anchor_preset", "ui", &"set_anchor_preset")
_dispatcher.register_lazy("set_text", "ui", &"set_text") _dispatcher.register_lazy("set_text", "ui", &"set_text")
_dispatcher.register_lazy("set_richtext", "ui", &"set_richtext")
_dispatcher.register_lazy("build_layout", "ui", &"build_layout") _dispatcher.register_lazy("build_layout", "ui", &"build_layout")
_dispatcher.register_lazy("create_theme", "theme", &"create_theme") _dispatcher.register_lazy("create_theme", "theme", &"create_theme")
_dispatcher.register_lazy("theme_set_color", "theme", &"set_color") _dispatcher.register_lazy("theme_set_color", "theme", &"set_color")
_dispatcher.register_lazy("theme_set_constant", "theme", &"set_constant") _dispatcher.register_lazy("theme_set_constant", "theme", &"set_constant")
_dispatcher.register_lazy("theme_set_font_size", "theme", &"set_font_size") _dispatcher.register_lazy("theme_set_font_size", "theme", &"set_font_size")
_dispatcher.register_lazy("theme_set_stylebox_flat", "theme", &"set_stylebox_flat") _dispatcher.register_lazy("theme_set_stylebox_flat", "theme", &"set_stylebox_flat")
_dispatcher.register_lazy("theme_set_stylebox_texture", "theme", &"set_stylebox_texture")
_dispatcher.register_lazy("theme_set_font", "theme", &"set_font")
_dispatcher.register_lazy("theme_set_icon", "theme", &"set_icon")
_dispatcher.register_lazy("theme_stylebox_override", "theme", &"stylebox_override")
_dispatcher.register_lazy("apply_theme", "theme", &"apply_theme") _dispatcher.register_lazy("apply_theme", "theme", &"apply_theme")
_dispatcher.register_lazy("animation_player_create", "animation", &"create_player") _dispatcher.register_lazy("animation_player_create", "animation", &"create_player")
_dispatcher.register_lazy("animation_create", "animation", &"create_animation") _dispatcher.register_lazy("animation_create", "animation", &"create_animation")
@@ -477,6 +489,10 @@ func _continue_enter_tree_after_update_barrier() -> void:
_dispatcher.register_lazy("animation_preset_shake", "animation", &"preset_shake") _dispatcher.register_lazy("animation_preset_shake", "animation", &"preset_shake")
_dispatcher.register_lazy("animation_preset_pulse", "animation", &"preset_pulse") _dispatcher.register_lazy("animation_preset_pulse", "animation", &"preset_pulse")
_dispatcher.register_lazy("material_create", "material", &"create_material") _dispatcher.register_lazy("material_create", "material", &"create_material")
_dispatcher.register_lazy("visual_shader_create_graph", "visual_shader", &"create_graph")
_dispatcher.register_lazy("visual_shader_get", "visual_shader", &"get_graph")
_dispatcher.register_lazy("visual_shader_node_catalog", "visual_shader", &"node_catalog")
_dispatcher.register_lazy("visual_shader_edit", "visual_shader", &"edit_graph")
_dispatcher.register_lazy("material_set_param", "material", &"set_param") _dispatcher.register_lazy("material_set_param", "material", &"set_param")
_dispatcher.register_lazy("material_set_shader_param", "material", &"set_shader_param") _dispatcher.register_lazy("material_set_shader_param", "material", &"set_shader_param")
_dispatcher.register_lazy("material_get", "material", &"get_material") _dispatcher.register_lazy("material_get", "material", &"get_material")
@@ -484,6 +500,10 @@ func _continue_enter_tree_after_update_barrier() -> void:
_dispatcher.register_lazy("material_assign", "material", &"assign_material") _dispatcher.register_lazy("material_assign", "material", &"assign_material")
_dispatcher.register_lazy("material_apply_to_node", "material", &"apply_to_node") _dispatcher.register_lazy("material_apply_to_node", "material", &"apply_to_node")
_dispatcher.register_lazy("material_apply_preset", "material", &"apply_preset") _dispatcher.register_lazy("material_apply_preset", "material", &"apply_preset")
_dispatcher.register_lazy("shader_create", "shader", &"create_shader")
_dispatcher.register_lazy("shader_get", "shader", &"get_shader")
_dispatcher.register_lazy("shader_validate", "shader", &"validate_shader")
_dispatcher.register_lazy("shader_patch", "shader", &"patch_shader")
_dispatcher.register_lazy("particle_create", "particle", &"create_particle") _dispatcher.register_lazy("particle_create", "particle", &"create_particle")
_dispatcher.register_lazy("particle_set_main", "particle", &"set_main") _dispatcher.register_lazy("particle_set_main", "particle", &"set_main")
_dispatcher.register_lazy("particle_set_process", "particle", &"set_process") _dispatcher.register_lazy("particle_set_process", "particle", &"set_process")
@@ -523,6 +543,8 @@ func _continue_enter_tree_after_update_barrier() -> void:
_dispatcher.register_lazy("gridmap_clear", "gridmap", &"clear_layer") _dispatcher.register_lazy("gridmap_clear", "gridmap", &"clear_layer")
_dispatcher.register_lazy("gridmap_get_used_cells", "gridmap", &"get_used_cells") _dispatcher.register_lazy("gridmap_get_used_cells", "gridmap", &"get_used_cells")
_dispatcher.register_lazy("gridmap_list_library_items", "gridmap", &"list_library_items") _dispatcher.register_lazy("gridmap_list_library_items", "gridmap", &"list_library_items")
_dispatcher.register_lazy("navigation_bake", "navigation", &"bake")
_dispatcher.register_lazy("navigation_path_get", "navigation", &"path_get")
_dispatcher.register_lazy("csg_create", "csg", &"create") _dispatcher.register_lazy("csg_create", "csg", &"create")
_dispatcher.register_lazy("csg_set_operation", "csg", &"set_operation") _dispatcher.register_lazy("csg_set_operation", "csg", &"set_operation")
@@ -545,6 +567,7 @@ func _continue_enter_tree_after_update_barrier() -> void:
_dock.name = "Godot AI" _dock.name = "Godot AI"
_dock.update_requested.connect(_on_dock_update_requested) _dock.update_requested.connect(_on_dock_update_requested)
_dock.client_action_requested.connect(_on_dock_client_action_requested) _dock.client_action_requested.connect(_on_dock_client_action_requested)
_dock.client_action_cancel_requested.connect(_on_dock_client_action_cancel_requested)
_dock.client_status_refresh_requested.connect(_on_dock_client_status_refresh_requested) _dock.client_status_refresh_requested.connect(_on_dock_client_status_refresh_requested)
_dock.status_snapshot_requested.connect(_on_dock_status_snapshot_requested) _dock.status_snapshot_requested.connect(_on_dock_status_snapshot_requested)
_dock.live_server_probe_requested.connect(_on_dock_live_server_probe_requested) _dock.live_server_probe_requested.connect(_on_dock_live_server_probe_requested)
@@ -569,18 +592,24 @@ func _continue_enter_tree_after_update_barrier() -> void:
## client migration worker exists. An old bridge keeps its own ports. ## client migration worker exists. An old bridge keeps its own ports.
func _activate_startup_endpoints() -> void: func _activate_startup_endpoints() -> void:
if str(_post_update_outcome.get("outcome", "")) == "success": if str(_post_update_outcome.get("outcome", "")) == "success":
var prepare_started := _startup_trace_call_begin("prepare_upgrade_endpoints")
var prepared := ClientConfigurator.prepare_major_upgrade_endpoints( var prepared := ClientConfigurator.prepare_major_upgrade_endpoints(
str(_post_update_outcome.get("from_version", "")), str(_post_update_outcome.get("from_version", "")),
str(_post_update_outcome.get("to_version", "")), str(_post_update_outcome.get("to_version", "")),
) )
_startup_trace_call_end("prepare_upgrade_endpoints", prepare_started)
if not bool(prepared.get("ok", false)): if not bool(prepared.get("ok", false)):
_present_endpoint_setup_failure(str(prepared.get("error", "Endpoint selection failed."))) _present_endpoint_setup_failure(str(prepared.get("error", "Endpoint selection failed.")))
return return
var override_started := _startup_trace_call_begin("endpoint_override")
var override := ClientConfigurator.v4_endpoint_ports_status() var override := ClientConfigurator.v4_endpoint_ports_status()
_startup_trace_call_end("endpoint_override", override_started)
if not bool(override.get("ok", false)): if not bool(override.get("ok", false)):
_present_endpoint_setup_failure(str(override.get("error", "Invalid endpoint override."))) _present_endpoint_setup_failure(str(override.get("error", "Invalid endpoint override.")))
return return
var policy_started := _startup_trace_call_begin("endpoint_policy")
var resolved_policy := ClientConfigurator.capture_endpoint_policy() var resolved_policy := ClientConfigurator.capture_endpoint_policy()
_startup_trace_call_end("endpoint_policy", policy_started)
var http_port := int(resolved_policy.http_port) var http_port := int(resolved_policy.http_port)
var configured_ws := int(resolved_policy.ws_port) var configured_ws := int(resolved_policy.ws_port)
if ( if (
@@ -590,7 +619,9 @@ func _activate_startup_endpoints() -> void:
): ):
_present_endpoint_setup_failure("Choose distinct HTTP and WebSocket ports between %d and %d in Godot AI settings." % [ClientConfigurator.MIN_PORT, ClientConfigurator.MAX_PORT]) _present_endpoint_setup_failure("Choose distinct HTTP and WebSocket ports between %d and %d in Godot AI settings." % [ClientConfigurator.MIN_PORT, ClientConfigurator.MAX_PORT])
return return
var ws_started := _startup_trace_call_begin("resolve_ws_port")
var resolved_ws := _resolve_ws_port(configured_ws) var resolved_ws := _resolve_ws_port(configured_ws)
_startup_trace_call_end("resolve_ws_port", ws_started)
if ( if (
resolved_ws < ClientConfigurator.MIN_PORT or resolved_ws > ClientConfigurator.MAX_PORT resolved_ws < ClientConfigurator.MIN_PORT or resolved_ws > ClientConfigurator.MAX_PORT
or resolved_ws == http_port or resolved_ws == http_port
@@ -599,7 +630,9 @@ func _activate_startup_endpoints() -> void:
_present_endpoint_setup_failure("The configured WebSocket port is unavailable. Choose another endpoint pair in Godot AI settings, then retry.") _present_endpoint_setup_failure("The configured WebSocket port is unavailable. Choose another endpoint pair in Godot AI settings, then retry.")
return return
resolved_policy["ws_port"] = resolved_ws resolved_policy["ws_port"] = resolved_ws
var capability_started := _startup_trace_call_begin("capability_path")
resolved_policy["capability_path"] = TransportCapability.path_for_http_port(http_port) resolved_policy["capability_path"] = TransportCapability.path_for_http_port(http_port)
_startup_trace_call_end("capability_path", capability_started)
_set_endpoint_policy(resolved_policy) _set_endpoint_policy(resolved_policy)
if _connection != null: if _connection != null:
_connection.ws_port = resolved_ws _connection.ws_port = resolved_ws
@@ -608,9 +641,16 @@ func _activate_startup_endpoints() -> void:
if _dock != null: if _dock != null:
_dock.present_update_state({"post_update_action": "", "status_text": "", "label_text": "", "banner_visible": false}) _dock.present_update_state({"post_update_action": "", "status_text": "", "label_text": "", "banner_visible": false})
## #691: publish every environment/setting value before the first worker. ## #691: publish every environment/setting value before the first worker.
var warm_started := _startup_trace_call_begin("warm_env_snapshot")
ClientConfigurator.warm_env_snapshot(_endpoint_policy) ClientConfigurator.warm_env_snapshot(_endpoint_policy)
_lifecycle.configure(_capture_lifecycle_plan()) _startup_trace_call_end("warm_env_snapshot", warm_started)
var plan := _capture_lifecycle_plan()
var configure_started := _startup_trace_call_begin("lifecycle_configure")
_lifecycle.configure(plan)
_startup_trace_call_end("lifecycle_configure", configure_started)
var release_started := _startup_trace_call_begin("startup_release")
_begin_startup_release() _begin_startup_release()
_startup_trace_call_end("startup_release", release_started)
func _present_endpoint_setup_failure(error: String) -> void: func _present_endpoint_setup_failure(error: String) -> void:
@@ -641,6 +681,14 @@ func _on_dock_client_action_requested(client_id: String, action: String) -> void
_dock.present_client_work_snapshot(_client_jobs.snapshot()) _dock.present_client_work_snapshot(_client_jobs.snapshot())
func _on_dock_client_action_cancel_requested(client_id: String) -> void:
if _client_jobs == null:
return
_client_jobs.cancel_pending_action(client_id)
if _dock != null:
_dock.present_client_work_snapshot(_client_jobs.snapshot())
func _on_dock_client_status_refresh_requested(client_ids: Array[String], force: bool) -> void: func _on_dock_client_status_refresh_requested(client_ids: Array[String], force: bool) -> void:
if _client_jobs == null: if _client_jobs == null:
return return
@@ -1287,6 +1335,31 @@ func _startup_trace_phase(name: String) -> void:
_startup_trace_last_ms = now _startup_trace_last_ms = now
## Call timings leave the coarse phase clock unchanged. A begin without an end
## identifies an unfinished synchronous call, not its underlying cause.
func _startup_trace_call_begin(name: String) -> int:
if not _startup_trace_enabled:
return 0
var now := Time.get_ticks_msec()
print("MCP startup trace | call=%s begin total_ms=%d" % [name, now - _startup_trace_start_ms])
return now
func _startup_trace_call_end(name: String, started_ms: int) -> void:
if not _startup_trace_enabled:
return
var now := Time.get_ticks_msec()
print(
"MCP startup trace | call=%s end elapsed_ms=%d total_ms=%d"
% [name, now - started_ms, now - _startup_trace_start_ms]
)
func _startup_trace_lookup(record: Dictionary) -> void:
if _startup_trace_enabled:
print("MCP startup trace | lookup=%s" % JSON.stringify(record))
func _startup_trace_finish(path: String) -> void: func _startup_trace_finish(path: String) -> void:
if not _startup_trace_enabled: if not _startup_trace_enabled:
return return
@@ -1316,19 +1389,31 @@ func _capture_lifecycle_plan() -> Dictionary:
var policy := _endpoint_policy.duplicate(true) var policy := _endpoint_policy.duplicate(true)
var http_port := int(policy.get("http_port", ClientConfigurator.DEFAULT_HTTP_PORT)) var http_port := int(policy.get("http_port", ClientConfigurator.DEFAULT_HTTP_PORT))
var worktree_src := "" var worktree_src := ""
var worktree_started := _startup_trace_call_begin("worktree_source")
if ClientConfigurator.is_dev_checkout(): if ClientConfigurator.is_dev_checkout():
worktree_src = ClientConfigurator.find_worktree_src_dir( worktree_src = ClientConfigurator.find_worktree_src_dir(
ProjectSettings.globalize_path("res://") ProjectSettings.globalize_path("res://")
) )
_startup_trace_call_end("worktree_source", worktree_started)
var expected_version := ClientConfigurator.get_plugin_version()
var command_started := _startup_trace_call_begin("server_command")
var lookup_trace := _startup_trace_lookup if _startup_trace_enabled else Callable()
var server_command := ClientConfigurator.get_server_command(lookup_trace)
_startup_trace_call_end("server_command", command_started)
var pid_file := ProjectSettings.globalize_path(PortResolver.SERVER_PID_FILE)
var startup_report := ProjectSettings.globalize_path(PortResolver.SERVER_STARTUP_REPORT)
var reservation_started := _startup_trace_call_begin("http_port_reservation")
var http_port_reserved := WindowsPortReservation.is_port_excluded(http_port)
_startup_trace_call_end("http_port_reservation", reservation_started)
return { return {
"http_port": http_port, "http_port": http_port,
"capability_path": str(policy.get("capability_path", "")), "capability_path": str(policy.get("capability_path", "")),
"ws_port": int(policy.get("ws_port", ClientConfigurator.DEFAULT_WS_PORT)), "ws_port": int(policy.get("ws_port", ClientConfigurator.DEFAULT_WS_PORT)),
"expected_version": ClientConfigurator.get_plugin_version(), "expected_version": expected_version,
"server_command": ClientConfigurator.get_server_command(), "server_command": server_command,
"pid_file": ProjectSettings.globalize_path(PortResolver.SERVER_PID_FILE), "pid_file": pid_file,
"startup_report": ProjectSettings.globalize_path(PortResolver.SERVER_STARTUP_REPORT), "startup_report": startup_report,
"http_port_reserved": WindowsPortReservation.is_port_excluded(http_port), "http_port_reserved": http_port_reserved,
"excluded_domains": str(policy.get("excluded_domains", "")), "excluded_domains": str(policy.get("excluded_domains", "")),
"allow_hosts": str(policy.get("allow_hosts", "")), "allow_hosts": str(policy.get("allow_hosts", "")),
"keep_alive": bool(policy.get("keep_alive", false)), "keep_alive": bool(policy.get("keep_alive", false)),
@@ -1744,7 +1829,16 @@ func restart_or_start_managed_server() -> bool:
_lifecycle.force_restart_server() _lifecycle.force_restart_server()
return true return true
var port := ClientConfigurator.http_port() var port := ClientConfigurator.http_port()
if PortResolver.is_port_in_use(port): var occupied: bool
if OS.get_name() == "Windows":
var occupancy := PortResolver.windows_port_occupancy(port)
if occupancy == PortResolver.PortOccupancy.UNKNOWN:
_lifecycle.start_server()
return true
occupied = occupancy == PortResolver.PortOccupancy.OCCUPIED
else:
occupied = PortResolver.is_port_in_use(port)
if occupied:
push_warning( push_warning(
"MCP | refusing to restart the unowned server on port %d; stop it from its launcher" "MCP | refusing to restart the unowned server on port %d; stop it from its launcher"
% port % port
+24 -9
View File
@@ -156,8 +156,13 @@ func _process(_delta: float) -> void:
## through the debugger packet path in a single tick. Surplus stays in ## through the debugger packet path in a single tick. Surplus stays in
## `_pending_outbound` and bleeds out across subsequent frames. ## `_pending_outbound` and bleeds out across subsequent frames.
if not _logger_attached or _logger == null: if not _logger_attached or _logger == null:
_pending_outbound.clear()
return return
if not EngineDebugger.is_active(): if not EngineDebugger.is_active():
## No remote can consume these lines. Drop current and already-drained
## batches so a detached/headless run cannot retain logs indefinitely (#1123).
_logger.clear()
_pending_outbound.clear()
return return
if _pending_outbound.is_empty(): if _pending_outbound.is_empty():
if not _logger.has_pending(): if not _logger.has_pending():
@@ -343,7 +348,7 @@ func _capture_and_reply(
_reply_error(request_id, "Captured an empty image from game viewport") _reply_error(request_id, "Captured an empty image from game viewport")
return return
var encoded: Dictionary = ScreenshotEncode.downscale_and_encode(image, max_resolution) var encoded: Dictionary = ScreenshotEncode.downscale_and_encode(image, max_resolution, viewport.use_hdr_2d)
var frames_drawn := Engine.get_frames_drawn() var frames_drawn := Engine.get_frames_drawn()
var stale := frames_drawn <= frames_at_request var stale := frames_drawn <= frames_at_request
@@ -1024,13 +1029,7 @@ func _handle_eval(data: Array) -> void:
_eval_token_counter += 1 _eval_token_counter += 1
var token := str(_eval_token_counter) var token := str(_eval_token_counter)
var run_fn := "_mcp_run_%s" % token var run_fn := "_mcp_run_%s" % token
var script_source := ( var script_source := _build_eval_script_source(run_fn, code)
"extends Node\n"
+ "func execute():\n"
+ "\treturn await %s()\n\n" % run_fn
+ "func %s():\n" % run_fn
+ _indent_eval_code(code)
)
## Snapshot the logger's script-error seq BEFORE running so we only attribute ## Snapshot the logger's script-error seq BEFORE running so we only attribute
## errors raised by this eval. In a debug build a parse error aborts reload() ## errors raised by this eval. In a debug build a parse error aborts reload()
@@ -1227,7 +1226,23 @@ func _handle_eval_check(data: Array) -> void:
_try_report_eval_runtime_error(request_id) _try_report_eval_runtime_error(request_id)
func _indent_eval_code(code: String) -> String: ## `execute()` always returns, so it carries a return type. The inner function
## holds caller code that need not return on every path; a declared return type
## there makes Godot reject it with "Not all code paths return a value". It
## stays untyped and suppresses `untyped_declaration` on its own declaration
## instead (#1119). The annotation shares the `func` line so the line numbers
## reported for caller code do not move.
static func _build_eval_script_source(run_fn: String, code: String) -> String:
return (
"extends Node\n"
+ "func execute() -> Variant:\n"
+ "\treturn await %s()\n\n" % run_fn
+ "@warning_ignore(\"untyped_declaration\") func %s():\n" % run_fn
+ _indent_eval_code(code)
)
static func _indent_eval_code(code: String) -> String:
var lines: PackedStringArray = code.split("\n") var lines: PackedStringArray = code.split("\n")
var out := "" var out := ""
for line in lines: for line in lines:
+26
View File
@@ -37,6 +37,11 @@ var _mutex := Mutex.new()
## (types 0/1) never count. Mutex-guarded: _log_error can fire from any thread. ## (types 0/1) never count. Mutex-guarded: _log_error can fire from any thread.
const _ERROR_TYPE_SCRIPT := 2 const _ERROR_TYPE_SCRIPT := 2
const _MAX_RECENT_SCRIPT_ERRORS := 64 const _MAX_RECENT_SCRIPT_ERRORS := 64
const _MAX_PENDING := 4096
const _PENDING_TRIM_THRESHOLD := _MAX_PENDING * 2
## Lines trimmed from `_pending` since the last drain. The next drain reports
## the count as a leading warn entry, so a gap in the log is never silent.
var _dropped: int = 0
var _script_error_seq: int = 0 var _script_error_seq: int = 0
var _recent_script_errors: Array = [] var _recent_script_errors: Array = []
@@ -93,6 +98,19 @@ func _append(level: String, text: String, details: Dictionary = {}) -> void:
_pending.append([level, text]) _pending.append([level, text])
else: else:
_pending.append([level, text, details.duplicate(true)]) _pending.append([level, text, details.duplicate(true)])
## Drop an old batch when a script floods the logger before the next frame.
## Trimming at 2x the cap amortizes the slice instead of doing it per line.
if _pending.size() >= _PENDING_TRIM_THRESHOLD:
_dropped += _pending.size() - _MAX_PENDING
_pending = _pending.slice(_pending.size() - _MAX_PENDING)
_mutex.unlock()
## Drop queued lines that no active debugger can consume.
func clear() -> void:
_mutex.lock()
_pending.clear()
_dropped = 0
_mutex.unlock() _mutex.unlock()
@@ -102,7 +120,15 @@ func drain() -> Array:
_mutex.lock() _mutex.lock()
var out := _pending var out := _pending
_pending = [] _pending = []
var dropped := _dropped
_dropped = 0
_mutex.unlock() _mutex.unlock()
if dropped > 0:
out.push_front([
"warn",
"[godot_ai] dropped %d older game log lines: the game logged faster than they could be forwarded"
% dropped,
])
return out return out
+1
View File
@@ -50,6 +50,7 @@ const DOMAINS := [
{"id": "game", "label": "game", "count": 1, "tools": ["game_manage"]}, {"id": "game", "label": "game", "count": 1, "tools": ["game_manage"]},
{"id": "input_map", "label": "input_map", "count": 1, "tools": ["input_map_manage"]}, {"id": "input_map", "label": "input_map", "count": 1, "tools": ["input_map_manage"]},
{"id": "material", "label": "material", "count": 1, "tools": ["material_manage"]}, {"id": "material", "label": "material", "count": 1, "tools": ["material_manage"]},
{"id": "navigation", "label": "navigation", "count": 1, "tools": ["navigation_manage"]},
{"id": "node", "label": "node", "count": 4, "tools": ["node_create", "node_find", "node_manage", "node_set_property"]}, {"id": "node", "label": "node", "count": 4, "tools": ["node_create", "node_find", "node_manage", "node_set_property"]},
{"id": "particle", "label": "particle", "count": 1, "tools": ["particle_manage"]}, {"id": "particle", "label": "particle", "count": 1, "tools": ["particle_manage"]},
{"id": "project", "label": "project", "count": 2, "tools": ["project_manage", "project_run"]}, {"id": "project", "label": "project", "count": 2, "tools": ["project_manage", "project_run"]},
+14
View File
@@ -168,3 +168,17 @@ static func _is_private_ipv4(addr: String) -> bool:
var second := int(addr.get_slice(".", 1)) var second := int(addr.get_slice(".", 1))
return second >= 16 and second <= 31 return second >= 16 and second <= 31
return false return false
## Syntax and bind-family errors shared by settings and saved launch plans.
static func configuration_error(raw: String, platform: String = OS.get_name()) -> String:
var invalid := invalid_tokens(raw)
if not invalid.is_empty():
return "Invalid entries (must be a CIDR or bare IP, comma-separated): %s" % ", ".join(invalid)
var value := normalize(raw)
if platform != "Windows" or value.is_empty():
return ""
for token in value.split(","):
if not token.contains(":"):
return ""
return "IPv6-only remote access is unsupported on Windows: the editor and attach bridge require IPv4 loopback. Use an IPv4 allowlist or clear Allow remote hosts to disable remote access. Full IPv6 support is tracked in #1072."
+70 -6
View File
@@ -6,8 +6,8 @@ extends Node
## The Dock is replaceable UI. Threads cannot share that lifetime: a timed-out ## The Dock is replaceable UI. Threads cannot share that lifetime: a timed-out
## probe or package pre-warm can outlive the panel that requested it, and ## probe or package pre-warm can outlive the panel that requested it, and
## destroying a live Godot Thread corrupts the GDScript VM. This node therefore ## destroying a live Godot Thread corrupts the GDScript VM. This node therefore
## owns both worker pools, their cancellation, phases, and one action slot per ## owns both worker pools, their cancellation, phases, and one mutation slot
## client until its Thread has been joined. It publishes copied value snapshots ## until its Thread has been joined. It publishes copied value snapshots
## and outcomes; it never retains the Dock, plugin, lifecycle, or update manager. ## and outcomes; it never retains the Dock, plugin, lifecycle, or update manager.
const Client := preload("res://addons/godot_ai/clients/_base.gd") const Client := preload("res://addons/godot_ai/clients/_base.gd")
@@ -57,6 +57,7 @@ var _refresh_generation := 0
var _mcp_status_waiters: Dictionary = {} var _mcp_status_waiters: Dictionary = {}
var _action_threads: Dictionary = {} var _action_threads: Dictionary = {}
var _pending_actions: Array[Dictionary] = []
var _action_started_msec: Dictionary = {} var _action_started_msec: Dictionary = {}
var _action_names: Dictionary = {} var _action_names: Dictionary = {}
var _action_request_ids: Dictionary = {} var _action_request_ids: Dictionary = {}
@@ -106,7 +107,7 @@ func _ready() -> void:
func _has_work_in_flight() -> bool: func _has_work_in_flight() -> bool:
return _post_update_thread != null or _refresh_thread != null or not _action_threads.is_empty() return _post_update_thread != null or _refresh_thread != null or not _action_threads.is_empty() or not _pending_actions.is_empty()
## Construction is inert. The composition root calls activate only after its ## Construction is inert. The composition root calls activate only after its
@@ -128,8 +129,15 @@ func set_client_health_blocked(blocked: bool) -> void:
func snapshot() -> Dictionary: func snapshot() -> Dictionary:
var busy: Array[String] = [] var busy: Array[String] = []
var names := _action_names.duplicate(true)
var phases := _read_action_phases()
for client_id in _action_threads: for client_id in _action_threads:
busy.append(String(client_id)) busy.append(String(client_id))
for pending in _pending_actions:
var client_id := str(pending.client_id)
busy.append(client_id)
names[client_id] = pending.action
phases[client_id] = "queued"
busy.sort() busy.sort()
return { return {
"accepting_work": _accepting_work, "accepting_work": _accepting_work,
@@ -137,8 +145,8 @@ func snapshot() -> Dictionary:
"refresh_completed": _refresh_completed_msec > 0, "refresh_completed": _refresh_completed_msec > 0,
"post_update_repin_running": _post_update_thread != null, "post_update_repin_running": _post_update_thread != null,
"busy_actions": busy, "busy_actions": busy,
"action_names": _action_names.duplicate(true), "action_names": names,
"action_phases": _read_action_phases(), "action_phases": phases,
} }
@@ -154,6 +162,7 @@ func _process(_delta: float) -> void:
_poll_post_update_repin() _poll_post_update_repin()
_poll_refresh() _poll_refresh()
_poll_actions() _poll_actions()
_start_next_action()
_check_refresh_timeout() _check_refresh_timeout()
_check_action_timeouts() _check_action_timeouts()
_retry_deferred_refresh() _retry_deferred_refresh()
@@ -166,6 +175,7 @@ func _process(_delta: float) -> void:
func quiesce(deadline_msec: int = 0) -> Dictionary: func quiesce(deadline_msec: int = 0) -> Dictionary:
_accepting_work = false _accepting_work = false
_refresh_state = RefreshState.SHUTTING_DOWN _refresh_state = RefreshState.SHUTTING_DOWN
_pending_actions.clear()
_publish_snapshot() _publish_snapshot()
for client_id in _action_threads: for client_id in _action_threads:
_set_action_cancelled(String(client_id), true) _set_action_cancelled(String(client_id), true)
@@ -464,7 +474,7 @@ func _start_action(client_id: String, action: String, request_id: String) -> Dic
return {"ok": false, "error": "Client worker is unavailable."} return {"ok": false, "error": "Client worker is unavailable."}
if action not in ["configure", "remove"]: if action not in ["configure", "remove"]:
return {"ok": false, "error": "Unknown client action: %s" % action} return {"ok": false, "error": "Unknown client action: %s" % action}
if _mutation_termination_unproven.has(client_id): if _post_update_termination_unproven or not _mutation_termination_unproven.is_empty():
return { return {
"ok": false, "ok": false,
"error": MutationLock.recovery_message(), "error": MutationLock.recovery_message(),
@@ -472,8 +482,61 @@ func _start_action(client_id: String, action: String, request_id: String) -> Dic
} }
if _action_threads.has(client_id): if _action_threads.has(client_id):
return {"ok": false, "error": "A client action is already running for %s." % client_id} return {"ok": false, "error": "A client action is already running for %s." % client_id}
for pending in _pending_actions:
if pending.client_id == client_id:
return {"ok": false, "error": "A client action is already queued for %s." % client_id}
if not ClientConfigurator.has_client(client_id): if not ClientConfigurator.has_client(client_id):
return {"ok": false, "error": "Unknown client: %s" % client_id} return {"ok": false, "error": "Unknown client: %s" % client_id}
if request_id.is_empty():
_pending_actions.append({"client_id": client_id, "action": action})
_start_next_action()
_publish_snapshot()
return {"ok": true}
if not _action_threads.is_empty() or not _pending_actions.is_empty():
return {"ok": false, "error": "Another client action is running or queued. Retry when it finishes."}
return _begin_action(client_id, action, request_id)
func _start_next_action() -> void:
if not _accepting_work or not _action_threads.is_empty() or _pending_actions.is_empty():
return
if _post_update_termination_unproven or not _mutation_termination_unproven.is_empty() or MutationLock.is_locked():
_discard_pending_actions(MutationLock.recovery_message())
return
var pending: Dictionary = _pending_actions.pop_front()
var started := _begin_action(str(pending.client_id), str(pending.action), "")
if not bool(started.get("ok", false)):
action_completed.emit(str(pending.client_id), str(pending.action), {
"status": "error", "message": str(started.error),
}, {})
_publish_snapshot()
func cancel_pending_action(client_id: String) -> bool:
for index in range(_pending_actions.size()):
var entry := _pending_actions[index]
if str(entry.client_id) != client_id:
continue
_pending_actions.remove_at(index)
action_completed.emit(client_id, str(entry.action), {
"status": "cancelled", "message": "Client action cancelled before it started.",
}, {})
_publish_snapshot()
return true
return false
func _discard_pending_actions(message: String) -> void:
var pending := _pending_actions.duplicate(true)
_pending_actions.clear()
for entry in pending:
action_completed.emit(str(entry.client_id), str(entry.action), {
"status": "error", "message": message,
}, {})
_publish_snapshot()
func _begin_action(client_id: String, action: String, request_id: String) -> Dictionary:
ClientConfigurator.warm_env_snapshot() ClientConfigurator.warm_env_snapshot()
_set_action_cancelled(client_id, false) _set_action_cancelled(client_id, false)
_clear_action_phase(client_id) _clear_action_phase(client_id)
@@ -631,6 +694,7 @@ func _record_unproven_action_result(
return return
if ( if (
bool(result.get("termination_failed", false)) bool(result.get("termination_failed", false))
or bool(result.get("mutation_lock_release_failed", false))
or bool(prewarm.get("termination_failed", false)) or bool(prewarm.get("termination_failed", false))
): ):
_mark_mutation_termination_unproven(client_id, action) _mark_mutation_termination_unproven(client_id, action)
@@ -102,6 +102,14 @@ static func _root_path() -> String:
## Unlike user://, the OS config directory is shared by every Godot ## Unlike user://, the OS config directory is shared by every Godot
## project owned by this account. User-scope client config is shared too, ## project owned by this account. User-scope client config is shared too,
## so a project-specific lock would leave the real cross-editor race open. ## so a project-specific lock would leave the real cross-editor race open.
##
## A Flatpak editor that shares the home directory writes the host's client
## configs, the same files an editor outside the sandbox writes, while its
## own OS config directory is the per-app ~/.var/app/<id>/config. It takes
## the lock where those other editors look for it.
var host_config_home := McpPathTemplate.flatpak_host_config_home()
if not host_config_home.is_empty():
return host_config_home.path_join(ROOT_DIR_NAME)
return OS.get_config_dir().path_join(ROOT_DIR_NAME) return OS.get_config_dir().path_join(ROOT_DIR_NAME)
+5
View File
@@ -26,6 +26,11 @@ const EDITOR_NOT_READY := "EDITOR_NOT_READY"
const UNKNOWN_COMMAND := "UNKNOWN_COMMAND" const UNKNOWN_COMMAND := "UNKNOWN_COMMAND"
const INTERNAL_ERROR := "INTERNAL_ERROR" const INTERNAL_ERROR := "INTERNAL_ERROR"
const DEFERRED_TIMEOUT := "DEFERRED_TIMEOUT" const DEFERRED_TIMEOUT := "DEFERRED_TIMEOUT"
## #1120: filesystem mutation discovery failed before any disk effect
## (deadline/cancellation, entry/byte limits, or unreadable owner metadata).
## Keep this separate from INVALID_PARAMS so callers can distinguish an
## environmental discovery failure from a bad filesystem_manage request.
const FILESYSTEM_DISCOVERY_FAILED := "FILESYSTEM_DISCOVERY_FAILED"
## Python-originated transport/attach bridge codes. GDScript has no emit path, ## Python-originated transport/attach bridge codes. GDScript has no emit path,
## but the public registry intentionally mirrors protocol/errors.py. ## but the public registry intentionally mirrors protocol/errors.py.
const TRANSPORT_OUTCOME_UNKNOWN := "TRANSPORT_OUTCOME_UNKNOWN" const TRANSPORT_OUTCOME_UNKNOWN := "TRANSPORT_OUTCOME_UNKNOWN"
+241
View File
@@ -18,6 +18,17 @@ extends RefCounted
const COLOR_KEYS: Array[String] = ["r", "g", "b"] const COLOR_KEYS: Array[String] = ["r", "g", "b"]
const VECTOR2_KEYS: Array[String] = ["x", "y"] const VECTOR2_KEYS: Array[String] = ["x", "y"]
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"] const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
const VECTOR3I_KEYS: Array[String] = ["x", "y", "z"]
const QUATERNION_KEYS: Array[String] = ["x", "y", "z", "w"]
const BASIS_KEYS: Array[String] = ["x", "y", "z"]
const RECT2_KEYS: Array[String] = ["position", "size"]
const AABB_KEYS: Array[String] = ["position", "size"]
## Vector3i components are 32-bit signed integers, and GDScript's int()
## conversion wraps (2147483648 -> -2147483648) or zeroes (1e40 -> 0)
## out-of-range floats, so the range is checked before any conversion.
const INT32_MIN := -2147483648
const INT32_MAX := 2147483647
static func parse_color(value: Variant) -> Variant: static func parse_color(value: Variant) -> Variant:
@@ -104,5 +115,235 @@ static func parse_float(value: Variant) -> Variant:
return null return null
static func parse_vector3i(value: Variant) -> Variant:
if value is Vector3i:
return value
var components: Array = []
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(VECTOR3I_KEYS):
return null
components = [d.x, d.y, d.z]
elif value is Array:
var arr: Array = value
if arr.size() != 3:
return null
components = arr
else:
return null
var parsed: Array = []
for component in components:
var number: Variant = _int32_component(component)
if number == null:
return null
parsed.append(number)
return Vector3i(parsed[0], parsed[1], parsed[2])
static func parse_quaternion(value: Variant) -> Variant:
if value is Quaternion:
return value
var components: Array = []
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(QUATERNION_KEYS):
return null
components = [d.x, d.y, d.z, d.w]
elif value is Array:
var arr: Array = value
if arr.size() != 4:
return null
components = arr
else:
return null
var parsed: Array = []
for component in components:
if not _is_finite_number(component):
return null
parsed.append(float(component))
## Mathematical value only: a zero-length quaternion is returned as-is.
## The animation boundary (AnimationValues.coerce_for_type) owns the
## rotation contract — it rejects zero-length and normalizes the rest.
var quat := Quaternion(parsed[0], parsed[1], parsed[2], parsed[3])
## Components are float32: a finite double (e.g. 1e40) can still overflow
## to INF at construction, so finiteness is re-checked on the result.
if not _is_finite_quaternion(quat):
return null
return quat
## Basis: accepts the canonical serializer shape ({x:{x,y,z}, y:{…}, z:{…}})
## and a 3-element array of axis vectors. Each axis goes through
## `parse_vector3`, so the component strictness is shared.
static func parse_basis(value: Variant) -> Variant:
if value is Basis:
return value
var rows: Array = []
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(BASIS_KEYS):
return null
rows = [d.x, d.y, d.z]
elif value is Array:
var arr: Array = value
if arr.size() != 3:
return null
rows = arr
else:
return null
var axes: Array = []
for row in rows:
var axis: Variant = parse_vector3(row)
if axis == null or not _is_finite_vector3(axis as Vector3):
return null
axes.append(axis)
return Basis(axes[0], axes[1], axes[2])
## Transform3D: accepts the canonical serializer shape
## ({basis:{x,y,z}, origin:{x,y,z}}) and the ergonomic
## ({position, rotation_degrees?, scale?}) shape — rotation defaults to zero
## and scale to one so a bare position is a valid translation.
static func parse_transform3d(value: Variant) -> Variant:
if value is Transform3D:
return value
if not value is Dictionary:
return null
var d: Dictionary = value
if d.has("basis") or d.has("origin"):
if not (d.has("basis") and d.has("origin")):
return null
var basis: Variant = parse_basis(d.basis)
var origin: Variant = parse_vector3(d.origin)
if basis == null or origin == null or not _is_finite_vector3(origin as Vector3):
return null
return Transform3D(basis, origin)
if d.has("position"):
var pos: Variant = parse_vector3(d.position)
if pos == null or not _is_finite_vector3(pos as Vector3):
return null
var rot_basis := Basis.IDENTITY
if d.has("rotation_degrees"):
var rot_deg: Variant = parse_vector3(d.rotation_degrees)
if rot_deg == null or not _is_finite_vector3(rot_deg as Vector3):
return null
rot_basis = Basis.from_euler((rot_deg as Vector3) * (PI / 180.0))
elif d.has("rotation"):
var rot: Variant = parse_vector3(d.rotation)
if rot == null or not _is_finite_vector3(rot as Vector3):
return null
rot_basis = Basis.from_euler(rot)
if d.has("scale"):
var scale: Variant = parse_vector3(d.scale)
if scale == null or not _is_finite_vector3(scale as Vector3):
return null
## Local axes: the ergonomic shape describes a node transform, and
## `scaled()` would apply the scale in global axes (skewing the
## rotation for a non-uniform scale).
rot_basis = rot_basis.scaled_local(scale)
return Transform3D(rot_basis, pos)
return null
static func parse_rect2(value: Variant) -> Variant:
if value is Rect2:
return value
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(RECT2_KEYS):
return null
var pos: Variant = parse_vector2(d.position)
var size: Variant = parse_vector2(d.size)
if pos == null or size == null:
return null
if not (_is_finite_vector2(pos as Vector2) and _is_finite_vector2(size as Vector2)):
return null
return Rect2(pos, size)
if value is Array:
var arr: Array = value
if arr.size() != 4:
return null
for item in arr:
if not _is_number(item):
return null
var rect := Rect2(float(arr[0]), float(arr[1]), float(arr[2]), float(arr[3]))
if not (_is_finite_vector2(rect.position) and _is_finite_vector2(rect.size)):
return null
return rect
return null
static func parse_aabb(value: Variant) -> Variant:
if value is AABB:
return value
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(AABB_KEYS):
return null
var pos: Variant = parse_vector3(d.position)
var size: Variant = parse_vector3(d.size)
if pos == null or size == null:
return null
if not (_is_finite_vector3(pos as Vector3) and _is_finite_vector3(size as Vector3)):
return null
return AABB(pos, size)
if value is Array:
var arr: Array = value
if arr.size() != 6:
return null
for item in arr:
if not _is_number(item):
return null
var pos := Vector3(float(arr[0]), float(arr[1]), float(arr[2]))
var size := Vector3(float(arr[3]), float(arr[4]), float(arr[5]))
if not (_is_finite_vector3(pos) and _is_finite_vector3(size)):
return null
return AABB(pos, size)
return null
static func parse_node_path(value: Variant) -> Variant:
if value is NodePath:
return value
if value is String:
return NodePath(value)
return null
static func parse_string_name(value: Variant) -> Variant:
if value is StringName:
return value
if value is String:
return StringName(value)
return null
static func _is_number(v: Variant) -> bool: static func _is_number(v: Variant) -> bool:
return v is int or v is float return v is int or v is float
## One Vector3i component: finite and within int32 range, else null.
## In-range fractional values keep the existing truncation policy.
static func _int32_component(v: Variant) -> Variant:
if not _is_number(v):
return null
var number := float(v)
if not is_finite(number) or number < float(INT32_MIN) or number > float(INT32_MAX):
return null
return int(number)
static func _is_finite_number(v: Variant) -> bool:
return _is_number(v) and is_finite(float(v))
static func _is_finite_vector2(v: Vector2) -> bool:
return is_finite(v.x) and is_finite(v.y)
static func _is_finite_vector3(v: Vector3) -> bool:
return is_finite(v.x) and is_finite(v.y) and is_finite(v.z)
static func _is_finite_quaternion(v: Quaternion) -> bool:
return is_finite(v.x) and is_finite(v.y) and is_finite(v.z) and is_finite(v.w)
+160
View File
@@ -0,0 +1,160 @@
@tool
extends RefCounted
## Linux process and listener evidence from the editor's own PID/network
## namespace. Never run host commands across a Flatpak/Steam sandbox boundary.
## proc files report length zero: read bounded chunks, not get_as_text().
static func read_text(path: String, limit := 1024 * 1024) -> Dictionary:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return {"known": false, "text": ""}
var bytes := PackedByteArray()
while bytes.size() <= limit:
var chunk := file.get_buffer(mini(4096, limit + 1 - bytes.size()))
if chunk.is_empty():
break
bytes.append_array(chunk)
if file.eof_reached():
break
var error := file.get_error()
file.close()
if bytes.size() > limit or error not in [OK, ERR_FILE_EOF]:
return {"known": false, "text": ""}
for index in range(bytes.size()):
if bytes[index] == 0:
bytes[index] = 32
return {"known": true, "text": bytes.get_string_from_utf8()}
static func parse_stat(raw: String, expected_pid: int) -> Dictionary:
## comm may contain spaces, parentheses and newlines. The final ')' is
## the delimiter; field 22 (starttime) is index 19 after comm.
var opening := raw.find(" (")
var closing := raw.rfind(") ")
if opening < 1 or closing <= opening:
return {}
var pid_text := raw.substr(0, opening)
var fields := raw.substr(closing + 2).strip_edges().split(" ", false)
if not pid_text.is_valid_int() or int(pid_text) != expected_pid or fields.size() < 20:
return {}
if fields[0] not in ["R", "S", "D", "T", "t", "Z", "X", "x", "K", "W", "P", "I"]:
return {}
if not fields[1].is_valid_int() or int(fields[1]) < 0 or not fields[19].is_valid_int() or int(fields[19]) < 0:
return {}
return {"state": fields[0], "parent_pid": int(fields[1]), "start": fields[19]}
static func process_stat(pid: int, root := "/proc") -> Dictionary:
var read := read_text(root.path_join("%d/stat" % pid), 8192)
return parse_stat(read.text, pid) if read.known else {}
static func is_alive(row: Dictionary) -> bool:
return not row.is_empty() and row.state not in ["Z", "X", "x"]
static func commandline(pid: int, root := "/proc") -> String:
var read := read_text(root.path_join("%d/cmdline" % pid))
return str(read.text).strip_edges() if read.known else ""
static func process_snapshot(pid: int, root := "/proc") -> Dictionary:
if DirAccess.open(root) == null:
return {"capture_error": true}
var snapshot := {}
var current := pid
for _depth in range(16):
if current <= 1:
break
if snapshot.has(current):
return {"capture_error": true}
var first := process_stat(current, root)
if first.is_empty():
## A vanished process differs from an unreadable proc mount.
return snapshot if not DirAccess.dir_exists_absolute(root.path_join(str(current))) else {"capture_error": true}
if not is_alive(first):
return snapshot
var read := read_text(root.path_join("%d/cmdline" % current))
var final := process_stat(current, root)
if not read.known or not is_alive(final) or first.start != final.start or first.parent_pid != final.parent_pid:
return {"capture_error": true}
var command := str(read.text).strip_edges()
snapshot[current] = {"pid": current, "parent_pid": first.parent_pid,
"identity": "linux:" + str(first.start) + "|" + command, "commandline": command}
current = int(first.parent_pid)
return snapshot
static func parse_tcp(raw: String) -> Dictionary:
var listeners := {}
var lines := raw.strip_edges().split("\n", false)
if lines.is_empty() or not lines[0].contains("local_address") or not lines[0].contains("inode"):
return {"known": false, "listeners": {}}
for line in lines.slice(1):
var fields := line.replace("\t", " ").strip_edges().split(" ", false)
if fields.size() < 10 or not fields[1].contains(":"):
return {"known": false, "listeners": {}}
if fields[3] != "0A":
continue
var port_hex := fields[1].get_slice(":", 1)
if port_hex.length() != 4 or not port_hex.is_valid_hex_number() or not fields[9].is_valid_int() or int(fields[9]) <= 0:
return {"known": false, "listeners": {}}
var port := port_hex.hex_to_int()
if not listeners.has(port):
listeners[port] = []
listeners[port].append(fields[9])
return {"known": true, "listeners": listeners}
static func listener_snapshot(root := "/proc") -> Dictionary:
var listeners := {}
for name in ["tcp", "tcp6"]:
var path := root.path_join("net/" + name)
## IPv6 may be disabled at kernel build time; IPv4 must be readable.
if name == "tcp6" and not FileAccess.file_exists(path):
continue
var read := read_text(path, 8 * 1024 * 1024)
var parsed := parse_tcp(read.text) if read.known else {"known": false}
if not parsed.known:
return {"known": false, "listeners": {}}
for port in parsed.listeners:
if not listeners.has(port):
listeners[port] = []
listeners[port].append_array(parsed.listeners[port])
return {"known": true, "listeners": listeners}
static func listener_pids(port: int, snapshot: Dictionary, root := "/proc") -> Array[int]:
var result: Array[int] = []
if not snapshot.get("known", false) or not snapshot.listeners.has(port):
return result
var wanted: Array = snapshot.listeners[port]
var proc := DirAccess.open(root)
if proc == null:
return result
for name in proc.get_directories():
if not name.is_valid_int() or int(name) <= 1:
continue
var descriptors := DirAccess.open(root.path_join(name).path_join("fd"))
if descriptors == null:
continue
## fd entries are symlinks; their socket targets need not exist as files.
descriptors.list_dir_begin()
var descriptor := descriptors.get_next()
while not descriptor.is_empty():
var target := descriptors.read_link(descriptor)
if target.begins_with("socket:[") and target.ends_with("]") and wanted.has(target.substr(8, target.length() - 9)):
result.append(int(name))
break
descriptor = descriptors.get_next()
descriptors.list_dir_end()
return result
static func available() -> bool:
return (
is_alive(process_stat(OS.get_process_id()))
and DirAccess.open("/proc/self/fd") != null
and listener_snapshot().known
)
+1
View File
@@ -0,0 +1 @@
uid://b6ojjbvx02x1g
+5 -2
View File
@@ -14,6 +14,7 @@ const PORT_EXCLUDED := 7
const FOREIGN_PORT := 8 const FOREIGN_PORT := 8
const STOPPING := 10 const STOPPING := 10
const STOPPED := 11 const STOPPED := 11
const UNSUPPORTED_CONFIG := 12
const _NAMES := { const _NAMES := {
UNINITIALIZED: "uninitialized", UNINITIALIZED: "uninitialized",
@@ -26,6 +27,7 @@ const _NAMES := {
FOREIGN_PORT: "foreign_port", FOREIGN_PORT: "foreign_port",
STOPPING: "stopping", STOPPING: "stopping",
STOPPED: "stopped", STOPPED: "stopped",
UNSUPPORTED_CONFIG: "unsupported_config",
} }
@@ -45,13 +47,14 @@ static func is_terminal_diagnosis(state: int) -> bool:
or state == PORT_EXCLUDED or state == PORT_EXCLUDED
or state == INCOMPATIBLE or state == INCOMPATIBLE
or state == FOREIGN_PORT or state == FOREIGN_PORT
or state == UNSUPPORTED_CONFIG
) )
## True when the dock should skip interpreting client health (incompatible ## True when the dock should skip interpreting client health (incompatible
## tool surface). This must NOT block Configure writes — those take an ## tool surface). This must NOT block Configure writes — those take an
## explicit url and the live plugin version (#916). Currently just ## explicit url and the live plugin version (#916). Currently just
## INCOMPATIBLE — FOREIGN_PORT is transitional and may resolve to READY ## INCOMPATIBLE and UNSUPPORTED_CONFIG — FOREIGN_PORT may resolve to READY
## if the foreign occupant turns out to speak our handshake. ## if the foreign occupant turns out to speak our handshake.
static func blocks_client_health(state: int) -> bool: static func blocks_client_health(state: int) -> bool:
return state == INCOMPATIBLE return state in [INCOMPATIBLE, UNSUPPORTED_CONFIG]
+51
View File
@@ -0,0 +1,51 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MAX_TRIANGLES := 2048
const MAX_VERTICES := 6144
const MAX_SURFACES := 32
static func estimate(mesh: Mesh) -> Dictionary:
if mesh.get_script() != null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Scripted meshes cannot be bounded before geometry extraction; use an ArrayMesh without a script")
var triangles := 0
var vertices := 0
if mesh is ArrayMesh:
var array_mesh := mesh as ArrayMesh
var surface_count := array_mesh.get_surface_count()
if surface_count > MAX_SURFACES:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Geometry extraction supports at most %d mesh surfaces; found %d" % [MAX_SURFACES, surface_count])
for surface in surface_count:
var vertex_count := array_mesh.surface_get_array_len(surface)
vertices += vertex_count
if array_mesh.surface_get_primitive_type(surface) != Mesh.PRIMITIVE_TRIANGLES:
continue
var index_count := array_mesh.surface_get_array_index_len(surface)
triangles += int((index_count if index_count > 0 else vertex_count) / 3)
return {"triangles": triangles, "vertices": vertices}
# PrimitiveMesh surface counters can generate the entire mesh on first use.
if mesh is BoxMesh:
var box := mesh as BoxMesh
var x := mini(box.subdivide_width, MAX_TRIANGLES) + 1
var y := mini(box.subdivide_height, MAX_TRIANGLES) + 1
var z := mini(box.subdivide_depth, MAX_TRIANGLES) + 1
triangles = 4 * (x * y + x * z + y * z)
elif mesh is PlaneMesh:
var plane := mesh as PlaneMesh
triangles = 2 * (mini(plane.subdivide_width, MAX_TRIANGLES) + 1) * (mini(plane.subdivide_depth, MAX_TRIANGLES) + 1)
elif mesh is SphereMesh or mesh is CylinderMesh or mesh is CapsuleMesh:
var radial := mini(int(mesh.get("radial_segments")), MAX_TRIANGLES)
var rings := mini(int(mesh.get("rings")), MAX_TRIANGLES)
triangles = 2 * radial * (rings + 2)
if mesh is CapsuleMesh:
triangles *= 3
elif mesh is PointMesh:
return {"triangles": 0, "vertices": 1}
else:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Mesh class %s has no bounded geometry preflight; use ArrayMesh, BoxMesh, PlaneMesh, SphereMesh, CylinderMesh or CapsuleMesh" % mesh.get_class())
return {"triangles": triangles, "vertices": triangles * 3}
@@ -0,0 +1 @@
uid://c7rw0m5m0i1x5
+245 -142
View File
@@ -14,8 +14,23 @@ const SERVER_PID_FILE := "user://godot_ai_server.pid"
## spawn, so a report that exists afterwards belongs to that launch. ## spawn, so a report that exists afterwards belongs to that launch.
const SERVER_STARTUP_REPORT := "user://godot_ai_server_startup.json" const SERVER_STARTUP_REPORT := "user://godot_ai_server_startup.json"
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
const LinuxProc := preload("res://addons/godot_ai/utils/linux_proc.gd")
const SNAPSHOT_DIAGNOSTIC_STAGES := ["single", "pair", "first", "final"]
const SNAPSHOT_DIAGNOSTIC_CATEGORIES := [
"invalid_pid", "shell_exit", "empty_output", "outer_size", "outer_json", "outer_shape",
"snapshot_size", "snapshot_json", "snapshot_shape", "collector_null", "row_shape",
"row_pid", "row_chain", "row_identity", "lineage_cycle",
]
## Public lifecycle evidence uses a smaller, stable vocabulary than the
## internal collector diagnostics. Keep the detailed category internally so a
## future fix can distinguish malformed JSON from an inaccessible ancestor.
const SNAPSHOT_FAILURE_CATEGORIES := [
"process_query", "json_shape", "ancestor_capture", "identity_mismatch",
]
static var _process_spawn_mutex := Mutex.new() static var _process_spawn_mutex := Mutex.new()
enum PortOccupancy { UNKNOWN, FREE, OCCUPIED }
## Serialize plugin-owned subprocess creation across the brief process-global ## Serialize plugin-owned subprocess creation across the brief process-global
## environment override used for server capabilities. Callers must keep this ## environment override used for server capabilities. Callers must keep this
@@ -28,10 +43,10 @@ static func unlock_process_spawn() -> void:
_process_spawn_mutex.unlock() _process_spawn_mutex.unlock()
## A managed Linux server needs a listener PID tool to prove ownership. ## Linux can prove ownership through procfs inside minimal desktop sandboxes.
## Query each launch so installing the missing tool makes Retry work. ## Query each launch so installing the missing tool makes Retry work.
static func listener_tools_problem() -> String: static func listener_tools_problem() -> String:
if OS.get_name() != "Linux": if OS.get_name() != "Linux" or LinuxProc.available():
return "" return ""
var output: Array = [] var output: Array = []
var available := OS.execute("/bin/sh", ["-c", var available := OS.execute("/bin/sh", ["-c",
@@ -39,7 +54,7 @@ static func listener_tools_problem() -> String:
], output, true) ], output, true)
if available == 0: if available == 0:
return "" return ""
return "Cannot verify Linux listener ownership: neither lsof nor ss is available on the editor's PATH. Install lsof or iproute2 (which provides ss), then retry starting the server." return "Cannot verify Linux listener ownership: /proc is unavailable and neither lsof nor ss is available on the editor's PATH. Make /proc accessible inside the editor environment, or install lsof or iproute2 there, then retry starting the server."
static func can_bind_local_port(port: int) -> bool: static func can_bind_local_port(port: int) -> bool:
@@ -51,18 +66,11 @@ static func can_bind_local_port(port: int) -> bool:
return false return false
## True when `port` is bound on 127.0.0.1. Probes via TCPServer first, ## Windows permits a loopback bind beside a wildcard listener. Use the OS
## falls back to OS scraping. Callers that want per-scraper trace ## listener table, and treat failed queries as unavailable rather than free.
## counters should call `is_port_in_use_via_scrape` with a trace hook
## after their own `can_bind_local_port` probe.
static func is_port_in_use(port: int) -> bool: static func is_port_in_use(port: int) -> bool:
if can_bind_local_port(port): if OS.get_name() == "Windows":
## On POSIX, an IPv6 wildcard listener can coexist with a return windows_port_occupancy(port) != PortOccupancy.FREE
## successful 127.0.0.1 bind probe. Confirm with lsof so startup
## sees the same listener set that shutdown/recovery would see.
if OS.get_name() != "Windows":
return is_port_in_use_via_scrape(port)
return false
return is_port_in_use_via_scrape(port) return is_port_in_use_via_scrape(port)
@@ -71,23 +79,16 @@ static func is_port_in_use(port: int) -> bool:
## a wrapping caller's startup trace sees a genuine PowerShell fallback ## a wrapping caller's startup trace sees a genuine PowerShell fallback
## as `powershell`, not as a silent extra second under `netstat`. ## as `powershell`, not as a silent extra second under `netstat`.
static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -> bool: static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -> bool:
if OS.get_name() == "Linux":
var linux := LinuxProc.listener_snapshot()
if linux.known:
return linux.listeners.has(port)
## A failed observation cannot prove that a port is free.
if not can_bind_local_port(port):
return true
var output: Array = [] var output: Array = []
if OS.get_name() == "Windows": if OS.get_name() == "Windows":
_trace(trace, "netstat") return windows_port_occupancy(port, windows_listener_snapshot(trace)) != PortOccupancy.FREE
var exit_code := OS.execute("netstat", ["-ano"], output, true)
if exit_code == 0 and output.size() > 0:
var stdout := str(output[0])
if parse_windows_netstat_listening(stdout, port):
return true
## A healthy dump with no listener row IS the answer — don't
## pay the ~1.2s powershell.exe spawn to confirm "not in use"
## (see find_all_pids_on_port for the cost rationale).
if windows_netstat_dump_parseable(stdout):
return false
## Fallback: netstat can be absent or unparseable on
## stripped/locale-odd Windows installs.
_trace(trace, "powershell")
return not find_listener_pids_windows(port).is_empty()
_trace(trace, "lsof") _trace(trace, "lsof")
var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true)
if exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty(): if exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty():
@@ -102,8 +103,89 @@ static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -
) )
## One snapshot per selection operation avoids a subprocess for every candidate.
## Unknown observations never prove a port free. This is not ownership evidence.
static func windows_listener_snapshot(trace: Callable = Callable()) -> Dictionary:
var output: Array = []
_trace(trace, "netstat")
var code := OS.execute("netstat", ["-ano"], output, true)
var snapshot := windows_snapshot_from_netstat(code, output)
if snapshot.known:
return snapshot
_trace(trace, "powershell")
output.clear()
var script := "$ErrorActionPreference='Stop'; try { $listeners = @(Get-NetTCPConnection -ErrorAction Stop | Where-Object { $_.State -eq 'Listen' } | Select-Object LocalPort,OwningProcess); ConvertTo-Json -Compress -Depth 3 -InputObject @{ listeners = $listeners }; exit 0 } catch { exit 1 }"
code = execute_windows_powershell(script, output)
return windows_snapshot_from_powershell(code, output)
static func windows_port_occupancy(port: int, snapshot: Dictionary = {}) -> PortOccupancy:
if snapshot.is_empty():
snapshot = windows_listener_snapshot()
if not snapshot.known:
return PortOccupancy.UNKNOWN
return PortOccupancy.OCCUPIED if snapshot.listeners.has(port) else PortOccupancy.FREE
static func windows_snapshot_from_netstat(exit_code: int, output: Array) -> Dictionary:
var unknown := {"known": false, "listeners": {}}
if exit_code != 0 or output.is_empty():
return unknown
var listeners := {}
var saw_tcp := false
for line in str(output[0]).split("\n"):
var fields := split_on_whitespace(line.strip_edges())
if fields.is_empty() or fields[0].to_upper() != "TCP":
continue
if fields.size() < 5 or not fields[1].contains(":") or not fields[2].contains(":"):
return unknown
var port_text := fields[1].get_slice(":", fields[1].get_slice_count(":") - 1)
var remote_port_text := fields[2].get_slice(":", fields[2].get_slice_count(":") - 1)
var pid_text := fields[fields.size() - 1]
if not port_text.is_valid_int() or not remote_port_text.is_valid_int() or not pid_text.is_valid_int():
return unknown
var port := int(port_text)
var pid := int(pid_text)
if port < 1 or port > 65535 or pid < 0 or pid > 4294967295 or int(remote_port_text) < 0 or int(remote_port_text) > 65535:
return unknown
saw_tcp = true
## Windows localizes the state column, but listeners keep a wildcard foreign port.
if fields[2].ends_with(":0"):
_record_windows_listener(listeners, port, pid)
return {"known": true, "listeners": listeners} if saw_tcp else unknown
static func windows_snapshot_from_powershell(exit_code: int, output: Array) -> Dictionary:
var unknown := {"known": false, "listeners": {}}
if exit_code != 0 or output.is_empty():
return unknown
var parsed: Variant = JSON.parse_string(str(output[0]))
if not (parsed is Dictionary) or not (parsed.get("listeners") is Array):
return unknown
var listeners := {}
for row in parsed.listeners:
if not (row is Dictionary):
return unknown
var port: Variant = row.get("LocalPort")
var pid: Variant = row.get("OwningProcess")
if not (port is int or port is float) or not is_finite(float(port)) or float(port) != floor(float(port)) or port < 1 or port > 65535:
return unknown
if not (pid is int or pid is float) or not is_finite(float(pid)) or float(pid) != floor(float(pid)) or pid < 0 or pid > 4294967295:
return unknown
_record_windows_listener(listeners, int(port), int(pid))
return {"known": true, "listeners": listeners}
static func _record_windows_listener(listeners: Dictionary, port: int, pid: int) -> void:
if not listeners.has(port):
listeners[port] = []
## PID 0 still occupies the port, but cannot identify a process we may stop.
if pid > 0 and not listeners[port].has(pid):
listeners[port].append(pid)
## Return the PID currently listening on the given TCP port, or 0 if ## Return the PID currently listening on the given TCP port, or 0 if
## the port is free. Thin convenience wrapper around `find_all_pids_on_port` ## no positive listener PID could be observed. Thin wrapper around `find_all_pids_on_port`
## — the per-OS scraping logic lives in one place. ## — the per-OS scraping logic lives in one place.
static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int: static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int:
var pids := find_all_pids_on_port(port, trace) var pids := find_all_pids_on_port(port, trace)
@@ -119,28 +201,20 @@ static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int:
## can keep its cold-start trace accurate. The Windows path may fall ## can keep its cold-start trace accurate. The Windows path may fall
## through netstat → PowerShell, and a wrapping caller can't see which ## through netstat → PowerShell, and a wrapping caller can't see which
## scraper actually ran without the hook. ## scraper actually ran without the hook.
static func find_all_pids_on_port(port: int, trace: Callable = Callable()) -> Array[int]: static func find_all_pids_on_port(port: int, trace: Callable = Callable(), snapshot: Dictionary = {}) -> Array[int]:
if OS.get_name() == "Windows": if OS.get_name() == "Windows":
var output: Array = [] if snapshot.is_empty():
_trace(trace, "netstat") snapshot = windows_listener_snapshot(trace)
var exit_code := OS.execute("netstat", ["-ano"], output, true) var pids: Array[int] = []
if exit_code == 0 and not output.is_empty(): if snapshot.known:
var stdout := str(output[0]) pids.assign(snapshot.listeners.get(port, []))
var netstat_pids := parse_windows_netstat_pids(stdout, port) return pids
if not netstat_pids.is_empty(): if OS.get_name() == "Linux":
return netstat_pids var linux := LinuxProc.listener_snapshot()
## An empty per-port parse from a healthy dump IS the answer if linux.known:
## ("no listener"). Confirming it through the PowerShell probe var linux_pids := LinuxProc.listener_pids(port, linux)
## costs a powershell.exe spawn (~1.2s measured) against ~30ms if not linux_pids.is_empty() or not linux.listeners.has(port):
## for the netstat scrape — two such confirmations dominated a return linux_pids
## ~7s Windows startup walk. Only fall through when the dump
## itself is unusable (netstat absent, or so format-odd that
## zero TCP rows parse).
if windows_netstat_dump_parseable(stdout):
var no_listeners: Array[int] = []
return no_listeners
_trace(trace, "powershell")
return find_listener_pids_windows(port)
var output: Array = [] var output: Array = []
_trace(trace, "lsof") _trace(trace, "lsof")
var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true)
@@ -164,17 +238,6 @@ static func _trace(trace: Callable, counter: String) -> void:
trace.call(counter) trace.call(counter)
static func find_listener_pids_windows(port: int) -> Array[int]:
var script := (
"Get-NetTCPConnection -LocalPort %d -State Listen "
+ "-ErrorAction SilentlyContinue | "
+ "Select-Object -ExpandProperty OwningProcess"
) % port
var output: Array = []
var exit_code := execute_windows_powershell(script, output)
return windows_listener_pids_from_execute_result(exit_code, output)
static func execute_windows_powershell(script: String, output: Array) -> int: static func execute_windows_powershell(script: String, output: Array) -> int:
var args := ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script] var args := ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]
for exe in windows_powershell_candidates(): for exe in windows_powershell_candidates():
@@ -205,17 +268,6 @@ static func windows_powershell_candidates() -> Array[String]:
return candidates return candidates
static func windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]:
var empty: Array[int] = []
if exit_code == 0 and not output.is_empty():
return parse_pid_lines(str(output[0]))
return empty
static func windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool:
return not windows_listener_pids_from_execute_result(exit_code, output).is_empty()
## Pure parser for `lsof -ti` output — newline-separated decimal PIDs. ## Pure parser for `lsof -ti` output — newline-separated decimal PIDs.
## Empty lines, non-positive values, non-numeric tokens, and duplicate ## Empty lines, non-positive values, non-numeric tokens, and duplicate
## IPv4/IPv6 rows for the same listener are dropped. ## IPv4/IPv6 rows for the same listener are dropped.
@@ -257,7 +309,7 @@ static func parse_pid_lines(raw: String) -> Array[int]:
## Parse a Windows `netstat -ano` dump and return PIDs of rows whose ## Parse a Windows `netstat -ano` dump and return PIDs of rows whose
## local address ends with `:port` AND state is `LISTENING`. Substring ## local address ends with `:port` and foreign port is zero. Substring
## matching the whole dump is wrong: a remote address containing ## matching the whole dump is wrong: a remote address containing
## `:port` would false-positive against an unrelated ESTABLISHED row. ## `:port` would false-positive against an unrelated ESTABLISHED row.
static func parse_windows_netstat_pid(stdout: String, port: int) -> int: static func parse_windows_netstat_pid(stdout: String, port: int) -> int:
@@ -266,54 +318,15 @@ static func parse_windows_netstat_pid(stdout: String, port: int) -> int:
static func parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]: static func parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]:
var snapshot := windows_snapshot_from_netstat(0, [stdout])
var pids: Array[int] = [] var pids: Array[int] = []
var port_suffix := ":%d" % port if snapshot.known:
for line in stdout.split("\n"): pids.assign(snapshot.listeners.get(port, []))
var s := line.strip_edges()
if s.is_empty():
continue
var fields := split_on_whitespace(s)
if fields.size() < 5: # proto, local, remote, state, pid
continue
## Locale-independent listener signal (mirrors script/_dev_env.py):
## the state column is localized ("LISTENING"/"ABHÖREN"/"ÉCOUTE"...),
## but a listener's FOREIGN address is always the wildcard ":0".
if not fields[2].ends_with(":0"):
continue
if not fields[1].ends_with(port_suffix):
continue
var pid_str := fields[fields.size() - 1]
if pid_str.is_valid_int():
var pid := int(pid_str)
if pid > 0 and not pids.has(pid):
pids.append(pid)
return pids return pids
static func parse_windows_netstat_listening(stdout: String, port: int) -> bool:
return parse_windows_netstat_pid(stdout, port) > 0
## True when `stdout` looks like a healthy `netstat -ano` dump: at least
## one row parses as a TCP connection (proto column literally "TCP", an
## address containing ":", an integer PID in the last column). Locale-
## independent — protocol names are never localized, unlike the state
## column. Gates whether an empty per-port parse can be trusted as "no
## listener": a live Windows host always carries TCP rows (svchost/RPC
## listen on 135 at minimum), so a dump with zero parseable rows means
## netstat itself is absent/broken and the PowerShell fallback must run.
static func windows_netstat_dump_parseable(stdout: String) -> bool: static func windows_netstat_dump_parseable(stdout: String) -> bool:
for line in stdout.split("\n"): return bool(windows_snapshot_from_netstat(0, [stdout]).known)
var fields := split_on_whitespace(line.strip_edges())
if fields.size() < 5:
continue
if fields[0].to_upper() != "TCP":
continue
if fields[1].find(":") < 0:
continue
if fields[fields.size() - 1].is_valid_int():
return true
return false
## `String.split(" ", false)` only splits on single spaces; netstat ## `String.split(" ", false)` only splits on single spaces; netstat
@@ -363,6 +376,8 @@ static func pid_alive(pid: int, snapshot: Variant = null) -> bool:
return false return false
if snapshot != null: if snapshot != null:
return not _process_snapshot_row(snapshot, pid).is_empty() return not _process_snapshot_row(snapshot, pid).is_empty()
if OS.get_name() == "Linux":
return LinuxProc.is_alive(LinuxProc.process_stat(pid))
if OS.get_name() == "Windows": if OS.get_name() == "Windows":
var output: Array = [] var output: Array = []
var exit_code := OS.execute("tasklist", ["/FI", "PID eq %d" % pid, "/NH", "/FO", "CSV"], output, true) var exit_code := OS.execute("tasklist", ["/FI", "PID eq %d" % pid, "/NH", "/FO", "CSV"], output, true)
@@ -386,19 +401,26 @@ static func pid_alive(pid: int, snapshot: Variant = null) -> bool:
## One Windows query captures the target and at most fifteen ancestors. The ## One Windows query captures the target and at most fifteen ancestors. The
## dictionary belongs to one proof boundary, never a cache: callers must take ## dictionary belongs to one proof boundary, never a cache: callers must take
## a fresh snapshot when closing the capture window or authorizing a kill. ## a fresh snapshot when closing the capture window or authorizing a kill.
## Non-Windows callers receive null and keep the existing live-query path. ## Linux captures the same bounded ancestry using procfs; macOS keeps ps.
## The process enumeration stays inside PowerShell; only the bounded target ## The process enumeration stays inside PowerShell; only the bounded target
## ancestry crosses back into Godot, without logging command-line metadata. ## ancestry crosses back into Godot, without logging command-line metadata.
static func capture_process_snapshot(pid: int) -> Variant: static func capture_process_snapshot(pid: int, diagnostics: Variant = null) -> Variant:
if OS.get_name() == "Linux":
return LinuxProc.process_snapshot(pid)
if OS.get_name() != "Windows": if OS.get_name() != "Windows":
return null return null
if pid <= 1: if pid <= 1:
return {"capture_error": true} return _snapshot_failure(diagnostics, "invalid_pid", "single")
var script := _windows_process_snapshot_script(pid) var script := _windows_process_snapshot_script(pid)
var output: Array = [] var output: Array = []
if execute_windows_powershell(script, output) != 0 or output.is_empty(): var began := Time.get_ticks_msec()
return {"capture_error": true} var code := execute_windows_powershell(script, output)
return parse_process_snapshot(str(output[0]), pid) if code != 0 or output.is_empty():
return _snapshot_failure(
diagnostics, "shell_exit" if code != 0 else "empty_output",
"single", -1, Time.get_ticks_msec() - began
)
return parse_process_snapshot(str(output[0]), pid, diagnostics)
static func _windows_process_snapshot_script(pid: int) -> String: static func _windows_process_snapshot_script(pid: int) -> String:
@@ -421,63 +443,134 @@ static func _windows_process_snapshot_script(pid: int) -> String:
## Independent CIM maps in separate scriptblock scopes are ## Independent CIM maps in separate scriptblock scopes are
## captured within one shell invocation. No snapshot crosses a grant call. ## captured within one shell invocation. No snapshot crosses a grant call.
static func _capture_windows_process_snapshot_pair(pid: int) -> Array: static func _capture_windows_process_snapshot_pair(pid: int, diagnostics: Variant = null) -> Array:
var query := _windows_process_snapshot_script(pid) var query := _windows_process_snapshot_script(pid)
var script := ( var script := (
"$first = & { %s }; $final = & { %s }; " "$first = & { %s }; $final = & { %s }; "
+ "ConvertTo-Json -InputObject @([string]$first,[string]$final) -Compress" + "ConvertTo-Json -InputObject @([string]$first,[string]$final) -Compress"
) % [query, query] ) % [query, query]
var output: Array = [] var output: Array = []
if execute_windows_powershell(script, output) != 0 or output.is_empty(): var began := Time.get_ticks_msec()
var code := execute_windows_powershell(script, output)
if code != 0 or output.is_empty():
_snapshot_failure(
diagnostics, "shell_exit" if code != 0 else "empty_output",
"pair", -1, Time.get_ticks_msec() - began
)
return [{"capture_error": true}, {"capture_error": true}] return [{"capture_error": true}, {"capture_error": true}]
return _parse_process_snapshot_pair(str(output[0]), pid) return _parse_process_snapshot_pair(str(output[0]), pid, diagnostics)
static func _parse_process_snapshot_pair(raw: String, pid: int) -> Array: static func _parse_process_snapshot_pair(
raw: String, pid: int, diagnostics: Variant = null
) -> Array:
if raw.length() > 4 * 1024 * 1024 + 16: if raw.length() > 4 * 1024 * 1024 + 16:
_snapshot_failure(diagnostics, "outer_size", "pair")
return [{"capture_error": true}, {"capture_error": true}] return [{"capture_error": true}, {"capture_error": true}]
var json := JSON.new() var json := JSON.new()
if json.parse(raw) != OK: if json.parse(raw) != OK:
_snapshot_failure(diagnostics, "outer_json", "pair")
return [{"capture_error": true}, {"capture_error": true}] return [{"capture_error": true}, {"capture_error": true}]
var pair: Variant = json.data var pair: Variant = json.data
if not (pair is Array) or pair.size() != 2 or not (pair[0] is String and pair[1] is String): if not (pair is Array) or pair.size() != 2 or not (pair[0] is String and pair[1] is String):
_snapshot_failure(diagnostics, "outer_shape", "pair")
return [{"capture_error": true}, {"capture_error": true}] return [{"capture_error": true}, {"capture_error": true}]
return [parse_process_snapshot(pair[0], pid), parse_process_snapshot(pair[1], pid)] return [
parse_process_snapshot(pair[0], pid, diagnostics, "first"),
parse_process_snapshot(pair[1], pid, diagnostics, "final"),
]
static func parse_process_snapshot(raw: String, expected_pid: int) -> Dictionary: static func parse_process_snapshot(
raw: String, expected_pid: int, diagnostics: Variant = null, stage := "single"
) -> Dictionary:
if raw.length() > 1024 * 1024: if raw.length() > 1024 * 1024:
return {"capture_error": true} return _snapshot_failure(diagnostics, "snapshot_size", stage, -1)
var json := JSON.new() var json := JSON.new()
if json.parse(raw) != OK: if json.parse(raw) != OK:
return {"capture_error": true} return _snapshot_failure(diagnostics, "snapshot_json", stage, -1)
var rows: Variant = json.data var rows: Variant = json.data
if not (rows is Array) or rows.size() > 16: if not (rows is Array) or rows.size() > 16:
return {"capture_error": true} return _snapshot_failure(diagnostics, "collector_null" if rows == null else "snapshot_shape", stage)
var snapshot := {} var snapshot := {}
var next_pid := expected_pid var next_pid := expected_pid
for row in rows: for row in rows:
if not (row is Dictionary): if not (row is Dictionary):
return {"capture_error": true} return _snapshot_failure(diagnostics, "row_shape", stage, snapshot.size())
var value: Variant = row.get("pid") var value: Variant = row.get("pid")
if ( if (
not (value is int or value is float) or not is_finite(float(value)) not (value is int or value is float) or not is_finite(float(value))
or float(value) <= 1.0 or float(value) > 4294967295.0 or float(value) <= 1.0 or float(value) > 4294967295.0
or float(value) != float(int(value)) or float(value) != float(int(value))
): ):
return {"capture_error": true} return _snapshot_failure(diagnostics, "row_pid", stage, snapshot.size())
var pid := int(value) var pid := int(value)
if pid <= 1 or pid != next_pid or snapshot.has(pid): if pid <= 1 or pid != next_pid or snapshot.has(pid):
return {"capture_error": true} return _snapshot_failure(diagnostics, "row_chain", stage, snapshot.size())
snapshot[pid] = row snapshot[pid] = row
if _process_snapshot_row(snapshot, pid).is_empty(): if _process_snapshot_row(snapshot, pid).is_empty():
return {"capture_error": true} return _snapshot_failure(diagnostics, "row_identity", stage, snapshot.size() - 1)
next_pid = int(row.parent_pid) next_pid = int(row.parent_pid)
if snapshot.has(next_pid): if snapshot.has(next_pid):
return {"capture_error": true} return _snapshot_failure(diagnostics, "lineage_cycle", stage, snapshot.size() - 1)
return snapshot return snapshot
## Diagnostics never carry proof rows, command lines, or shell output. The
## caller owns this sink: at most eight distinct failures, with bounded counts.
## Snapshots and authority remain unchanged.
static func _snapshot_failure(
diagnostics: Variant, category: String, stage: String, depth := -1, elapsed_ms := -1
) -> Dictionary:
stage = stage if stage in SNAPSHOT_DIAGNOSTIC_STAGES else "unknown"
category = category if category in SNAPSHOT_DIAGNOSTIC_CATEGORIES else "unknown"
if diagnostics is Array:
for item in diagnostics:
if item.category == category and item.stage == stage and item.depth == depth:
item.count = mini(int(item.count) + 1, 10000)
return {"capture_error": true}
if diagnostics.size() < 8:
diagnostics.append({
"category": category, "stage": stage, "depth": clampi(depth, -1, 16),
"elapsed_ms": clampi(elapsed_ms, -1, 600000), "count": 1,
})
return {"capture_error": true}
## Map one internal collector refusal to the bounded category retained in the
## editor log. `row_identity` at depth zero is malformed target metadata;
## the same refusal on an ancestor is an ancestor-capture failure. A refusal
## this table does not know stays "unknown" instead of borrowing a category.
static func snapshot_failure_category(detail: String, depth := -1) -> String:
match detail:
"shell_exit", "empty_output", "invalid_pid", "collector_null":
return "process_query"
"lineage_cycle":
return "ancestor_capture"
"row_identity":
return "ancestor_capture" if depth > 0 else "json_shape"
"outer_size", "outer_json", "outer_shape", "snapshot_size", "snapshot_json", \
"snapshot_shape", "row_shape", "row_pid", "row_chain":
return "json_shape"
return "unknown"
## Return only the creation-time portion of a captured identity. Windows rows
## append the command line after `|`; Linux rows use `linux:<start>|<command>`.
## The command line is intentionally never copied into diagnostics.
static func process_creation_identity(pid: int, snapshot: Variant = null) -> String:
if pid <= 1 or not (snapshot is Dictionary) or capture_failed(snapshot):
return ""
var identity := str(_process_snapshot_row(snapshot, pid).get("identity", "")).strip_edges()
if identity.is_empty():
return ""
var separator := identity.find("|")
if separator > 0:
identity = identity.substr(0, separator).strip_edges()
return identity.substr(0, 128)
## An unavailable capture is not evidence that its PID has exited. ## An unavailable capture is not evidence that its PID has exited.
static func capture_failed(snapshot: Variant) -> bool: static func capture_failed(snapshot: Variant) -> bool:
return snapshot is Dictionary and bool(snapshot.get("capture_error", false)) return snapshot is Dictionary and bool(snapshot.get("capture_error", false))
@@ -524,6 +617,8 @@ static func process_commandline(pid: int, snapshot: Variant = null) -> String:
if execute_windows_powershell(script, output) != 0 or output.is_empty(): if execute_windows_powershell(script, output) != 0 or output.is_empty():
return "" return ""
return str(output[0]).strip_edges() return str(output[0]).strip_edges()
if OS.get_name() == "Linux":
return LinuxProc.commandline(pid)
var proc_path := "/proc/%d/cmdline" % pid var proc_path := "/proc/%d/cmdline" % pid
if FileAccess.file_exists(proc_path): if FileAccess.file_exists(proc_path):
var file := FileAccess.open(proc_path, FileAccess.READ) var file := FileAccess.open(proc_path, FileAccess.READ)
@@ -552,6 +647,8 @@ static func process_parent(pid: int, snapshot: Variant = null) -> int:
return 0 return 0
if snapshot != null: if snapshot != null:
return int(_process_snapshot_row(snapshot, pid).get("parent_pid", 0)) return int(_process_snapshot_row(snapshot, pid).get("parent_pid", 0))
if OS.get_name() == "Linux":
return int(LinuxProc.process_stat(pid).get("parent_pid", 0))
var output: Array = [] var output: Array = []
if OS.get_name() == "Windows": if OS.get_name() == "Windows":
var script := ( var script := (
@@ -612,6 +709,8 @@ static func pid_cmdline_is_godot_ai(pid: int, snapshot: Variant = null) -> bool:
static func process_fingerprint(pid: int, snapshot: Variant = null) -> String: static func process_fingerprint(pid: int, snapshot: Variant = null) -> String:
if snapshot == null and OS.get_name() == "Linux":
snapshot = capture_process_snapshot(pid)
if not pid_alive(pid, snapshot): if not pid_alive(pid, snapshot):
return "" return ""
var output: Array = [] var output: Array = []
@@ -655,12 +754,12 @@ static func process_fingerprint(pid: int, snapshot: Variant = null) -> String:
## `diagnostics`, when given, receives one word naming the check that ## `diagnostics`, when given, receives one word naming the check that
## refused the grant. It costs no extra probe and never changes the result. ## refused the grant. It costs no extra probe and never changes the result.
static func capture_process_kill_grant( static func capture_process_kill_grant(
pid: int, require_brand := false, diagnostics: Array = [] pid: int, require_brand := false, diagnostics: Array = [], snapshot_diagnostics: Variant = null
) -> Dictionary: ) -> Dictionary:
if pid <= 1 or pid == OS.get_process_id(): if pid <= 1 or pid == OS.get_process_id():
diagnostics.append("invalid_pid") diagnostics.append("invalid_pid")
return {} return {}
var pair: Array = _capture_windows_process_snapshot_pair(pid) if OS.get_name() == "Windows" else [] var pair: Array = _capture_windows_process_snapshot_pair(pid, snapshot_diagnostics) if OS.get_name() == "Windows" else []
var first: Variant = pair[0] if not pair.is_empty() else capture_process_snapshot(pid) var first: Variant = pair[0] if not pair.is_empty() else capture_process_snapshot(pid)
if capture_failed(first): if capture_failed(first):
diagnostics.append("identity_unavailable") diagnostics.append("identity_unavailable")
@@ -727,13 +826,17 @@ static func kill_exact_processes(
## Poll until the given port is no longer bound, or the timeout elapses. ## Poll until the given port is no longer bound, or the timeout elapses.
## Used after `OS.kill` so we don't race the port-in-use check on rebind. ## Used after `OS.kill` so we don't race the port-in-use check on rebind.
static func wait_for_port_free(port: int, timeout_s: float) -> void: static func wait_for_port_free(port: int, timeout_s: float) -> PortOccupancy:
var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0) var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0)
while is_port_in_use(port): while true:
var occupancy := windows_port_occupancy(port) if OS.get_name() == "Windows" else (PortOccupancy.OCCUPIED if is_port_in_use(port) else PortOccupancy.FREE)
if occupancy != PortOccupancy.OCCUPIED:
return occupancy
if Time.get_ticks_msec() >= deadline: if Time.get_ticks_msec() >= deadline:
push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s]) push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s])
return return occupancy
OS.delay_msec(100) OS.delay_msec(100)
return PortOccupancy.UNKNOWN
## Choose a non-Windows-reserved WS port. Returns `configured` when free; ## Choose a non-Windows-reserved WS port. Returns `configured` when free;
+182
View File
@@ -0,0 +1,182 @@
@tool
extends RefCounted
## Per-call, native-only resource graphs. Limits bound output and traversal,
## not time spent inside native engine getters.
const Serializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const FAMILIES: Array[String] = [
"Shape2D", "Shape3D", "Mesh", "Material", "PhysicsMaterial", "StyleBox",
"Gradient", "Curve", "Curve2D", "Curve3D", "GradientTexture1D",
"GradientTexture2D", "CurveTexture", "CurveXYZTexture",
]
const MAX_RESOURCES := 32
const MAX_PROPERTIES := 64
const MAX_ENTRIES := 64
const MAX_VALUES := 512
const MAX_DEPTH := 3
const MAX_CONTAINER_DEPTH := 8
const MAX_STRING_BYTES := 1024
const MAX_STRING_CHARS := 256
const MAX_TRUNCATIONS := 32
const MAX_RESULT_BYTES := 65536
const BUILD_BYTES := 49152
var _depth: int
var _resources: Array[Dictionary] = []
var _references: Dictionary = {}
var _pending: Array[Dictionary] = []
var _truncations: Array[Dictionary] = []
var _values := 0
var _bytes_left := BUILD_BYTES
static func supported(resource: Resource) -> bool:
if resource.get_script() != null:
return false
for family in FAMILIES:
if resource.is_class(family):
return true
return false
func inspect(resource: Resource, depth: int) -> Dictionary:
_depth = depth
var root := _resource(resource, 0, "root")
var index := 0
while index < _pending.size():
var item := _pending[index]
_expand(item.resource, item.level, item.reference, item.row)
index += 1
var graph := {"root": root, "resources": _resources, "truncations": _truncations}
var result := {"data": graph}
if JSON.stringify(result).to_utf8_buffer().size() > MAX_RESULT_BYTES:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Resource inspection exceeded the 65536-byte result limit; inspect at a lower depth")
return result
func _omit(location: String, reason: String) -> Dictionary:
if _truncations.size() < MAX_TRUNCATIONS:
_truncations.append({"location": location.substr(0, 96), "reason": reason})
else:
_truncations[MAX_TRUNCATIONS - 1] = {"location": "root", "reason": "truncation_limit"}
return {"omitted": reason}
func _charge(bytes: int) -> bool:
if bytes > _bytes_left:
return false
_bytes_left -= bytes
return true
func _string(value: String, location: String) -> String:
var bounded := value.substr(0, MAX_STRING_CHARS)
while JSON.stringify(bounded).to_utf8_buffer().size() > MAX_STRING_BYTES:
bounded = bounded.substr(0, bounded.length() - 1)
if bounded.length() < value.length():
_omit(location, "string_bytes")
return bounded
func _resource(resource: Resource, level: int, location: String) -> Dictionary:
var identity := resource.get_instance_id()
if _references.has(identity):
return {"ref": _references[identity]}
if _resources.size() >= MAX_RESOURCES:
return _omit(location, "resources")
var bounded_path := _string(resource.resource_path, location)
if not _charge(128 + resource.get_class().length() * 6 + JSON.stringify(bounded_path).to_utf8_buffer().size()):
return _omit(location, "result_bytes")
var reference := "r%d" % (_resources.size() + 1)
_references[identity] = reference
var row := {
"id": reference, "type": resource.get_class(),
"path": bounded_path, "properties": {},
}
_resources.append(row)
_pending.append({"resource": resource, "level": level, "reference": reference, "row": row})
return {"ref": reference}
func _expand(resource: Resource, level: int, reference: String, row: Dictionary) -> void:
if resource.get_script() != null:
row["omitted"] = _omit(reference, "scripted_resource").omitted
return
if not supported(resource):
row["omitted"] = _omit(reference, "unsupported_resource").omitted
return
if level > _depth:
row["omitted"] = _omit(reference, "depth").omitted
return
var count := 0
for property in ClassDB.class_get_property_list(resource.get_class()):
var name: String = property.name
if name == "script" or not (int(property.usage) & PROPERTY_USAGE_EDITOR) or int(property.type) == TYPE_NIL:
continue
if count >= MAX_PROPERTIES or _values >= MAX_VALUES:
_omit(reference, "properties" if count >= MAX_PROPERTIES else "values")
break
if not _charge(name.length() * 6 + 64):
_omit(reference, "result_bytes")
break
count += 1
if int(property.type) in [TYPE_CALLABLE, TYPE_SIGNAL, TYPE_RID]:
row.properties[name] = _omit(reference + "." + name, "unsupported_value")
continue
row.properties[name] = _value(ClassDB.class_get_property(resource, name), level, 0, reference + "." + name)
func _value(value: Variant, level: int, nesting: int, location: String) -> Variant:
if _values >= MAX_VALUES:
return _omit(location, "values")
_values += 1
if not _charge(64):
return _omit(location, "result_bytes")
match typeof(value):
TYPE_OBJECT:
if value == null:
return null
if value is Resource:
return _resource(value, level + 1, location)
return _omit(location, "unsupported_object")
TYPE_STRING, TYPE_STRING_NAME, TYPE_NODE_PATH:
var bounded := _string(str(value), location)
if not _charge(JSON.stringify(bounded).to_utf8_buffer().size()):
return _omit(location, "result_bytes")
return bounded
TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_VECTOR4_ARRAY, TYPE_PACKED_COLOR_ARRAY:
if nesting >= MAX_CONTAINER_DEPTH:
return _omit(location, "container_depth")
var result: Array = []
for index in mini(value.size(), MAX_ENTRIES):
if _values >= MAX_VALUES or _bytes_left < 64:
_omit(location, "values" if _values >= MAX_VALUES else "result_bytes")
break
result.append(_value(value[index], level, nesting + 1, location + "[%d]" % index))
if value.size() > MAX_ENTRIES:
_omit(location, "collection_entries")
return result
TYPE_DICTIONARY:
if nesting >= MAX_CONTAINER_DEPTH:
return _omit(location, "container_depth")
var entries: Array = []
var examined := 0
for key in value:
if examined >= MAX_ENTRIES or _values >= MAX_VALUES or _bytes_left < 128:
_omit(location, "collection_entries" if examined >= MAX_ENTRIES else "values" if _values >= MAX_VALUES else "result_bytes")
break
examined += 1
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME, TYPE_INT, TYPE_BOOL]:
_omit(location, "unsupported_key")
continue
var at := location + "[%d]" % entries.size()
entries.append({"key": _value(key, level, nesting + 1, at), "value": _value(value[key], level, nesting + 1, at)})
return {"entries": entries}
TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_VECTOR2, TYPE_VECTOR2I, TYPE_VECTOR3, TYPE_VECTOR3I, TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_RECT2, TYPE_RECT2I, TYPE_AABB, TYPE_PLANE, TYPE_QUATERNION, TYPE_BASIS, TYPE_TRANSFORM2D, TYPE_TRANSFORM3D, TYPE_PROJECTION, TYPE_COLOR:
var serialized: Variant = Serializer.serialize(value)
if not _charge(JSON.stringify(serialized).to_utf8_buffer().size()):
return _omit(location, "result_bytes")
return serialized
_:
return _omit(location, "unsupported_value")
@@ -0,0 +1 @@
uid://ddbv5xpo41ed3
+8 -1
View File
@@ -14,7 +14,14 @@ extends RefCounted
## Downscale `image` in place so its longest edge is at most ## Downscale `image` in place so its longest edge is at most
## `max_resolution` (0 = no cap), then PNG-encode. Returns ## `max_resolution` (0 = no cap), then PNG-encode. Returns
## {base64, width, height, original_width, original_height}. ## {base64, width, height, original_width, original_height}.
static func downscale_and_encode(image: Image, max_resolution: int) -> Dictionary: static func downscale_and_encode(image: Image, max_resolution: int, use_hdr_2d := false) -> Dictionary:
## Compatibility readback stays sRGB, including when HDR 2D is enabled.
## Image.linear_to_srgb requires RGB8/RGBA8: converting first quantizes
## linear darks and clamps values above 1 rather than tone-mapping them.
## Midgray therefore truncates approximately 54.6 -> 54 -> 126.
if use_hdr_2d and RenderingServer.get_current_rendering_method() != "gl_compatibility":
image.convert(Image.FORMAT_RGBA8)
image.linear_to_srgb()
var original_width := image.get_width() var original_width := image.get_width()
var original_height := image.get_height() var original_height := image.get_height()
+6
View File
@@ -20,6 +20,12 @@ static func finish(id: int) -> void:
_active.erase(id) _active.erase(id)
static func active_count(label: String = "") -> int:
if label.is_empty():
return _active.size()
return _active.values().count(label)
static func quiescence() -> Dictionary: static func quiescence() -> Dictionary:
if not _active.is_empty(): if not _active.is_empty():
return { return {
+339 -60
View File
@@ -51,6 +51,7 @@ const STALE_PRE_V4_HINT := "stale_pre_v4_server"
const DEFAULT_PROVE_TIMEOUT_MS := 180_000 const DEFAULT_PROVE_TIMEOUT_MS := 180_000
const LAUNCH_FINGERPRINT_TIMEOUT_MS := 15_000 const LAUNCH_FINGERPRINT_TIMEOUT_MS := 15_000
const REPLACEMENT_TTL_MS := 15_000 const REPLACEMENT_TTL_MS := 15_000
const IDENTITY_CAPTURE_STAGES := ["launch", "first_server", "final_server"]
## An authenticated endpoint that drops (a late keepalive, a server restart) ## An authenticated endpoint that drops (a late keepalive, a server restart)
## is re-probed automatically with this backoff, each attempt re-proving the ## is re-probed automatically with this backoff, each attempt re-proving the
## server exactly as a start does; after the last one the block stays until ## server exactly as a start does; after the last one the block stays until
@@ -105,6 +106,11 @@ func _begin_start_episode(existing_id := 0, probe := true) -> void:
_cancel_effect() _cancel_effect()
_replacement_authorization = null _replacement_authorization = null
_transport = null _transport = null
var config_error := McpAllowHosts.configuration_error(str(_plan.get("allow_hosts", "")))
if not config_error.is_empty():
_block_without_effect("unsupported_remote_access", config_error)
startup_finished.emit("blocked:unsupported_remote_access")
return
if existing_id <= 0: if existing_id <= 0:
_next_episode_id += 1 _next_episode_id += 1
existing_id = _next_episode_id existing_id = _next_episode_id
@@ -121,6 +127,8 @@ func _begin_start_episode(existing_id := 0, probe := true) -> void:
"blocked_target": {}, "blocked_target": {},
"launch": {}, "launch": {},
"after_stop": "", "after_stop": "",
"snapshot_diagnostic": {},
"proof_stage_attempts": {"launch": 0, "first_server": 0, "final_server": 0},
} }
_publish() _publish()
if not probe: if not probe:
@@ -313,6 +321,7 @@ func transport_lost(reason := "Authenticated server endpoint was lost.") -> void
_ready_since_msec = 0 _ready_since_msec = 0
var limit := ENDPOINT_RECOVERY_DELAYS_SECONDS.size() var limit := ENDPOINT_RECOVERY_DELAYS_SECONDS.size()
if _endpoint_recovery_attempts >= limit: if _endpoint_recovery_attempts >= limit:
_endpoint_recovery_pending_episode = 0
_block( _block(
"endpoint_lost", "endpoint_lost",
"%s Automatic re-probing gave up after %d attempts; click Restart." % [reason, limit], "%s Automatic re-probing gave up after %d attempts; click Restart." % [reason, limit],
@@ -320,6 +329,7 @@ func transport_lost(reason := "Authenticated server endpoint was lost.") -> void
return return
_endpoint_recovery_attempts += 1 _endpoint_recovery_attempts += 1
var delay := float(ENDPOINT_RECOVERY_DELAYS_SECONDS[_endpoint_recovery_attempts - 1]) var delay := float(ENDPOINT_RECOVERY_DELAYS_SECONDS[_endpoint_recovery_attempts - 1])
_endpoint_recovery_pending_episode = int(_episode.get("id", 0))
_block( _block(
"endpoint_lost", "endpoint_lost",
"%s Re-probing in %ds (attempt %d of %d)." % [ "%s Re-probing in %ds (attempt %d of %d)." % [
@@ -330,7 +340,6 @@ func transport_lost(reason := "Authenticated server endpoint was lost.") -> void
"MCP | server endpoint lost (%s); re-probing in %ds (attempt %d of %d)" "MCP | server endpoint lost (%s); re-probing in %ds (attempt %d of %d)"
% [reason, int(delay), _endpoint_recovery_attempts, limit] % [reason, int(delay), _endpoint_recovery_attempts, limit]
) )
_endpoint_recovery_pending_episode = int(_episode.get("id", 0))
if bool(_plan.get("automatic_effects", true)): if bool(_plan.get("automatic_effects", true)):
_recover_lost_endpoint_after(delay, _endpoint_recovery_pending_episode) _recover_lost_endpoint_after(delay, _endpoint_recovery_pending_episode)
@@ -455,6 +464,7 @@ func _complete_launch(result: Dictionary) -> void:
func _complete_prove(result: Dictionary) -> void: func _complete_prove(result: Dictionary) -> void:
_remember_snapshot_evidence(result)
if bool(result.get("pending", false)): if bool(result.get("pending", false)):
_episode["proof_pending_reason"] = str(result.get("reason", "unknown")) _episode["proof_pending_reason"] = str(result.get("reason", "unknown"))
if Time.get_ticks_msec() >= int(_episode.get("prove_deadline_msec", 0)): if Time.get_ticks_msec() >= int(_episode.get("prove_deadline_msec", 0)):
@@ -462,6 +472,7 @@ func _complete_prove(result: Dictionary) -> void:
"proof_timeout", "proof_timeout",
"The managed server proof timed out at %s." "The managed server proof timed out at %s."
% str(_episode.get("proof_pending_reason", "unknown")) % str(_episode.get("proof_pending_reason", "unknown"))
+ _snapshot_diagnostic_sentence()
+ startup_report_summary(str(_plan.get("startup_report", ""))), + startup_report_summary(str(_plan.get("startup_report", ""))),
) )
return return
@@ -472,6 +483,7 @@ func _complete_prove(result: Dictionary) -> void:
var pending_reason := str(_episode.get("proof_pending_reason", "")) var pending_reason := str(_episode.get("proof_pending_reason", ""))
if not pending_reason.is_empty(): if not pending_reason.is_empty():
message += " Last pending proof: %s." % pending_reason message += " Last pending proof: %s." % pending_reason
message += _snapshot_diagnostic_sentence()
_block(str(result.get("reason", "proof_failed")), message) _block(str(result.get("reason", "proof_failed")), message)
return return
var launch: Dictionary = _episode.get("launch", {}) var launch: Dictionary = _episode.get("launch", {})
@@ -479,7 +491,11 @@ func _complete_prove(result: Dictionary) -> void:
var fingerprint := str(result.get("fingerprint", "")) var fingerprint := str(result.get("fingerprint", ""))
var exact_grant = _owned_process_grant(pid, fingerprint) var exact_grant = _owned_process_grant(pid, fingerprint)
if not exact_grant.is_valid(): if not exact_grant.is_valid():
_block("process_proof_failed", "The server process identity changed before proof completed.") _block(
"process_proof_failed",
"The server process identity changed before proof completed."
+ _snapshot_diagnostic_sentence(),
)
return return
_process_grant = exact_grant _process_grant = exact_grant
launch["pid"] = pid launch["pid"] = pid
@@ -700,8 +716,9 @@ func _effect_probe(payload: Dictionary) -> Dictionary:
if disposition == "unproven": if disposition == "unproven":
return _blocked_probe_result("process_authority_mismatch", port, {}, false, return _blocked_probe_result("process_authority_mismatch", port, {}, false,
"managed process identity could not be proven; click Restart to retry") "managed process identity could not be proven; click Restart to retry")
var listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
var capability := _read_capability(port) var capability := _read_capability(port)
var live := _probe_with_capability(port, capability, int(payload.timeout_ms)) var live := _probe_with_capability(port, capability, int(payload.timeout_ms), listeners)
if _authenticated_status_matches_record(live, capability): if _authenticated_status_matches_record(live, capability):
var version := str(live.get("version", "")) var version := str(live.get("version", ""))
var ws_port := int(live.get("ws_port", 0)) var ws_port := int(live.get("ws_port", 0))
@@ -719,7 +736,15 @@ func _effect_probe(payload: Dictionary) -> Dictionary:
"transport": _transport_from(port, ws_port, live, capability), "transport": _transport_from(port, ws_port, live, capability),
} }
return _blocked_probe_result("incompatible", port, live, true) return _blocked_probe_result("incompatible", port, live, true)
if PortResolver.is_port_in_use(port): var occupied: bool
if OS.get_name() == "Windows":
var occupancy := PortResolver.windows_port_occupancy(port, listeners)
if occupancy == PortResolver.PortOccupancy.UNKNOWN:
return _blocked_probe_result("port_occupancy_unknown", port, live)
occupied = occupancy == PortResolver.PortOccupancy.OCCUPIED
else:
occupied = PortResolver.is_port_in_use(port)
if occupied:
var detail := _record_probe_failure_detail(capability, live) var detail := _record_probe_failure_detail(capability, live)
var blocked := _blocked_probe_result("occupied", port, live, false, detail) var blocked := _blocked_probe_result("occupied", port, live, false, detail)
var pre_v4 := _untrusted_pre_v4_occupant_version(port, int(payload.timeout_ms)) var pre_v4 := _untrusted_pre_v4_occupant_version(port, int(payload.timeout_ms))
@@ -730,8 +755,13 @@ func _effect_probe(payload: Dictionary) -> Dictionary:
## The server binds both ports before it publishes anything. A held ## The server binds both ports before it publishes anything. A held
## WebSocket port (a server moved off the HTTP port, or another editor) ## WebSocket port (a server moved off the HTTP port, or another editor)
## would kill the launch at preflight; say so now and name the setting. ## would kill the launch at preflight; say so now and name the setting.
if expected_ws_port > 0 and PortResolver.is_port_in_use(expected_ws_port): if expected_ws_port > 0:
return ws_port_blocked_result(expected_ws_port) var ws_occupied := (
PortResolver.windows_port_occupancy(expected_ws_port, listeners) == PortResolver.PortOccupancy.OCCUPIED
if OS.get_name() == "Windows" else PortResolver.is_port_in_use(expected_ws_port)
)
if ws_occupied:
return ws_port_blocked_result(expected_ws_port)
return { return {
"outcome": "free", "outcome": "free",
"owned_disposition": disposition, "owned_disposition": disposition,
@@ -830,17 +860,20 @@ func _effect_launch(payload: Dictionary) -> Dictionary:
## stable across two reads; otherwise a legitimate exec looks like PID reuse. ## stable across two reads; otherwise a legitimate exec looks like PID reuse.
var capture_started := Time.get_ticks_msec() var capture_started := Time.get_ticks_msec()
var attempts: Array = [] var attempts: Array = []
var exact_grant := PortResolver.capture_process_kill_grant(pid, true, attempts) var snapshot_diagnostics: Array = []
var exact_grant := PortResolver.capture_process_kill_grant(pid, true, attempts, snapshot_diagnostics)
var fingerprint_deadline := capture_started + LAUNCH_FINGERPRINT_TIMEOUT_MS var fingerprint_deadline := capture_started + LAUNCH_FINGERPRINT_TIMEOUT_MS
while exact_grant.is_empty() and Time.get_ticks_msec() < fingerprint_deadline: while exact_grant.is_empty() and Time.get_ticks_msec() < fingerprint_deadline:
OS.delay_msec(100) OS.delay_msec(100)
exact_grant = PortResolver.capture_process_kill_grant(pid, true, attempts) exact_grant = PortResolver.capture_process_kill_grant(pid, true, attempts, snapshot_diagnostics)
var fingerprint := str(exact_grant.get("fingerprint", "")) var fingerprint := str(exact_grant.get("fingerprint", ""))
return { return {
"ok": not fingerprint.is_empty(), "ok": not fingerprint.is_empty(),
"reason": "launch_unproven" if fingerprint.is_empty() else "", "reason": "launch_unproven" if fingerprint.is_empty() else "",
"message": ( "message": (
_launch_unproven_message(pid, attempts, Time.get_ticks_msec() - capture_started) _launch_unproven_message(
pid, attempts, Time.get_ticks_msec() - capture_started, snapshot_diagnostics
)
if fingerprint.is_empty() if fingerprint.is_empty()
else "" else ""
), ),
@@ -853,16 +886,34 @@ func _effect_launch(payload: Dictionary) -> Dictionary:
} }
static func _snapshot_diagnostic_summary(diagnostics: Array) -> String:
var labels: Array[String] = []
for item in diagnostics.slice(0, 8):
var stage: String = item.stage if item.stage in PortResolver.SNAPSHOT_DIAGNOSTIC_STAGES else "unknown"
var category: String = item.category if item.category in PortResolver.SNAPSHOT_DIAGNOSTIC_CATEGORIES else "unknown"
var label := "launch_grant/%s/%s" % [stage, category]
if int(item.depth) >= 0:
label += " at depth %d" % clampi(int(item.depth), 0, 16)
if int(item.count) > 1:
label += " x%d" % clampi(int(item.count), 1, 10000)
if int(item.elapsed_ms) >= 0:
label += " (first query %d ms)" % clampi(int(item.elapsed_ms), 0, 600000)
if not labels.has(label):
labels.append(label)
return "" if labels.is_empty() else " Snapshot diagnostics: " + "; ".join(labels) + "."
## Name which identity check failed so a Windows report can be acted on ## Name which identity check failed so a Windows report can be acted on
## (#988, #1012 both surfaced as this bare sentence). Diagnostic only: the ## (#988, #1012 both surfaced as this bare sentence). Diagnostic only: the
## values are read once more after the capture budget and never grant ## values are read once more after the capture budget and never grant
## authority. ## authority.
func _launch_unproven_message(pid: int, attempts: Array, elapsed_ms: int) -> String: func _launch_unproven_message(
var snapshot: Variant = _capture_process_snapshot(pid) pid: int, attempts: Array, elapsed_ms: int, snapshot_diagnostics: Array = []
) -> String:
var capture_diagnostics: Array = []
var snapshot: Variant = _capture_process_snapshot(pid, capture_diagnostics)
var alive := PortResolver.pid_alive(pid, snapshot) var alive := PortResolver.pid_alive(pid, snapshot)
var commandline := PortResolver.process_commandline(pid, snapshot) if alive else "" var creation_identity := PortResolver.process_creation_identity(pid, snapshot)
if commandline.length() > 200:
commandline = commandline.substr(0, 199) + "…"
## Summarise the per-attempt refusals as "reason×count" in first-seen order. ## Summarise the per-attempt refusals as "reason×count" in first-seen order.
var counts := {} var counts := {}
var order: Array[String] = [] var order: Array[String] = []
@@ -875,43 +926,218 @@ func _launch_unproven_message(pid: int, attempts: Array, elapsed_ms: int) -> Str
var summary: Array[String] = [] var summary: Array[String] = []
for key in order: for key in order:
summary.append("%s×%d" % [key, int(counts[key])]) summary.append("%s×%d" % [key, int(counts[key])])
return ( var message := (
"The launched process identity could not be captured in %d attempts over %.1f s " "The launched process identity could not be captured in %d attempts over %.1f s "
+ "(pid %d, now alive=%s, refusals: %s%s)." + "(pid %d, creation_identity=%s, now alive=%s, refusals: %s)."
) % [ ) % [
attempts.size(), attempts.size(),
elapsed_ms / 1000.0, elapsed_ms / 1000.0,
pid, pid,
"unavailable" if creation_identity.is_empty() else creation_identity,
"unknown" if PortResolver.capture_failed(snapshot) else ("yes" if alive else "no"), "unknown" if PortResolver.capture_failed(snapshot) else ("yes" if alive else "no"),
", ".join(summary) if not summary.is_empty() else "none recorded", ", ".join(summary) if not summary.is_empty() else "none recorded",
"" if commandline.is_empty() else "; command: " + commandline, ]
return message + _snapshot_diagnostic_summary(snapshot_diagnostics + capture_diagnostics)
func _capture_process_snapshot(pid: int, diagnostics: Variant = null) -> Variant:
return PortResolver.capture_process_snapshot(pid, diagnostics)
func _remember_snapshot_evidence(result: Dictionary) -> void:
var diagnostic: Variant = result.get("snapshot_diagnostic")
if diagnostic is Dictionary:
_episode["snapshot_diagnostic"] = diagnostic.duplicate(true)
var attempts: Variant = result.get("proof_stage_attempts")
if attempts is Dictionary:
_episode["proof_stage_attempts"] = attempts.duplicate(true)
func _snapshot_diagnostic_sentence() -> String:
var diagnostic: Variant = _episode.get("snapshot_diagnostic", {})
if not (diagnostic is Dictionary) or diagnostic.is_empty():
return ""
var stage := str(diagnostic.get("stage", ""))
if stage not in IDENTITY_CAPTURE_STAGES:
stage = "unknown"
var category := str(diagnostic.get("category", ""))
if category not in PortResolver.SNAPSHOT_FAILURE_CATEGORIES:
category = "unknown"
var identity := str(diagnostic.get("creation_identity", "")).strip_edges()
if identity.is_empty():
identity = "unavailable"
identity = identity.get_slice("|", 0).replace("\n", " ").replace("\r", " ").substr(0, 128)
## The collector's own refusal, when one was recorded: the fixed category
## alone cannot tell a shell that exited from one that printed nothing.
var detail := str(diagnostic.get("detail", ""))
if not detail.is_empty():
if detail not in PortResolver.SNAPSHOT_DIAGNOSTIC_CATEGORIES:
detail = "unknown"
category += " detail=%s" % detail
var depth := int(diagnostic.get("depth", -1))
if depth >= 0:
category += " depth=%d" % clampi(depth, 0, 16)
return (
" Identity capture: stage=%s category=%s pid=%d creation_identity=%s attempt=%d elapsed_ms=%d."
) % [
stage,
category,
int(diagnostic.get("pid", -1)),
identity,
clampi(int(diagnostic.get("attempt", 1)), 1, 100000),
clampi(int(diagnostic.get("elapsed_ms", 0)), 0, 600000),
] ]
func _capture_process_snapshot(pid: int) -> Variant: func _capture_identity(
return PortResolver.capture_process_snapshot(pid) stage: String, pid: int, stage_attempts: Dictionary
) -> Dictionary:
var attempt := int(stage_attempts.get(stage, 0)) + 1
stage_attempts[stage] = attempt
var diagnostics: Array = []
var began := Time.get_ticks_msec()
var snapshot: Variant = _capture_process_snapshot(pid, diagnostics)
return {
"snapshot": snapshot,
"diagnostic": _snapshot_evidence(
stage, pid, snapshot, diagnostics, attempt, Time.get_ticks_msec() - began
),
}
func _record_snapshot_capture(
stage: String, pid: int, snapshot: Variant, stage_attempts: Dictionary, elapsed_ms: int = 0
) -> Dictionary:
var attempt := int(stage_attempts.get(stage, 0)) + 1
stage_attempts[stage] = attempt
return {
"snapshot": snapshot,
"diagnostic": _snapshot_evidence(stage, pid, snapshot, [], attempt, elapsed_ms),
}
static func _capture_elapsed_ms(capture: Dictionary) -> int:
var diagnostic: Variant = capture.get("diagnostic", {})
return int(diagnostic.get("elapsed_ms", 0)) if diagnostic is Dictionary else 0
static func _retarget_snapshot_capture(
capture: Dictionary, stage: String, pid: int
) -> Dictionary:
var snapshot: Variant = capture.get("snapshot")
var old_diagnostic: Variant = capture.get("diagnostic", {})
var attempt := int(old_diagnostic.get("attempt", 1)) if old_diagnostic is Dictionary else 1
var elapsed_ms := int(old_diagnostic.get("elapsed_ms", 0)) if old_diagnostic is Dictionary else 0
return {
"snapshot": snapshot,
"diagnostic": _snapshot_evidence(stage, pid, snapshot, [], attempt, elapsed_ms),
}
static func _snapshot_evidence(
stage: String,
pid: int,
snapshot: Variant,
diagnostics: Array,
attempt: int,
elapsed_ms: int,
category_override := "",
) -> Dictionary:
var detail := ""
var depth := -1
if not diagnostics.is_empty():
var last: Variant = diagnostics[diagnostics.size() - 1]
if last is Dictionary:
detail = str(last.get("category", ""))
depth = int(last.get("depth", -1))
var category := str(category_override)
if category.is_empty() and not detail.is_empty():
category = PortResolver.snapshot_failure_category(detail, depth)
elif category.is_empty() and PortResolver.capture_failed(snapshot):
category = "process_query"
var creation_identity := PortResolver.process_creation_identity(pid, snapshot)
return {
"stage": stage if stage in IDENTITY_CAPTURE_STAGES else "unknown",
"category": (
category
if category.is_empty() or category in PortResolver.SNAPSHOT_FAILURE_CATEGORIES
else "unknown"
),
"detail": (
detail
if detail.is_empty() or detail in PortResolver.SNAPSHOT_DIAGNOSTIC_CATEGORIES
else "unknown"
),
"depth": clampi(depth, -1, 16),
"pid": pid,
"creation_identity": "unavailable" if creation_identity.is_empty() else creation_identity,
"attempt": clampi(attempt, 1, 100000),
"elapsed_ms": clampi(elapsed_ms, 0, 600000),
}
func _proof_pending(
reason: String, stage_attempts: Dictionary, diagnostic: Dictionary = {}
) -> Dictionary:
var result := {
"pending": true,
"reason": reason,
"proof_stage_attempts": stage_attempts.duplicate(true),
}
if not diagnostic.is_empty():
result["snapshot_diagnostic"] = diagnostic.duplicate(true)
return result
func _capture_failure(
stage_attempts: Dictionary, capture: Dictionary, category_override := ""
) -> Dictionary:
var diagnostic: Dictionary = capture.get("diagnostic", {}).duplicate(true)
if not category_override.is_empty():
diagnostic["category"] = category_override
return _proof_pending("identity_unavailable", stage_attempts, diagnostic)
func _identity_mismatch(
reason: String, stage_attempts: Dictionary, capture: Dictionary
) -> Dictionary:
var diagnostic: Dictionary = capture.get("diagnostic", {}).duplicate(true)
diagnostic["category"] = "identity_mismatch"
return _proof_pending(reason, stage_attempts, diagnostic)
func _effect_prove(payload: Dictionary) -> Dictionary: func _effect_prove(payload: Dictionary) -> Dictionary:
var launch_grant = payload.get("grant") var launch_grant = payload.get("grant")
var launch_pid := int(launch_grant.process_id()) if launch_grant != null else -1 var launch_pid := int(launch_grant.process_id()) if launch_grant != null else -1
var stage_attempts: Variant = payload.get("proof_stage_attempts", {})
if not (stage_attempts is Dictionary):
stage_attempts = {}
else:
stage_attempts = stage_attempts.duplicate(true)
for stage in IDENTITY_CAPTURE_STAGES:
stage_attempts[stage] = maxi(0, int(stage_attempts.get(stage, 0)))
## The pid file is an untrusted hint. Reuse its ancestor snapshot only ## The pid file is an untrusted hint. Reuse its ancestor snapshot only
## when it includes the launcher; the original PID read and all final ## when it includes the launcher; the original PID read and all final
## capability, listener, identity, and lineage checks still follow. ## capability, listener, identity, and lineage checks still follow.
var hinted_pid := -1 var hinted_pid := -1
var first_snapshot: Variant = null var first_snapshot: Variant = null
var launch_snapshot: Variant = null var launch_snapshot: Variant = null
var launch_capture: Dictionary = {}
var first_capture: Dictionary = {}
if OS.get_name() == "Windows" and launch_pid > 1: if OS.get_name() == "Windows" and launch_pid > 1:
hinted_pid = PortResolver.read_pid_file(str(payload.get("pid_file", ""))) hinted_pid = PortResolver.read_pid_file(str(payload.get("pid_file", "")))
if hinted_pid > 1: if hinted_pid > 1:
first_snapshot = _capture_process_snapshot(hinted_pid) first_capture = _capture_identity("launch", hinted_pid, stage_attempts)
first_snapshot = first_capture.get("snapshot")
if PortResolver.process_descends_from(hinted_pid, launch_pid, first_snapshot): if PortResolver.process_descends_from(hinted_pid, launch_pid, first_snapshot):
launch_capture = _retarget_snapshot_capture(first_capture, "launch", launch_pid)
launch_snapshot = first_snapshot launch_snapshot = first_snapshot
if launch_snapshot == null: if launch_snapshot == null:
launch_snapshot = _capture_process_snapshot(launch_pid) launch_capture = _capture_identity("launch", launch_pid, stage_attempts)
launch_snapshot = launch_capture.get("snapshot")
first_snapshot = null first_snapshot = null
if PortResolver.capture_failed(launch_snapshot): if PortResolver.capture_failed(launch_snapshot):
return {"pending": true, "reason": "identity_unavailable"} return _capture_failure(stage_attempts, launch_capture)
var launch_alive := PortResolver.pid_alive(launch_pid, launch_snapshot) var launch_alive := PortResolver.pid_alive(launch_pid, launch_snapshot)
var launch_fingerprint := ( var launch_fingerprint := (
PortResolver.process_fingerprint(launch_pid, launch_snapshot) PortResolver.process_fingerprint(launch_pid, launch_snapshot)
@@ -924,70 +1150,103 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
launch_fingerprint, launch_fingerprint,
) )
if launch_disposition != "owned": if launch_disposition != "owned":
return { if launch_disposition == "gone":
"ok": false, return {
"reason": "launch_%s" % launch_disposition, "ok": false,
"message": ( "reason": "launch_gone",
"The launched server process exited or changed identity before publishing capabilities." "message": (
+ startup_report_summary(str(payload.get("startup_report", ""))) "The launched server process exited or changed identity before publishing capabilities."
), + startup_report_summary(str(payload.get("startup_report", "")))
} ),
"proof_stage_attempts": stage_attempts.duplicate(true),
}
var mismatch := _identity_mismatch("launch_%s" % launch_disposition, stage_attempts, launch_capture)
mismatch.erase("pending")
mismatch["ok"] = false
mismatch["message"] = (
"The launched server process exited or changed identity before publishing capabilities."
+ startup_report_summary(str(payload.get("startup_report", "")))
)
return mismatch
var port := int(payload.http_port) var port := int(payload.http_port)
var capability := _read_capability(port) var capability := _read_capability(port)
if capability.is_empty() or str(capability.get("instance_nonce", "")) == str(payload.get("baseline_instance_id", "")): if capability.is_empty() or str(capability.get("instance_nonce", "")) == str(payload.get("baseline_instance_id", "")):
return {"pending": true, "reason": "capability_record"} return _proof_pending("capability_record", stage_attempts)
if ( if (
str(capability.get("http", "")) != str(payload.get("http_capability", "")) str(capability.get("http", "")) != str(payload.get("http_capability", ""))
or str(capability.get("websocket", "")) != str(payload.get("ws_capability", "")) or str(capability.get("websocket", "")) != str(payload.get("ws_capability", ""))
): ):
return {"pending": true, "reason": "capability_pair"} return _proof_pending("capability_pair", stage_attempts)
var live := _probe_with_capability(port, capability, int(payload.timeout_ms)) var listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
var live := _probe_with_capability(port, capability, int(payload.timeout_ms), listeners)
if not _authenticated_status_matches_record(live, capability): if not _authenticated_status_matches_record(live, capability):
return {"pending": true, "reason": "authenticated_status"} return _proof_pending("authenticated_status", stage_attempts)
var version := str(live.get("version", "")) var version := str(live.get("version", ""))
var ws_port := int(live.get("ws_port", 0)) var ws_port := int(live.get("ws_port", 0))
if not _server_status_compatibility( if not _server_status_compatibility(
version, str(payload.expected_version), ws_port, int(payload.expected_ws_port) version, str(payload.expected_version), ws_port, int(payload.expected_ws_port)
).get("compatible", false): ).get("compatible", false):
return {"ok": false, "reason": "incompatible", "message": "The launched server does not match this plugin."} return {
"ok": false,
"reason": "incompatible",
"message": "The launched server does not match this plugin.",
"proof_stage_attempts": stage_attempts.duplicate(true),
}
var pid := PortResolver.read_pid_file(str(payload.get("pid_file", ""))) var pid := PortResolver.read_pid_file(str(payload.get("pid_file", "")))
if pid <= 1: if pid <= 1:
return {"pending": true, "reason": "pid_file"} return _proof_pending("pid_file", stage_attempts)
if not PortResolver.find_all_pids_on_port(port).has(pid): if not PortResolver.find_all_pids_on_port(port, Callable(), listeners).has(pid):
return {"pending": true, "reason": "listener_pid"} return _proof_pending("listener_pid", stage_attempts)
if first_snapshot == null or pid != hinted_pid: if first_snapshot == null or pid != hinted_pid:
first_snapshot = ( if pid == launch_pid:
launch_snapshot if pid == launch_pid else _capture_process_snapshot(pid) first_snapshot = launch_snapshot
first_capture = _record_snapshot_capture(
"first_server", pid, first_snapshot, stage_attempts,
_capture_elapsed_ms(launch_capture)
)
else:
first_capture = _capture_identity("first_server", pid, stage_attempts)
first_snapshot = first_capture.get("snapshot")
else:
first_capture = _record_snapshot_capture(
"first_server", pid, first_snapshot, stage_attempts,
_capture_elapsed_ms(first_capture)
) )
if PortResolver.capture_failed(first_snapshot): if PortResolver.capture_failed(first_snapshot):
return {"pending": true, "reason": "identity_unavailable"} return _capture_failure(stage_attempts, first_capture)
if not PortResolver.pid_cmdline_is_godot_ai(pid, first_snapshot): if not PortResolver.pid_cmdline_is_godot_ai(pid, first_snapshot):
return {"pending": true, "reason": "process_brand"} return _identity_mismatch("process_brand", stage_attempts, first_capture)
if not PortResolver.process_descends_from(pid, launch_pid, first_snapshot): if not PortResolver.process_descends_from(pid, launch_pid, first_snapshot):
return {"pending": true, "reason": "launch_lineage"} return _identity_mismatch("launch_lineage", stage_attempts, first_capture)
var fingerprint := PortResolver.process_fingerprint(pid, first_snapshot) var fingerprint := PortResolver.process_fingerprint(pid, first_snapshot)
if fingerprint.is_empty(): if fingerprint.is_empty():
return {"pending": true, "reason": "process_fingerprint"} return _identity_mismatch("process_fingerprint", stage_attempts, first_capture)
## Close every capture window before minting process authority. The same ## Close every capture window before minting process authority. The same
## launch-lineage PID must still own the listener, the private capability ## launch-lineage PID must still own the listener, the private capability
## record must still name the authenticated instance, and the process ## record must still name the authenticated instance, and the process
## fingerprint must remain unchanged after the final probe. ## fingerprint must remain unchanged after the final probe.
var final_capability := _read_capability(port) var final_capability := _read_capability(port)
var final_live := _probe_with_capability(port, final_capability, int(payload.timeout_ms)) var final_live := _probe_with_capability(port, final_capability, int(payload.timeout_ms), listeners)
var final_snapshot: Variant = _capture_process_snapshot(pid) var final_capture := _capture_identity("final_server", pid, stage_attempts)
var final_snapshot: Variant = final_capture.get("snapshot")
if PortResolver.capture_failed(final_snapshot): if PortResolver.capture_failed(final_snapshot):
return {"pending": true, "reason": "identity_unavailable"} return _capture_failure(stage_attempts, final_capture)
var final_fingerprint := PortResolver.process_fingerprint(pid, final_snapshot)
var final_listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
if ( if (
PortResolver.read_pid_file(str(payload.get("pid_file", ""))) != pid PortResolver.read_pid_file(str(payload.get("pid_file", ""))) != pid
or not PortResolver.find_all_pids_on_port(port).has(pid) or not PortResolver.find_all_pids_on_port(port, Callable(), final_listeners).has(pid)
or not PortResolver.process_descends_from(pid, launch_pid, final_snapshot) or not PortResolver.process_descends_from(pid, launch_pid, final_snapshot)
or not PortResolver.pid_cmdline_is_godot_ai(pid, final_snapshot) or not PortResolver.pid_cmdline_is_godot_ai(pid, final_snapshot)
or PortResolver.process_fingerprint(pid, final_snapshot) != fingerprint or final_fingerprint != fingerprint
or final_capability != capability ):
return _identity_mismatch("final_capture_window", stage_attempts, final_capture)
if (
final_capability != capability
or not _authenticated_status_matches_record(final_live, final_capability) or not _authenticated_status_matches_record(final_live, final_capability)
or str(final_live.get("instance_id", "")) != str(live.get("instance_id", "")) or str(final_live.get("instance_id", "")) != str(live.get("instance_id", ""))
): ):
return {"pending": true, "reason": "final_capture_window"} return _proof_pending("final_capture_window", stage_attempts)
var final_version := str(final_live.get("version", "")) var final_version := str(final_live.get("version", ""))
var final_ws_port := int(final_live.get("ws_port", 0)) var final_ws_port := int(final_live.get("ws_port", 0))
if not _server_status_compatibility( if not _server_status_compatibility(
@@ -1000,6 +1259,7 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
"ok": false, "ok": false,
"reason": "incompatible", "reason": "incompatible",
"message": "The launched server changed compatibility before authority was granted.", "message": "The launched server changed compatibility before authority was granted.",
"proof_stage_attempts": stage_attempts.duplicate(true),
} }
return { return {
"ok": true, "ok": true,
@@ -1007,6 +1267,7 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
"fingerprint": fingerprint, "fingerprint": fingerprint,
"version": final_version, "version": final_version,
"transport": _transport_from(port, final_ws_port, final_live, final_capability), "transport": _transport_from(port, final_ws_port, final_live, final_capability),
"proof_stage_attempts": stage_attempts.duplicate(true),
} }
@@ -1085,8 +1346,8 @@ func _effect_replace(payload: Dictionary) -> Dictionary:
) )
return {"ok": false, "reason": "replacement_failed"} return {"ok": false, "reason": "replacement_failed"}
if launch.is_empty(): if launch.is_empty():
PortResolver.wait_for_port_free(port, 5.0) var occupancy := PortResolver.wait_for_port_free(port, 5.0)
return {"ok": not PortResolver.is_port_in_use(port), "reason": ""} return {"ok": occupancy == PortResolver.PortOccupancy.FREE, "reason": ""}
return {"ok": true, "reason": "", "launch": launch} return {"ok": true, "reason": "", "launch": launch}
@@ -1165,13 +1426,14 @@ func _effect_stop(payload: Dictionary) -> Dictionary:
if disposition == "owned": if disposition == "owned":
grants.append({"pid": pid, "fingerprint": fingerprint}) grants.append({"pid": pid, "fingerprint": fingerprint})
PortResolver.kill_exact_processes(grants, true) PortResolver.kill_exact_processes(grants, true)
var occupancy := PortResolver.PortOccupancy.FREE
if port > 0 and not grants.is_empty(): if port > 0 and not grants.is_empty():
PortResolver.wait_for_port_free(port, 3.0) occupancy = PortResolver.wait_for_port_free(port, 3.0)
var targets_gone := true var targets_gone := true
for exact in grants: for exact in grants:
if PortResolver.process_fingerprint(int(exact.pid)) == str(exact.fingerprint): if PortResolver.process_fingerprint(int(exact.pid)) == str(exact.fingerprint):
targets_gone = false targets_gone = false
var port_free := port <= 0 or not PortResolver.is_port_in_use(port) var port_free := port <= 0 or (occupancy != PortResolver.PortOccupancy.UNKNOWN and not PortResolver.is_port_in_use(port))
## Killing something of ours must also free its listener. When there was ## Killing something of ours must also free its listener. When there was
## nothing of ours left to kill, whoever holds the port now is not ours to ## nothing of ours left to kill, whoever holds the port now is not ours to
## prove stopped; the next start's probe deals with that occupant. ## prove stopped; the next start's probe deals with that occupant.
@@ -1247,6 +1509,9 @@ func _prove_payload() -> Dictionary:
"ws_capability": str(launch.get("ws_capability", "")), "ws_capability": str(launch.get("ws_capability", "")),
"baseline_instance_id": str(launch.get("baseline_instance_id", "")), "baseline_instance_id": str(launch.get("baseline_instance_id", "")),
"grant": _process_grant, "grant": _process_grant,
"proof_stage_attempts": _episode.get(
"proof_stage_attempts", {"launch": 0, "first_server": 0, "final_server": 0}
),
} }
@@ -1283,7 +1548,7 @@ static func probe_live_server_status(
return _probe_with_capability(port, capability, timeout_ms) return _probe_with_capability(port, capability, timeout_ms)
static func _probe_with_capability(port: int, capability: Dictionary, timeout_ms: int) -> Dictionary: static func _probe_with_capability(port: int, capability: Dictionary, timeout_ms: int, listeners: Dictionary = {}) -> Dictionary:
var result := {"reachable": false, "name": "", "version": "", "ws_port": 0, "instance_id": "", "status_code": 0, "error": ""} var result := {"reachable": false, "name": "", "version": "", "ws_port": 0, "instance_id": "", "status_code": 0, "error": ""}
var http_capability := str(capability.get("http", "")) var http_capability := str(capability.get("http", ""))
if http_capability.is_empty(): if http_capability.is_empty():
@@ -1292,7 +1557,10 @@ static func _probe_with_capability(port: int, capability: Dictionary, timeout_ms
## Windows can otherwise spend the whole connect deadline on an unbound ## Windows can otherwise spend the whole connect deadline on an unbound
## loopback port. This is only an unreachable probe; callers still check ## loopback port. This is only an unreachable probe; callers still check
## occupancy and prove any later listener before using it. ## occupancy and prove any later listener before using it.
if OS.get_name() == "Windows" and PortResolver.can_bind_local_port(port): if (
OS.get_name() == "Windows" and PortResolver.can_bind_local_port(port)
and PortResolver.windows_port_occupancy(port, listeners) == PortResolver.PortOccupancy.FREE
):
result.error = "port_unbound" result.error = "port_unbound"
return result return result
var client := HTTPClient.new() var client := HTTPClient.new()
@@ -1412,7 +1680,9 @@ static func _blocked_probe_result(
var message := "Port %d is occupied by another process." % port var message := "Port %d is occupied by another process." % port
if not detail.is_empty(): if not detail.is_empty():
message = "Port %d is occupied by another process (%s)." % [port, detail] message = "Port %d is occupied by another process (%s)." % [port, detail]
if replaceable: if reason == "port_occupancy_unknown":
message = "Windows could not query listening ports; retry when port discovery is available."
elif replaceable:
message = "Port %d is occupied by godot-ai v%s; choose Replace to continue." % [port, version] message = "Port %d is occupied by godot-ai v%s; choose Replace to continue." % [port, version]
return { return {
"outcome": "blocked", "outcome": "blocked",
@@ -1627,6 +1897,13 @@ func get_status_dict() -> Dictionary:
return { return {
"episode_id": int(_episode.get("id", 0)), "episode_id": int(_episode.get("id", 0)),
"episode_state": state, "episode_state": state,
"reason": reason,
"recovery_pending": (
state == BLOCKED and reason == "endpoint_lost"
and _endpoint_recovery_pending_episode > 0
and _endpoint_recovery_pending_episode == int(_episode.get("id", 0))
),
"episode_reason": reason,
"phase": str(_episode.get("phase", "")), "phase": str(_episode.get("phase", "")),
"ready_kind": str(_episode.get("ready_kind", "")), "ready_kind": str(_episode.get("ready_kind", "")),
"state": _dock_state(state, reason), "state": _dock_state(state, reason),
@@ -1688,7 +1965,8 @@ static func _dormant_episode(episode_id: int) -> Dictionary:
"id": episode_id, "state": DORMANT, "phase": "", "reason": "", "id": episode_id, "state": DORMANT, "phase": "", "reason": "",
"message": "Server lifecycle is dormant.", "ready_kind": "", "effect": "", "message": "Server lifecycle is dormant.", "ready_kind": "", "effect": "",
"expected_version": "", "actual_version": "", "blocked_target": {}, "expected_version": "", "actual_version": "", "blocked_target": {},
"launch": {}, "after_stop": "", "launch": {}, "after_stop": "", "snapshot_diagnostic": {},
"proof_stage_attempts": {"launch": 0, "first_server": 0, "final_server": 0},
} }
@@ -1713,9 +1991,10 @@ static func _dock_state(state: String, reason: String) -> int:
return ServerState.STOPPING return ServerState.STOPPING
BLOCKED: BLOCKED:
match reason: match reason:
"unsupported_remote_access": return ServerState.UNSUPPORTED_CONFIG
"incompatible": return ServerState.INCOMPATIBLE "incompatible": return ServerState.INCOMPATIBLE
"no_command": return ServerState.NO_COMMAND "no_command": return ServerState.NO_COMMAND
"port_reserved": return ServerState.PORT_EXCLUDED "port_reserved": return ServerState.PORT_EXCLUDED
"occupied", "replacement_unproven": return ServerState.FOREIGN_PORT "occupied", "replacement_unproven", "port_occupancy_unknown": return ServerState.FOREIGN_PORT
_: return ServerState.CRASHED _: return ServerState.CRASHED
return ServerState.UNINITIALIZED return ServerState.UNINITIALIZED
+407 -7
View File
@@ -13,6 +13,19 @@ const _GROUP_OTHER_PERMISSION_MASK := 0x3f ## 0077
const _GROUP_OTHER_WRITE_MASK := 0x12 ## 0022 const _GROUP_OTHER_WRITE_MASK := 0x12 ## 0022
const _MAX_LINK_HOPS := 8 const _MAX_LINK_HOPS := 8
const _SYSTEM_TEMP_ROOTS: Array[String] = ["/tmp", "/private/tmp", "/var/tmp"] const _SYSTEM_TEMP_ROOTS: Array[String] = ["/tmp", "/private/tmp", "/var/tmp"]
const _FLATPAK_INFO_PATH := "/.flatpak-info"
## Every read-write spelling of the two grants that expose the home directory.
## Flatpak 1.16 writes plain read-write as the bare name; `:create` is
## read-write too. `:ro` is absent on purpose.
const _FLATPAK_HOME_GRANTS: Array[String] = [
"host", "host:rw", "host:create", "home", "home:rw", "home:create",
]
## Root directories a `host` grant leaves out of the sandbox, where Flatpak's
## own runtime lives (`dont_mount_in_root` in flatpak-context.c).
const _FLATPAK_HOST_EXCLUDED: Array[String] = [
"lib", "lib32", "lib64", "bin", "sbin", "usr", "boot", "root",
"tmp", "etc", "app", "run", "proc", "sys", "dev", "var",
]
const _KEYS: Array[String] = [ const _KEYS: Array[String] = [
"version", "http", "websocket", "instance_nonce", "version", "http", "websocket", "instance_nonce",
] ]
@@ -113,7 +126,8 @@ static func _private_path(path: String, directory: bool) -> bool:
## writes, so only that directory's owner or root could have placed it; ostree ## writes, so only that directory's owner or root could have placed it; ostree
## distributions need this for `/home -> /var/home` (#993). Godot exposes no ## distributions need this for `/home -> /var/home` (#993). Godot exposes no
## owning UID, so this is the strongest proof available here; Python, which ## owning UID, so this is the strongest proof available here; Python, which
## publishes the record, additionally requires such a link to be root-owned. ## publishes the record, additionally verifies root/current-user ownership of
## the link and its parent (including Steam's user-owned namespace root).
## The record file itself is never followed, and a chain longer than ## The record file itself is never followed, and a chain longer than
## `_MAX_LINK_HOPS` fails closed. Windows keeps rejecting every detectable ## `_MAX_LINK_HOPS` fails closed. Windows keeps rejecting every detectable
## link or reparse point. ## link or reparse point.
@@ -244,14 +258,39 @@ static func is_lower_hex(value: String, length: int) -> bool:
## spawn turns a silent "proof timed out at capability_record" into a message ## spawn turns a silent "proof timed out at capability_record" into a message
## that names the directory and the fix. ## that names the directory and the fix.
static func directory_write_problem(http_port: int) -> String: static func directory_write_problem(http_port: int) -> String:
if OS.get_name() != "Windows":
return ""
var record := path_for_http_port(http_port) var record := path_for_http_port(http_port)
if record.is_empty(): if record.is_empty():
return "" return ""
if OS.get_name() != "Windows":
return posix_directory_problem(record.get_base_dir())
return directory_write_problem_for(record.get_base_dir()) return directory_write_problem_for(record.get_base_dir())
## Diagnose all existing writable ancestors in one pass. Never chmod the
## user's home/config directories; Python remains the owning-UID authority.
static func posix_directory_problem(directory: String) -> String:
var problems: Array[String] = []
var current := directory.simplify_path()
while not current.is_empty():
if DirAccess.dir_exists_absolute(current):
var permissions := FileAccess.get_unix_permissions(current)
if not _safe_posix_ancestor_mode(current, permissions):
problems.push_front("%s (mode %03o)" % [current, permissions & _POSIX_PERMISSION_MASK])
var parent := current.get_base_dir()
if parent.is_empty() or parent == current:
break
current = parent
if problems.is_empty():
return ""
return (
"Godot AI cannot securely store connection credentials. Check these directories: %s. "
+ "They must be accessible and not writable by group or other users. "
+ "If you own them and shared write access is not intentional, remove group/other write "
+ "permission on each named directory (chmod go-w), then retry. "
+ "Do not apply chmod recursively. No permissions have been changed."
) % "; ".join(problems)
static func directory_write_problem_for(directory: String) -> String: static func directory_write_problem_for(directory: String) -> String:
if DirAccess.open(directory) == null: if DirAccess.open(directory) == null:
var created := DirAccess.make_dir_recursive_absolute(directory) var created := DirAccess.make_dir_recursive_absolute(directory)
@@ -296,10 +335,371 @@ static func path_for_http_port(http_port: int) -> String:
"Library/Application Support/godot-ai/capabilities" "Library/Application Support/godot-ai/capabilities"
) )
else: else:
directory = OS.get_environment("XDG_CONFIG_HOME").strip_edges() directory = linux_config_home(_flatpak_info()).path_join("godot-ai/capabilities")
if directory.is_empty():
directory = OS.get_environment("HOME").path_join(".config")
directory = directory.path_join("godot-ai/capabilities")
if directory.is_empty() or not directory.is_absolute_path(): if directory.is_empty() or not directory.is_absolute_path():
return "" return ""
return directory.simplify_path().path_join("http-%d.json" % http_port) return directory.simplify_path().path_join("http-%d.json" % http_port)
## The config directory a Linux process outside this one would also name.
## Flatpak points `XDG_CONFIG_HOME` at the app's own `~/.var/app/<id>/config`,
## which a client outside that sandbox never reads. When the sandbox shares
## the host's config directory, that is `HOST_XDG_CONFIG_HOME` if the host set
## one, else the `~/.config` default. Python's `capability_directory` applies
## the same rule, so the server publishes where this reads.
static func linux_config_home(flatpak_info: String) -> String:
var config := OS.get_environment(linux_config_home_variable(flatpak_info)).strip_edges()
if config.is_empty():
config = OS.get_environment("HOME").path_join(".config")
return config
## The variable that names that directory; unset or empty means `~/.config`.
static func linux_config_home_variable(flatpak_info: String) -> String:
return (
"HOST_XDG_CONFIG_HOME" if flatpak_shares_config_home(flatpak_info) else "XDG_CONFIG_HOME"
)
## Whether `/.flatpak-info` text describes a sandbox that can write the host's
## config directory at its own path: one that shares the home, or holds a
## read-write grant for the whole of `xdg-config`. Flatpak mounts that grant at
## the host's path only and leaves `XDG_CONFIG_HOME` on the per-app directory.
## An `xdg-config/<dir>` grant does not count: Flatpak mounts it inside the
## per-app directory as well, so `XDG_CONFIG_HOME` already reaches it.
static func flatpak_shares_config_home(flatpak_info: String) -> bool:
return (
flatpak_shares_home(flatpak_info)
or bool(_flatpak_filesystems(flatpak_info).get("xdg-config", false))
)
## Whether `/.flatpak-info` text describes a sandbox that can write the host's
## home directory. A read-write `host` or `home` grant exposes the real home at
## its own path; a `:ro` grant, a narrower one, or none leaves the app's private
## directories as the only ones a process outside the sandbox can also see.
static func flatpak_shares_home(flatpak_info: String) -> bool:
var group := ""
for line in flatpak_info.split("\n"):
if line.begins_with("["):
group = line.strip_edges()
elif group == "[Context]" and line.begins_with("filesystems="):
for grant in line.trim_prefix("filesystems=").strip_edges().split(";"):
if grant in _FLATPAK_HOME_GRANTS:
return true
return false
return false
## The grant in `/.flatpak-info` text that decides whether the sandbox shares
## `path` with the host, so that a process outside it finds the same file at
## the same location. Returned as {root, location, writable}: the directory or
## file the grant covers, the grant as Flatpak spells it (`home`, `~/dir`,
## `xdg-config/dir`), and whether it is read-write. {} when no grant covers
## the path.
##
## `home` and `host` share the home directory, except `~/.var/app`: there
## Flatpak hides every app's directory but the sandbox's own, whatever the
## grant, and shares that one with no grant at all. `host` also shares the
## root directories its runtime does not occupy. `~/dir` and `/dir` share that
## location, and `xdg-config[/dir]` the host's config directory,
## `host_config_home`. The narrowest grant decides, as the narrowest mount
## does. The other `xdg-` names share nothing here: where they lie depends on
## host settings the sandbox cannot read, so their paths fail closed.
##
## A grant says what Flatpak was asked for, not what it mounted. It skips a
## location that does not exist when the app starts, and `/.flatpak-info`
## leaves out denials (`--nofilesystem`). `flatpak_blocking_mount` checks the
## answer against the mounts.
static func flatpak_grant_over(
flatpak_info: String, path: String, home: String, host_config_home: String
) -> Dictionary:
var target := path.simplify_path()
if not target.is_absolute_path():
return {}
var grants := _flatpak_filesystems(flatpak_info)
var found := {}
for location in grants:
var root := _flatpak_grant_path(str(location), home, host_config_home)
if root.is_empty() or not path_is_within(root, target):
continue
var covered := str(found.get("root", ""))
if root.length() > covered.length():
found = {"root": root, "location": str(location), "writable": bool(grants[location])}
elif root == covered and found["writable"] and not grants[location]:
## Two spellings of one location: Flatpak mounts whichever it
## reaches last, so only agreement counts as writable.
found = {"root": root, "location": str(location), "writable": false}
var home_dir := home.simplify_path()
var top := target.get_slice("/", 1)
var wide := {}
if not home.is_empty() and path_is_within(home_dir, target):
var apps_dir := home_dir.path_join(".var/app")
var app_id := flatpak_application_id(flatpak_info)
if path_is_within(apps_dir, target):
if not app_id.is_empty() and path_is_within(apps_dir.path_join(app_id), target):
wide = {
"root": apps_dir.path_join(app_id),
"location": apps_dir.path_join(app_id),
"writable": true,
}
elif grants.has("home") or grants.has("host"):
## Flatpak applies the wider of the two modes to the home directory,
## so a read-write `home` is always enough for a path inside it.
wide = {
"root": home_dir,
"location": "home",
"writable": bool(grants.get("home", false)) or bool(grants.get("host", false)),
}
elif grants.has("host") and not top.is_empty() and top not in _FLATPAK_HOST_EXCLUDED:
wide = {"root": "/" + top, "location": "host", "writable": bool(grants["host"])}
if str(wide.get("root", "")).length() > str(found.get("root", "")).length():
return wide
return found
## The sandboxed app's ID from `/.flatpak-info` text, or "" outside Flatpak.
static func flatpak_application_id(flatpak_info: String) -> String:
var group := ""
for line in flatpak_info.split("\n"):
if line.begins_with("["):
group = line.strip_edges()
elif group == "[Application]" and line.begins_with("name="):
return line.trim_prefix("name=").strip_edges()
return ""
## Whether `directory` is `path` or one of its ancestors.
static func path_is_within(directory: String, path: String) -> bool:
return path == directory or path.begins_with(directory.trim_suffix("/") + "/")
## Where a grant's location lies in the sandbox, or "" for one this cannot
## place (`host-os`, `xdg-documents`, ...).
static func _flatpak_grant_path(location: String, home: String, host_config_home: String) -> String:
var placed := ""
if location == "~" or location.begins_with("~/"):
placed = home.path_join(location.substr(2))
elif location.begins_with("/"):
placed = _home_spelling(location, home)
elif not host_config_home.is_empty() and (
location == "xdg-config" or location.begins_with("xdg-config/")
):
placed = host_config_home + location.trim_prefix("xdg-config")
return placed.simplify_path() if placed.is_absolute_path() else ""
## `/home` and `/var/home` are one directory on ostree systems, and Flatpak
## mounts a grant at whichever is real, keeping the link. A grant spelled the
## way the home directory is not therefore reads as the one that is.
static func _home_spelling(path: String, home: String) -> String:
for spelling in [["/var/home/", "/home/"], ["/home/", "/var/home/"]]:
if home.begins_with(spelling[0]) and path.begins_with(spelling[1]):
return str(spelling[0]) + path.trim_prefix(spelling[1])
return path
## The `[Context] filesystems=` grants of `/.flatpak-info` text as
## {location: writable}, a location being the grant without its mode: `host`,
## `~/dir`, `/dir`, `xdg-config/dir`. Plain and `:create` grants are writable.
## `:ro`, a denial (`!`) and a mode this does not know are not.
static func _flatpak_filesystems(flatpak_info: String) -> Dictionary:
var grants := {}
var group := ""
for line in flatpak_info.split("\n"):
if line.begins_with("["):
group = line.strip_edges()
elif group == "[Context]" and line.begins_with("filesystems="):
for entry in _key_file_list(line.trim_prefix("filesystems=").strip_edges()):
var grant := _flatpak_location_and_mode(entry.trim_prefix("!"))
## `!host:reset` withdraws inherited grants. It names no location.
if grant[0].is_empty() or grant[1] == "reset":
continue
grants[grant[0]] = not entry.begins_with("!") and grant[1] in ["", "rw", "create"]
break
return grants
## A key file list value split on its unescaped `;`, with the key file's own
## escapes resolved. Flatpak's `\:` and `\\` reach the entry as `\:` and `\\`.
static func _key_file_list(value: String) -> PackedStringArray:
var entries := PackedStringArray()
var entry := ""
var index := 0
while index < value.length():
var character := value[index]
if character == "\\" and index + 1 < value.length():
index += 1
match value[index]:
"s":
entry += " "
"n":
entry += "\n"
"t":
entry += "\t"
"r":
entry += "\r"
_:
entry += value[index]
elif character == ";":
entries.append(entry)
entry = ""
else:
entry += character
index += 1
if not entry.is_empty():
entries.append(entry)
return entries
## One grant as [location, mode]. Flatpak escapes a `:` or `\` that belongs to
## the location, and the first bare `:` starts the mode (`ro`, `create`).
static func _flatpak_location_and_mode(entry: String) -> PackedStringArray:
var location := ""
var index := 0
while index < entry.length():
var character := entry[index]
if character == "\\" and index + 1 < entry.length():
index += 1
location += entry[index]
elif character == ":":
return PackedStringArray([location, entry.substr(index + 1)])
else:
location += character
index += 1
return PackedStringArray([location, ""])
## `/proc/self/mountinfo` text as one {mount_point, root, fstype, read_only}
## per mount, in the kernel's order. A line that does not parse is left out.
static func parse_mountinfo(mountinfo: String) -> Array[Dictionary]:
var mounts: Array[Dictionary] = []
for line in mountinfo.split("\n", false):
var fields := line.split(" ", false)
## Optional fields end at a lone "-"; the filesystem type follows it.
var separator := fields.find("-", 6)
if separator < 0 or separator + 1 >= fields.size():
continue
mounts.append({
"mount_point": _unescape_mount_path(fields[4]),
"root": _unescape_mount_path(fields[3]),
"fstype": fields[separator + 1],
"read_only": fields[5].split(",").has("ro"),
})
return mounts
## The mount that holds `path`: the one with the longest mount point at or
## above it and, among equals, the last listed, which is mounted on top.
## {} when none is.
static func mount_for_path(mounts: Array[Dictionary], path: String) -> Dictionary:
var target := path.simplify_path()
var found := {}
for mount in mounts:
var mount_point := str(mount["mount_point"])
if (
path_is_within(mount_point, target)
and mount_point.length() >= str(found.get("mount_point", "")).length()
):
found = mount
return found
## The mount that contradicts a grant covering `path`: a file written there
## would stay inside the sandbox, or cannot be written. {} when the mounts
## bear the grant out.
##
## An ungranted location lies on the tmpfs Flatpak builds the sandbox on, and
## so does a granted one that did not exist when the app started. A denial
## hides a location behind an empty tmpfs of its own, as Flatpak hides
## `~/.var/app`. Such a tmpfs is recognised by being mounted whole (root `/`)
## below `boundary`, the directory the caller knows to be the host's: the home
## directory for a path inside it, else the granted directory. A tmpfs at or
## above the boundary is the host's own (`/home` on a tmpfs) and blocks
## nothing. Neither does an empty mount list, so unreadable evidence leaves
## the grant's answer standing.
static func flatpak_blocking_mount(
mounts: Array[Dictionary], path: String, boundary: String
) -> Dictionary:
var mount := mount_for_path(mounts, path)
if mount.is_empty():
return {}
var mount_point := str(mount["mount_point"])
var host_directory := boundary.simplify_path()
if mount["fstype"] == "tmpfs" and (
mount_point == "/"
or (
mount["root"] == "/"
and mount_point != host_directory
and path_is_within(host_directory, mount_point)
)
):
return mount
return mount if mount["read_only"] else {}
## `path` as the kernel names it, with every symlink among its existing
## components replaced by its target. Mount points are listed by real path, so
## a home spelled `/home/<user>` on an ostree system has to be read as
## `/var/home/<user>` before it can be looked up among them. Components that do
## not exist are kept as written. A chain longer than `_MAX_LINK_HOPS` returns
## the path unchanged.
static func real_path(path: String) -> String:
if OS.get_name() == "Windows":
return path
var remaining := path.simplify_path().split("/", false)
var current := "/"
var hops := 0
while not remaining.is_empty():
var parent := DirAccess.open(current)
if parent == null:
break
var candidate := current.path_join(remaining[0])
remaining.remove_at(0)
if not parent.is_link(candidate):
current = candidate
continue
var target := parent.read_link(candidate)
if hops >= _MAX_LINK_HOPS or target.is_empty():
return path
hops += 1
if not target.is_absolute_path():
target = current.path_join(target)
var target_parts := target.simplify_path().split("/", false)
target_parts.append_array(remaining)
remaining = target_parts
current = "/"
return current if remaining.is_empty() else current.path_join("/".join(remaining))
## Undo the octal escapes mountinfo uses for space, tab, newline and backslash.
static func _unescape_mount_path(field: String) -> String:
if not field.contains("\\"):
return field
var path := ""
var index := 0
while index < field.length():
var code := field.substr(index + 1, 3)
if field[index] == "\\" and _is_octal_byte(code):
path += char(int(code[0]) * 64 + int(code[1]) * 8 + int(code[2]))
index += 4
else:
path += field[index]
index += 1
return path
static func _is_octal_byte(code: String) -> bool:
if code.length() != 3:
return false
for digit in code:
if digit not in "01234567":
return false
return true
static func _flatpak_info() -> String:
if not FileAccess.file_exists(_FLATPAK_INFO_PATH):
return ""
var file := FileAccess.open(_FLATPAK_INFO_PATH, FileAccess.READ)
return "" if file == null else file.get_as_text()
+7
View File
@@ -0,0 +1,7 @@
Copyright (c) 2023-2025 Serhii Snitsaruk and the LimboAI contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
LimboAI Logo
Copyright (c) 2023 Aleksandra Snitsaruk
This work is licensed under the Creative Commons Attribution 4.0 International
license (CC BY 4.0 International): https://creativecommons.org/licenses/by/4.0/
+158
View File
@@ -0,0 +1,158 @@
<p align="center">
<img src="doc/images/logo.svg" width="400" alt="LimboAI logo">
</p>
# LimboAI - Behavior Trees & State Machines for Godot 4
<!--
[![🔗 All builds](https://github.com/limbonaut/limboai/actions/workflows/all_builds.yml/badge.svg)](https://github.com/limbonaut/limboai/actions/workflows/all_builds.yml)
-->
[![🔎 Unit Tests](https://github.com/limbonaut/limboai/actions/workflows/test_builds.yml/badge.svg)](https://github.com/limbonaut/limboai/actions/workflows/test_builds.yml)
[![Documentation Status](https://readthedocs.org/projects/limboai/badge/?version=latest)](https://limboai.readthedocs.io/en/latest/?badge=latest)
[![GitHub License](https://img.shields.io/github/license/limbonaut/limboai)](https://github.com/limbonaut/limboai/blob/master/LICENSE.md)
[![Discord](https://img.shields.io/discord/1185664967379267774?logo=discord&link=https%3A%2F%2Fdiscord.gg%2FN5MGC95GpP)](https://discord.gg/N5MGC95GpP)
[![Mastodon Follow](https://img.shields.io/mastodon/follow/109346796150895359?domain=https%3A%2F%2Fmastodon.gamedev.place)](https://mastodon.gamedev.place/@limbo)
>**Supported Godot Engine:** **4.6**
> *(see below for older versions)*
**LimboAI** is an open-source C++ plugin for **Godot Engine 4** providing a combination of
**Behavior Trees** and **State Machines**, which can be used together to create complex AI behaviors.
It comes with a behavior tree editor, built-in documentation, visual debugger, extensive demo project with a tutorial, and more!
While it is implemented in C++, it fully supports GDScript for [creating your own tasks](https://limboai.readthedocs.io/en/stable/behavior-trees/custom-tasks.html) and [states](https://limboai.readthedocs.io/en/stable/hierarchical-state-machines/create-hsm.html).
If you enjoy using LimboAI, please **consider supporting** my efforts with a donation on Ko-fi 😊 Your contribution will help me continue developing and improving it.
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Y8Y2TCNH0)
![Textured screenshot](doc/images/behavior-tree-editor-debugger.png)
Behavior Trees are powerful hierarchical structures used to model and control the behavior of agents in a game (e.g., characters, enemies). They are designed to make it easier to create rich and highly modular behaviors for your games. To learn more about behavior trees, check out [Introduction to Behavior Trees](https://limboai.readthedocs.io/en/stable/behavior-trees/introduction.html) and our demo project, which includes a tutorial.
## Demonstration
![Charger from Demo](doc/images/demo_charger.gif)
> [!NOTE]
> **Demo project** lives in the `demo` folder, and is available separately in [**Releases**](https://github.com/limbonaut/limboai/releases).
> Run `demo/scenes/showcase.tscn` to get started.
> It also includes a tutorial that introduces behavior trees through illustrative examples.
### Videos
> YouTube videos produced by various creators
<a href="https://www.youtube.com/watch?v=cGqO7SVKqkM"><img src="https://img.youtube.com/vi/cGqO7SVKqkM/0.jpg" width=272></a>
<a href="https://www.youtube.com/watch?v=E_FIy2dTkNc"><img src="https://img.youtube.com/vi/E_FIy2dTkNc/0.jpg" width=272></a>
<a href="https://www.youtube.com/watch?v=45DaBV9FgOQ"><img src="https://img.youtube.com/vi/45DaBV9FgOQ/0.jpg" width=272></a>
<a href="https://www.youtube.com/watch?v=vZHzMO90IwQ"><img src="https://img.youtube.com/vi/vZHzMO90IwQ/0.jpg" width=272></a>
<a href="https://www.youtube.com/watch?v=gAk3xl5fBsM"><img src="https://img.youtube.com/vi/gAk3xl5fBsM/0.jpg" width=272></a>
<a href="https://www.youtube.com/watch?v=aP0Aacdxmno"><img src="https://img.youtube.com/vi/aP0Aacdxmno/0.jpg" width=272></a>
## Supported Godot versions
| Plugin Version | GDExtension/AssetLib | Module |
|--------------------------|------------------------|-----------|
| `1.8.x` releases | Godot 4.6 or higher | Godot 4.7 |
| `1.7.x` releases | Godot 4.6 or higher | Godot 4.6 |
| `1.6.x` releases | Godot 4.4, 4.5, 4.6 | Godot 4.6 |
| `1.5.x` releases | Godot 4.4, 4.5 | Godot 4.5 |
| `1.4.x` releases | Godot 4.4, 4.5 | Godot 4.4 |
| `1.2.0`-`1.3.x` releases | Godot 4.3 | Godot 4.3 |
| `1.1.x` releases | Godot 4.2 | Godot 4.2 |
## Features
- **Behavior Trees (BT):**
- Easily create, edit, and save `BehaviorTree` resources in the editor.
- Execute `BehaviorTree` resources using the `BTPlayer` node.
- Create complex behaviors by combining and nesting tasks in a hierarchy.
- Control execution flow using composite, decorator, and condition tasks.
- [Create custom tasks](https://limboai.readthedocs.io/en/stable/behavior-trees/custom-tasks.html) by extending core classes: `BTAction`, `BTCondition`, `BTDecorator`, and `BTComposite`.
- Built-in class documentation.
- Blackboard system: Share data seamlessly between tasks using the `Blackboard`.
- Blackboard plans: Define variables in the BehaviorTree resource and override their values in the BTPlayer node.
- Plan editor: Manage variables, their data types and property hints.
- Blackboard scopes: Prevent name conflicts and enable advanced techniques like [sharing data between several agents](https://limboai.readthedocs.io/en/stable/behavior-trees/using-blackboard.html#sharing-data-between-several-agents).
- Blackboard parameters: [Export a BB parameter](https://limboai.readthedocs.io/en/stable/behavior-trees/using-blackboard.html#task-parameters), for which user can provide a value or bind it to a blackboard variable (can be used in custom tasks).
- Inspector support for specifying blackboard variables (custom editor for exported `StringName` properties ending with "_var").
- Use the `BTSubtree` task to execute a tree from a different resource file, promoting organization and reusability.
- Visual Debugger: Inspect the execution of any BT in a running scene to identify and troubleshoot issues.
- Visualize BT in-game using `BehaviorTreeView` node (for custom in-game tools).
- Monitor tree performance with custom performance monitors.
- **Hierarchical State Machines (HSM):**
- Extend the `LimboState` class to implement state logic.
- `LimboHSM` node serves as a state machine that manages `LimboState` instances and transitions.
- `LimboHSM` is a state itself and can be nested within other `LimboHSM` instances.
- [Event-based](https://limboai.readthedocs.io/en/stable/hierarchical-state-machines/create-hsm.html#events-and-transitions): Transitions are associated with events and are triggered by the state machine when the relevant event is dispatched, allowing for better decoupling of transitions from state logic.
- Combine state machines with behavior trees using `BTState` for advanced reactive AI.
- Delegation Option: Using the vanilla `LimboState`, [delegate the implementation](https://limboai.readthedocs.io/en/stable/hierarchical-state-machines/create-hsm.html#single-file-state-machine-setup) to your callback functions, making it perfect for rapid prototyping and game jams.
- Note: State machine setup and initialization require code; there is no GUI editor.
- **Tested:** Behavior tree tasks and HSM are covered by unit tests.
- **GDExtension:** LimboAI can be [used as extension](https://limboai.readthedocs.io/en/stable/getting-started/getting-limboai.html#get-gdextension-version). Custom engine builds are not necessary.
- **Demo + Tutorial:** Check out our extensive demo project, which includes an introduction to behavior trees using examples.
## First steps
Follow the [Getting started](https://limboai.readthedocs.io/en/stable/getting-started/getting-limboai.html) guide to learn how to get started with LimboAI and the demo project.
## Getting LimboAI
LimboAI can be used as either a C++ module or as a GDExtension shared library. GDExtension version is more convenient to use but somewhat limited in features. Whichever you choose to use, your project will stay compatible with both and you can switch from one to the other any time. See [Using GDExtension](https://limboai.readthedocs.io/en/stable/getting-started/getting-limboai.html#get-gdextension-version).
### Precompiled builds
- For the most recent builds, navigate to **Actions** → [**All Builds**](https://github.com/limbonaut/limboai/actions/workflows/all_builds.yml), select a build from the list, and scroll down until you find the **Artifacts** section.
- For release builds, check [**Releases**](https://github.com/limbonaut/limboai/releases).
### Compiling from source
- Download the Godot Engine source code and put this module source into the `modules/limboai` directory.
- Consult the Godot Engine documentation for instructions on [how to build from source code](https://docs.godotengine.org/en/stable/engine_details/development/compiling/index.html).
- If you plan to export a game utilizing the LimboAI module, you'll also need to build export templates.
- To execute unit tests, compile the engine with `tests=yes` and run it with `--test --tc="*[LimboAI]*"`.
#### For GDExtension
- You'll need SCons build tool and a C++ compiler. See also [Compiling](https://docs.godotengine.org/en/stable/contributing/development/compiling/index.html).
- Run `scons target=editor` to build the plugin library for your current platform.
- SCons will automatically clone the godot-cpp/ repository if it doesn't already exist in the `limboai/godot-cpp` directory.
- By default, built targets are placed in the demo project: `demo/addons/limboai/bin/`
- Check `scons -h` for other options and targets.
## Using the plugin
- Online Documentation: [stable](https://limboai.readthedocs.io/en/stable/index.html), [latest](https://limboai.readthedocs.io/en/latest/index.html)
- [Getting started](https://limboai.readthedocs.io/en/stable/getting-started/getting-limboai.html)
- [Introduction to Behavior Trees](https://limboai.readthedocs.io/en/stable/behavior-trees/introduction.html)
- [Creating custom tasks in GDScript](https://limboai.readthedocs.io/en/stable/behavior-trees/custom-tasks.html)
- [Sharing data using Blackboard](https://limboai.readthedocs.io/en/stable/behavior-trees/using-blackboard.html)
- [Accessing nodes in the scene tree](https://limboai.readthedocs.io/en/stable/behavior-trees/accessing-nodes.html)
- [State machines](https://limboai.readthedocs.io/en/stable/hierarchical-state-machines/create-hsm.html)
- [Using GDExtension](https://limboai.readthedocs.io/en/stable/getting-started/getting-limboai.html#get-gdextension-version)
- [Using LimboAI with C#](https://limboai.readthedocs.io/en/stable/getting-started/c-sharp.html)
- [Class reference](https://limboai.readthedocs.io/en/stable/classes/featured-classes.html)
## Contributing
Contributions are welcome! Please open issues for bug reports, feature requests, or code changes.
For detailed guidelines on contributing to code or documentation, check out our [Contributing](https://limboai.readthedocs.io/en/latest/getting-started/contributing.html) page.
If you have an idea for a behavior tree task or a feature that could be useful in a variety of projects, open an issue to discuss it.
## Social
Need help? We have a Discord server: https://discord.gg/N5MGC95GpP
I write about LimboAI development on Mastodon: https://mastodon.gamedev.place/@limbo.
## License
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file or at https://opensource.org/licenses/MIT
LimboAI logo and demo project art assets are licensed under the Creative Commons Attribution 4.0 International license that can be found at https://creativecommons.org/licenses/by/4.0/
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More