@tool extends "res://addons/godot_ai/handlers/command_handler.gd" ## Create, read, and edit VisualShader resources. Scene assignment is separate. ## ## `create_graph` builds a fully validated graph from a declarative spec; ## `get_graph` reports an existing graph (stages, nodes, params, connections, ## varyings); `edit_graph` applies a validated operation list to an existing ## resource; `node_catalog` lists the instantiable VisualShaderNode classes ## with the properties this handler accepts. Every write validates the whole ## result in memory first and saves through a same-directory temporary, so a ## failure never truncates the destination. const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd") const MAX_NODES := 256 const MAX_CONNECTIONS := 1024 const MAX_OPERATIONS := 512 const MAX_CATALOG_LIMIT := 500 const MODES := { "spatial": Shader.MODE_SPATIAL, "canvas_item": Shader.MODE_CANVAS_ITEM, "particles": Shader.MODE_PARTICLES, "sky": Shader.MODE_SKY, "fog": Shader.MODE_FOG, } const MODE_NAMES := { Shader.MODE_SPATIAL: "spatial", Shader.MODE_CANVAS_ITEM: "canvas_item", Shader.MODE_PARTICLES: "particles", Shader.MODE_SKY: "sky", Shader.MODE_FOG: "fog", } const STAGES := { "vertex": VisualShader.TYPE_VERTEX, "fragment": VisualShader.TYPE_FRAGMENT, "light": VisualShader.TYPE_LIGHT, "start": VisualShader.TYPE_START, "process": VisualShader.TYPE_PROCESS, "collide": VisualShader.TYPE_COLLIDE, "start_custom": VisualShader.TYPE_START_CUSTOM, "process_custom": VisualShader.TYPE_PROCESS_CUSTOM, "sky": VisualShader.TYPE_SKY, "fog": VisualShader.TYPE_FOG, } const STAGE_NAMES := { VisualShader.TYPE_VERTEX: "vertex", VisualShader.TYPE_FRAGMENT: "fragment", VisualShader.TYPE_LIGHT: "light", VisualShader.TYPE_START: "start", VisualShader.TYPE_PROCESS: "process", VisualShader.TYPE_COLLIDE: "collide", VisualShader.TYPE_START_CUSTOM: "start_custom", VisualShader.TYPE_PROCESS_CUSTOM: "process_custom", VisualShader.TYPE_SKY: "sky", VisualShader.TYPE_FOG: "fog", } const MODE_STAGES := { "spatial": ["vertex", "fragment", "light"], "canvas_item": ["vertex", "fragment", "light"], "particles": ["start", "process", "collide", "start_custom", "process_custom"], "sky": ["sky"], "fog": ["fog"], } ## Varyings are a spatial/canvas_item feature; particles/sky/fog have no ## vertex-to-fragment or fragment-to-light stage pair to carry them. const VARYING_MODES := { "spatial": true, "canvas_item": true, } const VARYING_MODE_VALUES := { "vertex_to_frag_light": VisualShader.VARYING_MODE_VERTEX_TO_FRAG_LIGHT, "frag_to_light": VisualShader.VARYING_MODE_FRAG_TO_LIGHT, } const VARYING_TYPE_VALUES := { "float": VisualShader.VARYING_TYPE_FLOAT, "int": VisualShader.VARYING_TYPE_INT, "uint": VisualShader.VARYING_TYPE_UINT, "vector2": VisualShader.VARYING_TYPE_VECTOR_2D, "vector3": VisualShader.VARYING_TYPE_VECTOR_3D, "vector4": VisualShader.VARYING_TYPE_VECTOR_4D, "boolean": VisualShader.VARYING_TYPE_BOOLEAN, "transform": VisualShader.VARYING_TYPE_TRANSFORM, } const VARYING_MODE_NAMES := { VisualShader.VARYING_MODE_VERTEX_TO_FRAG_LIGHT: "vertex_to_frag_light", VisualShader.VARYING_MODE_FRAG_TO_LIGHT: "frag_to_light", } const VARYING_TYPE_NAMES := { VisualShader.VARYING_TYPE_FLOAT: "float", VisualShader.VARYING_TYPE_INT: "int", VisualShader.VARYING_TYPE_UINT: "uint", VisualShader.VARYING_TYPE_VECTOR_2D: "vector2", VisualShader.VARYING_TYPE_VECTOR_3D: "vector3", VisualShader.VARYING_TYPE_VECTOR_4D: "vector4", VisualShader.VARYING_TYPE_BOOLEAN: "boolean", VisualShader.VARYING_TYPE_TRANSFORM: "transform", } ## Preserve useful aliases from the original #869 implementation. const ALIASES := { "VisualShaderNodeScalarOp": "VisualShaderNodeFloatOp", "VisualShaderNodeScalarFunc": "VisualShaderNodeFloatFunc", "VisualShaderNodeVectorConstant": "VisualShaderNodeVec3Constant", "VisualShaderNodeVectorParameter": "VisualShaderNodeVec3Parameter", "VisualShaderNodeTime": "VisualShaderNodeInput", "VisualShaderNodeSin": "VisualShaderNodeFloatFunc", "VisualShaderNodeCos": "VisualShaderNodeFloatFunc", "VisualShaderNodeLength": "VisualShaderNodeVectorLen", } const IMPLICIT := { "VisualShaderNodeTime": {"input_name": "time"}, "VisualShaderNodeSin": {"function": "sin"}, "VisualShaderNodeCos": {"function": "cos"}, } ## Only node-authoring properties, never script/resource ownership or arbitrary code. const PROPERTIES := [ "constant", "texture", "operator", "function", "op_type", "input_name", "parameter_name", "varying_name", "varying_type", "default_value_enabled", "default_value", "qualifier", "source", "texture_type", "texture_filter", "texture_repeat", "hint", "hint_range_min", "hint_range_max", "hint_range_step", ] const UNSUPPORTED_NODES := [ "VisualShaderNodeOutput", "VisualShaderNodeCustom", "VisualShaderNodeExpression", "VisualShaderNodeGlobalExpression", ] func create_graph(params: Dictionary) -> Dictionary: var raw_path: Variant = params.get("resource_path", null) if not raw_path is String or raw_path.is_empty(): return _invalid("resource_path must be a nonempty res:// .tres path") var path: String = raw_path var path_error: Variant = McpPathValidator.path_error(path, "resource_path", true) if path_error != null: return path_error if path.get_extension().to_lower() != "tres": return _invalid("resource_path must end in .tres: %s" % path) var overwrite: Variant = params.get("overwrite", false) if not overwrite is bool: return _invalid("overwrite must be a boolean") if FileAccess.file_exists(path) and not overwrite: return _invalid("Resource already exists at %s (pass overwrite=true to replace)" % path) var mode: Variant = params.get("shader_type", "spatial") if not mode is String or not MODES.has(mode): return _invalid("Unknown shader_type: %s" % str(mode)) var stages: Variant = params.get("stages", null) if not stages is Array or stages.is_empty() or stages.size() > MODE_STAGES[mode].size(): return _invalid("stages must be a nonempty array of applicable, unique shader stages") var varyings := _parse_varyings(params.get("varyings", []), mode) if varyings.has("error"): return varyings var count_nodes := 0 var count_connections := 0 var seen := {} for stage in stages: if not stage is Dictionary or not stage.get("stage") is String: return _invalid("Each stages entry requires a stage name") var name: String = stage.stage if not name in MODE_STAGES[mode] or seen.has(name): return _invalid("Duplicate or incompatible stage '%s' for %s" % [name, mode]) seen[name] = true if not stage.get("nodes") is Array or not stage.get("connections") is Array: return _invalid("Stage %s requires nodes and connections arrays" % name) count_nodes += stage.nodes.size() count_connections += stage.connections.size() if count_nodes > MAX_NODES or count_connections > MAX_CONNECTIONS: return _invalid("Graph has %d nodes and %d connections; limits are %d and %d" % [count_nodes, count_connections, MAX_NODES, MAX_CONNECTIONS]) var shader := VisualShader.new() shader.set_mode(MODES[mode]) var maps := {} for stage in stages: var built := _build_stage(shader, stage) if built.has("error"): return built maps[stage.stage] = built.id_map for varying in varyings.specs: shader.add_varying(varying.name, varying.mode, varying.type) var saved := _save_atomic(shader, path, overwrite) if saved.has("error"): return saved return {"data": { "resource_path": path, "shader_type": mode, "id_map": maps, "node_count": count_nodes, "connection_count": count_connections, "varyings": varyings.specs.map(func(spec): return spec.name), "undoable": false, "reason": "Creating or replacing a resource file is not undoable; assign the material separately.", }} func get_graph(params: Dictionary) -> Dictionary: var path: String = params.get("path", "") var path_error: Variant = McpPathValidator.path_error(path, "path") if path_error != null: return path_error if not FileAccess.file_exists(path): return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "VisualShader not found: %s" % path) var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) if not (loaded is VisualShader): return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a VisualShader" % path) var shader := loaded as VisualShader var mode := str(MODE_NAMES.get(shader.get_mode(), "")) var stages: Array[Dictionary] = [] var node_count := 0 var connection_count := 0 for stage_name in MODE_STAGES.get(mode, []): var stage: int = STAGES[stage_name] var nodes: Array[Dictionary] = [] for node_id in shader.get_node_list(stage): if node_id == 0: continue var node := shader.get_node(stage, node_id) if node == null: continue var position := shader.get_node_position(stage, node_id) nodes.append({ "id": node_id, "type": node.get_class(), "position": {"x": position.x, "y": position.y}, "params": _serialize_node_params(node), }) var connections: Array[Dictionary] = [] for connection in shader.get_node_connections(stage): connections.append({ "from_node": int(connection.get("from_node", -1)), "from_port": int(connection.get("from_port", -1)), "to_node": int(connection.get("to_node", -1)), "to_port": int(connection.get("to_port", -1)), }) node_count += nodes.size() connection_count += connections.size() if nodes.is_empty() and connections.is_empty(): continue stages.append({ "stage": stage_name, "nodes": nodes, "connections": connections, }) return {"data": { "path": path, "shader_type": mode, "stages": stages, "node_count": node_count, "connection_count": connection_count, "varyings": _read_varyings(shader, path), }} func node_catalog(params: Dictionary) -> Dictionary: var filter: String = str(params.get("filter", "")).to_lower() var offset: int = max(0, int(params.get("offset", 0))) var limit: int = clampi(int(params.get("limit", 100)), 1, MAX_CATALOG_LIMIT) var matches: Array[String] = [] for class_name_value in ClassDB.get_class_list(): var class_name_string := str(class_name_value) if not filter.is_empty() and not class_name_string.to_lower().contains(filter): continue if class_name_string in UNSUPPORTED_NODES: continue if not ClassDB.can_instantiate(class_name_string): continue if not ClassDB.is_parent_class(class_name_string, "VisualShaderNode"): continue matches.append(class_name_string) matches.sort() var total := matches.size() var page: Array[Dictionary] = [] var end := mini(total, offset + limit) for i in range(offset, end): var class_name_string: String = matches[i] var node: VisualShaderNode = ClassDB.instantiate(class_name_string) var supported: Array[String] = [] if node != null: var present := {} for property in node.get_property_list(): present[str(property.name)] = property for property_name in PROPERTIES: if not present.has(property_name): continue var property: Dictionary = present[property_name] if int(property.usage) & PROPERTY_USAGE_READ_ONLY or not (int(property.usage) & PROPERTY_USAGE_STORAGE): continue supported.append(property_name) page.append({"type": class_name_string, "params": supported}) return {"data": { "nodes": page, "count": page.size(), "total": total, "offset": offset, "limit": limit, "aliases": ALIASES, }} func edit_graph(params: Dictionary) -> Dictionary: var path: String = params.get("resource_path", "") var path_error: Variant = McpPathValidator.path_error(path, "resource_path", true) if path_error != null: return path_error if path.get_extension().to_lower() != "tres": return _invalid("resource_path must end in .tres: %s" % path) if not FileAccess.file_exists(path): return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "VisualShader not found: %s" % path) var operations: Variant = params.get("operations", null) if not operations is Array or operations.is_empty(): return _invalid("operations must be a nonempty array") if operations.size() > MAX_OPERATIONS: return _invalid("operations exceeds the %d-entry limit" % MAX_OPERATIONS) var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) if not (loaded is VisualShader): return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a VisualShader" % path) var shader := loaded as VisualShader var mode := str(MODE_NAMES.get(shader.get_mode(), "")) ## Alias table for IDs introduced by this call: string ids are resolved to ## their allocated integer so later operations can reference them. var aliases := {} var added: Array[Dictionary] = [] var inputs := _collect_inputs(shader, mode) for i in operations.size(): var operation: Variant = operations[i] if not operation is Dictionary or not operation.get("op") is String: return _invalid("operations[%d] requires an op name" % i) var applied := _apply_edit(shader, mode, operation, aliases, inputs) if applied.has("error"): return ErrorCodes.prefix_message(applied, "operations[%d] (%s)" % [i, operation.op]) if applied.has("added"): added.append(applied.added) var total_nodes := 0 var total_connections := 0 for stage_name in MODE_STAGES.get(mode, []): var stage: int = STAGES[stage_name] for node_id in shader.get_node_list(stage): if node_id != 0: total_nodes += 1 total_connections += shader.get_node_connections(stage).size() if total_nodes > MAX_NODES or total_connections > MAX_CONNECTIONS: return _invalid("Resulting graph has %d nodes and %d connections; limits are %d and %d" % [total_nodes, total_connections, MAX_NODES, MAX_CONNECTIONS]) var saved := _save_atomic(shader, path, true) if saved.has("error"): return saved return {"data": { "resource_path": path, "operations_applied": operations.size(), "added": added, "node_count": total_nodes, "connection_count": total_connections, "varyings": _read_varyings(shader, path), "undoable": false, "reason": "Editing a resource file is not undoable; assign the material separately.", }} ## Apply one edit operation to `shader` in place. Returns {} on success (plus ## `added` for add_node) or an error dict. `aliases` maps this call's string ## ids to allocated integers; `inputs` tracks occupied (node, port) inputs. func _apply_edit( shader: VisualShader, mode: String, operation: Dictionary, aliases: Dictionary, inputs: Dictionary, ) -> Dictionary: match operation.op: "add_node": return _edit_add_node(shader, mode, operation, aliases) "remove_node": var stage := _edit_stage(operation, mode) if stage.has("error"): return stage var node_id := _edit_node_id(shader, stage.value, operation.get("id"), aliases) if node_id.has("error"): return node_id if node_id.value < 2: return _invalid("cannot remove the built-in output node") shader.remove_node(stage.value, node_id.value) inputs.clear() inputs.merge(_collect_inputs(shader, mode)) return {} "replace_node": var stage := _edit_stage(operation, mode) if stage.has("error"): return stage var node_id := _edit_node_id(shader, stage.value, operation.get("id"), aliases) if node_id.has("error"): return node_id if node_id.value < 2: return _invalid("cannot replace the built-in output node") var class_name_value: Variant = operation.get("type") if not class_name_value is String: return _invalid("replace_node requires a type") var real_type := _resolve_type(class_name_value) if real_type.is_empty(): return _invalid("%s is not a supported VisualShaderNode" % str(class_name_value)) var values: Variant = operation.get("params", {}) if not values is Dictionary: return _invalid("params must be an object") var merged: Dictionary = IMPLICIT.get(class_name_value, {}).duplicate() merged.merge(values, true) if ClassDB.is_parent_class(real_type, "VisualShaderNodeInput"): ## Engine gap: `VisualShader.replace_node` swaps the class but never ## wires `shader_mode`/`shader_type` on the new input node (only ## `add_node` does), so `input_name` reads as unavailable and codegen ## is wrong until a save+reload. Rebuild the node at the same id and ## restore its edges after the params land, when the port type is final. var position := shader.get_node_position(stage.value, node_id.value) var edges := _node_edges(shader, stage.value, node_id.value) var input_node: VisualShaderNode = ClassDB.instantiate(real_type) shader.remove_node(stage.value, node_id.value) shader.add_node(stage.value, input_node, position, node_id.value) var input_applied := _apply_properties(input_node, merged, str(operation.id)) if input_applied.has("error"): return input_applied for edge in edges: var connect_code := shader.connect_nodes( stage.value, int(edge.from_node), int(edge.from_port), int(edge.to_node), int(edge.to_port) ) if connect_code != OK: return _invalid("node %s cannot keep connection %d:%d -> %d:%d: %s" % [ str(operation.id), int(edge.from_node), int(edge.from_port), int(edge.to_node), int(edge.to_port), error_string(connect_code), ]) return {} shader.replace_node(stage.value, node_id.value, real_type) return _apply_properties(shader.get_node(stage.value, node_id.value), merged, str(operation.id)) "set_node_params": var stage := _edit_stage(operation, mode) if stage.has("error"): return stage var node_id := _edit_node_id(shader, stage.value, operation.get("id"), aliases) if node_id.has("error"): return node_id var node := shader.get_node(stage.value, node_id.value) if node == null: return _invalid("node %s is unavailable" % str(operation.id)) var values: Variant = operation.get("params", {}) if not values is Dictionary or values.is_empty(): return _invalid("set_node_params requires a nonempty params object") return _apply_properties(node, values, str(operation.id)) "set_node_position": var stage := _edit_stage(operation, mode) if stage.has("error"): return stage var node_id := _edit_node_id(shader, stage.value, operation.get("id"), aliases) if node_id.has("error"): return node_id var position := _typed_value(TYPE_VECTOR2, operation.get("position", null)) if not position.has("value"): return _invalid("position requires finite x/y numbers") shader.set_node_position(stage.value, node_id.value, position.value) return {} "connect", "disconnect": return _edit_connection(shader, mode, operation, aliases, inputs) "add_varying": if not VARYING_MODES.has(mode): return _invalid("varyings are only supported for spatial/canvas_item shaders") var parsed := _parse_varying(operation, "") if parsed.has("error"): return parsed if shader.has_varying(parsed.name): return _invalid("varying '%s' already exists" % parsed.name) shader.add_varying(parsed.name, parsed.mode, parsed.type) return {} "remove_varying": var varying_name: Variant = operation.get("name") if not varying_name is String or not (varying_name as String).is_valid_identifier(): return _invalid("remove_varying requires an identifier name") if not shader.has_varying(varying_name): return _invalid("varying '%s' does not exist" % varying_name) shader.remove_varying(varying_name) return {} return _invalid("unknown edit op '%s'" % operation.op) func _edit_add_node( shader: VisualShader, mode: String, operation: Dictionary, aliases: Dictionary, ) -> Dictionary: var stage_result := _edit_stage(operation, mode) if stage_result.has("error"): return stage_result var stage: int = stage_result.value var class_name_value: Variant = operation.get("type") if not class_name_value is String: return _invalid("add_node requires a type") var real_type := _resolve_type(class_name_value) if real_type.is_empty(): return _invalid("%s is not a supported VisualShaderNode" % str(class_name_value)) var raw_id: Variant = operation.get("id", null) var node_id := 0 var public_id: Variant = null if raw_id == null: node_id = shader.get_valid_node_id(stage) public_id = node_id else: var canonical := _canonical_id(raw_id) if not _valid_id(canonical): return _invalid("missing, reserved or invalid node ID %s" % str(canonical)) if canonical is int: if shader.get_node(stage, canonical) != null or canonical < 2: return _invalid("node ID %s is already used" % str(canonical)) node_id = canonical public_id = canonical else: var alias_key := _alias_key(stage, canonical) if aliases.has(alias_key): return _invalid("node ID '%s' is already used in this stage" % canonical) node_id = shader.get_valid_node_id(stage) aliases[alias_key] = node_id public_id = canonical var position := _typed_value(TYPE_VECTOR2, operation.get("position", {"x": 0, "y": 0})) if not position.has("value"): return _invalid("position requires finite x/y numbers") var node: VisualShaderNode = ClassDB.instantiate(real_type) shader.add_node(stage, node, position.value, node_id) var values: Variant = operation.get("params", {}) if not values is Dictionary: return _invalid("params must be an object") var merged: Dictionary = IMPLICIT.get(class_name_value, {}).duplicate() merged.merge(values, true) var applied := _apply_properties(node, merged, str(public_id)) if applied.has("error"): return applied return {"added": { "id": public_id, "node_id": node_id, "stage": STAGE_NAMES[stage], }} func _edit_connection( shader: VisualShader, mode: String, operation: Dictionary, aliases: Dictionary, inputs: Dictionary, ) -> Dictionary: var stage_result := _edit_stage(operation, mode) if stage_result.has("error"): return stage_result var stage: int = stage_result.value var source := _edit_endpoint(shader, stage, operation.get("from_node"), aliases) if source.has("error"): return source var target := _edit_endpoint(shader, stage, operation.get("to_node"), aliases) if target.has("error"): return target var from_port := _integral_number(operation.get("from_port")) var to_port := _integral_number(operation.get("to_port")) if from_port == null or to_port == null or from_port < 0 or to_port < 0 or from_port > 64 or to_port > 64: return _invalid("connection ports must be integers from 0 through 64") if operation.op == "disconnect": shader.disconnect_nodes(stage, source.value, from_port, target.value, to_port) inputs.erase("%d:%d:%d" % [stage, target.value, to_port]) inputs.erase("%d:%d:%d:%d" % [stage, target.value, to_port, source.value]) return {} return _connect_nodes(shader, stage, source.value, from_port, target.value, to_port, inputs) func _edit_stage(operation: Dictionary, mode: String) -> Dictionary: var stage_name: Variant = operation.get("stage") if not stage_name is String or not STAGES.has(stage_name): return _invalid("stage must be one of: %s" % ", ".join(STAGES.keys())) var allowed: Array = MODE_STAGES.get(mode, []) if not allowed.has(stage_name): return _invalid("stage '%s' is not available for %s shaders; use: %s" % [stage_name, mode, ", ".join(allowed)]) return {"value": STAGES[stage_name]} func _edit_node_id(shader: VisualShader, stage: int, raw: Variant, aliases: Dictionary) -> Dictionary: var canonical := _canonical_id(raw) if canonical is String: if canonical == "output": return {"value": 0} var alias_key := _alias_key(stage, canonical) if not aliases.has(alias_key): return _invalid("unknown node ID %s" % canonical) return {"value": int(aliases[alias_key])} var number := _integral_number(canonical) if number == null: return _invalid("node id must be an integer or a string used by add_node in this call") if number == 0: return {"value": 0} if shader.get_node(stage, number) == null: return _invalid("node ID %d does not exist in this stage" % number) return {"value": number} func _edit_endpoint(shader: VisualShader, stage: int, raw: Variant, aliases: Dictionary) -> Dictionary: var canonical := _canonical_id(raw) if (canonical is String and canonical == "output") or (canonical is int and canonical == 0): return {"value": 0} if canonical is String: var alias_key := _alias_key(stage, canonical) if not aliases.has(alias_key): return _invalid("unknown node ID %s" % canonical) return {"value": int(aliases[alias_key])} var number := _integral_number(canonical) if number == null: return _invalid("connection endpoints must be node IDs") if shader.get_node(stage, number) == null: return _invalid("node ID %d does not exist in this stage" % number) return {"value": number} ## Snapshot of occupied input ports per stage, keyed stage:target:port:source ## so a disconnect can release the exact edge and connect can reject duplicates. func _collect_inputs(shader: VisualShader, mode: String) -> Dictionary: var inputs := {} for stage_name in MODE_STAGES.get(mode, []): var stage: int = STAGES[stage_name] for connection in shader.get_node_connections(stage): var target := int(connection.get("to_node", -1)) var to_port := int(connection.get("to_port", -1)) var source := int(connection.get("from_node", -1)) inputs["%d:%d:%d" % [stage, target, to_port]] = true inputs["%d:%d:%d:%d" % [stage, target, to_port, source]] = true return inputs ## Every edge touching `node_id` in one stage, captured before a node rebuild ## drops them so the replacement can restore the same connections. static func _node_edges(shader: VisualShader, stage: int, node_id: int) -> Array[Dictionary]: var edges: Array[Dictionary] = [] for connection in shader.get_node_connections(stage): if int(connection.get("from_node", -1)) == node_id or int(connection.get("to_node", -1)) == node_id: edges.append(connection) return edges func _build_stage(shader: VisualShader, spec: Dictionary) -> Dictionary: var stage: int = STAGES[spec.stage] var ids := {} var used := {0: true, 1: true} ## Reserve all explicit integers before allocating any string ID. for node_spec in spec.nodes: if not node_spec is Dictionary: return _invalid("Stage %s: every node must be an object" % spec.stage) var raw: Variant = _canonical_id(node_spec.get("id")) if not _valid_id(raw) or ids.has(raw): return _invalid("Stage %s: missing, reserved or duplicate node ID %s" % [spec.stage, str(raw)]) ids[raw] = -1 if raw is int: used[raw] = true ids[raw] = raw var next_id := 2 for raw in ids: if raw is String: while used.has(next_id): next_id += 1 ids[raw] = next_id used[next_id] = true var public_ids := [] for node_spec in spec.nodes: var raw: Variant = _canonical_id(node_spec.id) var id: int = ids[raw] var class_name_value: Variant = node_spec.get("type") if not class_name_value is String: return _invalid("Node %s requires a VisualShaderNode type" % str(raw)) var real_type: String = ALIASES.get(class_name_value, class_name_value) if not ClassDB.class_exists(real_type) or not ClassDB.is_parent_class(real_type, "VisualShaderNode") or not ClassDB.can_instantiate(real_type): return _invalid("Node %s: %s is not an instantiable VisualShaderNode" % [str(raw), real_type]) if real_type in UNSUPPORTED_NODES: return _invalid("Node %s: %s is not supported in declarative graphs" % [str(raw), real_type]) var position_value: Variant = node_spec.get("position", {"x": 0, "y": 0}) var position_result := _typed_value(TYPE_VECTOR2, position_value) if not position_result.has("value"): return _invalid("Node %s: position requires finite x/y numbers" % str(raw)) var node: VisualShaderNode = ClassDB.instantiate(real_type) shader.add_node(stage, node, position_result.value, id) var values: Variant = node_spec.get("params", {}) if not values is Dictionary: return _invalid("Node %s: params must be an object" % str(raw)) var merged: Dictionary = IMPLICIT.get(class_name_value, {}).duplicate() merged.merge(values, true) var applied := _apply_properties(node, merged, str(raw)) if applied.has("error"): return applied public_ids.append({"id": raw, "node_id": id}) var inputs := {} for edge in spec.connections: if not edge is Dictionary or not edge.has_all(["from_node", "from_port", "to_node", "to_port"]): return _invalid("Stage %s: connection requires from_node/from_port/to_node/to_port" % spec.stage) if edge.has("stage") or edge.has("from_stage") or edge.has("to_stage"): return _invalid("Connections belong to their enclosing stage; cross-stage edges are unsupported") var source := _endpoint(edge.from_node, ids) var target := _endpoint(edge.to_node, ids) if source < 2 or target < 0: return _invalid("Stage %s: unknown or invalid connection endpoints %s -> %s" % [spec.stage, str(edge.from_node), str(edge.to_node)]) var from_port_value := _integral_number(edge.from_port) var to_port_value := _integral_number(edge.to_port) if from_port_value == null or to_port_value == null or from_port_value < 0 or to_port_value < 0: return _invalid("Connection ports must be nonnegative integers") var connected := _connect_nodes(shader, stage, source, from_port_value, target, to_port_value, inputs) if connected.has("error"): return _invalid("Stage %s: %s" % [spec.stage, connected.error.message]) return {"id_map": public_ids} ## Validate and create one connection. `inputs` maps `stage:target:port` to ## true; the caller owns the dict so duplicate-input detection spans a whole ## graph build or edit call. func _connect_nodes( shader: VisualShader, stage: int, source: int, from_port: int, target: int, to_port: int, inputs: Dictionary, ) -> Dictionary: var source_node := shader.get_node(stage, source) if from_port > 64 or to_port > 64: return _invalid("Connection port exceeds the supported maximum of 64") if source_node == null: return _invalid("source node %d is unavailable" % source) ## This hidden Array names base output ports whose vector components are ## exposed. Expand only existing base ports needed to reach a component. var raw_expanded: Variant = source_node.get("expanded_output_ports") var expanded: Array = raw_expanded if raw_expanded is Array else Array(raw_expanded) for port in range(from_port): if not expanded.has(port): expanded.append(port) expanded.sort() source_node.set("expanded_output_ports", expanded) var input_key := "%d:%d:%d" % [stage, target, to_port] if inputs.has(input_key) or not shader.can_connect_nodes(stage, source, from_port, target, to_port): return _invalid("invalid, duplicate-input or cyclic connection") var err := shader.connect_nodes(stage, source, from_port, target, to_port) if err != OK: return _invalid("cannot connect: %s" % error_string(err)) inputs[input_key] = true return {} ## Parse a top-level `varyings` array for create_graph. Returns ## {specs: [{name, mode, type}]} or an error dict. func _parse_varyings(raw: Variant, mode: String) -> Dictionary: if not raw is Array: return _invalid("varyings must be an array") if raw.is_empty(): return {"specs": []} if not VARYING_MODES.has(mode): return _invalid("varyings are only supported for spatial/canvas_item shaders") var specs: Array[Dictionary] = [] var seen := {} for entry in raw: if not entry is Dictionary: return _invalid("each varying must be an object") var parsed := _parse_varying(entry, "") if parsed.has("error"): return parsed if seen.has(parsed.name): return _invalid("duplicate varying '%s'" % parsed.name) seen[parsed.name] = true specs.append({"name": parsed.name, "mode": parsed.mode, "type": parsed.type}) return {"specs": specs} ## Parse one {name, mode, type} varying spec. `label` is unused today but keeps ## call sites explicit about which entry failed. func _parse_varying(entry: Dictionary, _label: String) -> Dictionary: var varying_name: Variant = entry.get("name") if not varying_name is String or not (varying_name as String).is_valid_identifier(): return _invalid("varying name must be a shader identifier") var mode := _enum_lookup(VARYING_MODE_VALUES, entry.get("mode"), "varying_mode_") if mode == null: return _invalid("varying mode must be one of: %s" % ", ".join(VARYING_MODE_VALUES.keys())) var varying_type := _enum_lookup(VARYING_TYPE_VALUES, entry.get("type"), "varying_type_") if varying_type == null: return _invalid("varying type must be one of: %s" % ", ".join(VARYING_TYPE_VALUES.keys())) return {"name": varying_name, "mode": mode, "type": varying_type} ## Resolve a friendly or engine enum name / raw integer against `table`. static func _enum_lookup(table: Dictionary, value: Variant, prefix: String) -> Variant: if value is int: return value if table.values().has(value) else null if not value is String: return null var normalized: String = value.to_lower().replace(prefix, "").replace("_", "") for key in table: if key.replace("_", "") == normalized: return table[key] return null ## Report a VisualShader's varyings. The engine exposes varying names through ## dynamic `varyings/` properties but no mode/type getters, so the ## serialized resource text carries the `","` values; fall back to ## names only when a value cannot be read. func _read_varyings(shader: VisualShader, path: String) -> Array[Dictionary]: var names: Array[String] = [] for property in shader.get_property_list(): var property_name := str(property.name) if property_name.begins_with("varyings/"): names.append(property_name.substr("varyings/".length())) names.sort() var text := "" if not names.is_empty(): var file := FileAccess.open(path, FileAccess.READ) if file != null: text = file.get_as_text() file.close() var out: Array[Dictionary] = [] for varying_name in names: var entry := {"name": varying_name} var raw: Variant = shader.get("varyings/" + varying_name) if raw is String: var parts: PackedStringArray = (raw as String).split(",") if parts.size() == 2: entry["mode"] = VARYING_MODE_NAMES.get(int(parts[0]), "") entry["type"] = VARYING_TYPE_NAMES.get(int(parts[1]), "") if not entry.has("mode"): var from_text := _varying_from_text(text, varying_name) if not from_text.is_empty(): var parts: PackedStringArray = from_text.split(",") if parts.size() == 2: entry["mode"] = VARYING_MODE_NAMES.get(int(parts[0]), "") entry["type"] = VARYING_TYPE_NAMES.get(int(parts[1]), "") out.append(entry) return out static func _varying_from_text(text: String, varying_name: String) -> String: var needle := "varyings/%s" % varying_name for raw_line in text.split("\n"): var line := raw_line.strip_edges() var eq := line.find("=") if eq < 0: continue var left := line.substr(0, eq).strip_edges().trim_prefix("\"").trim_suffix("\"") if left != needle: continue var value := line.substr(eq + 1).strip_edges() return value.trim_prefix("\"").trim_suffix("\"") return "" func _serialize_node_params(node: VisualShaderNode) -> Dictionary: var present := {} for property in node.get_property_list(): present[str(property.name)] = property var params := {} for property_name in PROPERTIES: if not present.has(property_name): continue var property: Dictionary = present[property_name] if not (int(property.usage) & PROPERTY_USAGE_STORAGE): continue params[property_name] = MaterialValues.serialize_value(node.get(property_name)) return params static func _resolve_type(value: String) -> String: var real_type: String = ALIASES.get(value, value) if not ClassDB.class_exists(real_type) or not ClassDB.is_parent_class(real_type, "VisualShaderNode") or not ClassDB.can_instantiate(real_type): return "" if real_type in UNSUPPORTED_NODES: return "" return real_type static func _valid_id(value: Variant) -> bool: return (value is int and value >= 2 and value <= 2147483647) or (value is String and not value.is_empty() and value != "output") static func _canonical_id(value: Variant) -> Variant: if value is String: return value var number := _integral_number(value) return number if number != null else value ## Aliases are scoped to the stage that introduced them: the same string id in ## two stages is two different nodes, and a duplicate inside one stage is ## ambiguous, so registration rejects it instead of overwriting. static func _alias_key(stage: int, canonical: String) -> String: return "%d:%s" % [stage, canonical] static func _endpoint(value: Variant, ids: Dictionary) -> int: value = _canonical_id(value) if (value is String and value == "output") or (value is int and value == 0): return 0 return int(ids.get(value, -1)) if value is int or value is String else -1 func _apply_properties(node: VisualShaderNode, values: Dictionary, id: String) -> Dictionary: if node == null: return _invalid("node %s is unavailable" % id) var properties := {} for property in node.get_property_list(): properties[str(property.name)] = property for key in values: if not key is String or not key in PROPERTIES or not properties.has(key): return _invalid("Node %s (%s): unsupported property %s" % [id, node.get_class(), str(key)]) var property: Dictionary = properties[key] if int(property.usage) & PROPERTY_USAGE_READ_ONLY or not (int(property.usage) & PROPERTY_USAGE_STORAGE): return _invalid("Node %s: property %s is not writable" % [id, key]) var value: Variant = values[key] var converted := {} if key == "texture": if not value is String: return _invalid("Node %s: texture requires a resource path" % id) var path_error: Variant = McpPathValidator.path_error(value, "texture") if path_error != null: return path_error if not ResourceLoader.exists(value): return _invalid("Node %s: texture not found: %s" % [id, value]) var texture := ResourceLoader.load(value) var expected: String = str(property.get("class_name", property.get("hint_string", ""))) if not (texture is Texture2D or texture is Texture3D) or (not expected.is_empty() and not texture.is_class(expected)): return _invalid("Node %s: incompatible texture %s (expected %s)" % [id, value, expected]) converted = {"value": texture} elif int(property.hint) == PROPERTY_HINT_ENUM and int(property.type) == TYPE_INT: converted = _enum_value(str(property.hint_string), value) else: converted = _typed_value(int(property.type), value) if not converted.has("value"): return _invalid("Node %s: invalid %s value %s" % [id, key, str(value)]) if key == "parameter_name" and (str(converted.value).is_empty() or not str(converted.value).is_valid_identifier()): return _invalid("Node %s: parameter_name must be a shader identifier" % id) if key == "varying_name" and (str(converted.value).is_empty() or not str(converted.value).is_valid_identifier()): return _invalid("Node %s: varying_name must be a shader identifier" % id) node.set(key, converted.value) if key == "input_name" and node.get_input_real_name().is_empty(): return _invalid("Node %s: input %s is unavailable in this shader stage" % [id, str(value)]) return {} static func _enum_value(hint: String, value: Variant) -> Dictionary: var aliases := {"sub": "subtract", "mul": "multiply", "div": "divide", "mod": "remainder", "pow": "power"} var normalized := str(aliases.get(value, value)).replace("_", "").replace(" ", "").to_lower() var index := 0 for item in hint.split(","): var parts := item.split(":") if parts.size() > 1: index = int(parts[1]) var numeric := _integral_number(value) if (numeric != null and numeric == index) or (value is String and parts[0].replace("_", "").replace(" ", "").to_lower() == normalized): return {"value": index} index += 1 return {} static func _typed_value(type: int, value: Variant) -> Dictionary: match type: TYPE_BOOL: return {"value": value} if value is bool else {} TYPE_INT: var integer := _integral_number(value) return {"value": integer} if integer != null else {} TYPE_FLOAT: return {"value": float(value)} if _number(value) else {} TYPE_STRING, TYPE_STRING_NAME: return {"value": value} if value is String else {} TYPE_VECTOR2, TYPE_VECTOR3, TYPE_VECTOR4, TYPE_COLOR: var keys: Array = ["x", "y"] if type == TYPE_VECTOR3: keys = ["x", "y", "z"] elif type == TYPE_VECTOR4: keys = ["x", "y", "z", "w"] elif type == TYPE_COLOR: keys = ["r", "g", "b"] if not value is Dictionary or not value.has_all(keys): return {} for key in keys: if not _number(value[key]): return {} match type: TYPE_VECTOR2: return {"value": Vector2(value.x, value.y)} TYPE_VECTOR3: return {"value": Vector3(value.x, value.y, value.z)} TYPE_VECTOR4: return {"value": Vector4(value.x, value.y, value.z, value.w)} TYPE_COLOR: if not _number(value.get("a", 1.0)): return {} return {"value": Color(value.r, value.g, value.b, value.get("a", 1.0))} return {} static func _number(value: Variant) -> bool: return (value is int or value is float) and is_finite(float(value)) ## JSON.parse_string represents every number as float. Accept only values that ## round-trip exactly to an integer; direct GDScript callers may still pass int. static func _integral_number(value: Variant) -> Variant: if value is int: return value if value is float and is_finite(value) and value == floor(value): if value >= -2147483648.0 and value <= 2147483647.0: return int(value) return null ## Parse `code` through the engine's shader compiler and report whether the ## appended sentinel uniform is reflected back. Uniform reflection proves the ## source parsed for the current renderer; it is not a final GPU pipeline ## compile and says nothing about other renderers. static func _source_parses(code: String, sentinel: String) -> bool: var probe := Shader.new() probe.code = code + "\nuniform float %s;\n" % sentinel for uniform in probe.get_shader_uniform_list(): if str(uniform.get("name", "")) == sentinel: return true return false ## The `uniform` and `varying` lines the engine emitted for the graph. Used ## when the current renderer cannot compile the shader type: declarations are ## mode-independent, so they still parse under a type the renderer accepts. static func _generated_declarations(generated: String) -> String: var declarations := PackedStringArray() for line in generated.split("\n"): var stripped := line.strip_edges() if stripped.begins_with("uniform ") or stripped.begins_with("varying "): declarations.append(stripped) return "\n".join(declarations) ## Godot's shader compiler is the authority on declaration legality: a ## parameter named after a shader keyword passes GDScript's ## `is_valid_identifier()` but generates `uniform float float;`, which the ## compiler rejects. Force a copy of the graph to generate its source, then ## parse that source through the engine. The appended sentinel uniform only ## appears in the uniform list when the parsed source is accepted, so a missing ## sentinel means the graph would fail to compile. ## ## Renderers differ in which shader types they compile — the Compatibility ## renderer rejects `shader_type fog` before the graph's content matters — so ## the type is probed first. When the current renderer cannot compile the type, ## the generated declarations are parsed under a supported type instead: the ## advertised modes stay accepted and identifier validation still applies. static func _validate_generated_graph(shader: VisualShader) -> Dictionary: var probe := shader.duplicate(true) as VisualShader if probe == null: return _invalid("Cannot copy the VisualShader for compile validation") probe._update_shader() var generated := probe.get_code() var sentinel := "_mcp_validate_%d" % Time.get_ticks_usec() var mode_name := str(MODE_NAMES.get(probe.get_mode(), "")) if mode_name.is_empty(): return _invalid("Cannot resolve the VisualShader mode for compile validation") var source := generated if not _source_parses("shader_type %s;" % mode_name, sentinel): source = "shader_type spatial;\n" + _generated_declarations(generated) if _source_parses(source, sentinel): return {} return _invalid( "The graph's generated shader does not compile; check parameter and varying " + "identifiers and declarations (reserved shader keywords are not legal names)" ) ## Stage in the destination directory, then use the OS rename/replace primitive. ## A failed save or rename never removes/truncates the existing destination. ## The generated shader is compile-checked first, so an uncompilable graph is ## rejected before the destination is created or replaced. func _save_atomic(shader: VisualShader, path: String, overwrite: bool) -> Dictionary: var compiled := _validate_generated_graph(shader) if compiled.has("error"): return compiled var directory := path.get_base_dir() if not DirAccess.dir_exists_absolute(directory): return _invalid("Destination directory does not exist: %s" % directory) var temporary := directory.path_join(".godot-ai-shader-%d-%d.tres" % [OS.get_process_id(), Time.get_ticks_usec()]) var prior_uid := ResourceLoader.get_resource_uid(path) if FileAccess.file_exists(path) else ResourceUID.INVALID_ID var err := ResourceSaver.save(shader, temporary) if err == OK and prior_uid != ResourceUID.INVALID_ID: err = ResourceSaver.set_uid(temporary, prior_uid) if err == OK and FileAccess.file_exists(path) and not overwrite: err = ERR_ALREADY_EXISTS if err == OK: err = DirAccess.rename_absolute(temporary, path) if err != OK: if FileAccess.file_exists(temporary): DirAccess.remove_absolute(temporary) return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Cannot save VisualShader at %s: %s" % [path, error_string(err)]) shader.take_over_path(path) var filesystem := EditorInterface.get_resource_filesystem() if filesystem != null: filesystem.update_file(path) return {} static func _invalid(message: String) -> Dictionary: return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message)