From f988c2a0c3f4d89b55f88528bf09ba5b92443987 Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Sun, 27 Sep 2026 01:20:49 +0800 Subject: [PATCH 1/4] test(compiler): characterize custom-instance Callable behavior - Capture the builtin constructor rejection for an object subclass argument and the runtime receiver-lifetime behavior of method-reference sugar - Document the planned call-boundary upcast fix while preserving Godot semantics and backend exact matching --- ...ontend_call_argument_object_upcast_plan.md | 287 ++++++++++++++++++ .../build/CustomReceiverCallableGapTest.java | 253 +++++++++++++++ 2 files changed, 540 insertions(+) create mode 100644 doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md create mode 100644 src/test/java/gd/script/gdcc/backend/c/build/CustomReceiverCallableGapTest.java diff --git a/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md b/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md new file mode 100644 index 00000000..d9bd6064 --- /dev/null +++ b/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md @@ -0,0 +1,287 @@ +# Frontend 调用参数对象上转型物化实施计划 + +> 本文档记录 `Callable(customInstance, &"method")` 等 builtin 构造调用在实参为 object 子类时被 C 后端拒绝这一缺陷的修复计划。修复手段是在**调用参数边界**物化纯表示的对象 upcast(目标类型临时槽 + `assign`),保持后端 builtin 构造器精确匹配合同不变。 + +## 文档状态 + +- 状态:计划待实施(成因链路已闭环,最小复现测试已存在;方案与范围已拍板,评审修订第 1 轮完成,尚未改代码) +- 更新时间:2026-09-27 +- 适用范围: + - `doc/module_impl/frontend/**` + - `src/main/java/gd/script/gdcc/frontend/lowering/**` + - `src/test/java/gd/script/gdcc/frontend/lowering/**` + - `src/test/java/gd/script/gdcc/backend/c/**` +- 关联文档: + - `frontend_implicit_conversion_matrix.md`(typed-boundary 兼容性唯一真源,须先改) + - `frontend_lowering_(un)pack_implementation.md`(boundary materialization 单一入口合同,须同步改) + - `frontend_rules.md`(slot 命名合同,须同步补一条) + - `doc/module_impl/backend/builtin_builder_implementation.md`(后端 exact-match 合同,本次不修改) + - `doc/module_impl/backend/object_value_fat_pointer_implementation.md`(upcast 表示转换合同) + - `doc/gdcc_ownership_lifecycle_spec.md`(槽写入 / managed local 清理规则) + - `frontend_signal_support.md`(Callable 不保活接收者条款) + - `doc/gdcc_low_ir.md`(`construct_callable` / `assign` 合同) + +--- + +## 1. 背景与成因链路 + +最小复现为 `src/test/java/gd/script/gdcc/backend/c/build/CustomReceiverCallableGapTest.java`,覆盖两种写法。 + +### 1.1 显式构造 `Callable(token, &"bump")` —— 本计划要修的缺陷 + +1. 前端按 builtin type-meta 构造解析,元数据确有 `Callable(Object, StringName)`(`extension_api_451.json` constructor index 2)。 +2. 参数边界 `Token -> Object` 经 `ClassRegistry.checkAssignable` 判为可赋值,`FrontendVariantBoundaryCompatibility` 返回 `ALLOW_DIRECT`;lowering 在 `materializeFrontendBoundaryValue` 的 `ALLOW_DIRECT` 分支原样复用**子类类型槽**(`FrontendBodyLoweringSession.java` `case ALLOW_DIRECT -> sourceSlot`)。 +3. `materializeCallArguments` 把该槽写入 `ConstructBuiltinInsn`,实参静态类型为 `RuntimeCallableGapProbe__sub__Token`。 +4. 后端 `CBuiltinBuilder.constructRegularBuiltin(...)` 先以 `hasConstructor()` 将两侧渲染为 GD 类型名后做**严格字符串相等**:`RuntimeCallableGapProbe__sub__Token != "Object"`;未命中且无 helper-shim 后在 `:404-406` 抛出 `Builtin constructor validation failed: 'Callable' with args [RuntimeCallableGapProbe__sub__Token, StringName] is not defined in ExtensionBuiltinClass`。 + +根因:前端"继承可赋值"与后端 builtin 构造器"精确名匹配"两个边界规则之间缺少一次纯表示 upcast 物化。职责划分上,`builtin_builder_implementation.md` 明确"需要 widening 时由上游 lowering 或 intrinsic 显式物化",因此修复点在前端 lowering,后端匹配器保持 exact。 + +注意范围:该精确匹配只存在于 builtin **构造器**路径。builtin/engine **方法**调用的后端实参处理已是 `checkAssignable` 校验 + 子类实参自动 upcast(`CallMethodInsnGen.java:518-533`,`valueOfCastedVar`),不存在同类失败;本计划对方法调用参数统一物化形态只是为了调用参数边界的不变量一致,不是为修复方法调用。 + +### 1.2 方法引用 `token.bump` 跨作用域失效 —— 已定性为 Godot 一致行为(B1),不在本计划修复范围 + +复现结论:同作用域调用成功(构造路径完全正确),跨作用域失败(`fire()` 返回 -1)。生成的 C 证实 `arm()` 返回时三个 RefCounted 槽按 managed-locals 规则全部 release,Token 归零析构;而 Godot 标准 `Callable` 内存布局只有 `ObjectID + StringName`,结构上不可能 retain 接收者。官方 GDScript 写同样代码行为一致。规范条款见 `frontend_signal_support.md`(Callable/Signal 只保存非 owning ObjectID)与 `doc/gdcc_low_ir.md` `construct_callable`(不保活接收者)。 + +已拍板(方向 B1):保持 Godot 语义,把 sugar 用例从"gap"重新定性为 Godot 一致性验收,仅更新测试表述与补充文档说明,不改运行行为。 + +--- + +## 2. 目标与非目标 + +### 2.1 目标 + +- `Callable(customInstance, &"method")`、`Signal(customInstance, &"signal")` 及其他"声明参数为具名 object 祖先类型、实参为其子类"的 builtin **构造器**调用(元数据 `Callable` / `Signal` constructor index 2 均为 `(Object, StringName)`)能通过前端 lowering 与 C 后端 codegen,运行时行为与 Godot 一致。 +- 后端 `CBuiltinBuilder` exact-match 合同与 LIR 指令集零改动。 +- sugar 用例按 B1 重新定性为一致性验收。 + +### 2.2 非目标 + +- 不改变赋值、返回、property store、merge 写入等非调用参数边界的 `ALLOW_DIRECT` 物化行为(这些消费者已有 C 级 upcast,见 `CBodyBuilder.convertObjectValueIfNeeded`,不依赖精确类型)。 +- 不改变 Callable 不保活接收者的语义;不引入 `CallableCustom` retain 机制。 +- 不引入新的 LIR 指令、新的 boundary `Decision` 枚举值或后端 assignability 匹配。 +- 以下两条**绕过调用参数物化**的已知路径为非目标,实现时不得"顺手"修改: + - builtin 单 `Variant` 实参构造特判(`FrontendSequenceItemInsnLoweringProcessors.java:946-970`):发射 `UnpackVariantInsn`,不经过 `materializeCallArguments`,也不产生 `ConstructBuiltinInsn`;`Variant` 内装子类再构造的同类失败不在本计划范围。 + - `materializeBuiltinConstructorBoundary(...)`(`FrontendBodyLoweringSession.java:1265-1278`):产生 `ConstructBuiltinInsn` 但不经过 `materializeCallArguments`,`String <-> StringName` 专用,实参类型恒为字符串家族;若未来某条 object 边界被标为 `ALLOW_WITH_BUILTIN_CONSTRUCTOR`,须另行立项处理。 +- engine/builtin 方法调用后端已有可赋值性处理(§1.1),其细节调整不在本计划内。 + +--- + +## 3. 已拍板的设计决策 + +1. **修复手段**:方案 A——前端在调用参数边界物化目标类型 upcast temp(`AssignInsn`),不放宽后端匹配器。 +2. **生效范围**:仅调用参数边界(`FrontendBodyLoweringSession.materializeCallArguments` 的 fixed-parameter 循环),不扩散到全部 typed boundary。 +3. **问题一(跨作用域失效)**:方向 B1,保持 Godot 语义。 +4. **文档顺序**:遵守 `frontend_implicit_conversion_matrix.md` 维护合同与 materialization 单一入口合同,先改文档(矩阵 + `(un)pack` + `frontend_rules.md` 命名条款),再改代码与测试。 + +--- + +## 4. 技术设计 + +### 4.1 改动位置 + +唯一代码改动点:`FrontendBodyLoweringSession.materializeCallArguments(...)` 的 fixed-parameter 循环(当前对每个实参直接调用 `materializeFrontendBoundaryValue` 非冻结重载)。新增一个调用参数专用的物化入口,vararg 尾段(目标恒为 `Variant`,走 pack)与 `DYNAMIC` 调用(按合同绕过签名边界)保持不变。 + +### 4.2 新 helper 形态(实现时以最终代码为准) + +```java +/// Materializes one fixed call argument. Ordinary typed boundaries reuse the source slot for +/// ALLOW_DIRECT, but a fixed call operand must carry the resolved signature's declared parameter +/// type: the backend builtin CONSTRUCTOR matcher validates metadata by exact type name +/// (CBuiltinBuilder.constructRegularBuiltin). A proven object upcast is therefore materialized as +/// a target-typed temp here instead of reusing the subclass slot. Method calls already tolerate +/// subclass args backend-side (CallMethodInsnGen checkAssignable + valueOfCastedVar); they share +/// this uniform shape without behavior change. +/// +/// Note: `CallArgumentBoundaryPlan` currently carries only `fixedParameterTypes` + `isVararg` +/// (no published `Decision`), so this helper re-derives the matrix decision exactly like the +/// existing fixed-parameter loop. Once the plan starts publishing a frozen `Decision`, this +/// helper MUST switch to the frozen-decision overload and must not re-query the matrix. +private @NotNull String materializeCallArgumentBoundaryValue( + @NotNull LirBasicBlock block, + @NotNull String sourceSlotId, + @NotNull GdType sourceType, + @NotNull GdType targetType, + @NotNull String boundaryUse +) { + var decision = FrontendVariantBoundaryCompatibility.determineFrontendBoundaryDecision( + classRegistry, sourceType, targetType + ); + if (decision == FrontendVariantBoundaryCompatibility.Decision.ALLOW_DIRECT + && sourceType instanceof GdObjectType + && targetType instanceof GdObjectType + && !sourceType.equals(targetType)) { + var upcastSlotId = nextBoundaryMaterializationSlotId(boundaryUse, "upcast"); + ensureVariable(upcastSlotId, targetType); + block.appendNonTerminatorInstruction(new AssignInsn(upcastSlotId, sourceSlotId)); + return upcastSlotId; + } + return materializeFrontendBoundaryValue(block, sourceSlotId, sourceType, targetType, decision, boundaryUse); +} +``` + +要点: + +- 同类型、非对象类型、`Variant` 目标、`LiteralNull`、pack/unpack/intrinsic/builtin-constructor 等其余 decision 全部走既有路径,零行为变化。 +- 可赋值性已由矩阵决策(`ALLOW_DIRECT`)保证,helper 不再重复继承判断。 +- 形态复刻显式 cast 的 `OBJECT_UPCAST` 分支(`emitExplicitCast` 中 `new AssignInsn(resultSlotId, sourceSlotId)`),不引入新指令。 +- 冻结/非冻结重载无歧义:冻结版多一个 `Decision` 形参;fallback 调用按六参数唯一解析。 +- `ensureVariable` 对同 id 同类型幂等;temp id 含单调递增 `boundaryMaterializationCounter`,同一实参值使用两次不会产生槽冲突。 + +### 4.3 LIR 前后对比(以 `Callable(token, &"bump")` 为例) + +```text +# 修复前(后端拒绝) +$cb = construct_builtin Callable [$token: RuntimeCallableGapProbe__sub__Token, $sn: StringName] + +# 修复后 +$cfg_boundary_call_fixed_0_upcast_0: Object = assign $token +$cb = construct_builtin Callable [$cfg_boundary_call_fixed_0_upcast_0: Object, $sn: StringName] +``` + +### 4.4 生成 C 形态(全部复用现有机制) + +```c +gdcc_Object_fat_ptr $cfg_boundary_call_fixed_0_upcast_0; +// __prepare__ 先行 null 初始化(CCodegen: object 槽 LiteralNullInsn),随后被 assign 覆写; +// 槽写入对捕获到的旧 null 做一次 try_release_object(NULL, 0),为 no-op +$cfg_boundary_call_fixed_0_upcast_0 = (gdcc_Object_fat_ptr){ 0 }; +... +// upcast helper:ownership-neutral,保留 instance_id,仅转换 ptr 表示 +gdcc_Object_fat_ptr __gdcc_tmp_old_obj_N = $cfg_boundary_call_fixed_0_upcast_0; +$cfg_boundary_call_fixed_0_upcast_0 = + gdcc_RuntimeCallableGapProbe_sub_Token_fat_ptr_upcast_to_Object($token); +// 槽写入规则:借用 RHS 写入 owning 槽 -> own;精确 Object 的 RefCountedStatus 为 UNKNOWN -> 双参数 try_ 变体 +try_own_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_upcast_0), + $cfg_boundary_call_fixed_0_upcast_0.instance_id); +try_release_object(gdcc_Object_fat_ptr_live_object(__gdcc_tmp_old_obj_N), __gdcc_tmp_old_obj_N.instance_id); +// construct_builtin 实参必须是变量操作数(ConstructInsnGen.resolveConstructorArguments): +// Object 实参经 live_object 取裸指针;StringName 字面量由 LiteralStringNameInsn 先物化到槽、按地址传参 +godot_Callable __gdcc_tmp_callable_N = godot_new_Callable_with_Object_StringName( + gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_upcast_0), + &$sn); +// ... __finally__: managed local 自动清理(UNKNOWN -> 双参数 try_release),与 own 一对一平衡 +try_release_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_upcast_0), + $cfg_boundary_call_fixed_0_upcast_0.instance_id); +``` + +涉及现有机制:`CBodyBuilder` assign 路径的 `convertObjectValueIfNeeded`(`checkAssignable` + 生成 upcast helper)、模板生成的 `*_upcast_to_*` helper(保留 `instance_id`)、`ownOrTryOwn` / `releaseOrTryRelease`(`try_*` 均为 `(live_ptr, fat_ptr.instance_id)` 双参数,`gdcc_helper.h`)、managed-local finally 清理(`CCodegen`)。注意 `GD_STATIC_SN(...)` 只出现在 `construct_callable` 方法引用路径,builtin 构造器的 `StringName` 实参是槽地址,二者不得混淆。 + +### 4.5 Ownership 平衡论证 + +- 临时槽按普通 `ensureVariable` 声明,走标准槽写入规则:`__prepare__` 先 null 初始化;assign 覆写时 own 新值一次、release 旧值(null,no-op)一次;函数出口按 managed-locals 规则再 release/try_release 一次。真正对象的 own/release 一对一平衡,无泄漏、无 double-free。 +- 目标为非 RefCounted 祖先(如 `Node`)时:RefCountedStatus `NO`,own/release 均不发射,temp 实为借用别名;实参先求值、随后在同一直线代码段内完成物化与调用发射,源槽存活覆盖整个区间,与现状(直接传源槽)安全性等价。 +- 目标为 RefCounted 派生具名祖先(如 `Resource`)时:status `YES`,`own_object` / `release_object` 标准配对。 +- 目标为精确 `Object` 时:status `UNKNOWN`,`try_own_object` / `try_release_object` 双参数变体,运行时按 ObjectID reference bit 判断。 +- upcast helper 保留 `instance_id`,构造出的 Callable 记录的 ObjectID 与源对象 `get_instance_id()` 完全一致,`get_object()` / 信号连接比较语义与 Godot 原生逐比特一致。 + +--- + +## 5. 分步骤实施与验收细则 + +每一步都保持可编译、可回归、可单独提交。测试命令统一使用 `script/run-gradle-targeted-tests.sh`。 + +### 步骤 0:文档先行(单独提交,三处同改) + +1. `frontend_implicit_conversion_matrix.md` + - 全局规则"任意 object subclass -> object superclass"行备注:兼容性维持 Y,物化形态加引用说明"fixed call argument 边界的物化形态例外见 `frontend_lowering_(un)pack_implementation.md` §4.2/§4.3"。 + - §9.1 `materializeFrontendBoundaryValue` 锚点处补一句:fixed call argument 的 object upcast 例外与该单一入口同层登记,不视为 consumer 私设局部分支。 + - 更新"文档状态"的更新时间与本计划文档链接。 +2. `frontend_lowering_(un)pack_implementation.md` + - §4.2 "helper 的当前合同":direct 默认仍"直接返回原 slot id";补唯一例外——fixed call argument 边界上严格 object 子类 -> 祖先,物化为 target-typed temp + `AssignInsn`。 + - §4.3 "call boundary 合同":写明该例外只发生在 `materializeCallArguments` 的 fixed 循环;vararg 与 `DYNAMIC` 不变。 +3. `frontend_rules.md` + - slot 命名条款(`:98`)补一条:boundary materialization temp(pack/unpack/null/intrinsic/builtin-constructor/call-arg upcast)使用 `cfg_boundary___`;它们不是 CFG value id,不适用 `cfg_tmp_` 规则。 + +验收:文档 diff 只涉及上述说明;无代码改动。 + +### 步骤 1:前端 helper 实现 + +改动:`FrontendBodyLoweringSession.java` + +- 新增 `materializeCallArgumentBoundaryValue(...)`(§4.2 形态,含注释中的 frozen-decision 后续约束)。 +- `materializeCallArguments` fixed-parameter 循环改调新 helper;vararg 循环与 `DYNAMIC` 分支不变。 + +验收:`./gradlew classes --no-daemon --info --console=plain` 编译通过。 + +### 步骤 2:前端 lowering 单测 + +改动:`FrontendLoweringBodyInsnPassTest`(或同包合适的测试类,实现时确认)。用例必须构造真实的自定义类 hierarchy(如 `Token extends RefCounted`),不能只喂两个孤立 `GdType`。 + +新增用例: + +- `Callable(token, &"bump")`(自定义子类实参)→ 断言三点:block 内出现 `AssignInsn(upcastTemp, tokenSlot)`;`ConstructBuiltinInsn` 第一个实参为该 temp;该 temp 经 `ensureVariable` 声明的类型为精确 `Object`。 +- 同类型实参(变量声明类型即 `Object`)→ 断言**不**出现 upcast temp,`ConstructBuiltinInsn` 直接引用源槽(零开销路径回归锚点)。 +- 非对象 boundary 回归抽样:`String -> StringName` 仍走 `ALLOW_WITH_BUILTIN_CONSTRUCTOR`,`int -> float` 仍走 intrinsic cast(防止 helper 改动污染其他 decision)。 + +验收:`script/run-gradle-targeted-tests.sh --tests 'FrontendLoweringBodyInsnPassTest'` 全绿。 + +### 步骤 3:后端 codegen 单测(只加测试,不改后端代码) + +改动:`CConstructInsnGenTest`,以及一个"前端实际 lowering 产物 -> codegen"的链路测试(可挂在现有 lowering-codegen 混合测试类上,实现时确认位置)。 + +- 后端单元锚点(手工 LIR,不经过前端 helper,措辞不得暗示端到端):`ConstructBuiltinInsn(Callable, [Object fat-ptr arg, StringName slot])` → 断言发射 `godot_new_Callable_with_Object_StringName(gdcc_Object_fat_ptr_live_object(...), &$sn)`,Object 实参为 live raw pointer、StringName 实参为槽地址;字面量槽初始化单独断言。自定义类 fat-ptr 需把多个 class 放进 module(参照文件内现有多 class 用例)。 +- 所有权三态锚点(对前端实际 lowering 所得 LIR 跑 codegen): + - 子类 -> 精确 `Object`(UNKNOWN):槽写入处出现**双参数** `try_own_object(live, slot.instance_id)`,`__finally__` 出现配对 `try_release_object(live, slot.instance_id)`。 + - 子类 -> `Node`(NO):upcast 槽周围不出现 own/release。 + - 子类 -> `Resource`(YES):出现精确 `own_object` / `release_object` 配对。 + - 另选一条普通方法调用(子类实参)覆盖同一 fixed 参数入口,确认方法路径功能等价(后端原有 `valueOfCastedVar` upcast 退化为同型直传)。 + +验收:`script/run-gradle-targeted-tests.sh --tests 'CConstructInsnGenTest'` 及所加链路测试类全绿。 + +### 步骤 4:翻转 `CustomReceiverCallableGapTest` 并按 B1 重定性 sugar 用例 + +改动:`CustomReceiverCallableGapTest.java`、`frontend_signal_support.md` + +- `explicitCallableConstructionFromCustomInstanceFailsCodegen` 翻转为成功路径,**保持 `fakeCompiler()` 结构**(该路径本就不依赖 Zig/Godot):断言 `buildProject` 不再抛出、且不再出现 `ExtensionBuiltinClass` 失败链;测试改名(如 `explicitCallableConstructionFromCustomInstancePassesCodegen`)。 +- 运行时段(`probe_immediate == 1`、`fire() == -1`)如需覆盖显式构造,复用 sugar 用例的门闩结构(Zig 缺失时 `Assumptions.abort`,Godot 缺失由 `runner.run(true)` 内部处理),不得合成"无 Zig 则只断言 codegen"的新模式。 +- `methodReferenceOnCustomInstanceDoesNotRetainReceiver` 重命名并改写注释:从"gap 待修"改为"Godot 一致性验收";断言(同作用域通过、跨作用域 `result=-1`)保持不变。 +- `frontend_signal_support.md` 在不保活条款处补一句实证说明(复现测试名 + 结论),不改动合同本身。 + +验收:`script/run-gradle-targeted-tests.sh --tests 'CustomReceiverCallableGapTest'` 全绿。 + +### 步骤 5:受影响面回归 + +- 先以文本搜索定位可能受精确 LIR 序列断言影响的测试(搜索 `ConstructBuiltinInsn`、`construct_builtin`、call 实参槽同一性断言、`materializeCallArguments` 相关断言),逐一运行核对;不要默认某个测试类会变红。 +- 若 characterization 测试因调用参数新增 upcast temp 而失败:逐一核对失败点是否确为预期形态变化,禁止为通过测试而回退实现;确属预期的按新形态更新测试并在提交说明中列出清单。 +- 最后 `./gradlew clean build --no-daemon --info --console=plain` 全量验证。 + +验收:上述命令全部通过;失败项均有明确归因(预期形态变化 or 真实回归),真实回归必须修复。 + +--- + +## 6. 边界情况清单(实现与评审时逐条核对) + +| 场景 | 预期行为 | +| --- | --- | +| source 与 target 同名(如 `Object -> Object`) | 复用源槽,无 upcast temp | +| target 为 `Variant`(vararg 或 fixed Variant 参数) | 走既有 pack 路径,不触发 upcast 分支 | +| source 为 `Variant`、target 为对象类型 | 走既有 unpack 路径 | +| null 字面量 -> 对象参数 | 走既有 `ALLOW_WITH_LITERAL_NULL` 路径 | +| `String -> StringName` 等其他 decision | 走既有路径,零变化 | +| `DYNAMIC` 调用 | 按合同绕过签名边界,实参槽原样透传 | +| engine/builtin 方法调用(如 `add_child(sprite)`) | 同样物化 upcast temp(范围内统一形态);后端原有 `checkAssignable` + `valueOfCastedVar` 退化为同型直传,功能等价,多一条 `assign` + own 对 | +| lambda capture / 参数 / merge 值作为实参 | 同一 helper 处理,ownership 由标准槽写入规则承接 | +| 目标为 GDCC 自定义祖先类 | 后端 upcast helper 经 `_super` 链转换 ptr 表示,`instance_id` 保留 | +| 单 `Variant` 实参构造 / `String <-> StringName` 边界构造 | 不经过本 helper,保持现状(已知非目标,§2.2) | + +--- + +## 7. 风险与回滚 + +- R1(中):characterization 测试因调用参数新增 temp 出现精确序列失败 → 预期内,按步骤 5 的搜索驱动流程核对更新。 +- R2(低):upcast temp ownership 不平衡 → 由 §4.5 论证 + 步骤 3 三态锚点 + 步骤 4 运行时测试三重兜底;`AssignInsn` 复用现有槽写入语义,无新机制。 +- R3(低):helper 误伤其他 decision 路径 → 步骤 2 的回归抽样锚定。 +- R4(低):`boundaryUse` 命名冲突 → temp 名含 `boundaryUse` 与自增计数,与现有 pack/unpack temp 同机制;命名合同在步骤 0 补齐。 +- 回滚:代码改动集中于单个 helper 与一处调用点,直接 revert 对应提交即可;文档改动随提交一并 revert。 + +--- + +## 8. 总体验收清单(DoD) + +- [ ] 三处文档(矩阵、`(un)pack`、`frontend_rules.md` 命名条款)已先于代码更新 +- [ ] `Callable(token, &"bump")`、`Signal(token, &"s")` 等子类实参 builtin 构造调用全链路通过 +- [ ] 后端 `CBuiltinBuilder`、LIR 指令集、`Decision` 枚举零改动 +- [ ] 同类型 / 非对象 / `Variant` 目标的既有路径零行为变化(有测试锚点) +- [ ] ownership 三态(UNKNOWN/NO/YES)均有 codegen 锚点 +- [ ] sugar 用例按 B1 重新定性,`frontend_signal_support.md` 补充实证说明 +- [ ] 步骤 0-5 的验收命令全部通过 diff --git a/src/test/java/gd/script/gdcc/backend/c/build/CustomReceiverCallableGapTest.java b/src/test/java/gd/script/gdcc/backend/c/build/CustomReceiverCallableGapTest.java new file mode 100644 index 00000000..86b7ecdd --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/build/CustomReceiverCallableGapTest.java @@ -0,0 +1,253 @@ +package gd.script.gdcc.backend.c.build; + +import gd.script.gdcc.backend.CodegenContext; +import gd.script.gdcc.backend.c.gen.CCodegen; +import gd.script.gdcc.enums.GodotVersion; +import gd.script.gdcc.frontend.diagnostic.DiagnosticManager; +import gd.script.gdcc.frontend.lowering.FrontendLoweringPassManager; +import gd.script.gdcc.frontend.parse.FrontendModule; +import gd.script.gdcc.frontend.parse.GdScriptParserService; +import gd.script.gdcc.scope.ClassRegistry; +import gd.script.gdcc.gdextension.ExtensionApiLoader; +import gd.script.gdcc.lir.LirClassDef; +import gd.script.gdcc.lir.LirModule; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CustomReceiverCallableGapTest { + /// Minimal shape: an inner custom class with a method, converted to a Callable. + /// `probe_immediate` calls it while the receiver local is alive; `arm`/`fire` split the + /// creation and the call across scopes so only the Callable could retain the receiver. + private static @NotNull String gapSource(@NotNull String callableExpression) { + return """ + class_name CallableGapProbe + extends Node + + var _pending: Callable + + class Token extends RefCounted: + var count: int = 0 + + func bump() -> int: + count += 1 + return count + + func probe_immediate() -> int: + var token: Token = Token.new() + var cb: Callable = %s + var result: Variant = cb.call() + if result is int: + return int(result) + return -1 + + func arm() -> void: + var token: Token = Token.new() + _pending = %s + # `token` has no strong owner after this point; only the Callable could + # retain the receiver. + + func fire() -> int: + var result: Variant = _pending.call() + if result is int: + return int(result) + return -1 + """.formatted(callableExpression, callableExpression); + } + + @Test + void methodReferenceOnCustomInstanceDoesNotRetainReceiver() throws Exception { + if (ZigUtil.findZig() == null) { + Assumptions.abort("Zig not found; skipping custom-receiver Callable runtime probe"); + return; + } + + var tempDir = Path.of("tmp/test/custom_receiver_callable_sugar_gap"); + Files.createDirectories(tempDir); + + // The frontend accepts the sugar with zero diagnostics — the gap is purely backend. + var lowered = lowerModule( + "custom_receiver_callable_sugar_gap", + tempDir.resolve("callable_gap_probe.gd"), + gapSource("token.bump"), + Map.of("CallableGapProbe", "RuntimeCallableGapProbe") + ); + + var projectDir = tempDir.resolve("project"); + Files.createDirectories(tempDir.resolve("project")); + var projectInfo = new CProjectInfo( + "custom_receiver_callable_sugar_gap", + GodotVersion.V451, + projectDir, + COptimizationLevel.DEBUG, + TargetPlatform.getNativePlatform() + ); + var codegen = new CCodegen(); + codegen.prepare(new CodegenContext(projectInfo, lowered.classRegistry()), lowered.module()); + var buildResult = new CProjectBuilder().buildProject(projectInfo, codegen); + assertTrue(buildResult.success(), () -> "Native build should succeed. Build log:\n" + buildResult.buildLog()); + + var runner = new GodotGdextensionTestRunner(Path.of("test_project")); + runner.prepareProject(new GodotGdextensionTestRunner.ProjectSetup( + buildResult.artifacts(), + List.of(new GodotGdextensionTestRunner.SceneNodeSpec( + "CallableGapNode", + "RuntimeCallableGapProbe", + ".", + Map.of() + )), + new GodotGdextensionTestRunner.TestScriptSpec(""" + extends Node + + func _ready() -> void: + var target = get_parent().get_node_or_null("CallableGapNode") + if target == null: + push_error("Target node missing.") + return + var immediate := int(target.call("probe_immediate")) + if immediate == 1: + print("callable sugar same-scope check passed.") + else: + print("callable sugar same-scope UNEXPECTEDLY broken: result=%d" % immediate) + target.call("arm") + var deferred := int(target.call("fire")) + if deferred == 1: + print("callable sugar cross-scope check passed.") + else: + print("callable sugar cross-scope gap reproduced: result=%d" % deferred) + """) + )); + + var runResult = runner.run(true); + var combinedOutput = runResult.combinedOutput(); + assertTrue( + runResult.stopSignalSeen(), + () -> "Godot run should emit the stop signal.\nOutput:\n" + combinedOutput + ); + // Control: the same-scope call must work — the Callable is created correctly. + assertTrue( + combinedOutput.contains("callable sugar same-scope check passed."), + () -> "The same-scope control should pass; something else broke.\nOutput:\n" + combinedOutput + ); + // Characterization of the gap: once the creating scope returned, the receiver is + // gone and the deferred call fails (probe returns -1). A backend fix must flip this + // to "cross-scope check passed" and update this test. + assertTrue( + combinedOutput.contains("callable sugar cross-scope gap reproduced: result=-1"), + () -> "Expected the receiver-retention gap to reproduce (fire result -1).\nOutput:\n" + combinedOutput + ); + assertFalse( + combinedOutput.contains("callable sugar cross-scope check passed."), + () -> "The retention gap unexpectedly disappeared; update this characterization test.\nOutput:\n" + combinedOutput + ); + } + + @Test + void explicitCallableConstructionFromCustomInstanceFailsCodegen() throws Exception { + var tempDir = Path.of("tmp/test/custom_receiver_callable_explicit_gap"); + Files.createDirectories(tempDir); + + // The frontend accepts the explicit construction (clean lowering) — the rejection is + // purely a C-backend boundary. + var lowered = lowerModule( + "custom_receiver_callable_explicit_gap", + tempDir.resolve("callable_gap_probe.gd"), + gapSource("Callable(token, &\"bump\")"), + Map.of("CallableGapProbe", "RuntimeCallableGapProbe") + ); + + var projectDir = tempDir.resolve("project"); + Files.createDirectories(projectDir); + var projectInfo = new CProjectInfo( + "custom_receiver_callable_explicit_gap", + GodotVersion.V451, + projectDir, + COptimizationLevel.DEBUG, + TargetPlatform.getNativePlatform() + ); + var codegen = new CCodegen(); + codegen.prepare(new CodegenContext(projectInfo, lowered.classRegistry()), lowered.module()); + // Generation happens while the builder writes sources; a fake compiler keeps the + // failure on the codegen side (no Zig needed). The builder wraps the codegen + // InvalidInsnException, so the ExtensionBuiltinClass message lives in the cause chain. + var thrown = assertThrows( + RuntimeException.class, + () -> new CProjectBuilder(fakeCompiler()).buildProject(projectInfo, codegen), + "Expected codegen to reject Callable(customInstance, &\"bump\")" + ); + var chain = new StringBuilder(); + for (Throwable cursor = thrown; cursor != null; cursor = cursor.getCause()) { + chain.append(cursor.getMessage()).append('\n'); + } + assertTrue( + chain.toString().contains("'Callable' with args [RuntimeCallableGapProbe__sub__Token, StringName]" + + " is not defined in ExtensionBuiltinClass"), + () -> "Unexpected failure chain:\n" + chain + ); + } + + private static @NotNull CCompiler fakeCompiler() { + return new CCompiler() { + @Override + public @NotNull CCompileResult compile( + @NotNull Path projectDir, + @NotNull List includeDirs, + @NotNull List cFiles, + @NotNull String outputBaseName, + @NotNull COptimizationLevel optimizationLevel, + @NotNull TargetPlatform targetPlatform + ) throws IOException { + var out = projectDir.resolve(outputBaseName + ".dll"); + Files.createDirectories(projectDir); + Files.writeString(out, "dummy"); + return new CCompileResult(true, "ok", List.of(out)); + } + }; + } + + private static @NotNull LoweredFixture lowerModule( + @NotNull String moduleName, + @NotNull Path sourcePath, + @NotNull String source, + @NotNull Map topLevelCanonicalNameMap + ) throws IOException { + var parser = new GdScriptParserService(); + var parseDiagnostics = new DiagnosticManager(); + var unit = parser.parseUnit(sourcePath, source, parseDiagnostics); + assertTrue(parseDiagnostics.isEmpty(), () -> "Unexpected parse diagnostics: " + parseDiagnostics.snapshot()); + + var diagnostics = new DiagnosticManager(); + var classRegistry = new ClassRegistry(ExtensionApiLoader.loadVersion(GodotVersion.V451)); + var module = new FrontendModule(moduleName, List.of(unit), topLevelCanonicalNameMap); + var lowered = new FrontendLoweringPassManager().lower(module, classRegistry, diagnostics); + + assertNotNull(lowered, () -> "Lowering returned null with diagnostics: " + diagnostics.snapshot()); + assertFalse(diagnostics.hasErrors(), () -> "Unexpected frontend diagnostics: " + diagnostics.snapshot()); + + // The inner class lowers to its own class def, so locate the top-level class by name. + var topLevel = lowered.getClassDefs().stream() + .filter(def -> def.getName().equals("RuntimeCallableGapProbe")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing top-level class def in " + lowered.getClassDefs())); + + return new LoweredFixture(lowered, classRegistry, topLevel); + } + + private record LoweredFixture( + @NotNull LirModule module, + @NotNull ClassRegistry classRegistry, + @NotNull LirClassDef lirClass + ) { + } +} From 58a218723202822eccae155f9ea5532d37023fe8 Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Sun, 27 Sep 2026 02:01:17 +0800 Subject: [PATCH 2/4] docs(frontend): define fixed-call object upcast materialization - Clarify the subclass-to-ancestor upcast exception for fixed call arguments - Document its boundary-temp naming and limit it to exact fixed-parameter routes - Mark the documentation-first step complete in the implementation plan --- .../frontend/frontend_call_argument_object_upcast_plan.md | 6 +++--- .../frontend/frontend_implicit_conversion_matrix.md | 6 ++++-- .../frontend/frontend_lowering_(un)pack_implementation.md | 5 +++-- doc/module_impl/frontend/frontend_rules.md | 1 + 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md b/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md index d9bd6064..8d51b8fb 100644 --- a/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md +++ b/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md @@ -4,7 +4,7 @@ ## 文档状态 -- 状态:计划待实施(成因链路已闭环,最小复现测试已存在;方案与范围已拍板,评审修订第 1 轮完成,尚未改代码) +- 状态:实施中(步骤 0 文档先行已完成——矩阵、`(un)pack`、`frontend_rules.md` 命名条款三处已同改;步骤 1 前端 helper 待实施) - 更新时间:2026-09-27 - 适用范围: - `doc/module_impl/frontend/**` @@ -180,7 +180,7 @@ try_release_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_up 每一步都保持可编译、可回归、可单独提交。测试命令统一使用 `script/run-gradle-targeted-tests.sh`。 -### 步骤 0:文档先行(单独提交,三处同改) +### 步骤 0:文档先行(单独提交,三处同改)——已完成(2026-09-27) 1. `frontend_implicit_conversion_matrix.md` - 全局规则"任意 object subclass -> object superclass"行备注:兼容性维持 Y,物化形态加引用说明"fixed call argument 边界的物化形态例外见 `frontend_lowering_(un)pack_implementation.md` §4.2/§4.3"。 @@ -278,7 +278,7 @@ try_release_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_up ## 8. 总体验收清单(DoD) -- [ ] 三处文档(矩阵、`(un)pack`、`frontend_rules.md` 命名条款)已先于代码更新 +- [x] 三处文档(矩阵、`(un)pack`、`frontend_rules.md` 命名条款)已先于代码更新 - [ ] `Callable(token, &"bump")`、`Signal(token, &"s")` 等子类实参 builtin 构造调用全链路通过 - [ ] 后端 `CBuiltinBuilder`、LIR 指令集、`Decision` 枚举零改动 - [ ] 同类型 / 非对象 / `Variant` 目标的既有路径零行为变化(有测试锚点) diff --git a/doc/module_impl/frontend/frontend_implicit_conversion_matrix.md b/doc/module_impl/frontend/frontend_implicit_conversion_matrix.md index 319c6935..7093f0e5 100644 --- a/doc/module_impl/frontend/frontend_implicit_conversion_matrix.md +++ b/doc/module_impl/frontend/frontend_implicit_conversion_matrix.md @@ -5,7 +5,7 @@ ## 文档状态 - 状态:事实源维护中(Godot 规则已梳理,`String <-> StringName` feature gate 已完成实现闭合;ordinary typed boundary 的 semantic / lowering / backend constructor 与 GDExtension `call_func` inbound wrapper 合同已同步) -- 更新时间:2026-08-19 +- 更新时间:2026-09-27 - 适用范围: - `doc/module_impl/frontend/**` - `src/main/java/gd/script/gdcc/frontend/**` @@ -19,6 +19,7 @@ - `frontend_type_check_analyzer_implementation.md` - `frontend_unary_binary_expr_semantic_implementation.md` - `frontend_lowering_cfg_pass_implementation.md` + - `frontend_call_argument_object_upcast_plan.md` - `doc/gdcc_type_system.md` - 主要事实来源: - Godot `GDScriptAnalyzer::check_type_compatibility(...)` @@ -169,7 +170,7 @@ GDExtension `call_func` inbound wrapper 两条路径的完成度对齐;它不 | same type -> same type | Y | Y | 最基础兼容 | | 任意 stable type -> `Variant` | Y | Y | GDCC 通过 `pack_variant` materialize | | stable `Variant` -> concrete target | Y | Y | GDCC 当前已接通 ordinary `Variant` boundary,并通过 `unpack_variant` materialize | -| 任意 object subclass -> object superclass | Y | Y | 例如 `Sprite2D -> Node -> Object` | +| 任意 object subclass -> object superclass | Y | Y | 例如 `Sprite2D -> Node -> Object`;fixed call argument 边界的物化形态例外见 `frontend_lowering_(un)pack_implementation.md` §4.2/§4.3 | | `null` / `Nil` -> object target | Y | Y | Godot 接受;GDCC frontend 通过 boundary helper 显式物化 object-typed `LiteralNullInsn` | | `enum` value -> `int` | Y | N | GDCC 没有 enum 一等类型模型;脚本枚举不是一等 `GdType`,声明类型经 declared-type `instanceType` 直接擦除为 `int`(见 `frontend_enum_implementation.md`),本行 `N` 仅指一等 enum 转换模型 | | `int` -> enum target | Y | N | Godot 允许但通常伴随 warning/显式语义讨论;GDCC 未建模一等 enum target,脚本枚举标注即 `int`,无需转换 | @@ -341,6 +342,7 @@ Godot strict implicit conversion 表里没有 `Dictionary` 到其他 builtin con - `FrontendBodyLoweringSession.materializeFrontendBoundaryValue(...)` - ordinary `(un)pack` consumer/materialization 的长期合同以 `frontend_lowering_(un)pack_implementation.md` 为准 - `String <-> StringName` 这类 constructor materialization 仍属于同一个 ordinary boundary materialization 入口;不得在 consumer 内维护局部分支 + - fixed call argument 边界上严格 object 子类 -> 祖先的 upcast 物化(target-typed temp + `AssignInsn`)同样与该单一入口同层登记,不视为 consumer 私设局部分支;合同见 `frontend_lowering_(un)pack_implementation.md` §4.2/§4.3 ### 9.2 当前明确拒绝 widened conversion 的文档锚点 diff --git a/doc/module_impl/frontend/frontend_lowering_(un)pack_implementation.md b/doc/module_impl/frontend/frontend_lowering_(un)pack_implementation.md index 91108994..38ca183e 100644 --- a/doc/module_impl/frontend/frontend_lowering_(un)pack_implementation.md +++ b/doc/module_impl/frontend/frontend_lowering_(un)pack_implementation.md @@ -1,6 +1,6 @@ # Frontend Lowering `(un)pack` Implementation -> Updated: 2026-05-29 +> Updated: 2026-09-27 > > 本文档是 frontend ordinary typed-boundary materialization 的事实源。 > `String <-> StringName` 条目已完成实现闭合;ordinary boundary、literal 路线与 backend constructor 消费均以当前合同为准。 @@ -182,7 +182,7 @@ local / assignment / call / return / subscript key/index consumer 都必须走 该 helper 当前只做六类结果: -- direct:直接返回原 slot id +- direct:直接返回原 slot id;唯一例外是 fixed call argument 边界上的严格 object 子类 -> 祖先——此时分配 target-typed 新 temp 并追加 `AssignInsn`,对构造器与方法调用的 fixed 参数统一生效(builtin 构造器后端按 metadata 精确匹配实参类型名,必须消费精确类型实参;方法调用后端虽已能自行 upcast,仍共用同一实参类型不变量),适用条件见 §4.3 - pack:分配新 temp,并追加 `PackVariantInsn` - unpack:分配新 temp,并追加 `UnpackVariantInsn` - null-object:分配新 temp,并追加 object-typed `LiteralNullInsn` @@ -202,6 +202,7 @@ ordinary call boundary 当前固定为: - exact `RESOLVED` route - fixed parameters 按 selected callable signature 做 ordinary boundary materialization + - §4.2 direct 的唯一例外只发生在 `materializeCallArguments` 的 fixed-parameter 循环内:实参为严格 object 子类、fixed 参数目标为其祖先类型时,物化为 target-typed temp + `AssignInsn`;vararg tail 与 `DYNAMIC_FALLBACK` route 均不触发该例外 - vararg tail 统一按 `Variant` tail 处理 - `DYNAMIC_FALLBACK` route - body lowering 不读取 exact callable signature diff --git a/doc/module_impl/frontend/frontend_rules.md b/doc/module_impl/frontend/frontend_rules.md index 64463f27..3b9746ac 100644 --- a/doc/module_impl/frontend/frontend_rules.md +++ b/doc/module_impl/frontend/frontend_rules.md @@ -96,6 +96,7 @@ - backend/LIR 的 control-flow 仍保持 bool-only 边界;truthiness / condition normalization 由 lowering 侧显式完成,不得反向把 frontend 收紧成 undocumented strict-bool dialect。 - lowering 侧的 condition normalization 合同已经冻结:`bool` 直接消费,`Variant` 只做 `unpack_variant -> bool temp`,其余 stable type 必须先 `pack_variant` 再 `unpack_variant`,不得绕过这条路径。唯一实现点是共享 helper `FrontendBodyLoweringSupport.materializeTruthinessToBool`(branch processor 消费后自行 `GoIfInsn`,assert processor 消费后发射 `AssertInsn`;helper 自身不设置 terminator)。 - body lowering 的 slot/materialization 命名必须固定:temp-backed CFG value 继续用 `cfg_tmp_`,merge-backed value 继续用 `cfg_merge_`,source-level local 直接沿用源码名;direct-slot alias value 与 statement-position resolved-void `CallItem` 则故意不声明独立 `cfg_tmp_*` 变量。 +- boundary materialization temp(pack / unpack / null-object / intrinsic cast / builtin constructor / fixed call argument 的 object upcast)统一命名为 `cfg_boundary___`(`` 为 session 级单调递增序号);它们是 lowering 物化产物,不是 CFG value id,不适用 `cfg_tmp_` 规则。 - merge 写入(`merge_write`)继续复用 `materializeFrontendBoundaryValue(...)` 唯一入口:`FrontendMergeValueInsnLoweringProcessor` 将每臂 `sourceValueId` 的类型物化到 `mergeAnchor` 的 published 合并类型后 `AssignInsn(cfg_merge_, materialized)`;`bool->bool` 为 `ALLOW_DIRECT`,故 value 语境 `and/or` 的 LIR 仍仅 `LiteralBoolInsn` + `AssignInsn(cfg_merge_*, cfg_tmp_*)`。 - `OpaqueExprValueItem` 当前只允许承载 ordinary leaf / eager unary / 非短路 eager binary / `PreloadExpression`(后者由专用 opaque processor 改写为 ResourceLoader singleton 调用对);`and` / `or`、assignment-as-opaque、以及绕过 dedicated item 的 attribute / call / subscript 必须视为协议违例。direct-slot mutating receiver 的 alias publication 现已通过独立 `DirectSlotAliasValueItem`。 - direct-slot receiver alias 的安全性必须写成显式语义合同,而不是“扫描参数 AST 里有没有某个节点名”: From 5a2c2cb4499229651a5ceade3a6ec0aacfafd6f5 Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Sun, 27 Sep 2026 10:02:28 +0800 Subject: [PATCH 3/4] feat(frontend): materialize object upcasts for fixed call arguments - Lower subclass arguments into parameter-typed temporaries for builtin, engine, and GDCC calls - Preserve exact-match backend constructor handling and verify upcast temporary ownership - Add lowering and codegen coverage for Callable, Signal, and regression boundaries - Complete implementation documentation and acceptance checklist --- doc/gdcc_ownership_lifecycle_spec.md | 14 + ...ontend_call_argument_object_upcast_plan.md | 27 +- ...ontend_lowering_(un)pack_implementation.md | 2 +- .../frontend/frontend_signal_support.md | 1 + .../body/FrontendBodyLoweringSession.java | 38 ++- .../CallArgumentObjectUpcastCodegenTest.java | 262 ++++++++++++++++++ .../build/CustomReceiverCallableGapTest.java | 253 ----------------- .../backend/c/gen/CConstructInsnGenTest.java | 86 ++++++ .../FrontendLoweringBodyInsnPassTest.java | 241 ++++++++++++++++ 9 files changed, 656 insertions(+), 268 deletions(-) create mode 100644 src/test/java/gd/script/gdcc/backend/c/build/CallArgumentObjectUpcastCodegenTest.java delete mode 100644 src/test/java/gd/script/gdcc/backend/c/build/CustomReceiverCallableGapTest.java diff --git a/doc/gdcc_ownership_lifecycle_spec.md b/doc/gdcc_ownership_lifecycle_spec.md index 2b6a3e2b..63a1fd76 100644 --- a/doc/gdcc_ownership_lifecycle_spec.md +++ b/doc/gdcc_ownership_lifecycle_spec.md @@ -167,6 +167,20 @@ Automatic local cleanup rule: the auto-cleanup set by contract. - This matches Godot's contract where non-`RefCounted` objects stay under explicit user-managed lifetime (`free`, `queue_free`, etc.) even when stored in local variables. +Boundary materialization temps (`cfg_boundary_*`: pack / unpack / null-object / intrinsic cast / +builtin constructor / fixed-call-argument object upcast) are ordinary managed locals: + +- They are owned per the standard slot-write rules (§3.2) at their single materialization write and + released by the `__finally__` auto cleanup above, so their lifetime spans the enclosing function, + not the consuming call. A reference-managed argument may consequently stay alive past the call that + consumes the temp until function exit. +- This is by design and not new in kind: argument-evaluation temps (`cfg_tmp_*`) already hold + reference-managed arguments until function exit on every call route, so adding one more such owning + slot demonstrates no Godot-observable divergence (any same-function early-release scenario is + already masked by the evaluation temps). +- Narrowing boundary temps to call-scoped lifetimes would require an explicit post-call release + mechanism and is intentionally not part of this contract. + ### 3.7 Constraints - Do not infer ownership from function name prefixes (e.g. `godot_`). diff --git a/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md b/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md index 8d51b8fb..bce32829 100644 --- a/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md +++ b/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md @@ -4,7 +4,8 @@ ## 文档状态 -- 状态:实施中(步骤 0 文档先行已完成——矩阵、`(un)pack`、`frontend_rules.md` 命名条款三处已同改;步骤 1 前端 helper 待实施) +- 状态:已完成(2026-09-27,步骤 0-5 全部实施并通过验收;全量 clean build 绿) +- 评审后续处置(2026-09-27):评审提出的"upcast temp 对 RefCounted 实参持有至函数 `__finally__`、超过单次调用"现象经核实为本计划 §4.5 的既定设计(且实参求值 temp 在所有调用路径上本就同粒度持有,未证实可观察的 Godot 分歧),决定**保持现状**并记录于 `doc/gdcc_ownership_lifecycle_spec.md` §3.6 的 boundary temp 条款与 `(un)pack` §4.2。临时复现测试 `CustomReceiverCallableGapTest` 在覆盖闭合后删除:显式构造通路由 `CallArgumentObjectUpcastCodegenTest` 承接,sugar 非保活锚点由 `CConstructInsnGenTest.constructCallableShouldEmitCallableFromReceiverAndDestroyResult`(构造体无 retain)与 `FrontendLoweringBodyInsnPassTest.runLowersBareAndReceiverMethodReferencesIntoConstructCallableInsn`(`construct_callable` 形态)承接。 - 更新时间:2026-09-27 - 适用范围: - `doc/module_impl/frontend/**` @@ -25,7 +26,7 @@ ## 1. 背景与成因链路 -最小复现为 `src/test/java/gd/script/gdcc/backend/c/build/CustomReceiverCallableGapTest.java`,覆盖两种写法。 +最小复现为 `src/test/java/gd/script/gdcc/backend/c/build/CustomReceiverCallableGapTest.java`,覆盖两种写法。(该临时复现测试已在覆盖闭合后删除,承接锚点见文档状态注记。) ### 1.1 显式构造 `Callable(token, &"bump")` —— 本计划要修的缺陷 @@ -194,7 +195,7 @@ try_release_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_up 验收:文档 diff 只涉及上述说明;无代码改动。 -### 步骤 1:前端 helper 实现 +### 步骤 1:前端 helper 实现——已完成(2026-09-27) 改动:`FrontendBodyLoweringSession.java` @@ -203,7 +204,7 @@ try_release_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_up 验收:`./gradlew classes --no-daemon --info --console=plain` 编译通过。 -### 步骤 2:前端 lowering 单测 +### 步骤 2:前端 lowering 单测——已完成(2026-09-27) 改动:`FrontendLoweringBodyInsnPassTest`(或同包合适的测试类,实现时确认)。用例必须构造真实的自定义类 hierarchy(如 `Token extends RefCounted`),不能只喂两个孤立 `GdType`。 @@ -215,7 +216,7 @@ try_release_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_up 验收:`script/run-gradle-targeted-tests.sh --tests 'FrontendLoweringBodyInsnPassTest'` 全绿。 -### 步骤 3:后端 codegen 单测(只加测试,不改后端代码) +### 步骤 3:后端 codegen 单测(只加测试,不改后端代码)——已完成(2026-09-27) 改动:`CConstructInsnGenTest`,以及一个"前端实际 lowering 产物 -> codegen"的链路测试(可挂在现有 lowering-codegen 混合测试类上,实现时确认位置)。 @@ -228,7 +229,7 @@ try_release_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_up 验收:`script/run-gradle-targeted-tests.sh --tests 'CConstructInsnGenTest'` 及所加链路测试类全绿。 -### 步骤 4:翻转 `CustomReceiverCallableGapTest` 并按 B1 重定性 sugar 用例 +### 步骤 4:翻转 `CustomReceiverCallableGapTest` 并按 B1 重定性 sugar 用例——已完成(2026-09-27) 改动:`CustomReceiverCallableGapTest.java`、`frontend_signal_support.md` @@ -239,7 +240,7 @@ try_release_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_up 验收:`script/run-gradle-targeted-tests.sh --tests 'CustomReceiverCallableGapTest'` 全绿。 -### 步骤 5:受影响面回归 +### 步骤 5:受影响面回归——已完成(2026-09-27) - 先以文本搜索定位可能受精确 LIR 序列断言影响的测试(搜索 `ConstructBuiltinInsn`、`construct_builtin`、call 实参槽同一性断言、`materializeCallArguments` 相关断言),逐一运行核对;不要默认某个测试类会变红。 - 若 characterization 测试因调用参数新增 upcast temp 而失败:逐一核对失败点是否确为预期形态变化,禁止为通过测试而回退实现;确属预期的按新形态更新测试并在提交说明中列出清单。 @@ -279,9 +280,9 @@ try_release_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_up ## 8. 总体验收清单(DoD) - [x] 三处文档(矩阵、`(un)pack`、`frontend_rules.md` 命名条款)已先于代码更新 -- [ ] `Callable(token, &"bump")`、`Signal(token, &"s")` 等子类实参 builtin 构造调用全链路通过 -- [ ] 后端 `CBuiltinBuilder`、LIR 指令集、`Decision` 枚举零改动 -- [ ] 同类型 / 非对象 / `Variant` 目标的既有路径零行为变化(有测试锚点) -- [ ] ownership 三态(UNKNOWN/NO/YES)均有 codegen 锚点 -- [ ] sugar 用例按 B1 重新定性,`frontend_signal_support.md` 补充实证说明 -- [ ] 步骤 0-5 的验收命令全部通过 +- [x] `Callable(token, &"bump")`、`Signal(token, &"s")` 等子类实参 builtin 构造调用全链路通过 +- [x] 后端 `CBuiltinBuilder`、LIR 指令集、`Decision` 枚举零改动 +- [x] 同类型 / 非对象 / `Variant` 目标的既有路径零行为变化(有测试锚点) +- [x] ownership 三态(UNKNOWN/NO/YES)均有 codegen 锚点 +- [x] sugar 用例按 B1 重新定性,`frontend_signal_support.md` 补充实证说明 +- [x] 步骤 0-5 的验收命令全部通过 diff --git a/doc/module_impl/frontend/frontend_lowering_(un)pack_implementation.md b/doc/module_impl/frontend/frontend_lowering_(un)pack_implementation.md index 38ca183e..f63397f1 100644 --- a/doc/module_impl/frontend/frontend_lowering_(un)pack_implementation.md +++ b/doc/module_impl/frontend/frontend_lowering_(un)pack_implementation.md @@ -182,7 +182,7 @@ local / assignment / call / return / subscript key/index consumer 都必须走 该 helper 当前只做六类结果: -- direct:直接返回原 slot id;唯一例外是 fixed call argument 边界上的严格 object 子类 -> 祖先——此时分配 target-typed 新 temp 并追加 `AssignInsn`,对构造器与方法调用的 fixed 参数统一生效(builtin 构造器后端按 metadata 精确匹配实参类型名,必须消费精确类型实参;方法调用后端虽已能自行 upcast,仍共用同一实参类型不变量),适用条件见 §4.3 +- direct:直接返回原 slot id;唯一例外是 fixed call argument 边界上的严格 object 子类 -> 祖先——此时分配 target-typed 新 temp 并追加 `AssignInsn`,对构造器与方法调用的 fixed 参数统一生效(builtin 构造器后端按 metadata 精确匹配实参类型名,必须消费精确类型实参;方法调用后端虽已能自行 upcast,仍共用同一实参类型不变量),适用条件见 §4.3;该 temp 按标准槽写入规则在物化写入处 own、由函数 `__finally__` 自动清理释放,持有期覆盖整个函数而非单次调用(见 `doc/gdcc_ownership_lifecycle_spec.md` §3.6 的 boundary temp 条款) - pack:分配新 temp,并追加 `PackVariantInsn` - unpack:分配新 temp,并追加 `UnpackVariantInsn` - null-object:分配新 temp,并追加 object-typed `LiteralNullInsn` diff --git a/doc/module_impl/frontend/frontend_signal_support.md b/doc/module_impl/frontend/frontend_signal_support.md index 08cf1d08..201f405b 100644 --- a/doc/module_impl/frontend/frontend_signal_support.md +++ b/doc/module_impl/frontend/frontend_signal_support.md @@ -73,6 +73,7 @@ inherited GDCC 同名 signal 允许 nearest-child shadow;GDCC 不得覆盖 inh - `Signal` 是 builtin variant,承载 `(ObjectID, StringName)`。读取 `obj.foo` 产生**新的** Signal 值,不是已存储字段。 - `Signal` / `Callable` 只保存 **ObjectID(非 owning)**,不保活 receiver。`godot_Signal_destroy` / `godot_Callable_destroy` 只销毁 value storage。构造后 receiver 被释放,value 仍在但失效;`.emit` / `.connect` 的失效行为由 Godot ObjectDB 决定。 + - 实证与锚点:显式 `Callable(token, &"bump")` 子类实参构造的 codegen 通路由 `CallArgumentObjectUpcastCodegenTest` 锚定;构造本身不 retain receiver(`CConstructInsnGenTest.constructCallableShouldEmitCallableFromReceiverAndDestroyResult` 断言构造体无 `own_object` / `try_own_object`);`token.bump` sugar 的 `construct_callable` lowering 形态由 `FrontendLoweringBodyInsnPassTest.runLowersBareAndReceiverMethodReferencesIntoConstructCallableInsn` 锚定。 - `Signal.emit` 是真正 vararg(`extension_api_451.json` 中 `is_vararg=true` 且无固定参数)。声明参数只作 ClassDB / 编辑器元数据。frontend 不得在调用点按声明签名拒绝多余或异型实参。 - `connect` 返回 `int`(错误码),`disconnect` 返回 void。`flags` 支持 `Object.CONNECT_DEFERRED` / `Object.CONNECT_ONE_SHOT`,可省略(默认 0)。 - 只注册当前类**新声明** signal;继承 signal 不重复注册。engine / native signal 只读、不由 GDCC 注册。 diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendBodyLoweringSession.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendBodyLoweringSession.java index 85152ebd..bf30cdda 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendBodyLoweringSession.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendBodyLoweringSession.java @@ -1433,7 +1433,7 @@ boolean isTargetFunctionCoroutine() { var fixedPrefixCount = Math.min(argumentValueIds.size(), boundaryPlan.fixedParameterTypes().size()); for (var index = 0; index < fixedPrefixCount; index++) { var argumentValueId = argumentValueIds.get(index); - var materializedSlotId = materializeFrontendBoundaryValue( + var materializedSlotId = materializeCallArgumentBoundaryValue( block, slotIdForValue(argumentValueId), requireValueType(argumentValueId), @@ -1456,6 +1456,42 @@ boolean isTargetFunctionCoroutine() { return List.copyOf(operands); } + /// Fixed call arguments carry one extra materialization shape on top of the shared + /// `(un)pack` entry: a strict object subclass passed to an ancestor-typed parameter is + /// copied into a target-typed temp via `AssignInsn`. The backend builtin constructor path + /// matches argument type names exactly (a subclass slot is rejected), and engine/builtin + /// method routes share the same argument-type invariant even though their backend could + /// upcast on its own. Only the fixed loop above routes here: vararg tails always target + /// `Variant` and `DYNAMIC` calls forward slots unchanged, so neither can trigger this shape. + /// The shape is re-derived at lowering time today; once call-argument plans publish frozen + /// boundary decisions, this helper must consume them instead of re-querying the registry. + private @NotNull String materializeCallArgumentBoundaryValue( + @NotNull LirBasicBlock block, + @NotNull String sourceSlotId, + @NotNull GdType sourceType, + @NotNull GdType targetType, + @NotNull String boundaryUse + ) { + if (!isStrictObjectSubclassArgument(sourceType, targetType)) { + return materializeFrontendBoundaryValue(block, sourceSlotId, sourceType, targetType, boundaryUse); + } + var upcastSlotId = nextBoundaryMaterializationSlotId(boundaryUse, "upcast"); + ensureVariable(upcastSlotId, targetType); + block.appendNonTerminatorInstruction(new AssignInsn(upcastSlotId, sourceSlotId)); + return upcastSlotId; + } + + /// Pure peek for the fixed-argument upcast shape: both sides must be engine object types + /// with different names (same-name pairs stay `ALLOW_DIRECT` on the shared path), and the + /// source must reach the target through the superclass chain. Unrelated object types return + /// false and keep the shared entry's existing rejection behavior. + private boolean isStrictObjectSubclassArgument(@NotNull GdType sourceType, @NotNull GdType targetType) { + return sourceType instanceof GdObjectType + && targetType instanceof GdObjectType + && !sourceType.getTypeName().equals(targetType.getTypeName()) + && classRegistry.checkAssignable(sourceType, targetType); + } + /// Property bindings always carry the skeleton-produced `PropertyDef` as declaration site /// (never the AST `VariableDeclaration`), so staticness must be read from the property model. boolean isStaticPropertyBinding(@NotNull FrontendBinding binding) { diff --git a/src/test/java/gd/script/gdcc/backend/c/build/CallArgumentObjectUpcastCodegenTest.java b/src/test/java/gd/script/gdcc/backend/c/build/CallArgumentObjectUpcastCodegenTest.java new file mode 100644 index 00000000..06806d7a --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/build/CallArgumentObjectUpcastCodegenTest.java @@ -0,0 +1,262 @@ +package gd.script.gdcc.backend.c.build; + +import gd.script.gdcc.backend.CodegenContext; +import gd.script.gdcc.backend.c.gen.CCodegen; +import gd.script.gdcc.enums.GodotVersion; +import gd.script.gdcc.frontend.diagnostic.DiagnosticManager; +import gd.script.gdcc.frontend.lowering.FrontendLoweringPassManager; +import gd.script.gdcc.frontend.parse.FrontendModule; +import gd.script.gdcc.frontend.parse.GdScriptParserService; +import gd.script.gdcc.gdextension.ExtensionApiLoader; +import gd.script.gdcc.lir.LirModule; +import gd.script.gdcc.scope.ClassRegistry; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Lowering -> codegen chain anchors for the fixed-call-argument object upcast shape. +/// GDScript is lowered by the real frontend and emitted as C text via `CCodegen.generate()` +/// (no Zig / Godot required). Assertions are scoped per generated C function body so the +/// same-named per-function boundary temps can never satisfy another function's anchor: +/// the upcast temp's ownership emission is pinned for all three RefCountedStatus states of +/// the parameter target type (assign-time own, paired release inside `__finally__`), and the +/// engine-method route is pinned as a same-type passthrough that consumes the temp directly. +public class CallArgumentObjectUpcastCodegenTest { + private static final Pattern OBJECT_UPCAST_TEMP = Pattern.compile( + "gdcc_Object_fat_ptr \\$(cfg_boundary_call_fixed_0_upcast_\\d+)" + ); + private static final Pattern NODE_UPCAST_TEMP = Pattern.compile( + "gdcc_Node_fat_ptr \\$(cfg_boundary_call_fixed_0_upcast_\\d+)" + ); + private static final Pattern RESOURCE_UPCAST_TEMP = Pattern.compile( + "gdcc_Resource_fat_ptr \\$(cfg_boundary_call_fixed_0_upcast_\\d+)" + ); + private static final String FINALLY_MARKER = "__finally__:"; + + @Test + void fixedCallArgumentUpcastTempOwnershipMatchesTargetRefCountedStatus() throws Exception { + var lowered = lowerModule( + "call_argument_object_upcast_chain", + Path.of("tmp/test/call_argument_object_upcast_chain/upcast_chain_probe.gd"), + """ + class_name UpcastChainProbe + extends Node + + class Token extends RefCounted: + var count: int = 0 + + func bump() -> int: + count += 1 + return count + + class CustomRes extends Resource: + var tag: int = 0 + + func take_res(res: Resource) -> void: + pass + + func probe_callable(token: Token) -> Callable: + return Callable(token, &"bump") + + func probe_signal(sprite: Sprite2D) -> Signal: + return Signal(sprite, &"renamed") + + func probe_node(sprite: Sprite2D) -> void: + add_child(sprite) + + func probe_resource(res: CustomRes) -> void: + take_res(res) + """, + Map.of("UpcastChainProbe", "RuntimeUpcastChainProbe") + ); + + var projectDir = Path.of("tmp/test/call_argument_object_upcast_chain/project"); + Files.createDirectories(projectDir); + var projectInfo = new CProjectInfo( + "call_argument_object_upcast_chain", + GodotVersion.V451, + projectDir, + COptimizationLevel.DEBUG, + TargetPlatform.getNativePlatform() + ); + var codegen = new CCodegen(); + codegen.prepare(new CodegenContext(projectInfo, lowered.classRegistry()), lowered.module()); + var entrySource = generateEntryC(codegen); + + var callableBody = requireFunctionCBody(entrySource, "RuntimeUpcastChainProbe_probe_callable"); + var signalBody = requireFunctionCBody(entrySource, "RuntimeUpcastChainProbe_probe_signal"); + var nodeBody = requireFunctionCBody(entrySource, "RuntimeUpcastChainProbe_probe_node"); + var resourceBody = requireFunctionCBody(entrySource, "RuntimeUpcastChainProbe_probe_resource"); + + var callableTemp = requireSingleMatch(OBJECT_UPCAST_TEMP, callableBody, "Object-typed upcast temp in probe_callable"); + var signalTemp = requireSingleMatch(OBJECT_UPCAST_TEMP, signalBody, "Object-typed upcast temp in probe_signal"); + var nodeTemp = requireSingleMatch(NODE_UPCAST_TEMP, nodeBody, "Node-typed upcast temp in probe_node"); + var resourceTemp = requireSingleMatch(RESOURCE_UPCAST_TEMP, resourceBody, "Resource-typed upcast temp in probe_resource"); + + assertAll( + // UNKNOWN (exact Object target): two-arg try_own at the slot write, exactly one + // paired try_release inside __finally__; the constructor consumes the temp as + // a live pointer. The explicit Signal constructor shares the same entry. + () -> assertTrue( + callableBody.contains( + "try_own_object(gdcc_Object_fat_ptr_live_object($" + callableTemp + + "), $" + callableTemp + ".instance_id);" + ), + callableBody + ), + () -> assertEquals( + 1, + countOccurrencesAfter( + callableBody, + "try_release_object(gdcc_Object_fat_ptr_live_object($" + callableTemp + + "), $" + callableTemp + ".instance_id);", + FINALLY_MARKER + ), + callableBody + ), + () -> assertTrue( + callableBody.contains( + "godot_new_Callable_with_Object_StringName(gdcc_Object_fat_ptr_live_object($" + + callableTemp + "), &$" + ), + callableBody + ), + () -> assertTrue( + signalBody.contains( + "godot_new_Signal_with_Object_StringName(gdcc_Object_fat_ptr_live_object($" + + signalTemp + "), &$" + ), + signalBody + ), + // NO (Node target): the engine method route consumes the temp in the add_child + // call as a bare fat pointer (same-type passthrough, no cast/live-ptr wrapper) + // with no ownership operation around it. + () -> assertTrue( + requireConsumingCallLine(nodeBody, "add_child", nodeTemp).contains(", $" + nodeTemp + ","), + nodeBody + ), + () -> assertFalse( + ownershipCallMentions(nodeBody, "gdcc_Node_fat_ptr_live_object", nodeTemp), + nodeBody + ), + // YES (Resource target): exact one-arg own at the slot write, exactly one paired + // release inside __finally__. + () -> assertTrue( + resourceBody.contains("own_object(gdcc_Resource_fat_ptr_live_object($" + resourceTemp + "));"), + resourceBody + ), + () -> assertEquals( + 1, + countOccurrencesAfter( + resourceBody, + "release_object(gdcc_Resource_fat_ptr_live_object($" + resourceTemp + "));", + FINALLY_MARKER + ), + resourceBody + ) + ); + } + + private static int countOccurrencesAfter(@NotNull String body, @NotNull String needle, @NotNull String afterMarker) { + var markerIndex = body.indexOf(afterMarker); + assertTrue(markerIndex >= 0, () -> "Marker " + afterMarker + " not found in:\n" + body); + var scope = body.substring(markerIndex); + var count = 0; + for (var index = scope.indexOf(needle); index >= 0; index = scope.indexOf(needle, index + needle.length())) { + count++; + } + return count; + } + + private static @NotNull String requireConsumingCallLine(@NotNull String body, @NotNull String calleeMarker, @NotNull String temp) { + var lines = body.lines() + .filter(line -> line.contains(calleeMarker) && line.contains("$" + temp)) + .toList(); + assertFalse(lines.isEmpty(), () -> "No call to " + calleeMarker + " consumes $" + temp + " in:\n" + body); + return lines.getFirst(); + } + + private static boolean ownershipCallMentions(@NotNull String body, @NotNull String fatPtrWrapper, @NotNull String temp) { + return body.lines().anyMatch(line -> + (line.contains("own_object(") || line.contains("release_object(")) + && line.contains(fatPtrWrapper + "($" + temp + ")") + ); + } + + private static @NotNull String requireSingleMatch( + @NotNull Pattern pattern, + @NotNull String body, + @NotNull String description + ) { + var matches = pattern.matcher(body).results().map(result -> result.group(1)).toList(); + assertEquals(1, matches.size(), () -> "Expected exactly one " + description + " in:\n" + body); + return matches.getFirst(); + } + + /// Extracts one generated C function body by name via brace matching, so anchors stay + /// scoped to that function even when boundary temp names repeat across functions. + private static @NotNull String requireFunctionCBody(@NotNull String entrySource, @NotNull String cFunctionName) { + var start = entrySource.indexOf(cFunctionName + "("); + assertTrue(start >= 0, () -> "C function not found: " + cFunctionName + " in:\n" + entrySource); + var openBrace = entrySource.indexOf('{', start); + assertTrue(openBrace >= 0, () -> "No body for C function: " + cFunctionName); + var depth = 0; + for (var index = openBrace; index < entrySource.length(); index++) { + var current = entrySource.charAt(index); + if (current == '{') { + depth++; + } else if (current == '}' && --depth == 0) { + return entrySource.substring(start, index + 1); + } + } + throw new AssertionError("Unbalanced braces after C function: " + cFunctionName); + } + + private static @NotNull String generateEntryC(@NotNull CCodegen codegen) { + return codegen.generate().stream() + .filter(file -> file.filePath().endsWith("entry.c")) + .findFirst() + .map(file -> new String(file.contentWriter(), StandardCharsets.UTF_8)) + .orElseThrow(() -> new AssertionError("entry.c not found in generated files")); + } + + private static @NotNull LoweredFixture lowerModule( + @NotNull String moduleName, + @NotNull Path sourcePath, + @NotNull String source, + @NotNull Map topLevelCanonicalNameMap + ) throws IOException { + var parser = new GdScriptParserService(); + var parseDiagnostics = new DiagnosticManager(); + var unit = parser.parseUnit(sourcePath, source, parseDiagnostics); + assertTrue(parseDiagnostics.isEmpty(), () -> "Unexpected parse diagnostics: " + parseDiagnostics.snapshot()); + + var diagnostics = new DiagnosticManager(); + var classRegistry = new ClassRegistry(ExtensionApiLoader.loadVersion(GodotVersion.V451)); + var module = new FrontendModule(moduleName, List.of(unit), topLevelCanonicalNameMap); + var lowered = new FrontendLoweringPassManager().lower(module, classRegistry, diagnostics); + + assertNotNull(lowered, () -> "Lowering returned null with diagnostics: " + diagnostics.snapshot()); + assertFalse(diagnostics.hasErrors(), () -> "Unexpected frontend diagnostics: " + diagnostics.snapshot()); + return new LoweredFixture(lowered, classRegistry); + } + + private record LoweredFixture( + @NotNull LirModule module, + @NotNull ClassRegistry classRegistry + ) { + } +} diff --git a/src/test/java/gd/script/gdcc/backend/c/build/CustomReceiverCallableGapTest.java b/src/test/java/gd/script/gdcc/backend/c/build/CustomReceiverCallableGapTest.java deleted file mode 100644 index 86b7ecdd..00000000 --- a/src/test/java/gd/script/gdcc/backend/c/build/CustomReceiverCallableGapTest.java +++ /dev/null @@ -1,253 +0,0 @@ -package gd.script.gdcc.backend.c.build; - -import gd.script.gdcc.backend.CodegenContext; -import gd.script.gdcc.backend.c.gen.CCodegen; -import gd.script.gdcc.enums.GodotVersion; -import gd.script.gdcc.frontend.diagnostic.DiagnosticManager; -import gd.script.gdcc.frontend.lowering.FrontendLoweringPassManager; -import gd.script.gdcc.frontend.parse.FrontendModule; -import gd.script.gdcc.frontend.parse.GdScriptParserService; -import gd.script.gdcc.scope.ClassRegistry; -import gd.script.gdcc.gdextension.ExtensionApiLoader; -import gd.script.gdcc.lir.LirClassDef; -import gd.script.gdcc.lir.LirModule; -import org.jetbrains.annotations.NotNull; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -public class CustomReceiverCallableGapTest { - /// Minimal shape: an inner custom class with a method, converted to a Callable. - /// `probe_immediate` calls it while the receiver local is alive; `arm`/`fire` split the - /// creation and the call across scopes so only the Callable could retain the receiver. - private static @NotNull String gapSource(@NotNull String callableExpression) { - return """ - class_name CallableGapProbe - extends Node - - var _pending: Callable - - class Token extends RefCounted: - var count: int = 0 - - func bump() -> int: - count += 1 - return count - - func probe_immediate() -> int: - var token: Token = Token.new() - var cb: Callable = %s - var result: Variant = cb.call() - if result is int: - return int(result) - return -1 - - func arm() -> void: - var token: Token = Token.new() - _pending = %s - # `token` has no strong owner after this point; only the Callable could - # retain the receiver. - - func fire() -> int: - var result: Variant = _pending.call() - if result is int: - return int(result) - return -1 - """.formatted(callableExpression, callableExpression); - } - - @Test - void methodReferenceOnCustomInstanceDoesNotRetainReceiver() throws Exception { - if (ZigUtil.findZig() == null) { - Assumptions.abort("Zig not found; skipping custom-receiver Callable runtime probe"); - return; - } - - var tempDir = Path.of("tmp/test/custom_receiver_callable_sugar_gap"); - Files.createDirectories(tempDir); - - // The frontend accepts the sugar with zero diagnostics — the gap is purely backend. - var lowered = lowerModule( - "custom_receiver_callable_sugar_gap", - tempDir.resolve("callable_gap_probe.gd"), - gapSource("token.bump"), - Map.of("CallableGapProbe", "RuntimeCallableGapProbe") - ); - - var projectDir = tempDir.resolve("project"); - Files.createDirectories(tempDir.resolve("project")); - var projectInfo = new CProjectInfo( - "custom_receiver_callable_sugar_gap", - GodotVersion.V451, - projectDir, - COptimizationLevel.DEBUG, - TargetPlatform.getNativePlatform() - ); - var codegen = new CCodegen(); - codegen.prepare(new CodegenContext(projectInfo, lowered.classRegistry()), lowered.module()); - var buildResult = new CProjectBuilder().buildProject(projectInfo, codegen); - assertTrue(buildResult.success(), () -> "Native build should succeed. Build log:\n" + buildResult.buildLog()); - - var runner = new GodotGdextensionTestRunner(Path.of("test_project")); - runner.prepareProject(new GodotGdextensionTestRunner.ProjectSetup( - buildResult.artifacts(), - List.of(new GodotGdextensionTestRunner.SceneNodeSpec( - "CallableGapNode", - "RuntimeCallableGapProbe", - ".", - Map.of() - )), - new GodotGdextensionTestRunner.TestScriptSpec(""" - extends Node - - func _ready() -> void: - var target = get_parent().get_node_or_null("CallableGapNode") - if target == null: - push_error("Target node missing.") - return - var immediate := int(target.call("probe_immediate")) - if immediate == 1: - print("callable sugar same-scope check passed.") - else: - print("callable sugar same-scope UNEXPECTEDLY broken: result=%d" % immediate) - target.call("arm") - var deferred := int(target.call("fire")) - if deferred == 1: - print("callable sugar cross-scope check passed.") - else: - print("callable sugar cross-scope gap reproduced: result=%d" % deferred) - """) - )); - - var runResult = runner.run(true); - var combinedOutput = runResult.combinedOutput(); - assertTrue( - runResult.stopSignalSeen(), - () -> "Godot run should emit the stop signal.\nOutput:\n" + combinedOutput - ); - // Control: the same-scope call must work — the Callable is created correctly. - assertTrue( - combinedOutput.contains("callable sugar same-scope check passed."), - () -> "The same-scope control should pass; something else broke.\nOutput:\n" + combinedOutput - ); - // Characterization of the gap: once the creating scope returned, the receiver is - // gone and the deferred call fails (probe returns -1). A backend fix must flip this - // to "cross-scope check passed" and update this test. - assertTrue( - combinedOutput.contains("callable sugar cross-scope gap reproduced: result=-1"), - () -> "Expected the receiver-retention gap to reproduce (fire result -1).\nOutput:\n" + combinedOutput - ); - assertFalse( - combinedOutput.contains("callable sugar cross-scope check passed."), - () -> "The retention gap unexpectedly disappeared; update this characterization test.\nOutput:\n" + combinedOutput - ); - } - - @Test - void explicitCallableConstructionFromCustomInstanceFailsCodegen() throws Exception { - var tempDir = Path.of("tmp/test/custom_receiver_callable_explicit_gap"); - Files.createDirectories(tempDir); - - // The frontend accepts the explicit construction (clean lowering) — the rejection is - // purely a C-backend boundary. - var lowered = lowerModule( - "custom_receiver_callable_explicit_gap", - tempDir.resolve("callable_gap_probe.gd"), - gapSource("Callable(token, &\"bump\")"), - Map.of("CallableGapProbe", "RuntimeCallableGapProbe") - ); - - var projectDir = tempDir.resolve("project"); - Files.createDirectories(projectDir); - var projectInfo = new CProjectInfo( - "custom_receiver_callable_explicit_gap", - GodotVersion.V451, - projectDir, - COptimizationLevel.DEBUG, - TargetPlatform.getNativePlatform() - ); - var codegen = new CCodegen(); - codegen.prepare(new CodegenContext(projectInfo, lowered.classRegistry()), lowered.module()); - // Generation happens while the builder writes sources; a fake compiler keeps the - // failure on the codegen side (no Zig needed). The builder wraps the codegen - // InvalidInsnException, so the ExtensionBuiltinClass message lives in the cause chain. - var thrown = assertThrows( - RuntimeException.class, - () -> new CProjectBuilder(fakeCompiler()).buildProject(projectInfo, codegen), - "Expected codegen to reject Callable(customInstance, &\"bump\")" - ); - var chain = new StringBuilder(); - for (Throwable cursor = thrown; cursor != null; cursor = cursor.getCause()) { - chain.append(cursor.getMessage()).append('\n'); - } - assertTrue( - chain.toString().contains("'Callable' with args [RuntimeCallableGapProbe__sub__Token, StringName]" - + " is not defined in ExtensionBuiltinClass"), - () -> "Unexpected failure chain:\n" + chain - ); - } - - private static @NotNull CCompiler fakeCompiler() { - return new CCompiler() { - @Override - public @NotNull CCompileResult compile( - @NotNull Path projectDir, - @NotNull List includeDirs, - @NotNull List cFiles, - @NotNull String outputBaseName, - @NotNull COptimizationLevel optimizationLevel, - @NotNull TargetPlatform targetPlatform - ) throws IOException { - var out = projectDir.resolve(outputBaseName + ".dll"); - Files.createDirectories(projectDir); - Files.writeString(out, "dummy"); - return new CCompileResult(true, "ok", List.of(out)); - } - }; - } - - private static @NotNull LoweredFixture lowerModule( - @NotNull String moduleName, - @NotNull Path sourcePath, - @NotNull String source, - @NotNull Map topLevelCanonicalNameMap - ) throws IOException { - var parser = new GdScriptParserService(); - var parseDiagnostics = new DiagnosticManager(); - var unit = parser.parseUnit(sourcePath, source, parseDiagnostics); - assertTrue(parseDiagnostics.isEmpty(), () -> "Unexpected parse diagnostics: " + parseDiagnostics.snapshot()); - - var diagnostics = new DiagnosticManager(); - var classRegistry = new ClassRegistry(ExtensionApiLoader.loadVersion(GodotVersion.V451)); - var module = new FrontendModule(moduleName, List.of(unit), topLevelCanonicalNameMap); - var lowered = new FrontendLoweringPassManager().lower(module, classRegistry, diagnostics); - - assertNotNull(lowered, () -> "Lowering returned null with diagnostics: " + diagnostics.snapshot()); - assertFalse(diagnostics.hasErrors(), () -> "Unexpected frontend diagnostics: " + diagnostics.snapshot()); - - // The inner class lowers to its own class def, so locate the top-level class by name. - var topLevel = lowered.getClassDefs().stream() - .filter(def -> def.getName().equals("RuntimeCallableGapProbe")) - .findFirst() - .orElseThrow(() -> new AssertionError("Missing top-level class def in " + lowered.getClassDefs())); - - return new LoweredFixture(lowered, classRegistry, topLevel); - } - - private record LoweredFixture( - @NotNull LirModule module, - @NotNull ClassRegistry classRegistry, - @NotNull LirClassDef lirClass - ) { - } -} diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/CConstructInsnGenTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/CConstructInsnGenTest.java index 86eadac3..a5cc8582 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/CConstructInsnGenTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/CConstructInsnGenTest.java @@ -30,6 +30,7 @@ import gd.script.gdcc.lir.insn.StandaloneCallableKind; import gd.script.gdcc.lir.insn.DestructInsn; import gd.script.gdcc.lir.insn.GetClassNameInsn; +import gd.script.gdcc.lir.insn.LiteralStringNameInsn; import gd.script.gdcc.lir.insn.ReturnInsn; import gd.script.gdcc.scope.ClassRegistry; import gd.script.gdcc.type.GdArrayType; @@ -63,6 +64,7 @@ import java.util.List; import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -1527,6 +1529,63 @@ void generateShouldEmitTypedArrayCtorInDefaultFieldInitHelper() { assertFalse(arrayCtorCall.contains("NULL"), arrayCtorCall); } + @Test + @DisplayName("construct_builtin should emit Callable(Object, StringName) with live object pointer") + void constructBuiltinShouldEmitCallableFromExactObjectArgument() { + // Hand-written LIR anchor (not an end-to-end statement): the frontend call-argument + // upcast contract guarantees the builtin constructor always sees an exact-Object slot. + var clazz = newTestClass(); + var func = newFunction("construct_callable_from_object"); + func.createAndAddVariable("receiver", GdObjectType.OBJECT); + func.createAndAddVariable("method", GdStringNameType.STRING_NAME); + func.createAndAddVariable("cb", new GdCallableType()); + + entry(func).appendInstruction(new LiteralStringNameInsn("method", "bump")); + entry(func).appendInstruction(new ConstructBuiltinInsn( + "cb", + List.of(new LirInstruction.VariableOperand("receiver"), new LirInstruction.VariableOperand("method")) + )); + clazz.addFunction(func); + + var body = generateBody(clazz, func, apiWithCallableObjectConstructor()); + var call = extractCall(body, "godot_new_Callable_with_Object_StringName"); + assertAll( + () -> assertEquals( + "godot_new_Callable_with_Object_StringName(gdcc_Object_fat_ptr_live_object($receiver), &$method);", + call + ), + () -> assertTrue(body.contains("godot_new_StringName_with_utf8_chars(u8\"bump\")"), body) + ); + } + + @Test + @DisplayName("construct_builtin should keep rejecting a subclass-typed Callable argument") + void constructBuiltinShouldKeepRejectingSubclassTypedCallableArgument() { + // Negative anchor: a custom-class fat pointer still does not satisfy the exact-match + // metadata contract; the frontend upcast temp is what makes the real pipeline exact. + var clazz = newTestClass(); + var tokenClass = new LirClassDef("MyToken", "RefCounted", false, false, Map.of(), List.of(), List.of(), List.of()); + var func = newFunction("construct_callable_from_subclass"); + func.createAndAddVariable("token", new GdObjectType("MyToken")); + func.createAndAddVariable("method", GdStringNameType.STRING_NAME); + func.createAndAddVariable("cb", new GdCallableType()); + + entry(func).appendInstruction(new LiteralStringNameInsn("method", "bump")); + entry(func).appendInstruction(new ConstructBuiltinInsn( + "cb", + List.of(new LirInstruction.VariableOperand("token"), new LirInstruction.VariableOperand("method")) + )); + clazz.addFunction(func); + + var module = new LirModule("test_module", List.of(clazz, tokenClass)); + var codegen = newCodegen(module, List.of(clazz, tokenClass), apiWithCallableObjectConstructor()); + var ex = assertThrows(InvalidInsnException.class, () -> codegen.generateFuncBody(clazz, func)); + assertAll( + () -> assertTrue(ex.getMessage().contains("'Callable' with args [MyToken, StringName]"), ex.getMessage()), + () -> assertTrue(ex.getMessage().contains("is not defined in ExtensionBuiltinClass"), ex.getMessage()) + ); + } + private LirClassDef newTestClass() { return new LirClassDef("Worker", "RefCounted", false, false, Map.of(), List.of(), List.of(), List.of()); } @@ -1806,6 +1865,33 @@ private ExtensionAPI apiWithBuiltins(List builtins) { ); } + private ExtensionAPI apiWithCallableObjectConstructor() { + return new ExtensionAPI( + null, + List.of(), + List.of(), + List.of(), + List.of(), + List.of(newBuiltinClass( + "Callable", + List.of(new ExtensionBuiltinClass.ConstructorInfo( + "Callable", + 0, + List.of( + new ExtensionFunctionArgument("object", "Object", null, null), + new ExtensionFunctionArgument("method", "StringName", null, null) + ) + )) + )), + List.of( + new ExtensionGdClass("Object", false, true, "", "core", List.of(), List.of(), List.of(), List.of(), List.of()), + new ExtensionGdClass("RefCounted", true, true, "Object", "core", List.of(), List.of(), List.of(), List.of(), List.of()) + ), + List.of(), + List.of() + ); + } + private ExtensionAPI apiWithConstructibleObjectClasses() { var parseString = new ExtensionGdClass.ClassMethod( "parse_string", diff --git a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBodyInsnPassTest.java b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBodyInsnPassTest.java index e626eb61..fb0bd90d 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBodyInsnPassTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBodyInsnPassTest.java @@ -4088,6 +4088,240 @@ func from_direct_literal() -> StringName: ); } + @Test + void runMaterializesObjectSubclassCallArgumentsThroughUpcastTemp() throws Exception { + var prepared = prepareContext( + "body_insn_call_object_upcast.gd", + """ + class_name BodyInsnCallObjectUpcast + extends Node + + class Token extends RefCounted: + var count: int = 0 + + func bump() -> int: + count += 1 + return count + + class SpecialToken extends Token: + pass + + func take_token(value: Token) -> void: + pass + + func make_callable_from_subclass(token: Token) -> Callable: + return Callable(token, &"bump") + + func add_sprite(sprite: Sprite2D) -> void: + add_child(sprite) + + func pass_special(special: SpecialToken) -> void: + take_token(special) + """, + Map.of("BodyInsnCallObjectUpcast", "RuntimeBodyInsnCallObjectUpcast"), + true + ); + var callableContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnCallObjectUpcast", + "make_callable_from_subclass" + ); + var methodContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnCallObjectUpcast", + "add_sprite" + ); + var customAncestorContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnCallObjectUpcast", + "pass_special" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var callableFunction = callableContext.targetFunction(); + var methodFunction = methodContext.targetFunction(); + var customAncestorFunction = customAncestorContext.targetFunction(); + var callableUpcastTemps = boundaryUpcastTempIds(callableFunction); + var methodUpcastTemps = boundaryUpcastTempIds(methodFunction); + var customAncestorUpcastTemps = boundaryUpcastTempIds(customAncestorFunction); + var callableAssignSources = assignSourcesByTarget(allInstructions(callableFunction)); + var methodAssignSources = assignSourcesByTarget(allInstructions(methodFunction)); + var customAncestorAssignSources = assignSourcesByTarget(allInstructions(customAncestorFunction)); + var constructInsn = requireOnlyInstruction(callableFunction, ConstructBuiltinInsn.class); + var addChildInsn = requireOnlyInstruction(methodFunction, CallMethodInsn.class); + var takeTokenInsn = requireOnlyInstruction(customAncestorFunction, CallMethodInsn.class); + + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + // builtin constructor route: Callable(token, &"bump") with custom Token extends RefCounted. + () -> assertEquals(1, callableUpcastTemps.size()), + () -> assertTrue(callableUpcastTemps.getFirst().startsWith("cfg_boundary_call_fixed_0_upcast_")), + () -> assertTrue( + requireVariableType(callableFunction, callableAssignSources.get(callableUpcastTemps.getFirst())) + .getTypeName() + .endsWith("__sub__Token") + ), + () -> assertEquals(GdObjectType.OBJECT, requireVariableType(callableFunction, callableUpcastTemps.getFirst())), + () -> assertEquals(2, constructInsn.args().size()), + () -> assertEquals( + callableUpcastTemps.getFirst(), + assertInstanceOf(LirInstruction.VariableOperand.class, constructInsn.args().getFirst()).id() + ), + // engine method route: add_child(sprite) upcasts Sprite2D to the Node parameter. + () -> assertEquals(1, methodUpcastTemps.size()), + () -> assertTrue(methodUpcastTemps.getFirst().startsWith("cfg_boundary_call_fixed_0_upcast_")), + () -> assertEquals( + new GdObjectType("Sprite2D"), + requireVariableType(methodFunction, methodAssignSources.get(methodUpcastTemps.getFirst())) + ), + () -> assertEquals(new GdObjectType("Node"), requireVariableType(methodFunction, methodUpcastTemps.getFirst())), + () -> assertEquals( + methodUpcastTemps.getFirst(), + assertInstanceOf(LirInstruction.VariableOperand.class, addChildInsn.args().getFirst()).id() + ), + // GDCC custom ancestor route: take_token(special) upcasts SpecialToken to the + // custom Token parameter. + () -> assertEquals(1, customAncestorUpcastTemps.size()), + () -> assertTrue( + requireVariableType( + customAncestorFunction, + customAncestorAssignSources.get(customAncestorUpcastTemps.getFirst()) + ).getTypeName().endsWith("__sub__SpecialToken") + ), + () -> assertTrue( + requireVariableType(customAncestorFunction, customAncestorUpcastTemps.getFirst()) + .getTypeName() + .endsWith("__sub__Token") + ), + () -> assertEquals( + customAncestorUpcastTemps.getFirst(), + assertInstanceOf(LirInstruction.VariableOperand.class, takeTokenInsn.args().getFirst()).id() + ) + ); + } + + @Test + void runKeepsExactObjectCallArgumentsOnDirectSlots() throws Exception { + var prepared = prepareContext( + "body_insn_call_object_exact.gd", + """ + class_name BodyInsnCallObjectExact + extends RefCounted + + func make_callable_from_exact(obj: Object) -> Callable: + return Callable(obj, &"ping") + """, + Map.of("BodyInsnCallObjectExact", "RuntimeBodyInsnCallObjectExact"), + true + ); + var callableContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnCallObjectExact", + "make_callable_from_exact" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var function = callableContext.targetFunction(); + var constructInsn = requireOnlyInstruction(function, ConstructBuiltinInsn.class); + + // Same-type Object argument stays ALLOW_DIRECT: no boundary temp of any kind, and the + // constructor consumes the argument's already-materialized slot (zero-overhead anchor). + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertTrue(boundaryUpcastTempIds(function).isEmpty()), + () -> assertTrue( + function.getVariables().keySet().stream().noneMatch(id -> id.startsWith("cfg_boundary_")) + ), + () -> assertEquals(2, constructInsn.args().size()), + () -> assertEquals( + GdObjectType.OBJECT, + requireVariableType( + function, + assertInstanceOf(LirInstruction.VariableOperand.class, constructInsn.args().getFirst()).id() + ) + ) + ); + } + + @Test + void runKeepsNonObjectCallArgumentBoundariesOnExistingPaths() throws Exception { + var prepared = prepareContext( + "body_insn_call_non_object_boundary.gd", + """ + class_name BodyInsnCallNonObjectBoundary + extends RefCounted + + func take_name(value: StringName) -> StringName: + return value + + func take_ratio(value: float) -> float: + return value + + func take_any(value: Variant) -> Variant: + return value + + func take_obj(value: Object) -> void: + pass + + func regression_boundaries(text: String, seed: int, sprite: Sprite2D, box: Variant) -> float: + take_name(text) + take_any(sprite) + take_obj(box) + take_obj(null) + return take_ratio(seed) + """, + Map.of("BodyInsnCallNonObjectBoundary", "RuntimeBodyInsnCallNonObjectBoundary"), + true + ); + var regressionContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnCallNonObjectBoundary", + "regression_boundaries" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var function = regressionContext.targetFunction(); + var instructions = allInstructions(function); + var constructorInsn = requireOnlyInstruction(function, ConstructBuiltinInsn.class); + var intrinsicInsn = requireOnlyInstruction(function, CallIntrinsicInsn.class); + var packInsn = requireOnlyInstruction(function, PackVariantInsn.class); + var unpackInsn = requireOnlyInstruction(function, UnpackVariantInsn.class); + var nullObjectInsn = requireOnlyInstruction(function, LiteralNullInsn.class); + var callArgumentIds = instructions.stream() + .filter(CallMethodInsn.class::isInstance) + .map(CallMethodInsn.class::cast) + .flatMap(insn -> insn.args().stream()) + .map(operand -> assertInstanceOf(LirInstruction.VariableOperand.class, operand).id()) + .toList(); + + // Regression sampling: String -> StringName constructor, int -> float intrinsic cast, + // object -> Variant pack, Variant -> Object unpack and null -> Object literal must all + // stay on their existing decisions, untouched by the upcast shape. + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertTrue(boundaryUpcastTempIds(function).isEmpty()), + () -> assertEquals(GdStringType.STRING, requireVariableType(function, onlyVariableOperandId(constructorInsn.args()))), + () -> assertEquals(GdStringNameType.STRING_NAME, requireVariableType(function, constructorInsn.resultId())), + () -> assertTrue(callArgumentIds.contains(constructorInsn.resultId())), + () -> assertTrue(callArgumentIds.contains(intrinsicInsn.resultId())), + () -> assertTrue(callArgumentIds.contains(packInsn.resultId())), + () -> assertTrue(callArgumentIds.contains(unpackInsn.resultId())), + () -> assertEquals(GdObjectType.OBJECT, requireVariableType(function, unpackInsn.resultId())), + () -> assertTrue(callArgumentIds.contains(nullObjectInsn.resultId())), + () -> assertEquals(GdObjectType.OBJECT, requireVariableType(function, nullObjectInsn.resultId())), + () -> assertEquals(1, countInstructions(instructions, UnpackVariantInsn.class)), + () -> assertEquals(1, countInstructions(instructions, LiteralNullInsn.class)) + ); + } + @Test void runLowersStringFamilyReturnSlotsThroughConstructBuiltinInsn() throws Exception { var prepared = prepareContext( @@ -13161,6 +13395,13 @@ private static int countInstructions( return Map.copyOf(assignSources); } + private static @NotNull List boundaryUpcastTempIds(@NotNull LirFunctionDef function) { + return function.getVariables().keySet().stream() + .filter(id -> id.startsWith("cfg_boundary_") && id.contains("_upcast_")) + .sorted() + .toList(); + } + private static void replaceParameterType( @NotNull FunctionLoweringContext context, @NotNull String parameterName, From 755d8094eb4f12cbc88db1a6e3ce38bf510fa742 Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Sun, 27 Sep 2026 10:29:06 +0800 Subject: [PATCH 4/4] docs(frontend): consolidate fixed-call object upcast documentation - Replace the completed implementation plan with a source-of-truth contract - Update documentation links and remove obsolete lowering guidance --- ...l_argument_object_upcast_implementation.md | 122 ++++++++ ...ontend_call_argument_object_upcast_plan.md | 288 ------------------ .../frontend_implicit_conversion_matrix.md | 2 +- .../body/FrontendBodyLoweringSession.java | 2 - 4 files changed, 123 insertions(+), 291 deletions(-) create mode 100644 doc/module_impl/frontend/frontend_call_argument_object_upcast_implementation.md delete mode 100644 doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md diff --git a/doc/module_impl/frontend/frontend_call_argument_object_upcast_implementation.md b/doc/module_impl/frontend/frontend_call_argument_object_upcast_implementation.md new file mode 100644 index 00000000..294d7571 --- /dev/null +++ b/doc/module_impl/frontend/frontend_call_argument_object_upcast_implementation.md @@ -0,0 +1,122 @@ +# Frontend 调用参数对象上转型物化实现说明 + +> Updated: 2026-09-27 +> +> 本文档是 fixed call argument 边界上"严格 object 子类 -> 祖先"实参物化例外(target-typed temp + `AssignInsn`)的长期事实源:记录该形态的存在理由、唯一实现点、生效与排除范围、ownership 合同与回归锚点。 +> 本文档替代已归档的实施计划(`frontend_call_argument_object_upcast_plan.md`),不保留分步骤实施、阶段状态、验收清单或评审记录;当前合同以本文与所引事实源为准。 + +- 适用范围: + - `doc/module_impl/frontend/**` + - `src/main/java/gd/script/gdcc/frontend/lowering/**` + - `src/test/java/gd/script/gdcc/frontend/lowering/**` + - `src/test/java/gd/script/gdcc/backend/c/**` +- 关联文档: + - `frontend_implicit_conversion_matrix.md`(typed-boundary 兼容性唯一真源) + - `frontend_lowering_(un)pack_implementation.md`(boundary materialization 单一入口合同,§4.2/§4.3 登记本例外) + - `frontend_rules.md`(`cfg_boundary___` 命名合同) + - `doc/module_impl/backend/builtin_builder_implementation.md`(后端构造器 exact-match 合同) + - `doc/module_impl/backend/object_value_fat_pointer_implementation.md`(upcast 表示转换合同) + - `doc/gdcc_ownership_lifecycle_spec.md`(§3.6 boundary temp 生命周期条款) + - `frontend_signal_support.md`(Callable/Signal 不保活接收者条款) + - `doc/gdcc_low_ir.md`(`construct_callable` / `assign` 合同) + +## 1. 背景与成因 + +`Callable(token, &"bump")`(`token` 为自定义 `class Token extends RefCounted`)这类显式构造曾无法通过 C 后端 codegen,成因链路: + +1. 前端按 builtin type-meta 构造解析,元数据确有 `Callable(Object, StringName)`(`extension_api_451.json` constructor index 2;`Signal(Object, StringName)` 同形)。 +2. 参数边界 `Token -> Object` 经 `ClassRegistry.checkAssignable` 判为可赋值,`FrontendVariantBoundaryCompatibility` 返回 `ALLOW_DIRECT`;`materializeFrontendBoundaryValue` 的 `ALLOW_DIRECT` 分支原样复用**子类类型槽**。 +3. `ConstructBuiltinInsn` 实参静态类型因此是子类名(如 `RuntimeCallableGapProbe__sub__Token`)。 +4. 后端 `CBuiltinBuilder.constructRegularBuiltin(...)` 对构造器实参做**严格类型名相等**匹配:子类名 != `Object`,抛出 `Builtin constructor validation failed: ... is not defined in ExtensionBuiltinClass`。 + +根因:前端"继承可赋值"与后端 builtin 构造器"精确名匹配"之间缺少一次纯表示 upcast 物化。按 `builtin_builder_implementation.md` 的职责划分——需要 widening 时由上游 lowering 显式物化——修复落点在前端 lowering,后端匹配器保持 exact 不变。 + +范围说明:精确匹配只存在于 builtin **构造器**路径。builtin/engine **方法**调用的后端实参处理本已支持子类实参(`CallMethodInsnGen` 的 `checkAssignable` + `valueOfCastedVar`);方法调用共享本物化形态只是为了让 fixed call argument 边界保持同一实参类型不变量,不是为了修复方法调用。 + +## 2. 当前合同 + +### 2.1 物化形态与唯一实现点 + +唯一实现点:`FrontendBodyLoweringSession.materializeCallArgumentBoundaryValue(...)`,由 `materializeCallArguments` 的 fixed-parameter 循环统一调用(construct / method / callable / super / static 各 route 共享)。 + +- 严格 object 子类 -> 祖先(双边 `GdObjectType`、类型名不同、`classRegistry.checkAssignable(source, target)` 成立)时:分配 `cfg_boundary_call_fixed__upcast_` 目标类型 temp,`ensureVariable` 声明,追加 `AssignInsn(temp, sourceSlot)`,实参消费该 temp。 +- 其余全部情形委托共享入口 `materializeFrontendBoundaryValue(...)`,decision 与物化行为不变。 + +形态复刻显式 cast 的 `OBJECT_UPCAST` 分支(同为 `AssignInsn`),不引入新 LIR 指令或 `Decision` 枚举值。 + +### 2.2 生效范围与排除路径 + +- 只发生在 fixed-parameter 循环;vararg 尾段目标恒为 `Variant`(走既有 pack 路径),`DYNAMIC` 调用按合同原样透传实参槽,二者均不触发本形态。 +- 两条**绕过调用参数物化**的既有路径不受本形态影响,维护时不得"顺手"改入: + - builtin 单 `Variant` 实参构造特判(`FrontendSequenceItemInsnLoweringProcessors`):发射 `UnpackVariantInsn`,不经过 `materializeCallArguments`。 + - `materializeBuiltinConstructorBoundary(...)`:`String <-> StringName` 专用的既有 boundary 构造路径,实参类型恒为字符串家族;若未来某条 object 边界被标为 `ALLOW_WITH_BUILTIN_CONSTRUCTOR`,须另行立项处理。 + +### 2.3 LIR / C 形态(以 `Callable(token, &"bump")` 为例) + +```text +$cfg_boundary_call_fixed_0_upcast_0: Object = assign $token +$cb = construct_builtin Callable [$cfg_boundary_call_fixed_0_upcast_0: Object, $sn: StringName] +``` + +生成 C(全部复用现有机制): + +```c +gdcc_Object_fat_ptr $cfg_boundary_call_fixed_0_upcast_0; +// __prepare__ 先行 null 初始化,随后被 assign 覆写 +$cfg_boundary_call_fixed_0_upcast_0 = (gdcc_Object_fat_ptr){ 0 }; +... +// upcast helper:ownership-neutral,保留 instance_id,仅转换 ptr 表示 +$cfg_boundary_call_fixed_0_upcast_0 = + gdcc_RuntimeCallableGapProbe_sub_Token_fat_ptr_upcast_to_Object($token); +// 槽写入规则:借用 RHS 写入 owning 槽 -> own;精确 Object 的 RefCountedStatus 为 UNKNOWN -> 双参数 try_ 变体 +try_own_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_upcast_0), + $cfg_boundary_call_fixed_0_upcast_0.instance_id); +// Object 实参经 live_object 取裸指针;StringName 实参为槽地址 +$cb = godot_new_Callable_with_Object_StringName( + gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_upcast_0), + &$sn); +// __finally__: managed local 自动清理,与 own 一对一平衡 +try_release_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_upcast_0), + $cfg_boundary_call_fixed_0_upcast_0.instance_id); +``` + +注意:`GD_STATIC_SN(...)` 只出现在 `construct_callable` 方法引用路径;builtin 构造器的 `StringName` 实参是槽地址,二者不得混淆。 + +### 2.4 Ownership 与生命周期 + +- temp 按标准槽写入规则在物化写入处 own、由函数 `__finally__` managed-locals 自动清理释放,own/release 一对一平衡,无泄漏、无 double-free。 +- 持有期覆盖整个函数而非单次调用(函数作用域持有是既定设计,见 `doc/gdcc_ownership_lifecycle_spec.md` §3.6 boundary temp 条款):实参求值 temp 在所有调用路径上本就同粒度持有,未证实可观察的 Godot 分歧;如需调用粒度释放须另行修订该生命周期合同。 +- 目标类型三态:精确 `Object` -> UNKNOWN(`try_own_object` / `try_release_object` 双参数变体);非 RefCounted 祖先(如 `Node`)-> NO(不发射 own/release,temp 实为借用别名);RefCounted 具名祖先(如 `Resource`)-> YES(`own_object` / `release_object`)。 +- upcast helper 保留 `instance_id`,构造出的 Callable 记录的 ObjectID 与源对象 `get_instance_id()` 一致。 + +### 2.5 Callable / Signal 不保活与 Godot 一致性 + +`Signal` / `Callable` 只保存非 owning ObjectID,不保活 receiver(合同见 `frontend_signal_support.md` §3)。`token.bump` 方法引用 sugar 跨作用域失效与官方 GDScript 行为一致,属既定语义,不得"修复"成 retain。 + +## 3. 边界情况清单 + +| 场景 | 行为 | +| --- | --- | +| source 与 target 同名(如 `Object -> Object`) | 复用源槽,无 upcast temp | +| target 为 `Variant`(vararg 或 fixed Variant 参数) | 走既有 pack 路径 | +| source 为 `Variant`、target 为对象类型 | 走既有 unpack 路径 | +| null 字面量 -> 对象参数 | 走既有 `ALLOW_WITH_LITERAL_NULL` 路径 | +| `String -> StringName` 等其他 decision | 走既有路径,零变化 | +| `DYNAMIC` 调用 | 绕过签名边界,实参槽原样透传 | +| engine/builtin 方法调用(如 `add_child(sprite)`) | 同样物化 upcast temp;后端 `checkAssignable` + `valueOfCastedVar` 退化为同型直传,功能等价 | +| lambda capture / 参数 / merge 值作为实参 | 同一 helper 处理,ownership 由标准槽写入规则承接 | +| 目标为 GDCC 自定义祖先类 | 后端 upcast helper 经 `_super` 链转换 ptr 表示,`instance_id` 保留 | + +## 4. 维护约束 + +- 后端 builtin 构造器 exact-match 合同不得放宽;widening 一律由上游 lowering 显式物化。 +- 不得为本形态新增 LIR 指令或 `FrontendVariantBoundaryCompatibility.Decision` 枚举值。 +- `CallArgumentBoundaryPlan` 目前只携带 `fixedParameterTypes` + `isVararg`(不发布冻结 `Decision`),helper 因此在 lowering 时重新推导判定;一旦 call-argument plan 开始发布冻结 `Decision`,该 helper 必须改消费冻结 decision,不得在 lowering 重复查询矩阵。 +- 修改本形态时须同步更新:`frontend_implicit_conversion_matrix.md` 全局规则行备注与 §9.1、`frontend_lowering_(un)pack_implementation.md` §4.2/§4.3、`frontend_rules.md` 命名条款。 + +## 5. 回归锚点 + +- 前端 LIR 形态:`FrontendLoweringBodyInsnPassTest.runMaterializesObjectSubclassCallArgumentsThroughUpcastTemp`(自定义 `Token`、engine `Sprite2D -> Node` 方法、GDCC 自定义祖先 `SpecialToken -> Token` 三条正例)、`runKeepsExactObjectCallArgumentsOnDirectSlots`(同类型零开销负例)、`runKeepsNonObjectCallArgumentBoundariesOnExistingPaths`(`String -> StringName` / `int -> float` / object->`Variant` / `Variant`->Object / null->Object 回归抽样)。 +- 后端手写 LIR 锚点:`CConstructInsnGenTest.constructBuiltinShouldEmitCallableFromExactObjectArgument`(精确 `Object` 实参发射)、`constructBuiltinShouldKeepRejectingSubclassTypedCallableArgument`(子类实参仍被拒,exact-match 合同不变)。 +- lowering -> codegen 链路:`CallArgumentObjectUpcastCodegenTest.fixedCallArgumentUpcastTempOwnershipMatchesTargetRefCountedStatus`(三态 ownership + `Signal` 同入口,按函数体切片断言)。 +- sugar 非保活锚点:`CConstructInsnGenTest.constructCallableShouldEmitCallableFromReceiverAndDestroyResult`(构造体无 retain)、`FrontendLoweringBodyInsnPassTest.runLowersBareAndReceiverMethodReferencesIntoConstructCallableInsn`(`construct_callable` 形态)。 diff --git a/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md b/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md deleted file mode 100644 index bce32829..00000000 --- a/doc/module_impl/frontend/frontend_call_argument_object_upcast_plan.md +++ /dev/null @@ -1,288 +0,0 @@ -# Frontend 调用参数对象上转型物化实施计划 - -> 本文档记录 `Callable(customInstance, &"method")` 等 builtin 构造调用在实参为 object 子类时被 C 后端拒绝这一缺陷的修复计划。修复手段是在**调用参数边界**物化纯表示的对象 upcast(目标类型临时槽 + `assign`),保持后端 builtin 构造器精确匹配合同不变。 - -## 文档状态 - -- 状态:已完成(2026-09-27,步骤 0-5 全部实施并通过验收;全量 clean build 绿) -- 评审后续处置(2026-09-27):评审提出的"upcast temp 对 RefCounted 实参持有至函数 `__finally__`、超过单次调用"现象经核实为本计划 §4.5 的既定设计(且实参求值 temp 在所有调用路径上本就同粒度持有,未证实可观察的 Godot 分歧),决定**保持现状**并记录于 `doc/gdcc_ownership_lifecycle_spec.md` §3.6 的 boundary temp 条款与 `(un)pack` §4.2。临时复现测试 `CustomReceiverCallableGapTest` 在覆盖闭合后删除:显式构造通路由 `CallArgumentObjectUpcastCodegenTest` 承接,sugar 非保活锚点由 `CConstructInsnGenTest.constructCallableShouldEmitCallableFromReceiverAndDestroyResult`(构造体无 retain)与 `FrontendLoweringBodyInsnPassTest.runLowersBareAndReceiverMethodReferencesIntoConstructCallableInsn`(`construct_callable` 形态)承接。 -- 更新时间:2026-09-27 -- 适用范围: - - `doc/module_impl/frontend/**` - - `src/main/java/gd/script/gdcc/frontend/lowering/**` - - `src/test/java/gd/script/gdcc/frontend/lowering/**` - - `src/test/java/gd/script/gdcc/backend/c/**` -- 关联文档: - - `frontend_implicit_conversion_matrix.md`(typed-boundary 兼容性唯一真源,须先改) - - `frontend_lowering_(un)pack_implementation.md`(boundary materialization 单一入口合同,须同步改) - - `frontend_rules.md`(slot 命名合同,须同步补一条) - - `doc/module_impl/backend/builtin_builder_implementation.md`(后端 exact-match 合同,本次不修改) - - `doc/module_impl/backend/object_value_fat_pointer_implementation.md`(upcast 表示转换合同) - - `doc/gdcc_ownership_lifecycle_spec.md`(槽写入 / managed local 清理规则) - - `frontend_signal_support.md`(Callable 不保活接收者条款) - - `doc/gdcc_low_ir.md`(`construct_callable` / `assign` 合同) - ---- - -## 1. 背景与成因链路 - -最小复现为 `src/test/java/gd/script/gdcc/backend/c/build/CustomReceiverCallableGapTest.java`,覆盖两种写法。(该临时复现测试已在覆盖闭合后删除,承接锚点见文档状态注记。) - -### 1.1 显式构造 `Callable(token, &"bump")` —— 本计划要修的缺陷 - -1. 前端按 builtin type-meta 构造解析,元数据确有 `Callable(Object, StringName)`(`extension_api_451.json` constructor index 2)。 -2. 参数边界 `Token -> Object` 经 `ClassRegistry.checkAssignable` 判为可赋值,`FrontendVariantBoundaryCompatibility` 返回 `ALLOW_DIRECT`;lowering 在 `materializeFrontendBoundaryValue` 的 `ALLOW_DIRECT` 分支原样复用**子类类型槽**(`FrontendBodyLoweringSession.java` `case ALLOW_DIRECT -> sourceSlot`)。 -3. `materializeCallArguments` 把该槽写入 `ConstructBuiltinInsn`,实参静态类型为 `RuntimeCallableGapProbe__sub__Token`。 -4. 后端 `CBuiltinBuilder.constructRegularBuiltin(...)` 先以 `hasConstructor()` 将两侧渲染为 GD 类型名后做**严格字符串相等**:`RuntimeCallableGapProbe__sub__Token != "Object"`;未命中且无 helper-shim 后在 `:404-406` 抛出 `Builtin constructor validation failed: 'Callable' with args [RuntimeCallableGapProbe__sub__Token, StringName] is not defined in ExtensionBuiltinClass`。 - -根因:前端"继承可赋值"与后端 builtin 构造器"精确名匹配"两个边界规则之间缺少一次纯表示 upcast 物化。职责划分上,`builtin_builder_implementation.md` 明确"需要 widening 时由上游 lowering 或 intrinsic 显式物化",因此修复点在前端 lowering,后端匹配器保持 exact。 - -注意范围:该精确匹配只存在于 builtin **构造器**路径。builtin/engine **方法**调用的后端实参处理已是 `checkAssignable` 校验 + 子类实参自动 upcast(`CallMethodInsnGen.java:518-533`,`valueOfCastedVar`),不存在同类失败;本计划对方法调用参数统一物化形态只是为了调用参数边界的不变量一致,不是为修复方法调用。 - -### 1.2 方法引用 `token.bump` 跨作用域失效 —— 已定性为 Godot 一致行为(B1),不在本计划修复范围 - -复现结论:同作用域调用成功(构造路径完全正确),跨作用域失败(`fire()` 返回 -1)。生成的 C 证实 `arm()` 返回时三个 RefCounted 槽按 managed-locals 规则全部 release,Token 归零析构;而 Godot 标准 `Callable` 内存布局只有 `ObjectID + StringName`,结构上不可能 retain 接收者。官方 GDScript 写同样代码行为一致。规范条款见 `frontend_signal_support.md`(Callable/Signal 只保存非 owning ObjectID)与 `doc/gdcc_low_ir.md` `construct_callable`(不保活接收者)。 - -已拍板(方向 B1):保持 Godot 语义,把 sugar 用例从"gap"重新定性为 Godot 一致性验收,仅更新测试表述与补充文档说明,不改运行行为。 - ---- - -## 2. 目标与非目标 - -### 2.1 目标 - -- `Callable(customInstance, &"method")`、`Signal(customInstance, &"signal")` 及其他"声明参数为具名 object 祖先类型、实参为其子类"的 builtin **构造器**调用(元数据 `Callable` / `Signal` constructor index 2 均为 `(Object, StringName)`)能通过前端 lowering 与 C 后端 codegen,运行时行为与 Godot 一致。 -- 后端 `CBuiltinBuilder` exact-match 合同与 LIR 指令集零改动。 -- sugar 用例按 B1 重新定性为一致性验收。 - -### 2.2 非目标 - -- 不改变赋值、返回、property store、merge 写入等非调用参数边界的 `ALLOW_DIRECT` 物化行为(这些消费者已有 C 级 upcast,见 `CBodyBuilder.convertObjectValueIfNeeded`,不依赖精确类型)。 -- 不改变 Callable 不保活接收者的语义;不引入 `CallableCustom` retain 机制。 -- 不引入新的 LIR 指令、新的 boundary `Decision` 枚举值或后端 assignability 匹配。 -- 以下两条**绕过调用参数物化**的已知路径为非目标,实现时不得"顺手"修改: - - builtin 单 `Variant` 实参构造特判(`FrontendSequenceItemInsnLoweringProcessors.java:946-970`):发射 `UnpackVariantInsn`,不经过 `materializeCallArguments`,也不产生 `ConstructBuiltinInsn`;`Variant` 内装子类再构造的同类失败不在本计划范围。 - - `materializeBuiltinConstructorBoundary(...)`(`FrontendBodyLoweringSession.java:1265-1278`):产生 `ConstructBuiltinInsn` 但不经过 `materializeCallArguments`,`String <-> StringName` 专用,实参类型恒为字符串家族;若未来某条 object 边界被标为 `ALLOW_WITH_BUILTIN_CONSTRUCTOR`,须另行立项处理。 -- engine/builtin 方法调用后端已有可赋值性处理(§1.1),其细节调整不在本计划内。 - ---- - -## 3. 已拍板的设计决策 - -1. **修复手段**:方案 A——前端在调用参数边界物化目标类型 upcast temp(`AssignInsn`),不放宽后端匹配器。 -2. **生效范围**:仅调用参数边界(`FrontendBodyLoweringSession.materializeCallArguments` 的 fixed-parameter 循环),不扩散到全部 typed boundary。 -3. **问题一(跨作用域失效)**:方向 B1,保持 Godot 语义。 -4. **文档顺序**:遵守 `frontend_implicit_conversion_matrix.md` 维护合同与 materialization 单一入口合同,先改文档(矩阵 + `(un)pack` + `frontend_rules.md` 命名条款),再改代码与测试。 - ---- - -## 4. 技术设计 - -### 4.1 改动位置 - -唯一代码改动点:`FrontendBodyLoweringSession.materializeCallArguments(...)` 的 fixed-parameter 循环(当前对每个实参直接调用 `materializeFrontendBoundaryValue` 非冻结重载)。新增一个调用参数专用的物化入口,vararg 尾段(目标恒为 `Variant`,走 pack)与 `DYNAMIC` 调用(按合同绕过签名边界)保持不变。 - -### 4.2 新 helper 形态(实现时以最终代码为准) - -```java -/// Materializes one fixed call argument. Ordinary typed boundaries reuse the source slot for -/// ALLOW_DIRECT, but a fixed call operand must carry the resolved signature's declared parameter -/// type: the backend builtin CONSTRUCTOR matcher validates metadata by exact type name -/// (CBuiltinBuilder.constructRegularBuiltin). A proven object upcast is therefore materialized as -/// a target-typed temp here instead of reusing the subclass slot. Method calls already tolerate -/// subclass args backend-side (CallMethodInsnGen checkAssignable + valueOfCastedVar); they share -/// this uniform shape without behavior change. -/// -/// Note: `CallArgumentBoundaryPlan` currently carries only `fixedParameterTypes` + `isVararg` -/// (no published `Decision`), so this helper re-derives the matrix decision exactly like the -/// existing fixed-parameter loop. Once the plan starts publishing a frozen `Decision`, this -/// helper MUST switch to the frozen-decision overload and must not re-query the matrix. -private @NotNull String materializeCallArgumentBoundaryValue( - @NotNull LirBasicBlock block, - @NotNull String sourceSlotId, - @NotNull GdType sourceType, - @NotNull GdType targetType, - @NotNull String boundaryUse -) { - var decision = FrontendVariantBoundaryCompatibility.determineFrontendBoundaryDecision( - classRegistry, sourceType, targetType - ); - if (decision == FrontendVariantBoundaryCompatibility.Decision.ALLOW_DIRECT - && sourceType instanceof GdObjectType - && targetType instanceof GdObjectType - && !sourceType.equals(targetType)) { - var upcastSlotId = nextBoundaryMaterializationSlotId(boundaryUse, "upcast"); - ensureVariable(upcastSlotId, targetType); - block.appendNonTerminatorInstruction(new AssignInsn(upcastSlotId, sourceSlotId)); - return upcastSlotId; - } - return materializeFrontendBoundaryValue(block, sourceSlotId, sourceType, targetType, decision, boundaryUse); -} -``` - -要点: - -- 同类型、非对象类型、`Variant` 目标、`LiteralNull`、pack/unpack/intrinsic/builtin-constructor 等其余 decision 全部走既有路径,零行为变化。 -- 可赋值性已由矩阵决策(`ALLOW_DIRECT`)保证,helper 不再重复继承判断。 -- 形态复刻显式 cast 的 `OBJECT_UPCAST` 分支(`emitExplicitCast` 中 `new AssignInsn(resultSlotId, sourceSlotId)`),不引入新指令。 -- 冻结/非冻结重载无歧义:冻结版多一个 `Decision` 形参;fallback 调用按六参数唯一解析。 -- `ensureVariable` 对同 id 同类型幂等;temp id 含单调递增 `boundaryMaterializationCounter`,同一实参值使用两次不会产生槽冲突。 - -### 4.3 LIR 前后对比(以 `Callable(token, &"bump")` 为例) - -```text -# 修复前(后端拒绝) -$cb = construct_builtin Callable [$token: RuntimeCallableGapProbe__sub__Token, $sn: StringName] - -# 修复后 -$cfg_boundary_call_fixed_0_upcast_0: Object = assign $token -$cb = construct_builtin Callable [$cfg_boundary_call_fixed_0_upcast_0: Object, $sn: StringName] -``` - -### 4.4 生成 C 形态(全部复用现有机制) - -```c -gdcc_Object_fat_ptr $cfg_boundary_call_fixed_0_upcast_0; -// __prepare__ 先行 null 初始化(CCodegen: object 槽 LiteralNullInsn),随后被 assign 覆写; -// 槽写入对捕获到的旧 null 做一次 try_release_object(NULL, 0),为 no-op -$cfg_boundary_call_fixed_0_upcast_0 = (gdcc_Object_fat_ptr){ 0 }; -... -// upcast helper:ownership-neutral,保留 instance_id,仅转换 ptr 表示 -gdcc_Object_fat_ptr __gdcc_tmp_old_obj_N = $cfg_boundary_call_fixed_0_upcast_0; -$cfg_boundary_call_fixed_0_upcast_0 = - gdcc_RuntimeCallableGapProbe_sub_Token_fat_ptr_upcast_to_Object($token); -// 槽写入规则:借用 RHS 写入 owning 槽 -> own;精确 Object 的 RefCountedStatus 为 UNKNOWN -> 双参数 try_ 变体 -try_own_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_upcast_0), - $cfg_boundary_call_fixed_0_upcast_0.instance_id); -try_release_object(gdcc_Object_fat_ptr_live_object(__gdcc_tmp_old_obj_N), __gdcc_tmp_old_obj_N.instance_id); -// construct_builtin 实参必须是变量操作数(ConstructInsnGen.resolveConstructorArguments): -// Object 实参经 live_object 取裸指针;StringName 字面量由 LiteralStringNameInsn 先物化到槽、按地址传参 -godot_Callable __gdcc_tmp_callable_N = godot_new_Callable_with_Object_StringName( - gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_upcast_0), - &$sn); -// ... __finally__: managed local 自动清理(UNKNOWN -> 双参数 try_release),与 own 一对一平衡 -try_release_object(gdcc_Object_fat_ptr_live_object($cfg_boundary_call_fixed_0_upcast_0), - $cfg_boundary_call_fixed_0_upcast_0.instance_id); -``` - -涉及现有机制:`CBodyBuilder` assign 路径的 `convertObjectValueIfNeeded`(`checkAssignable` + 生成 upcast helper)、模板生成的 `*_upcast_to_*` helper(保留 `instance_id`)、`ownOrTryOwn` / `releaseOrTryRelease`(`try_*` 均为 `(live_ptr, fat_ptr.instance_id)` 双参数,`gdcc_helper.h`)、managed-local finally 清理(`CCodegen`)。注意 `GD_STATIC_SN(...)` 只出现在 `construct_callable` 方法引用路径,builtin 构造器的 `StringName` 实参是槽地址,二者不得混淆。 - -### 4.5 Ownership 平衡论证 - -- 临时槽按普通 `ensureVariable` 声明,走标准槽写入规则:`__prepare__` 先 null 初始化;assign 覆写时 own 新值一次、release 旧值(null,no-op)一次;函数出口按 managed-locals 规则再 release/try_release 一次。真正对象的 own/release 一对一平衡,无泄漏、无 double-free。 -- 目标为非 RefCounted 祖先(如 `Node`)时:RefCountedStatus `NO`,own/release 均不发射,temp 实为借用别名;实参先求值、随后在同一直线代码段内完成物化与调用发射,源槽存活覆盖整个区间,与现状(直接传源槽)安全性等价。 -- 目标为 RefCounted 派生具名祖先(如 `Resource`)时:status `YES`,`own_object` / `release_object` 标准配对。 -- 目标为精确 `Object` 时:status `UNKNOWN`,`try_own_object` / `try_release_object` 双参数变体,运行时按 ObjectID reference bit 判断。 -- upcast helper 保留 `instance_id`,构造出的 Callable 记录的 ObjectID 与源对象 `get_instance_id()` 完全一致,`get_object()` / 信号连接比较语义与 Godot 原生逐比特一致。 - ---- - -## 5. 分步骤实施与验收细则 - -每一步都保持可编译、可回归、可单独提交。测试命令统一使用 `script/run-gradle-targeted-tests.sh`。 - -### 步骤 0:文档先行(单独提交,三处同改)——已完成(2026-09-27) - -1. `frontend_implicit_conversion_matrix.md` - - 全局规则"任意 object subclass -> object superclass"行备注:兼容性维持 Y,物化形态加引用说明"fixed call argument 边界的物化形态例外见 `frontend_lowering_(un)pack_implementation.md` §4.2/§4.3"。 - - §9.1 `materializeFrontendBoundaryValue` 锚点处补一句:fixed call argument 的 object upcast 例外与该单一入口同层登记,不视为 consumer 私设局部分支。 - - 更新"文档状态"的更新时间与本计划文档链接。 -2. `frontend_lowering_(un)pack_implementation.md` - - §4.2 "helper 的当前合同":direct 默认仍"直接返回原 slot id";补唯一例外——fixed call argument 边界上严格 object 子类 -> 祖先,物化为 target-typed temp + `AssignInsn`。 - - §4.3 "call boundary 合同":写明该例外只发生在 `materializeCallArguments` 的 fixed 循环;vararg 与 `DYNAMIC` 不变。 -3. `frontend_rules.md` - - slot 命名条款(`:98`)补一条:boundary materialization temp(pack/unpack/null/intrinsic/builtin-constructor/call-arg upcast)使用 `cfg_boundary___`;它们不是 CFG value id,不适用 `cfg_tmp_` 规则。 - -验收:文档 diff 只涉及上述说明;无代码改动。 - -### 步骤 1:前端 helper 实现——已完成(2026-09-27) - -改动:`FrontendBodyLoweringSession.java` - -- 新增 `materializeCallArgumentBoundaryValue(...)`(§4.2 形态,含注释中的 frozen-decision 后续约束)。 -- `materializeCallArguments` fixed-parameter 循环改调新 helper;vararg 循环与 `DYNAMIC` 分支不变。 - -验收:`./gradlew classes --no-daemon --info --console=plain` 编译通过。 - -### 步骤 2:前端 lowering 单测——已完成(2026-09-27) - -改动:`FrontendLoweringBodyInsnPassTest`(或同包合适的测试类,实现时确认)。用例必须构造真实的自定义类 hierarchy(如 `Token extends RefCounted`),不能只喂两个孤立 `GdType`。 - -新增用例: - -- `Callable(token, &"bump")`(自定义子类实参)→ 断言三点:block 内出现 `AssignInsn(upcastTemp, tokenSlot)`;`ConstructBuiltinInsn` 第一个实参为该 temp;该 temp 经 `ensureVariable` 声明的类型为精确 `Object`。 -- 同类型实参(变量声明类型即 `Object`)→ 断言**不**出现 upcast temp,`ConstructBuiltinInsn` 直接引用源槽(零开销路径回归锚点)。 -- 非对象 boundary 回归抽样:`String -> StringName` 仍走 `ALLOW_WITH_BUILTIN_CONSTRUCTOR`,`int -> float` 仍走 intrinsic cast(防止 helper 改动污染其他 decision)。 - -验收:`script/run-gradle-targeted-tests.sh --tests 'FrontendLoweringBodyInsnPassTest'` 全绿。 - -### 步骤 3:后端 codegen 单测(只加测试,不改后端代码)——已完成(2026-09-27) - -改动:`CConstructInsnGenTest`,以及一个"前端实际 lowering 产物 -> codegen"的链路测试(可挂在现有 lowering-codegen 混合测试类上,实现时确认位置)。 - -- 后端单元锚点(手工 LIR,不经过前端 helper,措辞不得暗示端到端):`ConstructBuiltinInsn(Callable, [Object fat-ptr arg, StringName slot])` → 断言发射 `godot_new_Callable_with_Object_StringName(gdcc_Object_fat_ptr_live_object(...), &$sn)`,Object 实参为 live raw pointer、StringName 实参为槽地址;字面量槽初始化单独断言。自定义类 fat-ptr 需把多个 class 放进 module(参照文件内现有多 class 用例)。 -- 所有权三态锚点(对前端实际 lowering 所得 LIR 跑 codegen): - - 子类 -> 精确 `Object`(UNKNOWN):槽写入处出现**双参数** `try_own_object(live, slot.instance_id)`,`__finally__` 出现配对 `try_release_object(live, slot.instance_id)`。 - - 子类 -> `Node`(NO):upcast 槽周围不出现 own/release。 - - 子类 -> `Resource`(YES):出现精确 `own_object` / `release_object` 配对。 - - 另选一条普通方法调用(子类实参)覆盖同一 fixed 参数入口,确认方法路径功能等价(后端原有 `valueOfCastedVar` upcast 退化为同型直传)。 - -验收:`script/run-gradle-targeted-tests.sh --tests 'CConstructInsnGenTest'` 及所加链路测试类全绿。 - -### 步骤 4:翻转 `CustomReceiverCallableGapTest` 并按 B1 重定性 sugar 用例——已完成(2026-09-27) - -改动:`CustomReceiverCallableGapTest.java`、`frontend_signal_support.md` - -- `explicitCallableConstructionFromCustomInstanceFailsCodegen` 翻转为成功路径,**保持 `fakeCompiler()` 结构**(该路径本就不依赖 Zig/Godot):断言 `buildProject` 不再抛出、且不再出现 `ExtensionBuiltinClass` 失败链;测试改名(如 `explicitCallableConstructionFromCustomInstancePassesCodegen`)。 -- 运行时段(`probe_immediate == 1`、`fire() == -1`)如需覆盖显式构造,复用 sugar 用例的门闩结构(Zig 缺失时 `Assumptions.abort`,Godot 缺失由 `runner.run(true)` 内部处理),不得合成"无 Zig 则只断言 codegen"的新模式。 -- `methodReferenceOnCustomInstanceDoesNotRetainReceiver` 重命名并改写注释:从"gap 待修"改为"Godot 一致性验收";断言(同作用域通过、跨作用域 `result=-1`)保持不变。 -- `frontend_signal_support.md` 在不保活条款处补一句实证说明(复现测试名 + 结论),不改动合同本身。 - -验收:`script/run-gradle-targeted-tests.sh --tests 'CustomReceiverCallableGapTest'` 全绿。 - -### 步骤 5:受影响面回归——已完成(2026-09-27) - -- 先以文本搜索定位可能受精确 LIR 序列断言影响的测试(搜索 `ConstructBuiltinInsn`、`construct_builtin`、call 实参槽同一性断言、`materializeCallArguments` 相关断言),逐一运行核对;不要默认某个测试类会变红。 -- 若 characterization 测试因调用参数新增 upcast temp 而失败:逐一核对失败点是否确为预期形态变化,禁止为通过测试而回退实现;确属预期的按新形态更新测试并在提交说明中列出清单。 -- 最后 `./gradlew clean build --no-daemon --info --console=plain` 全量验证。 - -验收:上述命令全部通过;失败项均有明确归因(预期形态变化 or 真实回归),真实回归必须修复。 - ---- - -## 6. 边界情况清单(实现与评审时逐条核对) - -| 场景 | 预期行为 | -| --- | --- | -| source 与 target 同名(如 `Object -> Object`) | 复用源槽,无 upcast temp | -| target 为 `Variant`(vararg 或 fixed Variant 参数) | 走既有 pack 路径,不触发 upcast 分支 | -| source 为 `Variant`、target 为对象类型 | 走既有 unpack 路径 | -| null 字面量 -> 对象参数 | 走既有 `ALLOW_WITH_LITERAL_NULL` 路径 | -| `String -> StringName` 等其他 decision | 走既有路径,零变化 | -| `DYNAMIC` 调用 | 按合同绕过签名边界,实参槽原样透传 | -| engine/builtin 方法调用(如 `add_child(sprite)`) | 同样物化 upcast temp(范围内统一形态);后端原有 `checkAssignable` + `valueOfCastedVar` 退化为同型直传,功能等价,多一条 `assign` + own 对 | -| lambda capture / 参数 / merge 值作为实参 | 同一 helper 处理,ownership 由标准槽写入规则承接 | -| 目标为 GDCC 自定义祖先类 | 后端 upcast helper 经 `_super` 链转换 ptr 表示,`instance_id` 保留 | -| 单 `Variant` 实参构造 / `String <-> StringName` 边界构造 | 不经过本 helper,保持现状(已知非目标,§2.2) | - ---- - -## 7. 风险与回滚 - -- R1(中):characterization 测试因调用参数新增 temp 出现精确序列失败 → 预期内,按步骤 5 的搜索驱动流程核对更新。 -- R2(低):upcast temp ownership 不平衡 → 由 §4.5 论证 + 步骤 3 三态锚点 + 步骤 4 运行时测试三重兜底;`AssignInsn` 复用现有槽写入语义,无新机制。 -- R3(低):helper 误伤其他 decision 路径 → 步骤 2 的回归抽样锚定。 -- R4(低):`boundaryUse` 命名冲突 → temp 名含 `boundaryUse` 与自增计数,与现有 pack/unpack temp 同机制;命名合同在步骤 0 补齐。 -- 回滚:代码改动集中于单个 helper 与一处调用点,直接 revert 对应提交即可;文档改动随提交一并 revert。 - ---- - -## 8. 总体验收清单(DoD) - -- [x] 三处文档(矩阵、`(un)pack`、`frontend_rules.md` 命名条款)已先于代码更新 -- [x] `Callable(token, &"bump")`、`Signal(token, &"s")` 等子类实参 builtin 构造调用全链路通过 -- [x] 后端 `CBuiltinBuilder`、LIR 指令集、`Decision` 枚举零改动 -- [x] 同类型 / 非对象 / `Variant` 目标的既有路径零行为变化(有测试锚点) -- [x] ownership 三态(UNKNOWN/NO/YES)均有 codegen 锚点 -- [x] sugar 用例按 B1 重新定性,`frontend_signal_support.md` 补充实证说明 -- [x] 步骤 0-5 的验收命令全部通过 diff --git a/doc/module_impl/frontend/frontend_implicit_conversion_matrix.md b/doc/module_impl/frontend/frontend_implicit_conversion_matrix.md index 7093f0e5..882b2603 100644 --- a/doc/module_impl/frontend/frontend_implicit_conversion_matrix.md +++ b/doc/module_impl/frontend/frontend_implicit_conversion_matrix.md @@ -19,7 +19,7 @@ - `frontend_type_check_analyzer_implementation.md` - `frontend_unary_binary_expr_semantic_implementation.md` - `frontend_lowering_cfg_pass_implementation.md` - - `frontend_call_argument_object_upcast_plan.md` + - `frontend_call_argument_object_upcast_implementation.md` - `doc/gdcc_type_system.md` - 主要事实来源: - Godot `GDScriptAnalyzer::check_type_compatibility(...)` diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendBodyLoweringSession.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendBodyLoweringSession.java index bf30cdda..fe0c8475 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendBodyLoweringSession.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendBodyLoweringSession.java @@ -1463,8 +1463,6 @@ boolean isTargetFunctionCoroutine() { /// method routes share the same argument-type invariant even though their backend could /// upcast on its own. Only the fixed loop above routes here: vararg tails always target /// `Variant` and `DYNAMIC` calls forward slots unchanged, so neither can trigger this shape. - /// The shape is re-derived at lowering time today; once call-argument plans publish frozen - /// boundary decisions, this helper must consume them instead of re-querying the registry. private @NotNull String materializeCallArgumentBoundaryValue( @NotNull LirBasicBlock block, @NotNull String sourceSlotId,