Editor/Prism/Toolchain/ShaderCompileService.cs

Editor service that manages generating, debouncing, writing, compiling and hot-reloading shaders from a Prism graph. It coordinates main-thread model access, workspace scratch files, calls the engine shader compiler, collects and remaps diagnostics, optionally validates Slang output, and publishes compile results to the editor UI.

File AccessNetworking
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Backends;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Sandbox.Engine.Shaders;
using System.Diagnostics;
using System.Text;

namespace Editor.Prism.Toolchain;

/// <summary>
/// Owns the whole "graph changed, show me pixels" loop: generate, diff, write, compile, hot-reload and
/// publish.
/// <para>
/// Four mechanisms keep it fast enough to feel live. Requests are debounced by
/// <see cref="PrismConstants.CompileDebounceMs"/> and coalesced to a single pending slot, so a burst of
/// edits costs one compile. The generated text is byte-compared against the last one that compiled, and
/// an identical shader skips the engine entirely — which is what makes dragging a slider free, because
/// in preview mode literals become uniforms and the text does not change. A structural change cancels
/// the compile already in flight instead of queueing behind it. And preview shaders declare the minimum
/// combo set, because static combos multiply compile time and nothing in the viewport needs the depth
/// or shading-complexity passes.
/// </para>
/// <para>
/// Thread discipline is deliberate and load-bearing. The graph is model state, so it is only ever read
/// on the main thread. The engine compile blocks its calling thread inside a <c>Parallel.ForEach</c>,
/// so it runs on the thread pool. <c>AssetSystem.RegisterFile</c> asserts the main thread, so it is
/// marshalled back. And <c>mat_reloadshaders</c> runs after the asset is up to date and before anyone
/// creates a material, because skipping it binds the previous binary.
/// </para>
/// </summary>
public sealed class ShaderCompileService : IDisposable
{
	/// <summary>How long to wait for the asset system to notice a freshly compiled shader.</summary>
	const int AssetWaitMs = 5000;

	/// <summary>Polling interval while waiting for the asset system.</summary>
	const int AssetPollMs = 16;

	/// <summary>Programs the engine recognises, in the order its own scanner looks for them.</summary>
	static readonly (string Block, string Program)[] s_programs =
	{
		("VS", "VFX_PROGRAM_VS"),
		("PS", "VFX_PROGRAM_PS"),
		("GS", "VFX_PROGRAM_GS"),
		("CS", "VFX_PROGRAM_CS"),
		("PS_RENDER_STATE", "VFX_PROGRAM_PS_RENDER_STATE"),
		("RTX", "VFX_PROGRAM_RTX")
	};

	readonly SemaphoreSlim _gate = new( 1, 1 );
	readonly object _state = new();

	/// <summary>
	/// Serialises every Prism compile that reaches the engine's shader compiler, process-wide. See
	/// <c>CompileWithEngine</c> for why this cannot be the per-service gate.
	/// </summary>
	static readonly SemaphoreSlim s_engineGate = new( 1, 1 );

	PrismGraph _pendingGraph;
	CompileMode _pendingMode = CompileMode.Preview;
	CancellationTokenSource _inFlight;
	ulong _inFlightStructure;
	int _generation;
	int _running;
	int _force;
	int _released;
	volatile bool _disposed;

	string _lastText;
	IReadOnlyList<Diagnostic> _lastEngineDiagnostics = Array.Empty<Diagnostic>();

	static readonly List<WeakReference<ShaderCompileService>> s_live = new();

	/// <summary>Create a service with its own scratch session, so two windows never collide.</summary>
	public ShaderCompileService( string sessionId = null )
	{
		Workspace = new TempWorkspace( sessionId );

		// A crashed editor leaves its session behind and nobody ever comes back for it.
		PrismLog.Guard( "Collecting stale Prism scratch sessions",
			() => TempWorkspace.CollectGarbage( PrismConstants.TempSessionLifetimeHours, Workspace.SessionId ) );

		lock ( s_live )
		{
			s_live.RemoveAll( x => !x.TryGetTarget( out _ ) );
			s_live.Add( new WeakReference<ShaderCompileService>( this ) );
		}
	}

	/// <summary>
	/// Cancel every compile running anywhere in the editor and sweep the scratch folders they were
	/// writing into.
	/// <para>
	/// This is what hotload calls. A compile in flight across an assembly reload is holding a graph, a
	/// backend and a set of delegates that are all about to be replaced underneath it, and its callbacks
	/// would land on an editor that no longer exists — so it is cancelled rather than left to finish.
	/// Held weakly, so a window that was closed without disposing its service cannot keep it alive.
	/// </para>
	/// </summary>
	public static int CancelAll()
	{
		var targets = new List<ShaderCompileService>();

		lock ( s_live )
		{
			s_live.RemoveAll( x => !x.TryGetTarget( out _ ) );

			foreach ( var reference in s_live )
			{
				if ( reference.TryGetTarget( out var service ) ) targets.Add( service );
			}
		}

		var cancelled = 0;

		foreach ( var service in targets )
		{
			if ( service._disposed ) continue;

			PrismLog.Guard( "Cancelling a compile for hotload", service.Cancel );
			cancelled++;
		}

		return cancelled;
	}

