Compare commits

..
25 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
moyu 93783b1bd9 debug和修复 2026-10-10 16:07:53 +08:00
569 changed files with 108280 additions and 9709 deletions
+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,出生会先坠落。)
+651
View File
File diff suppressed because one or more lines are too long
+61
View File
@@ -613,6 +613,12 @@ static func configure(id: String, url: String = "", launch_context: Dictionary =
if not client.automatic_config_edits:
return _manual_edit_result(client, "configure")
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():
return {"status": "error", "message": path_error}
## Capture `url` once so a port flip in EditorSettings between write and
@@ -857,6 +863,8 @@ static func remove(id: String, url: String = "", launch_context: Dictionary = {}
if not client.automatic_config_edits:
return _manual_edit_result(client, "remove")
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():
return {"status": "error", "message": path_error}
if url.is_empty():
@@ -897,6 +905,49 @@ static func _config_path_resolution_error(client: Client) -> String:
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) --------------------------
static func _manual_edit_result(client: Client, action: String) -> Dictionary:
@@ -1240,6 +1291,16 @@ static func manual_command(id: String) -> String:
)
if cmd.is_empty():
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
## surface the LAN URL so the user can copy-paste the right address into
## a remote agent. Informational only — configure/remove still WRITE the
+175 -6
View File
@@ -79,10 +79,21 @@ var path_template: Dictionary = {}
## a fallback path that may become invisible after copy-on-write. When
## that private leaf is new, Configure seeds it from the first later
## existing candidate so read-through content is not shadowed.
## 2. If no file or wildcard package match exists, the first non-wildcard
## template is the deterministic create target.
## 2. If no file or wildcard package match exists, the create target is the
## 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
## 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
## priority. When this map has no entry for the current platform,
@@ -331,6 +342,57 @@ func resolved_config_path_details() -> Dictionary:
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
## resolve. Used by the guard above and by the merge-tier loader in
## `_json_strategy.gd`, which resolves its own templates and never passes
@@ -404,7 +466,11 @@ func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
var ordered_templates: Array = []
for template_variant in templates:
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()):
var template := str(ordered_templates[index])
var expanded_template := McpPathTemplate.expand(template)
@@ -444,10 +510,113 @@ func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
return {"path": "", "error": seed["error"]}
_clear_config_path_warning()
return {"path": path, "error": "", "seed_path": seed.get("path", "")}
if fallback_create_path.is_empty():
fallback_create_path = path
# Creating below a directory the sandbox hides would land on its
# 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()
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.
+87 -11
View File
@@ -25,6 +25,8 @@ static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
static var _searched: Dictionary = {}
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.
@@ -56,7 +58,15 @@ static func _find_one(exe_name: String, trace: Callable = Callable()) -> String:
var cached: String = _cache.get(exe_name, "")
_mutex.unlock()
if already_searched:
_trace_lookup(trace, "cache", "resolved" if not cached.is_empty() else "cached_miss", started, {}, true)
_trace_lookup(
trace,
"cache",
"resolved" if not cached.is_empty() else "cached_miss",
started,
{},
true,
cached,
)
return cached
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
@@ -76,12 +86,14 @@ static func _resolve(exe_name: String, trace: Callable = Callable()) -> String:
var is_windows := OS.get_name() == "Windows"
# 1. Well-known locations
var well_known_paths := PackedStringArray()
for dir in _well_known_dirs():
var full := dir.path_join(exe_name)
well_known_paths.append(full)
if FileAccess.file_exists(full):
_trace_lookup(trace, "well_known", "resolved", started)
_trace_lookup(trace, "well_known", "resolved", started, {}, false, full, well_known_paths)
return full
_trace_lookup(trace, "well_known", "no_match", started)
_trace_lookup(trace, "well_known", "no_match", started, {}, false, "", well_known_paths)
# 2. Login shell lookup (Unix only)
if not is_windows:
@@ -97,22 +109,50 @@ static func _resolve(exe_name: String, trace: Callable = Callable()) -> String:
if int(login_result.get("exit_code", -1)) == 0:
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
if not login_found.is_empty() and FileAccess.file_exists(login_found):
_trace_lookup(trace, "login_shell", "resolved", started, login_result)
_trace_lookup(
trace,
"login_shell",
"resolved",
started,
login_result,
false,
login_found,
PackedStringArray([login_found]),
)
return login_found
_trace_lookup(trace, "login_shell", _lookup_failure(login_result, false), started, login_result)
# 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"
started = Time.get_ticks_msec()
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:
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()
if not found.is_empty():
_trace_lookup(trace, "inherited_path", "resolved", started, result)
_trace_lookup(trace, "inherited_path", "resolved", started, result, false, found, lines)
return found
_trace_lookup(trace, "inherited_path", _lookup_failure(result, true), started, result)
_trace_lookup(
trace, "inherited_path", _lookup_failure(result, true), started, result, false, "", lines
)
return ""
@@ -128,7 +168,8 @@ static func _lookup_failure(result: Dictionary, absence_exit: bool) -> String:
static func _trace_lookup(
trace: Callable, tier: String, status: String, started: int,
result: Dictionary = {}, cache_hit := false,
result: Dictionary = {}, cache_hit := false, resolved_path := "",
candidate_paths: PackedStringArray = PackedStringArray(),
) -> void:
if not trace.is_valid():
return
@@ -141,9 +182,44 @@ static func _trace_lookup(
"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
## (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
+33
View File
@@ -30,6 +30,9 @@ static func configure(
server_url: String,
launch: 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
## verified attach launcher must not register anything (see
## 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:
var write_error := remove_write_error(client)
if not write_error.is_empty():
return {"status": "error", "message": write_error}
var cli := _resolve_cli(client)
if cli.is_empty():
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)
## 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
## dock tells the user to run has to match what Configure actually runs (#877).
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 path := str(resolution.get("path", ""))
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():
return {"status": "error", "message": path_error}
if path.is_empty():
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.
var launch_error := command_launch_error(client, launch)
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 path := str(resolution.get("path", ""))
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():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
+29
View File
@@ -19,10 +19,16 @@ static func configure(
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
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():
return {"status": "error", "message": path_error}
if path.is_empty():
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 read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
@@ -59,6 +65,9 @@ static func _configure_merged(
launch: Dictionary,
project_roots: PackedStringArray,
) -> 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)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
@@ -259,6 +268,8 @@ static func remove(
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
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():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
@@ -284,6 +295,9 @@ static func remove(
static func _remove_merged(
client: McpClient, server_name: String, project_roots: PackedStringArray
) -> 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).
@@ -750,6 +764,21 @@ static func _merge_paths(client: McpClient) -> PackedStringArray:
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
## plausible roots only to detect overrides; callers fail closed rather than
## mutating both guesses.
+207 -1
View File
@@ -23,6 +23,19 @@ extends RefCounted
static var _env_snapshot := {}
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`
## `config_file_override_details`, `config_home_override_details`, `_cli_finder.gd` lookups,
## `client_configurator.gd` mode/trace reads) can touch off-main.
@@ -32,6 +45,10 @@ const _BASE_ENV_VARS: Array[String] = [
"HOME",
"USERPROFILE",
"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",
"LOCALAPPDATA",
"SHELL",
@@ -76,6 +93,120 @@ static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray(
for var_name in extra_vars:
if not String(var_name).is_empty():
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.
@@ -183,7 +314,7 @@ static func expand(template: String) -> String:
var token := "$%s" % var_name
if out.find(token) < 0:
continue
var value := env_lookup(var_name)
var value := env_lookup(_env_source(var_name))
if value.is_empty():
value = _home_fallback(var_name)
if value.is_empty():
@@ -192,6 +323,81 @@ static func expand(template: String) -> String:
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 ""
## 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 —
@@ -19,10 +19,16 @@ static func configure(
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
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():
return {"status": "error", "message": path_error}
if path.is_empty():
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 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 path := str(resolution.get("path", ""))
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():
return {"status": "error", "message": path_error}
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 path := str(resolution.get("path", ""))
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():
return {"status": "error", "message": path_error}
if path.is_empty():
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.
var launch_error := command_launch_error(client, launch)
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 path := str(resolution.get("path", ""))
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():
return {"status": "error", "message": path_error}
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",
"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"])
## Cline (like Roo) defaults a typeless entry to SSE transport, which
## 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",
"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"])
## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which
## 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/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"])
## Roo defaults an entry with no "type" to SSE transport — which returns
## 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",
"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"])
entry_extra_fields = {"type": "http"}
## Attach migration (#838). VS Code stdio entries are flat command/args/env
+10
View File
@@ -21,6 +21,16 @@ func _init() -> void:
"linux": "$XDG_CONFIG_HOME/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"])
## Attach migration (#838). Current Zed's context_servers entries are an
## 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",
"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"])
## Local validation against the installed extension shows Zoo stores MCP
## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's
+276 -34
View File
@@ -10,17 +10,30 @@ const MAX_ENTRIES := 10000
const MAX_FILE_BYTES := 256 * 1024
const MAX_TOTAL_BYTES := 64 * 1024 * 1024
const MAX_TARGETS := 256
const BUDGET_USEC := 2000
## 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.
@@ -59,6 +72,13 @@ func run(params: Dictionary, operation: String, active: Callable = Callable()) -
_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
@@ -100,7 +120,7 @@ func _run(params: Dictionary, operation: String) -> Dictionary:
return _unchanged(_fault)
var files: Array[String] = []
if not await _collect_tree("res://", files):
return _unchanged(_fault)
return _fault_result()
if directory and not _directories.has(source):
return _unchanged("Source directory was excluded or has noncanonical casing; nothing changed")
var targets := {}
@@ -112,27 +132,46 @@ func _run(params: Dictionary, operation: String) -> Dictionary:
if targets.size() > MAX_TARGETS:
return _unchanged("Mutation exceeds the %d-resource limit; use smaller groups" % MAX_TARGETS)
for path in files:
if path == source or (directory and path.begins_with(source + "/")) or path in [source + ".uid", source + ".import"]:
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.
if path.get_extension().to_lower() in OWNER_EXTENSIONS:
continue
if await _read_bounded(path) == null:
return _unchanged(_fault)
continue
if await _read_bounded(path) == null:
return _fault_result()
var owners := []
for path in files:
if path.get_extension().to_lower() not in OWNER_EXTENSIONS:
var extension := path.get_extension().to_lower()
if extension not in OWNER_EXTENSIONS:
continue
var content: Variant = await _read_bounded(path)
if content == null:
return _unchanged(_fault)
# One call per owner: _references lowercases and regex-scans the whole
# file, so a per-target loop repeated that up to MAX_TARGETS times.
var hits := await _references(path, content, targets)
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 _unchanged(_fault)
return _fault_result()
if not await _yield_if_needed():
return _unchanged(_fault)
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)
@@ -152,17 +191,18 @@ func _run(params: Dictionary, operation: String) -> Dictionary:
group.append({"from": source + suffix, "to": destination + suffix})
for item in group:
if not _confined_without_links(item.from):
return _unchanged(_fault)
return _fault_result()
if operation != "remove" and not _destination_available(item.from, item.to):
return _unchanged(_fault)
return _fault_result()
# Re-read owner bytes and directory entry censuses after the yielding plan.
if not await _revalidate():
return _unchanged(_fault)
return _fault_result()
if not _active():
return _unchanged("Filesystem request expired before mutation; nothing changed")
_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 _unchanged(_fault)
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)
@@ -179,17 +219,26 @@ func _active() -> bool:
func _yield_if_needed() -> bool:
if not _active():
_fault = "Filesystem discovery expired or was cancelled; nothing changed"
_set_fault("Filesystem discovery expired or was cancelled; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
return false
if Time.get_ticks_usec() - _yield_at >= BUDGET_USEC:
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():
_fault = "Filesystem discovery expired or was cancelled; nothing changed"
_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
@@ -197,11 +246,11 @@ func _collect_tree(root: String, files: Array[String]) -> bool:
var path: String = pending.pop_back()
var dir := DirAccess.open(path)
if dir == null:
_fault = "Cannot inspect directory: %s" % path
_set_fault("Cannot inspect directory: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
return false
dir.include_hidden = true
if dir.list_dir_begin() != OK:
_fault = "Cannot list directory: %s" % path
_set_fault("Cannot list directory: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
return false
var entries: Array[String] = []
var name := dir.get_next()
@@ -222,7 +271,7 @@ func _collect_tree(root: String, files: Array[String]) -> bool:
files.append(child)
count += 1
if count > MAX_ENTRIES:
_fault = "Project discovery exceeds %d entries; nothing changed" % 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():
@@ -237,20 +286,23 @@ func _collect_tree(root: String, files: Array[String]) -> bool:
func _read_bounded(path: String) -> Variant:
var file := FileAccess.open(path, FileAccess.READ)
if file == null or file.get_length() > MAX_FILE_BYTES:
_fault = "Cannot safely read owner %s (unreadable or exceeds %d bytes)" % [path, MAX_FILE_BYTES]
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():
_fault = "Short read while inspecting %s" % path
_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:
_fault = "Filesystem discovery exceeds its byte budget; nothing changed"
_set_fault("Filesystem discovery exceeds its byte budget; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
return null
if not await _yield_if_needed():
return null
@@ -262,11 +314,173 @@ func _read_bounded(path: String) -> Variant:
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] = []
if path.get_extension().to_lower() in ["res", "scn"]:
_fault = "Binary dependency discovery is unsupported for safe mutations: %s" % path
return hits
# 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()
@@ -358,9 +572,26 @@ func _revalidate() -> bool:
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"):
@@ -493,6 +724,17 @@ 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)
+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(node, "set_owner", scene_root)
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_reference(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 prop_type: int = TYPE_NIL
var prop_hint: int = PROPERTY_HINT_NONE
var prop_hint_string: String = ""
for prop in node.get_property_list():
if prop.name == property:
found = true
prop_type = prop.get("type", TYPE_NIL)
prop_hint = prop.get("hint", PROPERTY_HINT_NONE)
prop_hint_string = prop.get("hint_string", "")
break
if not found:
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 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 == "":
value = null
else:
@@ -305,13 +357,56 @@ func set_property(params: Dictionary) -> Dictionary:
"data": {
"path": node_path,
"property": property,
"value": _serialize_value(node.get(property)),
"old_value": _serialize_value(old_value),
"value": _serialize_slot(node.get(property), scene_root),
"old_value": _serialize_slot(old_value, scene_root),
"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:
var resolved := _resolve_node(params)
if resolved.has("error"):
@@ -1322,7 +1417,7 @@ func get_node_properties(params: Dictionary) -> Dictionary:
properties.append({
"name": prop.name,
"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
# "you asked for something that doesn't exist" from "exists and is null".
+12 -16
View File
@@ -167,11 +167,6 @@ var _uv_recheck_pending := false
## (foreign-port branch never sets `_server_pid`, so `_stop_server`
## can't kill it); the line has to show the mismatch honestly.
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
## `McpConnection.server_version` drifts from the plugin version. Hidden
## in the match case so the UI stays calm.
@@ -1358,13 +1353,19 @@ func _refresh_server_version_label(server_status: Dictionary = {}) -> void:
## look like it had multiple restart paths.
or (is_incompatible and can_recover and _crash_restart_btn == null)
)
if text == _last_rendered_server_text:
_setup_server_label.add_theme_color_override("font_color", color)
_update_restart_button(show_restart)
return
_last_rendered_server_text = text
## Runs every frame, so write only what changed (#1121). `Label.text`
## already ignores same-value writes; `add_theme_color_override` does
## not — every call re-sends NOTIFICATION_THEME_CHANGED and queues a
## redraw, which kept the idle editor redrawing while this row was
## 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.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)
@@ -1382,12 +1383,10 @@ func _on_restart_stale_server() -> void:
if _server_restart_in_progress:
return
_server_restart_in_progress = true
_last_rendered_server_text = ""
_refresh_server_version_label()
if not is_inside_tree():
_dispatch_stale_server_restart()
_server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label()
return
call_deferred("_restart_stale_server_after_feedback")
@@ -1397,7 +1396,6 @@ func _restart_stale_server_after_feedback() -> void:
await get_tree().create_timer(0.15).timeout
if not _dispatch_stale_server_restart():
_server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label()
@@ -1418,7 +1416,6 @@ func present_lifecycle_action_result(accepted: bool) -> void:
if accepted:
return
_server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label()
@@ -1516,7 +1513,6 @@ func _refresh_setup_status() -> void:
_version_restart_btn.visible = false
server_row.add_child(_version_restart_btn)
_setup_container.add_child(server_row)
_last_rendered_server_text = ""
_refresh_server_version_label()
else:
_setup_container.add_child(_make_status_row("uv", "not found", Color.RED))
+1 -1
View File
@@ -3,5 +3,5 @@
name="Godot AI"
description="MCP server and AI tools for Godot"
author="Godot AI"
version="4.2.3"
version="4.3.0"
script="plugin.gd"
+23 -8
View File
@@ -156,8 +156,13 @@ func _process(_delta: float) -> void:
## through the debugger packet path in a single tick. Surplus stays in
## `_pending_outbound` and bleeds out across subsequent frames.
if not _logger_attached or _logger == null:
_pending_outbound.clear()
return
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
if _pending_outbound.is_empty():
if not _logger.has_pending():
@@ -1024,13 +1029,7 @@ func _handle_eval(data: Array) -> void:
_eval_token_counter += 1
var token := str(_eval_token_counter)
var run_fn := "_mcp_run_%s" % token
var script_source := (
"extends Node\n"
+ "func execute():\n"
+ "\treturn await %s()\n\n" % run_fn
+ "func %s():\n" % run_fn
+ _indent_eval_code(code)
)
var script_source := _build_eval_script_source(run_fn, code)
## 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()
@@ -1227,7 +1226,23 @@ func _handle_eval_check(data: Array) -> void:
_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 out := ""
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.
const _ERROR_TYPE_SCRIPT := 2
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 _recent_script_errors: Array = []
@@ -93,6 +98,19 @@ func _append(level: String, text: String, details: Dictionary = {}) -> void:
_pending.append([level, text])
else:
_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()
@@ -102,7 +120,15 @@ func drain() -> Array:
_mutex.lock()
var out := _pending
_pending = []
var dropped := _dropped
_dropped = 0
_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
@@ -102,6 +102,14 @@ static func _root_path() -> String:
## Unlike user://, the OS config directory is shared by every Godot
## 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.
##
## 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)
+5
View File
@@ -26,6 +26,11 @@ const EDITOR_NOT_READY := "EDITOR_NOT_READY"
const UNKNOWN_COMMAND := "UNKNOWN_COMMAND"
const INTERNAL_ERROR := "INTERNAL_ERROR"
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,
## but the public registry intentionally mirrors protocol/errors.py.
const TRANSPORT_OUTCOME_UNKNOWN := "TRANSPORT_OUTCOME_UNKNOWN"
+39
View File
@@ -21,6 +21,12 @@ const SNAPSHOT_DIAGNOSTIC_CATEGORIES := [
"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()
enum PortOccupancy { UNKNOWN, FREE, OCCUPIED }
@@ -531,6 +537,39 @@ static func _snapshot_failure(
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.
static func capture_failed(snapshot: Variant) -> bool:
+266 -42
View File
@@ -51,6 +51,7 @@ const STALE_PRE_V4_HINT := "stale_pre_v4_server"
const DEFAULT_PROVE_TIMEOUT_MS := 180_000
const LAUNCH_FINGERPRINT_TIMEOUT_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)
## 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
@@ -126,6 +127,8 @@ func _begin_start_episode(existing_id := 0, probe := true) -> void:
"blocked_target": {},
"launch": {},
"after_stop": "",
"snapshot_diagnostic": {},
"proof_stage_attempts": {"launch": 0, "first_server": 0, "final_server": 0},
}
_publish()
if not probe:
@@ -461,6 +464,7 @@ func _complete_launch(result: Dictionary) -> void:
func _complete_prove(result: Dictionary) -> void:
_remember_snapshot_evidence(result)
if bool(result.get("pending", false)):
_episode["proof_pending_reason"] = str(result.get("reason", "unknown"))
if Time.get_ticks_msec() >= int(_episode.get("prove_deadline_msec", 0)):
@@ -468,6 +472,7 @@ func _complete_prove(result: Dictionary) -> void:
"proof_timeout",
"The managed server proof timed out at %s."
% str(_episode.get("proof_pending_reason", "unknown"))
+ _snapshot_diagnostic_sentence()
+ startup_report_summary(str(_plan.get("startup_report", ""))),
)
return
@@ -478,6 +483,7 @@ func _complete_prove(result: Dictionary) -> void:
var pending_reason := str(_episode.get("proof_pending_reason", ""))
if not pending_reason.is_empty():
message += " Last pending proof: %s." % pending_reason
message += _snapshot_diagnostic_sentence()
_block(str(result.get("reason", "proof_failed")), message)
return
var launch: Dictionary = _episode.get("launch", {})
@@ -485,7 +491,11 @@ func _complete_prove(result: Dictionary) -> void:
var fingerprint := str(result.get("fingerprint", ""))
var exact_grant = _owned_process_grant(pid, fingerprint)
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
_process_grant = exact_grant
launch["pid"] = pid
@@ -861,8 +871,9 @@ func _effect_launch(payload: Dictionary) -> Dictionary:
"ok": not fingerprint.is_empty(),
"reason": "launch_unproven" if fingerprint.is_empty() else "",
"message": (
_launch_unproven_message(pid, attempts, Time.get_ticks_msec() - capture_started)
+ _snapshot_diagnostic_summary(snapshot_diagnostics)
_launch_unproven_message(
pid, attempts, Time.get_ticks_msec() - capture_started, snapshot_diagnostics
)
if fingerprint.is_empty()
else ""
),
@@ -896,12 +907,13 @@ static func _snapshot_diagnostic_summary(diagnostics: Array) -> String:
## (#988, #1012 both surfaced as this bare sentence). Diagnostic only: the
## values are read once more after the capture budget and never grant
## authority.
func _launch_unproven_message(pid: int, attempts: Array, elapsed_ms: int) -> String:
var snapshot: Variant = _capture_process_snapshot(pid)
func _launch_unproven_message(
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 commandline := PortResolver.process_commandline(pid, snapshot) if alive else ""
if commandline.length() > 200:
commandline = commandline.substr(0, 199) + "…"
var creation_identity := PortResolver.process_creation_identity(pid, snapshot)
## Summarise the per-attempt refusals as "reason×count" in first-seen order.
var counts := {}
var order: Array[String] = []
@@ -914,43 +926,218 @@ func _launch_unproven_message(pid: int, attempts: Array, elapsed_ms: int) -> Str
var summary: Array[String] = []
for key in order:
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 "
+ "(pid %d, now alive=%s, refusals: %s%s)."
+ "(pid %d, creation_identity=%s, now alive=%s, refusals: %s)."
) % [
attempts.size(),
elapsed_ms / 1000.0,
pid,
"unavailable" if creation_identity.is_empty() else creation_identity,
"unknown" if PortResolver.capture_failed(snapshot) else ("yes" if alive else "no"),
", ".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:
return PortResolver.capture_process_snapshot(pid)
func _capture_identity(
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:
var launch_grant = payload.get("grant")
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
## when it includes the launcher; the original PID read and all final
## capability, listener, identity, and lineage checks still follow.
var hinted_pid := -1
var first_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:
hinted_pid = PortResolver.read_pid_file(str(payload.get("pid_file", "")))
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):
launch_capture = _retarget_snapshot_capture(first_capture, "launch", launch_pid)
launch_snapshot = first_snapshot
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
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_fingerprint := (
PortResolver.process_fingerprint(launch_pid, launch_snapshot)
@@ -963,72 +1150,103 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
launch_fingerprint,
)
if launch_disposition != "owned":
return {
"ok": false,
"reason": "launch_%s" % launch_disposition,
"message": (
"The launched server process exited or changed identity before publishing capabilities."
+ startup_report_summary(str(payload.get("startup_report", "")))
),
}
if launch_disposition == "gone":
return {
"ok": false,
"reason": "launch_gone",
"message": (
"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 capability := _read_capability(port)
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 (
str(capability.get("http", "")) != str(payload.get("http_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 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):
return {"pending": true, "reason": "authenticated_status"}
return _proof_pending("authenticated_status", stage_attempts)
var version := str(live.get("version", ""))
var ws_port := int(live.get("ws_port", 0))
if not _server_status_compatibility(
version, str(payload.expected_version), ws_port, int(payload.expected_ws_port)
).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", "")))
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, 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:
first_snapshot = (
launch_snapshot if pid == launch_pid else _capture_process_snapshot(pid)
if pid == launch_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):
return {"pending": true, "reason": "identity_unavailable"}
return _capture_failure(stage_attempts, first_capture)
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):
return {"pending": true, "reason": "launch_lineage"}
return _identity_mismatch("launch_lineage", stage_attempts, first_capture)
var fingerprint := PortResolver.process_fingerprint(pid, first_snapshot)
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
## launch-lineage PID must still own the listener, the private capability
## record must still name the authenticated instance, and the process
## fingerprint must remain unchanged after the final probe.
var final_capability := _read_capability(port)
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):
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 (
PortResolver.read_pid_file(str(payload.get("pid_file", ""))) != 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.pid_cmdline_is_godot_ai(pid, final_snapshot)
or PortResolver.process_fingerprint(pid, final_snapshot) != fingerprint
or final_capability != capability
or final_fingerprint != fingerprint
):
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 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_ws_port := int(final_live.get("ws_port", 0))
if not _server_status_compatibility(
@@ -1041,6 +1259,7 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
"ok": false,
"reason": "incompatible",
"message": "The launched server changed compatibility before authority was granted.",
"proof_stage_attempts": stage_attempts.duplicate(true),
}
return {
"ok": true,
@@ -1048,6 +1267,7 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
"fingerprint": fingerprint,
"version": final_version,
"transport": _transport_from(port, final_ws_port, final_live, final_capability),
"proof_stage_attempts": stage_attempts.duplicate(true),
}
@@ -1289,6 +1509,9 @@ func _prove_payload() -> Dictionary:
"ws_capability": str(launch.get("ws_capability", "")),
"baseline_instance_id": str(launch.get("baseline_instance_id", "")),
"grant": _process_grant,
"proof_stage_attempts": _episode.get(
"proof_stage_attempts", {"launch": 0, "first_server": 0, "final_server": 0}
),
}
@@ -1742,7 +1965,8 @@ static func _dormant_episode(episode_id: int) -> Dictionary:
"id": episode_id, "state": DORMANT, "phase": "", "reason": "",
"message": "Server lifecycle is dormant.", "ready_kind": "", "effect": "",
"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},
}
+378 -4
View File
@@ -13,6 +13,19 @@ const _GROUP_OTHER_PERMISSION_MASK := 0x3f ## 0077
const _GROUP_OTHER_WRITE_MASK := 0x12 ## 0022
const _MAX_LINK_HOPS := 8
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] = [
"version", "http", "websocket", "instance_nonce",
]
@@ -322,10 +335,371 @@ static func path_for_http_port(http_port: int) -> String:
"Library/Application Support/godot-ai/capabilities"
)
else:
directory = OS.get_environment("XDG_CONFIG_HOME").strip_edges()
if directory.is_empty():
directory = OS.get_environment("HOME").path_join(".config")
directory = directory.path_join("godot-ai/capabilities")
directory = linux_config_home(_flatpak_info()).path_join("godot-ai/capabilities")
if directory.is_empty() or not directory.is_absolute_path():
return ""
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 @@
[plugin]
name="Toon 材质自动刷新"
description="在编辑器里修改 shader/toon.tres(含内联的描边 next_pass 子材质)参数时,自动重刷当前编辑场景中所有 toon_apply 节点的材质,不必再手动勾 refresh_now。也可从 项目 > 工具 菜单手动触发。"
author="agame"
version="1.0"
script="plugin.gd"
+143
View File
@@ -0,0 +1,143 @@
@tool
extends EditorPlugin
## 让 toon_apply 的材质在编辑器里"改模板即时生效"。
##
## 背景:toon_apply.gd 给每个 MeshInstance3D 都 duplicate() 了一份独立材质
## (因为每个模型有自己的贴图,必须独立),所以之后再改 shader/toon.tres 上的
## 材质级参数(color / specular / fresnel / uv_scale / uv_offset / normal_scale …)
## 不会传到那些副本上,必须重新跑一次 _apply_materials()。
##
## ShaderMaterial 的 Resource.changed 信号在改 shader 参数时不会发出(官方出于性能
## 考虑只在 resource_name 变化时发),所以没法在 toon_apply.gd 内部自己监听。
## 这里改从编辑器侧挂:监听 Inspector 的 property_edited,命中 toon.tres 时重刷。
##
## 注意:本插件只影响"当前正在编辑的场景"里的 toon_apply 节点。
const TARGET_MATERIAL := "res://shader/toon.tres"
const TARGET_SCRIPT_FILE := "toon_apply.gd"
## 防抖间隔:拖颜色滑块时每一步都会触发 property_edited,等停手再重刷一次。
const DEBOUNCE_SEC := 0.2
const MENU_ITEM := "刷新 toon 材质(当前场景)"
var _inspector: EditorInspector
var _dirty := false
var _elapsed := 0.0
func _enter_tree() -> void:
_inspector = EditorInterface.get_inspector()
if _inspector != null and not _inspector.property_edited.is_connected(_on_property_edited):
_inspector.property_edited.connect(_on_property_edited)
add_tool_menu_item(MENU_ITEM, refresh_edited_scene)
set_process(false)
print("[toon_refresh] 已启用:改 %s 会自动重刷当前场景的 toon_apply 材质" % TARGET_MATERIAL)
func _exit_tree() -> void:
remove_tool_menu_item(MENU_ITEM)
if _inspector != null and _inspector.property_edited.is_connected(_on_property_edited):
_inspector.property_edited.disconnect(_on_property_edited)
_inspector = null
func _process(delta: float) -> void:
# 即便 set_process(false) 被引擎重开也安全:这里第一件事就是挡掉
if not _dirty:
return
_elapsed += delta
if _elapsed < DEBOUNCE_SEC:
return
_dirty = false
_elapsed = 0.0
set_process(false)
refresh_edited_scene()
# --- 触发源 ---------------------------------------------------------------
func _on_property_edited(_property: String) -> void:
if _inspector == null:
return
if not _is_target_material(_inspector.get_edited_object()):
return
_dirty = true
_elapsed = 0.0
set_process(true)
## 判断被编辑的资源是不是 toon.tres,或者是它内联的 next_pass 子材质(描边)。
func _is_target_material(obj: Object) -> bool:
if not (obj is ShaderMaterial):
return false
var mat: ShaderMaterial = obj
if mat.resource_path == TARGET_MATERIAL:
return true
# 内联子资源(描边材质)的路径形如
# res://shader/toon.tres::ShaderMaterial_m5gih
if mat.resource_path.begins_with(TARGET_MATERIAL + "::"):
return true
# 兜底:按实例比对 next_pass 链
var template := load(TARGET_MATERIAL) as ShaderMaterial
if template == null:
return false
var pass_mat: Material = template.next_pass
while pass_mat != null:
if pass_mat == mat:
return true
pass_mat = pass_mat.next_pass
return false
# --- 刷新 -----------------------------------------------------------------
## 重刷当前编辑场景里所有挂着 toon_apply.gd 的节点。返回刷新的节点数。
func refresh_edited_scene() -> int:
var root := EditorInterface.get_edited_scene_root()
if root == null:
return 0
var count := _refresh_branch(root)
if count > 0:
print("[toon_refresh] 已重刷 %d 个 toon_apply 节点的材质" % count)
return count
func _refresh_branch(root: Node) -> int:
var count := 0
var stack: Array[Node] = [root]
while not stack.is_empty():
var node: Node = stack.pop_back()
if _is_toon_apply(node):
node.call("_apply_materials")
count += 1
for child in node.get_children():
stack.append(child)
return count
## 按脚本文件名匹配,避免依赖 class_name;同时要求确实有 _apply_materials,
## 防止脚本编译失败时调用出错。
func _is_toon_apply(node: Node) -> bool:
if not node.has_method("_apply_materials"):
return false
var scr := node.get_script()
if not (scr is Script):
return false
return (scr as Script).resource_path.get_file() == TARGET_SCRIPT_FILE
+1
View File
@@ -0,0 +1 @@
uid://dvoc5463koalp
Binary file not shown.
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dg011nl6stbva"
path="res://.godot/imported/Bathroom_Mirror1.fbx-a2eb68acfb37dd1e775c64b39f8c7615.scn"
[deps]
source_file="res://assets/FBX/Bathroom_Mirror1.fbx"
dest_files=["res://.godot/imported/Bathroom_Mirror1.fbx-a2eb68acfb37dd1e775c64b39f8c7615.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
Binary file not shown.
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cv25wyly6jurq"
path="res://.godot/imported/Bathroom_Sink.fbx-f73818545adccbd5c40166a95ff4372e.scn"
[deps]
source_file="res://assets/FBX/Bathroom_Sink.fbx"
dest_files=["res://.godot/imported/Bathroom_Sink.fbx-f73818545adccbd5c40166a95ff4372e.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://k2aif4r0dcd4"
path="res://.godot/imported/Bathroom_Toilet.fbx-513786227618a7929d0fc25741c2841e.scn"
[deps]
source_file="res://assets/FBX/Bathroom_Toilet.fbx"
dest_files=["res://.godot/imported/Bathroom_Toilet.fbx-513786227618a7929d0fc25741c2841e.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b5m7jwgfkxk1l"
path="res://.godot/imported/Bathroom_Toilet2.fbx-8cf1dee2b1d9de048098bc2e2f4ca5af.scn"
[deps]
source_file="res://assets/FBX/Bathroom_Toilet2.fbx"
dest_files=["res://.godot/imported/Bathroom_Toilet2.fbx-8cf1dee2b1d9de048098bc2e2f4ca5af.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ddl5gjm3xypat"
path="res://.godot/imported/Bathroom_ToiletPaper.fbx-afac175125b849ad657a8ec10dfe3f68.scn"
[deps]
source_file="res://assets/FBX/Bathroom_ToiletPaper.fbx"
dest_files=["res://.godot/imported/Bathroom_ToiletPaper.fbx-afac175125b849ad657a8ec10dfe3f68.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ba8jhkc7ie00r"
path="res://.godot/imported/Bathroom_ToiletPaperPile.fbx-2e229cec4cd0fad5c46315c7a434ca61.scn"
[deps]
source_file="res://assets/FBX/Bathroom_ToiletPaperPile.fbx"
dest_files=["res://.godot/imported/Bathroom_ToiletPaperPile.fbx-2e229cec4cd0fad5c46315c7a434ca61.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bnyugn7nrk2le"
path="res://.godot/imported/Bathroom_Towel.fbx-c72402ba1374eedc4672a8381cc6209c.scn"
[deps]
source_file="res://assets/FBX/Bathroom_Towel.fbx"
dest_files=["res://.godot/imported/Bathroom_Towel.fbx-c72402ba1374eedc4672a8381cc6209c.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://18v03y3wcdst"
path="res://.godot/imported/Bathroom_WashingMachine.fbx-0a07d026da7ee9a90e715a0127a93ddc.scn"
[deps]
source_file="res://assets/FBX/Bathroom_WashingMachine.fbx"
dest_files=["res://.godot/imported/Bathroom_WashingMachine.fbx-0a07d026da7ee9a90e715a0127a93ddc.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ctqqsvwiyni71"
path="res://.godot/imported/Bed_Bunk.fbx-ccb0f1f996f757a7fbff2d6356d77ea3.scn"
[deps]
source_file="res://assets/FBX/Bed_Bunk.fbx"
dest_files=["res://.godot/imported/Bed_Bunk.fbx-ccb0f1f996f757a7fbff2d6356d77ea3.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://biyvr8j1layi6"
path="res://.godot/imported/Bed_King.fbx-2b269435c346e9ca358169e5b4ff3164.scn"
[deps]
source_file="res://assets/FBX/Bed_King.fbx"
dest_files=["res://.godot/imported/Bed_King.fbx-2b269435c346e9ca358169e5b4ff3164.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dq62o1d4704x"
path="res://.godot/imported/Bed_Single.fbx-62f18c9879d9f5eb4d93b09aabd8f46c.scn"
[deps]
source_file="res://assets/FBX/Bed_Single.fbx"
dest_files=["res://.godot/imported/Bed_Single.fbx-62f18c9879d9f5eb4d93b09aabd8f46c.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ca3rqdyobwob3"
path="res://.godot/imported/Bookshelf.fbx-a983b8c63c7c32bb2df51421ea956bb6.scn"
[deps]
source_file="res://assets/FBX/Bookshelf.fbx"
dest_files=["res://.godot/imported/Bookshelf.fbx-a983b8c63c7c32bb2df51421ea956bb6.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://tdimr466naha"
path="res://.godot/imported/Carpet_1.fbx-2c10d53226c5191391e089040cf2287b.scn"
[deps]
source_file="res://assets/FBX/Carpet_1.fbx"
dest_files=["res://.godot/imported/Carpet_1.fbx-2c10d53226c5191391e089040cf2287b.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://c0rwn2fc1xak6"
path="res://.godot/imported/Carpet_2.fbx-592eb37b63be0c171cf5b2331168f15d.scn"
[deps]
source_file="res://assets/FBX/Carpet_2.fbx"
dest_files=["res://.godot/imported/Carpet_2.fbx-592eb37b63be0c171cf5b2331168f15d.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://diprpv5om7ysa"
path="res://.godot/imported/Carpet_Round.fbx-52c87007e0783817494a63eca002cba4.scn"
[deps]
source_file="res://assets/FBX/Carpet_Round.fbx"
dest_files=["res://.godot/imported/Carpet_Round.fbx-52c87007e0783817494a63eca002cba4.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://c53up0eumdpep"
path="res://.godot/imported/Chair_1.fbx-f2a51505fad46edc06c20a549b95f9c3.scn"
[deps]
source_file="res://assets/FBX/Chair_1.fbx"
dest_files=["res://.godot/imported/Chair_1.fbx-f2a51505fad46edc06c20a549b95f9c3.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bg8a778i3a0x0"
path="res://.godot/imported/Chair_2.fbx-909ae5f07615b91e2546a02aa990c031.scn"
[deps]
source_file="res://assets/FBX/Chair_2.fbx"
dest_files=["res://.godot/imported/Chair_2.fbx-909ae5f07615b91e2546a02aa990c031.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dh84oq2pggxwm"
path="res://.godot/imported/Chair_3.fbx-9d6a201c91b4bf46aed3184d4ffc3425.scn"
[deps]
source_file="res://assets/FBX/Chair_3.fbx"
dest_files=["res://.godot/imported/Chair_3.fbx-9d6a201c91b4bf46aed3184d4ffc3425.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ehowbf1odm0n"
path="res://.godot/imported/Chair_4.fbx-65aba751c60c12b33d52be6a17469ceb.scn"
[deps]
source_file="res://assets/FBX/Chair_4.fbx"
dest_files=["res://.godot/imported/Chair_4.fbx-65aba751c60c12b33d52be6a17469ceb.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bsfvrs03oivya"
path="res://.godot/imported/Column_Round1.fbx-62b2a069845debb21f915eba3da9ed68.scn"
[deps]
source_file="res://assets/FBX/Column_Round1.fbx"
dest_files=["res://.godot/imported/Column_Round1.fbx-62b2a069845debb21f915eba3da9ed68.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cdu8hlcftny5m"
path="res://.godot/imported/Column_Round2.fbx-24578a74444d16b50524de02224db38a.scn"
[deps]
source_file="res://assets/FBX/Column_Round2.fbx"
dest_files=["res://.godot/imported/Column_Round2.fbx-24578a74444d16b50524de02224db38a.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://d2ma2b72vaxo1"
path="res://.godot/imported/Column_Round3.fbx-c867b3c5f6f7257a4604de0c1da671b3.scn"
[deps]
source_file="res://assets/FBX/Column_Round3.fbx"
dest_files=["res://.godot/imported/Column_Round3.fbx-c867b3c5f6f7257a4604de0c1da671b3.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://r6qi4q7145rj"
path="res://.godot/imported/Column_SquareBig.fbx-04761b9346db428bfa3e2c329b511320.scn"
[deps]
source_file="res://assets/FBX/Column_SquareBig.fbx"
dest_files=["res://.godot/imported/Column_SquareBig.fbx-04761b9346db428bfa3e2c329b511320.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b1to1ga1nvcnm"
path="res://.godot/imported/Column_SquareSmall.fbx-9ba9e18decc0952e9f15732610d86194.scn"
[deps]
source_file="res://assets/FBX/Column_SquareSmall.fbx"
dest_files=["res://.godot/imported/Column_SquareSmall.fbx-9ba9e18decc0952e9f15732610d86194.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b8uka7t77ltyw"
path="res://.godot/imported/Couch_L.fbx-7f6c1d7814ad8375eac94b87bf2d4b22.scn"
[deps]
source_file="res://assets/FBX/Couch_L.fbx"
dest_files=["res://.godot/imported/Couch_L.fbx-7f6c1d7814ad8375eac94b87bf2d4b22.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ce4l2vyn4fjqn"
path="res://.godot/imported/Couch_Large1.fbx-20abbe31fc2f3a62176129e6a569efd8.scn"
[deps]
source_file="res://assets/FBX/Couch_Large1.fbx"
dest_files=["res://.godot/imported/Couch_Large1.fbx-20abbe31fc2f3a62176129e6a569efd8.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://byxa7stif5pl3"
path="res://.godot/imported/Couch_Large2.fbx-380a0fa44488ebce8fb0f62c0ea1dc43.scn"
[deps]
source_file="res://assets/FBX/Couch_Large2.fbx"
dest_files=["res://.godot/imported/Couch_Large2.fbx-380a0fa44488ebce8fb0f62c0ea1dc43.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cjnl5r0myp76e"
path="res://.godot/imported/Couch_Large3.fbx-b6463a0d1b2434d1fe3e03fcb2ae193c.scn"
[deps]
source_file="res://assets/FBX/Couch_Large3.fbx"
dest_files=["res://.godot/imported/Couch_Large3.fbx-b6463a0d1b2434d1fe3e03fcb2ae193c.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dqvqypmifo3m3"
path="res://.godot/imported/Couch_Medium1.fbx-8a1d03b61f82922a30e0d9a5f36c6f49.scn"
[deps]
source_file="res://assets/FBX/Couch_Medium1.fbx"
dest_files=["res://.godot/imported/Couch_Medium1.fbx-8a1d03b61f82922a30e0d9a5f36c6f49.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.

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