Editor/Prism/Compiler/Backends/SlangBackend.cs

A backend that converts an IR shader module into a self-contained Slang (.slang) shader source file. It prepares globals, parameters, builtins and structs, then emits helpers, interfaces, functions and entry points with source mapping and optional preview instrumentation.

File AccessNetworking
using System.Globalization;
using System.Text;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;

namespace Editor.Prism.Compiler.Backends;

/// <summary>
/// Turns an <see cref="IrModule"/> into a self-contained, idiomatic Slang module.
/// <para>
/// The artifact is a real, portable <c>.slang</c> file: it pins the language version, declares a
/// module, imports the emitted <c>prism.core</c> prelude, groups every shader parameter into one
/// <c>ParameterBlock</c>, and exposes <c>[shader("vertex")]</c> / <c>[shader("pixel")]</c> /
/// <c>[shader("compute")]</c> entry points. It compiles with nothing but <c>slangc</c> and an include
/// path, which is exactly what makes it useful outside s&amp;box.
/// </para>
/// <para>
/// Two rules are structural rather than stylistic. <c>?:</c> is never emitted with a vector condition
/// and <c>&amp;&amp;</c> / <c>||</c> never appear on a vector, because neither is component-wise;
/// <see cref="SlangIntrinsics"/> owns both decisions so this file cannot break them by accident.
/// </para>
/// </summary>
public sealed class SlangBackend : IShaderBackend
{
	/// <inheritdoc/>
	public string Id => PrismConstants.BackendSlang;

	/// <inheritdoc/>
	public string DisplayName => "Slang";

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

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

	/// <summary>
	/// Emit the module. Never throws: a malformed module, a helper with no Slang body or an
	/// unexpected fault all come back as diagnostics plus an empty result.
	/// </summary>
	public BackendEmitResult Emit( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics )
	{
		var sink = diagnostics ?? new DiagnosticSink();
		var emitOptions = options ?? BackendEmitOptions.Default;

		if ( module is null )
		{
			sink.Error( DiagnosticCode.InvalidBlock, "The Slang backend was given no module to emit." );
			return BackendEmitResult.Empty( Id, FileExtension );
		}

		try
		{
			if ( !PreviewInstrumentation.IsEnabled( emitOptions.Mode ) )
			{
				return new Emitter( this, module, emitOptions, sink ).Run();
			}

			// A node's stage id is its rank among the nodes appearing in the finished module's source
			// map, so the module has to be written once before the numbers it bakes in can be known.
			// The throw-away pass reports into a sink nobody reads, because the real pass reports the
			// same diagnostics.
			var probe = new Emitter( this, module, emitOptions, new DiagnosticSink() );

			probe.Run();

			return new Emitter( this, module, emitOptions, sink )
			{
				StageIds = PreviewInstrumentation.BuildStageMap( probe.SourceMap )
			}.Run();
		}
		catch ( Exception e )
		{
			PrismLog.Error( e, "Slang emission failed" );
			sink.Error( DiagnosticCode.InvalidBlock,
				"Slang emission failed and produced no artifact.", null, e.Message );

			return BackendEmitResult.Empty( Id, FileExtension );
		}
	}

	// =========================================================================
	//  Writer
	// =========================================================================

	/// <summary>A line-counting, indenting text writer. Every line it writes can be source-mapped.</summary>
	sealed class Writer
	{
		readonly StringBuilder _text = new();
		readonly string _indent;
		readonly string _newLine;

		int _depth;
		int _line = 1;
		bool _lastWasBlank = true;

		public Writer( string indent, string newLine )
		{
			_indent = indent ?? "\t";
			_newLine = string.IsNullOrEmpty( newLine ) ? "\r\n" : newLine;
		}

		/// <summary>The line the next call to <see cref="Line"/> will land on.</summary>
		public int NextLine => _line;

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

		public void Push() => _depth++;

		public void Pop() => _depth = Math.Max( 0, _depth - 1 );

		/// <summary>Write one line at the current indent and return the line number it landed on.</summary>
		public int Line( string text = null )
		{
			var at = _line;

			if ( !string.IsNullOrEmpty( text ) )
			{
				for ( int i = 0; i < _depth; i++ ) _text.Append( _indent );
				_text.Append( text );
				_lastWasBlank = false;
			}
			else
			{
				_lastWasBlank = true;
			}

			_text.Append( _newLine );
			_line++;

			return at;
		}

		/// <summary>Write a blank line, collapsing runs of them so the output never double-spaces.</summary>
		public void Blank()
		{
			if ( _lastWasBlank ) return;

			Line();
		}

		/// <summary>
		/// Write a block of pre-formatted text — a helper body — re-indenting it to the current depth
		/// and counting every line so the source map stays accurate.
		/// </summary>
		public void Verbatim( string text )
		{
			if ( string.IsNullOrEmpty( text ) ) return;

			var normalised = text.Replace( "\r\n", "\n" ).Replace( '\r', '\n' );

			foreach ( var raw in normalised.Split( '\n' ) )
			{
				Line( raw.TrimEnd() );
			}
		}

		public override string ToString() => _text.ToString();
	}

	// =========================================================================
	//  Emitter
	// =========================================================================

	/// <summary>One emission of one module. Single use.</summary>
	sealed class Emitter
	{
		/// <summary>One field of a generated interface struct.</summary>
		sealed class InterfaceField
		{
			public string Name;
			public ShaderType Type;
			public string Semantic;
			public IrInterpolation Interpolation;
			public Builtin Source;
			public NodeId Origin;
		}

		/// <summary>One field of the generated parameter block.</summary>
		sealed class ParameterField
		{
			public GlobalDecl Decl;
			public string Name;
		}

		const int PrecPrimary = 100;
		const int PrecUnary = 90;

		readonly SlangBackend _backend;
		readonly IrModule _module;
		readonly BackendEmitOptions _options;
		readonly DiagnosticSink _sink;
		readonly Writer _w;
		readonly SourceMap _map = new();

		readonly Dictionary<string, string> _globalPath = new( StringComparer.Ordinal );
		readonly Dictionary<string, string> _structRename = new( StringComparer.Ordinal );
		readonly List<GlobalDecl> _constants = new();
		readonly List<ParameterField> _parameters = new();
		readonly List<InterfaceField> _vsIn = new();
		readonly List<InterfaceField> _vsOut = new();
		readonly List<Builtin> _vertexBuiltins = new();
		readonly List<Builtin> _pixelBuiltins = new();
		readonly List<Builtin> _computeBuiltins = new();
		readonly List<IrStruct> _userStructs = new();
		readonly HashSet<string> _reportedHelpers = new( StringComparer.Ordinal );

		IrFunction _vertex;
		IrFunction _pixel;
		IrFunction _geometry;
		IrFunction _compute;

		string _moduleName = "prism_shader";
		string _paramsStruct = "PrismParams";
		string _paramsBlock = "gParams";
		bool _needsFrontFace;
		ShaderStage _stage = ShaderStage.None;

		// The VsOut local the vertex entry point is currently building, and whether that entry point
		// displaces the vertex. WP-2 writes both interpolants and the position offset through one
		// synthetic "PixelInput i" struct, which is an s&box block-file convention with no meaning in a
		// standalone Slang module — these two let those writes be redirected to what Slang actually has.
		string _vertexOutput;
		bool _vertexMovesPosition;

		public Emitter( SlangBackend backend, IrModule module, BackendEmitOptions options, DiagnosticSink sink )
		{
			_backend = backend;
			_module = module;
			_options = options;
			_sink = sink;
			_w = new Writer( options.Indent, options.NewLine );
		}

		/// <summary>The map this emitter built, whether or not the options asked for one to be shipped.</summary>
		public SourceMap SourceMap => _map;

		/// <summary>
		/// Per-node preview stage ids. Non-null only on the real pass of an instrumented build, so it
		/// doubles as the flag that keeps the numbering pass from instrumenting itself.
		/// </summary>
		public IReadOnlyDictionary<NodeId, int> StageIds { get; init; }

		public BackendEmitResult Run()
		{
			Prepare();

			WriteBanner();
			WriteCombos();
			WriteConstants();
			WriteInstrumentation();
			WriteParameters();
			WriteUserStructs();
			WriteHelpers();
			WriteInterfaces();
			WriteFunctions();
			WriteEntryPoints();

			var text = _w.ToString();
			_map.File = $"{_moduleName}.{PrismConstants.SlangExtension}";

			var extra = new List<GeneratedArtifact> { SlangRuntimeModule.Artifact( _options.NewLine ) };

			return new BackendEmitResult( text, PrismConstants.SlangExtension,
				_options.EmitSourceMap ? _map : new SourceMap(), extra )
			{
				BackendId = _backend.Id,
				LineCount = _w.LineCount
			};
		}

		// ---- preparation ---------------------------------------------------

		void Prepare()
		{
			var name = string.IsNullOrWhiteSpace( _options.OutputName ) ? _module.Meta.Name : _options.OutputName;
			_moduleName = SlangIntrinsics.SanitizeIdentifier( name, "prism_shader" );
			_paramsStruct = SlangIntrinsics.PascalCase( _moduleName ) + "Params";

			_vertex = _module.EntryPoint( ShaderStage.Vertex );
			_pixel = _module.EntryPoint( ShaderStage.Pixel );
			_geometry = _module.EntryPoint( ShaderStage.Geometry );
			_compute = _module.EntryPoint( ShaderStage.Compute );

			PrepareGlobals();
			PrepareStructNames();
			PrepareBuiltins();
			PrepareVertexInput();
			PrepareVertexOutput();
			PrepareUserStructs();
		}

