Editor/Prism/Compiler/Backends/HlslBackend.cs

Hlsl backend and emitter for the Prism shader compiler. HlslSourceBuilder accumulates HLSL text while mapping emitted lines to originating graph nodes. HlslEmitter lowers IR expressions, statements, helper functions and module structures into HLSL text and emits diagnostics. HlslBackend wires the emitter into a higher-level writer and provides a standalone emitter for individual stages.

File Access
using System.Text;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;

namespace Editor.Prism.Compiler.Backends;

/// <summary>
/// A text buffer that remembers which graph node produced each line it wrote.
/// <para>
/// Every backend writes through this rather than a bare <see cref="StringBuilder"/>, because the
/// generated-line to <see cref="NodeId"/> map is what turns a raw compiler error into a selected
/// node. Losing it is losing the feature the built-in editor structurally cannot have.
/// </para>
/// </summary>
public sealed class HlslSourceBuilder
{
	readonly StringBuilder _text = new();
	readonly SourceMap _map = new();
	int _line = 1;
	int _indent;

	/// <summary>Create a builder. Generated code uses hard tabs and CRLF, like everything else here.</summary>
	public HlslSourceBuilder( string indent = "\t", string newLine = "\r\n" )
	{
		Indent = indent ?? "\t";
		NewLine = string.IsNullOrEmpty( newLine ) ? "\r\n" : newLine;
	}

	/// <summary>One level of indentation.</summary>
	public string Indent { get; }

	/// <summary>The line ending written after every line.</summary>
	public string NewLine { get; }

	/// <summary>The map from emitted line to originating node.</summary>
	public SourceMap SourceMap => _map;

	/// <summary>The 1-based number of the line that will be written next.</summary>
	public int LineNumber => _line;

	/// <summary>Lines written so far.</summary>
	public int LineCount => _line - 1;

	/// <summary>Current indentation depth.</summary>
	public int IndentLevel
	{
		get => _indent;
		set => _indent = Math.Max( 0, value );
	}

	/// <summary>Indent until the returned scope is disposed.</summary>
	public IDisposable Indented() => new IndentScope( this );

	/// <summary>Write an empty line.</summary>
	public HlslSourceBuilder Blank()
	{
		_text.Append( NewLine );
		_line++;
		return this;
	}

	/// <summary>Write one indented line with no origin.</summary>
	public HlslSourceBuilder Write( string text ) => Write( text, NodeId.None );

	/// <summary>Write one indented line and record which node produced it.</summary>
	public HlslSourceBuilder Write( string text, NodeId origin )
	{
		if ( text is null ) return this;

		if ( text.Length > 0 )
		{
			for ( int i = 0; i < _indent; i++ ) _text.Append( Indent );
			_text.Append( text );
		}

		_text.Append( NewLine );
		_map.Add( _line, origin );
		_line++;

		return this;
	}

	/// <summary>
	/// Write a multi-line chunk, stripping the shared leading whitespace so a template written at any
	/// C# indentation lands correctly. Every produced line is attributed to <paramref name="origin"/>.
	/// </summary>
	public HlslSourceBuilder WriteBlock( string text, NodeId origin = default )
	{
		if ( string.IsNullOrEmpty( text ) ) return this;

		foreach ( var line in SboxShaderTemplates.Dedent( text ).Split( '\n' ) )
		{
			Write( line.TrimEnd(), origin );
		}

		return this;
	}

	/// <summary>Write an opening brace and indent.</summary>
	public HlslSourceBuilder Open( NodeId origin = default )
	{
		Write( "{", origin );
		_indent++;
		return this;
	}

	/// <summary>Outdent and write a closing brace.</summary>
	public HlslSourceBuilder Close( string suffix = null, NodeId origin = default )
	{
		_indent = Math.Max( 0, _indent - 1 );
		Write( "}" + ( suffix ?? string.Empty ), origin );
		return this;
	}

	/// <inheritdoc/>
	public override string ToString() => _text.ToString();

	sealed class IndentScope : IDisposable
	{
		readonly HlslSourceBuilder _owner;

		public IndentScope( HlslSourceBuilder owner )
		{
			_owner = owner;
			_owner._indent++;
		}

		public void Dispose() => _owner._indent = Math.Max( 0, _owner._indent - 1 );
	}
}

/// <summary>
/// Lowers <see cref="IrModule"/> expressions, statements, helpers and declarations into HLSL text.
/// <para>
/// Deliberately knows nothing about the VFX block file — that is <see cref="SboxShaderWriter"/>'s
/// job. The split is what lets the same HLSL feed a probe compile from the text editor, a
/// <c>.shader</c>, or a future material-only target.
/// </para>
/// </summary>
public sealed class HlslEmitter
{
	const int PrecedencePrimary = 16;
	const int PrecedencePostfix = 15;
	const int PrecedenceUnary = 13;
	const int PrecedenceLowest = 0;

