    {
        string alias = (string)nodeSpec["alias"];
        string kind = (string)nodeSpec["kind"];
        var position = (Newtonsoft.Json.Linq.JArray)nodeSpec["position"];
        if (kind == "recipe")
        {
            var recipeName = (string)nodeSpec["recipe"];
            var recipeCode = (string)nodeSpec["code"];
            var recipeOutputType = (string)nodeSpec["output_type"];
            var recipeInputs = (Newtonsoft.Json.Linq.JArray)nodeSpec["inputs"];
            var expansion = (Newtonsoft.Json.Linq.JObject)nodeSpec["expansion"];
            var primitiveSpecs = (Newtonsoft.Json.Linq.JArray)expansion["primitives"];
            string expansionOutput = (string)expansion["output"];

            bool native = true;
            foreach (Newtonsoft.Json.Linq.JObject primitiveSpec in primitiveSpecs)
            {
                string opName = (string)primitiveSpec["op"];
                System.Type resolved = opName == "const" ? constClass((string)primitiveSpec["value"]) : primitiveClass(opName);
                if (resolved == null) { native = false; break; }
            }

            if (!native)
            {
                var expression = win.CreateNode(typeof(AmplifyShaderEditor.CustomExpressionNode),
                    new UnityEngine.Vector2((float)position[0], (float)position[1]), null, false) as AmplifyShaderEditor.CustomExpressionNode;
                if (expression == null)
                    throw new System.Exception("ASE failed to create recipe fallback expression: " + alias);
                var inputSpecs = recipeInputs;
                var items = new System.Collections.Generic.List<AmplifyShaderEditor.CustomExpressionInputItem>();
                for (int inputIndex = 0; inputIndex < inputSpecs.Count; inputIndex++)
                {
                    var inputSpec = (Newtonsoft.Json.Linq.JObject)inputSpecs[inputIndex];
                    string inputName = (string)inputSpec["name"];
                    var inputType = (AmplifyShaderEditor.WirePortDataType)System.Enum.Parse(
                        typeof(AmplifyShaderEditor.WirePortDataType), (string)inputSpec["type"]);
                    var item = new AmplifyShaderEditor.CustomExpressionInputItem(
                        AmplifyShaderEditor.PrecisionType.Inherit, AmplifyShaderEditor.VariableQualifiers.In,
                        string.Empty, false, true, string.Empty);
                    item.Type = inputType;
                    items.Add(item);
                    if (inputIndex == 0)
                        expression.InputPorts[0].ChangeProperties(inputName, inputType, false);
                    else
                        expression.AddInputPort(inputType, false, inputName);
                }
                var outputType = (AmplifyShaderEditor.WirePortDataType)System.Enum.Parse(
                    typeof(AmplifyShaderEditor.WirePortDataType), recipeOutputType);
                int outputTypeIndex = recipeOutputType == "INT" ? 0 :
                    recipeOutputType == "FLOAT" ? 1 :
                    recipeOutputType == "FLOAT2" ? 2 :
                    recipeOutputType == "FLOAT3" ? 3 :
                    recipeOutputType == "FLOAT4" ? 4 :
                    recipeOutputType == "FLOAT3x3" ? 5 : 6;
                expression.OutputPorts[0].ChangeType(outputType, false);
                expressionNameField.SetValue(expression, recipeName);
                expressionItemsField.SetValue(expression, items);
                expressionCodeField.SetValue(expression, recipeCode);
                expressionOutputField.SetValue(expression, outputTypeIndex);
                expressionFunctionField.SetValue(expression, recipeCode.Contains("return"));
                aliasIds.Add(alias, expression.UniqueId);
                continue;
            }

            var inputIndexByName = new System.Collections.Generic.Dictionary<string, int>();
            for (int i = 0; i < recipeInputs.Count; i++)
                inputIndexByName[(string)((Newtonsoft.Json.Linq.JObject)recipeInputs[i])["name"]] = i;
            var idToNode = new System.Collections.Generic.Dictionary<string, AmplifyShaderEditor.ParentNode>();
            var idToClass = new System.Collections.Generic.Dictionary<string, string>();
            foreach (Newtonsoft.Json.Linq.JObject primitiveSpec in primitiveSpecs)
            {
                string pid = (string)primitiveSpec["id"];
                string opName = (string)primitiveSpec["op"];
                System.Type resolved = opName == "const" ? constClass((string)primitiveSpec["value"]) : primitiveClass(opName);
                var pn = win.CreateNode(resolved,
                    new UnityEngine.Vector2((float)position[0], (float)position[1]), null, false);
                if (pn == null || pn.GetType() != resolved)
                    throw new System.Exception("ASE failed to create recipe primitive: " + pid);
                if (opName == "const")
                    writeConst(pn, (string)primitiveSpec["value"]);
                idToNode[pid] = pn;
                idToClass[pid] = resolved.Name;
            }
            var targets = new System.Collections.Generic.Dictionary<int, System.Collections.Generic.List<System.Tuple<int, int>>>();
            foreach (Newtonsoft.Json.Linq.JObject primitiveSpec in primitiveSpecs)
            {
                string pid = (string)primitiveSpec["id"];
                var pn = idToNode[pid];
                var args = (Newtonsoft.Json.Linq.JArray)primitiveSpec["args"];
                if (args == null) continue;
                for (int argIndex = 0; argIndex < args.Count; argIndex++)
                {
                    string argRef = (string)args[argIndex];
                    if (inputIndexByName.ContainsKey(argRef))
                    {
                        int inputPort = inputIndexByName[argRef];
                        if (!targets.ContainsKey(inputPort))
                            targets[inputPort] = new System.Collections.Generic.List<System.Tuple<int, int>>();
                        targets[inputPort].Add(System.Tuple.Create(pn.UniqueId, argIndex));
                        continue;
                    }
                    int sourceNodeId = idToNode[argRef].UniqueId;
                    graph.CreateConnection(pn.UniqueId, argIndex, sourceNodeId, 0, false);
                    if (!pn.GetInputPortByUniqueId(argIndex).IsConnectedTo(sourceNodeId, 0))
                        throw new System.Exception("ASE rejected recipe primitive connection");
                }
            }
            recipePrimitiveIds[alias] = new System.Collections.Generic.Dictionary<string, int>();
            recipePrimitiveClasses[alias] = idToClass;
            foreach (System.Collections.Generic.KeyValuePair<string, AmplifyShaderEditor.ParentNode> kv in idToNode)
                recipePrimitiveIds[alias][kv.Key] = kv.Value.UniqueId;
            recipeOutputIds[alias] = idToNode[expansionOutput].UniqueId;
            recipeInputTargets[alias] = targets;
            continue;
        }

        System.Type nodeType = null;
        if (kind == "sampler") nodeType = typeof(AmplifyShaderEditor.SamplerNode);
        else if (kind == "custom_expression") nodeType = typeof(AmplifyShaderEditor.CustomExpressionNode);
        else if (kind == "primitive")
        {
            nodeType = primitiveClass((string)nodeSpec["op"]);
            if (nodeType == null) throw new System.Exception("primitive op escaped closure: " + (string)nodeSpec["op"]);
        }
        else
        {
            string typeName = (string)nodeSpec["type"];
            if (typeName == "WorldPosInputsNode") nodeType = typeof(AmplifyShaderEditor.WorldPosInputsNode);
            else if (typeName == "TextureCoordinatesNode") nodeType = typeof(AmplifyShaderEditor.TextureCoordinatesNode);
            else if (typeName == "BreakToComponentsNode") nodeType = typeof(AmplifyShaderEditor.BreakToComponentsNode);
            else if (typeName == "SimpleAddOpNode") nodeType = typeof(AmplifyShaderEditor.SimpleAddOpNode);
            else if (typeName == "SimpleSubtractOpNode") nodeType = typeof(AmplifyShaderEditor.SimpleSubtractOpNode);
            else if (typeName == "SimpleMultiplyOpNode") nodeType = typeof(AmplifyShaderEditor.SimpleMultiplyOpNode);
            else if (typeName == "SimpleDivideOpNode") nodeType = typeof(AmplifyShaderEditor.SimpleDivideOpNode);
            else if (typeName == "SimpleMinOpNode") nodeType = typeof(AmplifyShaderEditor.SimpleMinOpNode);
            else if (typeName == "SimpleMaxOpNode") nodeType = typeof(AmplifyShaderEditor.SimpleMaxOpNode);
            else if (typeName == "SaturateNode") nodeType = typeof(AmplifyShaderEditor.SaturateNode);
            else if (typeName == "OneMinusNode") nodeType = typeof(AmplifyShaderEditor.OneMinusNode);
            else if (typeName == "LerpOp") nodeType = typeof(AmplifyShaderEditor.LerpOp);
            else if (typeName == "TexturePropertyNode") nodeType = typeof(AmplifyShaderEditor.TexturePropertyNode);
            else if (typeName == "RangedFloatNode") nodeType = typeof(AmplifyShaderEditor.RangedFloatNode);
            else if (typeName == "Matrix4X4Node") nodeType = typeof(AmplifyShaderEditor.Matrix4X4Node);
            else if (typeName == "Vector4Node") nodeType = typeof(AmplifyShaderEditor.Vector4Node);
            else throw new System.Exception("node type escaped allowlist: " + typeName);
        }