		void PrepareGlobals()
		{
			var used = new HashSet<string>( StringComparer.Ordinal ) { _paramsBlock, _paramsStruct };
			var declared = new List<GlobalDecl>();

			foreach ( var global in _module.Globals )
			{
				if ( global is null || string.IsNullOrWhiteSpace( global.Name ) ) continue;
				if ( _globalPath.ContainsKey( global.Name ) ) continue;

				declared.Add( global );
				AddGlobal( global, used );
			}

			// A global referenced by an expression but missing from the module list would otherwise emit
			// an undeclared identifier. Adopt it rather than producing text that cannot compile.
			foreach ( var expr in AllExpressions() )
			{
				if ( expr is not IrGlobalRef reference ) continue;
				if ( reference.Decl is null || string.IsNullOrWhiteSpace( reference.Decl.Name ) ) continue;
				if ( _globalPath.ContainsKey( reference.Decl.Name ) ) continue;

				declared.Add( reference.Decl );
				AddGlobal( reference.Decl, used );

				_sink.Warn( DiagnosticCode.GlobalCollision,
					$"'{reference.Decl.Name}' is used but was not declared in the module; the Slang backend declared it." );
			}

			// A texture may name a sampler that nothing declared separately. Declare it beside the texture.
			foreach ( var global in declared )
			{
				if ( global.Kind != GlobalKind.Texture ) continue;
				if ( string.IsNullOrWhiteSpace( global.SamplerName ) ) continue;
				if ( _globalPath.ContainsKey( global.SamplerName ) ) continue;

				AddGlobal( new GlobalDecl( global.SamplerName, ShaderType.Sampler, GlobalKind.Sampler ), used );
			}

			// Resources first, then numeric uniforms: the shape a reader expects of a parameter block.
			// OrderBy is a stable sort, which keeps emission byte-identical across runs.
			var ordered = _parameters.OrderBy( x => GroupOf( x.Decl ) ).ToList();
			_parameters.Clear();
			_parameters.AddRange( ordered );
		}

		void AddGlobal( GlobalDecl global, HashSet<string> used )
		{
			if ( global.Kind == GlobalKind.Constant )
			{
				var constant = Unique( SlangIntrinsics.SanitizeIdentifier( global.Name, "kConstant" ), used );
				_constants.Add( global with { Name = constant } );
				_globalPath[global.Name] = constant;
				return;
			}

			var preferred = SlangIntrinsics.PascalCase( global.Name );

			// A sampler usually shares its texture's name once the hungarian prefix is gone.
			// "BaseColorSampler" reads far better than "BaseColor2".
			if ( global.Kind == GlobalKind.Sampler && used.Contains( preferred ) ) preferred += "Sampler";

			var field = Unique( preferred, used );
			_parameters.Add( new ParameterField { Decl = global, Name = field } );
			_globalPath[global.Name] = $"{_paramsBlock}.{field}";
		}

		static int GroupOf( GlobalDecl global ) => global.Kind switch
		{
			GlobalKind.Texture or GlobalKind.RwTexture => 0,
			GlobalKind.Sampler => 1,
			GlobalKind.Buffer or GlobalKind.RwBuffer => 2,
			_ => 3
		};

		static string Unique( string name, HashSet<string> used )
		{
			if ( used.Add( name ) ) return name;

			for ( int i = 2; i < 1000; i++ )
			{
				var candidate = name + i.ToString( CultureInfo.InvariantCulture );
				if ( used.Add( candidate ) ) return candidate;
			}

			return name + "_x";
		}

		void PrepareStructNames()
		{
			var inputName = StructParameterName( _vertex );
			var outputName = _vertex?.ReturnStruct;

			if ( string.IsNullOrWhiteSpace( outputName ) && _vertex is not null && _vertex.ReturnType.IsStruct )
			{
				outputName = _vertex.ReturnType.StructName;
			}

			outputName ??= StructParameterName( _pixel );

			if ( !string.IsNullOrWhiteSpace( inputName ) )
			{
				_structRename[inputName] = SlangIntrinsics.VertexInputStruct;
			}

			if ( !string.IsNullOrWhiteSpace( outputName ) )
			{
				_structRename[outputName] = SlangIntrinsics.VertexOutputStruct;
			}
		}

		static string StructParameterName( IrFunction function )
		{
			if ( function is null ) return null;

			foreach ( var parameter in function.Parameters )
			{
				if ( parameter.Type.IsStruct && !string.IsNullOrEmpty( parameter.Type.StructName ) )
				{
					return parameter.Type.StructName;
				}
			}

			return null;
		}

		void PrepareBuiltins()
		{
			CollectBuiltins( _pixel, _pixelBuiltins, ShaderStage.Pixel );
			CollectBuiltins( _compute, _computeBuiltins, ShaderStage.Compute );

			var vertex = new HashSet<Builtin>();
			foreach ( var id in ReferencedBuiltins( _vertex ) ) vertex.Add( id );

			// Whatever else happens, the vertex program has to produce a clip-space position, and it has
			// to produce every interpolant the pixel program is going to read back — but only the ones
			// its own body did not already write, so the prologue never binds a value nothing reads.
			var output = VertexOutputVariable( _vertex );
			var assigned = _vertex is not null && output is not null
				? AssignedFields( _vertex.Body, output )
				: new HashSet<string>( StringComparer.Ordinal );

			if ( !assigned.Contains( "Position" ) ) vertex.Add( Builtin.ClipPosition );

			// A graph that displaces the vertex writes through the world position and then re-derives clip
			// space from it, so both locals must exist even when nothing in the body reads them as builtins.
			if ( _vertex is not null && MovesWorldPosition( _vertex.Body ) )
			{
				vertex.Add( Builtin.WorldPosition );
				vertex.Add( Builtin.ClipPosition );
			}

			foreach ( var id in _pixelBuiltins )
			{
				var field = SlangIntrinsics.InterpolantField( id );

				if ( field is null || assigned.Contains( field ) ) continue;

				vertex.Add( id );
			}

			Close( vertex, ShaderStage.Vertex );
			_vertexBuiltins.AddRange( vertex.OrderBy( x => (int)x ) );

			_needsFrontFace = _pixelBuiltins.Contains( Builtin.IsFrontFace );
		}

		void CollectBuiltins( IrFunction function, List<Builtin> into, ShaderStage stage )
		{
			var set = new HashSet<Builtin>();
			foreach ( var id in ReferencedBuiltins( function ) ) set.Add( id );

			Close( set, stage );
			into.AddRange( set.OrderBy( x => (int)x ) );
		}

		void Close( HashSet<Builtin> set, ShaderStage stage )
		{
			// Enum order already satisfies every dependency edge, so one pass over a snapshot is enough
			// for the two-level chains the table actually contains, and a second pass covers the rest.
			for ( int pass = 0; pass < 3; pass++ )
			{
				var added = false;

				foreach ( var id in set.ToList() )
				{
					foreach ( var dependency in SlangIntrinsics.Dependencies( id, stage ) )
					{
						if ( set.Add( dependency ) ) added = true;
					}
				}

				if ( !added ) break;
			}
		}

		static IEnumerable<Builtin> ReferencedBuiltins( IrFunction function )
		{
			if ( function is null ) yield break;

			foreach ( var expr in Expressions( function.Body ) )
			{
				if ( expr is IrBuiltinRef reference && reference.Id != Builtin.None )
				{
					yield return reference.Id;
				}
			}
		}

		void PrepareVertexInput()
		{
			if ( _module.Meta.Domain == ShaderDomain.PostProcess )
			{
				_vsIn.Add( Field( "VertexId", ShaderType.UInt, "SV_VertexID" ) );
			}
			else
			{
				_vsIn.Add( Field( "Position", ShaderType.Float3, "POSITION" ) );
				_vsIn.Add( Field( "Normal", ShaderType.Float3, "NORMAL" ) );
				_vsIn.Add( Field( "Tangent", ShaderType.Float4, "TANGENT" ) );
				_vsIn.Add( Field( "Uv", ShaderType.Float2, "TEXCOORD0" ) );
				_vsIn.Add( Field( "Uv2", ShaderType.Float2, "TEXCOORD1" ) );
				_vsIn.Add( Field( "Color", ShaderType.Float4, "COLOR0" ) );
				_vsIn.Add( Field( "VertexId", ShaderType.UInt, "SV_VertexID" ) );
				_vsIn.Add( Field( "InstanceId", ShaderType.UInt, "SV_InstanceID" ) );
			}

			MergeStructFields( StructParameterName( _vertex ), _vsIn, "TEXCOORD", 8 );
		}

		void PrepareVertexOutput()
		{
			_vsOut.Add( Field( "Position", ShaderType.Float4, "SV_Position" ) );

			foreach ( var id in _pixelBuiltins )
			{
				var name = SlangIntrinsics.InterpolantField( id );
				if ( name is null ) continue;
				if ( _vsOut.Any( x => x.Name == name ) ) continue;

				_vsOut.Add( new InterfaceField
				{
					Name = name,
					Type = Builtins.TypeOf( id ),
					Interpolation = SlangIntrinsics.InterpolantMode( id ),
					Source = id
				} );
			}

			var outputName = _vertex?.ReturnStruct;
			if ( string.IsNullOrWhiteSpace( outputName ) ) outputName = StructParameterName( _pixel );
			MergeStructFields( outputName, _vsOut, "TEXCOORD", 0 );

			foreach ( var varying in _module.Varyings )
			{
				if ( varying is null ) continue;

				var name = SlangIntrinsics.FieldName( varying.Name );
				if ( string.IsNullOrEmpty( name ) ) continue;
				if ( _vsOut.Any( x => x.Name == name ) ) continue;

				var slot = PrismConstants.FirstFreeTexcoord + Math.Max( 0, varying.Slot );

				_vsOut.Add( new InterfaceField
				{
					Name = name,
					Type = varying.Type,
					Semantic = string.IsNullOrWhiteSpace( varying.Semantic ) ? $"TEXCOORD{slot}" : varying.Semantic,
					Interpolation = varying.Interpolation,
					Origin = varying.Origin
				} );
			}

			AssignSemantics( _vsOut, "TEXCOORD" );
		}

