159 lines
8.6 KiB
GDScript
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
|