Compare commits
20
Commits
020c2a50ce
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fb16e45c5 | ||
|
|
d2b0a47850 | ||
|
|
e2491bc4e1 | ||
|
|
5c0adb3a96 | ||
|
|
b8bbe21c62 | ||
|
|
1822a2211e | ||
|
|
a76c4aebef | ||
|
|
18e7b2daa8 | ||
|
|
ecebf76f47 | ||
|
|
877d3bbad0 | ||
|
|
f2e06e9dee | ||
|
|
a1d7007241 | ||
|
|
ed1a9949c8 | ||
|
|
f296b643d4 | ||
|
|
c81571ec1e | ||
|
|
850d657404 | ||
|
|
322c1c22eb | ||
|
|
9d60807642 | ||
|
|
fc89790363 | ||
|
|
02c8d36f8d |
@@ -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`。
|
||||
@@ -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,无法自动语法校验。
|
||||
|
||||
@@ -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` 同样被忽略。
|
||||
@@ -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`。
|
||||
|
||||
@@ -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),
|
||||
所以修缩放不影响拾取距离。
|
||||
@@ -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,出生会先坠落。)
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvoc5463koalp
|
||||
Binary file not shown.
@@ -1,46 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://cog1wka5poqjn"
|
||||
path="res://.godot/imported/Bathroom_Bathtub.fbx-756d957486824576e1b70e791ab28c3a.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/FBX/Bathroom_Bathtub.fbx"
|
||||
dest_files=["res://.godot/imported/Bathroom_Bathtub.fbx-756d957486824576e1b70e791ab28c3a.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://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.
@@ -1,46 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://cj6oa0aye8omk"
|
||||
path="res://.godot/imported/Bathroom_Mirror2.fbx-4a0005e834336133440e19b8d47bdd97.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/FBX/Bathroom_Mirror2.fbx"
|
||||
dest_files=["res://.godot/imported/Bathroom_Mirror2.fbx-4a0005e834336133440e19b8d47bdd97.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://dkvxl0c8ygjqe"
|
||||
path="res://.godot/imported/Bathroom_Shower1.fbx-98c42ade0f9085c4faa6c1dbbfcc00c2.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/FBX/Bathroom_Shower1.fbx"
|
||||
dest_files=["res://.godot/imported/Bathroom_Shower1.fbx-98c42ade0f9085c4faa6c1dbbfcc00c2.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://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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user