		void MergeStructFields( string structName, List<InterfaceField> into, string semanticPrefix, int firstIndex )
		{
			if ( string.IsNullOrWhiteSpace( structName ) ) return;

			var source = _module.FindStruct( structName );
			if ( source is null ) return;

			foreach ( var field in source.Fields )
			{
				if ( field is null || string.IsNullOrWhiteSpace( field.Name ) ) continue;

				var name = SlangIntrinsics.FieldName( field.Name );
				if ( into.Any( x => x.Name == name ) ) continue;

				into.Add( new InterfaceField
				{
					Name = name,
					Type = field.Type,
					Semantic = field.Semantic,
					Interpolation = field.Interpolation
				} );
			}

			if ( source.Includes.Count > 0 && into == _vsIn )
			{
				_sink.Info( DiagnosticCode.BackendUnsupported,
					$"'{structName}' pulls in engine headers, which a standalone Slang module cannot use; " +
					"the Slang backend declared the fields directly instead." );
			}

			AssignSemantics( into, semanticPrefix, firstIndex );
		}

		static void AssignSemantics( List<InterfaceField> fields, string prefix, int firstIndex = 0 )
		{
			var used = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

			foreach ( var field in fields )
			{
				if ( !string.IsNullOrWhiteSpace( field.Semantic ) ) used.Add( field.Semantic );
			}

			var next = firstIndex;

			foreach ( var field in fields )
			{
				if ( !string.IsNullOrWhiteSpace( field.Semantic ) ) continue;

				string candidate;
				do
				{
					candidate = $"{prefix}{next.ToString( CultureInfo.InvariantCulture )}";
					next++;
				}
				while ( !used.Add( candidate ) );

				field.Semantic = candidate;
			}
		}

		static InterfaceField Field( string name, ShaderType type, string semantic ) =>
			new() { Name = name, Type = type, Semantic = semantic };

		void PrepareUserStructs()
		{
			foreach ( var structure in _module.Structs )
			{
				if ( structure is null || string.IsNullOrWhiteSpace( structure.Name ) ) continue;
				if ( _structRename.ContainsKey( structure.Name ) ) continue;

				_userStructs.Add( structure );
			}
		}

		// ---- sections ------------------------------------------------------

		void WriteBanner()
		{
			var meta = _module.Meta;

			_w.Line( $"#language slang {PrismConstants.SlangLanguageVersion}" );
			_w.Line( $"module {_moduleName};" );
			_w.Blank();

			_w.Line( Rule() );
			_w.Line( $"//  {_moduleName}" );

			if ( !string.IsNullOrWhiteSpace( meta.Description ) )
			{
				_w.Line( $"//  {Single( meta.Description )}" );
			}

			_w.Line( $"//  Generated by {PrismConstants.ProductName} {meta.Version} - regenerate from the graph, do not hand edit." );
			_w.Line( "//" );
			_w.Line( $"//  Domain          {meta.Domain}" );
			_w.Line( $"//  Shading model   {meta.ShadingModel}" );
			_w.Line( $"//  Blend           {meta.BlendMode}" );
			_w.Line( $"//  Cull            {meta.CullMode}" );
			_w.Line( $"//  Stages          {StageList()}" );

			if ( meta.Capabilities.Count > 0 )
			{
				var capabilities = meta.Capabilities.Select( x => x.ToString() ).OrderBy( x => x, StringComparer.Ordinal );
				_w.Line( $"//  Capabilities    {string.Join( ", ", capabilities )}" );
			}

			if ( _module.Includes.Count > 0 )
			{
				_w.Line( "//" );
				_w.Line( "//  The graph asked for these engine headers. They do not exist outside s&box, so" );
				_w.Line( "//  this module does not include them; anything it needed from them is inlined." );

				foreach ( var include in _module.Includes )
				{
					_w.Line( $"//    {Single( include )}" );
				}
			}

			_w.Line( Rule() );
			_w.Blank();
			_w.Line( SlangRuntimeModule.ImportStatement );
			_w.Blank();
		}

		string StageList()
		{
			var stages = _module.Meta.Stages.Stages().Select( x => x.DisplayName() ).ToList();
			return stages.Count == 0 ? "None" : string.Join( ", ", stages );
		}

		static string Rule() => "// " + new string( '=', 74 );

		void Section( string title )
		{
			_w.Blank();
			_w.Line( $"// {new string( '-', 74 )}" );
			_w.Line( $"//  {title}" );
			_w.Line( $"// {new string( '-', 74 )}" );
			_w.Blank();
		}

		void WriteCombos()
		{
			if ( _module.Meta.Combos.Count == 0 ) return;

			Section( "Combos" );
			_w.Line( "// Graph keywords become link-time specialization constants: cheaper than a" );
			_w.Line( "// preprocessor permutation, because the compiler front end is reused." );
			_w.Blank();

			foreach ( var combo in _module.Meta.Combos )
			{
				if ( combo is null || string.IsNullOrWhiteSpace( combo.Name ) ) continue;

				var name = SlangIntrinsics.SanitizeIdentifier( combo.Name, "F_COMBO" );

				if ( combo.Values is { Count: > 0 } )
				{
					var values = combo.Values.Select( ( x, i ) => $"{i} = {Single( x )}" );
					_w.Line( $"// {combo.Kind}: {string.Join( ", ", values )}" );
				}
				else
				{
					_w.Line( $"// {combo.Kind}" );
				}

				_w.Line( $"extern static const int {name} = {combo.Default.ToString( CultureInfo.InvariantCulture )};" );
			}
		}

		void WriteConstants()
		{
			if ( _constants.Count == 0 ) return;

			Section( "Constants" );

			foreach ( var constant in _constants )
			{
				var value = constant.Default.HasValue
					? SlangIntrinsics.Literal( constant.Type, constant.Default.Value )
					: SlangIntrinsics.Zero( constant.Type );

				_w.Line( $"static const {TypeName( constant.Type )} {constant.Name} = {value};" );
			}
		}

		/// <summary>
		/// Declare the two preview uniforms. They are plain module-scope uniforms rather than members of
		/// the parameter block: they are not part of the material's interface, they exist only while the
		/// graph is being previewed, and a host pushes them per draw.
		/// </summary>
		void WriteInstrumentation()
		{
			if ( StageIds is null ) return;

			Section( "Preview instrumentation" );

			foreach ( var line in PreviewInstrumentation.Banner() ) _w.Line( line );
			foreach ( var line in PreviewInstrumentation.SlangDeclarations() ) _w.Line( line );
		}

		/// <summary>
		/// Write the pixel body, interleaving the preview stage switch between statements. The test sits
		/// next to the temp it reads because a temp bound inside a loop or a branch is out of scope by
		/// the end of the function.
		/// </summary>
		void WritePixelBody( IrBlock body )
		{
			if ( body is null ) return;

			if ( StageIds is null )
			{
				WriteBlock( body );
				return;
			}

			var statements = body.Statements;
			var last = new Dictionary<NodeId, int>();

			for ( int i = 0; i < statements.Count; i++ )
			{
				if ( statements[i] is not IrDecl decl || !decl.Origin.IsValid ) continue;
				if ( !PreviewInstrumentation.CanShow( decl.Type ) ) continue;

				last[decl.Origin] = i;
			}

			for ( int i = 0; i < statements.Count; i++ )
			{
				var statement = statements[i];

				WriteStatement( statement );

				if ( statement is not IrDecl decl ) continue;
				if ( !last.TryGetValue( decl.Origin, out var index ) || index != i ) continue;

				var line = PreviewInstrumentation.StageCase(
					PreviewInstrumentation.StageIdOf( StageIds, decl.Origin ),
					SlangIntrinsics.SanitizeIdentifier( decl.Name, "value" ), decl.Type );

				if ( line is not null ) Emit( line, decl.Origin );
			}
		}

		/// <summary>
		/// Write the debug-channel tail. A standalone module has no engine shading model, so the
		/// channels read the <c>PrismMaterial</c> the graph filled in and the interpolants the module
		/// actually declared — anything it did not declare has no case and falls through.
		/// </summary>
		void WriteChannelTail( string material )
		{
			if ( StageIds is null ) return;

			var lines = PreviewInstrumentation.ChannelLines( ChannelEnvironment( material ) );

			if ( lines.Count == 0 ) return;

			_w.Blank();

			foreach ( var line in lines ) _w.Line( line );
		}

