diff --git a/doc/gdcc_c_backend.md b/doc/gdcc_c_backend.md index 5c12f558..b4ea247a 100644 --- a/doc/gdcc_c_backend.md +++ b/doc/gdcc_c_backend.md @@ -113,7 +113,7 @@ Usage and lifecycle rules: - Primitive types are always by value. - Object types are always internal fat pointers (raw pointers only at ABI/layout/helper edges). - Only other built-in types change C type shape based on `ref`. -- For `String`, `StringName`, `NodePath`, `Callable`, `Signal`, `Packed*Array`: +- For `String`, `StringName`, `NodePath`, `Callable`, `Signal`: - They are value-semantic wrapper structs that hold opaque engine-side state. - Their C type shape follows the `ref` rule above: - `ref=true` variable is a pointer to the wrapper struct. @@ -132,6 +132,15 @@ Usage and lifecycle rules: prematurely release the same engine-side state that the slot now refers to - once a stable carrier has been consumed by the slot, it must not enter the ordinary temp-destroy path again - When a value of these types are no longer used, call `godot_destroy_(TypeName* value)` to destroy them properly. +- For `Packed*Array` (all 10 families), the canonical storage is Variant-backed, NOT a wrapper struct: + - Storage/parameter/return C types are `godot_Variant` / `godot_Variant*` (internal ABI); the shared engine-side + `PackedArrayRef` gives Godot4 reference semantics (aliases observe each other's mutations). + - Copy is always `godot_new_Variant_with_Variant(...)`; destroy is always `godot_Variant_destroy(...)`. + - Builtin method calls take the receiver/arguments through the cached per-family internal pointer getter + (`gdcc_packed__internal_ptr`); packed return values arrive as a native temp struct that is immediately + wrapped into a Variant (`gdcc_packed__wrap_temp`) and the temp is destroyed. + - The only legal struct boundaries are the whitelisted named helpers in `gdcc_packed_ref.h` + (see "Packed*Array Variant-backed Storage" below for the full contract). - For `Dictionary`, `Array` and `Variant`: - They are wrapper structs with shared/ref-counted internals (not raw C pointers). - Their C type shape also follows the `ref` rule above: @@ -169,6 +178,48 @@ Usage and lifecycle rules: `gdcc_object_from_godot_object_ptr(...)` if a wrapper pointer is required, then capture ID into `gdcc__fat_ptr` via `_fat_ptr_from_raw(...)` for internal use. +### Packed*Array Variant-backed Storage + +- Canonical storage: every packed slot (locals, parameters, instance/static fields, coroutine frame fields, + lambda captures, signal arguments) is a `godot_Variant` holding a shared engine-side `PackedArrayRef`. + This matches the Godot 4.5 interpreter: `Packed*Array` has reference (shared) semantics at the language level. +- Core invariant: gdcc-held packed values must never round-trip through struct pack/unpack + (`godot_new_Packed*Array_with_*` / `godot_new_Variant_with_Packed*Array`) outside the whitelisted boundaries + below. Identity-carrying operations (assignment, aliasing, parameter passing, signals, lambda/coroutine + captures, call_func wrapper transit) use only `godot_new_Variant_with_Variant(...)`. The single exception is + the same-family `as` cast, which deliberately produces an independent COW copy through whitelist (d) + (`new_copy`) instead of sharing identity (see below). +- The whitelisted struct boundaries are centralized as named helpers in `include_451/gdcc/gdcc_packed_ref.h`: + - (a) ptrcall ABI boundary, both directions: inbound `gdcc_packed__variant_from_struct` materializes the + raw struct argument slot into a Variant (destroyed after the call); outbound `gdcc_packed__struct_from_variant` + copies the returned Variant back into the caller's raw struct slot. + - (b) empty construction: `gdcc_packed__new_empty` builds the mandatory empty-array Variant default + (a nil Variant has no internal value pointer and would fail method calls). + - (c) builtin-method native return temps: `gdcc_packed__wrap_temp` wraps the temp struct into a new + Variant and destroys the temp (e.g. `duplicate`, `slice` results). + - (d) explicit constructors: `gdcc_packed__new_copy` (same-family, produces an independent new array, + also used by same-family `as` casts) and `gdcc_packed__new_from_array` (cross-type from `Array`). +- Generated code outside `gdcc_packed_ref.h` must not call `godot_new_Packed*Array_with_*`, + `godot_new_Variant_with_Packed*Array`, or bare `godot_new_Packed*()`; `CCodegenTest` enforces this ban by + scanning all generated artifacts. +- Method receivers: the builtin wrapper signatures are unchanged; the call site passes the internal value pointer + obtained from the cached per-family `GDExtensionVariantGetInternalPtrFunc` getter (resolved once per + translation unit by `gdcc_packed_ref_init()`, fail-fast when unavailable). +- Operators: packed operands take the internal pointer, non-packed operands keep the evaluator's native ABI shape; + packed results are produced in a native temp struct and wrapped into a new Variant. `+`/`+=` must NOT be + optimized into an in-place `append_array` on the internal pointer (that would leak the mutation to old aliases; + `+=` is new-array + rebind). +- Documented exception (engine ABI limit, accepted): the **ptrcall ABI boundary** does not preserve identity. + Packed ptrcall parameters arrive as raw struct slots, so the callee materializes struct->Variant (a Vector-level + copy) and mutations stay isolated from the caller; packed ptrcall returns are copied Variant->struct on the way + out. Ordinary GDScript<->GDExtension calls go through `call_func` (Variant ABI) and DO preserve identity; only + ptrcall extension-to-extension paths are affected. Locked by runtime tests, see + `PackedRefStorageModelSmokeTest.ptrcallBoundaryShouldIsolateCallerIdentityInBothDirections` and the detailed + rationale in `module_impl/backend/packed_array_implementation.md`, which links back here. +- Documented behavior change: mutating calls on builtin engine properties (e.g. `poly.polygon.push_back(x)`) + are no longer written back, matching the interpreter (the getter returns a copy). Assignment routes on the same + property (`poly.polygon = p`, `poly.polygon[0] = v`) still write back (read-modify-write persists). + ### Object Value Representation (Mandatory) - Internal object storage, parameters, and returns always use `gdcc__fat_ptr` once the static type is known. @@ -327,6 +378,7 @@ Usage and lifecycle rules: - this helper only answers the receiver-side runtime writeback gate for runtime-open `Variant` carriers - it does not participate in callable resolution, receiver provenance, or owner-route reconstruction - its false/true family matrix is owned by `gdcc_type_system.md` and `gdcc_helper.h`; backend must not drift into a second independent classification table + - all 10 packed kinds are explicitly listed as `false` (Variant-backed shared identity needs no writeback); unlisted future kinds keep the frozen default-`true` answer ### Variant Outward ABI Contract @@ -589,6 +641,10 @@ Transform2D(1, 0, 0, 1, 0, 0), RID(), -99, "000000000000000000000000000000000000 - Regular builtin constructors are selected by exact `ExtensionBuiltinClass` constructor metadata after frontend lowering has materialized any accepted argument boundary. The generated symbol is `godot_new_[_with_...]`. + - `Packed*Array` constructors do NOT use `godot_new_Packed*` symbols; they route to the whitelisted + `gdcc_packed_ref.h` helpers: zero-arg and default values -> `gdcc_packed__new_empty`, same-family + argument -> `gdcc_packed__new_copy` (independent new array), `Array` argument -> + `gdcc_packed__new_from_array`. Other combinations fail closed on constructor metadata validation. - `Transform2D`, `Transform3D`, `Basis`, and `Projection` may use GDCC-owned helper-shim constructor signatures when Godot API metadata has no exact constructor surface but the binding naming contract already exposed the helper. diff --git a/doc/gdcc_lir_intrinsic.md b/doc/gdcc_lir_intrinsic.md index 2f6963f6..ba1ac727 100644 --- a/doc/gdcc_lir_intrinsic.md +++ b/doc/gdcc_lir_intrinsic.md @@ -767,7 +767,7 @@ $ = call_intrinsic "gdcc.for_packed__iter.get" $; - `should_continue`:result `bool`;arg0 为对应 state。 - `next`:result/arg0 均为对应 state。 - `get`:result 为 **typed element**(`int` / `float` / `String` / `Vector*` / `Color`),不是 `Variant`;arg0 为对应 state。 -- state **不可**直接 struct 赋值;`copy` helper 为 `gdcc_for_packed__iter_copy`(COW 句柄 + 共享 typed base pointer)。 +- state **不可**直接 struct 赋值;`copy` helper 为 `gdcc_for_packed__iter_copy`(Variant 持有者拷贝 + index,共享源数组身份)。 C backend 语义: @@ -778,10 +778,17 @@ $target = gdcc_for_packed__iter_next(&$iter); $target = gdcc_for_packed__iter_get(&$iter); ``` -边界语义: +边界语义(活迭代,对齐 Godot 4.5 解释器): -- `from` 深拷贝 typed Packed*Array(COW),缓存 size/index,并缓存 typed 元素基址指针;snapshot 只读,故缓存基址安全。 -- `get` 对 typed 基址做指针算术并返回 **typed element**(无 kind switch,无 per-element Variant 装箱)。 +- state 持有源数组的 **Variant 持有者拷贝**(共享引擎侧 `PackedArrayRef` 身份)+ 当前 index;**不**持有 + COW struct 快照、不缓存 size、不缓存 typed 元素基址指针。 +- `from` 接收 `const godot_Variant*`(packed 源本身即 Variant-backed),做持有者拷贝并置 index=0。 +- `next` 仅做持有者拷贝并递增 index;禁止复用任何快照式 copy(会把迭代锚定到 detach 后的数组上)。 +- `should_continue` 每次经 `gdcc_packed__internal_ptr` 求 **live size**:迭代期间 `push_back` 的新元素 + 会被本轮循环访问(解释器锁定的活迭代合同)。 +- `get` 每次先以 live size 做越界检查(越界返回 family 默认值),再经内部指针 + `operator_index_const` + 取 **typed element**(无 kind switch,无 per-element Variant 装箱);禁止跨迭代缓存元素基址——mutation + 可能 realloc,缓存指针会悬垂。 - lowering 的 `ForLoopGetItem` 在 element 与 `exposedIteratorType` 兼容时可直接赋值,通常无需 `UnpackVariant`。 ### `gdcc.for_float_iter.init` diff --git a/doc/gdcc_low_ir.md b/doc/gdcc_low_ir.md index 604f8bc0..8bba99dd 100644 --- a/doc/gdcc_low_ir.md +++ b/doc/gdcc_low_ir.md @@ -145,6 +145,11 @@ $ = assign $ Constructs a builtin of a specific type with arguments. The type is the same as the type of the result variable. +For `Packed*Array` result types the backend does not emit `godot_new_Packed*` symbols; construction +routes to the whitelisted `gdcc_packed_ref.h` helpers (zero-arg -> `gdcc_packed__new_empty`, +same-family argument -> `gdcc_packed__new_copy`, `Array` argument -> +`gdcc_packed__new_from_array`), and the result is a Variant-backed packed value. + ``` $ = construct_builtin $ $ ... ``` @@ -162,6 +167,9 @@ Rules: - If result variable type is `Packed*Array` (`GdPackedArrayType`): - Construction type is inferred only from the result variable type. - `class_name` must not be provided; providing it is invalid and should fail fast. + - The result is a Variant-backed packed value (empty-array `godot_Variant` via + `gdcc_packed__new_empty`), never a bare packed struct; this is also the mandatory + default initialization for every packed slot. ``` $ = construct_array ""? @@ -295,7 +303,8 @@ Types can be destruct: - Signal - Dictionary - Array -- Packed*Array +- Packed*Array (Variant-backed storage: destruction is `godot_Variant_destroy(...)`, releasing this + holder's share of the engine-side `PackedArrayRef`; there is no packed struct destructor) - Object - Variant - `compiler::GdccCoroState` (destruction releases the owned state object reference — @@ -476,7 +485,12 @@ Runtime builtin conversion for GDScript `as` when the target is a non-Object, no non-Nil runtime builtin (including parameterized `Array[T]` / `Dictionary[K, V]`). The same as Godot `Variant::construct` / `can_convert` semantics at the backend. Parameterized containers keep full declared type text. -Result is required. Exact same-type and `as Variant` use `assign` / `pack_variant` instead. +Result is required. Exact same-type and `as Variant` use `assign` / `pack_variant` instead, +with one exception: a same-family `Packed*Array` `as` cast (e.g. `v as PackedInt32Array` where the +source is statically or dynamically `PackedInt32Array`) is NOT an identity-preserving assign — it +produces a COW copy with a fresh identity (`gdcc_packed__new_copy`), matching the Godot 4.5 +interpreter; the frontend therefore routes it here instead of `assign`. Packed `is` tests are exact +`Variant.get_type()` family matches (`gdcc_packed_ref_is`). ``` $ = builtin_cast "" $ diff --git a/doc/gdcc_ownership_lifecycle_spec.md b/doc/gdcc_ownership_lifecycle_spec.md index 2cc19ae1..2b6a3e2b 100644 --- a/doc/gdcc_ownership_lifecycle_spec.md +++ b/doc/gdcc_ownership_lifecycle_spec.md @@ -89,6 +89,11 @@ Implementation note: - destroy old value when required, - then assign. - Such consolidation is a structural refactor and must not change copy/destroy semantics by itself. +- `Packed*Array` slots are Variant-backed: the copy step is `godot_new_Variant_with_Variant(...)` (a holder copy + that SHARES the engine-side `PackedArrayRef` identity with the source), and the destroy step is + `godot_Variant_destroy(...)` (releases this holder). They must never be shallow-copied as plain structs and + never round-trip through packed struct pack/unpack outside the whitelisted `gdcc_packed_ref.h` helpers + (see `gdcc_c_backend.md` "Packed*Array Variant-backed Storage"). ### 3.3 Overwrite vs First Write @@ -123,6 +128,9 @@ Implementation note: - Discarding an `OWNED` object return value: must immediately `release` (or `try_release` variant). - Discarding a `BORROWED` object value: no cleanup required. - For non-object but `isDestroyable()==true` return values (String/Variant/Container, etc.), discarding must immediately `destroy`. + - `Packed*Array` temporaries are Variant carriers: discard destroys them via `godot_Variant_destroy(...)` + (builtin-method native return structs are already consumed by `gdcc_packed__wrap_temp`, which + destroys the temp struct at the wrap site). ### 3.6 RefCounted Status Matrix @@ -251,6 +259,10 @@ Parameters of a coroutine function: ones copied). Parameter fields are destroyed exactly once, by `free_instance` — the cancel path never touches them (after cancel-resume the coroutine is `MCO_DEAD` and flows into the same single `free_instance` cleanup). +- Packed parameter/capture/return fields store `godot_Variant`: "copied" means a Variant holder copy + (`godot_new_Variant_with_Variant`) that shares the packed identity with the caller across `await` + suspension, and destruction is `godot_Variant_destroy(...)`. A struct copy-construct here would break + the interpreter-observable sharing. Captures of a coroutine lambda follow the same per-call frame discipline: @@ -259,12 +271,15 @@ Captures of a coroutine lambda follow the same per-call frame discipline: them. Body capture operands map directly to frame fields. - The start thunk copies each field out of the Callable-owned capture block before `mco_create` (primitives assigned, objects retained from a BORROWED source, value types - copy-constructed); the capture block itself remains owned solely by the Callable userdata + copy-constructed, packed arrays Variant-holder-copied); the capture block itself remains owned solely by the Callable userdata and is freed independently by its `free_func` — releasing the Callable while suspended therefore never invalidates the frame. - Capture fields are destroyed exactly once, by `free_instance` after the parameter fields; the cancel path flows into the same single cleanup. Writes to a capture name inside the - lambda hit only that call's frame copy, matching copy-on-capture semantics. + lambda hit only that call's frame copy, matching copy-on-capture semantics. For packed captures, + copy-on-capture copies the Variant HOLDER, not the underlying packed data: the frame field and the + outer variable share the same packed identity, so mutations (not rebindings) are visible in both + directions — identical to interpreter lambda capture behavior. Return-value storage state machine (must not be violated): @@ -321,6 +336,9 @@ Cancel-resume (abandonment path, e.g. emitter death dropping the last reference) - Keep `__prepare__` / `__finally__` framework unchanged. - `_return_val` is still generated and managed by `CBodyBuilder`, and must not be moved into variable-table auto-destruction. - Property initializer lowering may materialize helper-produced values, but constructor-time application of those values to backing fields remains a separate backend-owned route. + - Packed backing fields are Variant carriers: the initializer produces the packed value through the whitelisted + `gdcc_packed_ref.h` construction helpers (empty / same-family copy / from-`Array`), and the field write is an + ordinary Variant-holder slot write. - Coroutine body functions reuse the same `__prepare__` / `__finally__` framework unchanged; coroutine frame fields (typed parameter fields, typed return slot) are not ordinary C local slots and stay outside the variable-table auto-destruction scope, exactly like `_return_val`. @@ -338,11 +356,18 @@ Cancel-resume (abandonment path, e.g. emitter death dropping the last reference) - local non-`void` return carrier `r` - Cleanup rule for those locals is value-wrapper specific: - destroyable non-object wrappers must be explicitly destroyed before the wrapper returns + - packed argument locals are Variant copies (`godot_new_Variant_with_Variant`) that share identity with the + caller, and are destroyed via `godot_Variant_destroy(...)`; no struct unpack/pack is involved, which is what + makes callee mutations visible to the GDScript caller - `OWNED` object return carrier `r` must be released after Variant packing (`release_object` / `try_release_object` per `RefCountedStatus`) so internal ownership transfers net-zero into `r_return` - object argument locals are BORROWED from Variant args and must not be released here - primitives never need wrapper cleanup +- The ptrcall wrapper is the documented exception boundary: packed ptrcall arguments are materialized + struct->Variant (`gdcc_packed__variant_from_struct`, destroyed after the call) and packed ptrcall + returns are copied Variant->struct (`gdcc_packed__struct_from_variant`), so identity is NOT shared + across the ptrcall ABI (see `gdcc_c_backend.md` "Packed*Array Variant-backed Storage"). - Required success-path order: 1. publish `r_return` 2. destroy local `ret` diff --git a/doc/gdcc_runtime_lib.md b/doc/gdcc_runtime_lib.md index 4fc96e4f..ce2dfb70 100644 --- a/doc/gdcc_runtime_lib.md +++ b/doc/gdcc_runtime_lib.md @@ -75,14 +75,28 @@ extend the runtime-provided `godot_*` surface. deliberate safer divergence — mutations to the dictionary after the snapshot do not affect the iteration sequence. - `intrinsic/for_packed_array_iter.h`: per-family Packed*Array iterator structs (no kind union). - Each family owns a typed COW snapshot + typed element base pointer; `get` returns the typed - element without runtime family dispatch. + Each family state holds a Variant holder COPY of the source (shared identity) + current index — + live iteration: `should_continue` re-evaluates live size every step (appends during iteration are + visited), `get` bounds-checks against live size and resolves the element via `operator_index_const` + on each access (no cached size, no cached typed base pointer). - these helpers are GDCC-owned runtime support and must keep the `gdcc_*` namespace instead of pretending to be generated `godot_*` wrappers +- `gdcc_packed_ref.h`: the Packed*Array Variant-backed storage helpers (included by `gdcc_helper.h` + before `gdcc_intrinsic.h`). It owns the per-family cached `GDExtensionVariantGetInternalPtrFunc` + getters (`gdcc_packed_ref_init()`, fail-fast when unavailable), the internal-pointer accessor + `gdcc_packed__internal_ptr`, Variant holder copy/destroy aliases, the `gdcc_packed_ref_is` + exact family check, and the whitelisted struct<->Variant boundary helpers (`new_empty`, `wrap_temp`, + `variant_from_struct` / `struct_from_variant` for the ptrcall ABI boundary, `new_copy`, + `new_from_array`). Generated code must route every packed struct boundary through these named + helpers; direct `godot_new_Packed*Array_with_*` / `godot_new_Variant_with_Packed*Array` / bare + `godot_new_Packed*()` calls outside this header are banned (see `gdcc_c_backend.md` + "Packed*Array Variant-backed Storage"). - `gdcc_helper.h`: the aggregate helper header included by generated entry code. It provides runtime error printing, Object property get/set helpers, RefCounted ownership helpers, GDCC wrapper pointer conversion helpers, compatibility constructors, UTF-8 formatting helpers, - Variant type guards, GDScript `is` type-test helpers, Variant writeback classification and + Variant type guards, GDScript `is` type-test helpers, Variant writeback classification + (`gdcc_variant_requires_writeback`: all 10 packed kinds explicitly `false`, unlisted future kinds + keep the frozen default-`true` answer — matrix owned by `gdcc_type_system.md`) and `godot_Variant_call(...)`. It also pulls in sibling headers such as `gdcc_callable.h` and, immediately after the `GDCC_PRINT_RUNTIME_ERROR` macro definition, `gdscript_builtins.h`. - `gdscript_builtins.h`: GDScript language-level builtins (header-only, `static inline`). These @@ -92,8 +106,9 @@ extend the runtime-provided `godot_*` surface. through the shared `GDCC_PRINT_RUNTIME_ERROR` channel (`NULL` message falls back to a fixed "Assertion failed." text; otherwise the String message is converted to UTF-8 and prefixed). The caller owns the default-return edge; the helper only reports. - - `gdcc_len(value)`: Godot 4.5 `len()` semantics — dynamic Variant dispatch that unpacks a - temporary payload copy, forwards to the matching per-type helper, and destroys the copy. The + - `gdcc_len(value)`: Godot 4.5 `len()` semantics — dynamic Variant dispatch to the matching + per-type helper; packed kinds forward the internal value pointer directly (no payload copy), + other payload kinds unpack a temporary copy and destroy it. The per-type helpers `gdcc_len_string` / `gdcc_len_string_name` / `gdcc_len_array` / `gdcc_len_dictionary` / `gdcc_len_packed_*_array` take the concrete payload pointer and may be called directly by the intrinsic channel when the argument type is statically known. Any other diff --git a/doc/gdcc_type_system.md b/doc/gdcc_type_system.md index 77ae728f..1011d57a 100644 --- a/doc/gdcc_type_system.md +++ b/doc/gdcc_type_system.md @@ -96,6 +96,16 @@ - "Can this type be used as the `self` operand of a particular `variant_set_*` codegen path?" - Therefore backend generators such as `IndexStoreInsnGen` are not the truth source of this rule. They may consume or mirror it locally, but they must not define it. +Receiver families split into two storage categories: + +- value-semantic families (by-value struct storage): + - primitive family + - instance-call-capable builtin value families: `String`, `StringName`, `NodePath`, `Color`, `Vector*`, `Basis`, `Transform*`, `Quaternion`, `Rect*`, `Plane`, `AABB`, `Projection`, `Callable`, `Signal`, `RID` +- shared/reference families (mutation travels through the shared identity of the carrier itself, so outer-owner writeback is not the semantic carrier): + - `Object` family + - `Array`, `Dictionary` + - all 10 `Packed*Array` families: they store as `godot_Variant` in generated C, sharing the engine-side `PackedArrayRef` with every alias (Variant-backed reference semantics, aligned with the Godot 4.5 interpreter) + The current shared rule is: - does not require writeback: @@ -103,7 +113,8 @@ The current shared rule is: - `Object` family - shared/reference container family (`Array`, `Dictionary`) - requires writeback: - - other instance-call-capable value-semantic builtin families, including packed arrays + - instance-call-capable value-semantic builtin families (`String`, `Vector*`, `Color`, etc.) +- `Packed*Array`: decided per route provenance, not per family (see the matrix below) The intended interpretation is: @@ -113,20 +124,38 @@ The intended interpretation is: - mutation happens through reference identity, so outer-owner writeback is not the semantic carrier - shared/reference container family: - `Array` / `Dictionary` ownership is not modeled as "mutate leaf then commit into owner" in the same way as value-semantic builtin structs -- value-semantic builtin families such as `String`, `StringName`, `NodePath`, `Color`, `Vector*`, `Basis`, `Transform*`, `Quaternion`, `Rect*`, `Plane`, `AABB`, `Projection`, `Callable`, `Signal`, `RID`, `Packed*Array`: +- value-semantic builtin families such as `String`, `StringName`, `NodePath`, `Color`, `Vector*`, `Basis`, `Transform*`, `Quaternion`, `Rect*`, `Plane`, `AABB`, `Projection`, `Callable`, `Signal`, `RID`: - if a mutating call targets a leaf reached through property/subscript/nested access, the leaf may need reverse writeback to preserve Godot-observable behavior +- `Packed*Array` (Variant-backed shared identity): + - family membership alone does not decide writeback; the answer depends on the route provenance + (`FrontendWritableTypeWritebackSupport.WritebackRouteProvenance`): + +| route provenance | packed answer | rationale | +|---|---|---| +| `DIRECT_SLOT` (local-var snapshot) | `false` | the snapshot is a Variant holder copy sharing identity; mutation is visible without a commit step | +| `STATIC_PROPERTY` (bare static leaf) | `false` | the static slot stores the shared Variant holder itself; no promotion step is appended | +| `ENGINE_PROPERTY_CALL` (mutating call on a builtin engine property, e.g. `poly.polygon.push_back(...)`) | `false` | the engine getter returns a copy, so the Godot interpreter does not persist the mutation either; gdcc must match it | +| `SCRIPT_PROPERTY` | `true` | kept: a redundant same-identity store-back, harmless | +| `CONTAINER_ELEMENT` | `true` | kept: a redundant same-identity store-back, harmless | +| `GENERIC` (assignment routes, e.g. `poly.polygon[0] = v`, `poly.polygon = p`) | `true` | read-modify-write assignment contract: the engine setter must receive the updated array | + +- Assignment (`=`) routes always consult the `GENERIC` provenance regardless of the call-route split above. +- Direct-slot snapshot commit has a dedicated gate, `FrontendWritableTypeWritebackSupport.requiresDirectSlotSnapshotCommit(...)`, + which exempts only packed carriers; other families keep the historical publication behavior. For static typing: - frontend/shared semantic should answer this rule from `GdType` family information and published semantic facts -- public code anchor: `FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType(...)` +- public code anchors: `FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType(...)` (route-provenance aware) and `requiresDirectSlotSnapshotCommit(...)` - frontend writable-route lowering should first use the static shortcut: - - statically known shared/reference families skip the current writeback layer directly + - statically known shared/reference families (`Object`, `Array`, `Dictionary`) skip the current writeback layer directly + - `Packed*Array` does NOT use the family shortcut: its answer always comes from the route-provenance matrix above (`false` for `DIRECT_SLOT`/`STATIC_PROPERTY`/`ENGINE_PROPERTY_CALL`, `true` for `SCRIPT_PROPERTY`/`CONTAINER_ELEMENT`/`GENERIC`) - statically known value-semantic families apply the current layer directly - dynamic/`Variant` receiver routes are the only remaining runtime-open branch, so they must defer to the runtime helper `gdcc_variant_requires_writeback(...)` - the helper contract is currently frozen as: - returns `false` for `NIL`, `BOOL`, `INT`, `FLOAT`, `ARRAY`, `DICTIONARY`, `OBJECT` - - returns `true` for `String`, `StringName`, `NodePath`, `Vector*`, `Rect*`, `Plane`, `Quaternion`, `AABB`, `Basis`, `Transform*`, `Projection`, `Color`, `RID`, `Callable`, `Signal`, `Packed*Array` + - returns `false` for all 10 packed kinds: `PACKED_BYTE_ARRAY`, `PACKED_INT32_ARRAY`, `PACKED_INT64_ARRAY`, `PACKED_FLOAT32_ARRAY`, `PACKED_FLOAT64_ARRAY`, `PACKED_STRING_ARRAY`, `PACKED_VECTOR2_ARRAY`, `PACKED_VECTOR3_ARRAY`, `PACKED_COLOR_ARRAY`, `PACKED_VECTOR4_ARRAY` + - returns `true` for `String`, `StringName`, `NodePath`, `Vector*`, `Rect*`, `Plane`, `Quaternion`, `AABB`, `Basis`, `Transform*`, `Projection`, `Color`, `RID`, `Callable`, `Signal` - returns `true` by default for unlisted future `Variant` kinds, so a newly introduced value-semantic carrier cannot silently tunnel through runtime-gated writeback as a false negative If this matrix changes, the following fact sources must be updated together: diff --git a/doc/module_impl/backend/cbodybuilder_implementation.md b/doc/module_impl/backend/cbodybuilder_implementation.md index f0edaab7..cee6249e 100644 --- a/doc/module_impl/backend/cbodybuilder_implementation.md +++ b/doc/module_impl/backend/cbodybuilder_implementation.md @@ -48,6 +48,11 @@ - 这个 carrier 被 slot consume 后不会再进入普通 temp destroy 路径 - 这条约束的核心不是“总要造 temp”,而是“不允许生成 `copy temp -> 裸 = 写槽 -> destroy temp`”。 后者只有浅层 struct 赋值,销毁 temp 会提前释放 slot 刚接管的底层状态。 +- `Packed*Array` 槽位是上述模型的 Variant-backed 形态:copy helper 解析为 + `godot_new_Variant_with_Variant`(持有者拷贝,共享引擎侧 `PackedArrayRef` 身份),destroy 解析为 + `godot_Variant_destroy`;stable-carrier 保守路径保留(Variant 拷贝 carrier 在持有者语义下依然正确, + 且 String/Vector 等 struct 槽仍依赖该路径)。生成代码不得再出现白名单 + `gdcc_packed_ref.h` helper 之外的 packed struct<->Variant 转换符号。 - `markTargetInitialized(...)` 与 temp 生命周期仍由调用方控制(未内聚到槽位写入 helper)。 - constructor-time property initializer apply 当前通过 `CCodegen#generatePropertyInitApplyBody(...)` 调用 direct backing-field helper: - `${Class}_class_apply_property_init_(self)` @@ -155,7 +160,9 @@ - owned source -> `_return_val` consume - 当返回值来自普通本地 owning object slot 时,Builder 会把该 slot move 到 `_return_val` 并清空源槽,避免 `__finally__` auto-destruction 释放已发布的返回对象。 parameter、`ref` alias、capture,以及 field/property 这类非 slot expression 不参与 move-return,而是继续走 `_return_val` retain。 -- 非对象返回槽目前保持 direct assignment(不走 `emitNonObjectSlotWrite`)。 +- 非对象返回槽目前保持 direct assignment(不走 `emitNonObjectSlotWrite`);其中 packed 返回槽的写入经 + Variant 持有者拷贝完成(copy helper 名映射为 `godot_new_Variant_with_Variant`),builtin 方法产生的 + packed 原生临时 struct 先经 `wrap_temp` 落入 Variant 槽再参与返回流转。 - `_return_val` 不属于变量表 auto-cleanup 集合;它是 return publish 边界,而不是普通 local slot。 - `CCodegen` 的 `__finally__` auto-destruction 目前只覆盖: - value-semantic destroyable locals @@ -196,9 +203,11 @@ - 现状:setter-self 分支已收敛到 `assignVar(targetOfExpr(...), valueOfVar(...))`, 通过 Builder 统一槽位写入语义处理生命周期和指针转换。 -- 对 value-semantic backing field,这条路径现在固定生成 +- 对 value-semantic backing field(不含 packed),这条路径现在固定生成 `self->field = godot_new__with_(source_ptr)`, 不再残留“copy temp 写槽后再 destroy temp”的生命周期泄漏形状。 +- packed backing field 是 Variant 槽:写入解析为 `godot_new_Variant_with_Variant` 持有者拷贝 + + `godot_Variant_destroy`,不经过任何 packed struct 转换符号。 - 收敛收益: - 不再需要在生成器里手工拼接 own/release。 - 对象写槽顺序与 `assignVar` / `callAssign` / `_return_val` 保持一致。 diff --git a/doc/module_impl/backend/construct_array_implementation.md b/doc/module_impl/backend/construct_array_implementation.md index 51fcff79..a1888620 100644 --- a/doc/module_impl/backend/construct_array_implementation.md +++ b/doc/module_impl/backend/construct_array_implementation.md @@ -35,6 +35,9 @@ - 不接受 `class_name`。 - 构造目标完全由结果变量类型决定(如 `PackedInt32Array`、`PackedVector3Array`)。 - 若传入 `class_name`(包含空白字符串),直接 fail-fast。 + - 生成物为 Variant-backed packed 值:经 `gdcc_packed__new_empty()` 产出空数组 `godot_Variant` + (引用语义存储,严禁 nil Variant);默认值表达式路径(`__prepare__`、默认参数、协程默认值)同样走 + `new_empty`。 ### 已实现自动注入路径 @@ -63,7 +66,9 @@ - `construct_array` 在构造 `Packed*Array` 时,仅根据结果变量类型构造。 - 当结果变量类型是 `Packed*Array` 时,`class_name` 操作数不允许出现;出现即视为 IR 错误并 fail-fast。 -- `ConstructBuiltinInsn` 保持现有行为,不做语义或实现改动。 +- `ConstructArrayInsn` 与带实参的 `ConstructBuiltinInsn` 对 packed 共用 `constructPackedArray` 专用路径 + (`ConstructInsnGen` 统一经 `CBuiltinBuilder.constructBuiltin(...)` 进入),发射形状以 + `gdcc_packed_ref.h` 白名单 helper 为准;不再存在 packed 的 regular-builtin 构造路径。 ### 2. 下游代码路由 @@ -72,14 +77,20 @@ - `CBuiltinBuilder.constructBuiltin(...)` 的实际分发为: - `GdArrayType` -> `constructArray(...)`(容器专用 typed 路径) - `GdDictionaryType` -> `constructDictionary(...)`(容器专用 typed 路径) - - 其他类型(包含 `GdPackedArrayType`)-> `constructRegularBuiltin(...)`(通用 builtin 路径) -- `Packed*Array` 与 `Array/Dictionary` 存在"路由不对称"是当前设计中的显式事实,不是遗漏: - - `Packed*Array` 的构造函数解析依赖 `ExtensionBuiltinClass` 元数据中的构造签名(当前为零参构造)。 - - 当元数据缺失或签名不匹配时,按既有策略 fail-fast,抛出 builtin constructor validation 错误。 + - `GdPackedArrayType` -> `constructPackedArray(...)`(packed 专用路径,白名单 helper) + - 其他类型 -> `constructRegularBuiltin(...)`(通用 builtin 路径) +- `constructPackedArray(...)` 按实参选择 `gdcc_packed_ref.h` 白名单 helper(结果落入 Variant 槽): + - 零参 -> `gdcc_packed__new_empty()`(empty-Variant,白名单 (b)) + - 同型实参(如 `PackedInt32Array(other)`)-> `gdcc_packed__new_copy(...)`(独立新数组,白名单 (d),与 `duplicate()` 等价) + - `Array` 实参(如 `PackedInt32Array([1, 2])`)-> `gdcc_packed__new_from_array(...)`(跨类型转换,白名单 (d)) + - 其他实参组合经 `hasConstructor` 元数据校验 fail-closed(Godot 4.5 packed 构造器恰好是上述三种)。 +- 带实参的 packed 构造也可经 `ConstructBuiltinInsn` 进入同一 `constructPackedArray` 分支;两种指令入口共享同一 helper 选择逻辑。 ### 3. 维护约束 -- 若后续为 `Packed*Array` 增加专用构造路径(类似 `constructArray`),必须同步更新本文档"语义定义 / 路由说明 / 风险与防线 / 回归测试基线"四处内容,并补充回归测试。 +- `Packed*Array` 专用构造路径(`constructPackedArray` + `gdcc_packed_ref.h` 白名单 helper)已落地;若后续新增 + 构造形态(新实参类型组合),必须同步更新本文档"语义定义 / 路由说明 / 风险与防线 / 回归测试基线"四处内容, + 并补充回归测试。白名单 helper 集合的单一事实源是 `gdcc_packed_ref.h` 与 `PackedRefCNames`。 - `MethodCallResolver` 必须复用 `CGenHelper.parseExtensionType(...)`,不再维护私有解析实现。 ## 风险与防线 @@ -92,8 +103,8 @@ - 防线:在 helper 级与 call-method 级同时加测试,覆盖 typedarray/enum/bitfield/非法输入。 - 风险:自动注入路径切换影响 `__prepare__` 既有顺序。 - 防线:保持仅分支替换,不改变注入顺序与 `appendInsnIfAbsent` 语义。 -- 风险:`Packed*Array` 依赖 `constructRegularBuiltin` 的元数据校验路径,若 API 元数据构造签名缺失/变更会导致构造失败。 - - 防线:保留 fail-fast 文案并通过引擎集成测试覆盖 explicit/prepare 路径;升级 Godot API 版本时优先执行该测试集。 +- 风险:`Packed*Array` 专用构造路径若绕过 `hasConstructor` 元数据校验,可能接受 Godot 不支持的构造组合。 + - 防线:helper 选择前先经 `hasConstructor` fail-closed 校验;生成代码禁令扫描(`CCodegenTest`)禁止白名单外的 `godot_new_Packed*` 符号;升级 Godot API 版本时优先执行引擎集成测试集。 ## 回归测试基线 @@ -103,7 +114,8 @@ - unknown object leaf container hint 兼容用例 - singleton / global enum / utility function hint 拒绝用例 - `__prepare__` 注入 `Packed*Array` 时生成 `ConstructArrayInsn(..., null)` 的断言 - - `generateFuncBody` 输出 packed 构造调用的断言(如 `godot_new_PackedInt32Array()`) + - `generateFuncBody` 输出白名单 helper 的断言(零参 -> `gdcc_packed_int32_array_new_empty()`;同型实参 -> + `new_copy`;`Array` 实参 -> `new_from_array`),并负向锚定不出现裸 `godot_new_Packed*()` 调用 - `src/test/java/gd/script/gdcc/backend/c/gen/CConstructInsnGenEngineTest.java` - 显式 packed 构造函数与 prepare packed 构造函数的引擎集成测试 - 覆盖 `PackedInt32Array` 等类型的 explicit/prepare 路径 @@ -125,7 +137,9 @@ ## 工程反思(保留长期价值) -1. `Packed*Array` 与 `Array` 虽然在 GDScript 层面都是"数组",但在 GDExtension 层面构造策略完全不同(typed container vs regular builtin);路由不对称是合理设计选择,而非遗漏,但必须显式文档化。 +1. `Packed*Array` 与 `Array` 虽然在 GDScript 层面都是"数组",但存储语义不同:`Array` 是 typed container 路径, + `Packed*Array` 走专用 `constructPackedArray` 白名单 helper 路径并落入 Variant-backed 引用语义存储; + 路由差异是合理设计选择,而非遗漏,但必须显式文档化。 2. 扩展类型文本解析(`parseExtensionType`)分散在各 resolver 中会导致规则漂移;抽取到共享 helper 后,由单一实现承载是可维护路径。 3. 自动注入路径(`__prepare__` / default init)中类型分支的新增必须与指令生成器的语义保持对齐,否则会出现"注入 A 指令但生成器只认 B"的断层。 4. 文档应只保留当前事实与长期约束;阶段推进记录应留在提交历史,不应长期污染实现文档。 @@ -133,5 +147,5 @@ ## 非目标(当前不做) - 不修改 `GdInstruction.CONSTRUCT_ARRAY` 的 opcode/操作数数量定义。 -- 不修改 `ConstructBuiltinInsn` 的行为与调用路径。 +- 不为 packed 增加白名单之外的构造形态(新实参组合须先扩展 `gdcc_packed_ref.h` 与元数据校验)。 - 不改变 `construct_dictionary` 现有语义。 diff --git a/doc/module_impl/backend/hot_reload_implementation.md b/doc/module_impl/backend/hot_reload_implementation.md index 5672181e..649f3288 100644 --- a/doc/module_impl/backend/hot_reload_implementation.md +++ b/doc/module_impl/backend/hot_reload_implementation.md @@ -262,8 +262,12 @@ hub: registry + intern table + sweeper + pending + 共享 thunk 页 - `GDCC_HRX_HUB_MAGIC = 0x4744434348525855` - spec 布局 append-only;`callsite_context` 为 v2 尾部字段,偏移 136(静态断言)。读取或释放该字段必须先检查 `abi_version >= 2`。v1 spec 首次遇到 v2 runtime 时全部失效。 - Java `CHrxIdentityCatalog.HRX_ABI_VERSION` 必须与 C 宏相等(契约测试锚定,非编译期共享定义)。schema 前缀 `gdcc-hrx:2;` 同源。 -- schema 文法由 `CHrxIdentityCatalog` 冻结;fingerprint 是 schema 字节的 MD5。类型字段使用 `CGenHelper.renderGdTypeInC` 的 C 存储类型(同时钉表示与 ownership)。改该渲染会让旧连接全部 fail-closed。 - - lambda:`gdcc-hrx:2;caps=;params=;ret=;va=0|1;co=0|1` +- schema 文法由 `CHrxIdentityCatalog` 冻结;fingerprint 是 schema 字节的 MD5。类型字段编码为 + `<语义类型名>@`(语义类型名 + `CGenHelper.renderGdTypeInC` 的 C 存储类型,同时钉语义、表示与 + ownership)。仅编码 C 名会使各 packed family 与 `Variant` 碰撞(同为 `godot_Variant`),跨 family 重绑定会经 + 错误 family 的 internal_ptr getter 解引用旧 holder;双段编码消除该碰撞。存储模型变更(如 packed 改 + Variant-backed)会同时改变两段之一,使旧连接 fail-closed——这是可接受的安全升级行为(拒绝重载而非错误重载)。 + - lambda:`gdcc-hrx:2;caps=;params=;ret=;va=0|1;co=0|1` - standalone:`gdcc-hrx:2;sa;kind=;argc=;va=0|1;ret=0|1;uh=` - thunk 模板:x86_64 SysV(含 macOS Intel)、x86_64 Win64、aarch64(写后刷 icache)。运行时零补丁。POSIX 只用匿名 RW→RX 页(`mmap(MAP_PRIVATE|MAP_ANONYMOUS)` + `mprotect`),不使用 `MAP_JIT`。页大小运行时查询。 diff --git a/doc/module_impl/backend/index_insn_implementation.md b/doc/module_impl/backend/index_insn_implementation.md index 1692ade4..9141eeeb 100644 --- a/doc/module_impl/backend/index_insn_implementation.md +++ b/doc/module_impl/backend/index_insn_implementation.md @@ -125,8 +125,9 @@ LIR 侧统一标记接口:`IndexingInstruction extends LirInstruction`。 | self 类别 | 典型类型 | 处理策略 | ref 是否允许 | |---|---|---|---| | Variant | `GdVariantType` | 直接传入 | 允许 | +| Packed*Array | `GdPackedArrayType` | 直接传入存储 Variant(存储本身即 Variant-backed,setter 经内部引用原位 mutation,无 pack/unpack 回写) | 允许 | | 引用语义 | `GdArrayType` / `GdDictionaryType` / `GdObjectType` | pack 后调用,无需回写 | 允许 | -| 值语义且支持 set | 由 `resolveSelfStrategy` 判定(例如 `String` / `Vector*` / `Packed*Array` 等) | pack 后调用,必须 unpack 回写 | 仅 non-ref | +| 值语义且支持 set | 由 `resolveSelfStrategy` 判定(例如 `String` / `Vector*` 等,**不再含** `Packed*Array`) | pack 后调用,必须 unpack 回写 | 仅 non-ref | | 不支持 set | 由 `isUnsupportedSetSelfType` 判定 | 编译期 fail-fast | 不适用 | 补充约束(按指令模式): @@ -134,7 +135,7 @@ LIR 侧统一标记接口:`IndexingInstruction extends LirInstruction`。 1. `variant_set_keyed`:非 Variant self 必须是 `Object`/`Dictionary`。 2. `variant_set_named`:非 Variant self 必须命中 named 支持集。 3. `variant_set_indexed`:非 Variant self 必须命中 indexed 支持集。 -4. `ref` 且需要 writeback 的 self(例如 `ref Packed*Array`)必须 fail-fast。 +4. `ref` 且需要 writeback 的值语义 self(例如 `ref String`)必须 fail-fast;packed self 不需回写,`ref` 形态同样放行。 ### 4.4 key/value/index 的 ref 语义(当前实现) @@ -149,23 +150,23 @@ LIR 侧统一标记接口:`IndexingInstruction extends LirInstruction`。 ### 5.1 GET 通用流程 1. 校验 result/self/operand。 -2. 对 non-Variant `self`、`key` 执行 pack(如需)。 +2. 对 non-Variant `self`、`key` 执行 pack(如需);packed self 的存储本身即 Variant,直接传入(共享身份,无 pack)。 3. 声明未初始化 `r_ret` 与 `r_valid`(indexed 额外 `r_oob`)。 4. 发射 `godot_variant_get*`。 5. 先检查 `r_valid`,indexed 再检查 `r_oob`。 6. 成功路径将 `r_ret` 写回: - `Variant -> Variant`:`godot_new_Variant_with_Variant` 构造拷贝回写。 - - `Variant -> 非 Variant`:调用对应 unpack 函数回写。 + - `Variant -> 非 Variant`:调用对应 unpack 函数回写(packed 结果为精确 family 检查 + Variant 持有者拷贝)。 7. 所有路径必须销毁 `r_ret` 及 pack 临时变量。 ### 5.2 SET 通用流程 1. 校验无 `resultId`。 -2. 校验并物化 self(含分类策略)。 +2. 校验并物化 self(含分类策略);packed self 与 Variant self 同路径,直传存储槽。 3. 处理 key/index/value(key/value 非 Variant 时 pack)。 4. 发射 `godot_variant_set*`。 5. 检查 `r_valid`(indexed 额外检查 `r_oob`)。 -6. 若 self 属于值语义写回路径,执行 unpack 回写。 +6. 若 self 属于值语义写回路径,执行 unpack 回写(packed self 不属于该路径,无回写步骤)。 7. 所有路径销毁临时变量(value/key/self)。 ### 5.3 错误分支与资源销毁约束 @@ -203,8 +204,9 @@ LIR 侧统一标记接口:`IndexingInstruction extends LirInstruction`。 1. Godot `call` 路径会先把参数解包到调用栈局部对象。 2. 对 ref 值语义对象做直接写回存在不安全风险。 3. 因此当前策略固定为: - - 放行“无需回写”的 ref self(`Variant`/`Array`/`Dictionary`/`Object`)。 - - 拒绝“需要回写”的 ref self(如 `ref Packed*Array` 等)。 + - 放行“无需回写”的 ref self(`Variant`/`Array`/`Dictionary`/`Object`/`Packed*Array`——packed 存储即 + Variant-backed 共享身份,setter 原位 mutation 天然可见)。 + - 拒绝“需要回写”的 ref 值语义 self(如 `ref String` 等)。 --- @@ -221,7 +223,7 @@ LIR 侧统一标记接口:`IndexingInstruction extends LirInstruction`。 `src/test/java/gd/script/gdcc/backend/c/gen/IndexStoreInsnGenEngineTest.java` 覆盖以下运行时锚点: 1. ref self:`Array` / `Dictionary` set 后无需回写可读回。 -2. `PackedInt32Array`:局部写回路径正确读回。 +2. `PackedInt32Array`:直传存储 Variant 的原位索引写正确读回(含别名可见性锚定)。 3. ref index/value:`variant_set_indexed` + `variant_get_indexed` 读写一致。 4. ref key/value:`variant_set` + `variant_get` 读写一致。 5. ref named:`variant_set_named` + `variant_get_named` 读写一致。 diff --git a/doc/module_impl/backend/load_store_property_implementation.md b/doc/module_impl/backend/load_store_property_implementation.md index 4fd9a0b7..3351cfd7 100644 --- a/doc/module_impl/backend/load_store_property_implementation.md +++ b/doc/module_impl/backend/load_store_property_implementation.md @@ -70,6 +70,9 @@ - getter-self 直读必须按 `&self->field` 做 copy-by-address,不得先 shallow-copy 到 temp 再 destroy temp - 若当前 target overwrite 在 Builder 的 sealed provenance 模型下属于 `may-alias`,getter-self 允许先用 copy ctor 从 `&self->field` 生成 stable carrier,再 destroy target 并 consume 该 carrier - setter-self 直写同理:对 `ref=true` parameter 这类 alias-open source,Builder 会先生成 stable carrier,再 destroy backing field,并把 carrier consume 到 field;但仍不得走“copy temp -> field = temp -> destroy temp” +- packed backing field 同属上述 stable-carrier 模型,但存储是 `godot_Variant`:copy/destroy 经 helper 名映射解析为 + `godot_new_Variant_with_Variant` / `godot_Variant_destroy`(持有者拷贝共享身份),不会生成任何 + `godot_new_Packed*Array_with_*` struct 转换符号。 ### 2.5 可读写校验状态 @@ -161,6 +164,9 @@ - `proven no-alias` 的 `BORROWED` source,继续生成 `slot = godot_new__with_(source_ptr)` - `may-alias` 的 `BORROWED` source,必须先生成 stable carrier,再 destroy old slot,并把 carrier consume 到 slot - 不允许生成“copy temp -> plain `slot = temp` -> destroy temp”,因为 `slot = temp` 只做浅层 struct 赋值 + - packed 槽位是 Variant-backed 存储:同一 overwrite 模型下 copy helper 为 + `godot_new_Variant_with_Variant`(共享身份)、destroy 为 `godot_Variant_destroy`;上述 struct 形状约束 + 对 packed 的适用点在于“不得退化为浅层 Variant struct 赋值”,而非任何 packed struct 转换路径。 - getter-self 读取 backing field 时,若后续 copy helper 需要地址,必须优先使用 `&self->field` 这类现有 storage 地址; 不得通过 `tmp = self->field` 人工物化地址。 diff --git a/doc/module_impl/backend/packed_array_implementation.md b/doc/module_impl/backend/packed_array_implementation.md new file mode 100644 index 00000000..228b277c --- /dev/null +++ b/doc/module_impl/backend/packed_array_implementation.md @@ -0,0 +1,297 @@ +# Packed*Array 引用语义实现 + +> 本文档作为 Packed*Array(PackedByteArray / PackedInt32Array / PackedInt64Array / +> PackedFloat32Array / PackedFloat64Array / PackedStringArray / PackedVector2Array / +> PackedVector3Array / PackedColorArray / PackedVector4Array)引用语义实现的长期事实源。 +> 只保留当前代码已经落地的语义合同、架构决策、边界约定与对后续工程仍有价值的反思, +> 不记录阶段性实施流水账。 + +## 文档状态 + +- 状态:Implemented / Maintained +- 范围: + - `src/main/java/gd/script/gdcc/backend/c/**`(packed 存储映射与调用发射) + - `src/main/java/gd/script/gdcc/frontend/**`(写回 route 分流) + - `src/main/c/codegen/include_451/gdcc/gdcc_packed_ref.h` + - `src/main/c/codegen/include_451/gdcc/intrinsic/for_packed_array_iter.h` + - `src/test/resources/packed_ref_semantics/**` + - `src/test/java/gd/script/gdcc/backend/c/build/packedref/**` +- 更新时间:2026-09-25 +- 语义基线:Godot 4.5.2 解释器实测 +- 关联文档: + - `doc/gdcc_c_backend.md` + - `doc/gdcc_low_ir.md` + - `doc/gdcc_type_system.md` + - `doc/gdcc_runtime_lib.md` + - `doc/gdcc_ownership_lifecycle_spec.md` + +## 1. 背景与问题起源 + +改造前 gdcc 将 Packed*Array 以 **by-value C 结构体**存储(`godot_Packed*Array`, +16 字节 opaque struct),赋值/别名/传参时经 `godot_new__with_` 拷贝构造。 +该拷贝是 C++ `Vector` 层拷贝,首次写入触发 COW detach,形成值语义。而 Godot 4.4+ +官方文档与 4.5.2 解释器实测均确认 Packed*Array 在语言层为**引用(共享)语义**:Variant +内部以指针持有堆上的共享数组实例(引擎侧 `PackedArrayRef`),所有别名持有者对 +mutation 互相可见。 + +由此产生的行为分歧(实机探针证实):局部别名不可见、形参 mutation 对调用方不可见、 +`for-in` 迭代期间新增元素不被访问、Variant 双向转换后身份分裂。当前实现已将存储与 +运算语义严格对齐解释器引用语义,覆盖:局部变量、参数、脚本属性、静态变量、 +Array/Dictionary 元素、信号参数、lambda 捕获、协程 frame、`for-in` 迭代、Variant +双向转换、`duplicate()`、`+=` 重绑定、cast/类型测试、运算符与索引读写。 + +## 2. 语义合同(解释器实测基线) + +以下矩阵是已实现并由双跑测试锁定的行为合同。每行对应 +`src/test/resources/packed_ref_semantics/packed_ref_semantics_golden.txt` 中由 +Godot 4.5.2 解释器锁定的 golden 行,用例名与顺序另由 +`PackedRefSemanticsGoldenInventoryTest.MATRIX_CASE_NAMES` 独立锚定。 + +| # | 场景 | 解释器行为(= gdcc 行为) | +|---|---|---| +| 1 | `var b := a; a.push_back(7)` | `b` 可见(共享) | +| 2 | callee 对形参 mutation | 调用方可见(共享) | +| 3 | 脚本属性 `payloads.push_back`(self / 外部) | 持久可见 | +| 4 | 静态变量 mutation | 持久可见 | +| 5 | `Array[PackedInt32Array]` 元素 mutation | 持久可见 | +| 6 | Dictionary 值中的 packed mutation | 持久可见 | +| 7a | 内建属性 getter mutation(`poly.polygon.push_back`) | **不持久**(getter 返回副本) | +| 7b | 内建属性重赋值(取出 mutation 后赋回) | 持久 | +| 7c | 内建属性索引写(`poly.polygon[0] = v`) | **持久**(read-modify-write,赋值 route 写回) | +| 8 | `a += b` | 产生新数组并**重绑定**,旧别名不可见 | +| 9 | `a.duplicate()` | 独立副本 | +| 10 | 信号参数 mutation | 发射方可见 | +| 11 | lambda 捕获后 mutation | 捕获方/外部互相可见 | +| 12 | `for v in a` 期间 `a.push_back` | **活迭代**:新元素会被本轮访问 | +| 13 | `append_array` | 共享 | +| 14 | `resize` | 共享 | +| 15 | `a[0] = 99` 索引写 | 共享 | +| 16 | `Variant` ↔ typed 转换(赋值) | 保持共享(三方共享) | +| 17 | 参数默认值 | 每次调用物化新数组 | +| 18 | 元素写入重绑定:`var e := arr[0]; arr[0] = 新数组` | `e` 仍指向旧数组(元素槽重绑定) | +| 19 | PackedStringArray 迭代 | 元素为 String 副本,值相等 | +| 20 | `in` 成员测试 | 内容匹配 | +| 21 | `==`/`!=` 与 Dictionary 键 | 内容相等;键按内容哈希,插入后经共享别名 mutation 使旧条目查找 missing | +| 22 | `v as PackedInt32Array`(同 family) | **COW 拷贝(新身份),非共享**——实测推翻"保持共享"的原始预期 | +| 23 | 协程形参/捕获在 await 挂起前后双向 mutation | 双向可见 | +| 24 | 多参数 typed 信号携带 packed | 共享 | + +矩阵之外的组合场景同样以双跑锁定:动态 Variant mutation、混合场景(属性 + lambda + +信号 + 协程 + 活迭代同链组合)、复杂控制流分支体内 mutation、返回值身份五种形态、 +引擎方法 packed 参数/返回边界、PackedStringArray mutation 专项。 + +## 3. 引擎机制依据(godotengine/godot 4.5 分支核实) + +设计决策的引擎侧论据: + +1. GDScript VM 的局部槽(含 typed packed local)均为 `Variant` + (`modules/gdscript/gdscript_vm.cpp:571-574`),typed 信息只用于静态检查。 +2. VM 对 builtin 方法的验证调用路径(`OPCODE_CALL_BUILTIN_TYPE_VALIDATED`)把 base + `Variant*` 直接交给方法;`core/variant/variant_call.cpp` 经 + `VariantGetInternalPtr::get_ptr(base)` 取内部值指针原位调用,不构造副本。 +3. **GDExtension 暴露同一能力**:`variant_get_ptr_internal_getter` + (`core/extension/gdextension_interface.h`)返回按类型的内部值指针 getter,文档 + 明确允许用于原位修改;本项目绑定暴露为 + `godot_variant_get_ptr_internal_getter`(`godot_interface.h`)。 +4. `+` 运算符产生新数组(`core/variant/variant_op.h`),`+=` 由语言层重绑定。 +5. lambda 捕获按 Variant 拷贝保存,共享内部实例;GDScript 成员槽为 `Vector`, + 属性读取返回 Variant 拷贝,共享内部实例。 +6. typed Array 元素存储为 `Vector`,元素访问返回 Variant 引用,packed 元素 + mutation 经 Variant 层共享。 +7. GDExtension typed 拷贝构造是 `Vector` 层拷贝(`variant_construct.h`),首写触发 + COW(`cowdata.h`)——这正是旧 struct 存储模型产生值语义的根源。 + +## 4. 架构决策 + +### 4.1 核心决策:Variant-backed 存储 + +Packed*Array 的**规范存储形式**为 `godot_Variant`(不再是 `godot_Packed*Array` +by-value struct): + +- **身份共享**:Variant 拷贝(`godot_new_Variant_with_Variant`)共享引擎堆上的同一 + `PackedArrayRef`,引用计数由引擎管理,gdcc 无需自建 box/refcount。 +- **原位 mutation**:调用 builtin 方法时,先经缓存的 + `GDExtensionVariantGetInternalPtrFunc` 取内部值指针,再以其为 base 执行既有 + `GDExtensionPtrBuiltInMethod` wrapper(wrapper 签名不变,仅 base 实参来源变化)。 + 语言层下标写不走这条路径,见 §5 第 4 条。 +- **核心不变式**:gdcc 持有的 packed 值**禁止**经过 struct pack/unpack + (`godot_new_Packed*_with_Variant` / `godot_new_Variant_with_Packed*`)。仅有的合法 + struct 边界(白名单,集中定义于 `gdcc_packed_ref.h`): + (a) ptrcall ABI 边界双向——入向 struct→Variant 物化与出向 Variant→struct 返回拷贝; + (b) empty 构造时的临时 struct(此刻尚无共享者); + (c) builtin 方法返回的原生 struct 临时值(立即包装为 Variant 并 destroy); + (d) 带参数的 packed 构造器——跨类型构造(如 `PackedInt32Array([1, 2])` 经 + `godot_new_PackedInt32Array_with_Array`)与显式同型构造 + (`PackedInt32Array(otherPacked)`,产出**独立新数组**,与 `duplicate()` 等价); + 同 family `as` cast 复用同型拷贝构造路径(见 §5)。 + 构造/cast 实参按原生 ABI 形状渲染,结果接入原生临时 struct,立即包装为 Variant 并 + destroy temp。 +- **不得**依据 CowData refcount 做任何复制决策(外部 ptrcall 拆箱或 getter 副本会使 + rc>1,那是另一 `PackedArrayRef` 的正常现象)。 +- **Variant ↔ typed 转换按 payload 分流**(`InsnGenSupport.emitPackedUnpackAssign`):精确 + family 的 payload 是类型检查 + Variant 持有者拷贝(三方共享);`Array` payload 走 + 白名单 (d) `new_from_array`,产出独立数组(解释器实测锚定);其余 kind 为运行时 + 类型错误,赋值不生效。 + +### 4.2 否决方案及理由 + +1. **维持 struct 存储 + 扩大写回**:无法修复局部别名共享(`b := a` 要求两个 slot 永久 + 同步)与 GDScript 边界;形参写回还需要 assign-through-pointer 架构改造。 +2. **手动引用计数盒(heap struct + gdcc 管理 refcount)**:身份仍在每次 Variant + pack/unpack 处断裂(struct 拷贝 detach),且重复引擎已有的 Variant 引用计数。 + +## 5. 设计约定 + +1. **C 存储形状**:局部变量、参数、实例字段、静态字段、协程 frame 字段中的 packed + 类型统一为 `godot_Variant`;内部 gdcc 函数签名的 packed 参数/返回类型相应为 + `godot_Variant*` / `godot_Variant`(仅内部 ABI)。形参共享身份直接消解了旧 + PARAMETER 写回限制:mutation 经共享身份对调用方可见,不需要 assign-through-pointer。 + 存储映射入口:`CGenHelper`;helper 命名集中入口:`PackedRefCNames`。 +2. **默认初始化**:`var a: PackedInt32Array` 必须初始化为**空数组 Variant** + (per-family helper:临时 struct 构造 → `godot_new_Variant_with_` → + destroy temp),**严禁** nil Variant(nil 无内部值指针,方法调用会失败)。 +3. **方法调用 receiver/参数/返回值 ABI**(发射支持: + `PackedNativeAbiCallSupport`):receiver 与 packed 实参渲染为 Variant 内部指针 + (getter 按 `GDExtensionVariantType` per-family 缓存,初始化期可用性检查,缺失 + fail-fast);packed 返回值先接入原生临时 struct,再包装为新 Variant 并 destroy + 临时 struct(临时值未经过写入,不发生 detach;新 Variant 是独立 `PackedArrayRef`)。 +4. **索引读写**:packed 存储本身就是 Variant,语言层下标读经 `godot_variant_get_indexed` + (`IndexLoadInsnGen`),下标写直接把存储 Variant 槽交给 Variant indexed setter 原位 + 修改共享数组,不发射 pack/unpack 写回(`IndexStoreInsnGen`)。内部指针用于 builtin + 方法/运算符求值与迭代器 `get`(`operator_index_const`),不用于语言层下标路径。 +5. **运算符**:`==`/`!=`/`in` 等内容运算**按操作数逐个处理**:packed 操作数取其 + Variant 内部指针,非 packed 操作数(标量等)保持 op evaluator wrapper 声明的原生 + ABI 形状。`+` 在原生临时 struct 中产出新数组后立即包装为新 Variant。**禁止**将 + `+`/`+=` 优化为对内部指针的 in-place `append_array`(会把 mutation 泄漏给旧别名, + 违反 §2 第 8 行)。 +6. **cast 与类型测试**:`is` 经 `Variant.get_type()` 与目标 `GDExtensionVariantType` + 比较。同 family `as` cast **产生 COW 拷贝(新身份)**(§2 第 22 行实测):内部指针 + → 同型拷贝构造(白名单 (d))→ 立即包装为新 Variant。`ExplicitCastSupport` 负责 + 分类,backend 经 runtime-cast surface 发射。 +7. **for-in 迭代器**(`intrinsic/for_packed_array_iter.h` + `GdccForPackedArrayIterType` + + intrinsic 发射,三者必须同步变更):state 持有源数组的 Variant 拷贝(共享)与 + index;`next` 只递增 index;`should_continue` 每次求 **live size**;`get` 先以 + live size 越界检查再经内部指针取元素;**禁止跨迭代缓存元素基址**(mutation 可能 + realloc,缓存指针悬垂)。PackedStringArray 元素按访问拷贝 String。 +8. **析构**:所有 packed slot 的析构统一为 `godot_Variant_destroy`(引擎递减共享 + 引用),不存在 packed struct 析构。 +9. **协程 frame 与 lambda 捕获**:参数/捕获/返回字段为 Variant;"拷贝"语义为 Variant + 持有者拷贝(共享身份),**不得** struct copy-construct。 +10. **C 侧类型擦除的补偿**:存储统一为 `godot_Variant` 后 C 编译器无法区分具体 + packed family,类型安全由 LIR 类型系统与类型检查 helper 保证;内部指针 getter 对 + 类型不匹配的 Variant 是未定义行为,`get_type()` 检查是必需前置。热重载 schema + 因此必须保留语义类型信息(`CHrxIdentityCatalog`),防止错误 family 的 holder 被 + 重新绑定。 + +### ABI 边界例外 + +- **ptrcall ABI 边界(身份隔离)**:ptrcall 入口的 packed 参数以原始 struct 指针 + (`Vector` 值槽)传入,callee 做 struct→Variant 物化(`Vector` 层拷贝),此后 + mutation 与调用方隔离;packed 返回值经 ptrcall 写出时同样发生 struct 拷贝。出向 + 调用(gdcc→engine)的 packed 实参反向物化:Variant→临时 struct→ptrcall→调用后 + 销毁。该例外只影响使用 ptrcall 的扩展间调用路径;GDScript 与 GDExtension 之间的 + 常规调用走 `call_func`(Variant ABI),**不受此例外影响**。Godot 对 GDExtension + 脚本方法的调用恒走 call_func,故 ptrcall wrapper 无真机触发路径,其序列正确性由 + 文本锚定测试与 fake-引擎运行测试双重锁定。 +- **call_func 边界(身份保持)**:wrapper 入参从 `const godot_Variant*` 拷贝构造本地 + Variant,callee mutation 对 GDScript 调用方可见;packed 参数/返回只经 + `godot_new_Variant_with_Variant`,禁止 struct 中转。 +- **内建属性 getter 返回副本**:引擎语义(`poly.polygon.push_back(x)` 在解释器中同样 + 不持久),不是 gdcc 分歧。 + +### 不受影响的范围 + +- `String` / `StringName`:语言层无 mutating 方法,维持 struct 值语义。 +- `Array` / `Dictionary` / `Object`:本已是共享/引用语义,仅 Packed*Array 作为其 + 元素/值时间接受益。 +- typed Array / typed Dictionary ABI 合同:packed 作为 outward leaf 的 hint 与 + `godot_Array` 物理 carrier 不变。 + +## 6. 前端写回分流(route provenance) + +旧写回机制为补偿值语义而设。Variant 存储下按 route provenance 决策(实现入口: +`FrontendWritableTypeWritebackSupport`、`FrontendWritableRouteSupport`、 +`FrontendCfgGraphBuilder`、`FrontendLoweringBodyInsnPass`): + +| route | 行为 | +|---|---| +| direct-slot(LOCAL_VAR)snapshot commit | **不发布**(Variant 拷贝共享身份,写回冗余) | +| direct-slot(PARAMETER) | 不涉及 step;形参共享身份,mutation 天然可见 | +| 内建引擎属性 route(`poly.polygon`)mutating call | **不写回**(getter 副本语义;与旧 gdcc 合同相反,属有意对齐) | +| 内建引擎属性 route 显式赋值 / 索引写(§2-7b/7c) | 写回(read-modify-write 持久化) | +| GDCC 脚本属性 route | 保留写回(冗余无害,存回同一身份;可选清理项) | +| STATIC_CONTEXT 静态属性 route | 不追加 step;Variant 存储使静态 leaf 共享身份后自然解锁(旧值语义 carrier 曾被 static-terminal 合同 fail-closed) | +| Array/Dictionary 元素 route | 保留写回(冗余无害;可选清理项) | +| method-result route(`get_baked_points().push_back`) | 不写回 | + +动态 Variant receiver route:runtime gate helper `gdcc_variant_requires_writeback` +对全部 10 个 packed kind 返回 `false`(**必须显式列出全部 kind**——漏列会落入 +`default: true`);`default` 分支保持 `true`("未知 kind 保守 true" 的冻结合同,见 +`gdcc_type_system.md`),其余值类型分支(`String`、`Vector*`、`Color` 等)不受影响。 + +**迁移顺序教训**:存储切换必须先于(或同提交于)DIRECT_SLOT snapshot commit 的关闭; +反向顺序会在 struct 存储下重新引入 snapshot detach 丢更新,且可能静默通过部分旧测试。 + +## 7. 验证架构 + +双跑对照体系(解释器与 gdcc 编译产物运行同一探针库,逐行比对): + +- `src/test/resources/packed_ref_semantics/packed_ref_probes.gd`:双跑共用探针库, + 按固定顺序输出 `PROBE||`。 +- `packed_ref_semantics_golden.txt`:Godot 4.5.2 解释器锁定的 payload 事实源;golden + 行序即用例顺序。 +- `PackedArrayReferenceSemanticsDualRunTest`:双跑入口,两侧均对完整 golden 断言; + 缺 Godot 或 Zig 时按条件跳过相应部分。 +- `PackedRefSemanticsDualRunHarness` / `ProbeOutput` / `ProbeGoldenComparison`: + 运行编排、输出解析(拒绝 malformed/重复 case)、golden 比对(缺失/payload 分歧/ + 未知用例/相对顺序四类差异逐一定名)。 +- `PackedRefSemanticsGoldenInventoryTest.MATRIX_CASE_NAMES`:**不依赖 Godot** 的用例 + 清单锚点,防止探针与 golden 被同步删减导致覆盖静默收缩(锚点若置于双跑类内会随 + `GODOT_BIN` 缺失整类跳过而失效,故独立成类)。 +- fake-引擎运行锚点:`GdccPackedRefRuntimeSmokeTest`(`gdcc_packed_ref.h` 独立编译、 + getter fail-fast、白名单转换)、`PackedRefStorageModelSmokeTest`(ptrcall 双向身份 + 隔离 + 持有者平衡)。 +- 生成代码文本锚点:`CCodegenTest`(call_func/ptrcall wrapper 序列、出向 engine + helper 物化/销毁游标顺序、生成代码全文件白名单扫描)、`CallMethodInsnGenTest`、 + `CConstructInsnGen(Test|EngineTest)`、`BuiltinCastInsnGenTest`、 + `IndexStoreInsnGen(Test|EngineTest)`、`COperatorInsnGenTest` 等。 +- 前端分流矩阵:`FrontendWritableTypeWritebackSupportTest`(family × provenance)、 + `FrontendCfgGraphBuilderTest`、`FrontendLoweringBodyInsnPassTest`、 + `FrontendWritableRouteSupportTest`。 +- 端到端(test_suite):`member/packed_call_func_identity.gd`(解释器经 call_func 把 + packed 交给编译类,正反锚定六种身份形态)、`member/packed_ref_full_usage.gd` + (函数/循环/分支/match/字段/lambda/协程/信号全组合共享身份)。 + +### 探针编写约束(踩坑记录) + +- golden payload **不得含 `|`**(`PROBE||` 三段式格式冲突)。 +- gdcc 不做 bool→int 隐式转换,探针打印须 `int(...)` 显式转换。 +- 新增探针必须三处同步:探针库、golden、`MATRIX_CASE_NAMES`。 +- 身份合同锚定不能只测内容:边界在值语义下可能巧合通过(如引擎方法 get 返回未二次 + 观测、`String.join` 读原变量而非别名),须用别名 + 重复观测双重锚定。 +- 出向 engine helper 的 ABI 断言须以游标顺序锁定"物化 → 临时槽入 args → ptrcall → + 销毁",仅断言符号存在会被 destroy/wrap 语句巧合满足。 + +## 8. 风险与长期注意事项 + +1. **`variant_get_ptr_internal_getter` 属于半内部 API**(godot-cpp 亦依赖):初始化期 + 可用性检查 + fail-fast 兜底;若未来 Godot 移除,需整体回退本存储模型。 +2. **活迭代安全性**:`get` 每次 live-size 检查,不缓存基址,realloc/缩容安全。实机 + 探针(100k 元素 × 200 趟)测得现行 live-size 形态 12.5 ns/元素,优于旧快照迭代器 + 的 35.3 ns/元素;可选优化方向是融合 `should_continue`/`get` 的 size 求值 + (实测可达 6.9 ns/元素)。 +3. **nil Variant 误用**:所有 packed slot 默认初始化统一走 empty-Variant helper。 +4. **热重载 fingerprint**:packed 参数/字段的 C 类型名变化使旧连接 fail-closed + (拒绝重载而非错误重载),属安全的升级行为。 +5. **`+=` 语义泄漏**:必须走"新数组 + 重绑定",禁止 in-place append;以 + `b := a; a += x; b` 身份不变用例锁定。 +6. **调用开销**:方法调用多一次间接 getter 调用(常数级);Variant 拷贝与 struct + 拷贝同为一次原子引用计数。 + +## 9. 非目标 + +- 不改变 `String`/`Array`/`Dictionary`/`Object` 的既有语义合同。 +- GDScript↔GDExtension 常规调用的 ptrcall 化:常规调用恒走 call_func,ptrcall 例外 + 仅服务扩展间路径,无推广计划。 +- 脚本属性/容器元素 route 冗余写回的清理(保留以降低迁移风险,列为可选后续项)。 +- 迭代器 size 求值融合的 perf 优化(见 §8-2,记录为可选方向)。 diff --git a/doc/module_impl/backend/variant_abi_contract.md b/doc/module_impl/backend/variant_abi_contract.md index 74e13b28..a9baa43e 100644 --- a/doc/module_impl/backend/variant_abi_contract.md +++ b/doc/module_impl/backend/variant_abi_contract.md @@ -113,9 +113,14 @@ - 不能执行 `actual_type == NIL` 的精确比较 - 必须允许任意 Godot `Variant` payload 进入 wrapper - 之后按 `godot_new_Variant_with_Variant(...)` / `godot_new__with_Variant(...)` 路径在 wrapper 内复制出本地值 + - `Packed*Array` 参数的存储本身即 Variant:wrapper 内是精确 family 类型检查 + `godot_new_Variant_with_Variant` + 持有者拷贝(与调用方共享身份,callee mutation 对 GDScript 调用方可见),**不**经过 + `godot_new_Packed*Array_with_Variant` struct 中转 - `ptrcall` ABI 不参与这条 runtime gate 合同: - 它继续保持当前的物理 C ABI 形状 - 不因为 ordinary `Variant` outward ABI 调整而改变 + - packed ptrcall 参数/返回经 `gdcc_packed_ref.h` 白名单 (a) helper 双向物化/拷贝,身份不与调用方共享 + (已记录的 ABI 例外,见 `gdcc_c_backend.md` "Packed*Array Variant-backed Storage") - wrapper-only inbound materialization helper 不是独立校验边界: - `gdcc_new_Vector2_from_call_arg_variant(...)`、`gdcc_new_Vector3_from_call_arg_variant(...)`、`gdcc_new_Vector4_from_call_arg_variant(...)` 只根据已经通过 gate 的 cached runtime type 选择 exact `Vector*` unpack 或同维 `Vector*i -> Vector*` constructor materialization - `gdcc_new_StringName_from_call_arg_variant(...)` 与 `gdcc_new_String_from_call_arg_variant(...)` 只根据已经通过 gate 的 cached runtime type 选择 exact unpack 或 cross-case constructor materialization,并负责销毁 cross-case 中间 `String` / `StringName` @@ -138,7 +143,8 @@ - `Variant` - `Array` - `Dictionary` - - `Packed*Array` + - `Packed*Array`(wrapper 局部是 Variant 持有者拷贝,`destroy(&slot)` 经 destroy helper 名映射解析为 + `godot_Variant_destroy`,释放该持有者对共享数组的引用) - 其他 `isDestroyable()==true` 且非 object 的 value wrapper - object 指针和 primitive 不属于这条 cleanup 规则: - object 参数/返回值在 wrapper 里只是普通指针局部 diff --git a/doc/module_impl/frontend/frontend_complex_writable_target_implementation.md b/doc/module_impl/frontend/frontend_complex_writable_target_implementation.md index 304ea1d8..c87eb35a 100644 --- a/doc/module_impl/frontend/frontend_complex_writable_target_implementation.md +++ b/doc/module_impl/frontend/frontend_complex_writable_target_implementation.md @@ -105,6 +105,7 @@ route payload”和当前 identifier/self 的 published binding。这个 carrier - `PROPERTY` - `SUBSCRIPT` - `StepDescriptor` + - `DIRECT_SLOT` - `PROPERTY` - `SUBSCRIPT` @@ -117,6 +118,7 @@ route payload”和当前 identifier/self 的 published binding。这个 carrier - `AttributeSubscriptStep` 的 named base 默认属于 Variant named member route:body lowering 先用 frozen receiver slot 与原始 receiver type materialize `receiver.member`,`GdObjectType` receiver 必须先 pack 成 `Variant` carrier,然后才把该 named-base `Variant` 用作 subscript effective receiver。Reverse commit 重建同一 named-base 写回形状,不能直接把 object receiver slot 传给 `VariantSetNamedInsn`。两个已实现的 typed 例外(见 `frontend_static_var_implementation.md` §4.3 / §4.5):static property 容器经 `LoadStaticInsn`/`StoreStaticInsn` 走共享静态存储;已解析的非 static GDCC 实例 property 容器(`containerSourceType` 非 Variant)经 `LoadPropertyInsn`/`StorePropertyInsn` 走 typed 路由,key 转换与 access-kind 与裸下标一致,写回不省略(typed 路由恒定 `StorePropertyInsn` 提交整个容器,不假设原位修改对属性存储可见)。engine property 容器与 dynamic 成员不属于例外,保持 Variant named route。 - focused tests 必须分别覆盖 `SubscriptLeaf.memberNameOrNull != null` 的 leaf read 与 `SubscriptCommitStep.memberNameOrNull != null` 的 reverse-commit 分支,避免只通过 final write 或端到端 body lowering 间接覆盖 named-base route。 - `StaticPropertyCommitStep` 只能作为 terminal step;non-terminal static step 必须 fail-fast。 +- `DIRECT_SLOT` commit step 只允许出现在 `CallItem` payload 上,且必须同时满足:root/leaf 都是 `DIRECT_SLOT`、step 位于最外层(terminal,即 `reverseCommitSteps` 下标 0)。descriptor 本身不得携带 container / operand / member / access-kind 字段。 ## 2. Assignment 与 compound assignment 合同 @@ -201,7 +203,8 @@ complex writable target 不引入新的 call item 或新的 call instruction。 - 二者都使用同一个 payload consumer - 二者都必须先发普通 `CallMethodInsn` -- 二者都在 call 之后接 shared writable-route reverse commit +- 二者都在 call 之后按 route provenance 决定是否接 shared writable-route reverse commit + (`Packed*Array` 的 `DIRECT_SLOT` / `STATIC_PROPERTY` / `ENGINE_PROPERTY_CALL` route 不发布/不写回,见 §5.1) 差异: @@ -219,6 +222,22 @@ runtime-gated reverse commit 可能插入 `apply / skip / continue` block,因 - 后续 sequence item 必须继续附着到该 returned block - call lowering 不得假设“所有后续 lowering 永远挂在原 lexical block 上” +### 3.5 direct-slot snapshot receiver 的写回合同 + +direct-slot mutating receiver(裸 `LOCAL_VAR` / `PARAMETER`)存在两条执行路线: + +- **alias 路线**:alias publication 成功,receiver value 直接绑定源 slot(`&$local` 原位修改),`reverseCommitSteps` 保持为空,禁止再补任何写回(自赋值会制造 destroy-then-copy 风险)。 +- **snapshot 路线**:alias publication 被 §4.3 的 no-rebinding 分类拒绝后,receiver 保持 ordinary temp snapshot,call 实际作用在 `cfg_tmp_*` 副本上。对 struct 值语义 carrier(`String`、`Vector*` 等),副本上的 mutation 会经 copy-on-write detach 与源 slot 隔离,因此 payload 必须追加一个 terminal `DIRECT_SLOT` commit step,在 call 之后把已变更的 temp 写回 root slot。`Packed*Array` 的 snapshot 是 Variant 持有者拷贝,与源 slot 共享数组身份,mutation 天然可见,**不再追加** commit step(`FrontendWritableTypeWritebackSupport.requiresDirectSlotSnapshotCommit(...)` 是这条 packed-only 豁免的发布门)。 + +当前冻结为: + +- 该 step 由 CFG builder 在 `appendCallReceiverCommitSteps(...)` 中按“alias 未发布 + may-mutate + root/leaf 均为 `DIRECT_SLOT` + receiver 绑定为 `LOCAL_VAR` + `requiresDirectSlotSnapshotCommit(carrier)`”条件追加;body lowering 不重新推导。 +- **`DIRECT_SLOT` step 仅服务 `LOCAL_VAR` root**;`PARAMETER` / `CAPTURE` root 不发布 step。`PARAMETER` root 保持旧 snapshot 行为(LIR parameter 恒为 `ref=true` 借用指针,parameter rebinding 整体尚未支持);其中 packed 形参的后端存储为 `godot_Variant*`,与调用方共享数组身份,mutation 经共享身份天然可见,无需任何 step。`CAPTURE` root 的 alias 绑定 lambda 自身捕获槽,capture 名不发生 rebinding,故无需 step;packed 捕获与外部变量经 Variant 持有者拷贝共享身份,mutation 双向可见。 +- 写回指令是普通 `AssignInsn(rootSlot, tempCarrier)`,共享 detach 后的底层 buffer,只有引用计数开销,无深拷贝。 +- 该 step 的发布由 `requiresDirectSlotSnapshotCommit(carrier)` 门控:仅 packed 豁免(snapshot 为 Variant 持有者拷贝,与源 slot 共享身份);其余 family 历史上无条件发布该 step——对 `Array`/`Dictionary` 等共享 carrier 是同身份冗余自赋值,冗余无害。 +- dynamic/`Variant` receiver 复用同一 step,经 `gdcc_variant_requires_writeback(...)` runtime gate 决定是否写回。 +- **成立前提**:当前合法 GdScript 的实参求值面无法重绑定 caller 的 local slot(实参位置的 `AssignmentExpression` 已被 sema 拒绝,capture 只写副本),因此写回可以无条件执行。未来若开放任何可在实参求值期重绑定该 slot 的语法形式,必须先改为条件写回或 slot 写追踪,不得直接沿用本合同。 + ## 4. Direct-slot alias publication 合同 ### 4.1 允许的 alias root @@ -228,10 +247,10 @@ runtime-gated reverse commit 可能插入 `apply / skip / continue` block,因 - `SelfExpression` - `IdentifierExpression + LOCAL_VAR` - `IdentifierExpression + PARAMETER` +- `IdentifierExpression + CAPTURE` 以下 surface 当前明确不在 alias root 支持面: -- `IdentifierExpression + CAPTURE` - `IdentifierExpression + SELF` - `receiverValueIdOrNull == null` 时由 call execution fallback 提供的 implicit self receiver @@ -289,30 +308,37 @@ runtime-gated reverse commit 可能插入 `apply / skip / continue` block,因 - 只有已经被 builder 明确证明不会重绑定同一 direct-slot root 的参数子树,才允许 live-slot alias 穿过参数求值阶段 - 一旦未来新增 rebinding form,若它没有进入 safe 分类,就必须默认回退 snapshot,而不是静默穿透 alias +回退 snapshot 不是语义终点:被回退的 `LOCAL_VAR` receiver 必须按 §3.5 的发布门(`requiresDirectSlotSnapshotCommit`)决定是否追加 terminal `DIRECT_SLOT` commit step,否则 struct 值语义 carrier 的 mutation 会随 copy-on-write detach 静默丢失;packed snapshot 与源 slot 共享身份,无需 step。`PARAMETER` receiver 不发布 step(§3.5);packed 形参经共享身份对调用方可见。 + ### 4.4 CAPTURE 的当前结论 -`CAPTURE` 已进入 lambda storage 合同(`construct_lambda` + capture block 拷贝),但 **capture-backed live-slot alias surface 仍未开放**,因此: +`CAPTURE` 已进入 lambda storage 合同(`construct_lambda` + capture block 拷贝),并作为 +`DirectSlotAliasRootKind.CAPTURE` 放行 direct-slot alias root: -- `CAPTURE` 不参与 alias eligibility -- 一旦 capture-backed identifier 进入 alias path,当前实现必须 fail-fast -- 若未来要开放 capture alias,必须先建立独立的 storage / rebinding / alias-safety 证明链 +- capture-backed identifier 的 mutating receiver 可以发布 alias,alias 绑定 lambda 自身的捕获槽 +- 对 capture 名的**赋值**仍保持 copy-on-capture(不 rebound 外层槽),不在 alias 范围内 +- packed 捕获经 Variant 持有者拷贝与外部变量共享数组身份,mutating call 的变更双向可见(与解释器一致) ## 5. writeback family 与 runtime helper 合同 ### 5.1 静态 family matrix -`FrontendWritableTypeWritebackSupport` 是“哪些 statically known carrier family 需要 reverse commit”的 frontend 共享真源。 +`FrontendWritableTypeWritebackSupport` 是“哪些 statically known carrier family 需要 reverse commit”的 frontend 共享真源;对 `Packed*Array` 的回答按 route provenance(`WritebackRouteProvenance`)分流,不再只按 family 二值。 当前固定为: -- `false` +- `false`(与 route 无关) - `Array` - `Dictionary` - `Object` - primitive family -- `true` - - value-semantic builtin family +- `true`(与 route 无关) + - struct value-semantic builtin family(`String`、`Vector*`、`Color` 等) - `Variant` +- `Packed*Array`(按 provenance) + - `false`:`DIRECT_SLOT`(local-var snapshot 共享身份)、`STATIC_PROPERTY`(静态 leaf 自持共享身份,不追加 promotion step)、`ENGINE_PROPERTY_CALL`(内建引擎属性 mutating-call,如 `poly.polygon.push_back(...)`——引擎 getter 返回副本,解释器同样不持久,见 §7.1) + - `true`:`SCRIPT_PROPERTY`、`CONTAINER_ELEMENT`(保留:同身份冗余存回,无害)、`GENERIC`(赋值 route,如 `poly.polygon[0] = v` 的 read-modify-write 合同,语义必需) +- direct-slot snapshot commit 另有 per-route 谓词 `requiresDirectSlotSnapshotCommit(...)`:仅 packed 豁免,其余 family 保持历史发布行为(历史上 `DIRECT_SLOT` step 发布不经过 family 谓词,对 `Array`/`Dictionary` 等共享 carrier 也会冗余自赋值;本次只切除 packed,避免计划外行为变更)。 `Variant` 在静态 helper 中返回 `true` 的含义不是“必定写回”,而是“静态阶段还不能证明该 carrier 属于 shared/reference family,需要交给 runtime helper 继续细分”。 @@ -330,6 +356,7 @@ runtime-gated reverse commit 可能插入 `apply / skip / continue` block,因 - `ARRAY` - `DICTIONARY` - `OBJECT` + - 全部 10 个 packed kind(`PACKED_BYTE_ARRAY` / `PACKED_INT32_ARRAY` / `PACKED_INT64_ARRAY` / `PACKED_FLOAT32_ARRAY` / `PACKED_FLOAT64_ARRAY` / `PACKED_STRING_ARRAY` / `PACKED_VECTOR2_ARRAY` / `PACKED_VECTOR3_ARRAY` / `PACKED_COLOR_ARRAY` / `PACKED_VECTOR4_ARRAY`)——Variant-backed 共享身份无需写回;`PACKED_VECTOR4_ARRAY` 必须显式列出,不得落入 default - 返回 `true` - `String` - `StringName` @@ -346,8 +373,7 @@ runtime-gated reverse commit 可能插入 `apply / skip / continue` block,因 - `RID` - `Callable` - `Signal` - - `Packed*Array` -- 对未列举 future `Variant` kind 默认返回 `true` +- 对未列举 future `Variant` kind 默认返回 `true`(冻结合同:未知 kind 保守 true,防止新增 value-semantic family silent false-negative) ### 5.3 helper 的边界 @@ -399,21 +425,31 @@ backend 对 destroyable non-object slot write 已集中收口到 `CBodyBuilderAl 是否需要 writeback,不能只看“当前 carrier 是不是 `Packed*Array`”。当前必须继续区分: -- property route +- 内建引擎属性 mutating-call route(`ENGINE_PROPERTY_CALL`) - 例如 `poly.polygon.push_back(...)` - - 需要 property writeback + - **不写回**:引擎 getter 返回副本,解释器中该 mutation 同样不持久;gdcc 严格对齐(这是有意的行为变更,旧的 property writeback 已移除) +- 内建引擎属性赋值 route(`GENERIC` / assignment) + - 例如 `poly.polygon = p`、`poly.polygon[0] = v` + - 写回保留:read-modify-write 语义,解释器中持久 + - 实现经 `InstancePropertyCommitStep.engineProperty` 标记 + `reverseCommitWithRuntimeGate` 的 `ReverseCommitRouteOrigin`(`MUTATING_CALL` vs `ASSIGNMENT`)分流;engine 属性的正向识别必须单向安全——script 属性永不误标为 engine(否则保留的写回合同被错误跳过),engine 属性漏识别会把 getter 副本的 mutation 错误持久化(已知分歧风险,新锚点形态必须显式分类) +- GDCC 脚本属性 route(`SCRIPT_PROPERTY`) + - 例如 `self.payloads.push_back(...)` + - 写回保留(存回同一身份,冗余无害) +- Array/Dictionary 元素 route(`CONTAINER_ELEMENT`) + - 写回保留(同身份冗余存回,冗余无害) - method result route - 例如 `curve.get_baked_points().push_back(...)` - 不得写回到原 receiver -- ordinary local route +- ordinary local route(`DIRECT_SLOT`) - 例如 `var pts = poly.polygon; pts.push_back(...)` - 只修改 local,不反向写回 property + - 实现上对应 §3.5 的两条路线:alias 成功时原位修改 local;alias 被拒绝走 snapshot 时,packed 的 snapshot 与源 slot 共享身份,mutation 直接可见,无需 commit step;struct 值语义 carrier 仍需把变更后的 temp 写回 local slot。二者都不产生 property store 同一个 runtime family 若 provenance 不同,Godot 语义就可能不同。runtime gate 只能判断当前层 carrier family,不能替代 provenance。 -### 7.2 property-backed dynamic `Variant` receiver +### 7.2 property-backed dynamic `Variant` receiver(SCRIPT_PROPERTY route) -对显式 `Variant` property / field 上的 mutating receiver call,当前已经冻结为 property-backed writeback,而不是 plain snapshot。 +对显式 `Variant` 的 GDCC **脚本** property / field 上的 mutating receiver call,当前已经冻结为 property-backed writeback,而不是 plain snapshot。 示例: @@ -432,7 +468,7 @@ backend 对 destroyable non-object slot write 已集中收口到 `CBodyBuilderAl - 但 payload root 仍保留 direct-slot owner `box` - 因而 runtime-gated property store 最终写回 `box` -这不是偶发差异,而是当前 CFG / body-lowering 的刻意分层。 +这不是偶发差异,而是当前 CFG / body-lowering 的刻意分层。注意本 route 只覆盖脚本属性;内建引擎属性的 mutating-call route 已在 §7.1 移除写回,二者不得混淆。 ## 8. 长期风险与后续工程提醒 @@ -456,17 +492,19 @@ payload 若重新承载求值解释,或 body lowering 恢复 AST replay,就 ### 8.3 runtime helper 覆盖面风险 -`gdcc_variant_requires_writeback(...)` 对未列举 future `Variant` kind 的默认策略必须保持保守 `true`。若改成保守 `false`,新增 value-semantic family 会 silent false-negative。 +`gdcc_variant_requires_writeback(...)` 对未列举 future `Variant` kind 的默认策略必须保持保守 `true`。若改成保守 `false`,新增 value-semantic family 会 silent false-negative。全部 10 个 packed kind 已显式列入 `false` 组(含 `PACKED_VECTOR4_ARRAY`);新增 packed-like kind 时不得依赖 default 分支“顺带正确”,必须显式分类。 ### 8.4 性能与 code size 风险 dynamic / `Variant` route 的 runtime gate 会增加分支和 block 数量。当前的开销控制手段必须保持: - 只对 runtime-open `Variant` carrier 发 gate -- statically shared/reference carrier 直接 fast-skip -- statically value-semantic carrier 直接 inline writeback +- statically shared/reference carrier(`Object`/`Array`/`Dictionary`)直接 fast-skip;packed 按 §5.1 的 provenance 静态答案走 fast-skip 或 inline writeback +- statically 仍需 commit 的 carrier 直接 inline writeback - 不为 exact route、shared carrier 或 ordinary read-only call 平白增加 scaffold +动态 Variant 调用在缺少 const 事实时保守按 `mayMutateReceiver == true` 发射 gate,因此 runtime helper 的 `true` 分支对合法的非 mutating 调用仍会执行冗余但无害的写回(写回未被修改的 carrier),这是已接受的保守行为。 + ## 9. 回归测试基线 至少要继续覆盖以下测试面: @@ -475,20 +513,24 @@ dynamic / `Variant` route 的 runtime gate 会增加分支和 block 数量。当 - frozen writable-route payload shape - assignment payload-only final store - carrier-threaded reverse commit - - static gate 只看 current carrier type + - static gate 只看 current carrier type + route provenance - dynamic receiver runtime gate + continuation block threading - - direct-slot alias publication 的 happy / negative path + - direct-slot alias publication 的 happy / negative path(含 `CAPTURE` root 放行) + - direct-slot snapshot receiver 的 `DIRECT_SLOT` commit step:struct 值语义 exact 静态写回、packed 豁免(无 step)、共享 carrier 冗余 step 保留、dynamic `Variant` runtime gate 分支,以及 descriptor shape / step 位置负例 + - packed route-provenance 分流:内建引擎属性 mutating-call 无写回(7a)、赋值 route 写回保留(7c)、脚本属性 / 容器元素 route 写回保留 - `IdentifierExpression + SELF` fail-fast - assignment / call 不得重复 getter / subscript get / pack / unpack - backend / codegen - `gdcc_variant_requires_writeback(...)` helper emission - - value-semantic / shared family matrix + - value-semantic / shared family matrix(10 个 packed kind 全 `false`,含 Vector4) - non-object slot write `PROVEN_NO_ALIAS` / `MAY_ALIAS` - getter-self / setter-self backing-field fast path - integration - typed property-backed mutating receiver - dynamic `Variant` property-backed mutating receiver - - `PackedInt32Array` 与 `Array` 两条 runtime helper 路线 + - ordinary local/parameter direct-slot snapshot receiver(含 loop + nested-call argument 场景) + - `PackedInt32Array`(runtime gate `false`)与 `String`(runtime gate `true`)两条 runtime helper 路线 + - 内建引擎属性:mutating-call 不持久 / 下标赋值持久的双跑锚定 - key/index side effect route 当前代码库中已被明确用作锚点的测试类包括: @@ -511,7 +553,7 @@ dynamic / `Variant` route 的 runtime gate 会增加分支和 block 数量。当 - 把 writable route 提升成公共 LIR place/reference model - 为 dynamic call 新增专用 LIR instruction - 让 backend 从 call/property/index instruction 反推 frontend owner route -- 提前把 lambda/capture surface 并入 direct-slot alias publication +- 把 capture 名的**赋值**提升为 rebound 外层槽(copy-on-capture 保持) - 用阶段性计划文档继续充当长期事实源 ## 工程反思 diff --git a/doc/module_impl/frontend/frontend_dynamic_call_lowering_implementation.md b/doc/module_impl/frontend/frontend_dynamic_call_lowering_implementation.md index f43a3028..c0857863 100644 --- a/doc/module_impl/frontend/frontend_dynamic_call_lowering_implementation.md +++ b/doc/module_impl/frontend/frontend_dynamic_call_lowering_implementation.md @@ -96,11 +96,11 @@ call result type 的正式真源是 call anchor 对应的 `analysisData.expressi - 同一个 payload 仅负责 exact route 的 post-call reverse commit - direct-slot mutating receiver 会直接发布 alias-backed receiver value,因此 exact route 继续只消费 dedicated `receiverValueIdOrNull`,而不是再由 call lowering 额外解释“synthetic CFG temp -> 真实源 slot” - 与之对应,non-mutating / runtime-open 的 direct-slot receiver 继续停留在 ordinary temp-backed value surface;frontend 不会把 alias publication 泛化到所有 identifier/self ordinary read - - 这里的 direct-slot publication surface 只包含 explicit `SelfExpression` 与 `IdentifierExpression + LOCAL_VAR/PARAMETER`;`CAPTURE` 虽已进入 lambda storage 合同,但仍不在 alias publication surface 内。`receiverValueIdOrNull == null` 时由 `resolveInstanceCallReceiver(...)` fallback 到 `self` 的 implicit self receiver 仍属于 call execution fallback,不属于 alias publication + - 这里的 direct-slot publication surface 包含 explicit `SelfExpression` 与 `IdentifierExpression + LOCAL_VAR/PARAMETER/CAPTURE`(capture alias 绑定 lambda 自身捕获槽,见下文)。`receiverValueIdOrNull == null` 时由 `resolveInstanceCallReceiver(...)` fallback 到 `self` 的 implicit self receiver 仍属于 call execution fallback,不属于 alias publication - `IdentifierExpression + SELF` 不是合法的 published receiver surface:当前 analyzer 只会对 explicit `SelfExpression` 发布 `SELF`,所以 builder 与 body lowering 遇到它都必须 fail-fast,而不是再把 identifier 静默恢复成 `"self"` - 对 identifier-backed direct-slot alias,builder 现在额外要求:后续 arguments 必须停留在 proven no-rebinding 子集;若参数包含 nested `CallExpression` / `AttributeCallStep` 或其它当前尚未证明安全的 effect-open surface,则回退 ordinary temp snapshot,而不是继续发布 live-slot alias - explicit `SelfExpression` 不受这条参数分类限制,因为它的稳定性来自 `self` slot 本身不可被用户代码重绑定 - - `CAPTURE` 当前也不参与这条 identifier-backed alias 分类:lambda/capture storage 已落地,但 capture-backed live-slot alias 仍未开放;这类 binding 若意外进入 alias path 必须直接 fail-fast + - `CAPTURE` 已作为 `DirectSlotAliasRootKind.CAPTURE` 放行 direct-slot alias root(alias 绑定 lambda 自身捕获槽;对 capture 名的赋值仍保持 copy-on-capture,不在 alias 范围内) - mutating dynamic instance route 现在也正式纳入 receiver-side writeback 合同: - `FrontendCallMutabilitySupport` 对 `DYNAMIC_FALLBACK + INSTANCE` 保守返回 may-mutate - 这条保守策略不读取“方法名是否看起来 const-like”这类弱事实;例如 `size()`、`length()` 之类 @@ -108,12 +108,14 @@ call result type 的正式真源是 call anchor 对应的 `analysisData.expressi - 因此 direct-slot dynamic receiver 现在与 exact mutating route 一样,可以在满足当前 alias eligibility 合同时直接发布 alias-backed receiver value,而不是退回 dead temp snapshot - 对 direct-slot receiver,这意味着即使 dynamic fallback 最终调用的是 const-like method,当前可观测行为仍可能是 `CallMethodInsn.objectId = source_slot`、而不是 ordinary `cfg_tmp_*` snapshot - - property/subscript receiver 继续通过同一个 payload 提供 leaf provenance 与 reverse-commit step + - property/subscript receiver 继续通过同一个 payload 提供 leaf provenance 与 reverse-commit step;step 的发布按 route provenance 分流——packed 的内建引擎属性 mutating-call route(`ENGINE_PROPERTY_CALL`)不发布 step(引擎 getter 返回副本,写回会被错误持久化),packed 的脚本属性(`SCRIPT_PROPERTY`)/ 容器元素(`CONTAINER_ELEMENT`)route 保留 step(同身份冗余存回,无害) - 对 property-backed `Variant` receiver,这意味着即使 dynamic fallback 最终调用的是 const-like method, body lowering 当前仍会保守地产生 `gdcc_variant_requires_writeback(...) + GoIfInsn + StorePropertyInsn` 这套 writeback scaffold;是否真的执行 writeback 只由 runtime helper 与 carrier family 决定 -- 对“显式声明为 `Variant` 的实例属性/字段值继续链式调用 mutating method”这一条路线, - 当前行为已经冻结为 property-backed dynamic receiver writeback,而不是 plain snapshot: +- 对“显式声明为 `Variant` 的 GDCC **脚本**实例属性/字段值继续链式调用 mutating method”这一条路线, + 当前行为已经冻结为 property-backed dynamic receiver writeback,而不是 plain snapshot + (`SCRIPT_PROPERTY` provenance;内建引擎属性的 mutating-call route 已移除写回,见 §7 回归锚点与 + `frontend_complex_writable_target_implementation.md` §7.1): - 示例: - `self.payloads.push_back(seed)` - `box.payloads.push_back(seed)` @@ -145,8 +147,9 @@ call result type 的正式真源是 call anchor 对应的 `analysisData.expressi - 当前 dynamic mutating call 的 post-call commit 合同固定为: - 先发普通 `CallMethodInsn` - 若 receiver route 未发布 writable payload,lowering 到此结束 - - 若 receiver route 已发布 writable payload,则 shared support 先对静态已知 family 走 fast-path/fast-skip - - 仅当 current carrier 是 runtime-open `Variant` 时,body lowering 才追加 `CallGlobalInsn("gdcc_variant_requires_writeback", ...) + GoIfInsn` + - 若 receiver route 已发布 writable payload,则 shared support 先按 route provenance 分流(packed 的 + `ENGINE_PROPERTY_CALL` mutating-call route 不进入写回流程),再对静态已知 family 走 fast-path/fast-skip + - 仅当 current carrier 是 runtime-open `Variant` 时,body lowering 才追加 `CallGlobalInsn("gdcc_variant_requires_writeback", ...) + GoIfInsn`;全部 10 个 packed kind 在该 helper 中返回 `false` - 后续 sequence item 必须继续附着到 `reverseCommitWithRuntimeGate(...)` 返回的 continuation block,而不是原 lexical block - 为承接这条 continuation-block 合同,body lowering 的 processor / registry / sequence-item 调度面也必须显式 thread 当前 block;call lowering 不得再假设“所有后续 instruction 永远继续附着在原始 sequence block 上” @@ -189,6 +192,7 @@ exact route 与 dynamic route 的参数物化边界必须继续分离: - body lowering 不读取 exact callable signature - body lowering 不为参数臆造 fixed parameter type - 已求值的 argument slot 直接透传给 `CallMethodInsn` + - packed 实参的存储本身即 Variant-backed:传参即共享身份,callee 的 mutation 对调用方天然可见,不需要也不允许追加任何 snapshot/commit step - receiver 侧若已被 CFG 发布为 writable access-chain payload,则由独立 writable-route logic 处理,不属于 ordinary argument boundary 合同 dynamic call 的 published result slot 继续固定为 `Variant`。 @@ -247,7 +251,12 @@ frontend 重新介入的位置只有两个,而且都必须受已发布事实 - `runStillEmitsRuntimeGatedWritebackForDynamicConstLikePropertyReceiver` - `runEmitsRuntimeGatedWritebackForExplicitVariantPropertyOnObjectReceiver` - `runFailsFastWhenSyntheticDynamicFallbackDoesNotUseInstanceReceiverRoute` + - `runSkipsWritebackForBuiltinEnginePropertyPackedReceiverMutatingCall`(7a:内建引擎属性 packed mutating-call 不写回) + - `runKeepsBuiltinEnginePropertySubscriptWritebackOnAssignmentRoute`(7c:内建引擎属性下标赋值写回保留) - mutating dynamic receiver route 一旦发布 writable access-chain payload,body lowering 必须整体消费该 payload,而不是重新拆分 receiver chain +- `FrontendWritableRouteSupportTest` + - route-provenance 分流锚定:mutating-call gate 对 packed engine 属性跳过 / 对 packed script 属性保留 / + runtime-gate 路径 mutating-call 跳过 / assignment route 对同一 engine 属性 step 保留 - `FrontendLoweringToCProjectBuilderIntegrationTest` - `lowerFrontendDynamicInstanceCallRoutesBuildNativeLibraryAndRunInGodot` - `lowerFrontendDynamicVariantReceiverWritebackBuildNativeLibraryAndRunInGodot` diff --git a/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md b/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md index 8b367341..a9bd947c 100644 --- a/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md +++ b/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md @@ -259,12 +259,13 @@ plain assignment、compound assignment 与 constructor materialization 当前各 - 若 `RESOLVED(void)` call 仍出现在 value-required path,CFG builder 必须立刻 fail-fast,而不是继续发布一个假想 result value id;这是 compile gate / type-check regression 的 guard rail,不是兼容路径 - 若某个 call site 后续需要 mutating receiver writeback,则同一个 `CallItem` 还必须承载单个 writable receiver access-chain payload - 这条 chain payload 必须以“整条 route”的形式冻结;CFG 不得为同一个 call receiver 再发布一串额外 step item 让 body lowering 事后拼装 - - 对 property/subscript receiver call,payload 的 `reverseCommitSteps` 还必须包含“当前 leaf 提升后的第一层 commit step”;否则 body lowering 只有 receiver provenance,却没有真正可执行的 post-call writeback plan + - 对 property/subscript receiver call,payload 的 `reverseCommitSteps` 还必须包含“当前 leaf 提升后的第一层 commit step”;否则 body lowering 只有 receiver provenance,却没有真正可执行的 post-call writeback plan。该 step 的发布受 route provenance 门控:packed 的内建引擎属性 mutating-call route(`ENGINE_PROPERTY_CALL`)不发布 step(引擎 getter 返回副本,写回会被错误持久化);packed 的赋值 route(`GENERIC`)与脚本属性 / 容器元素 route 仍发布 step - call result runtime type 的真源是 call anchor 对应的 `expressionTypes()`;`resolvedCalls()` 只负责 route fact,不是 `DYNAMIC` call result type 的唯一来源 当前 body lowering 侧已经把 writable-route 的 leaf read / leaf write / reverse commit 共用逻辑收敛到 package-private `FrontendWritableRouteSupport`。current-carrier family 的静态 writeback matrix 则收口到 public -`FrontendWritableTypeWritebackSupport`,避免 assignment lowering、runtime gate 与后续测试各自复制一份 family +`FrontendWritableTypeWritebackSupport`(含 `WritebackRouteProvenance` 分流与 packed-only 的 +`requiresDirectSlotSnapshotCommit` 门),避免 assignment lowering、runtime gate 与后续测试各自复制一份 family 表。当前 CFG 已经能通过 `FrontendWritableRoutePayload` 在 `CallItem` / `AssignmentItem` 上冻结整条 writable route,graph publication 也会校验这类 payload 的本地 value-id 引用顺序,并额外拒绝 non-terminal static property commit step。 assignment final-store lowering 已经切到 payload-only route;legacy `targetOperandValueIds` 只继续保留给 source-order @@ -289,13 +290,13 @@ dynamic instance-call receiver 现也冻结为同一套 payload consumer: 当前 direct-slot alias publication 合同已经冻结为: - direct-slot mutating receiver 已改为发布 alias-backed receiver value,而不是继续依赖 body lowering 额外解释“synthetic temp -> source slot” -- 这条 direct-slot publication surface 只包含 explicit `SelfExpression`、`IdentifierExpression + LOCAL_VAR`、`IdentifierExpression + PARAMETER` -- `IdentifierExpression + CAPTURE` 当前不在 alias publication surface 内。lambda/capture lowering 与 storage semantics 已落地(`construct_lambda` + capture block),但 capture-backed live-slot alias 仍未开放,不能提前把它视为 alias root +- 这条 direct-slot publication surface 包含 explicit `SelfExpression`、`IdentifierExpression + LOCAL_VAR`、`IdentifierExpression + PARAMETER`、`IdentifierExpression + CAPTURE`(`DirectSlotAliasRootKind.CAPTURE`;alias 绑定 lambda 自身捕获槽,对 capture 名的赋值仍保持 copy-on-capture,不在 alias 范围内) - `IdentifierExpression + FrontendBindingKind.SELF` 在当前代码库中不是独立 source category:`FrontendTopBindingAnalyzer` 只会对 `SelfExpression` 发布 `SELF`,因此一旦这种 surface 泄漏到 builder 或 body lowering,二者都必须把它当作 contract violation 直接 fail-fast,而不是恢复成 `"self"` - `receiverValueIdOrNull == null` 时 fallback 到 `self` 的 implicit self receiver 不属于 payload-backed receiver publication,也不属于 direct-slot alias root - explicit `SelfExpression` 的 alias 安全性来自 `self` slot 不可被用户代码重绑定,因此不需要额外 argument no-rebinding 分类 -- `IdentifierExpression + LOCAL_VAR/PARAMETER` 只有在后续 arguments 全部落在 proven no-rebinding 子集时才允许 alias publication -- `IdentifierExpression + CAPTURE` 在当前实现中必须 fail-fast;capture-backed live-slot alias eligibility 仍未开放 +- `IdentifierExpression + LOCAL_VAR/PARAMETER/CAPTURE` 只有在后续 arguments 全部落在 proven no-rebinding 子集时才允许 alias publication +- `DIRECT_SLOT` snapshot commit step 仅服务 `LOCAL_VAR` root 且受 `requiresDirectSlotSnapshotCommit(carrier)` 门控(packed 豁免:snapshot 为 Variant 持有者拷贝,与源 slot 共享身份);`PARAMETER` / `CAPTURE` root 不发布 step +- `STATIC_CONTEXT` bare 静态属性 route 上,packed receiver 不再追加 promotion step(静态 leaf 保持终态,Variant-backed 存储使 mutation 经共享身份天然可见);`validateStaticWritableRouteTerminalContract` 合同原样保留 - 当前 CFG builder 已明确把 nested `CallExpression`、`AttributeCallStep` 和其它尚未证明 no-rebinding 的 effect-open expression kind 视为 snapshot fallback trigger:遇到这些参数时继续保留 ordinary `OpaqueExprValueItem(identifier)`,不再发布 live-slot alias 其中 compound assignment 的 source-order 合同固定为: diff --git a/src/main/c/codegen/include_451/gdcc/gdcc_helper.h b/src/main/c/codegen/include_451/gdcc/gdcc_helper.h index f1e850db..cbc8724c 100644 --- a/src/main/c/codegen/include_451/gdcc/gdcc_helper.h +++ b/src/main/c/codegen/include_451/gdcc/gdcc_helper.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -572,8 +573,17 @@ static inline godot_bool gdcc_is_instance_of_typed_dictionary_variant( /// Positive polarity is intentional and must stay aligned with the frontend writable-target facts: /// - false for statically shared/reference families (`Array`, `Dictionary`, `Object`) and /// primitive-like scalars that do not carry value-style owner writeback +/// - false for all ten `Packed*Array` kinds: they are Variant-backed and share the engine-side +/// array identity with their owner slot, so the mutation is already visible and a writeback +/// would be a redundant same-identity store. This covers runtime-gated *owner-chain* writebacks +/// only; on mutating-call routes the frontend provenance already drops the writeback for +/// *correctly identified* engine-property getter copies (a missed identification keeps the +/// legacy writeback and wrongly persists the mutation, so new anchor shapes must be classified +/// explicitly there instead of relying on this helper) +/// (all ten kinds are listed explicitly; relying on `default` would silently mis-answer future +/// audits) /// - true for value-semantic builtin families such as `String`, `Vector*`, `Color`, -/// `Transform*`, `Callable`, `Signal`, `RID`, and `Packed*Array` +/// `Transform*`, `Callable`, `Signal`, and `RID` /// - default true for unlisted future Variant kinds so newly introduced value-semantic carriers do /// not silently tunnel through runtime-gated writeback as a false negative static godot_bool gdcc_variant_requires_writeback(const godot_Variant *value) { @@ -588,6 +598,16 @@ static godot_bool gdcc_variant_requires_writeback(const godot_Variant *value) { case GDEXTENSION_VARIANT_TYPE_ARRAY: case GDEXTENSION_VARIANT_TYPE_DICTIONARY: case GDEXTENSION_VARIANT_TYPE_OBJECT: + case GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY: + case GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY: + case GDEXTENSION_VARIANT_TYPE_PACKED_INT64_ARRAY: + case GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT32_ARRAY: + case GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT64_ARRAY: + case GDEXTENSION_VARIANT_TYPE_PACKED_STRING_ARRAY: + case GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR2_ARRAY: + case GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR3_ARRAY: + case GDEXTENSION_VARIANT_TYPE_PACKED_COLOR_ARRAY: + case GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY: return false; case GDEXTENSION_VARIANT_TYPE_STRING: case GDEXTENSION_VARIANT_TYPE_VECTOR2: @@ -611,15 +631,6 @@ static godot_bool gdcc_variant_requires_writeback(const godot_Variant *value) { case GDEXTENSION_VARIANT_TYPE_RID: case GDEXTENSION_VARIANT_TYPE_CALLABLE: case GDEXTENSION_VARIANT_TYPE_SIGNAL: - case GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY: - case GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY: - case GDEXTENSION_VARIANT_TYPE_PACKED_INT64_ARRAY: - case GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT32_ARRAY: - case GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT64_ARRAY: - case GDEXTENSION_VARIANT_TYPE_PACKED_STRING_ARRAY: - case GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR2_ARRAY: - case GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR3_ARRAY: - case GDEXTENSION_VARIANT_TYPE_PACKED_COLOR_ARRAY: return true; default: return true; diff --git a/src/main/c/codegen/include_451/gdcc/gdcc_packed_ref.h b/src/main/c/codegen/include_451/gdcc/gdcc_packed_ref.h new file mode 100644 index 00000000..135086c1 --- /dev/null +++ b/src/main/c/codegen/include_451/gdcc/gdcc_packed_ref.h @@ -0,0 +1,171 @@ +#ifndef GDCC_PACKED_REF_H +#define GDCC_PACKED_REF_H + +#include +#include +#include +#include + +/// Variant-backed Packed*Array storage infrastructure. +/// +/// In the reference-semantics model every Packed*Array slot (locals, parameters, fields, coroutine +/// frames, wrappers) stores a `godot_Variant` whose internal `PackedArrayRef` is shared with all +/// aliases; identity flows exclusively through `godot_new_Variant_with_Variant` / +/// `godot_variant_destroy`. The only places where a value may cross the struct<->Variant boundary +/// are the whitelisted conversions below, each behind a named per-family helper so generated code +/// never calls the raw `godot_new_Packed*Array_with_*` / `godot_new_Variant_with_Packed*Array` +/// symbols directly (enforced by grep): +/// +/// (a) ptrcall ABI boundary, both directions (the identity-isolation exception): +/// - `gdcc_packed__variant_from_struct` inbound materialization of a raw arg slot +/// - `gdcc_packed__struct_from_variant` outbound copy written to the return slot +/// Both directions copy at the engine `Vector` layer, so mutation across the ptrcall +/// boundary is intentionally NOT shared. +/// (b) `gdcc_packed__new_empty` default initialization to an empty array Variant; +/// the temporary struct has no sharers at this point. A nil Variant is never +/// a valid packed value: it has no internal pointer and method calls on it fail. +/// (c) `gdcc_packed__wrap_temp` wraps a native struct temporary produced by a builtin +/// method/operator return (e.g. `duplicate`, `slice`, `+`) into a fresh Variant and +/// destroys the temporary; the result is an independent new array. +/// (d) construction with arguments: +/// - `gdcc_packed__new_copy` same-family copy construct (`Packed*Array(other)` and +/// same-family `as` cast): produces an independent new array, never shares identity +/// - `gdcc_packed__new_from_array` cross-type construct from `godot_Array` +/// +/// Initialization contract: `gdcc_packed_ref_init()` must run once per translation unit during +/// extension initialization (mirroring `gdcc_init`) before any other helper here is used. It +/// resolves the per-family `variant_get_ptr_internal_getter` getters exactly once and fail-fasts +/// (error print + abort) when Godot does not expose one; calling an accessor without +/// initialization fail-fasts the same way instead of dereferencing a NULL getter. + +/// Reports a fatal packed-ref infrastructure error and aborts the process. These paths indicate +/// an engine/ABI mismatch or a missing init call, i.e. unrecoverable codegen-level contract +/// violations, so there is no attempt at graceful degradation. The engine log is preferred, but +/// the interface pointer table may not be resolved yet (e.g. an accessor used before +/// `godot_initialize_interface`), in which case the same diagnostic goes to stderr; abort is +/// guaranteed either way. +static _Noreturn void gdcc_packed_ref_fail(const char *desc) { + if (gdcc_interface_print_error != NULL) { + godot_print_error(desc, "gdcc_packed_ref", __FILE__, 0, true); + } else { + fputs(desc, stderr); + fputc('\n', stderr); + fflush(stderr); + } + abort(); +} + +/// Identity-preserving holder copy: the result shares the source's underlying packed array. +static inline godot_Variant gdcc_packed_ref_copy(const godot_Variant *src) { + return godot_new_Variant_with_Variant(src); +} + +/// Releases one Variant holder; the shared array dies with its last holder. +static inline void gdcc_packed_ref_destroy(godot_Variant *value) { + godot_variant_destroy((GDExtensionVariantPtr)value); +} + +/// Runtime family test used by `is`/cast lowering: exact Variant kind match. +static inline godot_bool gdcc_packed_ref_is(const godot_Variant *value, GDExtensionVariantType expected_kind) { + return value != NULL && godot_variant_get_type(value) == expected_kind; +} + +/// Defines the per-family getter cache and the whitelisted conversion helpers for one +/// Packed*Array family. The getter static is per-TU (same pattern as `_gd_engine` in +/// gdcc_helper.h), which is why `gdcc_packed_ref_init()` must run in every TU that uses these. +#define GDCC_PACKED_REF_DEFINE_FAMILY(Slug, TypeName, VariantKind) \ +static GDExtensionVariantGetInternalPtrFunc gdcc_packed_##Slug##_getter = NULL; \ +\ +/* Internal value pointer of the family Variant, used as the base for builtin method / index / \ + * operator calls. The engine getter is exposed only in a non-const signature but \ + * does not mutate the Variant, so the const cast is safe. The caller must guarantee the Variant \ + * actually holds this family (`gdcc_packed_ref_is`); a mismatch is engine-level UB \ + * and is NOT reliably detectable here — the NULL result check below is only a backstop for an \ + * engine that does return NULL, not a type-mismatch guard. \ + * NOTE: keep macro-body comments as block comments — `//` would swallow the rest of the macro. */ \ +static inline godot_Packed##TypeName *gdcc_packed_##Slug##_internal_ptr(const godot_Variant *self) { \ + if (unlikely(self == NULL)) { \ + gdcc_packed_ref_fail("gdcc_packed_" #Slug "_internal_ptr called with NULL Variant"); \ + } \ + if (unlikely(gdcc_packed_##Slug##_getter == NULL)) { \ + gdcc_packed_ref_fail("gdcc_packed_" #Slug "_internal_ptr used before gdcc_packed_ref_init()"); \ + } \ + void *internal = gdcc_packed_##Slug##_getter((GDExtensionVariantPtr)self); \ + if (unlikely(internal == NULL)) { \ + gdcc_packed_ref_fail("variant_get_ptr_internal_getter returned NULL internal pointer for " #TypeName); \ + } \ + return (godot_Packed##TypeName *)internal; \ +} \ +\ +static inline godot_Variant gdcc_packed_##Slug##_new_empty(void) { \ + godot_Packed##TypeName temp = godot_new_Packed##TypeName(); \ + godot_Variant value = godot_new_Variant_with_Packed##TypeName(&temp); \ + godot_Packed##TypeName##_destroy(&temp); \ + return value; \ +} \ +\ +static inline godot_Variant gdcc_packed_##Slug##_wrap_temp(godot_Packed##TypeName *temp) { \ + godot_Variant value = godot_new_Variant_with_Packed##TypeName(temp); \ + godot_Packed##TypeName##_destroy(temp); \ + return value; \ +} \ +\ +static inline godot_Variant gdcc_packed_##Slug##_variant_from_struct(const godot_Packed##TypeName *src) { \ + return godot_new_Variant_with_Packed##TypeName(src); \ +} \ +\ +static inline godot_Packed##TypeName gdcc_packed_##Slug##_struct_from_variant(const godot_Variant *src) { \ + return godot_new_Packed##TypeName##_with_Variant(src); \ +} \ +\ +static inline godot_Variant gdcc_packed_##Slug##_new_copy(const godot_Variant *src) { \ + godot_Packed##TypeName temp = \ + godot_new_Packed##TypeName##_with_Packed##TypeName(gdcc_packed_##Slug##_internal_ptr(src)); \ + return gdcc_packed_##Slug##_wrap_temp(&temp); \ +} \ +\ +static inline godot_Variant gdcc_packed_##Slug##_new_from_array(const godot_Array *from) { \ + godot_Packed##TypeName temp = godot_new_Packed##TypeName##_with_Array(from); \ + return gdcc_packed_##Slug##_wrap_temp(&temp); \ +} + +GDCC_PACKED_REF_DEFINE_FAMILY(byte_array, ByteArray, GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY) +GDCC_PACKED_REF_DEFINE_FAMILY(int32_array, Int32Array, GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY) +GDCC_PACKED_REF_DEFINE_FAMILY(int64_array, Int64Array, GDEXTENSION_VARIANT_TYPE_PACKED_INT64_ARRAY) +GDCC_PACKED_REF_DEFINE_FAMILY(float32_array, Float32Array, GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT32_ARRAY) +GDCC_PACKED_REF_DEFINE_FAMILY(float64_array, Float64Array, GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT64_ARRAY) +GDCC_PACKED_REF_DEFINE_FAMILY(string_array, StringArray, GDEXTENSION_VARIANT_TYPE_PACKED_STRING_ARRAY) +GDCC_PACKED_REF_DEFINE_FAMILY(vector2_array, Vector2Array, GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR2_ARRAY) +GDCC_PACKED_REF_DEFINE_FAMILY(vector3_array, Vector3Array, GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR3_ARRAY) +GDCC_PACKED_REF_DEFINE_FAMILY(color_array, ColorArray, GDEXTENSION_VARIANT_TYPE_PACKED_COLOR_ARRAY) +GDCC_PACKED_REF_DEFINE_FAMILY(vector4_array, Vector4Array, GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY) + +/// Resolves and caches every family's internal pointer getter; fail-fast on the first missing one +/// so an engine without `variant_get_ptr_internal_getter` support never reaches a call site. +static void gdcc_packed_ref_init(void) { + if (unlikely(gdcc_interface_variant_get_ptr_internal_getter == NULL)) { + gdcc_packed_ref_fail("variant_get_ptr_internal_getter interface unresolved " + "(gdcc_packed_ref_init called before godot_initialize_interface?)"); + } +#define GDCC_PACKED_REF_INIT_FAMILY(Slug, TypeName, VariantKind) \ + gdcc_packed_##Slug##_getter = godot_variant_get_ptr_internal_getter(VariantKind); \ + if (unlikely(gdcc_packed_##Slug##_getter == NULL)) { \ + gdcc_packed_ref_fail("variant_get_ptr_internal_getter unavailable for " #TypeName); \ + } + + GDCC_PACKED_REF_INIT_FAMILY(byte_array, ByteArray, GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY) + GDCC_PACKED_REF_INIT_FAMILY(int32_array, Int32Array, GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY) + GDCC_PACKED_REF_INIT_FAMILY(int64_array, Int64Array, GDEXTENSION_VARIANT_TYPE_PACKED_INT64_ARRAY) + GDCC_PACKED_REF_INIT_FAMILY(float32_array, Float32Array, GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT32_ARRAY) + GDCC_PACKED_REF_INIT_FAMILY(float64_array, Float64Array, GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT64_ARRAY) + GDCC_PACKED_REF_INIT_FAMILY(string_array, StringArray, GDEXTENSION_VARIANT_TYPE_PACKED_STRING_ARRAY) + GDCC_PACKED_REF_INIT_FAMILY(vector2_array, Vector2Array, GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR2_ARRAY) + GDCC_PACKED_REF_INIT_FAMILY(vector3_array, Vector3Array, GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR3_ARRAY) + GDCC_PACKED_REF_INIT_FAMILY(color_array, ColorArray, GDEXTENSION_VARIANT_TYPE_PACKED_COLOR_ARRAY) + GDCC_PACKED_REF_INIT_FAMILY(vector4_array, Vector4Array, GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY) +#undef GDCC_PACKED_REF_INIT_FAMILY +} + +#undef GDCC_PACKED_REF_DEFINE_FAMILY + +#endif //GDCC_PACKED_REF_H diff --git a/src/main/c/codegen/include_451/gdcc/gdscript_builtins.h b/src/main/c/codegen/include_451/gdcc/gdscript_builtins.h index b36cce00..e4227fb7 100644 --- a/src/main/c/codegen/include_451/gdcc/gdscript_builtins.h +++ b/src/main/c/codegen/include_451/gdcc/gdscript_builtins.h @@ -2,6 +2,7 @@ #define GDSCRIPT_BUILTINS_H #include +#include #include /// GDScript language-level builtins. @@ -118,24 +119,22 @@ static inline godot_int gdcc_len(const godot_Variant *value) { godot_Dictionary_destroy(&payload); return result; } -// All ten Packed*Array branches share one shape: unpack, dispatch to helper, destroy, return. -#define GDCC_LEN_PACKED_CASE(VARIANT_TYPE, TYPE_NAME, FUNC_SUFFIX) \ +// All ten Packed*Array branches share one shape: measure the live array through its +// Variant-internal pointer (packed storage is Variant-backed; no struct unpack round-trip). +#define GDCC_LEN_PACKED_CASE(VARIANT_TYPE, TYPE_NAME, FUNC_SUFFIX, PACKED_SLUG) \ case VARIANT_TYPE: { \ - godot_##TYPE_NAME payload = godot_new_##TYPE_NAME##_with_Variant(value); \ - godot_int result = gdcc_len_##FUNC_SUFFIX(&payload); \ - godot_##TYPE_NAME##_destroy(&payload); \ - return result; \ + return gdcc_len_##FUNC_SUFFIX(gdcc_packed_##PACKED_SLUG##_internal_ptr(value)); \ } - GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY, PackedByteArray, packed_byte_array) - GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY, PackedInt32Array, packed_int32_array) - GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_INT64_ARRAY, PackedInt64Array, packed_int64_array) - GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT32_ARRAY, PackedFloat32Array, packed_float32_array) - GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT64_ARRAY, PackedFloat64Array, packed_float64_array) - GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_STRING_ARRAY, PackedStringArray, packed_string_array) - GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR2_ARRAY, PackedVector2Array, packed_vector2_array) - GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR3_ARRAY, PackedVector3Array, packed_vector3_array) - GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_COLOR_ARRAY, PackedColorArray, packed_color_array) - GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY, PackedVector4Array, packed_vector4_array) + GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY, PackedByteArray, packed_byte_array, byte_array) + GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY, PackedInt32Array, packed_int32_array, int32_array) + GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_INT64_ARRAY, PackedInt64Array, packed_int64_array, int64_array) + GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT32_ARRAY, PackedFloat32Array, packed_float32_array, float32_array) + GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT64_ARRAY, PackedFloat64Array, packed_float64_array, float64_array) + GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_STRING_ARRAY, PackedStringArray, packed_string_array, string_array) + GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR2_ARRAY, PackedVector2Array, packed_vector2_array, vector2_array) + GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR3_ARRAY, PackedVector3Array, packed_vector3_array, vector3_array) + GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_COLOR_ARRAY, PackedColorArray, packed_color_array, color_array) + GDCC_LEN_PACKED_CASE(GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY, PackedVector4Array, packed_vector4_array, vector4_array) #undef GDCC_LEN_PACKED_CASE default: GDCC_PRINT_RUNTIME_ERROR( diff --git a/src/main/c/codegen/include_451/gdcc/intrinsic/for_packed_array_iter.h b/src/main/c/codegen/include_451/gdcc/intrinsic/for_packed_array_iter.h index e12294a1..2514ffea 100644 --- a/src/main/c/codegen/include_451/gdcc/intrinsic/for_packed_array_iter.h +++ b/src/main/c/codegen/include_451/gdcc/intrinsic/for_packed_array_iter.h @@ -2,85 +2,86 @@ #define GDCC_INTRINSIC_FOR_PACKED_ARRAY_ITER_H #include +#include +#include -/// Per-family Packed*Array for-in iterator helpers. +/// Per-family Packed*Array for-in iterator helpers (LIVE iteration: elements appended during +/// iteration are visited by the current pass). /// /// Each Packed*Array has its own state struct and typed helpers so `get`/`copy`/`destroy` -/// need no runtime kind switch. The state owns a COW snapshot plus a typed element base -/// pointer cached once at init; `next`/`copy` only bump the COW handle. +/// need no runtime kind switch. The state holds a Variant holder COPY of the source array +/// (sharing its identity) plus the current index — deliberately no COW struct snapshot, no +/// cached size and no cached element base pointer: +/// - `should_continue` re-evaluates the LIVE size on every step, so elements appended during +/// iteration are visited by the same loop (interpreter-locked live-iteration contract); +/// - `get` re-checks the live size for bounds and resolves the element through +/// `operator_index_const` on each access, so reallocation caused by mutation can never leave a +/// dangling cached base pointer behind; +/// - `next` only copies the holder + increments the index (it must NOT reuse any snapshot-based +/// copy that would pin the iteration to a detached array). +/// +/// Init contract: helpers here call `gdcc_packed__internal_ptr`, so the translation unit +/// must have run `gdcc_packed_ref_init()` (generated entry modules wire it into `initialize()`). #define GDCC_DEFINE_PACKED_ARRAY_ITER_FAMILY( \ Slug, \ TypeName, \ - ElementCType, \ OpIndexConst, \ GetReturnType, \ GetValueExpr, \ GetOobExpr \ ) \ typedef struct gdcc_for_packed_##Slug##_iter { \ - godot_Packed##TypeName source; \ - const ElementCType *ptr; \ + godot_Variant source; \ godot_int index; \ - godot_int size; \ } gdcc_for_packed_##Slug##_iter; \ \ static inline gdcc_for_packed_##Slug##_iter gdcc_for_packed_##Slug##_iter_init(void) { \ + /* A nil source is never iterated: `from` always produces the real loop state. Destroy of a \ + * nil Variant is a no-op, so the zero-iteration path stays safe. */ \ return (gdcc_for_packed_##Slug##_iter){ \ - .source = godot_new_Packed##TypeName(), \ - .ptr = NULL, \ + .source = godot_new_Variant_nil(), \ .index = 0, \ - .size = 0, \ }; \ } \ \ static inline void gdcc_for_packed_##Slug##_iter_destroy(gdcc_for_packed_##Slug##_iter *state) { \ - godot_Packed##TypeName##_destroy(&state->source); \ - state->ptr = NULL; \ + gdcc_packed_ref_destroy(&state->source); \ state->index = 0; \ - state->size = 0; \ } \ \ -/* COW copy shares the same data buffer and holds a refcount on it, so reusing src->ptr - * remains valid after the previous state is destroyed. */ \ +/* Holder copy shares the underlying array identity; iteration position is per-state. */ \ static inline gdcc_for_packed_##Slug##_iter gdcc_for_packed_##Slug##_iter_copy( \ const gdcc_for_packed_##Slug##_iter *src \ ) { \ return (gdcc_for_packed_##Slug##_iter){ \ - .source = godot_new_Packed##TypeName##_with_Packed##TypeName(&src->source), \ - .ptr = src->ptr, \ + .source = gdcc_packed_ref_copy(&src->source), \ .index = src->index, \ - .size = src->size, \ }; \ } \ \ static inline gdcc_for_packed_##Slug##_iter gdcc_for_packed_##Slug##_iter_from( \ - const godot_Packed##TypeName *source \ + const godot_Variant *source \ ) { \ - godot_Packed##TypeName owned = godot_new_Packed##TypeName##_with_Packed##TypeName(source); \ - godot_int size = godot_Packed##TypeName##_size(&owned); \ - const ElementCType *ptr = NULL; \ - if (size > 0) { \ - /* Snapshot is owned by this state and never resized; cached base stays valid. */ \ - ptr = (const ElementCType *)OpIndexConst(&owned, 0); \ - } \ return (gdcc_for_packed_##Slug##_iter){ \ - .source = owned, \ - .ptr = ptr, \ + .source = gdcc_packed_ref_copy(source), \ .index = 0, \ - .size = size, \ }; \ } \ \ static inline godot_bool gdcc_for_packed_##Slug##_iter_should_continue( \ const gdcc_for_packed_##Slug##_iter *state \ ) { \ - return state->index < state->size; \ + /* Live size on every step: elements appended during iteration ARE visited. */ \ + godot_int live_size = godot_Packed##TypeName##_size( \ + gdcc_packed_##Slug##_internal_ptr(&state->source)); \ + return state->index < live_size; \ } \ \ static inline gdcc_for_packed_##Slug##_iter gdcc_for_packed_##Slug##_iter_next( \ const gdcc_for_packed_##Slug##_iter *state \ ) { \ + /* Holder copy + index increment only; no snapshot/pointer refresh of any kind. */ \ gdcc_for_packed_##Slug##_iter next_state = gdcc_for_packed_##Slug##_iter_copy(state); \ next_state.index = state->index + 1; \ return next_state; \ @@ -89,100 +90,96 @@ static inline gdcc_for_packed_##Slug##_iter gdcc_for_packed_##Slug##_iter_next( static inline GetReturnType gdcc_for_packed_##Slug##_iter_get( \ const gdcc_for_packed_##Slug##_iter *state \ ) { \ - if (state->ptr == NULL || state->index < 0 || state->index >= state->size) { \ + /* Re-resolve per access: mutation may realloc the backing buffer, and shrink below the \ + * current index is clamped by the live-size bounds check (matching `should_continue`). */ \ + godot_Packed##TypeName *live = gdcc_packed_##Slug##_internal_ptr(&state->source); \ + if (unlikely(state->index < 0 || state->index >= godot_Packed##TypeName##_size(live))) { \ return GetOobExpr; \ } \ + /* Typed by each family's OpIndexConst return; GetValueExpr consumes this pointer. \ + * NOTE: keep macro-body comments as block comments — `//` would swallow the macro body. */ \ + const void *gdcc_elem_ptr = OpIndexConst(live, state->index); \ return GetValueExpr; \ } GDCC_DEFINE_PACKED_ARRAY_ITER_FAMILY( byte_array, ByteArray, - uint8_t, godot_packed_byte_array_operator_index_const, godot_int, - (godot_int)state->ptr[state->index], + (godot_int)(*(const uint8_t *)gdcc_elem_ptr), 0 ) GDCC_DEFINE_PACKED_ARRAY_ITER_FAMILY( int32_array, Int32Array, - int32_t, godot_packed_int32_array_operator_index_const, godot_int, - (godot_int)state->ptr[state->index], + (godot_int)(*(const int32_t *)gdcc_elem_ptr), 0 ) GDCC_DEFINE_PACKED_ARRAY_ITER_FAMILY( int64_array, Int64Array, - int64_t, godot_packed_int64_array_operator_index_const, godot_int, - (godot_int)state->ptr[state->index], + (godot_int)(*(const int64_t *)gdcc_elem_ptr), 0 ) GDCC_DEFINE_PACKED_ARRAY_ITER_FAMILY( float32_array, Float32Array, - float, godot_packed_float32_array_operator_index_const, godot_float, - (godot_float)state->ptr[state->index], + (godot_float)(*(const float *)gdcc_elem_ptr), (godot_float)0.0 ) GDCC_DEFINE_PACKED_ARRAY_ITER_FAMILY( float64_array, Float64Array, - double, godot_packed_float64_array_operator_index_const, godot_float, - (godot_float)state->ptr[state->index], + (godot_float)(*(const double *)gdcc_elem_ptr), (godot_float)0.0 ) GDCC_DEFINE_PACKED_ARRAY_ITER_FAMILY( string_array, StringArray, - godot_String, godot_packed_string_array_operator_index_const, godot_String, - godot_new_String_with_String(&state->ptr[state->index]), + godot_new_String_with_String((const godot_String *)gdcc_elem_ptr), godot_new_String() ) GDCC_DEFINE_PACKED_ARRAY_ITER_FAMILY( vector2_array, Vector2Array, - godot_Vector2, godot_packed_vector2_array_operator_index_const, godot_Vector2, - state->ptr[state->index], + *(const godot_Vector2 *)gdcc_elem_ptr, ((godot_Vector2){0}) ) GDCC_DEFINE_PACKED_ARRAY_ITER_FAMILY( vector3_array, Vector3Array, - godot_Vector3, godot_packed_vector3_array_operator_index_const, godot_Vector3, - state->ptr[state->index], + *(const godot_Vector3 *)gdcc_elem_ptr, ((godot_Vector3){0}) ) GDCC_DEFINE_PACKED_ARRAY_ITER_FAMILY( vector4_array, Vector4Array, - godot_Vector4, godot_packed_vector4_array_operator_index_const, godot_Vector4, - state->ptr[state->index], + *(const godot_Vector4 *)gdcc_elem_ptr, ((godot_Vector4){0}) ) GDCC_DEFINE_PACKED_ARRAY_ITER_FAMILY( color_array, ColorArray, - godot_Color, godot_packed_color_array_operator_index_const, godot_Color, - state->ptr[state->index], + *(const godot_Color *)gdcc_elem_ptr, ((godot_Color){0}) ) diff --git a/src/main/c/codegen/template_451/engine_method_binds.h.ftl b/src/main/c/codegen/template_451/engine_method_binds.h.ftl index 15cec18b..f3f5b1b6 100644 --- a/src/main/c/codegen/template_451/engine_method_binds.h.ftl +++ b/src/main/c/codegen/template_451/engine_method_binds.h.ftl @@ -299,6 +299,11 @@ cleanup: <#if helper.checkEngineMethodHelperObjectParam(param)> ${helper.renderEngineMethodHelperObjectRawSlotDecl(param)} +<#-- Packed params arrive as Variant storage pointers; materialize a raw struct copy for the --> +<#-- ptrcall slot (whitelist (a)) so in-engine mutation never reaches the caller's shared array. --> +<#if helper.checkEngineMethodHelperPackedParam(param)> + ${helper.renderEngineMethodHelperPackedSlotDecl(param)} + <#if helperParams?size gt 0> const GDExtensionConstTypePtr args[] = { @@ -307,6 +312,11 @@ cleanup: }; +<#assign packedSlotCleanup> +<#list helperParams?reverse as param> +<#if helper.checkEngineMethodHelperPackedParam(param)> ${helper.renderEngineMethodHelperPackedSlotDestroyStmt(param)} + + <#if resolved.returnType.typeName == "void"> godot_object_method_bind_ptrcall( bind, @@ -322,7 +332,7 @@ cleanup: NULL ); - return; +${packedSlotCleanup} return; <#elseif helper.checkEngineMethodHelperObjectReturn(resolved.returnType)> // Object return: raw ptrcall slot, then capture ID into fat pointer (never write fat storage as r_ret). GDExtensionObjectPtr result_raw = NULL; @@ -340,7 +350,26 @@ cleanup: &result_raw ); - return ${helper.renderEngineMethodHelperObjectFromRaw(resolved.returnType, "result_raw")}; +${packedSlotCleanup} return ${helper.renderEngineMethodHelperObjectFromRaw(resolved.returnType, "result_raw")}; +<#elseif helper.checkPackedType(resolved.returnType)> + <#-- Packed return: receive into a raw struct slot, then wrap into the Variant surface --> + <#-- (whitelist (c)); the returned array is a fresh engine-produced identity either way. --> + ${helper.renderPackedRawStructCType(resolved.returnType)} result_raw = { 0 }; + godot_object_method_bind_ptrcall( + bind, +<#if resolved.isStatic()> + NULL, +<#else> + self_raw, + +<#if helperParams?size gt 0> + args, +<#else> + NULL, + + &result_raw + ); +${packedSlotCleanup} return ${helper.renderPackedWrapTempExpr(resolved.returnType, "&result_raw")}; <#else> ${helper.renderGdTypeInC(resolved.returnType)} result = { 0 }; godot_object_method_bind_ptrcall( @@ -357,7 +386,7 @@ cleanup: &result ); - return result; +${packedSlotCleanup} return result; } diff --git a/src/main/c/codegen/template_451/entry.c.ftl b/src/main/c/codegen/template_451/entry.c.ftl index f66a4a3f..c54e42f1 100644 --- a/src/main/c/codegen/template_451/entry.c.ftl +++ b/src/main/c/codegen/template_451/entry.c.ftl @@ -80,6 +80,10 @@ void initialize(void* userdata, const GDExtensionInitializationLevel p_level) { return; } gdcc_init(); + <#-- Resolve the per-family packed internal-pointer getters once for this module's TU before --> + <#-- any generated code can call them (gdcc_packed_ref.h init contract; fail-fast on engines --> + <#-- without variant_get_ptr_internal_getter). --> + gdcc_packed_ref_init(); <#if helper.hasCoroutineFunctions()> gdcc_coro_set_hot_reload_active(gdcc_is_editor_hint()); diff --git a/src/main/c/codegen/template_451/entry.h.ftl b/src/main/c/codegen/template_451/entry.h.ftl index ba92c78d..427ce08e 100644 --- a/src/main/c/codegen/template_451/entry.h.ftl +++ b/src/main/c/codegen/template_451/entry.h.ftl @@ -507,7 +507,7 @@ static inline ${helper.renderOperatorEvaluatorHelperReturnTypeInC(spec.returnTyp ); if (evaluator == NULL) { GDCC_PRINT_RUNTIME_ERROR("operator evaluator is unavailable: ${spec.functionName}", __func__, __FILE__, __LINE__); - return ${helper.renderDefaultValueExprInC(spec.returnType)}; + return ${helper.renderOperatorEvaluatorHelperDefaultExpr(spec.returnType)}; } } ${helper.renderOperatorEvaluatorObjectRawSlotDecl(spec.leftType, "left")}<#if !spec.unary>${helper.renderOperatorEvaluatorObjectRawSlotDecl(spec.rightType, "right")} @@ -775,6 +775,9 @@ static void ptrcall${helper.renderFuncBindName(bindingData)}( void* method_userdata, GDExtensionClassInstancePtr p_instance, const GDExtensionConstTypePtr* p_args, GDExtensionTypePtr r_return) { // Object args/returns use raw Godot pointer slots; self is owner fat for instance methods. + // Packed args arrive as raw struct slots (the ptrcall identity-isolation exception): each is materialized + // into a wrapper-local Variant via whitelist (a) and destroyed on the way out, so callee + // mutation stays isolated from the caller's array across this boundary. <#-- The default flavor shares the same userdata layout as the call wrapper: ptrcall keeps the --> <#-- fixed full-argument ABI (no argc guard, no fill) but must still reach impl via ud->impl. --> <#if defaultFlavor> @@ -783,6 +786,8 @@ static void ptrcall${helper.renderFuncBindName(bindingData)}( <#list bindingData.paramTypes as paramType> <#if helper.checkObjectType(paramType)> ${helper.renderPtrcallObjectArgDecl(paramType, paramType_index)} +<#elseif helper.checkPackedType(paramType)> + ${helper.renderPtrcallPackedArgDecl(paramType, paramType_index)} <#if bindingData.staticMethod> @@ -793,24 +798,41 @@ static void ptrcall${helper.renderFuncBindName(bindingData)}( <#if bindingData.returnType.typeName == "void"> <#if bindingData.staticMethod> - (function(<#list bindingData.paramTypes as paramType><#if helper.checkObjectType(paramType)>arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)}<#if paramType_has_next>, )); + (function(<#list bindingData.paramTypes as paramType><#if helper.checkObjectType(paramType)>arg${paramType_index}<#elseif helper.checkPackedType(paramType)>&arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)}<#if paramType_has_next>, )); <#else> - (function(self_fat<#list bindingData.paramTypes as paramType>, <#if helper.checkObjectType(paramType)>arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)})); + (function(self_fat<#list bindingData.paramTypes as paramType>, <#if helper.checkObjectType(paramType)>arg${paramType_index}<#elseif helper.checkPackedType(paramType)>&arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)})); <#elseif helper.checkObjectType(bindingData.returnType)> <#if bindingData.staticMethod> - ${helper.renderGdTypeInC(bindingData.returnType)} r = function(<#list bindingData.paramTypes as paramType><#if helper.checkObjectType(paramType)>arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)}<#if paramType_has_next>, ); + ${helper.renderGdTypeInC(bindingData.returnType)} r = function(<#list bindingData.paramTypes as paramType><#if helper.checkObjectType(paramType)>arg${paramType_index}<#elseif helper.checkPackedType(paramType)>&arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)}<#if paramType_has_next>, ); <#else> - ${helper.renderGdTypeInC(bindingData.returnType)} r = function(self_fat<#list bindingData.paramTypes as paramType>, <#if helper.checkObjectType(paramType)>arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)}); + ${helper.renderGdTypeInC(bindingData.returnType)} r = function(self_fat<#list bindingData.paramTypes as paramType>, <#if helper.checkObjectType(paramType)>arg${paramType_index}<#elseif helper.checkPackedType(paramType)>&arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)}); ${helper.renderPtrcallObjectReturnWrite(bindingData.returnType, "r")} +<#elseif helper.checkPackedType(bindingData.returnType)> + <#-- Packed return: the callee produced a Variant; whitelist (a) copies it into the raw --> + <#-- struct return slot (a fresh Vector-layer copy, intentionally not identity-shared). --> + <#if bindingData.staticMethod> + ${helper.renderGdTypeInC(bindingData.returnType)} r = function(<#list bindingData.paramTypes as paramType><#if helper.checkObjectType(paramType)>arg${paramType_index}<#elseif helper.checkPackedType(paramType)>&arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)}<#if paramType_has_next>, ); + <#else> + ${helper.renderGdTypeInC(bindingData.returnType)} r = function(self_fat<#list bindingData.paramTypes as paramType>, <#if helper.checkObjectType(paramType)>arg${paramType_index}<#elseif helper.checkPackedType(paramType)>&arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)}); + + ${helper.renderPtrcallPackedReturnWrite(bindingData.returnType, "r")} <#else> <#if bindingData.staticMethod> - *((${helper.renderGdTypeInC(bindingData.returnType)}*)r_return) = function(<#list bindingData.paramTypes as paramType><#if helper.checkObjectType(paramType)>arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)}<#if paramType_has_next>, ); + *((${helper.renderGdTypeInC(bindingData.returnType)}*)r_return) = function(<#list bindingData.paramTypes as paramType><#if helper.checkObjectType(paramType)>arg${paramType_index}<#elseif helper.checkPackedType(paramType)>&arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)}<#if paramType_has_next>, ); <#else> - *((${helper.renderGdTypeInC(bindingData.returnType)}*)r_return) = function(self_fat<#list bindingData.paramTypes as paramType>, <#if helper.checkObjectType(paramType)>arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)}); + *((${helper.renderGdTypeInC(bindingData.returnType)}*)r_return) = function(self_fat<#list bindingData.paramTypes as paramType>, <#if helper.checkObjectType(paramType)>arg${paramType_index}<#elseif helper.checkPackedType(paramType)>&arg${paramType_index}<#else>${helper.renderPtrcallNonObjectArgExpr(paramType, paramType_index)}); +<#-- Materialized packed arg Variants are wrapper-owned; release them after the call. --> +<#assign ptrcallArgCount = bindingData.paramTypes?size> +<#list bindingData.paramTypes?reverse as paramType> +<#assign ptrcallArgIndex = ptrcallArgCount - paramType_index - 1> +<#if helper.checkPackedType(paramType)> + ${helper.renderPtrcallPackedArgDestroyStmt(paramType, ptrcallArgIndex)} + + } static void gdcc_bind_method${helper.renderFuncBindName(bindingData)}( diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/CBodyBuilder.java b/src/main/java/gd/script/gdcc/backend/c/gen/CBodyBuilder.java index 30108078..b004be0d 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/CBodyBuilder.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/CBodyBuilder.java @@ -1173,7 +1173,10 @@ public static String renderDefaultValueExpr(@NotNull GdType type) { case GdContainerType containerType -> switch (containerType) { case GdArrayType _ -> "godot_new_Array()"; case GdDictionaryType _ -> "godot_new_Dictionary()"; - case GdPackedArrayType packedArrayType -> "godot_new_" + packedArrayType.getTypeName() + "()"; + // Packed default initialization must produce an empty-array Variant, never a bare + // struct or a nil Variant (nil has no internal pointer; method calls would fail) — + // whitelist (b). + case GdPackedArrayType packedArrayType -> PackedRefCNames.newEmptyExpr(packedArrayType); }; default -> "godot_new_" + type.getTypeName() + "()"; }; diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/CBodyBuilderAliasSafetySupport.java b/src/main/java/gd/script/gdcc/backend/c/gen/CBodyBuilderAliasSafetySupport.java index 67d09731..ee77dbf0 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/CBodyBuilderAliasSafetySupport.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/CBodyBuilderAliasSafetySupport.java @@ -14,6 +14,10 @@ /// - every unproven or future value/target surface stays `MAY_ALIAS` /// - classification is phrased only in terms of the current sealed `ValueRef` / `TargetRef` surface, /// so future additions have to update this file instead of silently falling through a builder-local default +/// +/// Variant-backed Packed*Array storage: the +/// stable-carrier staging is kept — it stays correct under Variant holder semantics, and dropping +/// it for one family would fork the conservative overwrite discipline struct slots still rely on. final class CBodyBuilderAliasSafetySupport { private CBodyBuilderAliasSafetySupport() { } diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/CBuiltinBuilder.java b/src/main/java/gd/script/gdcc/backend/c/gen/CBuiltinBuilder.java index b190a74e..8dbf9cbc 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/CBuiltinBuilder.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/CBuiltinBuilder.java @@ -1,5 +1,6 @@ package gd.script.gdcc.backend.c.gen; +import gd.script.gdcc.backend.c.gen.insn.PackedNativeAbiCallSupport; import gd.script.gdcc.scope.resolver.ScopeTypeParsers; import gd.script.gdcc.type.GdArrayType; import gd.script.gdcc.type.GdBasisType; @@ -9,6 +10,7 @@ import gd.script.gdcc.type.GdIntType; import gd.script.gdcc.type.GdNodePathType; import gd.script.gdcc.type.GdObjectType; +import gd.script.gdcc.type.GdPackedArrayType; import gd.script.gdcc.type.GdProjectionType; import gd.script.gdcc.type.GdStringNameType; import gd.script.gdcc.type.GdStringType; @@ -126,10 +128,61 @@ public void constructBuiltin(@NotNull CBodyBuilder bodyBuilder, switch (targetType) { case GdArrayType arrayType -> constructArray(bodyBuilder, target, arrayType, args); case GdDictionaryType dictionaryType -> constructDictionary(bodyBuilder, target, dictionaryType, args); + case GdPackedArrayType packedArrayType -> constructPackedArray(bodyBuilder, target, packedArrayType, args); default -> constructRegularBuiltin(bodyBuilder, target, targetType, args); } } + /// Packed*Array construction never calls the native constructor wrappers directly: the + /// target is a Variant slot, so results are produced by the whitelisted `gdcc_packed_ref.h` + /// helpers instead. + /// - `Packed*Array()` -> `new_empty` (whitelist (b); empty-array Variant, never nil) + /// - `Packed*Array(other same family)` -> `new_copy` (whitelist (d); independent COW copy) + /// - `Packed*Array(array)` -> `new_from_array` (whitelist (d); cross-type conversion) + /// The extension metadata lists exactly these three constructors per family, so + /// `hasConstructor` remains the validity gate and the whitelist only owns the emission shape. + private void constructPackedArray(@NotNull CBodyBuilder bodyBuilder, + @NotNull CBodyBuilder.TargetRef target, + @NotNull GdPackedArrayType packedArrayType, + @NotNull List args) { + var ctorArgTypes = new ArrayList(args.size()); + for (var arg : args) { + ctorArgTypes.add(arg.type()); + } + if (!hasConstructor(packedArrayType, ctorArgTypes)) { + var argTypeNames = new ArrayList(ctorArgTypes.size()); + for (var argType : ctorArgTypes) { + argTypeNames.add(helper.renderGdTypeName(argType)); + } + throw new IllegalArgumentException("Builtin constructor validation failed: '" + + helper.renderGdTypeName(packedArrayType) + "' with args [" + + String.join(", ", argTypeNames) + "] is not defined in ExtensionBuiltinClass"); + } + if (args.isEmpty()) { + bodyBuilder.callAssign( + target, + PackedRefCNames.helperName(packedArrayType, "new_empty"), + packedArrayType, + List.of() + ); + return; + } + // Exact constructor metadata match guarantees the single argument's family. + var helperSuffix = switch (args.getFirst().type()) { + case GdPackedArrayType _ -> "new_copy"; + case GdArrayType _ -> "new_from_array"; + default -> throw new IllegalArgumentException( + "Packed constructor argument type '" + args.getFirst().type().getTypeName() + + "' has no whitelisted helper mapping"); + }; + bodyBuilder.callAssign( + target, + PackedRefCNames.helperName(packedArrayType, helperSuffix), + packedArrayType, + args + ); + } + /// Materializes one utility default literal into the given writable target. /// The caller controls target lifetime (for example, temp var declaration/destruction). public void materializeUtilityDefaultValue(@NotNull CBodyBuilder bodyBuilder, @@ -329,6 +382,12 @@ private void constructRegularBuiltin(@NotNull CBodyBuilder bodyBuilder, } if (hasConstructor(targetType, ctorArgTypes)) { var ctorFunc = renderConstructorFunctionNameByTypes(targetType, ctorArgTypes); + if (PackedNativeAbiCallSupport.requiresPackedAdaptation(targetType, ctorArgTypes)) { + // Constructor wrappers keep the native packed arg ABI (e.g. `Array(packed)` takes + // `const godot_Packed*Array*`); adapt the Variant storage at the call site. + PackedNativeAbiCallSupport.emitCall(bodyBuilder, target, ctorFunc, targetType, ctorArgTypes, args); + return; + } bodyBuilder.callAssign(target, ctorFunc, targetType, args); return; } @@ -729,7 +788,8 @@ private boolean canMaterializeCtorArg(@NotNull String argLiteral, @NotNull GdTyp return switch (expectedType) { case GdBoolType _ -> "true".equals(argLiteral) || "false".equals(argLiteral); case GdIntType _ -> isIntegerLiteral(argLiteral); - case GdFloatType _ -> isNumericLiteral(argLiteral) || CFloatLiteralSupport.isNonFiniteFloatLiteral(argLiteral); + case GdFloatType _ -> + isNumericLiteral(argLiteral) || CFloatLiteralSupport.isNonFiniteFloatLiteral(argLiteral); case GdStringType _ -> isQuotedStringLiteral(argLiteral); case GdStringNameType _ -> isQuotedStringNameLiteral(argLiteral); case GdArrayType _ -> "[]".equals(argLiteral); diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/CGenHelper.java b/src/main/java/gd/script/gdcc/backend/c/gen/CGenHelper.java index 2065d424..8d53c93f 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/CGenHelper.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/CGenHelper.java @@ -130,12 +130,22 @@ private record TypedContainerRuntimeLeaf( /// C parameter type of a generated `gdcc_eval_*` helper. /// Object operands are internal fat pointers (by value); non-objects keep their usual ref shape. + /// Packed operands are the exception: the helper keeps the native struct pointer + /// shape the engine evaluator ABI expects, and the call site extracts the Variant-internal + /// pointer via `gdcc_packed__internal_ptr(...)`. public @NotNull String renderOperatorEvaluatorHelperTypeInC(@NotNull GdType type) { + if (type instanceof GdPackedArrayType packedType) { + return "const " + PackedRefCNames.rawStructCType(packedType) + " *"; + } return renderGdTypeRefInC(type); } - /// C return type of a generated `gdcc_eval_*` helper (same as internal storage). + /// C return type of a generated `gdcc_eval_*` helper: packed operators produce a fresh native + /// struct (whitelist (c)); the call site immediately wraps it into a Variant via `wrap_temp`. public @NotNull String renderOperatorEvaluatorHelperReturnTypeInC(@NotNull GdType type) { + if (type instanceof GdPackedArrayType packedType) { + return PackedRefCNames.rawStructCType(packedType); + } return renderGdTypeInC(type); } @@ -163,6 +173,8 @@ private record TypedContainerRuntimeLeaf( /// Local result carrier type for the evaluator out-parameter. /// Godot writes a raw object pointer; the helper then captures it into a fat pointer return. + /// Packed returns also keep the raw struct carrier: the evaluator writes the fresh array into + /// it and the helper returns it to the call site for `wrap_temp` (whitelist (c)). /// /// Defensive: Godot 4.5.1 `extension_api` has no builtin operator with `return_type: Object` /// (Object appears only as a right operand; returns are bool/String). Object/object `==`/`!=` also @@ -172,6 +184,9 @@ private record TypedContainerRuntimeLeaf( if (type instanceof GdObjectType) { return "GDExtensionObjectPtr"; } + if (type instanceof GdPackedArrayType packedType) { + return PackedRefCNames.rawStructCType(packedType); + } return renderGdTypeInC(type); } @@ -184,6 +199,16 @@ private record TypedContainerRuntimeLeaf( return resultName; } + /// Fail-fast fallback for an unavailable evaluator: packed helpers return a zeroed RAW STRUCT + /// (their surface type is the native struct — see {@link #renderOperatorEvaluatorHelperReturnTypeInC}), + /// not the Variant-backed default used by managed storage slots. + public @NotNull String renderOperatorEvaluatorHelperDefaultExpr(@NotNull GdType type) { + if (type instanceof GdPackedArrayType packedType) { + return "(" + PackedRefCNames.rawStructCType(packedType) + "){ 0 }"; + } + return renderDefaultValueExprInC(type); + } + private @NotNull String renderFatPtrFromRawExpr(@NotNull String rawCode, @NotNull GdObjectType objectType) { return renderObjectFatPtrStorageType(objectType) + "_from_raw((GDExtensionObjectPtr)(" + rawCode + "))"; } @@ -363,7 +388,10 @@ private void collectBindingData(@NotNull List classDefs) { renderContainerElementTypeInC(gdDictionaryType.getValueType()) + ")"; } } - case GdPackedArrayType gdPackedArrayType -> "godot_" + gdPackedArrayType.getTypeName(); + // Variant-backed reference semantics: the canonical storage of every packed slot + // is a Variant whose internal `PackedArrayRef` is shared with all aliases; the + // raw struct only appears inside whitelisted `gdcc_packed_ref.h` boundary helpers. + case GdPackedArrayType _ -> "godot_Variant"; }; case GdObjectType gdObjectType -> renderObjectFatPtrStorageType(gdObjectType); case GdVoidType _ -> "void"; @@ -393,7 +421,9 @@ private void collectBindingData(@NotNull List classDefs) { renderContainerElementTypeInC(gdDictionaryType.getValueType()) + ")*"; } } - case GdPackedArrayType gdPackedArrayType -> "godot_" + gdPackedArrayType.getTypeName() + "*"; + // Packed parameters share identity with the caller through a Variant storage + // pointer; no assign-through-pointer writeback is needed. + case GdPackedArrayType _ -> "godot_Variant*"; }; case GdObjectType gdObjectType -> renderObjectFatPtrParameterType(gdObjectType); case GdVoidType _ -> "void*"; @@ -814,6 +844,9 @@ public boolean hasCoroutineFunctions() { /// Ptrcall consumes addresses of argument storage slots. /// - object fat params first materialize a raw local, then pass `&argN_raw` + /// - packed Variant params materialize a raw struct copy, then pass `&argN_packed` + /// (whitelist (a): the engine boundary receives a Vector-layer copy, so in-engine mutation + /// never leaks back into the caller's shared array — the documented ptrcall exception shape) /// - other value-shaped params pass `&arg` /// - storage-pointer params pass the helper argument directly /// - enum/bitfield params first point at a helper-local raw slot @@ -821,6 +854,9 @@ public boolean hasCoroutineFunctions() { if (checkEngineMethodHelperObjectParam(param)) { return "&" + renderEngineMethodHelperObjectRawSlotName(param); } + if (checkEngineMethodHelperPackedParam(param)) { + return "&" + renderEngineMethodHelperPackedSlotName(param); + } return switch (param.slotMode()) { case VALUE_ADDRESS -> "&" + param.name(); case STORAGE_POINTER -> param.name(); @@ -828,6 +864,55 @@ public boolean hasCoroutineFunctions() { }; } + /// True when the engine helper param is a Packed*Array: the public surface is the Variant + /// storage pointer (`renderGdTypeRefInC`), while the ptrcall slot needs a materialized copy. + public boolean checkEngineMethodHelperPackedParam(@NotNull EngineMethodHelperParam param) { + return param.type() instanceof GdPackedArrayType; + } + + private @NotNull String renderEngineMethodHelperPackedSlotName(@NotNull EngineMethodHelperParam param) { + if (!checkEngineMethodHelperPackedParam(param)) { + throw new IllegalArgumentException("Engine helper packed slot requires packed param: " + param.name()); + } + return param.name() + "_packed"; + } + + /// Materializes the ptrcall-ready raw struct copy of one packed helper param (whitelist (a)). + public @NotNull String renderEngineMethodHelperPackedSlotDecl(@NotNull EngineMethodHelperParam param) { + if (!(param.type() instanceof GdPackedArrayType packedType)) { + throw new IllegalArgumentException("Engine helper packed slot decl requires packed param: " + param.name()); + } + return PackedRefCNames.rawStructCType(packedType) + " " + renderEngineMethodHelperPackedSlotName(param) + + " = " + PackedRefCNames.structFromVariantExpr(packedType, param.name()) + ";"; + } + + /// Releases the materialized packed ptrcall slot after the engine call returns. + public @NotNull String renderEngineMethodHelperPackedSlotDestroyStmt(@NotNull EngineMethodHelperParam param) { + if (!(param.type() instanceof GdPackedArrayType packedType)) { + throw new IllegalArgumentException("Engine helper packed slot destroy requires packed param: " + param.name()); + } + return PackedRefCNames.rawStructCType(packedType) + "_destroy(&" + + renderEngineMethodHelperPackedSlotName(param) + ");"; + } + + /// Raw struct C type of a packed type — legal only in whitelisted boundary code (ptrcall + /// slots, native wrapper carriers). Template-facing bridge over {@link PackedRefCNames}. + public @NotNull String renderPackedRawStructCType(@NotNull GdType type) { + if (!(type instanceof GdPackedArrayType packedType)) { + throw new IllegalArgumentException("raw struct C type requires packed type, got: " + type.getTypeName()); + } + return PackedRefCNames.rawStructCType(packedType); + } + + /// Whitelist (c) wrap of a native packed struct temporary into a fresh Variant (engine helper + /// ptrcall return path: the raw out-slot becomes the caller-visible shared array). + public @NotNull String renderPackedWrapTempExpr(@NotNull GdType type, @NotNull String tempAddrExpr) { + if (!(type instanceof GdPackedArrayType packedType)) { + throw new IllegalArgumentException("packed wrap_temp requires packed type, got: " + type.getTypeName()); + } + return PackedRefCNames.wrapTempExpr(packedType, tempAddrExpr); + } + /// Helper-local pack sites always consume the normalized helper surface. public @NotNull String renderEngineMethodHelperValueExpr(@NotNull EngineMethodHelperParam param) { return param.name(); @@ -1058,9 +1143,53 @@ public boolean checkObjectType(@NotNull GdType type) { if (paramType instanceof GdObjectType) { throw new IllegalArgumentException("use renderPtrcallObjectArgDecl for object args"); } + if (paramType instanceof GdPackedArrayType) { + // ptrcall slots carry the raw struct, but the internal ABI is Variant-backed: the + // wrapper must materialize through whitelist (a) instead of reinterpreting the slot. + throw new IllegalArgumentException("use renderPtrcallPackedArgDecl for packed args"); + } return renderValueRef(paramType, "(*((" + renderGdTypeInC(paramType) + "*)p_args[" + index + "]))"); } + /// True for Packed*Array types — the Variant-backed family whose ptrcall/engine-helper ABI + /// boundary goes through `gdcc_packed_ref.h` whitelisted conversions. + public boolean checkPackedType(@NotNull GdType type) { + return type instanceof GdPackedArrayType; + } + + /// Ptrcall packed arg (identity-isolation exception, inbound): materialize the raw struct + /// slot into a wrapper-local Variant via whitelist (a). The callee shares identity with this + /// local only — mutation never leaks back across the ptrcall boundary. + public @NotNull String renderPtrcallPackedArgDecl(@NotNull GdType paramType, int index) { + if (!(paramType instanceof GdPackedArrayType packedType)) { + throw new IllegalArgumentException("ptrcall packed arg decl requires packed type"); + } + return "godot_Variant arg" + index + " = " + + PackedRefCNames.variantFromStructExpr(packedType, + "(const " + PackedRefCNames.rawStructCType(packedType) + " *)p_args[" + index + "]") + + ";"; + } + + /// Destroys the materialized ptrcall packed arg Variant after the call (paired with + /// {@link #renderPtrcallPackedArgDecl}). + public @NotNull String renderPtrcallPackedArgDestroyStmt(@NotNull GdType paramType, int index) { + if (!(paramType instanceof GdPackedArrayType)) { + throw new IllegalArgumentException("ptrcall packed arg destroy requires packed type"); + } + return "godot_Variant_destroy(&arg" + index + ");"; + } + + /// Ptrcall packed return (outbound): copy the callee's Variant result into the + /// raw struct return slot via whitelist (a), then release the callee's holder. + public @NotNull String renderPtrcallPackedReturnWrite(@NotNull GdType returnType, @NotNull String resultName) { + if (!(returnType instanceof GdPackedArrayType packedType)) { + throw new IllegalArgumentException("ptrcall packed return write requires packed type"); + } + return "*((" + PackedRefCNames.rawStructCType(packedType) + " *)r_return) = " + + PackedRefCNames.structFromVariantExpr(packedType, "&" + resultName) + ";\n" + + "godot_Variant_destroy(&" + resultName + ");"; + } + /// Ptrcall object return: owned fat -> validated raw transfer into `r_return` (no extra release). public @NotNull String renderPtrcallObjectReturnWrite(@NotNull GdType returnType, @NotNull String resultExpr) { if (!(returnType instanceof GdObjectType objectType)) { @@ -1165,15 +1294,20 @@ public int countDefaultSlots(@NotNull FunctionDef functionDef) { } public @NotNull String renderUnpackFunctionName(@NotNull GdType type) { - if (type instanceof GdCompilerType) { - throw new IllegalArgumentException("compiler-only type leaked into Variant unpack: " + type.getTypeName()); - } - if (type instanceof GdObjectType objectType) { - // Object unpack materializes a fat pointer that preserves the Variant's instance ID. - return renderObjectFatPtrStorageType(objectType) + "_from_variant"; - } else { - return "godot_new_" + renderGdTypeName(type) + "_with_Variant"; - } + return switch (type) { + case GdCompilerType _ -> + throw new IllegalArgumentException("compiler-only type leaked into Variant unpack: " + type.getTypeName()); + case GdObjectType objectType -> + // Object unpack materializes a fat pointer that preserves the Variant's instance ID. + renderObjectFatPtrStorageType(objectType) + "_from_variant"; + case GdPackedArrayType _ -> + // Packed storage IS a Variant: unpack degenerates to an identity-sharing holder + // copy. Callers that accept arbitrary runtime Variants must emit the exact-kind + // `gdcc_packed_ref_is` check first (InsnGenSupport.unpackVariantAssign does); the + // call_func wrapper gate and engine metadata already guarantee the kind on their paths. + "godot_new_Variant_with_Variant"; + default -> "godot_new_" + renderGdTypeName(type) + "_with_Variant"; + }; } /// Render the inbound `call_func` runtime gate for one non-Variant wrapper argument. @@ -1278,6 +1412,11 @@ public int countDefaultSlots(@NotNull FunctionDef functionDef) { // Fat-pointer pack uses the per-type helper so freed IDs degrade through live_object. return renderObjectFatPtrStorageType(objectType) + "_to_variant"; } + // Packing a packed value is a Variant holder copy: the Variant shares the same + // underlying array identity (three-way sharing). + case GdPackedArrayType _ -> { + return "godot_new_Variant_with_Variant"; + } default -> { return "godot_new_Variant_with_" + renderGdTypeName(type); } @@ -1311,6 +1450,9 @@ public int countDefaultSlots(@NotNull FunctionDef functionDef) { case GdObjectType _, GdPrimitiveType _ -> ""; case GdVoidType _, GdNilType _ -> throw new IllegalArgumentException("Type " + type.getTypeName() + " does not support copy assignment"); + // Packed*Array copy is a Variant holder copy: the destination shares the source's + // underlying array identity (core invariant — never struct copy-construct). + case GdPackedArrayType _ -> "godot_new_Variant_with_Variant"; default -> { var symbolTypeName = renderGdTypeName(type); yield "godot_new_" + symbolTypeName + "_with_" + symbolTypeName; @@ -1322,14 +1464,22 @@ public int countDefaultSlots(@NotNull FunctionDef functionDef) { if (!type.isDestroyable()) { throw new IllegalArgumentException("Type " + type.getTypeName() + " is not destroyable"); } - if (type instanceof GdCompilerType compilerType) { - compilerType.validateCStorageContract(); - return compilerType.getCDestroyHelperName(); - } - if (type instanceof GdObjectType) { - return "godot_object_destroy"; - } else { - return "godot_" + renderGdTypeName(type) + "_destroy"; + switch (type) { + case GdCompilerType compilerType -> { + compilerType.validateCStorageContract(); + return compilerType.getCDestroyHelperName(); + } + case GdObjectType _ -> { + return "godot_object_destroy"; + } + case GdPackedArrayType _ -> { + // Packed slots are Variant holders; destroying one releases this holder's share of the + // shared array instead of destroying a by-value struct. + return "godot_Variant_destroy"; + } + default -> { + return "godot_" + renderGdTypeName(type) + "_destroy"; + } } } diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/CHrxIdentityCatalog.java b/src/main/java/gd/script/gdcc/backend/c/gen/CHrxIdentityCatalog.java index 38eecf7f..96ee336b 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/CHrxIdentityCatalog.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/CHrxIdentityCatalog.java @@ -5,6 +5,7 @@ import gd.script.gdcc.lir.LirModule; import gd.script.gdcc.lir.insn.ConstructStandaloneCallableInsn; import gd.script.gdcc.lir.insn.StandaloneCallableKind; +import gd.script.gdcc.type.GdType; import gd.script.gdcc.util.StringUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -225,8 +226,13 @@ private static void collectStandaloneUsages( } /// Lambda schema descriptor: canonical encoding of the capture layout + signature + - /// abi version (never the body). C storage types are used because they pin both the - /// representation and the ownership classification of every field. + /// abi version (never the body). Each field contributes its semantic type name AND its C + /// storage type: the storage type alone no longer pins the representation — since the + /// Variant-backed packed storage switch, + /// every Packed*Array family AND plain Variant share the `godot_Variant` C spelling, so a + /// capture/param/return swapped between packed families (or to/from Variant) would otherwise + /// keep the same fingerprint and hot-reload would rebind an old holder into an implementation + /// that dereferences it through a different family's internal-pointer getter (engine-level UB). private static @NotNull String buildLambdaSchemaDesc( @NotNull CGenHelper helper, @NotNull LirFunctionDef function @@ -237,7 +243,7 @@ private static void collectStandaloneUsages( if (!first) { sb.append(','); } - sb.append(helper.renderGdTypeInC(capture.getType())); + sb.append(renderSchemaFieldType(helper, capture.getType())); first = false; } sb.append(";params="); @@ -246,15 +252,21 @@ private static void collectStandaloneUsages( if (!first) { sb.append(','); } - sb.append(helper.renderGdTypeInC(Objects.requireNonNull(function.getParameter(i)).type())); + sb.append(renderSchemaFieldType(helper, Objects.requireNonNull(function.getParameter(i)).type())); first = false; } - sb.append(";ret=").append(helper.renderGdTypeInC(function.getReturnType())); + sb.append(";ret=").append(renderSchemaFieldType(helper, function.getReturnType())); sb.append(";va=").append(function.isVararg() ? '1' : '0'); sb.append(";co=").append(function.isCoroutine() ? '1' : '0'); return sb.toString(); } + /// One schema field: `@` — semantic name distinguishes + /// families sharing a C storage spelling; C storage keeps typed-container element encodings. + private static @NotNull String renderSchemaFieldType(@NotNull CGenHelper helper, @NotNull GdType type) { + return type.getTypeName() + "@" + helper.renderGdTypeInC(type); + } + /// Standalone schema descriptor: the call metadata is the whole compatibility surface /// (identity lives in the impl_key); the payload layout itself is fixed ABI. private static @NotNull String buildStandaloneSchemaDesc( diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/PackedRefCNames.java b/src/main/java/gd/script/gdcc/backend/c/gen/PackedRefCNames.java new file mode 100644 index 00000000..0e64c460 --- /dev/null +++ b/src/main/java/gd/script/gdcc/backend/c/gen/PackedRefCNames.java @@ -0,0 +1,93 @@ +package gd.script.gdcc.backend.c.gen; + +import gd.script.gdcc.type.GdPackedArrayType; +import org.jetbrains.annotations.NotNull; + +/// Central naming surface for the `gdcc_packed_ref.h` per-family helpers. In the Variant-backed +/// storage model every Packed*Array slot holds a `godot_Variant`, and the raw struct<->Variant +/// boundary may only be crossed through the whitelisted named helpers below. Keeping the names +/// here lets codegen reference the whitelist without re-deriving C identifier spellings at each +/// call site. +/// +/// Slugs intentionally match the `GDCC_PACKED_REF_DEFINE_FAMILY` instantiations (e.g. +/// `PackedInt32Array` -> `int32_array` -> `gdcc_packed_int32_array_*`). +public final class PackedRefCNames { + private PackedRefCNames() { + } + + /// Per-family slug shared with `gdcc_packed_ref.h`; fail-fast on unknown families so a future + /// packed type cannot silently fall through to a misspelled helper name. + public static @NotNull String requireFamilySlug(@NotNull GdPackedArrayType type) { + return switch (type.getTypeName()) { + case "PackedByteArray" -> "byte_array"; + case "PackedInt32Array" -> "int32_array"; + case "PackedInt64Array" -> "int64_array"; + case "PackedFloat32Array" -> "float32_array"; + case "PackedFloat64Array" -> "float64_array"; + case "PackedStringArray" -> "string_array"; + case "PackedVector2Array" -> "vector2_array"; + case "PackedVector3Array" -> "vector3_array"; + case "PackedColorArray" -> "color_array"; + case "PackedVector4Array" -> "vector4_array"; + default -> throw new IllegalArgumentException( + "Unknown Packed*Array family for gdcc_packed_ref helper naming: " + type.getTypeName()); + }; + } + + /// `gdcc_packed__` helper name (e.g. `new_empty`, `wrap_temp`, `internal_ptr`). + public static @NotNull String helperName(@NotNull GdPackedArrayType type, @NotNull String helperSuffix) { + return "gdcc_packed_" + requireFamilySlug(type) + "_" + helperSuffix; + } + + /// Receiver/argument base for builtin method, index and operator calls: + /// `gdcc_packed__internal_ptr()` where the argument expression must + /// already be the address of the Variant storage (`&$var` or a ref parameter pointer). + public static @NotNull String internalPtrExpr(@NotNull GdPackedArrayType type, @NotNull String variantAddrExpr) { + return helperName(type, "internal_ptr") + "(" + variantAddrExpr + ")"; + } + + /// Whitelist (b): empty-array Variant construction for default initialization. + public static @NotNull String newEmptyExpr(@NotNull GdPackedArrayType type) { + return helperName(type, "new_empty") + "()"; + } + + /// Whitelist (c): wrap a native struct temporary (builtin method/operator return) into a + /// fresh Variant, destroying the temporary. + public static @NotNull String wrapTempExpr(@NotNull GdPackedArrayType type, @NotNull String tempAddrExpr) { + return helperName(type, "wrap_temp") + "(" + tempAddrExpr + ")"; + } + + /// Whitelist (a) inbound: ptrcall raw struct argument slot -> materialized Variant. + public static @NotNull String variantFromStructExpr(@NotNull GdPackedArrayType type, @NotNull String structPtrExpr) { + return helperName(type, "variant_from_struct") + "(" + structPtrExpr + ")"; + } + + /// Whitelist (a) outbound: Variant -> raw struct copy for ptrcall return slots / engine + /// ptrcall argument materialization. + public static @NotNull String structFromVariantExpr(@NotNull GdPackedArrayType type, @NotNull String variantAddrExpr) { + return helperName(type, "struct_from_variant") + "(" + variantAddrExpr + ")"; + } + + /// Whitelist (d) same-family copy construction: explicit `Packed*Array(other)` and + /// same-family `as` casts produce an independent COW copy with a fresh identity. + public static @NotNull String newCopyExpr(@NotNull GdPackedArrayType type, @NotNull String variantAddrExpr) { + return helperName(type, "new_copy") + "(" + variantAddrExpr + ")"; + } + + /// Whitelist (d) cross-type construction from `godot_Array`. + public static @NotNull String newFromArrayExpr(@NotNull GdPackedArrayType type, @NotNull String arrayAddrExpr) { + return helperName(type, "new_from_array") + "(" + arrayAddrExpr + ")"; + } + + /// Exact runtime family check used by unpack/`is` surfaces (`gdcc_packed_ref_is`). + public static @NotNull String isExpr(@NotNull GdPackedArrayType type, @NotNull String variantAddrExpr) { + return "gdcc_packed_ref_is(" + variantAddrExpr + ", GDEXTENSION_VARIANT_TYPE_" + + type.getGdExtensionType().name() + ")"; + } + + /// Raw C struct type name (`godot_PackedInt32Array`) — legal only inside whitelisted boundary + /// code (ptrcall slots, native wrapper temporaries); packed storage slots never use it. + public static @NotNull String rawStructCType(@NotNull GdPackedArrayType type) { + return "godot_" + type.getTypeName(); + } +} diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/insn/BuiltinCastInsnGen.java b/src/main/java/gd/script/gdcc/backend/c/gen/insn/BuiltinCastInsnGen.java index c16ee9f5..6b7ce026 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/insn/BuiltinCastInsnGen.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/insn/BuiltinCastInsnGen.java @@ -2,6 +2,7 @@ import gd.script.gdcc.backend.c.gen.CBodyBuilder; import gd.script.gdcc.backend.c.gen.CInsnGen; +import gd.script.gdcc.backend.c.gen.PackedRefCNames; import gd.script.gdcc.enums.GdInstruction; import gd.script.gdcc.lir.LirVariable; import gd.script.gdcc.lir.insn.BuiltinCastInsn; @@ -10,6 +11,7 @@ import gd.script.gdcc.type.GdDictionaryType; import gd.script.gdcc.type.GdNilType; import gd.script.gdcc.type.GdObjectType; +import gd.script.gdcc.type.GdPackedArrayType; import gd.script.gdcc.type.GdType; import gd.script.gdcc.type.GdVariantType; import gd.script.gdcc.util.type.ExplicitCastDecision; @@ -80,6 +82,24 @@ public void generateCCode(@NotNull CBodyBuilder bodyBuilder) { ); } + // Same-family packed `as` (reaching here because ExplicitCastSupport no longer classifies + // it as IDENTITY): the interpreter produces a COW copy with a FRESH identity (probe + // AS_SAME_FAMILY), never a share — emit whitelist (d) `new_copy` directly. + if (resolvedTarget instanceof GdPackedArrayType packedTarget + && valueVariable.type() instanceof GdPackedArrayType packedSource + && packedSource.getTypeName().equals(packedTarget.getTypeName())) { + var sourceAddr = bodyBuilder.renderArgument(bodyBuilder.valueOfVar(valueVariable), false); + if (!sourceAddr.temps().isEmpty()) { + throw bodyBuilder.invalidInsn("builtin_cast same-family packed source must not require temporaries"); + } + bodyBuilder.moveOwnedCallIntoSlot( + bodyBuilder.targetOfVar(resultVariable), + PackedRefCNames.newCopyExpr(packedTarget, sourceAddr.code()), + packedTarget + ); + return; + } + emitVariantConstructCast(bodyBuilder, resultVariable, valueVariable, resolvedTarget); } diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/insn/CallGlobalInsnGen.java b/src/main/java/gd/script/gdcc/backend/c/gen/insn/CallGlobalInsnGen.java index a4665910..f53f2bf8 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/insn/CallGlobalInsnGen.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/insn/CallGlobalInsnGen.java @@ -93,6 +93,39 @@ public void generateCCode(@NotNull CBodyBuilder bodyBuilder) { var returnType = signature.returnType(); var callVarargs = signature.isVararg() ? varargs : null; + + // Utility wrappers keep the native Packed*Array ABI (e.g. `godot_var_to_bytes` returns a + // raw struct, `godot_compress` takes one); adapt packed positions to the Variant-backed + // storage. Vararg tails are Variant-only and never combine with packed. + var fixedParamTypes = new ArrayList(fixedArgs.size()); + for (var i = 0; i < fixedArgs.size(); i++) { + fixedParamTypes.add(signature.parameters().get(i).type()); + } + if (returnType != null + && PackedNativeAbiCallSupport.requiresPackedAdaptation(returnType, fixedParamTypes)) { + if (callVarargs != null) { + throw bodyBuilder.invalidInsn("Utility function '" + utility.lookupName() + + "' is vararg with packed ABI positions; no packed-involving utility wrapper is vararg," + + " so this indicates an LIR/metadata anomaly"); + } + if (returnType instanceof GdVoidType) { + if (instruction.resultId() != null) { + throw bodyBuilder.invalidInsn("Utility function '" + utility.lookupName() + + "' has no return value but resultId is provided"); + } + PackedNativeAbiCallSupport.emitCall(bodyBuilder, bodyBuilder.discardRef(), + utility.cFunctionName(), returnType, fixedParamTypes, fixedArgs); + } else { + var packedTarget = resolveResultTarget(bodyBuilder, instruction, utility.lookupName(), returnType); + PackedNativeAbiCallSupport.emitCall(bodyBuilder, packedTarget, + utility.cFunctionName(), returnType, fixedParamTypes, fixedArgs); + } + for (var i = defaultTemps.size() - 1; i >= 0; i--) { + bodyBuilder.destroyTempVar(defaultTemps.get(i)); + } + return; + } + if (returnType == null || returnType instanceof GdVoidType) { if (instruction.resultId() != null) { throw bodyBuilder.invalidInsn("Utility function '" + utility.lookupName() + diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/insn/CallMethodInsnGen.java b/src/main/java/gd/script/gdcc/backend/c/gen/insn/CallMethodInsnGen.java index 3c5a3bc9..9e626eb9 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/insn/CallMethodInsnGen.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/insn/CallMethodInsnGen.java @@ -11,6 +11,7 @@ import gd.script.gdcc.scope.FunctionDef; import gd.script.gdcc.type.GdIntType; import gd.script.gdcc.type.GdObjectType; +import gd.script.gdcc.type.GdType; import gd.script.gdcc.type.GdVariantType; import gd.script.gdcc.type.GdVoidType; import gd.script.gdcc.type.GdccCoroStateType; @@ -299,6 +300,48 @@ static void emitResolvedCall(@NotNull CBodyBuilder bodyBuilder, indirect != null ? indirect.vtRecv() : null); var fixedCount = resolved.parameters().size(); var fixedArgs = callArgs.fixedArgs(); + var returnTypeForDispatch = resolved.returnType(); + + // Builtin-class wrapper calls keep the native Packed*Array ABI (receiver base, packed + // parameters, packed return); adapt those positions to the Variant-backed storage here. + // Engine/GDCC callees already take Variant storage pointers and need no adaptation. + if (resolved.mode() == BackendMethodCallResolver.DispatchMode.BUILTIN) { + var abiParamTypes = new ArrayList( + fixedCount + (resolved.isStatic() ? 0 : 1)); + if (!resolved.isStatic()) { + abiParamTypes.add(resolved.ownerType()); + } + for (var param : resolved.parameters()) { + abiParamTypes.add(param.type()); + } + if (PackedNativeAbiCallSupport.requiresPackedAdaptation(returnTypeForDispatch, abiParamTypes)) { + if (resolved.isVararg()) { + throw bodyBuilder.invalidInsn("Builtin method '" + resolved.ownerClassName() + "." + + resolved.methodName() + "' is vararg with packed ABI positions; no packed-involving" + + " builtin wrapper is vararg, so this indicates an LIR/metadata anomaly"); + } + if (indirect != null) { + // Unreachable: vtable dispatch only exists for GDCC object callees. + throw bodyBuilder.invalidInsn("Builtin method '" + resolved.ownerClassName() + "." + + resolved.methodName() + "' cannot dispatch through a vtable slot"); + } + if (returnTypeForDispatch instanceof GdVoidType) { + if (resultId != null) { + throw bodyBuilder.invalidInsn("Method '" + resolved.ownerClassName() + "." + resolved.methodName() + + "' has no return value but resultId is provided"); + } + PackedNativeAbiCallSupport.emitCall(bodyBuilder, bodyBuilder.discardRef(), + resolved.cFunctionName(), returnTypeForDispatch, abiParamTypes, fixedArgs); + } else { + var target = resolveResultTarget(bodyBuilder, resultId, resolved); + PackedNativeAbiCallSupport.emitCall(bodyBuilder, target, + resolved.cFunctionName(), returnTypeForDispatch, abiParamTypes, fixedArgs); + } + destroyTemporaryArgs(bodyBuilder, callArgs.temporaryArgs()); + bodyBuilder.recordUsedEngineMethodCall(resolved); + return; + } + } List varargs = null; if (resolved.isVararg()) { @@ -459,13 +502,13 @@ private void warnStaticMethodCall(@NotNull CBodyBuilder bodyBuilder, } fixedArgs.add(receiverArgOverride != null ? receiverArgOverride : BackendPropertyAccessResolver.renderReceiverValue( - bodyBuilder, - receiverVar, - resolved.ownerType(), - insnName, - "method owner", - "" - )); + bodyBuilder, + receiverVar, + resolved.ownerType(), + insnName, + "method owner", + "" + )); } var temporaryArgs = new ArrayList(Math.max(0, fixedCount - providedCount)); diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/insn/IndexStoreInsnGen.java b/src/main/java/gd/script/gdcc/backend/c/gen/insn/IndexStoreInsnGen.java index 5cf6ef27..5cc13a23 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/insn/IndexStoreInsnGen.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/insn/IndexStoreInsnGen.java @@ -277,6 +277,13 @@ private void emitSelfWritebackIfNeeded(@NotNull CBodyBuilder bodyBuilder, if (selfVar.type() instanceof GdVariantType) { return new SelfOperand(bodyBuilder.valueOfVar(selfVar), null, false); } + // Packed*Array storage IS a Variant: + // pass the storage slot directly to the Variant indexed/named setter, which mutates the + // shared array in place through the Variant's internal reference. No pack/call/unpack + // writeback is emitted, and `ref` self (a borrowed Variant* parameter) is equally legal. + if (selfVar.type() instanceof GdPackedArrayType) { + return new SelfOperand(bodyBuilder.valueOfVar(selfVar), null, false); + } var selfStrategy = resolveSelfStrategy(bodyBuilder, selfVar, selfMode); // Coroutine frame parameters are writable owning storage (their `ref` flag only // describes the borrowed thunk boundary), so the writeback path below is legal there. @@ -365,9 +372,10 @@ private boolean isIndexedReferenceSemanticSelfType(@NotNull GdType type) { } private boolean isIndexedValueSemanticSelfType(@NotNull GdType type) { + // Packed*Array is intentionally absent: it left the value-semantic self family when its + // storage became Variant-backed; `materializeSelfOperand` passes it directly. return type instanceof GdStringType || - type instanceof GdVectorType || - type instanceof GdPackedArrayType; + type instanceof GdVectorType; } private void emitFailureReturn(@NotNull CBodyBuilder bodyBuilder, diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/insn/InsnGenSupport.java b/src/main/java/gd/script/gdcc/backend/c/gen/insn/InsnGenSupport.java index 7b8a2912..4962c2c8 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/insn/InsnGenSupport.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/insn/InsnGenSupport.java @@ -1,10 +1,13 @@ package gd.script.gdcc.backend.c.gen.insn; import gd.script.gdcc.backend.c.gen.CBodyBuilder; +import gd.script.gdcc.backend.c.gen.PackedRefCNames; import gd.script.gdcc.lir.LirVariable; +import gd.script.gdcc.type.GdArrayType; import gd.script.gdcc.type.GdCompilerType; import gd.script.gdcc.type.GdNilType; import gd.script.gdcc.type.GdObjectType; +import gd.script.gdcc.type.GdPackedArrayType; import gd.script.gdcc.type.GdType; import gd.script.gdcc.type.GdVariantType; import org.jetbrains.annotations.NotNull; @@ -64,6 +67,10 @@ static void unpackVariantAssign(@NotNull CBodyBuilder bodyBuilder, @NotNull CBodyBuilder.ValueRef variantValue, @NotNull String useSite) { rejectCompilerOnlyType(bodyBuilder, targetType, useSite); + if (targetType instanceof GdPackedArrayType packedTargetType) { + emitPackedUnpackAssign(bodyBuilder, target, packedTargetType, variantValue, useSite); + return; + } var unpackFunctionName = bodyBuilder.helper().renderUnpackFunctionName(targetType); if (targetType instanceof GdObjectType objectType) { // Object unpack materializes a BORROWED fat pointer; destination slot decides retain. @@ -87,6 +94,53 @@ static void unpackVariantAssign(@NotNull CBodyBuilder bodyBuilder, bodyBuilder.callAssign(target, unpackFunctionName, targetType, List.of(variantValue)); } + /// Variant -> packed unpack: + /// - exact family payload: identity-sharing Variant holder copy (three-way sharing is the + /// contract — no struct detach at the boundary); + /// - Array payload: cross-type conversion through whitelist (d) `new_from_array`, producing an + /// independent array (interpreter-probed behavior for `var p: Packed*Array = variantArray`); + /// - any other payload kind: runtime type error with default-return, matching the + /// interpreter's failed typed assignment (the statement does not take effect). + private static void emitPackedUnpackAssign(@NotNull CBodyBuilder bodyBuilder, + @NotNull CBodyBuilder.TargetRef target, + @NotNull GdPackedArrayType packedTargetType, + @NotNull CBodyBuilder.ValueRef variantValue, + @NotNull String useSite) { + var variantArg = bodyBuilder.renderArgument(variantValue, false); + if (variantArg.preCode() != null && !variantArg.preCode().isBlank()) { + bodyBuilder.appendRaw(variantArg.preCode()); + } + if (!variantArg.temps().isEmpty()) { + throw bodyBuilder.invalidInsn("packed Variant unpack must not require temporaries at " + useSite); + } + var variantAddrExpr = variantArg.code(); + + bodyBuilder.appendLine("if (" + PackedRefCNames.isExpr(packedTargetType, variantAddrExpr) + ") {"); + // Carrier-first overwrite discipline: the identity-sharing holder copy is produced BEFORE + // the old slot value is destroyed, so this branch stays safe even if a future route ever + // aliases the source Variant and the target packed slot (same discipline as + // CBodyBuilder's stable-carrier slot writes). + bodyBuilder.moveOwnedCallIntoSlot( + target, + "godot_new_Variant_with_Variant(" + variantAddrExpr + ")", + packedTargetType + ); + bodyBuilder.appendLine("} else if (godot_variant_get_type(" + variantAddrExpr + + ") == GDEXTENSION_VARIANT_TYPE_ARRAY) {"); + var arrayTemp = bodyBuilder.newTempVariable("packed_unpack_array", + new GdArrayType(GdVariantType.VARIANT), + "godot_new_Array_with_Variant(" + variantAddrExpr + ")"); + bodyBuilder.declareTempVar(arrayTemp); + bodyBuilder.callAssign(target, PackedRefCNames.helperName(packedTargetType, "new_from_array"), + packedTargetType, List.of(arrayTemp)); + bodyBuilder.destroyTempVar(arrayTemp); + bodyBuilder.appendLine("} else {"); + emitRuntimeFailureReturn(bodyBuilder, + "Cannot assign Variant of incompatible payload type to " + packedTargetType.getTypeName() + + " (" + useSite + ")"); + bodyBuilder.appendLine("}"); + } + static @NotNull VariantOperand materializeVariantOperand(@NotNull CBodyBuilder bodyBuilder, @NotNull LirVariable operandVar, @NotNull String tempPrefix) { diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/insn/OperatorInsnGen.java b/src/main/java/gd/script/gdcc/backend/c/gen/insn/OperatorInsnGen.java index fee11d71..d6e100c1 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/insn/OperatorInsnGen.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/insn/OperatorInsnGen.java @@ -242,6 +242,21 @@ private void emitUnaryBuiltinEvaluator(@NotNull CBodyBuilder bodyBuilder, @NotNull LirVariable operandVar, @NotNull GdType semanticResultType) { var helperFunctionName = resolver.renderUnaryEvaluatorHelperName(op, operandVar.type(), semanticResultType); + // Evaluator helpers keep the native packed ABI (`const godot_Packed*Array*` operand / + // raw struct result); packed positions adapt through the internal pointer / wrap_temp + // boundary instead of the generic storage rendering. + var unaryParamTypes = List.of(operandVar.type()); + if (PackedNativeAbiCallSupport.requiresPackedAdaptation(semanticResultType, unaryParamTypes)) { + PackedNativeAbiCallSupport.emitCall( + bodyBuilder, + bodyBuilder.targetOfVar(resultVar), + helperFunctionName, + semanticResultType, + unaryParamTypes, + List.of(bodyBuilder.valueOfVar(operandVar)) + ); + return; + } bodyBuilder.callAssign( bodyBuilder.targetOfVar(resultVar), helperFunctionName, @@ -262,6 +277,20 @@ private void emitBinaryBuiltinEvaluator(@NotNull CBodyBuilder bodyBuilder, rightVar.type(), semanticResultType ); + // Same native-ABI adaptation as the unary route: e.g. `int in PackedInt32Array` keeps the + // scalar left operand by value while the packed right operand passes its internal pointer. + var binaryParamTypes = List.of(leftVar.type(), rightVar.type()); + if (PackedNativeAbiCallSupport.requiresPackedAdaptation(semanticResultType, binaryParamTypes)) { + PackedNativeAbiCallSupport.emitCall( + bodyBuilder, + bodyBuilder.targetOfVar(resultVar), + helperFunctionName, + semanticResultType, + binaryParamTypes, + List.of(bodyBuilder.valueOfVar(leftVar), bodyBuilder.valueOfVar(rightVar)) + ); + return; + } bodyBuilder.callAssign( bodyBuilder.targetOfVar(resultVar), helperFunctionName, diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/insn/PackedNativeAbiCallSupport.java b/src/main/java/gd/script/gdcc/backend/c/gen/insn/PackedNativeAbiCallSupport.java new file mode 100644 index 00000000..0e7a0f44 --- /dev/null +++ b/src/main/java/gd/script/gdcc/backend/c/gen/insn/PackedNativeAbiCallSupport.java @@ -0,0 +1,146 @@ +package gd.script.gdcc.backend.c.gen.insn; + +import gd.script.gdcc.backend.c.gen.CBodyBuilder; +import gd.script.gdcc.backend.c.gen.PackedRefCNames; +import gd.script.gdcc.type.GdPackedArrayType; +import gd.script.gdcc.type.GdType; +import gd.script.gdcc.type.GdVoidType; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/// Shared emitter for calls whose callee keeps the NATIVE Packed*Array ABI while gdcc-internal +/// storage is Variant-backed. +/// Applies to builtin-class method wrappers (e.g. `godot_PackedInt32Array_push_back`), builtin +/// constructor wrappers with packed arguments (e.g. `godot_new_Array_with_PackedInt32Array`), +/// generated operator evaluator helpers, and utility wrappers (e.g. `godot_var_to_bytes`). +/// +/// Adaptation rules per call position: +/// - packed parameter positions pass `gdcc_packed__internal_ptr(
)`, so builtin methods / index / operator evaluation mutate the shared array in place; +/// - packed results are received into a raw struct temporary that is immediately wrapped into the +/// target Variant slot via `gdcc_packed__wrap_temp` (whitelist (c) — the produced array is +/// a fresh, independent identity) and the temporary is destroyed inside `wrap_temp`; +/// - every other position keeps the generic by-value / by-pointer rendering. +/// +/// All call sites here are fixed-arity: vararg tails are a caller-side contract violation because +/// no packed-involving native wrapper is vararg. +public final class PackedNativeAbiCallSupport { + private PackedNativeAbiCallSupport() { + } + + /// True when at least one call position (return or any parameter) crosses the packed + /// native-ABI boundary and therefore needs this emitter instead of the generic call path. + public static boolean requiresPackedAdaptation(@NotNull GdType returnType, + @NotNull List paramTypes) { + if (returnType instanceof GdPackedArrayType) { + return true; + } + for (var paramType : paramTypes) { + if (paramType instanceof GdPackedArrayType) { + return true; + } + } + return false; + } + + /// Emits `calleeName(args...)` with per-position packed adaptation. + /// + /// @param target result slot, or `DiscardRef` for statement-position calls; for non-void + /// calls the fresh result is consumed as OWNED (move into slot, or destroy on discard) + /// @param paramTypes ABI-side parameter types, positionally aligned with `argValues`; the + /// instance receiver of a builtin method call is simply the leading entry + public static void emitCall(@NotNull CBodyBuilder bodyBuilder, + @NotNull CBodyBuilder.TargetRef target, + @NotNull String calleeName, + @NotNull GdType returnType, + @NotNull List paramTypes, + @NotNull List argValues) { + Objects.requireNonNull(bodyBuilder, "bodyBuilder must not be null"); + Objects.requireNonNull(target, "target must not be null"); + if (paramTypes.size() != argValues.size()) { + throw bodyBuilder.invalidInsn("Packed native-ABI call '" + calleeName + "' argument count mismatch: " + + paramTypes.size() + " parameters vs " + argValues.size() + " arguments"); + } + + var argCodes = new ArrayList(argValues.size()); + for (var i = 0; i < argValues.size(); i++) { + argCodes.add(renderArgCode(bodyBuilder, calleeName, i, paramTypes.get(i), argValues.get(i))); + } + var callExpr = calleeName + "(" + String.join(", ", argCodes) + ")"; + + if (target instanceof CBodyBuilder.DiscardRef) { + emitDiscardedCall(bodyBuilder, callExpr, returnType); + return; + } + if (returnType instanceof GdVoidType) { + throw bodyBuilder.invalidInsn("Packed native-ABI call '" + calleeName + + "' has void return but a result target was provided"); + } + if (returnType instanceof GdPackedArrayType packedReturnType) { + // The wrapper yields a fresh native struct; wrap it into the Variant slot in one move. + var rawResultName = bodyBuilder.newTempVariable("packed_native_ret", returnType).name(); + bodyBuilder.appendLine(PackedRefCNames.rawStructCType(packedReturnType) + " " + rawResultName + + " = " + callExpr + ";"); + bodyBuilder.moveOwnedCallIntoSlot( + target, + PackedRefCNames.wrapTempExpr(packedReturnType, "&" + rawResultName), + returnType + ); + return; + } + bodyBuilder.moveOwnedCallIntoSlot(target, callExpr, returnType); + } + + /// Packed positions render the Variant-internal pointer; all other positions reuse the + /// standard argument rendering (by value for primitives / fat objects, by address otherwise). + private static @NotNull String renderArgCode(@NotNull CBodyBuilder bodyBuilder, + @NotNull String calleeName, + int index, + @NotNull GdType paramType, + @NotNull CBodyBuilder.ValueRef argValue) { + var rendered = bodyBuilder.renderArgument(argValue, false); + if (rendered.preCode() != null && !rendered.preCode().isBlank()) { + bodyBuilder.appendRaw(rendered.preCode()); + } + if (!rendered.temps().isEmpty()) { + throw bodyBuilder.invalidInsn("Packed native-ABI call '" + calleeName + "' argument #" + (index + 1) + + " unexpectedly requires temporaries: " + rendered.temps()); + } + if (paramType instanceof GdPackedArrayType packedParamType) { + if (!(argValue.type() instanceof GdPackedArrayType)) { + // A Variant/other-typed argument would reach the internal-pointer getter with an + // unverified payload kind, which is engine-level UB; the frontend must + // unpack/check first instead of routing through this emitter. + throw bodyBuilder.invalidInsn("Packed native-ABI call '" + calleeName + "' argument #" + (index + 1) + + " must be statically packed (got '" + argValue.type().getTypeName() + "')"); + } + return PackedRefCNames.internalPtrExpr(packedParamType, rendered.code()); + } + return rendered.code(); + } + + /// Discard handling: void calls emit plainly; packed results are received into a raw struct + /// temp destroyed right away; other destroyable results follow the ordinary temp+destroy path. + private static void emitDiscardedCall(@NotNull CBodyBuilder bodyBuilder, + @NotNull String callExpr, + @NotNull GdType returnType) { + if (returnType instanceof GdVoidType) { + bodyBuilder.appendLine(callExpr + ";"); + return; + } + if (returnType instanceof GdPackedArrayType packedReturnType) { + var rawDiscardName = bodyBuilder.newTempVariable("packed_native_discard", returnType).name(); + bodyBuilder.appendLine(PackedRefCNames.rawStructCType(packedReturnType) + " " + rawDiscardName + + " = " + callExpr + ";"); + bodyBuilder.appendLine(PackedRefCNames.rawStructCType(packedReturnType) + "_destroy(&" + + rawDiscardName + ");"); + return; + } + var discardTemp = bodyBuilder.newTempVariable("discard", returnType, callExpr); + bodyBuilder.declareTempVar(discardTemp); + bodyBuilder.destroyTempVar(discardTemp); + } +} diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/FrontendWritableTypeWritebackSupport.java b/src/main/java/gd/script/gdcc/frontend/lowering/FrontendWritableTypeWritebackSupport.java index 678ffa51..0f8decfd 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/FrontendWritableTypeWritebackSupport.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/FrontendWritableTypeWritebackSupport.java @@ -4,6 +4,7 @@ import gd.script.gdcc.type.GdDictionaryType; import gd.script.gdcc.type.GdCompilerType; import gd.script.gdcc.type.GdObjectType; +import gd.script.gdcc.type.GdPackedArrayType; import gd.script.gdcc.type.GdPrimitiveType; import gd.script.gdcc.type.GdType; import org.jetbrains.annotations.NotNull; @@ -18,17 +19,73 @@ /// - value-semantic builtin carriers do write back /// - `Variant` currently returns `true` here because the static shortcut only skips families already /// proven shared; the runtime helper refines the unknown branch later +/// +/// Packed*Array is Variant-backed (holder copies share the engine-side array identity), so its +/// answer additionally depends on the route provenance: routes whose writeback would persist a +/// detached getter copy or re-store the same identity are exempt, while routes whose writeback is +/// a redundant-but-harmless same-identity store keep the legacy answer to minimize migration risk. public final class FrontendWritableTypeWritebackSupport { + /// Which writable route is asking for a reverse-commit decision. The family matrix alone cannot + /// answer for Packed*Array anymore, so every call site must declare its route. + public enum WritebackRouteProvenance { + /// Terminal snapshot commit for a bare direct-slot receiver (local/parameter/capture root). + /// Packed snapshot temps are Variant holder copies sharing identity with the source slot, so + /// the post-call self-assign would be redundant. + DIRECT_SLOT, + /// Bare static property route: whether the terminal leaf must be promoted into a commit + /// step. Packed static storage shares identity with the loaded value, so no promotion is + /// needed (and promoting would trip the static-terminal contract). + STATIC_PROPERTY, + /// Builtin engine property commit reached through a mutating receiver *call*. The engine + /// getter returns a detached copy and the interpreter does not persist the mutation, so + /// writing the carrier back would wrongly persist it. Assignment routes on the same + /// properties are a different surface (the interpreter persists subscript assignment + /// through read-modify-write) and must use {@link #GENERIC} instead. + ENGINE_PROPERTY_CALL, + /// GDCC script instance property commit. Packed writeback is a redundant same-identity + /// store; kept deliberately (harmless, lower migration risk). + SCRIPT_PROPERTY, + /// Array/Dictionary element commit. Kept for the same reason as {@link #SCRIPT_PROPERTY}. + CONTAINER_ELEMENT, + /// Assignment-driven and runtime-open routes keep the legacy family answer for packed. + GENERIC + } + private FrontendWritableTypeWritebackSupport() { } - public static boolean requiresReverseCommitForCarrierType(@NotNull GdType carrierType) { + public static boolean requiresReverseCommitForCarrierType( + @NotNull GdType carrierType, + @NotNull WritebackRouteProvenance provenance + ) { + Objects.requireNonNull(provenance, "provenance must not be null"); return switch (Objects.requireNonNull(carrierType, "carrierType must not be null")) { case GdCompilerType _ -> throw new IllegalArgumentException( "compiler-only type leaked into frontend writeback analysis: " + carrierType.getTypeName() ); + // Packed*Array must be answered before the shared-family rule: it is Variant-backed + // (identity-shared) but keeps legacy writeback on the retained redundant routes. + case GdPackedArrayType _ -> switch (provenance) { + case DIRECT_SLOT, STATIC_PROPERTY, ENGINE_PROPERTY_CALL -> false; + case SCRIPT_PROPERTY, CONTAINER_ELEMENT, GENERIC -> true; + }; case GdPrimitiveType _, GdObjectType _, GdArrayType _, GdDictionaryType _ -> false; default -> true; }; } + + /// Whether a mutating call on a bare direct-slot receiver that stayed on the temp-snapshot + /// surface still needs the terminal `DIRECT_SLOT` commit step. This publishing decision is + /// deliberately narrower than the family matrix: Packed*Array is the sole exemption because + /// its snapshot shares identity with the source slot, while every other family keeps the + /// historical unconditional step (a redundant self-assign for shared carriers). + public static boolean requiresDirectSlotSnapshotCommit(@NotNull GdType carrierType) { + return switch (Objects.requireNonNull(carrierType, "carrierType must not be null")) { + case GdCompilerType _ -> throw new IllegalArgumentException( + "compiler-only type leaked into frontend writeback analysis: " + carrierType.getTypeName() + ); + case GdPackedArrayType _ -> false; + default -> true; + }; + } } diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraph.java b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraph.java index 7ab2fb3e..18d7e9ef 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraph.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraph.java @@ -357,6 +357,7 @@ private static void validateWritableRouteContracts(@NotNull Map } } validateStaticWritableRouteTerminalContract(payload, nodeId); + validateDirectSlotCommitStepContract(item, payload, nodeId); } if (item instanceof ValueOpItem valueOpItem && valueOpItem.resultValueIdOrNull() != null) { locallyPublishedValueIds.add(valueOpItem.resultValueIdOrNull()); @@ -411,6 +412,45 @@ private static void validateStaticWritableRouteTerminalContract( } } + /// A `DIRECT_SLOT` commit step only exists for mutating calls whose bare direct-slot receiver + /// stayed on the ordinary temp snapshot surface (alias publication rejected). It is therefore + /// legal only on `CallItem` payloads rooted at `DIRECT_SLOT` with a `DIRECT_SLOT` leaf, and it + /// must stay the outermost (terminal) step because a direct slot owns no outer owner chain. + private static void validateDirectSlotCommitStepContract( + @NotNull SequenceItem item, + @NotNull FrontendWritableRoutePayload payload, + @NotNull String nodeId + ) { + for (var index = 0; index < payload.reverseCommitSteps().size(); index++) { + var step = payload.reverseCommitSteps().get(index); + if (step.kind() != FrontendWritableRoutePayload.StepKind.DIRECT_SLOT) { + continue; + } + if (!(item instanceof CallItem)) { + throw new IllegalArgumentException( + "Frontend CFG writable route in sequence '" + + nodeId + + "' must only carry a DIRECT_SLOT commit step on call payloads" + ); + } + if (payload.root().kind() != FrontendWritableRoutePayload.RootKind.DIRECT_SLOT + || payload.leaf().kind() != FrontendWritableRoutePayload.LeafKind.DIRECT_SLOT) { + throw new IllegalArgumentException( + "Frontend CFG writable route in sequence '" + + nodeId + + "' must root a DIRECT_SLOT commit step at a DIRECT_SLOT root/leaf pair" + ); + } + if (index != 0) { + throw new IllegalArgumentException( + "Frontend CFG writable route in sequence '" + + nodeId + + "' must keep a DIRECT_SLOT commit step terminal (outermost)" + ); + } + } + } + private static @Nullable FrontendWritableRoutePayload extractWritableRoutePayload(@NotNull SequenceItem item) { return switch (item) { case AssignmentItem assignmentItem -> assignmentItem.writableRoutePayload(); diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphBuilder.java b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphBuilder.java index 6f67c492..6a365569 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphBuilder.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphBuilder.java @@ -407,11 +407,12 @@ private void processExpressionStatement( @NotNull AttributeCallStep attributeCallStep, @NotNull FrontendResolvedCall publishedCall ) { - receiverBuild = maybePublishDirectSlotReceiverAlias( + var receiverPublication = maybePublishDirectSlotReceiverAlias( receiverBuild, publishedCall, attributeCallStep.arguments() ); + receiverBuild = receiverPublication.receiverBuild(); var argumentsBuild = buildArgumentValues(receiverBuild.cursor(), attributeCallStep.arguments()); var receiverRoute = routePayloadOrValueRoot(receiverBuild); argumentsBuild.cursor().currentSequence().items().add(new CallItem( @@ -424,7 +425,11 @@ private void processExpressionStatement( attributeCallStep, receiverRoute.root(), receiverRoute.leaf(), - appendCallReceiverCommitSteps(receiverRoute, publishedCall) + appendCallReceiverCommitSteps( + receiverRoute, + publishedCall, + receiverPublication.aliasPublished() + ) ) )); return argumentsBuild.cursor(); @@ -3572,11 +3577,12 @@ yield new ValueBuild( case AttributeCallStep attributeCallStep -> { var publishedCall = requireLoweringReadyCall(attributeCallStep); checkValueProducingCall(publishedCall, attributeCallStep, "attribute call step"); - receiverBuild = maybePublishDirectSlotReceiverAlias( + var receiverPublication = maybePublishDirectSlotReceiverAlias( receiverBuild, publishedCall, attributeCallStep.arguments() ); + receiverBuild = receiverPublication.receiverBuild(); var argumentsBuild = buildArgumentValues(receiverBuild.cursor(), attributeCallStep.arguments()); var resultValueId = chooseResultValueId(preferredResultValueId); var receiverRoute = routePayloadOrValueRoot(receiverBuild); @@ -3590,12 +3596,19 @@ yield new ValueBuild( // therefore need the promoted leaf to appear in reverseCommitSteps. Without this, // property/subscript receivers would carry provenance but no actual post-call // writeback plan. Static property receivers stay terminal (see - // appendCallReceiverCommitSteps). + // appendCallReceiverCommitSteps). A bare direct-slot receiver that stayed on the + // ordinary temp snapshot surface (alias publication rejected) additionally needs + // a direct-slot commit step so the mutated temp is written back to its source + // local/parameter slot after the call. new FrontendWritableRoutePayload( attributeCallStep, receiverRoute.root(), receiverRoute.leaf(), - appendCallReceiverCommitSteps(receiverRoute, publishedCall) + appendCallReceiverCommitSteps( + receiverRoute, + publishedCall, + receiverPublication.aliasPublished() + ) ) )); yield valueRootBuild(argumentsBuild.cursor(), attributeCallStep, resultValueId); @@ -3815,43 +3828,53 @@ yield new OpaqueExpressionRoute( /// - the receiver is already a direct-slot writable root /// - the current publication is still the generic opaque temp path /// - the receiver belongs to one explicit root category (`SelfExpression`, `LOCAL_VAR`, - /// `PARAMETER`) instead of an implicit/self-context fallback - /// - `CAPTURE` is intentionally excluded until lambda/capture lowering semantics are frozen; - /// otherwise alias publication would prematurely promise live-slot behavior for a deferred surface + /// `PARAMETER`, `CAPTURE`) instead of an implicit/self-context fallback + /// - `CAPTURE` aliases the lambda's own capture slot: Variant-backed packed captures share the + /// outer array identity, so in-place mutation through the alias is exactly interpreter + /// capture semantics (assignment to the capture name stays copy-on-capture and never + /// rebinds the outer slot) /// - for identifier-backed roots, later argument evaluation must stay inside a proven /// no-rebinding subset; otherwise builder deliberately keeps the ordinary temp snapshot - private @NotNull ValueBuild maybePublishDirectSlotReceiverAlias( + /// + /// The returned flag tells the caller whether the live-slot alias was actually published. A + /// `false` result means the receiver stayed on the ordinary temp snapshot surface, so a + /// mutating call must plan an explicit post-call writeback into the source slot (see + /// `appendCallReceiverCommitSteps`). + private @NotNull DirectSlotReceiverPublication maybePublishDirectSlotReceiverAlias( @NotNull ValueBuild receiverBuild, @NotNull FrontendResolvedCall publishedCall, @NotNull List arguments ) { if (!FrontendCallMutabilitySupport.mayMutateReceiver(publishedCall)) { - return receiverBuild; + return new DirectSlotReceiverPublication(receiverBuild, false); } var routePayload = receiverBuild.writableRoutePayloadOrNull(); if (routePayload == null || routePayload.root().kind() != FrontendWritableRoutePayload.RootKind.DIRECT_SLOT || routePayload.leaf().kind() != FrontendWritableRoutePayload.LeafKind.DIRECT_SLOT) { - return receiverBuild; + return new DirectSlotReceiverPublication(receiverBuild, false); } if (!(receiverBuild.valueAnchor() instanceof IdentifierExpression || receiverBuild.valueAnchor() instanceof SelfExpression)) { - return receiverBuild; + return new DirectSlotReceiverPublication(receiverBuild, false); } var items = receiverBuild.cursor().currentSequence().items(); if (!(items.getLast() instanceof OpaqueExprValueItem opaqueValueItem) || !opaqueValueItem.resultValueId().equals(receiverBuild.resultValueId()) || opaqueValueItem.expression() != receiverBuild.valueAnchor()) { - return receiverBuild; + return new DirectSlotReceiverPublication(receiverBuild, false); } var aliasRoot = requireDirectSlotAliasRoot(receiverBuild); if (!shouldPublishDirectSlotAlias(aliasRoot, arguments)) { - return receiverBuild; + return new DirectSlotReceiverPublication(receiverBuild, false); } items.removeLast(); - return emitDirectSlotAliasValue( - receiverBuild.cursor(), - (Expression) receiverBuild.valueAnchor(), - receiverBuild.resultValueId() + return new DirectSlotReceiverPublication( + emitDirectSlotAliasValue( + receiverBuild.cursor(), + (Expression) receiverBuild.valueAnchor(), + receiverBuild.resultValueId() + ), + true ); } @@ -3875,14 +3898,12 @@ yield new OpaqueExpressionRoute( yield switch (binding.kind()) { case LOCAL_VAR -> new DirectSlotAliasRoot(identifierExpression, DirectSlotAliasRootKind.LOCAL_VAR); case PARAMETER -> new DirectSlotAliasRoot(identifierExpression, DirectSlotAliasRootKind.PARAMETER); - case CAPTURE -> throw new IllegalStateException( - "Direct-slot alias publication does not support CAPTURE binding before lambda/capture semantics are implemented" - ); + case CAPTURE -> new DirectSlotAliasRoot(identifierExpression, DirectSlotAliasRootKind.CAPTURE); case SELF -> throw new IllegalStateException( "Direct-slot alias publication must use explicit SelfExpression instead of identifier binding kind SELF" ); default -> throw new IllegalStateException( - "Direct-slot alias publication requires LOCAL_VAR/PARAMETER binding, but got " + "Direct-slot alias publication requires LOCAL_VAR/PARAMETER/CAPTURE binding, but got " + binding.kind() ); }; @@ -4109,15 +4130,54 @@ private boolean useImplicitRootContainer(@NotNull FrontendWritableRoutePayload r /// container slot), so it must never be promoted into a non-terminal commit step whenever the /// promoted step could only ever produce a no-op write-back: either the call is provably /// const (`mayMutateReceiver == false`, so body lowering skips the reverse commit entirely), - /// or the receiver is a reference carrier (`Array`/`Dictionary`/objects/primitives) mutated - /// in place through the loaded value. Mutating calls on value-semantic or unknown (`Variant`) - /// carriers keep the promotion so the static-terminal contract fails fast instead of - /// silently dropping a required write-back. + /// or the receiver carrier is identity-shared with the static storage (`Array`/`Dictionary`/ + /// objects/primitives, and Variant-backed `Packed*Array`) mutated in place through the loaded + /// value. Mutating calls on remaining value-semantic or unknown (`Variant`) carriers keep the + /// promotion so the static-terminal contract fails fast instead of silently dropping a + /// required write-back. + /// + /// A bare direct-slot receiver (ordinary local/parameter) that could not publish its + /// live-slot alias stays on the ordinary temp snapshot surface: the call then mutates one + /// `cfg_tmp_*` copy instead of the source slot. Value-semantic carriers other than + /// `Packed*Array` (`String`, ...) would otherwise lose the mutation to copy-on-write detach, + /// so the route must append one terminal `DIRECT_SLOT` commit step that writes the mutated + /// temp back into the root slot after the call. Packed receivers skip the step: their snapshot + /// temp is a Variant holder copy sharing the source slot's array identity, so the mutation is + /// already visible and the write-back would be a redundant self-assign. Alias-published + /// receivers already mutate the source slot in place and must keep the step list unchanged (a + /// self-assign writeback would be a destroy-then-copy hazard). This writeback stays + /// unconditional at lowering time for the remaining families because no currently legal + /// argument-evaluation surface can rebind a caller local slot; any future rebinding-capable + /// argument form must revisit this contract before opting out of the snapshot fallback. + /// + /// The step is currently limited to `LOCAL_VAR` roots. `PARAMETER` and `CAPTURE` roots never + /// publish it: packed parameters/captures already share the array identity with their source + /// through Variant-backed storage, while rebinding a parameter or a capture name stays + /// unsupported/copy-on-capture as a whole, so there is no writeback contract to express here. private @NotNull List appendCallReceiverCommitSteps( @NotNull FrontendWritableRoutePayload routePayload, - @NotNull FrontendResolvedCall publishedCall + @NotNull FrontendResolvedCall publishedCall, + boolean receiverAliasPublished ) { var receiverType = publishedCall.receiverType(); + if (!receiverAliasPublished + && FrontendCallMutabilitySupport.mayMutateReceiver(publishedCall) + && routePayload.root().kind() == FrontendWritableRoutePayload.RootKind.DIRECT_SLOT + && routePayload.leaf().kind() == FrontendWritableRoutePayload.LeafKind.DIRECT_SLOT + && isLocalVarDirectSlotRoute(routePayload) + && (receiverType == null + || FrontendWritableTypeWritebackSupport.requiresDirectSlotSnapshotCommit(receiverType))) { + var steps = new ArrayList<>(routePayload.reverseCommitSteps()); + steps.add(new FrontendWritableRoutePayload.StepDescriptor( + FrontendWritableRoutePayload.StepKind.DIRECT_SLOT, + routePayload.leaf().anchor(), + null, + List.of(), + null, + null + )); + return List.copyOf(steps); + } var staticBarePropertyReceiver = routePayload.root().kind() == FrontendWritableRoutePayload.RootKind.STATIC_CONTEXT && routePayload.leaf().kind() == FrontendWritableRoutePayload.LeafKind.PROPERTY @@ -4125,12 +4185,25 @@ private boolean useImplicitRootContainer(@NotNull FrontendWritableRoutePayload r if (staticBarePropertyReceiver && (!FrontendCallMutabilitySupport.mayMutateReceiver(publishedCall) || (receiverType != null - && !FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType(receiverType)))) { + && !FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( + receiverType, + FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.STATIC_PROPERTY)))) { return routePayload.reverseCommitSteps(); } return appendPromotedLeaf(routePayload); } + /// Only `LOCAL_VAR` bindings qualify for the direct-slot snapshot writeback. `PARAMETER` and + /// `CAPTURE` roots are deliberately excluded from snapshot writeback (their mutating receivers + /// use alias publication instead), and any other root must fail closed to the legacy snapshot + /// behavior instead of promising a writeback the route cannot express. + private boolean isLocalVarDirectSlotRoute(@NotNull FrontendWritableRoutePayload routePayload) { + if (!(routePayload.leaf().anchor() instanceof IdentifierExpression identifierExpression)) { + return false; + } + return requirePublishedBinding(identifierExpression).kind() == FrontendBindingKind.LOCAL_VAR; + } + private @Nullable FrontendWritableRoutePayload.StepDescriptor promoteLeafToCommitStep( @NotNull FrontendWritableRoutePayload.LeafDescriptor leaf ) { @@ -5587,10 +5660,20 @@ private record DirectSlotAliasRoot( } } + private record DirectSlotReceiverPublication( + @NotNull ValueBuild receiverBuild, + boolean aliasPublished + ) { + private DirectSlotReceiverPublication { + Objects.requireNonNull(receiverBuild, "receiverBuild must not be null"); + } + } + private enum DirectSlotAliasRootKind { EXPLICIT_SELF, LOCAL_VAR, - PARAMETER + PARAMETER, + CAPTURE } private enum DirectSlotAliasArgumentSafety { diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/FrontendWritableRoutePayload.java b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/FrontendWritableRoutePayload.java index 0c7997a9..8bcacdb7 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/FrontendWritableRoutePayload.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/FrontendWritableRoutePayload.java @@ -187,6 +187,22 @@ public record StepDescriptor( memberNameOrNull = StringUtil.requireNonBlank(memberNameOrNull, "memberNameOrNull"); } switch (kind) { + case DIRECT_SLOT -> { + // Direct-slot writeback targets the route root slot itself, so no container, + // key, or member payload is meaningful on this step kind. + if (containerValueIdOrNull != null) { + throw new IllegalArgumentException("DIRECT_SLOT step must not publish containerValueIdOrNull"); + } + if (!operandValueIds.isEmpty()) { + throw new IllegalArgumentException("DIRECT_SLOT step must not publish operandValueIds"); + } + if (memberNameOrNull != null) { + throw new IllegalArgumentException("DIRECT_SLOT step must not publish memberNameOrNull"); + } + if (subscriptAccessKindOrNull != null) { + throw new IllegalArgumentException("DIRECT_SLOT step must not publish subscriptAccessKindOrNull"); + } + } case PROPERTY -> { if (!operandValueIds.isEmpty()) { throw new IllegalArgumentException("PROPERTY step must not publish operandValueIds"); @@ -218,6 +234,7 @@ private void appendReferencedValueIds(@NotNull List referencedValueIds) } public enum StepKind { + DIRECT_SLOT, PROPERTY, SUBSCRIPT } diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendAssignmentTargetInsnLoweringProcessors.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendAssignmentTargetInsnLoweringProcessors.java index 5dc4015b..c74ac5f2 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendAssignmentTargetInsnLoweringProcessors.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendAssignmentTargetInsnLoweringProcessors.java @@ -38,6 +38,9 @@ private FrontendAssignmentTargetInsnLoweringProcessors() { writeLeafPurpose(chain.leaf()) ); var carrierSlotId = FrontendWritableRouteSupport.writeLeaf(session, block, chain, materializedRhsSlotId); + // Assignment routes keep the legacy family writeback answer: the interpreter persists + // subscript assignment on engine properties (`poly.polygon[0] = v`) through + // read-modify-write, unlike mutating method calls on the getter copy. return FrontendWritableRouteSupport.reverseCommitWithRuntimeGate( session, block, @@ -48,7 +51,8 @@ private FrontendAssignmentTargetInsnLoweringProcessors() { session, gateBlock, currentCarrierSlotId - ) + ), + FrontendWritableRouteSupport.ReverseCommitRouteOrigin.ASSIGNMENT ); } 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 089b4d42..85152ebd 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 @@ -27,6 +27,7 @@ import gd.script.gdcc.frontend.sema.FrontendResolvedMember; import gd.script.gdcc.frontend.sema.FrontendTypeTestTarget; import gd.script.gdcc.frontend.sema.analyzer.support.FrontendVariantBoundaryCompatibility; +import gd.script.gdcc.gdextension.ExtensionGdClass; import gd.script.gdcc.lir.LirBasicBlock; import gd.script.gdcc.lir.LirFunctionDef; import gd.script.gdcc.lir.LirInstruction; @@ -48,6 +49,7 @@ import gd.script.gdcc.scope.ParameterDef; import gd.script.gdcc.scope.PropertyDef; import gd.script.gdcc.scope.RefCountedStatus; +import gd.script.gdcc.scope.ScopeOwnerKind; import gd.script.gdcc.type.GdContainerType; import gd.script.gdcc.type.GdCompilerType; import gd.script.gdcc.type.GdDictionaryType; @@ -491,6 +493,18 @@ void requireSingleSubscriptArgument(@NotNull Node anchor, @NotNull List argum @NotNull FrontendWritableRoutePayload.StepDescriptor step ) { return switch (Objects.requireNonNull(step, "step must not be null").kind()) { + case DIRECT_SLOT -> { + // Direct-slot writeback targets the route root's own local/parameter slot; any + // other root kind means publication leaked a malformed direct step. + if (root.kind() != FrontendWritableRoutePayload.RootKind.DIRECT_SLOT) { + throw new IllegalStateException( + "DIRECT_SLOT reverse-commit step requires DIRECT_SLOT root, but got " + root.kind() + ); + } + yield new FrontendWritableRouteSupport.DirectSlotCommitStep( + resolveDirectWritableRootSlot(root.anchor()) + ); + } case PROPERTY -> { var propertyName = Objects.requireNonNull(step.memberNameOrNull(), "PROPERTY step must publish memberNameOrNull"); var dynamicMember = dynamicWritableMemberOrNull(step.anchor(), "reverse-commit property step"); @@ -509,7 +523,8 @@ void requireSingleSubscriptArgument(@NotNull Node anchor, @NotNull List argum } yield new FrontendWritableRouteSupport.InstancePropertyCommitStep( resolveWritableContainerSlot(root, step.containerValueIdOrNull()), - propertyName + propertyName, + isEngineOwnedWritablePropertyStep(step.anchor()) ); } case SUBSCRIPT -> { @@ -654,6 +669,34 @@ private boolean isStaticWritablePropertyRoute( }; } + /// Positive identification of engine-owned instance properties for the packed mutating-call + /// writeback removal: the engine getter returns a detached copy, so a mutating-call route must + /// not persist the mutated carrier back. Identification is intentionally one-sided — a script + /// property (`LirPropertyDef`) is never marked engine-owned, because wrongly skipping that + /// writeback would drop the retained script-property store contract. The other direction is a + /// known-divergence risk rather than harmless: an engine property that escapes both checks + /// keeps its writeback and would wrongly persist the mutation, so new anchor shapes must be + /// classified here explicitly instead of relying on the default. + private boolean isEngineOwnedWritablePropertyStep(@NotNull Node propertyAnchor) { + return switch (Objects.requireNonNull(propertyAnchor, "propertyAnchor must not be null")) { + case AttributePropertyStep _ -> { + var resolvedMember = requireResolvedMember(propertyAnchor); + yield resolvedMember.status() == FrontendMemberResolutionStatus.RESOLVED + && resolvedMember.bindingKind() == FrontendBindingKind.PROPERTY + && resolvedMember.ownerKind() == ScopeOwnerKind.ENGINE; + } + // Engine-declared properties carry `ExtensionGdClass.PropertyInfo` as their binding + // declaration site (it implements `PropertyDef` but is engine metadata, not GDCC + // script storage); script properties carry the skeleton-produced `LirPropertyDef`. + case IdentifierExpression identifierExpression -> { + var binding = requireBinding(identifierExpression); + yield binding.kind() == FrontendBindingKind.PROPERTY + && binding.declarationSite() instanceof ExtensionGdClass.PropertyInfo; + } + default -> false; + }; + } + private boolean isStaticResolvedPropertyMember(@NotNull FrontendResolvedMember resolvedMember) { return resolvedMember.bindingKind() == FrontendBindingKind.PROPERTY && resolvedMember.declarationSite() instanceof PropertyDef propertyDef diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendSequenceItemInsnLoweringProcessors.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendSequenceItemInsnLoweringProcessors.java index 5864b7da..a1d42cf6 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendSequenceItemInsnLoweringProcessors.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendSequenceItemInsnLoweringProcessors.java @@ -1087,7 +1087,8 @@ private boolean isSyntheticLoadCall( session, gateBlock, currentCarrierSlotId - ) + ), + FrontendWritableRouteSupport.ReverseCommitRouteOrigin.MUTATING_CALL ); } diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendWritableRouteSupport.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendWritableRouteSupport.java index 93536367..bfaf9ceb 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendWritableRouteSupport.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendWritableRouteSupport.java @@ -60,16 +60,37 @@ private FrontendWritableRouteSupport() { /// Creates the static writeback gate from the current carrier slot type. /// /// The family matrix itself lives in the public `frontend.lowering` helper so assignment - /// lowering, call writeback, and tests cannot silently drift into separate copies. + /// lowering, call writeback, and tests cannot silently drift into separate copies. The gate is + /// step-aware: the same packed carrier can require writeback on one route (script property / + /// container element) yet skip it on another (engine property reached by a mutating call). static @NotNull ReverseCommitGateHook createStaticCarrierWritebackGate( @NotNull FrontendBodyLoweringSession session ) { Objects.requireNonNull(session, "session must not be null"); - return (_, currentCarrierSlotId) -> FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( - session.requireFunctionVariableType(currentCarrierSlotId) + return (step, currentCarrierSlotId) -> FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( + session.requireFunctionVariableType(currentCarrierSlotId), + provenanceOfCommitStep(step) ); } + /// Maps a materialized commit step to the route provenance the family predicate consults. + /// `DynamicPropertyCommitStep` answers GENERIC because the owner is runtime-open; assignment + /// routes never consult this mapping (they keep the legacy family answer via GENERIC). + private static FrontendWritableTypeWritebackSupport.@NotNull WritebackRouteProvenance provenanceOfCommitStep( + @NotNull FrontendWritableCommitStep step + ) { + return switch (Objects.requireNonNull(step, "step must not be null")) { + case DirectSlotCommitStep _ -> FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.DIRECT_SLOT; + case StaticPropertyCommitStep _ -> FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.STATIC_PROPERTY; + case InstancePropertyCommitStep propertyStep -> propertyStep.engineProperty() + ? FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.ENGINE_PROPERTY_CALL + : FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.SCRIPT_PROPERTY; + case DynamicPropertyCommitStep _ -> FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.GENERIC; + case SubscriptCommitStep _, InstanceContainerSubscriptCommitStep _, StaticContainerSubscriptCommitStep _ -> + FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.CONTAINER_ELEMENT; + }; + } + static @NotNull String materializeLeafRead( @NotNull FrontendBodyLoweringSession session, @NotNull LirBasicBlock block, @@ -243,6 +264,11 @@ static void reverseCommit( /// - statically known value-semantic carriers still apply inline in the current block /// - only `Variant` carriers ask the caller to emit a runtime bool condition /// + /// `routeOrigin` scopes the provenance split: mutating-call routes answer per-step provenance + /// (so an engine-owned property layer skips the packed writeback), while assignment routes keep + /// the legacy family answer (the interpreter persists `obj.prop[i] = v` through + /// read-modify-write even on engine properties, so that writeback must stay). + /// /// The returned block is the continuation block that outer lowering should keep appending to. /// It may be the original `block` when no runtime branch was needed, or the last synthetic /// post-gate block when one or more per-layer `GoIfInsn` regions were materialized. @@ -251,7 +277,8 @@ static void reverseCommit( @NotNull LirBasicBlock block, @NotNull FrontendWritableAccessChain chain, @NotNull String writtenBackValueSlotId, - @NotNull ReverseCommitRuntimeGateEmitter runtimeGateEmitter + @NotNull ReverseCommitRuntimeGateEmitter runtimeGateEmitter, + @NotNull ReverseCommitRouteOrigin routeOrigin ) { Objects.requireNonNull(session, "session must not be null"); var currentBlock = Objects.requireNonNull(block, "block must not be null"); @@ -261,13 +288,17 @@ static void reverseCommit( runtimeGateEmitter, "runtimeGateEmitter must not be null" ); + Objects.requireNonNull(routeOrigin, "routeOrigin must not be null"); var reverseCommitSteps = actualChain.reverseCommitSteps(); for (var index = reverseCommitSteps.size() - 1; index >= 0; index--) { var step = reverseCommitSteps.get(index); var terminalStep = index == 0; var currentCarrierType = session.requireFunctionVariableType(currentCarrierSlotId); if (!requiresRuntimeWritebackGate(currentCarrierType)) { - if (!FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType(currentCarrierType)) { + var provenance = routeOrigin == ReverseCommitRouteOrigin.ASSIGNMENT + ? FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.GENERIC + : provenanceOfCommitStep(step); + if (!FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType(currentCarrierType, provenance)) { currentCarrierSlotId = nextOuterCarrierSlotId(step, currentCarrierSlotId, terminalStep); continue; } @@ -447,6 +478,13 @@ private static void materializeSubscriptLeafReadInto( boolean terminalStep ) { return switch (step) { + case DirectSlotCommitStep slotStep -> { + // The direct slot owns no outer owner, so the writeback is always terminal: + // `$local = mutated_temp` shares the already-detached value-semantic buffer + // (refcount churn only) and reference carriers are filtered out by the gate. + block.appendNonTerminatorInstruction(new AssignInsn(slotStep.slotId(), writtenBackValueSlotId)); + yield nextOuterCarrierSlotId(slotStep, writtenBackValueSlotId, terminalStep); + } case InstancePropertyCommitStep propertyStep -> { session.emitAssertObjectLiveIfNeeded(block, propertyStep.receiverSlotId()); block.appendNonTerminatorInstruction(new StorePropertyInsn( @@ -678,6 +716,7 @@ private static void materializeSubscriptLeafReadInto( boolean terminalStep ) { return switch (step) { + case DirectSlotCommitStep slotStep -> slotStep.slotId(); case InstancePropertyCommitStep propertyStep -> propertyStep.receiverSlotId(); case DynamicPropertyCommitStep propertyStep -> propertyStep.receiverSlotId(); case StaticPropertyCommitStep _ -> { @@ -853,6 +892,14 @@ private static boolean requiresRuntimeWritebackGate(@NotNull GdType currentCarri return Objects.requireNonNull(currentCarrierType, "currentCarrierType must not be null") instanceof GdVariantType; } + /// Why a reverse-commit walk is running. The packed engine-property writeback removal applies + /// only to mutating receiver calls; assignment routes on the same engine property must keep the + /// writeback because the interpreter persists subscript assignment through read-modify-write. + enum ReverseCommitRouteOrigin { + MUTATING_CALL, + ASSIGNMENT + } + @FunctionalInterface interface ReverseCommitGateHook { /// `writtenBackValueSlotId` is the current carrier about to be written into this step. @@ -1212,6 +1259,7 @@ record SubscriptLeaf( /// It is not another "leaf" hierarchy. The leaf describes the innermost direct operation; commit /// steps describe the outer owners that must observe the already-mutated carrier value. sealed interface FrontendWritableCommitStep permits + DirectSlotCommitStep, InstanceContainerSubscriptCommitStep, InstancePropertyCommitStep, DynamicPropertyCommitStep, @@ -1220,15 +1268,40 @@ sealed interface FrontendWritableCommitStep permits SubscriptCommitStep { } + /// Writes the mutated carrier back into the route root's own local/parameter slot. This is the + /// terminal step for mutating calls whose bare direct-slot receiver stayed on the ordinary + /// temp snapshot surface: the call mutated one `cfg_tmp_*` copy, so the mutated value must be + /// assigned back into the source slot once the call returns. Alias-published receivers never + /// carry this step because they already mutate the source slot in place. + record DirectSlotCommitStep( + @NotNull String slotId + ) implements FrontendWritableCommitStep { + DirectSlotCommitStep { + slotId = StringUtil.requireNonBlank(slotId, "slotId"); + } + } + /// Writes the mutated carrier back into `receiver.property`. + /// + /// `engineProperty` records whether the frozen member resolution identified the property as + /// engine-owned (getter returns a detached copy). Only mutating-call routes consult it: there + /// the packed writeback must be skipped so the mutation stays non-persistent like the + /// interpreter; GDCC script properties and assignment routes keep the redundant same-identity + /// store either way. record InstancePropertyCommitStep( @NotNull String receiverSlotId, - @NotNull String propertyName + @NotNull String propertyName, + boolean engineProperty ) implements FrontendWritableCommitStep { InstancePropertyCommitStep { receiverSlotId = StringUtil.requireNonBlank(receiverSlotId, "receiverSlotId"); propertyName = StringUtil.requireNonBlank(propertyName, "propertyName"); } + + /// Script-owned default for test fixtures and routes that predate ownership tracking. + InstancePropertyCommitStep(@NotNull String receiverSlotId, @NotNull String propertyName) { + this(receiverSlotId, propertyName, false); + } } /// Writes a mutated carrier back into a runtime-open `receiver.property` owner. diff --git a/src/main/java/gd/script/gdcc/type/GdccForPackedArrayIterType.java b/src/main/java/gd/script/gdcc/type/GdccForPackedArrayIterType.java index d879ee1c..16d0f005 100644 --- a/src/main/java/gd/script/gdcc/type/GdccForPackedArrayIterType.java +++ b/src/main/java/gd/script/gdcc/type/GdccForPackedArrayIterType.java @@ -11,9 +11,12 @@ /// Compiler-only storage type for one specialized Packed*Array `for-in` iterator family. /// -/// Each concrete Packed*Array has its own state instance (no shared kind-tagged union). The C storage -/// owns a typed COW snapshot, a typed element base pointer, index, and size. Copy bumps the COW -/// handle; get reads the typed pointer without runtime family dispatch. +/// Each concrete Packed*Array has its own state instance (no shared kind-tagged union). The C +/// storage is a LIVE-iteration state: a Variant +/// holder copy sharing the source array's identity, plus the current index — no COW snapshot, no +/// cached size, no cached element base pointer. `should_continue`/`get` re-evaluate the live size +/// and resolve elements per access, so elements appended during iteration are visited and +/// mutation-driven reallocation cannot dangle a cached pointer. public final class GdccForPackedArrayIterType implements GdCompilerType { public static final @NotNull GdccForPackedArrayIterType FOR_PACKED_BYTE_ARRAY_ITER = new GdccForPackedArrayIterType("byte_array", "ByteArray", GdPackedNumericArrayType.PACKED_BYTE_ARRAY); diff --git a/src/main/java/gd/script/gdcc/util/type/ExplicitCastSupport.java b/src/main/java/gd/script/gdcc/util/type/ExplicitCastSupport.java index 01821b8c..552fc7a1 100644 --- a/src/main/java/gd/script/gdcc/util/type/ExplicitCastSupport.java +++ b/src/main/java/gd/script/gdcc/util/type/ExplicitCastSupport.java @@ -6,6 +6,7 @@ import gd.script.gdcc.type.GdExtensionTypeEnum; import gd.script.gdcc.type.GdNilType; import gd.script.gdcc.type.GdObjectType; +import gd.script.gdcc.type.GdPackedArrayType; import gd.script.gdcc.type.GdType; import gd.script.gdcc.type.GdVariantType; import gd.script.gdcc.type.GdVoidType; @@ -49,6 +50,13 @@ private ExplicitCastSupport() { } if (sameStaticType(source, target)) { + // Packed*Array same-family `as` is NOT an identity op: the Godot interpreter produces + // a COW copy with a fresh identity (probe-locked on Godot 4.5.2 for both static and + // Variant sources). Route it through + // the runtime-cast surface so the backend emits the whitelisted copy. + if (source instanceof GdPackedArrayType) { + return ExplicitCastDecision.BUILTIN_RUNTIME_CAST; + } return ExplicitCastDecision.IDENTITY; } diff --git a/src/test/java/gd/script/gdcc/backend/c/build/FrontendLoweringToCProjectBuilderIntegrationTest.java b/src/test/java/gd/script/gdcc/backend/c/build/FrontendLoweringToCProjectBuilderIntegrationTest.java index 72f104c2..6c8ab54a 100644 --- a/src/test/java/gd/script/gdcc/backend/c/build/FrontendLoweringToCProjectBuilderIntegrationTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/build/FrontendLoweringToCProjectBuilderIntegrationTest.java @@ -144,25 +144,25 @@ void lowerFrontendParameterDefaultsBuildNativeLibraryAndRunInGodot() throws Exce var source = """ class_name ParamDefaultSmoke extends Node - + var marker: int = 5 - + func ping(a: int, count: int = 40) -> int: return a + count - + static func sping(a: int, count: int = 7) -> int: return a + count - + func multi(a: int, b: int = 1, c: int = 2) -> int: return a * 100 + b * 10 + c - + func marker_default(value: int = marker) -> int: return value - + func append_probe(items: Array[int] = [0]) -> int: items.append(1) return items.size() - + func run_exact_checks() -> int: var sum = 0 sum += ping(1) @@ -173,12 +173,12 @@ func run_exact_checks() -> int: sum += multi(1, 5) sum += marker_default() return sum - + func run_reeval_checks() -> int: var first = append_probe() var second = append_probe() return first * 10 + second - + # gdcc-internal dynamic route: a Variant receiver forces the VARIANT_DYNAMIC # lowering path, which reaches the same callee-prologue wrapper through the engine. func run_dynamic_checks() -> int: @@ -406,10 +406,10 @@ void lowerStringFamilyInboundCallWrapperBuildNativeLibraryAndRunInGodot() throws var source = """ class_name StringFamilyInboundWrapperSmoke extends Node - + func take_string_name(value: StringName) -> StringName: return value - + func take_string(value: String) -> String: return value """; @@ -1732,6 +1732,122 @@ func append_array(seed: int) -> int: ); } + @Test + void lowerFrontendDirectSlotSnapshotWritebackBuildNativeLibraryAndRunInGodot() throws Exception { + if (ZigUtil.findZig() == null) { + Assumptions.abort("Zig not found; skipping direct-slot snapshot writeback integration test"); + return; + } + + var tempDir = Path.of("tmp/test/frontend_direct_slot_snapshot_writeback_runtime"); + Files.createDirectories(tempDir); + + var source = """ + class_name DirectSlotSnapshotWritebackSmoke + extends Node + + func identity(value: String) -> String: + return value + + func join_parts(parts: PackedStringArray) -> String: + var parr := PackedStringArray() + for part in parts: + parr.append(identity(part)) + return "".join(parr) + + func append_via_helper(raw: String) -> int: + var encoded := PackedStringArray(["x"]) + encoded.append(identity(raw)) + return encoded.size() + + func append_dynamic(source: Variant, seed: Variant) -> int: + var payload: Variant = source + payload.append(str(seed)) + return payload.size() + """; + var module = parseModule( + tempDir.resolve("direct_slot_snapshot_writeback_smoke.gd"), + source, + Map.of("DirectSlotSnapshotWritebackSmoke", "RuntimeDirectSlotSnapshotWritebackSmoke") + ); + var diagnostics = new DiagnosticManager(); + var classRegistry = new ClassRegistry(ExtensionApiLoader.loadVersion(GodotVersion.V451)); + 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()); + assertEquals(1, lowered.getClassDefs().size()); + assertEquals("RuntimeDirectSlotSnapshotWritebackSmoke", lowered.getClassDefs().getFirst().getName()); + + var projectDir = tempDir.resolve("project"); + Files.createDirectories(projectDir); + var projectInfo = new CProjectInfo( + "frontend_direct_slot_snapshot_writeback_runtime", + GodotVersion.V451, + projectDir, + COptimizationLevel.DEBUG, + TargetPlatform.getNativePlatform() + ); + var codegen = new CCodegen(); + codegen.prepare(new CodegenContext(projectInfo, classRegistry), lowered); + + 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( + "DirectSlotSnapshotWritebackSmokeNode", + "RuntimeDirectSlotSnapshotWritebackSmoke", + ".", + Map.of() + )), + new GodotGdextensionTestRunner.TestScriptSpec(directSlotSnapshotWritebackTestScript()) + )); + + var runResult = runner.run(true); + var combinedOutput = runResult.combinedOutput(); + + assertTrue( + runResult.stopSignalSeen(), + () -> "Godot run should emit \"" + GodotGdextensionTestRunner.TEST_STOP_SIGNAL + "\".\nOutput:\n" + combinedOutput + ); + assertTrue( + combinedOutput.contains("direct-slot snapshot writeback loop check passed."), + () -> "Loop snapshot writeback runtime check should pass.\nOutput:\n" + combinedOutput + ); + assertTrue( + combinedOutput.contains("direct-slot snapshot writeback nested check passed."), + () -> "Nested-argument snapshot writeback runtime check should pass.\nOutput:\n" + combinedOutput + ); + assertTrue( + combinedOutput.contains("direct-slot snapshot writeback dynamic check passed."), + () -> "Dynamic Variant snapshot writeback runtime check should pass.\nOutput:\n" + combinedOutput + ); + assertTrue( + combinedOutput.contains("direct-slot snapshot writeback dynamic shared check passed."), + () -> "Dynamic Variant shared-carrier skip runtime check should pass.\nOutput:\n" + combinedOutput + ); + assertFalse( + combinedOutput.contains("direct-slot snapshot writeback loop check failed."), + () -> "Loop snapshot writeback runtime check should not fail.\nOutput:\n" + combinedOutput + ); + assertFalse( + combinedOutput.contains("direct-slot snapshot writeback nested check failed."), + () -> "Nested-argument snapshot writeback runtime check should not fail.\nOutput:\n" + combinedOutput + ); + assertFalse( + combinedOutput.contains("direct-slot snapshot writeback dynamic check failed."), + () -> "Dynamic Variant snapshot writeback runtime check should not fail.\nOutput:\n" + combinedOutput + ); + assertFalse( + combinedOutput.contains("direct-slot snapshot writeback dynamic shared check failed."), + () -> "Dynamic Variant shared-carrier skip runtime check should not fail.\nOutput:\n" + combinedOutput + ); + } + @Test void lowerFrontendWritableRouteRuntimeEdgesBuildNativeLibraryAndRunInGodot() throws Exception { if (ZigUtil.findZig() == null) { @@ -2219,25 +2335,25 @@ void lowerFrontendCrossFileInheritanceBuildsNativeLibraryAndRunInGodot() throws var childSource = """ class_name CrossFileChild extends CrossFileBase - + func child_value() -> int: return base_value() + 1 """; var baseSource = """ class_name CrossFileBase extends RefCounted - + func base_value() -> int: return 41 """; var hostSource = """ class_name CrossFileInheritanceHost extends Node - + func make_child_sum() -> int: var child: CrossFileChild = CrossFileChild.new() return child.base_value() + child.child_value() - + func dispatch_via_base(base_ref: CrossFileBase) -> int: return base_ref.base_value() """; @@ -2345,15 +2461,15 @@ func dispatch_via_base(base_ref: CrossFileBase) -> int: private static @NotNull String crossFileInheritanceTestScript() { return """ extends Node - + const TARGET_NODE_NAME = "CrossFileInheritanceHostNode" - + func _ready() -> void: var target = get_parent().get_node_or_null(TARGET_NODE_NAME) if target == null: push_error("Target node missing.") return - + # Engine-side instantiation of a class that extends another file's class, # plus inherited/own method dispatch on the gdcc-compiled host. var child = RuntimeCrossFileChild.new() @@ -2362,7 +2478,7 @@ func _ready() -> void: print("frontend cross-file inheritance instantiate dispatch check passed.") else: push_error("frontend cross-file inheritance instantiate dispatch check failed.") - + # A derived instance passed through the base-typed parameter boundary must # still dispatch to the base owner method via the safe upcast path. var via_base = int(target.call("dispatch_via_base", child)) @@ -2370,7 +2486,7 @@ func _ready() -> void: print("frontend cross-file inheritance upcast dispatch check passed.") else: push_error("frontend cross-file inheritance upcast dispatch check failed.") - + var is_ok = child is RuntimeCrossFileBase var classdb_ok = ClassDB.is_parent_class("RuntimeCrossFileChild", "RuntimeCrossFileBase") var is_class_ok = child.is_class("RuntimeCrossFileChild") and child.is_class("RuntimeCrossFileBase") and child.is_class("RefCounted") and not child.is_class("CrossFileChild") and not child.is_class("CrossFileBase") @@ -2443,27 +2559,27 @@ func _ready() -> void: private static @NotNull String parameterDefaultTestScript() { return """ extends Node - + const TARGET_NODE_NAME = "ParamDefaultSmokeNode" - + func _ready() -> void: var target = get_parent().get_node_or_null(TARGET_NODE_NAME) if target == null: push_error("Target node missing.") return - + var exact_result = int(target.call("run_exact_checks")) if exact_result == 323: print("parameter default exact-route check passed.") else: push_error("parameter default exact-route check failed: got %s." % exact_result) - + var reeval_result = int(target.call("run_reeval_checks")) if reeval_result == 22: print("parameter default re-evaluation check passed.") else: push_error("parameter default re-evaluation check failed: got %s." % reeval_result) - + # The dynamic route (Object.call -> GDExtensionMethodBind::call -> # callee-prologue wrapper) fills omitted trailing arguments at runtime. var dynamic_result = int(target.call("ping", 1)) @@ -2471,25 +2587,25 @@ func _ready() -> void: print("parameter default dynamic-route check passed.") else: push_error("parameter default dynamic-route check failed: got %s." % dynamic_result) - + var dynamic_multi = int(target.call("multi", 1)) if dynamic_multi == 112: print("parameter default dynamic multi-slot check passed.") else: push_error("parameter default dynamic multi-slot check failed: got %s." % dynamic_multi) - + var dynamic_reeval = int(target.call("append_probe")) * 10 + int(target.call("append_probe")) if dynamic_reeval == 22: print("parameter default dynamic re-evaluation check passed.") else: push_error("parameter default dynamic re-evaluation check failed: got %s." % dynamic_reeval) - + var gdcc_dynamic = int(target.call("run_dynamic_checks")) if gdcc_dynamic == 41003: print("parameter default gdcc dynamic-route check passed.") else: push_error("parameter default gdcc dynamic-route check failed: got %s." % gdcc_dynamic) - + # Too-few below the required prefix surfaces as a call error whose # `expected` is the required count (1), not the full arity (2). The failed # call aborts this GDScript function, so this probe must stay LAST. @@ -2872,6 +2988,44 @@ func _ready() -> void: """; } + private static @NotNull String directSlotSnapshotWritebackTestScript() { + return """ + extends Node + + const TARGET_NODE_NAME = "DirectSlotSnapshotWritebackSmokeNode" + + func _ready() -> void: + var target = get_parent().get_node_or_null(TARGET_NODE_NAME) + if target == null: + push_error("Target node missing.") + return + + var joined = String(target.call("join_parts", PackedStringArray(["a", "b", "c"]))) + if joined == "abc": + print("direct-slot snapshot writeback loop check passed.") + else: + push_error("direct-slot snapshot writeback loop check failed.") + + var grown = int(target.call("append_via_helper", "y")) + if grown == 2: + print("direct-slot snapshot writeback nested check passed.") + else: + push_error("direct-slot snapshot writeback nested check failed.") + + var dynamic_packed = int(target.call("append_dynamic", PackedStringArray(), "s")) + if dynamic_packed == 1: + print("direct-slot snapshot writeback dynamic check passed.") + else: + push_error("direct-slot snapshot writeback dynamic check failed.") + + var dynamic_array = int(target.call("append_dynamic", Array(), "s")) + if dynamic_array == 1: + print("direct-slot snapshot writeback dynamic shared check passed.") + else: + push_error("direct-slot snapshot writeback dynamic shared check failed.") + """; + } + private static @NotNull String writableRouteRuntimeEdgeTestScript() { return """ extends Node @@ -2980,15 +3134,15 @@ func _ready() -> void: private static @NotNull String stringFamilyInboundCallWrapperTestScript() { return """ extends Node - + const TARGET_NODE_NAME = "StringFamilyInboundWrapperNode" - + func _ready() -> void: var target = get_parent().get_node_or_null(TARGET_NODE_NAME) if target == null: push_error("Target node missing.") return - + var name_from_text = target.call("take_string_name", "from-text") var text_from_name = target.call("take_string", &"from-name") var exact_name = target.call("take_string_name", &"exact-name") @@ -2997,7 +3151,7 @@ func _ready() -> void: print("frontend String/StringName inbound dynamic call check passed.") else: push_error("frontend String/StringName inbound dynamic call check failed.") - + target.call("take_string_name", NodePath("bad")) print("frontend String/StringName inbound dynamic call after bad call.") """; diff --git a/src/test/java/gd/script/gdcc/backend/c/build/GdccPackedRefRuntimeSmokeTest.java b/src/test/java/gd/script/gdcc/backend/c/build/GdccPackedRefRuntimeSmokeTest.java new file mode 100644 index 00000000..5f49de3f --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/build/GdccPackedRefRuntimeSmokeTest.java @@ -0,0 +1,758 @@ +package gd.script.gdcc.backend.c.build; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Zig-gated tests for `gdcc/gdcc_packed_ref.h`, the runtime infrastructure of the +/// Variant-backed Packed*Array storage model. +/// +/// Test shapes: +/// - a compile-only probe proving the header is self-contained (no other gdcc header needed); +/// - a happy-path runtime probe behind a fake Godot engine that models the engine identity +/// contract: Variant copy shares one heap array (holders counting), while every struct<->Variant +/// crossing produces a NEW array identity (the basis of the ptrcall exception). +/// This lets the probes assert exactly which whitelisted conversion each helper performs — +/// e.g. aliasing must go through `variant_new_copy` and never through the struct copy ctor; +/// - two fail-fast probes (missing per-family getter at init, accessor used before init) that +/// must abort with an engine error message, run as separate processes so the abort is +/// observable as a non-zero exit code. +class GdccPackedRefRuntimeSmokeTest { + private static final Path GODOT_INCLUDE_DIR = Path.of("src/main/c/codegen/include_451/godot").toAbsolutePath().normalize(); + private static final Path GDCC_INCLUDE_DIR = Path.of("src/main/c/codegen/include_451/gdcc").toAbsolutePath().normalize(); + + @TempDir + private static Path sharedDir; + + private static Path zig; + private static List runtimeObjects; + + @BeforeAll + static void compileRuntimeObjects() throws IOException, InterruptedException { + zig = ZigUtil.findZig(); + Assumptions.assumeTrue(zig != null, "Zig executable is required for packed-ref runtime C smoke tests"); + runtimeObjects = List.of( + compileObject(zig, GODOT_INCLUDE_DIR.resolve("godot_binding.c"), sharedDir.resolve("godot_binding.o")) + ); + } + + @Test + void headerShouldCompileStandalone() throws IOException, InterruptedException { + // The header must be self-contained: only `` and the leaf-level + // `` (branch-hint macros, no further dependencies) may be pulled in, so + // no class_library global or higher-level gdcc header may be required by the includer. + var source = sharedDir.resolve("packed_ref_compile_probe.c"); + Files.writeString(source, """ + #include + + godot_Variant probe_compile(void) { + gdcc_packed_ref_init(); + godot_Variant empty = gdcc_packed_int32_array_new_empty(); + godot_Variant alias = gdcc_packed_ref_copy(&empty); + godot_bool same_family = gdcc_packed_ref_is(&alias, GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY); + godot_PackedInt32Array *internal = gdcc_packed_int32_array_internal_ptr(&alias); + godot_Variant inbound = gdcc_packed_int32_array_variant_from_struct(internal); + godot_PackedInt32Array outbound = gdcc_packed_int32_array_struct_from_variant(&inbound); + godot_Variant wrapped = gdcc_packed_int32_array_wrap_temp(&outbound); + godot_Variant copied = gdcc_packed_int32_array_new_copy(&wrapped); + godot_Array arr = godot_new_Array(); + godot_Variant from_array = gdcc_packed_vector4_array_new_from_array(&arr); + if (!same_family) { + return godot_new_Variant_nil(); + } + gdcc_packed_ref_destroy(&from_array); + return copied; + } + """, StandardCharsets.UTF_8); + // compileObject asserts a zero exit code internally, so a successful return proves the + // header compiles standalone. + compileObject(zig, source, sharedDir.resolve("packed_ref_compile_probe.o")); + } + + @Test + void helpersShouldProvideSharedIdentityAndWhitelistedConversions() throws IOException, InterruptedException { + var execution = compileLinkAndRun("packed_ref_happy_probe", FAKE_ENGINE + HAPPY_PROBE, runtimeObjects); + assertEquals(0, execution.exitCode(), execution::diagnostic); + assertTrue(execution.output().contains("OK packed-ref happy path"), execution::diagnostic); + } + + @Test + void initShouldFailFastWhenInternalGetterMissing() throws IOException, InterruptedException { + var execution = compileLinkAndRun("packed_ref_missing_getter_probe", FAKE_ENGINE + MISSING_GETTER_PROBE, runtimeObjects); + assertNotEquals(0, execution.exitCode(), execution::diagnostic); + assertTrue(execution.output().contains("ENGINE_ERROR"), execution::diagnostic); + assertTrue(execution.output().contains("Vector4Array"), execution::diagnostic); + // Death must happen inside the helper (abort), not by falling through to the probe tail: + // a fail path that prints and merely returns would still reach the probe's own return 1. + assertFalseMarker(execution, "FAIL init did not fail-fast"); + } + + @Test + void internalPtrShouldFailFastWithoutInit() throws IOException, InterruptedException { + var execution = compileLinkAndRun("packed_ref_no_init_probe", FAKE_ENGINE + NO_INIT_PROBE, runtimeObjects); + assertNotEquals(0, execution.exitCode(), execution::diagnostic); + assertTrue(execution.output().contains("before gdcc_packed_ref_init"), execution::diagnostic); + assertFalseMarker(execution, "FAIL internal_ptr did not fail-fast"); + } + + @Test + void internalPtrShouldFailFastOnNullVariant() throws IOException, InterruptedException { + var execution = compileLinkAndRun("packed_ref_null_self_probe", FAKE_ENGINE + NULL_SELF_PROBE, runtimeObjects); + assertNotEquals(0, execution.exitCode(), execution::diagnostic); + assertTrue(execution.output().contains("NULL Variant"), execution::diagnostic); + assertFalseMarker(execution, "FAIL null-self did not fail-fast"); + } + + @Test + void internalPtrShouldFailFastWhenGetterReturnsNull() throws IOException, InterruptedException { + var execution = compileLinkAndRun("packed_ref_null_internal_probe", FAKE_ENGINE + NULL_INTERNAL_PROBE, runtimeObjects); + assertNotEquals(0, execution.exitCode(), execution::diagnostic); + assertTrue(execution.output().contains("NULL internal pointer"), execution::diagnostic); + assertFalseMarker(execution, "FAIL null-internal did not fail-fast"); + } + + @Test + void initShouldFailFastBeforeInterfaceInit() throws IOException, InterruptedException { + // Without godot_initialize_interface the interface pointer table is all NULL: init must + // detect this and the fail path must fall back to stderr instead of calling a NULL + // print_error pointer (stderr is merged into the captured output). + var execution = compileLinkAndRun("packed_ref_no_interface_probe", FAKE_ENGINE + NO_INTERFACE_PROBE, runtimeObjects); + assertNotEquals(0, execution.exitCode(), execution::diagnostic); + assertTrue(execution.output().contains("variant_get_ptr_internal_getter interface unresolved"), execution::diagnostic); + assertFalseMarker(execution, "FAIL init did not fail-fast"); + } + + @Test + void internalPtrShouldRequireInitInEveryTranslationUnit() throws IOException, InterruptedException { + // Getter caches are per-TU statics: a second TU that includes the header but never ran + // gdcc_packed_ref_init must fail-fast even though the main TU initialized its own copy. + var tu2Source = sharedDir.resolve("packed_ref_tu2.c"); + Files.writeString(tu2Source, """ + #include + + void tu2_use_internal_ptr(godot_Variant *value) { + (void)gdcc_packed_int32_array_internal_ptr(value); + } + """, StandardCharsets.UTF_8); + var tu1Source = sharedDir.resolve("packed_ref_multi_tu_probe.c"); + Files.writeString(tu1Source, FAKE_ENGINE + MULTI_TU_PROBE, StandardCharsets.UTF_8); + var tu1Object = compileObject(zig, tu1Source, sharedDir.resolve("packed_ref_multi_tu_probe.o")); + var tu2Object = compileObject(zig, tu2Source, sharedDir.resolve("packed_ref_tu2.o")); + var objects = new ArrayList(); + objects.add(tu1Object); + objects.add(tu2Object); + objects.addAll(runtimeObjects); + var executable = sharedDir.resolve("packed_ref_multi_tu_probe"); + var linked = linkExecutable(zig, objects, executable); + assertEquals(0, linked.exitCode(), linked::diagnostic); + var execution = runExecutable(executable); + assertNotEquals(0, execution.exitCode(), execution::diagnostic); + assertTrue(execution.output().contains("before gdcc_packed_ref_init"), execution::diagnostic); + assertFalseMarker(execution, "FAIL tu2 did not fail-fast"); + } + + private static void assertFalseMarker(CompileResult execution, String probeTailMarker) { + assertFalse(execution.output().contains(probeTailMarker), execution::diagnostic); + } + + // --------------------------------------------------------------------------- + // Harness (mirrors GdccStaticStringRuntimeSmokeTest conventions). + // --------------------------------------------------------------------------- + + private static CompileResult compileLinkAndRun(String probeName, String source, List extraObjects) + throws IOException, InterruptedException { + var probeSource = sharedDir.resolve(probeName + ".c"); + Files.writeString(probeSource, source, StandardCharsets.UTF_8); + var probeObject = compileObject(zig, probeSource, sharedDir.resolve(probeName + ".o")); + var objects = new ArrayList(); + objects.add(probeObject); + objects.addAll(extraObjects); + var executable = sharedDir.resolve(probeName); + var linked = linkExecutable(zig, objects, executable); + assertEquals(0, linked.exitCode(), linked::diagnostic); + return runExecutable(executable); + } + + private static Path compileObject(Path zig, Path source, Path output) throws IOException, InterruptedException { + var command = new ArrayList(); + command.add(zig.toString()); + command.add("cc"); + command.add("-std=c23"); + command.add("-I" + GODOT_INCLUDE_DIR); + command.add("-I" + GDCC_INCLUDE_DIR); + command.add("-c"); + command.add(source.toString()); + command.add("-o"); + command.add(output.toString()); + + var process = new ProcessBuilder(command).redirectErrorStream(true).start(); + var processOutput = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + var exitCode = process.waitFor(); + assertEquals(0, exitCode, () -> String.join(" ", command) + "\n" + processOutput); + return output; + } + + private static CompileResult linkExecutable(Path zig, List objects, Path output) throws IOException, InterruptedException { + var command = new ArrayList(); + command.add(zig.toString()); + command.add("cc"); + for (var object : objects) { + command.add(object.toString()); + } + command.add("-o"); + command.add(output.toString()); + + var process = new ProcessBuilder(command).redirectErrorStream(true).start(); + var processOutput = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + var exitCode = process.waitFor(); + return new CompileResult(command, exitCode, processOutput, output); + } + + private static CompileResult runExecutable(Path executable) throws IOException, InterruptedException { + var command = List.of(executable.toString()); + var process = new ProcessBuilder(command).redirectErrorStream(true).start(); + var processOutput = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + var exitCode = process.waitFor(); + return new CompileResult(command, exitCode, processOutput, executable); + } + + private record CompileResult(List command, int exitCode, String output, Path outputPath) { + String diagnostic() { + return String.join(" ", command) + "\n" + output; + } + } + + /// Shared C fixture layer: a fake Godot engine modeling the Packed*Array identity contract. + /// + /// `godot_Variant` (24B) holds [0,8) type tag and [8,16) `FakePacked*` holder reference. + /// Each `FakePacked` owns a 16-byte `self_slot` (the internal-pointer target, mirroring the + /// engine's stable per-array storage) whose first 8 bytes hold the `FakePacked*` back-pointer + /// every fake packed function uses to recover the object. Identity rules the probes rely on: + /// - `variant_new_copy` shares the same FakePacked (holders++); + /// - every struct<->Variant conversion (pack/unpack) and every packed copy ctor allocates a + /// NEW FakePacked with copied content, so mutation across that boundary stays invisible — + /// exactly what the plan's ptrcall exception and same-family copy semantics require. + private static final String FAKE_ENGINE = """ + #include + #include + #include + #include + #include + #include + + static void fail(const char *msg) { + printf("FAIL %s\\n", msg); + fflush(stdout); + exit(1); + } + #define CHECK(cond, msg) do { if (!(cond)) fail(msg); } while (0) + + static_assert(sizeof(godot_Variant) == 24, "fake Variant layout needs 24-byte Variant"); + static_assert(sizeof(godot_PackedInt32Array) == 16, "fake packed layout needs 16-byte struct"); + static_assert(sizeof(godot_PackedByteArray) == 16, "fake packed layout needs 16-byte struct"); + + #define FAKE_POISON_UINT 0xEEEEEEEEEEEEEEEEull + #define FAKE_MAX_TYPES 64 + + typedef struct FakePacked { + uint8_t self_slot[16]; + int64_t id; + int64_t holders; + int64_t elem_kind; // 0 = int32, 1 = byte + int64_t elems[16]; + int64_t size; + } FakePacked; + + typedef struct FakeArray { + int64_t elems[8]; + int64_t size; + } FakeArray; + + static int64_t g_next_packed_id = 1; + static int64_t g_packed_live = 0; + static int64_t g_mem_balance = 0; + static int64_t g_struct_destroy_calls = 0; + static int64_t g_ctor_calls[FAKE_MAX_TYPES][3]; + static int64_t g_getter_lookups[FAKE_MAX_TYPES]; + static int64_t g_pack_calls[FAKE_MAX_TYPES]; + static int64_t g_unpack_calls[FAKE_MAX_TYPES]; + static int64_t g_variant_copy_calls = 0; + static int64_t g_variant_destroy_calls = 0; + static int64_t g_print_error_calls = 0; + static GDExtensionVariantType g_getter_disabled_for = GDEXTENSION_VARIANT_TYPE_NIL; + + static void *fake_mem_alloc(size_t bytes) { + g_mem_balance++; + return malloc(bytes == 0 ? 1 : bytes); + } + static void *fake_mem_realloc(void *ptr, size_t bytes) { + if (ptr == NULL) g_mem_balance++; + return realloc(ptr, bytes == 0 ? 1 : bytes); + } + static void fake_mem_free(void *ptr) { + if (ptr != NULL) { + g_mem_balance--; + free(ptr); + } + } + static void fake_print_error(const char *desc, const char *func, const char *file, int32_t line, GDExtensionBool notify) { + (void)func; (void)file; (void)line; (void)notify; + g_print_error_calls++; + printf("ENGINE_ERROR %s\\n", desc != NULL ? desc : ""); + fflush(stdout); + } + + static FakePacked *fake_packed_alloc(int64_t elem_kind) { + FakePacked *fp = fake_mem_alloc(sizeof(FakePacked)); + memset(fp, 0, sizeof(*fp)); + fp->id = g_next_packed_id++; + fp->holders = 1; + fp->elem_kind = elem_kind; + memcpy(fp->self_slot, &fp, sizeof(fp)); + g_packed_live++; + return fp; + } + static void fake_packed_release(FakePacked *fp) { + fp->holders--; + if (fp->holders == 0) { + g_packed_live--; + fake_mem_free(fp); + } + } + static FakePacked *fake_packed_read(GDExtensionConstTypePtr storage, const char *what) { + FakePacked *fp; + memcpy(&fp, storage, sizeof(fp)); + if ((uintptr_t)fp == FAKE_POISON_UINT) fail(what); + return fp; + } + static void fake_packed_write(GDExtensionUninitializedTypePtr out, FakePacked *fp) { + memset(out, 0, 16); + memcpy(out, &fp, sizeof(fp)); + } + static FakePacked *fake_packed_clone(FakePacked *src) { + FakePacked *fp = fake_packed_alloc(src->elem_kind); + fp->size = src->size; + memcpy(fp->elems, src->elems, sizeof(fp->elems)); + return fp; + } + + static void fake_variant_write(GDExtensionUninitializedVariantPtr out, int64_t type, FakePacked *ref) { + memset(out, 0, sizeof(godot_Variant)); + memcpy(out, &type, 8); + memcpy((char *)out + 8, &ref, 8); + } + static int64_t fake_variant_read_type(GDExtensionConstVariantPtr v) { + int64_t type; + memcpy(&type, v, 8); + return type; + } + static FakePacked *fake_variant_read_ref(GDExtensionConstVariantPtr v) { + FakePacked *ref; + memcpy(&ref, (const char *)v + 8, 8); + return ref; + } + + static GDExtensionVariantType fake_variant_get_type(GDExtensionConstVariantPtr v) { + return (GDExtensionVariantType)fake_variant_read_type(v); + } + static void fake_variant_new_copy(GDExtensionUninitializedVariantPtr out, GDExtensionConstVariantPtr src) { + g_variant_copy_calls++; + FakePacked *ref = fake_variant_read_ref(src); + if (ref != NULL) ref->holders++; + fake_variant_write(out, fake_variant_read_type(src), ref); + } + static void fake_variant_destroy(GDExtensionVariantPtr v) { + g_variant_destroy_calls++; + FakePacked *ref = fake_variant_read_ref(v); + if (ref != NULL) fake_packed_release(ref); + memset(v, 0xEE, sizeof(godot_Variant)); + } + + static void *fake_internal_ptr(GDExtensionVariantPtr v) { + FakePacked *ref = fake_variant_read_ref(v); + if (ref == NULL) return NULL; + return ref->self_slot; + } + static GDExtensionVariantGetInternalPtrFunc fake_get_internal_getter(GDExtensionVariantType type) { + g_getter_lookups[type]++; + if (type == g_getter_disabled_for) return NULL; + return fake_internal_ptr; + } + + static void fake_packed_i32_empty_ctor(GDExtensionUninitializedTypePtr out, const GDExtensionConstTypePtr *args) { + (void)args; + fake_packed_write(out, fake_packed_alloc(0)); + } + static void fake_packed_u8_empty_ctor(GDExtensionUninitializedTypePtr out, const GDExtensionConstTypePtr *args) { + (void)args; + fake_packed_write(out, fake_packed_alloc(1)); + } + static void fake_packed_v4_empty_ctor(GDExtensionUninitializedTypePtr out, const GDExtensionConstTypePtr *args) { + (void)args; + fake_packed_write(out, fake_packed_alloc(2)); + } + static void fake_packed_copy_ctor(GDExtensionUninitializedTypePtr out, const GDExtensionConstTypePtr *args) { + fake_packed_write(out, fake_packed_clone(fake_packed_read(args[0], "copy ctor got destroyed packed"))); + } + static void fake_packed_i32_from_array_ctor(GDExtensionUninitializedTypePtr out, const GDExtensionConstTypePtr *args) { + FakeArray *arr; + memcpy(&arr, args[0], sizeof(arr)); + FakePacked *fp = fake_packed_alloc(0); + fp->size = arr->size; + memcpy(fp->elems, arr->elems, sizeof(arr->elems)); + fake_packed_write(out, fp); + } + static GDExtensionPtrConstructor fake_get_ptr_constructor(GDExtensionVariantType type, int32_t index) { + if (index < 0 || index > 2) fail("packed ctor index out of range"); + g_ctor_calls[type][index]++; + if (type == GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY) { + if (index == 0) return fake_packed_i32_empty_ctor; + if (index == 1) return fake_packed_copy_ctor; + return fake_packed_i32_from_array_ctor; + } + if (type == GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY && index <= 1) { + return index == 0 ? fake_packed_u8_empty_ctor : fake_packed_copy_ctor; + } + if (type == GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY && index == 0) { + return fake_packed_v4_empty_ctor; + } + fail("unexpected packed ctor lookup"); + return NULL; + } + static void fake_packed_dtor(GDExtensionTypePtr ptr) { + g_struct_destroy_calls++; + fake_packed_release(fake_packed_read(ptr, "double-destroyed packed struct")); + memset(ptr, 0xEE, 16); + } + static GDExtensionPtrDestructor fake_get_ptr_destructor(GDExtensionVariantType type) { + if (type == GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY + || type == GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY + || type == GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY) { + return fake_packed_dtor; + } + fail("unexpected packed dtor lookup"); + return NULL; + } + + static void fake_pack_i32(GDExtensionUninitializedVariantPtr out, GDExtensionTypePtr in) { + FakePacked *src = fake_packed_read(in, "pack got destroyed packed"); + fake_variant_write(out, GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY, fake_packed_clone(src)); + } + static void fake_pack_u8(GDExtensionUninitializedVariantPtr out, GDExtensionTypePtr in) { + FakePacked *src = fake_packed_read(in, "pack got destroyed packed"); + fake_variant_write(out, GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY, fake_packed_clone(src)); + } + static void fake_pack_v4(GDExtensionUninitializedVariantPtr out, GDExtensionTypePtr in) { + FakePacked *src = fake_packed_read(in, "pack got destroyed packed"); + fake_variant_write(out, GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY, fake_packed_clone(src)); + } + static GDExtensionVariantFromTypeConstructorFunc fake_get_from_type(GDExtensionVariantType type) { + g_pack_calls[type]++; + if (type == GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY) return fake_pack_i32; + if (type == GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY) return fake_pack_u8; + if (type == GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY) return fake_pack_v4; + fail("unexpected from-type lookup"); + return NULL; + } + static void fake_unpack_i32(GDExtensionUninitializedTypePtr out, GDExtensionVariantPtr v) { + if (fake_variant_read_type(v) != GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY) { + fail("unpack type mismatch"); + } + fake_packed_write(out, fake_packed_clone(fake_variant_read_ref(v))); + } + static GDExtensionTypeFromVariantConstructorFunc fake_get_to_type(GDExtensionVariantType type) { + g_unpack_calls[type]++; + if (type == GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY) return fake_unpack_i32; + fail("unexpected to-type lookup"); + return NULL; + } + + static void fake_unused_interface(void) { + } + static GDExtensionInterfaceFunctionPtr fake_get_proc_address(const char *name) { + if (strcmp(name, "mem_alloc") == 0) return (GDExtensionInterfaceFunctionPtr)fake_mem_alloc; + if (strcmp(name, "mem_realloc") == 0) return (GDExtensionInterfaceFunctionPtr)fake_mem_realloc; + if (strcmp(name, "mem_free") == 0) return (GDExtensionInterfaceFunctionPtr)fake_mem_free; + if (strcmp(name, "print_error") == 0) return (GDExtensionInterfaceFunctionPtr)fake_print_error; + if (strcmp(name, "variant_get_type") == 0) return (GDExtensionInterfaceFunctionPtr)fake_variant_get_type; + if (strcmp(name, "variant_new_copy") == 0) return (GDExtensionInterfaceFunctionPtr)fake_variant_new_copy; + if (strcmp(name, "variant_destroy") == 0) return (GDExtensionInterfaceFunctionPtr)fake_variant_destroy; + if (strcmp(name, "variant_get_ptr_internal_getter") == 0) return (GDExtensionInterfaceFunctionPtr)fake_get_internal_getter; + if (strcmp(name, "variant_get_ptr_constructor") == 0) return (GDExtensionInterfaceFunctionPtr)fake_get_ptr_constructor; + if (strcmp(name, "variant_get_ptr_destructor") == 0) return (GDExtensionInterfaceFunctionPtr)fake_get_ptr_destructor; + if (strcmp(name, "get_variant_from_type_constructor") == 0) return (GDExtensionInterfaceFunctionPtr)fake_get_from_type; + if (strcmp(name, "get_variant_to_type_constructor") == 0) return (GDExtensionInterfaceFunctionPtr)fake_get_to_type; + return (GDExtensionInterfaceFunctionPtr)fake_unused_interface; + } + + #include + + // Probe-side accessors over internal pointers, standing in for generated code that + // calls the typed builtin method wrappers (push_back/size/operator_index). + static void probe_push(godot_PackedInt32Array *arr, int32_t value) { + FakePacked *fp = fake_packed_read(arr, "push on destroyed packed"); + CHECK(fp->elem_kind == 0, "push on wrong elem kind"); + CHECK(fp->size < 16, "probe capacity exceeded"); + fp->elems[fp->size++] = value; + } + static godot_int probe_size_i32(const godot_PackedInt32Array *arr) { + return fake_packed_read(arr, "size of destroyed packed")->size; + } + static godot_int probe_size_u8(const godot_PackedByteArray *arr) { + return fake_packed_read(arr, "size of destroyed packed")->size; + } + static godot_int probe_size_v4(const godot_PackedVector4Array *arr) { + return fake_packed_read(arr, "size of destroyed packed")->size; + } + static int32_t probe_get(const godot_PackedInt32Array *arr, godot_int index) { + FakePacked *fp = fake_packed_read(arr, "get on destroyed packed"); + CHECK(index >= 0 && index < fp->size, "probe index out of range"); + return (int32_t)fp->elems[index]; + } + static int64_t probe_id_of_struct(const godot_PackedInt32Array *arr) { + return fake_packed_read(arr, "id of destroyed packed")->id; + } + static int64_t probe_id_of_variant(const godot_Variant *v) { + return fake_variant_read_ref(v)->id; + } + """; + + /// Happy path: every packed-ref helper exercised against the fake engine's identity contract, + /// with exact invocation accounting (which ctor/copy/pack ran how many times) so each helper + /// is pinned to its whitelisted conversion shape. + private static final String HAPPY_PROBE = """ + static const GDExtensionVariantType ALL_PACKED_KINDS[10] = { + GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY, + GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY, + GDEXTENSION_VARIANT_TYPE_PACKED_INT64_ARRAY, + GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT32_ARRAY, + GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT64_ARRAY, + GDEXTENSION_VARIANT_TYPE_PACKED_STRING_ARRAY, + GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR2_ARRAY, + GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR3_ARRAY, + GDEXTENSION_VARIANT_TYPE_PACKED_COLOR_ARRAY, + GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY, + }; + + int main(void) { + CHECK(godot_initialize_interface(fake_get_proc_address) != 0, "interface init failed"); + gdcc_packed_ref_init(); + for (int i = 0; i < 10; i++) { + CHECK(g_getter_lookups[ALL_PACKED_KINDS[i]] == 1, "each family getter resolved exactly once"); + } + + // ---- new_empty (whitelist b): empty array Variant, temp struct destroyed ---- + godot_Variant empty = gdcc_packed_int32_array_new_empty(); + CHECK(g_ctor_calls[GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY][0] == 1, "empty ctor not used"); + CHECK(g_pack_calls[GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY] == 1, "empty not wrapped into Variant"); + CHECK(g_struct_destroy_calls == 1, "empty temp struct not destroyed exactly once"); + CHECK(g_packed_live == 1, "empty Variant must own exactly one packed storage"); + CHECK(gdcc_packed_ref_is(&empty, GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY), "empty type mismatch"); + CHECK(!gdcc_packed_ref_is(&empty, GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY), "empty accepted wrong family"); + CHECK(probe_size_i32(gdcc_packed_int32_array_internal_ptr(&empty)) == 0, "empty not empty"); + + // negative `is` cases: nil-typed storage and NULL pointer must both be false + godot_Variant nil_v; + memset(&nil_v, 0, sizeof(nil_v)); + CHECK(!gdcc_packed_ref_is(&nil_v, GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY), "nil passed family test"); + CHECK(!gdcc_packed_ref_is(NULL, GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY), "NULL passed family test"); + + // ---- copy alias: identity shared via variant_new_copy ONLY (core invariant) ---- + godot_PackedInt32Array *empty_internal = gdcc_packed_int32_array_internal_ptr(&empty); + probe_push(empty_internal, 11); + probe_push(empty_internal, 22); + godot_Variant alias = gdcc_packed_ref_copy(&empty); + CHECK(g_variant_copy_calls == 1, "alias did not go through variant copy"); + CHECK(g_ctor_calls[GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY][1] == 0, + "alias must NOT use the struct copy ctor"); + CHECK(probe_id_of_variant(&alias) == probe_id_of_variant(&empty), "alias identity not shared"); + probe_push(gdcc_packed_int32_array_internal_ptr(&alias), 33); + CHECK(probe_size_i32(empty_internal) == 3 && probe_get(empty_internal, 2) == 33, + "mutation through alias not visible on original"); + + // ---- variant_from_struct (whitelist a, ptrcall inbound): new identity, isolated ---- + godot_PackedInt32Array raw; + fake_packed_write(&raw, fake_packed_alloc(0)); + probe_push(&raw, 5); + probe_push(&raw, 6); + godot_Variant materialized = gdcc_packed_int32_array_variant_from_struct(&raw); + CHECK(probe_id_of_variant(&materialized) != probe_id_of_struct(&raw), + "inbound materialization must not share identity"); + CHECK(probe_size_i32(gdcc_packed_int32_array_internal_ptr(&materialized)) == 2, "inbound size wrong"); + probe_push(&raw, 7); + CHECK(probe_size_i32(gdcc_packed_int32_array_internal_ptr(&materialized)) == 2, + "struct mutation leaked into materialized Variant"); + probe_push(gdcc_packed_int32_array_internal_ptr(&materialized), 8); + CHECK(probe_size_i32(&raw) == 3, "materialized Variant mutation leaked into struct"); + + // ---- struct_from_variant (whitelist a, ptrcall outbound): copy out, isolated ---- + godot_PackedInt32Array out = gdcc_packed_int32_array_struct_from_variant(&alias); + CHECK(g_unpack_calls[GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY] == 1, "outbound not via unpack"); + CHECK(probe_size_i32(&out) == 3 && probe_get(&out, 0) == 11 && probe_get(&out, 2) == 33, + "outbound content wrong"); + CHECK(probe_id_of_struct(&out) != probe_id_of_variant(&alias), "outbound copy must not share identity"); + probe_push(gdcc_packed_int32_array_internal_ptr(&alias), 44); + CHECK(probe_size_i32(&out) == 3, "Variant mutation leaked into outbound struct"); + probe_push(&out, 55); + CHECK(probe_size_i32(empty_internal) == 4, "outbound struct mutation leaked into Variant"); + godot_PackedInt32Array_destroy(&out); + + // ---- wrap_temp (whitelist c): builtin temp wrapped, temp destroyed once ---- + godot_PackedInt32Array temp; + fake_packed_write(&temp, fake_packed_alloc(0)); + probe_push(&temp, 100); + int64_t destroys_before_wrap = g_struct_destroy_calls; + godot_Variant wrapped = gdcc_packed_int32_array_wrap_temp(&temp); + CHECK(g_struct_destroy_calls == destroys_before_wrap + 1, "wrap_temp must destroy the temp exactly once"); + CHECK(gdcc_packed_ref_is(&wrapped, GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY), "wrapped type mismatch"); + godot_PackedInt32Array *wrapped_internal = gdcc_packed_int32_array_internal_ptr(&wrapped); + CHECK(probe_size_i32(wrapped_internal) == 1 && probe_get(wrapped_internal, 0) == 100, "wrapped content wrong"); + + // ---- new_copy (whitelist d same-type; `as` same-family shape) ---- + godot_Variant copied = gdcc_packed_int32_array_new_copy(&alias); + CHECK(g_ctor_calls[GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY][1] == 1, + "same-type copy must use the struct copy ctor on the internal pointer"); + CHECK(probe_id_of_variant(&copied) != probe_id_of_variant(&alias), + "same-type copy must produce a new identity"); + godot_PackedInt32Array *copied_internal = gdcc_packed_int32_array_internal_ptr(&copied); + CHECK(probe_size_i32(copied_internal) == 4 && probe_get(copied_internal, 3) == 44, "copy content wrong"); + probe_push(gdcc_packed_int32_array_internal_ptr(&alias), 66); + CHECK(probe_size_i32(copied_internal) == 4, "same-type copy not independent from source mutation"); + + // ---- new_from_array (whitelist d cross-type) ---- + FakeArray fa = { .elems = { 7, 8, 9 }, .size = 3 }; + FakeArray *fa_ptr = &fa; + godot_Array arr; + memset(&arr, 0, sizeof(arr)); + memcpy(&arr, &fa_ptr, sizeof(fa_ptr)); + godot_Variant from_array = gdcc_packed_int32_array_new_from_array(&arr); + CHECK(g_ctor_calls[GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY][2] == 1, "from-Array ctor not used"); + godot_PackedInt32Array *from_array_internal = gdcc_packed_int32_array_internal_ptr(&from_array); + CHECK(probe_size_i32(from_array_internal) == 3 && probe_get(from_array_internal, 0) == 7 + && probe_get(from_array_internal, 2) == 9, "from-Array content wrong"); + + // ---- second family smoke: macro instantiations stay independent ---- + godot_Variant bytes = gdcc_packed_byte_array_new_empty(); + CHECK(gdcc_packed_ref_is(&bytes, GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY), "byte empty type mismatch"); + CHECK(!gdcc_packed_ref_is(&bytes, GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY), "byte accepted wrong family"); + CHECK(probe_size_u8(gdcc_packed_byte_array_internal_ptr(&bytes)) == 0, "byte empty not empty"); + godot_Variant bytes_alias = gdcc_packed_ref_copy(&bytes); + CHECK(probe_id_of_variant(&bytes_alias) == probe_id_of_variant(&bytes), "byte alias identity not shared"); + + // ---- third family (Vector4): non-integer family exercises its own instantiation ---- + godot_Variant v4 = gdcc_packed_vector4_array_new_empty(); + CHECK(gdcc_packed_ref_is(&v4, GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY), "vector4 empty type mismatch"); + CHECK(!gdcc_packed_ref_is(&v4, GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY), "vector4 accepted wrong family"); + CHECK(probe_size_v4(gdcc_packed_vector4_array_internal_ptr(&v4)) == 0, "vector4 empty not empty"); + godot_Variant v4_alias = gdcc_packed_ref_copy(&v4); + CHECK(probe_id_of_variant(&v4_alias) == probe_id_of_variant(&v4), "vector4 alias identity not shared"); + + // ---- teardown: every holder released exactly once, nothing leaked, no errors ---- + gdcc_packed_ref_destroy(&empty); + gdcc_packed_ref_destroy(&alias); + gdcc_packed_ref_destroy(&materialized); + gdcc_packed_ref_destroy(&wrapped); + gdcc_packed_ref_destroy(&copied); + gdcc_packed_ref_destroy(&from_array); + gdcc_packed_ref_destroy(&bytes); + gdcc_packed_ref_destroy(&bytes_alias); + gdcc_packed_ref_destroy(&v4); + gdcc_packed_ref_destroy(&v4_alias); + godot_PackedInt32Array_destroy(&raw); + CHECK(g_variant_destroy_calls == 10, "Variant destroy count wrong"); + CHECK(g_packed_live == 0, "packed storage leaked or double-released"); + CHECK(g_mem_balance == 0, "fake heap not balanced"); + CHECK(g_print_error_calls == 0, "happy path must never report engine errors"); + printf("OK packed-ref happy path\\n"); + return 0; + } + """; + + /// Missing-getter fail-fast: the fake engine refuses to expose the Vector4Array internal + /// getter, so `gdcc_packed_ref_init` must print an engine error naming the family and abort. + private static final String MISSING_GETTER_PROBE = """ + int main(void) { + CHECK(godot_initialize_interface(fake_get_proc_address) != 0, "interface init failed"); + g_getter_disabled_for = GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY; + gdcc_packed_ref_init(); + printf("FAIL init did not fail-fast\\n"); + return 1; + } + """; + + /// Uninitialized-access fail-fast: using an internal-pointer accessor before + /// `gdcc_packed_ref_init()` ran must abort with a clear error instead of calling a NULL getter. + private static final String NO_INIT_PROBE = """ + int main(void) { + CHECK(godot_initialize_interface(fake_get_proc_address) != 0, "interface init failed"); + godot_Variant value = gdcc_packed_int32_array_new_empty(); + (void)gdcc_packed_int32_array_internal_ptr(&value); + printf("FAIL internal_ptr did not fail-fast\\n"); + return 1; + } + """; + + /// NULL-self fail-fast: the accessor must reject a NULL Variant pointer outright. + private static final String NULL_SELF_PROBE = """ + int main(void) { + CHECK(godot_initialize_interface(fake_get_proc_address) != 0, "interface init failed"); + gdcc_packed_ref_init(); + (void)gdcc_packed_int32_array_internal_ptr(NULL); + printf("FAIL null-self did not fail-fast\\n"); + return 1; + } + """; + + /// NULL-result backstop: against an engine whose getter does return NULL (the fake models a + /// nil-backed Variant this way), the accessor must fail-fast instead of propagating NULL. + /// A type-mismatched Variant remains caller-side UB and is deliberately NOT probed. + private static final String NULL_INTERNAL_PROBE = """ + int main(void) { + CHECK(godot_initialize_interface(fake_get_proc_address) != 0, "interface init failed"); + gdcc_packed_ref_init(); + godot_Variant nil_v; + memset(&nil_v, 0, sizeof(nil_v)); + (void)gdcc_packed_int32_array_internal_ptr(&nil_v); + printf("FAIL null-internal did not fail-fast\\n"); + return 1; + } + """; + + /// Interface-not-ready fail-fast: init before `godot_initialize_interface` must detect the + /// unresolved getter interface and report via the stderr fallback (print_error is NULL here). + private static final String NO_INTERFACE_PROBE = """ + int main(void) { + gdcc_packed_ref_init(); + printf("FAIL init did not fail-fast\\n"); + return 1; + } + """; + + /// Per-TU init contract: the main TU initializes its own getter caches, but the second TU + /// (packed_ref_tu2.c) never does, so its accessor call must fail-fast inside that TU. + private static final String MULTI_TU_PROBE = """ + void tu2_use_internal_ptr(godot_Variant *value); + + int main(void) { + CHECK(godot_initialize_interface(fake_get_proc_address) != 0, "interface init failed"); + gdcc_packed_ref_init(); + godot_Variant value = gdcc_packed_int32_array_new_empty(); + tu2_use_internal_ptr(&value); + printf("FAIL tu2 did not fail-fast\\n"); + return 1; + } + """; +} diff --git a/src/test/java/gd/script/gdcc/backend/c/build/PackedRefStorageModelSmokeTest.java b/src/test/java/gd/script/gdcc/backend/c/build/PackedRefStorageModelSmokeTest.java new file mode 100644 index 00000000..fdcf5a8a --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/build/PackedRefStorageModelSmokeTest.java @@ -0,0 +1,562 @@ +package gd.script.gdcc.backend.c.build; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +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.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Zig-gated runtime smoke tests for the Variant-backed Packed*Array storage model, +/// complementing the +/// Godot-side dual-run suite with C-level probes against a fake engine that models the +/// Packed*Array identity contract: +/// - `for_packed_array_iter.h` live iteration: appends during iteration are visited, +/// `get` re-checks the live size so shrink/OOB stays safe, and holder copies keep refcounting +/// balanced through init/from/next/destroy cycles; +/// - the ptrcall wrapper sequence (as generated by `entry.h.ftl`, text-anchored by +/// `CCodegenTest.generatesPackedWrapperVariantCallBoundaryAndPtrcallMaterialization`) isolates +/// caller identity across the boundary in both directions while mutation stays visible inside. +/// +/// The fake engine mirrors the packed-ref fixture's identity rules: Variant holder copies share the +/// same backing object, while every struct<->Variant conversion and every struct copy constructor +/// produces a NEW backing object with copied content. +public class PackedRefStorageModelSmokeTest { + + private static final Path GODOT_INCLUDE_DIR = Path.of("src/main/c/codegen/include_451/godot").toAbsolutePath().normalize(); + private static final Path GDCC_INCLUDE_DIR = Path.of("src/main/c/codegen/include_451/gdcc").toAbsolutePath().normalize(); + private static final Path WORK_DIR = Path.of("tmp/test/packed_ref_storage_model_smoke").toAbsolutePath().normalize(); + + private static Path zig; + private static Path sharedDir; + private static Path godotBindingObj; + + @BeforeAll + static void compileRuntimeObjects() throws IOException, InterruptedException { + zig = ZigUtil.findZig(); + Assumptions.assumeTrue(zig != null, "Zig executable is required for packed storage-model C smoke tests"); + sharedDir = Files.createDirectories(WORK_DIR.resolve("shared")); + // godot_binding.c is the unity TU (it includes godot_interface.c / godot_builtin.c / + // godot_utility.c / godot_fixed_binding.c), so it alone carries the whole binding surface. + godotBindingObj = compileObject(zig, GODOT_INCLUDE_DIR.resolve("godot_binding.c"), sharedDir.resolve("godot_binding.o")); + } + + /// Live iteration, positive + negative anchors: appended elements ARE visited in + /// the same loop, shrink stops the loop early (live-size re-check), OOB `get` returns the + /// family default without touching freed memory, and every holder path stays balanced. + @Test + void packedIteratorShouldIterateLiveAndStayBalanced() throws IOException, InterruptedException { + var result = compileLinkAndRun("packed_iter_live", ITER_PROBE); + assertEquals(0, result.exitCode(), result::diagnostic); + assertTrue(result.output().contains("LIVE_ITER sum=10 visits=4 alias_size=4"), result.output()); + assertTrue(result.output().contains("OOB_GET ok"), result.output()); + assertTrue(result.output().contains("SHRINK visits=2"), result.output()); + assertTrue(result.output().contains("BALANCED"), result.output()); + assertTrue(result.output().contains("PROBE_DONE"), result.output()); + assertEquals(-1, result.output().indexOf("FAIL"), () -> "probe reported FAIL:\n" + result.output()); + } + + /// Ptrcall identity-isolation exception run test: the generated ptrcall wrapper sequence (materialize inbound, + /// copy outbound — whitelist (a)) must NOT share identity with the caller across the boundary, + /// while the callee's mutation stays visible on the callee side and the returned copy carries + /// it out. Identity isolation is asserted, never assumed from comments. + @Test + void ptrcallBoundaryShouldIsolateCallerIdentityInBothDirections() throws IOException, InterruptedException { + var result = compileLinkAndRun("packed_ptrcall_isolation", PTRCALL_PROBE); + assertEquals(0, result.exitCode(), result::diagnostic); + assertTrue(result.output().contains("PTRCALL_ISOLATION caller_unchanged=1 ret_has_mutation=1 distinct_identity=1"), result.output()); + assertTrue(result.output().contains("CALLEE_SHARED visible=1"), result.output()); + assertTrue(result.output().contains("BALANCED"), result.output()); + assertTrue(result.output().contains("PROBE_DONE"), result.output()); + assertEquals(-1, result.output().indexOf("FAIL"), () -> "probe reported FAIL:\n" + result.output()); + } + + private CompileResult compileLinkAndRun(String probeName, String probeSource) throws IOException, InterruptedException { + var probeDir = Files.createDirectories(WORK_DIR.resolve(probeName)); + var source = probeDir.resolve(probeName + ".c"); + Files.writeString(source, probeSource, StandardCharsets.UTF_8); + var probeObj = compileObject(zig, source, probeDir.resolve(probeName + ".o")); + var executable = linkExecutable(zig, List.of(godotBindingObj, probeObj), probeDir.resolve(probeName)); + return runExecutable(executable); + } + + private static Path compileObject(Path zig, Path source, Path output) throws IOException, InterruptedException { + var command = new ArrayList(); + command.add(zig.toString()); + command.add("cc"); + command.add("-std=c23"); + command.add("-I" + GODOT_INCLUDE_DIR); + command.add("-I" + GDCC_INCLUDE_DIR); + command.add("-c"); + command.add(source.toString()); + command.add("-o"); + command.add(output.toString()); + + var process = new ProcessBuilder(command).redirectErrorStream(true).start(); + var processOutput = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + var exitCode = process.waitFor(); + assertEquals(0, exitCode, () -> String.join(" ", command) + "\n" + processOutput); + return output; + } + + private static Path linkExecutable(Path zig, List objects, Path output) throws IOException, InterruptedException { + var command = new ArrayList(); + command.add(zig.toString()); + command.add("cc"); + for (var object : objects) { + command.add(object.toString()); + } + command.add("-o"); + command.add(output.toString()); + + var process = new ProcessBuilder(command).redirectErrorStream(true).start(); + var processOutput = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + var exitCode = process.waitFor(); + assertEquals(0, exitCode, () -> String.join(" ", command) + "\n" + processOutput); + return output; + } + + private static CompileResult runExecutable(Path executable) throws IOException, InterruptedException { + var command = List.of(executable.toString()); + var process = new ProcessBuilder(command).redirectErrorStream(true).start(); + var processOutput = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + var exitCode = process.waitFor(); + return new CompileResult(command, exitCode, processOutput, executable); + } + + private record CompileResult(List command, int exitCode, String output, Path outputPath) { + String diagnostic() { + return String.join(" ", command) + "\n" + output; + } + } + + /// Shared fake-engine layer: a 24-byte Variant (type tag + backing pointer) over a ref-counted + /// `FakePackedI32`. Identity rules (the contract under test): + /// - `variant_new_copy` shares the backing object (holders++); + /// - struct<->Variant conversions and the struct copy constructor clone content into a NEW + /// backing object — mutation can never cross those boundaries; + /// - builtin `size` / `operator_index_const` operate on the live backing in place. + private static final String FAKE_ENGINE = """ + #include + #include + #include + #include + #include + #include + + static void fail(const char *msg) { + printf("FAIL %s\\n", msg); + fflush(stdout); + exit(1); + } + #define CHECK(cond, msg) do { if (!(cond)) fail(msg); } while (0) + + static_assert(sizeof(godot_Variant) == 24, "fake Variant layout needs 24-byte Variant"); + static_assert(sizeof(godot_PackedInt32Array) == 16, "fake packed layout needs 16-byte struct"); + + #define FAKE_CAPACITY 16 + + typedef struct FakePackedI32 { + uint8_t self_slot[16]; + int64_t id; + int64_t holders; + int32_t elems[FAKE_CAPACITY]; + int64_t size; + } FakePackedI32; + + static int64_t g_next_id = 1; + static int64_t g_live = 0; + static int64_t g_variant_copy_calls = 0; + static int64_t g_variant_destroy_calls = 0; + static int64_t g_struct_destroy_calls = 0; + + static void *fake_mem_alloc(size_t bytes) { + void *ptr = malloc(bytes == 0 ? 1 : bytes); + return ptr; + } + static void *fake_mem_realloc(void *ptr, size_t bytes) { return realloc(ptr, bytes == 0 ? 1 : bytes); } + static void fake_mem_free(void *ptr) { free(ptr); } + static void fake_print_error(const char *desc, const char *func, const char *file, int32_t line, GDExtensionBool notify) { + (void)func; (void)file; (void)line; (void)notify; + printf("ENGINE_ERROR %s\\n", desc != NULL ? desc : ""); + fflush(stdout); + } + + static FakePackedI32 *fake_alloc(void) { + FakePackedI32 *fp = malloc(sizeof(FakePackedI32)); + memset(fp, 0, sizeof(*fp)); + fp->id = g_next_id++; + fp->holders = 1; + memcpy(fp->self_slot, &fp, sizeof(fp)); + g_live++; + return fp; + } + static void fake_release(FakePackedI32 *fp) { + fp->holders--; + if (fp->holders == 0) { + g_live--; + memset(fp, 0xEE, sizeof(*fp)); + free(fp); + } + } + static FakePackedI32 *fake_read(GDExtensionConstTypePtr storage, const char *what) { + FakePackedI32 *fp; + memcpy(&fp, storage, sizeof(fp)); + if (fp == NULL) fail(what); + return fp; + } + static void fake_write(GDExtensionUninitializedTypePtr out, FakePackedI32 *fp) { + memset(out, 0, 16); + memcpy(out, &fp, sizeof(fp)); + } + static FakePackedI32 *fake_clone(FakePackedI32 *src) { + FakePackedI32 *fp = fake_alloc(); + fp->size = src->size; + memcpy(fp->elems, src->elems, sizeof(fp->elems)); + return fp; + } + + static void fake_variant_write(GDExtensionUninitializedVariantPtr out, int64_t type, FakePackedI32 *ref) { + memset(out, 0, sizeof(godot_Variant)); + memcpy(out, &type, 8); + memcpy((char *)out + 8, &ref, 8); + } + static FakePackedI32 *fake_variant_ref(GDExtensionConstVariantPtr v) { + FakePackedI32 *ref; + memcpy(&ref, (const char *)v + 8, 8); + return ref; + } + static GDExtensionVariantType fake_variant_get_type(GDExtensionConstVariantPtr v) { + int64_t type; + memcpy(&type, v, 8); + return (GDExtensionVariantType)type; + } + // A NIL Variant is a zeroed slot (type tag NIL + NULL ref); without this the + // iterator init path would destroy an UNINITIALIZED Variant (garbage ref pointer), + // which only passes by stack-luck on some platforms (real CI runners segfaulted). + static void fake_variant_new_nil(GDExtensionUninitializedVariantPtr out) { + fake_variant_write(out, GDEXTENSION_VARIANT_TYPE_NIL, NULL); + } + static void fake_variant_new_copy(GDExtensionUninitializedVariantPtr out, GDExtensionConstVariantPtr src) { + g_variant_copy_calls++; + FakePackedI32 *ref = fake_variant_ref(src); + if (ref != NULL) ref->holders++; + fake_variant_write(out, fake_variant_get_type(src), ref); + } + static void fake_variant_destroy(GDExtensionVariantPtr v) { + g_variant_destroy_calls++; + FakePackedI32 *ref = fake_variant_ref(v); + if (ref != NULL) fake_release(ref); + memset(v, 0xEE, sizeof(godot_Variant)); + } + + static void *fake_internal_ptr(GDExtensionVariantPtr v) { + FakePackedI32 *ref = fake_variant_ref(v); + if (ref == NULL) return NULL; + return ref->self_slot; + } + static GDExtensionVariantGetInternalPtrFunc fake_get_internal_getter(GDExtensionVariantType type) { + // gdcc_packed_ref_init resolves all ten families up front; only int32 is exercised + // by the probes, but every packed kind must resolve to keep init from aborting. + if (type < GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY || type > GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY) { + fail("unexpected getter family"); + } + return fake_internal_ptr; + } + + static void fake_empty_ctor(GDExtensionUninitializedTypePtr out, const GDExtensionConstTypePtr *args) { + (void)args; + fake_write(out, fake_alloc()); + } + static void fake_copy_ctor(GDExtensionUninitializedTypePtr out, const GDExtensionConstTypePtr *args) { + fake_write(out, fake_clone(fake_read(args[0], "copy ctor on destroyed packed"))); + } + static GDExtensionPtrConstructor fake_get_ptr_constructor(GDExtensionVariantType type, int32_t index) { + if (type != GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY) fail("unexpected ctor family"); + if (index == 0) return fake_empty_ctor; + if (index == 1) return fake_copy_ctor; + fail("unexpected ctor index"); + return NULL; + } + static void fake_dtor(GDExtensionTypePtr ptr) { + g_struct_destroy_calls++; + fake_release(fake_read(ptr, "double-destroyed packed struct")); + memset(ptr, 0xEE, 16); + } + static void fake_string_name_struct_dtor(GDExtensionTypePtr ptr) { + memset(ptr, 0xEE, sizeof(godot_StringName)); + } + static GDExtensionPtrDestructor fake_get_ptr_destructor(GDExtensionVariantType type) { + if (type == GDEXTENSION_VARIANT_TYPE_STRING_NAME) return fake_string_name_struct_dtor; + if (type != GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY) fail("unexpected dtor family"); + return fake_dtor; + } + static void fake_pack(GDExtensionUninitializedVariantPtr out, GDExtensionTypePtr in) { + fake_variant_write(out, GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY, + fake_clone(fake_read(in, "pack on destroyed packed"))); + } + static GDExtensionVariantFromTypeConstructorFunc fake_get_from_type(GDExtensionVariantType type) { + if (type != GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY) fail("unexpected pack family"); + return fake_pack; + } + static void fake_unpack(GDExtensionUninitializedTypePtr out, GDExtensionVariantPtr v) { + if (fake_variant_get_type(v) != GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY) fail("unpack type mismatch"); + fake_write(out, fake_clone(fake_variant_ref(v))); + } + static GDExtensionTypeFromVariantConstructorFunc fake_get_to_type(GDExtensionVariantType type) { + if (type != GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY) fail("unexpected unpack family"); + return fake_unpack; + } + + // Builtin `size` resolution: the generated wrapper resolves through + // variant_get_ptr_builtin_method (with a StringName round-trip) and then calls the + // returned method with the base as first argument. + static void fake_size_method(GDExtensionTypePtr base, const GDExtensionConstTypePtr *args, GDExtensionTypePtr ret, int arg_count) { + (void)args; + CHECK(arg_count == 0, "size method arg count"); + FakePackedI32 *fp = fake_read(base, "size on destroyed packed"); + memset(ret, 0, sizeof(godot_int)); + godot_int size = fp->size; + memcpy(ret, &size, sizeof(godot_int)); + } + static GDExtensionPtrBuiltInMethod fake_get_builtin_method(GDExtensionVariantType type, GDExtensionConstStringNamePtr name, GDExtensionInt hash) { + (void)hash; + const char *method; + memcpy(&method, name, sizeof(method)); + CHECK(strcmp(method, "size") == 0, "unexpected builtin method lookup"); + if (type != GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY) fail("unexpected builtin method family"); + return fake_size_method; + } + static void fake_string_name_new_with_utf8(GDExtensionUninitializedStringNamePtr out, const char *text) { + memset(out, 0, sizeof(godot_StringName)); + memcpy(out, &text, sizeof(text)); + } + static void fake_string_name_destroy(GDExtensionStringNamePtr ptr) { + memset(ptr, 0xEE, sizeof(godot_StringName)); + } + + static const int32_t *fake_operator_index_const(GDExtensionConstTypePtr self, GDExtensionInt index) { + FakePackedI32 *fp = fake_read(self, "operator_index_const on destroyed packed"); + CHECK(index >= 0 && index < fp->size, "operator_index_const out of range"); + return &fp->elems[index]; + } + + static void fake_unused_interface(void) { + } + static GDExtensionInterfaceFunctionPtr fake_get_proc_address(const char *name) { + if (strcmp(name, "mem_alloc") == 0) return (GDExtensionInterfaceFunctionPtr)fake_mem_alloc; + if (strcmp(name, "mem_realloc") == 0) return (GDExtensionInterfaceFunctionPtr)fake_mem_realloc; + if (strcmp(name, "mem_free") == 0) return (GDExtensionInterfaceFunctionPtr)fake_mem_free; + if (strcmp(name, "print_error") == 0) return (GDExtensionInterfaceFunctionPtr)fake_print_error; + if (strcmp(name, "variant_get_type") == 0) return (GDExtensionInterfaceFunctionPtr)fake_variant_get_type; + if (strcmp(name, "variant_new_nil") == 0) return (GDExtensionInterfaceFunctionPtr)fake_variant_new_nil; + if (strcmp(name, "variant_new_copy") == 0) return (GDExtensionInterfaceFunctionPtr)fake_variant_new_copy; + if (strcmp(name, "variant_destroy") == 0) return (GDExtensionInterfaceFunctionPtr)fake_variant_destroy; + if (strcmp(name, "variant_get_ptr_internal_getter") == 0) return (GDExtensionInterfaceFunctionPtr)fake_get_internal_getter; + if (strcmp(name, "variant_get_ptr_constructor") == 0) return (GDExtensionInterfaceFunctionPtr)fake_get_ptr_constructor; + if (strcmp(name, "variant_get_ptr_destructor") == 0) return (GDExtensionInterfaceFunctionPtr)fake_get_ptr_destructor; + if (strcmp(name, "get_variant_from_type_constructor") == 0) return (GDExtensionInterfaceFunctionPtr)fake_get_from_type; + if (strcmp(name, "get_variant_to_type_constructor") == 0) return (GDExtensionInterfaceFunctionPtr)fake_get_to_type; + if (strcmp(name, "variant_get_ptr_builtin_method") == 0) return (GDExtensionInterfaceFunctionPtr)fake_get_builtin_method; + if (strcmp(name, "string_name_new_with_utf8_chars") == 0) return (GDExtensionInterfaceFunctionPtr)fake_string_name_new_with_utf8; + if (strcmp(name, "string_name_destroy") == 0) return (GDExtensionInterfaceFunctionPtr)fake_string_name_destroy; + if (strcmp(name, "godot_packed_int32_array_operator_index_const") == 0) return (GDExtensionInterfaceFunctionPtr)fake_operator_index_const; + if (strcmp(name, "packed_int32_array_operator_index_const") == 0) return (GDExtensionInterfaceFunctionPtr)fake_operator_index_const; + return (GDExtensionInterfaceFunctionPtr)fake_unused_interface; + } + + #include + #include + + // Probe-side mutation/read helpers standing in for generated code driving the internal + // pointer (push_back / indexing). + static void probe_push(godot_PackedInt32Array *arr, int32_t value) { + FakePackedI32 *fp = fake_read(arr, "push on destroyed packed"); + CHECK(fp->size < FAKE_CAPACITY, "probe capacity exceeded"); + fp->elems[fp->size++] = value; + } + static void probe_pop(godot_PackedInt32Array *arr) { + FakePackedI32 *fp = fake_read(arr, "pop on destroyed packed"); + CHECK(fp->size > 0, "pop on empty packed"); + fp->size--; + } + static int64_t probe_size(const godot_PackedInt32Array *arr) { + return fake_read(arr, "size of destroyed packed")->size; + } + static int64_t probe_id_of_variant(const godot_Variant *v) { + FakePackedI32 *ref = fake_variant_ref(v); + return ref == NULL ? -1 : ref->id; + } + static int64_t probe_holders_of_variant(const godot_Variant *v) { + FakePackedI32 *ref = fake_variant_ref(v); + return ref == NULL ? -1 : ref->holders; + } + static int64_t probe_id_of_struct(const godot_PackedInt32Array *arr) { + return fake_read(arr, "id of destroyed packed")->id; + } + static int32_t probe_get(const godot_PackedInt32Array *arr, int64_t index) { + FakePackedI32 *fp = fake_read(arr, "get on destroyed packed"); + CHECK(index >= 0 && index < fp->size, "probe index out of range"); + return fp->elems[index]; + } + """; + + /// Live-iteration probe: mirrors the exact generated for-loop protocol + /// (FrontendSequenceItemInsnLoweringProcessors): `next_temp = next(&state)` (overwriting the + /// predeclared temp), then `state = copy(&next_temp)` — the iterator type is not + /// direct-struct-assignable, so the assign goes through the holder-copy helper and BOTH slots + /// hold the source array inside the loop body. + private static final String ITER_PROBE = FAKE_ENGINE + """ + + int main(void) { + godot_initialize_interface(&fake_get_proc_address); + gdcc_packed_ref_init(); + + godot_Variant src = gdcc_packed_int32_array_new_empty(); + probe_push(gdcc_packed_int32_array_internal_ptr(&src), 1); + probe_push(gdcc_packed_int32_array_internal_ptr(&src), 2); + godot_Variant alias = gdcc_packed_ref_copy(&src); + + // Positive: appending 3 and 4 while visiting element 2 must be visited by the + // SAME loop (live size), and the shared alias observes the growth. + int64_t sum = 0; + int64_t visits = 0; + gdcc_for_packed_int32_array_iter it = gdcc_for_packed_int32_array_iter_from(&src); + gdcc_for_packed_int32_array_iter next_tmp = gdcc_for_packed_int32_array_iter_init(); + while (gdcc_for_packed_int32_array_iter_should_continue(&it)) { + int32_t value = gdcc_for_packed_int32_array_iter_get(&it); + sum += value; + visits++; + if (value == 2) { + probe_push(gdcc_packed_int32_array_internal_ptr(&src), 3); + probe_push(gdcc_packed_int32_array_internal_ptr(&src), 4); + } + // generated order: overwrite the next temp (destroy old, then assign), then + // commit into the state slot through the holder copy. + gdcc_for_packed_int32_array_iter_destroy(&next_tmp); + next_tmp = gdcc_for_packed_int32_array_iter_next(&it); + gdcc_for_packed_int32_array_iter_destroy(&it); + it = gdcc_for_packed_int32_array_iter_copy(&next_tmp); + // Two iterator slots + src + alias all share the one backing array here. + CHECK(probe_holders_of_variant(&alias) == 4, "state and next temp must both hold the shared array"); + } + CHECK(sum == 10, "live iteration must visit appended elements (1+2+3+4)"); + CHECK(visits == 4, "live iteration visit count"); + CHECK(probe_size(gdcc_packed_int32_array_internal_ptr(&alias)) == 4, "alias must observe shared growth"); + printf("LIVE_ITER sum=%lld visits=%lld alias_size=%lld\\n", + (long long)sum, (long long)visits, (long long)probe_size(gdcc_packed_int32_array_internal_ptr(&alias))); + + // Negative: after the loop the iterator's index is past the live size; `get` must + // return the family default (0) instead of reading out of bounds. + CHECK(gdcc_for_packed_int32_array_iter_get(&it) == 0, "OOB get must yield the family default"); + printf("OOB_GET ok\\n"); + + // Negative: shrinking the shared array BELOW the iterator cursor mid-loop must + // stop the loop early (live-size check in should_continue), never reading + // detached elements. src is [1,2,3,4] here: pop twice while visiting 2 at + // index 1 -> live size drops to 2 -> the loop stops after visiting {1, 2}. + gdcc_for_packed_int32_array_iter_destroy(&it); + it = gdcc_for_packed_int32_array_iter_from(&src); + int64_t shrink_visits = 0; + int64_t shrink_sum = 0; + while (gdcc_for_packed_int32_array_iter_should_continue(&it)) { + int32_t value2 = gdcc_for_packed_int32_array_iter_get(&it); + shrink_sum += value2; + shrink_visits++; + if (value2 == 2) { + probe_pop(gdcc_packed_int32_array_internal_ptr(&src)); + probe_pop(gdcc_packed_int32_array_internal_ptr(&src)); + } + gdcc_for_packed_int32_array_iter_destroy(&next_tmp); + next_tmp = gdcc_for_packed_int32_array_iter_next(&it); + gdcc_for_packed_int32_array_iter_destroy(&it); + it = gdcc_for_packed_int32_array_iter_copy(&next_tmp); + } + CHECK(shrink_visits == 2 && shrink_sum == 3, + "shrink below the cursor must clamp iteration to the live size"); + printf("SHRINK visits=%lld\\n", (long long)shrink_visits); + + gdcc_for_packed_int32_array_iter_destroy(&it); + gdcc_for_packed_int32_array_iter_destroy(&next_tmp); + gdcc_packed_ref_destroy(&alias); + gdcc_packed_ref_destroy(&src); + CHECK(g_live == 0, "holder leak across iterator lifecycle"); + printf("BALANCED\\n"); + printf("PROBE_DONE\\n"); + return 0; + } + """; + + /// ptrcall boundary probe: mirrors the generated wrapper sequence in + /// `entry.h.ftl` (`ptrcall`): inbound `variant_from_struct` materialization, callee on + /// the internal pointer, outbound `struct_from_variant` copy, wrapper-owned cleanup. + private static final String PTRCALL_PROBE = FAKE_ENGINE + """ + + // Stands in for the generated user function `func mutate(p: PackedInt32Array) + // -> PackedInt32Array: p.push_back(7); return p` — mutation through the internal + // pointer, return through an identity-sharing holder copy. + static godot_Variant fake_callee_mutate(godot_Variant *p) { + probe_push(gdcc_packed_int32_array_internal_ptr(p), 7); + // A callee-side alias must observe the mutation (shared identity inside). + godot_Variant callee_alias = gdcc_packed_ref_copy(p); + CHECK(probe_size(gdcc_packed_int32_array_internal_ptr(&callee_alias)) == 2, + "callee alias must observe the in-place mutation"); + gdcc_packed_ref_destroy(&callee_alias); + return gdcc_packed_ref_copy(p); + } + + int main(void) { + godot_initialize_interface(&fake_get_proc_address); + gdcc_packed_ref_init(); + + // Caller side: a live array [1] whose identity must survive the boundary intact. + godot_Variant caller = gdcc_packed_int32_array_new_empty(); + probe_push(gdcc_packed_int32_array_internal_ptr(&caller), 1); + int64_t caller_id = probe_id_of_variant(&caller); + + // The engine hands the callee a RAW STRUCT slot across ptrcall (Vector-layer copy). + godot_PackedInt32Array caller_slot = gdcc_packed_int32_array_struct_from_variant(&caller); + + // === generated ptrcall wrapper body (entry.h.ftl ptrcall) === + const GDExtensionConstTypePtr p_args[] = { &caller_slot }; + godot_PackedInt32Array ret_slot; + { + godot_Variant arg0 = gdcc_packed_int32_array_variant_from_struct( + (const godot_PackedInt32Array *)p_args[0]); + godot_Variant r = fake_callee_mutate(&arg0); + *((godot_PackedInt32Array *)&ret_slot) = gdcc_packed_int32_array_struct_from_variant(&r); + godot_Variant_destroy(&r); + godot_Variant_destroy(&arg0); + } + // === end generated wrapper body === + + int caller_unchanged = probe_size(&caller_slot) == 1 && probe_get(&caller_slot, 0) == 1; + int ret_has_mutation = probe_size(&ret_slot) == 2 && probe_get(&ret_slot, 0) == 1 && probe_get(&ret_slot, 1) == 7; + int distinct_identity = probe_id_of_struct(&caller_slot) != probe_id_of_struct(&ret_slot) + && probe_id_of_variant(&caller) != probe_id_of_struct(&ret_slot); + CHECK(caller_unchanged, "ptrcall boundary must isolate the caller from callee mutation"); + CHECK(ret_has_mutation, "ptrcall return must carry the callee mutation out"); + CHECK(distinct_identity, "ptrcall boundary must not share identity in either direction"); + printf("PTRCALL_ISOLATION caller_unchanged=%d ret_has_mutation=%d distinct_identity=%d\\n", + caller_unchanged, ret_has_mutation, distinct_identity); + printf("CALLEE_SHARED visible=1\\n"); + + godot_PackedInt32Array_destroy(&ret_slot); + godot_PackedInt32Array_destroy(&caller_slot); + gdcc_packed_ref_destroy(&caller); + CHECK(g_live == 0, "holder leak across ptrcall boundary"); + // new_empty's temporary struct + the two boundary slots, each destroyed exactly once. + CHECK(g_struct_destroy_calls == 3, "raw struct slots must be destroyed exactly once"); + printf("BALANCED\\n"); + printf("PROBE_DONE\\n"); + return 0; + } + """; +} diff --git a/src/test/java/gd/script/gdcc/backend/c/build/packedref/PackedArrayReferenceSemanticsDualRunTest.java b/src/test/java/gd/script/gdcc/backend/c/build/packedref/PackedArrayReferenceSemanticsDualRunTest.java new file mode 100644 index 00000000..e5ebacab --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/build/packedref/PackedArrayReferenceSemanticsDualRunTest.java @@ -0,0 +1,109 @@ +package gd.script.gdcc.backend.c.build.packedref; + +import gd.script.gdcc.backend.c.build.GodotGdextensionTestRunner; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Dual-run golden comparison for the Packed*Array reference-semantics behavior contract +/// (semantics matrix + documented ABI exceptions). +/// +/// The same `packed_ref_probes.gd` source runs once under the Godot interpreter and once +/// compiled by gdcc; both sides must reproduce the committed golden file in full. Truth +/// sources: the golden file owns case PAYLOADS and is always validated against the +/// interpreter side first (it is the baseline the golden was locked with), while +/// {@link PackedRefSemanticsGoldenInventoryTest} anchors the case inventory +/// (names + order, Godot-independent) so coverage cannot silently shrink. +/// +/// Gating: skipped via JUnit assumptions when `GODOT_BIN` is missing; the gdcc side is +/// additionally skipped when Zig is unavailable (the interpreter baseline still runs). +public class PackedArrayReferenceSemanticsDualRunTest { + + private static PackedRefSemanticsDualRunHarness.DualRunResult dualRun; + private static ProbeOutput golden; + + @BeforeAll + static void runBothSides() throws IOException, InterruptedException { + var godotBinary = GodotGdextensionTestRunner.findGodotBinaryFromEnv(); + Assumptions.assumeTrue( + godotBinary != null, + "GODOT_BIN not found; skipping Packed*Array reference-semantics dual-run test" + ); + golden = PackedRefSemanticsDualRunHarness.loadGolden(); + dualRun = PackedRefSemanticsDualRunHarness.runBothSides(godotBinary); + } + + /// The interpreter side must reproduce the full golden exactly — all cases, payloads and + /// emission order. This re-validates the baseline on every run (engine version drift on + /// the machine surfaces here rather than corrupting comparisons against the gdcc side). + @Test + void interpreterRunMatchesGolden() { + var comparison = ProbeGoldenComparison.compare(dualRun.interpreterOutput(), golden); + assertTrue( + comparison.matches(), + () -> "Interpreter run diverges from golden.\n" + comparison.describe() + + "\nTranscript: " + PackedRefSemanticsDualRunHarness.TRANSCRIPTS_DIR.resolve("interpreter_stdout.txt") + ); + } + + /// The gdcc side must reproduce the same full golden: every case present, every payload + /// equal, no unknown cases, golden relative order. This is the executable form of the + /// behavior matrix plus the documented ABI exceptions. + @Test + void gdccRunMatchesGolden() { + Assumptions.assumeTrue( + dualRun.gdccOutput() != null, + "Zig not found; gdcc side was not built (interpreter baseline still validated)" + ); + var comparison = ProbeGoldenComparison.compare(dualRun.gdccOutput(), golden); + assertTrue( + comparison.matches(), + () -> "gdcc-compiled run diverges from golden.\n" + comparison.describe() + + "\nTranscript: " + PackedRefSemanticsDualRunHarness.TRANSCRIPTS_DIR.resolve("gdcc_stdout.txt") + ); + } + + /// Per-case granularity in golden order: every case re-checks its interpreter payload + /// against the golden and asserts gdcc-side payload equality. + @TestFactory + List perCaseGoldenAlignment() { + return golden.caseNames().stream() + .map(caseName -> DynamicTest.dynamicTest( + caseName, + () -> assertCaseAlignment(caseName) + )) + .toList(); + } + + private static void assertCaseAlignment(String caseName) { + var expectedPayload = golden.requirePayload(caseName); + assertEquals( + expectedPayload, + dualRun.interpreterOutput().requirePayload(caseName), + "Interpreter payload diverges from golden for case " + caseName + ); + Assumptions.assumeTrue( + dualRun.gdccOutput() != null, + "Zig not found; gdcc side was not built (interpreter baseline still validated)" + ); + var gdccOutput = dualRun.gdccOutput(); + assertTrue( + gdccOutput.hasCase(caseName), + () -> "gdcc-compiled run is missing case " + caseName + ".\ngdcc PROBE lines:\n" + + String.join("\n", gdccOutput.probeLines()) + ); + assertEquals( + expectedPayload, + gdccOutput.requirePayload(caseName), + "gdcc-compiled payload diverges from golden for case " + caseName + ); + } +} diff --git a/src/test/java/gd/script/gdcc/backend/c/build/packedref/PackedRefSemanticsDualRunHarness.java b/src/test/java/gd/script/gdcc/backend/c/build/packedref/PackedRefSemanticsDualRunHarness.java new file mode 100644 index 00000000..9efcb0bb --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/build/packedref/PackedRefSemanticsDualRunHarness.java @@ -0,0 +1,444 @@ +package gd.script.gdcc.backend.c.build.packedref; + +import gd.script.gdcc.backend.CodegenContext; +import gd.script.gdcc.backend.c.build.CBuildResult; +import gd.script.gdcc.backend.c.build.COptimizationLevel; +import gd.script.gdcc.backend.c.build.CProjectBuilder; +import gd.script.gdcc.backend.c.build.CProjectInfo; +import gd.script.gdcc.backend.c.build.GdextensionMetadataFile; +import gd.script.gdcc.backend.c.build.TargetPlatform; +import gd.script.gdcc.backend.c.build.ZigUtil; +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.scope.ClassRegistry; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/// Orchestrates the Packed*Array reference-semantics dual-run comparison: the same probe +/// source runs twice side by side — once under the Godot interpreter (the golden baseline) and +/// once compiled by gdcc — and both outputs are normalized for comparison. +/// +/// - interpreter run: a standalone Godot project where the probe library stays plain GDScript +/// and a `SceneTree` driver `preload`s it (headless `-s` launches have no editor-generated +/// global class cache, so `class_name` lookup would fail there); +/// - gdcc run: the identical library source is compiled by gdcc into a GDExtension library and +/// the same driver body instantiates the registered class instead. +/// +/// Raw transcripts of every run are written under `TRANSCRIPTS_DIR` for failure triage. +/// +/// This harness is a new dual-project comparison facility; it deliberately does not reuse the +/// scene-based `GodotGdextensionTestRunner` (which targets `main.tscn` node fixtures) or the +/// gdscript-unit verifier scripts. +public final class PackedRefSemanticsDualRunHarness { + /// Classpath directory holding the shared probe library and the golden file. + public static final @NotNull String RESOURCE_DIR = "/packed_ref_semantics"; + public static final @NotNull String PROBE_LIBRARY_RESOURCE = RESOURCE_DIR + "/packed_ref_probes.gd"; + public static final @NotNull String GOLDEN_RESOURCE = RESOURCE_DIR + "/packed_ref_semantics_golden.txt"; + + /// Fixed workspace for this harness (existing convention: deterministic, not cleaned up). + public static final @NotNull Path WORK_DIR = Path.of("tmp/test/packed_ref_semantics_dual_run").toAbsolutePath(); + public static final @NotNull Path TRANSCRIPTS_DIR = WORK_DIR.resolve("transcripts"); + + private static final @NotNull String DRIVER_SCRIPT_NAME = "driver.gd"; + private static final @NotNull Duration PROCESS_TIMEOUT = Duration.ofSeconds(60); + + private static final @NotNull ProbeModuleSpec MAIN_MODULE = new ProbeModuleSpec( + "packed_ref_semantics", + "PackedRefProbes", + PROBE_LIBRARY_RESOURCE, + "probes.run_all(self)", + "packed_ref_semantics.gdextension" + ); + + private PackedRefSemanticsDualRunHarness() { + } + + /// One probe module: gdcc module name, registered class name, library resource, the driver + /// body line invoking the probes, and the `.gdextension` file name for the gdcc side. The + /// driver never calls `quit()` itself: the library's coroutine `run_all` quits the tree after + /// its final probe because the interpreter cannot await a compiled void coroutine. + private record ProbeModuleSpec( + @NotNull String moduleName, + @NotNull String className, + @NotNull String libraryResource, + @NotNull String driverInvocation, + @NotNull String gdextensionFileName + ) { + } + + /// Outputs of one dual run. `gdccOutput`/`gdccStdout` are null when Zig was unavailable. + public record DualRunResult( + @NotNull ProbeOutput interpreterOutput, + @NotNull String interpreterStdout, + @Nullable ProbeOutput gdccOutput, + @Nullable String gdccStdout + ) { + } + + /// Raw streams of one `godot -s driver.gd` process run. PROBE lines are parsed from + /// `stdout`; `stderr` is kept for transcripts (engine script errors land there). + private record ScriptProjectRun(@NotNull String stdout, @NotNull String stderr) { + } + + /// Runs both sides end to end and stores transcripts (transcripts are written even when a + /// run fails, so timeout/crash triage always has the partial output; once the gdcc side has + /// started, its transcript files are always rewritten so a stale previous run cannot be + /// mistaken for the current one). The gdcc side is skipped (null result fields) when + /// `ZigUtil.findZig()` cannot locate a Zig toolchain. + public static @NotNull DualRunResult runBothSides(@NotNull Path godotBinary) throws IOException, InterruptedException { + Objects.requireNonNull(godotBinary, "godotBinary must not be null"); + var interpreterStdout = new StringBuilder(); + var interpreterStderr = new StringBuilder(); + var gdccStdout = new StringBuilder(); + var gdccStderr = new StringBuilder(); + var gdccStarted = false; + try { + var interpreterMain = runInterpreterModule(godotBinary, MAIN_MODULE); + interpreterStdout.append(interpreterMain.stdout()); + interpreterStderr.append(interpreterMain.stderr()); + var interpreterOutput = ProbeOutput.parse(interpreterStdout.toString()); + + ProbeOutput gdccOutput = null; + if (ZigUtil.findZig() != null) { + gdccStarted = true; + var gdccMain = runBuiltGdccModule(godotBinary, MAIN_MODULE, compileModuleChecked(MAIN_MODULE)); + gdccStdout.append(gdccMain.stdout()); + gdccStderr.append(gdccMain.stderr()); + gdccOutput = ProbeOutput.parse(gdccStdout.toString()); + } + return new DualRunResult( + interpreterOutput, + interpreterStdout.toString(), + gdccOutput, + gdccStarted ? gdccStdout.toString() : null + ); + } finally { + writeTranscriptsQuietly( + interpreterStdout.toString(), + interpreterStderr.toString(), + gdccStarted ? gdccStdout.toString() : null, + gdccStarted ? gdccStderr.toString() : null + ); + } + } + + /// Loads and parses the committed golden resource. + public static @NotNull ProbeOutput loadGolden() throws IOException { + return ProbeOutput.parse(loadResource(GOLDEN_RESOURCE)); + } + + /// Assembles and runs the interpreter project for one module, returning its streams. + private static @NotNull ScriptProjectRun runInterpreterModule(@NotNull Path godotBinary, @NotNull ProbeModuleSpec spec) throws IOException, InterruptedException { + var projectDir = WORK_DIR.resolve("interpreter_" + spec.moduleName()); + recreateDirectory(projectDir); + Files.writeString(projectDir.resolve("project.godot"), projectGodotSource(), StandardCharsets.UTF_8); + Files.writeString(projectDir.resolve(libraryFileName(spec)), loadResource(spec.libraryResource()), StandardCharsets.UTF_8); + Files.writeString(projectDir.resolve(DRIVER_SCRIPT_NAME), interpreterDriverSource(spec), StandardCharsets.UTF_8); + return runScriptProject(godotBinary, projectDir); + } + + /// Compiles one probe module and rejects an unsuccessful native build. + private static @NotNull CBuildResult compileModuleChecked(@NotNull ProbeModuleSpec spec) throws IOException { + var buildResult = compileModule(spec); + if (!buildResult.success()) { + throw new IOException("gdcc native build failed for module " + spec.moduleName() + ". Build log:\n" + buildResult.buildLog()); + } + return buildResult; + } + + /// Assembles the GDExtension project for an already-compiled module and runs it. + private static @NotNull ScriptProjectRun runBuiltGdccModule(@NotNull Path godotBinary, @NotNull ProbeModuleSpec spec, @NotNull CBuildResult buildResult) throws IOException, InterruptedException { + var projectDir = WORK_DIR.resolve("gdcc_" + spec.moduleName()); + recreateDirectory(projectDir); + Files.writeString(projectDir.resolve("project.godot"), projectGodotSource(), StandardCharsets.UTF_8); + Files.writeString(projectDir.resolve(DRIVER_SCRIPT_NAME), gdccDriverSource(spec), StandardCharsets.UTF_8); + + var binDir = projectDir.resolve("bin"); + Files.createDirectories(binDir); + var libraryName = copyArtifacts(buildResult.artifacts(), binDir); + Files.writeString( + projectDir.resolve(spec.gdextensionFileName()), + GdextensionMetadataFile.render( + "res://bin/" + libraryName, + COptimizationLevel.DEBUG, + TargetPlatform.getNativePlatform() + ), + StandardCharsets.UTF_8 + ); + // Plain runtime launches skip the editor filesystem scan that discovers .gdextension + // files, so the extension list must be written explicitly (same contract as + // GodotGdextensionTestRunner.writeExtensionListFile). + var extensionListPath = projectDir.resolve(".godot").resolve("extension_list.cfg"); + Files.createDirectories(extensionListPath.getParent()); + Files.writeString(extensionListPath, "res://" + spec.gdextensionFileName() + "\n", StandardCharsets.UTF_8); + return runScriptProject(godotBinary, projectDir); + } + + /// Compiles one probe module through the standard frontend -> C codegen -> Zig pipeline, + /// registering the compiled class under the same name the gdcc-side driver references. + static @NotNull CBuildResult compileModule(@NotNull ProbeModuleSpec spec) throws IOException { + var parser = new GdScriptParserService(); + var parseDiagnostics = new DiagnosticManager(); + var units = List.of(parser.parseUnit( + WORK_DIR.resolve(libraryFileName(spec)), + loadResource(spec.libraryResource()), + parseDiagnostics + )); + if (!parseDiagnostics.isEmpty()) { + throw new IOException("Unexpected probe library parse diagnostics: " + parseDiagnostics.snapshot()); + } + var module = new FrontendModule(spec.moduleName(), units, Map.of(spec.className(), spec.className())); + + var diagnostics = new DiagnosticManager(); + var classRegistry = new ClassRegistry(ExtensionApiLoader.loadVersion(GodotVersion.V451)); + var lowered = new FrontendLoweringPassManager().lower(module, classRegistry, diagnostics); + if (lowered == null || diagnostics.hasErrors()) { + throw new IOException("Probe library lowering failed: " + diagnostics.snapshot()); + } + + var buildDir = WORK_DIR.resolve("build_" + spec.moduleName()); + Files.createDirectories(buildDir); + var projectInfo = new CProjectInfo( + spec.moduleName(), + GodotVersion.V451, + buildDir, + COptimizationLevel.DEBUG, + TargetPlatform.getNativePlatform() + ); + var codegen = new CCodegen(); + codegen.prepare(new CodegenContext(projectInfo, classRegistry), lowered); + return new CProjectBuilder().buildProject(projectInfo, codegen); + } + + /// Runs `godot --headless --path -s driver.gd` and returns the captured + /// streams. The process is expected to `quit()` itself; the timeout only guards against + /// wedged runs (e.g. a probe regression that skips the quit path). + static @NotNull ScriptProjectRun runScriptProject(@NotNull Path godotBinary, @NotNull Path projectDir) throws IOException, InterruptedException { + var command = List.of( + godotBinary.toString(), + "--headless", + "--path", + projectDir.toString(), + "-s", + DRIVER_SCRIPT_NAME + ); + var process = new ProcessBuilder(command).directory(projectDir.toFile()).start(); + // Virtual-thread readers (same approach as GodotGdextensionTestRunner): never the + // common pool, so a pipe-full stream cannot starve the sibling reader and wedge the + // Godot process on 1-2 core machines. InputStreamReader decodes UTF-8 across chunk + // boundaries, which a per-read `new String(bytes)` cannot do. + var stdoutBuffer = new StringBuffer(); + var stderrBuffer = new StringBuffer(); + var stdoutReader = startStreamReader(process.getInputStream(), stdoutBuffer); + var stderrReader = startStreamReader(process.getErrorStream(), stderrBuffer); + boolean exited; + try { + exited = process.waitFor(PROCESS_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + process.destroyForcibly(); + joinStreamReadersQuietly(stdoutReader, stderrReader); + throw e; + } + if (!exited) { + process.destroyForcibly(); + joinStreamReadersQuietly(stdoutReader, stderrReader); + throw new IOException( + "Godot script project run timed out after " + PROCESS_TIMEOUT + ": " + command + + "\n--- partial stdout ---\n" + stdoutBuffer + "\n--- partial stderr ---\n" + stderrBuffer); + } + joinStreamReader(stdoutReader, "stdout"); + joinStreamReader(stderrReader, "stderr"); + var stdout = stdoutBuffer.toString(); + var stderr = stderrBuffer.toString(); + if (process.exitValue() != 0) { + throw new IOException( + "Godot script project run failed with exit code " + process.exitValue() + ": " + command + + "\n--- stdout ---\n" + stdout + "\n--- stderr ---\n" + stderr); + } + return new ScriptProjectRun(stdout, stderr); + } + + /// Starts a virtual thread draining one process stream into `buffer`, so content read + /// before a timeout kill is retained for diagnostics. + private static @NotNull Thread startStreamReader(@NotNull InputStream stream, @NotNull StringBuffer buffer) { + return Thread.ofVirtual().start(() -> { + var chunk = new char[4096]; + try (var reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { + int read; + while ((read = reader.read(chunk)) != -1) { + buffer.append(chunk, 0, read); + } + } catch (IOException e) { + // Stream closed while the process was being destroyed; partial content is kept. + } + }); + } + + /// Strict join for the success path: a still-running reader means the returned output would + /// be silently truncated, so fail instead (same contract as GodotGdextensionTestRunner). + private static void joinStreamReader(@NotNull Thread reader, @NotNull String streamName) throws IOException { + try { + reader.join(5_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted collecting Godot " + streamName + " stream", e); + } + if (reader.isAlive()) { + throw new IOException("Timed out collecting Godot " + streamName + " stream"); + } + } + + /// Best-effort join for the timeout/interrupt teardown path, where the run has already + /// failed and any partial output is a bonus. + private static void joinStreamReadersQuietly(@NotNull Thread... readers) { + for (var reader : readers) { + try { + reader.join(5_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private static @NotNull String loadResource(@NotNull String resourcePath) throws IOException { + try (var stream = PackedRefSemanticsDualRunHarness.class.getResourceAsStream(resourcePath)) { + if (stream == null) { + throw new IOException("Test resource not found on classpath: " + resourcePath); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /// Removes and recreates a project directory so stale artifacts from previous runs cannot + /// leak into the next comparison. + private static void recreateDirectory(@NotNull Path dir) throws IOException { + if (Files.exists(dir)) { + try (var walk = Files.walk(dir)) { + for (var path : walk.sorted(Comparator.reverseOrder()).toList()) { + if (!path.equals(dir)) { + Files.deleteIfExists(path); + } + } + } + } + Files.createDirectories(dir); + } + + /// Copies build artifacts into `bin/`, returning the dynamic library file name preferred + /// for the current platform (same selection rule as GodotGdextensionTestRunner). + private static @NotNull String copyArtifacts(@NotNull List artifacts, @NotNull Path binDir) throws IOException { + var preferredSuffix = platformLibrarySuffix(); + String fallback = null; + var copied = new ArrayList(); + for (var artifact : artifacts) { + if (!Files.exists(artifact)) { + throw new IOException("Artifact not found: " + artifact); + } + var fileName = artifact.getFileName().toString(); + Files.copy(artifact, binDir.resolve(fileName), StandardCopyOption.REPLACE_EXISTING); + copied.add(fileName); + if (!isDynamicLibrary(fileName)) { + continue; + } + if (fileName.endsWith(preferredSuffix)) { + return fileName; + } + if (fallback == null) { + fallback = fileName; + } + } + if (fallback != null) { + return fallback; + } + throw new IOException("No dynamic library artifact found in build output: " + copied); + } + + private static @NotNull String platformLibrarySuffix() { + var fileName = TargetPlatform.getNativePlatform().sharedLibraryFileName("x"); + return fileName.substring(fileName.lastIndexOf('.')); + } + + private static boolean isDynamicLibrary(@NotNull String fileName) { + return fileName.endsWith(".dll") || fileName.endsWith(".so") || fileName.endsWith(".dylib") || fileName.endsWith(".wasm"); + } + + /// Best-effort transcript write from `runBothSides`' finally path: transcript failures must + /// never mask the primary run result. + private static void writeTranscriptsQuietly( + @NotNull String interpreterStdout, + @NotNull String interpreterStderr, + @Nullable String gdccStdout, + @Nullable String gdccStderr + ) { + try { + Files.createDirectories(TRANSCRIPTS_DIR); + Files.writeString(TRANSCRIPTS_DIR.resolve("interpreter_stdout.txt"), interpreterStdout, StandardCharsets.UTF_8); + Files.writeString(TRANSCRIPTS_DIR.resolve("interpreter_stderr.txt"), interpreterStderr, StandardCharsets.UTF_8); + if (gdccStdout != null) { + Files.writeString(TRANSCRIPTS_DIR.resolve("gdcc_stdout.txt"), gdccStdout, StandardCharsets.UTF_8); + Files.writeString(TRANSCRIPTS_DIR.resolve("gdcc_stderr.txt"), gdccStderr != null ? gdccStderr : "", StandardCharsets.UTF_8); + } + } catch (IOException e) { + System.err.println("[packed-ref-semantics harness] failed to write transcripts: " + e); + } + } + + private static @NotNull String libraryFileName(@NotNull ProbeModuleSpec spec) { + var resourcePath = spec.libraryResource(); + return resourcePath.substring(resourcePath.lastIndexOf('/') + 1); + } + + private static @NotNull String projectGodotSource() { + return """ + config_version=5 + + [application] + config/name="PackedRefSemanticsDualRun" + config/features=PackedStringArray("4.5") + """; + } + + /// Interpreter-side driver: headless `-s` launches have no editor-generated global class + /// cache, so the probe library is acquired through `preload` of the identical source file. + private static @NotNull String interpreterDriverSource(@NotNull ProbeModuleSpec spec) { + return driverSource("preload(\"res://" + libraryFileName(spec) + "\").new()", spec); + } + + /// gdcc-side driver: identical orchestration, but the probe library arrives as the compiled + /// GDExtension class registered under the same name. + private static @NotNull String gdccDriverSource(@NotNull ProbeModuleSpec spec) { + return driverSource(spec.className() + ".new()", spec); + } + + /// Renders the shared `SceneTree` driver skeleton. Two lifetime/ownership contracts: + /// `probes` is an instance variable because `_initialize` returns while the coroutine probe + /// chain is still suspended, and releasing the probe object there would kill the pending + /// coroutine states with it; the driver never quits itself — the library's coroutine + /// `run_all` quits the tree (the interpreter cannot await a gdcc-compiled void coroutine, so + /// the driver must fire-and-forget). + private static @NotNull String driverSource(@NotNull String acquisitionExpression, @NotNull ProbeModuleSpec spec) { + return "extends SceneTree\n\nvar probes\n\nfunc _initialize() -> void:\n" + '\t' + "probes = " + acquisitionExpression + '\n' + + '\t' + spec.driverInvocation() + '\n'; + } +} diff --git a/src/test/java/gd/script/gdcc/backend/c/build/packedref/PackedRefSemanticsGoldenInventoryTest.java b/src/test/java/gd/script/gdcc/backend/c/build/packedref/PackedRefSemanticsGoldenInventoryTest.java new file mode 100644 index 00000000..39f5d743 --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/build/packedref/PackedRefSemanticsGoldenInventoryTest.java @@ -0,0 +1,66 @@ +package gd.script.gdcc.backend.c.build.packedref; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Godot-independent anchor for the Packed*Array reference-semantics case inventory. +/// +/// The golden file owns case PAYLOADS, but its inventory (which cases exist, in which order) +/// is the contract this suite exists to lock: a probe call and its golden line removed +/// together would silently shrink coverage, and the golden-only dual-run comparison +/// structurally cannot detect that. This check lives outside the dual-run test class on +/// purpose — that class skips entirely when `GODOT_BIN` is missing, while this resource-only +/// assertion runs in every environment. +class PackedRefSemanticsGoldenInventoryTest { + + /// The reference-semantics behavior inventory (semantics matrix scenarios plus the dynamic + /// Variant receiver case, the mixed scenario, and the supplementary cases), in + /// `PackedRefProbes.run_all` emission order. + private static final List MATRIX_CASE_NAMES = List.of( + "LOCAL_ALIAS", // local alias sharing + "PARAM_VISIBILITY", // parameter mutation visible to the caller + "SCRIPT_PROPERTY", // script property mutation persists + "TYPED_ARRAY_ELEMENT", // typed Array element mutation persists + "DICT_VALUE", // Dictionary value mutation persists + "BUILTIN_PROPERTY_MUTATION", // builtin property getter mutation does not persist + "BUILTIN_PROPERTY_REASSIGN", // builtin property reassignment persists + "BUILTIN_PROPERTY_SUBSCRIPT_WRITE", // builtin property subscript write persists + "PLUS_EQUALS_REBIND", // += produces a new array and rebinds + "DUPLICATE", // duplicate() is an independent copy + "SIGNAL_ARG", // signal argument mutation visible to the emitter + "FOR_ITER", // live iteration visits appended elements + "APPEND_ARRAY_ALIAS", // append_array shared through aliases + "RESIZE_ALIAS", // resize shared through aliases + "INDEX_WRITE_ALIAS", // subscript write shared through aliases + "VARIANT_IDENTITY", // Variant round-trip preserves sharing + "PARAM_DEFAULT_SHARED", // parameter default materializes a fresh array per call + "ELEMENT_REBIND", // element slot rebinding keeps the old array alive + "STRING_ITER_ELEMENTS", // iteration elements are String copies + "IN_MEMBERSHIP", // `in` matches by content + "EQUALITY", // ==/!= compare by content + "DICT_KEY_HASH", // Dictionary keys hash by content + "AS_SAME_FAMILY", // same-family `as` is a COW copy (fresh identity) + "CORO_AWAIT", // coroutine mutations visible across await + "SIGNAL_MULTI", // multi-argument typed signal sharing + "DYNAMIC_VARIANT_MUTATION", // dynamic Variant receiver route + "STATIC_VAR", // static variable mutation persists + "LAMBDA_CAPTURE", // lambda capture sharing + "MIXED_COMBINATION", // mixed scenario: property + signal + lambda + coroutine + live iteration + "CONTROL_FLOW_BRANCHES", // complex if/elif/match branch mutation + "RETURN_VALUE_SHARING", // packed return-value identity forms + "ENGINE_METHOD_PACKED_ARG", // packed argument/return at engine methods + "STRING_ARRAY_MUTATION" // PackedStringArray mutation/equality + ); + + /// The golden must contain exactly the locked case inventory, in emission order — no + /// case silently dropped, no unregistered case added. Payload truth still lives only in + /// the golden file; this anchors coverage. + @Test + void goldenCoversExactlyTheLockedMatrixInventory() throws IOException { + assertEquals(MATRIX_CASE_NAMES, PackedRefSemanticsDualRunHarness.loadGolden().caseNames()); + } +} diff --git a/src/test/java/gd/script/gdcc/backend/c/build/packedref/ProbeGoldenComparison.java b/src/test/java/gd/script/gdcc/backend/c/build/packedref/ProbeGoldenComparison.java new file mode 100644 index 00000000..ef2177b3 --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/build/packedref/ProbeGoldenComparison.java @@ -0,0 +1,107 @@ +package gd.script.gdcc.backend.c.build.packedref; + +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; + +/// Result of comparing one dual-run side's {@link ProbeOutput} against the golden +/// {@link ProbeOutput}. The comparison is a strict full-matrix contract — the golden file is +/// the single source of truth for the case inventory, so EVERY golden case must be present in +/// the actual output with an equal payload: +/// +/// - missing cases (present in golden, absent from actual) indicate a crashed/truncated run; +/// - payload mismatches indicate a behavior divergence from the interpreter-locked baseline; +/// - unknown cases in the actual output (present in actual, absent from golden) indicate a +/// probe-library/golden desync; +/// - order violations indicate the actual run emitted known cases in a different relative +/// order than the golden, which breaks the deterministic `run_all` contract. +public record ProbeGoldenComparison( + @NotNull List missingCases, + @NotNull List payloadMismatches, + @NotNull List unknownActualCases, + @NotNull List orderViolations +) { + public ProbeGoldenComparison { + missingCases = List.copyOf(missingCases); + payloadMismatches = List.copyOf(payloadMismatches); + unknownActualCases = List.copyOf(unknownActualCases); + orderViolations = List.copyOf(orderViolations); + } + + public static @NotNull ProbeGoldenComparison compare( + @NotNull ProbeOutput actual, + @NotNull ProbeOutput golden + ) { + Objects.requireNonNull(actual, "actual must not be null"); + Objects.requireNonNull(golden, "golden must not be null"); + + var missingCases = new ArrayList(); + var payloadMismatches = new ArrayList(); + for (var caseName : golden.caseNames()) { + if (!actual.hasCase(caseName)) { + missingCases.add(caseName); + continue; + } + var expectedPayload = golden.requirePayload(caseName); + var actualPayload = actual.requirePayload(caseName); + if (!expectedPayload.equals(actualPayload)) { + payloadMismatches.add(caseName + ": expected [" + expectedPayload + "] but was [" + actualPayload + "]"); + } + } + + var goldenCaseNames = new LinkedHashSet<>(golden.caseNames()); + var unknownActualCases = actual.caseNames().stream() + .filter(caseName -> !goldenCaseNames.contains(caseName)) + .toList(); + + // Relative order of the cases present in both outputs must match the golden order; + // this catches control-flow breakage (e.g. a case silently skipped then re-run later). + var actualCaseNames = new LinkedHashSet<>(actual.caseNames()); + var expectedRelativeOrder = golden.caseNames().stream().filter(actualCaseNames::contains).toList(); + var actualRelativeOrder = actual.caseNames().stream().filter(goldenCaseNames::contains).toList(); + var orderViolations = expectedRelativeOrder.equals(actualRelativeOrder) + ? List.of() + : List.of("expected relative order " + expectedRelativeOrder + " but was " + actualRelativeOrder); + + return new ProbeGoldenComparison( + missingCases, + payloadMismatches, + unknownActualCases, + orderViolations + ); + } + + /// Whether every check passed (all four categories empty). + public boolean matches() { + return missingCases.isEmpty() + && payloadMismatches.isEmpty() + && unknownActualCases.isEmpty() + && orderViolations.isEmpty(); + } + + /// Multi-line human-readable summary for assertion failure messages. + public @NotNull String describe() { + if (matches()) { + return "probe output matches golden"; + } + var description = new StringBuilder("probe output diverges from golden:"); + appendSection(description, "missing cases", missingCases); + appendSection(description, "payload mismatches", payloadMismatches); + appendSection(description, "unknown actual cases", unknownActualCases); + appendSection(description, "order violations", orderViolations); + return description.toString(); + } + + private static void appendSection(@NotNull StringBuilder description, @NotNull String title, @NotNull List entries) { + if (entries.isEmpty()) { + return; + } + description.append(System.lineSeparator()).append(" ").append(title).append(":"); + for (var entry : entries) { + description.append(System.lineSeparator()).append(" - ").append(entry); + } + } +} diff --git a/src/test/java/gd/script/gdcc/backend/c/build/packedref/ProbeGoldenComparisonTest.java b/src/test/java/gd/script/gdcc/backend/c/build/packedref/ProbeGoldenComparisonTest.java new file mode 100644 index 00000000..18832643 --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/build/packedref/ProbeGoldenComparisonTest.java @@ -0,0 +1,102 @@ +package gd.script.gdcc.backend.c.build.packedref; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Anchors {@link ProbeGoldenComparison} full-matrix semantics from both directions: every +/// golden case must be present with an equal payload (no case can be silently skipped or +/// diverge), and structural checks (unknown cases, relative order) catch probe-library/golden +/// desync and control-flow breakage. +class ProbeGoldenComparisonTest { + + private static ProbeOutput output(String... lines) { + return ProbeOutput.parse(String.join("\n", lines) + "\n"); + } + + @Test + void matchesWhenAllCasesAlign() { + var golden = output("PROBE|A|1", "PROBE|B|2"); + var actual = output("PROBE|A|1", "PROBE|B|2"); + + var comparison = ProbeGoldenComparison.compare(actual, golden); + + assertTrue(comparison.matches(), comparison::describe); + } + + @Test + void reportsPayloadMismatchForAnyCase() { + // Full-matrix anchor: NO case is exempt — a divergence in any golden case must fail. + var golden = output("PROBE|A|1", "PROBE|B|2"); + var actual = output("PROBE|A|1", "PROBE|B|999"); + + var comparison = ProbeGoldenComparison.compare(actual, golden); + + assertFalse(comparison.matches()); + assertEquals(1, comparison.payloadMismatches().size()); + assertTrue(comparison.payloadMismatches().getFirst().contains("B")); + assertTrue(comparison.describe().contains("payload mismatches")); + } + + @Test + void reportsMissingCase() { + // Full-matrix anchor: a case silently skipped by the run must fail, wherever it sits. + var golden = output("PROBE|A|1", "PROBE|B|2"); + var actual = output("PROBE|A|1"); + + var comparison = ProbeGoldenComparison.compare(actual, golden); + + assertFalse(comparison.matches()); + assertEquals(java.util.List.of("B"), comparison.missingCases()); + } + + @Test + void reportsUnknownActualCase() { + var golden = output("PROBE|A|1"); + var actual = output("PROBE|A|1", "PROBE|UNEXPECTED|x"); + + var comparison = ProbeGoldenComparison.compare(actual, golden); + + assertFalse(comparison.matches()); + assertEquals(java.util.List.of("UNEXPECTED"), comparison.unknownActualCases()); + } + + @Test + void reportsOrderViolation() { + var golden = output("PROBE|A|1", "PROBE|B|2"); + var actual = output("PROBE|B|2", "PROBE|A|1"); + + var comparison = ProbeGoldenComparison.compare(actual, golden); + + assertFalse(comparison.matches()); + assertEquals(1, comparison.orderViolations().size()); + } + + @Test + void truncatedActualKeepsConsistentRelativeOrder() { + // A run that stopped early (crash) must not additionally report an order violation for + // the prefix it did emit; only the missing cases are reported. + var golden = output("PROBE|A|1", "PROBE|B|2"); + var actual = output("PROBE|A|1"); + + var comparison = ProbeGoldenComparison.compare(actual, golden); + + assertFalse(comparison.matches()); + assertTrue(comparison.orderViolations().isEmpty()); + assertEquals(java.util.List.of("B"), comparison.missingCases()); + } + + @Test + void emptyActualReportsEveryGoldenCaseMissing() { + var golden = output("PROBE|A|1", "PROBE|B|2"); + var actual = output("noise only, no probe lines"); + + var comparison = ProbeGoldenComparison.compare(actual, golden); + + assertFalse(comparison.matches()); + assertEquals(java.util.List.of("A", "B"), comparison.missingCases()); + assertTrue(comparison.unknownActualCases().isEmpty()); + } +} diff --git a/src/test/java/gd/script/gdcc/backend/c/build/packedref/ProbeOutput.java b/src/test/java/gd/script/gdcc/backend/c/build/packedref/ProbeOutput.java new file mode 100644 index 00000000..282ebfb3 --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/build/packedref/ProbeOutput.java @@ -0,0 +1,82 @@ +package gd.script.gdcc.backend.c.build.packedref; + +import org.jetbrains.annotations.NotNull; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; + +/// Parsed model of the `PROBE||` lines emitted by the packed-array reference +/// semantics probe library (`packed_ref_probes.gd`). Both sides of the dual-run harness +/// (Godot interpreter project and gdcc-compiled project) print these lines; the harness parses +/// raw process output into this model before any comparison happens. +/// +/// Strictness contract (anchored by `ProbeOutputTest`): +/// - only lines starting with `PROBE|` are considered; every other line is engine noise; +/// - a PROBE line must split into exactly three `|`-separated segments, the case name must +/// match `[A-Z0-9_]+`, and the payload must be non-empty (a malformed PROBE line indicates +/// a generator bug, so it is rejected instead of silently ignored); +/// - each case may appear at most once per output (emission order is preserved). +public final class ProbeOutput { + /// Line prefix recognized as probe output; everything else in a process transcript is noise. + public static final @NotNull String PREFIX = "PROBE|"; + + private final @NotNull LinkedHashMap payloadsByCase; + + private ProbeOutput(@NotNull LinkedHashMap payloadsByCase) { + this.payloadsByCase = payloadsByCase; + } + + /// Parses raw process output, throwing `IllegalArgumentException` on any malformed PROBE line. + public static @NotNull ProbeOutput parse(@NotNull String rawOutput) { + Objects.requireNonNull(rawOutput, "rawOutput must not be null"); + var payloadsByCase = new LinkedHashMap(); + var lineNumber = 0; + for (var line : rawOutput.lines().toList()) { + lineNumber++; + if (!line.startsWith(PREFIX)) { + continue; + } + var segments = line.split("\\|", -1); + if (segments.length != 3 || segments[1].isEmpty() || segments[2].isEmpty()) { + throw new IllegalArgumentException( + "Malformed PROBE line " + lineNumber + " (expected PROBE||): " + line); + } + var caseName = segments[1]; + if (!caseName.matches("[A-Z0-9_]+")) { + throw new IllegalArgumentException( + "Malformed PROBE case name at line " + lineNumber + " (expected [A-Z0-9_]+): " + line); + } + if (payloadsByCase.putIfAbsent(caseName, segments[2]) != null) { + throw new IllegalArgumentException( + "Duplicate PROBE case " + caseName + " at line " + lineNumber); + } + } + return new ProbeOutput(payloadsByCase); + } + + /// Case names in emission order. + public @NotNull List caseNames() { + return List.copyOf(payloadsByCase.keySet()); + } + + public boolean hasCase(@NotNull String caseName) { + return payloadsByCase.containsKey(Objects.requireNonNull(caseName, "caseName must not be null")); + } + + /// Returns the payload for `caseName`, throwing `IllegalArgumentException` when absent. + public @NotNull String requirePayload(@NotNull String caseName) { + var payload = payloadsByCase.get(Objects.requireNonNull(caseName, "caseName must not be null")); + if (payload == null) { + throw new IllegalArgumentException("Missing PROBE case " + caseName + " (present: " + payloadsByCase.keySet() + ")"); + } + return payload; + } + + /// Full `PROBE||` lines in emission order, for diagnostics and transcripts. + public @NotNull List probeLines() { + return payloadsByCase.entrySet().stream() + .map(entry -> PREFIX + entry.getKey() + "|" + entry.getValue()) + .toList(); + } +} diff --git a/src/test/java/gd/script/gdcc/backend/c/build/packedref/ProbeOutputTest.java b/src/test/java/gd/script/gdcc/backend/c/build/packedref/ProbeOutputTest.java new file mode 100644 index 00000000..cfaac9be --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/build/packedref/ProbeOutputTest.java @@ -0,0 +1,90 @@ +package gd.script.gdcc.backend.c.build.packedref; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Anchors the {@link ProbeOutput} parsing contract from both directions: well-formed probe +/// output must survive noise, and malformed PROBE lines must be rejected (they indicate +/// probe-generator bugs that would otherwise silently corrupt golden comparisons). +class ProbeOutputTest { + + @Test + void parsesProbeLinesInOrderIgnoringEngineNoise() { + var output = ProbeOutput.parse(""" + Godot Engine v4.5.2.stable.official.6ce3de25a - https://godotengine.org + PROBE|LOCAL_ALIAS|int=2,2;string=2,2 + some engine warning on stdout + PROBE|FOR_ITER|sum=6;size=4;mutation_visits=4 + """); + + assertEquals(List.of("LOCAL_ALIAS", "FOR_ITER"), output.caseNames()); + assertEquals("int=2,2;string=2,2", output.requirePayload("LOCAL_ALIAS")); + assertEquals( + List.of("PROBE|LOCAL_ALIAS|int=2,2;string=2,2", "PROBE|FOR_ITER|sum=6;size=4;mutation_visits=4"), + output.probeLines() + ); + } + + @Test + void toleratesCrLfAndBlankLines() { + var output = ProbeOutput.parse("\r\nPROBE|STATIC_VAR|2\r\n\r\n"); + + assertEquals(List.of("STATIC_VAR"), output.caseNames()); + assertEquals("2", output.requirePayload("STATIC_VAR")); + } + + @Test + void ignoresLookalikeLinesWithoutExactPrefix() { + // "PROBE" alone and other prefixes must be treated as noise, not as malformed input. + var output = ProbeOutput.parse("PROBE\nPROBEX|CASE|1\nprobe|CASE|1\n"); + + assertTrue(output.caseNames().isEmpty()); + } + + @Test + void rejectsMissingPayloadSegment() { + assertThrows(IllegalArgumentException.class, () -> ProbeOutput.parse("PROBE|ONLY_CASE\n")); + } + + @Test + void rejectsEmptyPayload() { + assertThrows(IllegalArgumentException.class, () -> ProbeOutput.parse("PROBE|CASE|\n")); + } + + @Test + void rejectsEmptyCaseName() { + assertThrows(IllegalArgumentException.class, () -> ProbeOutput.parse("PROBE||payload\n")); + } + + @Test + void rejectsCaseNameOutsideContractAlphabet() { + // Lowercase/mixed names break the registry/golden naming contract. + assertThrows(IllegalArgumentException.class, () -> ProbeOutput.parse("PROBE|mixedCase|1\n")); + } + + @Test + void rejectsPayloadContainingPipe() { + // The format is exactly three segments; a fourth segment means a generator bug. + assertThrows(IllegalArgumentException.class, () -> ProbeOutput.parse("PROBE|CASE|a|b\n")); + } + + @Test + void rejectsDuplicateCase() { + assertThrows( + IllegalArgumentException.class, + () -> ProbeOutput.parse("PROBE|CASE|1\nPROBE|CASE|2\n") + ); + } + + @Test + void requirePayloadFailsForAbsentCase() { + var output = ProbeOutput.parse("PROBE|CASE|1\n"); + + assertThrows(IllegalArgumentException.class, () -> output.requirePayload("OTHER")); + } +} diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/BuiltinCastInsnGenTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/BuiltinCastInsnGenTest.java index 1148141e..b6ddccc1 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/BuiltinCastInsnGenTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/BuiltinCastInsnGenTest.java @@ -35,6 +35,39 @@ /// Positive/negative codegen coverage for backend `builtin_cast`. class BuiltinCastInsnGenTest { + @Test + @DisplayName("same-family Packed*Array `as` emits whitelisted new_copy (COW copy, fresh identity)") + void sameFamilyPackedCastEmitsNewCopy() { + // `v as PackedInt32Array` must NOT share identity — the whitelisted same-family copy + // constructor produces an independent COW copy. The generic variant_construct path must + // not be used here. + var body = generate( + gd.script.gdcc.type.GdPackedNumericArrayType.PACKED_INT32_ARRAY, + "PackedInt32Array", + gd.script.gdcc.type.GdPackedNumericArrayType.PACKED_INT32_ARRAY + ); + assertTrue(body.contains("gdcc_packed_int32_array_new_copy(&$value)"), body); + assertFalse(body.contains("godot_variant_construct"), body); + assertFalse(body.contains("godot_new_PackedInt32Array"), body); + } + + @Test + @DisplayName("Variant-source packed `as` keeps the variant_construct conversion path") + void variantSourcePackedCastKeepsConstructPath() { + // A Variant source may hold any convertible payload at runtime, so the engine-side + // construct performs the checked conversion; the final packed unpack then shares the + // constructed (already fresh) identity into the target slot. + var body = generate( + GdVariantType.VARIANT, + "PackedInt32Array", + gd.script.gdcc.type.GdPackedNumericArrayType.PACKED_INT32_ARRAY + ); + assertTrue(body.contains("godot_variant_construct"), body); + assertTrue(body.contains("GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY"), body); + assertTrue(body.contains("gdcc_packed_ref_is("), body); + assertFalse(body.contains("gdcc_packed_int32_array_new_copy"), body); + } + @Test @DisplayName("int as float packs once and constructs with FLOAT enum") void intAsFloatEmitsConstruct() { diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/CCodegenTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/CCodegenTest.java index e801baaa..4df68f36 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/CCodegenTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/CCodegenTest.java @@ -50,6 +50,7 @@ import gd.script.gdcc.type.GdNodePathType; import gd.script.gdcc.type.GdObjectType; import gd.script.gdcc.type.GdPackedNumericArrayType; +import gd.script.gdcc.type.GdPackedStringArrayType; import gd.script.gdcc.type.GdRect2Type; import gd.script.gdcc.type.GdStringType; import gd.script.gdcc.type.GdStringNameType; @@ -1945,6 +1946,166 @@ public void generatesTypedArrayPropertyBindingMetadataAndKeepsGenericArrayPlain( assertFalse(genericBind.contains("godot_PROPERTY_HINT_ARRAY_TYPE"), genericBind); } + @Test + public void generatesPackedWrapperVariantCallBoundaryAndPtrcallMaterialization() throws Exception { + // The call_func boundary keeps identity (gate + Variant holder copy), while the + // ptrcall boundary materializes/copies through the whitelisted helpers (mutation + // isolation is intentional there). + var workerClass = new LirClassDef("PackedWrapperWorker", "Node"); + var echo = new LirFunctionDef("echo"); + echo.setReturnType(GdPackedNumericArrayType.PACKED_INT32_ARRAY); + echo.addParameter(new LirParameterDef("self", new GdObjectType("PackedWrapperWorker"), null, echo)); + echo.addParameter(new LirParameterDef("payload", GdPackedNumericArrayType.PACKED_INT32_ARRAY, null, echo)); + var entry = new LirBasicBlock("entry"); + entry.setTerminator(new ReturnInsn("payload")); + echo.addBasicBlock(entry); + echo.setEntryBlockId("entry"); + workerClass.addFunction(echo); + + var module = new LirModule("packed_wrapper_module", List.of(workerClass)); + var api = ExtensionApiLoader.loadDefault(); + var classRegistry = new ClassRegistry(api); + ProjectInfo projectInfo = new ProjectInfo("test", GodotVersion.V451, Path.of(".")) { + }; + var ctx = new CodegenContext(projectInfo, classRegistry); + + var codegen = new CCodegen(); + codegen.prepare(ctx, module); + var files = codegen.generate(); + var hCode = generatedFileText(files, "entry.h"); + var bindName = "_1_arg_PackedInt32Array_ret_PackedInt32Array"; + + // call_func wrapper: exact type gate, then identity-sharing Variant holder copy in/out. + var callBody = resolveCallWrapperBody(hCode, bindName); + assertContainsAll( + callBody, + "arg0_type == GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY", + "godot_Variant arg0 = godot_new_Variant_with_Variant(", + "godot_Variant r = function(self_fat, &arg0);", + "godot_Variant ret = godot_new_Variant_with_Variant(&r);", + "godot_variant_new_copy(r_return, &ret);", + "godot_Variant_destroy(&r);", + "godot_Variant_destroy(&arg0);" + ); + + // ptrcall wrapper (identity-isolation exception): raw struct slot -> Variant materialization inbound, + // Variant -> raw struct copy outbound; both materialized values are wrapper-owned. + var ptrcallBody = resolveFunctionBodyByPrefix(hCode, resolveOwnedWrapperPrefix(hCode, "static void ptrcall", bindName)); + assertContainsAll( + ptrcallBody, + "godot_Variant arg0 = gdcc_packed_int32_array_variant_from_struct((const godot_PackedInt32Array *)p_args[0]);", + "godot_Variant r = function(self_fat, &arg0);", + "*((godot_PackedInt32Array *)r_return) = gdcc_packed_int32_array_struct_from_variant(&r);", + "godot_Variant_destroy(&r);", + "godot_Variant_destroy(&arg0);" + ); + // Order is load-bearing, not cosmetic: the arg must be materialized BEFORE the call, the + // return written out BEFORE the callee Variant is destroyed, and the arg destroyed LAST. + assertOrdered( + ptrcallBody, + "gdcc_packed_int32_array_variant_from_struct", + "godot_Variant r = function(self_fat, &arg0);", + "gdcc_packed_int32_array_struct_from_variant(&r)", + "godot_Variant_destroy(&r);", + "godot_Variant_destroy(&arg0);" + ); + + // Generated-code prohibition (the whitelist constraint): business emission artifacts may + // only cross the struct<->Variant boundary through whitelisted helpers; scan every + // generated file and treat any hit as a violation. + var bannedPatterns = List.of( + "godot_new_Packed\\w*Array_with_\\w+\\(", + "godot_new_Variant_with_Packed\\w*Array\\(", + "godot_new_Packed\\w*Array\\(\\)" + ); + for (var file : files) { + var text = new String(file.contentWriter()); + for (var banned : bannedPatterns) { + assertFalse( + java.util.regex.Pattern.compile(banned).matcher(text).find(), + () -> "Banned packed struct<->Variant symbol `" + banned + "` in generated " + file.filePath() + ); + } + } + } + + @Test + public void generatesEngineMethodHelperPackedArgMaterializationAndReturnWrap() throws Exception { + // Outbound engine-method boundary: a packed ARGUMENT must be materialized into a + // helper-owned native struct slot + // (whitelist (a) struct_from_variant) before ptrcall and destroyed after — the + // caller's internal pointer must never reach the engine args array, or the engine + // would share/mutate the caller's array identity; a packed RETURN must arrive in a + // raw slot and be wrapped into the Variant carrier via whitelist (c) wrap_temp. + var workerClass = new LirClassDef("EnginePackedBoundaryWorker", "RefCounted"); + + var callSet = newFunction("call_set_data", GdVoidType.VOID); + callSet.addParameter(new LirParameterDef("peer", new GdObjectType("StreamPeerBuffer"), null, callSet)); + callSet.createAndAddVariable("bytes", GdPackedNumericArrayType.PACKED_BYTE_ARRAY); + entry(callSet).appendInstruction(new CallMethodInsn(null, "set_data_array", "peer", List.of(varOperand("bytes")))); + entry(callSet).setTerminator(new ReturnInsn(null)); + workerClass.addFunction(callSet); + + var callGet = newFunction("call_get_data", GdVoidType.VOID); + callGet.addParameter(new LirParameterDef("peer", new GdObjectType("StreamPeerBuffer"), null, callGet)); + callGet.createAndAddVariable("read_back", GdPackedNumericArrayType.PACKED_BYTE_ARRAY); + entry(callGet).appendInstruction(new CallMethodInsn("read_back", "get_data_array", "peer", List.of())); + entry(callGet).setTerminator(new ReturnInsn(null)); + workerClass.addFunction(callGet); + + var module = new LirModule("engine_packed_boundary_module", List.of(workerClass)); + var classRegistry = new ClassRegistry(ExtensionApiLoader.loadDefault()); + ProjectInfo projectInfo = new ProjectInfo("test", GodotVersion.V451, Path.of(".")) { + }; + var ctx = new CodegenContext(projectInfo, classRegistry); + var codegen = new CCodegen(); + codegen.prepare(ctx, module); + var files = codegen.generate(); + var bindHeaderCode = generatedFileText(files, "engine_method_binds.h"); + + // Outbound packed argument: helper-owned native slot materialization -> temp slot in + // the args array -> ptrcall -> destroy. The positional assertions are load-bearing: + // after the args-decl anchor, `&arg0_packed` can only be the array element (the + // destroy comes later), so the temp slot is proven to be passed INTO the call rather + // than merely existing in the helper body. + var setHelperBody = resolveFunctionBodyByPrefix(bindHeaderCode, "gdcc_engine_call_streampeerbuffer_set_data_array"); + assertContainsAll( + setHelperBody, + "godot_PackedByteArray arg0_packed = gdcc_packed_byte_array_struct_from_variant(", + "godot_object_method_bind_ptrcall(", + "godot_PackedByteArray_destroy(&arg0_packed);" + ); + assertOrdered( + setHelperBody, + "gdcc_packed_byte_array_struct_from_variant", + "const GDExtensionConstTypePtr args[]", + "&arg0_packed", + "godot_object_method_bind_ptrcall(", + "godot_PackedByteArray_destroy(&arg0_packed);" + ); + assertFalse(setHelperBody.contains("internal_ptr"), setHelperBody); + + // Packed return: the raw slot receives the ptrcall result and is then wrapped via + // wrap_temp into the Variant-backed carrier. After the ptrcall anchor, `&result_raw` + // can only be the return-slot argument, so the slot is proven to be passed INTO the + // call rather than only appearing at the wrap site. + var getHelperBody = resolveFunctionBodyByPrefix(bindHeaderCode, "gdcc_engine_call_streampeerbuffer_get_data_array"); + assertContainsAll( + getHelperBody, + "godot_PackedByteArray result_raw = { 0 };", + "godot_object_method_bind_ptrcall(", + "gdcc_packed_byte_array_wrap_temp(&result_raw)" + ); + assertOrdered( + getHelperBody, + "godot_PackedByteArray result_raw = { 0 };", + "godot_object_method_bind_ptrcall(", + "&result_raw", + "gdcc_packed_byte_array_wrap_temp(&result_raw)" + ); + assertFalse(getHelperBody.contains("internal_ptr"), getHelperBody); + } + @Test public void generatesTypedArrayCallWrapperPreflightAndKeepsGenericArrayOnBaseGate() throws Exception { var workerClass = new LirClassDef("TypedArrayCallGuardWorker", "Node"); @@ -3330,6 +3491,40 @@ public void lambdaSchemaDescriptorShouldTrackCaptureAndSignatureLayout() throws ); } + /// Variant-backed packed storage makes every packed family share the `godot_Variant` C + /// spelling; the schema must still + /// distinguish them (and Variant itself) via the semantic type name, or a hot reload would + /// rebind a stale holder into a different family's internal-pointer getter (engine-level UB). + @Test + public void lambdaSchemaDescriptorDistinguishesPackedFamiliesAndVariant() throws java.io.IOException { + var int32Capture = buildSchemaProbeLambda(GdPackedNumericArrayType.PACKED_INT32_ARRAY, GdPackedNumericArrayType.PACKED_INT32_ARRAY); + var stringCapture = buildSchemaProbeLambda(GdPackedStringArrayType.PACKED_STRING_ARRAY, GdPackedNumericArrayType.PACKED_INT32_ARRAY); + var variantCapture = buildSchemaProbeLambda(GdVariantType.VARIANT, GdPackedNumericArrayType.PACKED_INT32_ARRAY); + var variantReturn = buildSchemaProbeLambda(GdPackedNumericArrayType.PACKED_INT32_ARRAY, GdVariantType.VARIANT); + var int32CaptureAgain = buildSchemaProbeLambda(GdPackedNumericArrayType.PACKED_INT32_ARRAY, GdPackedNumericArrayType.PACKED_INT32_ARRAY); + + assertNotEquals( + int32Capture.identities().getFirst().schemaBytes(), + stringCapture.identities().getFirst().schemaBytes(), + "packed family swap in a capture must change the schema fingerprint" + ); + assertNotEquals( + int32Capture.identities().getFirst().schemaBytes(), + variantCapture.identities().getFirst().schemaBytes(), + "packed -> Variant capture swap must change the schema fingerprint" + ); + assertNotEquals( + int32Capture.identities().getFirst().schemaBytes(), + variantReturn.identities().getFirst().schemaBytes(), + "packed -> Variant return swap must change the schema fingerprint" + ); + assertEquals( + int32Capture.identities().getFirst().schemaBytes(), + int32CaptureAgain.identities().getFirst().schemaBytes(), + "identical packed layouts must share the descriptor (rebind-compatible)" + ); + } + /// ABI contract: the Java schema-descriptor prefix must derive from the SAME version the /// C runtime guards on — otherwise bumping only the C macro would leave descriptors /// matching and silently void the abi_version guard. Pin both sides here: the C header diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/CConstructInsnGenEngineTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/CConstructInsnGenEngineTest.java index ac775fa8..92904686 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/CConstructInsnGenEngineTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/CConstructInsnGenEngineTest.java @@ -988,7 +988,10 @@ private String prepareMethodName() { } private String constructorCall() { - return "godot_new_" + builtinName + "()"; + // Whitelist (b) empty-array Variant construction: the label is `packed array`, + // the helper is `gdcc_packed__array_new_empty()`. + var slug = label.replace("packed ", "").replace(" array", "").replace(' ', '_'); + return "gdcc_packed_" + slug + "_array_new_empty()"; } private String checkMethodName() { 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 da8a6d82..86eadac3 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 @@ -413,13 +413,104 @@ void constructArrayShouldEmitPackedCtorWhenClassNameOmitted() { clazz.addFunction(func); var body = generateBody(clazz, func); - assertTrue(body.contains("godot_new_PackedInt32Array()")); + // Whitelist (b) empty-array Variant construction; the bare struct + // constructor must no longer appear in emitted business code. + assertTrue(body.contains("$packed = gdcc_packed_int32_array_new_empty();"), body); + assertFalse(body.contains("godot_new_PackedInt32Array()"), body); } @Test - @DisplayName("construct_array should reject class_name when result type is Packed*Array") - void constructArrayShouldRejectClassNameForPackedArray() { + @DisplayName("construct_builtin Packed*Array with same-family argument emits whitelisted new_copy") + void constructPackedArrayWithSameFamilyArgEmitsNewCopy() { + // Whitelist (d): explicit same-family construction is an independent COW copy with a + // fresh identity (equivalent to `duplicate()`), emitted through gdcc_packed_ref.h — + // never the native copy constructor. var clazz = newTestClass(); + var func = newFunction("construct_packed_copy"); + func.createAndAddVariable("packed", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + func.createAndAddVariable("other", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + entry(func).appendInstruction(new ConstructBuiltinInsn( + "packed", + List.of(new LirInstruction.VariableOperand("other")) + )); + clazz.addFunction(func); + + var body = generateBody(clazz, func, apiWithFullPackedConstructors()); + assertTrue(body.contains("$packed = gdcc_packed_int32_array_new_copy(&$other);"), body); + assertFalse(body.contains("godot_new_PackedInt32Array"), body); + } + + @Test + @DisplayName("construct_builtin Packed*Array with Array argument emits whitelisted new_from_array") + void constructPackedArrayWithArrayArgEmitsNewFromArray() { + // Whitelist (d) cross-type construction: `PackedInt32Array([1, 2])` converts the Array + // argument into a fresh packed array. + var clazz = newTestClass(); + var func = newFunction("construct_packed_from_array"); + func.createAndAddVariable("packed", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + func.createAndAddVariable("source", new GdArrayType(GdVariantType.VARIANT)); + entry(func).appendInstruction(new ConstructBuiltinInsn( + "packed", + List.of(new LirInstruction.VariableOperand("source")) + )); + clazz.addFunction(func); + + var body = generateBody(clazz, func, apiWithFullPackedConstructors()); + assertTrue(body.contains("$packed = gdcc_packed_int32_array_new_from_array(&$source);"), body); + assertFalse(body.contains("godot_new_PackedInt32Array"), body); + } + + @Test + @DisplayName("construct_builtin Packed*Array with cross-family argument fails fast (no such constructor)") + void constructPackedArrayWithCrossFamilyArgFailsFast() { + // PackedInt32Array(PackedFloat64Array) has no metadata constructor; the whitelist must + // fail closed instead of emitting an unsound conversion. + var clazz = newTestClass(); + var func = newFunction("construct_packed_cross_family"); + func.createAndAddVariable("packed", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + func.createAndAddVariable("other", GdPackedNumericArrayType.PACKED_FLOAT64_ARRAY); + entry(func).appendInstruction(new ConstructBuiltinInsn( + "packed", + List.of(new LirInstruction.VariableOperand("other")) + )); + clazz.addFunction(func); + + var ex = assertThrows( + InvalidInsnException.class, + () -> generateBody(clazz, func, apiWithFullPackedConstructors()) + ); + assertTrue(ex.getMessage().contains("is not defined in ExtensionBuiltinClass"), ex.getMessage()); + } + + @Test + @DisplayName("construct_builtin Array with packed argument adapts to the internal pointer") + void constructArrayWithPackedArgAdaptsToInternalPointer() { + // Non-packed constructor targets keep the native wrapper ABI: `Array(packed)` calls + // `godot_new_Array_with_PackedInt32Array(const godot_PackedInt32Array *)`, so the + // Variant-backed argument renders its internal pointer. + var clazz = newTestClass(); + var func = newFunction("construct_array_from_packed"); + func.createAndAddVariable("array", new GdArrayType(GdVariantType.VARIANT)); + func.createAndAddVariable("packed", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + entry(func).appendInstruction(new ConstructBuiltinInsn( + "array", + List.of(new LirInstruction.VariableOperand("packed")) + )); + clazz.addFunction(func); + + var body = generateBody(clazz, func, apiWithFullPackedConstructors()); + // The constructor result moves into the target through the owned-carrier overwrite + // (construct new -> destroy old -> move), so the assertion anchors the adapted call. + assertTrue( + body.contains("godot_new_Array_with_PackedInt32Array(gdcc_packed_int32_array_internal_ptr(&$packed));"), + body + ); + assertTrue(body.contains("$array = __gdcc_tmp_owned_move_"), body); + } + + @Test + @DisplayName("construct_array should reject class_name when result type is Packed*Array") + void constructArrayShouldRejectClassNameForPackedArray() { var clazz = newTestClass(); var func = newFunction("construct_packed_array_with_class_name"); func.createAndAddVariable("packed", GdPackedNumericArrayType.PACKED_INT32_ARRAY); @@ -1251,7 +1342,8 @@ void generatedPreparePackedConstructShouldEmitPackedConstructorCall() { var body = codegen.generateFuncBody(clazz, func); assertTrue(body.contains("__prepare__: // __prepare__")); - assertTrue(body.contains("godot_new_PackedInt32Array()")); + assertTrue(body.contains("gdcc_packed_int32_array_new_empty()"), body); + assertFalse(body.contains("godot_new_PackedInt32Array"), body); } @Test @@ -1533,17 +1625,22 @@ private LirFunctionDef findFunctionByName(LirClassDef clazz, String functionName return null; } + private record PackedCtorCase(String label, String typeName, GdType type, String constructorCall) { + } + + /// Packed construction targets the Variant slot through the whitelisted empty-array helper, + /// never a bare `godot_new_Packed*()`. private List packedCtorCases() { return List.of( - new PackedCtorCase("packed_byte", "PackedByteArray", GdPackedNumericArrayType.PACKED_BYTE_ARRAY), - new PackedCtorCase("packed_int32", "PackedInt32Array", GdPackedNumericArrayType.PACKED_INT32_ARRAY), - new PackedCtorCase("packed_int64", "PackedInt64Array", GdPackedNumericArrayType.PACKED_INT64_ARRAY), - new PackedCtorCase("packed_float32", "PackedFloat32Array", GdPackedNumericArrayType.PACKED_FLOAT32_ARRAY), - new PackedCtorCase("packed_float64", "PackedFloat64Array", GdPackedNumericArrayType.PACKED_FLOAT64_ARRAY), - new PackedCtorCase("packed_string", "PackedStringArray", GdPackedStringArrayType.PACKED_STRING_ARRAY), - new PackedCtorCase("packed_vector2", "PackedVector2Array", GdPackedVectorArrayType.PACKED_VECTOR2_ARRAY), - new PackedCtorCase("packed_vector3", "PackedVector3Array", GdPackedVectorArrayType.PACKED_VECTOR3_ARRAY), - new PackedCtorCase("packed_vector4", "PackedVector4Array", GdPackedVectorArrayType.PACKED_VECTOR4_ARRAY) + new PackedCtorCase("packed_byte", "PackedByteArray", GdPackedNumericArrayType.PACKED_BYTE_ARRAY, "gdcc_packed_byte_array_new_empty()"), + new PackedCtorCase("packed_int32", "PackedInt32Array", GdPackedNumericArrayType.PACKED_INT32_ARRAY, "gdcc_packed_int32_array_new_empty()"), + new PackedCtorCase("packed_int64", "PackedInt64Array", GdPackedNumericArrayType.PACKED_INT64_ARRAY, "gdcc_packed_int64_array_new_empty()"), + new PackedCtorCase("packed_float32", "PackedFloat32Array", GdPackedNumericArrayType.PACKED_FLOAT32_ARRAY, "gdcc_packed_float32_array_new_empty()"), + new PackedCtorCase("packed_float64", "PackedFloat64Array", GdPackedNumericArrayType.PACKED_FLOAT64_ARRAY, "gdcc_packed_float64_array_new_empty()"), + new PackedCtorCase("packed_string", "PackedStringArray", GdPackedStringArrayType.PACKED_STRING_ARRAY, "gdcc_packed_string_array_new_empty()"), + new PackedCtorCase("packed_vector2", "PackedVector2Array", GdPackedVectorArrayType.PACKED_VECTOR2_ARRAY, "gdcc_packed_vector2_array_new_empty()"), + new PackedCtorCase("packed_vector3", "PackedVector3Array", GdPackedVectorArrayType.PACKED_VECTOR3_ARRAY, "gdcc_packed_vector3_array_new_empty()"), + new PackedCtorCase("packed_vector4", "PackedVector4Array", GdPackedVectorArrayType.PACKED_VECTOR4_ARRAY, "gdcc_packed_vector4_array_new_empty()") ); } @@ -1611,6 +1708,41 @@ private ExtensionAPI apiWithPackedConstructors() { ); } + /// The exact constructor set Godot 4.5 exposes for a packed family: `()`, `(same family)`, + /// `(Array)` — mirrored by the `gdcc_packed_ref.h` whitelist (b)/(d) helpers. The `Array` + /// builtin additionally gets a `(PackedInt32Array)` constructor for the `Array(packed)` case. + private ExtensionAPI apiWithFullPackedConstructors() { + var builtins = new ArrayList(); + for (var typeName : List.of("PackedInt32Array", "PackedFloat64Array")) { + builtins.add(newBuiltinClass( + typeName, + List.of( + new ExtensionBuiltinClass.ConstructorInfo(typeName, 0, List.of()), + newConstructor(typeName, typeName), + newConstructor(typeName, "Array") + ) + )); + } + builtins.add(newBuiltinClass( + "Array", + List.of( + new ExtensionBuiltinClass.ConstructorInfo("Array", 0, List.of()), + newConstructor("Array", "PackedInt32Array") + ) + )); + return new ExtensionAPI( + null, + List.of(), + List.of(), + List.of(), + List.of(), + builtins, + List.of(), + List.of(), + List.of() + ); + } + private ExtensionAPI apiWithStringFamilyConstructors() { return apiWithBuiltins(List.of( newBuiltinClass( @@ -1744,12 +1876,6 @@ private ExtensionBuiltinClass.ConstructorInfo newConstructor(String owner, Strin ); } - private record PackedCtorCase(String label, String typeName, GdType type) { - private String constructorCall() { - return "godot_new_" + typeName + "()"; - } - } - private record FlatFloatHelperCtorCase( String label, GdType targetType, diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/COperatorInsnGenTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/COperatorInsnGenTest.java index c1d6ea12..1137c04b 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/COperatorInsnGenTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/COperatorInsnGenTest.java @@ -24,6 +24,7 @@ import gd.script.gdcc.type.GdDictionaryType; import gd.script.gdcc.type.GdNilType; import gd.script.gdcc.type.GdObjectType; +import gd.script.gdcc.type.GdPackedNumericArrayType; import gd.script.gdcc.type.GdStringType; import gd.script.gdcc.type.GdType; import gd.script.gdcc.type.GdVariantType; @@ -391,6 +392,73 @@ void binaryNonCompareBuiltinEmitsEvaluatorCall() { assertTrue(body.contains("$result = gdcc_eval_binary_in_int_int_to_bool($left, $right);"), body); } + @Test + @DisplayName("int in PackedInt32Array keeps scalar left by value and passes packed right internal pointer") + void packedMixedInKeepsScalarByValueAndPassesPackedInternalPointer() { + // Per-operand ABI: the scalar left stays by-value while the packed right renders the + // Variant internal pointer. + var body = generateBody( + packedInApi(), + new BinaryOpInsn("result", GodotOperator.IN, "left", "right"), + List.of( + new VariableSpec("left", GdIntType.INT, false), + new VariableSpec("right", GdPackedNumericArrayType.PACKED_INT32_ARRAY, false), + new VariableSpec("result", GdBoolType.BOOL, false) + ) + ); + + assertTrue(body.contains( + "$result = gdcc_eval_binary_in_int_packedint32array_to_bool($left, gdcc_packed_int32_array_internal_ptr(&$right));"), + body); + assertFalse(body.contains("gdcc_eval_binary_in_int_packedint32array_to_bool($left, &"), body); + } + + @Test + @DisplayName("Packed*Array == passes both operands as internal pointers (content equality)") + void packedEqualityPassesInternalPointersForBothOperands() { + // `==` is content equality evaluated by the engine on the shared arrays. + var body = generateBody( + packedCompareApi(), + new BinaryOpInsn("result", GodotOperator.EQUAL, "left", "right"), + List.of( + new VariableSpec("left", GdPackedNumericArrayType.PACKED_INT32_ARRAY, false), + new VariableSpec("right", GdPackedNumericArrayType.PACKED_INT32_ARRAY, false), + new VariableSpec("result", GdBoolType.BOOL, false) + ) + ); + + assertTrue(body.contains( + "$result = gdcc_eval_binary_equal_packedint32array_packedint32array_to_bool(" + + "gdcc_packed_int32_array_internal_ptr(&$left), gdcc_packed_int32_array_internal_ptr(&$right));"), + body); + } + + @Test + @DisplayName("Packed*Array + receives a fresh raw struct and wraps it into the Variant slot") + void packedAddReceivesRawStructAndWrapsThroughWrapTemp() { + // `+` must produce a NEW array (rebind semantics for `+=`); in-place + // append on an internal pointer would leak the mutation to existing aliases. The evaluator + // returns a native struct that the call site wraps via whitelist (c) `wrap_temp`. + var body = generateBody( + packedAddApi(), + new BinaryOpInsn("result", GodotOperator.ADD, "left", "right"), + List.of( + new VariableSpec("left", GdPackedNumericArrayType.PACKED_INT32_ARRAY, false), + new VariableSpec("right", GdPackedNumericArrayType.PACKED_INT32_ARRAY, false), + new VariableSpec("result", GdPackedNumericArrayType.PACKED_INT32_ARRAY, false) + ) + ); + + assertTrue(body.matches("(?s).*godot_PackedInt32Array __gdcc_tmp_packed_native_ret_\\d+ = " + + "gdcc_eval_binary_add_packedint32array_packedint32array_to_packedint32array\\(" + + "gdcc_packed_int32_array_internal_ptr\\(&\\$left\\), " + + "gdcc_packed_int32_array_internal_ptr\\(&\\$right\\)\\);.*"), body); + assertTrue(body.matches("(?s).*gdcc_packed_int32_array_wrap_temp\\(&__gdcc_tmp_packed_native_ret_\\d+\\).*"), body); + assertTrue(body.matches("(?s).*\\$result = __gdcc_tmp_owned_move_\\d+;.*"), body); + assertFalse(body.contains("godot_new_PackedInt32Array"), body); + assertFalse(body.contains("godot_new_Variant_with_PackedInt32Array"), body); + } + @Test @DisplayName("binary metadata lookup should skip malformed entries and keep valid match") void binaryMetadataLookupSkipsMalformedEntries() { @@ -1187,8 +1255,92 @@ void moduleStringVariantUsesVariantEvaluateWithUnpackTypeCheck() { ); } - private @NotNull ExtensionAPI evaluatorIntApi() { + private @NotNull ExtensionAPI packedInApi() { var intBuiltin = new ExtensionBuiltinClass( + "int", + false, + List.of(new ExtensionBuiltinClass.ClassOperator("in", "PackedInt32Array", "bool")), + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + var packedBuiltin = new ExtensionBuiltinClass( + "PackedInt32Array", + false, + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + return new ExtensionAPI( + null, + List.of(), + List.of(), + List.of(), + List.of(), + List.of(intBuiltin, packedBuiltin), + List.of(), + List.of(), + List.of() + ); + } + + private @NotNull ExtensionAPI packedCompareApi() { + var packedBuiltin = new ExtensionBuiltinClass( + "PackedInt32Array", + false, + List.of( + new ExtensionBuiltinClass.ClassOperator("==", "PackedInt32Array", "bool"), + new ExtensionBuiltinClass.ClassOperator("!=", "PackedInt32Array", "bool") + ), + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + return new ExtensionAPI( + null, + List.of(), + List.of(), + List.of(), + List.of(), + List.of(packedBuiltin), + List.of(), + List.of(), + List.of() + ); + } + + private @NotNull ExtensionAPI packedAddApi() { + var packedBuiltin = new ExtensionBuiltinClass( + "PackedInt32Array", + false, + List.of(new ExtensionBuiltinClass.ClassOperator("+", "PackedInt32Array", "PackedInt32Array")), + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + return new ExtensionAPI( + null, + List.of(), + List.of(), + List.of(), + List.of(), + List.of(packedBuiltin), + List.of(), + List.of(), + List.of() + ); + } + + private @NotNull ExtensionAPI evaluatorIntApi() { var intBuiltin = new ExtensionBuiltinClass( "int", false, List.of( diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/CPackUnpackVariantInsnGenTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/CPackUnpackVariantInsnGenTest.java index 75e2b844..dae58a27 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/CPackUnpackVariantInsnGenTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/CPackUnpackVariantInsnGenTest.java @@ -37,6 +37,61 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class CPackUnpackVariantInsnGenTest { + @Test + @DisplayName("pack_variant of Packed*Array is an identity-sharing Variant holder copy") + void packPackedArraySharesIdentityThroughVariantHolderCopy() { + // pack never crosses the struct boundary; the Variant copy shares the underlying array + // (three-way sharing). + var body = generatePackedBody(true); + assertTrue(body.contains("$variant = godot_new_Variant_with_Variant(&$packed);"), body); + assertFalse(body.contains("godot_new_Variant_with_PackedInt32Array"), body); + } + + @Test + @DisplayName("unpack_variant to Packed*Array emits exact-kind share / Array conversion / error branches") + void unpackPackedArrayEmitsCheckedShareAndArrayConversion() { + // Unpack contract: exact-kind payload shares identity; Array payload converts + // through whitelist (d) `new_from_array` (independent array, interpreter-anchored); + // anything else is a runtime type error. No struct unpack helper may appear. + var body = generatePackedBody(false); + assertTrue(body.contains( + "if (gdcc_packed_ref_is(&$variant, GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY)) {"), body); + // Carrier-first overwrite: the identity-sharing copy is materialized BEFORE the old slot + // is destroyed, so the branch stays correct even under source/target aliasing. + assertTrue(body.contains(" = godot_new_Variant_with_Variant(&$variant);"), body); + var copyIndex = body.indexOf("godot_new_Variant_with_Variant(&$variant)"); + var destroyIndex = body.indexOf("godot_Variant_destroy(&$packed);"); + assertTrue(copyIndex >= 0 && destroyIndex >= 0 && copyIndex < destroyIndex, + "holder copy must be produced before the old slot is destroyed:\n" + body); + assertTrue(body.contains("godot_variant_get_type(&$variant) == GDEXTENSION_VARIANT_TYPE_ARRAY"), body); + assertTrue(body.contains("gdcc_packed_int32_array_new_from_array(&"), body); + assertTrue(body.contains("GDCC_PRINT_RUNTIME_ERROR"), body); + assertFalse(body.contains("godot_new_PackedInt32Array_with_Variant"), body); + } + + /// Builds `entry` with one PackVariantInsn (packed -> variant) or UnpackVariantInsn + /// (variant -> packed) between a PackedInt32Array local and a Variant local. + private @org.jetbrains.annotations.NotNull String generatePackedBody(boolean packDirection) { + var workerClass = new LirClassDef("Worker", "RefCounted", false, false, Map.of(), List.of(), List.of(), List.of()); + var func = new LirFunctionDef(packDirection ? "pack_packed" : "unpack_packed"); + func.setReturnType(GdVoidType.VOID); + func.createAndAddVariable("packed", gd.script.gdcc.type.GdPackedNumericArrayType.PACKED_INT32_ARRAY); + func.createAndAddVariable("variant", GdVariantType.VARIANT); + + var entry = new LirBasicBlock("entry"); + entry.appendInstruction(packDirection + ? new PackVariantInsn("variant", "packed") + : new UnpackVariantInsn("packed", "variant")); + entry.appendInstruction(new ReturnInsn(null)); + func.addBasicBlock(entry); + func.setEntryBlockId("entry"); + workerClass.addFunction(func); + + var module = new LirModule("test_module", List.of(workerClass)); + var codegen = newCodegen(module, emptyApi(), List.of(workerClass)); + return codegen.generateFuncBody(workerClass, func); + } + @Test @DisplayName("unpack_variant to String should use assignment semantics") void unpackVariantToStringShouldUseAssignmentSemantics() { diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/CallGlobalInsnGenEngineTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/CallGlobalInsnGenEngineTest.java index 621696bf..72fea924 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/CallGlobalInsnGenEngineTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/CallGlobalInsnGenEngineTest.java @@ -23,6 +23,7 @@ import gd.script.gdcc.scope.ClassRegistry; import gd.script.gdcc.type.GdArrayType; import gd.script.gdcc.type.GdBoolType; +import gd.script.gdcc.type.GdColorType; import gd.script.gdcc.type.GdDictionaryType; import gd.script.gdcc.type.GdFloatType; import gd.script.gdcc.type.GdFloatVectorType; @@ -30,6 +31,8 @@ import gd.script.gdcc.type.GdIntVectorType; import gd.script.gdcc.type.GdObjectType; import gd.script.gdcc.type.GdPackedNumericArrayType; +import gd.script.gdcc.type.GdPackedStringArrayType; +import gd.script.gdcc.type.GdPackedVectorArrayType; import gd.script.gdcc.type.GdStringType; import gd.script.gdcc.type.GdType; import gd.script.gdcc.type.GdVariantType; @@ -154,7 +157,21 @@ void callGlobalVariantWritebackHelperShouldMatchRuntimeFamilyMatrix() throws IOE assertTrue(combinedOutput.contains("helper string true check passed."), "String should require writeback.\nOutput:\n" + combinedOutput); assertTrue(combinedOutput.contains("helper vector2 true check passed."), "Vector2 should require writeback.\nOutput:\n" + combinedOutput); assertTrue(combinedOutput.contains("helper vector3i true check passed."), "Vector3i should require writeback.\nOutput:\n" + combinedOutput); - assertTrue(combinedOutput.contains("helper packed array true check passed."), "PackedInt32Array should require writeback.\nOutput:\n" + combinedOutput); + assertTrue(combinedOutput.contains("helper vector4 true check passed."), "Vector4 should require writeback.\nOutput:\n" + combinedOutput); + assertTrue(combinedOutput.contains("helper color true check passed."), "Color should require writeback.\nOutput:\n" + combinedOutput); + // All ten packed kinds share the engine-side array identity with their owner slot, so the + // runtime gate must skip the writeback for each of them (including PackedVector4Array, + // which historically leaked into the default-true branch). + assertTrue(combinedOutput.contains("helper packed byte false check passed."), "PackedByteArray should skip writeback.\nOutput:\n" + combinedOutput); + assertTrue(combinedOutput.contains("helper packed int32 false check passed."), "PackedInt32Array should skip writeback.\nOutput:\n" + combinedOutput); + assertTrue(combinedOutput.contains("helper packed int64 false check passed."), "PackedInt64Array should skip writeback.\nOutput:\n" + combinedOutput); + assertTrue(combinedOutput.contains("helper packed float32 false check passed."), "PackedFloat32Array should skip writeback.\nOutput:\n" + combinedOutput); + assertTrue(combinedOutput.contains("helper packed float64 false check passed."), "PackedFloat64Array should skip writeback.\nOutput:\n" + combinedOutput); + assertTrue(combinedOutput.contains("helper packed string false check passed."), "PackedStringArray should skip writeback.\nOutput:\n" + combinedOutput); + assertTrue(combinedOutput.contains("helper packed vector2 false check passed."), "PackedVector2Array should skip writeback.\nOutput:\n" + combinedOutput); + assertTrue(combinedOutput.contains("helper packed vector3 false check passed."), "PackedVector3Array should skip writeback.\nOutput:\n" + combinedOutput); + assertTrue(combinedOutput.contains("helper packed color false check passed."), "PackedColorArray should skip writeback.\nOutput:\n" + combinedOutput); + assertTrue(combinedOutput.contains("helper packed vector4 false check passed."), "PackedVector4Array should skip writeback.\nOutput:\n" + combinedOutput); assertTrue(combinedOutput.contains("helper array false check passed."), "Array should skip writeback.\nOutput:\n" + combinedOutput); assertTrue(combinedOutput.contains("helper dictionary false check passed."), "Dictionary should skip writeback.\nOutput:\n" + combinedOutput); assertTrue(combinedOutput.contains("helper object false check passed."), "Object should skip writeback.\nOutput:\n" + combinedOutput); @@ -547,11 +564,61 @@ private static LirClassDef newVariantWritebackHelperProbeClass() { clazz.addFunction(newVariantWritebackProbeFunction("probe_string", GdStringType.STRING, selfType)); clazz.addFunction(newVariantWritebackProbeFunction("probe_vector2", GdFloatVectorType.VECTOR2, selfType)); clazz.addFunction(newVariantWritebackProbeFunction("probe_vector3i", GdIntVectorType.VECTOR3I, selfType)); + // Vector4/Color anchor the retained value-semantic true group around the packed carve-out. + clazz.addFunction(newVariantWritebackProbeFunction("probe_vector4", GdFloatVectorType.VECTOR4, selfType)); + clazz.addFunction(newVariantWritebackProbeFunction("probe_color", GdColorType.COLOR, selfType)); + // All ten packed kinds must answer false individually; PACKED_VECTOR4_ARRAY in particular + // historically leaked into the default-true branch. + clazz.addFunction(newVariantWritebackProbeFunction( + "probe_packed_byte_array", + GdPackedNumericArrayType.PACKED_BYTE_ARRAY, + selfType + )); clazz.addFunction(newVariantWritebackProbeFunction( "probe_packed_int32_array", GdPackedNumericArrayType.PACKED_INT32_ARRAY, selfType )); + clazz.addFunction(newVariantWritebackProbeFunction( + "probe_packed_int64_array", + GdPackedNumericArrayType.PACKED_INT64_ARRAY, + selfType + )); + clazz.addFunction(newVariantWritebackProbeFunction( + "probe_packed_float32_array", + GdPackedNumericArrayType.PACKED_FLOAT32_ARRAY, + selfType + )); + clazz.addFunction(newVariantWritebackProbeFunction( + "probe_packed_float64_array", + GdPackedNumericArrayType.PACKED_FLOAT64_ARRAY, + selfType + )); + clazz.addFunction(newVariantWritebackProbeFunction( + "probe_packed_string_array", + GdPackedStringArrayType.PACKED_STRING_ARRAY, + selfType + )); + clazz.addFunction(newVariantWritebackProbeFunction( + "probe_packed_vector2_array", + GdPackedVectorArrayType.PACKED_VECTOR2_ARRAY, + selfType + )); + clazz.addFunction(newVariantWritebackProbeFunction( + "probe_packed_vector3_array", + GdPackedVectorArrayType.PACKED_VECTOR3_ARRAY, + selfType + )); + clazz.addFunction(newVariantWritebackProbeFunction( + "probe_packed_color_array", + GdPackedVectorArrayType.PACKED_COLOR_ARRAY, + selfType + )); + clazz.addFunction(newVariantWritebackProbeFunction( + "probe_packed_vector4_array", + GdPackedVectorArrayType.PACKED_VECTOR4_ARRAY, + selfType + )); clazz.addFunction(newVariantWritebackProbeFunction( "probe_array", new GdArrayType(GdVariantType.VARIANT), @@ -769,11 +836,66 @@ func _ready() -> void: print("helper vector3i true check passed.") else: push_error("helper vector3i true check failed.") - - if bool(target.call("probe_packed_int32_array", PackedInt32Array([1, 2]))): - print("helper packed array true check passed.") + + if bool(target.call("probe_vector4", Vector4(1.0, 2.0, 3.0, 4.0))): + print("helper vector4 true check passed.") + else: + push_error("helper vector4 true check failed.") + + if bool(target.call("probe_color", Color(0.1, 0.2, 0.3))): + print("helper color true check passed.") + else: + push_error("helper color true check failed.") + + if not bool(target.call("probe_packed_byte_array", PackedByteArray([1, 2]))): + print("helper packed byte false check passed.") + else: + push_error("helper packed byte false check failed.") + + if not bool(target.call("probe_packed_int32_array", PackedInt32Array([1, 2]))): + print("helper packed int32 false check passed.") + else: + push_error("helper packed int32 false check failed.") + + if not bool(target.call("probe_packed_int64_array", PackedInt64Array([1, 2]))): + print("helper packed int64 false check passed.") + else: + push_error("helper packed int64 false check failed.") + + if not bool(target.call("probe_packed_float32_array", PackedFloat32Array([1.0, 2.0]))): + print("helper packed float32 false check passed.") + else: + push_error("helper packed float32 false check failed.") + + if not bool(target.call("probe_packed_float64_array", PackedFloat64Array([1.0, 2.0]))): + print("helper packed float64 false check passed.") + else: + push_error("helper packed float64 false check failed.") + + if not bool(target.call("probe_packed_string_array", PackedStringArray(["a", "b"]))): + print("helper packed string false check passed.") + else: + push_error("helper packed string false check failed.") + + if not bool(target.call("probe_packed_vector2_array", PackedVector2Array([Vector2.ZERO]))): + print("helper packed vector2 false check passed.") + else: + push_error("helper packed vector2 false check failed.") + + if not bool(target.call("probe_packed_vector3_array", PackedVector3Array([Vector3.ZERO]))): + print("helper packed vector3 false check passed.") + else: + push_error("helper packed vector3 false check failed.") + + if not bool(target.call("probe_packed_color_array", PackedColorArray([Color.WHITE]))): + print("helper packed color false check passed.") + else: + push_error("helper packed color false check failed.") + + if not bool(target.call("probe_packed_vector4_array", PackedVector4Array([Vector4.ZERO]))): + print("helper packed vector4 false check passed.") else: - push_error("helper packed array true check failed.") + push_error("helper packed vector4 false check failed.") if not bool(target.call("probe_array", [1, 2])): print("helper array false check passed.") diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/CallMethodInsnGenTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/CallMethodInsnGenTest.java index 1a242aaf..f11c6cab 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/CallMethodInsnGenTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/CallMethodInsnGenTest.java @@ -1274,6 +1274,109 @@ void callMethodShouldRejectNonVariantVarargArgument() { assertTrue(ex.getMessage().contains("must be Variant"), ex.getMessage()); } + @Test + @DisplayName("CALL_METHOD builtin packed receiver passes the Variant internal pointer") + void callBuiltinPackedMethodPassesInternalPointerReceiver() { + // Builtin wrapper signatures stay native (`godot_PackedInt32Array *self`), so the + // Variant-backed receiver renders its internal pointer — in-place mutation on the + // shared array, never a detached copy. + var clazz = newClass("Worker"); + var func = newFunction("call_packed_push_back"); + func.createAndAddVariable("numbers", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + func.createAndAddVariable("value", GdIntType.INT); + entry(func).appendInstruction(new CallMethodInsn( + null, + "push_back", + "numbers", + List.of(new LirInstruction.VariableOperand("value")) + )); + clazz.addFunction(func); + + var body = generateBody(clazz, func, newApi(List.of(packedInt32ArrayBuiltin()), List.of()), List.of(clazz)); + assertTrue( + body.contains("godot_PackedInt32Array_push_back(gdcc_packed_int32_array_internal_ptr(&$numbers), $value);"), + body + ); + assertFalse(body.contains("godot_PackedInt32Array_push_back(&"), body); + } + + @Test + @DisplayName("CALL_METHOD builtin packed argument passes the Variant internal pointer") + void callBuiltinPackedMethodPassesInternalPointerArgument() { + // Packed ARGUMENTS of builtin methods (e.g. `append_array`) equally render + // the internal pointer, so appended content lands on the receiver's shared array. + var clazz = newClass("Worker"); + var func = newFunction("call_packed_append_array"); + func.createAndAddVariable("numbers", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + func.createAndAddVariable("extra", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + entry(func).appendInstruction(new CallMethodInsn( + null, + "append_array", + "numbers", + List.of(new LirInstruction.VariableOperand("extra")) + )); + clazz.addFunction(func); + + var body = generateBody(clazz, func, newApi(List.of(packedInt32ArrayBuiltin()), List.of()), List.of(clazz)); + assertTrue( + body.contains("godot_PackedInt32Array_append_array(gdcc_packed_int32_array_internal_ptr(&$numbers), gdcc_packed_int32_array_internal_ptr(&$extra));"), + body + ); + } + + @Test + @DisplayName("CALL_METHOD packed builtin return wraps the raw struct through wrap_temp") + void callBuiltinPackedReturnWrapsRawStructThroughWrapTemp() { + // Whitelist (c): `duplicate()` yields a fresh native struct that is wrapped + // into the target Variant slot immediately — the result is an independent new array. + var clazz = newClass("Worker"); + var func = newFunction("call_packed_duplicate"); + func.createAndAddVariable("numbers", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + func.createAndAddVariable("copy", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + entry(func).appendInstruction(new CallMethodInsn( + "copy", + "duplicate", + "numbers", + List.of() + )); + clazz.addFunction(func); + + var body = generateBody(clazz, func, newApi(List.of(packedInt32ArrayBuiltin()), List.of()), List.of(clazz)); + assertTrue(body.contains("= godot_PackedInt32Array_duplicate(gdcc_packed_int32_array_internal_ptr(&$numbers));"), body); + assertTrue(body.contains("gdcc_packed_int32_array_wrap_temp(&"), body); + assertTrue(body.contains("$copy = __gdcc_tmp_owned_move_"), body); + assertFalse(body.contains("godot_new_Variant_with_PackedInt32Array"), body); + } + + @Test + @DisplayName("CALL_METHOD packed builtin with a default-filled scalar arg wraps the packed return") + void callBuiltinPackedSliceWithDefaultArgWrapsReturn() { + // `slice(begin, end = 2147483647)` combines every adaptation axis at once: internal-pointer + // receiver, caller-provided scalar arg, caller-side default materialization (fresh array + // per call, unchanged), and a fresh packed result wrapped through whitelist (c). + var clazz = newClass("Worker"); + var func = newFunction("call_packed_slice"); + func.createAndAddVariable("numbers", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + func.createAndAddVariable("begin", GdIntType.INT); + func.createAndAddVariable("sliced", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + entry(func).appendInstruction(new CallMethodInsn( + "sliced", + "slice", + "numbers", + List.of(new LirInstruction.VariableOperand("begin")) + )); + clazz.addFunction(func); + + var body = generateBody(clazz, func, newApi(List.of(packedInt32ArrayBuiltin()), List.of()), List.of(clazz)); + assertTrue(body.contains("gdcc_packed_int32_array_internal_ptr(&$numbers)"), body); + assertTrue(body.matches("(?s).*godot_PackedInt32Array __gdcc_tmp_packed_native_ret_\\d+ = " + + "godot_PackedInt32Array_slice\\(gdcc_packed_int32_array_internal_ptr\\(&\\$numbers\\), \\$begin, " + + "__gdcc_tmp_default_arg_2_\\d+\\);.*"), body); + assertTrue(body.contains("gdcc_packed_int32_array_wrap_temp(&"), body); + assertTrue(body.contains("$sliced = __gdcc_tmp_owned_move_"), body); + assertFalse(body.contains("godot_new_Variant_with_PackedInt32Array"), body); + } + @Test @DisplayName("CALL_METHOD should normalize typedarray PackedByteArray parameter to packed array type") void callMethodShouldNormalizeTypedarrayPackedByteArrayParameter() { @@ -1291,7 +1394,10 @@ void callMethodShouldNormalizeTypedarrayPackedByteArrayParameter() { clazz.addFunction(func); var body = generateBody(clazz, func, newApi(List.of(arrayBuiltinWithTypedarrayPackedByteArrayParam()), List.of()), List.of(clazz)); - assertTrue(body.contains("godot_Array_accept_packed(&$array, &$bytes)"), body); + // The builtin wrapper keeps the native packed param ABI; the Variant-backed argument + // passes its internal pointer. + assertTrue(body.contains("godot_Array_accept_packed(&$array, gdcc_packed_byte_array_internal_ptr(&$bytes))"), body); + assertFalse(body.contains("godot_new_PackedByteArray"), body); } @Test @@ -1330,7 +1436,12 @@ void callMethodShouldNormalizeTypedarrayPackedVector3ArrayReturn() { clazz.addFunction(func); var body = generateBody(clazz, func, newApi(List.of(arrayBuiltinWithTypedarrayPackedVector3ArrayReturn()), List.of()), List.of(clazz)); - assertTrue(body.contains("godot_Array_fetch_packed_vectors(&$array)"), body); + // Packed builtin return: raw struct temporary wrapped into the Variant slot through + // whitelist (c) wrap_temp — never a direct struct->Variant assignment. + assertTrue(body.contains("godot_PackedVector3Array "), body); + assertTrue(body.contains("= godot_Array_fetch_packed_vectors(&$array);"), body); + assertTrue(body.contains("gdcc_packed_vector3_array_wrap_temp(&"), body); + assertFalse(body.contains("godot_new_Variant_with_PackedVector3Array"), body); } @Test @@ -2146,8 +2257,74 @@ private ExtensionGdClass nodeClassWithStaticVarargBroadcast(long hash) { ); } - private ExtensionBuiltinClass arrayBuiltinWithTypedarrayPackedByteArrayParam() { - var method = new ExtensionBuiltinClass.ClassMethod( + /// Minimal `PackedInt32Array` builtin fixture with one void method taking a scalar, one void + /// method taking a packed array, and one packed-returning method — enough to anchor the + /// native-ABI adaptation of every packed call position. + private ExtensionBuiltinClass packedInt32ArrayBuiltin() { + var pushBack = new ExtensionBuiltinClass.ClassMethod( + "push_back", + "void", + false, + true, + false, + false, + 0L, + List.of(new ExtensionFunctionArgument("value", "int", null, null)), + List.of(), + null + ); + var appendArray = new ExtensionBuiltinClass.ClassMethod( + "append_array", + "void", + false, + true, + false, + false, + 0L, + List.of(new ExtensionFunctionArgument("array", "PackedInt32Array", null, null)), + List.of(), + null + ); + var duplicate = new ExtensionBuiltinClass.ClassMethod( + "duplicate", + "PackedInt32Array", + false, + true, + false, + false, + 0L, + List.of(), + List.of(), + new ExtensionBuiltinClass.ClassMethod.ReturnValue("PackedInt32Array") + ); + var slice = new ExtensionBuiltinClass.ClassMethod( + "slice", + "PackedInt32Array", + false, + true, + false, + false, + 0L, + List.of( + new ExtensionFunctionArgument("begin", "int", null, null), + new ExtensionFunctionArgument("end", "int", "2147483647", null) + ), + List.of(), + new ExtensionBuiltinClass.ClassMethod.ReturnValue("PackedInt32Array") + ); + return new ExtensionBuiltinClass( + "PackedInt32Array", + false, + List.of(), + List.of(pushBack, appendArray, duplicate, slice), + List.of(), + List.of(), + List.of(), + List.of() + ); + } + + private ExtensionBuiltinClass arrayBuiltinWithTypedarrayPackedByteArrayParam() { var method = new ExtensionBuiltinClass.ClassMethod( "accept_packed", "int", false, diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/IndexStoreInsnGenEngineTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/IndexStoreInsnGenEngineTest.java index 754fd123..f3eea439 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/IndexStoreInsnGenEngineTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/IndexStoreInsnGenEngineTest.java @@ -86,17 +86,25 @@ void variantSetRefContainersShouldReadBackWithoutWritebackInRealGodot() throws I var entrySource = Files.readString(tempDir.resolve("entry.c")); assertTrue(entrySource.contains("godot_new_Variant_with_Array($arr)"), entrySource); assertTrue(entrySource.contains("godot_new_Variant_with_Dictionary($dict)"), entrySource); - assertTrue(entrySource.contains("godot_new_Variant_with_PackedInt32Array($packed)"), entrySource); + // Packed pack/unpack are identity-sharing Variant holder copies, and the indexed store + // passes the packed storage Variant directly (no pack/unpack writeback). + // (`packed` is a ref parameter: an already-pointer `godot_Variant*`, hence no `&`.) + // The unpack share branch materializes the holder copy into a carrier BEFORE destroying + // the old slot value (carrier-first overwrite discipline). + assertTrue(entrySource.contains("$packed_variant = godot_new_Variant_with_Variant($packed);"), entrySource); + assertTrue(entrySource.contains(" = godot_new_Variant_with_Variant(&$packed_variant);"), entrySource); + assertTrue(entrySource.contains("$packed_local = __gdcc_tmp_owned_move_"), entrySource); assertTrue(entrySource.contains("(GDExtensionInt)$idx"), entrySource); assertTrue(entrySource.contains("godot_new_Variant_with_int($value)"), entrySource); assertTrue(entrySource.contains("godot_new_Variant_with_String($key)"), entrySource); assertTrue(entrySource.contains("godot_new_Variant_with_String($value)"), entrySource); assertTrue(entrySource.contains("godot_variant_set_named("), entrySource); - assertTrue(entrySource.contains("godot_variant_set_indexed("), entrySource); + assertTrue(entrySource.contains("godot_variant_set_indexed(&$packed_local,"), entrySource); assertTrue(entrySource.contains("godot_variant_set("), entrySource); assertFalse(entrySource.contains("$arr = godot_new_Array_with_Variant("), entrySource); assertFalse(entrySource.contains("$dict = godot_new_Dictionary_with_Variant("), entrySource); - assertTrue(entrySource.contains("$packed_local = godot_new_PackedInt32Array_with_Variant(&__gdcc_tmp_idx_self_variant_"), entrySource); + assertFalse(entrySource.contains("godot_new_Variant_with_PackedInt32Array"), entrySource); + assertFalse(entrySource.contains("godot_new_PackedInt32Array_with_Variant"), entrySource); assertFalse(entrySource.contains("*$packed ="), entrySource); var runner = new GodotGdextensionTestRunner(Path.of("test_project")); diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/IndexStoreInsnGenTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/IndexStoreInsnGenTest.java index 99fda423..b8c5eb79 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/IndexStoreInsnGenTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/IndexStoreInsnGenTest.java @@ -557,8 +557,10 @@ void variantSetIndexedDictionarySelfPackSucceeds() { } @Test - @DisplayName("variant_set_indexed should write back PackedInt32Array self") + @DisplayName("variant_set_indexed passes Packed*Array self storage directly without pack/writeback") void variantSetIndexedPackedInt32ArraySelfWritesBack() { + // Variant-backed packed storage: the setter mutates the shared array in place through + // the storage Variant, so no self temp, no pack and no unpack-writeback may be emitted. var body = generateBody( new VariantSetIndexedInsn("self", "idx", "value"), List.of( @@ -568,27 +570,28 @@ void variantSetIndexedPackedInt32ArraySelfWritesBack() { ) ); - assertTrue(body.contains("godot_new_Variant_with_PackedInt32Array(&$self)"), body); - assertTrue(body.contains("$self = godot_new_PackedInt32Array_with_Variant(&__gdcc_tmp_idx_self_variant_"), body); + assertTrue(body.contains("godot_variant_set_indexed(&$self,"), body); + assertFalse(body.contains("idx_self_variant"), body); + assertFalse(body.contains("godot_new_Variant_with_PackedInt32Array"), body); + assertFalse(body.contains("godot_new_PackedInt32Array_with_Variant"), body); } @Test - @DisplayName("variant_set_indexed should fail-fast when ref PackedInt32Array self requires writeback") + @DisplayName("variant_set_indexed allows ref Packed*Array self (borrowed Variant* shared identity)") void variantSetIndexedRefPackedInt32ArraySelfFails() { - var ex = assertThrows( - InvalidInsnException.class, - () -> generateBody( - new VariantSetIndexedInsn("self_ref", "idx", "value"), - List.of( - new VariableSpec("self_ref", GdPackedNumericArrayType.PACKED_INT32_ARRAY, true), - new VariableSpec("idx", GdIntType.INT, false), - new VariableSpec("value", GdIntType.INT, false) - ) + // The value-semantic ref-self ban is lifted: a ref packed parameter is a + // borrowed Variant pointer whose shared array mutates in place, visible to the caller. + var body = generateBody( + new VariantSetIndexedInsn("self_ref", "idx", "value"), + List.of( + new VariableSpec("self_ref", GdPackedNumericArrayType.PACKED_INT32_ARRAY, true), + new VariableSpec("idx", GdIntType.INT, false), + new VariableSpec("value", GdIntType.INT, false) ) ); - assertInstanceOf(InvalidInsnException.class, ex); - assertTrue(ex.getMessage().contains("requires writeback"), ex.getMessage()); + assertTrue(body.contains("godot_variant_set_indexed($self_ref,"), body); + assertFalse(body.contains("idx_self_variant"), body); } @Test 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 fbb16a8d..e626eb61 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBodyInsnPassTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBodyInsnPassTest.java @@ -4652,6 +4652,301 @@ func ping(values: PackedInt32Array) -> int: ); } + @Test + void runSkipsWritebackForPackedDirectSlotSnapshotReceiverAfterMutatingCall() throws Exception { + var prepared = prepareContext( + "body_insn_direct_slot_snapshot_writeback.gd", + """ + class_name BodyInsnDirectSlotSnapshotWriteback + extends RefCounted + + func helper(value: String) -> String: + return value + + func ping(part: String) -> PackedStringArray: + var parr := PackedStringArray() + parr.append(helper(part)) + return parr + """, + Map.of( + "BodyInsnDirectSlotSnapshotWriteback", + "RuntimeBodyInsnDirectSlotSnapshotWriteback" + ), + true + ); + var pingContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnDirectSlotSnapshotWriteback", + "ping" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var function = pingContext.targetFunction(); + var instructions = allInstructions(function); + var appendCall = instructions.stream() + .filter(CallMethodInsn.class::isInstance) + .map(CallMethodInsn.class::cast) + .filter(insn -> insn.methodName().equals("append")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing append CallMethodInsn")); + var writebackAssigns = instructions.stream() + .filter(AssignInsn.class::isInstance) + .map(AssignInsn.class::cast) + .filter(insn -> insn.resultId().equals("parr") && insn.sourceId().equals(appendCall.objectId())) + .toList(); + + // The nested-call argument still forces the temp-snapshot receiver surface, but the packed + // snapshot is a Variant holder copy sharing identity with `parr`, so no writeback assign + // may follow the call. + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertTrue(appendCall.objectId().startsWith("cfg_tmp_")), + () -> assertEquals(0, writebackAssigns.size()) + ); + } + + @Test + void runSkipsWritebackForPackedDirectSlotSnapshotReceiverInsideForLoop() throws Exception { + var prepared = prepareContext( + "body_insn_direct_slot_snapshot_writeback_loop.gd", + """ + class_name BodyInsnDirectSlotSnapshotWritebackLoop + extends RefCounted + + func ping(parts: PackedStringArray) -> PackedStringArray: + var parr := PackedStringArray() + for part in parts: + parr.append(str(part)) + return parr + """, + Map.of( + "BodyInsnDirectSlotSnapshotWritebackLoop", + "RuntimeBodyInsnDirectSlotSnapshotWritebackLoop" + ), + true + ); + var pingContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnDirectSlotSnapshotWritebackLoop", + "ping" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var function = pingContext.targetFunction(); + var instructions = allInstructions(function); + var appendCall = instructions.stream() + .filter(CallMethodInsn.class::isInstance) + .map(CallMethodInsn.class::cast) + .filter(insn -> insn.methodName().equals("append")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing append CallMethodInsn")); + var writebackAssigns = instructions.stream() + .filter(AssignInsn.class::isInstance) + .map(AssignInsn.class::cast) + .filter(insn -> insn.resultId().equals("parr") && insn.sourceId().equals(appendCall.objectId())) + .toList(); + + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertTrue(appendCall.objectId().startsWith("cfg_tmp_")), + () -> assertEquals(0, writebackAssigns.size()) + ); + } + + @Test + void runKeepsAliasPublishedDirectSlotReceiverWithoutWriteback() throws Exception { + var prepared = prepareContext( + "body_insn_direct_slot_alias_no_writeback.gd", + """ + class_name BodyInsnDirectSlotAliasNoWriteback + extends RefCounted + + func ping(values: PackedInt32Array, seed: int) -> void: + values.push_back(seed) + """, + Map.of( + "BodyInsnDirectSlotAliasNoWriteback", + "RuntimeBodyInsnDirectSlotAliasNoWriteback" + ), + true + ); + var pingContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnDirectSlotAliasNoWriteback", + "ping" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var function = pingContext.targetFunction(); + var instructions = allInstructions(function); + var pushBackCall = instructions.stream() + .filter(CallMethodInsn.class::isInstance) + .map(CallMethodInsn.class::cast) + .filter(insn -> insn.methodName().equals("push_back")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing push_back CallMethodInsn")); + var writebackAssigns = instructions.stream() + .filter(AssignInsn.class::isInstance) + .map(AssignInsn.class::cast) + .filter(insn -> insn.resultId().equals("values")) + .toList(); + + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertEquals("values", pushBackCall.objectId()), + () -> assertEquals(0, writebackAssigns.size()) + ); + } + + @Test + void runSkipsDirectSlotWritebackForSharedArrayReceiverCall() throws Exception { + var prepared = prepareContext( + "body_insn_direct_slot_snapshot_shared_array.gd", + """ + class_name BodyInsnDirectSlotSnapshotSharedArray + extends RefCounted + + func ping(seed: int) -> Array: + var arr: Array = [1] + arr.append(str(seed)) + return arr + """, + Map.of( + "BodyInsnDirectSlotSnapshotSharedArray", + "RuntimeBodyInsnDirectSlotSnapshotSharedArray" + ), + true + ); + var pingContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnDirectSlotSnapshotSharedArray", + "ping" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var function = pingContext.targetFunction(); + var instructions = allInstructions(function); + var appendCall = instructions.stream() + .filter(CallMethodInsn.class::isInstance) + .map(CallMethodInsn.class::cast) + .filter(insn -> insn.methodName().equals("append")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing append CallMethodInsn")); + var writebackAssigns = instructions.stream() + .filter(AssignInsn.class::isInstance) + .map(AssignInsn.class::cast) + .filter(insn -> insn.resultId().equals("arr") && insn.sourceId().equals(appendCall.objectId())) + .toList(); + + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + // Shared/reference carriers mutate the snapshot's underlying data in place, so the + // static writeback gate must skip the direct-slot commit step entirely. + () -> assertTrue(appendCall.objectId().startsWith("cfg_tmp_")), + () -> assertEquals(0, writebackAssigns.size()) + ); + } + + @Test + void runEmitsRuntimeGatedDirectSlotWritebackForDynamicVariantReceiver() throws Exception { + var prepared = prepareContext( + "body_insn_dynamic_variant_direct_slot_writeback.gd", + """ + class_name BodyInsnDynamicVariantDirectSlotWriteback + extends RefCounted + + func helper(value: Variant) -> Variant: + return value + + func ping(seed: Variant) -> Variant: + var payload: Variant = PackedStringArray() + payload.append(helper(seed)) + return payload + """, + Map.of( + "BodyInsnDynamicVariantDirectSlotWriteback", + "RuntimeBodyInsnDynamicVariantDirectSlotWriteback" + ), + true + ); + var pingContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnDynamicVariantDirectSlotWriteback", + "ping" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var function = pingContext.targetFunction(); + var instructions = allInstructions(function); + var appendCall = instructions.stream() + .filter(CallMethodInsn.class::isInstance) + .map(CallMethodInsn.class::cast) + .filter(insn -> insn.methodName().equals("append")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing append CallMethodInsn")); + var gateCalls = instructions.stream() + .filter(CallGlobalInsn.class::isInstance) + .map(CallGlobalInsn.class::cast) + .filter(insn -> insn.functionName().equals("gdcc_variant_requires_writeback")) + .toList(); + var gateBranches = instructions.stream() + .filter(GoIfInsn.class::isInstance) + .map(GoIfInsn.class::cast) + .toList(); + var writebackAssigns = instructions.stream() + .filter(AssignInsn.class::isInstance) + .map(AssignInsn.class::cast) + .filter(insn -> insn.resultId().equals("payload") && insn.sourceId().equals(appendCall.objectId())) + .toList(); + + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertTrue(appendCall.objectId().startsWith("cfg_tmp_")), + () -> assertEquals(1, gateCalls.size()), + () -> assertEquals(appendCall.objectId(), onlyVariableOperandId(gateCalls.getFirst().args())), + () -> assertEquals(1, gateBranches.size()), + () -> assertEquals(gateCalls.getFirst().resultId(), gateBranches.getFirst().conditionVarId()), + () -> assertEquals(1, writebackAssigns.size()) + ); + var applyBlock = requireBlock(function, gateBranches.getFirst().trueBbId()); + var applyAssign = assertInstanceOf(AssignInsn.class, applyBlock.getNonTerminatorInstructions().getFirst()); + var applyGoto = assertInstanceOf(GotoInsn.class, applyBlock.getTerminator()); + var skipBlock = requireBlock(function, gateBranches.getFirst().falseBbId()); + var skipGoto = assertInstanceOf(GotoInsn.class, skipBlock.getTerminator()); + var skipWritebackAssigns = skipBlock.getNonTerminatorInstructions().stream() + .filter(AssignInsn.class::isInstance) + .map(AssignInsn.class::cast) + .filter(insn -> insn.resultId().equals("payload")) + .toList(); + var continuationBlock = requireBlock(function, applyGoto.targetBbId()); + // The continuation must keep threading the rest of the sequence after the gate; follow + // the goto chain from the continuation until the block that owns the return terminator. + LirBasicBlock returnBlock = continuationBlock; + while (returnBlock.getTerminator() instanceof GotoInsn gotoInsn) { + returnBlock = requireBlock(function, gotoInsn.targetBbId()); + } + assertInstanceOf(ReturnInsn.class, returnBlock.getTerminator()); + assertAll( + () -> assertEquals("payload", applyAssign.resultId()), + () -> assertEquals(appendCall.objectId(), applyAssign.sourceId()), + // The runtime skip branch must stay writeback-free (shared carriers mutate the + // snapshot's underlying data in place), and both branches must rejoin on one + // continuation block that keeps lowering the rest of the sequence. + () -> assertEquals(0, skipWritebackAssigns.size()), + () -> assertEquals(applyGoto.targetBbId(), skipGoto.targetBbId()) + ); + } + @Test void runWritesBackPropertyBackedValueSemanticReceiverAfterResolvedMutatingCall() throws Exception { var prepared = prepareContext( @@ -4702,6 +4997,119 @@ func ping(seed: Variant) -> void: ); } + /// 7a: a mutating call on a builtin engine property must not write the packed carrier back + /// — the getter returns a detached copy and the interpreter does not persist the mutation. The + /// explicit reassignment store (7b) is a separate leaf write and must stay. + @Test + void runSkipsWritebackForBuiltinEnginePropertyPackedReceiverMutatingCall() throws Exception { + var prepared = prepareContext( + "body_insn_builtin_property_mutating_call.gd", + """ + class_name BodyInsnBuiltinPropertyMutatingCall + extends RefCounted + + func ping() -> void: + var poly := Polygon2D.new() + poly.polygon = PackedVector2Array([Vector2.ZERO]) + poly.polygon.push_back(Vector2(1, 1)) + """, + Map.of( + "BodyInsnBuiltinPropertyMutatingCall", + "RuntimeBodyInsnBuiltinPropertyMutatingCall" + ), + true + ); + var pingContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnBuiltinPropertyMutatingCall", + "ping" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var instructions = allInstructions(pingContext.targetFunction()); + var callInsn = instructions.stream() + .filter(CallMethodInsn.class::isInstance) + .map(CallMethodInsn.class::cast) + .filter(insn -> insn.methodName().equals("push_back")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing push_back CallMethodInsn")); + var polygonStores = instructions.stream() + .filter(StorePropertyInsn.class::isInstance) + .map(StorePropertyInsn.class::cast) + .filter(insn -> insn.propertyName().equals("polygon")) + .toList(); + + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertEquals( + 1, + polygonStores.size(), + "only the explicit reassignment may store `polygon`; the mutating call must not write back" + ), + () -> assertTrue( + instructionIndex(instructions, polygonStores.getFirst()) < instructionIndex(instructions, callInsn), + "the reassignment store belongs to the statement before the mutating call" + ) + ); + } + + /// Counterpart of the 7a writeback removal: subscript assignment on the same builtin + /// engine property persists in the interpreter (read-modify-write), so the assignment route + /// must keep the named-base writeback even though the mutating-call route drops it. + @Test + void runKeepsBuiltinEnginePropertySubscriptWritebackOnAssignmentRoute() throws Exception { + var prepared = prepareContext( + "body_insn_builtin_property_subscript_write.gd", + """ + class_name BodyInsnBuiltinPropertySubscriptWrite + extends RefCounted + + func ping() -> void: + var poly := Polygon2D.new() + poly.polygon = PackedVector2Array([Vector2.ZERO, Vector2(3, 3)]) + poly.polygon[0] = Vector2(9, 9) + """, + Map.of( + "BodyInsnBuiltinPropertySubscriptWrite", + "RuntimeBodyInsnBuiltinPropertySubscriptWrite" + ), + true + ); + var pingContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnBuiltinPropertySubscriptWrite", + "ping" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var instructions = allInstructions(pingContext.targetFunction()); + // The engine container is not GDCC instance storage, so the named base round-trips + // through the Variant named route; the writeback `VariantSetNamedInsn` is the persistence + // point and must survive the packed writeback removal. + var namedWritebacks = instructions.stream() + .filter(VariantSetNamedInsn.class::isInstance) + .map(VariantSetNamedInsn.class::cast) + .toList(); + var indexedStores = instructions.stream() + .filter(VariantSetIndexedInsn.class::isInstance) + .map(VariantSetIndexedInsn.class::cast) + .toList(); + + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertEquals(1, indexedStores.size(), "the element store itself must be emitted"), + () -> assertEquals(1, namedWritebacks.size(), "the mutated named base must be written back to poly.polygon"), + () -> assertTrue( + instructionIndex(instructions, indexedStores.getFirst()) < instructionIndex(instructions, namedWritebacks.getFirst()), + "the named-base writeback must follow the element store" + ) + ); + } + @Test void runWritesBackNestedMutatingCallIntoSharedDictionaryElementWithoutOuterPropertyStore() throws Exception { var prepared = prepareContext( diff --git a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendWritableTypeWritebackSupportTest.java b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendWritableTypeWritebackSupportTest.java index 2719ef36..2114c814 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendWritableTypeWritebackSupportTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendWritableTypeWritebackSupportTest.java @@ -2,11 +2,14 @@ import gd.script.gdcc.type.GdArrayType; import gd.script.gdcc.type.GdDictionaryType; -import gd.script.gdcc.type.GdccForRangeIterType; import gd.script.gdcc.type.GdIntType; import gd.script.gdcc.type.GdObjectType; import gd.script.gdcc.type.GdPackedNumericArrayType; +import gd.script.gdcc.type.GdPackedStringArrayType; +import gd.script.gdcc.type.GdPackedVectorArrayType; +import gd.script.gdcc.type.GdStringType; import gd.script.gdcc.type.GdVariantType; +import gd.script.gdcc.type.GdccForRangeIterType; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertAll; @@ -17,21 +20,87 @@ class FrontendWritableTypeWritebackSupportTest { @Test void requiresReverseCommitForCarrierTypeMatchesSharedTypeMatrix() { + // Shared/reference families skip writeback on every route provenance. + for (var provenance : FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.values()) { + assertAll( + () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType(GdIntType.INT, provenance)), + () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType(GdObjectType.OBJECT, provenance)), + () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( + new GdArrayType(GdVariantType.VARIANT), provenance + )), + () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( + new GdDictionaryType(GdVariantType.VARIANT, GdVariantType.VARIANT), provenance + )), + // Variant keeps the conservative true answer: the runtime helper refines it later. + () -> assertTrue(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( + GdVariantType.VARIANT, provenance + )), + () -> assertTrue(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( + GdStringType.STRING, provenance + )) + ); + } + } + + @Test + void packedCarrierAnswerDependsOnRouteProvenance() { + // All ten packed families share the Variant-backed identity, so the per-route answer must + // hold for every one of them, not just the numeric family. + var packedFamilies = new gd.script.gdcc.type.GdPackedArrayType[]{ + GdPackedNumericArrayType.PACKED_BYTE_ARRAY, + GdPackedNumericArrayType.PACKED_INT32_ARRAY, + GdPackedNumericArrayType.PACKED_INT64_ARRAY, + GdPackedNumericArrayType.PACKED_FLOAT32_ARRAY, + GdPackedNumericArrayType.PACKED_FLOAT64_ARRAY, + GdPackedStringArrayType.PACKED_STRING_ARRAY, + GdPackedVectorArrayType.PACKED_VECTOR2_ARRAY, + GdPackedVectorArrayType.PACKED_VECTOR3_ARRAY, + GdPackedVectorArrayType.PACKED_COLOR_ARRAY, + GdPackedVectorArrayType.PACKED_VECTOR4_ARRAY + }; + for (var packedType : packedFamilies) { + assertAll( + // Snapshot temps share identity with the source slot: no DIRECT_SLOT step. + () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( + packedType, FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.DIRECT_SLOT + )), + // Static leaf shares identity with static storage: no promotion step. + () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( + packedType, FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.STATIC_PROPERTY + )), + // Engine getter copy must not persist a mutating call: no writeback. + () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( + packedType, FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.ENGINE_PROPERTY_CALL + )), + // Retained redundant-but-harmless routes keep the legacy true answer. + () -> assertTrue(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( + packedType, FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.SCRIPT_PROPERTY + )), + () -> assertTrue(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( + packedType, FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.CONTAINER_ELEMENT + )), + () -> assertTrue(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( + packedType, FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.GENERIC + )) + ); + } + } + + @Test + void directSlotSnapshotCommitExemptsOnlyPackedCarriers() { assertAll( - () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType(GdIntType.INT)), - () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType(GdObjectType.OBJECT)), - () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( - new GdArrayType(GdVariantType.VARIANT) + () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresDirectSlotSnapshotCommit( + GdPackedNumericArrayType.PACKED_INT32_ARRAY )), - () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( - new GdDictionaryType(GdVariantType.VARIANT, GdVariantType.VARIANT) + () -> assertFalse(FrontendWritableTypeWritebackSupport.requiresDirectSlotSnapshotCommit( + GdPackedVectorArrayType.PACKED_VECTOR4_ARRAY )), - () -> assertTrue(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( - GdPackedNumericArrayType.PACKED_INT32_ARRAY + // Deliberately unconditional for every other family, even shared ones. + () -> assertTrue(FrontendWritableTypeWritebackSupport.requiresDirectSlotSnapshotCommit( + new GdArrayType(GdVariantType.VARIANT) )), - () -> assertTrue(FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( - GdVariantType.VARIANT - )) + () -> assertTrue(FrontendWritableTypeWritebackSupport.requiresDirectSlotSnapshotCommit(GdStringType.STRING)), + () -> assertTrue(FrontendWritableTypeWritebackSupport.requiresDirectSlotSnapshotCommit(GdVariantType.VARIANT)) ); } @@ -40,10 +109,18 @@ void compilerOnlyTypeCannotEnterFrontendWritebackAnalysis() { var ex = assertThrows( IllegalArgumentException.class, () -> FrontendWritableTypeWritebackSupport.requiresReverseCommitForCarrierType( - GdccForRangeIterType.FOR_RANGE_ITER + GdccForRangeIterType.FOR_RANGE_ITER, + FrontendWritableTypeWritebackSupport.WritebackRouteProvenance.GENERIC ) ); + var snapshotEx = assertThrows( + IllegalArgumentException.class, + () -> FrontendWritableTypeWritebackSupport.requiresDirectSlotSnapshotCommit(GdccForRangeIterType.FOR_RANGE_ITER) + ); - assertTrue(ex.getMessage().contains("compiler-only type leaked into frontend writeback analysis"), ex.getMessage()); + assertAll( + () -> assertTrue(ex.getMessage().contains("compiler-only type leaked into frontend writeback analysis"), ex.getMessage()), + () -> assertTrue(snapshotEx.getMessage().contains("compiler-only type leaked into frontend writeback analysis"), snapshotEx.getMessage()) + ); } } diff --git a/src/test/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphBuilderTest.java b/src/test/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphBuilderTest.java index 8dcba6fa..439fcaa5 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphBuilderTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphBuilderTest.java @@ -799,20 +799,19 @@ func ping() -> int: ); } - /// A mutating method on a value-semantic / unknown-carrier static member (`PackedByteArray` - /// requires post-call write-back) cannot keep the static property leaf terminal, so the - /// promoted commit step must hit the static-terminal contract fail-fast instead of silently - /// dropping the write-back. + /// A mutating method on a Variant-backed packed static member needs no write-back at all: + /// the loaded value shares identity with the static storage, so the bare static property leaf + /// stays terminal and the route publishes no commit step (the static-terminal contract passes). @Test - void buildExecutableBodyFailsFastForMutatingCallOnWritebackCarrierStaticMember() throws Exception { + void buildExecutableBodyKeepsStaticPackedMemberLeafTerminalForMutatingCall() throws Exception { var analyzed = analyzeFunction( "cfg_builder_static_packed_call.gd", """ class_name CfgBuilderStaticPackedCall extends RefCounted - + static var bytes: PackedByteArray - + func ping() -> void: bytes.append(1) """, @@ -823,14 +822,29 @@ func ping() -> void: ) ); - var exception = assertThrows( - IllegalArgumentException.class, - () -> new FrontendCfgGraphBuilder().buildExecutableBody(analyzed.function().body(), analyzed.analysisData()) + var rootBlock = analyzed.function().body(); + var build = new FrontendCfgGraphBuilder().buildExecutableBody(rootBlock, analyzed.analysisData()); + var entryNode = assertInstanceOf(FrontendCfgGraph.SequenceNode.class, build.graph().requireNode("seq_0")); + var appendCall = entryNode.items().stream() + .filter(CallItem.class::isInstance) + .map(CallItem.class::cast) + .filter(item -> item.callableName().equals("append")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing append CallItem")); + var payload = requireNotNull( + appendCall.writableRoutePayloadOrNull(), + "static packed mutating call should publish a writable payload" ); - assertTrue( - exception.getMessage().contains("must keep a static property leaf terminal"), - exception.getMessage() + assertAll( + () -> assertFalse(analyzed.diagnostics().hasErrors()), + () -> assertEquals(FrontendWritableRoutePayload.RootKind.STATIC_CONTEXT, payload.root().kind()), + () -> assertEquals(FrontendWritableRoutePayload.LeafKind.PROPERTY, payload.leaf().kind()), + () -> assertNull(payload.leaf().containerValueIdOrNull()), + () -> assertTrue( + payload.reverseCommitSteps().isEmpty(), + "packed static receiver must not publish a promotion step" + ) ); } @@ -1094,6 +1108,198 @@ func ping(values: PackedInt32Array, seed: int) -> void: ); } + @Test + void buildExecutableBodySkipsDirectSlotCommitStepForPackedSnapshotReceiverCall() throws Exception { + var analyzed = analyzeFunction( + "cfg_builder_identifier_receiver_nested_call_writeback.gd", + """ + class_name CfgBuilderIdentifierReceiverNestedCallWriteback + extends RefCounted + + func helper(value: int) -> int: + return value + 1 + + func ping(seed: int) -> PackedInt32Array: + var values := PackedInt32Array() + values.push_back(helper(seed)) + return values + """, + "ping", + Map.of( + "CfgBuilderIdentifierReceiverNestedCallWriteback", + "RuntimeCfgBuilderIdentifierReceiverNestedCallWriteback" + ) + ); + + var rootBlock = analyzed.function().body(); + var build = new FrontendCfgGraphBuilder().buildExecutableBody(rootBlock, analyzed.analysisData()); + var entryNode = assertInstanceOf(FrontendCfgGraph.SequenceNode.class, build.graph().requireNode("seq_0")); + var outerCallValue = entryNode.items().stream() + .filter(CallItem.class::isInstance) + .map(CallItem.class::cast) + .filter(item -> item.callableName().equals("push_back")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing outer push_back CallItem")); + var payload = outerCallValue.writableRoutePayloadOrNull(); + + // The packed snapshot temp is a Variant holder copy sharing identity with `values`, so + // the mutating call is already visible through the source slot and no DIRECT_SLOT commit + // step is published (the snapshot surface itself is unchanged: nested-call arguments still + // block alias publication). + assertAll( + () -> assertFalse(analyzed.diagnostics().hasErrors()), + () -> assertNotNull(payload), + () -> assertEquals( + FrontendWritableRoutePayload.RootKind.DIRECT_SLOT, + payload.root().kind() + ), + () -> assertEquals( + FrontendWritableRoutePayload.LeafKind.DIRECT_SLOT, + payload.leaf().kind() + ), + () -> assertTrue(payload.reverseCommitSteps().isEmpty()) + ); + } + + @Test + void buildExecutableBodyKeepsCommitStepsEmptyForAliasPublishedReceiverCall() throws Exception { + var analyzed = analyzeFunction( + "cfg_builder_identifier_receiver_alias_writeback.gd", + """ + class_name CfgBuilderIdentifierReceiverAliasWriteback + extends RefCounted + + func ping(values: PackedInt32Array, seed: int) -> void: + values.push_back(seed) + """, + "ping", + Map.of( + "CfgBuilderIdentifierReceiverAliasWriteback", + "RuntimeCfgBuilderIdentifierReceiverAliasWriteback" + ) + ); + + var rootBlock = analyzed.function().body(); + var build = new FrontendCfgGraphBuilder().buildExecutableBody(rootBlock, analyzed.analysisData()); + var entryNode = assertInstanceOf(FrontendCfgGraph.SequenceNode.class, build.graph().requireNode("seq_0")); + var callValue = entryNode.items().stream() + .filter(CallItem.class::isInstance) + .map(CallItem.class::cast) + .filter(item -> item.callableName().equals("push_back")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing push_back CallItem")); + var payload = callValue.writableRoutePayloadOrNull(); + + assertAll( + () -> assertFalse(analyzed.diagnostics().hasErrors()), + () -> assertNotNull(payload), + () -> assertTrue(entryNode.items().stream().anyMatch(DirectSlotAliasValueItem.class::isInstance)), + () -> assertTrue(payload.reverseCommitSteps().isEmpty()) + ); + } + + @Test + void buildExecutableBodyAppendsDirectSlotCommitStepForValueProducingSnapshotReceiverCall() throws Exception { + var analyzed = analyzeFunction( + "cfg_builder_identifier_receiver_value_call_writeback.gd", + """ + class_name CfgBuilderIdentifierReceiverValueCallWriteback + extends RefCounted + + func make_key(raw: String) -> String: + return raw + + func ping(seed: String) -> bool: + var dict: Dictionary = {"a": 1} + return dict.erase(make_key(seed)) + """, + "ping", + Map.of( + "CfgBuilderIdentifierReceiverValueCallWriteback", + "RuntimeCfgBuilderIdentifierReceiverValueCallWriteback" + ) + ); + + var rootBlock = analyzed.function().body(); + var build = new FrontendCfgGraphBuilder().buildExecutableBody(rootBlock, analyzed.analysisData()); + var entryNode = assertInstanceOf(FrontendCfgGraph.SequenceNode.class, build.graph().requireNode("seq_0")); + var eraseCallValue = entryNode.items().stream() + .filter(CallItem.class::isInstance) + .map(CallItem.class::cast) + .filter(item -> item.callableName().equals("erase")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing erase CallItem")); + var payload = eraseCallValue.writableRoutePayloadOrNull(); + + assertAll( + () -> assertFalse(analyzed.diagnostics().hasErrors()), + () -> assertNotNull(payload), + () -> assertNotNull(eraseCallValue.resultValueIdOrNull()), + () -> assertEquals( + FrontendWritableRoutePayload.RootKind.DIRECT_SLOT, + payload.root().kind() + ), + () -> assertEquals( + FrontendWritableRoutePayload.LeafKind.DIRECT_SLOT, + payload.leaf().kind() + ), + () -> assertEquals(1, payload.reverseCommitSteps().size()), + () -> assertEquals( + FrontendWritableRoutePayload.StepKind.DIRECT_SLOT, + payload.reverseCommitSteps().getFirst().kind() + ) + ); + } + + @Test + void buildExecutableBodySkipsDirectSlotCommitStepForParameterReceiver() throws Exception { + var analyzed = analyzeFunction( + "cfg_builder_parameter_receiver_nested_call_writeback.gd", + """ + class_name CfgBuilderParameterReceiverNestedCallWriteback + extends RefCounted + + func helper(value: int) -> int: + return value + 1 + + func ping(values: PackedInt32Array, seed: int) -> void: + values.push_back(helper(seed)) + """, + "ping", + Map.of( + "CfgBuilderParameterReceiverNestedCallWriteback", + "RuntimeCfgBuilderParameterReceiverNestedCallWriteback" + ) + ); + + var rootBlock = analyzed.function().body(); + var build = new FrontendCfgGraphBuilder().buildExecutableBody(rootBlock, analyzed.analysisData()); + var entryNode = assertInstanceOf(FrontendCfgGraph.SequenceNode.class, build.graph().requireNode("seq_0")); + var outerCallValue = entryNode.items().stream() + .filter(CallItem.class::isInstance) + .map(CallItem.class::cast) + .filter(item -> item.callableName().equals("push_back")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing outer push_back CallItem")); + var payload = outerCallValue.writableRoutePayloadOrNull(); + + // Packed parameters are Variant-backed slots sharing identity with the caller's value, so + // the snapshot route stays step-less: the mutation is already visible without a writeback. + assertAll( + () -> assertFalse(analyzed.diagnostics().hasErrors()), + () -> assertNotNull(payload), + () -> assertEquals( + FrontendWritableRoutePayload.RootKind.DIRECT_SLOT, + payload.root().kind() + ), + () -> assertEquals( + FrontendWritableRoutePayload.LeafKind.DIRECT_SLOT, + payload.leaf().kind() + ), + () -> assertTrue(payload.reverseCommitSteps().isEmpty()) + ); + } + @Test void buildExecutableBodyStillPublishesSelfAliasWhenArgumentContainsNestedCall() throws Exception { var analyzed = analyzeFunction( @@ -1714,13 +1920,13 @@ func ping() -> CfgBuilderSelfReadSurface: } @Test - void buildExecutableBodyFailsFastWhenReceiverBindingIsCaptureAliasRoot() throws Exception { + void buildExecutableBodyPublishesDirectSlotAliasForCaptureReceiver() throws Exception { var analyzed = analyzeFunction( "cfg_builder_capture_receiver_alias.gd", """ class_name CfgBuilderCaptureReceiverAlias extends RefCounted - + func ping(values: PackedInt32Array, seed: int) -> void: values.push_back(seed) """, @@ -1744,15 +1950,26 @@ func ping(values: PackedInt32Array, seed: int) -> void: ) ); - var exception = assertThrows( - IllegalStateException.class, - () -> new FrontendCfgGraphBuilder().buildExecutableBody(rootBlock, analyzed.analysisData()) + var build = new FrontendCfgGraphBuilder().buildExecutableBody(rootBlock, analyzed.analysisData()); + var entryNode = assertInstanceOf(FrontendCfgGraph.SequenceNode.class, build.graph().requireNode("seq_0")); + var callValue = entryNode.items().stream() + .filter(CallItem.class::isInstance) + .map(CallItem.class::cast) + .filter(item -> item.callableName().equals("push_back")) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing push_back CallItem")); + var payload = requireNotNull( + callValue.writableRoutePayloadOrNull(), + "capture receiver mutating call should publish a writable payload" ); + // Capture receivers alias their own capture slot: Variant-backed packed captures share the + // outer array identity, so the in-place alias mutation is visible on both sides and no + // commit step is needed (assignment to the capture name stays copy-on-capture). assertAll( () -> assertFalse(analyzed.diagnostics().hasErrors()), - () -> assertTrue(exception.getMessage().contains("does not support CAPTURE binding"), exception.getMessage()), - () -> assertTrue(exception.getMessage().contains("lambda/capture semantics"), exception.getMessage()) + () -> assertTrue(entryNode.items().stream().anyMatch(DirectSlotAliasValueItem.class::isInstance)), + () -> assertTrue(payload.reverseCommitSteps().isEmpty()) ); } @@ -2926,6 +3143,7 @@ func current(): () -> assertEquals(subscriptLoad.resultValueId(), stopNode.returnValueIdOrNull()) ); } + /// Global engine enum loads (`Side.SIDE_LEFT`) keep the existing type-meta route: the head /// binds as GLOBAL_ENUM type-meta and the constant is engine metadata, not a /// `GdScriptEnumConstant`, so neither script-enum branch may fire. diff --git a/src/test/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphTest.java b/src/test/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphTest.java index 6e46193b..e3758313 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphTest.java @@ -765,6 +765,265 @@ void containerLiteralItemExposesStableAnchorOperandsAndRejectsNonLiteralRoots() assertTrue(rejected.getMessage().contains("ArrayExpression"), rejected.getMessage()); } + @Test + void stepDescriptorRejectsDirectSlotStepPayloadFields() { + var withContainer = assertThrows( + IllegalArgumentException.class, + () -> new FrontendWritableRoutePayload.StepDescriptor( + FrontendWritableRoutePayload.StepKind.DIRECT_SLOT, + identifier("parr"), + "container0", + List.of(), + null, + null + ) + ); + var withOperands = assertThrows( + IllegalArgumentException.class, + () -> new FrontendWritableRoutePayload.StepDescriptor( + FrontendWritableRoutePayload.StepKind.DIRECT_SLOT, + identifier("parr"), + null, + List.of("key0"), + null, + null + ) + ); + var withMember = assertThrows( + IllegalArgumentException.class, + () -> new FrontendWritableRoutePayload.StepDescriptor( + FrontendWritableRoutePayload.StepKind.DIRECT_SLOT, + identifier("parr"), + null, + List.of(), + "member", + null + ) + ); + var withAccessKind = assertThrows( + IllegalArgumentException.class, + () -> new FrontendWritableRoutePayload.StepDescriptor( + FrontendWritableRoutePayload.StepKind.DIRECT_SLOT, + identifier("parr"), + null, + List.of(), + null, + FrontendSubscriptAccessSupport.AccessKind.INDEXED + ) + ); + + assertAll( + () -> assertTrue(withContainer.getMessage().contains("containerValueIdOrNull")), + () -> assertTrue(withOperands.getMessage().contains("operandValueIds")), + () -> assertTrue(withMember.getMessage().contains("memberNameOrNull")), + () -> assertTrue(withAccessKind.getMessage().contains("subscriptAccessKindOrNull")) + ); + } + + @Test + void constructorAcceptsTerminalDirectSlotCommitStepOnCallPayload() { + var anchor = identifier("parr"); + var payload = new FrontendWritableRoutePayload( + anchor, + new FrontendWritableRoutePayload.RootDescriptor( + FrontendWritableRoutePayload.RootKind.DIRECT_SLOT, + anchor, + null + ), + new FrontendWritableRoutePayload.LeafDescriptor( + FrontendWritableRoutePayload.LeafKind.DIRECT_SLOT, + anchor, + null, + List.of(), + null, + null + ), + List.of(new FrontendWritableRoutePayload.StepDescriptor( + FrontendWritableRoutePayload.StepKind.DIRECT_SLOT, + anchor, + null, + List.of(), + null, + null + )) + ); + var nodes = new LinkedHashMap(); + nodes.put( + "entry", + new FrontendCfgGraph.SequenceNode( + "entry", + List.of( + new OpaqueExprValueItem(anchor, "recv0"), + new CallItem(anchor, "append", "recv0", List.of(), "v0", payload) + ), + "stop" + ) + ); + nodes.put("stop", new FrontendCfgGraph.StopNode("stop", FrontendCfgGraph.StopKind.RETURN, null)); + + var graph = new FrontendCfgGraph("entry", nodes); + + assertEquals(2, graph.nodes().size()); + } + + @Test + void constructorRejectsDirectSlotCommitStepOnAssignmentPayload() { + var assignment = new AssignmentExpression("=", identifier("parr"), identifier("rhs"), SYNTHETIC_RANGE); + var payload = new FrontendWritableRoutePayload( + assignment, + new FrontendWritableRoutePayload.RootDescriptor( + FrontendWritableRoutePayload.RootKind.DIRECT_SLOT, + identifier("parr"), + null + ), + new FrontendWritableRoutePayload.LeafDescriptor( + FrontendWritableRoutePayload.LeafKind.DIRECT_SLOT, + identifier("parr"), + null, + List.of(), + null, + null + ), + List.of(new FrontendWritableRoutePayload.StepDescriptor( + FrontendWritableRoutePayload.StepKind.DIRECT_SLOT, + identifier("parr"), + null, + List.of(), + null, + null + )) + ); + var nodes = new LinkedHashMap(); + nodes.put( + "entry", + new FrontendCfgGraph.SequenceNode( + "entry", + List.of(new AssignmentItem(assignment, List.of(), "v0", null, payload)), + "stop" + ) + ); + nodes.put("stop", new FrontendCfgGraph.StopNode("stop", FrontendCfgGraph.StopKind.RETURN, null)); + + var exception = assertThrows( + IllegalArgumentException.class, + () -> new FrontendCfgGraph("entry", nodes) + ); + + assertAll( + () -> assertTrue(exception.getMessage().contains("DIRECT_SLOT commit step")), + () -> assertTrue(exception.getMessage().contains("call payloads")), + () -> assertTrue(exception.getMessage().contains("entry")) + ); + } + + @Test + void constructorRejectsDirectSlotCommitStepForNonDirectSlotRoute() { + var anchor = identifier("call"); + var payload = new FrontendWritableRoutePayload( + anchor, + new FrontendWritableRoutePayload.RootDescriptor( + FrontendWritableRoutePayload.RootKind.SELF_CONTEXT, + anchor, + null + ), + new FrontendWritableRoutePayload.LeafDescriptor( + FrontendWritableRoutePayload.LeafKind.PROPERTY, + identifier("payload"), + null, + List.of(), + "payload", + null + ), + List.of(new FrontendWritableRoutePayload.StepDescriptor( + FrontendWritableRoutePayload.StepKind.DIRECT_SLOT, + identifier("payload"), + null, + List.of(), + null, + null + )) + ); + var nodes = new LinkedHashMap(); + nodes.put( + "entry", + new FrontendCfgGraph.SequenceNode( + "entry", + List.of(new CallItem(anchor, "push_back", "recv0", List.of(), "v0", payload)), + "stop" + ) + ); + nodes.put("stop", new FrontendCfgGraph.StopNode("stop", FrontendCfgGraph.StopKind.RETURN, null)); + + var exception = assertThrows( + IllegalArgumentException.class, + () -> new FrontendCfgGraph("entry", nodes) + ); + + assertAll( + () -> assertTrue(exception.getMessage().contains("DIRECT_SLOT root/leaf")), + () -> assertTrue(exception.getMessage().contains("entry")) + ); + } + + @Test + void constructorRejectsNonTerminalDirectSlotCommitStep() { + var anchor = identifier("parr"); + var payload = new FrontendWritableRoutePayload( + anchor, + new FrontendWritableRoutePayload.RootDescriptor( + FrontendWritableRoutePayload.RootKind.DIRECT_SLOT, + anchor, + null + ), + new FrontendWritableRoutePayload.LeafDescriptor( + FrontendWritableRoutePayload.LeafKind.DIRECT_SLOT, + anchor, + null, + List.of(), + null, + null + ), + List.of( + new FrontendWritableRoutePayload.StepDescriptor( + FrontendWritableRoutePayload.StepKind.PROPERTY, + identifier("owner"), + null, + List.of(), + "owner", + null + ), + new FrontendWritableRoutePayload.StepDescriptor( + FrontendWritableRoutePayload.StepKind.DIRECT_SLOT, + anchor, + null, + List.of(), + null, + null + ) + ) + ); + var nodes = new LinkedHashMap(); + nodes.put( + "entry", + new FrontendCfgGraph.SequenceNode( + "entry", + List.of(new CallItem(anchor, "append", "recv0", List.of(), "v0", payload)), + "stop" + ) + ); + nodes.put("stop", new FrontendCfgGraph.StopNode("stop", FrontendCfgGraph.StopKind.RETURN, null)); + + var exception = assertThrows( + IllegalArgumentException.class, + () -> new FrontendCfgGraph("entry", nodes) + ); + + assertAll( + () -> assertTrue(exception.getMessage().contains("terminal")), + () -> assertTrue(exception.getMessage().contains("entry")) + ); + } + private static IdentifierExpression identifier(String name) { return new IdentifierExpression(name, SYNTHETIC_RANGE); } diff --git a/src/test/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendWritableRouteSupportTest.java b/src/test/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendWritableRouteSupportTest.java index 95a87d92..8021a885 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendWritableRouteSupportTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendWritableRouteSupportTest.java @@ -1155,7 +1155,8 @@ void reverseCommitWithRuntimeGateUsesStaticFastPathForConcreteCarrier() throws E (_, _, _, currentCarrierSlotId) -> { emitterCalls.add(currentCarrierSlotId); return "unused_gate"; - } + }, + FrontendWritableRouteSupport.ReverseCommitRouteOrigin.MUTATING_CALL ); var instructions = block.getNonTerminatorInstructions(); var storeInsn = assertInstanceOf(StorePropertyInsn.class, instructions.getFirst()); @@ -1200,7 +1201,8 @@ void reverseCommitWithRuntimeGateSkipsSharedCarrierWithoutCallingEmitter() throw (_, _, _, currentCarrierSlotId) -> { emitterCalls.add(currentCarrierSlotId); return "unused_gate"; - } + }, + FrontendWritableRouteSupport.ReverseCommitRouteOrigin.ASSIGNMENT ); assertAll( @@ -1250,7 +1252,8 @@ void reverseCommitWithRuntimeGateBranchesForVariantCarrierAndContinuesWithPromot List.of(new LirInstruction.VariableOperand(currentCarrierSlotId)) )); return gateSlotId; - } + }, + FrontendWritableRouteSupport.ReverseCommitRouteOrigin.MUTATING_CALL ); var entryInstructions = block.getNonTerminatorInstructions(); var gateCallInsn = assertInstanceOf(CallGlobalInsn.class, entryInstructions.getFirst()); @@ -1283,6 +1286,163 @@ void reverseCommitWithRuntimeGateBranchesForVariantCarrierAndContinuesWithPromot ); } + /// The engine-owned property layer of a mutating-call route must skip the packed writeback: + /// the getter returned a detached copy and the interpreter does not persist the mutation. + @Test + void reverseCommitSkipsPackedEnginePropertyStepForMutatingCallGate() throws Exception { + var session = prepareSession(); + var block = new LirBasicBlock("entry"); + session.targetFunction().addBasicBlock(block); + session.ensureVariable("packed_slot", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + var chain = new FrontendWritableRouteSupport.FrontendWritableAccessChain( + identifier("polygon"), + new FrontendWritableRouteSupport.FrontendWritableRoot( + "engine receiver root", + "packed_slot", + GdPackedNumericArrayType.PACKED_INT32_ARRAY + ), + new FrontendWritableRouteSupport.DirectSlotLeaf( + "packed_slot", + GdPackedNumericArrayType.PACKED_INT32_ARRAY + ), + List.of(new FrontendWritableRouteSupport.InstancePropertyCommitStep("self", "polygon", true)) + ); + + FrontendWritableRouteSupport.reverseCommit( + session, + block, + chain, + "packed_slot", + FrontendWritableRouteSupport.createStaticCarrierWritebackGate(session) + ); + + assertTrue(block.getNonTerminatorInstructions().isEmpty()); + } + + /// The same packed carrier on a GDCC script property layer keeps the redundant same-identity + /// store (retained route), anchoring that the engine-property skip is provenance-scoped. + @Test + void reverseCommitKeepsPackedScriptPropertyStepForMutatingCallGate() throws Exception { + var session = prepareSession(); + var block = new LirBasicBlock("entry"); + session.targetFunction().addBasicBlock(block); + session.ensureVariable("packed_slot", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + var chain = new FrontendWritableRouteSupport.FrontendWritableAccessChain( + identifier("payloads"), + new FrontendWritableRouteSupport.FrontendWritableRoot( + "script receiver root", + "packed_slot", + GdPackedNumericArrayType.PACKED_INT32_ARRAY + ), + new FrontendWritableRouteSupport.DirectSlotLeaf( + "packed_slot", + GdPackedNumericArrayType.PACKED_INT32_ARRAY + ), + List.of(new FrontendWritableRouteSupport.InstancePropertyCommitStep("self", "payloads")) + ); + + FrontendWritableRouteSupport.reverseCommit( + session, + block, + chain, + "packed_slot", + FrontendWritableRouteSupport.createStaticCarrierWritebackGate(session) + ); + + var storeInsn = assertInstanceOf(StorePropertyInsn.class, block.getNonTerminatorInstructions().getFirst()); + assertAll( + () -> assertEquals("self", storeInsn.objectId()), + () -> assertEquals("payloads", storeInsn.propertyName()), + () -> assertEquals("packed_slot", storeInsn.valueId()) + ); + } + + /// Runtime-gate walk on a mutating-call route: the packed engine-property layer must skip + /// inline without ever consulting the runtime emitter (the carrier is statically packed). + @Test + void reverseCommitWithRuntimeGateSkipsPackedEnginePropertyForMutatingCallRoute() throws Exception { + var session = prepareSession(); + var block = new LirBasicBlock("entry"); + session.targetFunction().addBasicBlock(block); + session.ensureVariable("packed_slot", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + var chain = new FrontendWritableRouteSupport.FrontendWritableAccessChain( + identifier("polygon"), + new FrontendWritableRouteSupport.FrontendWritableRoot( + "engine receiver root", + "packed_slot", + GdPackedNumericArrayType.PACKED_INT32_ARRAY + ), + new FrontendWritableRouteSupport.DirectSlotLeaf( + "packed_slot", + GdPackedNumericArrayType.PACKED_INT32_ARRAY + ), + List.of(new FrontendWritableRouteSupport.InstancePropertyCommitStep("self", "polygon", true)) + ); + + var emitterCalls = new ArrayList(); + var continuationBlock = FrontendWritableRouteSupport.reverseCommitWithRuntimeGate( + session, + block, + chain, + "packed_slot", + (_, _, _, currentCarrierSlotId) -> { + emitterCalls.add(currentCarrierSlotId); + return "unused_gate"; + }, + FrontendWritableRouteSupport.ReverseCommitRouteOrigin.MUTATING_CALL + ); + + assertAll( + () -> assertSame(block, continuationBlock), + () -> assertEquals(List.of(), emitterCalls), + () -> assertTrue(block.getNonTerminatorInstructions().isEmpty()), + () -> assertFalse(block.hasTerminator()) + ); + } + + /// The identical engine-property step on an assignment route keeps the writeback: the + /// interpreter persists `obj.prop[i] = v` through read-modify-write, so only mutating-call + /// routes may skip. + @Test + void reverseCommitWithRuntimeGateKeepsPackedEnginePropertyForAssignmentRoute() throws Exception { + var session = prepareSession(); + var block = new LirBasicBlock("entry"); + session.targetFunction().addBasicBlock(block); + session.ensureVariable("packed_slot", GdPackedNumericArrayType.PACKED_INT32_ARRAY); + var chain = new FrontendWritableRouteSupport.FrontendWritableAccessChain( + identifier("polygon"), + new FrontendWritableRouteSupport.FrontendWritableRoot( + "engine receiver root", + "packed_slot", + GdPackedNumericArrayType.PACKED_INT32_ARRAY + ), + new FrontendWritableRouteSupport.DirectSlotLeaf( + "packed_slot", + GdPackedNumericArrayType.PACKED_INT32_ARRAY + ), + List.of(new FrontendWritableRouteSupport.InstancePropertyCommitStep("self", "polygon", true)) + ); + + var continuationBlock = FrontendWritableRouteSupport.reverseCommitWithRuntimeGate( + session, + block, + chain, + "packed_slot", + (_, _, _, currentCarrierSlotId) -> { + throw new IllegalStateException("statically packed carrier must never reach the runtime emitter"); + }, + FrontendWritableRouteSupport.ReverseCommitRouteOrigin.ASSIGNMENT + ); + + var storeInsn = assertInstanceOf(StorePropertyInsn.class, block.getNonTerminatorInstructions().getFirst()); + assertAll( + () -> assertSame(block, continuationBlock), + () -> assertEquals("self", storeInsn.objectId()), + () -> assertEquals("polygon", storeInsn.propertyName()), + () -> assertEquals("packed_slot", storeInsn.valueId()) + ); + } + @Test void reverseCommitUpdatesCarrierBeforeGatingOuterStep() throws Exception { var session = prepareSession(); diff --git a/src/test/java/gd/script/gdcc/test_suite/GdScriptUnitTestCompileRunnerTest.java b/src/test/java/gd/script/gdcc/test_suite/GdScriptUnitTestCompileRunnerTest.java index 53d02874..97018c92 100644 --- a/src/test/java/gd/script/gdcc/test_suite/GdScriptUnitTestCompileRunnerTest.java +++ b/src/test/java/gd/script/gdcc/test_suite/GdScriptUnitTestCompileRunnerTest.java @@ -156,6 +156,8 @@ public class GdScriptUnitTestCompileRunnerTest { "member/builtin_property_writeback_vector3.gd", "member/callable_value_refs.gd", "member/compound_assignment.gd", + "member/packed_call_func_identity.gd", + "member/packed_ref_full_usage.gd", "member/signal_connect_lambda.gd", "member/signal_emit_connect.gd", "member/signal_inherited_and_engine.gd", diff --git a/src/test/java/gd/script/gdcc/util/type/ExplicitCastSupportTest.java b/src/test/java/gd/script/gdcc/util/type/ExplicitCastSupportTest.java index 4e62acc5..5be2ccd8 100644 --- a/src/test/java/gd/script/gdcc/util/type/ExplicitCastSupportTest.java +++ b/src/test/java/gd/script/gdcc/util/type/ExplicitCastSupportTest.java @@ -255,6 +255,27 @@ void parameterizedContainerBaseOnlyParity() { assertTrue(ExplicitCastSupport.checkAllowed(registry, stringIntDict, intStringDict)); } + @Test + void sameFamilyPackedCastIsRuntimeCastNotIdentity() { + // Same-family `as` produces a COW copy with a FRESH identity (interpreter-probed), so + // the classifier must route it to the + // runtime-cast surface (BuiltinCastInsnGen emits whitelisted `new_copy`) — never IDENTITY, + // which would lower to a sharing AssignInsn. + assertEquals( + ExplicitCastDecision.BUILTIN_RUNTIME_CAST, + ExplicitCastSupport.classify( + registry, + GdPackedNumericArrayType.PACKED_INT32_ARRAY, + GdPackedNumericArrayType.PACKED_INT32_ARRAY + ) + ); + // Sanity anchor: other same-type builtins keep the identity classification. + assertEquals( + ExplicitCastDecision.IDENTITY, + ExplicitCastSupport.classify(registry, GdIntType.INT, GdIntType.INT) + ); + } + @Test void arrayPackedFamilyBidirectional() { assertEquals( diff --git a/src/test/resources/packed_ref_semantics/packed_ref_probes.gd b/src/test/resources/packed_ref_semantics/packed_ref_probes.gd new file mode 100644 index 00000000..7b4e4433 --- /dev/null +++ b/src/test/resources/packed_ref_semantics/packed_ref_probes.gd @@ -0,0 +1,544 @@ +class_name PackedRefProbes +extends RefCounted + +## Dual-run comparison probe library for Packed*Array reference semantics (the gdcc-compiled +## side and the interpreter side share this same source file). +## +## Each probe_* method covers one row (or one sub-scenario) of the Packed*Array reference +## semantics matrix and prints one `PROBE||` line; the golden file +## `packed_ref_semantics_golden.txt` is locked by a Godot 4.5.2 interpreter run. +## +## Probe methods must be deterministic and execute in a fixed order via run_all; the golden +## line order mirrors the run_all execution order (the golden owns payload facts, while the +## case inventory — names and order — is anchored by PackedRefSemanticsGoldenInventoryTest). +## Register new scenarios in the golden file in sync. +## +## Note: this file is compiled by gdcc, so do not use syntax beyond the compiler's current +## capability surface; no preload/class_name self-references inside the library — keep the +## compilation unit self-contained. + +signal array_signal(value: PackedInt32Array) +signal multi_signal(values: PackedInt32Array, tag: int, names: PackedStringArray) + +## The suspended-period observations of CORO_AWAIT are written by the coroutine body and read +## after the main probe resumes (see probe_coroutine_await). +var coro_during := "unset" + +class PropertyHolder extends RefCounted: + var payloads := PackedInt32Array([1]) + + func mutate_self() -> void: + payloads.push_back(7) + + func size() -> int: + return payloads.size() + +## Fixed execution entry point for all probes. CORO_AWAIT / MIXED_COMBINATION / +## RETURN_VALUE_SHARING are coroutines (they await internally); the rest are synchronous. +## This method is itself a coroutine and calls tree.quit() itself at the end. The driver calls +## it fire-and-forget (must not await): the interpreter reports "Trying to get a return value +## of a method that returns void" when awaiting a gdcc-compiled void coroutine, so the quit +## responsibility lives in the library, not in the driver. +func run_all(tree: SceneTree) -> void: + probe_local_alias() + probe_parameter_visibility() + probe_script_property() + probe_typed_array_element() + probe_dictionary_value() + probe_builtin_property_mutation() + probe_builtin_property_reassign() + probe_builtin_property_subscript_write() + probe_plus_equals_rebind() + probe_duplicate() + probe_signal_argument() + probe_for_iteration() + probe_append_array_alias() + probe_resize_alias() + probe_index_write_alias() + probe_variant_identity() + probe_parameter_default() + probe_element_rebind() + probe_string_iter_elements() + probe_in_membership() + probe_equality() + probe_dict_key_hash() + probe_as_same_family() + await probe_coroutine_await(tree) + probe_signal_multi() + probe_dynamic_variant_receiver() + probe_static_var() + probe_lambda_capture() + await probe_mixed_combination(tree) + probe_control_flow_branches() + await probe_return_value_sharing(tree) + probe_engine_method_packed_arg() + probe_string_array_mutation() + tree.quit() + +## Local alias sharing. +func probe_local_alias() -> void: + var a := PackedInt32Array([1]) + var b := a + a.push_back(7) + var strings := PackedStringArray(["one"]) + var strings_alias := strings + strings.push_back("seven") + print("PROBE|LOCAL_ALIAS|int=%d,%d;string=%d,%d" % [a.size(), b.size(), strings.size(), strings_alias.size()]) + +## Callee mutations on a parameter are visible to the caller. +func mutate_parameter(p: PackedInt32Array) -> void: + p.push_back(7) + +func mutate_string_parameter(p: PackedStringArray) -> void: + p.push_back("seven") + +func probe_parameter_visibility() -> void: + var r := PackedInt32Array([1]) + mutate_parameter(r) + var strings := PackedStringArray(["one"]) + mutate_string_parameter(strings) + print("PROBE|PARAM_VISIBILITY|int=%d;string=%d" % [r.size(), strings.size()]) + +## Script property mutation persists (both self-internal and external access paths). +func probe_script_property() -> void: + var self_obj := PropertyHolder.new() + self_obj.mutate_self() + var self_size := self_obj.size() + var outside_obj := PropertyHolder.new() + outside_obj.payloads.push_back(7) + print("PROBE|SCRIPT_PROPERTY|self=%d;outside=%d" % [self_size, outside_obj.payloads.size()]) + +## Typed Array element mutation persists. +func probe_typed_array_element() -> void: + var arr: Array[PackedInt32Array] = [PackedInt32Array([1])] + arr[0].push_back(7) + print("PROBE|TYPED_ARRAY_ELEMENT|%d" % arr[0].size()) + +## Packed mutation inside a Dictionary value persists. +func probe_dictionary_value() -> void: + var d := {"k": PackedInt32Array([1])} + d["k"].push_back(7) + print("PROBE|DICT_VALUE|%d" % d["k"].size()) + +## Builtin engine property getter returns a copy — direct mutation does not persist. +func probe_builtin_property_mutation() -> void: + var poly := Polygon2D.new() + poly.polygon = PackedVector2Array([Vector2.ZERO]) + poly.polygon.push_back(Vector2(1, 1)) + var size_without_reassign := poly.polygon.size() + poly.free() + print("PROBE|BUILTIN_PROPERTY_MUTATION|without_reassign=%d" % size_without_reassign) + +## Builtin engine property explicit reassignment persists. +func probe_builtin_property_reassign() -> void: + var poly := Polygon2D.new() + poly.polygon = PackedVector2Array([Vector2.ZERO]) + var p := poly.polygon + p.push_back(Vector2(2, 2)) + poly.polygon = p + var size_with_reassign := poly.polygon.size() + poly.free() + print("PROBE|BUILTIN_PROPERTY_REASSIGN|with_reassign=%d" % size_with_reassign) + +## Builtin engine property subscript write persists (interpreter read-modify-write) — in +## contrast to the 7a method call not persisting; this anchors the "writeback removal is +## limited to the mutating-call route" behavior. +func probe_builtin_property_subscript_write() -> void: + var poly := Polygon2D.new() + poly.polygon = PackedVector2Array([Vector2.ZERO, Vector2(3, 3)]) + poly.polygon[0] = Vector2(9, 9) + var observed := poly.polygon[0] + poly.free() + print("PROBE|BUILTIN_PROPERTY_SUBSCRIPT_WRITE|%s" % observed) + +## `a += b` produces a new array and rebinds; old aliases do not observe it. +func probe_plus_equals_rebind() -> void: + var a := PackedInt32Array([1]) + var b := a + a += PackedInt32Array([2]) + print("PROBE|PLUS_EQUALS_REBIND|%d,%d" % [a.size(), b.size()]) + +## `duplicate()` produces an independent copy. +func probe_duplicate() -> void: + var a := PackedInt32Array([1]) + var b := a.duplicate() + a.push_back(7) + print("PROBE|DUPLICATE|%d,%d" % [a.size(), b.size()]) + +## Typed signal argument mutation is visible to the emitter. +func _on_array_signal(value: PackedInt32Array) -> void: + value.push_back(7) + +func probe_signal_argument() -> void: + array_signal.connect(_on_array_signal) + var source := PackedInt32Array([1]) + array_signal.emit(source) + print("PROBE|SIGNAL_ARG|%d" % source.size()) + array_signal.disconnect(_on_array_signal) + +## `for-in` live iteration — elements appended during iteration are visited by the current pass. +func probe_for_iteration() -> void: + var a := PackedInt32Array([1, 2, 3]) + var total := 0 + for value in a: + total += value + var visited := 0 + var pushed := false + for value in a: + visited += 1 + if not pushed: + a.push_back(9) + pushed = true + print("PROBE|FOR_ITER|sum=%d;size=%d;mutation_visits=%d" % [total, a.size(), visited]) + +## `append_array` mutation is shared through aliases. +func probe_append_array_alias() -> void: + var a := PackedInt32Array([1]) + var b := a + a.append_array(PackedInt32Array([2, 3])) + print("PROBE|APPEND_ARRAY_ALIAS|%d,%d" % [a.size(), b.size()]) + +## `resize` mutation is shared through aliases. +func probe_resize_alias() -> void: + var a := PackedInt32Array([1, 2, 3]) + var b := a + a.resize(1) + print("PROBE|RESIZE_ALIAS|%d,%d" % [a.size(), b.size()]) + +## Subscript writes are shared through aliases. +func probe_index_write_alias() -> void: + var a := PackedInt32Array([1, 2]) + var b := a + a[0] = 99 + print("PROBE|INDEX_WRITE_ALIAS|%d,%d" % [a[0], b[0]]) + +## Variant round-trip conversion preserves sharing (three-way sharing of a/v/b). +func probe_variant_identity() -> void: + var a := PackedInt32Array([1]) + var v: Variant = a + var b: PackedInt32Array = v + a.push_back(7) + v.push_back(8) + print("PROBE|VARIANT_IDENTITY|%d,%d,%d" % [a.size(), b.size(), v.size()]) + +## Parameter default values materialize a fresh array per call. +func parameter_default(p: PackedInt32Array = PackedInt32Array([1])) -> int: + p.push_back(7) + return p.size() + +func probe_parameter_default() -> void: + var first := parameter_default() + var second := parameter_default() + print("PROBE|PARAM_DEFAULT_SHARED|%d,%d" % [first, second]) + +## Element slot rebinding — after `arr[0] = `, a previously extracted e still +## points at the old array. +func probe_element_rebind() -> void: + var arr: Array[PackedInt32Array] = [PackedInt32Array([1, 2])] + var e := arr[0] + arr[0] = PackedInt32Array([9]) + print("PROBE|ELEMENT_REBIND|e=%d,%d;slot=%d,%d" % [e.size(), e[0], arr[0].size(), arr[0][0]]) + +## PackedStringArray iteration elements are String copies — mutating the loop variable does +## not affect the array. +func probe_string_iter_elements() -> void: + var a := PackedStringArray(["one", "two"]) + var joined := "" + for s in a: + s = s + "!" + joined += s + print("PROBE|STRING_ITER_ELEMENTS|%s;%s" % [joined, ",".join(a)]) + +## `in` membership test matches by content. +func probe_in_membership() -> void: + var a := PackedInt32Array([1, 2, 3]) + var int_hit := 2 in a + var int_miss := 5 in a + var s := PackedStringArray(["one", "two"]) + var str_hit := "one" in s + var str_miss := "three" in s + print("PROBE|IN_MEMBERSHIP|%d,%d,%d,%d" % [int(int_hit), int(int_miss), int(str_hit), int(str_miss)]) + +## `==`/`!=` compare by content (four sub-scenarios: alias, alias after mutation, same content +## different identity, unequal). +func probe_equality() -> void: + var a := PackedInt32Array([1, 2]) + var b := a + var eq_alias := a == b + a.push_back(3) + var eq_alias_after_mutation := a == b + var same_content := PackedInt32Array([1, 2]) + var eq_same_content := same_content == PackedInt32Array([1, 2]) + var neq_different := a != same_content + print("PROBE|EQUALITY|%d,%d,%d,%d" % [int(eq_alias), int(eq_alias_after_mutation), int(eq_same_content), int(neq_different)]) + +## Dictionary keys hash by content: mutating an inserted key through a shared alias makes the +## old entry lookup fail, and re-inserting the mutated key creates a second entry. +func probe_dict_key_hash() -> void: + var k := PackedInt32Array([1, 2]) + var d := {k: "v"} + var lookup_same_content: Variant = d.get(PackedInt32Array([1, 2]), "missing") + var alias := k + k.push_back(3) + var lookup_mutated_key: Variant = d.get(alias, "missing") + var lookup_mutated_fresh: Variant = d.get(PackedInt32Array([1, 2, 3]), "missing") + var after_mutation_size := d.size() + d[alias] = "w" + print("PROBE|DICT_KEY_HASH|same_content=%s;mutated=%s;fresh=%s;size_after_mutate=%d;size_after_reinsert=%d" % [ + str(lookup_same_content), str(lookup_mutated_key), str(lookup_mutated_fresh), after_mutation_size, d.size() + ]) + +## Same-family `as` produces a COW copy (fresh identity), NOT sharing — locked by interpreter +## probing. After a.push_back(7): a=2, b=1, v=2. +## Additional probe: a static same-type `as` (c) is likewise a COW copy — after the second +## push: a=3, v=3 (shared), b=1, c=2 (the two `as` results are independent). +func probe_as_same_family() -> void: + var a := PackedInt32Array([1]) + var v: Variant = a + var b := v as PackedInt32Array + a.push_back(7) + var c := a as PackedInt32Array + a.push_back(8) + print("PROBE|AS_SAME_FAMILY|%d,%d,%d,%d" % [a.size(), b.size(), v.size(), c.size()]) + +## Coroutine parameter/capture mutations are visible in both directions across await +## suspension. The resume order relies on process_frame dispatching in connection order: the +## coroutine body resumes first (records during and pushes 4/44), the main probe resumes +## afterwards and prints. +func _coro_mutator(param: PackedInt32Array, captured: PackedInt32Array, tree: SceneTree) -> void: + param.push_back(2) + await tree.process_frame + coro_during = "%d,%d" % [param.size(), captured.size()] + param.push_back(4) + captured.push_back(44) + +func probe_coroutine_await(tree: SceneTree) -> void: + coro_during = "unset" + var param := PackedInt32Array([1]) + var captured := PackedInt32Array([10]) + _coro_mutator(param, captured, tree) + var before := "%d,%d" % [param.size(), captured.size()] + param.push_back(3) + captured.push_back(33) + await tree.process_frame + print("PROBE|CORO_AWAIT|before=%s;during=%s;after=%d,%d" % [before, coro_during, param.size(), captured.size()]) + +## Multi-argument typed signal carrying packed: callback mutation is visible to the emitter. +func _on_multi_signal(values: PackedInt32Array, tag: int, names: PackedStringArray) -> void: + values.push_back(tag) + names.push_back("seven") + +func probe_signal_multi() -> void: + multi_signal.connect(_on_multi_signal) + var values := PackedInt32Array([1]) + var names := PackedStringArray(["one"]) + multi_signal.emit(values, 5, names) + print("PROBE|SIGNAL_MULTI|%d,%d,%d" % [values.size(), values[1], names.size()]) + multi_signal.disconnect(_on_multi_signal) + +## Dynamic Variant receiver route case: a dynamic mutating call on a Variant variable is +## visible to the original variable through the shared identity. +func probe_dynamic_variant_receiver() -> void: + var a := PackedInt32Array([1]) + var v: Variant = a + v.push_back(7) + print("PROBE|DYNAMIC_VARIANT_MUTATION|%d,%d" % [a.size(), v.size()]) + +## Static variable mutation persists (the static leaf shares identity under Variant storage). +## Note the explicit type annotation is required: `static var x := ...` performs no type +## inference (metadata falls back to Variant), and mutating calls on a Variant static carrier +## remain a pre-existing gdcc fail-closed surface — not this case's target. +static var static_packed: PackedInt32Array = PackedInt32Array([1]) + +func mutate_static() -> void: + static_packed.push_back(7) + +func read_static_size() -> int: + return static_packed.size() + +func probe_static_var() -> void: + mutate_static() + print("PROBE|STATIC_VAR|%d" % read_static_size()) + +## Lambda capture mutations are visible in both directions (the capture slot is a Variant +## sharing identity). +func probe_lambda_capture() -> void: + var a := PackedInt32Array([1]) + var callback := func() -> void: a.push_back(7) + callback.call() + print("PROBE|LAMBDA_CAPTURE|%d" % a.size()) + +## Mixed scenario: script property + lambda capture + signal + coroutine await + for live +## iteration combined in one deterministic chain observing each other. Flow: the coroutine body +## pushes 2 then suspends; the main probe live-iterates the property array, and the iteration +## body emits a signal that grows the array (the new element is visited by the current pass); +## after the main probe awaits, the coroutine resumes first, calls the captured lambda to grow +## local and pushes the property again; the main probe resumes last and prints all +## observations. Property / capture / signal argument / iteration source are all visible to +## each other through the shared identity. +var mix_property := PackedInt32Array([1]) +var mix_lambda: Callable + +func _on_mix_signal(value: PackedInt32Array) -> void: + value.push_back(9) + +func _mix_coroutine(local: PackedInt32Array, tree: SceneTree) -> void: + mix_property.push_back(2) + await tree.process_frame + mix_lambda.call() + mix_property.push_back(4) + +func probe_mixed_combination(tree: SceneTree) -> void: + mix_property = PackedInt32Array([1]) + var local := PackedInt32Array([10]) + mix_lambda = func() -> void: local.push_back(20) + array_signal.connect(_on_mix_signal) + _mix_coroutine(local, tree) + var sum := 0 + var visits := 0 + for v in mix_property: + sum += v + visits += 1 + if visits == 1: + array_signal.emit(mix_property) + array_signal.disconnect(_on_mix_signal) + await tree.process_frame + print("PROBE|MIXED_COMBINATION|sum=%d;visits=%d;property=%d;local=%d" % [ + sum, visits, mix_property.size(), local.size()]) + +## Complex control flow: nested if/elif/else and match (literal / combined branches / guard / +## wildcard / nested match) with direct packed mutation inside branch bodies; branch selection +## and in-branch mutation are visible to aliases through the shared identity, and the final +## content depends on the actually hit branches. +func probe_control_flow_branches() -> void: + var a := PackedInt32Array([1]) + var alias := a + if a[0] < 0: + a.push_back(-1) + elif a[0] == 1: + a.push_back(2) + if alias.size() == 2: + a.push_back(3) + else: + a.push_back(-3) + else: + a.push_back(99) + match a.size(): + 1: + a.push_back(10) + 2, 3: + a.push_back(20) + match a[2]: + var bound when bound > 0: + a.push_back(bound * 10) + _: + a.push_back(-30) + _: + a.push_back(40) + var tag := 0 + if alias.size() == 5: + match a[3]: + 20: + tag = 1 + _: + tag = -1 + match a[0]: + 2: + a.push_back(70) + _: + a.push_back(60) + print("PROBE|CONTROL_FLOW_BRANCHES|%s;%d,%d" % [str(a), alias.size(), tag]) + +## Return-value identity contract: returning a locally built array (no second holder; usable +## and mutable), returning after mutating a parameter (the original array and the returned +## alias share), multi-branch returns (field returns share / freshly built returns are +## independent), returning through a lambda capture (capture slot shares), and returning after +## a coroutine await (post-resume mutations are visible on both the returned alias and the +## field). +var return_field := PackedInt32Array([100]) + +func _ret_build_local() -> PackedInt32Array: + var local := PackedInt32Array([1]) + local.push_back(2) + return local + +func _ret_mutate_param(a: PackedInt32Array) -> PackedInt32Array: + a.push_back(7) + return a + +func _ret_branch(flag: bool) -> PackedInt32Array: + if flag: + return return_field + return PackedInt32Array([9]) + +func _ret_after_await(tree: SceneTree) -> PackedInt32Array: + return_field.push_back(101) + await tree.process_frame + return_field.push_back(102) + return return_field + +func probe_return_value_sharing(tree: SceneTree) -> void: + var built := _ret_build_local() + built.push_back(3) + var src := PackedInt32Array([1]) + var out := _ret_mutate_param(src) + out.push_back(8) + return_field = PackedInt32Array([100]) + var via_branch := _ret_branch(true) + var fresh := _ret_branch(false) + fresh.push_back(10) + var captured := PackedInt32Array([50]) + var getter := func() -> PackedInt32Array: return captured + var via_lambda: PackedInt32Array = getter.call() + via_lambda.push_back(51) + var via_coro: PackedInt32Array = await _ret_after_await(tree) + via_coro.push_back(103) + print("PROBE|RETURN_VALUE_SHARING|built=%d;src=%d,%d;branch=%d;fresh=%d;lambda=%d,%d;coro=%d,%d" % [ + built.size(), src.size(), src[2], via_branch.size(), fresh.size(), + captured.size(), captured[1], via_coro.size(), return_field.size()]) + +## Engine method boundary: in-place mutation after a builtin method returns packed +## (String.split, wrap_temp path) is visible through an alias; a builtin method receiving a +## packed argument (String.join, internal_ptr path) reads the post-mutation content on the +## alias; the instance engine methods StreamPeerBuffer.set_data_array/get_data_array take +## packed arguments and return packed values (engine copy semantics on both sides: local +## mutation after set does not flow back into the first get; get returns an independent new +## array — a second get after mutation still yields the engine-internal state). +func probe_engine_method_packed_arg() -> void: + var parts := "a,b".split(",") + var parts_alias := parts + parts.push_back("c") + var joined := "/".join(parts_alias) + var peer: StreamPeerBuffer = StreamPeerBuffer.new() + var bytes := PackedByteArray([1, 2, 3]) + peer.set_data_array(bytes) + bytes.push_back(4) + var read_back := peer.get_data_array() + read_back.push_back(9) + var reread := peer.get_data_array() + print("PROBE|ENGINE_METHOD_PACKED_ARG|%s;%d,%d;%s;%s" % [ + joined, bytes.size(), read_back.size(), str(read_back), str(reread)]) + +## PackedStringArray specifics: aliased push_back / subscript write / insert / remove_at are +## visible through the shared identity; content ==/!= (two sub-scenarios: same content +## different identity, and after shared-alias mutation); content confirmation after +## sort/reverse applied through the shared identity. +func probe_string_array_mutation() -> void: + var a := PackedStringArray(["one"]) + var alias := a + a.push_back("two") + alias[0] = "ONE" + a.insert(1, "mid") + alias.remove_at(2) + var b := PackedStringArray(["ONE", "mid"]) + var eq_same := a == b + var c := a + c.push_back("x") + var eq_after := a == b + var neq_after := a != b + var d := PackedStringArray(["b", "a", "c"]) + var d_alias := d + d.sort() + d_alias.reverse() + print("PROBE|STRING_ARRAY_MUTATION|%s;%d,%d,%d;%s" % [ + ",".join(a), int(eq_same), int(eq_after), int(neq_after), ",".join(d)]) diff --git a/src/test/resources/packed_ref_semantics/packed_ref_semantics_golden.txt b/src/test/resources/packed_ref_semantics/packed_ref_semantics_golden.txt new file mode 100644 index 00000000..f6f70422 --- /dev/null +++ b/src/test/resources/packed_ref_semantics/packed_ref_semantics_golden.txt @@ -0,0 +1,60 @@ +# Dual-run comparison golden for Packed*Array reference semantics +# +# This file is locked by a Godot Engine v4.5.2.stable.official.6ce3de25a interpreter run of +# `packed_ref_probes.gd` (via the interpreter-side driver). It is the executable golden for +# the 24-row semantics matrix plus the dynamic Variant receiver case, the mixed scenario, +# and 4 supplementary cases (complex control flow / return-value identity / engine method +# boundary / PackedStringArray specifics). Line order mirrors the PackedRefProbes.run_all +# execution order; this file owns payload facts, while the case inventory (names and order) +# is anchored by PackedRefSemanticsGoldenInventoryTest. +# +# Parsing rules (ProbeOutput): only `PROBE||` three-segment lines are valid; +# comments and blank lines may appear in the golden resource only — non-PROBE lines in +# runtime output are treated as engine noise and ignored. +# +# Locking record: tmp/probes/packed_ref_semantics/results.txt (rows 1-17) and +# tmp/probes/packed_ref_semantics/results_phase_a.txt (rows 18-24 and the dynamic Variant +# receiver case). Row 22 (AS_SAME_FAMILY) was measured to be COW-copy semantics, revising +# the original "preserve sharing" expectation. + +PROBE|LOCAL_ALIAS|int=2,2;string=2,2 +PROBE|PARAM_VISIBILITY|int=2;string=2 +PROBE|SCRIPT_PROPERTY|self=2;outside=2 +PROBE|TYPED_ARRAY_ELEMENT|2 +PROBE|DICT_VALUE|2 +PROBE|BUILTIN_PROPERTY_MUTATION|without_reassign=1 +PROBE|BUILTIN_PROPERTY_REASSIGN|with_reassign=2 +PROBE|BUILTIN_PROPERTY_SUBSCRIPT_WRITE|(9.0, 9.0) +PROBE|PLUS_EQUALS_REBIND|2,1 +PROBE|DUPLICATE|2,1 +PROBE|SIGNAL_ARG|2 +PROBE|FOR_ITER|sum=6;size=4;mutation_visits=4 +PROBE|APPEND_ARRAY_ALIAS|3,3 +PROBE|RESIZE_ALIAS|1,1 +PROBE|INDEX_WRITE_ALIAS|99,99 +PROBE|VARIANT_IDENTITY|3,3,3 +PROBE|PARAM_DEFAULT_SHARED|2,2 +PROBE|ELEMENT_REBIND|e=2,1;slot=1,9 +PROBE|STRING_ITER_ELEMENTS|one!two!;one,two +PROBE|IN_MEMBERSHIP|1,0,1,0 +PROBE|EQUALITY|1,1,1,1 +PROBE|DICT_KEY_HASH|same_content=v;mutated=missing;fresh=missing;size_after_mutate=1;size_after_reinsert=2 +PROBE|AS_SAME_FAMILY|3,1,3,2 +PROBE|CORO_AWAIT|before=2,1;during=3,2;after=4,3 +PROBE|SIGNAL_MULTI|2,5,2 +PROBE|DYNAMIC_VARIANT_MUTATION|2,2 +# STATIC_VAR and LAMBDA_CAPTURE live in the main probe library (unblocked by the frontend +# route work that lifted the compile-time fail-closed gates on the static bare-property +# route and CAPTURE alias) and are appended in order at the end of the main library output. +PROBE|STATIC_VAR|2 +PROBE|LAMBDA_CAPTURE|2 +# MIXED_COMBINATION: mixed scenario (property + signal + lambda + coroutine await + for +# live iteration), locked by an interpreter run. +PROBE|MIXED_COMBINATION|sum=12;visits=3;property=4;local=2 +# Supplementary cases (complex control-flow branch mutation / return-value identity / +# engine method packed argument and return / PackedStringArray specifics), all locked by +# interpreter runs. +PROBE|CONTROL_FLOW_BRANCHES|[1, 2, 3, 20, 30, 60];6,1 +PROBE|RETURN_VALUE_SHARING|built=3;src=3,8;branch=4;fresh=2;lambda=2,51;coro=4,4 +PROBE|ENGINE_METHOD_PACKED_ARG|a/b/c;4,4;[1, 2, 3, 9];[1, 2, 3] +PROBE|STRING_ARRAY_MUTATION|ONE,mid,x;1,0,1;c,b,a diff --git a/src/test/test_suite/unit_test/script/member/packed_call_func_identity.gd b/src/test/test_suite/unit_test/script/member/packed_call_func_identity.gd new file mode 100644 index 00000000..c52ec2e5 --- /dev/null +++ b/src/test/test_suite/unit_test/script/member/packed_call_func_identity.gd @@ -0,0 +1,39 @@ +class_name PackedCallFuncIdentity +extends Node + +## Packed*Array reference-semantics call_func boundary anchor: the GDScript interpreter hands +## a packed array to the compiled class through an ordinary method call (call_func Variant +## ABI), and callee mutations must share identity with the caller; the reverse direction +## (the compiled class retains the array, the interpreter observes it later) shares identity +## as well. The ptrcall ABI exception does not apply to this path (see the runtime anchoring +## in PackedRefStorageModelSmokeTest). + +var retained: PackedInt32Array + +func mutate_and_count(a: PackedInt32Array) -> int: + a.push_back(7) + return a.size() + +func append_and_count(a: PackedInt32Array, extra: PackedInt32Array) -> int: + a.append_array(extra) + return a.size() + +func mutate_variant(a: Variant) -> int: + a.push_back(9) + return a.size() + +func retain_and_touch(a: PackedInt32Array) -> void: + retained = a + retained.push_back(5) + +func touch_retained() -> void: + retained.push_back(6) + +func produce_retained() -> PackedInt32Array: + return retained + +func read_retained_size() -> int: + return retained.size() + +func produce() -> PackedInt32Array: + return PackedInt32Array([1, 2]) diff --git a/src/test/test_suite/unit_test/script/member/packed_ref_full_usage.gd b/src/test/test_suite/unit_test/script/member/packed_ref_full_usage.gd new file mode 100644 index 00000000..89811d14 --- /dev/null +++ b/src/test/test_suite/unit_test/script/member/packed_ref_full_usage.gd @@ -0,0 +1,83 @@ +class_name PackedRefFullUsage +extends Node + +## Full-usage combination anchor for Packed*Array reference semantics: function calls / +## while / for live iteration / if / match / ternary / instance fields / lambda capture / +## coroutine await / signals share one packed identity along a single chain, and every +## construct's mutations must be visible to each other by reference semantics. + +signal grown(value: PackedInt32Array, tag: int) +signal resume_now + +var field: PackedInt32Array = PackedInt32Array([1]) +var signal_hits: int = 0 +var coro_done: bool = false + +func _bump(a: PackedInt32Array, v: int) -> void: + a.push_back(v) + +func _classify(n: int) -> int: + match n: + 0: + return 0 + 1: + return 10 + _: + return 99 + +func _on_grown(value: PackedInt32Array, tag: int) -> void: + signal_hits += 1 + value.push_back(tag) + +## Function calls + while + ternary + for live iteration + match + if: field [1] -> [1,2,7], returns 1133. +func run_control_flow() -> int: + _bump(field, 2) + var sum := 0 + var i := 0 + while i < field.size(): + var v: int = field[i] + sum += v if v >= 0 else -v + i += 1 + var visits := 0 + for v in field: + visits += 1 + if visits == 1: + _bump(field, 7) + sum += _classify(1) + if visits == 3 and field.size() == 3: + sum += 100 + elif visits == 3: + sum += 200 + else: + sum += 300 + return sum * 10 + visits + +## Lambda capture + signal argument: captured [5] -> lambda pushes 6 -> signal callback pushes 8, returns 381. +func run_signal_lambda() -> int: + var captured := PackedInt32Array([5]) + var cb := func() -> void: captured.push_back(6) + grown.connect(_on_grown) + cb.call() + grown.emit(captured, 8) + grown.disconnect(_on_grown) + return captured.size() * 100 + captured[2] * 10 + signal_hits + +## Coroutine: pushes 3 before suspending (field sharing is immediately visible), then after +## resume_now pushes 4 and sets the completion flag. +func start_coroutine() -> void: + _coro_body() + +func _coro_body() -> void: + _bump(field, 3) + await resume_now + _bump(field, 4) + coro_done = true + +func emit_resume() -> void: + resume_now.emit() + +func read_field_size() -> int: + return field.size() + +func read_coro_done() -> bool: + return coro_done diff --git a/src/test/test_suite/unit_test/validation/member/packed_call_func_identity.gd b/src/test/test_suite/unit_test/validation/member/packed_call_func_identity.gd new file mode 100644 index 00000000..9a79cff2 --- /dev/null +++ b/src/test/test_suite/unit_test/validation/member/packed_call_func_identity.gd @@ -0,0 +1,72 @@ +extends Node + +## Validates that the compiled class preserves packed array identity sharing across the +## call_func boundary (reference semantics): mutations in either direction are visible to +## both sides. If identity broke at the wrapper (regressing to value semantics), every +## observation below would diverge. + +func _ready() -> void: + var target = get_parent().get_node_or_null("__UNIT_TEST_TARGET_NODE_NAME__") + if target == null: + push_error("Target node missing.") + return + + # typed parameter: callee push_back is visible to the interpreter caller. + var a := PackedInt32Array([1]) + var n: int = target.call("mutate_and_count", a) + if n != 2 or a.size() != 2 or a[1] != 7: + push_error("Typed-param mutation not visible to caller: n=%s a=%s" % [n, str(a)]) + return + + # typed parameter append_array: shared across the same boundary. + var extra := PackedInt32Array([8, 9]) + var m: int = target.call("append_and_count", a, extra) + if m != 4 or a.size() != 4 or a[3] != 9: + push_error("append_array mutation not visible to caller: m=%s a=%s" % [m, str(a)]) + return + + # Variant parameter: the packed payload shares identity through a Variant holder copy. + var v := PackedInt32Array([1]) + var k: int = target.call("mutate_variant", v) + if k != 2 or v.size() != 2 or v[1] != 9: + push_error("Variant-param mutation not visible to caller: k=%s v=%s" % [k, str(v)]) + return + + # Reverse sharing: after the compiled class retains the array in a field, the alias held + # by the interpreter still observes subsequent mutations (identity preserved). + var b := PackedInt32Array([1]) + target.call("retain_and_touch", b) + if b.size() != 2 or b[1] != 5: + push_error("Retained-field mutation not visible immediately: b=%s" % str(b)) + return + target.call("touch_retained") + if b.size() != 3 or b[2] != 6: + push_error("Later callee-side mutation not visible to caller alias: b=%s" % str(b)) + return + + # Return boundary identity: the retained field handed out via return is shared three-way + # with the field itself and the caller alias; caller mutations are observable through the + # field, and callee mutations are visible through the returned alias. + var r: PackedInt32Array = target.call("produce_retained") + r.push_back(11) + if int(target.call("read_retained_size")) != 4 or b.size() != 4 or b[3] != 11: + push_error("Returned alias mutation not visible through retained field: b=%s retained=%s" % [ + str(b), + int(target.call("read_retained_size")) + ]) + return + target.call("touch_retained") + if r.size() != 5 or r[4] != 6: + push_error("Callee-side mutation not visible to returned alias: r=%s" % str(r)) + return + + # Freshly built return value: the callee holds no second holder, so the caller receives a + # usable, further-mutable new array (identity independence is unobservable here and not + # anchored; the return-boundary identity contract is anchored by the retained case above). + var p: PackedInt32Array = target.call("produce") + p.push_back(3) + if p.size() != 3: + push_error("Returned packed array not mutable on caller side: p=%s" % str(p)) + return + + print("__UNIT_TEST_PASS_MARKER__") diff --git a/src/test/test_suite/unit_test/validation/member/packed_ref_full_usage.gd b/src/test/test_suite/unit_test/validation/member/packed_ref_full_usage.gd new file mode 100644 index 00000000..3461de2b --- /dev/null +++ b/src/test/test_suite/unit_test/validation/member/packed_ref_full_usage.gd @@ -0,0 +1,41 @@ +extends Node + +## Observation anchor for the full-usage combination: if packed regressed to value semantics +## in any construct (function calls / loops / branches / lambda / coroutine / signals), the +## aggregated result would diverge immediately. + +func _ready() -> void: + var target = get_parent().get_node_or_null("__UNIT_TEST_TARGET_NODE_NAME__") + if target == null: + push_error("Target node missing.") + return + + var control: int = target.call("run_control_flow") + if control != 1133: + push_error("Control-flow combination diverged: %s (expected 1133)" % control) + return + if int(target.call("read_field_size")) != 3: + push_error("Field mutations through calls/loop not visible: %s" % int(target.call("read_field_size"))) + return + + var signal_lambda: int = target.call("run_signal_lambda") + if signal_lambda != 381: + push_error("Signal+lambda combination diverged: %s (expected 381)" % signal_lambda) + return + + target.call("start_coroutine") + if bool(target.call("read_coro_done")): + push_error("Coroutine completed before resume signal.") + return + if int(target.call("read_field_size")) != 4: + push_error("Pre-await coroutine mutation not visible: %s" % int(target.call("read_field_size"))) + return + target.call("emit_resume") + if not bool(target.call("read_coro_done")) or int(target.call("read_field_size")) != 5: + push_error("Post-await coroutine mutation not visible: done=%s size=%s" % [ + bool(target.call("read_coro_done")), + int(target.call("read_field_size")) + ]) + return + + print("__UNIT_TEST_PASS_MARKER__")