	readonly HashSet<string> _reported = new();

	/// <summary>Create an emitter for one module.</summary>
	public HlslEmitter( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics )
	{
		Module = module;
		Options = options ?? BackendEmitOptions.Default;
		Diagnostics = diagnostics ?? new DiagnosticSink();
	}

	/// <summary>The module being lowered.</summary>
	public IrModule Module { get; }

	/// <summary>Emission options.</summary>
	public BackendEmitOptions Options { get; }

	/// <summary>Where problems go. A backend never throws for user error.</summary>
	public DiagnosticSink Diagnostics { get; }

	/// <summary>Which HLSL flavour to write.</summary>
	public HlslDialect Dialect => Options.Dialect;

	/// <summary>The stage currently being written. Drives builtin lowering and stage legality.</summary>
	public ShaderStage Stage { get; set; }

	/// <summary>The domain the module targets.</summary>
	public ShaderDomain Domain => Module?.Meta?.Domain ?? ShaderDomain.Surface;

	/// <summary>True when comments should be written into the output.</summary>
	public bool WantsComments => Options.EmitComments || Options.DebugSymbols;

	NodeId _origin;

	// ---- expressions ------------------------------------------------------

	/// <summary>Render an expression as HLSL, parenthesised only where precedence requires it.</summary>
	public string Expression( IrExpr expr ) => Expression( expr, PrecedenceLowest );

	string Expression( IrExpr expr, int minPrecedence )
	{
		if ( expr is null ) return "0";

		var (text, precedence) = Render( expr );

		return precedence < minPrecedence ? $"( {text} )" : text;
	}

	(string Text, int Precedence) Render( IrExpr expr )
	{
		switch ( expr )
		{
			case IrConst c:
				return ( HlslIntrinsics.Literal( c.Type, c.Value ), PrecedencePrimary );

			case IrVar v:
				return ( v.Name ?? "0", PrecedencePrimary );

			// Sanitised to match SboxMaterialBinding.Declare: the block parser is ASCII only, so a
			// declaration and every reference to it have to agree on the same renamed spelling.
			case IrGlobalRef g:
				return ( SboxShaderTemplates.SafeIdentifier( g.Decl?.Name ) ?? "0", PrecedencePrimary );

			case IrBuiltinRef b:
				return ( RenderBuiltin( b ), PrecedencePrimary );

			case IrCall call:
				return ( RenderCall( call ), PrecedencePrimary );

			case IrHelperCall helper:
				return ( RenderHelperCall( helper ), PrecedencePrimary );

			case IrBinary binary:
				return RenderBinary( binary );

			case IrUnary unary:
			{
				var symbol = UnaryOps.Symbol( unary.Op );
				var operand = Expression( unary.V, PrecedenceUnary );

				// A unary operand is not parenthesised — unary binds tighter than everything below it —
				// so a nested negate would print `--x`, which DXC and Slang both lex as pre-decrement:
				// an error on a non-lvalue and a different program on one. `-(-1.0f)` has the same
				// shape. A single space separates the two tokens and costs nothing; `!!x` and `~~x` are
				// legal but read better spaced too. Reachable with folding off, which the docs
				// recommend for bug reports.
				var gap = operand.Length > 0 && operand[0] == symbol[0] ? " " : string.Empty;

				return ( $"{symbol}{gap}{operand}", PrecedenceUnary );
			}

			case IrSwizzle swizzle:
				return ( $"{Expression( swizzle.V, PrecedencePostfix )}.{HlslIntrinsics.NormalizeSwizzle( swizzle.Mask )}",
					PrecedencePostfix );

			case IrConstruct construct:
				return ( RenderConstruct( construct ), PrecedencePrimary );

			case IrCast cast:
				return RenderCast( cast );

			case IrSelect select:
				return ( $"select( {Expression( select.C )}, {Expression( select.A )}, {Expression( select.B )} )",
					PrecedencePrimary );

			case IrIndex index:
				return ( $"{Expression( index.V, PrecedencePostfix )}[{Expression( index.I )}]", PrecedencePostfix );

			case IrMember member:
				return ( $"{Expression( member.V, PrecedencePostfix )}.{member.Field}", PrecedencePostfix );

			default:
				Report( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed,
					$"The HLSL backend does not know how to write a {expr.GetType().Name}." );
				return ( HlslIntrinsics.Fallback( expr.Type ), PrecedencePrimary );
		}
	}