		PreviewChannelEnvironment ChannelEnvironment( string material )
		{
			var uv0 = Interpolant( Builtin.TexCoord0 );
			var normal = Interpolant( Builtin.WorldNormal );
			var tangentU = Interpolant( Builtin.WorldTangentU );
			var tangentV = Interpolant( Builtin.WorldTangentV );

			return new PreviewChannelEnvironment
			{
				Albedo = Field( material, "Albedo" ),
				Opacity = Field( material, "Opacity" ),
				NormalTangent = Field( material, "Normal" ),

				// PrismMaterial::WorldNormal takes the tangent basis, so the channel is only available
				// when the module carries a complete frame.
				NormalWorld = material is not null && normal is not null && tangentU is not null && tangentV is not null
					? $"{material}.WorldNormal( float3x3( {tangentU}, {tangentV}, {normal} ) )"
					: null,

				Roughness = Field( material, "Roughness" ),
				Metalness = Field( material, "Metalness" ),
				AmbientOcclusion = Field( material, "AmbientOcclusion" ),
				Emission = Field( material, "Emission" ),
				Transmission = Field( material, "Transmission" ),
				TintMask = Field( material, "TintMask" ),

				Uv0 = uv0,
				Uv1 = Interpolant( Builtin.TexCoord1 ),
				VertexColor = Interpolant( Builtin.VertexColor ),
				WorldPosition = Interpolant( Builtin.WorldPosition ),
				DerivativeSource = uv0
			};
		}

		/// <summary>The interpolant carrying a builtin, or null when this module never asked for it.</summary>
		string Interpolant( Builtin id )
		{
			foreach ( var field in _vsOut )
			{
				if ( field.Source == id ) return $"{SlangIntrinsics.InputParameter}.{field.Name}";
			}

			return null;
		}

		static string Field( string material, string name ) =>
			material is null ? null : $"{material}.{name}";

		void WriteParameters()
		{
			if ( _parameters.Count == 0 ) return;

			Section( "Parameters" );
			_w.Line( "// One ParameterBlock means one descriptor set (Vulkan) or register space (D3D12)," );
			_w.Line( "// and it is the only Slang container that may hold textures and samplers directly." );
			_w.Blank();

			_w.Line( $"struct {_paramsStruct}" );
			_w.Line( "{" );
			_w.Push();

			var group = -1;

			foreach ( var parameter in _parameters )
			{
				var next = GroupOf( parameter.Decl );
				if ( group >= 0 && next != group ) _w.Blank();

				WriteParameterField( parameter, group >= 0 );
				group = next;
			}

			_w.Pop();
			_w.Line( "};" );
			_w.Blank();
			_w.Line( $"ParameterBlock<{_paramsStruct}> {_paramsBlock};" );
		}

		void WriteParameterField( ParameterField parameter, bool allowLeadingBlank )
		{
			var decl = parameter.Decl;
			var attributes = UiAttributes( decl );

			if ( attributes.Count > 0 )
			{
				var joined = string.Join( " ", attributes );

				// An annotated parameter gets breathing room, and a long annotation set breaks onto one
				// line per attribute rather than running off the right of the screen.
				if ( allowLeadingBlank ) _w.Blank();

				if ( joined.Length <= 96 )
				{
					_w.Line( joined );
				}
				else
				{
					foreach ( var attribute in attributes ) _w.Line( attribute );
				}
			}

			var array = decl.ArraySize > 0
				? $"[{decl.ArraySize.ToString( CultureInfo.InvariantCulture )}]"
				: string.Empty;

			var suffix = decl.PreviewOnly ? "   // preview only" : string.Empty;

			_w.Line( $"{TypeName( decl.Type )} {parameter.Name}{array};{suffix}" );
		}

		List<string> UiAttributes( GlobalDecl decl )
		{
			var attributes = new List<string>();

			// A sampler is plumbing, not a material parameter. Annotating it would only add noise to
			// the reflection output the material inspector is built from.
			if ( decl.Kind == GlobalKind.Sampler ) return attributes;

			var ui = decl.Ui;
			var display = SplitWords( decl.Name );
			if ( !string.IsNullOrWhiteSpace( display ) )
			{
				attributes.Add( $"[UiLabel( {SlangIntrinsics.QuotedString( display )} )]" );
			}

			if ( ui is not null )
			{
				if ( !string.IsNullOrWhiteSpace( ui.Group ) )
				{
					attributes.Add( $"[UiGroup( {SlangIntrinsics.QuotedString( ui.Group )}, {Int( ui.Order )} )]" );
				}

				if ( ui.Control != UiControl.Default )
				{
					attributes.Add( $"[UiControl( {SlangIntrinsics.QuotedString( ui.Control.ToString() )} )]" );
				}

				if ( ui.Control == UiControl.Slider || ui.Min != 0f || ui.Max != 1f )
				{
					attributes.Add( $"[UiRange( {SlangIntrinsics.Real( ui.Min )}, {SlangIntrinsics.Real( ui.Max )} )]" );
				}

				if ( !string.IsNullOrWhiteSpace( ui.Tooltip ) )
				{
					attributes.Add( $"[UiTooltip( {SlangIntrinsics.QuotedString( ui.Tooltip )} )]" );
				}
			}

			if ( decl.Default.HasValue )
			{
				var value = decl.Default.Value;
				attributes.Add( $"[UiDefault( {SlangIntrinsics.Real( value.X )}, {SlangIntrinsics.Real( value.Y )}, " +
					$"{SlangIntrinsics.Real( value.Z )}, {SlangIntrinsics.Real( value.W )} )]" );
			}

			if ( !string.IsNullOrWhiteSpace( decl.DefaultAsset ) )
			{
				attributes.Add( $"[UiAsset( {SlangIntrinsics.QuotedString( decl.DefaultAsset )} )]" );
			}

			if ( !string.IsNullOrWhiteSpace( decl.AttributeName ) )
			{
				attributes.Add( $"[PrismAttribute( {SlangIntrinsics.QuotedString( decl.AttributeName )} )]" );
			}

			if ( decl.Srgb )
			{
				attributes.Add( "[PrismSrgb( 1 )]" );
			}

			return attributes;
		}

		void WriteUserStructs()
		{
			if ( _userStructs.Count == 0 ) return;

			Section( "Types" );

			foreach ( var structure in _userStructs )
			{
				_w.Line( $"struct {SlangIntrinsics.SanitizeIdentifier( structure.Name, "PrismStruct" )}" );
				_w.Line( "{" );
				_w.Push();

				foreach ( var field in structure.Fields )
				{
					if ( field is null || string.IsNullOrWhiteSpace( field.Name ) ) continue;

					var semantic = string.IsNullOrWhiteSpace( field.Semantic ) ? string.Empty : $" : {field.Semantic}";
					var interpolation = SlangIntrinsics.Interpolation( field.Interpolation );
					var prefix = string.IsNullOrEmpty( interpolation ) ? string.Empty : interpolation + " ";

					_w.Line( $"{prefix}{TypeName( field.Type )} {SlangIntrinsics.FieldName( field.Name )}{semantic};" );
				}

				_w.Pop();
				_w.Line( "};" );
				_w.Blank();
			}
		}

		void WriteHelpers()
		{
			if ( _module.Helpers.Count == 0 ) return;

			Section( "Helpers" );

			var emitted = new HashSet<string>( StringComparer.Ordinal );

			foreach ( var helper in _module.Helpers )
			{
				if ( helper is null || string.IsNullOrWhiteSpace( helper.Name ) ) continue;
				if ( !emitted.Add( helper.Name ) ) continue;

				if ( helper.Includes.Count > 0 )
				{
					_w.Line( $"// {helper.Name} was written against: {string.Join( ", ", helper.Includes.Select( Single ) )}" );
				}

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

				if ( string.IsNullOrWhiteSpace( body ) )
				{
					WriteHelperStub( helper );
					continue;
				}

				_w.Verbatim( body.Trim() );
				_w.Blank();
			}
		}

		void WriteHelperStub( HelperFunction helper )
		{
			if ( _reportedHelpers.Add( helper.Name ) )
			{
				_sink.Warn( DiagnosticCode.BackendUnsupported,
					$"Helper '{helper.Name}' has no body for the Slang backend.",
					null, "The generated module declares it and returns zero so the rest of the shader still compiles." );
			}

			_w.Line( $"// !! {helper.Name} has no Slang body. Returning zero so the module still compiles." );
			_w.Line( Signature( helper ) );
			_w.Line( "{" );
			_w.Push();
			_w.Line( helper.ReturnType.IsVoid ? "return;" : $"return {SlangIntrinsics.Zero( helper.ReturnType )};" );
			_w.Pop();
			_w.Line( "}" );
			_w.Blank();
		}

		string Signature( HelperFunction helper )
		{
			var parameters = helper.Parameters.Select( ParameterText );
			return $"{TypeName( helper.ReturnType )} {helper.Name}( {string.Join( ", ", parameters )} )";
		}

		string ParameterText( HelperParam parameter )
		{
			var modifier = parameter.Modifier switch
			{
				IrParamModifier.Out => "out ",
				IrParamModifier.InOut => "inout ",
				IrParamModifier.Uniform => "uniform ",
				_ => string.Empty
			};

			return $"{modifier}{TypeName( parameter.Type )} {SlangIntrinsics.SanitizeIdentifier( parameter.Name, "value" )}";
		}

		void WriteInterfaces()
		{
			// Only a module that emits a rasterisation entry point needs a stage interface.
			if ( _vertex is null && _pixel is null && _geometry is null ) return;

			Section( "Stage interfaces" );

			WriteInterface( SlangIntrinsics.VertexInputStruct, _vsIn,
				"What the vertex program reads per vertex." );

			WriteInterface( SlangIntrinsics.VertexOutputStruct, _vsOut,
				"What travels from the vertex program to the pixel program." );
		}

