Files
spellforge/docs_dev/2026-07-23-native-migration-notes.md
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

6.4 KiB
Raw Permalink Blame History

原生迁移笔记 —— 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_resultclear()+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 已对齐(提交 7038505dotnet build 0 错误):

权威外壳 spatial_grid.gd 修前 SpatialGridCs.cs
网格尺寸 GRID_COLS/ROWS = 1288192² 覆盖) 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_COLORenemy_type 键的 GDScript Dictionaryenemy_manager.gd:40-43), 在 _gd_update_movement 内循环 _SPEED.get(int(_data[base+6]), ...) 逐敌查字典 :110apply_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