2026-10-09 12:49:07 +02:00

159 lines
8.6 KiB
GDScript

@tool
extends RefCounted
## Pure, headless-testable detector for a silently-dropped property write.
##
## Godot's [method Object.set] is void and discards a Variant it cannot assign to
## the target property (a wrong type), so a write can "succeed" while the value
## never lands — a false success that sends an LLM caller chasing a value that was
## never stored. This module brackets a set with before/after readbacks and reports
## the drop. It is the single SSOT shared by every property-set path: the editor
## node/scene handlers (via [code]commands/editor_helpers.gd[/code]) and the runtime
## autoload's [code]runtime.set_property[/code] (via a direct preload).
##
## Runtime-clean by construction — it names only value types, [String] formatting,
## and [@GlobalScope] functions ([method @GlobalScope.type_string],
## [method @GlobalScope.is_equal_approx]), never an editor class, and preloads
## nothing. An editor symbol anywhere in this static graph would parse-fail the
## runtime autoload in an unstripped export (godotengine/godot#91713), and the
## runtime server preloads this file; keep it editor-free.
## Classify a property write as clean / accepted-but-adjusted / silently dropped.
## Godot's [method Object.set] is void: it silently discards a wrong-type Variant
## (a false success), but it also silently RESHAPES a convertible one (float 7.9 →
## int 7, "Foo/" → "Foo", a normalized direction) — accepted, yet the stored value
## differs from what was asked. Bracket a set with before/after readbacks and this
## returns a tri-state so the caller can succeed-clean, succeed-with-a-warning, or
## fail. Shared by the editor node/scene paths and the runtime autoload.
## [br]
## [param before] the property's value BEFORE the set (its type is the oracle)
## [param after] the property's value AFTER the set (read from the same place)
## [param coerced] the value that was assigned (already Coerce-d from JSON)
## [param path] property path, for the message
## [br]
## Returns a tri-state Dictionary:
## [code]{"status": "ok"}[/code] — stored ≈ requested (exact set, int→float where
## 5.0 == 5, set-to-same); clean success.
## [code]{"status": "adjusted", "warning": String}[/code] — the write was ACCEPTED
## but the engine reshaped it (truncate/sanitize/clamp/normalize) so the stored
## value ≠ the request; success + a warning naming property/requested/stored.
## [code]{"status": "dropped", "error": String}[/code] — a silent wrong-type drop
## (cross-family, value unchanged, ≠ request) → the caller emits SET_FAILED.
static func describe_set_drop(
before: Variant, after: Variant, coerced: Variant, path: String,
) -> Dictionary:
# Reported no error but nothing is there: the property is not on this object. The
# hint leads with the most common build-flow cause — a script-defined property set
# before its script is attached — then the mistyped-name and dedicated-API cases.
if after == null and coerced != null:
return {"status": "dropped", "error":
"set() on '%s' reported no error but readback is null. " % path
+ "The property is not present on this object — most often it is defined "
+ "by a script not attached to this node yet (attach the script first, "
+ "then set its properties), a mistyped property name, or a property that "
+ "needs a dedicated API (e.g. set_shader_parameter, add_animation_library)."}
# Stored value equals the request → clean success (exact, int→float 5.0==5, set-to-same).
if _values_loosely_equal(after, coerced):
return {"status": "ok"}
# Stored value ≠ requested. The convertible-family gate is the SINGLE arbiter of
# reshape-vs-drop on BOTH the changed- and unchanged-value paths:
# • same family → the engine legitimately reshaped an in-family value
# (truncate 7.9→7, sanitize "Foo/"→"Foo", normalize a direction) → ADJUSTED.
# • cross family → the engine did NOT store a real value. It either kept the old
# one (a plain drop, after == before) OR — for a bound setter like position /
# modulate — Variant-converted the wrong type to a ZERO and stored that,
# DESTROYING the prior value (after ≠ before). BOTH are discards, not reshapes.
# Gating on the family (not on after == before) is what catches this
# destructive-zero case that a non-zero prior exposes.
if before != null and _same_convertible_family(typeof(coerced), typeof(before)):
return {"status": "adjusted", "warning": _adjusted_warning(after, coerced, path)}
# Cross-family → the write did not land as a real value (kept-old or zeroed).
return {"status": "dropped", "error":
"property '%s' rejected the value: expected type %s, got %s (%s). "
% [path, type_string(typeof(before)), type_string(typeof(coerced)),
_brief_set_value(coerced)]
+ "Godot's set() silently discards a wrong-type value. Pass a value of the "
+ "expected type — for struct types use the tagged form "
+ "(e.g. {\"type\":\"Vector2\",\"x\":0,\"y\":0})."}
## Warning text for an accepted-but-adjusted write, naming what was stored vs asked.
static func _adjusted_warning(after: Variant, coerced: Variant, path: String) -> String:
return ("note: '%s' stored %s but you requested %s — the engine adjusted the value to fit %s."
% [path, _brief_set_value(after), _brief_set_value(coerced), type_string(typeof(after))])
## True when assigning type [param t_coerced] onto a property of type
## [param t_before] stays within one convertible family Godot's set() accepts and
## reshapes in place: the exact same type (a value type may normalize/transform on
## set), both numeric (bool/int/float — the engine truncates float→int etc.), or
## both stringy (String/StringName/NodePath — normalized/sanitized). OBJECT is
## excluded — Resource assignment has no in-family reshape, so a wrong-subtype drop
## must reach the identity backstop rather than being trusted here.
static func _same_convertible_family(t_coerced: int, t_before: int) -> bool:
if t_coerced == TYPE_OBJECT or t_before == TYPE_OBJECT:
return false
if t_coerced == t_before:
return true
if _is_numeric_type(t_coerced) and _is_numeric_type(t_before):
return true
if _is_stringy_type(t_coerced) and _is_stringy_type(t_before):
return true
# Integer/float vector variants convert too (Vector2 7.9 → Vector2i 7), mirroring
# _values_loosely_equal so a truncate-onto-current here reads as adjusted, not dropped.
if (t_coerced == TYPE_VECTOR2 or t_coerced == TYPE_VECTOR2I) \
and (t_before == TYPE_VECTOR2 or t_before == TYPE_VECTOR2I):
return true
if (t_coerced == TYPE_VECTOR3 or t_coerced == TYPE_VECTOR3I) \
and (t_before == TYPE_VECTOR3 or t_before == TYPE_VECTOR3I):
return true
return false
## True when [param t] is a numeric Variant type. bool counts — the engine assigns
## it as 0/1 in a numeric slot, so a bool↔int/float write is a real conversion.
static func _is_numeric_type(t: int) -> bool:
return t == TYPE_BOOL or t == TYPE_INT or t == TYPE_FLOAT
## True when [param t] is a string-like Variant type. String, StringName and
## NodePath are mutually assignable in the engine (set() converts between them).
static func _is_stringy_type(t: int) -> bool:
return t == TYPE_STRING or t == TYPE_STRING_NAME or t == TYPE_NODE_PATH
## Cross-type-tolerant value equality used to judge whether a write landed. Same
## type → exact [code]==[/code]. Within the numeric family (bool/int/float) and the
## string family (String/StringName/NodePath) values compare across the family, and
## integer/float vector variants (Vector2i↔Vector2, Vector3i↔Vector3) compare
## approximately — each mirrors a conversion Godot's set() performs. Anything else
## across types is unequal.
static func _values_loosely_equal(a: Variant, b: Variant) -> bool:
var ta := typeof(a)
var tb := typeof(b)
if ta == tb:
return a == b
if _is_numeric_type(ta) and _is_numeric_type(tb):
return is_equal_approx(float(a), float(b))
if _is_stringy_type(ta) and _is_stringy_type(tb):
return str(a) == str(b)
if (ta == TYPE_VECTOR2 or ta == TYPE_VECTOR2I) \
and (tb == TYPE_VECTOR2 or tb == TYPE_VECTOR2I):
var v2a: Vector2 = type_convert(a, TYPE_VECTOR2)
var v2b: Vector2 = type_convert(b, TYPE_VECTOR2)
return v2a.is_equal_approx(v2b)
if (ta == TYPE_VECTOR3 or ta == TYPE_VECTOR3I) \
and (tb == TYPE_VECTOR3 or tb == TYPE_VECTOR3I):
var v3a: Vector3 = type_convert(a, TYPE_VECTOR3)
var v3b: Vector3 = type_convert(b, TYPE_VECTOR3)
return v3a.is_equal_approx(v3b)
return false
## Short, safe rendering of a value for an error message (truncated at 60 chars).
static func _brief_set_value(value: Variant) -> String:
var s := var_to_str(value)
if s.length() > 60:
return s.left(57) + "..."
return s