Files
spellforge/docs_dev/2026-07-23-native-migration-notes.md
T
joywayerandClaude Opus 4.8 1215b08e88 docs(dev): 新增原生迁移笔记——GDScript 别名陷阱 + C#/C++ 激活期接缝一致性项
沉淀两类内容并登记 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>
2026-07-23 16:38:55 +08:00

96 lines
6.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 原生迁移笔记 —— 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×644096²)~~ → 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()`」(每次约 15µ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 预算表,~786808 行)
- 文档 vs 代码审计(C# 死代码现状):[`doc_code_audit_2026-07-20.md`](doc_code_audit_2026-07-20.md)