		void WriteInterface( string name, List<InterfaceField> fields, string description )
		{
			_w.Line( $"// {description}" );
			_w.Line( $"struct {name}" );
			_w.Line( "{" );
			_w.Push();

			foreach ( var field in fields )
			{
				var interpolation = SlangIntrinsics.Interpolation( field.Interpolation );
				var prefix = string.IsNullOrEmpty( interpolation ) ? string.Empty : interpolation + " ";
				var semantic = string.IsNullOrWhiteSpace( field.Semantic ) ? string.Empty : $" : {field.Semantic}";
				var line = _w.Line( $"{prefix}{TypeName( field.Type )} {field.Name}{semantic};" );

				if ( field.Origin.IsValid ) _map.Add( line, field.Origin );
			}

			_w.Pop();
			_w.Line( "};" );
			_w.Blank();
		}

		void WriteFunctions()
		{
			var functions = _module.Functions.Where( x => x is not null && !x.IsEntryPoint ).ToList();
			if ( functions.Count == 0 ) return;

			Section( "Functions" );

			foreach ( var function in functions )
			{
				_stage = function.Stage;

				foreach ( var attribute in function.Attributes )
				{
					if ( string.IsNullOrWhiteSpace( attribute ) ) continue;
					if ( attribute.Contains( "shader(", StringComparison.Ordinal ) ) continue;

					_w.Line( attribute.Trim() );
				}

				var parameters = string.Join( ", ", function.Parameters.Select( ParameterText ) );
				var returnType = string.IsNullOrWhiteSpace( function.ReturnStruct )
					? TypeName( function.ReturnType )
					: TypeName( ShaderType.Struct( function.ReturnStruct ) );

				_w.Line( $"{returnType} {SlangIntrinsics.SanitizeIdentifier( function.Name, "PrismFunction" )}( {parameters} )" );
				_w.Line( "{" );
				_w.Push();
				WriteBlock( function.Body );
				_w.Pop();
				_w.Line( "}" );
				_w.Blank();
			}

			_stage = ShaderStage.None;
		}

		// ---- entry points --------------------------------------------------

		void WriteEntryPoints()
		{
			var domain = _module.Meta.Domain;
			var wantsGraphics = domain is ShaderDomain.Surface or ShaderDomain.PostProcess;

			if ( _vertex is not null || ( wantsGraphics && _pixel is not null ) )
			{
				Section( "Vertex" );
				WriteVertexEntry();
			}

			if ( _pixel is not null )
			{
				Section( "Pixel" );
				WritePixelEntry();
			}

			if ( _geometry is not null )
			{
				Section( "Geometry" );
				WriteGeometryEntry();
			}

			if ( _compute is not null )
			{
				Section( "Compute" );
				WriteComputeEntry();
			}
		}

		void WriteVertexEntry()
		{
			_stage = ShaderStage.Vertex;

			var name = EntryName( _vertex, ShaderStage.Vertex );

			_w.Line( SlangIntrinsics.StageAttribute( ShaderStage.Vertex ) );
			WriteExtraAttributes( _vertex );
			_w.Line( $"{SlangIntrinsics.VertexOutputStruct} {name}( {SlangIntrinsics.VertexInputStruct} {SlangIntrinsics.InputParameter} )" );
			_w.Line( "{" );
			_w.Push();

			var output = VertexOutputVariable( _vertex );
			var fullscreen = _vertex is null && _module.Meta.Domain == ShaderDomain.PostProcess;

			_vertexMovesPosition = _vertex is not null && MovesWorldPosition( _vertex.Body );

			// A generated full-screen pass reads nothing but the vertex id, so a prologue would only
			// bind values it is about to overwrite.
			if ( !fullscreen ) WritePrologue( _vertexBuiltins, ShaderStage.Vertex );

			if ( _vertex is null )
			{
				WriteSynthesizedVertexBody();
			}
			else
			{
				WriteVertexBody( output );
			}

			_w.Pop();
			_w.Line( "}" );
			_stage = ShaderStage.None;
		}

		/// <summary>
		/// Emit the graph's own vertex program, extended with whatever interpolants the pixel program
		/// needs and the body did not already write. The body is never discarded: when its shape leaves
		/// no place to insert the extension, it is emitted verbatim and the gap is stated in a comment.
		/// </summary>
		void WriteVertexBody( string output )
		{
			if ( output is null )
			{
				if ( EndsWithReturn( _vertex.Body ) )
				{
					WriteBlock( _vertex.Body );

					_w.Line( "// The vertex program returns an expression rather than a local, so Prism could" );
					_w.Line( "// not append the interpolants the pixel program reads. Check them by hand." );
					return;
				}

				// The output local is declared *before* the body, not after it: the body's own interpolant
				// writes are redirected onto it, so it has to already exist when they are emitted.
				var local = FreeLocal( _vertex.Body, "o" );

				_vertexOutput = local;

				_w.Line( $"{SlangIntrinsics.VertexOutputStruct} {local};" );
				_w.Blank();

				WriteBlock( _vertex.Body );
				WriteVertexPositionResync();
				WriteInterpolantWriteback( local, AssignedFields( _vertex.Body, local ), true );

				_w.Blank();
				_w.Line( $"return {local};" );

				_vertexOutput = null;
				return;
			}

			_vertexOutput = output;

			var assigned = AssignedFields( _vertex.Body, output );
			var wroteInterpolants = false;

			foreach ( var statement in _vertex.Body.Statements )
			{
				if ( !wroteInterpolants && statement is IrReturn )
				{
					WriteVertexPositionResync();
					WriteInterpolantWriteback( output, assigned );
					wroteInterpolants = true;
				}

				WriteStatement( statement );
			}

			if ( !wroteInterpolants )
			{
				WriteVertexPositionResync();
				WriteInterpolantWriteback( output, assigned );
				_w.Line( $"return {output};" );
			}

			_vertexOutput = null;
		}

		/// <summary>
		/// Recompute clip space when the graph displaced the vertex, mirroring what the s&amp;box backend
		/// emits before <c>FinalizeVertex</c>. Without it the interpolant writeback would hand the pixel
		/// program a clip position computed from the position the vertex had before it moved.
		/// </summary>
		void WriteVertexPositionResync()
		{
			if ( !_vertexMovesPosition ) return;

			var world = SlangIntrinsics.BuiltinLocal( Builtin.WorldPosition );
			var clip = SlangIntrinsics.BuiltinLocal( Builtin.ClipPosition );

			_w.Blank();
			_w.Line( "// The graph moved the vertex, so clip space is recomputed from the new position." );
			_w.Line( $"{clip} = PrismWorldToClip( {world} );" );
		}

		void WriteSynthesizedVertexBody()
		{
			var output = "o";

			if ( _module.Meta.Domain == ShaderDomain.PostProcess )
			{
				_w.Line( "// A fullscreen triangle, generated from the vertex id. No vertex buffer needed." );
				_w.Line( $"float2 uv = float2( ( {SlangIntrinsics.InputParameter}.VertexId << 1 ) & 2, {SlangIntrinsics.InputParameter}.VertexId & 2 );" );
				_w.Blank();
				_w.Line( $"{SlangIntrinsics.VertexOutputStruct} {output};" );
				_w.Line( $"{output}.Position = float4( uv * float2( 2.0, -2.0 ) + float2( -1.0, 1.0 ), 0.0, 1.0 );" );

				foreach ( var field in _vsOut )
				{
					if ( field.Name == "Position" ) continue;

					_w.Line( field.Name == "Uv"
						? $"{output}.Uv = uv;"
						: $"{output}.{field.Name} = {SlangIntrinsics.Zero( field.Type )};" );
				}

				_w.Blank();
				_w.Line( $"return {output};" );
				return;
			}

			_w.Line( "// The graph produced no vertex program, so this is the standard one: transform the" );
			_w.Line( "// vertex and hand the pixel program every interpolant it asked for." );
			_w.Line( $"{SlangIntrinsics.VertexOutputStruct} {output};" );

			WriteInterpolantWriteback( output, new HashSet<string>( StringComparer.Ordinal ), true );

			_w.Blank();
			_w.Line( $"return {output};" );
		}

		void WriteInterpolantWriteback( string output, HashSet<string> assigned, bool includeVaryings = false )
		{
			var lines = new List<string>();

			foreach ( var field in _vsOut )
			{
				if ( assigned.Contains( field.Name ) ) continue;

				if ( field.Name == "Position" )
				{
					lines.Add( $"{output}.Position = {SlangIntrinsics.BuiltinLocal( Builtin.ClipPosition )};" );
					continue;
				}

				if ( field.Source != Builtin.None )
				{
					var local = SlangIntrinsics.BuiltinLocal( field.Source );
					lines.Add( $"{output}.{field.Name} = {Convert( local, Builtins.TypeOf( field.Source ), field.Type )};" );
					continue;
				}

				if ( !includeVaryings ) continue;

				lines.Add( $"{output}.{field.Name} = {SlangIntrinsics.Zero( field.Type )};   // TODO(WP-2): no vertex program wrote this varying" );
			}

			if ( lines.Count == 0 ) return;

			_w.Blank();
			_w.Line( "// Interpolants" );

			foreach ( var line in lines ) _w.Line( line );
		}