	string RenderBuiltin( IrBuiltinRef builtin )
	{
		var text = HlslIntrinsics.BuiltinExpression( builtin.Id, Stage, Domain );

		if ( !string.IsNullOrEmpty( text ) ) return text;

		Report( DiagnosticSeverity.Error, DiagnosticCode.BackendUnsupported,
			$"'{builtin.Id}' has no representation in the {Stage.DisplayName().ToLowerInvariant()} stage of a {Domain} shader.",
			$"The s&box shader environment provides no expression for it here. Compute the value where it exists and pass it through a varying, or bind it as a render attribute." );

		return HlslIntrinsics.Fallback( builtin.Type );
	}

	string RenderCall( IrCall call )
	{
		var id = call.Id;
		var args = call.Args ?? Array.Empty<IrExpr>();
		var rendered = new string[args.Length];
		var types = new ShaderType[args.Length];

		for ( int i = 0; i < args.Length; i++ )
		{
			rendered[i] = Expression( args[i] );
			types[i] = args[i]?.Type ?? ShaderType.Void;
		}

		var info = IntrinsicCatalog.Get( id );

		if ( !info.IsAvailableOnTarget )
		{
			Report( DiagnosticSeverity.Error, DiagnosticCode.ShaderModelTooHigh,
				$"'{info.Name}' requires SM {info.MinShaderModel}; s&box compiles at SM {ShaderModel.Target} (Vulkan).",
				info.Description );
		}
		else if ( !IntrinsicCatalog.IsLegalIn( id, Stage ) )
		{
			if ( HlslIntrinsics.TryLowerForStage( id, Stage, out var lowered ) )
			{
				Report( DiagnosticSeverity.Info, DiagnosticCode.SampleLowered,
					$"'{info.Name}' was lowered to '{IntrinsicCatalog.Name( lowered )}' because the {Stage.DisplayName().ToLowerInvariant()} stage has no screen-space derivatives.",
					"Mip selection falls back to level 0. Feed an explicit LOD if that is not what you want." );
			}
			else
			{
				Report( DiagnosticSeverity.Error, DiagnosticCode.StageViolation,
					$"'{info.Name}' is not legal in the {Stage.DisplayName().ToLowerInvariant()} stage.",
					"There is no meaning-preserving substitute. Move the operation to the pixel stage." );
			}
		}

		if ( !info.AcceptsArity( args.Length ) )
		{
			Report( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed,
				$"'{info.Name}' takes {info.MinArgs}..{info.MaxArgs} arguments but was given {args.Length}." );
		}

		return HlslIntrinsics.Call( id, rendered, types, Stage, Dialect );
	}

	string RenderHelperCall( IrHelperCall call )
	{
		var args = call.Args ?? Array.Empty<IrExpr>();

		if ( call.Fn is null )
		{
			Report( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed, "A helper call has no helper attached." );
			return HlslIntrinsics.Fallback( call.Type );
		}

		if ( args.Length == 0 ) return $"{call.Fn.Name}()";

		var parts = new string[args.Length];

		for ( int i = 0; i < args.Length; i++ ) parts[i] = Expression( args[i] );

		return $"{call.Fn.Name}( {string.Join( ", ", parts )} )";
	}

	(string Text, int Precedence) RenderBinary( IrBinary binary )
	{
		var precedence = BinaryOps.Precedence( binary.Op );

		if ( BinaryOps.IsShortCircuit( binary.Op ) && !( binary.L?.Type.IsScalar ?? true ) )
		{
			// && and || short-circuit and therefore never work component-wise. The IR is supposed to
			// use Intrinsic.AndFn / OrFn for vectors; recover instead of emitting silently wrong code.
			var fn = binary.Op == BinaryOp.LogicalAnd ? Intrinsic.AndFn : Intrinsic.OrFn;

			Report( DiagnosticSeverity.Warning, DiagnosticCode.BackendUnsupported,
				$"'{BinaryOps.Symbol( binary.Op )}' short-circuits and cannot be applied component-wise; emitted '{HlslIntrinsics.Spelling( fn )}' instead." );

			return ( $"{HlslIntrinsics.Spelling( fn )}( {Expression( binary.L )}, {Expression( binary.R )} )",
				PrecedencePrimary );
		}

		var left = Expression( binary.L, precedence );
		var right = Expression( binary.R, precedence + 1 );

		return ( $"{left} {BinaryOps.Symbol( binary.Op )} {right}", precedence );
	}