	// ---- contract --------------------------------------------------------

	/// <summary>Raised on the main thread when a compile actually starts, after the debounce.</summary>
	public event Action Started;

	/// <summary>Raised on the main thread with every published result, successful or not.</summary>
	public event Action<CompileResult> Completed;

	/// <summary>
	/// True while a compile is running.
	/// <para>
	/// Backed by a counter rather than a flag, and moved only inside the try/finally that owns the
	/// compile. A stuck "compiling" spinner is one of the few failure modes a user cannot recover from
	/// without closing the window, so it has to be impossible for an early return, a cancellation or an
	/// exception to leave this set.
	/// </para>
	/// </summary>
	public bool IsCompiling => Volatile.Read( ref _running ) > 0;

	/// <summary>The most recent result, or null before the first compile.</summary>
	public CompileResult Last { get; private set; }

	/// <summary>
	/// Path of the last successfully compiled preview shader, relative to <c>Editor.FileSystem.Root</c>.
	/// </summary>
	public string LastShaderPath { get; private set; }

	/// <summary>
	/// Debounced and coalesced. Safe to call on every graph edit — twenty edits inside the debounce
	/// window cost one compile, and a structural change cancels whatever is already running.
	/// </summary>
	public void Request( PrismGraph graph, CompileMode mode = CompileMode.Preview )
	{
		if ( _disposed || graph is null ) return;

		var structure = PrismLog.Guard( "Hashing the graph", () => graph.StructureHash, 0UL );

		lock ( _state )
		{
			_pendingGraph = graph;
			_pendingMode = mode;

			// A value-only change is answered by render attributes, so the compile in flight is still
			// the right one. A structural change makes it obsolete before it finishes.
			if ( _inFlight is not null && _inFlightStructure != structure )
			{
				PrismLog.Guard( "Cancelling a superseded compile", () => _inFlight.Cancel() );
			}
		}

		var generation = Interlocked.Increment( ref _generation );

		_ = Debounced( generation );
	}

	/// <summary>
	/// Compile right now, skipping the debounce, and hand back the result. Await it — blocking on the
	/// returned task from the main thread deadlocks, because part of the work is marshalled back to it.
	/// </summary>
	public Task<CompileResult> RequestNow( PrismGraph graph, CompileMode mode = CompileMode.Preview,
		CancellationToken ct = default )
	{
		if ( _disposed || graph is null )
		{
			return Task.FromResult( Fail( "There is nothing to compile" ) );
		}

		lock ( _state )
		{
			_pendingGraph = null;
		}

		Interlocked.Increment( ref _generation );

		return Guarded( graph, mode, ct, 0 );
	}

	/// <summary>Cancel the compile in flight and forget anything waiting behind it.</summary>
	public void Cancel()
	{
		lock ( _state )
		{
			_pendingGraph = null;

			PrismLog.Guard( "Cancelling the shader compile", () => _inFlight?.Cancel() );
		}

		Interlocked.Increment( ref _generation );
	}

	/// <summary>
	/// Cancel everything, sweep the scratch folder and remove the session.
	/// <para>
	/// The scratch folder and the gate are only torn down once nothing is inside the compiler. A compile
	/// runs on a thread-pool thread and cannot be interrupted synchronously — cancellation is
	/// cooperative and the engine's own <c>CompileShader</c> checks it between combos — so freeing the
	/// workspace it is writing into, and the semaphore it still holds, is a use-after-free the running
	/// compile would report as a spurious failure. When one is still in flight the teardown is handed to
	/// it: the compile's own <c>finally</c> calls <see cref="ReleaseResources"/> on the way out.
	/// </para>
	/// </summary>
	public void Dispose()
	{
		if ( _disposed ) return;

		_disposed = true;

		Cancel();

		lock ( s_live )
		{
			s_live.RemoveAll( x => !x.TryGetTarget( out var service ) || ReferenceEquals( service, this ) );
		}

		ReleaseResources();
	}

	/// <summary>
	/// Free the scratch session and the compile gate, but only once no compile is inside them. Called
	/// from <see cref="Dispose"/> and again from the last compile to leave after a disposal, so
	/// whichever happens second does the work. Idempotent.
	/// </summary>
	void ReleaseResources()
	{
		if ( !_disposed ) return;
		if ( Volatile.Read( ref _running ) > 0 ) return;

		if ( Interlocked.Exchange( ref _released, 1 ) != 0 ) return;

		PrismLog.Guard( "Disposing the shader compile service", () =>
		{
			Workspace?.Dispose();
			_gate.Dispose();
		} );
	}

	// ---- knobs -----------------------------------------------------------