		void WritePixelEntry()
		{
			_stage = ShaderStage.Pixel;

			var name = EntryName( _pixel, ShaderStage.Pixel );
			var returnType = _pixel.ReturnType.IsVoid || _pixel.ReturnType.IsStruct
				? ShaderType.Float4
				: _pixel.ReturnType;

			var semantic = string.IsNullOrWhiteSpace( _pixel.ReturnSemantic ) ? "SV_Target0" : _pixel.ReturnSemantic;

			var parameters = $"{SlangIntrinsics.VertexOutputStruct} {SlangIntrinsics.InputParameter}";
			if ( _needsFrontFace ) parameters += $", bool {SlangIntrinsics.FrontFaceParameter} : SV_IsFrontFace";

			_w.Line( SlangIntrinsics.StageAttribute( ShaderStage.Pixel ) );
			WriteExtraAttributes( _pixel );
			_w.Line( $"{TypeName( returnType )} {name}( {parameters} ) : {semantic}" );
			_w.Line( "{" );
			_w.Push();

			WritePrologue( _pixelBuiltins, ShaderStage.Pixel );

			// A surface graph writes its results into a material struct rather than returning a colour.
			// The engine's Material and its shading model do not exist here, so the prelude's stand-in is
			// declared instead and resolved at the end — the module stays self-contained and compilable,
			// and stays an honest description of the *material* rather than pretending to be s&box's
			// lighting. See PrismMaterial in prism.core.
			var material = MaterialLocal( _pixel.Body );

			if ( material is not null )
			{
				_w.Line( $"{SlangRuntimeModule.MaterialStruct} {material} = {SlangRuntimeModule.MaterialStruct}::Init();" );
				_w.Blank();
			}

			WritePixelBody( _pixel.Body );

			if ( !EndsWithReturn( _pixel.Body ) )
			{
				WriteChannelTail( material );

				_w.Blank();

				if ( material is not null )
				{
					_w.Line( "// No engine shading model in a standalone module: resolve the material unlit." );
					_w.Line( $"return {material}.ToUnlitColor();" );
				}
				else
				{
					_w.Line( $"return {SlangIntrinsics.Zero( returnType )};" );
				}
			}

			_w.Pop();
			_w.Line( "}" );
			_stage = ShaderStage.None;
		}

		void WriteGeometryEntry()
		{
			_stage = ShaderStage.Geometry;

			var name = EntryName( _geometry, ShaderStage.Geometry );
			var parameters = string.Join( ", ", _geometry.Parameters.Select( ParameterText ) );
			var returnType = string.IsNullOrWhiteSpace( _geometry.ReturnStruct )
				? TypeName( _geometry.ReturnType )
				: TypeName( ShaderType.Struct( _geometry.ReturnStruct ) );

			_w.Line( SlangIntrinsics.StageAttribute( ShaderStage.Geometry ) );
			WriteExtraAttributes( _geometry );
			_w.Line( $"{returnType} {name}( {parameters} )" );
			_w.Line( "{" );
			_w.Push();
			WriteBlock( _geometry.Body );
			_w.Pop();
			_w.Line( "}" );
			_stage = ShaderStage.None;
		}

		void WriteComputeEntry()
		{
			_stage = ShaderStage.Compute;

			var name = EntryName( _compute, ShaderStage.Compute );
			var threads = _compute.Attributes.FirstOrDefault( x =>
				!string.IsNullOrWhiteSpace( x ) && x.Contains( "numthreads", StringComparison.OrdinalIgnoreCase ) );

			var parameters = new List<string>();

			if ( _computeBuiltins.Contains( Builtin.DispatchThreadId ) || _computeBuiltins.Count == 0 )
			{
				parameters.Add( $"uint3 {SlangIntrinsics.DispatchThreadIdParameter} : SV_DispatchThreadID" );
			}

			if ( _computeBuiltins.Contains( Builtin.GroupThreadId ) )
			{
				parameters.Add( $"uint3 {SlangIntrinsics.GroupThreadIdParameter} : SV_GroupThreadID" );
			}

			if ( _computeBuiltins.Contains( Builtin.GroupId ) )
			{
				parameters.Add( $"uint3 {SlangIntrinsics.GroupIdParameter} : SV_GroupID" );
			}

			_w.Line( SlangIntrinsics.StageAttribute( ShaderStage.Compute ) );
			_w.Line( string.IsNullOrWhiteSpace( threads ) ? "[numthreads(8, 8, 1)]" : threads.Trim() );
			WriteExtraAttributes( _compute, skipNumThreads: true );
			_w.Line( $"void {name}( {string.Join( ", ", parameters )} )" );
			_w.Line( "{" );
			_w.Push();
			WritePrologue( _computeBuiltins, ShaderStage.Compute );
			WriteBlock( _compute.Body );
			_w.Pop();
			_w.Line( "}" );
			_stage = ShaderStage.None;
		}

		void WriteExtraAttributes( IrFunction function, bool skipNumThreads = false )
		{
			if ( function is null ) return;

			foreach ( var attribute in function.Attributes )
			{
				if ( string.IsNullOrWhiteSpace( attribute ) ) continue;
				if ( attribute.Contains( "shader(", StringComparison.Ordinal ) ) continue;
				if ( skipNumThreads && attribute.Contains( "numthreads", StringComparison.OrdinalIgnoreCase ) ) continue;

				_w.Line( attribute.Trim() );
			}
		}

		void WritePrologue( List<Builtin> builtins, ShaderStage stage )
		{
			var emitted = builtins.Where( x => !Builtins.TypeOf( x ).IsVoid ).ToList();
			if ( emitted.Count == 0 ) return;

			_w.Line( "// Environment" );

			foreach ( var id in emitted )
			{
				var type = Builtins.TypeOf( id );
				var expression = SlangIntrinsics.BuiltinExpression( id, stage );
				var comment = string.Empty;

				// A builtin derived from a vertex attribute this domain does not carry binds to zero
				// rather than naming a field the generated VsIn struct never declared.
				if ( stage == ShaderStage.Vertex )
				{
					var required = SlangIntrinsics.VertexInputField( id );

					if ( required is not null && !_vsIn.Any( x => x.Name == required ) )
					{
						expression = SlangIntrinsics.Zero( type );
						comment = $"   // this domain has no {required} attribute";
					}
				}

				// A displaced vertex writes through the world-position local and then re-derives clip
				// space from it, so neither of those two can be const in that case.
				var mutable = stage == ShaderStage.Vertex && _vertexMovesPosition &&
					id is Builtin.WorldPosition or Builtin.ClipPosition;

				var qualifier = mutable ? string.Empty : "const ";

				_w.Line( $"{qualifier}{TypeName( type )} {SlangIntrinsics.BuiltinLocal( id )} = {expression};{comment}" );
			}

			_w.Blank();
		}

		/// <summary>
		/// True when a block assigns through the compiler's world-position member, i.e. the graph drives a
		/// vertex offset. Mirrors the same test in the s&amp;box backend.
		/// </summary>
		static bool MovesWorldPosition( IrBlock block )
		{
			if ( block is null ) return false;

			foreach ( var statement in Statements( block ) )
			{
				if ( statement is not IrAssign assign ) continue;

				foreach ( var node in IrExprUtil.Walk( assign.Target ) )
				{
					if ( node is not IrMember member || !IsPixelInputVar( member.V ) ) continue;

					if ( string.Equals( SlangIntrinsics.FieldName( member.Field ), s_worldPositionField,
						StringComparison.Ordinal ) )
					{
						return true;
					}
				}
			}

			return false;
		}

		string EntryName( IrFunction function, ShaderStage stage )
		{
			if ( function is not null && !string.IsNullOrWhiteSpace( function.Name ) )
			{
				return SlangIntrinsics.SanitizeIdentifier( function.Name, stage.EntryPoint() );
			}

			var prefix = _options.EntryPointPrefix ?? string.Empty;
			return SlangIntrinsics.SanitizeIdentifier( prefix + stage.EntryPoint(), "Main" );
		}

		/// <summary>
		/// The local the vertex program builds its output in: the variable it returns, or failing that
		/// the local it declared with the vertex-output struct type. Finding it is what lets Prism add
		/// the interpolants the pixel program needs without disturbing the program the graph wrote.
		/// </summary>
		string VertexOutputVariable( IrFunction function )
		{
			if ( function is null ) return null;

			foreach ( var statement in function.Body.Statements )
			{
				if ( statement is IrReturn { Value: IrVar variable } ) return variable.Name;
			}

			foreach ( var statement in function.Body.Statements )
			{
				if ( statement is IrDecl declaration && declaration.Type.IsStruct &&
					TypeName( declaration.Type ) == SlangIntrinsics.VertexOutputStruct )
				{
					return SlangIntrinsics.SanitizeIdentifier( declaration.Name, "o" );
				}
			}

			return null;
		}

		/// <summary>A local name no declaration in the block has already claimed.</summary>
		static string FreeLocal( IrBlock block, string preferred )
		{
			var used = new HashSet<string>( StringComparer.Ordinal );

			foreach ( var statement in Statements( block ) )
			{
				if ( statement is IrDecl declaration && !string.IsNullOrEmpty( declaration.Name ) )
				{
					used.Add( SlangIntrinsics.SanitizeIdentifier( declaration.Name, "value" ) );
				}
			}

			if ( !used.Contains( preferred ) ) return preferred;

			for ( int i = 2; i < 1000; i++ )
			{
				var candidate = preferred + i.ToString( CultureInfo.InvariantCulture );
				if ( !used.Contains( candidate ) ) return candidate;
			}

			return preferred + "_x";
		}

		static bool EndsWithReturn( IrBlock block )
		{
			if ( block is null || block.Statements.Count == 0 ) return false;

			return block.Statements[^1] is IrReturn;
		}

		HashSet<string> AssignedFields( IrBlock block, string variable )
		{
			var assigned = new HashSet<string>( StringComparer.Ordinal );

			foreach ( var statement in Statements( block ) )
			{
				if ( statement is not IrAssign assign ) continue;
				if ( assign.Target is not IrMember member ) continue;
				if ( member.V is not IrVar target || target.Name != variable ) continue;

				assigned.Add( SlangIntrinsics.FieldName( member.Field ) );
			}

			return assigned;
		}

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

