127 lines
6.4 KiB
GDScript
127 lines
6.4 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
## Pure decision + message helpers for the stale-live-instance method-call hazard.
|
|
## On Godot < 4.4 a live instance already running a script does NOT see edits to that
|
|
## script — newly-added members AND changed method bodies — until the editor relaunches:
|
|
## editor.refresh, re-attaching the script, and even a brand-new node all keep the OLD
|
|
## code. 4.4+ hot-reloads promptly on a display editor, so no hint there — but a HEADLESS
|
|
## 4.4+ editor never re-instantiates the live node, so the stale hint fires there too,
|
|
## with its own headless-specific wording.
|
|
##
|
|
## Empirically characterised across 4.2.0 / 4.3.0 / 4.4.1 / 4.5.0 / 4.6.2 (boundary
|
|
## 4.3 -> 4.4). The characterisation also proved a fresh node is
|
|
## ALSO stale on 4.2 AND 4.3, so both collapse to one recovery (relaunch) — there is
|
|
## no 4.2-vs-4.3 split.
|
|
##
|
|
## SPLIT (mirrors unfocused_backup.gd): every function here is PURE and headless —
|
|
## the decision predicates take the version `major`/`minor` plus the `headless` flag as
|
|
## data, so run_unit_tests.gd exercises every branch across major/minor/headless
|
|
## combinations (4.2-4.6 + a 5.0 guard) without an editor. The
|
|
## editor-coupled callers read the running version / on-disk source and feed these:
|
|
## - script_commands.gd : proactive hint on script.write of an existing .gd
|
|
## - node_commands.gd : reactive hint on node.call_method -> INVALID_METHOD
|
|
##
|
|
## Godot 4.x is the supported world; the gate is `major == 4 and (minor < 4 or headless)`,
|
|
## so the 4.0-4.3 hot-reload hazard is always matched, the 4.4+ hazard is matched only
|
|
## when headless, and any future 5.x correctly skips it. The editor-coupled callers feed
|
|
## `major`, `minor`, and the headless flag from `Engine.get_version_info()` /
|
|
## `DisplayServer`.
|
|
|
|
const _RECOVERY := (
|
|
"On Godot %s, a live instance already running this script keeps the OLD code: "
|
|
+ "changed method bodies AND newly-added members stay invisible to it. "
|
|
+ "editor.refresh, re-attaching the script, and even creating a fresh node do NOT "
|
|
+ "pick up the edit on Godot < 4.4 — relaunch the editor (or disable then re-enable "
|
|
+ "the plugin) before calling the changed or added members."
|
|
)
|
|
|
|
# Godot 4.4+ headless: a display editor hot-reloads live instances promptly, but a
|
|
# headless editor never re-instantiates them — a distinct hazard from the < 4.4
|
|
# engine-cache staleness, so it carries its own guidance (recovery is re-create or
|
|
# relaunch, never editor.refresh).
|
|
const _RECOVERY_HEADLESS := (
|
|
"On Godot %s, the live instance running this script is stale — "
|
|
+ "headless editors don't re-instantiate live nodes on reload — re-create the node "
|
|
+ "or relaunch a display editor; the edit is on disk, confirm with script_check."
|
|
)
|
|
|
|
const _WRITE_PREFIX := (
|
|
"Validate scripts with script_check or lsp_diagnostics (errors also surface in "
|
|
+ "editor_get_console). Then note: "
|
|
)
|
|
|
|
|
|
## Proactive trigger: an EXISTING .gd was re-written AND it compiled OK AND the
|
|
## editor is Godot < 4.4. (Create / 4.4+ / compile-fail / non-.gd -> no hint.)
|
|
static func should_warn_on_write(existed: bool, compiled_ok: bool, extension: String, major: int, minor: int) -> bool:
|
|
return existed and compiled_ok and extension == "gd" and major == 4 and minor < 4
|
|
|
|
|
|
## Reactive trigger: node.call_method hit INVALID_METHOD (has_method false) on a
|
|
## .gd-scripted node whose ON-DISK source DEFINES the method and COMPILES, and the live
|
|
## instance is stale. Two stale regimes: Godot < 4.4 in any mode (the editor never
|
|
## hot-reloads a live instance), OR Godot 4.4+ when [param headless] (a display editor
|
|
## hot-reloads — has_method would already be true — but a headless editor never
|
|
## re-instantiates the reloaded node). Method absent on disk -> typo, no hint. Disk
|
|
## doesn't compile -> the real fix is the compile error (Option B), no stale hint. 4.4+
|
|
## with a display -> not stale, so this never fires.
|
|
static func should_hint_on_call(
|
|
has_method: bool, disk_has_method: bool, disk_compiles: bool, is_gd: bool,
|
|
major: int, minor: int, headless: bool = false,
|
|
) -> bool:
|
|
if not ((not has_method) and disk_has_method and disk_compiles and is_gd and major == 4):
|
|
return false
|
|
return minor < 4 or headless
|
|
|
|
|
|
## The recovery guidance, tailored to the stale regime. [param ver_label] is the detected
|
|
## "major.minor" so the message names the real running version. When [param headless] and
|
|
## the engine is 4.4+ ([param minor] >= 4), returns the headless re-instantiation form;
|
|
## otherwise the < 4.4 engine-cache form (4.2 and 4.3 BOTH need relaunch; a fresh node
|
|
## helps on neither). The defaults keep the single-arg < 4.4 callers (write_hint) intact.
|
|
static func recovery_message(ver_label: String, minor: int = -1, headless: bool = false) -> String:
|
|
if headless and minor >= 4:
|
|
return _RECOVERY_HEADLESS % ver_label
|
|
return _RECOVERY % ver_label
|
|
|
|
|
|
## Composed hint for a successful script.write (deliberate ordering: validation
|
|
## guidance leads, the situational stale nudge takes the recency slot).
|
|
static func write_hint(ver_label: String) -> String:
|
|
return _WRITE_PREFIX + recovery_message(ver_label)
|
|
|
|
|
|
## True if `source` parses/compiles as GDScript. SAFE in-process parse via
|
|
## GDScript.new().reload() — NOT ResourceLoader.load(CACHE_MODE_IGNORE), which
|
|
## corrupts already-loaded scripts on every version (P-056). class_name is blanked
|
|
## first to avoid a global-class double-registration false positive (P-053).
|
|
static func source_compiles(source: String) -> bool:
|
|
var lines := source.split("\n")
|
|
for i in lines.size():
|
|
if lines[i].strip_edges().begins_with("class_name "):
|
|
lines[i] = ""
|
|
break
|
|
var script := GDScript.new()
|
|
script.source_code = "\n".join(lines)
|
|
return script.reload(false) == OK
|
|
|
|
|
|
## True if `source` defines `func <method>` (line scan on the raw text — no compile,
|
|
## no regex flags). Matches `func name(`, `static func name (`, and indented
|
|
## inner-class methods; ignores `func` appearing inside strings/comments because the
|
|
## stripped line must START with `func ` / `static func `.
|
|
static func source_has_method(source: String, method: String) -> bool:
|
|
if method.is_empty():
|
|
return false
|
|
for raw_line in source.split("\n"):
|
|
var line := raw_line.strip_edges()
|
|
if not (line.begins_with("func ") or line.begins_with("static func ")):
|
|
continue
|
|
var after := line.substr(line.find("func ") + 5).strip_edges()
|
|
var paren := after.find("(")
|
|
if paren == -1:
|
|
continue
|
|
if after.substr(0, paren).strip_edges() == method:
|
|
return true
|
|
return false
|