	/// <summary>The scratch session this service writes into.</summary>
	public TempWorkspace Workspace { get; }

	/// <summary>Debounce in milliseconds. Zero compiles on the next tick.</summary>
	public int DebounceMs { get; set; } = PrismConstants.CompileDebounceMs;

	/// <summary>
	/// Whether a preview compile declares only the <c>Forward</c> mode. Static combos multiply compile
	/// time and the viewport renders one pass, so this is on by default.
	/// </summary>
	public bool MinimalPreviewCombos { get; set; } = true;

	/// <summary>Whether to run <c>mat_reloadshaders</c> after a successful compile. Off only for tests.</summary>
	public bool ReloadShaders { get; set; } = true;

	/// <summary>
	/// Makes the next compile ignore the byte-compare short circuit and go all the way to the engine
	/// compiler, then clears itself.
	/// <para>
	/// Identical text cannot compile differently, so skipping it is normally right. It stops being right
	/// when something outside the graph changed — an included file was edited, a texture was reimported,
	/// or the user simply does not believe the cached result. That is what Compile ▸ Force Recompile is
	/// for, and without this flag it had no way to actually mean it.
	/// </para>
	/// <para>
	/// Setting it also cancels whatever compile is already in flight, because that compile started before
	/// the user asked for a fresh one and satisfying the request with it would be a lie. The flag is
	/// consumed atomically by exactly one compile, so two windows or two clicks cannot both clear it and
	/// leave neither of them actually forced.
	/// </para>
	/// </summary>
	public bool ForceRegenerate
	{
		get => Volatile.Read( ref _force ) != 0;

		set
		{
			Interlocked.Exchange( ref _force, value ? 1 : 0 );

			if ( !value || _disposed ) return;

			lock ( _state )
			{
				PrismLog.Guard( "Cancelling a compile a forced recompile superseded", () => _inFlight?.Cancel() );
			}
		}
	}

	/// <summary>Take the force flag, if it is set. Exactly one caller ever sees it as true.</summary>
	bool ConsumeForceRegenerate() => Interlocked.Exchange( ref _force, 0 ) != 0;

	/// <summary>
	/// Whether to also run the emitted Slang module past a validator. Off for the preview loop: it is a
	/// second process and Tier 2 never gates rendering.
	/// </summary>
	public bool ValidateSlang { get; set; }

	/// <summary>
	/// The Slang validator. Defaults to whatever <see cref="SlangToolchain"/> found, which is
	/// <see cref="NullSlangValidator"/> when there is nothing installed.
	/// </summary>
	public ISlangValidator SlangValidator
	{
		get => field ??= NullSlangValidator.Instance;
		set;
	}

	/// <summary>Wall time of the last compile, in milliseconds.</summary>
	public double LastCompileMs { get; private set; }

	/// <summary>True when the last engine compile was skipped because nothing had changed.</summary>
	public bool LastWasSkipped { get; private set; }

	/// <summary>
	/// The preview shader as the runtime resolves it — the argument for <c>mat_reloadshaders</c> and
	/// <c>Material.Create</c>. Null until something has compiled.
	/// </summary>
	public string PreviewShaderPath { get; private set; }

	/// <summary>A stable material name for the preview, unique per session.</summary>
	public string PreviewMaterialName => $"prism_preview_{Workspace?.SessionId ?? "0"}";

	// ---- the loop --------------------------------------------------------

	async Task Debounced( int generation )
	{
		try
		{
			var delay = Math.Max( 0, DebounceMs );

			if ( delay > 0 ) await Task.Delay( delay ).ConfigureAwait( false );
		}
		catch ( Exception )
		{
			return;
		}

		// Somebody asked again while we waited; that request owns the compile.
		if ( Volatile.Read( ref _generation ) != generation ) return;

		PrismGraph graph;
		CompileMode mode;

		lock ( _state )
		{
			graph = _pendingGraph;
			mode = _pendingMode;
		}

		if ( graph is null || _disposed ) return;

		await Guarded( graph, mode, CancellationToken.None, generation ).ConfigureAwait( false );
	}