		void WriteBlock( IrBlock block )
		{
			if ( block is null ) return;

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

		void WriteStatement( IrStmt statement )
		{
			switch ( statement )
			{
				case IrComment comment:
					if ( _options.EmitComments || _module.Meta.DebugSymbols )
					{
						Emit( $"// {Single( comment.Text )}", comment.Origin );
					}
					break;

				// clip(), the barriers and the interlocked operations produce nothing. Binding one to a
				// local would not compile, so it becomes a bare statement instead.
				case IrDecl { Init: IrCall call } voidCall when SlangIntrinsics.IsVoidResult( call.Id ):
					Emit( $"{Expr( call )};", voidCall.Origin );
					break;

				case IrDecl decl:
					var name = SlangIntrinsics.SanitizeIdentifier( decl.Name, "value" );
					Emit( decl.Init is null
						? $"{TypeName( decl.Type )} {name};"
						: $"{TypeName( decl.Type )} {name} = {Expr( decl.Init )};", decl.Origin );
					break;

				case IrAssign assign:
					Emit( $"{Expr( assign.Target, PrecPrimary )} = {Expr( assign.Value )};", assign.Origin );
					break;

				case IrIf branch:
					Emit( $"if ( {Expr( branch.Cond )} )", branch.Origin );
					Emit( "{", branch.Origin );
					_w.Push();
					WriteBlock( branch.Then );
					_w.Pop();
					Emit( "}", branch.Origin );

					if ( branch.Else is { IsEmpty: false } )
					{
						Emit( "else", branch.Origin );
						Emit( "{", branch.Origin );
						_w.Push();
						WriteBlock( branch.Else );
						_w.Pop();
						Emit( "}", branch.Origin );
					}
					break;

				case IrFor loop:
					var index = SlangIntrinsics.SanitizeIdentifier( loop.Var, "index" );
					Emit( $"for ( int {index} = 0; {index} < {Expr( loop.Count, PrecPrimary )}; ++{index} )", loop.Origin );
					Emit( "{", loop.Origin );
					_w.Push();
					WriteBlock( loop.Body );
					_w.Pop();
					Emit( "}", loop.Origin );
					break;

				case IrWhile loop:
					Emit( $"while ( {Expr( loop.Cond )} )", loop.Origin );
					Emit( "{", loop.Origin );
					_w.Push();
					WriteBlock( loop.Body );
					_w.Pop();
					Emit( "}", loop.Origin );
					break;

				case IrBreak stop:
					Emit( "break;", stop.Origin );
					break;

				case IrContinue skip:
					Emit( "continue;", skip.Origin );
					break;

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

				case IrDiscard kill:
					Emit( "discard;", kill.Origin );
					break;

				case IrExprStmt expression:
					Emit( $"{Expr( expression.Value )};", expression.Origin );
					break;

				case IrScope scope:
					Emit( "{", scope.Origin );
					_w.Push();
					WriteBlock( scope.Body );
					_w.Pop();
					Emit( "}", scope.Origin );
					break;

				// Slang runs a C preprocessor of its own, so a compile-time branch lowers to exactly the
				// same directives the .shader gets and only the taken side reaches the compiler.
				case IrPreprocessorIf guard:
					WritePreprocessorIf( guard );
					break;
			}
		}

		void WritePreprocessorIf( IrPreprocessorIf guard )
		{
			var directive = IrPreprocessor.OpenDirective( guard.Condition );

			if ( string.IsNullOrEmpty( directive ) )
			{
				// A condition we cannot spell must not become a directive the preprocessor rejects.
				// Folding both sides in keeps the module compiling and costs only the exclusion.
				_sink.Warn( DiagnosticCode.BackendUnsupported,
					"A compile-time branch had no usable combo condition, so both of its sides were emitted.",
					guard.Origin.IsValid ? GraphRef.ForNode( guard.Origin ) : null,
					"Bind the node to a graph keyword. Without one there is nothing for the preprocessor to test." );

				WriteBlock( guard.Then );
				WriteBlock( guard.Else );

				return;
			}

			Emit( directive, guard.Origin );
			WriteBlock( guard.Then );

			if ( guard.HasElse )
			{
				Emit( IrPreprocessor.ElseDirective, guard.Origin );
				WriteBlock( guard.Else );
			}

			Emit( IrPreprocessor.EndDirective, guard.Origin );
		}

		void Emit( string text, NodeId origin )
		{
			var line = _w.Line( text );

			if ( origin.IsValid ) _map.Add( line, origin );
		}

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

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

			switch ( expr )
			{
				case IrConst constant:
					return SlangIntrinsics.Literal( constant.Type, constant.Value );

				case IrVar variable:
					return SlangIntrinsics.SanitizeIdentifier( variable.Name, "value" );

				case IrGlobalRef reference:
					return GlobalPath( reference );

				case IrBuiltinRef builtin:
					return BuiltinPath( builtin.Id );

				case IrCall call:
					return CallText( call );

				case IrHelperCall helper:
					return $"{SlangIntrinsics.SanitizeIdentifier( helper.Fn?.Name, "PrismHelper" )}( {Args( helper.Args )} )";

				case IrBinary binary:
					return BinaryText( binary, minPrecedence );

				case IrUnary unary:
				{
					var symbol = SlangIntrinsics.Symbol( unary.Op );
					var operand = Expr( unary.V, PrecUnary );

					// See HlslEmitter: without the space a nested negate prints `--x`, which lexes as
					// pre-decrement rather than as two negations.
					var gap = operand.Length > 0 && operand[0] == symbol[0] ? " " : string.Empty;

					return Wrap( $"{symbol}{gap}{operand}", PrecUnary, minPrecedence );
				}

				case IrSwizzle swizzle:
					return $"{Expr( swizzle.V, PrecPrimary )}.{Mask( swizzle )}";

				case IrConstruct construct:
					return $"{TypeName( construct.Type )}( {Args( construct.Parts )} )";

				case IrCast cast:
					return CastText( cast );

				case IrSelect select:
					return SelectText( select.Type, select.C, select.A, select.B );

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

				case IrMember member:
					if ( TryRedirectStageMember( member, out var redirected ) ) return redirected;

					return $"{Expr( member.V, PrecPrimary )}.{SlangIntrinsics.FieldName( member.Field )}";
			}

			return SlangIntrinsics.Zero( expr.Type );
		}

		/// <summary>
		/// Resolve a member access on the compiler's synthetic pixel-input struct to something that exists
		/// in a standalone Slang module.
		/// <para>
		/// WP-2 emits every vertex-stage result as a write through <c>PixelInput i</c>, because that is the
		/// shape an s&amp;box <c>.shader</c> block wants. Slang has no such struct: the vertex entry point
		/// takes a <c>VsIn</c> and returns a <c>VsOut</c>, so writing <c>i.Something</c> would either not
		/// compile or, worse, mutate the input. Two cases: the world position is a prologue-bound local,
		/// because displacing the vertex has to feed the clip-space transform; everything else is an
		/// interpolant and belongs on the output struct.
		/// </para>
		/// </summary>
		bool TryRedirectStageMember( IrMember member, out string text )
		{
			text = null;

			if ( _stage != ShaderStage.Vertex ) return false;
			if ( member?.V is not IrVar variable || !variable.Type.IsStruct ) return false;

			if ( !string.Equals( variable.Type.StructName, GraphCompiler.PixelInputStruct, StringComparison.Ordinal ) )
			{
				return false;
			}

			var field = SlangIntrinsics.FieldName( member.Field );

			if ( string.Equals( field, s_worldPositionField, StringComparison.Ordinal ) )
			{
				text = SlangIntrinsics.BuiltinLocal( Builtin.WorldPosition );
				return true;
			}

			if ( string.IsNullOrEmpty( _vertexOutput ) ) return false;

			text = $"{_vertexOutput}.{field}";
			return true;
		}

		/// <summary>
		/// The name of the material local a pixel body writes through, or null when it does not use one.
		/// </summary>
		static string MaterialLocal( IrBlock block )
		{
			if ( block is null ) return null;

			foreach ( var statement in Statements( block ) )
			{
				if ( statement is not IrAssign assign ) continue;

				foreach ( var node in IrExprUtil.Walk( assign.Target ) )
				{
					if ( node is not IrMember member ) continue;
					if ( member.V is not IrVar variable || !variable.Type.IsStruct ) continue;

					if ( string.Equals( variable.Type.StructName, GraphCompiler.MaterialStruct, StringComparison.Ordinal ) )
					{
						return SlangIntrinsics.SanitizeIdentifier( variable.Name, "prismMaterial" );
					}
				}
			}

			return null;
		}

		/// <summary>True when this expression is the compiler's synthetic pixel-input struct instance.</summary>
		static bool IsPixelInputVar( IrExpr expr ) =>
			expr is IrVar variable && variable.Type.IsStruct &&
			string.Equals( variable.Type.StructName, GraphCompiler.PixelInputStruct, StringComparison.Ordinal );

		static readonly string s_worldPositionField = SlangIntrinsics.FieldName( GraphCompiler.WorldPositionField );

		string GlobalPath( IrGlobalRef reference )
		{
			var declName = reference.Decl?.Name;

			if ( !string.IsNullOrEmpty( declName ) && _globalPath.TryGetValue( declName, out var path ) ) return path;

			return SlangIntrinsics.SanitizeIdentifier( declName, "prismGlobal" );
		}