	string RenderConstruct( IrConstruct construct )
	{
		var parts = construct.Parts ?? Array.Empty<IrExpr>();
		var rendered = new string[parts.Length];

		for ( int i = 0; i < parts.Length; i++ ) rendered[i] = Expression( parts[i] );

		if ( construct.Type.IsStruct )
		{
			if ( parts.Length == 0 ) return $"( {construct.Type.Hlsl} )0";

			// Slang gives every struct a synthesised constructor, which composes anywhere an expression
			// can appear. Plain HLSL only has the initialiser list, which is legal solely as the
			// right-hand side of a declaration — the one place the IR ever builds a struct.
			return Dialect == HlslDialect.SboxSlang
				? $"{construct.Type.Hlsl}( {string.Join( ", ", rendered )} )"
				: $"{{ {string.Join( ", ", rendered )} }}";
		}

		if ( parts.Length == 0 ) return HlslIntrinsics.Fallback( construct.Type );

		return $"{construct.Type.Hlsl}( {string.Join( ", ", rendered )} )";
	}

	(string Text, int Precedence) RenderCast( IrCast cast )
	{
		var source = cast.V?.Type ?? ShaderType.Void;
		var target = cast.Type;

		switch ( cast.Kind )
		{
			case CastKind.Bitcast:
				var reinterpret = target.Scalar switch
				{
					ScalarKind.Int => Intrinsic.AsInt,
					ScalarKind.UInt => Intrinsic.AsUint,
					_ => Intrinsic.AsFloat
				};

				return ( $"{IntrinsicCatalog.Name( reinterpret )}( {Expression( cast.V )} )", PrecedencePrimary );

			case CastKind.Truncate when source.IsScalarOrVector && target.IsScalarOrVector &&
										target.Components < source.Components &&
										target.Scalar == source.Scalar:
				return ( $"{Expression( cast.V, PrecedencePostfix )}.{HlslIntrinsics.LeadingMask( target.Components )}",
					PrecedencePostfix );

			case CastKind.Pad when source.IsScalarOrVector && target.IsScalarOrVector &&
								   target.Components > source.Components:
				var padded = new List<string>( target.Components ) { Expression( cast.V ) };

				for ( int i = source.Components; i < target.Components; i++ )
				{
					padded.Add( HlslIntrinsics.Number( cast.Fill, target.Scalar ) );
				}

				return ( $"{target.Hlsl}( {string.Join( ", ", padded )} )", PrecedencePrimary );

			default:
				return ( $"( {target.Hlsl} ){Expression( cast.V, PrecedenceUnary )}", PrecedenceUnary );
		}
	}

	// ---- statements -------------------------------------------------------

	/// <summary>Write a block's statements at the builder's current indentation.</summary>
	public void WriteStatements( HlslSourceBuilder builder, IrBlock block )
	{
		if ( builder is null || block is null ) return;

		foreach ( var statement in block.Statements ) WriteStatement( builder, statement );
	}

	/// <summary>Write a braced block.</summary>
	public void WriteBracedBlock( HlslSourceBuilder builder, IrBlock block, NodeId origin )
	{
		builder.Open( origin );
		WriteStatements( builder, block );
		builder.Close( origin: origin );
	}