	async Task<CompileResult> Guarded( PrismGraph graph, CompileMode mode, CancellationToken external,
		int generation )
	{
		var acquired = false;
		var entered = false;
		CancellationTokenSource cts = null;

		try
		{
			await _gate.WaitAsync( external ).ConfigureAwait( false );

			acquired = true;

			// Waiting for the gate can take as long as a compile, and by then a newer request may own
			// the work. Doing it again would be correct but wasteful.
			if ( generation != 0 && Volatile.Read( ref _generation ) != generation ) return Last;
			if ( _disposed ) return Last;

			cts = external.CanBeCanceled
				? CancellationTokenSource.CreateLinkedTokenSource( external )
				: new CancellationTokenSource();

			lock ( _state )
			{
				_inFlight = cts;
				_inFlightStructure = PrismLog.Guard( "Hashing the graph", () => graph.StructureHash, 0UL );
			}

			Interlocked.Increment( ref _running );
			entered = true;

			await OnMainThread( () =>
			{
				PrismLog.Guard( "Raising ShaderCompileService.Started", () => Started?.Invoke() );
			}, cts.Token ).ConfigureAwait( false );

			return await Run( graph, mode, cts.Token ).ConfigureAwait( false );
		}
		catch ( OperationCanceledException )
		{
			// A cancelled compile must not leave anything behind for the next one to trip over: the
			// engine's resource compiler may have been killed halfway through writing its output.
			PrismLog.Guard( "Sweeping after a cancelled compile", () => { Workspace?.Sweep(); } );

			// A half-written artifact means the text on disk no longer matches what we think compiled,
			// so the byte-compare short circuit has to be disarmed or the next compile would skip.
			_lastText = null;

			return Last;
		}
		catch ( Exception e )
		{
			PrismLog.Error( e, "The shader compile service failed" );

			PrismLog.Guard( "Sweeping after a failed compile", () => { Workspace?.Sweep(); } );

			_lastText = null;

			return await Publish( Fail( $"The compile service failed unexpectedly: {e.Message}" ) )
				.ConfigureAwait( false );
		}
		finally
		{
			if ( entered ) Interlocked.Decrement( ref _running );

			lock ( _state )
			{
				if ( ReferenceEquals( _inFlight, cts ) ) _inFlight = null;
			}

			cts?.Dispose();

			// Released unconditionally. Skipping the release when the service had been disposed under a
			// running compile left the semaphore permanently short one count, so every later waiter
			// blocked — and disposal is exactly when the gate most needs to drain. The guard swallows
			// the ObjectDisposedException in the one case where Dispose won the race.
			if ( acquired )
			{
				PrismLog.Guard( "Releasing the compile gate", () => { _gate.Release(); } );
			}

			// If the service was disposed while this compile was running, Dispose deliberately left the
			// workspace and the gate alone. This is the last compile out, so it does the teardown.
			ReleaseResources();
		}
	}

	async Task<CompileResult> Run( PrismGraph graph, CompileMode mode, CancellationToken ct )
	{
		var timer = Stopwatch.StartNew();

		// ---- 1. generate, on the thread that owns the model ----------------
		var generated = await OnMainThread( () => Generate( graph, mode, ct ), ct )
			.ConfigureAwait( false );

		ct.ThrowIfCancellationRequested();

		if ( generated is null )
		{
			return await Publish( Fail( "The graph produced no result" ), timer, ct ).ConfigureAwait( false );
		}

		var text = generated.ShaderText;

		if ( !generated.Ok || string.IsNullOrWhiteSpace( text ) )
		{
			// Tier 0 already failed, so there is nothing worth handing to the real compiler.
			return await Publish( generated, timer, ct ).ConfigureAwait( false );
		}

		if ( !Workspace.IsValid )
		{
			return await Publish( With( generated, new[]
			{
				Diagnostic.Info( DiagnosticCode.CompilerRaw,
					"The shader was generated but not compiled",
					null, "Prism could not create its scratch folder under .source2/temp, so the engine " +
					"compiler was skipped. The generated code is still available in the Code panel." )
			} ), timer, ct ).ConfigureAwait( false );
		}

		// ---- 2. the short circuit ------------------------------------------
		var fileName = FileNameFor( mode );

		// One-shot, and consumed here rather than by the caller so a forced compile cannot leak into the
		// next one and defeat the short circuit for the rest of the session.
		if ( ConsumeForceRegenerate() ) _lastText = null;

		if ( string.Equals( text, _lastText, StringComparison.Ordinal ) && Workspace.Exists( fileName ) )
		{
			// Byte-identical output cannot compile differently. Re-attach what the engine said last time
			// so the diagnostics panel does not blink empty.
			LastWasSkipped = true;

			return await Publish( With( generated, _lastEngineDiagnostics ), timer, ct ).ConfigureAwait( false );
		}

		LastWasSkipped = false;

		if ( !Workspace.Write( fileName, text ) )
		{
			return await Publish( With( generated, new[]
			{
				Diagnostic.Error( DiagnosticCode.CompilerRaw, "The generated shader could not be written to disk" )
			} ), timer, ct ).ConfigureAwait( false );
		}

		ct.ThrowIfCancellationRequested();

		// ---- 3. the real compiler -------------------------------------------
		var relative = Workspace.Relative( fileName );
		var engine = await CompileWithEngine( relative, fileName, text, generated, ct ).ConfigureAwait( false );

		ct.ThrowIfCancellationRequested();

		_lastEngineDiagnostics = engine.Diagnostics;
		LastWasSkipped = engine.Skipped;

		if ( engine.Ok )
		{
			_lastText = text;
			LastShaderPath = relative;
			PreviewShaderPath = Workspace.ContentPath( fileName );

			await Reload( fileName, ct ).ConfigureAwait( false );
		}
		else
		{
			// Never let a failed compile look identical to the next attempt.
			_lastText = null;
		}

		// ---- 4. the optional second opinion ---------------------------------
		var slang = await ValidateSlangModule( generated, ct ).ConfigureAwait( false );

		var result = With( generated, engine.Diagnostics.Concat( slang ).ToArray(), engine.Ok );

		result = WithPreprocessed( result, engine.Preprocessed );

		return await Publish( result, timer, ct ).ConfigureAwait( false );
	}