		string BuiltinPath( Builtin id )
		{
			if ( id == Builtin.None ) return "0";

			var list = _stage switch
			{
				ShaderStage.Vertex => _vertexBuiltins,
				ShaderStage.Pixel => _pixelBuiltins,
				ShaderStage.Compute => _computeBuiltins,
				_ => null
			};

			// Inside an entry point the prologue has already bound the value to a readable local.
			// Anywhere else (a generated free function) there is no prologue, so inline the expression.
			if ( list is not null && list.Contains( id ) ) return SlangIntrinsics.BuiltinLocal( id );

			return SlangIntrinsics.BuiltinExpression( id, _stage );
		}

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

			if ( call.Id == Intrinsic.Select && args.Length >= 3 )
			{
				return SelectText( call.Type, args[0], args[1], args[2] );
			}

			var name = SlangIntrinsics.Name( call.Id );

			if ( SlangIntrinsics.IsObjectMethod( call.Id ) && args.Length >= 1 )
			{
				var receiver = Expr( args[0], PrecPrimary );
				var rest = args.Skip( 1 ).Select( x => Expr( x ) );

				return rest.Any()
					? $"{receiver}.{name}( {string.Join( ", ", rest )} )"
					: $"{receiver}.{name}()";
			}

			return args.Length == 0 ? $"{name}()" : $"{name}( {Args( args )} )";
		}

		string SelectText( ShaderType type, IrExpr condition, IrExpr whenTrue, IrExpr whenFalse )
		{
			// select() takes a bool of the same shape as its operands. A scalar condition driving a
			// vector result has to be splatted, and a non-boolean condition has to be converted.
			var conditionType = condition?.Type ?? ShaderType.Bool;
			var wanted = type.IsScalarOrVector
				? ShaderType.Vec( ScalarKind.Bool, Math.Max( 1, type.Components ) )
				: ShaderType.Bool;

			var text = Expr( condition );

			if ( conditionType != wanted && !wanted.IsVoid )
			{
				text = $"{TypeName( wanted )}( {text} )";
			}

			return $"select( {text}, {Expr( whenTrue )}, {Expr( whenFalse )} )";
		}

		string BinaryText( IrBinary binary, int minPrecedence )
		{
			var operandType = binary.L?.Type ?? binary.R?.Type ?? binary.Type;

			if ( SlangIntrinsics.RequiresFunctionForm( binary.Op, operandType ) )
			{
				return $"{SlangIntrinsics.FunctionForm( binary.Op )}( {Expr( binary.L )}, {Expr( binary.R )} )";
			}

			var precedence = BinaryOps.Precedence( binary.Op ) * 5;
			var text = $"{Expr( binary.L, precedence )} {SlangIntrinsics.Symbol( binary.Op )} {Expr( binary.R, precedence + 1 )}";

			return Wrap( text, precedence, minPrecedence );
		}

		string CastText( IrCast cast )
		{
			if ( cast.V is null ) return SlangIntrinsics.Zero( cast.Type );

			var target = cast.Type;
			var source = cast.V.Type;

			if ( source == target ) return Expr( cast.V );

			switch ( cast.Kind )
			{
				case CastKind.Pad:
				{
					var missing = target.Components - source.Components;
					if ( missing <= 0 ) break;

					var fill = SlangIntrinsics.Scalar( cast.Fill, target.Scalar );
					var parts = new List<string> { Expr( cast.V ) };
					for ( int i = 0; i < missing; i++ ) parts.Add( fill );

					return $"{TypeName( target )}( {string.Join( ", ", parts )} )";
				}

				case CastKind.Truncate:
				{
					if ( !source.IsScalarOrVector || !target.IsScalarOrVector ) break;
					if ( target.Components >= source.Components ) break;

					var swizzled = $"{Expr( cast.V, PrecPrimary )}{TypeRules.SwizzleFor( target.Components )}";

					return target.Scalar == source.Scalar ? swizzled : $"{TypeName( target )}( {swizzled} )";
				}

				case CastKind.Bitcast:
					return BitcastText( cast, target );
			}

			return $"{TypeName( target )}( {Expr( cast.V )} )";
		}

		string BitcastText( IrCast cast, ShaderType target )
		{
			var inner = Expr( cast.V );

			return target.Scalar switch
			{
				ScalarKind.Float => $"asfloat( {inner} )",
				ScalarKind.Int => $"asint( {inner} )",
				ScalarKind.UInt => $"asuint( {inner} )",
				_ => $"reinterpret< {TypeName( target )} >( {inner} )"
			};
		}

		static string Mask( IrSwizzle swizzle )
		{
			var mask = swizzle.Mask;
			if ( string.IsNullOrEmpty( mask ) ) return "x";

			var builder = new StringBuilder( mask.Length );

			foreach ( var c in mask )
			{
				var lower = char.ToLowerInvariant( c );

				if ( lower is 'x' or 'y' or 'z' or 'w' or 'r' or 'g' or 'b' or 'a' ) builder.Append( lower );
			}

			return builder.Length == 0 ? "x" : builder.ToString();
		}

		string Args( IrExpr[] args )
		{
			if ( args is null || args.Length == 0 ) return string.Empty;

			return string.Join( ", ", args.Select( x => Expr( x ) ) );
		}

		string Convert( string text, ShaderType from, ShaderType to )
		{
			if ( from == to || to.IsVoid ) return text;

			if ( from.IsScalarOrVector && to.IsScalarOrVector && to.Components < from.Components )
			{
				var swizzled = $"{text}{TypeRules.SwizzleFor( to.Components )}";
				return to.Scalar == from.Scalar ? swizzled : $"{TypeName( to )}( {swizzled} )";
			}

			return $"{TypeName( to )}( {text} )";
		}

		static string Wrap( string text, int precedence, int minPrecedence ) =>
			precedence < minPrecedence ? $"( {text} )" : text;

		string TypeName( ShaderType type )
		{
			if ( type.IsStruct && !string.IsNullOrEmpty( type.StructName ) )
			{
				return _structRename.TryGetValue( type.StructName, out var renamed )
					? renamed
					: SlangIntrinsics.SanitizeIdentifier( type.StructName, "PrismStruct" );
			}

			return SlangIntrinsics.TypeName( type );
		}

		static string Int( int value ) => value.ToString( CultureInfo.InvariantCulture );

		/// <summary>Collapse text onto one line so it can never break out of a comment.</summary>
		static string Single( string text )
		{
			if ( string.IsNullOrEmpty( text ) ) return string.Empty;

			return text.Replace( "\r", string.Empty ).Replace( '\n', ' ' ).Replace( "*/", "* /" ).Trim();
		}

		/// <summary>Turn <c>g_flBaseRoughness</c> into <c>Base Roughness</c> for the material inspector.</summary>
		static string SplitWords( string name )
		{
			var identifier = SlangIntrinsics.PascalCase( name );
			if ( string.IsNullOrEmpty( identifier ) ) return string.Empty;

			var builder = new StringBuilder( identifier.Length + 8 );

			for ( int i = 0; i < identifier.Length; i++ )
			{
				var c = identifier[i];

				if ( c == '_' )
				{
					if ( builder.Length > 0 && builder[^1] != ' ' ) builder.Append( ' ' );
					continue;
				}

				if ( i > 0 && char.IsUpper( c ) && !char.IsUpper( identifier[i - 1] ) && builder.Length > 0 )
				{
					builder.Append( ' ' );
				}

				builder.Append( c );
			}

			return builder.ToString().Trim();
		}

		// ---- traversal -----------------------------------------------------

		IEnumerable<IrExpr> AllExpressions()
		{
			foreach ( var function in _module.Functions )
			{
				if ( function is null ) continue;

				foreach ( var expr in Expressions( function.Body ) ) yield return expr;
			}
		}

		static IEnumerable<IrStmt> Statements( IrBlock block )
		{
			if ( block is null ) yield break;

			foreach ( var statement in block.Statements )
			{
				yield return statement;

				switch ( statement )
				{
					case IrIf branch:
						foreach ( var nested in Statements( branch.Then ) ) yield return nested;
						foreach ( var nested in Statements( branch.Else ) ) yield return nested;
						break;

					case IrFor loop:
						foreach ( var nested in Statements( loop.Body ) ) yield return nested;
						break;

					case IrWhile loop:
						foreach ( var nested in Statements( loop.Body ) ) yield return nested;
						break;

					case IrScope scope:
						foreach ( var nested in Statements( scope.Body ) ) yield return nested;
						break;

					case IrPreprocessorIf guard:
						foreach ( var nested in Statements( guard.Then ) ) yield return nested;
						foreach ( var nested in Statements( guard.Else ) ) yield return nested;
						break;
				}
			}
		}

		static IEnumerable<IrExpr> Expressions( IrBlock block )
		{
			foreach ( var statement in Statements( block ) )
			{
				switch ( statement )
				{
					case IrDecl decl:
						foreach ( var expr in IrExprUtil.Walk( decl.Init ) ) yield return expr;
						break;

					case IrAssign assign:
						foreach ( var expr in IrExprUtil.Walk( assign.Target ) ) yield return expr;
						foreach ( var expr in IrExprUtil.Walk( assign.Value ) ) yield return expr;
						break;

					case IrIf branch:
						foreach ( var expr in IrExprUtil.Walk( branch.Cond ) ) yield return expr;
						break;

					case IrFor loop:
						foreach ( var expr in IrExprUtil.Walk( loop.Count ) ) yield return expr;
						break;

					case IrWhile loop:
						foreach ( var expr in IrExprUtil.Walk( loop.Cond ) ) yield return expr;
						break;

					case IrReturn ret:
						foreach ( var expr in IrExprUtil.Walk( ret.Value ) ) yield return expr;
						break;

					case IrExprStmt expression:
						foreach ( var expr in IrExprUtil.Walk( expression.Value ) ) yield return expr;
						break;
				}
			}
		}
	}
}