# 原生迁移笔记 —— 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)