	/// <summary>Run the Prism compiler. Called on the main thread, because it reads the model.</summary>
	CompileResult Generate( PrismGraph graph, CompileMode mode, CancellationToken ct )
	{
		return PrismLog.Guard( "Generating the shader", () =>
		{
			var request = GraphCompiler.RequestFor( graph, mode );

			if ( request is null ) return null;

			request = request with { Cancellation = ct };

			// Preview shaders declare the minimum combo set: every extra static combo is another full
			// permutation compiled, and the viewport only ever renders the forward pass.
			if ( mode == CompileMode.Preview && MinimalPreviewCombos )
			{
				request = request with
				{
					Graph = new PreviewGraph( graph, GraphSettings.PreviewModes ),
					Targets = new[] { PrismConstants.BackendHlsl }
				};
			}

			return GraphCompiler.Compile( request );
		}, null );
	}

	/// <summary>What the engine's compiler said.</summary>
	readonly record struct EngineOutcome( bool Ok, bool Skipped, IReadOnlyList<Diagnostic> Diagnostics )
	{
		/// <summary>
		/// The text the engine actually handed its compiler, per program, with every include pasted in
		/// and every combo resolved. This is the only place it exists — the engine keeps it inside the
		/// compile results and never writes it anywhere — so it is captured here and carried out to the
		/// Code panel's Preprocessed tab.
		/// </summary>
		public string Preprocessed { get; init; }
	}

	async Task<EngineOutcome> CompileWithEngine( string relativePath, string fileName, string generatedText,
		CompileResult generated, CancellationToken ct )
	{
		var options = new ShaderCompileOptions
		{
			// true bypasses the on-disk .vshadercache and turns a lit shader into a multi-second wait.
			ForceRecompile = false,

			// The editor console is not where a shader editor's diagnostics belong.
			ConsoleOutput = false,

			SingleThreaded = false
		};

		ShaderCompile.Results results = null;
		string failure = null;

		// One Prism compile inside the engine's compiler at a time, across the whole process. The gate
		// below is deliberately static: _gate is per service and every PrismSession builds its own, so
		// two open documents would otherwise drive `Shader.LoadFromSource` and
		// `ShaderCompile.GetSharedContext` — a process-global native handle and a shared per-program
		// context — concurrently from two thread-pool threads. Nothing in the managed layer documents
		// either as thread-safe, and every engine call site reaches them from the main thread.
		await s_engineGate.WaitAsync( ct ).ConfigureAwait( false );

		try
		{
			// Off the UI thread deliberately: ProgramSource.Compile blocks its caller inside a
			// Parallel.ForEach, so the engine's own tools freeze for the length of a cold compile.
			results = await Task.Run( () =>
				EditorUtility.CompileShader( Editor.FileSystem.Root, relativePath, options, ct ), ct )
				.ConfigureAwait( false );
		}
		catch ( OperationCanceledException )
		{
			throw;
		}
		catch ( Exception e )
		{
			PrismLog.Error( e, "The engine shader compiler threw" );

			failure = e.Message;
		}
		finally
		{
			PrismLog.Guard( "Releasing the engine compile gate", () => { s_engineGate.Release(); } );
		}

		var diagnostics = new List<Diagnostic>();
		var seen = new HashSet<string>( StringComparer.Ordinal );

		void Add( Diagnostic diagnostic )
		{
			if ( diagnostic is null ) return;

			// The same COMMON-block error is reported once per program; show it once.
			if ( !seen.Add( $"{diagnostic.Severity}|{diagnostic.Code}|{diagnostic.Span}|{diagnostic.Message}" ) ) return;

			diagnostics.Add( diagnostic );
		}

		if ( failure is not null )
		{
			Add( Diagnostic.Error( DiagnosticCode.CompilerRaw,
				"The engine shader compiler failed", null, failure ) );

			return new EngineOutcome( false, false, diagnostics );
		}

		if ( results is null )
		{
			Add( Diagnostic.Error( DiagnosticCode.CompilerRaw, "The engine shader compiler returned nothing" ) );

			return new EngineOutcome( false, false, diagnostics );
		}

		var programs = results.Programs ?? new List<ShaderCompile.Results.Program>();

		// The worst failure mode the engine has: the block header did not parse, so LoadFromSource
		// returned false, Programs is empty and the real message went only to the native log.
		if ( !results.Success && programs.Count == 0 )
		{
			Add( CompilerOutputParser.BlockHeaderFailure( fileName ) );

			return new EngineOutcome( false, false, diagnostics );
		}

		var sourceMap = generated?.Artifact( PrismConstants.BackendHlsl )?.SourceMap;
		var preprocessed = new StringBuilder();

		foreach ( var program in programs )
		{
			// Captured for every program, including the ones that compiled cleanly: the Preprocessed tab
			// is most useful precisely when the generated code looks right and the expansion does not.
			if ( !string.IsNullOrEmpty( program?.Source ) )
			{
				if ( preprocessed.Length > 0 ) preprocessed.AppendLine();

				preprocessed.AppendLine( $"// ==== {CompilerOutputParser.Pretty( program.Name )} ====" );
				preprocessed.AppendLine( program.Source );
			}

			if ( program?.Output is not { Count: > 0 } ) continue;

			// Every program has its own preprocessed text, so every program has its own line map.
			var map = LineDirectiveMap.Build( program.Source, fileName ).Calibrate( generatedText );
			var parsed = CompilerOutputParser.Parse( program.Output, fileName );
			var stage = CompilerOutputParser.Pretty( program.Name );

			foreach ( var diagnostic in map.RemapAll( parsed, sourceMap, fileName ) )
			{
				Add( string.IsNullOrEmpty( stage )
					? diagnostic
					: diagnostic with
					{
						Detail = string.IsNullOrWhiteSpace( diagnostic.Detail )
							? $"Reported while compiling {stage}."
							: $"{diagnostic.Detail}\nReported while compiling {stage}."
					} );
			}
		}

		// On the first failing combo the engine skips every remaining combo and every remaining program,
		// so a vertex error hides all the pixel errors. Say so rather than implying the rest is clean.
		if ( !results.Success )
		{
			var compiled = new HashSet<string>( programs.Select( x => x?.Name ?? string.Empty ),
				StringComparer.OrdinalIgnoreCase );

			var blocker = CompilerOutputParser.Pretty( programs.LastOrDefault( x => x is { Success: false } )?.Name );

			foreach ( var (block, program) in s_programs )
			{
				if ( !DeclaresBlock( generatedText, block ) || compiled.Contains( program ) ) continue;

				Add( CompilerOutputParser.ProgramSkipped( program, blocker ) );
			}
		}

		return new EngineOutcome( results.Success, results.Skipped, diagnostics )
		{
			Preprocessed = preprocessed.Length > 0 ? preprocessed.ToString() : null
		};
	}

