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_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_implicit_conversion_matrix.md b/doc/module_impl/frontend/frontend_implicit_conversion_matrix.md index 319c6935..882b2603 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_implementation.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..f63397f1 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;该 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` @@ -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 里有没有某个节点名”: 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..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 @@ -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,40 @@ 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. + 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/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,