extends Node
## Compile and run a GDScript file at runtime. The execution half of
## spec/bot-testbed.md (d); AssetSync already does the sync half.
##
## ⚠ NO `class_name`: registered as the autoload singleton `ScriptHost`, and a
## global class of the same name makes Godot refuse to compile it. Tests
## instantiate the script directly with load(...).new(), as asset_sync.gd does.
##
## WHAT THIS IS FOR. A behaviour -- a bot brain, a world tweak, a probe -- should
## be a file you can edit and re-run without a rebuild or a redeploy, and that
## every peer in the room picks up. The sync is not this file's job: a script is
## an asset of kind "script", it is NOT in AssetSync.IMMUTABLE_KINDS, so it gets
## the MUTABLE rule (world:assets:script:<name> -> newest publish wins), which is
## exactly "edit the file and it propagates".
##
## ⛔ TRUST. There is NO sandbox here. A compiled script has the full engine:
## OS, FileAccess, DirAccess, every singleton. GDScript is for TRUSTED peers only.
## The trust decision is NOT made in this file and must not be: it belongs at the
## POINTER, because a content hash is an address, not a proof
## (keet_tile_index.gd:11-13). StateBase writes are already role-gated by room
## membership, so the room roles ARE the gate -- whoever could publish the pointer
## was already allowed to. This file only asks: did the caller say this source is
## trusted? If a sandboxed VM ever lands, it replaces `_compile` and nothing else.
##
## ⚠ KNOWN GDScript RUNTIME-COMPILE LIMITS (docs/07-metaverse-runtime-scripting.md):
## - inner classes fail to resolve (godot#70518) -- keep scripts flat
## - a spurious "Can't open file" can be printed even on success (godot#66593)
## - exported builds are documented as needing GDScript export mode "Text";
## export_presets.cfg sets no script_export_mode, so this is UNVERIFIED for
## an exported build. The editor binary (scripts/dev-run.sh) is unaffected.
## A script compiled and instantiated successfully.
signal script_loaded(name: String)
## Compile or instantiation failed. `reason` is engine text, already logged.
signal script_failed(name: String, reason: String)
## The script called finish() on itself, or was unloaded.
signal script_unloaded(name: String)
## A published script's bytes arrived and can be run. It is NOT running.
signal script_available(name: String, path: String)
## AssetSync kind. Not in IMMUTABLE_KINDS, so mutable: newest publish wins.
const KIND := "script"
## Meta names owned by AdminFacade. Referenced rather than retyped so a rename
## there is a compile error here instead of a silently ignored meta.
class AdminFacadeScopes:
const CALL_SCOPES := "admin_call_scopes"
const ROLES := "admin_roles"
## Entry points, all optional. A script implementing none still compiles and is
## held -- useful for a one-shot that does its work in _init.
const ENTRY_INIT := "_bot_init"
const ENTRY_TICK := "_bot_tick"
const ENTRY_EXIT := "_bot_exit"
## Containment bindings, from spec/bot-testbed.md (e2). A script is a bot with
## body = none, so it obeys the same rule as any other bot: it dies with its
## container. The full registry takes a SET of bindings (map / room / peer:<id> /
## session); until it exists, ScriptHost honours the one that matters here.
##
## ⚠ THE DEFAULT IS "map", AND THAT IS A BEHAVIOUR CHANGE. Shipped 0.1123.0 held
## every script at session scope with no way to say otherwise, so a _bot_tick kept
## running after a map change -- the same defect as the creature leak, in the
## execution layer instead of the scene tree. Leaking must stop being the default:
## a script that genuinely wants to outlive the map now says so.
const BINDING_MAP := "map"
const BINDING_SESSION := "session"
const DEFAULT_BINDINGS: Array[String] = [BINDING_MAP]
## name -> {script: GDScript, instance: Object, ticks: bool, bindings: Array}
var _loaded: Dictionary = {}
## name -> local path of a script whose bytes have arrived but which is NOT
## running. Arrival does not execute: see _on_asset_ready.
var _available: Dictionary = {}
## Names sync_and_run() is waiting on. A fetch that has no holder yet is not an
## error -- it stays open and completes when a peer with the bytes joins -- so
## this can outlive the call that created it.
var _pending_run: Dictionary = {}
## Injected so the arrival path is testable with no overlay, room or mesh
## (scenes/test/gate_script_host.gd). Defaults to the autoload at runtime, the
## same shape asset_sync.gd uses for its own dependencies.
var sync: Object = null
func _ready() -> void:
_register_admin()
# EVENT-DRIVEN, no polling: the map change announces itself. GameState may be
# absent in a gate that instantiates this script directly, so this is guarded
# rather than assumed.
var gs: Node = get_node_or_null(^"/root/GameState")
if gs != null and gs.has_signal("map_changing"):
if not gs.map_changing.is_connected(_on_map_changing):
gs.map_changing.connect(_on_map_changing)
if sync == null:
sync = get_node_or_null(^"/root/AssetSync")
if sync != null and sync.has_signal("asset_ready"):
if not sync.asset_ready.is_connected(_on_asset_ready):
sync.asset_ready.connect(_on_asset_ready)
## Reachable from `admin-cli.cjs`, which is the project's existing agent surface
## (spec/daemon-session.md calls --daemon-only the canonical agent interface).
## Without this the whole autoload is unreachable dead code.
##
## node scripts/admin-cli.cjs raw state.set \
## '{"roomId":"<room>","key":"admin:cmd:1","value":"{\"op\":\"call\",
## \"path\":\"/global/scripts.sync_and_run\",\"args\":[\"mybot\"],
## \"requester_role\":\"admin\"}"}'
##
## Methods are opt-in (admin_facade.gd:516) and default to role >= admin.
##
## ⚠ admin_call_scope MATTERS HERE. An admin:cmd travels over StateBase to EVERY
## peer, and the default scope "authority" means only the authority actually runs
## it. That is right for publish (one publisher) and wrong for sync_and_run --
## the entire point of which is that every peer fetches the script and runs it.
## sync_and_run is idempotent by construction (run() unloads any previous holder
## of the name first), which is the precondition "local" requires.
func _register_admin() -> void:
add_to_group("admin_controllable")
set_meta("admin_scope", "global") # persists across map changes
set_meta("admin_name", "scripts") # -> /global/scripts
set_meta("admin_exposed_methods", [
"run_file", "sync_and_run", "unload", "publish",
"loaded_names", "available_names", "available_path", "is_loaded",
"bindings_of",
])
# sync_and_run MUST be "local": an admin:cmd reaches every peer and the
# default scope "authority" would mean only ONE peer ever runs the script --
# the opposite of the feature. Safe because run() unloads any previous holder
# of the name first, so a repeat is idempotent.
#
# Dictionary metas, not `admin_call_scope:<method>`: Object::set_meta rejects
# a colon (core/object/object.cpp:1036, is_valid_ascii_identifier), which is
# why the old per-method convention never worked on any Godot 4.
set_meta(AdminFacadeScopes.CALL_SCOPES, {
"sync_and_run": "local",
"unload": "local",
"loaded_names": "local",
"available_names": "local",
"available_path": "local",
"is_loaded": "local",
"bindings_of": "local",
})
set_meta(AdminFacadeScopes.ROLES, {
"loaded_names": {"call": "peer"},
"available_names": {"call": "peer"},
"available_path": {"call": "peer"},
"is_loaded": {"call": "peer"},
"bindings_of": {"call": "peer"},
})
## Bytes for some asset landed. If it is a script, record it as AVAILABLE.
##
## ⛔ ARRIVAL DOES NOT RUN IT. A file that executes itself on arrival turns "sync
## a script" into "remote code execution by default", and that is a different
## feature with a different blast radius -- even inside a trusted fleet, and even
## though the publisher had to pass the room's role gate to write the pointer.
## Running is an explicit act: run_file(), or sync_and_run() where the CALLER has
## already said it wants this name executed when it turns up.
func _on_asset_ready(kind: String, name: String, path: String) -> void:
if kind != KIND:
return
_available[name] = path
script_available.emit(name, path)
if _pending_run.has(name):
var pending_bindings: Array = _pending_run[name] as Array
_pending_run.erase(name)
run_file(path, name, pending_bindings)
else:
print("[ScriptHost] script '%s' available at %s (not running -- explicit by design)"
% [name, path])
## The map changed: unload every script bound to it. A script bound to "session"
## survives, which is the ONLY way to get the old behaviour and now has to be asked
## for explicitly.
##
## Availability is NOT cleared -- the bytes are still fetched and still valid, and
## _available is a cache of what could be run, not of what is running. Only the
## running instance is map-bound.
func _on_map_changing(_map_id: String) -> void:
var doomed: Array[String] = []
for name: String in _loaded.keys():
var b: Array = (_loaded[name] as Dictionary).get("bindings", DEFAULT_BINDINGS) as Array
if b.has(BINDING_MAP):
doomed.append(name)
for name: String in doomed:
print("[ScriptHost] map change: unloading map-bound script '%s'" % name)
unload(name)
_pending_run.clear()
## Which containment bindings a running script holds, or [] if it is not running.
func bindings_of(name: String) -> Array:
if not _loaded.has(name):
return []
return (_loaded[name] as Dictionary).get("bindings", DEFAULT_BINDINGS) as Array
## Bytes for a script arrived and it is ready to run. NOT running yet.
func available_names() -> Array:
return _available.keys()
func available_path(name: String) -> String:
return String(_available.get(name, ""))
## Fetch a published script by name and run it when the bytes are here.
##
## Returns true if it ran immediately (already local), false if it is now pending
## -- which is NOT a failure. AssetSync.ensure returns "" when the bytes are not
## here yet and the fetch stays open, so a script published by a peer who is
## currently offline will run when they come back.
func sync_and_run(name: String, bindings: Array = []) -> bool:
if _available.has(name):
return run_file(_available[name], name, bindings) != null
if sync == null:
push_warning("[ScriptHost] no AssetSync — cannot sync '%s'" % name)
return false
var path := String(sync.ensure(KIND, name))
if path != "":
_available[name] = path
return run_file(path, name, bindings) != null
_pending_run[name] = bindings
print("[ScriptHost] '%s' not local yet — will run when the bytes arrive" % name)
return false
func _process(delta: float) -> void:
# Tick only what asked to be ticked. A script that throws here would take the
# frame down, so it is unloaded on the first failure rather than every frame.
for name: String in _loaded.keys():
var e: Dictionary = _loaded[name]
if not e.get("ticks", false):
continue
var inst: Object = e.get("instance")
if inst == null or not is_instance_valid(inst):
unload(name)
continue
# Benchmark (#114 §7): per brain script, CPU per tick (nav queries: bot.nav).
var bench_t0 := Time.get_ticks_usec()
inst.call(ENTRY_TICK, delta)
SimStats.add("bot.brain:" + name.get_file(), Time.get_ticks_usec() - bench_t0)
## Compile source text into a GDScript. Returns null and emits script_failed on
## error. Does NOT run anything.
##
## Source text, NOT load(): load() on a fetched .gd goes through the resource
## cache, which does not invalidate for a path it has already seen -- so the
## SECOND version of a script would silently run as the first. Compiling the
## bytes sidesteps the cache entirely, which matters precisely because the whole
## point is that a script gets edited and re-published.
func compile(source: String, name: String) -> GDScript:
var gd := GDScript.new()
gd.source_code = source
var err: int = gd.reload()
if err != OK:
var reason := "reload() failed with error %d" % err
push_error("[ScriptHost] %s: %s" % [name, reason])
script_failed.emit(name, reason)
return null
return gd
## Compile, instantiate and start a script under `name`. Replaces any script
## already loaded under that name -- that IS the update path.
##
## Returns the instance, or null on failure.
## `context` is applied to the instance BEFORE _bot_init, which is the only
## ordering that works: a brain that must act on its own body needs to know which
## body that is while it is initialising, not a frame later. Applied with set(),
## so a script simply declares `var bot_id: String` and receives it; a script that
## declares nothing is unaffected.
func run(source: String, name: String, bindings: Array = [], context: Dictionary = {}) -> Object:
var gd := compile(source, name)
if gd == null:
return null
return _start(gd, name, bindings, context)
## Instantiate an already-compiled GDScript and start it under `name`.
##
## Split out of run() so a script that arrives as a RESOURCE rather than as text
## can take the same path. Everything after compilation is identical for both,
## and the two cases drifting apart is exactly how a packed brain would end up
## skipping _bot_init or the context application.
func _start(gd: GDScript, name: String, bindings: Array = [], context: Dictionary = {}) -> Object:
if _loaded.has(name):
unload(name)
var inst: Variant = gd.new()
if inst == null:
var reason := "new() returned null (script has no instantiable base?)"
push_error("[ScriptHost] %s: %s" % [name, reason])
script_failed.emit(name, reason)
return null
_loaded[name] = {
"script": gd,
"instance": inst,
"ticks": (inst as Object).has_method(ENTRY_TICK),
"bindings": (bindings if not bindings.is_empty() else DEFAULT_BINDINGS),
}
for k: Variant in context.keys():
(inst as Object).set(String(k), context[k])
if (inst as Object).has_method(ENTRY_INIT):
(inst as Object).call(ENTRY_INIT)
script_loaded.emit(name)
return inst
## Run a script from a path on disk. The path may be res://, user:// or absolute
## -- a fetched script lands under user://content, an authored one is wherever
## you wrote it.
func run_file(path: String, name: String = "", bindings: Array = [], context: Dictionary = {}) -> Object:
var script_name := name if name != "" else path.get_file().get_basename()
# res:// is the PACKED case, and it cannot be read as text. GDScript exports
# as .gdc binary tokens, so FileAccess + compile() returned error 7 for every
# shipped brain in an exported build -- bots/*.gd reached the pck in 0.1141.0
# and still could not run (hyper-games #77 item 3).
#
# load() is safe HERE and nowhere else in this file: a packed script is
# immutable for the life of the build, so the resource cache cannot serve a
# stale version of it. That is precisely the hazard compile() exists to avoid
# for user:// scripts, which are fetched and re-published while running.
if path.begins_with("res://"):
var gd: Variant = load(path)
if gd == null or not (gd is GDScript):
var why := "load(%s) returned %s, expected a GDScript" % [path, "null" if gd == null else "a " + type_string(typeof(gd))]
push_error("[ScriptHost] %s" % why)
script_failed.emit(script_name, why)
return null
return _start(gd as GDScript, script_name, bindings, context)
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
var reason := "cannot open %s (error %d)" % [path, FileAccess.get_open_error()]
push_error("[ScriptHost] %s" % reason)
script_failed.emit(script_name, reason)
return null
var src := f.get_as_text()
f.close()
return run(src, script_name, bindings, context)
## Stop and drop a script. Safe to call for an unknown name.
func unload(name: String) -> void:
if not _loaded.has(name):
return
var e: Dictionary = _loaded[name]
var inst: Object = e.get("instance")
if inst != null and is_instance_valid(inst) and inst.has_method(ENTRY_EXIT):
inst.call(ENTRY_EXIT)
# RefCounted instances free themselves; a Node would not, and a script that
# returns a Node from new() is not something this holds a tree slot for.
if inst is Node and is_instance_valid(inst):
(inst as Node).queue_free()
_loaded.erase(name)
script_unloaded.emit(name)
func is_loaded(name: String) -> bool:
return _loaded.has(name)
func loaded_names() -> Array:
return _loaded.keys()
## Publish a local .gd so every peer in the room gets it. Returns the oid, or ""
## if the overlay is not up. Mutable kind: re-publishing the same name supersedes.
func publish(path: String, name: String) -> String:
if sync == null:
push_warning("[ScriptHost] no AssetSync — cannot publish %s" % name)
return ""
return String(sync.publish_file(KIND, name, path, "gd"))