	/// <summary>
	/// Register the freshly written shader, wait for the asset system to catch up, then reload it. The
	/// order matters: creating a material before <c>mat_reloadshaders</c> binds the previous binary.
	/// </summary>
	async Task Reload( string fileName, CancellationToken ct )
	{
		if ( !ReloadShaders ) return;

		var absolute = Workspace.Absolute( fileName );
		var content = Workspace.ContentPath( fileName );

		if ( string.IsNullOrWhiteSpace( absolute ) ) return;

		var asset = await OnMainThread( () => PrismLog.Guard( "Registering the preview shader",
			() => AssetSystem.RegisterFile( absolute ), null ), ct ).ConfigureAwait( false );

		if ( asset is not null )
		{
			var waited = Stopwatch.StartNew();

			while ( waited.ElapsedMilliseconds < AssetWaitMs )
			{
				ct.ThrowIfCancellationRequested();

				var ready = await OnMainThread( () => PrismLog.Guard( "Polling the preview shader asset",
					() => asset.IsCompiledAndUpToDate, true ), ct ).ConfigureAwait( false );

				if ( ready ) break;

				await Task.Delay( AssetPollMs, ct ).ConfigureAwait( false );
			}
		}

		await OnMainThread( () =>
		{
			PrismLog.Guard( "Reloading the preview shader",
				() => ConsoleSystem.Run( $"mat_reloadshaders {content}" ) );
		}, ct ).ConfigureAwait( false );
	}

	async Task<IReadOnlyList<Diagnostic>> ValidateSlangModule( CompileResult generated, CancellationToken ct )
	{
		if ( !ValidateSlang ) return Array.Empty<Diagnostic>();

		var validator = SlangValidator;

		if ( validator is null || !validator.Available ) return Array.Empty<Diagnostic>();

		var slang = generated?.SlangText;

		if ( string.IsNullOrWhiteSpace( slang ) ) return Array.Empty<Diagnostic>();

		// The prelude has to sit beside the module for `import` to resolve it.
		var artifact = generated.Artifact( PrismConstants.BackendSlang );

		foreach ( var extra in artifact?.Extra ?? Array.Empty<Compiler.Backends.GeneratedArtifact>() )
		{
			Workspace.Write( extra.FileName, extra.Text );
		}

		var moduleFile = $"preview.{PrismConstants.SlangExtension}";

		if ( !Workspace.Write( moduleFile, slang ) ) return Array.Empty<Diagnostic>();

		var request = new SlangValidationRequest
		{
			FilePath = Workspace.Absolute( moduleFile ),
			DisplayName = moduleFile,
			IncludePaths = new[] { Workspace.RootAbsolute },
			EntryPoints = new[]
			{
				new SlangEntryPoint( PrismConstants.EntryPointVertex, ShaderStage.Vertex ),
				new SlangEntryPoint( PrismConstants.EntryPointPixel, ShaderStage.Pixel )
			}
		};

		try
		{
			return await validator.Validate( request, ct ).ConfigureAwait( false );
		}
		catch ( OperationCanceledException )
		{
			throw;
		}
		catch ( Exception e )
		{
			PrismLog.Error( e, "Slang validation failed" );

			return Array.Empty<Diagnostic>();
		}
	}

