沉淀两类内容并登记 docs_dev 索引: ① GDScript 陷阱:PackedArray 返回值是活引用别名(非 CoW 副本),复用成员缓冲后 return 会被调用方遍历期间的嵌套调用就地覆写(query_circle 复用优化翻车、已回退的教训), 附实测证据 + 通用规则(默认每次新建;省分配用 out 参数+各调用点独立缓冲)。 ② C#/C++ 激活期接缝一致性项:SpatialGridCs 网格常量已对齐(已修); EnemyManager 原型字典 _SPEED/_ARMOR 内循环逐敌查,迁移期须在 C# 侧从 enemies.json 自建原生数组以守 ADR-L1 铁律(a),现在不改(避免投机 churn)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
96 lines
6.4 KiB
Markdown
96 lines
6.4 KiB
Markdown
# 原生迁移笔记 —— GDScript 陷阱与 C#/C++ 激活期注意项(2026-07-23)
|
||
|
||
> 类型:笔记 / 迁移备忘
|
||
>
|
||
> 背景:本工程运行时 100% 为 GDScript,C#/C++ 为「就绪但休眠」的逃生舱(见
|
||
> [`../CLAUDE.md`](../CLAUDE.md) 编程语言规范、`R-08` / `ADR-L1`)。实测 500 敌 + 1500 弹
|
||
> `_physics_process` ≈ 0.6ms(帧预算 ~3.6%),性能充裕,**当前无任何热路径够格迁移**。
|
||
> 本文沉淀两类内容:① 一次热路径「零成本优化」翻车暴露的 GDScript 陷阱与通用规则;
|
||
> ② 将来真按 `ADR-L1` 激活 C#/C++ 内核时必须处理的接缝一致性项。
|
||
|
||
---
|
||
|
||
## 1. GDScript 陷阱:PackedArray 返回值是「活引用别名」,不可复用成员缓冲后返回
|
||
|
||
### 事件
|
||
为削减 `SpatialGrid.query_circle`(每帧被 BulletManager 调用 1500+ 次)每次新建
|
||
`PackedInt32Array` 的开销,曾将其改为复用一个成员缓冲 `_query_result`(`clear()`+`append`
|
||
就地填充后 `return`),editor 基准显示约 **11% 提速**、2000 次**顺序**查询结果与原实现逐点一致。
|
||
**但该改动有重入(reentrancy)缺陷,已回退**(提交 `35c1742` 引入 → `2a7658e` 回退)。
|
||
|
||
### 根因
|
||
GDScript 中 `Array` / `Dictionary` / `Packed*Array` **按引用传递**,且**函数返回的 PackedArray
|
||
是底层缓冲的活引用别名,而非写时复制(CoW)副本**。因此当调用方仍在遍历返回的
|
||
`hits` 时,若中途触发一次**嵌套** `query_circle`(真实路径:子弹命中 →
|
||
`SpellEvaluator.execute_sub` 的区域法术再查一次网格),嵌套调用的 `clear()`+`append`
|
||
会**就地覆写外层正在遍历的同一缓冲**,导致碰撞遍历读到错误的 entity_id(打错敌人 / 漏敌)。
|
||
|
||
### 实测证据(editor 内复现)
|
||
- out 参数确会传回:`fill(out_arr)` 内 `resize`+写入后,调用方变量 size/值均更新(证伪「CoW 会分叉」的误判)。
|
||
- 重入覆写复现:外层缓冲填 `[10,20,30]` 开始遍历,遍历中嵌套查询填 `[77,88]`,
|
||
外层实际遍历到 **`[10,88]`**(而非期望的 `[10,20,30]`)。
|
||
|
||
### 通用规则(写 GDScript 热路径时遵守)
|
||
1. **返回 PackedArray 的函数,若调用方可能在遍历返回值期间重入本函数,绝不可复用成员缓冲后 `return`**——
|
||
返回值是活引用别名,会被重入覆写。默认每次 `return` 新建数组是安全写法。
|
||
2. 想省分配又要安全,用 **out 参数 + 每个调用点各自持有独立持久缓冲**
|
||
(`func query_into(center, radius, out: PackedInt32Array)`,out 会正确传回)。
|
||
前提:须逐调用点确认该缓冲不会自我重入。`query_circle` 有 4 处调用点
|
||
(BulletManager / ZoneManager / MinionManager / SpellEvaluator),改造面大。
|
||
3. **只有实测收益足够大才值得**。本例仅省约 **0.064ms/帧(帧预算 ~0.4%)**,
|
||
不足以支撑跨 4 调用点的重入安全改造,故回退——符合工程「先测量后优化、无凭据不下探」原则。
|
||
|
||
### 元教训
|
||
「顺序调用逐点一致」**不等于**正确;**没覆盖到的路径(此处是重入)就是没验证**。
|
||
热路径改动务必显式设计并验证重入 / 别名场景。
|
||
|
||
---
|
||
|
||
## 2. C#/C++ 激活期接缝一致性项(`ADR-L1` 触发时必查)
|
||
|
||
真按 `ADR-L1` 激活原生内核前,务必核对休眠骨架与 GDScript 权威外壳的**接缝契约**逐一对齐,
|
||
否则「激活即塌」。当前已知项:
|
||
|
||
### 2.1【已修】SpatialGridCs 网格常量 / 坐标偏移
|
||
`csharp/systems/SpatialGridCs.cs` 曾与权威外壳 `scripts/autoloads/spatial_grid.gd` 三处不一致,
|
||
2026-07-23 已对齐(提交 `7038505`,`dotnet build` 0 错误):
|
||
|
||
| 项 | 权威外壳 `spatial_grid.gd` | 修前 `SpatialGridCs.cs` |
|
||
| :-- | :-- | :-- |
|
||
| 网格尺寸 | `GRID_COLS/ROWS = 128`(8192² 覆盖) | ~~64×64(4096²)~~ → 128×128 |
|
||
| 坐标偏移 | `GRID_OFFSET = 4096`(支持负坐标) | ~~无偏移~~ → 补 `GridOffset=4096` |
|
||
| 格映射 | `(coord ± radius + 4096) / 64` | ~~`coord/64`(负坐标塌到 0 格)~~ → 已加偏移 |
|
||
|
||
> C# 侧已加注释「必须与 spatial_grid.gd 常量逐一对齐」。今后改任一侧常量须同步另一侧。
|
||
|
||
### 2.2【待办·迁移期】EnemyManager 原型数值为 GDScript 字典,内循环逐敌查
|
||
`scripts/autoloads/enemy_manager.gd` 的 `_SPEED / _ARMOR / _RENDER_SIZE / _RENDER_COLOR`
|
||
是**按 `enemy_type` 键的 GDScript `Dictionary`**(`enemy_manager.gd:40-43`),
|
||
在 `_gd_update_movement` 内循环 `_SPEED.get(int(_data[base+6]), ...)` **逐敌查字典**
|
||
(`:110`,`apply_damage` 的 `_ARMOR.get` 同理 `:268`)。
|
||
|
||
- **现状(纯 GDScript)无问题**:字典查在 GD 内是本地操作,无跨语言封送。
|
||
- **迁移期风险**:一旦 EnemyManagerCs 接管内循环,若在 C# 内层 `Get("_SPEED")...` 逐敌读该字典,
|
||
即违反 `ADR-L1` 铁律 (a)「内循环禁止 `GodotObject.Call()/.Get()/.Set()`」(每次约 1–5µs 封送,
|
||
×上千敌/帧即耗尽帧预算)。
|
||
- **迁移期做法**:C# 内核启动时从**同一份** `res://data/enemies.json` 自建
|
||
`type → speed/armor/...` 原生数组(`int[]` / `float[]`),内循环只读原生数组,
|
||
不回调 GDScript 字典。属纯数据驱动、无内容副本,与现有加载逻辑 `_load_json_archetypes()` 同源。
|
||
- **注意**:此项**现在不改**——在纯 GD 期把它改成别的结构属无数据背书的投机 churn。
|
||
仅当真触发 C# 迁移时按上述做法在 C# 侧落地。
|
||
|
||
### 2.3 其余接缝(已核对,就绪)
|
||
- BulletManager / EnemyManager 热数据均为 SoA `PackedFloat32Array _data` 成员,
|
||
步长为命名常量(`BULLET_STRIDE=12` / `ENEMY_STRIDE=8`)并有布局注释,C# 可 `AsSpan()` 零拷贝直读。
|
||
- 三处外壳均有 `_cs_node` 接缝 + 干净 GD 回退(`if not _cs_node:`),骨架未加载时游戏照常运行。
|
||
- BulletManagerCs / EnemyManagerCs 现为 S0 骨架,仅读 `_active_count` 打帧计时,
|
||
不含 SoA 布局常量,无对齐问题。
|
||
|
||
---
|
||
|
||
## 参考
|
||
- 语言规范 / 三级逃生梯:[`../CLAUDE.md`](../CLAUDE.md)
|
||
- 热路径边界规则(`ADR-L1` 三铁律 + `R-08`):`../docs/technical/architecture_design.md` §6.1
|
||
- S0 预算与实测:`../docs/technical/architecture_design.md` §(S0 预算表,~786–808 行)
|
||
- 文档 vs 代码审计(C# 死代码现状):[`doc_code_audit_2026-07-20.md`](doc_code_audit_2026-07-20.md)
|