	/// <summary>Write one statement, recording every line it produces against its originating node.</summary>
	public void WriteStatement( HlslSourceBuilder builder, IrStmt statement )
	{
		if ( builder is null || statement is null ) return;

		var previous = _origin;
		_origin = statement.Origin;

		try
		{
			switch ( statement )
			{
				case IrDecl decl:
					builder.Write( decl.Init is null
						? $"{decl.Type.Hlsl} {decl.Name};"
						: $"{decl.Type.Hlsl} {decl.Name} = {Expression( decl.Init )};", decl.Origin );
					break;

				case IrAssign assign:
					builder.Write( $"{Expression( assign.Target )} = {Expression( assign.Value )};", assign.Origin );
					break;

				case IrIf branch:
					builder.Write( $"if ( {Expression( branch.Cond )} )", branch.Origin );
					WriteBracedBlock( builder, branch.Then, branch.Origin );

					if ( branch.Else is { IsEmpty: false } )
					{
						builder.Write( "else", branch.Origin );
						WriteBracedBlock( builder, branch.Else, branch.Origin );
					}

					break;

				case IrFor loop:
					var counter = string.IsNullOrEmpty( loop.Var ) ? "n" : loop.Var;

					builder.Write(
						$"for ( int {counter} = 0; {counter} < ( int )( {Expression( loop.Count )} ); {counter}++ )",
						loop.Origin );
					WriteBracedBlock( builder, loop.Body, loop.Origin );
					break;

				case IrWhile loop:
					builder.Write( $"while ( {Expression( loop.Cond )} )", loop.Origin );
					WriteBracedBlock( builder, loop.Body, loop.Origin );
					break;

				case IrBreak:
					builder.Write( "break;", statement.Origin );
					break;

				case IrContinue:
					builder.Write( "continue;", statement.Origin );
					break;

				case IrReturn ret:
					builder.Write( ret.Value is null ? "return;" : $"return {Expression( ret.Value )};", ret.Origin );
					break;

				case IrDiscard:
					if ( !Stage.CanDiscard() )
					{
						Report( DiagnosticSeverity.Error, DiagnosticCode.StageViolation,
							$"A fragment can only be discarded in the pixel stage, not in the {Stage.DisplayName().ToLowerInvariant()} stage." );
						break;
					}

					builder.Write( "discard;", statement.Origin );
					break;

				case IrExprStmt expression:
					builder.Write( $"{Expression( expression.Value )};", expression.Origin );
					break;

				case IrComment comment:
					if ( !WantsComments || string.IsNullOrWhiteSpace( comment.Text ) ) break;

					foreach ( var line in comment.Text.Replace( "\r\n", "\n" ).Split( '\n' ) )
					{
						builder.Write( $"// {line.Trim()}", comment.Origin );
					}

					break;

				case IrScope scope:
					WriteBracedBlock( builder, scope.Body, scope.Origin );
					break;

				case IrPreprocessorIf guard:
				{
					// The .shader writer handles guards itself; this is the standalone emit the text
					// editor probe-compiles, and it has to produce the same directives rather than
					// reporting the statement as one the backend does not understand.
					var directive = IrPreprocessor.OpenDirective( guard.Condition );

					if ( string.IsNullOrEmpty( directive ) )
					{
						// An unusable condition must not become a directive the preprocessor rejects: a
						// preprocessor error has no line that maps back to a node. Emitting both sides
						// unguarded keeps the shader compiling and costs only the exclusion.
						Report( DiagnosticSeverity.Warning, DiagnosticCode.InvalidBlock,
							"A compile-time branch had no usable combo condition, so both of its sides were emitted.",
							"Bind the node to a graph keyword. Without one there is nothing for the preprocessor to test, and the branch cannot be resolved at compile time." );

						WriteStatements( builder, guard.Then );
						WriteStatements( builder, guard.Else );

						break;
					}

					builder.Write( directive, guard.Origin );
					WriteStatements( builder, guard.Then );

					if ( guard.HasElse )
					{
						builder.Write( IrPreprocessor.ElseDirective, guard.Origin );
						WriteStatements( builder, guard.Else );
					}

					builder.Write( IrPreprocessor.EndDirective, guard.Origin );
					break;
				}

				default:
					Report( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed,
						$"The HLSL backend does not know how to write a {statement.GetType().Name}." );
					break;
			}
		}
		finally
		{
			_origin = previous;
		}
	}

	// ---- declarations -----------------------------------------------------

	/// <summary>
	/// The module's helpers in dependency order, deduplicated by name.
	/// <para>
	/// A same-name, different-body collision is a hard error naming both bodies, unlike the built-in
	/// editor's process-global function table which silently keeps whichever registered first.
	/// </para>
	/// </summary>
	public IReadOnlyList<HelperFunction> OrderedHelpers()
	{
		var ordered = new List<HelperFunction>();

		if ( Module is null ) return ordered;

		var accepted = new Dictionary<string, HelperFunction>( StringComparer.Ordinal );
		var visiting = new HashSet<string>( StringComparer.Ordinal );

		foreach ( var helper in Module.Helpers ) Visit( helper );

		return ordered;

		void Visit( HelperFunction helper )
		{
			if ( helper is null || string.IsNullOrWhiteSpace( helper.Name ) ) return;

			if ( accepted.TryGetValue( helper.Name, out var existing ) )
			{
				if ( existing.ConflictsWith( helper ) )
				{
					Report( DiagnosticSeverity.Error, DiagnosticCode.HelperCollision,
						$"Two different helper functions are both named '{helper.Name}'.",
						$"Signatures: '{existing.SignatureHlsl}' and '{helper.SignatureHlsl}'. Helper names are the deduplication key, so they must be unique per module." );
				}

				return;
			}

			if ( !visiting.Add( helper.Name ) )
			{
				Report( DiagnosticSeverity.Error, DiagnosticCode.HelperCollision,
					$"Helper function '{helper.Name}' depends on itself.",
					"Helper requirement chains must form a directed acyclic graph." );
				return;
			}

			foreach ( var requirement in helper.Requires ?? Array.Empty<HelperFunction>() ) Visit( requirement );

			visiting.Remove( helper.Name );
			accepted[helper.Name] = helper;
			ordered.Add( helper );
		}
	}