	// ---- publishing ------------------------------------------------------

	async Task<CompileResult> Publish( CompileResult result, Stopwatch timer = null,
		CancellationToken ct = default )
	{
		if ( timer is not null ) LastCompileMs = timer.Elapsed.TotalMilliseconds;

		Last = result;

		// A service whose window has already closed must not raise Completed. Closing a window while a
		// compile is in flight otherwise delivers a result to a torn-down session — and because the
		// debounce loop is fire-and-forget, anything the handler throws becomes an unobserved task
		// exception rather than a diagnostic.
		if ( _disposed ) return result;

		await OnMainThread( () =>
		{
			if ( _disposed ) return;

			PrismLog.Guard( "Raising ShaderCompileService.Completed", () => Completed?.Invoke( result ) );

			PrismLog.Guard( "Raising the compile-finished editor event",
				() => EditorEvent.Run( PrismConstants.EventCompileFinished, LastShaderPath ?? string.Empty ) );
		}, ct ).ConfigureAwait( false );

		return result;
	}

	static CompileResult Fail( string message ) =>
		CompileResult.Failed( new[] { Diagnostic.Error( DiagnosticCode.CompilerRaw, message ) } );

	/// <summary>Append diagnostics to a result, optionally overriding whether it succeeded.</summary>
	static CompileResult With( CompileResult result, IReadOnlyList<Diagnostic> extra, bool? ok = null )
	{
		if ( result is null ) return null;
		if ( ( extra is null || extra.Count == 0 ) && ok is null ) return result;

		var diagnostics = extra is { Count: > 0 }
			? result.Diagnostics.Concat( extra ).ToArray()
			: result.Diagnostics;

		var succeeded = ( ok ?? result.Ok ) &&
			!diagnostics.Any( x => x.Severity == DiagnosticSeverity.Error );

		return result with { Diagnostics = diagnostics, Ok = succeeded };
	}

	/// <summary>
	/// Attach the engine's preprocessed text to the HLSL artifact as an extra file.
	/// <para>
	/// It rides along as a <see cref="GeneratedArtifact"/> rather than as a new field on
	/// <see cref="CompileResult"/>, which is a frozen type — and it is marked as not belonging beside
	/// the document, because it is a diagnostic view of what the compiler saw, not something anybody
	/// wants appearing in their content folder on every save.
	/// </para>
	/// </summary>
	static CompileResult WithPreprocessed( CompileResult result, string preprocessed )
	{
		if ( result?.Artifacts is null || string.IsNullOrEmpty( preprocessed ) ) return result;

		var hlsl = result.Artifact( PrismConstants.BackendHlsl );

		if ( hlsl is null ) return result;

		var extra = new List<GeneratedArtifact>( hlsl.Extra ?? (IReadOnlyList<GeneratedArtifact>)Array.Empty<GeneratedArtifact>() )
		{
			new( $"prism.preprocessed.{PrismConstants.HlslExtension}", preprocessed )
			{
				BesideDocument = false
			}
		};

		var artifacts = new Dictionary<string, BackendEmitResult>( StringComparer.Ordinal );

		foreach ( var pair in result.Artifacts ) artifacts[pair.Key] = pair.Value;

		artifacts[PrismConstants.BackendHlsl] = hlsl with { Extra = extra };

		return result with { Artifacts = artifacts };
	}

	// ---- helpers ---------------------------------------------------------

	/// <summary>The scratch file a mode writes to. One name per mode, overwritten in place.</summary>
	static string FileNameFor( CompileMode mode ) => mode switch
	{
		CompileMode.Thumbnail => $"thumbs.{PrismConstants.ShaderExtension}",
		CompileMode.Final => $"final.{PrismConstants.ShaderExtension}",
		_ => $"preview.{PrismConstants.ShaderExtension}"
	};

	/// <summary>
	/// True when the generated text opens a program block. This mirrors the engine's own scanner, which
	/// only recognises a block when the trimmed line is exactly the keyword.
	/// </summary>
	static bool DeclaresBlock( string text, string block )
	{
		if ( string.IsNullOrEmpty( text ) ) return false;

		foreach ( var line in text.Split( '\n' ) )
		{
			if ( string.Equals( line.Trim(), block, StringComparison.Ordinal ) ) return true;
		}

		return false;
	}

