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