	/// <summary>
	/// The helpers a stage's own code can actually reach, transitively.
	/// <para>
	/// <see cref="HelperFunction.Stages"/> says where a helper <i>may</i> be written, not where it is
	/// wanted: a pure-maths helper declares <see cref="StageMask.All"/> and would otherwise be emitted
	/// into the vertex program of every graph whose pixel program happens to call it. DXC drops the dead
	/// code, but Prism ships a viewer for the generated text, and hundreds of lines of functions the
	/// program never calls is the difference between a shader a person can read and one they cannot.
	/// </para>
	/// </summary>
	public HashSet<string> ReachableHelpers( ShaderStage stage, IReadOnlyList<HelperFunction> helpers )
	{
		var reachable = new HashSet<string>( StringComparer.Ordinal );

		if ( Module is null || helpers is null || helpers.Count == 0 ) return reachable;

		var byName = new Dictionary<string, HelperFunction>( StringComparer.Ordinal );

		foreach ( var helper in helpers )
		{
			if ( !string.IsNullOrEmpty( helper?.Name ) ) byName[helper.Name] = helper;
		}

		foreach ( var function in Module.Functions )
		{
			if ( function is null ) continue;

			var belongs = function.IsEntryPoint
				? function.Stage == stage
				: function.Stage == ShaderStage.None || function.Stage == stage;

			if ( !belongs ) continue;

			foreach ( var statement in WalkStatements( function.Body ) )
			{
				foreach ( var expression in StatementExpressions( statement ) )
				{
					foreach ( var node in IrExprUtil.Walk( expression ) )
					{
						if ( node is IrHelperCall call && !string.IsNullOrEmpty( call.Fn?.Name ) ) Pull( call.Fn.Name );
					}
				}
			}
		}

		return reachable;

		void Pull( string name )
		{
			if ( !byName.TryGetValue( name, out var helper ) ) return;
			if ( !reachable.Add( name ) ) return;

			foreach ( var requirement in helper.Requires ?? Array.Empty<HelperFunction>() )
			{
				if ( !string.IsNullOrEmpty( requirement?.Name ) ) Pull( requirement.Name );
			}

			// A helper body is author-supplied text, so one helper calling another need not have been
			// declared through Requires. Naming another helper anywhere in the body pulls it in: over-
			// including costs one dead function, under-including costs a compile error.
			var body = helper.BodyFor( PrismConstants.BackendHlsl );

			if ( string.IsNullOrEmpty( body ) ) return;

			foreach ( var candidate in byName.Keys.ToArray() )
			{
				if ( reachable.Contains( candidate ) ) continue;
				if ( body.Contains( candidate, StringComparison.Ordinal ) ) Pull( candidate );
			}
		}
	}

	/// <summary>Every statement in a block, including the ones nested inside control flow.</summary>
	static IEnumerable<IrStmt> WalkStatements( IrBlock block )
	{
		if ( block is null ) yield break;

		foreach ( var statement in block.Statements )
		{
			if ( statement is null ) continue;

			yield return statement;

			IrBlock[] nested = statement switch
			{
				IrIf branch => [branch.Then, branch.Else],
				IrFor loop => [loop.Body],
				IrWhile loop => [loop.Body],
				IrScope scope => [scope.Body],
				_ => null
			};

			if ( nested is null ) continue;

			foreach ( var child in nested )
			{
				foreach ( var inner in WalkStatements( child ) ) yield return inner;
			}
		}
	}

	/// <summary>The expressions one statement holds directly.</summary>
	static IEnumerable<IrExpr> StatementExpressions( IrStmt statement )
	{
		switch ( statement )
		{
			case IrDecl decl:
				yield return decl.Init;
				break;

			case IrAssign assign:
				yield return assign.Target;
				yield return assign.Value;
				break;

			case IrIf branch:
				yield return branch.Cond;
				break;

			case IrFor loop:
				yield return loop.Count;
				break;

			case IrWhile loop:
				yield return loop.Cond;
				break;

			case IrReturn returned:
				yield return returned.Value;
				break;

			case IrExprStmt expression:
				yield return expression.Value;
				break;
		}
	}

	/// <summary>Write the helper bodies a stage actually calls, in dependency order.</summary>
	public void WriteHelpers( HlslSourceBuilder builder, ShaderStage stage, IReadOnlyList<HelperFunction> helpers )
	{
		if ( builder is null || helpers is null ) return;

		var reachable = ReachableHelpers( stage, helpers );

		foreach ( var helper in helpers )
		{
			if ( !helper.Stages.Contains( stage ) ) continue;
			if ( !reachable.Contains( helper.Name ) ) continue;

			var body = helper.BodyFor( PrismConstants.BackendHlsl );

			if ( string.IsNullOrWhiteSpace( body ) )
			{
				Report( DiagnosticSeverity.Error, DiagnosticCode.HelperCollision,
					$"Helper function '{helper.Name}' has no HLSL body." );
				continue;
			}

			if ( helper.MinShaderModel > ShaderModel.Target )
			{
				Report( DiagnosticSeverity.Error, DiagnosticCode.ShaderModelTooHigh,
					$"Helper function '{helper.Name}' requires SM {helper.MinShaderModel}; s&box compiles at SM {ShaderModel.Target} (Vulkan)." );
			}

			if ( Dialect == HlslDialect.StrictHlsl2021 ) CheckStrictDialect( helper, body );

			builder.WriteBlock( body );
			builder.Blank();
		}
	}

