沉淀两类内容并登记 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>
6.4 KiB
原生迁移笔记 —— GDScript 陷阱与 C#/C++ 激活期注意项(2026-07-23)
类型:笔记 / 迁移备忘
背景:本工程运行时 100% 为 GDScript,C#/C++ 为「就绪但休眠」的逃生舱(见
../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 热路径时遵守)
- 返回 PackedArray 的函数,若调用方可能在遍历返回值期间重入本函数,绝不可复用成员缓冲后
return—— 返回值是活引用别名,会被重入覆写。默认每次return新建数组是安全写法。 - 想省分配又要安全,用 out 参数 + 每个调用点各自持有独立持久缓冲
(
func query_into(center, radius, out: PackedInt32Array),out 会正确传回)。 前提:须逐调用点确认该缓冲不会自我重入。query_circle有 4 处调用点 (BulletManager / ZoneManager / MinionManager / SpellEvaluator),改造面大。 - 只有实测收益足够大才值得。本例仅省约 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² 覆盖) |
|
| 坐标偏移 | 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 - 热路径边界规则(
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