	/// <summary>Run an action on the main thread and wait for it. Completes inline when already there.</summary>
	static Task OnMainThread( Action action, CancellationToken ct = default )
	{
		var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously );

		MainThread.Queue( () =>
		{
			try
			{
				action();
				completion.TrySetResult();
			}
			catch ( Exception e )
			{
				completion.TrySetException( e );
			}
		} );

		return ct.CanBeCanceled ? Until( completion.Task, ct ) : completion.Task;
	}

	/// <summary>Run a function on the main thread and wait for its result.</summary>
	static Task<T> OnMainThread<T>( Func<T> func, CancellationToken ct = default )
	{
		var completion = new TaskCompletionSource<T>( TaskCreationOptions.RunContinuationsAsynchronously );

		MainThread.Queue( () =>
		{
			try
			{
				completion.TrySetResult( func() );
			}
			catch ( Exception e )
			{
				completion.TrySetException( e );
			}
		} );

		return ct.CanBeCanceled ? Until( completion.Task, ct ) : completion.Task;
	}

	/// <summary>
	/// Wait for a task, but stop waiting when the token is cancelled.
	/// <para>
	/// Every main-thread hop hands work to a pump this class does not own. During editor shutdown,
	/// across a hotload, or in a host with no pump at all, the queued callback may simply never run — and
	/// an awaiter with nothing to cancel it never returns, so the compile never leaves the try/finally
	/// that owns it and <see cref="IsCompiling"/> stays true for the rest of the session. A spinner the
	/// user cannot clear without closing the window is precisely the failure this class promises not to
	/// have, so cancelling a compile has to be able to unwedge it from the outside.
	/// </para>
	/// <para>
	/// The queued callback still runs if the pump ever comes back; its result is simply nobody's, which
	/// is the correct meaning of a cancelled compile.
	/// </para>
	/// </summary>
	static async Task Until( Task task, CancellationToken ct )
	{
		var cancelled = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously );

		using ( ct.Register( () => cancelled.TrySetCanceled( ct ) ) )
		{
			var winner = await Task.WhenAny( task, cancelled.Task ).ConfigureAwait( false );

			await winner.ConfigureAwait( false );
		}
	}

	/// <inheritdoc cref="Until(Task, CancellationToken)"/>
	static async Task<T> Until<T>( Task<T> task, CancellationToken ct )
	{
		var cancelled = new TaskCompletionSource<T>( TaskCreationOptions.RunContinuationsAsynchronously );

		using ( ct.Register( () => cancelled.TrySetCanceled( ct ) ) )
		{
			var winner = await Task.WhenAny( task, cancelled.Task ).ConfigureAwait( false );

			return await winner.ConfigureAwait( false );
		}
	}

	/// <summary>
	/// A read-only view of a graph with a different mode list, used to give preview compiles the minimum
	/// combo set without touching the document the user is editing. Everything else delegates, so the
	/// compiler sees the same nodes, edges, parameters and keywords it always does.
	/// </summary>
	sealed class PreviewGraph : ICompilableGraph
	{
		readonly ICompilableGraph _inner;

		public PreviewGraph( ICompilableGraph inner, IReadOnlyList<string> modes )
		{
			_inner = inner;
			Modes = modes ?? Array.Empty<string>();
		}

		public IReadOnlyList<string> Modes { get; }

		public string DocumentId => _inner.DocumentId;
		public bool IsSubgraph => _inner.IsSubgraph;
		public IReadOnlyList<PrismNode> Nodes => _inner.Nodes;
		public IReadOnlyList<Edge> Edges => _inner.Edges;

		public PrismNode FindNode( NodeId id ) => _inner.FindNode( id );

		public bool TryGetIncomingEdge( NodeId node, PortId port, out Edge edge ) =>
			_inner.TryGetIncomingEdge( node, port, out edge );

		public IEnumerable<Edge> GetOutgoingEdges( NodeId node, PortId port ) =>
			_inner.GetOutgoingEdges( node, port );

		public void OnNodeChanged( PrismNode node, NodeChangeKind kind ) => _inner.OnNodeChanged( node, kind );

		public ShaderDomain Domain => _inner.Domain;
		public ShadingModel ShadingModel => _inner.ShadingModel;
		public SurfaceBlendMode BlendMode => _inner.BlendMode;
		public CullMode CullMode => _inner.CullMode;
		public bool UsesUv2 => _inner.UsesUv2;
		public bool RenderBackfaces => _inner.RenderBackfaces;
		public string Title => _inner.Title;
		public string Description => _inner.Description;
		public IReadOnlyList<IGraphParameter> Parameters => _inner.Parameters;
		public IReadOnlyList<IGraphKeyword> Keywords => _inner.Keywords;
		public PrismNode OutputNode => _inner.OutputNode;
	}
}