	static readonly string[] s_slangOnlySyntax =
	[
		"[mutating]", "__init", "extension ", "interface ", "associatedtype", "no_diff", "__generic",
		"property ", "[ForceInline]", "[Differentiable]"
	];

	/// <summary>
	/// Warn about Slang-only syntax in a helper body when the graph asked for portable HLSL 2021.
	/// <para>
	/// Prism's own emission already avoids these constructs in the strict dialect; a helper's body is
	/// author-supplied text, so the best we can do is name the construct rather than let DXC reject it
	/// with a message pointing at a generated line.
	/// </para>
	/// </summary>
	void CheckStrictDialect( HelperFunction helper, string body )
	{
		foreach ( var syntax in s_slangOnlySyntax )
		{
			if ( body.IndexOf( syntax, StringComparison.Ordinal ) < 0 ) continue;

			Report( DiagnosticSeverity.Warning, DiagnosticCode.BackendUnsupported,
				$"Helper function '{helper.Name}' uses the Slang-only construct '{syntax.Trim()}', but this graph targets strict HLSL 2021.",
				"Either rewrite the helper in plain HLSL or switch the graph's dialect back to s&box Slang, which the engine's own headers already require." );
		}
	}

	/// <summary>Write the non-entry-point functions the module carries for a stage.</summary>
	public void WriteFunctions( HlslSourceBuilder builder, ShaderStage stage )
	{
		if ( builder is null || Module is null ) return;

		var previous = Stage;
		Stage = stage;

		try
		{
			foreach ( var function in Module.Functions )
			{
				if ( function is null || function.IsEntryPoint ) continue;
				if ( function.Stage != ShaderStage.None && function.Stage != stage ) continue;

				foreach ( var attribute in function.Attributes ) builder.Write( attribute );

				builder.Write( function.SignatureHlsl );
				builder.Open();
				WriteStatements( builder, function.Body );
				builder.Close();
				builder.Blank();
			}
		}
		finally
		{
			Stage = previous;
		}
	}

	// ---- diagnostics ------------------------------------------------------

	/// <summary>
	/// Report a problem against the node currently being written, once per distinct message. A backend
	/// never throws for user error and never floods the panel with one repeated line.
	/// </summary>
	public void Report( DiagnosticSeverity severity, string code, string message, string detail = null )
	{
		var key = $"{code}|{message}|{_origin}";

		if ( !_reported.Add( key ) ) return;

		GraphRef? graph = _origin.IsValid ? GraphRef.ForNode( _origin ) : null;

		Diagnostics.Report( new Diagnostic( severity, code, message, detail, null, graph ) );
	}

	/// <summary>The node whose statement is currently being written, for diagnostics attribution.</summary>
	public NodeId CurrentOrigin
	{
		get => _origin;
		set => _origin = value;
	}
}

/// <summary>
/// The s&amp;box HLSL backend: turns an <see cref="IrModule"/> into a complete VFX <c>.shader</c>
/// file that the engine compiles and the preview renders.
/// <para>
/// The heavy lifting is split in two on purpose. <see cref="HlslEmitter"/> lowers IR to HLSL
/// declarations and function bodies; <see cref="SboxShaderWriter"/> wraps those in the block file.
/// That separation is what lets the same HLSL feed a probe compile, a <c>.shader</c>, or a future
/// target, and it keeps the block-file knowledge in one auditable place.
/// </para>
/// </summary>
public sealed class HlslBackend : IShaderBackend
{
	/// <inheritdoc/>
	public string Id => PrismConstants.BackendHlsl;

	/// <inheritdoc/>
	public string DisplayName => "s&box Shader (HLSL / VFX)";

	/// <inheritdoc/>
	public string FileExtension => PrismConstants.ShaderExtension;

	/// <inheritdoc/>
	public BackendCapabilities Capabilities => BackendCapabilities.Sbox;

	/// <inheritdoc/>
	public BackendEmitResult Emit( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics )
	{
		diagnostics ??= new DiagnosticSink();
		options ??= BackendEmitOptions.Default;

		if ( module is null )
		{
			diagnostics.Error( DiagnosticCode.InvalidBlock, "There is nothing to emit: the compiler produced no module." );
			return BackendEmitResult.Empty( Id, FileExtension );
		}

		return PrismLog.Guard( "HlslBackend.Emit",
			() => new SboxShaderWriter( module, options, diagnostics ).Write(),
			BackendEmitResult.Empty( Id, FileExtension ) );
	}

	/// <summary>
	/// Emit one stage as a plain HLSL translation unit, with no VFX blocks around it.
	/// <para>
	/// This is what the text editor's probe compiler and the IR debug view want: declarations, helper
	/// bodies and the entry point, in a form a bare DXC or slangc invocation can read.
	/// </para>
	/// </summary>
	public string EmitStandalone( IrModule module, ShaderStage stage, BackendEmitOptions options,
		DiagnosticSink diagnostics )
	{
		diagnostics ??= new DiagnosticSink();
		options ??= BackendEmitOptions.Default;

		if ( module is null ) return string.Empty;

		return PrismLog.Guard( "HlslBackend.EmitStandalone", () =>
		{
			var builder = new HlslSourceBuilder( options.Indent, options.NewLine );
			var emitter = new HlslEmitter( module, options, diagnostics ) { Stage = stage };

			foreach ( var include in module.Includes ) builder.Write( $"#include \"{include}\"" );

			if ( module.Includes.Count > 0 ) builder.Blank();

			foreach ( var structure in module.Structs )
			{
				builder.Write( $"struct {structure.Name}" );
				builder.Open();

				foreach ( var include in structure.Includes ) builder.Write( $"#include \"{include}\"" );

				foreach ( var field in structure.Fields )
				{
					var semantic = string.IsNullOrEmpty( field.Semantic ) ? string.Empty : $" : {field.Semantic}";
					builder.Write( $"{Interpolation( field.Interpolation )}{field.Type.Hlsl} {field.Name}{semantic};" );
				}

				builder.Close( ";" );
				builder.Blank();
			}

			SboxMaterialBinding.WriteGlobals( builder, emitter, stage );
			emitter.WriteHelpers( builder, stage, emitter.OrderedHelpers() );
			emitter.WriteFunctions( builder, stage );

			var entry = module.EntryPoint( stage );

			if ( entry is not null ) WriteStandaloneEntry( builder, emitter, module, entry, stage );

			return builder.ToString();
		}, string.Empty );
	}

	/// <summary>
	/// Write an entry point with the fixed signature the engine expects, plus the material prologue
	/// and tail. The IR's own signature is deliberately not used: entry-point names, parameters and
	/// semantics are fixed by the engine, and a probe compile is only useful if the locals the body
	/// refers to actually exist.
	/// </summary>
	static void WriteStandaloneEntry( HlslSourceBuilder builder, HlslEmitter emitter, IrModule module,
		IrFunction entry, ShaderStage stage )
	{
		foreach ( var attribute in entry.Attributes ) builder.Write( attribute );

		switch ( stage )
		{
			case ShaderStage.Vertex:
				builder.Write(
					$"{SboxShaderTemplates.StructPixelInput} {PrismConstants.EntryPointVertex}( {SboxShaderTemplates.StructVertexInput} {SboxShaderTemplates.VertexInputLocal} )" );
				builder.Open();
				builder.WriteBlock( SboxShaderTemplates.SurfaceVertexPrologue );
				emitter.WriteStatements( builder, entry.Body );
				builder.Write( SboxShaderTemplates.SurfaceVertexEpilogue );
				builder.Close();
				break;

			case ShaderStage.Pixel:
				var returned = SboxMaterialBinding.EndsWithReturn( entry.Body );

				builder.Write(
					$"float4 {PrismConstants.EntryPointPixel}( {SboxShaderTemplates.StructPixelInput} {SboxShaderTemplates.PixelInputLocal} ) : SV_Target0" );
				builder.Open();

				if ( !returned ) SboxMaterialBinding.WritePixelPrologue( builder, module );

				emitter.WriteStatements( builder, entry.Body );

				if ( !returned ) SboxMaterialBinding.WritePixelEpilogue( builder, module, emitter );

				builder.Close();
				break;

			case ShaderStage.Compute:
				if ( entry.Attributes.Count == 0 ) builder.Write( SboxShaderTemplates.ComputeDefaultNumThreads );

				builder.Write(
					$"void {PrismConstants.EntryPointCompute}( {SboxShaderTemplates.ComputeEntryParameters} )" );
				builder.Open();
				emitter.WriteStatements( builder, entry.Body );
				builder.Close();
				break;

			default:
				builder.Write( entry.SignatureHlsl );
				builder.Open();
				emitter.WriteStatements( builder, entry.Body );
				builder.Close();
				break;
		}
	}

	/// <summary>The HLSL interpolation modifier prefix for a field, including its trailing space.</summary>
	public static string Interpolation( IrInterpolation interpolation ) => interpolation switch
	{
		IrInterpolation.NoPerspective => "noperspective ",
		IrInterpolation.NoInterpolation => "nointerpolation ",
		IrInterpolation.Centroid => "centroid ",
		IrInterpolation.Sample => "sample ",
		_ => string.Empty
	};
}