🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=f4industries.prism&take=20
Showing code results for query:
*
(201 total matches found)
Editor
library
using Editor.Prism.Core;
namespace Editor.Prism.Compiler.Backends;
/// <summary>
/// What a backend can express.
/// <para>
/// Consulted during <em>validation</em>, not at emit time, so the user is told "this graph uses a
/// loop, which the strict-HLSL dialect cannot express" long before anything is written to disk.
/// </para>
/// </summary>
public sealed record BackendCapabilities(
ShaderModel MaxShaderModel,
StageMask Stages,
bool Loops,
bool RealBranching,
bool StructMethods,
bool Interpolators,
bool Combos,
int MaxVaryingSlots,
int MaxSamplers )
{
/// <summary>True when the backend can emit this stage.</summary>
public bool Supports( ShaderStage stage ) => Stages.Contains( stage );
/// <summary>True when the backend can provide a capability at its maximum shader model.</summary>
public bool Supports( Capability capability )
{
if ( Capabilities.MinShaderModel( capability ) > MaxShaderModel ) return false;
return capability switch
{
Capability.Loops => Loops,
Capability.DynamicBranching => RealBranching,
Capability.StructMethods => StructMethods,
Capability.Interpolators => Interpolators,
Capability.Combos => Combos,
_ => true
};
}
/// <summary>
/// The s&box VFX target: SM 6.0 Vulkan, no hull or domain stage (the engine's block parser
/// throws on those), everything else available.
/// </summary>
public static readonly BackendCapabilities Sbox = new(
ShaderModel.Sm6_0,
StageMask.Vertex | StageMask.Pixel | StageMask.Geometry | StageMask.Compute,
Loops: true,
RealBranching: true,
StructMethods: true,
Interpolators: true,
Combos: true,
MaxVaryingSlots: PrismConstants.MaxVaryingSlots,
MaxSamplers: PrismConstants.MaxSamplers );
/// <summary>The portable Slang target: no engine combos, no engine interpolator budget.</summary>
public static readonly BackendCapabilities Slang = new(
ShaderModel.Sm6_5,
StageMask.All,
Loops: true,
RealBranching: true,
StructMethods: true,
Interpolators: true,
Combos: false,
MaxVaryingSlots: 32,
MaxSamplers: 32 );
}
Editor
library
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
namespace Editor.Prism.Compiler.Backends;
/// <summary>
/// Wraps the HLSL an <see cref="HlslEmitter"/> produces in a complete s&box VFX
/// <c>.shader</c> file.
/// <para>
/// A <c>.shader</c> is not plain HLSL: it is a block language the engine's native front-end parses
/// before anything reaches a compiler. The block order, the placement of the blend defines relative
/// to <c>common/pixel.hlsl</c>, and the exact spelling of the annotation grammar all decide whether
/// the result renders correctly, renders wrongly, or fails to parse with no diagnostic at all.
/// </para>
/// </summary>
public sealed class SboxShaderWriter
{
readonly HlslSourceBuilder _builder;
readonly HlslEmitter _emitter;
readonly IReadOnlyList<HelperFunction> _helpers;
// Non-null only while writing an instrumented build. It doubles as the "is this the real pass"
// flag, which is what keeps the throw-away numbering pass from instrumenting itself.
IReadOnlyDictionary<NodeId, int> _stageIds;
/// <summary>Prepare to write one module.</summary>
public SboxShaderWriter( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics )
{
Module = module;
Options = options ?? BackendEmitOptions.Default;
Diagnostics = diagnostics ?? new DiagnosticSink();
_builder = new HlslSourceBuilder( Options.Indent, Options.NewLine );
_emitter = new HlslEmitter( Module, Options, Diagnostics );
_helpers = _emitter.OrderedHelpers();
}
/// <summary>The module being written.</summary>
public IrModule Module { get; }
/// <summary>Emission options.</summary>
public BackendEmitOptions Options { get; }
/// <summary>Where problems go.</summary>
public DiagnosticSink Diagnostics { get; }
ModuleMetadata Meta => Module.Meta;
ShaderDomain Domain => Meta.Domain;
bool IsSurface => Domain is ShaderDomain.Surface or ShaderDomain.PostProcess;
/// <summary>Write the module as a complete <c>.shader</c> file, with its source map.</summary>
public BackendEmitResult Write()
{
if ( Module is null )
{
Diagnostics.Error( DiagnosticCode.InvalidBlock, "There is no module to write." );
return BackendEmitResult.Empty( PrismConstants.BackendHlsl, PrismConstants.ShaderExtension );
}
if ( Domain == ShaderDomain.Subgraph )
{
Diagnostics.Error( DiagnosticCode.SubgraphUnavailable,
"A subgraph has no shader of its own.", null,
"Subgraphs are inlined into the graph that instances them; only a shader or post-process graph produces a .shader file." );
return BackendEmitResult.Empty( PrismConstants.BackendHlsl, PrismConstants.ShaderExtension );
}
if ( !VfxBlockValidator.Validate( Module, Diagnostics ) )
{
// The engine's block parser reports these only to the native log, with an empty program
// list and no line numbers. Refusing to write is far kinder than letting that happen.
return BackendEmitResult.Empty( PrismConstants.BackendHlsl, PrismConstants.ShaderExtension );
}
if ( PreviewInstrumentation.IsEnabled( Options.Mode ) && Domain != ShaderDomain.Compute )
{
// A node's stage id is its rank among the nodes appearing in the finished artifact's source
// map, so it cannot be known until the file has been written once. Writing it twice is far
// cheaper and far safer than predicting that order: the throw-away pass costs one more text
// generation and its diagnostics are discarded, because the real pass reports exactly the
// same set. Adding the instrumentation never changes the order — every line it writes is
// attributed either to a node that already appeared above it, or to nothing at all.
var probe = new SboxShaderWriter( Module, Options, new DiagnosticSink() );
probe.WriteBlocks();
_stageIds = PreviewInstrumentation.BuildStageMap( probe._builder.SourceMap );
}
WriteBlocks();
var text = _builder.ToString();
var map = _builder.SourceMap;
map.File = $"{Options.OutputName}.{PrismConstants.ShaderExtension}";
return new BackendEmitResult( text, PrismConstants.ShaderExtension, map, Array.Empty<GeneratedArtifact>() )
{
BackendId = PrismConstants.BackendHlsl,
LineCount = _builder.LineCount
};
}
/// <summary>Write every block of the file, in the order the engine's parser expects them.</summary>
void WriteBlocks()
{
WriteHeaderBlock();
WriteModesBlock();
WriteFeaturesBlock();
WriteCommonBlock();
if ( Domain != ShaderDomain.Compute )
{
WriteVertexInputStruct();
WritePixelInputStruct();
WriteVertexBlock();
WritePixelBlock();
}
else
{
WriteComputeBlock();
}
}
/// <summary>Write a module straight to text, for callers that only want the string.</summary>
public static string WriteText( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics ) =>
new SboxShaderWriter( module, options, diagnostics ).Write().Text;
// ---- HEADER -----------------------------------------------------------
void WriteHeaderBlock()
{
_builder.Write( SboxShaderTemplates.BlockHeader );
_builder.Open();
var description = string.IsNullOrWhiteSpace( Meta.Description )
? $"{Meta.Name} — generated by {PrismConstants.ProductName}"
: Meta.Description;
_builder.Write( $"Description = \"{SboxShaderTemplates.QuoteSafe( description )}\";" );
_builder.Write( $"DevShader = {( Options.Mode == CompileMode.Final ? "false" : "true" )};" );
_builder.Write( $"Version = {HeaderVersion()};" );
if ( Options.DebugSymbols ) _builder.Write( "DebugInfo = true;" );
_builder.Close();
_builder.Blank();
}
int HeaderVersion()
{
var version = Meta.Version;
if ( string.IsNullOrWhiteSpace( version ) ) return 1;
if ( int.TryParse( version, out var whole ) && whole > 0 ) return whole;
var dot = version.IndexOf( '.' );
if ( dot > 0 && int.TryParse( version[..dot], out var major ) && major > 0 ) return major;
return 1;
}
// ---- MODES ------------------------------------------------------------
void WriteModesBlock()
{
_builder.Write( SboxShaderTemplates.BlockModes );
_builder.Open();
foreach ( var mode in DeclaredModes() )
{
var statement = SboxShaderTemplates.ModeStatement( mode );
if ( !string.IsNullOrEmpty( statement ) ) _builder.Write( statement );
}
_builder.Close();
_builder.Blank();
}
IReadOnlyList<string> DefaultModes() => SboxShaderTemplates.DefaultModesFor( Domain );
/// <summary>
/// The render passes this file actually declares.
/// <para>
/// The domain and the pass list are authored independently, so a graph switched to PostProcess after
/// the fact still carries the surface passes. Declaring <c>Depth()</c> on a full-screen pass asks the
/// engine to render a full-screen triangle into the depth buffer, and a post-process material invoked
/// through the standard path needs <c>Default()</c> whether or not the document remembered it — so a
/// non-surface domain starts from its own pass set and only then takes whatever the document adds.
/// </para>
/// </summary>
IReadOnlyList<string> DeclaredModes()
{
var declared = Meta.Modes.Count > 0 ? Meta.Modes : DefaultModes();
var modes = new List<string>( declared.Count + 2 );
bool Has( string mode ) => modes.Any( x => string.Equals( x, mode, StringComparison.OrdinalIgnoreCase ) );
if ( Domain != ShaderDomain.Surface ) modes.AddRange( DefaultModes() );
foreach ( var mode in declared )
{
if ( string.IsNullOrWhiteSpace( mode ) || Has( mode ) ) continue;
if ( !SboxShaderTemplates.IsModeLegalFor( Domain, mode ) )
{
Diagnostics.Info( DiagnosticCode.InvalidBlock,
$"Render pass '{mode}' means nothing to a {Domain} graph and was not declared." );
continue;
}
modes.Add( mode );
}
if ( modes.Count == 0 ) modes.AddRange( DefaultModes() );
return modes;
}
// ---- FEATURES ---------------------------------------------------------
void WriteFeaturesBlock()
{
if ( Domain == ShaderDomain.Compute ) return;
_builder.Write( SboxShaderTemplates.BlockFeatures );
_builder.Open();
_builder.Write( $"#include \"{SboxShaderTemplates.IncludeFeatures}\"" );
foreach ( var combo in Meta.Combos )
{
if ( combo is null || combo.Kind != ComboKind.Feature ) continue;
_builder.Write( FeatureStatement( combo ) );
}
_builder.Close();
_builder.Blank();
}
static string FeatureStatement( ComboDecl combo )
{
var group = string.IsNullOrWhiteSpace( combo.Group ) ? "Features" : SboxShaderTemplates.QuoteSafe( combo.Group );
var values = combo.Values ?? Array.Empty<string>();
// A two-value feature whose labels say nothing beyond "off" and "on" is a checkbox in the material
// editor. Spelling those labels out explicitly turns it into a two-item combo box instead, which
// is the wrong control for a boolean, so the bare range is emitted for the conventional pairs.
if ( values.Count < 2 || IsPlainToggle( values ) ) return $"Feature( {combo.Name}, 0..1, \"{group}\" );";
var labels = new List<string>( values.Count );
for ( int i = 0; i < values.Count; i++ )
{
var label = values[i] ?? string.Empty;
var separator = label.IndexOf( '=' );
if ( separator >= 0 ) label = label[( separator + 1 )..];
labels.Add( $"{i}=\"{SboxShaderTemplates.QuoteSafe( label.Trim().Trim( '"' ) )}\"" );
}
return $"Feature( {combo.Name}, 0..{values.Count - 1} ( {string.Join( ", ", labels )} ), \"{group}\" );";
}
/// <summary>
/// True when a two-value combo's labels carry no information a checkbox does not already convey.
/// </summary>
static bool IsPlainToggle( IReadOnlyList<string> values )
{
if ( values.Count != 2 ) return false;
var off = ( values[0] ?? string.Empty ).Trim().Trim( '"' );
var on = ( values[1] ?? string.Empty ).Trim().Trim( '"' );
foreach ( var (a, b) in s_toggleLabels )
{
if ( string.Equals( off, a, StringComparison.OrdinalIgnoreCase ) &&
string.Equals( on, b, StringComparison.OrdinalIgnoreCase ) )
{
return true;
}
}
return false;
}
static readonly (string Off, string On)[] s_toggleLabels =
[
("Off", "On"), ("0", "1"), ("False", "True"), ("No", "Yes"), ("Disabled", "Enabled")
];
// ---- COMMON -----------------------------------------------------------
void WriteCommonBlock()
{
_builder.Write( SboxShaderTemplates.BlockCommon );
_builder.Open();
if ( Domain == ShaderDomain.Compute )
{
// A compute program has no render state, no material and no pixel input; the shipped
// compute shaders include nothing but the macro header.
_builder.Write( $"#include \"{SboxShaderTemplates.IncludeSystem}\"" );
WriteModuleIncludes();
_builder.Close();
_builder.Blank();
return;
}
// Everything that steers render state has to be defined BEFORE common/pixel.hlsl pulls in
// sbox_pixel.fxc, which reads S_TRANSLUCENT and S_ALPHA_TEST at include time. Defining them
// afterwards silently produces opaque render state, which is the single most common way a
// generated transparent shader comes out wrong.
var blend = Meta.BlendMode;
var alphaTest = blend == SurfaceBlendMode.Masked;
var translucent = blend is SurfaceBlendMode.Translucent or SurfaceBlendMode.Additive or SurfaceBlendMode.Multiply;
WriteDefine( "S_ALPHA_TEST", alphaTest ? "1" : "0" );
WriteDefine( "S_TRANSLUCENT", translucent ? "1" : "0" );
if ( blend == SurfaceBlendMode.Additive ) WriteDefine( "S_ADDITIVE_BLEND", "1" );
if ( blend == SurfaceBlendMode.Multiply )
{
// Multiply is not one of the engine's built-in blend paths, so we take ownership of the
// blend state and write it ourselves in the pixel block.
WriteDefine( "BLEND_MODE_ALREADY_SET", "1" );
}
if ( Meta.UsesUv2 || Domain == ShaderDomain.Surface ) WriteDefine( "S_UV2", "1" );
if ( Meta.ShadingModel == ShadingModel.Unlit && Domain == ShaderDomain.Surface )
{
WriteDefine( "S_UNLIT", "1" );
}
_builder.Blank();
_builder.Write( $"#include \"{SboxShaderTemplates.IncludeShared}\"" );
if ( IsSurface ) _builder.Write( $"#include \"{SboxShaderTemplates.IncludeProcedural}\"" );
WriteModuleIncludes();
_builder.Close();
_builder.Blank();
}
/// <summary>
/// Emit the module's includes plus every include its helpers asked for, deduplicated and in a
/// stable order. Folding the helper includes in here means a helper that needs a header still
/// compiles even if nothing upstream remembered to register it on the module.
/// </summary>
void WriteModuleIncludes()
{
var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
foreach ( var include in Module.Includes ) WriteInclude( include );
foreach ( var helper in _helpers )
{
foreach ( var include in helper.Includes ?? Array.Empty<string>() ) WriteInclude( include );
}
void WriteInclude( string include )
{
if ( string.IsNullOrWhiteSpace( include ) ) return;
if ( IsImplicitInclude( include ) ) return;
if ( !seen.Add( include ) ) return;
_builder.Write( $"#include \"{include}\"" );
}
}
void WriteDefine( string name, string value )
{
_builder.Write( $"#ifndef {name}" );
_builder.Write( $"#define {name} {value}" );
_builder.Write( "#endif" );
}
static bool IsImplicitInclude( string include ) =>
include is SboxShaderTemplates.IncludeShared or SboxShaderTemplates.IncludeProcedural or
SboxShaderTemplates.IncludeSystem or SboxShaderTemplates.IncludePixel or
SboxShaderTemplates.IncludeVertex or SboxShaderTemplates.IncludeFeatures or
SboxShaderTemplates.IncludeVertexInput or SboxShaderTemplates.IncludePixelInput;
// ---- structs ----------------------------------------------------------
void WriteVertexInputStruct()
{
_builder.Write( $"struct {SboxShaderTemplates.StructVertexInput}" );
_builder.Open();
_builder.Write( $"#include \"{SboxShaderTemplates.IncludeVertexInput}\"" );
if ( Domain == ShaderDomain.Surface ) _builder.WriteBlock( SboxShaderTemplates.SurfaceVertexInputExtras );
WriteUserStructFields( SboxShaderTemplates.StructVertexInput );
_builder.Close( ";" );
_builder.Blank();
}
void WritePixelInputStruct()
{
_builder.Write( $"struct {SboxShaderTemplates.StructPixelInput}" );
_builder.Open();
_builder.Write( $"#include \"{SboxShaderTemplates.IncludePixelInput}\"" );
if ( Domain == ShaderDomain.Surface )
{
_builder.WriteBlock( SboxShaderTemplates.SurfacePixelInputExtras );
}
foreach ( var varying in Module.Varyings )
{
if ( varying is null ) continue;
var semantic = SboxShaderTemplates.VaryingSemantic( varying );
_builder.Write(
$"{HlslBackend.Interpolation( varying.Interpolation )}{varying.Type.Hlsl} {varying.Name} : {semantic};",
varying.Origin );
}
WriteUserStructFields( SboxShaderTemplates.StructPixelInput );
if ( Domain == ShaderDomain.Surface ) _builder.WriteBlock( SboxShaderTemplates.PixelInputFrontFacing );
_builder.Close( ";" );
_builder.Blank();
}
void WriteUserStructFields( string name )
{
var structure = Module.FindStruct( name );
if ( structure is null ) return;
foreach ( var include in structure.Includes )
{
if ( string.IsNullOrWhiteSpace( include ) ) continue;
if ( IsImplicitInclude( include ) ) continue;
_builder.Write( $"#include \"{include}\"" );
}
foreach ( var field in structure.Fields )
{
if ( field is null ) continue;
var semantic = string.IsNullOrEmpty( field.Semantic ) ? string.Empty : $" : {field.Semantic}";
_builder.Write(
$"{HlslBackend.Interpolation( field.Interpolation )}{field.Type.Hlsl} {field.Name}{semantic};" );
}
}
// ---- VS ---------------------------------------------------------------
void WriteVertexBlock()
{
_emitter.Stage = ShaderStage.Vertex;
_builder.Write( ShaderStage.Vertex.BlockName() );
_builder.Open();
if ( Domain == ShaderDomain.Surface )
{
_builder.Write( $"#include \"{SboxShaderTemplates.IncludeVertex}\"" );
_builder.Blank();
}
WriteCombos( ShaderStage.Vertex );
SboxMaterialBinding.WriteGlobals( _builder, _emitter, ShaderStage.Vertex );
_emitter.WriteHelpers( _builder, ShaderStage.Vertex, _helpers );
_emitter.WriteFunctions( _builder, ShaderStage.Vertex );
var entry = Module.EntryPoint( ShaderStage.Vertex );
// SV_VertexID has no stream in common/vertexinput.hlsl, so it rides in as a second entry-point
// parameter — and only when the graph reads it, so every other shader keeps the stock signature.
var parameters = $"{SboxShaderTemplates.StructVertexInput} {SboxShaderTemplates.VertexInputLocal}";
if ( UsesBuiltin( entry?.Body, Builtin.VertexId ) )
{
parameters += $", {SboxShaderTemplates.VertexIdParameterDeclaration}";
}
_builder.Write(
$"{SboxShaderTemplates.StructPixelInput} {PrismConstants.EntryPointVertex}( {parameters} )" );
_builder.Open();
if ( Domain == ShaderDomain.PostProcess )
{
_builder.WriteBlock( SboxShaderTemplates.PostProcessVertexPrologue );
// The graph's own vertex statements go here, not nowhere. GraphCompiler.EmitRoots builds a
// real vertex entry for a post-process domain and the pixel input struct declares every
// varying, so dropping the body left every interpolated value reading zero.
if ( entry is not null )
{
_builder.Blank();
WriteStatements( entry.Body );
}
_builder.Blank();
_builder.Write( SboxShaderTemplates.PostProcessVertexEpilogue );
}
else
{
_builder.WriteBlock( SboxShaderTemplates.SurfaceVertexPrologue );
_builder.Blank();
if ( entry is not null )
{
WriteStatements( entry.Body );
if ( WritesWorldPosition( entry.Body ) )
{
_builder.Write( SboxShaderTemplates.VertexPositionResync );
}
_builder.Blank();
}
_builder.Write( SboxShaderTemplates.SurfaceVertexEpilogue );
}
_builder.Close();
_builder.Close();
_builder.Blank();
}
/// <summary>
/// True when the vertex program moved the world-space position, so clip space has to be recomputed
/// before <c>FinalizeVertex</c> subtracts the high-precision offset.
/// </summary>
static bool WritesWorldPosition( IrBlock block )
{
foreach ( var statement in IrWalk.Statements( block ) )
{
if ( statement is IrAssign assign && TouchesWorldPosition( assign.Target ) ) return true;
}
return false;
}
/// <summary>True when any expression anywhere in a block reads a particular environment value.</summary>
static bool UsesBuiltin( IrBlock block, Builtin id )
{
foreach ( var statement in IrWalk.Statements( block ) )
{
foreach ( var expression in IrWalk.Expressions( statement ) )
{
if ( Reads( expression, id ) ) return true;
}
}
return false;
}
static bool Reads( IrExpr expr, Builtin id )
{
if ( expr is null ) return false;
foreach ( var node in IrExprUtil.Walk( expr ) )
{
if ( node is IrBuiltinRef reference && reference.Id == id ) return true;
}
return false;
}
static bool TouchesWorldPosition( IrExpr target )
{
foreach ( var node in IrExprUtil.Walk( target ) )
{
if ( node is IrMember member && member.Field == "vPositionWs" ) return true;
}
return false;
}
// ---- PS ---------------------------------------------------------------
void WritePixelBlock()
{
_emitter.Stage = ShaderStage.Pixel;
_builder.Write( ShaderStage.Pixel.BlockName() );
_builder.Open();
_builder.Write( $"#include \"{SboxShaderTemplates.IncludePixel}\"" );
if ( Domain == ShaderDomain.PostProcess )
{
_builder.Write( $"#include \"{SboxShaderTemplates.IncludePostProcessCommon}\"" );
_builder.Write( $"#include \"{SboxShaderTemplates.IncludePostProcessFunctions}\"" );
}
_builder.Blank();
WriteCombos( ShaderStage.Pixel );
WriteRenderState();
// The colour buffer is boilerplate for a post-process pass, but a node that reads it declares it
// too — and Slang rejects the second declaration outright rather than merging them, so a graph
// that actually sampled the frame buffer used to fail to compile. The node's declaration wins:
// it carries the node's own sRGB and attribute metadata, and it is emitted with the rest of the
// module globals a few lines below.
if ( Domain == ShaderDomain.PostProcess &&
Module.FindGlobal( SboxShaderTemplates.PostProcessColorBufferSymbol ) is null )
{
_builder.WriteBlock( SboxShaderTemplates.PostProcessColorBuffer );
_builder.Blank();
}
SboxMaterialBinding.WriteGlobals( _builder, _emitter, ShaderStage.Pixel );
WriteInstrumentationDeclarations();
_emitter.WriteHelpers( _builder, ShaderStage.Pixel, _helpers );
_emitter.WriteFunctions( _builder, ShaderStage.Pixel );
_builder.Write(
$"float4 {PrismConstants.EntryPointPixel}( {SboxShaderTemplates.StructPixelInput} {SboxShaderTemplates.PixelInputLocal} ) : SV_Target0" );
_builder.Open();
var entry = Module.EntryPoint( ShaderStage.Pixel );
var returned = SboxMaterialBinding.EndsWithReturn( entry?.Body );
if ( !returned ) SboxMaterialBinding.WritePixelPrologue( _builder, Module );
if ( entry is not null )
{
WritePixelBody( entry.Body );
_builder.Blank();
}
if ( !returned ) WriteChannelTail();
if ( !returned ) SboxMaterialBinding.WritePixelEpilogue( _builder, Module, _emitter );
_builder.Close();
_builder.Close();
_builder.Blank();
}
void WriteRenderState()
{
var wrote = false;
// The engine only fills the frame-buffer copy for a material that asks for it, and the ask is a
// PS-block attribute rather than anything a node can declare. Emitting it here means a graph that
// reads scene colour gets a filled texture instead of last frame's stale contents.
if ( WantsFrameBufferCopy() )
{
_builder.Write( $"BoolAttribute( {SboxShaderTemplates.FrameBufferCopyFlag}, true );" );
wrote = true;
}
if ( Meta.BlendMode == SurfaceBlendMode.Multiply )
{
_builder.WriteBlock( SboxShaderTemplates.MultiplyBlendState );
wrote = true;
}
if ( Options.Mode is CompileMode.Preview or CompileMode.Thumbnail )
{
// The preview toggles backface rendering without recompiling the material.
_builder.WriteBlock( SboxShaderTemplates.CullModePreview );
wrote = true;
}
else
{
var cull = Meta.RenderBackfaces ? CullMode.None : Meta.CullMode;
switch ( cull )
{
case CullMode.None:
_builder.Write( "RenderState( CullMode, NONE );" );
break;
case CullMode.Front:
_builder.Write( "RenderState( CullMode, FRONT );" );
break;
default:
_builder.Write( SboxShaderTemplates.CullModeFromFeature );
break;
}
wrote = true;
}
if ( wrote ) _builder.Blank();
}
/// <summary>True when the module reads the frame-buffer copy and must therefore request it.</summary>
bool WantsFrameBufferCopy()
{
if ( Module?.Globals is null ) return false;
foreach ( var global in Module.Globals )
{
if ( global is null ) continue;
if ( string.Equals( global.Name, SboxShaderTemplates.FrameBufferCopyTexture, StringComparison.Ordinal ) )
return true;
}
return false;
}
// ---- CS ---------------------------------------------------------------
void WriteComputeBlock()
{
_emitter.Stage = ShaderStage.Compute;
_builder.Write( ShaderStage.Compute.BlockName() );
_builder.Open();
WriteCombos( ShaderStage.Compute );
SboxMaterialBinding.WriteGlobals( _builder, _emitter, ShaderStage.Compute );
_emitter.WriteHelpers( _builder, ShaderStage.Compute, _helpers );
_emitter.WriteFunctions( _builder, ShaderStage.Compute );
var entry = Module.EntryPoint( ShaderStage.Compute );
var threads = entry?.Attributes.FirstOrDefault( x => x?.StartsWith( "[numthreads", StringComparison.OrdinalIgnoreCase ) == true );
_builder.Write( threads ?? SboxShaderTemplates.ComputeDefaultNumThreads );
_builder.Write( $"void {PrismConstants.EntryPointCompute}( {SboxShaderTemplates.ComputeEntryParameters} )" );
_builder.Open();
if ( entry is not null ) WriteStatements( entry.Body );
_builder.Close();
_builder.Close();
_builder.Blank();
}
// ---- statements -------------------------------------------------------
/// <summary>
/// Write a block's statements.
/// <para>
/// Everything <see cref="HlslEmitter"/> already knows how to write is handed straight back to it,
/// character for character. This layer exists for the one statement the emitter cannot see —
/// <see cref="IrPreprocessorIf"/>, which lowers to directives rather than to an expression — and
/// the block-carrying statements are reproduced here only so that a preprocessor branch nested
/// inside a loop or a conditional still reaches this writer.
/// </para>
/// </summary>
void WriteStatements( IrBlock block )
{
if ( block is null ) return;
foreach ( var statement in block.Statements ) WriteStatement( statement );
}
void WriteStatement( IrStmt statement )
{
if ( statement is null ) return;
var previous = _emitter.CurrentOrigin;
switch ( statement )
{
case IrPreprocessorIf guard:
_emitter.CurrentOrigin = guard.Origin;
WritePreprocessorIf( guard );
break;
case IrIf branch:
_emitter.CurrentOrigin = branch.Origin;
_builder.Write( $"if ( {_emitter.Expression( branch.Cond )} )", branch.Origin );
WriteBraced( branch.Then, branch.Origin );
if ( branch.Else is { IsEmpty: false } )
{
_builder.Write( "else", branch.Origin );
WriteBraced( branch.Else, branch.Origin );
}
break;
case IrFor loop:
_emitter.CurrentOrigin = loop.Origin;
var counter = string.IsNullOrEmpty( loop.Var ) ? "n" : loop.Var;
_builder.Write(
$"for ( int {counter} = 0; {counter} < ( int )( {_emitter.Expression( loop.Count )} ); {counter}++ )",
loop.Origin );
WriteBraced( loop.Body, loop.Origin );
break;
case IrWhile loop:
_emitter.CurrentOrigin = loop.Origin;
_builder.Write( $"while ( {_emitter.Expression( loop.Cond )} )", loop.Origin );
WriteBraced( loop.Body, loop.Origin );
break;
case IrScope scope:
_emitter.CurrentOrigin = scope.Origin;
WriteBraced( scope.Body, scope.Origin );
break;
default:
_emitter.WriteStatement( _builder, statement );
break;
}
_emitter.CurrentOrigin = previous;
}
void WriteBraced( IrBlock block, NodeId origin )
{
_builder.Open( origin );
WriteStatements( block );
_builder.Close( origin: origin );
}
/// <summary>
/// Write a preprocessor branch as real <c>#if</c> / <c>#else</c> / <c>#endif</c> directives, so
/// only the taken side ever reaches the compiler.
/// <para>
/// This is what a static combo is supposed to cost. A run-time <c>select</c> evaluates both sides
/// and pays for the texture samples and the loops in the one that was never wanted; a
/// preprocessor branch deletes them.
/// </para>
/// </summary>
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, because
// a preprocessor error has no line we can map back to a node. Folding both sides in keeps
// the shader compiling and costs only the exclusion.
_emitter.Report( DiagnosticSeverity.Warning, DiagnosticCode.InvalidBlock,
"A compile-time branch had no usable combo condition, so both of its sides were emitted.",
"Bind the node to a graph keyword. Without one there is nothing for the preprocessor to test, and the branch cannot be resolved at compile time." );
WriteStatements( guard.Then );
WriteStatements( guard.Else );
return;
}
_builder.Write( directive, guard.Origin );
WriteStatements( guard.Then );
if ( guard.HasElse )
{
_builder.Write( IrPreprocessor.ElseDirective, guard.Origin );
WriteStatements( guard.Else );
}
_builder.Write( IrPreprocessor.EndDirective, guard.Origin );
}
// ---- preview instrumentation ------------------------------------------
/// <summary>
/// Declare the two preview uniforms. They are attribute-bound and default to zero, so a shader
/// built with instrumentation still renders normally until something pushes them, and switching
/// what the viewport displays costs one attribute write rather than a recompile.
/// </summary>
void WriteInstrumentationDeclarations()
{
if ( _stageIds is null ) return;
foreach ( var line in PreviewInstrumentation.Banner() ) _builder.Write( line );
foreach ( var line in PreviewInstrumentation.HlslDeclarations() ) _builder.Write( line );
_builder.Blank();
}
/// <summary>
/// Write the pixel entry's body, interleaving the preview stage switch between statements.
/// <para>
/// The test sits next to the temp it reads rather than in a tail at the end of the function, and
/// that is not a stylistic choice: a temp bound inside a loop or a branch has gone out of scope by
/// the time the function ends. One test per node, after the last statement that node produced, so
/// what the switch returns is the node's result rather than an intermediate.
/// </para>
/// </summary>
void WritePixelBody( IrBlock body )
{
if ( body is null ) return;
if ( _stageIds is null )
{
WriteStatements( body );
return;
}
var statements = body.Statements;
var cases = StageCases( statements );
for ( int i = 0; i < statements.Count; i++ )
{
WriteStatement( statements[i] );
if ( !cases.TryGetValue( i, out var line ) ) continue;
_builder.Write( line, statements[i].Origin );
}
}
/// <summary>
/// The switch case for each top-level statement that ends a node's contribution to the pixel
/// program. Nodes whose work the stage planner put in the vertex program, and values that cannot
/// be shown as a colour at all, produce no case — selecting one of those shows the shaded result
/// rather than a wrong one.
/// </summary>
Dictionary<int, string> StageCases( IReadOnlyList<IrStmt> 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;
}
var cases = new Dictionary<int, string>();
foreach ( var (origin, index) in last )
{
if ( statements[index] is not IrDecl decl ) continue;
var line = PreviewInstrumentation.StageCase(
PreviewInstrumentation.StageIdOf( _stageIds, origin ), decl.Name, decl.Type );
if ( line is not null ) cases[index] = line;
}
return cases;
}
/// <summary>
/// Write the debug-channel tail, just before the shading epilogue so the material the graph filled
/// in is still in scope and still unclamped.
/// </summary>
void WriteChannelTail()
{
if ( _stageIds is null ) return;
var lines = PreviewInstrumentation.ChannelLines( ChannelEnvironment() );
if ( lines.Count == 0 ) return;
foreach ( var line in lines ) _builder.Write( line );
_builder.Blank();
}
/// <summary>
/// What this shader can answer about itself, channel by channel. A null entry means the generated
/// shader has no honest expression for that channel — no material struct under a custom shading
/// model, no vertex colour outside a surface graph — and the channel is then simply absent, which
/// the viewport reads as "keep showing the shaded result".
/// </summary>
PreviewChannelEnvironment ChannelEnvironment()
{
var surface = Domain == ShaderDomain.Surface;
var input = SboxShaderTemplates.PixelInputLocal;
var material = SboxMaterialBinding.UsesMaterial( Module );
return new PreviewChannelEnvironment
{
Albedo = MaterialField( material, "Albedo" ),
Opacity = MaterialField( material, "Opacity" ),
NormalTangent = MaterialField( material, "Normal" ),
// The graph authors the normal in tangent space; the same conversion the shading epilogue
// performs is what makes this channel comparable with the engine's own normal debug view.
NormalWorld = material && surface
? $"TransformNormal( {SboxShaderTemplates.MaterialLocal}.Normal, {input}.vNormalWs, {input}.vTangentUWs, {input}.vTangentVWs )"
: null,
Roughness = MaterialField( material, "Roughness" ),
Metalness = MaterialField( material, "Metalness" ),
AmbientOcclusion = MaterialField( material, "AmbientOcclusion" ),
Emission = MaterialField( material, "Emission" ),
Transmission = MaterialField( material, "Transmission" ),
TintMask = MaterialField( material, "TintMask" ),
Uv0 = $"{input}.vTextureCoords.xy",
Uv1 = $"{input}.vTextureCoords.zw",
VertexColor = surface ? $"{input}.vColor" : null,
WorldPosition = surface
? HlslIntrinsics.BuiltinExpression( Builtin.WorldPosition, ShaderStage.Pixel, Domain )
: null,
DerivativeSource = $"{input}.vTextureCoords.xy"
};
}
/// <summary>The expression for one material field, or null when this shader has no material.</summary>
static string MaterialField( bool material, string name ) =>
material && SboxMaterialBinding.TryGetField( name, out var field ) ? field.Reference : null;
// ---- combos -----------------------------------------------------------
void WriteCombos( ShaderStage stage )
{
var wrote = false;
foreach ( var combo in Meta.Combos )
{
if ( combo is null ) continue;
switch ( combo.Kind )
{
case ComboKind.Feature:
// A feature is only visible to a program through a static combo bound to it.
_builder.Write( $"StaticCombo( {StaticNameFor( combo.Name )}, {combo.Name}, Sys( ALL ) );" );
break;
case ComboKind.Static:
_builder.Write( $"StaticCombo( {combo.Name}, 0..{ComboMaximum( combo )}, Sys( ALL ) );" );
break;
default:
_builder.Write( $"DynamicCombo( {combo.Name}, 0..{ComboMaximum( combo )}, Sys( ALL ) );" );
break;
}
wrote = true;
}
if ( wrote ) _builder.Blank();
}
static int ComboMaximum( ComboDecl combo )
{
var count = combo.Values?.Count ?? 0;
return count < 2 ? 1 : count - 1;
}
/// <summary>The static-combo symbol a feature is bound to: <c>F_PUDDLES</c> becomes <c>S_PUDDLES</c>.</summary>
public static string StaticNameFor( string featureName )
{
if ( string.IsNullOrEmpty( featureName ) ) return "S_UNNAMED";
return featureName.StartsWith( "F_", StringComparison.Ordinal )
? "S_" + featureName[2..]
: "S_" + featureName;
}
}
Editor
library
using Editor.Prism.Core;
namespace Editor.Prism.Compiler.Backends;
/// <summary>
/// The small <c>prism.core</c> Slang module every generated Prism module imports.
/// <para>
/// It carries the three things a standalone <c>.slang</c> artifact cannot get from the engine: the
/// environment parameter block (camera, viewport, object transform, time), the handful of math and
/// colour-space helpers the emitted code calls into, and the <c>PrismMaterial</c> struct a surface
/// graph fills in. It is emitted as a <see cref="GeneratedArtifact"/> beside the main module, so the
/// pair compiles with nothing but <c>slangc</c> and an include path.
/// </para>
/// </summary>
public static class SlangRuntimeModule
{
/// <summary>The module name an emitted Prism module imports.</summary>
public const string ModuleName = PrismConstants.SlangRuntimeModule;
/// <summary>
/// Path of the emitted file, relative to the main module. <c>import prism.core;</c> resolves a
/// dotted module name to this path, so the directory is part of the contract.
/// </summary>
public const string FileName = "prism/core.slang";
/// <summary>The import statement an emitted module writes.</summary>
public const string ImportStatement = "import " + ModuleName + ";";
/// <summary>Name of the environment parameter block this module declares.</summary>
public const string EnvironmentBlock = SlangIntrinsics.EnvironmentBlock;
/// <summary>Name of the material struct a surface graph fills in.</summary>
public const string MaterialStruct = "PrismMaterial";
/// <summary>The prelude source, with CRLF line endings.</summary>
public static string Source => SourceWith( "\r\n" );
/// <summary>The prelude source with a chosen line ending.</summary>
public static string SourceWith( string newLine )
{
if ( string.IsNullOrEmpty( newLine ) ) newLine = "\r\n";
// The literal below picks up whatever line ending this file happens to be saved with, so it is
// normalised before substituting. Without this a CRLF source would emit CR CR LF.
var normalised = s_source.Replace( "\r\n", "\n" ).Replace( '\r', '\n' );
return newLine == "\n" ? normalised : normalised.Replace( "\n", newLine );
}
/// <summary>
/// The prelude packaged as an artifact the backend returns alongside its main result. It is
/// written beside the saved document, which is also where the module's include path points.
/// </summary>
public static GeneratedArtifact Artifact( string newLine = "\r\n" ) =>
new( FileName, SourceWith( newLine ) ) { BesideDocument = true };
// The source is stored with plain LF and normalised on the way out, so the literal below stays
// readable and the emitted file still honours BackendEmitOptions.NewLine.
const string s_source = """
#language slang 2026
module "prism/core";
// =============================================================================
// prism.core - the shared prelude for Prism-generated Slang modules
//
// Generated by Prism. Editing this file is fine, but regenerating a graph
// overwrites it: keep local changes in a module of your own and import both.
//
// Contents
// 1. Material-UI attributes - reflected into `-reflection-json` userAttribs
// 2. Environment - camera, viewport, object transform, time
// 3. Transforms - object/world/clip space conversions
// 4. Math - the safe-by-default helpers emitted code calls
// 5. Textures - value-returning wrappers over out-param methods
// 6. Colour - sRGB, HSV and luminance
// 7. PrismMaterial - what a surface graph fills in
// =============================================================================
// -----------------------------------------------------------------------------
// 1. Material-UI attributes
//
// Prism annotates every generated shader parameter with these. They carry no
// runtime cost: `slangc -reflection-json` reports them under "userAttribs",
// which is how a host application rebuilds the material inspector.
// -----------------------------------------------------------------------------
/// Display name of a parameter.
[__AttributeUsage( _AttributeTargets.Var )]
public struct UiLabelAttribute { string text; }
/// Group heading and sort order in the material inspector.
[__AttributeUsage( _AttributeTargets.Var )]
public struct UiGroupAttribute { string group; int order; }
/// Inclusive numeric range of a slider.
[__AttributeUsage( _AttributeTargets.Var )]
public struct UiRangeAttribute { float min; float max; }
/// Which editor to show: slider, color, toggle, dropdown, vector, texture.
[__AttributeUsage( _AttributeTargets.Var )]
public struct UiControlAttribute { string control; }
/// Hover text.
[__AttributeUsage( _AttributeTargets.Var )]
public struct UiTooltipAttribute { string text; }
/// Default value, splatted across the parameter's components.
[__AttributeUsage( _AttributeTargets.Var )]
public struct UiDefaultAttribute { float x; float y; float z; float w; }
/// Default asset path for a texture parameter.
[__AttributeUsage( _AttributeTargets.Var )]
public struct UiAssetAttribute { string path; }
/// Render-attribute name, so a host can push a value without recompiling.
[__AttributeUsage( _AttributeTargets.Var )]
public struct PrismAttributeAttribute { string name; }
/// Non-zero when a texture's contents are sRGB encoded.
[__AttributeUsage( _AttributeTargets.Var )]
public struct PrismSrgbAttribute { int srgb; }
// -----------------------------------------------------------------------------
// 2. Environment
//
// Everything a shader knows about the frame and the object it is drawing.
// A ParameterBlock gets its own descriptor set / register space, so binding it
// once per frame and once per object is the natural split for a host renderer.
// -----------------------------------------------------------------------------
/// Per-frame constants.
public struct PrismFrameParams
{
float4x4 WorldToView;
float4x4 ViewToProjection;
float4x4 WorldToProjection;
float3 CameraPosition;
float CameraNear;
float3 CameraForward;
float CameraFar;
float2 ViewportSize;
float2 ViewportInvSize;
float2 ViewportOffset;
float3 SunDirection;
float3 SunColor;
float Time;
float DeltaTime;
int FrameCount;
}
/// Per-object constants.
public struct PrismObjectParams
{
float4x4 ObjectToWorld;
float4x4 WorldToObject;
float3 ObjectOrigin;
float3 ObjectScale;
float4 TintColor;
}
/// The environment a Prism module is rendered in.
public struct PrismEnvironment
{
PrismFrameParams Frame;
PrismObjectParams Object;
}
/// The one environment binding every generated module reads from.
public ParameterBlock<PrismEnvironment> gPrismEnv;
// -----------------------------------------------------------------------------
// 3. Transforms
// -----------------------------------------------------------------------------
/// Object space to world space, as a position.
public float3 PrismObjectToWorldPoint( float3 positionOs )
{
return mul( gPrismEnv.Object.ObjectToWorld, float4( positionOs, 1.0 ) ).xyz;
}
/// Object space to world space, as a direction. Not normalised; scale is preserved.
public float3 PrismObjectToWorldDirection( float3 directionOs )
{
return mul( gPrismEnv.Object.ObjectToWorld, float4( directionOs, 0.0 ) ).xyz;
}
/// Object space to world space, as a normal. Uses the inverse transpose, so non-uniform scale is safe.
public float3 PrismObjectToWorldNormal( float3 normalOs )
{
return normalize( mul( float4( normalOs, 0.0 ), gPrismEnv.Object.WorldToObject ).xyz );
}
/// World space to object space, as a position.
public float3 PrismWorldToObjectPoint( float3 positionWs )
{
return mul( gPrismEnv.Object.WorldToObject, float4( positionWs, 1.0 ) ).xyz;
}
/// World space to clip space.
public float4 PrismWorldToClip( float3 positionWs )
{
return mul( gPrismEnv.Frame.WorldToProjection, float4( positionWs, 1.0 ) );
}
/// Clip space to a 0..1 screen UV, with the origin in the top left.
public float2 PrismScreenUvFromClip( float4 positionPs )
{
float2 ndc = positionPs.xy / max( abs( positionPs.w ), 1.0e-6 );
return ndc * float2( 0.5, -0.5 ) + 0.5;
}
// -----------------------------------------------------------------------------
// 4. Math
//
// The emitted code prefers these over the raw intrinsics wherever a zero or a
// denormal would otherwise produce a NaN that is invisible until it is not.
// -----------------------------------------------------------------------------
/// Normalise, returning a zero vector instead of a NaN for a zero-length input.
public float3 PrismSafeNormalize( float3 v )
{
float lengthSquared = dot( v, v );
return lengthSquared > 1.0e-12 ? v * rsqrt( lengthSquared ) : float3( 0.0 );
}
/// Normalise a 2D vector, returning zero instead of a NaN for a zero-length input.
public float2 PrismSafeNormalize( float2 v )
{
float lengthSquared = dot( v, v );
return lengthSquared > 1.0e-12 ? v * rsqrt( lengthSquared ) : float2( 0.0 );
}
/// Reciprocal that returns zero rather than an infinity at zero.
public float PrismSafeRcp( float v )
{
return abs( v ) > 1.0e-12 ? 1.0 / v : 0.0;
}
/// Divide, returning zero rather than a NaN or an infinity when the denominator vanishes.
public float3 PrismSafeDivide( float3 a, float3 b )
{
bool3 ok = abs( b ) > float3( 1.0e-12 );
float3 divisor = select( ok, b, float3( 1.0 ) );
return select( ok, a / divisor, float3( 0.0 ) );
}
/// Linear remap from one inclusive range to another. Both ranges are packed as (min, max).
public float PrismRemap( float value, float2 fromRange, float2 toRange )
{
float t = ( value - fromRange.x ) * PrismSafeRcp( fromRange.y - fromRange.x );
return lerp( toRange.x, toRange.y, t );
}
/// Build a tangent-to-world basis from an interpolated normal and tangent.
public float3x3 PrismTangentBasis( float3 normalWs, float3 tangentUWs, float3 tangentVWs )
{
float3 n = PrismSafeNormalize( normalWs );
float3 t = PrismSafeNormalize( tangentUWs - n * dot( n, tangentUWs ) );
float3 b = PrismSafeNormalize( tangentVWs );
return float3x3( t, b, n );
}
// -----------------------------------------------------------------------------
// 5. Textures
//
// GetDimensions writes through out parameters and therefore cannot appear in an
// expression. These wrappers give the graph a value it can feed into math.
// -----------------------------------------------------------------------------
/// Width and height of a 2D texture, in texels.
public float2 PrismTextureSize( Texture2D texture )
{
uint width, height;
texture.GetDimensions( width, height );
return float2( width, height );
}
/// Width, height and slice count of a 2D texture array, in texels.
public float3 PrismTextureSize( Texture2DArray texture )
{
uint width, height, slices;
texture.GetDimensions( width, height, slices );
return float3( width, height, slices );
}
/// Width, height and depth of a 3D texture, in texels.
public float3 PrismTextureSize( Texture3D texture )
{
uint width, height, depth;
texture.GetDimensions( width, height, depth );
return float3( width, height, depth );
}
/// Face width and height of a cube map, in texels.
public float2 PrismTextureSize( TextureCube texture )
{
uint width, height;
texture.GetDimensions( width, height );
return float2( width, height );
}
// -----------------------------------------------------------------------------
// 6. Colour
// -----------------------------------------------------------------------------
/// sRGB to linear, using the exact piecewise transfer function.
public float3 PrismSrgbToLinear( float3 srgb )
{
float3 low = srgb / 12.92;
float3 high = pow( max( ( srgb + 0.055 ) / 1.055, 0.0 ), 2.4 );
return select( srgb <= float3( 0.04045 ), low, high );
}
/// sRGB to linear, leaving alpha alone.
public float4 PrismSrgbToLinear( float4 srgb )
{
return float4( PrismSrgbToLinear( srgb.rgb ), srgb.a );
}
/// Linear to sRGB, using the exact piecewise transfer function.
public float3 PrismLinearToSrgb( float3 linearColor )
{
float3 low = linearColor * 12.92;
float3 high = 1.055 * pow( max( linearColor, 0.0 ), 1.0 / 2.4 ) - 0.055;
return select( linearColor <= float3( 0.0031308 ), low, high );
}
/// Linear to sRGB, leaving alpha alone.
public float4 PrismLinearToSrgb( float4 linearColor )
{
return float4( PrismLinearToSrgb( linearColor.rgb ), linearColor.a );
}
/// Rec. 709 relative luminance of a linear colour.
public float PrismLuminance( float3 linearColor )
{
return dot( linearColor, float3( 0.2126, 0.7152, 0.0722 ) );
}
/// RGB to HSV. Hue is 0..1, not degrees.
public float3 PrismRgbToHsv( float3 rgb )
{
const float4 k = float4( 0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0 );
const float epsilon = 1.0e-10;
float4 p = select( bool4( rgb.g < rgb.b ), float4( rgb.bg, k.wz ), float4( rgb.gb, k.xy ) );
float4 q = select( bool4( rgb.r < p.x ), float4( p.xyw, rgb.r ), float4( rgb.r, p.yzx ) );
float chroma = q.x - min( q.w, q.y );
return float3( abs( q.z + ( q.w - q.y ) / ( 6.0 * chroma + epsilon ) ), chroma / ( q.x + epsilon ), q.x );
}
/// HSV to RGB. Hue is 0..1, not degrees.
public float3 PrismHsvToRgb( float3 hsv )
{
const float4 k = float4( 1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0 );
float3 p = abs( frac( hsv.xxx + k.xyz ) * 6.0 - k.www );
return hsv.z * lerp( k.xxx, saturate( p - k.xxx ), hsv.y );
}
/// Blend two linear colours with the classic overlay operator.
public float3 PrismOverlay( float3 baseColor, float3 blend )
{
float3 low = 2.0 * baseColor * blend;
float3 high = 1.0 - 2.0 * ( 1.0 - baseColor ) * ( 1.0 - blend );
return select( baseColor <= float3( 0.5 ), low, high );
}
// -----------------------------------------------------------------------------
// 7. PrismMaterial
//
// What a surface graph produces. A host renderer reads these fields and runs
// whatever shading model it likes; `ToUnlitColor` is the trivial one.
// -----------------------------------------------------------------------------
/// The surface description a Prism surface graph fills in.
public struct PrismMaterial
{
/// Linear base colour.
float3 Albedo;
/// Coverage. Compared against the alpha-test threshold for a masked material.
float Opacity;
/// Tangent-space normal, with the usual (0, 0, 1) meaning "unperturbed".
float3 Normal;
/// Perceptual roughness, 0 mirror to 1 fully rough.
float Roughness;
/// Metalness, 0 dielectric to 1 conductor.
float Metalness;
/// Baked ambient occlusion.
float AmbientOcclusion;
/// Linear emissive radiance.
float3 Emission;
/// Light transmitted through the surface.
float3 Transmission;
/// Where a per-instance tint applies.
float TintMask;
/// A sensible neutral surface: white, opaque, flat, rough, dielectric.
public static PrismMaterial Init()
{
PrismMaterial m;
m.Albedo = float3( 1.0 );
m.Opacity = 1.0;
m.Normal = float3( 0.0, 0.0, 1.0 );
m.Roughness = 1.0;
m.Metalness = 0.0;
m.AmbientOcclusion = 1.0;
m.Emission = float3( 0.0 );
m.Transmission = float3( 0.0 );
m.TintMask = 1.0;
return m;
}
/// Replace the tangent-space normal. Mutates, so it carries [mutating].
[mutating]
public void SetNormal( float3 tangentSpaceNormal )
{
Normal = PrismSafeNormalize( tangentSpaceNormal );
}
/// Kill the fragment when coverage falls below a threshold. Pixel stage only.
public void AlphaTest( float threshold )
{
if ( Opacity < threshold ) discard;
}
/// The world-space normal implied by this material's tangent-space normal.
public float3 WorldNormal( float3x3 tangentBasis )
{
return PrismSafeNormalize( mul( Normal, tangentBasis ) );
}
/// The unlit resolve: albedo plus emission, with coverage in alpha.
public float4 ToUnlitColor()
{
return float4( Albedo + Emission, Opacity );
}
}
""";
}
Editor
library
namespace Editor.Prism.Compiler;
/// <summary>
/// What a compile is for. The mode changes what the compiler emits, not just where it writes it.
/// </summary>
public enum CompileMode
{
/// <summary>
/// The artifact saved beside the document. Literals are baked, every declared mode and combo is
/// emitted, and no preview instrumentation is added.
/// </summary>
Final,
/// <summary>
/// The live viewport shader. Literals become named uniforms recorded in
/// <c>CompileResult.PreviewAttributes</c>, so dragging a slider updates at frame rate with zero
/// recompiles. The minimum combo set is declared to keep compile latency down.
/// </summary>
Preview,
/// <summary>
/// One shader containing every previewable node's expression behind a stage-id switch, used to
/// render all node thumbnails from a single compile.
/// </summary>
Thumbnail,
/// <summary>
/// Type-check and emit far enough to produce diagnostics, then stop. Used by the debounced
/// validation pass and by the code panel's IR tab.
/// </summary>
SyntaxOnly
}
Editor
library
using Editor.Prism.Compiler.Backends;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
namespace Editor.Prism.Compiler;
/// <summary>
/// A uniform the preview can push straight to the GPU. In <see cref="CompileMode.Preview"/> every
/// literal and every graph parameter becomes one of these, which is why dragging a slider costs zero
/// compiles. Pushed with a dictionary indexer, never <c>Dictionary.Add</c> — the built-in editor's
/// attribute helper throws on a duplicate name.
/// </summary>
public sealed record PreviewAttribute( string Name, ShaderType Type, ConstValue Value )
{
/// <summary>The node whose literal this is, when it came from one.</summary>
public NodeId Node { get; init; }
/// <summary>The port whose literal this is, when it came from one.</summary>
public PortId Port { get; init; }
/// <summary>The blackboard parameter this came from, when it came from one.</summary>
public ParamId Parameter { get; init; }
/// <inheritdoc/>
public override string ToString() => $"{Type.Hlsl} {Name} = {Value}";
}
/// <summary>
/// A texture slot the preview has to fill itself, because nothing bakes it for a shader rendered
/// without a material. See <c>NodeEmitter.PreviewTextureBinding</c> for why this exists.
/// </summary>
/// <param name="Name">The render-attribute name the shader binds the slot to.</param>
/// <param name="Asset">Path of the source image the graph asked for.</param>
/// <param name="Srgb">True when the slot holds sRGB-encoded colour rather than linear data.</param>
public sealed record PreviewTexture( string Name, string Asset, bool Srgb )
{
/// <summary>The blackboard parameter behind the slot, when it came from one.</summary>
public ParamId Parameter { get; init; }
/// <inheritdoc/>
public override string ToString() => $"{Name} = \"{Asset}\"{( Srgb ? " (srgb)" : string.Empty )}";
}
/// <summary>Counters for the status bar and for spotting performance regressions between builds.</summary>
public sealed record CompileStats
{
/// <summary>Nodes visited during emission.</summary>
public int NodeCount { get; init; }
/// <summary>Statements in the emitted module.</summary>
public int StatementCount { get; init; }
/// <summary>Temps bound by the emitter after CSE.</summary>
public int TempCount { get; init; }
/// <summary>Expressions removed by CSE, folding and dead-code elimination.</summary>
public int OptimizedAway { get; init; }
/// <summary>Module-level declarations emitted.</summary>
public int GlobalCount { get; init; }
/// <summary>Interpolators allocated.</summary>
public int VaryingCount { get; init; }
/// <summary>Helper functions emitted.</summary>
public int HelperCount { get; init; }
/// <summary>Milliseconds spent in validation, solving and stage planning.</summary>
public double AnalysisMs { get; init; }
/// <summary>Milliseconds spent building and optimising the IR.</summary>
public double EmitMs { get; init; }
/// <summary>Milliseconds spent in the backends.</summary>
public double BackendMs { get; init; }
/// <summary>Total wall time of the compile.</summary>
public double TotalMs { get; init; }
/// <inheritdoc/>
public override string ToString() =>
$"{NodeCount} nodes, {StatementCount} statements, {TotalMs:0} ms";
}
/// <summary>
/// The result of a compile: one artifact per requested backend, everything that went wrong, the
/// uniforms the preview can push live, and the counters for the status bar.
/// </summary>
public sealed record CompileResult(
bool Ok,
IReadOnlyDictionary<string, BackendEmitResult> Artifacts,
IReadOnlyList<Diagnostic> Diagnostics,
IReadOnlyList<PreviewAttribute> PreviewAttributes,
CompileStats Stats )
{
/// <summary>The IR the artifacts were generated from. Kept so the code panel can print it.</summary>
public IrModule Module { get; init; }
/// <summary>
/// Texture slots the preview must push itself. Empty for every mode but
/// <see cref="CompileMode.Preview"/>, where a shipping <c>CreateInputTexture2D</c> slot would never
/// be filled because nothing compiles a material for the preview.
/// </summary>
public IReadOnlyList<PreviewTexture> PreviewTextures { get; init; } = Array.Empty<PreviewTexture>();
/// <summary>The request this result answers.</summary>
public CompileRequest Request { get; init; }
/// <summary>Number of errors reported.</summary>
public int ErrorCount => Diagnostics?.Count( x => x.Severity == DiagnosticSeverity.Error ) ?? 0;
/// <summary>Number of warnings reported.</summary>
public int WarningCount => Diagnostics?.Count( x => x.Severity == DiagnosticSeverity.Warning ) ?? 0;
/// <summary>The artifact produced by a backend, or null when that backend was not requested.</summary>
public BackendEmitResult Artifact( string backendId ) =>
Artifacts is not null && Artifacts.TryGetValue( backendId, out var result ) ? result : null;
/// <summary>The generated <c>.shader</c> text, when the s&box backend ran.</summary>
public string ShaderText => Artifact( PrismConstants.BackendHlsl )?.Text;
/// <summary>The generated <c>.slang</c> text, when the Slang backend ran.</summary>
public string SlangText => Artifact( PrismConstants.BackendSlang )?.Text;
/// <summary>A failed result carrying only diagnostics.</summary>
public static CompileResult Failed( IReadOnlyList<Diagnostic> diagnostics, CompileRequest request = null ) =>
new( false, new Dictionary<string, BackendEmitResult>(), diagnostics ?? Array.Empty<Diagnostic>(),
Array.Empty<PreviewAttribute>(), new CompileStats() )
{
Request = request
};
/// <inheritdoc/>
public override string ToString() =>
$"{( Ok ? "ok" : "failed" )}: {ErrorCount} errors, {WarningCount} warnings, {Stats}";
}
Editor
library
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
namespace Editor.Prism.Compiler;
/// <summary>
/// The one lowering of an implicit conversion into IR.
/// <para>
/// Two callers need it and used to spell it differently. <c>NodeEmitContext.Coerce</c> lowered a
/// narrowing as a swizzle plus an optional convert; <c>GraphCompiler.Fit</c> lowered the identical
/// conversion as a single <c>CastKind.Truncate</c>, which the HLSL backend only renders as a mask when
/// the scalar kinds already agree and otherwise falls back to a C-style <c>( float3 )v</c>. Both are
/// legal, but two structurally different expressions for one conversion never hash-cons against each
/// other, so CSE missed and the generated text differed between two paths for no reason.
/// </para>
/// <para>
/// This class holds no diagnostics on purpose: reporting a lossy or illegal conversion is the caller's
/// job, because only the caller knows which port to attach it to. An unclassifiable conversion comes
/// back as <see cref="IrValue.Invalid"/>.
/// </para>
/// </summary>
public static class IrConversions
{
/// <summary>
/// Emit the conversion of <paramref name="value"/> to <paramref name="target"/>, or
/// <see cref="IrValue.Invalid"/> when the two types cannot be converted at all.
/// </summary>
/// <param name="builder">The builder to emit into.</param>
/// <param name="value">The value being converted.</param>
/// <param name="target">The type wanted.</param>
/// <param name="fill">What to pad a widening with, or null for <c>TypeRules.DefaultFill</c>.</param>
public static IrValue Emit( IrBuilder builder, IrValue value, ShaderType target, float? fill = null )
{
if ( builder is null || !value.IsValid ) return IrValue.Invalid;
if ( target.IsVoid || value.Type == target ) return value;
var from = value.Type;
switch ( TypeRules.Classify( from, target ) )
{
case ConversionKind.Identity:
return value;
case ConversionKind.Splat:
return builder.Cast( target, value, CastKind.Splat );
case ConversionKind.Widen:
case ConversionKind.IntToFloat:
return builder.Cast( target, value, CastKind.Convert );
case ConversionKind.Pad:
{
var actual = fill ?? TypeRules.DefaultFill( from, target, target.Components - 1 );
// Convert the components before widening, so the pad literal and the existing lanes are
// already the same scalar kind by the time the constructor is printed.
var widened = from.Scalar == target.Scalar
? value
: builder.Cast( from.WithScalar( target.Scalar ), value, CastKind.Convert );
return builder.Cast( target, widened, CastKind.Pad, actual );
}
case ConversionKind.Truncate:
{
var narrowed = value;
if ( from.IsScalarOrVector && target.IsScalarOrVector && from.Components > target.Components )
{
var mask = "xyzw"[..Math.Clamp( target.Components, 1, 4 )];
narrowed = builder.Swizzle( ShaderType.Vec( from.Scalar, target.Components ), value, mask );
}
if ( narrowed.Type == target ) return narrowed;
return builder.Cast( target, narrowed, CastKind.Convert );
}
default:
return IrValue.Invalid;
}
}
}
Editor
library
using Editor.Prism.Core;
using Editor.Prism.Model;
namespace Editor.Prism.Compiler;
/// <summary>
/// The result of running <see cref="TypeSolver"/> over a graph: a concrete <see cref="ShaderType"/>
/// for every port, the conversion each edge performs, and a topological node order the rest of the
/// pipeline can reuse.
/// </summary>
public sealed class TypeSolution
{
internal TypeSolution(
IReadOnlyDictionary<PortRef, ShaderType> types,
IReadOnlyDictionary<EdgeId, ConversionKind> conversions,
IReadOnlyList<NodeId> order,
int unresolved,
bool ok )
{
Types = types;
Conversions = conversions;
TopologicalOrder = order;
UnresolvedCount = unresolved;
Ok = ok;
}
/// <summary>An empty solution, used when there is nothing to solve.</summary>
public static TypeSolution Empty { get; } = new(
new Dictionary<PortRef, ShaderType>(), new Dictionary<EdgeId, ConversionKind>(),
Array.Empty<NodeId>(), 0, true );
/// <summary>True when every port resolved and no unification failed.</summary>
public bool Ok { get; }
/// <summary>How many ports had to fall back to a default type.</summary>
public int UnresolvedCount { get; }
/// <summary>The solved type of every port in the graph.</summary>
public IReadOnlyDictionary<PortRef, ShaderType> Types { get; }
/// <summary>The conversion each edge performs, for wire markers and tooltips.</summary>
public IReadOnlyDictionary<EdgeId, ConversionKind> Conversions { get; }
/// <summary>
/// Producers before consumers. Nodes caught in a cycle are appended at the end in document order,
/// so this is always a total order even for a malformed graph.
/// </summary>
public IReadOnlyList<NodeId> TopologicalOrder { get; }
/// <summary>The solved type of one port, or void when it is not in the solution.</summary>
public ShaderType TypeOf( NodeId node, PortId port ) =>
Types.TryGetValue( new PortRef( node, port ), out var type ) ? type : ShaderType.Void;
/// <summary>The solved type of one port.</summary>
public ShaderType TypeOf( Port port ) =>
port is null ? ShaderType.Void : TypeOf( port.Node?.Id ?? NodeId.None, port.Id );
/// <summary>The conversion an edge performs, or <see cref="ConversionKind.Identity"/> when unknown.</summary>
public ConversionKind ConversionOn( EdgeId edge ) =>
Conversions.TryGetValue( edge, out var kind ) ? kind : ConversionKind.Identity;
}
/// <summary>
/// Hindley–Milner-lite unification over a whole graph.
/// <para>
/// A port's declared type is either a concrete spelling (<c>float3</c>, <c>Texture2D</c>) or a term in
/// a small algebra: <c>T</c> — a variable shared by every port on the node that names it; <c>T.scalar</c>
/// — the component type of <c>T</c>; <c>vecN</c> — a float vector whose width unifies; <c>float{N}</c> —
/// a float vector sharing the width variable <c>N</c>; <c>any</c> — a passthrough that adopts whatever
/// reaches it. Every unrecognised spelling is treated as a fresh variable named after itself, so
/// <c>U</c> and <c>Element</c> work exactly like <c>T</c>.
/// </para>
/// <para>
/// The solver runs forward along the topological order, then backward for anything still open, then
/// defaults what is left to <c>float</c>. Afterwards every <see cref="Port.ResolvedType"/> is concrete
/// and every edge has been classified, which is what lets the IR be built without a single type guess.
/// </para>
/// </summary>
public sealed class TypeSolver
{
const string PassthroughGroup = "passthrough";
readonly IPrismGraph _graph;
readonly DiagnosticSink _diagnostics;
readonly List<Slot> _slots = new();
readonly Dictionary<(NodeId Node, string Name), int> _vars = new();
readonly Dictionary<PortRef, Term> _terms = new();
bool _failed;
/// <summary>Build a solver for one graph.</summary>
public TypeSolver( IPrismGraph graph, DiagnosticSink diagnostics )
{
_graph = graph;
_diagnostics = diagnostics ?? new DiagnosticSink();
}
/// <summary>How many forward/backward sweeps to run before giving up on convergence.</summary>
public int MaxIterations { get; set; } = 8;
/// <summary>Write the solved types back onto <see cref="Port.ResolvedType"/>. On by default.</summary>
public bool ApplyToPorts { get; set; } = true;
/// <summary>Report warnings for lossy and padded edge conversions. On by default.</summary>
public bool ReportConversions { get; set; } = true;
/// <summary>Solve a graph in one call.</summary>
public static TypeSolution Solve( IPrismGraph graph, DiagnosticSink diagnostics ) =>
new TypeSolver( graph, diagnostics ).Solve();
/// <summary>Run the solver.</summary>
public TypeSolution Solve()
{
if ( _graph?.Nodes is null || _graph.Nodes.Count == 0 ) return TypeSolution.Empty;
var order = TopologicalOrder( _graph );
Seed();
var edges = ValidEdges().ToArray();
for ( int pass = 0; pass < Math.Max( 1, MaxIterations ); pass++ )
{
var changed = Forward( order, edges );
changed |= Backward( edges );
if ( !changed ) break;
}
DefaultUnresolved();
var types = new Dictionary<PortRef, ShaderType>();
var unresolved = 0;
foreach ( var node in _graph.Nodes )
{
if ( node is null ) continue;
foreach ( var port in AllPorts( node ) )
{
var key = new PortRef( node.Id, port.Id );
var type = Read( key );
if ( type.IsVoid )
{
type = ShaderType.Float;
unresolved++;
if ( !port.Def.IsGeneric )
{
// A concrete declaration that came back void means the spelling is unparseable.
_diagnostics.Warn( DiagnosticCode.UnresolvedType,
$"Port '{port.DisplayName}' declares an unrecognised type '{port.DeclaredType}'; assuming float",
GraphRef.ForPort( node.Id, port.Id ) );
}
}
types[key] = type;
if ( ApplyToPorts ) port.ResolvedType = type;
}
}
var conversions = ClassifyEdges( edges, types );
return new TypeSolution( types, conversions, order, unresolved, !_failed );
}
/// <summary>
/// Producers before consumers, cycles appended in document order. Kahn's algorithm, so a cyclic
/// graph degrades into a stable-but-arbitrary order instead of hanging.
/// </summary>
public static IReadOnlyList<NodeId> TopologicalOrder( IPrismGraph graph )
{
if ( graph?.Nodes is null ) return Array.Empty<NodeId>();
var indegree = new Dictionary<NodeId, int>();
var successors = new Dictionary<NodeId, List<NodeId>>();
foreach ( var node in graph.Nodes )
{
if ( node is null ) continue;
indegree.TryAdd( node.Id, 0 );
successors.TryAdd( node.Id, new List<NodeId>() );
}
foreach ( var edge in graph.Edges ?? Array.Empty<Edge>() )
{
if ( edge is null || !edge.IsValid ) continue;
if ( !indegree.ContainsKey( edge.FromNode ) || !indegree.ContainsKey( edge.ToNode ) ) continue;
if ( edge.FromNode == edge.ToNode ) continue;
successors[edge.FromNode].Add( edge.ToNode );
indegree[edge.ToNode] = indegree[edge.ToNode] + 1;
}
// Seed in document order so the result is deterministic run to run.
var ready = new List<NodeId>();
foreach ( var node in graph.Nodes )
{
if ( node is null ) continue;
if ( indegree[node.Id] == 0 ) ready.Add( node.Id );
}
var order = new List<NodeId>( indegree.Count );
var cursor = 0;
while ( cursor < ready.Count )
{
var id = ready[cursor++];
order.Add( id );
foreach ( var next in successors[id] )
{
var remaining = indegree[next] - 1;
indegree[next] = remaining;
if ( remaining == 0 ) ready.Add( next );
}
}
if ( order.Count < indegree.Count )
{
var seen = new HashSet<NodeId>( order );
foreach ( var node in graph.Nodes )
{
if ( node is null || seen.Contains( node.Id ) ) continue;
order.Add( node.Id );
}
}
return order;
}
// ---- seeding ----------------------------------------------------------
void Seed()
{
foreach ( var node in _graph.Nodes )
{
if ( node is null ) continue;
foreach ( var port in AllPorts( node ) )
{
_terms[new PortRef( node.Id, port.Id )] = MakeTerm( node.Id, port );
}
}
}
Term MakeTerm( NodeId node, Port port )
{
var declared = port.DeclaredType;
if ( ( port.Flags & PortFlags.Passthrough ) != 0 )
{
return Term.Variable( VarSlot( node, PassthroughGroup ), null );
}
if ( !TypeRules.IsTypeVariable( declared ) && ShaderType.TryParse( declared, out var concrete ) )
{
return Term.Fixed( concrete );
}
var text = ( declared ?? string.Empty ).Trim();
if ( text.Length == 0 || text == TypeRules.TypeVarAny )
{
return Term.Variable( VarSlot( node, PassthroughGroup ), null );
}
if ( text == TypeRules.TypeVarVecN )
{
return Term.Variable( VarSlot( node, TypeRules.TypeVarVecN ), ScalarKind.Float );
}
// "T.scalar" — the component type of another variable on the same node.
var dot = text.IndexOf( '.' );
if ( dot > 0 && text[( dot + 1 )..].Equals( "scalar", StringComparison.OrdinalIgnoreCase ) )
{
return Term.ScalarOf( VarSlot( node, text[..dot] ) );
}
// "float{N}" — a vector of the shared width variable N, with the component kind pinned.
var open = text.IndexOf( '{' );
var close = text.IndexOf( '}' );
if ( open > 0 && close > open + 1 )
{
var prefix = text[..open];
var width = text[( open + 1 )..close];
var scalar = ShaderType.TryParse( prefix, out var prefixType ) && prefixType.IsNumeric
? prefixType.Scalar
: ScalarKind.Float;
return Term.Variable( VarSlot( node, width ), scalar );
}
return Term.Variable( VarSlot( node, text ), null );
}
int VarSlot( NodeId node, string name )
{
var key = (node, name ?? string.Empty);
if ( _vars.TryGetValue( key, out var index ) ) return index;
index = _slots.Count;
_slots.Add( new Slot() );
_vars[key] = index;
return index;
}
// ---- propagation ------------------------------------------------------
bool Forward( IReadOnlyList<NodeId> order, IReadOnlyList<Edge> edges )
{
var incoming = new Dictionary<PortRef, List<Edge>>();
foreach ( var edge in edges )
{
var key = edge.To;
if ( !incoming.TryGetValue( key, out var list ) )
{
list = new List<Edge>();
incoming[key] = list;
}
list.Add( edge );
}
var changed = false;
foreach ( var id in order )
{
var node = _graph.FindNode( id );
if ( node is null ) continue;
foreach ( var input in node.Inputs )
{
var key = new PortRef( id, input.Id );
if ( !incoming.TryGetValue( key, out var sources ) ) continue;
foreach ( var edge in sources )
{
var produced = Read( edge.From );
if ( produced.IsVoid ) continue;
changed |= Constrain( key, produced, GraphRef.ForPort( id, input.Id ), edge );
}
}
}
return changed;
}
bool Backward( IReadOnlyList<Edge> edges )
{
var changed = false;
for ( int i = edges.Count - 1; i >= 0; i-- )
{
var edge = edges[i];
var consumed = Read( edge.To );
if ( consumed.IsVoid ) continue;
if ( !Read( edge.From ).IsVoid ) continue;
changed |= Constrain( edge.From, consumed, GraphRef.ForPort( edge.FromNode, edge.FromPort ), edge );
}
return changed;
}
void DefaultUnresolved()
{
for ( int i = 0; i < _slots.Count; i++ )
{
var root = _slots[i];
if ( root.Type.IsVoid ) root.Type = ShaderType.Float;
}
}
// ---- term access ------------------------------------------------------
ShaderType Read( PortRef port )
{
if ( !_terms.TryGetValue( port, out var term ) ) return ShaderType.Void;
switch ( term.Kind )
{
case TermKind.Fixed:
return term.Concrete;
case TermKind.Variable:
{
var type = _slots[term.Slot].Type;
if ( type.IsVoid ) return ShaderType.Void;
return term.Force.HasValue && type.IsNumeric ? type.WithScalar( term.Force.Value ) : type;
}
case TermKind.ScalarOf:
{
var type = _slots[term.Slot].Type;
return type.IsVoid ? ShaderType.Void : type.ScalarType;
}
default:
return ShaderType.Void;
}
}
bool Constrain( PortRef port, ShaderType incoming, GraphRef where, Edge edge )
{
if ( incoming.IsVoid ) return false;
if ( !_terms.TryGetValue( port, out var term ) ) return false;
switch ( term.Kind )
{
case TermKind.Fixed:
return false;
case TermKind.Variable:
{
var wanted = term.Force.HasValue && incoming.IsNumeric
? incoming.WithScalar( term.Force.Value )
: incoming;
return Bind( term.Slot, wanted, where, edge );
}
case TermKind.ScalarOf:
{
var slot = _slots[term.Slot];
var wanted = slot.Type.IsVoid
? incoming.ScalarType
: slot.Type.WithScalar( TypeRules.PromoteScalar( slot.Type.Scalar, incoming.Scalar ) );
return Bind( term.Slot, wanted, where, edge );
}
default:
return false;
}
}
bool Bind( int slotIndex, ShaderType incoming, GraphRef where, Edge edge )
{
if ( incoming.IsVoid ) return false;
var slot = _slots[slotIndex];
if ( slot.Type.IsVoid )
{
slot.Type = incoming;
return true;
}
if ( slot.Type == incoming ) return false;
if ( !TypeRules.Unify( slot.Type, incoming, out var unified ) )
{
if ( slot.Failed ) return false;
slot.Failed = true;
_failed = true;
var detail = edge is null ? null : $"Connection {edge}";
_diagnostics.Error( DiagnosticCode.UnificationFailure,
$"Cannot reconcile {slot.Type.Hlsl} and {incoming.Hlsl} on the same generic port group",
where, detail );
return false;
}
if ( unified == slot.Type ) return false;
slot.Type = unified;
return true;
}
// ---- edges ------------------------------------------------------------
IEnumerable<Edge> ValidEdges()
{
foreach ( var edge in _graph.Edges ?? Array.Empty<Edge>() )
{
if ( edge is null || !edge.IsValid ) continue;
if ( !_terms.ContainsKey( edge.From ) || !_terms.ContainsKey( edge.To ) ) continue;
yield return edge;
}
}
Dictionary<EdgeId, ConversionKind> ClassifyEdges( IReadOnlyList<Edge> edges,
IReadOnlyDictionary<PortRef, ShaderType> types )
{
var conversions = new Dictionary<EdgeId, ConversionKind>();
foreach ( var edge in edges )
{
if ( !types.TryGetValue( edge.From, out var from ) ) continue;
if ( !types.TryGetValue( edge.To, out var to ) ) continue;
var kind = TypeRules.Classify( from, to );
conversions[edge.Id] = kind;
if ( !ReportConversions ) continue;
var where = new GraphRef( edge.ToNode, edge.ToPort, edge.Id );
switch ( kind )
{
case ConversionKind.Illegal:
_failed = true;
_diagnostics.Error( DiagnosticCode.IllegalConversion,
$"{from.Hlsl} cannot connect to {to.Hlsl}", where,
TypeRules.Describe( from, to, kind ) );
break;
case ConversionKind.Truncate:
_diagnostics.Warn( DiagnosticCode.LossyConversion,
$"{from.Hlsl} narrows to {to.Hlsl}", where,
TypeRules.Describe( from, to, kind ) );
break;
case ConversionKind.Pad:
var fill = edge.Fill ?? TypeRules.DefaultFill( from, to, to.Components - 1 );
_diagnostics.Warn( DiagnosticCode.PaddedConversion,
$"{from.Hlsl} widens to {to.Hlsl}, filling with {fill}", where,
TypeRules.Describe( from, to, kind ) );
break;
}
}
return conversions;
}
static IEnumerable<Port> AllPorts( PrismNode node )
{
foreach ( var input in node.Inputs ) yield return input;
foreach ( var output in node.Outputs ) yield return output;
}
enum TermKind
{
Fixed,
Variable,
ScalarOf
}
readonly struct Term
{
Term( TermKind kind, ShaderType concrete, int slot, ScalarKind? force )
{
Kind = kind;
Concrete = concrete;
Slot = slot;
Force = force;
}
public TermKind Kind { get; }
public ShaderType Concrete { get; }
public int Slot { get; }
public ScalarKind? Force { get; }
public static Term Fixed( ShaderType type ) => new( TermKind.Fixed, type, -1, null );
public static Term Variable( int slot, ScalarKind? force ) => new( TermKind.Variable, ShaderType.Void, slot, force );
public static Term ScalarOf( int slot ) => new( TermKind.ScalarOf, ShaderType.Void, slot, null );
}
/// <summary>
/// One type variable's current binding.
/// <para>
/// There is deliberately no union-find here. Prism's type algebra is per-(node, variable name):
/// <c>VarSlot</c> mints one slot for each and nothing ever merges two, because a constraint that
/// spans nodes is expressed by propagating a concrete type along an edge rather than by equating two
/// variables. The class used to carry a <c>Parent</c> field and a path-compressing <c>Find</c> that
/// could only ever return its own argument — it read as Hindley-Milner and behaved as a lookup, and
/// a later pass adding a real cross-node constraint would have assumed the merging worked. If one is
/// ever needed, add <c>Union</c> and <c>Find</c> together.
/// </para>
/// </summary>
sealed class Slot
{
public ShaderType Type;
public bool Failed;
}
}
Editor
library
using Editor.Prism.Core;
using System.IO;
namespace Editor.Prism.Integration;
/// <summary>
/// Which files Prism's code window is willing to own.
/// <para>
/// Shader sources only. C#, Razor and SCSS belong to a real IDE and Prism never claims them, which is
/// what makes it safe to let Prism act as the editor-wide code editor.
/// </para>
/// </summary>
public static class PrismShaderFiles
{
/// <summary>Extensions, without the leading dot, that Prism opens as shader source.</summary>
public static readonly IReadOnlyList<string> Extensions = new[]
{
PrismConstants.ShaderExtension, // shader — the engine's VFX block format
PrismConstants.HlslExtension, // hlsl
"hlsli",
"fxc",
PrismConstants.SlangExtension, // slang
"slangh",
"vfx"
};
/// <summary>True when Prism's code window is the right place for this path.</summary>
public static bool IsShaderSource( string path )
{
if ( string.IsNullOrWhiteSpace( path ) ) return false;
var extension = Path.GetExtension( path );
if ( string.IsNullOrEmpty( extension ) ) return false;
extension = extension.TrimStart( '.' );
foreach ( var candidate in Extensions )
{
if ( extension.Equals( candidate, StringComparison.OrdinalIgnoreCase ) ) return true;
}
return false;
}
/// <summary>A name filter string suitable for <see cref="FileDialog.SetNameFilter"/>.</summary>
public static string NameFilter =>
"Shader Source (" + string.Join( " ", Extensions.Select( x => $"*.{x}" ) ) + ")";
}
/// <summary>
/// Prism as an editor-wide code editor, offered but never imposed.
/// <para>
/// Any type implementing <c>ICodeEditor</c> is listed in <i>Editor Settings ▸ General ▸ Code Editor</i>
/// automatically, so this shows up as a choice the moment the assembly loads. Selecting it routes
/// shader sources into Prism's code window; everything else — C#, Razor, SCSS, solutions, addons — is
/// handed straight to whichever editor was selected before, so picking Prism never costs you your IDE.
/// </para>
/// </summary>
[Title( "Prism" ), Icon( "gradient" )]
public sealed class PrismCodeEditor : ICodeEditor
{
/// <summary>
/// Always available: it ships inside the editor assembly, so unlike an external IDE there is
/// nothing to find on disk. Note that selecting it only takes over <em>shader</em> sources —
/// everything else is forwarded to <see cref="CodeFileEditor.Fallback"/>, which is why this is not
/// gated on one existing.
/// </summary>
public bool IsInstalled() => true;
/// <summary>Shader sources open in Prism; everything else goes to the fallback editor.</summary>
public void OpenFile( string path, int? line = null, int? column = null )
{
if ( string.IsNullOrWhiteSpace( path ) ) return;
if ( PrismShaderFiles.IsShaderSource( path ) )
{
PrismLauncher.OpenCode( path, line ?? 0, column ?? 1 );
return;
}
var fallback = CodeFileEditor.Fallback;
if ( fallback is null )
{
// Prism is a shader editor; a .cs file has to go somewhere else. Saying so beats a
// double-click that appears to do nothing at all.
PrismLog.Warn( $"Prism cannot open '{Path.GetFileName( path )}' — it edits shader sources " +
"only, and no other code editor is available to hand it to. Pick one in " +
"Editor Settings ▸ Code Editor." );
return;
}
fallback.OpenFile( path, line, column );
}
/// <summary>Prism has no notion of a solution. Delegated.</summary>
public void OpenSolution() => CodeFileEditor.Fallback?.OpenSolution();
/// <summary>Prism has no notion of an addon workspace. Delegated.</summary>
public void OpenAddon( Project addon ) => CodeFileEditor.Fallback?.OpenAddon( addon );
}
/// <summary>
/// Routes shader text files into Prism's code window.
/// <para>
/// Three separate paths reach a text file in this editor, and none of them can be intercepted the
/// same way:
/// </para>
/// <list type="number">
/// <item><description><c>.shader</c> is a native asset type whose <c>OpenInEditor</c> short-circuits
/// to <c>EditorEvent.Run( "open.shader", path )</c> before <c>IAssetEditor</c> is ever consulted, so
/// the only hook is the event — which is multicast and uncancellable, meaning the tools addon still
/// launches VS Code alongside us if it is installed. Hence the preference.</description></item>
/// <item><description><c>.hlsl</c> and <c>.slang</c> cannot be registered as asset types at all; they
/// arrive as plain files through the asset browser's <c>OnFileSelected</c> delegate, which we chain
/// rather than replace.</description></item>
/// <item><description>Anything routed through <c>CodeEditor.OpenFile</c> reaches
/// <see cref="PrismCodeEditor"/>, but only if the user opted in.</description></item>
/// </list>
/// </summary>
public static class CodeFileEditor
{
static ICodeEditor s_fallback;
static Action<string> s_previousFileSelected;
static AssetBrowser s_routedBrowser;
/// <summary>
/// The editor Prism hands non-shader files to. Resolved lazily, cached until hotload, and never
/// resolves to Prism itself.
/// </summary>
public static ICodeEditor Fallback
{
get
{
s_fallback ??= ResolveFallback();
return s_fallback;
}
}
/// <summary>Friendly name of the fallback editor, for the preferences page.</summary>
public static string FallbackTitle =>
PrismLog.Guard( "Describing the fallback code editor",
() => Fallback?.Title, null ) ?? "no external editor";
/// <summary>True when Prism is currently the editor-wide code editor.</summary>
public static bool IsCurrentCodeEditor =>
PrismLog.Guard( "Reading the current code editor",
() => CodeEditor.Current is PrismCodeEditor, false );
// ---- the .shader event ------------------------------------------------
/// <summary>
/// Double-clicking a <c>.shader</c> lands here. Runs early so Prism is up before any external
/// editor steals focus.
/// </summary>
[Event( "open.shader", Priority = -100 )]
public static void OnOpenShader( string absolutePath )
{
if ( !PrismCookies.ClaimShaderFiles ) return;
if ( string.IsNullOrWhiteSpace( absolutePath ) ) return;
PrismLauncher.OpenCode( absolutePath );
}
// ---- asset browser routing --------------------------------------------
/// <summary>
/// Chain ourselves onto the asset browser's plain-file handler, so an unregistered
/// <c>.hlsl</c>/<c>.slang</c> opens in Prism instead of the operating system's shell handler.
/// <para>
/// Idempotent, and safe to call every frame: it re-installs if the browser is recreated or if
/// something else has overwritten the delegate since.
/// </para>
/// </summary>
public static void EnsureAssetBrowserRouting()
{
PrismLog.Guard( "Routing plain files through Prism", () =>
{
var local = MainAssetBrowser.Instance?.Local;
if ( local is null || !local.IsValid ) return;
if ( ReferenceEquals( s_routedBrowser, local ) && IsOurs( local.OnFileSelected ) ) return;
var previous = local.OnFileSelected;
// Never chain to ourselves — after a hotload the delegate sitting there is our own
// handler from the outgoing assembly, and chaining would grow a new link every reload.
s_previousFileSelected = IsOurs( previous ) ? null : previous;
s_routedBrowser = local;
local.OnFileSelected = OnFileSelected;
} );
}
/// <summary>Hand the plain-file handler back to whoever had it. Called when the preference goes off.</summary>
public static void RemoveAssetBrowserRouting()
{
PrismLog.Guard( "Restoring the asset browser file handler", () =>
{
var local = MainAssetBrowser.Instance?.Local;
if ( local is null || !local.IsValid ) return;
if ( !IsOurs( local.OnFileSelected ) ) return;
local.OnFileSelected = s_previousFileSelected ?? ( f => EditorUtility.OpenFile( f ) );
s_routedBrowser = null;
s_previousFileSelected = null;
} );
}
static void OnFileSelected( string absolutePath )
{
if ( PrismCookies.ClaimShaderFiles && PrismShaderFiles.IsShaderSource( absolutePath ) )
{
PrismLauncher.OpenCode( absolutePath );
return;
}
if ( PrismAssetEditor.IsPrismDocument( absolutePath ) )
{
PrismAssetEditor.Open( absolutePath );
return;
}
if ( s_previousFileSelected is not null )
{
s_previousFileSelected( absolutePath );
return;
}
// Same behaviour MainAssetBrowser installs by default.
PrismLog.Guard( "Opening a file with the shell handler", () => EditorUtility.OpenFile( absolutePath ) );
}
/// <summary>
/// A delegate is one of ours when it was declared on this type — compared by full name, so it
/// still matches an instance left behind by the previous assembly.
/// </summary>
static bool IsOurs( Action<string> handler )
{
var declaring = handler?.Method?.DeclaringType;
return declaring is not null
&& string.Equals( declaring.FullName, typeof( CodeFileEditor ).FullName, StringComparison.Ordinal );
}
// ---- the editor-wide code editor preference ---------------------------
static bool s_reconciling;
/// <summary>
/// Startup reconciliation.
/// <para>
/// If the user picked Prism directly in <i>Editor Settings ▸ Code Editor</i>, that choice wins and
/// the preference is updated to match — reverting it would be the tool arguing with the person
/// using it. Otherwise the preference is applied.
/// </para>
/// </summary>
public static void ApplyCodeEditorPreference()
{
PrismLog.Guard( "Applying the Prism code editor preference", () =>
{
// Read the raw cookie rather than CodeEditor.Current: the getter instantiates the selected
// editor and probes the filesystem and registry for it, and no editor session should pay
// that at startup just because Prism happens to be installed.
var selected = EditorCookie?.GetString( CodeEditorCookie, null );
if ( string.Equals( selected, nameof( PrismCodeEditor ), StringComparison.Ordinal ) )
{
if ( !PrismCookies.RouteCodeFiles ) PrismCookies.RouteCodeFiles = true;
return;
}
if ( !PrismCookies.RouteCodeFiles ) return;
ReconcileCodeEditorPreference();
} );
}
/// <summary>
/// The engine's own key for the selected code editor. Hard-coded in <c>CodeEditor.Current</c>, and
/// stored as the implementing type's short name.
/// </summary>
const string CodeEditorCookie = "CodeEditor";
/// <summary>
/// Make <c>CodeEditor.Current</c> agree with <see cref="PrismCookies.RouteCodeFiles"/>.
/// <para>
/// Turning it on remembers whatever was selected before, so turning it off puts that back rather
/// than leaving the editor with no code editor at all. Subscribed to
/// <see cref="PrismCookies.Changed"/>, so flipping the toggle in the preferences page takes effect
/// immediately.
/// </para>
/// </summary>
public static void ReconcileCodeEditorPreference()
{
if ( s_reconciling ) return;
s_reconciling = true;
try
{
PrismLog.Guard( "Reconciling the Prism code editor preference", () =>
{
var current = CodeEditor.Current;
var wanted = PrismCookies.RouteCodeFiles;
if ( wanted )
{
// Compared by full name, not with `is`. CodeEditor.Current is cached in a private
// static on Sandbox.Tools, which does not hotload, so after the editor assembly is
// swapped that field still holds a PrismCodeEditor from the OUTGOING assembly — a
// different Type identity, so `is` says false. The old code then recorded
// "PrismCodeEditor" as the user's fallback IDE, permanently, and ResolveFallback
// excludes PrismCodeEditor by type, so the remembered name could never match again
// and the user silently got whichever of VisualStudio/VSCode/Rider probed first.
if ( IsPrism( current ) ) return;
if ( current is not null )
{
PrismCookies.FallbackCodeEditor = current.GetType().Name;
s_fallback = current;
}
CodeEditor.Current = new PrismCodeEditor();
return;
}
if ( !IsPrism( current ) ) return;
var restored = Fallback;
if ( restored is not null ) CodeEditor.Current = restored;
} );
}
finally
{
s_reconciling = false;
}
}
/// <summary>
/// Whether an <c>ICodeEditor</c> is Prism's, judged by full type name rather than by type identity.
/// A hotload leaves an instance of the outgoing assembly's <c>PrismCodeEditor</c> in a static that
/// does not hotload, and that instance fails <c>is PrismCodeEditor</c> against the new type.
/// </summary>
static bool IsPrism( ICodeEditor editor ) =>
editor is not null &&
string.Equals( editor.GetType().FullName, typeof( PrismCodeEditor ).FullName, StringComparison.Ordinal );
/// <summary>Drop the cached fallback so it is resolved again after a hotload or a settings change.</summary>
public static void FlushFallback()
{
s_fallback = null;
}
/// <summary>Forget the chained delegate — it points into the outgoing assembly after a hotload.</summary>
public static void ForgetRouting()
{
s_previousFileSelected = null;
s_routedBrowser = null;
}
static ICodeEditor ResolveFallback()
{
return PrismLog.Guard( "Resolving the fallback code editor", () =>
{
var types = EditorTypeLibrary.GetTypes<ICodeEditor>()
.Where( x => !x.IsInterface && !x.IsAbstract )
.Where( x => x.TargetType != typeof( PrismCodeEditor ) )
.ToList();
ICodeEditor Instantiate( TypeDescription type )
{
var editor = type?.Create<ICodeEditor>();
return editor is not null && editor.IsInstalled() ? editor : null;
}
var remembered = PrismCookies.FallbackCodeEditor;
if ( !string.IsNullOrWhiteSpace( remembered ) )
{
var match = Instantiate( types.FirstOrDefault( x => x.Name == remembered ) );
if ( match is not null ) return match;
}
foreach ( var preferred in new[] { "VisualStudio", "VisualStudioCode", "Rider" } )
{
var match = Instantiate( types.FirstOrDefault( x => x.Name == preferred ) );
if ( match is not null ) return match;
}
foreach ( var type in types )
{
var match = Instantiate( type );
if ( match is not null ) return match;
}
return null;
}, null );
}
}
Editor
library
using Editor.Prism.Core;
namespace Editor.Prism.Model;
/// <summary>
/// Read-only analysis of a document: reachability, topological order, real cycle detection,
/// dependency subtrees and orphan detection.
/// <para>
/// Every traversal here is iterative rather than recursive, so a pathological graph produces a
/// diagnostic instead of a stack overflow, and <b>every traversal includes reroute nodes</b>. The
/// built-in editor exempts reroutes from its cycle check, which is why a reroute loop can hang it;
/// treating every node identically is both simpler and correct.
/// </para>
/// </summary>
public static class GraphQueries
{
/// <summary>The node a port reference points at. Null when it does not resolve.</summary>
public static PrismNode NodeOf( IPrismGraph graph, PortRef reference ) => graph?.FindNode( reference.Node );
/// <summary>Resolve a port reference to a live port.</summary>
public static bool TryGetPort( IPrismGraph graph, PortRef reference, out Port port )
{
port = graph?.FindNode( reference.Node )?.FindPort( reference.Port );
return port is not null;
}
/// <summary>Every edge terminating on a node.</summary>
public static IEnumerable<Edge> IncomingEdges( IPrismGraph graph, NodeId node )
{
if ( graph?.Edges is null ) yield break;
foreach ( var edge in graph.Edges )
{
if ( edge is not null && edge.ToNode == node ) yield return edge;
}
}
/// <summary>Every edge leaving a node.</summary>
public static IEnumerable<Edge> OutgoingEdges( IPrismGraph graph, NodeId node )
{
if ( graph?.Edges is null ) yield break;
foreach ( var edge in graph.Edges )
{
if ( edge is not null && edge.FromNode == node ) yield return edge;
}
}
/// <summary>Every node that directly feeds this one.</summary>
public static IEnumerable<NodeId> Predecessors( IPrismGraph graph, NodeId node ) =>
IncomingEdges( graph, node ).Select( x => x.FromNode ).Distinct();
/// <summary>Every node this one directly feeds.</summary>
public static IEnumerable<NodeId> Successors( IPrismGraph graph, NodeId node ) =>
OutgoingEdges( graph, node ).Select( x => x.ToNode ).Distinct();
/// <summary>
/// The nodes a compile starts from: registered output nodes when there are any, otherwise every
/// node with no outgoing edge. The fallback is what makes a half-built graph still previewable.
/// </summary>
public static IReadOnlyList<NodeId> OutputNodes( IPrismGraph graph )
{
if ( graph?.Nodes is null ) return Array.Empty<NodeId>();
var outputs = new List<NodeId>();
foreach ( var node in graph.Nodes )
{
if ( node is null ) continue;
if ( !IsOutputNode( node ) ) continue;
outputs.Add( node.Id );
}
if ( outputs.Count > 0 ) return outputs;
return TerminalNodes( graph );
}
/// <summary>True when a node looks like a graph terminal: an output-category node with no outputs.</summary>
public static bool IsOutputNode( PrismNode node )
{
if ( node is null ) return false;
if ( node.Outputs.Count > 0 ) return false;
var id = node.Descriptor?.Id;
if ( !string.IsNullOrEmpty( id ) && id.StartsWith( "prism.output.", StringComparison.OrdinalIgnoreCase ) )
{
return true;
}
var category = node.Descriptor?.Category;
return !string.IsNullOrEmpty( category ) &&
category.StartsWith( "Output", StringComparison.OrdinalIgnoreCase );
}
/// <summary>Every node with no outgoing edge.</summary>
public static IReadOnlyList<NodeId> TerminalNodes( IPrismGraph graph )
{
if ( graph?.Nodes is null ) return Array.Empty<NodeId>();
var hasOutgoing = new HashSet<NodeId>();
foreach ( var edge in graph.Edges ?? Array.Empty<Edge>() )
{
if ( edge is not null ) hasOutgoing.Add( edge.FromNode );
}
var result = new List<NodeId>();
foreach ( var node in graph.Nodes )
{
if ( node is null || hasOutgoing.Contains( node.Id ) ) continue;
result.Add( node.Id );
}
return result;
}
/// <summary>
/// Every node reachable by walking <em>backwards</em> from the given roots — that is, everything
/// that contributes to the roots' values. Disabled nodes stop the walk, because a disabled node
/// falls back to inline values and its inputs are not evaluated.
/// </summary>
public static IReadOnlyCollection<NodeId> Reachable( IPrismGraph graph, IEnumerable<NodeId> roots,
bool stopAtDisabled = true )
{
var visited = new HashSet<NodeId>();
if ( graph is null || roots is null ) return visited;
var stack = new Stack<NodeId>();
foreach ( var root in roots )
{
if ( root.IsValid && visited.Add( root ) ) stack.Push( root );
}
var incoming = BuildIncomingMap( graph );
while ( stack.Count > 0 )
{
var current = stack.Pop();
if ( stopAtDisabled && IsDisabled( graph, current ) ) continue;
if ( !incoming.TryGetValue( current, out var sources ) ) continue;
foreach ( var source in sources )
{
if ( visited.Add( source ) ) stack.Push( source );
}
}
return visited;
}
/// <summary>Every node reachable backwards from the graph's output nodes.</summary>
public static IReadOnlyCollection<NodeId> ReachableFromOutputs( IPrismGraph graph ) =>
Reachable( graph, OutputNodes( graph ) );
/// <summary>
/// Nodes that contribute to nothing: not reachable backwards from any output and not an output
/// themselves. Purely informational — an orphan is a perfectly legal work in progress.
/// </summary>
public static IReadOnlyList<NodeId> Orphans( IPrismGraph graph, IEnumerable<NodeId> roots = null )
{
if ( graph?.Nodes is null ) return Array.Empty<NodeId>();
var reachable = Reachable( graph, roots ?? OutputNodes( graph ), false );
var result = new List<NodeId>();
foreach ( var node in graph.Nodes )
{
if ( node is null || reachable.Contains( node.Id ) ) continue;
result.Add( node.Id );
}
return result;
}
/// <summary>
/// Every node the given node depends on, including itself, in dependency-first order. This is the
/// subtree a "compile just this node" preview needs.
/// </summary>
public static IReadOnlyList<NodeId> DependencySubtree( IPrismGraph graph, NodeId node )
{
var subtree = Reachable( graph, new[] { node }, false );
return TopologicalOrder( graph, new[] { node } ).Where( subtree.Contains ).ToArray();
}
/// <summary>Every node that depends, directly or transitively, on the given node.</summary>
public static IReadOnlyList<NodeId> Dependents( IPrismGraph graph, NodeId node )
{
var visited = new HashSet<NodeId>();
if ( graph is null || !node.IsValid ) return Array.Empty<NodeId>();
var outgoing = BuildOutgoingMap( graph );
var stack = new Stack<NodeId>();
stack.Push( node );
while ( stack.Count > 0 )
{
var current = stack.Pop();
if ( !outgoing.TryGetValue( current, out var targets ) ) continue;
foreach ( var target in targets )
{
if ( visited.Add( target ) ) stack.Push( target );
}
}
return visited.ToArray();
}
/// <summary>
/// Dependency-first order over the nodes reachable from <paramref name="roots"/>, or over the whole
/// document when roots are omitted. Nodes involved in a cycle are appended at the end rather than
/// dropped, so a cyclic graph still produces a usable ordering for the UI.
/// </summary>
public static IReadOnlyList<NodeId> TopologicalOrder( IPrismGraph graph, IEnumerable<NodeId> roots = null )
{
if ( graph?.Nodes is null ) return Array.Empty<NodeId>();
var scope = roots is null
? new HashSet<NodeId>( graph.Nodes.Where( x => x is not null ).Select( x => x.Id ) )
: new HashSet<NodeId>( Reachable( graph, roots, false ) );
if ( scope.Count == 0 ) return Array.Empty<NodeId>();
var incoming = BuildIncomingMap( graph );
var order = new List<NodeId>( scope.Count );
var state = new Dictionary<NodeId, byte>( scope.Count );
// Iterative post-order DFS. 0 = unvisited, 1 = on the stack (grey), 2 = emitted (black).
var work = new Stack<(NodeId Node, int Index)>();
foreach ( var root in Ordered( graph, scope ) )
{
if ( state.TryGetValue( root, out var seen ) && seen == 2 ) continue;
work.Push( (root, 0) );
state[root] = 1;
while ( work.Count > 0 )
{
var (node, index) = work.Pop();
var sources = incoming.TryGetValue( node, out var list ) ? list : s_noIds;
if ( index < sources.Count )
{
work.Push( (node, index + 1) );
var source = sources[index];
if ( !scope.Contains( source ) ) continue;
state.TryGetValue( source, out var sourceState );
if ( sourceState == 0 )
{
state[source] = 1;
work.Push( (source, 0) );
}
continue;
}
state[node] = 2;
order.Add( node );
}
}
// Anything still grey belongs to a cycle: emit it so callers see every node exactly once.
foreach ( var node in Ordered( graph, scope ) )
{
if ( state.TryGetValue( node, out var seen ) && seen == 2 ) continue;
order.Add( node );
state[node] = 2;
}
return order;
}
/// <summary>
/// Find one cycle, reporting the full path in traversal order. Reroutes participate exactly like
/// any other node. Returns false when the document is acyclic.
/// </summary>
public static bool TryFindCycle( IPrismGraph graph, out IReadOnlyList<NodeId> cycle )
{
var cycles = FindCycles( graph, 1 );
cycle = cycles.Count > 0 ? cycles[0] : Array.Empty<NodeId>();
return cycles.Count > 0;
}
/// <summary>
/// Find up to <paramref name="limit"/> distinct cycles, each reported as the full node path with
/// the entry node repeated at the end so the loop reads naturally in a diagnostic.
/// </summary>
public static IReadOnlyList<IReadOnlyList<NodeId>> FindCycles( IPrismGraph graph, int limit = 8 )
{
var found = new List<IReadOnlyList<NodeId>>();
if ( graph?.Nodes is null ) return found;
var outgoing = BuildOutgoingMap( graph );
var state = new Dictionary<NodeId, byte>();
var path = new List<NodeId>();
var onPath = new HashSet<NodeId>();
var seenCycles = new HashSet<string>();
foreach ( var start in graph.Nodes.Where( x => x is not null ).Select( x => x.Id ) )
{
if ( state.TryGetValue( start, out var seen ) && seen == 2 ) continue;
if ( found.Count >= limit ) break;
var work = new Stack<(NodeId Node, int Index)>();
work.Push( (start, 0) );
while ( work.Count > 0 )
{
var (node, index) = work.Pop();
if ( index == 0 )
{
state[node] = 1;
path.Add( node );
onPath.Add( node );
}
var targets = outgoing.TryGetValue( node, out var list ) ? list : s_noIds;
if ( index < targets.Count )
{
work.Push( (node, index + 1) );
var next = targets[index];
if ( onPath.Contains( next ) )
{
var at = path.LastIndexOf( next );
if ( at >= 0 && found.Count < limit )
{
var loop = new List<NodeId>( path.Count - at + 1 );
for ( int i = at; i < path.Count; i++ )
{
loop.Add( path[i] );
}
loop.Add( next );
var key = string.Join( ">", loop.Select( x => x.Value ).OrderBy( x => x, StringComparer.Ordinal ) );
if ( seenCycles.Add( key ) ) found.Add( loop );
}
continue;
}
state.TryGetValue( next, out var nextState );
if ( nextState == 0 ) work.Push( (next, 0) );
continue;
}
state[node] = 2;
onPath.Remove( node );
if ( path.Count > 0 && path[^1] == node ) path.RemoveAt( path.Count - 1 );
}
path.Clear();
onPath.Clear();
}
return found;
}
/// <summary>
/// Would adding this connection close a loop? Answered without mutating the document, so the plug
/// setter can refuse a drop before anything changes.
/// </summary>
public static bool WouldCreateCycle( IPrismGraph graph, PortRef from, PortRef to )
{
if ( graph is null ) return false;
if ( !from.IsValid || !to.IsValid ) return false;
if ( from.Node == to.Node ) return true;
// The new edge runs from.Node -> to.Node. It closes a loop when from.Node is already
// reachable downstream of to.Node.
var outgoing = BuildOutgoingMap( graph );
var visited = new HashSet<NodeId> { to.Node };
var stack = new Stack<NodeId>();
stack.Push( to.Node );
while ( stack.Count > 0 )
{
var current = stack.Pop();
if ( current == from.Node ) return true;
if ( !outgoing.TryGetValue( current, out var targets ) ) continue;
foreach ( var target in targets )
{
if ( visited.Add( target ) ) stack.Push( target );
}
}
return false;
}
/// <summary>
/// How many nodes of a cycle path are named before the description gives up and counts the rest.
/// A cycle through a thousand nodes is not more informative than a cycle through twenty, and the
/// text ends up in a diagnostic detail body, a tooltip and a log line.
/// </summary>
public const int MaxDescribedCycleNodes = 24;
/// <summary>
/// Render a cycle path as <c>Title #id → Title #id → …</c> for a diagnostic detail body. Long cycles
/// are elided in the middle: the two ends are what identifies the loop, and the length is stated.
/// </summary>
public static string DescribeCycle( IPrismGraph graph, IReadOnlyList<NodeId> cycle )
{
if ( cycle is null || cycle.Count == 0 ) return string.Empty;
string Name( NodeId id )
{
var node = graph?.FindNode( id );
var title = node switch
{
UnknownNode unknown => unknown.DisplayTitle,
null => "<missing>",
_ => node.Descriptor?.Title ?? node.GetType().Name
};
return $"{title} #{id}";
}
if ( cycle.Count <= MaxDescribedCycleNodes )
{
return string.Join( " → ", cycle.Select( Name ) );
}
var head = MaxDescribedCycleNodes / 2;
var tail = MaxDescribedCycleNodes - head;
var parts = cycle.Take( head ).Select( Name ).ToList();
parts.Add( $"… {cycle.Count - MaxDescribedCycleNodes} more …" );
parts.AddRange( cycle.Skip( cycle.Count - tail ).Select( Name ) );
return string.Join( " → ", parts );
}
/// <summary>
/// The static checks that do not need the type solver: cycles, dangling edges, missing required
/// inputs, unresolved parameter references and a missing output node. Never throws; a node whose
/// <c>OnValidate</c> misbehaves is isolated and reported.
/// </summary>
public static IReadOnlyList<Diagnostic> Validate( IPrismGraph graph, DiagnosticSink sink = null )
{
var target = sink ?? new DiagnosticSink();
if ( graph is null ) return target.All;
foreach ( var cycle in FindCycles( graph ) )
{
target.Error( DiagnosticCode.Cycle, "This graph contains a cycle",
GraphRef.ForNode( cycle.Count > 0 ? cycle[0] : NodeId.None ), DescribeCycle( graph, cycle ) );
}
foreach ( var edge in graph.Edges ?? Array.Empty<Edge>() )
{
if ( edge is null ) continue;
if ( graph.FindNode( edge.FromNode ) is null || graph.FindNode( edge.ToNode ) is null )
{
target.Error( DiagnosticCode.DanglingEdge, "Connection references a node that does not exist",
GraphRef.ForEdge( edge.Id ), edge.ToString() );
}
}
foreach ( var node in graph.Nodes ?? Array.Empty<PrismNode>() )
{
if ( node is null ) continue;
foreach ( var input in node.Inputs )
{
if ( !input.Required ) continue;
if ( input.IsConnected ) continue;
if ( input.InlineValue is not null ) continue;
target.Error( DiagnosticCode.MissingInput,
$"'{input.DisplayName}' is required and has nothing connected",
GraphRef.ForPort( node.Id, input.Id ) );
}
var scoped = target.Scoped( GraphRef.ForNode( node.Id ) );
PrismLog.Try( $"Validate node {node.Id}",
() => node.OnValidate( new ValidationContext( node, graph, scoped ) ),
target, DiagnosticCode.NodeEmitFailed, GraphRef.ForNode( node.Id ) );
}
if ( OutputNodes( graph ).Count == 0 )
{
target.Error( DiagnosticCode.NoOutput, "This graph has no output node" );
}
return target.All;
}
static bool IsDisabled( IPrismGraph graph, NodeId id ) =>
graph?.FindNode( id ) is { } node && ( node.Flags & NodeFlags.Disabled ) != 0;
static IEnumerable<NodeId> Ordered( IPrismGraph graph, HashSet<NodeId> scope )
{
// Iterate in document order so the result is stable between runs, which is what makes
// regenerated shader text byte-identical for an unchanged graph.
foreach ( var node in graph.Nodes ?? Array.Empty<PrismNode>() )
{
if ( node is null || !scope.Contains( node.Id ) ) continue;
yield return node.Id;
}
}
static Dictionary<NodeId, List<NodeId>> BuildIncomingMap( IPrismGraph graph )
{
var map = new Dictionary<NodeId, List<NodeId>>();
foreach ( var edge in graph.Edges ?? Array.Empty<Edge>() )
{
if ( edge is null ) continue;
if ( !map.TryGetValue( edge.ToNode, out var list ) )
{
list = new List<NodeId>();
map[edge.ToNode] = list;
}
if ( !list.Contains( edge.FromNode ) ) list.Add( edge.FromNode );
}
return map;
}
static Dictionary<NodeId, List<NodeId>> BuildOutgoingMap( IPrismGraph graph )
{
var map = new Dictionary<NodeId, List<NodeId>>();
foreach ( var edge in graph.Edges ?? Array.Empty<Edge>() )
{
if ( edge is null ) continue;
if ( !map.TryGetValue( edge.FromNode, out var list ) )
{
list = new List<NodeId>();
map[edge.FromNode] = list;
}
if ( !list.Contains( edge.ToNode ) ) list.Add( edge.ToNode );
}
return map;
}
static readonly List<NodeId> s_noIds = new();
}
Editor
library
using Editor.Prism.Compiler;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Nodes;
using Editor.Prism.Serialization;
using Editor.Prism.Text;
using Editor.Prism.Toolchain;
namespace Editor.Prism;
/// <summary>
/// The one place every static cache in Prism is dropped when the editor hotloads this assembly.
/// <para>
/// Almost every package caches something keyed by <see cref="Type"/>, <c>PropertyInfo</c> or a live
/// instance — the node registry, the port and property reflection tables, the backend list, the
/// subgraph document cache, the legacy import table. Every one of those holds the outgoing assembly
/// alive and hands out stale metadata after a reload, so they are all flushed together here rather
/// than each package hoping someone else remembered.
/// </para>
/// <para>
/// Order matters: the registry is flushed last because flushing it re-registers the descriptor
/// provider, and nothing should be able to rebuild the catalogue from half-cleared tables.
/// </para>
/// </summary>
public static class PrismHotload
{
/// <summary>Raised after every cache has been dropped, so a window can rebuild whatever it holds.</summary>
public static event Action Flushed;
/// <summary>Drop every static cache in Prism. Safe to call at any time; never throws.</summary>
public static void FlushAll()
{
// First, because a compile in flight is holding a graph, a backend and a pile of callbacks that
// are all about to be replaced underneath it. Nothing below is safe while one is running.
PrismLog.Guard( "Cancel compiles in flight", () => ShaderCompileService.CancelAll() );
PrismLog.Guard( "Flush subgraph documents", SubgraphLibrary.Flush );
PrismLog.Guard( "Flush compiler backends", GraphCompiler.FlushBackends );
PrismLog.Guard( "Flush the legacy import table", LegacyShaderGraphImporter.Reset );
// Migration steps are delegates, so an outgoing assembly's upgraders would otherwise stay
// registered and run against documents loaded by the new one. Anything that registers steps must
// do so again from <see cref="Flushed"/>, which is raised at the end of this method.
//
// Both registries, not just the per-node one: a document-level upgrader is the same delegate held
// the same way, and it runs on the path that turns an older file into the current schema — the one
// place a stale function body would silently rewrite somebody's document.
PrismLog.Guard( "Flush node migrations", NodeMigrations.Reset );
PrismLog.Guard( "Flush schema migrations", SchemaMigrations.Reset );
PrismLog.Guard( "Flush port reflection", PortBuilder.FlushCache );
PrismLog.Guard( "Flush node properties", NodeProperties.Flush );
// The lexers, the language databases, the include resolver and the header symbol tables. A
// hotload that skipped these would leave every open document lexing against word tables and
// delegate-backed lazies belonging to the assembly that just went away.
PrismLog.Guard( "Flush the text editor caches", TextCaches.Flush );
PrismLog.Guard( "Flush the node registry", NodeRegistry.Flush );
PrismLog.Guard( "Raising PrismHotload.Flushed", () => Flushed?.Invoke() );
}
/// <summary>Drop every cache when the editor reloads this assembly.</summary>
[EditorEvent.Hotload]
static void OnHotload() => FlushAll();
}
Editor
library
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using System.Globalization;
namespace Editor.Prism.Serialization;
/// <summary>
/// A texture reference as it appears in a document: an asset path plus the import intent that decides
/// how the sampler is generated. Stored as an object rather than a bare string so colour space and
/// processor survive a round-trip.
/// </summary>
public sealed record TextureValue
{
/// <summary>Relative asset path, e.g. <c>materials/dev/white_color.tga</c>.</summary>
public string Path { get; init; }
/// <summary>How the texture is read: <c>Srgb</c> or <c>Linear</c>.</summary>
public string ColorSpace { get; init; } = "Srgb";
/// <summary>Import processor name, e.g. <c>None</c>, <c>NormalizeNormals</c>.</summary>
public string Processor { get; init; } = "None";
/// <summary>True when no asset is referenced.</summary>
public bool IsEmpty => string.IsNullOrWhiteSpace( Path );
/// <summary>True when the texture should be sampled through an sRGB view.</summary>
public bool IsSrgb => string.Equals( ColorSpace, "Srgb", StringComparison.OrdinalIgnoreCase );
/// <summary>Emit the document shape: <c>{ path, colorSpace, processor }</c>.</summary>
public JsonObject ToJson()
{
var json = new JsonObject { ["path"] = Path };
if ( !string.IsNullOrEmpty( ColorSpace ) && ColorSpace != "Srgb" ) json["colorSpace"] = ColorSpace;
if ( !string.IsNullOrEmpty( Processor ) && Processor != "None" ) json["processor"] = Processor;
return json;
}
/// <summary>Read the document shape. A bare string is accepted as a path-only descriptor.</summary>
public static TextureValue From( JsonNode node )
{
if ( node is null ) return null;
if ( node is JsonValue value && value.TryGetValue<string>( out var path ) )
{
return new TextureValue { Path = path };
}
if ( node is not JsonObject obj ) return null;
return new TextureValue
{
Path = ValueCodec.StringOf( obj["path"] ),
ColorSpace = ValueCodec.StringOf( obj["colorSpace"] ) ?? "Srgb",
Processor = ValueCodec.StringOf( obj["processor"] ) ?? "None"
};
}
/// <inheritdoc/>
public override string ToString() => IsEmpty ? "(no texture)" : Path;
}
/// <summary>
/// Typed literal encoding: the bridge between the boxed values the model stores in port inline slots
/// and parameter defaults, and the JSON a document holds.
/// <para>
/// Every float goes out through <see cref="Number(float)"/>, which formats with <c>"R"</c> so a value
/// read back is bit-identical to the one written. That is what makes "save an unchanged graph and get
/// a byte-identical file" true, which in turn is what makes the text-diff short-circuit before a
/// recompile trustworthy.
/// </para>
/// </summary>
public static class ValueCodec
{
// ---------------------------------------------------------------- writing ----
/// <summary>
/// Encode a boxed value using the shape implied by its CLR type. Colours become <c>"r,g,b,a"</c>,
/// vectors become arrays, enums become their declared names, textures become objects.
/// </summary>
public static JsonNode Write( object value )
{
switch ( value )
{
case null:
return null;
case bool b:
return JsonValue.Create( b );
case int i:
return JsonValue.Create( i );
case uint u:
return JsonValue.Create( u );
case long l:
return JsonValue.Create( l );
case float f:
return Number( f );
case double d:
return Number( (float)d );
case string s:
return JsonValue.Create( s );
case Color c:
return JsonValue.Create( FormatColor( c ) );
case Vector2 v2:
return new JsonArray( Number( v2.x ), Number( v2.y ) );
case Vector3 v3:
return new JsonArray( Number( v3.x ), Number( v3.y ), Number( v3.z ) );
case Vector4 v4:
return new JsonArray( Number( v4.x ), Number( v4.y ), Number( v4.z ), Number( v4.w ) );
case TextureValue texture:
return texture.ToJson();
case Enum e:
return JsonValue.Create( e.ToString() );
case JsonNode json:
return json.DeepClone();
case float[] array:
return Vector( array );
default:
return JsonValue.Create( PrismLog.Guard( "encode value", () => value.ToString(), string.Empty ) );
}
}
/// <summary>
/// Encode a boxed value for a known shader type, coercing it into that type's canonical shape
/// first. This is the form used for port inline literals and parameter defaults.
/// </summary>
public static JsonNode Write( ShaderType type, object value ) => Write( Coerce( value, type ) );
/// <summary>Format a float exactly, so reading it back yields the same bits.</summary>
public static JsonNode Number( float value )
{
if ( float.IsNaN( value ) ) return JsonValue.Create( "NaN" );
if ( float.IsPositiveInfinity( value ) ) return JsonValue.Create( "Infinity" );
if ( float.IsNegativeInfinity( value ) ) return JsonValue.Create( "-Infinity" );
var text = value.ToString( "R", CultureInfo.InvariantCulture );
return JsonNode.Parse( text ) ?? JsonValue.Create( 0 );
}
/// <summary>Format a component array as a JSON array of exact floats.</summary>
public static JsonArray Vector( params float[] components )
{
var array = new JsonArray();
foreach ( var component in components ?? Array.Empty<float>() )
{
array.Add( Number( component ) );
}
return array;
}
/// <summary>Format a colour the way the engine does: four exact components separated by commas.</summary>
public static string FormatColor( Color color ) =>
string.Join( ",",
color.r.ToString( "R", CultureInfo.InvariantCulture ),
color.g.ToString( "R", CultureInfo.InvariantCulture ),
color.b.ToString( "R", CultureInfo.InvariantCulture ),
color.a.ToString( "R", CultureInfo.InvariantCulture ) );
// ---------------------------------------------------------------- reading ----
/// <summary>
/// Decode a literal for a known shader type. Returns the type's default rather than throwing when
/// the JSON is the wrong shape — a corrupt literal must never take a document down.
/// </summary>
public static object Read( ShaderType type, JsonNode node )
{
TryRead( type, node, out var value );
return value;
}
/// <summary>Decode a literal, reporting whether the JSON actually matched the requested type.</summary>
public static bool TryRead( ShaderType type, JsonNode node, out object value )
{
value = Default( type );
if ( node is null ) return false;
if ( type.IsObject )
{
if ( type.IsTexture )
{
var texture = TextureValue.From( node );
if ( texture is null ) return false;
value = texture;
return true;
}
var path = StringOf( node );
if ( path is null ) return false;
value = path;
return true;
}
if ( type.IsBoolean && type.IsScalar )
{
if ( !TryNumbers( node, out var bits ) || bits.Length == 0 ) return false;
value = bits[0] != 0f;
return true;
}
if ( type.IsScalar && type.IsIntegral )
{
if ( !TryNumbers( node, out var ints ) || ints.Length == 0 ) return false;
value = (int)ints[0];
return true;
}
if ( type.IsScalar )
{
if ( !TryNumbers( node, out var scalars ) || scalars.Length == 0 ) return false;
value = scalars[0];
return true;
}
if ( type.IsVector || type.IsMatrix )
{
var wantsColor = node is JsonValue jv && jv.TryGetValue<string>( out var text ) &&
text.Contains( ',' );
if ( wantsColor && TryParseColor( StringOf( node ), out var color ) )
{
value = type.Components == 4 ? color : ToComponents( color, type.Components );
return true;
}
if ( !TryNumbers( node, out var numbers ) ) return false;
value = ToComponents( numbers, type.Components );
return true;
}
return false;
}
/// <summary>
/// Decode a literal with no declared type, guessing from the JSON shape. Used for the inline slots
/// of an unregistered node, where we know nothing about the port.
/// </summary>
public static object ReadUntyped( JsonNode node )
{
switch ( node )
{
case null:
return null;
case JsonArray array:
{
var numbers = new float[array.Count];
for ( int i = 0; i < array.Count; i++ )
{
numbers[i] = NumberOf( array[i] );
}
return ToComponents( numbers, numbers.Length );
}
case JsonObject obj when obj.ContainsKey( "path" ):
return TextureValue.From( obj );
case JsonObject obj:
return obj.DeepClone();
case JsonValue value:
{
if ( value.TryGetValue<bool>( out var b ) ) return b;
if ( value.TryGetValue<int>( out var i ) ) return i;
if ( value.TryGetValue<string>( out var s ) )
{
if ( TryParseColor( s, out var color ) ) return color;
return s;
}
// Anything numeric that was not an int, whatever CLR type is behind it.
if ( IsNumber( value ) ) return NumberOf( value );
return null;
}
default:
return null;
}
}
/// <summary>Parse the engine's <c>"r,g,b,a"</c> colour form. Accepts three or four components.</summary>
public static bool TryParseColor( string text, out Color color )
{
color = Color.White;
if ( string.IsNullOrWhiteSpace( text ) ) return false;
var parts = text.Split( ',', StringSplitOptions.TrimEntries );
if ( parts.Length is < 3 or > 4 ) return false;
var values = new float[4];
values[3] = 1f;
for ( int i = 0; i < parts.Length; i++ )
{
if ( !float.TryParse( parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i] ) )
{
return false;
}
}
color = new Color( values[0], values[1], values[2], values[3] );
return true;
}
/// <summary>The string behind a JSON value, or null when it is not a string.</summary>
public static string StringOf( JsonNode node )
{
if ( node is not JsonValue value ) return null;
return value.TryGetValue<string>( out var text ) ? text : null;
}
/// <summary>
/// The number behind a JSON value, tolerating numbers written as strings.
/// <para>
/// Every numeric backing has to be tried by hand. A <see cref="JsonValue"/> parsed from text wraps a
/// <c>JsonElement</c> and converts to anything numeric, but one built in memory wraps the exact CLR
/// type it was created from — and <c>TryGetValue<float></c> on a <c>JsonValue<int></c>
/// returns <b>false</b>. Documents reach us both ways: parsed from disk, and handed over as a live
/// <c>JsonObject</c> by the asset system or by a migration step that synthesised it. Asking for only
/// one type would silently read every number in the second kind as zero.
/// </para>
/// </summary>
public static float NumberOf( JsonNode node, float fallback = 0f )
{
if ( node is not JsonValue value ) return fallback;
if ( value.TryGetValue<float>( out var f ) ) return f;
if ( value.TryGetValue<double>( out var d ) ) return (float)d;
if ( value.TryGetValue<int>( out var i ) ) return i;
if ( value.TryGetValue<long>( out var l ) ) return l;
if ( value.TryGetValue<uint>( out var u ) ) return u;
if ( value.TryGetValue<ulong>( out var ul ) ) return ul;
if ( value.TryGetValue<decimal>( out var m ) ) return (float)m;
if ( value.TryGetValue<bool>( out var b ) ) return b ? 1f : 0f;
if ( value.TryGetValue<string>( out var text ) )
{
if ( float.TryParse( text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed ) )
{
return parsed;
}
return text switch
{
"NaN" => float.NaN,
"Infinity" => float.PositiveInfinity,
"-Infinity" => float.NegativeInfinity,
_ => fallback
};
}
return fallback;
}
// ---------------------------------------------------------------- shaping ----
/// <summary>The zero value of a shader type, in the boxed shape the model stores.</summary>
public static object Default( ShaderType type )
{
if ( type.IsTexture ) return new TextureValue();
if ( type.IsObject ) return string.Empty;
if ( type.IsBoolean && type.IsScalar ) return false;
if ( type.IsScalar && type.IsIntegral ) return 0;
if ( type.IsScalar ) return 0f;
return type.Components switch
{
2 => Vector2.Zero,
3 => Vector3.Zero,
4 => new Vector4( 0f, 0f, 0f, 0f ),
_ => 0f
};
}
/// <summary>
/// Reshape a boxed value into the canonical form for a type: widening a scalar into a vector by
/// splat, truncating a wider vector, and converting between colours and vectors.
/// </summary>
public static object Coerce( object value, ShaderType type )
{
// There is no such thing as a value of type void, so there is nothing to keep. Saying so here
// rather than letting it fall through the vector path keeps encoding a void slot idempotent.
if ( type.IsVoid ) return Default( type );
if ( value is null ) return Default( type );
if ( type.IsTexture )
{
return value switch
{
TextureValue texture => texture,
string path => new TextureValue { Path = path },
_ => new TextureValue()
};
}
if ( type.IsObject ) return value as string ?? string.Empty;
if ( type.IsBoolean && type.IsScalar )
{
return value switch
{
bool b => b,
float f => f != 0f,
int i => i != 0,
_ => false
};
}
if ( type.IsScalar && type.IsIntegral )
{
return value switch
{
int i => i,
float f => (int)f,
bool b => b ? 1 : 0,
_ => 0
};
}
var components = ToFloats( value );
if ( components.Length == 0 ) return Default( type );
if ( type.IsScalar ) return components[0];
// Keep a colour a colour: it is what tells the writer to use the "r,g,b,a" form.
if ( value is Color && type.Components == 4 ) return value;
return ToComponents( components, type.Components );
}
/// <summary>Flatten any supported boxed value into its float components.</summary>
public static float[] ToFloats( object value ) => value switch
{
null => Array.Empty<float>(),
float f => new[] { f },
double d => new[] { (float)d },
int i => new[] { (float)i },
bool b => new[] { b ? 1f : 0f },
Vector2 v2 => new[] { v2.x, v2.y },
Vector3 v3 => new[] { v3.x, v3.y, v3.z },
Vector4 v4 => new[] { v4.x, v4.y, v4.z, v4.w },
Color c => new[] { c.r, c.g, c.b, c.a },
float[] array => array,
string s => TryParseColor( s, out var parsed )
? new[] { parsed.r, parsed.g, parsed.b, parsed.a }
: Array.Empty<float>(),
_ => Array.Empty<float>()
};
/// <summary>Box a component array as the vector or scalar type of that width, splatting when short.</summary>
public static object ToComponents( float[] components, int width )
{
if ( components is null || components.Length == 0 ) components = new[] { 0f };
float At( int index ) =>
index < components.Length ? components[index] : components.Length == 1 ? components[0] : 0f;
return width switch
{
<= 1 => At( 0 ),
2 => new Vector2( At( 0 ), At( 1 ) ),
3 => new Vector3( At( 0 ), At( 1 ), At( 2 ) ),
_ => new Vector4( At( 0 ), At( 1 ), At( 2 ), components.Length > 3 ? At( 3 ) : 1f )
};
}
/// <summary>Box a colour as the vector type of a given width.</summary>
public static object ToComponents( Color color, int width ) =>
ToComponents( new[] { color.r, color.g, color.b, color.a }, width );
/// <summary>Lower a boxed literal into the IR's constant representation.</summary>
public static ConstValue ToConst( object value )
{
var components = ToFloats( value );
return components.Length switch
{
0 => ConstValue.Zero,
1 => new ConstValue( components[0], 0, 0, 0 ),
2 => new ConstValue( components[0], components[1], 0, 0 ),
3 => new ConstValue( components[0], components[1], components[2], 0 ),
_ => new ConstValue( components[0], components[1], components[2], components[3] )
};
}
/// <summary>
/// Value equality across the boxed shapes, so a "did this literal change" test does not report a
/// change when a <c>float</c> and a one-element vector describe the same thing.
/// </summary>
public static bool Equal( object a, object b )
{
if ( ReferenceEquals( a, b ) ) return true;
if ( a is null || b is null ) return false;
if ( a is TextureValue ta && b is TextureValue tb ) return ta == tb;
if ( a is string sa && b is string sb ) return string.Equals( sa, sb, StringComparison.Ordinal );
if ( a is bool ba && b is bool bb ) return ba == bb;
var fa = ToFloats( a );
var fb = ToFloats( b );
if ( fa.Length == 0 && fb.Length == 0 ) return Equals( a, b );
if ( fa.Length != fb.Length ) return false;
for ( int i = 0; i < fa.Length; i++ )
{
if ( !fa[i].Equals( fb[i] ) ) return false;
}
return true;
}
/// <summary>A short, human-readable form for inline pills and tooltips.</summary>
public static string Describe( object value )
{
switch ( value )
{
case null:
return "-";
case bool b:
return b ? "true" : "false";
case int i:
return i.ToString( CultureInfo.InvariantCulture );
case float f:
return f.ToString( "0.###", CultureInfo.InvariantCulture );
case string s:
return s;
case TextureValue texture:
return texture.ToString();
case Color c:
return $"{c.r:0.##}, {c.g:0.##}, {c.b:0.##}, {c.a:0.##}";
default:
{
var components = ToFloats( value );
if ( components.Length == 0 ) return value.ToString();
return string.Join( ", ", components.Select( x => x.ToString( "0.###", CultureInfo.InvariantCulture ) ) );
}
}
}
/// <summary>True when a JSON value holds a number, whatever CLR type is behind it.</summary>
public static bool IsNumber( JsonNode node )
{
if ( node is not JsonValue value ) return false;
if ( value.TryGetValue<bool>( out _ ) ) return false;
if ( value.TryGetValue<string>( out _ ) ) return false;
return value.TryGetValue<float>( out _ ) || value.TryGetValue<double>( out _ ) ||
value.TryGetValue<int>( out _ ) || value.TryGetValue<long>( out _ ) ||
value.TryGetValue<uint>( out _ ) || value.TryGetValue<ulong>( out _ ) ||
value.TryGetValue<decimal>( out _ );
}
static bool TryNumbers( JsonNode node, out float[] numbers )
{
switch ( node )
{
case JsonArray array:
{
numbers = new float[array.Count];
for ( int i = 0; i < array.Count; i++ )
{
numbers[i] = NumberOf( array[i] );
}
return true;
}
case JsonValue value when value.TryGetValue<string>( out var text ) && text.Contains( ',' ):
{
var parts = text.Split( ',', StringSplitOptions.TrimEntries );
numbers = new float[parts.Length];
for ( int i = 0; i < parts.Length; i++ )
{
float.TryParse( parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out numbers[i] );
}
return true;
}
case JsonValue:
numbers = new[] { NumberOf( node ) };
return true;
default:
numbers = Array.Empty<float>();
return false;
}
}
}
Editor
library
using Editor.Prism.Core;
using Editor.Prism.Integration;
using Editor.Prism.Ui;
using Margin = Sandbox.UI.Margin;
using System.IO;
using System.Text;
using PrismDiagnostic = Editor.Prism.Core.Diagnostic;
namespace Editor.Prism.Text;
/// <summary>One open file or buffer in the <see cref="CodeWindow"/>.</summary>
public sealed class CodeTab
{
/// <summary>The editor widget showing this buffer.</summary>
public CodeEditorWidget Editor { get; init; }
/// <summary>The document. Shorthand for <c>Editor.Document</c>.</summary>
public TextDocument Document => Editor?.Document;
/// <summary>Absolute path on disk, or null for a synthetic buffer such as generated code.</summary>
public string FilePath { get; set; }
/// <summary>Tab caption.</summary>
public string Title { get; set; } = "untitled";
/// <summary>Language id driving highlighting.</summary>
public string Language { get; set; } = "hlsl";
/// <summary>Whether the buffer can be edited.</summary>
public bool ReadOnly { get; set; }
/// <summary>Whether the buffer differs from disk.</summary>
public bool IsModified => Document is { IsModified: true };
/// <summary>
/// Set when the file changed on disk while this buffer had unsaved edits, so the tab can say the
/// two have diverged. Cleared by a reload or a save.
/// </summary>
public bool ChangedOnDisk { get; set; }
/// <summary>Caption with the modified and diverged markers, as drawn on the tab.</summary>
public string DisplayTitle => ChangedOnDisk ? Title + " ⚠" : IsModified ? Title + " •" : Title;
/// <summary>
/// Completion, hover and background validation for this buffer. Owned by the tab and disposed with
/// it, because every part of it holds a reference to the editor widget.
/// </summary>
internal Completion.CodeIntelligence Intelligence { get; set; }
/// <summary>Cached tab width from the last paint, used for hit testing.</summary>
internal Rect TabRect { get; set; }
/// <summary>Diagnostic rendering.</summary>
public override string ToString() => Title;
}
/// <summary>One entry in the outline dock.</summary>
public sealed record CodeSymbol( string Name, string Detail, int Line, string Icon, int Depth );
/// <summary>
/// The code editor window: a tab strip over a stack of <see cref="CodeEditorWidget"/>s, a find bar, a
/// diagnostics dock and an outline dock. This is the shell WP-11 owns; the graph window docks its own
/// generated-code panel separately.
/// </summary>
public sealed class CodeWindow : DockWindow
{
static CodeWindow s_instance;
readonly List<CodeTab> _tabs = new();
CodeTabStrip _strip;
FindReplaceBar _findBar;
Widget _editorStack;
ListView _diagnosticsList;
ListView _outlineList;
LineEdit _outlineFilter;
Label _statusPosition;
Label _statusSelection;
Label _statusLanguage;
Label _statusEncoding;
Widget _diagnosticsPanel;
Widget _outlinePanel;
CodeTab _active;
RealTimeSince _sinceOutlineRefresh;
int _outlineVersion = -1;
/// <summary>The live window, or null when it has never been opened or was closed.</summary>
public static CodeWindow Instance => s_instance is { IsValid: true } ? s_instance : null;
/// <summary>Opens the window, or raises it when it is already open.</summary>
public static CodeWindow Open()
{
if ( Instance is not null )
{
Instance.Show();
Instance.Focus();
return Instance;
}
var window = new CodeWindow();
window.Show();
return window;
}
/// <summary>Opens a file in the window, creating the window if needed. Line and column are one-based.</summary>
public static CodeWindow OpenFile( string absolutePath, int line = 0, int column = 1 )
{
var window = Open();
if ( window is null )
return null;
var tab = window.OpenDocument( absolutePath );
if ( tab is not null && line > 0 )
tab.Editor.GoToLine( line, Math.Max( 1, column ) );
return window;
}
/// <summary>Creates the window. Prefer <see cref="Open"/>.</summary>
public CodeWindow()
{
s_instance = this;
DeleteOnClose = true;
Title = $"{PrismConstants.ProductName} — Code";
Size = new Vector2( 1280, 820 );
PrismLog.Guard( "Prism.Text: window icon", () => SetWindowIcon( "code" ) );
BuildMenu();
BuildStatusBar();
var host = BuildHost();
DockManager.SetCentralWidget( host );
BuildDocks();
// Assigning the cookie restores window geometry and the saved dock layout, so every dock has
// to exist by now or the restore has nothing to place.
StateCookie = "PrismCodeWindow";
// External-change detection. AssetHooks watches the content folder; without this subscriber a
// file edited in another program stayed stale in the buffer here and was silently overwritten
// by the next save. Nothing reloads behind the user's back — an unmodified buffer refreshes in
// place, a modified one is marked and says so.
AssetHooks.ShaderSourceChangedOnDisk += OnFileChangedOnDisk;
AssetHooks.DocumentChangedOnDisk += OnFileChangedOnDisk;
UpdateStatus();
}
/// <summary>
/// A file this window has open was changed by something else.
/// <para>
/// An untouched buffer is re-read on the spot: it has nothing to lose and showing stale text is
/// strictly worse. A buffer with unsaved edits is left exactly as it is and the status bar says the
/// file moved underneath it, because silently discarding the user's work — or silently keeping it
/// and overwriting theirs — are both worse than telling them.
/// </para>
/// </summary>
void OnFileChangedOnDisk( string absolutePath )
{
if ( string.IsNullOrWhiteSpace( absolutePath ) || !this.IsValid() ) return;
PrismLog.Guard( "Handling an external file change", () =>
{
var full = Path.GetFullPath( absolutePath );
foreach ( var tab in _tabs )
{
if ( tab?.FilePath is null ) continue;
if ( !string.Equals( Path.GetFullPath( tab.FilePath ), full, StringComparison.OrdinalIgnoreCase ) ) continue;
if ( tab.IsModified )
{
tab.ChangedOnDisk = true;
_strip?.Update();
StatusBar?.ShowMessage(
$"\"{tab.Title}\" changed on disk and has unsaved edits — File ▸ Reload From Disk to take theirs" );
continue;
}
ReloadTab( tab );
}
} );
}
/// <summary>Every open tab, in strip order.</summary>
public IReadOnlyList<CodeTab> Tabs => _tabs;
/// <summary>The tab currently showing, or null.</summary>
public CodeTab ActiveTab => _active;
/// <summary>The editor currently showing, or null.</summary>
public CodeEditorWidget ActiveEditor => _active?.Editor;
// ---- construction -----------------------------------------------------
Widget BuildHost()
{
var host = new Widget( null );
host.Layout = Layout.Column();
host.Layout.Margin = 0;
host.Layout.Spacing = 0;
_strip = new CodeTabStrip( host );
_strip.TabSelected = SetActiveTab;
_strip.TabClosed = tab => CloseTab( tab );
_strip.NewTabRequested = () => NewDocument();
host.Layout.Add( _strip );
_findBar = new FindReplaceBar( host );
host.Layout.Add( _findBar );
_editorStack = new Widget( host );
_editorStack.Layout = Layout.Column();
_editorStack.Layout.Margin = 0;
host.Layout.Add( _editorStack, 1 );
return host;
}
void BuildDocks()
{
_diagnosticsPanel = BuildDiagnosticsPanel();
_outlinePanel = BuildOutlinePanel();
DockManager.AddDock( "Diagnostics", "error_outline", _diagnosticsPanel, DockArea.Bottom );
DockManager.AddDock( "Outline", "list", _outlinePanel, DockArea.Right );
}
Widget BuildDiagnosticsPanel()
{
var panel = new Widget( null );
panel.Layout = Layout.Column();
panel.Layout.Margin = 0;
_diagnosticsList = new ListView( panel )
{
ItemSize = new Vector2( -1, 24 ),
ItemPaint = PaintDiagnosticRow,
ItemActivated = OnDiagnosticActivated,
ItemClicked = OnDiagnosticActivated
};
panel.Layout.Add( _diagnosticsList, 1 );
return panel;
}
Widget BuildOutlinePanel()
{
var panel = new Widget( null );
panel.Layout = Layout.Column();
panel.Layout.Margin = new Margin( 4, 4, 4, 4 );
panel.Layout.Spacing = 4;
_outlineFilter = new LineEdit( panel ) { PlaceholderText = "Filter symbols" };
_outlineFilter.TextEdited += _ => RefreshOutline( true );
panel.Layout.Add( _outlineFilter );
_outlineList = new ListView( panel )
{
ItemSize = new Vector2( -1, 22 ),
ItemPaint = PaintOutlineRow,
ItemActivated = OnOutlineActivated,
ItemClicked = OnOutlineActivated
};
panel.Layout.Add( _outlineList, 1 );
return panel;
}
/// <summary>Places the docks in their default arrangement.</summary>
protected override void BuildDefaultLayout()
{
var diagnostics = DockManager.OpenDock( "Diagnostics", DockArea.Bottom );
var outline = DockManager.OpenDock( "Outline", DockArea.Right );
PrismLog.Guard( "Prism.Text: default layout", () =>
{
DockManager.SetSplitterProportions( outline, 0.78f, 0.22f );
DockManager.SetSplitterProportions( diagnostics, 0.76f, 0.24f );
} );
}
void BuildStatusBar()
{
StatusBar = new StatusBar( this );
_statusPosition = new Label( "Ln 1, Col 1" ) { Color = PrismTheme.TextSecondary };
_statusSelection = new Label( "" ) { Color = PrismTheme.TextMuted };
_statusLanguage = new Label( "" ) { Color = PrismTheme.TextSecondary };
_statusEncoding = new Label( "" ) { Color = PrismTheme.TextMuted };
StatusBar.AddWidgetLeft( _statusPosition );
StatusBar.AddWidgetLeft( _statusSelection );
StatusBar.AddWidgetRight( _statusLanguage );
StatusBar.AddWidgetRight( _statusEncoding );
}
void BuildMenu()
{
var menu = new MenuBar( this );
MenuBar = menu;
menu.AddOption( "File/New", "note_add", () => NewDocument(), "Ctrl+N" );
menu.AddOption( "File/Open…", "folder_open", PromptOpen, "Ctrl+O" );
menu.AddSeparator();
menu.AddOption( "File/Save", "save", () => SaveActive(), "Ctrl+S" );
menu.AddOption( "File/Save As…", "save_as", PromptSaveAs );
menu.AddOption( "File/Save All", "done_all", () => SaveAll(), "Ctrl+Shift+S" );
menu.AddSeparator();
menu.AddOption( "File/Reload From Disk", "refresh", () => ReloadTab( _active ) );
menu.AddOption( "File/Open in External Editor", "open_in_new", OpenExternally );
menu.AddSeparator();
menu.AddOption( "File/Close Tab", "close", () => { if ( _active is not null ) CloseTab( _active ); }, "Ctrl+W" );
menu.AddOption( "File/Close Window", "logout", Close );
menu.AddOption( "Edit/Undo", "undo", () => WithEditor( e => { e.Controller.PerformUndo(); e.EnsureCaretVisible(); } ), "Ctrl+Z" );
menu.AddOption( "Edit/Redo", "redo", () => WithEditor( e => { e.Controller.PerformRedo(); e.EnsureCaretVisible(); } ), "Ctrl+Shift+Z" );
menu.AddSeparator();
menu.AddOption( "Edit/Cut", "content_cut", () => WithEditor( e => e.Controller.Cut() ), "Ctrl+X" );
menu.AddOption( "Edit/Copy", "content_copy", () => WithEditor( e => e.Controller.Copy() ), "Ctrl+C" );
menu.AddOption( "Edit/Paste", "content_paste", () => WithEditor( e => e.Controller.Paste() ), "Ctrl+V" );
menu.AddSeparator();
menu.AddOption( "Edit/Find…", "search", () => ShowFind( false ), "Ctrl+F" );
menu.AddOption( "Edit/Replace…", "find_replace", () => ShowFind( true ), "Ctrl+H" );
menu.AddOption( "Edit/Go To Line…", "my_location", ShowGoToLine, "Ctrl+G" );
menu.AddSeparator();
menu.AddOption( "Edit/Toggle Comment", "comment", () => WithEditor( e => e.Controller.ToggleLineComment() ), "Ctrl+/" );
menu.AddOption( "Edit/Toggle Block Comment", "notes", () => WithEditor( e => e.Controller.ToggleBlockComment() ) );
menu.AddOption( "Edit/Trim Trailing Whitespace", "cleaning_services", () => WithEditor( e => e.Controller.TrimTrailingWhitespace() ) );
AddToggle( menu, "View/Line Numbers", () => ActiveEditor?.ShowLineNumbers ?? true, value => ForEachEditor( e => e.ShowLineNumbers = value ) );
AddToggle( menu, "View/Indent Guides", () => ActiveEditor?.ShowIndentGuides ?? true, value => ForEachEditor( e => e.ShowIndentGuides = value ) );
AddToggle( menu, "View/Whitespace", () => ActiveEditor?.ShowWhitespace ?? false, value => ForEachEditor( e => e.ShowWhitespace = value ) );
AddToggle( menu, "View/Current Line Highlight", () => ActiveEditor?.HighlightCurrentLine ?? true, value => ForEachEditor( e => e.HighlightCurrentLine = value ) );
AddToggle( menu, "View/Occurrence Highlight", () => ActiveEditor?.HighlightOccurrences ?? true, value => ForEachEditor( e => e.HighlightOccurrences = value ) );
AddToggle( menu, "View/Column Ruler", () => ActiveEditor?.ShowRuler ?? false, value => ForEachEditor( e => e.ShowRuler = value ) );
menu.AddSeparator();
menu.AddOption( "View/Zoom In", "zoom_in", () => ForEachEditor( e => e.FontSize++ ), "Ctrl++" );
menu.AddOption( "View/Zoom Out", "zoom_out", () => ForEachEditor( e => e.FontSize-- ), "Ctrl+-" );
menu.AddOption( "View/Reset Zoom", "search", () => ForEachEditor( e => e.FontSize = PrismTheme.CodeSize ) );
menu.AddSeparator();
menu.AddOption( "View/Fold All", "unfold_less", () => WithEditor( e => { e.Folding?.CollapseAll(); e.LayoutScrollbars(); e.Update(); } ) );
menu.AddOption( "View/Unfold All", "unfold_more", () => WithEditor( e => { e.Folding?.ExpandAll(); e.LayoutScrollbars(); e.Update(); } ) );
var view = menu.FindOrCreateMenu( "View" );
if ( view is not null )
{
view.AddSeparator();
var docks = view.AddMenu( "Panels", "dashboard" );
docks.AboutToShow += () => CreateDynamicViewMenu( docks );
}
menu.AddOption( "Go/Next Problem", "arrow_downward", () => StepDiagnostic( 1 ), "F8" );
menu.AddOption( "Go/Previous Problem", "arrow_upward", () => StepDiagnostic( -1 ), "Shift+F8" );
menu.AddSeparator();
menu.AddOption( "Go/Next Match", "navigate_next", () => _findBar?.FindNext(), "F3" );
menu.AddOption( "Go/Previous Match", "navigate_before", () => _findBar?.FindNext( false ), "Shift+F3" );
}
static void AddToggle( MenuBar menu, string path, Func<bool> get, Action<bool> set )
{
var option = menu.AddOption( path, null, null );
option.Checkable = true;
option.FetchCheckedState = get;
option.Toggled += set;
}
// ---- tabs -------------------------------------------------------------
/// <summary>Creates an empty buffer and focuses it.</summary>
public CodeTab NewDocument( string language = "hlsl" )
{
var tab = CreateTab( new TextDocument(), "untitled", null, language, false );
SetActiveTab( tab );
return tab;
}
/// <summary>
/// Opens a file, focusing the existing tab when it is already open. Returns null when the file
/// could not be read.
/// </summary>
public CodeTab OpenDocument( string absolutePath )
{
if ( string.IsNullOrWhiteSpace( absolutePath ) )
return null;
var full = PrismLog.Guard( "Prism.Text: resolve path", () => Path.GetFullPath( absolutePath ), absolutePath );
for ( var i = 0; i < _tabs.Count; i++ )
{
if ( !string.Equals( _tabs[i].FilePath, full, StringComparison.OrdinalIgnoreCase ) )
continue;
SetActiveTab( _tabs[i] );
return _tabs[i];
}
// Breadcrumbs. Opening a file walks straight into the engine's shader compiler, and a native fault
// there takes the process down with no managed exception and nothing in the log — so the log has
// to say how far we got before it happened.
PrismLog.Info( $"Prism.Text: opening '{full}'" );
var document = new TextDocument();
if ( !document.Load( full ) )
{
StatusBar?.ShowMessage( $"Could not open {Path.GetFileName( full )}: {document.LoadError}" );
return null;
}
var language = LanguageForPath( full );
PrismLog.Info( $"Prism.Text: loaded {document.LineCount} line(s), language '{language}' — building the tab" );
var tab = CreateTab( document, Path.GetFileName( full ), full, language, false );
SetActiveTab( tab );
PrismLog.Info( $"Prism.Text: '{Path.GetFileName( full )}' is open" );
return tab;
}
/// <summary>Opens an in-memory buffer, such as generated shader text. Returns the new tab.</summary>
public CodeTab OpenText( string title, string text, string language, bool readOnly = true )
{
for ( var i = 0; i < _tabs.Count; i++ )
{
if ( _tabs[i].FilePath is not null || !string.Equals( _tabs[i].Title, title, StringComparison.Ordinal ) )
continue;
_tabs[i].Editor.SetText( text, language );
SetActiveTab( _tabs[i] );
return _tabs[i];
}
var document = new TextDocument( text ?? string.Empty );
document.MarkSaved();
var tab = CreateTab( document, title, null, language, readOnly );
SetActiveTab( tab );
return tab;
}
CodeTab CreateTab( TextDocument document, string title, string path, string language, bool readOnly )
{
var editor = new CodeEditorWidget( _editorStack )
{
ReadOnly = readOnly
};
editor.SetDocument( document, language );
editor.ReadOnly = readOnly;
var tab = new CodeTab
{
Editor = editor,
FilePath = path,
Title = string.IsNullOrEmpty( title ) ? "untitled" : title,
Language = language,
ReadOnly = readOnly
};
editor.UnhandledKey = ( _, key ) => HandleWindowKey( key );
editor.SaveRequested += _ => SaveTab( tab );
editor.FindRequested += ( _, replace ) => ShowFind( replace );
editor.GoToLineRequested += _ => ShowGoToLine();
editor.FindStepRequested += ( _, direction ) => _findBar?.FindNext( direction >= 0 );
editor.CaretMoved += _ => UpdateStatus();
editor.TextChanged += _ =>
{
_strip?.Update();
UpdateStatus();
};
// Completion, signature help, hover and background validation, all in one attach. A read-only
// buffer still gets hover and highlighting, but not the compiler tier: it is generated text the
// user cannot fix, and probe-compiling it on every keystroke it will never receive is waste.
tab.Intelligence = PrismLog.Guard( "Prism.Text: attach code intelligence",
() => Completion.CodeIntelligence.Attach( editor, path, !readOnly ), null );
_tabs.Add( tab );
_editorStack.Layout.Add( editor, 1 );
editor.Visible = false;
_strip.SetTabs( _tabs );
return tab;
}
/// <summary>Shows one tab and hides the rest.</summary>
public void SetActiveTab( CodeTab tab )
{
if ( tab is null || !_tabs.Contains( tab ) )
return;
_active = tab;
for ( var i = 0; i < _tabs.Count; i++ )
_tabs[i].Editor.Visible = ReferenceEquals( _tabs[i], tab );
_findBar.Editor = tab.Editor;
if ( _findBar.Visible )
_findBar.Refresh();
_strip.SetActive( tab );
_outlineVersion = -1;
RefreshDiagnostics();
RefreshOutline( true );
UpdateStatus();
tab.Editor.Focus();
tab.Editor.Update();
}
/// <summary>
/// Closes a tab. A modified buffer raises a non-blocking prompt and returns false; answering the
/// prompt closes the tab. Pass <paramref name="force"/> to skip the prompt.
/// </summary>
public bool CloseTab( CodeTab tab, bool force = false )
{
if ( tab is null || !_tabs.Contains( tab ) )
return false;
if ( !force && tab.IsModified && !tab.ReadOnly )
{
PromptUnsaved( $"\"{tab.Title}\" has unsaved changes.",
() => { if ( SaveTab( tab ) ) CloseTab( tab, true ); },
() => CloseTab( tab, true ) );
return false;
}
var index = _tabs.IndexOf( tab );
_tabs.Remove( tab );
PrismLog.Guard( "Prism.Text: destroy editor", () =>
{
// Before the widget, not after: the completion popup, the hover watcher and the validator
// all hold the editor and all unsubscribe from it on dispose.
tab.Intelligence?.Dispose();
tab.Intelligence = null;
tab.Editor.Teardown();
tab.Editor.Destroy();
} );
_strip.SetTabs( _tabs );
if ( ReferenceEquals( _active, tab ) )
{
_active = null;
if ( _tabs.Count > 0 )
SetActiveTab( _tabs[Math.Clamp( index, 0, _tabs.Count - 1 )] );
else
UpdateStatus();
}
return true;
}
/// <summary>Saves the active tab.</summary>
public bool SaveActive() => SaveTab( _active );
/// <summary>Saves one tab, prompting for a path when it has none.</summary>
public bool SaveTab( CodeTab tab )
{
if ( tab is null || tab.ReadOnly )
return false;
if ( string.IsNullOrEmpty( tab.FilePath ) )
return SaveTabAs( tab );
if ( !tab.Document.Save( tab.FilePath ) )
{
StatusBar?.ShowMessage( $"Could not save {tab.Title}: {tab.Document.SaveError}" );
return false;
}
// Whatever the file said before, this buffer is now what is on disk.
tab.ChangedOnDisk = false;
StatusBar?.ShowMessage( $"Saved {tab.Title}" );
_strip?.Update();
UpdateStatus();
return true;
}
/// <summary>Saves one tab to a path chosen by the user.</summary>
public bool SaveTabAs( CodeTab tab )
{
if ( tab is null )
return false;
var dialog = new FileDialog( this ) { Title = "Save Shader Source" };
dialog.SetModeSave();
dialog.SetNameFilter( "Shader source (*.hlsl *.slang *.shader *.fxc *.h *.txt)" );
dialog.DefaultSuffix = tab.Language == "slang" ? PrismConstants.SlangExtension : PrismConstants.HlslExtension;
if ( !string.IsNullOrEmpty( tab.FilePath ) )
dialog.SelectFile( tab.FilePath );
if ( !dialog.Execute() )
return false;
var path = dialog.SelectedFile;
if ( string.IsNullOrWhiteSpace( path ) )
return false;
if ( !tab.Document.Save( path ) )
{
StatusBar?.ShowMessage( $"Could not save: {tab.Document.SaveError}" );
return false;
}
tab.FilePath = path;
tab.Title = Path.GetFileName( path );
tab.Language = LanguageForPath( path );
tab.Editor.Language = tab.Language;
// Include resolution is relative to the including file's own directory, so a buffer that just
// moved resolves its includes from somewhere else now.
if ( tab.Intelligence is not null ) tab.Intelligence.FilePath = path;
_strip.SetTabs( _tabs );
UpdateStatus();
return true;
}
/// <summary>
/// Re-reads a tab from disk, keeping the viewport. A modified buffer prompts first; answering the
/// prompt performs the reload.
/// </summary>
public bool ReloadTab( CodeTab tab )
{
if ( tab?.FilePath is null )
return false;
if ( tab.IsModified )
{
PromptUnsaved( $"\"{tab.Title}\" has unsaved changes that reloading will discard.",
() => { if ( SaveTab( tab ) ) ReloadTab( tab ); },
() => { tab.Document.MarkSaved(); ReloadTab( tab ); } );
return false;
}
var fresh = new TextDocument();
if ( !fresh.Load( tab.FilePath ) )
{
StatusBar?.ShowMessage( $"Could not reload {tab.Title}: {fresh.LoadError}" );
return false;
}
tab.Document.LineEnding = fresh.LineEnding;
tab.Document.Encoding = fresh.Encoding;
tab.Document.HasByteOrderMark = fresh.HasByteOrderMark;
tab.Editor.SetText( fresh.Text, tab.Language );
tab.ChangedOnDisk = false;
StatusBar?.ShowMessage( $"Reloaded {tab.Title}" );
_strip?.Update();
UpdateStatus();
return true;
}
/// <summary>Saves every modified tab that has a path.</summary>
public int SaveAll()
{
var saved = 0;
for ( var i = 0; i < _tabs.Count; i++ )
{
if ( _tabs[i].IsModified && !_tabs[i].ReadOnly && SaveTab( _tabs[i] ) )
saved++;
}
return saved;
}
void PromptOpen()
{
var dialog = new FileDialog( this ) { Title = "Open Shader Source" };
dialog.SetModeOpen();
dialog.SetFindExistingFile();
dialog.SetNameFilter( "Shader source (*.hlsl *.slang *.shader *.fxc *.h *.txt)" );
if ( dialog.Execute() )
OpenDocument( dialog.SelectedFile );
}
void PromptSaveAs() => SaveTabAs( _active );
void OpenExternally()
{
var tab = _active;
if ( tab?.FilePath is null )
return;
PrismLog.Guard( "Prism.Text: external editor",
() => CodeEditor.OpenFile( tab.FilePath, tab.Editor.CaretPosition.Line + 1, tab.Editor.CaretPosition.Column + 1 ) );
}
/// <summary>Maps a file extension to a lexer language id.</summary>
public static string LanguageForPath( string path )
{
if ( string.IsNullOrEmpty( path ) )
return "hlsl";
var extension = Path.GetExtension( path ).ToLowerInvariant();
return extension switch
{
".slang" => "slang",
".shader" => "vfx",
".vfx" => "vfx",
_ => "hlsl"
};
}
// ---- find and navigation ----------------------------------------------
/// <summary>Shows the find bar, optionally with the replace row.</summary>
public void ShowFind( bool replace )
{
if ( _active is null )
return;
_findBar.Editor = _active.Editor;
_findBar.Open( replace );
}
/// <summary>Opens the go-to-line prompt.</summary>
public void ShowGoToLine()
{
var editor = ActiveEditor;
if ( editor is null )
return;
var popup = new PopupWidget( this );
popup.Layout = Layout.Row();
popup.Layout.Margin = new Margin( 8, 6, 8, 6 );
popup.Layout.Spacing = 6;
popup.Layout.Add( new Label( $"Go to line (1 – {editor.Document.LineCount}):" ) );
var entry = new LineEdit( popup ) { PlaceholderText = "line[:column]" };
entry.MinimumWidth = 120;
entry.ReturnPressed += () =>
{
var parts = (entry.Text ?? string.Empty).Split( ':', StringSplitOptions.RemoveEmptyEntries );
if ( parts.Length > 0 && int.TryParse( parts[0].Trim(), out var line ) )
{
var column = 1;
if ( parts.Length > 1 )
int.TryParse( parts[1].Trim(), out column );
editor.GoToLine( line, Math.Max( 1, column ) );
}
popup.Destroy();
};
popup.Layout.Add( entry );
popup.OpenAtCursor();
entry.Focus();
}
void StepDiagnostic( int direction )
{
var editor = ActiveEditor;
if ( editor is null || editor.Diagnostics.Count == 0 )
return;
var ordered = new List<CodeDiagnostic>( editor.Diagnostics );
ordered.Sort( static ( a, b ) => a.Range.Min.CompareTo( b.Range.Min ) );
var caret = editor.CaretPosition;
var target = -1;
if ( direction >= 0 )
{
for ( var i = 0; i < ordered.Count; i++ )
{
if ( ordered[i].Range.Min > caret )
{
target = i;
break;
}
}
if ( target < 0 )
target = 0;
}
else
{
for ( var i = ordered.Count - 1; i >= 0; i-- )
{
if ( ordered[i].Range.Min < caret )
{
target = i;
break;
}
}
if ( target < 0 )
target = ordered.Count - 1;
}
editor.Reveal( ordered[target].Range, true, 4 );
_diagnosticsList?.SelectItem( ordered[target] );
}
// ---- diagnostics ------------------------------------------------------
/// <summary>Pushes pipeline diagnostics onto a tab and refreshes the dock.</summary>
public void SetDiagnostics( CodeTab tab, IEnumerable<PrismDiagnostic> diagnostics )
{
if ( tab is null )
return;
tab.Editor.SetDiagnostics( diagnostics );
if ( ReferenceEquals( tab, _active ) )
RefreshDiagnostics();
}
/// <summary>Rebuilds the diagnostics dock from the active tab.</summary>
public void RefreshDiagnostics()
{
if ( _diagnosticsList is not { IsValid: true } )
return;
var editor = ActiveEditor;
if ( editor is null )
{
_diagnosticsList.SetItems( Array.Empty<object>() );
return;
}
var items = new List<CodeDiagnostic>( editor.Diagnostics );
items.Sort( static ( a, b ) =>
{
var bySeverity = b.Severity.CompareTo( a.Severity );
return bySeverity != 0 ? bySeverity : a.Range.Min.CompareTo( b.Range.Min );
} );
_diagnosticsList.SetItems( items );
}
void OnDiagnosticActivated( object item )
{
if ( item is not CodeDiagnostic diagnostic || ActiveEditor is null )
return;
ActiveEditor.Reveal( diagnostic.Range, true, 4 );
ActiveEditor.Focus();
}
void PaintDiagnosticRow( VirtualWidget item )
{
if ( item.Object is not CodeDiagnostic diagnostic )
return;
var rect = item.Rect;
if ( item.Selected )
item.PaintBackground( PrismTheme.AccentSoft, 3f );
else if ( item.Hovered )
item.PaintBackground( PrismTheme.PanelAlt, 3f );
var color = PrismTheme.ForSeverity( diagnostic.Severity );
Paint.SetPen( color );
Paint.DrawIcon( new Rect( rect.Left + 4f, rect.Top, 18f, rect.Height ), IconFor( diagnostic.Severity ), 13f, TextFlag.Center );
Paint.SetDefaultFont( PrismTheme.BodySize, 400, false, true );
var lineText = $"{diagnostic.Range.Min.Line + 1}";
Paint.SetPen( PrismTheme.TextMuted );
Paint.DrawText( new Rect( rect.Left + 24f, rect.Top, 42f, rect.Height ), lineText, TextFlag.LeftCenter | TextFlag.SingleLine );
if ( !string.IsNullOrEmpty( diagnostic.Code ) )
{
Paint.SetPen( PrismTheme.TextDisabled );
Paint.DrawText( new Rect( rect.Left + 66f, rect.Top, 54f, rect.Height ), diagnostic.Code, TextFlag.LeftCenter | TextFlag.SingleLine );
}
Paint.SetPen( item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );
Paint.DrawText( new Rect( rect.Left + 124f, rect.Top, rect.Width - 130f, rect.Height ),
diagnostic.Message ?? string.Empty, TextFlag.LeftCenter | TextFlag.SingleLine );
}
static string IconFor( DiagnosticSeverity severity ) => severity switch
{
DiagnosticSeverity.Error => "error",
DiagnosticSeverity.Warning => "warning",
_ => "info"
};
// ---- outline ----------------------------------------------------------
/// <summary>Rebuilds the outline dock from the active document.</summary>
public void RefreshOutline( bool force = false )
{
if ( _outlineList is not { IsValid: true } )
return;
var editor = ActiveEditor;
if ( editor is null )
{
_outlineList.SetItems( Array.Empty<object>() );
return;
}
if ( !force && _outlineVersion == editor.Document.Version )
return;
_outlineVersion = editor.Document.Version;
// The real parser rather than the regex fallback: it runs over the token stream the lexer has
// already produced, so it is not fooled by a declaration inside a comment or a string, and it
// knows about containers and parameters the regex pass cannot see.
var symbols = PrismLog.Guard( "Prism.Text: outline",
() => Completion.DocumentSymbols.For( editor.Document, editor.Language ).ToOutline(),
null ) ?? CodeOutline.Scan( editor.Document );
var filter = _outlineFilter?.Text;
if ( !string.IsNullOrWhiteSpace( filter ) )
{
var narrowed = new List<CodeSymbol>();
for ( var i = 0; i < symbols.Count; i++ )
{
if ( symbols[i].Name is not null &&
symbols[i].Name.Contains( filter, StringComparison.OrdinalIgnoreCase ) )
narrowed.Add( symbols[i] );
}
symbols = narrowed;
}
_outlineList.SetItems( symbols );
}
void OnOutlineActivated( object item )
{
if ( item is not CodeSymbol symbol || ActiveEditor is null )
return;
ActiveEditor.GoToLine( symbol.Line + 1 );
}
void PaintOutlineRow( VirtualWidget item )
{
if ( item.Object is not CodeSymbol symbol )
return;
var rect = item.Rect;
if ( item.Selected )
item.PaintBackground( PrismTheme.AccentSoft, 3f );
else if ( item.Hovered )
item.PaintBackground( PrismTheme.PanelAlt, 3f );
var indent = 6f + symbol.Depth * 12f;
Paint.SetPen( PrismTheme.TextMuted );
Paint.DrawIcon( new Rect( rect.Left + indent, rect.Top, 16f, rect.Height ), symbol.Icon, 12f, TextFlag.Center );
Paint.SetDefaultFont( PrismTheme.BodySize, 400, false, true );
Paint.SetPen( item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );
Paint.DrawText( new Rect( rect.Left + indent + 20f, rect.Top, rect.Width - indent - 26f, rect.Height ),
symbol.Name ?? string.Empty, TextFlag.LeftCenter | TextFlag.SingleLine );
if ( string.IsNullOrEmpty( symbol.Detail ) )
return;
Paint.SetPen( PrismTheme.TextDisabled );
Paint.DrawText( new Rect( rect.Left, rect.Top, rect.Width - 8f, rect.Height ),
symbol.Detail, TextFlag.RightCenter | TextFlag.SingleLine );
}
// ---- status -----------------------------------------------------------
void UpdateStatus()
{
if ( _statusPosition is not { IsValid: true } )
return;
var editor = ActiveEditor;
if ( editor is null )
{
_statusPosition.Text = "";
_statusSelection.Text = "";
_statusLanguage.Text = "";
_statusEncoding.Text = "";
Title = $"{PrismConstants.ProductName} — Code";
return;
}
var caret = editor.CaretPosition;
_statusPosition.Text = $"Ln {caret.Line + 1}, Col {caret.Column + 1}";
var selection = editor.Selection;
var selected = 0;
for ( var i = 0; i < selection.Count; i++ )
selected += editor.Document.GetText( selection[i].Selection ).Length;
if ( selection.Count > 1 )
_statusSelection.Text = $"{selection.Count} carets · {selected} selected";
else if ( selected > 0 )
_statusSelection.Text = $"{selected} selected";
else
_statusSelection.Text = "";
var indent = editor.Controller.UseTabs ? "Tabs" : $"Spaces: {editor.Controller.IndentSize}";
_statusLanguage.Text = $"{editor.Language.ToUpperInvariant()} · {indent}";
var ending = editor.Document.LineEnding switch
{
LineEndingStyle.Lf => "LF",
LineEndingStyle.Cr => "CR",
_ => "CRLF"
};
_statusEncoding.Text = $"{ending} · {(editor.Document.HasByteOrderMark ? "UTF-8 BOM" : "UTF-8")}";
var title = _active?.DisplayTitle ?? string.Empty;
Title = string.IsNullOrEmpty( title )
? $"{PrismConstants.ProductName} — Code"
: $"{title} — {PrismConstants.ProductName}";
}
/// <summary>
/// Window-level accelerators, routed from the focused editor because it consumes every shortcut.
/// Returns true when the key was consumed.
/// </summary>
bool HandleWindowKey( CodeKeyInfo key )
{
if ( key.Ctrl && !key.Alt )
{
switch ( key.Key )
{
case KeyCode.N when !key.Shift:
NewDocument();
return true;
case KeyCode.O when !key.Shift:
PromptOpen();
return true;
case KeyCode.W when !key.Shift:
if ( _active is not null )
CloseTab( _active );
return true;
case KeyCode.S when key.Shift:
SaveAll();
return true;
case KeyCode.Tab:
case KeyCode.Backtab:
StepTab( key.Shift ? -1 : 1 );
return true;
}
}
if ( key.Key == KeyCode.F8 )
{
StepDiagnostic( key.Shift ? -1 : 1 );
return true;
}
return false;
}
/// <summary>Moves to the next or previous tab, wrapping around.</summary>
public void StepTab( int direction )
{
if ( _tabs.Count < 2 || _active is null )
return;
var index = _tabs.IndexOf( _active );
if ( index < 0 )
return;
index = (index + direction + _tabs.Count) % _tabs.Count;
SetActiveTab( _tabs[index] );
}
void WithEditor( Action<CodeEditorWidget> action )
{
var editor = ActiveEditor;
if ( editor is null )
return;
PrismLog.Guard( "Prism.Text: command", () => action( editor ) );
editor.Update();
}
void ForEachEditor( Action<CodeEditorWidget> action )
{
for ( var i = 0; i < _tabs.Count; i++ )
{
var editor = _tabs[i].Editor;
if ( editor is { IsValid: true } )
PrismLog.Guard( "Prism.Text: view option", () => action( editor ) );
}
}
[EditorEvent.Frame]
void CodeWindowFrame()
{
if ( !IsValid || _sinceOutlineRefresh < 0.75f )
return;
_sinceOutlineRefresh = 0;
RefreshOutline();
}
/// <summary>Shows the non-blocking save / discard / cancel prompt.</summary>
void PromptUnsaved( string message, Action onSave, Action onDiscard )
{
PrismLog.Guard( "Prism.Text: unsaved prompt", () =>
{
var popup = new PopupDialogWidget( "❓" );
popup.WindowTitle = "Unsaved Changes";
popup.MessageLabel.Text = message;
popup.ButtonLayout.AddStretchCell();
popup.ButtonLayout.Add( new Button( "Cancel" ) { Clicked = () => popup.Destroy() } );
popup.ButtonLayout.Add( new Button( "Discard" ) { Clicked = () => { popup.Destroy(); onDiscard?.Invoke(); } } );
popup.ButtonLayout.Add( new Button.Primary( "Save" ) { Clicked = () => { popup.Destroy(); onSave?.Invoke(); } } );
popup.SetModal( true, true );
popup.Hide();
popup.Show();
} );
}
bool _forceClose;
protected override bool OnClose()
{
if ( _forceClose )
return base.OnClose();
var modified = 0;
for ( var i = 0; i < _tabs.Count; i++ )
{
if ( _tabs[i].IsModified && !_tabs[i].ReadOnly )
modified++;
}
if ( modified == 0 )
return base.OnClose();
PromptUnsaved( $"{modified} file(s) have unsaved changes.",
() => { SaveAll(); _forceClose = true; Close(); },
() => { _forceClose = true; Close(); } );
return false;
}
protected override void OnClosed()
{
// AssetHooks is static and would otherwise pin this window, its tabs and every document in them
// for the rest of the session.
AssetHooks.ShaderSourceChangedOnDisk -= OnFileChangedOnDisk;
AssetHooks.DocumentChangedOnDisk -= OnFileChangedOnDisk;
if ( ReferenceEquals( s_instance, this ) )
s_instance = null;
base.OnClosed();
}
}
/// <summary>
/// The tab strip. Painted rather than composed, so it matches <see cref="PrismTheme"/> exactly — the
/// engine's <c>TabBar</c> binding exposes no managed API at all.
/// </summary>
internal sealed class CodeTabStrip : Widget
{
readonly List<CodeTab> _tabs = new();
CodeTab _active;
CodeTab _hovered;
bool _hoverClose;
float _scroll;
public CodeTabStrip( Widget parent ) : base( parent )
{
FixedHeight = 30f;
MouseTracking = true;
Cursor = CursorShape.Finger;
}
public Action<CodeTab> TabSelected { get; set; }
public Action<CodeTab> TabClosed { get; set; }
public Action NewTabRequested { get; set; }
public void SetTabs( IReadOnlyList<CodeTab> tabs )
{
_tabs.Clear();
if ( tabs is not null )
_tabs.AddRange( tabs );
Update();
}
public void SetActive( CodeTab tab )
{
_active = tab;
Update();
}
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( PrismTheme.Panel );
Paint.DrawRect( LocalRect );
Paint.SetPen( PrismTheme.BorderSubtle, 1f );
Paint.DrawLine( new Vector2( 0f, LocalRect.Bottom - 0.5f ), new Vector2( LocalRect.Right, LocalRect.Bottom - 0.5f ) );
Paint.SetDefaultFont( PrismTheme.BodySize, 400, false, true );
var x = 4f - _scroll;
for ( var i = 0; i < _tabs.Count; i++ )
{
var tab = _tabs[i];
var caption = tab.DisplayTitle;
var width = Math.Clamp( Paint.MeasureText( caption ).x + 46f, 90f, 240f );
var rect = new Rect( x, 3f, width, LocalRect.Height - 3f );
tab.TabRect = rect;
x += width + 2f;
if ( rect.Right < 0f || rect.Left > LocalRect.Right )
continue;
var isActive = ReferenceEquals( tab, _active );
var isHovered = ReferenceEquals( tab, _hovered );
Paint.ClearPen();
Paint.SetBrush( isActive ? PrismTheme.Code.Background : isHovered ? PrismTheme.PanelAlt : PrismTheme.Panel );
Paint.DrawRect( rect, PrismTheme.RadiusChip );
if ( isActive )
{
Paint.SetBrush( PrismTheme.Accent );
Paint.DrawRect( new Rect( rect.Left, rect.Top, rect.Width, 2f ), 1f );
}
Paint.SetPen( isActive ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );
Paint.DrawText( new Rect( rect.Left + 10f, rect.Top, rect.Width - 34f, rect.Height ),
caption, TextFlag.LeftCenter | TextFlag.SingleLine );
var closeRect = CloseRect( rect );
Paint.SetPen( isHovered && _hoverClose ? PrismTheme.Error : PrismTheme.TextMuted );
Paint.DrawIcon( closeRect, "close", 12f, TextFlag.Center );
}
var plus = new Rect( x + 4f, 4f, 22f, LocalRect.Height - 8f );
Paint.SetPen( PrismTheme.TextMuted );
Paint.DrawIcon( plus, "add", 14f, TextFlag.Center );
}
static Rect CloseRect( Rect tabRect ) => new( tabRect.Right - 24f, tabRect.Top + 4f, 18f, tabRect.Height - 8f );
protected override void OnMouseMove( MouseEvent e )
{
var previous = _hovered;
var previousClose = _hoverClose;
_hovered = HitTest( e.LocalPosition, out _hoverClose );
if ( !ReferenceEquals( previous, _hovered ) || previousClose != _hoverClose )
Update();
}
protected override void OnMouseLeave()
{
_hovered = null;
_hoverClose = false;
Update();
}
protected override void OnMousePress( MouseEvent e )
{
var tab = HitTest( e.LocalPosition, out var onClose );
if ( tab is null )
{
if ( e.LeftMouseButton && e.LocalPosition.x > LastTabRight() )
NewTabRequested?.Invoke();
e.Accepted = true;
return;
}
if ( e.MiddleMouseButton || (e.LeftMouseButton && onClose) )
TabClosed?.Invoke( tab );
else if ( e.LeftMouseButton )
TabSelected?.Invoke( tab );
e.Accepted = true;
}
protected override void OnMouseWheel( WheelEvent e )
{
_scroll = Math.Max( 0f, _scroll + (e.Delta > 0 ? -40f : 40f) );
Update();
e.Accept();
}
float LastTabRight() => _tabs.Count == 0 ? 4f : _tabs[^1].TabRect.Right;
CodeTab HitTest( Vector2 local, out bool onClose )
{
onClose = false;
for ( var i = 0; i < _tabs.Count; i++ )
{
if ( !_tabs[i].TabRect.IsInside( local ) )
continue;
onClose = CloseRect( _tabs[i].TabRect ).IsInside( local );
return _tabs[i];
}
return null;
}
}
/// <summary>
/// A deliberately small structural scanner that feeds the outline dock: VFX block headers, macros,
/// structs, constant buffers and top-level function definitions. It is a placeholder for the richer
/// document-symbol parser the language-intelligence package owns; swapping it out only changes this
/// file.
/// </summary>
internal static class CodeOutline
{
static readonly string[] s_blocks =
{
"HEADER", "MODES", "FEATURES", "COMMON", "VS", "PS", "GS", "CS", "PS_RENDER_STATE", "RTX"
};
static readonly HashSet<string> s_notFunctions = new( StringComparer.Ordinal )
{
"if", "for", "while", "switch", "return", "else", "do", "case", "sizeof", "defined"
};
public static List<CodeSymbol> Scan( TextDocument document )
{
var symbols = new List<CodeSymbol>();
if ( document is null )
return symbols;
var depth = 0;
for ( var line = 0; line < document.LineCount; line++ )
{
var raw = document.GetLine( line );
var text = raw.Trim();
var startDepth = depth;
depth += CountUnquoted( raw, '{' ) - CountUnquoted( raw, '}' );
if ( text.Length == 0 || text.StartsWith( "//", StringComparison.Ordinal ) )
continue;
if ( startDepth == 0 && TryBlock( text, out var block ) )
{
symbols.Add( new CodeSymbol( block, "block", line, "widgets", 0 ) );
continue;
}
if ( text.StartsWith( "#define ", StringComparison.Ordinal ) )
{
var name = ReadIdentifier( text, 8 );
if ( !string.IsNullOrEmpty( name ) )
symbols.Add( new CodeSymbol( name, "define", line, "tag", startDepth > 0 ? 1 : 0 ) );
continue;
}
if ( text.StartsWith( "struct ", StringComparison.Ordinal ) )
{
var name = ReadIdentifier( text, 7 );
if ( !string.IsNullOrEmpty( name ) )
symbols.Add( new CodeSymbol( name, "struct", line, "data_object", startDepth > 0 ? 1 : 0 ) );
continue;
}
if ( text.StartsWith( "cbuffer ", StringComparison.Ordinal ) )
{
var name = ReadIdentifier( text, 8 );
if ( !string.IsNullOrEmpty( name ) )
symbols.Add( new CodeSymbol( name, "cbuffer", line, "view_list", startDepth > 0 ? 1 : 0 ) );
continue;
}
if ( startDepth > 1 )
continue;
if ( TryFunction( text, out var function, out var signature ) )
symbols.Add( new CodeSymbol( function, signature, line, "functions", startDepth > 0 ? 1 : 0 ) );
}
return symbols;
}
static bool TryBlock( string text, out string block )
{
block = null;
var candidate = text.EndsWith( "{", StringComparison.Ordinal ) ? text[..^1].Trim() : text;
for ( var i = 0; i < s_blocks.Length; i++ )
{
if ( !string.Equals( candidate, s_blocks[i], StringComparison.Ordinal ) )
continue;
block = s_blocks[i];
return true;
}
return false;
}
static bool TryFunction( string text, out string name, out string signature )
{
name = null;
signature = null;
if ( text.StartsWith( "#", StringComparison.Ordinal ) )
return false;
var open = text.IndexOf( '(' );
if ( open <= 0 )
return false;
if ( text.EndsWith( ";", StringComparison.Ordinal ) )
return false;
var head = text[..open].Trim();
if ( head.Length == 0 )
return false;
var lastSpace = head.LastIndexOfAny( new[] { ' ', '\t', '*', '&', ':' } );
if ( lastSpace <= 0 || lastSpace >= head.Length - 1 )
return false;
var candidate = head[(lastSpace + 1)..].Trim();
if ( candidate.Length == 0 || s_notFunctions.Contains( candidate ) )
return false;
for ( var i = 0; i < candidate.Length; i++ )
{
if ( !char.IsLetterOrDigit( candidate[i] ) && candidate[i] != '_' )
return false;
}
name = candidate;
signature = head[..lastSpace].Trim();
return true;
}
static string ReadIdentifier( string text, int start )
{
var index = start;
while ( index < text.Length && char.IsWhiteSpace( text[index] ) )
index++;
var builder = new StringBuilder();
while ( index < text.Length && (char.IsLetterOrDigit( text[index] ) || text[index] == '_') )
builder.Append( text[index++] );
return builder.ToString();
}
static int CountUnquoted( string text, char target )
{
var count = 0;
var inString = false;
for ( var i = 0; i < text.Length; i++ )
{
var c = text[i];
if ( c == '"' && (i == 0 || text[i - 1] != '\\') )
{
inString = !inString;
continue;
}
if ( inString )
continue;
if ( c == '/' && i + 1 < text.Length && text[i + 1] == '/' )
break;
if ( c == target )
count++;
}
return count;
}
}
Editor
library
using Editor.Prism.Core;
using Editor.Prism.Text.Completion;
using Editor.Prism.Text.Lexer;
using Editor.Prism.Text.LanguageDb;
using Editor.Prism.Toolchain;
using Sandbox.Engine.Shaders;
using PrismDiagnostic = Editor.Prism.Core.Diagnostic;
namespace Editor.Prism.Text.Diagnostics;
/// <summary>
/// Diagnostic codes produced by the text editor's own analysis, as opposed to the graph pipeline's.
/// <para>
/// <b>These are now forwarders.</b> The <c>PR6xxx</c> range was folded into
/// <see cref="Core.DiagnosticCode"/> so there is one table of codes rather than two lists of identical
/// string literals that could drift apart. Every member below is defined as the corresponding
/// <c>DiagnosticCode</c> constant, so the two agree by construction and not by coincidence. New
/// text-tier codes go in <c>DiagnosticCode</c>; this type stays for the callers that already name it.
/// </para>
/// </summary>
public static class TextDiagnosticCode
{
/// <summary>A call to something nothing in scope declares.</summary>
public const string UnknownIdentifier = DiagnosticCode.UnknownIdentifier;
/// <summary>A Direct3D 9 sampler intrinsic DXC removed.</summary>
public const string DeprecatedIntrinsic = DiagnosticCode.DeprecatedIntrinsic;
/// <summary>Braces, parentheses or brackets do not balance.</summary>
public const string Unbalanced = DiagnosticCode.Unbalanced;
/// <summary>A string literal or block comment is never closed.</summary>
public const string Unterminated = DiagnosticCode.Unterminated;
/// <summary>A <c>#</c> directive the preprocessor does not know.</summary>
public const string UnknownDirective = DiagnosticCode.UnknownDirective;
/// <summary>An <c>#include</c> that resolves to no file on any search path.</summary>
public const string MissingInclude = DiagnosticCode.MissingInclude;
/// <summary>An <c>#include <…></c>, which the engine's preprocessor never expands.</summary>
public const string AngleBracketInclude = DiagnosticCode.AngleBracketInclude;
/// <summary>An <c>#include</c> whose spacing the engine's regex does not match.</summary>
public const string IncludeSpacing = DiagnosticCode.IncludeSpacing;
/// <summary>A VFX block the engine's <c>.shader</c> parser throws on.</summary>
public const string RejectedBlock = DiagnosticCode.RejectedBlock;
/// <summary>A declaration that shadows an engine global.</summary>
public const string ShadowedGlobal = DiagnosticCode.ShadowedGlobal;
/// <summary>The Slang toolchain is absent, so a <c>.slang</c> buffer only gets local checks.</summary>
public const string SlangNotValidated = DiagnosticCode.SlangNotValidated;
}
/// <summary>
/// Runs the right validator for a buffer and hands back diagnostics, debounced and cancellable.
/// <para>
/// Three tiers, in the order they arrive. Local checks are instant, in-process and always on: unknown
/// calls, DX9 intrinsics DXC removed, intrinsics above Shader Model 6.0, unbalanced brackets, unknown
/// directives and unresolvable includes. They land on the editor before the user has stopped typing.
/// Then the authoritative tier: a <c>.shader</c> is compiled for real by the engine, and a bare
/// <c>.hlsl</c> is wrapped in the smallest legal shader that will hold it and compiled the same way —
/// which is how this editor gets real compiler errors for an include file, something nothing else in
/// s&box does. A <c>.slang</c> goes to <c>slangc</c> when the user installed one, and quietly does
/// without when they did not.
/// </para>
/// </summary>
public sealed class TextDiagnosticService : IDisposable
{
/// <summary>
/// Ceiling on unknown-identifier warnings in one file. Past this the file is not wrong, it is
/// <i>incomplete</i>, and the whole set is dropped — see <see cref="MaxDistinctUnknownIdentifiers"/>.
/// </summary>
const int MaxUnknownIdentifiers = 8;
/// <summary>
/// Ceiling on <i>distinct</i> unknown names in one file.
/// <para>
/// The check exists to catch an isolated mistake — a misspelled intrinsic, a helper that was renamed.
/// Once five different names in one buffer are unresolved, the far likelier explanation is that the
/// buffer is an include fragment whose scope its callers supply. The engine's own headers do exactly
/// this: <c>ffx_fsr1.h</c> calls <c>ARcpF1</c> fourteen times and deliberately does not include
/// <c>ffx_a.h</c>, and every <c>ffx_denoiser_reflections_*.h</c> calls twenty callbacks the including
/// shader is required to define. Reporting those as mistakes is simply wrong, so nothing is reported.
/// </para>
/// </summary>
const int MaxDistinctUnknownIdentifiers = 4;
/// <summary>
/// Ceiling on how often one unknown name may appear before the file is treated as incomplete. Nobody
/// misspells the same identifier three times; a missing header goes wrong on every use.
/// </summary>
const int MaxUsesOfOneUnknown = 2;
/// <summary>
/// How deep the include graph is walked when harvesting the names a buffer can see. Deeper than
/// <see cref="CompletenessDepth"/> on purpose: every extra name found can only remove a false
/// positive, never create one.
/// </summary>
const int IncludeDepth = 4;
/// <summary>
/// How deep the "did every include resolve?" test looks. Kept shallow deliberately: engine header
/// trees fan out into the fourteen compiler-embedded includes within three or four hops, and a
/// stricter test would simply stop checking two thirds of the shipped shaders.
/// </summary>
const int CompletenessDepth = 2;
/// <summary>Ceiling on files walked while harvesting, so a pathological graph cannot stall a check.</summary>
const int IncludeFiles = 96;
static bool s_collected;
readonly object _lock = new();
CancellationTokenSource _inFlight;
TempWorkspace _workspace;
int _generation;
bool _disposed;
CodeEditorWidget _editor;
Action<CodeEditorWidget> _settled;
/// <summary>
/// Creates a service with its own scratch folder. The folder is per instance rather than per
/// session because two tabs editing files with the same name would otherwise compile over each
/// other, and the output path the engine picks is derived from the file name.
/// </summary>
public TextDiagnosticService( string sessionId = null )
{
SessionId = string.IsNullOrWhiteSpace( sessionId )
? $"text-{Ids.NewShortId()}"
: sessionId;
// A crashed editor leaves its scratch folders behind and nobody comes back for them. Once per
// process is enough; every tab does not need to rescan the directory.
if ( !s_collected )
{
s_collected = true;
PrismLog.Guard( "Prism.Text: collect stale scratch sessions",
() => TempWorkspace.CollectGarbage( PrismConstants.TempSessionLifetimeHours, SessionId ) );
}
}
/// <summary>
/// Wires a service to an editor: validates when typing settles, pushes the result onto the editor's
/// squiggles, and validates once immediately so a freshly opened file is not silently unchecked.
/// </summary>
public static TextDiagnosticService Attach( CodeEditorWidget editor, string filePath = null )
{
if ( editor is not { IsValid: true } )
return null;
var service = new TextDiagnosticService
{
FilePath = filePath ?? editor.Document?.FilePath,
_editor = editor
};
service._settled = _ => service.RequestFor( editor );
editor.TextSettled += service._settled;
service.Completed += diagnostics =>
{
if ( editor is { IsValid: true } )
editor.SetDiagnostics( diagnostics );
};
service.RequestFor( editor );
return service;
}
/// <summary>The scratch session this service compiles through.</summary>
public string SessionId { get; }
/// <summary>Path of the buffer being validated. Keep it in step with Save As.</summary>
public string FilePath { get; set; }
/// <summary>Whether the authoritative compiler tier runs at all. Local checks always do.</summary>
public bool UseCompiler { get; set; } = true;
/// <summary>Whether unknown calls are reported. On by default; off for buffers full of generated macros.</summary>
public bool ReportUnknownIdentifiers { get; set; } = true;
/// <summary>How long after the last keystroke a validation starts.</summary>
public int DebounceMs { get; set; } = PrismConstants.TextDebounceMs;
/// <summary>True while a validation is running.</summary>
public bool IsRunning { get; private set; }
/// <summary>The most recent result. Never null.</summary>
public IReadOnlyList<PrismDiagnostic> Last { get; private set; } = Array.Empty<PrismDiagnostic>();
/// <summary>Raised on the main thread when a validation starts.</summary>
public event Action Started;
/// <summary>Raised on the main thread with every completed result, in order.</summary>
public event Action<IReadOnlyList<PrismDiagnostic>> Completed;
// ---- driving ----------------------------------------------------------
/// <summary>Validates an editor's buffer. Safe to call on every keystroke.</summary>
public void RequestFor( CodeEditorWidget editor )
{
if ( editor is not { IsValid: true } || editor.Document is null )
return;
Request( editor.Document.Text, FilePath ?? editor.Document.FilePath, editor.Language );
}
/// <summary>
/// Validates a buffer after the debounce, cancelling whatever was already running. The local checks
/// are published as soon as they are done so the editor never waits on the compiler to show an
/// obviously broken line.
/// </summary>
public void Request( string text, string filePath, string language )
{
if ( _disposed )
return;
var generation = Interlocked.Increment( ref _generation );
// Resolving includes enumerates mounted projects, which is editor state. Do it here, on the
// thread the caller is on, rather than from the worker below.
PrismLog.Guard( "Prism.Text: warm include roots", IncludeResolver.Warm );
CancellationTokenSource cancellation;
lock ( _lock )
{
_inFlight?.Cancel();
_inFlight?.Dispose();
_inFlight = new CancellationTokenSource();
cancellation = _inFlight;
}
var token = cancellation.Token;
_ = Task.Run( async () =>
{
try
{
await Task.Delay( Math.Max( 0, DebounceMs ), token ).ConfigureAwait( false );
if ( generation != Volatile.Read( ref _generation ) )
return;
MainThread.Queue( () =>
{
if ( generation != Volatile.Read( ref _generation ) )
return;
IsRunning = true;
PrismLog.Guard( "Prism.Text: diagnostics started", () => Started?.Invoke() );
} );
var definition = LanguageDefinition.For( language );
var local = LocalChecks( text, filePath, definition, ReportUnknownIdentifiers );
Publish( generation, local );
if ( !UseCompiler )
{
Finish( generation );
return;
}
var deep = await Compile( text, filePath, definition, token ).ConfigureAwait( false );
if ( token.IsCancellationRequested )
return;
var all = new List<PrismDiagnostic>( local );
all.AddRange( deep );
Publish( generation, all );
Finish( generation );
}
catch ( OperationCanceledException )
{
// Superseded by a newer request; the newer one publishes.
}
catch ( Exception e )
{
PrismLog.Error( e, "Prism.Text: validation failed" );
Finish( generation );
}
} );
}
/// <summary>Runs a validation right now, with no debounce, and hands back the result.</summary>
public async Task<IReadOnlyList<PrismDiagnostic>> Validate( string text, string filePath, string language,
CancellationToken ct )
{
var definition = LanguageDefinition.For( language );
var results = new List<PrismDiagnostic>( LocalChecks( text, filePath, definition, ReportUnknownIdentifiers ) );
if ( UseCompiler )
results.AddRange( await Compile( text, filePath, definition, ct ).ConfigureAwait( false ) );
return results;
}
/// <summary>Cancels whatever is running and leaves the last published result in place.</summary>
public void Cancel()
{
Interlocked.Increment( ref _generation );
lock ( _lock )
{
_inFlight?.Cancel();
}
}
void Publish( int generation, IReadOnlyList<PrismDiagnostic> diagnostics )
{
MainThread.Queue( () =>
{
if ( _disposed || generation != Volatile.Read( ref _generation ) )
return;
Last = diagnostics;
PrismLog.Guard( "Prism.Text: diagnostics published", () => Completed?.Invoke( diagnostics ) );
} );
}
void Finish( int generation )
{
MainThread.Queue( () =>
{
if ( generation != Volatile.Read( ref _generation ) )
return;
IsRunning = false;
} );
}
// ---- local checks -----------------------------------------------------
/// <summary>
/// Everything that can be decided without a compiler, in a single lexer pass. Fast enough to run on
/// a keystroke and precise enough that the squiggle lands on the right token.
/// </summary>
public static IReadOnlyList<PrismDiagnostic> LocalChecks( string text, string filePath,
LanguageDefinition language, bool reportUnknownIdentifiers = true )
{
var results = new List<PrismDiagnostic>();
if ( string.IsNullOrEmpty( text ) )
return results;
language ??= LanguageDefinition.Hlsl;
PrismLog.Guard( "Prism.Text: local checks",
() => RunLocalChecks( text, filePath, language, reportUnknownIdentifiers, results ) );
return results;
}
static void RunLocalChecks( string text, string filePath, LanguageDefinition language,
bool reportUnknownIdentifiers, List<PrismDiagnostic> results )
{
var file = filePath ?? string.Empty;
var lines = text.Replace( "\r\n", "\n" ).Replace( '\r', '\n' ).Split( '\n' );
var lexer = Lexers.For( language.Id );
var state = LexState.Default;
var tokens = new List<Token>( 64 );
var symbols = DocumentSymbols.Parse( text, language.Id, filePath );
var declared = new HashSet<string>( StringComparer.Ordinal );
// A name declared anywhere in the buffer counts, wherever the caret is and whichever branch of
// the preprocessor it sits in: this pass answers "does anything declare it", not "is it in scope
// on line N", and a forward reference to a function defined lower down is perfectly normal.
foreach ( var symbol in symbols.All )
declared.Add( symbol.Name );
var includedMacros = new HashSet<string>( StringComparer.Ordinal );
// The same walk the completeness test does, so a name declared in a header we did read can never
// be reported as unknown just because the harvest stopped one level shallower than the test.
foreach ( var path in IncludeResolver.Transitive( text, filePath, IncludeDepth, IncludeFiles ) )
{
foreach ( var symbol in DocumentSymbols.ForFile( path, language.Id ).All )
{
declared.Add( symbol.Name );
if ( symbol.Kind == DocumentSymbolKind.Macro )
includedMacros.Add( symbol.Name );
}
}
// If any include could not be read — because it is missing, or because it is one of the
// fourteen that live inside the compiler and have no file at all — then we genuinely do not
// know what is in scope, and every "undeclared" warning would be a guess. Almost every real
// s&box shader reaches a compiler-embedded header eventually, so this is the common case, and
// staying quiet is the only honest thing to do. The real compile still catches everything.
if ( reportUnknownIdentifiers && !IncludeResolver.IsGraphComplete( text, filePath, CompletenessDepth, IncludeFiles ) )
reportUnknownIdentifiers = false;
var conditionals = reportUnknownIdentifiers ? new PreprocessorRegions( symbols, includedMacros ) : null;
var braces = new Stack<(char Kind, int Line, int Column)>();
var unknown = reportUnknownIdentifiers ? new List<PrismDiagnostic>() : null;
var unknownNames = reportUnknownIdentifiers ? new Dictionary<string, int>( StringComparer.Ordinal ) : null;
for ( var line = 0; line < lines.Length; line++ )
{
var content = lines[line];
var continued = ( state.Flags & LexFlags.PreprocessorContinuation ) != 0;
tokens.Clear();
state = lexer.Lex( content, state, tokens );
if ( conditionals is not null && !continued )
conditionals.Feed( content, line );
// A directive is its own little language: `defined(X)` is not a call, and a macro body's
// braces do not have to balance on the line they are written on.
var directive = continued || FirstKind( tokens ) == TokenKind.Preprocessor;
for ( var i = 0; i < tokens.Count; i++ )
{
var token = tokens[i];
if ( token.Start < 0 || token.Length <= 0 || token.Start + token.Length > content.Length )
continue;
if ( directive && token.Kind != TokenKind.Preprocessor )
continue;
var word = content.Substring( token.Start, token.Length );
switch ( token.Kind )
{
case TokenKind.String:
// A literal that runs to the end of the line without a closing quote never ends.
if ( token.Start + token.Length == content.Length && token.Length >= 1 &&
( token.Length == 1 || content[^1] != word[0] ) )
{
results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,
TextDiagnosticCode.Unterminated, "Unterminated string literal",
Span( file, line, token ) ) );
}
continue;
case TokenKind.Comment:
case TokenKind.DocComment:
case TokenKind.Whitespace:
continue;
case TokenKind.Punctuation:
Balance( results, braces, word, file, line, token );
continue;
case TokenKind.Preprocessor:
// Only at the head of a line: `##` inside a macro body lexes the same way.
if ( IsFirstOnLine( tokens, i ) )
CheckDirective( results, language, content, line, token, tokens, i, file );
continue;
case TokenKind.IncludePath:
continue;
case TokenKind.BlockKeyword:
if ( SboxSymbols.RejectedBlockNames.Contains( word ) )
{
results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,
TextDiagnosticCode.RejectedBlock,
$"s&box cannot compile a {word} block",
Span( file, line, token ),
$"The engine's .shader parser throws \"{word} does nothing!\" and the whole file " +
"fails to load, with no diagnostic of its own." ) );
}
continue;
}
if ( token.Kind is not ( TokenKind.Identifier or TokenKind.Intrinsic or TokenKind.FunctionName ) )
continue;
// A member access is resolved by the compiler, not by us.
if ( PrecededByAccess( content, token.Start ) )
continue;
if ( IntrinsicDb.TryGet( word, out var intrinsic ) )
{
if ( intrinsic.Deprecated )
{
results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,
TextDiagnosticCode.DeprecatedIntrinsic,
$"'{word}' was removed by Shader Model 6",
Span( file, line, token ), intrinsic.UnavailableReason ) );
continue;
}
if ( intrinsic.NeedsHigherShaderModel )
{
results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,
DiagnosticCode.ShaderModelTooHigh, intrinsic.UnavailableReason,
Span( file, line, token ) ) );
continue;
}
continue;
}
if ( !reportUnknownIdentifiers )
continue;
// Code the preprocessor may never reach cannot be judged: `ffx_a.h` calls `fract` and
// `mix` inside `#ifdef A_GLSL`, and whether that branch exists is decided by whoever
// includes it. Only unconditional code, and code a condition we could actually evaluate
// selected, is checked.
if ( !conditionals.IsLive )
continue;
// Only calls are reported. An unknown bare identifier is far more often a macro, a
// combo or something a header we could not resolve declares than a real mistake.
if ( !FollowedByCall( content, token.Start + token.Length ) )
continue;
if ( declared.Contains( word ) || language.IsKnownIdentifier( word ) ||
SboxSymbols.IsComboSymbol( word ) || SboxSymbols.IsEngineGlobal( word ) ||
SboxSymbols.IsModeFunction( word ) )
{
continue;
}
unknownNames.TryGetValue( word, out var uses );
unknownNames[word] = uses + 1;
unknown.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Warning,
TextDiagnosticCode.UnknownIdentifier,
$"Nothing in scope declares '{word}'",
Span( file, line, token ),
"It is not an intrinsic, an s&box symbol, or declared in this file or any include " +
"Prism could resolve. If it comes from a header, check the #include path." ) );
// Past either ceiling the verdict is already "incomplete file", so stop collecting: a
// generated header can otherwise pile up thousands of diagnostics nobody will ever see.
if ( unknown.Count > MaxUnknownIdentifiers || unknownNames.Count > MaxDistinctUnknownIdentifiers )
{
reportUnknownIdentifiers = false;
unknown.Clear();
}
}
}
if ( unknown is { Count: > 0 } && !LooksLikeFragment( unknown.Count, unknownNames ) )
results.AddRange( unknown );
if ( ( state.Flags & LexFlags.BlockComment ) != 0 )
{
results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.Unterminated,
"Unterminated block comment",
SourceSpan.AtLine( file, Math.Max( 1, lines.Length ) ),
"Everything after the last /* is being treated as a comment." ) );
}
while ( braces.Count > 0 )
{
var (kind, line, column) = braces.Pop();
results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.Unbalanced,
$"'{kind}' is never closed",
SourceSpan.At( file, line + 1, column + 1 ) ) );
}
results.AddRange( IncludeResolver.Validate( text, filePath, language ) );
}
/// <summary>
/// Whether the unknown names found in a buffer say "this file has a mistake in it" or "this file is
/// half of a translation unit". See <see cref="MaxDistinctUnknownIdentifiers"/> for the reasoning.
/// </summary>
static bool LooksLikeFragment( int total, Dictionary<string, int> names )
{
if ( total > MaxUnknownIdentifiers || names.Count > MaxDistinctUnknownIdentifiers )
return true;
foreach ( var uses in names.Values )
{
if ( uses > MaxUsesOfOneUnknown )
return true;
}
return false;
}
/// <summary>
/// A three-valued <c>#if</c> tracker: a region is <b>live</b>, <b>dead</b>, or — the case that
/// matters — <b>undecidable</b>.
/// <para>
/// Only the conditions that can be settled from the buffer alone are evaluated: <c>#if 0</c>,
/// <c>#if 1</c>, and <c>#ifdef</c> / <c>#ifndef</c> / <c>defined(X)</c> where <c>X</c> is
/// <c>#define</c>d earlier in this file or in an include we read. The "earlier" matters: an include
/// guard defines its own symbol <i>inside</i> the <c>#ifndef</c> it opens, and treating that as
/// already-defined would mark every file dead. Everything else — <c>#ifdef A_GLSL</c>,
/// <c>#if ( S_MODE == 2 )</c>, any arithmetic — stays undecidable, because the symbol may be defined
/// by the shader that includes this one or by the engine's own preprocessor.
/// </para>
/// </summary>
sealed class PreprocessorRegions
{
enum Branch { Live, Dead, Unknown }
readonly Dictionary<string, int> _defined = new( StringComparer.Ordinal );
readonly List<Branch> _stack = new();
string _guard;
int _guardDepth;
public PreprocessorRegions( DocumentSymbols symbols, HashSet<string> fromIncludes )
{
if ( symbols is not null )
{
foreach ( var symbol in symbols.All )
{
if ( symbol.Kind != DocumentSymbolKind.Macro )
continue;
if ( !_defined.TryGetValue( symbol.Name, out var first ) || symbol.Line < first )
_defined[symbol.Name] = symbol.Line;
}
}
// A macro from an include is in scope from the first line, so it gets a line number no
// directive in this buffer can precede.
if ( fromIncludes is null )
return;
foreach ( var name in fromIncludes )
_defined.TryAdd( name, int.MinValue );
}
/// <summary>True when nothing on the conditional stack is dead or undecidable.</summary>
public bool IsLive
{
get
{
for ( var i = 0; i < _stack.Count; i++ )
{
if ( _stack[i] != Branch.Live )
return false;
}
return true;
}
}
/// <summary>Feeds one physical line, which is a no-op unless it opens or closes a region.</summary>
public void Feed( string line, int lineIndex )
{
var i = 0;
while ( i < line.Length && ( line[i] == ' ' || line[i] == '\t' ) )
i++;
if ( i >= line.Length || line[i] != '#' )
return;
i++;
while ( i < line.Length && ( line[i] == ' ' || line[i] == '\t' ) )
i++;
var nameStart = i;
while ( i < line.Length && ( char.IsLetterOrDigit( line[i] ) || line[i] == '_' ) )
i++;
var directive = line.Substring( nameStart, i - nameStart );
var rest = i < line.Length ? line.Substring( i ) : string.Empty;
var guard = _guard;
_guard = null;
switch ( directive )
{
case "if":
_stack.Add( Evaluate( rest, lineIndex ) );
return;
case "ifdef":
_stack.Add( DefinedBefore( FirstWord( rest ), lineIndex ) ? Branch.Live : Branch.Unknown );
return;
case "ifndef":
var undefined = FirstWord( rest );
_stack.Add( DefinedBefore( undefined, lineIndex ) ? Branch.Dead : Branch.Unknown );
// Remember it in case the next directive turns out to be its include guard.
_guard = undefined;
_guardDepth = _stack.Count;
return;
case "define":
// `#ifndef FOO_H` / `#define FOO_H` is an include guard, and the first inclusion always
// takes it. Without this, every guarded header would be one big undecidable region and
// nothing in it would ever be checked.
if ( guard is not null && guard.Length > 0 && _stack.Count == _guardDepth &&
_stack[^1] == Branch.Unknown && FirstWord( rest ) == guard )
{
_stack[^1] = Branch.Live;
}
return;
case "elif":
// The branch before this one was live, so this one cannot be; otherwise re-evaluate.
if ( _stack.Count > 0 )
_stack[^1] = _stack[^1] == Branch.Live ? Branch.Dead : Evaluate( rest, lineIndex );
return;
case "else":
if ( _stack.Count > 0 )
{
_stack[^1] = _stack[^1] switch
{
Branch.Live => Branch.Dead,
Branch.Dead => Branch.Live,
_ => Branch.Unknown
};
}
return;
case "endif":
if ( _stack.Count > 0 )
_stack.RemoveAt( _stack.Count - 1 );
return;
}
}
Branch Evaluate( string expression, int lineIndex )
{
var text = expression.Trim();
// Strip one layer of wrapping parentheses: `#if ( 0 )` is written as often as `#if 0`.
while ( text.Length > 2 && text[0] == '(' && text[^1] == ')' )
text = text.Substring( 1, text.Length - 2 ).Trim();
if ( text == "0" )
return Branch.Dead;
if ( text == "1" )
return Branch.Live;
var negated = text.StartsWith( "!", StringComparison.Ordinal );
if ( negated )
text = text.Substring( 1 ).TrimStart();
if ( !text.StartsWith( "defined", StringComparison.Ordinal ) )
return Branch.Unknown;
var argument = text.Substring( "defined".Length ).Trim();
while ( argument.Length > 2 && argument[0] == '(' && argument[^1] == ')' )
argument = argument.Substring( 1, argument.Length - 2 ).Trim();
var name = FirstWord( argument );
// `defined(A) && defined(B)` leaves a tail behind; anything left over is not decidable.
if ( name.Length == 0 || name.Length != argument.Length )
return Branch.Unknown;
if ( !DefinedBefore( name, lineIndex ) )
return Branch.Unknown;
return negated ? Branch.Dead : Branch.Live;
}
bool DefinedBefore( string name, int lineIndex ) =>
name.Length > 0 && _defined.TryGetValue( name, out var line ) && line < lineIndex;
static string FirstWord( string text )
{
var i = 0;
while ( i < text.Length && ( text[i] == ' ' || text[i] == '\t' || text[i] == '(' ) )
i++;
var start = i;
while ( i < text.Length && ( char.IsLetterOrDigit( text[i] ) || text[i] == '_' ) )
i++;
return text.Substring( start, i - start );
}
}
static void Balance( List<PrismDiagnostic> results, Stack<(char, int, int)> braces, string word,
string file, int line, Token token )
{
if ( word.Length != 1 )
return;
var c = word[0];
if ( c is '{' or '(' or '[' )
{
braces.Push( (c, line, token.Start) );
return;
}
if ( c is not ( '}' or ')' or ']' ) )
return;
var expected = c switch { '}' => '{', ')' => '(', _ => '[' };
if ( braces.Count == 0 )
{
results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.Unbalanced,
$"'{c}' has no matching '{expected}'", Span( file, line, token ) ) );
return;
}
var top = braces.Peek();
if ( top.Item1 != expected )
{
results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.Unbalanced,
$"'{c}' closes a '{top.Item1}' opened on line {top.Item2 + 1}", Span( file, line, token ) ) );
}
braces.Pop();
}
static TokenKind FirstKind( List<Token> tokens )
{
for ( var i = 0; i < tokens.Count; i++ )
{
if ( tokens[i].Kind != TokenKind.Whitespace )
return tokens[i].Kind;
}
return TokenKind.None;
}
static bool IsFirstOnLine( List<Token> tokens, int index )
{
for ( var i = 0; i < index; i++ )
{
if ( tokens[i].Kind != TokenKind.Whitespace )
return false;
}
return true;
}
static void CheckDirective( List<PrismDiagnostic> results, LanguageDefinition language, string content,
int line, Token hash, List<Token> tokens, int index, string file )
{
// The lexer emits `#include` as one token; a lexer that splits the hash off has to work too.
var name = content.Substring( hash.Start, hash.Length ).TrimStart( '#' ).Trim();
var end = hash.Start + hash.Length;
if ( name.Length == 0 )
{
if ( index + 1 >= tokens.Count )
return;
var next = tokens[index + 1];
if ( next.Start < 0 || next.Start + next.Length > content.Length )
return;
name = content.Substring( next.Start, next.Length );
end = next.Start + next.Length;
}
if ( language.IsDirective( name ) )
return;
// `# 42 "file"` is a line marker the preprocessor emits; never a mistake in authored code.
if ( name.Length == 0 || char.IsDigit( name[0] ) )
return;
results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.UnknownDirective,
$"Unknown preprocessor directive '#{name}'",
new SourceSpan( file, line + 1, hash.Start + 1, line + 1, end + 1 ) ) );
}
static bool PrecededByAccess( string content, int start )
{
var i = start - 1;
while ( i >= 0 && content[i] == ' ' )
i--;
if ( i < 0 )
return false;
if ( content[i] == '.' )
return true;
return i >= 1 && content[i] == ':' && content[i - 1] == ':';
}
static bool FollowedByCall( string content, int end )
{
for ( var i = end; i < content.Length; i++ )
{
if ( content[i] == ' ' || content[i] == '\t' )
continue;
return content[i] == '(';
}
return false;
}
static SourceSpan Span( string file, int line, Token token ) =>
new( file, line + 1, token.Start + 1, line + 1, token.Start + token.Length + 1 );
// ---- compiler tier ----------------------------------------------------
async Task<IReadOnlyList<PrismDiagnostic>> Compile( string text, string filePath,
LanguageDefinition definition, CancellationToken ct )
{
if ( string.IsNullOrWhiteSpace( text ) )
return Array.Empty<PrismDiagnostic>();
var kind = definition?.Id ?? "hlsl";
if ( string.Equals( kind, "slang", StringComparison.OrdinalIgnoreCase ) )
return await ValidateSlang( text, filePath, ct ).ConfigureAwait( false );
return await ValidateShader( text, filePath, kind, ct ).ConfigureAwait( false );
}
TempWorkspace Workspace
{
get
{
lock ( _lock )
{
_workspace ??= new TempWorkspace( SessionId );
return _workspace;
}
}
}
async Task<IReadOnlyList<PrismDiagnostic>> ValidateShader( string text, string filePath, string kind,
CancellationToken ct )
{
var results = new List<PrismDiagnostic>();
var stem = Stem( filePath );
// A .shader is already a block file. A bare .hlsl is an include, and the engine refuses to
// compile one, so it gets wrapped in the smallest legal shader that will hold it.
var probe = string.Equals( kind, "vfx", StringComparison.OrdinalIgnoreCase )
? ShaderProbeBuilder.ForShaderFile( text, stem )
: ShaderProbeBuilder.ForHlsl( text, new ShaderProbeOptions { Name = stem } );
var fileName = $"{stem}.{PrismConstants.ShaderExtension}";
var workspace = Workspace;
if ( !workspace.IsValid || !workspace.Write( fileName, probe.Text ) )
{
results.Add( PrismDiagnostic.Info( DiagnosticCode.CompilerRaw,
"Prism could not write its scratch shader, so only local checks ran" ) );
return results;
}
var relative = workspace.Relative( fileName );
var options = new ShaderCompileOptions
{
ForceRecompile = false,
ConsoleOutput = false,
SingleThreaded = false
};
ShaderCompile.Results compiled = null;
try
{
// Back to the main thread before touching the engine compiler.
//
// EditorUtility.CompileShader reaches straight into native code: Shader.LoadFromSource, the
// vfx_vulkan.dll interface (lazily loaded by ShaderCompile's static constructor, on whatever
// thread happens to touch it first), FinalizeCompile, InitializeWrite and the native resource
// compiler. None of that is thread-affine by contract, and none of it is documented as safe to
// call from anywhere.
//
// Every call site in the engine invokes it from the main thread and simply awaits — see
// ShaderGraph's MainWindow, ShaderHooks and StartupLoadProject. The engine offloads the part
// that is actually parallel itself: ProgramSource.CompileCore wraps the combo loop in its own
// Task.Run/Parallel.ForEach. Wrapping the whole call in Task.Run, as this used to, put the
// serial native prologue and epilogue on a pool thread instead, which no engine code ever
// does. Awaiting from the main thread does not block the editor — the await yields, and the
// expensive combo loop still runs on the pool where the engine put it.
await MainThread.Wait();
PrismLog.Info( $"Prism.Text: compiling '{relative}' through the engine shader compiler" );
compiled = await EditorUtility.CompileShader( Editor.FileSystem.Root, relative, options, ct );
PrismLog.Info( $"Prism.Text: engine compile of '{relative}' returned " +
$"success={compiled?.Success}, programs={compiled?.Programs?.Count ?? 0}" );
}
catch ( OperationCanceledException )
{
throw;
}
catch ( Exception e )
{
PrismLog.Error( e, "Prism.Text: the engine shader compiler threw" );
results.Add( PrismDiagnostic.Error( DiagnosticCode.CompilerRaw,
"The engine shader compiler failed", null, e.Message ) );
return results;
}
if ( compiled is null )
return results;
var programs = compiled.Programs ?? new List<ShaderCompile.Results.Program>();
if ( !compiled.Success && programs.Count == 0 )
{
results.Add( probe.MapBack( CompilerOutputParser.BlockHeaderFailure( fileName ), filePath ) );
return results;
}
var seen = new HashSet<string>( StringComparer.Ordinal );
foreach ( var program in programs )
{
if ( program?.Output is not { Count: > 0 } )
continue;
var map = LineDirectiveMap.Build( program.Source, fileName ).Calibrate( probe.Text );
var parsed = CompilerOutputParser.Parse( program.Output, fileName );
var stage = CompilerOutputParser.Pretty( program.Name );
foreach ( var diagnostic in map.RemapAll( parsed, null, fileName ) )
{
var mapped = probe.MapBack( diagnostic, filePath );
if ( mapped is null )
continue;
// The same COMMON-block error is reported once per program; show it once.
if ( !seen.Add( $"{mapped.Severity}|{mapped.Code}|{mapped.Span}|{mapped.Message}" ) )
continue;
results.Add( string.IsNullOrEmpty( stage )
? mapped
: mapped with
{
Detail = string.IsNullOrWhiteSpace( mapped.Detail )
? $"Reported while compiling {stage}."
: $"{mapped.Detail}\nReported while compiling {stage}."
} );
}
}
return results;
}
async Task<IReadOnlyList<PrismDiagnostic>> ValidateSlang( string text, string filePath, CancellationToken ct )
{
var validator = SlangToolchain.CreateValidator();
if ( validator is null || !validator.Available )
{
return new[]
{
PrismDiagnostic.Info( TextDiagnosticCode.SlangNotValidated,
"Slang is not validated: no slangc was found",
null,
"Install the Slang toolchain from Preferences to have slangc check this file. Local " +
"checks still run, and nothing else in Prism depends on it." )
};
}
var probe = ShaderProbeBuilder.ForSlang( text, new ShaderProbeOptions { Name = Stem( filePath ) } );
var entries = ShaderProbeBuilder.DiscoverSlangEntryPoints( probe.Text );
var request = new SlangValidationRequest
{
Source = probe.Text,
EntryPoints = entries,
DisplayName = probe.FileName,
IncludePaths = IncludeResolver.SearchRoots.ToArray()
};
var diagnostics = await validator.Validate( request, ct ).ConfigureAwait( false );
return probe.MapBack( diagnostics, filePath );
}
static string Stem( string filePath )
{
var name = string.IsNullOrWhiteSpace( filePath )
? "buffer"
: System.IO.Path.GetFileNameWithoutExtension( filePath );
if ( string.IsNullOrWhiteSpace( name ) )
name = "buffer";
var clean = new System.Text.StringBuilder( name.Length );
foreach ( var c in name )
clean.Append( char.IsLetterOrDigit( c ) || c == '_' ? c : '_' );
return "prism_text_" + clean;
}
/// <summary>Stops any work, drops the scratch folder and detaches from the editor.</summary>
public void Dispose()
{
if ( _disposed )
return;
_disposed = true;
Cancel();
if ( _editor is { IsValid: true } && _settled is not null )
_editor.TextSettled -= _settled;
_editor = null;
_settled = null;
lock ( _lock )
{
PrismLog.Guard( "Prism.Text: drop scratch workspace", () => _workspace?.Dispose() );
_workspace = null;
_inFlight?.Dispose();
_inFlight = null;
}
}
}
Editor
library
using Editor.Prism.Core;
using Editor.Prism.Undo;
using Margin = Sandbox.UI.Margin;
namespace Editor.Prism.Ui;
/// <summary>One row of the History panel: a level in the undo stack.</summary>
internal sealed class HistoryRow
{
/// <summary>The stack level this row jumps to. Zero is the document as opened.</summary>
public int Level { get; init; }
/// <summary>The label of the edit.</summary>
public string Name { get; init; }
/// <summary>True when this is where the document currently sits.</summary>
public bool IsCurrent { get; init; }
/// <summary>True when this row is ahead of the current level, i.e. redoable.</summary>
public bool IsFuture { get; init; }
/// <summary>When the edit was committed.</summary>
public DateTime Time { get; init; }
/// <summary>How many characters the snapshot pair costs.</summary>
public int Size { get; init; }
/// <inheritdoc/>
public override string ToString() => $"{Level}. {Name}";
}
/// <summary>
/// The History dock: the undo stack, as a list you can click.
/// <para>
/// Prism records snapshots rather than commands, so every level is a complete, valid document and
/// jumping to any of them is exactly as safe as jumping to the one next door. That makes a clickable
/// history honest rather than a trap — which is why it gets a panel instead of two toolbar arrows.
/// </para>
/// </summary>
public sealed class HistoryPanel : Widget
{
/// <summary>The dock name this panel registers under. Frozen.</summary>
public const string DockName = "History";
readonly List<HistoryRow> _rows = new();
PrismSession _session;
PrismUndoStack _undo;
ListView _list;
PrismEmptyState _empty;
Label _status;
bool _rebuildQueued;
/// <summary>Build the panel. A null session is legal and shows the empty state.</summary>
public HistoryPanel( PrismSession session ) : base( null )
{
Name = "PrismHistory";
WindowTitle = DockName;
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
_list = new ListView( this )
{
ItemSize = new Vector2( -1, PrismPanelChrome.RowHeight ),
ItemPaint = PaintRow,
ItemClicked = OnRowClicked,
ItemActivated = OnRowClicked,
ItemContextMenu = OnRowContextMenu,
MultiSelect = false
};
Layout.Add( _list, 1 );
_empty = new PrismEmptyState( this, "history", "Nothing to undo yet",
"Every edit you make appears here. Click one to jump back to it." );
Layout.Add( _empty, 1 );
_status = new Label( string.Empty ) { Color = PrismTheme.TextMuted };
_status.ContentMargins = new Margin( 8, 2, 8, 4 );
Layout.Add( _status );
Bind( session );
}
/// <summary>Material icon shown on the dock tab.</summary>
public string DockIcon => "history";
/// <summary>The session this panel is bound to. Null is legal.</summary>
public PrismSession Session => _session;
// ---------------------------------------------------------------- binding ----
void Bind( PrismSession session )
{
Unbind();
_session = session;
if ( _session is not null )
{
_session.DocumentReplaced += OnDocumentReplaced;
Attach( _session.Undo );
}
Rebuild();
}
void Attach( PrismUndoStack undo )
{
if ( ReferenceEquals( _undo, undo ) ) return;
if ( _undo is not null )
{
_undo.Changed -= QueueRebuild;
_undo.Restored -= QueueRebuild;
}
_undo = undo;
if ( _undo is null ) return;
_undo.Changed += QueueRebuild;
_undo.Restored += QueueRebuild;
}
void Unbind()
{
Attach( null );
if ( _session is null ) return;
_session.DocumentReplaced -= OnDocumentReplaced;
_session = null;
}
/// <inheritdoc/>
public override void OnDestroyed()
{
Unbind();
base.OnDestroyed();
}
void OnDocumentReplaced()
{
Attach( _session?.Undo );
QueueRebuild();
}
void QueueRebuild()
{
if ( _rebuildQueued ) return;
_rebuildQueued = true;
MainThread.Queue( () =>
{
_rebuildQueued = false;
if ( !this.IsValid() ) return;
Rebuild();
} );
}
// ---------------------------------------------------------------- model ----
void Rebuild()
{
_rows.Clear();
if ( _undo is null )
{
_empty.Set( "No document", "Open a graph to see its edit history." );
_empty.Visible = true;
_list.Visible = false;
_status.Text = string.Empty;
_list.SetItems( _rows );
return;
}
var level = _undo.Level;
foreach ( var item in _undo.History )
{
_rows.Add( new HistoryRow
{
Level = item.Level,
Name = item.Name,
IsCurrent = item.IsCurrent,
IsFuture = item.Level > level,
Time = item.Time,
Size = item.Size
} );
}
var meaningful = _undo.Count > 0;
_empty.Visible = !meaningful;
_list.Visible = meaningful;
if ( !meaningful )
{
_empty.Set( "Nothing to undo yet", "Every edit you make appears here. Click one to jump back to it." );
}
_list.SetItems( _rows );
var current = _rows.FirstOrDefault( x => x.IsCurrent );
if ( current is not null )
{
_list.SelectItem( current, false, true );
PrismLog.Guard( "History: scroll to current", () => _list.ScrollTo( current ) );
}
_status.Text = _undo.Describe();
}
// ---------------------------------------------------------------- painting ----
void PaintRow( VirtualWidget item )
{
if ( item.Object is not HistoryRow row ) return;
var rect = item.Rect;
var index = _rows.IndexOf( row );
PrismPanelChrome.PaintRow( rect, index, item.Hovered, row.IsCurrent );
var alpha = row.IsFuture ? 0.42f : 1f;
var inner = rect.Shrink( PrismPanelChrome.Pad, 0f, PrismPanelChrome.Pad, 0f );
var icon = IconFor( row );
var color = row.IsCurrent ? PrismTheme.Accent : PrismTheme.TextMuted;
Paint.SetPen( color.WithAlpha( alpha ) );
Paint.DrawIcon( new Rect( inner.Left, inner.Top, 16f, inner.Height ), icon, 13f, TextFlag.Center );
var right = inner.Right;
if ( inner.Width > 170f )
{
var time = row.Time == default ? string.Empty : row.Time.ToLocalTime().ToString( "HH:mm:ss" );
if ( !string.IsNullOrEmpty( time ) )
{
Paint.SetFont( PrismTheme.FontFamily, 10, 400, false, true );
Paint.SetPen( PrismTheme.TextDisabled.WithAlpha( alpha ) );
Paint.DrawText( new Rect( right - 52f, inner.Top, 52f, inner.Height ), time,
TextFlag.RightCenter | TextFlag.SingleLine );
right -= 58f;
}
}
if ( row.IsCurrent && inner.Width > 220f )
{
Paint.SetFont( PrismTheme.FontFamily, 9, 600, false, true );
Paint.SetPen( PrismTheme.Accent );
Paint.DrawText( new Rect( right - 44f, inner.Top, 44f, inner.Height ), "CURRENT",
TextFlag.RightCenter | TextFlag.SingleLine );
right -= 50f;
}
var nameRect = new Rect( inner.Left + 20f, inner.Top,
MathF.Max( 20f, right - inner.Left - 20f ), inner.Height );
PrismPaint.Text( nameRect, row.Name,
( row.IsCurrent ? PrismTheme.TextPrimary : PrismTheme.TextSecondary ).WithAlpha( alpha ),
PrismTheme.BodySize, row.IsCurrent ? 500 : 400 );
}
/// <summary>
/// The glyph for an edit, matched on its label. Undo entries are labelled by the mutation API from
/// a small, stable vocabulary, so a prefix match is reliable and one unknown label degrades to a
/// generic pencil rather than a blank row.
/// </summary>
static string IconFor( HistoryRow row )
{
if ( row.Level == 0 ) return PrismIcons.Open;
var name = row.Name ?? string.Empty;
if ( name.StartsWith( "Add", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Add;
if ( name.StartsWith( "Delete", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Delete;
if ( name.StartsWith( "Move", StringComparison.OrdinalIgnoreCase ) ) return "open_with";
if ( name.StartsWith( "Resize", StringComparison.OrdinalIgnoreCase ) ) return "aspect_ratio";
if ( name.StartsWith( "Create Connection", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Connect;
if ( name.StartsWith( "Disconnect", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Disconnect;
if ( name.StartsWith( "Reroute", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Reroute;
if ( name.StartsWith( "Route", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Reroute;
if ( name.StartsWith( "Paste", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Paste;
if ( name.StartsWith( "Cut", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Cut;
if ( name.StartsWith( "Duplicate", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Duplicate;
if ( name.StartsWith( "Rename", StringComparison.OrdinalIgnoreCase ) ) return "edit";
if ( name.StartsWith( "Reorder", StringComparison.OrdinalIgnoreCase ) ) return "swap_vert";
if ( name.StartsWith( "Change Settings", StringComparison.OrdinalIgnoreCase ) ) return "settings";
if ( name.StartsWith( "Change Preview", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Preview;
if ( name.StartsWith( "Set", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Parameter;
if ( name.StartsWith( "Edit", StringComparison.OrdinalIgnoreCase ) ) return "edit";
return "edit_note";
}
/// <inheritdoc/>
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( PrismTheme.Panel );
Paint.DrawRect( LocalRect );
}
// ---------------------------------------------------------------- interaction ----
void OnRowClicked( object item )
{
if ( item is not HistoryRow row || _undo is null ) return;
if ( row.IsCurrent ) return;
if ( !_undo.JumpTo( row.Level ) ) return;
_session?.MarkDirty();
_session?.Touch();
}
void OnRowContextMenu( object item )
{
var menu = new Menu( this );
if ( item is HistoryRow row && !row.IsCurrent )
{
menu.AddOption( $"Jump To “{row.Name}”", "history", () => OnRowClicked( row ) );
menu.AddSeparator();
}
menu.AddOption( "Undo", PrismIcons.Undo, () => { _undo?.Undo(); _session?.Touch(); } )
.Enabled = _undo is { CanUndo: true };
menu.AddOption( "Redo", PrismIcons.Redo, () => { _undo?.Redo(); _session?.Touch(); } )
.Enabled = _undo is { CanRedo: true };
menu.AddSeparator();
menu.AddOption( "Clear History", PrismIcons.Delete, () => { _undo?.Clear(); Rebuild(); } );
menu.AddOption( "Copy Stack Dump", PrismIcons.Copy,
() => EditorUtility.Clipboard.Copy( _undo?.Dump() ?? string.Empty ) );
menu.OpenAtCursor( false );
}
}
Editor
library
using System.Text;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
namespace Editor.Prism.Compiler.Backends;
/// <summary>
/// A text buffer that remembers which graph node produced each line it wrote.
/// <para>
/// Every backend writes through this rather than a bare <see cref="StringBuilder"/>, because the
/// generated-line to <see cref="NodeId"/> map is what turns a raw compiler error into a selected
/// node. Losing it is losing the feature the built-in editor structurally cannot have.
/// </para>
/// </summary>
public sealed class HlslSourceBuilder
{
readonly StringBuilder _text = new();
readonly SourceMap _map = new();
int _line = 1;
int _indent;
/// <summary>Create a builder. Generated code uses hard tabs and CRLF, like everything else here.</summary>
public HlslSourceBuilder( string indent = "\t", string newLine = "\r\n" )
{
Indent = indent ?? "\t";
NewLine = string.IsNullOrEmpty( newLine ) ? "\r\n" : newLine;
}
/// <summary>One level of indentation.</summary>
public string Indent { get; }
/// <summary>The line ending written after every line.</summary>
public string NewLine { get; }
/// <summary>The map from emitted line to originating node.</summary>
public SourceMap SourceMap => _map;
/// <summary>The 1-based number of the line that will be written next.</summary>
public int LineNumber => _line;
/// <summary>Lines written so far.</summary>
public int LineCount => _line - 1;
/// <summary>Current indentation depth.</summary>
public int IndentLevel
{
get => _indent;
set => _indent = Math.Max( 0, value );
}
/// <summary>Indent until the returned scope is disposed.</summary>
public IDisposable Indented() => new IndentScope( this );
/// <summary>Write an empty line.</summary>
public HlslSourceBuilder Blank()
{
_text.Append( NewLine );
_line++;
return this;
}
/// <summary>Write one indented line with no origin.</summary>
public HlslSourceBuilder Write( string text ) => Write( text, NodeId.None );
/// <summary>Write one indented line and record which node produced it.</summary>
public HlslSourceBuilder Write( string text, NodeId origin )
{
if ( text is null ) return this;
if ( text.Length > 0 )
{
for ( int i = 0; i < _indent; i++ ) _text.Append( Indent );
_text.Append( text );
}
_text.Append( NewLine );
_map.Add( _line, origin );
_line++;
return this;
}
/// <summary>
/// Write a multi-line chunk, stripping the shared leading whitespace so a template written at any
/// C# indentation lands correctly. Every produced line is attributed to <paramref name="origin"/>.
/// </summary>
public HlslSourceBuilder WriteBlock( string text, NodeId origin = default )
{
if ( string.IsNullOrEmpty( text ) ) return this;
foreach ( var line in SboxShaderTemplates.Dedent( text ).Split( '\n' ) )
{
Write( line.TrimEnd(), origin );
}
return this;
}
/// <summary>Write an opening brace and indent.</summary>
public HlslSourceBuilder Open( NodeId origin = default )
{
Write( "{", origin );
_indent++;
return this;
}
/// <summary>Outdent and write a closing brace.</summary>
public HlslSourceBuilder Close( string suffix = null, NodeId origin = default )
{
_indent = Math.Max( 0, _indent - 1 );
Write( "}" + ( suffix ?? string.Empty ), origin );
return this;
}
/// <inheritdoc/>
public override string ToString() => _text.ToString();
sealed class IndentScope : IDisposable
{
readonly HlslSourceBuilder _owner;
public IndentScope( HlslSourceBuilder owner )
{
_owner = owner;
_owner._indent++;
}
public void Dispose() => _owner._indent = Math.Max( 0, _owner._indent - 1 );
}
}
/// <summary>
/// Lowers <see cref="IrModule"/> expressions, statements, helpers and declarations into HLSL text.
/// <para>
/// Deliberately knows nothing about the VFX block file — that is <see cref="SboxShaderWriter"/>'s
/// job. The split is what lets the same HLSL feed a probe compile from the text editor, a
/// <c>.shader</c>, or a future material-only target.
/// </para>
/// </summary>
public sealed class HlslEmitter
{
const int PrecedencePrimary = 16;
const int PrecedencePostfix = 15;
const int PrecedenceUnary = 13;
const int PrecedenceLowest = 0;
readonly HashSet<string> _reported = new();
/// <summary>Create an emitter for one module.</summary>
public HlslEmitter( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics )
{
Module = module;
Options = options ?? BackendEmitOptions.Default;
Diagnostics = diagnostics ?? new DiagnosticSink();
}
/// <summary>The module being lowered.</summary>
public IrModule Module { get; }
/// <summary>Emission options.</summary>
public BackendEmitOptions Options { get; }
/// <summary>Where problems go. A backend never throws for user error.</summary>
public DiagnosticSink Diagnostics { get; }
/// <summary>Which HLSL flavour to write.</summary>
public HlslDialect Dialect => Options.Dialect;
/// <summary>The stage currently being written. Drives builtin lowering and stage legality.</summary>
public ShaderStage Stage { get; set; }
/// <summary>The domain the module targets.</summary>
public ShaderDomain Domain => Module?.Meta?.Domain ?? ShaderDomain.Surface;
/// <summary>True when comments should be written into the output.</summary>
public bool WantsComments => Options.EmitComments || Options.DebugSymbols;
NodeId _origin;
// ---- expressions ------------------------------------------------------
/// <summary>Render an expression as HLSL, parenthesised only where precedence requires it.</summary>
public string Expression( IrExpr expr ) => Expression( expr, PrecedenceLowest );
string Expression( IrExpr expr, int minPrecedence )
{
if ( expr is null ) return "0";
var (text, precedence) = Render( expr );
return precedence < minPrecedence ? $"( {text} )" : text;
}
(string Text, int Precedence) Render( IrExpr expr )
{
switch ( expr )
{
case IrConst c:
return ( HlslIntrinsics.Literal( c.Type, c.Value ), PrecedencePrimary );
case IrVar v:
return ( v.Name ?? "0", PrecedencePrimary );
// Sanitised to match SboxMaterialBinding.Declare: the block parser is ASCII only, so a
// declaration and every reference to it have to agree on the same renamed spelling.
case IrGlobalRef g:
return ( SboxShaderTemplates.SafeIdentifier( g.Decl?.Name ) ?? "0", PrecedencePrimary );
case IrBuiltinRef b:
return ( RenderBuiltin( b ), PrecedencePrimary );
case IrCall call:
return ( RenderCall( call ), PrecedencePrimary );
case IrHelperCall helper:
return ( RenderHelperCall( helper ), PrecedencePrimary );
case IrBinary binary:
return RenderBinary( binary );
case IrUnary unary:
{
var symbol = UnaryOps.Symbol( unary.Op );
var operand = Expression( unary.V, PrecedenceUnary );
// A unary operand is not parenthesised — unary binds tighter than everything below it —
// so a nested negate would print `--x`, which DXC and Slang both lex as pre-decrement:
// an error on a non-lvalue and a different program on one. `-(-1.0f)` has the same
// shape. A single space separates the two tokens and costs nothing; `!!x` and `~~x` are
// legal but read better spaced too. Reachable with folding off, which the docs
// recommend for bug reports.
var gap = operand.Length > 0 && operand[0] == symbol[0] ? " " : string.Empty;
return ( $"{symbol}{gap}{operand}", PrecedenceUnary );
}
case IrSwizzle swizzle:
return ( $"{Expression( swizzle.V, PrecedencePostfix )}.{HlslIntrinsics.NormalizeSwizzle( swizzle.Mask )}",
PrecedencePostfix );
case IrConstruct construct:
return ( RenderConstruct( construct ), PrecedencePrimary );
case IrCast cast:
return RenderCast( cast );
case IrSelect select:
return ( $"select( {Expression( select.C )}, {Expression( select.A )}, {Expression( select.B )} )",
PrecedencePrimary );
case IrIndex index:
return ( $"{Expression( index.V, PrecedencePostfix )}[{Expression( index.I )}]", PrecedencePostfix );
case IrMember member:
return ( $"{Expression( member.V, PrecedencePostfix )}.{member.Field}", PrecedencePostfix );
default:
Report( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed,
$"The HLSL backend does not know how to write a {expr.GetType().Name}." );
return ( HlslIntrinsics.Fallback( expr.Type ), PrecedencePrimary );
}
}
string RenderBuiltin( IrBuiltinRef builtin )
{
var text = HlslIntrinsics.BuiltinExpression( builtin.Id, Stage, Domain );
if ( !string.IsNullOrEmpty( text ) ) return text;
Report( DiagnosticSeverity.Error, DiagnosticCode.BackendUnsupported,
$"'{builtin.Id}' has no representation in the {Stage.DisplayName().ToLowerInvariant()} stage of a {Domain} shader.",
$"The s&box shader environment provides no expression for it here. Compute the value where it exists and pass it through a varying, or bind it as a render attribute." );
return HlslIntrinsics.Fallback( builtin.Type );
}
string RenderCall( IrCall call )
{
var id = call.Id;
var args = call.Args ?? Array.Empty<IrExpr>();
var rendered = new string[args.Length];
var types = new ShaderType[args.Length];
for ( int i = 0; i < args.Length; i++ )
{
rendered[i] = Expression( args[i] );
types[i] = args[i]?.Type ?? ShaderType.Void;
}
var info = IntrinsicCatalog.Get( id );
if ( !info.IsAvailableOnTarget )
{
Report( DiagnosticSeverity.Error, DiagnosticCode.ShaderModelTooHigh,
$"'{info.Name}' requires SM {info.MinShaderModel}; s&box compiles at SM {ShaderModel.Target} (Vulkan).",
info.Description );
}
else if ( !IntrinsicCatalog.IsLegalIn( id, Stage ) )
{
if ( HlslIntrinsics.TryLowerForStage( id, Stage, out var lowered ) )
{
Report( DiagnosticSeverity.Info, DiagnosticCode.SampleLowered,
$"'{info.Name}' was lowered to '{IntrinsicCatalog.Name( lowered )}' because the {Stage.DisplayName().ToLowerInvariant()} stage has no screen-space derivatives.",
"Mip selection falls back to level 0. Feed an explicit LOD if that is not what you want." );
}
else
{
Report( DiagnosticSeverity.Error, DiagnosticCode.StageViolation,
$"'{info.Name}' is not legal in the {Stage.DisplayName().ToLowerInvariant()} stage.",
"There is no meaning-preserving substitute. Move the operation to the pixel stage." );
}
}
if ( !info.AcceptsArity( args.Length ) )
{
Report( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed,
$"'{info.Name}' takes {info.MinArgs}..{info.MaxArgs} arguments but was given {args.Length}." );
}
return HlslIntrinsics.Call( id, rendered, types, Stage, Dialect );
}
string RenderHelperCall( IrHelperCall call )
{
var args = call.Args ?? Array.Empty<IrExpr>();
if ( call.Fn is null )
{
Report( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed, "A helper call has no helper attached." );
return HlslIntrinsics.Fallback( call.Type );
}
if ( args.Length == 0 ) return $"{call.Fn.Name}()";
var parts = new string[args.Length];
for ( int i = 0; i < args.Length; i++ ) parts[i] = Expression( args[i] );
return $"{call.Fn.Name}( {string.Join( ", ", parts )} )";
}
(string Text, int Precedence) RenderBinary( IrBinary binary )
{
var precedence = BinaryOps.Precedence( binary.Op );
if ( BinaryOps.IsShortCircuit( binary.Op ) && !( binary.L?.Type.IsScalar ?? true ) )
{
// && and || short-circuit and therefore never work component-wise. The IR is supposed to
// use Intrinsic.AndFn / OrFn for vectors; recover instead of emitting silently wrong code.
var fn = binary.Op == BinaryOp.LogicalAnd ? Intrinsic.AndFn : Intrinsic.OrFn;
Report( DiagnosticSeverity.Warning, DiagnosticCode.BackendUnsupported,
$"'{BinaryOps.Symbol( binary.Op )}' short-circuits and cannot be applied component-wise; emitted '{HlslIntrinsics.Spelling( fn )}' instead." );
return ( $"{HlslIntrinsics.Spelling( fn )}( {Expression( binary.L )}, {Expression( binary.R )} )",
PrecedencePrimary );
}
var left = Expression( binary.L, precedence );
var right = Expression( binary.R, precedence + 1 );
return ( $"{left} {BinaryOps.Symbol( binary.Op )} {right}", precedence );
}
string RenderConstruct( IrConstruct construct )
{
var parts = construct.Parts ?? Array.Empty<IrExpr>();
var rendered = new string[parts.Length];
for ( int i = 0; i < parts.Length; i++ ) rendered[i] = Expression( parts[i] );
if ( construct.Type.IsStruct )
{
if ( parts.Length == 0 ) return $"( {construct.Type.Hlsl} )0";
// Slang gives every struct a synthesised constructor, which composes anywhere an expression
// can appear. Plain HLSL only has the initialiser list, which is legal solely as the
// right-hand side of a declaration — the one place the IR ever builds a struct.
return Dialect == HlslDialect.SboxSlang
? $"{construct.Type.Hlsl}( {string.Join( ", ", rendered )} )"
: $"{{ {string.Join( ", ", rendered )} }}";
}
if ( parts.Length == 0 ) return HlslIntrinsics.Fallback( construct.Type );
return $"{construct.Type.Hlsl}( {string.Join( ", ", rendered )} )";
}
(string Text, int Precedence) RenderCast( IrCast cast )
{
var source = cast.V?.Type ?? ShaderType.Void;
var target = cast.Type;
switch ( cast.Kind )
{
case CastKind.Bitcast:
var reinterpret = target.Scalar switch
{
ScalarKind.Int => Intrinsic.AsInt,
ScalarKind.UInt => Intrinsic.AsUint,
_ => Intrinsic.AsFloat
};
return ( $"{IntrinsicCatalog.Name( reinterpret )}( {Expression( cast.V )} )", PrecedencePrimary );
case CastKind.Truncate when source.IsScalarOrVector && target.IsScalarOrVector &&
target.Components < source.Components &&
target.Scalar == source.Scalar:
return ( $"{Expression( cast.V, PrecedencePostfix )}.{HlslIntrinsics.LeadingMask( target.Components )}",
PrecedencePostfix );
case CastKind.Pad when source.IsScalarOrVector && target.IsScalarOrVector &&
target.Components > source.Components:
var padded = new List<string>( target.Components ) { Expression( cast.V ) };
for ( int i = source.Components; i < target.Components; i++ )
{
padded.Add( HlslIntrinsics.Number( cast.Fill, target.Scalar ) );
}
return ( $"{target.Hlsl}( {string.Join( ", ", padded )} )", PrecedencePrimary );
default:
return ( $"( {target.Hlsl} ){Expression( cast.V, PrecedenceUnary )}", PrecedenceUnary );
}
}
// ---- statements -------------------------------------------------------
/// <summary>Write a block's statements at the builder's current indentation.</summary>
public void WriteStatements( HlslSourceBuilder builder, IrBlock block )
{
if ( builder is null || block is null ) return;
foreach ( var statement in block.Statements ) WriteStatement( builder, statement );
}
/// <summary>Write a braced block.</summary>
public void WriteBracedBlock( HlslSourceBuilder builder, IrBlock block, NodeId origin )
{
builder.Open( origin );
WriteStatements( builder, block );
builder.Close( origin: origin );
}
/// <summary>Write one statement, recording every line it produces against its originating node.</summary>
public void WriteStatement( HlslSourceBuilder builder, IrStmt statement )
{
if ( builder is null || statement is null ) return;
var previous = _origin;
_origin = statement.Origin;
try
{
switch ( statement )
{
case IrDecl decl:
builder.Write( decl.Init is null
? $"{decl.Type.Hlsl} {decl.Name};"
: $"{decl.Type.Hlsl} {decl.Name} = {Expression( decl.Init )};", decl.Origin );
break;
case IrAssign assign:
builder.Write( $"{Expression( assign.Target )} = {Expression( assign.Value )};", assign.Origin );
break;
case IrIf branch:
builder.Write( $"if ( {Expression( branch.Cond )} )", branch.Origin );
WriteBracedBlock( builder, branch.Then, branch.Origin );
if ( branch.Else is { IsEmpty: false } )
{
builder.Write( "else", branch.Origin );
WriteBracedBlock( builder, branch.Else, branch.Origin );
}
break;
case IrFor loop:
var counter = string.IsNullOrEmpty( loop.Var ) ? "n" : loop.Var;
builder.Write(
$"for ( int {counter} = 0; {counter} < ( int )( {Expression( loop.Count )} ); {counter}++ )",
loop.Origin );
WriteBracedBlock( builder, loop.Body, loop.Origin );
break;
case IrWhile loop:
builder.Write( $"while ( {Expression( loop.Cond )} )", loop.Origin );
WriteBracedBlock( builder, loop.Body, loop.Origin );
break;
case IrBreak:
builder.Write( "break;", statement.Origin );
break;
case IrContinue:
builder.Write( "continue;", statement.Origin );
break;
case IrReturn ret:
builder.Write( ret.Value is null ? "return;" : $"return {Expression( ret.Value )};", ret.Origin );
break;
case IrDiscard:
if ( !Stage.CanDiscard() )
{
Report( DiagnosticSeverity.Error, DiagnosticCode.StageViolation,
$"A fragment can only be discarded in the pixel stage, not in the {Stage.DisplayName().ToLowerInvariant()} stage." );
break;
}
builder.Write( "discard;", statement.Origin );
break;
case IrExprStmt expression:
builder.Write( $"{Expression( expression.Value )};", expression.Origin );
break;
case IrComment comment:
if ( !WantsComments || string.IsNullOrWhiteSpace( comment.Text ) ) break;
foreach ( var line in comment.Text.Replace( "\r\n", "\n" ).Split( '\n' ) )
{
builder.Write( $"// {line.Trim()}", comment.Origin );
}
break;
case IrScope scope:
WriteBracedBlock( builder, scope.Body, scope.Origin );
break;
case IrPreprocessorIf guard:
{
// The .shader writer handles guards itself; this is the standalone emit the text
// editor probe-compiles, and it has to produce the same directives rather than
// reporting the statement as one the backend does not understand.
var directive = IrPreprocessor.OpenDirective( guard.Condition );
if ( string.IsNullOrEmpty( directive ) )
{
// An unusable condition must not become a directive the preprocessor rejects: a
// preprocessor error has no line that maps back to a node. Emitting both sides
// unguarded keeps the shader compiling and costs only the exclusion.
Report( DiagnosticSeverity.Warning, DiagnosticCode.InvalidBlock,
"A compile-time branch had no usable combo condition, so both of its sides were emitted.",
"Bind the node to a graph keyword. Without one there is nothing for the preprocessor to test, and the branch cannot be resolved at compile time." );
WriteStatements( builder, guard.Then );
WriteStatements( builder, guard.Else );
break;
}
builder.Write( directive, guard.Origin );
WriteStatements( builder, guard.Then );
if ( guard.HasElse )
{
builder.Write( IrPreprocessor.ElseDirective, guard.Origin );
WriteStatements( builder, guard.Else );
}
builder.Write( IrPreprocessor.EndDirective, guard.Origin );
break;
}
default:
Report( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed,
$"The HLSL backend does not know how to write a {statement.GetType().Name}." );
break;
}
}
finally
{
_origin = previous;
}
}
// ---- declarations -----------------------------------------------------
/// <summary>
/// The module's helpers in dependency order, deduplicated by name.
/// <para>
/// A same-name, different-body collision is a hard error naming both bodies, unlike the built-in
/// editor's process-global function table which silently keeps whichever registered first.
/// </para>
/// </summary>
public IReadOnlyList<HelperFunction> OrderedHelpers()
{
var ordered = new List<HelperFunction>();
if ( Module is null ) return ordered;
var accepted = new Dictionary<string, HelperFunction>( StringComparer.Ordinal );
var visiting = new HashSet<string>( StringComparer.Ordinal );
foreach ( var helper in Module.Helpers ) Visit( helper );
return ordered;
void Visit( HelperFunction helper )
{
if ( helper is null || string.IsNullOrWhiteSpace( helper.Name ) ) return;
if ( accepted.TryGetValue( helper.Name, out var existing ) )
{
if ( existing.ConflictsWith( helper ) )
{
Report( DiagnosticSeverity.Error, DiagnosticCode.HelperCollision,
$"Two different helper functions are both named '{helper.Name}'.",
$"Signatures: '{existing.SignatureHlsl}' and '{helper.SignatureHlsl}'. Helper names are the deduplication key, so they must be unique per module." );
}
return;
}
if ( !visiting.Add( helper.Name ) )
{
Report( DiagnosticSeverity.Error, DiagnosticCode.HelperCollision,
$"Helper function '{helper.Name}' depends on itself.",
"Helper requirement chains must form a directed acyclic graph." );
return;
}
foreach ( var requirement in helper.Requires ?? Array.Empty<HelperFunction>() ) Visit( requirement );
visiting.Remove( helper.Name );
accepted[helper.Name] = helper;
ordered.Add( helper );
}
}
/// <summary>
/// The helpers a stage's own code can actually reach, transitively.
/// <para>
/// <see cref="HelperFunction.Stages"/> says where a helper <i>may</i> be written, not where it is
/// wanted: a pure-maths helper declares <see cref="StageMask.All"/> and would otherwise be emitted
/// into the vertex program of every graph whose pixel program happens to call it. DXC drops the dead
/// code, but Prism ships a viewer for the generated text, and hundreds of lines of functions the
/// program never calls is the difference between a shader a person can read and one they cannot.
/// </para>
/// </summary>
public HashSet<string> ReachableHelpers( ShaderStage stage, IReadOnlyList<HelperFunction> helpers )
{
var reachable = new HashSet<string>( StringComparer.Ordinal );
if ( Module is null || helpers is null || helpers.Count == 0 ) return reachable;
var byName = new Dictionary<string, HelperFunction>( StringComparer.Ordinal );
foreach ( var helper in helpers )
{
if ( !string.IsNullOrEmpty( helper?.Name ) ) byName[helper.Name] = helper;
}
foreach ( var function in Module.Functions )
{
if ( function is null ) continue;
var belongs = function.IsEntryPoint
? function.Stage == stage
: function.Stage == ShaderStage.None || function.Stage == stage;
if ( !belongs ) continue;
foreach ( var statement in WalkStatements( function.Body ) )
{
foreach ( var expression in StatementExpressions( statement ) )
{
foreach ( var node in IrExprUtil.Walk( expression ) )
{
if ( node is IrHelperCall call && !string.IsNullOrEmpty( call.Fn?.Name ) ) Pull( call.Fn.Name );
}
}
}
}
return reachable;
void Pull( string name )
{
if ( !byName.TryGetValue( name, out var helper ) ) return;
if ( !reachable.Add( name ) ) return;
foreach ( var requirement in helper.Requires ?? Array.Empty<HelperFunction>() )
{
if ( !string.IsNullOrEmpty( requirement?.Name ) ) Pull( requirement.Name );
}
// A helper body is author-supplied text, so one helper calling another need not have been
// declared through Requires. Naming another helper anywhere in the body pulls it in: over-
// including costs one dead function, under-including costs a compile error.
var body = helper.BodyFor( PrismConstants.BackendHlsl );
if ( string.IsNullOrEmpty( body ) ) return;
foreach ( var candidate in byName.Keys.ToArray() )
{
if ( reachable.Contains( candidate ) ) continue;
if ( body.Contains( candidate, StringComparison.Ordinal ) ) Pull( candidate );
}
}
}
/// <summary>Every statement in a block, including the ones nested inside control flow.</summary>
static IEnumerable<IrStmt> WalkStatements( IrBlock block )
{
if ( block is null ) yield break;
foreach ( var statement in block.Statements )
{
if ( statement is null ) continue;
yield return statement;
IrBlock[] nested = statement switch
{
IrIf branch => [branch.Then, branch.Else],
IrFor loop => [loop.Body],
IrWhile loop => [loop.Body],
IrScope scope => [scope.Body],
_ => null
};
if ( nested is null ) continue;
foreach ( var child in nested )
{
foreach ( var inner in WalkStatements( child ) ) yield return inner;
}
}
}
/// <summary>The expressions one statement holds directly.</summary>
static IEnumerable<IrExpr> StatementExpressions( IrStmt statement )
{
switch ( statement )
{
case IrDecl decl:
yield return decl.Init;
break;
case IrAssign assign:
yield return assign.Target;
yield return assign.Value;
break;
case IrIf branch:
yield return branch.Cond;
break;
case IrFor loop:
yield return loop.Count;
break;
case IrWhile loop:
yield return loop.Cond;
break;
case IrReturn returned:
yield return returned.Value;
break;
case IrExprStmt expression:
yield return expression.Value;
break;
}
}
/// <summary>Write the helper bodies a stage actually calls, in dependency order.</summary>
public void WriteHelpers( HlslSourceBuilder builder, ShaderStage stage, IReadOnlyList<HelperFunction> helpers )
{
if ( builder is null || helpers is null ) return;
var reachable = ReachableHelpers( stage, helpers );
foreach ( var helper in helpers )
{
if ( !helper.Stages.Contains( stage ) ) continue;
if ( !reachable.Contains( helper.Name ) ) continue;
var body = helper.BodyFor( PrismConstants.BackendHlsl );
if ( string.IsNullOrWhiteSpace( body ) )
{
Report( DiagnosticSeverity.Error, DiagnosticCode.HelperCollision,
$"Helper function '{helper.Name}' has no HLSL body." );
continue;
}
if ( helper.MinShaderModel > ShaderModel.Target )
{
Report( DiagnosticSeverity.Error, DiagnosticCode.ShaderModelTooHigh,
$"Helper function '{helper.Name}' requires SM {helper.MinShaderModel}; s&box compiles at SM {ShaderModel.Target} (Vulkan)." );
}
if ( Dialect == HlslDialect.StrictHlsl2021 ) CheckStrictDialect( helper, body );
builder.WriteBlock( body );
builder.Blank();
}
}
static readonly string[] s_slangOnlySyntax =
[
"[mutating]", "__init", "extension ", "interface ", "associatedtype", "no_diff", "__generic",
"property ", "[ForceInline]", "[Differentiable]"
];
/// <summary>
/// Warn about Slang-only syntax in a helper body when the graph asked for portable HLSL 2021.
/// <para>
/// Prism's own emission already avoids these constructs in the strict dialect; a helper's body is
/// author-supplied text, so the best we can do is name the construct rather than let DXC reject it
/// with a message pointing at a generated line.
/// </para>
/// </summary>
void CheckStrictDialect( HelperFunction helper, string body )
{
foreach ( var syntax in s_slangOnlySyntax )
{
if ( body.IndexOf( syntax, StringComparison.Ordinal ) < 0 ) continue;
Report( DiagnosticSeverity.Warning, DiagnosticCode.BackendUnsupported,
$"Helper function '{helper.Name}' uses the Slang-only construct '{syntax.Trim()}', but this graph targets strict HLSL 2021.",
"Either rewrite the helper in plain HLSL or switch the graph's dialect back to s&box Slang, which the engine's own headers already require." );
}
}
/// <summary>Write the non-entry-point functions the module carries for a stage.</summary>
public void WriteFunctions( HlslSourceBuilder builder, ShaderStage stage )
{
if ( builder is null || Module is null ) return;
var previous = Stage;
Stage = stage;
try
{
foreach ( var function in Module.Functions )
{
if ( function is null || function.IsEntryPoint ) continue;
if ( function.Stage != ShaderStage.None && function.Stage != stage ) continue;
foreach ( var attribute in function.Attributes ) builder.Write( attribute );
builder.Write( function.SignatureHlsl );
builder.Open();
WriteStatements( builder, function.Body );
builder.Close();
builder.Blank();
}
}
finally
{
Stage = previous;
}
}
// ---- diagnostics ------------------------------------------------------
/// <summary>
/// Report a problem against the node currently being written, once per distinct message. A backend
/// never throws for user error and never floods the panel with one repeated line.
/// </summary>
public void Report( DiagnosticSeverity severity, string code, string message, string detail = null )
{
var key = $"{code}|{message}|{_origin}";
if ( !_reported.Add( key ) ) return;
GraphRef? graph = _origin.IsValid ? GraphRef.ForNode( _origin ) : null;
Diagnostics.Report( new Diagnostic( severity, code, message, detail, null, graph ) );
}
/// <summary>The node whose statement is currently being written, for diagnostics attribution.</summary>
public NodeId CurrentOrigin
{
get => _origin;
set => _origin = value;
}
}
/// <summary>
/// The s&box HLSL backend: turns an <see cref="IrModule"/> into a complete VFX <c>.shader</c>
/// file that the engine compiles and the preview renders.
/// <para>
/// The heavy lifting is split in two on purpose. <see cref="HlslEmitter"/> lowers IR to HLSL
/// declarations and function bodies; <see cref="SboxShaderWriter"/> wraps those in the block file.
/// That separation is what lets the same HLSL feed a probe compile, a <c>.shader</c>, or a future
/// target, and it keeps the block-file knowledge in one auditable place.
/// </para>
/// </summary>
public sealed class HlslBackend : IShaderBackend
{
/// <inheritdoc/>
public string Id => PrismConstants.BackendHlsl;
/// <inheritdoc/>
public string DisplayName => "s&box Shader (HLSL / VFX)";
/// <inheritdoc/>
public string FileExtension => PrismConstants.ShaderExtension;
/// <inheritdoc/>
public BackendCapabilities Capabilities => BackendCapabilities.Sbox;
/// <inheritdoc/>
public BackendEmitResult Emit( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics )
{
diagnostics ??= new DiagnosticSink();
options ??= BackendEmitOptions.Default;
if ( module is null )
{
diagnostics.Error( DiagnosticCode.InvalidBlock, "There is nothing to emit: the compiler produced no module." );
return BackendEmitResult.Empty( Id, FileExtension );
}
return PrismLog.Guard( "HlslBackend.Emit",
() => new SboxShaderWriter( module, options, diagnostics ).Write(),
BackendEmitResult.Empty( Id, FileExtension ) );
}
/// <summary>
/// Emit one stage as a plain HLSL translation unit, with no VFX blocks around it.
/// <para>
/// This is what the text editor's probe compiler and the IR debug view want: declarations, helper
/// bodies and the entry point, in a form a bare DXC or slangc invocation can read.
/// </para>
/// </summary>
public string EmitStandalone( IrModule module, ShaderStage stage, BackendEmitOptions options,
DiagnosticSink diagnostics )
{
diagnostics ??= new DiagnosticSink();
options ??= BackendEmitOptions.Default;
if ( module is null ) return string.Empty;
return PrismLog.Guard( "HlslBackend.EmitStandalone", () =>
{
var builder = new HlslSourceBuilder( options.Indent, options.NewLine );
var emitter = new HlslEmitter( module, options, diagnostics ) { Stage = stage };
foreach ( var include in module.Includes ) builder.Write( $"#include \"{include}\"" );
if ( module.Includes.Count > 0 ) builder.Blank();
foreach ( var structure in module.Structs )
{
builder.Write( $"struct {structure.Name}" );
builder.Open();
foreach ( var include in structure.Includes ) builder.Write( $"#include \"{include}\"" );
foreach ( var field in structure.Fields )
{
var semantic = string.IsNullOrEmpty( field.Semantic ) ? string.Empty : $" : {field.Semantic}";
builder.Write( $"{Interpolation( field.Interpolation )}{field.Type.Hlsl} {field.Name}{semantic};" );
}
builder.Close( ";" );
builder.Blank();
}
SboxMaterialBinding.WriteGlobals( builder, emitter, stage );
emitter.WriteHelpers( builder, stage, emitter.OrderedHelpers() );
emitter.WriteFunctions( builder, stage );
var entry = module.EntryPoint( stage );
if ( entry is not null ) WriteStandaloneEntry( builder, emitter, module, entry, stage );
return builder.ToString();
}, string.Empty );
}
/// <summary>
/// Write an entry point with the fixed signature the engine expects, plus the material prologue
/// and tail. The IR's own signature is deliberately not used: entry-point names, parameters and
/// semantics are fixed by the engine, and a probe compile is only useful if the locals the body
/// refers to actually exist.
/// </summary>
static void WriteStandaloneEntry( HlslSourceBuilder builder, HlslEmitter emitter, IrModule module,
IrFunction entry, ShaderStage stage )
{
foreach ( var attribute in entry.Attributes ) builder.Write( attribute );
switch ( stage )
{
case ShaderStage.Vertex:
builder.Write(
$"{SboxShaderTemplates.StructPixelInput} {PrismConstants.EntryPointVertex}( {SboxShaderTemplates.StructVertexInput} {SboxShaderTemplates.VertexInputLocal} )" );
builder.Open();
builder.WriteBlock( SboxShaderTemplates.SurfaceVertexPrologue );
emitter.WriteStatements( builder, entry.Body );
builder.Write( SboxShaderTemplates.SurfaceVertexEpilogue );
builder.Close();
break;
case ShaderStage.Pixel:
var returned = SboxMaterialBinding.EndsWithReturn( entry.Body );
builder.Write(
$"float4 {PrismConstants.EntryPointPixel}( {SboxShaderTemplates.StructPixelInput} {SboxShaderTemplates.PixelInputLocal} ) : SV_Target0" );
builder.Open();
if ( !returned ) SboxMaterialBinding.WritePixelPrologue( builder, module );
emitter.WriteStatements( builder, entry.Body );
if ( !returned ) SboxMaterialBinding.WritePixelEpilogue( builder, module, emitter );
builder.Close();
break;
case ShaderStage.Compute:
if ( entry.Attributes.Count == 0 ) builder.Write( SboxShaderTemplates.ComputeDefaultNumThreads );
builder.Write(
$"void {PrismConstants.EntryPointCompute}( {SboxShaderTemplates.ComputeEntryParameters} )" );
builder.Open();
emitter.WriteStatements( builder, entry.Body );
builder.Close();
break;
default:
builder.Write( entry.SignatureHlsl );
builder.Open();
emitter.WriteStatements( builder, entry.Body );
builder.Close();
break;
}
}
/// <summary>The HLSL interpolation modifier prefix for a field, including its trailing space.</summary>
public static string Interpolation( IrInterpolation interpolation ) => interpolation switch
{
IrInterpolation.NoPerspective => "noperspective ",
IrInterpolation.NoInterpolation => "nointerpolation ",
IrInterpolation.Centroid => "centroid ",
IrInterpolation.Sample => "sample ",
_ => string.Empty
};
}
Editor
library
using System.Globalization;
using System.Text;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
namespace Editor.Prism.Compiler.Backends;
/// <summary>
/// The spelling table that turns Prism's canonical, backend-independent operations into Slang syntax.
/// <para>
/// Everything the <see cref="SlangBackend"/> writes goes through here: intrinsic names, operator
/// symbols, type spellings, literals, identifiers and the per-stage lowering of every
/// <see cref="Core.ShaderStage"/>-dependent <see cref="Compiler.Builtin"/>. Keeping it in one place is
/// what makes the emitted module consistent, and what makes "never emit <c>?:</c> on a vector" a rule
/// the backend cannot accidentally break.
/// </para>
/// </summary>
public static class SlangIntrinsics
{
// ---- well-known names the emitted module and the prelude agree on -------
/// <summary>Name of the single parameter every graphics entry point takes.</summary>
public const string InputParameter = "i";
/// <summary>Name of the pixel entry point's <c>SV_IsFrontFace</c> parameter.</summary>
public const string FrontFaceParameter = "isFrontFace";
/// <summary>Name of the compute entry point's <c>SV_DispatchThreadID</c> parameter.</summary>
public const string DispatchThreadIdParameter = "dispatchThreadId";
/// <summary>Name of the compute entry point's <c>SV_GroupThreadID</c> parameter.</summary>
public const string GroupThreadIdParameter = "groupThreadId";
/// <summary>Name of the compute entry point's <c>SV_GroupID</c> parameter.</summary>
public const string GroupIdParameter = "groupId";
/// <summary>Name of the generated vertex input struct.</summary>
public const string VertexInputStruct = "VsIn";
/// <summary>Name of the generated vertex output / pixel input struct.</summary>
public const string VertexOutputStruct = "VsOut";
/// <summary>Name of the environment parameter block declared by the <c>prism.core</c> prelude.</summary>
public const string EnvironmentBlock = "gPrismEnv";
/// <summary>Prefix given to the locals an entry point prologue declares for builtins.</summary>
public const string LocalPrefix = "prism";
// ---- intrinsics --------------------------------------------------------
/// <summary>
/// The Slang spelling of a canonical intrinsic. Falls back to the HLSL spelling in
/// <see cref="IntrinsicCatalog"/>, because Slang accepts the whole HLSL intrinsic surface.
/// </summary>
public static string Name( Intrinsic id ) => id switch
{
// Slang spells the centroid evaluator the DXC way.
Intrinsic.EvaluateAttributeCentroid => "EvaluateAttributeAtCentroid",
// GetDimensions is an out-parameter method in Slang and cannot appear in an expression,
// so the prelude provides a value-returning wrapper instead.
Intrinsic.TextureSize => "PrismTextureSize",
// Component-wise logic. Never `&&` / `||`, which only short-circuit for scalars.
Intrinsic.AndFn => "and",
Intrinsic.OrFn => "or",
_ => IntrinsicCatalog.Name( id )
};
/// <summary>
/// True when the intrinsic is a method on its first argument — <c>tex.Sample( s, uv )</c> rather
/// than <c>Sample( tex, s, uv )</c>.
/// </summary>
public static bool IsObjectMethod( Intrinsic id ) => id is
Intrinsic.Sample or Intrinsic.SampleLevel or Intrinsic.SampleBias or Intrinsic.SampleGrad or
Intrinsic.SampleCmp or Intrinsic.SampleCmpLevelZero or
Intrinsic.Gather or Intrinsic.GatherRed or Intrinsic.GatherGreen or Intrinsic.GatherBlue or
Intrinsic.GatherAlpha or Intrinsic.GatherCmp or
Intrinsic.Load or
Intrinsic.CalculateLevelOfDetail or Intrinsic.CalculateLevelOfDetailUnclamped;
/// <summary>True when the operation is provided by the emitted <c>prism.core</c> prelude.</summary>
public static bool IsPreludeHelper( Intrinsic id ) => id is Intrinsic.TextureSize;
/// <summary>True when the intrinsic writes through an <c>out</c> parameter and is a statement, not a value.</summary>
public static bool IsVoidResult( Intrinsic id ) => id is
Intrinsic.SinCos or Intrinsic.Clip or
Intrinsic.AllMemoryBarrier or Intrinsic.AllMemoryBarrierWithGroupSync or
Intrinsic.DeviceMemoryBarrier or Intrinsic.DeviceMemoryBarrierWithGroupSync or
Intrinsic.GroupMemoryBarrier or Intrinsic.GroupMemoryBarrierWithGroupSync or
Intrinsic.InterlockedAdd or Intrinsic.InterlockedMin or Intrinsic.InterlockedMax or
Intrinsic.InterlockedAnd or Intrinsic.InterlockedOr or Intrinsic.InterlockedXor or
Intrinsic.InterlockedExchange or Intrinsic.InterlockedCompareExchange or
Intrinsic.InterlockedCompareStore;
// ---- operators ---------------------------------------------------------
/// <summary>The Slang symbol for a binary operator.</summary>
public static string Symbol( BinaryOp op ) => BinaryOps.Symbol( op );
/// <summary>The Slang symbol for a unary operator.</summary>
public static string Symbol( UnaryOp op ) => UnaryOps.Symbol( op );
/// <summary>
/// True when an operator must be written in function form instead of symbol form.
/// <para>
/// <c>&&</c> and <c>||</c> only short-circuit for scalar operands; on a vector Slang
/// evaluates both sides and warns. The core module's <c>and()</c> / <c>or()</c> are the
/// component-wise spellings, so that is what we emit.
/// </para>
/// </summary>
public static bool RequiresFunctionForm( BinaryOp op, ShaderType operandType ) =>
BinaryOps.IsShortCircuit( op ) && !operandType.IsScalar && !operandType.IsVoid;
/// <summary>The function spelling of a short-circuit operator, for component-wise use.</summary>
public static string FunctionForm( BinaryOp op ) => op == BinaryOp.LogicalOr ? "or" : "and";
// ---- types -------------------------------------------------------------
/// <summary>
/// Element type given to a buffer whose element type the IR does not carry. Slang's buffer types
/// are generic with no default, unlike its textures, so a spelling has to be chosen.
/// </summary>
public const string DefaultBufferElement = "float4";
/// <summary>The Slang spelling of a type.</summary>
public static string TypeName( ShaderType type )
{
if ( type.IsObject )
{
switch ( type.Object )
{
case ObjectKind.Buffer:
case ObjectKind.StructuredBuffer:
case ObjectKind.RWBuffer:
case ObjectKind.RWStructuredBuffer:
return $"{ShaderType.ObjectName( type.Object )}<{DefaultBufferElement}>";
}
}
return type.Slang;
}
/// <summary>The Slang interpolation modifier, or an empty string for the default.</summary>
public static string Interpolation( IrInterpolation interpolation ) => interpolation switch
{
IrInterpolation.NoPerspective => "noperspective",
IrInterpolation.NoInterpolation => "nointerpolation",
IrInterpolation.Centroid => "centroid",
IrInterpolation.Sample => "sample",
_ => string.Empty
};
/// <summary>The <c>[shader("...")]</c> attribute for a stage, or null when the stage has none.</summary>
public static string StageAttribute( ShaderStage stage )
{
var name = stage.SlangStage();
return string.IsNullOrEmpty( name ) ? null : $"[shader(\"{name}\")]";
}
// ---- literals ----------------------------------------------------------
/// <summary>Format one component of a literal according to the component type.</summary>
public static string Scalar( double value, ScalarKind kind ) => kind switch
{
ScalarKind.Bool => value != 0 ? "true" : "false",
ScalarKind.Int => ( (long)Math.Clamp( value, int.MinValue, int.MaxValue ) ).ToString( CultureInfo.InvariantCulture ),
ScalarKind.UInt => ( (ulong)Math.Clamp( value, 0, uint.MaxValue ) ).ToString( CultureInfo.InvariantCulture ) + "u",
_ => Real( value )
};
/// <summary>
/// Format a literal of any type. Vectors whose components are all equal collapse to the
/// single-argument constructor, which is both shorter and how a human would write it.
/// </summary>
public static string Literal( ShaderType type, ConstValue value )
{
if ( type.IsVoid ) return "0";
if ( type.IsScalar ) return Scalar( value[0], type.Scalar );
if ( type.IsVector )
{
var components = Math.Clamp( type.Components, 1, 4 );
if ( value.AllEqual( value[0], components ) )
{
return $"{TypeName( type )}( {Scalar( value[0], type.Scalar )} )";
}
var parts = new string[components];
for ( int i = 0; i < components; i++ ) parts[i] = Scalar( value[i], type.Scalar );
return $"{TypeName( type )}( {string.Join( ", ", parts )} )";
}
// A matrix literal cannot be fully represented by four components, so a matrix constant is
// always a broadcast of its first component. The IR builds real matrices with IrConstruct.
if ( type.IsMatrix ) return $"{TypeName( type )}( {Scalar( value[0], type.Scalar )} )";
return $"( {TypeName( type )} )0";
}
/// <summary>Format a floating-point literal so it round-trips and always reads as a float.</summary>
public static string Real( double value )
{
if ( double.IsNaN( value ) ) value = 0;
if ( double.IsPositiveInfinity( value ) ) value = 3.402823466e+38;
if ( double.IsNegativeInfinity( value ) ) value = -3.402823466e+38;
var text = ( (float)value ).ToString( "R", CultureInfo.InvariantCulture );
if ( text.IndexOf( '.' ) < 0 && text.IndexOf( 'E' ) < 0 && text.IndexOf( 'e' ) < 0 )
{
text += ".0";
}
return text;
}
/// <summary>Escape a string so it can appear inside a Slang string literal.</summary>
public static string QuotedString( string value )
{
if ( string.IsNullOrEmpty( value ) ) return "\"\"";
var builder = new StringBuilder( value.Length + 2 );
builder.Append( '"' );
foreach ( var c in value )
{
switch ( c )
{
case '"': builder.Append( "\\\"" ); break;
case '\\': builder.Append( "\\\\" ); break;
case '\r': break;
case '\n': builder.Append( ' ' ); break;
case '\t': builder.Append( ' ' ); break;
default: builder.Append( c ); break;
}
}
builder.Append( '"' );
return builder.ToString();
}
// ---- identifiers -------------------------------------------------------
/// <summary>True when the identifier collides with a Slang keyword or modifier.</summary>
public static bool IsReserved( string identifier ) =>
!string.IsNullOrEmpty( identifier ) && s_reserved.Contains( identifier );
/// <summary>
/// Turn arbitrary text into a legal Slang identifier, preserving as much of the original as
/// possible so the generated module still reads like the graph that produced it.
/// </summary>
public static string SanitizeIdentifier( string name, string fallback = "prismValue" )
{
if ( string.IsNullOrWhiteSpace( name ) ) return fallback;
var builder = new StringBuilder( name.Length );
foreach ( var c in name )
{
if ( char.IsLetterOrDigit( c ) || c == '_' ) builder.Append( c );
else if ( builder.Length > 0 && builder[^1] != '_' ) builder.Append( '_' );
}
while ( builder.Length > 0 && builder[^1] == '_' ) builder.Length--;
if ( builder.Length == 0 ) return fallback;
if ( char.IsDigit( builder[0] ) ) builder.Insert( 0, '_' );
var result = builder.ToString();
return IsReserved( result ) ? result + "_" : result;
}
/// <summary>PascalCase an identifier, dropping the shader-world hungarian prefixes on the way.</summary>
public static string PascalCase( string name )
{
var identifier = SanitizeIdentifier( name, "Value" );
identifier = StripPrefix( identifier );
if ( identifier.Length == 0 ) return "Value";
var builder = new StringBuilder( identifier.Length );
var upper = true;
foreach ( var c in identifier )
{
if ( c == '_' )
{
upper = true;
continue;
}
builder.Append( upper ? char.ToUpperInvariant( c ) : c );
upper = false;
}
if ( builder.Length == 0 ) return "Value";
if ( char.IsDigit( builder[0] ) ) builder.Insert( 0, '_' );
var result = builder.ToString();
return IsReserved( result ) ? result + "_" : result;
}
/// <summary>
/// Normalise an engine field spelling to the Slang-idiomatic name the generated interface structs
/// use. Anything unrecognised passes through untouched, so a struct field the graph invented still
/// resolves against the declaration we copied from the module.
/// </summary>
public static string FieldName( string name )
{
if ( string.IsNullOrEmpty( name ) ) return name;
if ( s_fieldAliases.TryGetValue( name, out var alias ) ) return alias;
return SanitizeIdentifier( name, "Field" );
}
static string StripPrefix( string identifier )
{
// g_flRoughness -> Roughness, g_vTint -> Tint, m_Foo -> Foo.
foreach ( var prefix in s_symbolPrefixes )
{
if ( identifier.Length <= prefix.Length ) continue;
if ( !identifier.StartsWith( prefix, StringComparison.Ordinal ) ) continue;
var tail = identifier[prefix.Length..];
if ( tail.Length > 0 && ( char.IsLetter( tail[0] ) || tail[0] == '_' ) ) return tail.TrimStart( '_' );
}
return identifier;
}
// ---- builtins ----------------------------------------------------------
/// <summary>The name of the prologue local an entry point binds a builtin to.</summary>
public static string BuiltinLocal( Builtin id ) => LocalPrefix + id;
/// <summary>
/// Builtins this one is derived from. The prologue emits dependencies first, so
/// <c>ViewDirection</c> can be written in terms of the already-bound <c>WorldPosition</c> local.
/// </summary>
public static IReadOnlyList<Builtin> Dependencies( Builtin id, ShaderStage stage )
{
if ( stage != ShaderStage.Vertex )
{
return id == Builtin.ViewDirection ? s_dependsWorldPosition : Array.Empty<Builtin>();
}
return id switch
{
Builtin.WorldTangentV => s_dependsTangentFrame,
Builtin.ClipPosition => s_dependsWorldPosition,
Builtin.ScreenUv => s_dependsClipPosition,
Builtin.ViewDirection => s_dependsWorldPosition,
_ => Array.Empty<Builtin>()
};
}
/// <summary>
/// True when a builtin has to travel from the vertex stage to the pixel stage through an
/// interpolator, and therefore becomes a field of the generated <c>VsOut</c> struct.
/// </summary>
public static bool IsInterpolated( Builtin id ) => InterpolantField( id ) is not null;
/// <summary>The <c>VsOut</c> field that carries a builtin, or null when it is not interpolated.</summary>
public static string InterpolantField( Builtin id ) => id switch
{
Builtin.WorldPosition => "WorldPosition",
Builtin.ObjectPosition => "ObjectPosition",
Builtin.WorldNormal => "WorldNormal",
Builtin.ObjectNormal => "ObjectNormal",
Builtin.WorldTangentU => "WorldTangentU",
Builtin.WorldTangentV => "WorldTangentV",
Builtin.ObjectTangentU => "ObjectTangentU",
Builtin.VertexColor => "Color",
Builtin.TexCoord0 => "Uv",
Builtin.TexCoord1 => "Uv2",
Builtin.VertexId => "VertexId",
Builtin.InstanceId => "InstanceId",
_ => null
};
/// <summary>
/// The <c>VsIn</c> attribute a builtin is derived from in the vertex stage, or null when it needs
/// none. A domain whose vertex input does not carry that attribute — a full-screen post-process
/// pass, for instance — binds the builtin to zero instead of naming a field that does not exist.
/// </summary>
public static string VertexInputField( Builtin id ) => id switch
{
Builtin.WorldPosition or Builtin.ObjectPosition or
Builtin.ClipPosition or Builtin.ScreenUv => "Position",
Builtin.WorldNormal or Builtin.ObjectNormal => "Normal",
Builtin.WorldTangentU or Builtin.WorldTangentV or Builtin.ObjectTangentU => "Tangent",
Builtin.VertexColor => "Color",
Builtin.TexCoord0 => "Uv",
Builtin.TexCoord1 => "Uv2",
Builtin.VertexId => "VertexId",
Builtin.InstanceId => "InstanceId",
_ => null
};
/// <summary>How an interpolated builtin's field interpolates across a triangle.</summary>
public static IrInterpolation InterpolantMode( Builtin id ) =>
id is Builtin.VertexId or Builtin.InstanceId ? IrInterpolation.NoInterpolation : IrInterpolation.Linear;
/// <summary>
/// The Slang expression a builtin lowers to in a given stage.
/// <para>
/// Vertex-stage expressions are computed from the <c>VsIn</c> attributes and the environment
/// parameter block; pixel-stage expressions read the interpolated <c>VsOut</c> field. This is the
/// single place that knows a builtin is a different expression in each stage — nodes never do.
/// </para>
/// </summary>
public static string BuiltinExpression( Builtin id, ShaderStage stage )
{
var input = InputParameter;
var frame = EnvironmentBlock + ".Frame";
var obj = EnvironmentBlock + ".Object";
switch ( id )
{
// -- uniform: identical in every stage
case Builtin.ObjectOrigin: return $"{obj}.ObjectOrigin";
case Builtin.ObjectScale: return $"{obj}.ObjectScale";
case Builtin.TintColor: return $"{obj}.TintColor";
case Builtin.ObjectToWorld: return $"{obj}.ObjectToWorld";
case Builtin.WorldToObject: return $"{obj}.WorldToObject";
case Builtin.CameraPosition: return $"{frame}.CameraPosition";
case Builtin.CameraForward: return $"{frame}.CameraForward";
case Builtin.CameraNear: return $"{frame}.CameraNear";
case Builtin.CameraFar: return $"{frame}.CameraFar";
case Builtin.ViewportSize: return $"{frame}.ViewportSize";
case Builtin.ViewportInvSize: return $"{frame}.ViewportInvSize";
case Builtin.ViewportOffset: return $"{frame}.ViewportOffset";
case Builtin.SunDirection: return $"{frame}.SunDirection";
case Builtin.SunColor: return $"{frame}.SunColor";
case Builtin.Time: return $"{frame}.Time";
case Builtin.DeltaTime: return $"{frame}.DeltaTime";
case Builtin.FrameCount: return $"{frame}.FrameCount";
case Builtin.ViewMatrix: return $"{frame}.WorldToView";
case Builtin.ProjectionMatrix: return $"{frame}.ViewToProjection";
case Builtin.ViewProjectionMatrix: return $"{frame}.WorldToProjection";
// -- compute
case Builtin.DispatchThreadId:
return stage == ShaderStage.Compute ? DispatchThreadIdParameter : "uint3( 0 )";
case Builtin.GroupThreadId:
return stage == ShaderStage.Compute ? GroupThreadIdParameter : "uint3( 0 )";
case Builtin.GroupId:
return stage == ShaderStage.Compute ? GroupIdParameter : "uint3( 0 )";
// -- view dependent
case Builtin.ViewDirection:
return $"PrismSafeNormalize( {frame}.CameraPosition - {BuiltinLocal( Builtin.WorldPosition )} )";
}
if ( stage == ShaderStage.Vertex ) return VertexExpression( id, input );
if ( stage == ShaderStage.Pixel ) return PixelExpression( id, input );
return Zero( Builtins.TypeOf( id ) );
}
static string VertexExpression( Builtin id, string input ) => id switch
{
Builtin.WorldPosition => $"PrismObjectToWorldPoint( {input}.Position )",
Builtin.ObjectPosition => $"{input}.Position",
Builtin.WorldNormal => $"PrismObjectToWorldNormal( {input}.Normal )",
Builtin.ObjectNormal => $"{input}.Normal",
Builtin.WorldTangentU => $"PrismObjectToWorldDirection( {input}.Tangent.xyz )",
Builtin.WorldTangentV =>
$"cross( {BuiltinLocal( Builtin.WorldNormal )}, {BuiltinLocal( Builtin.WorldTangentU )} ) * {input}.Tangent.w",
Builtin.ObjectTangentU => $"{input}.Tangent.xyz",
Builtin.VertexColor => $"{input}.Color",
Builtin.TexCoord0 => $"{input}.Uv",
Builtin.TexCoord1 => $"{input}.Uv2",
Builtin.ClipPosition => $"PrismWorldToClip( {BuiltinLocal( Builtin.WorldPosition )} )",
Builtin.ScreenUv => $"PrismScreenUvFromClip( {BuiltinLocal( Builtin.ClipPosition )} )",
Builtin.VertexId => $"{input}.VertexId",
Builtin.InstanceId => $"{input}.InstanceId",
Builtin.IsFrontFace => "true",
_ => Zero( Builtins.TypeOf( id ) )
};
static string PixelExpression( Builtin id, string input ) => id switch
{
Builtin.WorldPosition => $"{input}.WorldPosition",
Builtin.ObjectPosition => $"{input}.ObjectPosition",
Builtin.WorldNormal => $"PrismSafeNormalize( {input}.WorldNormal )",
Builtin.ObjectNormal => $"PrismSafeNormalize( {input}.ObjectNormal )",
Builtin.WorldTangentU => $"PrismSafeNormalize( {input}.WorldTangentU )",
Builtin.WorldTangentV => $"PrismSafeNormalize( {input}.WorldTangentV )",
Builtin.ObjectTangentU => $"{input}.ObjectTangentU",
Builtin.VertexColor => $"{input}.Color",
Builtin.TexCoord0 => $"{input}.Uv",
Builtin.TexCoord1 => $"{input}.Uv2",
Builtin.ClipPosition => $"{input}.Position",
Builtin.ScreenUv => $"{input}.Position.xy * {EnvironmentBlock}.Frame.ViewportInvSize",
Builtin.PixelPosition => $"{input}.Position.xy",
Builtin.FragmentDepth => $"{input}.Position.z",
Builtin.IsFrontFace => FrontFaceParameter,
Builtin.VertexId => $"{input}.VertexId",
Builtin.InstanceId => $"{input}.InstanceId",
_ => Zero( Builtins.TypeOf( id ) )
};
/// <summary>A zero value of a type, used where a builtin has no meaning in the current stage.</summary>
public static string Zero( ShaderType type )
{
if ( type.IsVoid ) return "0";
if ( type.IsScalar ) return Scalar( 0, type.Scalar );
return $"{TypeName( type )}( {Scalar( 0, type.Scalar )} )";
}
static readonly Builtin[] s_dependsWorldPosition = [Builtin.WorldPosition];
static readonly Builtin[] s_dependsClipPosition = [Builtin.WorldPosition, Builtin.ClipPosition];
static readonly Builtin[] s_dependsTangentFrame = [Builtin.WorldNormal, Builtin.WorldTangentU];
static readonly string[] s_symbolPrefixes =
[
"g_fl", "g_v", "g_col", "g_b", "g_n", "g_i", "g_t", "g_m", "g_s", "g_", "m_", "s_", "_"
];
static readonly Dictionary<string, string> s_fieldAliases = new( StringComparer.Ordinal )
{
["vPositionOs"] = "Position",
["vPositionWs"] = "WorldPosition",
["vPositionPs"] = "Position",
["vPositionSs"] = "Position",
["vPositionWithOffsetWs"] = "WorldPosition",
["vNormalOs"] = "Normal",
["vNormalWs"] = "WorldNormal",
["vTangentUOs_flTangentVSign"] = "Tangent",
["vTangentUWs"] = "WorldTangentU",
["vTangentVWs"] = "WorldTangentV",
["vTexCoord"] = "Uv",
["vTextureCoords"] = "Uv",
["vTexCoord2"] = "Uv2",
["vVertexColor"] = "Color",
["vColor"] = "Color",
["vBlendValues"] = "BlendValues",
["nInstanceTransformID"] = "InstanceId",
["nVertexIndex"] = "VertexId",
["vLightmapUVs"] = "LightmapUv"
};
static readonly HashSet<string> s_reserved = new( StringComparer.Ordinal )
{
// control flow
"if", "else", "switch", "case", "default", "return", "try", "throw", "throws", "catch",
"while", "for", "do", "break", "continue", "discard", "defer",
// declarations
"let", "var", "func", "typedef", "typealias", "property", "get", "set",
"class", "struct", "interface", "enum", "extension", "associatedtype",
"namespace", "using", "import", "module", "implementing",
"cbuffer", "tbuffer", "where", "syntax", "semantic", "type_param", "typename",
// modifiers
"static", "const", "extern", "inline", "public", "private", "internal", "protected",
"uniform", "groupshared", "shared", "volatile", "coherent", "restrict",
"readonly", "writeonly", "export", "override", "param", "require",
"row_major", "column_major", "nointerpolation", "noperspective", "linear", "sample",
"centroid", "precise", "in", "out", "inout", "ref", "dyn", "some", "implicit",
"noncopyable", "constexpr", "mutating", "point", "line", "triangle", "lineadj",
"triangleadj", "vertices", "indices", "primitives", "payload", "layout",
// expressions and literals
"as", "is", "this", "This", "sizeof", "alignof", "countof", "each", "expand",
"optional", "nonempty", "true", "false", "nullptr", "none", "no_diff",
// types
"void", "bool", "int", "uint", "half", "float", "double", "string",
"vector", "matrix", "functype", "int8_t", "int16_t", "int32_t", "int64_t",
"uint8_t", "uint16_t", "uint32_t", "uint64_t", "float16_t", "float32_t", "float64_t"
};
}
Editor
library
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using Editor.Prism.Model;
using System.Globalization;
using System.Reflection;
namespace Editor.Prism.Compiler;
/// <summary>Cheap lookup tables built once per compile so traversal never rescans the edge list.</summary>
public static class GraphIndex
{
/// <summary>Every edge terminating on each input port.</summary>
public static Dictionary<PortRef, List<Edge>> IncomingEdges( IPrismGraph graph )
{
var map = new Dictionary<PortRef, List<Edge>>();
if ( graph?.Edges is null ) return map;
foreach ( var edge in graph.Edges )
{
if ( edge is null || !edge.IsValid ) continue;
var key = edge.To;
if ( !map.TryGetValue( key, out var list ) )
{
list = new List<Edge>();
map[key] = list;
}
list.Add( edge );
}
return map;
}
/// <summary>Every edge leaving each output port.</summary>
public static Dictionary<PortRef, List<Edge>> OutgoingEdges( IPrismGraph graph )
{
var map = new Dictionary<PortRef, List<Edge>>();
if ( graph?.Edges is null ) return map;
foreach ( var edge in graph.Edges )
{
if ( edge is null || !edge.IsValid ) continue;
var key = edge.From;
if ( !map.TryGetValue( key, out var list ) )
{
list = new List<Edge>();
map[key] = list;
}
list.Add( edge );
}
return map;
}
}
/// <summary>
/// The demand-driven, memoised, post-order traversal that turns a graph into IR.
/// <para>
/// A value is computed by asking for it. The memo key is <c>(NodeId, PortId, ShaderStage)</c>, so a
/// node used in both stages is emitted twice — which is correct, because the expressions genuinely
/// differ there — while a node used twice within one stage is emitted once.
/// </para>
/// <para>
/// Three properties matter more than the traversal itself. Cycles are detected with an explicit
/// visit-state map <em>including reroutes</em>, and reported with the full path rather than hanging.
/// A node that throws inside <see cref="PrismNode.Emit"/> is quarantined: the exception is logged, a
/// <c>PR3001</c> diagnostic is attached to that node, its outputs become <see cref="IrValue.Invalid"/>
/// and traversal continues. And emission order is a deterministic function of the graph, which is what
/// makes "regenerate, compare text, skip the compile" reliable.
/// </para>
/// </summary>
public sealed class NodeEmitter
{
/// <summary>The name of the pixel-stage input struct instance the backends emit.</summary>
public const string PixelInputVariable = "i";
/// <summary>The name of the pixel-stage input struct type the backends emit.</summary>
public const string PixelInputStruct = "PixelInput";
readonly Dictionary<ShaderStage, IrBuilder> _builders = new();
readonly Dictionary<(NodeId Node, PortId Port, ShaderStage Stage), IrValue> _outputs = new();
readonly Dictionary<(NodeId Node, ShaderStage Stage), VisitState> _visited = new();
readonly Dictionary<(NodeId Node, string Name), IrValue> _varyingSources = new();
readonly Dictionary<string, VaryingBinding> _varyingBindings = new( StringComparer.Ordinal );
readonly List<(NodeId Node, ShaderStage Stage)> _path = new();
readonly List<PreviewAttribute> _previewAttributes = new();
readonly List<PreviewTexture> _previewTextures = new();
readonly HashSet<string> _reportedCycles = new( StringComparer.Ordinal );
Dictionary<PortRef, List<Edge>> _incoming = new();
int _previewSerial;
int _depthExceeded;
/// <summary>Build an emitter for one compile.</summary>
public NodeEmitter(
IPrismGraph graph,
IrModule module,
CompileRequest request,
DiagnosticSink diagnostics,
Backends.IShaderBackend backend,
StagePlan plan,
VaryingAllocator varyings )
{
Graph = graph;
Module = module ?? new IrModule();
Request = request;
Diagnostics = diagnostics ?? new DiagnosticSink();
Backend = backend;
Plan = plan ?? StagePlan.Empty;
Varyings = varyings ?? new VaryingAllocator();
_incoming = GraphIndex.IncomingEdges( graph );
}
/// <summary>The document being compiled.</summary>
public IPrismGraph Graph { get; }
/// <summary>The module being built.</summary>
public IrModule Module { get; }
/// <summary>The request this emission answers.</summary>
public CompileRequest Request { get; }
/// <summary>What the compile is for.</summary>
public CompileMode Mode => Request?.Mode ?? CompileMode.Final;
/// <summary>Where problems go.</summary>
public DiagnosticSink Diagnostics { get; }
/// <summary>The primary target backend, or null when the module serves more than one.</summary>
public Backends.IShaderBackend Backend { get; }
/// <summary>Where every node runs.</summary>
public StagePlan Plan { get; }
/// <summary>The interpolator budget.</summary>
public VaryingAllocator Varyings { get; }
/// <summary>
/// A prefix that scopes every interpolator this emitter allocates.
/// <para>
/// Empty for the document's own emitter. A subgraph splice builds a <em>second</em> emitter over the
/// inlined document while sharing the outer <see cref="VaryingAllocator"/>, and the inner document's
/// node ids are the same for every instance of the same <c>.prismfn</c> — so without a per-instance
/// prefix two instances produce the same interpolator key, the allocator hands the second instance
/// the first one's register, and the second instance's vertex-side write clobbers the first's. The
/// shader compiles and renders wrong, which is why this is a prefix rather than a comment.
/// </para>
/// </summary>
public string KeyPrefix { get; init; } = string.Empty;
/// <summary>
/// The interpolator name one logical key resolves to. Scoped by <see cref="KeyPrefix"/> so keys
/// minted by two emitters sharing one allocator cannot collide.
/// </summary>
string ScopedKey( string key ) => string.IsNullOrEmpty( KeyPrefix ) ? key : KeyPrefix + key;
/// <summary>Emit descriptive temp names and per-node comments.</summary>
public bool DebugSymbols => Request?.DebugSymbols ?? false;
/// <summary>Emit explanatory comments alongside the generated code.</summary>
public bool EmitComments => Request?.EmitComments ?? false;
/// <summary>True when literals should become live-pushable uniforms instead of constants.</summary>
public bool PreviewUniforms => Mode == CompileMode.Preview;
/// <summary>
/// Promote <em>every</em> literal a node creates in preview mode, not just the inline port values and
/// parameters a user can actually drag.
/// <para>
/// Off by default, and deliberately. Inline literals and blackboard parameters are the values a
/// slider moves, and those are promoted unconditionally; a magic number baked into a noise node's
/// hash is not, and turning it into a uniform would cost a register, defeat constant folding and
/// make the preview shader slower for no benefit.
/// </para>
/// </summary>
public bool PromoteAllConstants { get; set; }
/// <summary>Uniforms the preview can push without a recompile.</summary>
public IReadOnlyList<PreviewAttribute> PreviewAttributes => _previewAttributes;
/// <summary>Texture slots the preview has to fill itself. See <see cref="PreviewTextureBinding"/>.</summary>
public IReadOnlyList<PreviewTexture> PreviewTextures => _previewTextures;
/// <summary>How many node emissions ran.</summary>
public int NodesEmitted { get; private set; }
/// <summary>How many nodes were quarantined after throwing.</summary>
public int FailedNodes { get; private set; }
/// <summary>How many cycles were detected and cut.</summary>
public int Cycles { get; private set; }
/// <summary>The builder for one stage, created on first use.</summary>
public IrBuilder Builder( ShaderStage stage )
{
if ( _builders.TryGetValue( stage, out var builder ) ) return builder;
builder = new IrBuilder( stage, DebugSymbols );
_builders[stage] = builder;
return builder;
}
/// <summary>Every stage that had code emitted into it, in stage order.</summary>
public IEnumerable<ShaderStage> ActiveStages =>
ShaderStages.All.Where( x => _builders.ContainsKey( x ) && !_builders[x].Root.IsEmpty );
/// <summary>Statements emitted across every stage.</summary>
public int StatementCount => _builders.Values.Sum( x => Count( x.Root ) );
/// <summary>Temps declared across every stage.</summary>
public int TempCount => _builders.Values.Sum( x => x.TempCount );
/// <summary>Expressions answered from an existing temp instead of being recomputed.</summary>
public int CseHits => _builders.Values.Sum( x => x.CseHits );
// ---- demand -----------------------------------------------------------
/// <summary>
/// The value of one producer port in one stage, emitting whatever is needed to produce it.
/// Returns <see cref="IrValue.Invalid"/> for a disabled node, a cycle or a node that threw.
/// </summary>
public IrValue Demand( PortRef producer, ShaderStage stage )
{
if ( !producer.IsValid ) return IrValue.Invalid;
var key = (producer.Node, producer.Port, stage);
if ( _outputs.TryGetValue( key, out var cached ) ) return cached;
// Traversal is demand-driven and therefore recursive: one managed frame per node in the
// dependency chain. A long enough chain — measured at around 290 chained add nodes — exhausts
// the stack, and a .NET StackOverflowException cannot be caught: it bypasses the PrismLog.Guard
// quarantine entirely and takes the whole editor process down, with no diagnostic and no
// autosave. 290 nodes is a large graph but not an absurd one.
//
// TryEnsureSufficientExecutionStack asks the runtime whether there is room for another frame
// rather than guessing a depth limit, so this stays correct whatever the stack size and whatever
// the frames happen to cost in a given build. Failing here costs one wrong value and a
// diagnostic that names the node.
if ( !System.Runtime.CompilerServices.RuntimeHelpers.TryEnsureSufficientExecutionStack() )
{
// Reported once. The guard trips at whatever depth the stack ran out and then trips again on
// every frame as the recursion unwinds and re-descends, which would bury the diagnostics
// panel under hundreds of copies of the same sentence.
if ( _depthExceeded == 0 )
{
_depthExceeded = 1;
Diagnostics.Error( DiagnosticCode.NodeEmitFailed,
"This graph's dependency chain is too deep to compile",
GraphRef.ForPort( producer.Node, producer.Port ),
"Values are produced by walking backwards from the output, one step per node, and " +
"this chain ran out of room. Break it up with a subgraph, or fold a run of " +
"operations into one Custom Code node." );
}
_outputs[key] = IrValue.Invalid;
return IrValue.Invalid;
}
var node = Graph?.FindNode( producer.Node );
if ( node is null )
{
Diagnostics.Error( DiagnosticCode.DanglingEdge,
$"Connection reads from node '{producer.Node}', which is not in this document",
GraphRef.ForNode( producer.Node ) );
_outputs[key] = IrValue.Invalid;
return IrValue.Invalid;
}
if ( ( node.Flags & NodeFlags.Disabled ) != 0 )
{
_outputs[key] = IrValue.Invalid;
return IrValue.Invalid;
}
// The stage planner decided this value is produced per vertex and interpolated. Honour that here
// rather than in the node, so nodes never have to know which side of the boundary they are on.
if ( stage == ShaderStage.Pixel && Plan.IsVarying( producer ) )
{
var vertex = Demand( producer, ShaderStage.Vertex );
if ( vertex.IsValid )
{
// "port:" namespaces this against the "user:" keys EmitContext.Varying mints, so a node
// whose output port is called Result and which also calls Varying( "Result", … ) gets two
// interpolators rather than one shared by accident.
var interpolated = Interpolate( $"port:{producer.Node}.{producer.Port}", vertex, producer.Node );
_outputs[key] = interpolated;
return interpolated;
}
}
EmitNode( node, stage );
if ( _outputs.TryGetValue( key, out var produced ) ) return produced;
// The node ran but never wrote this port.
Diagnostics.Warn( DiagnosticCode.MissingInput,
$"'{Describe( node )}' produced no value for output '{producer.Port}'",
GraphRef.ForPort( producer.Node, producer.Port ) );
_outputs[key] = IrValue.Invalid;
return IrValue.Invalid;
}
/// <summary>
/// The value feeding one input port in one stage: the connected producer coerced to the port's
/// resolved type, or the port's inline literal, or <see cref="IrValue.Invalid"/>. Silent when the
/// port is simply unconnected — reporting that is the caller's decision.
/// </summary>
public IrValue DemandInput( PrismNode node, InputPort port, ShaderStage stage, NodeEmitContext context )
{
if ( node is null || port is null ) return IrValue.Invalid;
var edges = IncomingEdges( node.Id, port.Id );
if ( edges.Count > 0 )
{
var edge = edges[0];
var value = Demand( edge.From, stage );
if ( !value.IsValid ) return IrValue.Invalid;
var target = port.EffectiveType;
if ( target.IsVoid || target == value.Type ) return value;
return context is null ? value : context.Coerce( value, target, edge.Fill, port.Id );
}
// The inline literal goes through the same coercion as a connected value. Without this a port
// answers with a different type depending on whether anything is plugged into it: an
// [In( "float3" )] port whose [InlineValue] property is a Color reads back as float4 when
// unwired — TryReadConstant keeps the literal's own component count and only adopts the port's
// scalar kind — and float3 once wired. EmitContext.Out then trusts the node and retypes the
// output port, so the whole downstream chain widens on a port nobody connected.
var literal = InlineValue( node, port, stage );
if ( !literal.IsValid || context is null ) return literal;
var wanted = port.EffectiveType;
if ( wanted.IsVoid || wanted == literal.Type ) return literal;
return context.Coerce( literal, wanted, null, port.Id );
}
/// <summary>The literal a port falls back to when nothing is connected.</summary>
public IrValue InlineValue( PrismNode node, InputPort port, ShaderStage stage )
{
if ( node is null || port is null ) return IrValue.Invalid;
var hint = port.EffectiveType;
if ( hint.IsObject ) return IrValue.Invalid;
object raw = port.InlineValue;
if ( raw is null && !string.IsNullOrEmpty( port.Def.InlineValueProperty ) )
{
raw = ReadProperty( node, port.Def.InlineValueProperty );
}
if ( raw is null ) return IrValue.Invalid;
if ( !TryReadConstant( raw, hint, out var type, out var value ) ) return IrValue.Invalid;
if ( PreviewUniforms && type.IsNumeric && type.Components <= 4 )
{
return PreviewUniform( node.Id, port.Id, type, value, stage );
}
return Builder( stage ).Const( type, value );
}
IReadOnlyList<Edge> IncomingEdges( NodeId node, PortId port ) =>
_incoming.TryGetValue( new PortRef( node, port ), out var edges ) ? edges : Array.Empty<Edge>();
/// <summary>Publish the value of one output port. Called by <c>EmitContext.Out</c>.</summary>
public void SetOutput( NodeId node, PortId port, ShaderStage stage, IrValue value ) =>
_outputs[(node, port, stage)] = value;
/// <summary>True when a value has already been published for this output.</summary>
public bool HasOutput( NodeId node, PortId port, ShaderStage stage ) =>
_outputs.ContainsKey( (node, port, stage) );
// ---- node emission ----------------------------------------------------
/// <summary>
/// Run one node's <see cref="PrismNode.Emit"/> for one stage, at most once. Cycles are cut and
/// reported with their full path; exceptions are quarantined to the node that threw.
/// </summary>
public bool EmitNode( PrismNode node, ShaderStage stage )
{
if ( node is null ) return false;
var key = (node.Id, stage);
if ( _visited.TryGetValue( key, out var state ) )
{
if ( state == VisitState.Done ) return true;
if ( state == VisitState.Failed ) return false;
ReportCycle( node, stage );
return false;
}
_visited[key] = VisitState.Visiting;
_path.Add( key );
var builder = Builder( stage );
var context = new NodeEmitContext( this, node, stage, builder );
var ok = true;
try
{
if ( EmitComments || DebugSymbols )
{
builder.Comment( node.Id, $"{Describe( node )} #{node.Id}" );
}
node.Emit( context );
}
catch ( Exception e )
{
ok = false;
FailedNodes++;
PrismLog.Error( e, $"Node '{Describe( node )}' ({node.Id}) threw while emitting" );
Diagnostics.Report( new Diagnostic( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed,
$"'{Describe( node )}' failed to emit: {e.Message}", e.ToString(), null,
GraphRef.ForNode( node.Id ) ) );
}
finally
{
_path.RemoveAt( _path.Count - 1 );
NodesEmitted++;
}
// Every output the node did not write becomes invalid, so a partial failure degrades one wire
// at a time rather than taking the compile down.
foreach ( var output in node.Outputs )
{
var outputKey = (node.Id, output.Id, stage);
if ( _outputs.ContainsKey( outputKey ) ) continue;
if ( ok ) continue;
_outputs[outputKey] = IrValue.Invalid;
}
_visited[key] = ok ? VisitState.Done : VisitState.Failed;
return ok;
}
void ReportCycle( PrismNode node, ShaderStage stage )
{
Cycles++;
var start = _path.FindIndex( x => x.Node == node.Id && x.Stage == stage );
var names = new List<string>();
for ( int i = Math.Max( 0, start ); i < _path.Count; i++ )
{
var member = Graph?.FindNode( _path[i].Node );
names.Add( member is null ? _path[i].Node.ToString() : $"{Describe( member )} #{_path[i].Node}" );
}
names.Add( $"{Describe( node )} #{node.Id}" );
var path = string.Join( " -> ", names );
// Only the first report per distinct cycle; a diamond above a cycle would otherwise repeat it.
if ( !_reportedCycles.Add( path ) ) return;
Diagnostics.Error( DiagnosticCode.Cycle,
$"'{Describe( node )}' is part of a feedback loop and cannot be compiled",
GraphRef.ForNode( node.Id ),
$"Cycle: {path}" );
}
// ---- varyings ---------------------------------------------------------
/// <summary>
/// Move a value across the vertex-to-pixel boundary.
/// <para>
/// Called from the vertex stage this only records the source expression and hands the value straight
/// back. Called from the pixel stage it re-runs the owning node in the vertex stage — the emission
/// is memoised per stage, so this costs nothing the second time — takes the value the node
/// registered there, allocates an interpolator, emits the vertex-side write and returns the
/// interpolated read.
/// </para>
/// </summary>
public IrValue Varying( PrismNode node, string name, IrValue vsValue, ShaderStage consumerStage )
{
if ( node is null || string.IsNullOrWhiteSpace( name ) ) return vsValue;
var key = $"user:{node.Id}.{name}";
if ( consumerStage == ShaderStage.Vertex )
{
if ( vsValue.IsValid ) _varyingSources[(node.Id, name)] = vsValue;
return vsValue;
}
if ( consumerStage != ShaderStage.Pixel )
{
// Geometry and compute have no interpolators of ours; the value stays where it was computed.
return vsValue;
}
if ( _varyingBindings.TryGetValue( ScopedKey( key ), out var known ) )
{
return ReadVarying( known, consumerStage );
}
// Ask the vertex stage for the value. Guard against a node that calls Varying while it is
// already being emitted in the vertex stage.
if ( !_varyingSources.TryGetValue( (node.Id, name), out var source ) )
{
if ( !IsVisiting( node.Id, ShaderStage.Vertex ) ) EmitNode( node, ShaderStage.Vertex );
_varyingSources.TryGetValue( (node.Id, name), out source );
}
if ( !source.IsValid )
{
Diagnostics.Info( DiagnosticCode.SampleLowered,
$"'{Describe( node )}' could not produce '{name}' in the vertex stage; it is computed per pixel instead",
GraphRef.ForNode( node.Id ) );
return vsValue;
}
var interpolated = Interpolate( key, source, node.Id );
return interpolated.IsValid ? interpolated : vsValue;
}
/// <summary>
/// Allocate an interpolator for a vertex-stage value, emit the vertex-side write and return the
/// pixel-stage read. Idempotent per key, so demanding the same value twice costs one register.
/// <para>
/// <paramref name="key"/> is a <em>logical</em> key. It is scoped by <see cref="KeyPrefix"/> before
/// it reaches the shared allocator, so a caller never has to know whether this emitter is the
/// document's own or one splicing a subgraph into it.
/// </para>
/// </summary>
public IrValue Interpolate( string key, IrValue source, NodeId origin )
{
if ( string.IsNullOrEmpty( key ) || !source.IsValid ) return IrValue.Invalid;
var scoped = ScopedKey( key );
if ( _varyingBindings.TryGetValue( scoped, out var known ) ) return ReadVarying( known, ShaderStage.Pixel );
var binding = Varyings.Allocate( scoped, source.Type, InterpolationFor( source.Type ), origin, Diagnostics );
if ( !binding.IsValid ) return IrValue.Invalid;
_varyingBindings[scoped] = binding;
var vertex = Builder( ShaderStage.Vertex );
vertex.Assign( origin, VaryingAccess( vertex, binding ), source );
return ReadVarying( binding, ShaderStage.Pixel );
}
static IrInterpolation InterpolationFor( ShaderType type ) =>
type.IsFloatingPoint ? IrInterpolation.Linear : IrInterpolation.NoInterpolation;
/// <summary>
/// The expression that names one packed value inside its interpolator register.
/// <para>
/// The register is typed as a full four components on purpose. Its real width is not known until
/// allocation has finished — another value may still be packed alongside this one — and a swizzle
/// whose base claims the narrower width would look like an identity to the optimiser and be folded
/// away, silently widening the read once the register grew.
/// </para>
/// </summary>
IrValue VaryingAccess( IrBuilder builder, VaryingBinding binding )
{
var input = builder.Var( ShaderType.Struct( PixelInputStruct ), PixelInputVariable );
var register = ShaderType.Vec( binding.Type.Scalar, 4 );
var field = builder.Member( register, input, binding.Slot );
return binding.IsWholeSlot ? field : builder.Swizzle( binding.Type, field, binding.Swizzle );
}
IrValue ReadVarying( VaryingBinding binding, ShaderStage stage ) => VaryingAccess( Builder( stage ), binding );
bool IsVisiting( NodeId node, ShaderStage stage ) =>
_visited.TryGetValue( (node, stage), out var state ) && state == VisitState.Visiting;
// ---- module registration ----------------------------------------------
/// <summary>
/// Add a global to the module, or reuse the identical declaration already there. A different
/// declaration claiming the same name is reported rather than silently overwriting.
/// </summary>
public GlobalDecl RegisterGlobal( GlobalDecl decl, NodeId origin )
{
if ( decl is null || string.IsNullOrEmpty( decl.Name ) ) return null;
decl = PreviewTextureBinding( decl );
var existing = Module.FindGlobal( decl.Name );
if ( existing is null )
{
Module.Globals.Add( decl );
// Recorded here rather than in PreviewTextureBinding: four nodes sampling one slot register
// the same declaration four times, and the preview only needs to be told about it once.
RecordPreviewTexture( decl );
return decl;
}
if ( existing.ConflictsWith( decl ) )
{
Diagnostics.Error( DiagnosticCode.GlobalCollision,
$"Two different declarations both claim the name '{decl.Name}'",
GraphRef.ForNode( origin ),
$"{existing} vs {decl}" );
}
return existing;
}
/// <summary>
/// Add a helper and everything it needs to the module, in dependency order. A same-name helper with
/// a different body is a hard error naming both, not a silent first-one-wins.
/// </summary>
public HelperFunction RegisterHelper( HelperFunction fn, NodeId origin )
{
if ( fn is null || string.IsNullOrEmpty( fn.Name ) ) return null;
var existing = Module.Helpers.FirstOrDefault( x => x.Name == fn.Name );
if ( existing is not null )
{
if ( existing.ConflictsWith( fn ) )
{
Diagnostics.Error( DiagnosticCode.HelperCollision,
$"Two different helper functions are both called '{fn.Name}'",
GraphRef.ForNode( origin ),
"Helpers are deduplicated by name per module. Rename one of them." );
}
return existing;
}
foreach ( var required in fn.Requires ?? Array.Empty<HelperFunction>() )
{
if ( required is null || ReferenceEquals( required, fn ) ) continue;
RegisterHelper( required, origin );
}
Module.Helpers.Add( fn );
foreach ( var include in fn.Includes ?? Array.Empty<string>() ) Module.AddInclude( include );
foreach ( var capability in fn.Capabilities ?? Array.Empty<Capability>() )
{
if ( capability != Capability.None ) Module.Meta.Capabilities.Add( capability );
}
return fn;
}
/// <summary>
/// Rebind a texture slot to a render attribute for the preview, and remember which asset belongs in
/// it so the viewport can push the real texture.
/// <remarks>
/// A shipping shader declares a texture as <c>CreateInputTexture2D</c> plus a <c>Channel( … Box( … ) )</c>
/// slot. That pair is resolved by the <em>resource compiler</em> when a material is built: the source
/// image named by <c>DefaultFile</c> is baked into the material's own texture. The preview has no
/// material — it renders the shader straight onto a scene object with render attributes — so nothing
/// ever performs that bake and every sampler reads black. A graph whose output is multiplied by its
/// textures then previews as a black surface, and any animation in it is invisible because it is
/// being multiplied by zero.
/// <para>
/// Binding to an attribute instead is what the built-in editor does for exactly this reason (see its
/// <c>GraphCompiler</c> preview branch), and <c>DeclareTexture</c> already emits that form for any
/// declaration carrying an <c>AttributeName</c>. The asset path travels out on the compile result so
/// the viewport can load it once and push it through <see cref="Preview.PreviewAttributeBus"/>.
/// </para>
/// </remarks>
/// </summary>
GlobalDecl PreviewTextureBinding( GlobalDecl decl )
{
if ( Mode != CompileMode.Preview || decl.Kind != GlobalKind.Texture ) return decl;
// Already attribute-bound: the graph asked for that itself, and whatever drives it owns the push.
if ( !string.IsNullOrEmpty( decl.AttributeName ) ) return decl;
return decl with { AttributeName = decl.Name };
}
/// <summary>
/// Note a newly declared preview texture slot so the viewport can fill it. Only slots naming an
/// asset are recorded; one with nothing to load would push white over a slot the user may be driving
/// themselves through the parameter panel.
/// </summary>
void RecordPreviewTexture( GlobalDecl decl )
{
if ( Mode != CompileMode.Preview || decl.Kind != GlobalKind.Texture ) return;
if ( string.IsNullOrWhiteSpace( decl.DefaultAsset ) || string.IsNullOrEmpty( decl.AttributeName ) ) return;
_previewTextures.Add( new PreviewTexture( decl.AttributeName, decl.DefaultAsset, decl.Srgb )
{
Parameter = decl.Parameter
} );
}
/// <summary>
/// Turn a literal into a uniform the preview can push straight to the GPU, so dragging a slider
/// updates the frame without recompiling anything.
/// </summary>
public IrValue PreviewUniform( NodeId node, PortId port, ShaderType type, ConstValue value, ShaderStage stage )
{
var tag = stage switch
{
ShaderStage.Vertex => "vs",
ShaderStage.Pixel => "ps",
ShaderStage.Geometry => "gs",
ShaderStage.Compute => "cs",
_ => "any"
};
var name = $"{PrismConstants.SymbolPrefix}_{tag}_{_previewSerial++}";
var decl = new GlobalDecl( name, type, GlobalKind.Uniform )
{
AttributeName = name,
Default = value,
PreviewOnly = true,
Stages = stage.ToMask()
};
RegisterGlobal( decl, node );
_previewAttributes.Add( new PreviewAttribute( name, type, value )
{
Node = node,
Port = port
} );
return Builder( stage ).GlobalRef( decl );
}
// ---- literals ---------------------------------------------------------
/// <summary>Read a node property by name, tolerating anything that is not there.</summary>
public static object ReadProperty( PrismNode node, string name )
{
if ( node is null || string.IsNullOrEmpty( name ) ) return null;
try
{
var property = node.GetType().GetProperty( name,
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy );
if ( property is not null && property.CanRead ) return property.GetValue( node );
var field = node.GetType().GetField( name,
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance );
return field?.GetValue( node );
}
catch ( Exception e )
{
PrismLog.Error( e, $"Reading property '{name}' from {node.GetType().Name} failed" );
return null;
}
}
/// <summary>
/// Turn a boxed authored literal into a typed constant. Deliberately permissive: an inline value
/// arrives from JSON, from a node property or from a paste, and none of those are trustworthy.
/// </summary>
public static bool TryReadConstant( object raw, ShaderType hint, out ShaderType type, out ConstValue value )
{
type = ShaderType.Void;
value = default;
if ( raw is null ) return false;
switch ( raw )
{
case bool b:
type = Shape( hint, ShaderType.Bool );
value = ConstValue.From( b );
return true;
case float f:
type = Shape( hint, ShaderType.Float );
value = ConstValue.From( f );
return true;
case double d:
type = Shape( hint, ShaderType.Float );
value = ConstValue.From( (float)d );
return true;
case int i:
type = Shape( hint, hint.IsFloatingPoint ? ShaderType.Float : ShaderType.Int );
value = ConstValue.From( i );
return true;
case long l:
type = Shape( hint, hint.IsFloatingPoint ? ShaderType.Float : ShaderType.Int );
value = ConstValue.From( (int)l );
return true;
case short s:
type = Shape( hint, ShaderType.Int );
value = ConstValue.From( (int)s );
return true;
case byte by:
type = Shape( hint, ShaderType.Int );
value = ConstValue.From( (int)by );
return true;
case Vector2 v2:
type = Shape( hint, ShaderType.Float2 );
value = ConstValue.From( v2 );
return true;
case Vector3 v3:
type = Shape( hint, ShaderType.Float3 );
value = ConstValue.From( v3 );
return true;
case Vector4 v4:
type = Shape( hint, ShaderType.Float4 );
value = ConstValue.From( v4 );
return true;
case Color color:
type = Shape( hint, ShaderType.Float4 );
value = ConstValue.From( color );
return true;
case Enum e:
type = Shape( hint, ShaderType.Int );
value = ConstValue.From( Convert.ToInt32( e, CultureInfo.InvariantCulture ) );
return true;
case ConstValue constant:
type = hint.IsNumeric ? hint : ShaderType.Float4;
value = constant;
return true;
case string text:
return TryParseText( text, hint, out type, out value );
case JsonNode json:
return TryReadJson( json, hint, out type, out value );
}
if ( raw is System.Collections.IEnumerable sequence and not string )
{
var numbers = new List<double>( 4 );
foreach ( var item in sequence )
{
if ( item is null ) continue;
try
{
numbers.Add( Convert.ToDouble( item, CultureInfo.InvariantCulture ) );
}
catch ( Exception )
{
return false;
}
if ( numbers.Count == 4 ) break;
}
if ( numbers.Count == 0 ) return false;
type = Shape( hint, ShaderType.Vec( hint.IsNumeric ? hint.Scalar : ScalarKind.Float, numbers.Count ) );
value = FromList( numbers );
return true;
}
return false;
}
static bool TryParseText( string text, ShaderType hint, out ShaderType type, out ConstValue value )
{
type = ShaderType.Void;
value = default;
if ( string.IsNullOrWhiteSpace( text ) ) return false;
if ( bool.TryParse( text, out var flag ) )
{
type = Shape( hint, ShaderType.Bool );
value = ConstValue.From( flag );
return true;
}
var parts = text.Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
var numbers = new List<double>( 4 );
foreach ( var part in parts )
{
if ( !double.TryParse( part, NumberStyles.Float, CultureInfo.InvariantCulture, out var number ) )
{
return false;
}
numbers.Add( number );
if ( numbers.Count == 4 ) break;
}
if ( numbers.Count == 0 ) return false;
type = Shape( hint, ShaderType.Vec( hint.IsNumeric ? hint.Scalar : ScalarKind.Float, numbers.Count ) );
value = FromList( numbers );
return true;
}
static bool TryReadJson( JsonNode json, ShaderType hint, out ShaderType type, out ConstValue value )
{
type = ShaderType.Void;
value = default;
try
{
if ( json is JsonArray array )
{
var numbers = new List<double>( 4 );
foreach ( var item in array )
{
if ( item is null ) continue;
numbers.Add( item.GetValue<double>() );
if ( numbers.Count == 4 ) break;
}
if ( numbers.Count == 0 ) return false;
type = Shape( hint, ShaderType.Vec( hint.IsNumeric ? hint.Scalar : ScalarKind.Float, numbers.Count ) );
value = FromList( numbers );
return true;
}
if ( json is JsonValue scalar )
{
if ( scalar.TryGetValue<bool>( out var flag ) )
{
type = Shape( hint, ShaderType.Bool );
value = ConstValue.From( flag );
return true;
}
if ( scalar.TryGetValue<double>( out var number ) )
{
type = Shape( hint, hint.IsIntegral ? ShaderType.Int : ShaderType.Float );
value = ConstValue.From( (float)number );
return true;
}
if ( scalar.TryGetValue<string>( out var text ) ) return TryParseText( text, hint, out type, out value );
}
}
catch ( Exception )
{
return false;
}
return false;
}
static ConstValue FromList( IReadOnlyList<double> numbers )
{
double At( int index ) => index < numbers.Count ? numbers[index] : numbers.Count == 1 ? numbers[0] : 0;
return new ConstValue( At( 0 ), At( 1 ), At( 2 ), At( 3 ) );
}
/// <summary>
/// Reconcile the shape the literal arrived in with the shape the port wants. A scalar feeding a
/// vector port stays a scalar and is splatted by the conversion machinery; anything else adopts the
/// port's component kind so an authored <c>1</c> on a float port is a float, not an int.
/// </summary>
static ShaderType Shape( ShaderType hint, ShaderType natural )
{
if ( !hint.IsNumeric ) return natural;
if ( !natural.IsNumeric ) return natural;
if ( natural.Components == 1 && hint.Components > 1 ) return ShaderType.Vec( hint.Scalar, 1 );
return ShaderType.Vec( hint.Scalar, natural.Components );
}
static int Count( IrBlock block )
{
if ( block is null ) return 0;
var total = 0;
foreach ( var statement in block.Statements )
{
total++;
switch ( statement )
{
case IrIf branch:
total += Count( branch.Then ) + Count( branch.Else );
break;
case IrFor loop:
total += Count( loop.Body );
break;
case IrWhile loop:
total += Count( loop.Body );
break;
case IrScope scope:
total += Count( scope.Body );
break;
}
}
return total;
}
/// <summary>A node's display name, falling back to its type name.</summary>
public static string Describe( PrismNode node )
{
if ( node is null ) return "<missing node>";
var title = PrismLog.Guard( "Reading node descriptor", () => node.Descriptor?.Title, null );
return string.IsNullOrEmpty( title ) ? node.GetType().Name : title;
}
enum VisitState
{
Visiting,
Done,
Failed
}
}
Editor
library
using Editor.Prism.Core;
using System.IO;
namespace Editor.Prism.Integration;
/// <summary>
/// Routes a double-clicked <c>.prism</c> or <c>.prismfn</c> into the Prism window.
/// <para>
/// These are deliberately <b>static method</b> handlers rather than an <c>IAssetEditor</c> window
/// class. <c>IAssetEditor.OpenInEditor</c> runs <c>TryOpenUsingStaticMethod</c> first, so a static
/// handler is the only registration that resolves deterministically — the class path picks a winner
/// with <c>FirstOrDefault()</c> over an unordered type list. It also keeps us out of the two static
/// dictionaries <c>IAssetEditor</c> keeps alive across hotloads, which are the usual source of
/// "double-clicking the asset does nothing after a reload".
/// </para>
/// <para>
/// The method must take exactly one <see cref="Asset"/> parameter and be static, or the dispatcher
/// silently ignores it.
/// </para>
/// </summary>
public static class PrismAssetEditor
{
/// <summary>Open a shader graph document. Bound to the <c>prism</c> extension.</summary>
[EditorForAssetType( PrismConstants.GraphExtension )]
public static void OpenGraph( Asset asset )
{
Open( asset );
}
/// <summary>Open a subgraph document. Bound to the <c>prismfn</c> extension.</summary>
[EditorForAssetType( PrismConstants.SubgraphExtension )]
public static void OpenSubgraph( Asset asset )
{
Open( asset );
}
/// <summary>
/// Open an asset in Prism, reporting rather than throwing when it cannot be opened. Safe to call
/// from a context menu, a drag-drop handler or a console command.
/// </summary>
public static bool Open( Asset asset )
{
if ( asset is null ) return false;
if ( asset.IsDeleted )
{
PrismLog.Warn( $"'{asset.Name}' has been deleted" );
return false;
}
return PrismLauncher.OpenAsset( asset );
}
/// <summary>Open by absolute path, registering the file with the asset system first if we can.</summary>
public static bool Open( string absolutePath )
{
if ( string.IsNullOrWhiteSpace( absolutePath ) ) return false;
var asset = PrismLog.Guard( "Finding the asset for a Prism document",
() => AssetSystem.FindByPath( absolutePath ), null );
if ( asset is not null ) return Open( asset );
// Not registered — either it lives outside a mounted content path, or the asset system has
// not caught up with a file we only just wrote. Opening by path always works.
return PrismLauncher.OpenDocument( absolutePath );
}
/// <summary>True when the path is a Prism document we own.</summary>
public static bool IsPrismDocument( string path )
{
if ( string.IsNullOrWhiteSpace( path ) ) return false;
var extension = Path.GetExtension( path );
if ( string.IsNullOrEmpty( extension ) ) return false;
extension = extension.TrimStart( '.' );
return extension.Equals( PrismConstants.GraphExtension, StringComparison.OrdinalIgnoreCase )
|| extension.Equals( PrismConstants.SubgraphExtension, StringComparison.OrdinalIgnoreCase );
}
/// <summary>True when the path is specifically a subgraph.</summary>
public static bool IsSubgraphDocument( string path )
{
if ( string.IsNullOrWhiteSpace( path ) ) return false;
return Path.GetExtension( path )
.TrimStart( '.' )
.Equals( PrismConstants.SubgraphExtension, StringComparison.OrdinalIgnoreCase );
}
/// <summary>
/// Drop dead entries out of the two static maps <c>IAssetEditor</c> keeps.
/// <para>
/// Both survive a hotload because they are static fields on an interface, and both are keyed by
/// strings that outlive the windows they point at. Entries whose window has been destroyed, or
/// whose type came from an assembly that has since been swapped out, leave the asset browser
/// believing a document is already open and silently doing nothing on double-click.
/// </para>
/// </summary>
public static int PruneStaleEditors()
{
var removed = 0;
PrismLog.Guard( "Pruning stale asset editors", () =>
{
removed += Prune( IAssetEditor.OpenSingleEditors );
removed += Prune( IAssetEditor.OpenMultiAssetEditors );
} );
return removed;
}
static int Prune( Dictionary<string, IAssetEditor> map )
{
if ( map is null || map.Count == 0 ) return 0;
var dead = new List<string>();
foreach ( var pair in map )
{
var editor = pair.Value;
if ( editor is null || !editor.IsValid )
{
dead.Add( pair.Key );
continue;
}
// One of our windows left behind by a hotload is a zombie: the native widget is still
// alive so IsValid answers true, but every delegate on it points into the old assembly.
// Someone else's editor is none of our business, current or not.
var type = editor.GetType();
if ( type.Assembly == typeof( PrismAssetEditor ).Assembly ) continue;
if ( type.FullName is null ) continue;
if ( !type.FullName.StartsWith( "Editor.Prism", StringComparison.Ordinal ) ) continue;
dead.Add( pair.Key );
}
foreach ( var key in dead )
{
map.Remove( key );
}
return dead.Count;
}
}
Editor
library
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Ui;
using System.Text;
namespace Editor.Prism.Integration;
/// <summary>
/// Everything Prism knows how to explain about one node type, assembled from the same metadata the
/// graph and the node library already use — never a second, drifting copy.
/// </summary>
public sealed record PrismNodeHelp(
string Id, string Title, string Category, string Icon, string Summary,
IReadOnlyList<string> Keywords, NodeTier Tier, string Since, string DeprecatedBy,
IReadOnlyList<PortDef> Inputs, IReadOnlyList<PortDef> Outputs )
{
/// <summary>True when there is nothing useful to show.</summary>
public bool IsEmpty => string.IsNullOrEmpty( Id );
/// <summary>A one-line status for the header: tier, availability and replacement.</summary>
public string Status
{
get
{
var parts = new List<string>();
if ( Tier != NodeTier.Common ) parts.Add( Tier.ToString() );
if ( !string.IsNullOrWhiteSpace( Since ) ) parts.Add( $"since {Since}" );
if ( !string.IsNullOrWhiteSpace( DeprecatedBy ) ) parts.Add( $"replaced by {DeprecatedBy}" );
return string.Join( " · ", parts );
}
}
/// <summary>Plain-text rendering, for a tooltip or the clipboard.</summary>
public string ToPlainText()
{
var builder = new StringBuilder();
builder.AppendLine( Title );
if ( !string.IsNullOrWhiteSpace( Category ) ) builder.AppendLine( Category );
builder.AppendLine();
if ( !string.IsNullOrWhiteSpace( Summary ) )
{
builder.AppendLine( Summary );
builder.AppendLine();
}
Append( builder, "Inputs", Inputs );
Append( builder, "Outputs", Outputs );
builder.AppendLine( $"Type id: {Id}" );
return builder.ToString();
}
static void Append( StringBuilder builder, string heading, IReadOnlyList<PortDef> ports )
{
if ( ports is null || ports.Count == 0 ) return;
builder.AppendLine( heading );
foreach ( var port in ports )
{
builder.Append( " " ).Append( port.DisplayName ).Append( " " ).Append( port.DeclaredType );
if ( !string.IsNullOrWhiteSpace( port.Tooltip ) ) builder.Append( " — " ).Append( port.Tooltip );
builder.AppendLine();
}
builder.AppendLine();
}
}
/// <summary>
/// In-editor help: the orientation panel a new user sees once, and the per-node reference every user
/// reaches from the node library, the inspector or the <c>Prism</c> menu.
/// </summary>
public static class PrismDocumentation
{
static PrismWelcomeWindow s_welcome;
static PrismNodeReferenceWindow s_reference;
/// <summary>Documentation for one registered node type, or an empty record when it is unknown.</summary>
public static PrismNodeHelp Lookup( string typeId )
{
if ( string.IsNullOrWhiteSpace( typeId ) ) return Empty;
return PrismLog.Guard( "Looking up node documentation", () =>
{
NodeRegistry.EnsureBuilt();
return NodeRegistry.TryResolve( typeId, out var descriptor ) ? For( descriptor ) : Empty;
}, Empty );
}
/// <summary>Documentation for a node instance.</summary>
public static PrismNodeHelp For( PrismNode node ) => node is null ? Empty : For( node.Descriptor );
/// <summary>Documentation built from a descriptor.</summary>
public static PrismNodeHelp For( NodeDescriptor descriptor )
{
if ( descriptor is null ) return Empty;
return new PrismNodeHelp(
descriptor.Id,
string.IsNullOrWhiteSpace( descriptor.Title ) ? descriptor.Id : descriptor.Title,
descriptor.Category,
string.IsNullOrWhiteSpace( descriptor.Icon ) ? "extension" : descriptor.Icon,
descriptor.Description,
descriptor.Keywords ?? Array.Empty<string>(),
descriptor.Tier,
descriptor.Since,
descriptor.DeprecatedBy,
descriptor.Inputs ?? Array.Empty<PortDef>(),
descriptor.Outputs ?? Array.Empty<PortDef>() );
}
/// <summary>The "nothing to show" record.</summary>
public static PrismNodeHelp Empty { get; } = new( null, null, null, null, null,
Array.Empty<string>(), NodeTier.Common, null, null,
Array.Empty<PortDef>(), Array.Empty<PortDef>() );
// ---- windows -----------------------------------------------------------
/// <summary>Open the node reference, optionally scrolled to one node.</summary>
public static void ShowNodeReference( string typeId = null )
{
PrismLog.Guard( "Opening the Prism node reference", () =>
{
if ( s_reference is null || !s_reference.IsValid )
{
s_reference = new PrismNodeReferenceWindow();
}
s_reference.Show();
s_reference.Focus();
if ( !string.IsNullOrWhiteSpace( typeId ) ) s_reference.SelectNode( typeId );
} );
}
/// <summary>Open the orientation panel on demand.</summary>
public static void ShowWelcome()
{
PrismLog.Guard( "Opening the Prism welcome panel", () =>
{
if ( s_welcome is null || !s_welcome.IsValid )
{
s_welcome = new PrismWelcomeWindow();
}
s_welcome.Show();
s_welcome.Focus();
} );
}
/// <summary>
/// Show the orientation panel the very first time Prism is opened, and never again unless it is
/// asked for. Called from every path that opens a window.
/// </summary>
public static void ShowWelcomeIfFirstRun()
{
if ( PrismCookies.WelcomeShown ) return;
PrismCookies.WelcomeShown = true;
ShowWelcome();
}
/// <summary>Drop the cached windows outright.</summary>
public static void Reset()
{
s_welcome = null;
s_reference = null;
}
/// <summary>
/// What hotload calls. A window that is still on screen is kept — the hotload system migrates the
/// instance rather than destroying it, and dropping the reference here would leave the user with a
/// second copy the next time they asked for one.
/// </summary>
public static void Revalidate()
{
if ( s_welcome is not null && !s_welcome.IsValid ) s_welcome = null;
if ( s_reference is not null && !s_reference.IsValid ) s_reference = null;
PrismLog.Guard( "Reloading the Prism node reference", () =>
{
if ( s_reference is not null && s_reference.IsValid ) s_reference.Reload();
} );
}
}
/// <summary>
/// The first-run orientation panel: what Prism is, what makes it different from the built-in shader
/// graph, and the six things worth knowing before the first graph.
/// </summary>
public sealed class PrismWelcomeWindow : BaseWindow
{
/// <summary>Build the panel.</summary>
public PrismWelcomeWindow()
{
WindowTitle = "What Is Prism?";
SetWindowIcon( "gradient" );
Size = new Vector2( 720f, 660f );
MinimumSize = new Vector2( 560f, 420f );
Layout = Layout.Column();
Layout.Margin = 0f;
var scroll = new ScrollArea( this );
scroll.Canvas = new Widget( scroll );
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Margin = 28f;
scroll.Canvas.Layout.Spacing = 10f;
Build( scroll.Canvas.Layout );
Layout.Add( scroll, 1 );
var footer = Layout.AddRow();
footer.Margin = new Sandbox.UI.Margin( 28f, 0f, 28f, 20f );
footer.Spacing = 8f;
var reference = footer.Add( new Button( "Node Reference", "menu_book", this ) );
reference.Clicked = () => PrismDocumentation.ShowNodeReference();
footer.AddStretchCell();
var close = footer.Add( new Button.Primary( "Start Building", "arrow_forward", this ) );
close.Clicked = Close;
}
void Build( Layout layout )
{
var title = layout.Add( new Label.Title( "Prism" ) );
title.Color = PrismTheme.TextPrimary;
var lead = layout.Add( new Label.Subtitle(
"A node-based shader editor for s&box that treats generated code as something you are meant to read." ) );
lead.Color = PrismTheme.TextSecondary;
lead.WordWrap = true;
layout.AddSpacingCell( 8f );
Section( layout, "gradient", "Graphs compile to real HLSL",
"Everything you wire up becomes a readable .shader beside the document, with the same block "
+ "structure a hand-written one has. The Code panel shows it live, and clicking a line "
+ "selects the node that produced it." );
Section( layout, "rule", "Connections are type-checked",
"Free conversions connect silently. A lossy or padded one connects, warns, and draws a marker "
+ "on the wire telling you exactly what it did — the built-in editor pads float2 to float3 with "
+ "zero and never says so. Illegal connections are refused at the drop." );
Section( layout, "history", "Nothing is quietly destroyed",
"Node ids are minted once and never renumbered. A node whose plugin is missing survives as a "
+ "placeholder and re-saves byte-identically. A connection that cannot resolve stays as a "
+ "visible ghost instead of vanishing." );
Section( layout, "bolt", "The preview is the shader",
"There is no separate preview path. Edits are debounced and recompiled with the minimum combo "
+ "set, so the sphere shows the same code the material will use. The status strip tells you "
+ "how long each compile took." );
Section( layout, "functions", "Subgraphs and custom code are first class",
"A .prismfn is a reusable function with its own inputs and outputs. When a node does not exist "
+ "yet, the Custom Code node takes HLSL directly — a missing node is an inconvenience, not a wall." );
Section( layout, "keyboard", "Worth learning early",
"Space or double-click on empty canvas opens the node search. Dragging a wire into empty space "
+ "opens it filtered by type. Ctrl+Z and Ctrl+Y are per-document. Ctrl+S saves the document and "
+ "regenerates the shader beside it." );
layout.AddSpacingCell( 8f );
var footnote = layout.Add( new Label.Small(
"Prism never registers the built-in .shdrgrph or .shdrfunc extensions. To bring an existing graph "
+ "across, right-click it and choose Import into Prism." ) );
footnote.Color = PrismTheme.TextMuted;
footnote.WordWrap = true;
layout.AddStretchCell();
}
void Section( Layout layout, string icon, string heading, string body )
{
layout.AddSpacingCell( 12f );
var row = layout.AddRow();
row.Spacing = 12f;
row.Add( new PrismGlyph( this, icon, PrismTheme.Accent ) );
var column = row.AddColumn( 1 );
column.Spacing = 3f;
var header = column.Add( new Label.Header( heading ) );
header.Color = PrismTheme.TextPrimary;
var text = column.Add( new Label.Body( body ) );
text.Color = PrismTheme.TextSecondary;
text.WordWrap = true;
}
}
/// <summary>
/// A fixed-size material icon as a layout item. Qt labels cannot render one, and a whole
/// <c>IconButton</c> would bring click behaviour and hover states nobody asked for.
/// </summary>
internal sealed class PrismGlyph : Widget
{
readonly string _icon;
readonly Color _color;
readonly float _size;
/// <summary>Build a glyph of the given size, in the given colour.</summary>
public PrismGlyph( Widget parent, string icon, Color color, float size = 20f ) : base( parent )
{
_icon = string.IsNullOrWhiteSpace( icon ) ? "circle" : icon;
_color = color;
_size = size;
FixedSize = new Vector2( size + 6f, size + 6f );
}
/// <summary>Draw the glyph, centred.</summary>
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.SetPen( _color );
Paint.DrawIcon( LocalRect, _icon, _size, TextFlag.Center );
}
}
/// <summary>
/// Every registered node type, searchable, with the ports and description the compiler and the node
/// library read from the same metadata.
/// </summary>
public sealed class PrismNodeReferenceWindow : BaseWindow
{
readonly List<PrismNodeType> _all = new();
ListView _list;
LineEdit _search;
Widget _detail;
Label _count;
/// <summary>Build the window and load the registry.</summary>
public PrismNodeReferenceWindow()
{
WindowTitle = "Prism Node Reference";
SetWindowIcon( "menu_book" );
Size = new Vector2( 1040f, 700f );
MinimumSize = new Vector2( 720f, 460f );
Layout = Layout.Column();
Layout.Margin = 16f;
Layout.Spacing = 10f;
BuildHeader();
BuildBody();
Reload();
}
void BuildHeader()
{
var row = Layout.AddRow();
row.Spacing = 8f;
_search = row.Add( new LineEdit( this ), 1 );
_search.PlaceholderText = "Search nodes by name, category or keyword";
_search.TextEdited += _ => Populate();
var refresh = row.Add( new Button( "", "refresh", this ) );
refresh.Clicked = Reload;
refresh.StatusTip = "Rebuild the node registry";
_count = Layout.Add( new Label.Small( "" ) );
_count.Color = PrismTheme.TextMuted;
}
void BuildBody()
{
var row = Layout.AddRow( 1 );
row.Spacing = 12f;
_list = row.Add( new ListView( this ), 1 );
_list.ItemSize = new Vector2( -1f, 34f );
_list.ItemSpacing = new Vector2( 0f, 2f );
_list.Margin = 2f;
_list.ItemPaint = PaintRow;
_list.ItemSelected = item => ShowDetail( item as PrismNodeType );
var scroll = new ScrollArea( this );
scroll.Canvas = new Widget( scroll );
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Margin = 4f;
scroll.Canvas.Layout.Spacing = 6f;
_detail = scroll.Canvas;
row.Add( scroll, 2 );
ShowDetail( null );
}
/// <summary>Rebuild from the registry — useful after a hotload adds node types.</summary>
public void Reload()
{
PrismLog.Guard( "Loading the Prism node registry", () =>
{
NodeRegistry.EnsureBuilt();
_all.Clear();
_all.AddRange( NodeRegistry.Types
.OrderBy( x => x.Category ?? string.Empty, StringComparer.OrdinalIgnoreCase )
.ThenBy( x => x.Title ?? string.Empty, StringComparer.OrdinalIgnoreCase ) );
} );
Populate();
}
/// <summary>Select and reveal one node type by its stable id.</summary>
public void SelectNode( string typeId )
{
PrismLog.Guard( "Selecting a node in the reference", () =>
{
var match = _all.FirstOrDefault( x => string.Equals( x.Id, typeId, StringComparison.Ordinal ) );
if ( match is null ) return;
_list?.ScrollTo( match );
ShowDetail( match );
} );
}
void Populate()
{
PrismLog.Guard( "Filtering the Prism node reference", () =>
{
var text = _search?.Text ?? string.Empty;
var matches = string.IsNullOrWhiteSpace( text )
? _all
: NodeRegistry.Search( text ).ToList();
_list?.SetItems( matches.Cast<object>() );
if ( _count is not null )
{
_count.Text = matches.Count == _all.Count
? $"{_all.Count} node types"
: $"{matches.Count} of {_all.Count} node types";
}
} );
}
void PaintRow( VirtualWidget item )
{
if ( item?.Object is not PrismNodeType type ) return;
var rect = item.Rect;
Paint.Antialiasing = true;
Paint.ClearPen();
if ( item.Selected ) Paint.SetBrush( PrismTheme.AccentSoft );
else if ( item.Hovered ) Paint.SetBrush( PrismTheme.PanelAlt );
else Paint.ClearBrush();
if ( item.Selected || item.Hovered ) Paint.DrawRect( rect, PrismTheme.RadiusChip );
var iconRect = new Rect( rect.Left + 8f, rect.Top + ( rect.Height - 16f ) * 0.5f, 16f, 16f );
Paint.SetPen( item.Selected ? PrismTheme.Accent : PrismTheme.TextMuted );
Paint.DrawIcon( iconRect, string.IsNullOrWhiteSpace( type.Icon ) ? "extension" : type.Icon, 15f );
var textRect = new Rect( rect.Left + 32f, rect.Top, rect.Width - 40f, rect.Height );
Paint.SetPen( item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );
Paint.SetFont( PrismTheme.FontFamily, PrismTheme.BodySize, 500, false, false );
Paint.DrawText( textRect, type.Title ?? type.Id, TextFlag.LeftCenter );
Paint.SetPen( PrismTheme.TextDisabled );
Paint.SetFont( PrismTheme.FontFamily, PrismTheme.PortLabelSize, 400, false, false );
Paint.DrawText( textRect, type.Category ?? string.Empty, TextFlag.RightCenter );
}
void ShowDetail( PrismNodeType type )
{
if ( _detail is null ) return;
_detail.Layout.Clear( true );
if ( type is null )
{
var empty = _detail.Layout.Add( new Label.Body(
"Pick a node on the left to see what it does, what it takes and what it returns." ) );
empty.Color = PrismTheme.TextMuted;
empty.WordWrap = true;
_detail.Layout.AddStretchCell();
return;
}
var help = PrismDocumentation.For( type.Descriptor );
var title = _detail.Layout.Add( new Label.Title( help.Title ) );
title.Color = PrismTheme.TextPrimary;
var subtitle = _detail.Layout.Add( new Label.Small(
string.Join( " · ", new[] { help.Category, help.Status }.Where( x => !string.IsNullOrWhiteSpace( x ) ) ) ) );
subtitle.Color = PrismTheme.TextMuted;
if ( !string.IsNullOrWhiteSpace( help.Summary ) )
{
_detail.Layout.AddSpacingCell( 6f );
var summary = _detail.Layout.Add( new Label.Body( help.Summary ) );
summary.Color = PrismTheme.TextSecondary;
summary.WordWrap = true;
}
Ports( "Inputs", help.Inputs );
Ports( "Outputs", help.Outputs );
if ( help.Keywords.Count > 0 )
{
_detail.Layout.AddSpacingCell( 8f );
var keywords = _detail.Layout.Add( new Label.Small( "Also found by: " + string.Join( ", ", help.Keywords ) ) );
keywords.Color = PrismTheme.TextDisabled;
keywords.WordWrap = true;
}
_detail.Layout.AddSpacingCell( 8f );
var id = _detail.Layout.Add( new Label.Small( $"Type id {help.Id}" ) );
id.Color = PrismTheme.TextDisabled;
id.TextSelectable = true;
var copy = _detail.Layout.Add( new Button( "Copy Documentation", "content_copy", this ) );
copy.Clicked = () => PrismLog.Guard( "Copying node documentation",
() => EditorUtility.Clipboard.Copy( help.ToPlainText() ) );
_detail.Layout.AddStretchCell();
}
void Ports( string heading, IReadOnlyList<PortDef> ports )
{
if ( ports is null || ports.Count == 0 ) return;
_detail.Layout.AddSpacingCell( 10f );
var header = _detail.Layout.Add( new Label.Header( heading ) );
header.Color = PrismTheme.TextPrimary;
foreach ( var port in ports )
{
var row = _detail.Layout.AddRow();
row.Spacing = 8f;
var name = row.Add( new Label( port.DisplayName ?? port.Id.ToString(), this ) );
name.Color = PrismTheme.TextSecondary;
name.MinimumWidth = 130f;
var declared = row.Add( new Label( port.DeclaredType ?? "float", this ) );
declared.Color = PrismTheme.TypeGeneric;
declared.MinimumWidth = 70f;
var tooltip = row.Add( new Label( port.Tooltip ?? string.Empty, this ), 1 );
tooltip.Color = PrismTheme.TextMuted;
tooltip.WordWrap = true;
}
}
}
Editor
library
using Editor.Prism.Core;
using System.ComponentModel;
using System.Reflection;
namespace Editor.Prism.Model;
/// <summary>Which side of a node a port lives on.</summary>
public enum PortDirection
{
/// <summary>Consumes a value. At most one incoming edge.</summary>
Input,
/// <summary>Produces a value. Any number of outgoing edges.</summary>
Output
}
/// <summary>Behavioural flags on a port.</summary>
[Flags]
public enum PortFlags
{
/// <summary>Nothing special.</summary>
None = 0,
/// <summary>Leaving this input unconnected with no inline value is an error.</summary>
Required = 1 << 0,
/// <summary>Not drawn on the card. Still connectable programmatically.</summary>
Hidden = 1 << 1,
/// <summary>Never draw an inline value pill for this input.</summary>
NoInlineEditor = 1 << 2,
/// <summary>Part of a variadic group; the node grows another socket as this one is filled.</summary>
Variadic = 1 << 3,
/// <summary>Opts out of type inference — the value passes through unchanged (reroute, custom code).</summary>
Passthrough = 1 << 4,
/// <summary>Drawn in the node's title bar rather than a port row.</summary>
InTitleBar = 1 << 5,
/// <summary>This input accepts more than one incoming edge (variadic sums, subgraph fan-in).</summary>
AllowMultiple = 1 << 6,
/// <summary>Created by <see cref="PortBuilder"/> at runtime rather than by an attribute.</summary>
Dynamic = 1 << 7
}
/// <summary>
/// A reference to one port of one node. This is the property type used by <c>[In]</c> and
/// <c>[Out]</c> declarations, and the shape both ends of an <see cref="Edge"/> serialize as.
/// </summary>
public readonly record struct PortRef( NodeId Node, PortId Port )
{
/// <summary>The unset reference.</summary>
public static readonly PortRef None = default;
/// <summary>True when both halves are set.</summary>
public bool IsValid => Node.IsValid && Port.IsValid;
/// <summary>Build a reference from raw strings.</summary>
public static PortRef Parse( string node, string port ) => new( NodeId.Parse( node ), PortId.Parse( port ) );
/// <inheritdoc/>
public override string ToString() => IsValid ? $"{Node}.{Port}" : "<none>";
}
/// <summary>
/// The immutable declaration of a port: what it is called, what type it claims to be and how it
/// behaves. Produced by reflection over <c>[In]</c>/<c>[Out]</c> properties and then optionally
/// amended by <see cref="PortBuilder"/> inside <c>PrismNode.OnDefinePorts</c>.
/// </summary>
public sealed record PortDef( PortId Id, string DisplayName, string DeclaredType, PortDirection Direction )
{
/// <summary>Optional collapsible group on the card.</summary>
public string Group { get; init; }
/// <summary>Tooltip shown on the handle and the label.</summary>
public string Tooltip { get; init; }
/// <summary>Behavioural flags.</summary>
public PortFlags Flags { get; init; }
/// <summary>Sort key within the node. Ties keep declaration order.</summary>
public int Order { get; init; }
/// <summary>Name of the <c>[In]</c>/<c>[Out]</c> property that declared this port, when there is one.</summary>
public string PropertyName { get; init; }
/// <summary>Name of the <c>[InlineValue]</c> property that supplies the unconnected value, when there is one.</summary>
public string InlineValueProperty { get; init; }
/// <summary>Former ids that must still deserialize into this port.</summary>
public IReadOnlyList<string> FormerIds { get; init; }
/// <summary>True when the declared type is a type variable rather than a concrete spelling.</summary>
public bool IsGeneric => TypeRules.IsTypeVariable( DeclaredType );
/// <summary>The concrete declared type, or <see cref="ShaderType.Void"/> when the port is generic.</summary>
public ShaderType FixedType => ShaderType.Parse( DeclaredType );
/// <summary>True when leaving this input unconnected is an error.</summary>
public bool Required => ( Flags & PortFlags.Required ) != 0;
/// <summary>True when the port should not be drawn.</summary>
public bool Hidden => ( Flags & PortFlags.Hidden ) != 0;
/// <inheritdoc/>
public override string ToString() => $"{Direction} {Id}:{DeclaredType}";
}
/// <summary>A live port on a live node. Carries the declaration plus everything the solver resolves.</summary>
public abstract class Port
{
/// <summary>Build a port from its declaration.</summary>
protected Port( PrismNode node, PortDef def )
{
Node = node;
Def = def;
}
/// <summary>
/// The node this port belongs to.
/// <para>
/// Hidden from reflection-driven UI: this is a back-reference, so a <c>SerializedObject</c> walk
/// that reaches a port would loop <c>port -> Node -> Inputs -> port</c> forever. That is an
/// uncatchable <c>StackOverflowException</c> inside engine code, which kills the whole editor.
/// </para>
/// </summary>
[Hide, Browsable( false ), JsonIgnore]
public PrismNode Node { get; }
/// <summary>The declaration this port was built from.</summary>
public PortDef Def { get; internal set; }
/// <summary>Stable id, unique within the node.</summary>
public PortId Id => Def.Id;
/// <summary>Display label. May be empty for an unlabelled socket.</summary>
public string DisplayName => Def.DisplayName;
/// <summary>The declared type spelling, concrete or generic.</summary>
public string DeclaredType => Def.DeclaredType;
/// <summary>Optional port group.</summary>
public string Group => Def.Group;
/// <summary>Tooltip text.</summary>
public string Tooltip => Def.Tooltip;
/// <summary>Behavioural flags.</summary>
public PortFlags Flags => Def.Flags;
/// <summary>True when leaving this input unconnected is an error.</summary>
public bool Required => Def.Required;
/// <summary>Which side of the node this port is on.</summary>
public abstract PortDirection Direction { get; }
/// <summary>Position within the node's port list. Assigned when the collection is built.</summary>
public int Index { get; internal set; }
/// <summary>
/// The concrete type assigned by the type solver. Void until the first successful solve;
/// for a non-generic port it always ends up equal to <see cref="PortDef.FixedType"/>.
/// </summary>
public ShaderType ResolvedType { get; set; }
/// <summary>The best type we know: the resolved one when solved, otherwise the declared one.</summary>
public ShaderType EffectiveType => ResolvedType.IsVoid ? Def.FixedType : ResolvedType;
/// <inheritdoc/>
public override string ToString() => $"{Node?.Id}.{Id}";
}
/// <summary>An input port. At most one incoming edge unless <see cref="PortFlags.AllowMultiple"/> is set.</summary>
public sealed class InputPort : Port
{
/// <summary>Build an input port.</summary>
public InputPort( PrismNode node, PortDef def ) : base( node, def ) { }
/// <inheritdoc/>
public override PortDirection Direction => PortDirection.Input;
/// <summary>
/// The literal used when nothing is connected. Boxed because it may be any of the value shapes
/// <c>ValueCodec</c> understands; the node's <c>[InlineValue]</c> property is the authored source
/// when <see cref="PortDef.InlineValueProperty"/> is set.
/// </summary>
public object InlineValue { get; set; }
/// <summary>True when an edge terminates on this port.</summary>
public bool IsConnected =>
Node?.Graph is { } graph && graph.TryGetIncomingEdge( Node.Id, Id, out _ );
}
/// <summary>An output port. May fan out to any number of inputs.</summary>
public sealed class OutputPort : Port
{
/// <summary>Build an output port.</summary>
public OutputPort( PrismNode node, PortDef def ) : base( node, def ) { }
/// <inheritdoc/>
public override PortDirection Direction => PortDirection.Output;
/// <summary>True when at least one edge starts at this port.</summary>
public bool IsConnected =>
Node?.Graph is { } graph && graph.GetOutgoingEdges( Node.Id, Id ).Any();
}
/// <summary>
/// Builds the port list for a node: first from reflection over <c>[In]</c>/<c>[Out]</c> properties,
/// then amended by the node's <c>OnDefinePorts</c> override. Ports that cannot be expressed as
/// properties — variadic sockets, subgraph signatures, mode-dependent sets — are added here.
/// </summary>
public sealed class PortBuilder
{
readonly List<PortDef> _inputs = new();
readonly List<PortDef> _outputs = new();
/// <summary>Input declarations, in socket order.</summary>
public IReadOnlyList<PortDef> Inputs => _inputs;
/// <summary>Output declarations, in socket order.</summary>
public IReadOnlyList<PortDef> Outputs => _outputs;
/// <summary>Append an input port.</summary>
public PortBuilder Input( string id, string type = "float", string name = null, string group = null,
PortFlags flags = PortFlags.None, string tooltip = null, int order = 0 )
{
_inputs.Add( new PortDef( PortId.Parse( id ), name ?? id, type ?? "float", PortDirection.Input )
{
Group = group,
Tooltip = tooltip,
Flags = flags | PortFlags.Dynamic,
Order = order
} );
return this;
}
/// <summary>Append an output port.</summary>
public PortBuilder Output( string id, string type = "float", string name = null, string group = null,
PortFlags flags = PortFlags.None, string tooltip = null, int order = 0 )
{
_outputs.Add( new PortDef( PortId.Parse( id ), name ?? id, type ?? "float", PortDirection.Output )
{
Group = group,
Tooltip = tooltip,
Flags = flags | PortFlags.Dynamic,
Order = order
} );
return this;
}
/// <summary>Append a declaration built elsewhere.</summary>
public PortBuilder Add( PortDef def )
{
if ( def is null ) return this;
if ( def.Direction == PortDirection.Input ) _inputs.Add( def );
else _outputs.Add( def );
return this;
}
/// <summary>Remove a port by id from whichever side it is on.</summary>
public PortBuilder Remove( string id )
{
var portId = PortId.Parse( id );
_inputs.RemoveAll( x => x.Id == portId );
_outputs.RemoveAll( x => x.Id == portId );
return this;
}
/// <summary>Change a port's declared type.</summary>
public PortBuilder Retype( string id, string declaredType )
{
Mutate( id, def => def with { DeclaredType = declaredType } );
return this;
}
/// <summary>Change a port's display label.</summary>
public PortBuilder Rename( string id, string displayName )
{
Mutate( id, def => def with { DisplayName = displayName } );
return this;
}
/// <summary>Add flags to a port.</summary>
public PortBuilder SetFlags( string id, PortFlags flags )
{
Mutate( id, def => def with { Flags = def.Flags | flags } );
return this;
}
/// <summary>True when a port with this id exists on either side.</summary>
public bool Has( string id )
{
var portId = PortId.Parse( id );
return _inputs.Any( x => x.Id == portId ) || _outputs.Any( x => x.Id == portId );
}
/// <summary>Drop every declaration. Used by nodes that build their entire signature dynamically.</summary>
public PortBuilder Clear()
{
_inputs.Clear();
_outputs.Clear();
return this;
}
void Mutate( string id, Func<PortDef, PortDef> mutate )
{
var portId = PortId.Parse( id );
for ( int i = 0; i < _inputs.Count; i++ )
{
if ( _inputs[i].Id == portId ) _inputs[i] = mutate( _inputs[i] );
}
for ( int i = 0; i < _outputs.Count; i++ )
{
if ( _outputs[i].Id == portId ) _outputs[i] = mutate( _outputs[i] );
}
}
/// <summary>Build a builder pre-populated with the reflected declarations of a node type.</summary>
public static PortBuilder FromReflection( Type nodeType )
{
var builder = new PortBuilder();
var (inputs, outputs) = Reflect( nodeType );
builder._inputs.AddRange( inputs );
builder._outputs.AddRange( outputs );
return builder;
}
/// <summary>
/// The port declarations implied by a node type's <c>[In]</c>/<c>[Out]</c> properties, in
/// declaration order (base class first). Cached per type; call <see cref="FlushCache"/> on hotload.
/// </summary>
public static (IReadOnlyList<PortDef> Inputs, IReadOnlyList<PortDef> Outputs) Reflect( Type nodeType )
{
if ( nodeType is null ) return ( Array.Empty<PortDef>(), Array.Empty<PortDef>() );
lock ( s_cacheLock )
{
if ( s_cache.TryGetValue( nodeType, out var cached ) ) return cached;
}
var inputs = new List<PortDef>();
var outputs = new List<PortDef>();
var properties = nodeType
.GetProperties( BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy )
.OrderBy( DeclarationDepth )
.ThenBy( x => x.MetadataToken )
.ToArray();
// Map port name -> the [InlineValue] property that feeds it.
var inlineValues = new Dictionary<string, string>();
foreach ( var property in properties )
{
var inline = property.GetCustomAttribute<InlineValueAttribute>();
if ( inline is null || string.IsNullOrEmpty( inline.PortName ) ) continue;
inlineValues[inline.PortName] = property.Name;
}
foreach ( var property in properties )
{
var formerly = property.GetCustomAttributes<FormerlyKnownAsAttribute>()
.Select( x => x.OldName )
.Where( x => !string.IsNullOrEmpty( x ) )
.ToArray();
if ( property.GetCustomAttribute<InAttribute>() is { } input )
{
inlineValues.TryGetValue( property.Name, out var inlineProperty );
inputs.Add( new PortDef( PortId.Parse( property.Name ), input.Name ?? property.Name,
input.Type ?? "float", PortDirection.Input )
{
Group = input.Group,
Tooltip = input.Tooltip,
Flags = input.Required ? PortFlags.Required : PortFlags.None,
Order = input.Order,
PropertyName = property.Name,
InlineValueProperty = inlineProperty,
FormerIds = formerly.Length > 0 ? formerly : null
} );
}
if ( property.GetCustomAttribute<OutAttribute>() is { } output )
{
outputs.Add( new PortDef( PortId.Parse( property.Name ), output.Name ?? property.Name,
output.Type ?? "float", PortDirection.Output )
{
Group = output.Group,
Tooltip = output.Tooltip,
Order = output.Order,
PropertyName = property.Name,
FormerIds = formerly.Length > 0 ? formerly : null
} );
}
}
var result = ( (IReadOnlyList<PortDef>)StableSort( inputs ), (IReadOnlyList<PortDef>)StableSort( outputs ) );
lock ( s_cacheLock )
{
s_cache[nodeType] = result;
}
return result;
}
/// <summary>Drop the reflection cache. Must run on hotload — <see cref="PortDef"/>s outlive the assembly otherwise.</summary>
public static void FlushCache()
{
lock ( s_cacheLock )
{
s_cache.Clear();
}
}
/// <summary>
/// Sort by explicit order, keeping declaration order for ties, and drop duplicate ids — a derived
/// class that redeclares a base port wins, because its declaration is the more specific one.
/// </summary>
static PortDef[] StableSort( List<PortDef> defs )
{
var deduped = new List<PortDef>( defs.Count );
for ( int i = 0; i < defs.Count; i++ )
{
var later = false;
for ( int j = i + 1; j < defs.Count; j++ )
{
if ( defs[j].Id != defs[i].Id ) continue;
later = true;
break;
}
if ( !later ) deduped.Add( defs[i] );
}
return deduped.OrderBy( x => x.Order ).ToArray();
}
static int DeclarationDepth( PropertyInfo property )
{
var depth = 0;
var type = property.DeclaringType;
while ( type is not null && type != typeof( object ) )
{
depth++;
type = type.BaseType;
}
return depth;
}
static readonly Dictionary<Type, (IReadOnlyList<PortDef> Inputs, IReadOnlyList<PortDef> Outputs)> s_cache = new();
static readonly object s_cacheLock = new();
}
/// <summary>
/// The live ports of one node. Rebuilding preserves the resolved type and inline value of every
/// port whose id survives; ports that disappear leave their edges to be converted into
/// <see cref="BrokenEdge"/> ghosts by the graph, never silently deleted.
/// </summary>
public sealed class PortCollection
{
readonly List<InputPort> _inputs = new();
readonly List<OutputPort> _outputs = new();
/// <summary>Input ports, in socket order.</summary>
public IReadOnlyList<InputPort> Inputs => _inputs;
/// <summary>Output ports, in socket order.</summary>
public IReadOnlyList<OutputPort> Outputs => _outputs;
/// <summary>Find an input port by id.</summary>
public InputPort FindInput( PortId id ) => _inputs.FirstOrDefault( x => x.Id == id );
/// <summary>Find an output port by id.</summary>
public OutputPort FindOutput( PortId id ) => _outputs.FirstOrDefault( x => x.Id == id );
/// <summary>Find a port of either direction by id.</summary>
public Port Find( PortId id ) => (Port)FindInput( id ) ?? FindOutput( id );
/// <summary>
/// Replace the port set with the declarations in <paramref name="builder"/>, carrying over state
/// from ports whose ids survive. Returns the ids that disappeared.
/// <para>
/// Duplicate ids are tolerated rather than fatal: a node whose <c>OnDefinePorts</c> re-declares a
/// reflected port keeps the last declaration, matching the "more specific wins" rule the reflection
/// pass already uses. A malformed node must never take the document down.
/// </para>
/// </summary>
public IReadOnlyList<PortId> Apply( PrismNode node, PortBuilder builder )
{
var removed = new List<PortId>();
var oldInputs = ToLookup( _inputs );
var oldOutputs = ToLookup( _outputs );
_inputs.Clear();
_outputs.Clear();
foreach ( var def in Dedupe( builder.Inputs ) )
{
var port = new InputPort( node, def ) { Index = _inputs.Count };
if ( oldInputs.TryGetValue( def.Id, out var old ) )
{
port.ResolvedType = old.ResolvedType;
port.InlineValue = old.InlineValue;
oldInputs.Remove( def.Id );
}
_inputs.Add( port );
}
foreach ( var def in Dedupe( builder.Outputs ) )
{
var port = new OutputPort( node, def ) { Index = _outputs.Count };
if ( oldOutputs.TryGetValue( def.Id, out var old ) )
{
port.ResolvedType = old.ResolvedType;
oldOutputs.Remove( def.Id );
}
_outputs.Add( port );
}
removed.AddRange( oldInputs.Keys );
removed.AddRange( oldOutputs.Keys );
return removed;
}
/// <summary>Keep the last declaration for each id, preserving declaration order otherwise.</summary>
static List<PortDef> Dedupe( IReadOnlyList<PortDef> defs )
{
var result = new List<PortDef>( defs.Count );
for ( int i = 0; i < defs.Count; i++ )
{
var later = false;
for ( int j = i + 1; j < defs.Count; j++ )
{
if ( defs[j].Id != defs[i].Id ) continue;
later = true;
break;
}
if ( !later ) result.Add( defs[i] );
}
return result;
}
static Dictionary<PortId, T> ToLookup<T>( List<T> ports ) where T : Port
{
var map = new Dictionary<PortId, T>();
foreach ( var port in ports )
{
map[port.Id] = port;
}
return map;
}
}
Debug: View Raw JSON Response
{
"TotalCount": 201,
"Files": [
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Compiler/Backends/BackendCapabilities.cs",
"FileName": "BackendCapabilities.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Core;\r\n\r\nnamespace Editor.Prism.Compiler.Backends;\r\n\r\n/// <summary>\r\n/// What a backend can express.\r\n/// <para>\r\n/// Consulted during <em>validation</em>, not at emit time, so the user is told \"this graph uses a\r\n/// loop, which the strict-HLSL dialect cannot express\" long before anything is written to disk.\r\n/// </para>\r\n/// </summary>\r\npublic sealed record BackendCapabilities(\r\n\tShaderModel MaxShaderModel,\r\n\tStageMask Stages,\r\n\tbool Loops,\r\n\tbool RealBranching,\r\n\tbool StructMethods,\r\n\tbool Interpolators,\r\n\tbool Combos,\r\n\tint MaxVaryingSlots,\r\n\tint MaxSamplers )\r\n{\r\n\t/// <summary>True when the backend can emit this stage.</summary>\r\n\tpublic bool Supports( ShaderStage stage ) => Stages.Contains( stage );\r\n\r\n\t/// <summary>True when the backend can provide a capability at its maximum shader model.</summary>\r\n\tpublic bool Supports( Capability capability )\r\n\t{\r\n\t\tif ( Capabilities.MinShaderModel( capability ) > MaxShaderModel ) return false;\r\n\r\n\t\treturn capability switch\r\n\t\t{\r\n\t\t\tCapability.Loops => Loops,\r\n\t\t\tCapability.DynamicBranching => RealBranching,\r\n\t\t\tCapability.StructMethods => StructMethods,\r\n\t\t\tCapability.Interpolators => Interpolators,\r\n\t\t\tCapability.Combos => Combos,\r\n\t\t\t_ => true\r\n\t\t};\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The s&box VFX target: SM 6.0 Vulkan, no hull or domain stage (the engine's block parser\r\n\t/// throws on those), everything else available.\r\n\t/// </summary>\r\n\tpublic static readonly BackendCapabilities Sbox = new(\r\n\t\tShaderModel.Sm6_0,\r\n\t\tStageMask.Vertex | StageMask.Pixel | StageMask.Geometry | StageMask.Compute,\r\n\t\tLoops: true,\r\n\t\tRealBranching: true,\r\n\t\tStructMethods: true,\r\n\t\tInterpolators: true,\r\n\t\tCombos: true,\r\n\t\tMaxVaryingSlots: PrismConstants.MaxVaryingSlots,\r\n\t\tMaxSamplers: PrismConstants.MaxSamplers );\r\n\r\n\t/// <summary>The portable Slang target: no engine combos, no engine interpolator budget.</summary>\r\n\tpublic static readonly BackendCapabilities Slang = new(\r\n\t\tShaderModel.Sm6_5,\r\n\t\tStageMask.All,\r\n\t\tLoops: true,\r\n\t\tRealBranching: true,\r\n\t\tStructMethods: true,\r\n\t\tInterpolators: true,\r\n\t\tCombos: false,\r\n\t\tMaxVaryingSlots: 32,\r\n\t\tMaxSamplers: 32 );\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Compiler/Backends/SboxShaderWriter.cs",
"FileName": "SboxShaderWriter.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Compiler.Ir;\r\nusing Editor.Prism.Core;\r\n\r\nnamespace Editor.Prism.Compiler.Backends;\r\n\r\n/// <summary>\r\n/// Wraps the HLSL an <see cref=\"HlslEmitter\"/> produces in a complete s&box VFX\r\n/// <c>.shader</c> file.\r\n/// <para>\r\n/// A <c>.shader</c> is not plain HLSL: it is a block language the engine's native front-end parses\r\n/// before anything reaches a compiler. The block order, the placement of the blend defines relative\r\n/// to <c>common/pixel.hlsl</c>, and the exact spelling of the annotation grammar all decide whether\r\n/// the result renders correctly, renders wrongly, or fails to parse with no diagnostic at all.\r\n/// </para>\r\n/// </summary>\r\npublic sealed class SboxShaderWriter\r\n{\r\n\treadonly HlslSourceBuilder _builder;\r\n\treadonly HlslEmitter _emitter;\r\n\treadonly IReadOnlyList<HelperFunction> _helpers;\r\n\r\n\t// Non-null only while writing an instrumented build. It doubles as the \"is this the real pass\"\r\n\t// flag, which is what keeps the throw-away numbering pass from instrumenting itself.\r\n\tIReadOnlyDictionary<NodeId, int> _stageIds;\r\n\r\n\t/// <summary>Prepare to write one module.</summary>\r\n\tpublic SboxShaderWriter( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics )\r\n\t{\r\n\t\tModule = module;\r\n\t\tOptions = options ?? BackendEmitOptions.Default;\r\n\t\tDiagnostics = diagnostics ?? new DiagnosticSink();\r\n\r\n\t\t_builder = new HlslSourceBuilder( Options.Indent, Options.NewLine );\r\n\t\t_emitter = new HlslEmitter( Module, Options, Diagnostics );\r\n\t\t_helpers = _emitter.OrderedHelpers();\r\n\t}\r\n\r\n\t/// <summary>The module being written.</summary>\r\n\tpublic IrModule Module { get; }\r\n\r\n\t/// <summary>Emission options.</summary>\r\n\tpublic BackendEmitOptions Options { get; }\r\n\r\n\t/// <summary>Where problems go.</summary>\r\n\tpublic DiagnosticSink Diagnostics { get; }\r\n\r\n\tModuleMetadata Meta => Module.Meta;\r\n\r\n\tShaderDomain Domain => Meta.Domain;\r\n\r\n\tbool IsSurface => Domain is ShaderDomain.Surface or ShaderDomain.PostProcess;\r\n\r\n\t/// <summary>Write the module as a complete <c>.shader</c> file, with its source map.</summary>\r\n\tpublic BackendEmitResult Write()\r\n\t{\r\n\t\tif ( Module is null )\r\n\t\t{\r\n\t\t\tDiagnostics.Error( DiagnosticCode.InvalidBlock, \"There is no module to write.\" );\r\n\t\t\treturn BackendEmitResult.Empty( PrismConstants.BackendHlsl, PrismConstants.ShaderExtension );\r\n\t\t}\r\n\r\n\t\tif ( Domain == ShaderDomain.Subgraph )\r\n\t\t{\r\n\t\t\tDiagnostics.Error( DiagnosticCode.SubgraphUnavailable,\r\n\t\t\t\t\"A subgraph has no shader of its own.\", null,\r\n\t\t\t\t\"Subgraphs are inlined into the graph that instances them; only a shader or post-process graph produces a .shader file.\" );\r\n\t\t\treturn BackendEmitResult.Empty( PrismConstants.BackendHlsl, PrismConstants.ShaderExtension );\r\n\t\t}\r\n\r\n\t\tif ( !VfxBlockValidator.Validate( Module, Diagnostics ) )\r\n\t\t{\r\n\t\t\t// The engine's block parser reports these only to the native log, with an empty program\r\n\t\t\t// list and no line numbers. Refusing to write is far kinder than letting that happen.\r\n\t\t\treturn BackendEmitResult.Empty( PrismConstants.BackendHlsl, PrismConstants.ShaderExtension );\r\n\t\t}\r\n\r\n\t\tif ( PreviewInstrumentation.IsEnabled( Options.Mode ) && Domain != ShaderDomain.Compute )\r\n\t\t{\r\n\t\t\t// A node's stage id is its rank among the nodes appearing in the finished artifact's source\r\n\t\t\t// map, so it cannot be known until the file has been written once. Writing it twice is far\r\n\t\t\t// cheaper and far safer than predicting that order: the throw-away pass costs one more text\r\n\t\t\t// generation and its diagnostics are discarded, because the real pass reports exactly the\r\n\t\t\t// same set. Adding the instrumentation never changes the order \u2014 every line it writes is\r\n\t\t\t// attributed either to a node that already appeared above it, or to nothing at all.\r\n\t\t\tvar probe = new SboxShaderWriter( Module, Options, new DiagnosticSink() );\r\n\r\n\t\t\tprobe.WriteBlocks();\r\n\r\n\t\t\t_stageIds = PreviewInstrumentation.BuildStageMap( probe._builder.SourceMap );\r\n\t\t}\r\n\r\n\t\tWriteBlocks();\r\n\r\n\t\tvar text = _builder.ToString();\r\n\t\tvar map = _builder.SourceMap;\r\n\r\n\t\tmap.File = $\"{Options.OutputName}.{PrismConstants.ShaderExtension}\";\r\n\r\n\t\treturn new BackendEmitResult( text, PrismConstants.ShaderExtension, map, Array.Empty<GeneratedArtifact>() )\r\n\t\t{\r\n\t\t\tBackendId = PrismConstants.BackendHlsl,\r\n\t\t\tLineCount = _builder.LineCount\r\n\t\t};\r\n\t}\r\n\r\n\t/// <summary>Write every block of the file, in the order the engine's parser expects them.</summary>\r\n\tvoid WriteBlocks()\r\n\t{\r\n\t\tWriteHeaderBlock();\r\n\t\tWriteModesBlock();\r\n\t\tWriteFeaturesBlock();\r\n\t\tWriteCommonBlock();\r\n\r\n\t\tif ( Domain != ShaderDomain.Compute )\r\n\t\t{\r\n\t\t\tWriteVertexInputStruct();\r\n\t\t\tWritePixelInputStruct();\r\n\t\t\tWriteVertexBlock();\r\n\t\t\tWritePixelBlock();\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tWriteComputeBlock();\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Write a module straight to text, for callers that only want the string.</summary>\r\n\tpublic static string WriteText( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics ) =>\r\n\t\tnew SboxShaderWriter( module, options, diagnostics ).Write().Text;\r\n\r\n\t// ---- HEADER -----------------------------------------------------------\r\n\r\n\tvoid WriteHeaderBlock()\r\n\t{\r\n\t\t_builder.Write( SboxShaderTemplates.BlockHeader );\r\n\t\t_builder.Open();\r\n\r\n\t\tvar description = string.IsNullOrWhiteSpace( Meta.Description )\r\n\t\t\t? $\"{Meta.Name} \u2014 generated by {PrismConstants.ProductName}\"\r\n\t\t\t: Meta.Description;\r\n\r\n\t\t_builder.Write( $\"Description = \\\"{SboxShaderTemplates.QuoteSafe( description )}\\\";\" );\r\n\t\t_builder.Write( $\"DevShader = {( Options.Mode == CompileMode.Final ? \"false\" : \"true\" )};\" );\r\n\t\t_builder.Write( $\"Version = {HeaderVersion()};\" );\r\n\r\n\t\tif ( Options.DebugSymbols ) _builder.Write( \"DebugInfo = true;\" );\r\n\r\n\t\t_builder.Close();\r\n\t\t_builder.Blank();\r\n\t}\r\n\r\n\tint HeaderVersion()\r\n\t{\r\n\t\tvar version = Meta.Version;\r\n\r\n\t\tif ( string.IsNullOrWhiteSpace( version ) ) return 1;\r\n\t\tif ( int.TryParse( version, out var whole ) && whole > 0 ) return whole;\r\n\r\n\t\tvar dot = version.IndexOf( '.' );\r\n\r\n\t\tif ( dot > 0 && int.TryParse( version[..dot], out var major ) && major > 0 ) return major;\r\n\r\n\t\treturn 1;\r\n\t}\r\n\r\n\t// ---- MODES ------------------------------------------------------------\r\n\r\n\tvoid WriteModesBlock()\r\n\t{\r\n\t\t_builder.Write( SboxShaderTemplates.BlockModes );\r\n\t\t_builder.Open();\r\n\r\n\t\tforeach ( var mode in DeclaredModes() )\r\n\t\t{\r\n\t\t\tvar statement = SboxShaderTemplates.ModeStatement( mode );\r\n\r\n\t\t\tif ( !string.IsNullOrEmpty( statement ) ) _builder.Write( statement );\r\n\t\t}\r\n\r\n\t\t_builder.Close();\r\n\t\t_builder.Blank();\r\n\t}\r\n\r\n\tIReadOnlyList<string> DefaultModes() => SboxShaderTemplates.DefaultModesFor( Domain );\r\n\r\n\t/// <summary>\r\n\t/// The render passes this file actually declares.\r\n\t/// <para>\r\n\t/// The domain and the pass list are authored independently, so a graph switched to PostProcess after\r\n\t/// the fact still carries the surface passes. Declaring <c>Depth()</c> on a full-screen pass asks the\r\n\t/// engine to render a full-screen triangle into the depth buffer, and a post-process material invoked\r\n\t/// through the standard path needs <c>Default()</c> whether or not the document remembered it \u2014 so a\r\n\t/// non-surface domain starts from its own pass set and only then takes whatever the document adds.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tIReadOnlyList<string> DeclaredModes()\r\n\t{\r\n\t\tvar declared = Meta.Modes.Count > 0 ? Meta.Modes : DefaultModes();\r\n\t\tvar modes = new List<string>( declared.Count + 2 );\r\n\r\n\t\tbool Has( string mode ) => modes.Any( x => string.Equals( x, mode, StringComparison.OrdinalIgnoreCase ) );\r\n\r\n\t\tif ( Domain != ShaderDomain.Surface ) modes.AddRange( DefaultModes() );\r\n\r\n\t\tforeach ( var mode in declared )\r\n\t\t{\r\n\t\t\tif ( string.IsNullOrWhiteSpace( mode ) || Has( mode ) ) continue;\r\n\r\n\t\t\tif ( !SboxShaderTemplates.IsModeLegalFor( Domain, mode ) )\r\n\t\t\t{\r\n\t\t\t\tDiagnostics.Info( DiagnosticCode.InvalidBlock,\r\n\t\t\t\t\t$\"Render pass '{mode}' means nothing to a {Domain} graph and was not declared.\" );\r\n\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tmodes.Add( mode );\r\n\t\t}\r\n\r\n\t\tif ( modes.Count == 0 ) modes.AddRange( DefaultModes() );\r\n\r\n\t\treturn modes;\r\n\t}\r\n\r\n\t// ---- FEATURES ---------------------------------------------------------\r\n\r\n\tvoid WriteFeaturesBlock()\r\n\t{\r\n\t\tif ( Domain == ShaderDomain.Compute ) return;\r\n\r\n\t\t_builder.Write( SboxShaderTemplates.BlockFeatures );\r\n\t\t_builder.Open();\r\n\t\t_builder.Write( $\"#include \\\"{SboxShaderTemplates.IncludeFeatures}\\\"\" );\r\n\r\n\t\tforeach ( var combo in Meta.Combos )\r\n\t\t{\r\n\t\t\tif ( combo is null || combo.Kind != ComboKind.Feature ) continue;\r\n\r\n\t\t\t_builder.Write( FeatureStatement( combo ) );\r\n\t\t}\r\n\r\n\t\t_builder.Close();\r\n\t\t_builder.Blank();\r\n\t}\r\n\r\n\tstatic string FeatureStatement( ComboDecl combo )\r\n\t{\r\n\t\tvar group = string.IsNullOrWhiteSpace( combo.Group ) ? \"Features\" : SboxShaderTemplates.QuoteSafe( combo.Group );\r\n\t\tvar values = combo.Values ?? Array.Empty<string>();\r\n\r\n\t\t// A two-value feature whose labels say nothing beyond \"off\" and \"on\" is a checkbox in the material\r\n\t\t// editor. Spelling those labels out explicitly turns it into a two-item combo box instead, which\r\n\t\t// is the wrong control for a boolean, so the bare range is emitted for the conventional pairs.\r\n\t\tif ( values.Count < 2 || IsPlainToggle( values ) ) return $\"Feature( {combo.Name}, 0..1, \\\"{group}\\\" );\";\r\n\r\n\t\tvar labels = new List<string>( values.Count );\r\n\r\n\t\tfor ( int i = 0; i < values.Count; i++ )\r\n\t\t{\r\n\t\t\tvar label = values[i] ?? string.Empty;\r\n\t\t\tvar separator = label.IndexOf( '=' );\r\n\r\n\t\t\tif ( separator >= 0 ) label = label[( separator + 1 )..];\r\n\r\n\t\t\tlabels.Add( $\"{i}=\\\"{SboxShaderTemplates.QuoteSafe( label.Trim().Trim( '\"' ) )}\\\"\" );\r\n\t\t}\r\n\r\n\t\treturn $\"Feature( {combo.Name}, 0..{values.Count - 1} ( {string.Join( \", \", labels )} ), \\\"{group}\\\" );\";\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// True when a two-value combo's labels carry no information a checkbox does not already convey.\r\n\t/// </summary>\r\n\tstatic bool IsPlainToggle( IReadOnlyList<string> values )\r\n\t{\r\n\t\tif ( values.Count != 2 ) return false;\r\n\r\n\t\tvar off = ( values[0] ?? string.Empty ).Trim().Trim( '\"' );\r\n\t\tvar on = ( values[1] ?? string.Empty ).Trim().Trim( '\"' );\r\n\r\n\t\tforeach ( var (a, b) in s_toggleLabels )\r\n\t\t{\r\n\t\t\tif ( string.Equals( off, a, StringComparison.OrdinalIgnoreCase ) &&\r\n\t\t\t\tstring.Equals( on, b, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t{\r\n\t\t\t\treturn true;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tstatic readonly (string Off, string On)[] s_toggleLabels =\r\n\t[\r\n\t\t(\"Off\", \"On\"), (\"0\", \"1\"), (\"False\", \"True\"), (\"No\", \"Yes\"), (\"Disabled\", \"Enabled\")\r\n\t];\r\n\r\n\t// ---- COMMON -----------------------------------------------------------\r\n\r\n\tvoid WriteCommonBlock()\r\n\t{\r\n\t\t_builder.Write( SboxShaderTemplates.BlockCommon );\r\n\t\t_builder.Open();\r\n\r\n\t\tif ( Domain == ShaderDomain.Compute )\r\n\t\t{\r\n\t\t\t// A compute program has no render state, no material and no pixel input; the shipped\r\n\t\t\t// compute shaders include nothing but the macro header.\r\n\t\t\t_builder.Write( $\"#include \\\"{SboxShaderTemplates.IncludeSystem}\\\"\" );\r\n\t\t\tWriteModuleIncludes();\r\n\t\t\t_builder.Close();\r\n\t\t\t_builder.Blank();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Everything that steers render state has to be defined BEFORE common/pixel.hlsl pulls in\r\n\t\t// sbox_pixel.fxc, which reads S_TRANSLUCENT and S_ALPHA_TEST at include time. Defining them\r\n\t\t// afterwards silently produces opaque render state, which is the single most common way a\r\n\t\t// generated transparent shader comes out wrong.\r\n\t\tvar blend = Meta.BlendMode;\r\n\t\tvar alphaTest = blend == SurfaceBlendMode.Masked;\r\n\t\tvar translucent = blend is SurfaceBlendMode.Translucent or SurfaceBlendMode.Additive or SurfaceBlendMode.Multiply;\r\n\r\n\t\tWriteDefine( \"S_ALPHA_TEST\", alphaTest ? \"1\" : \"0\" );\r\n\t\tWriteDefine( \"S_TRANSLUCENT\", translucent ? \"1\" : \"0\" );\r\n\r\n\t\tif ( blend == SurfaceBlendMode.Additive ) WriteDefine( \"S_ADDITIVE_BLEND\", \"1\" );\r\n\r\n\t\tif ( blend == SurfaceBlendMode.Multiply )\r\n\t\t{\r\n\t\t\t// Multiply is not one of the engine's built-in blend paths, so we take ownership of the\r\n\t\t\t// blend state and write it ourselves in the pixel block.\r\n\t\t\tWriteDefine( \"BLEND_MODE_ALREADY_SET\", \"1\" );\r\n\t\t}\r\n\r\n\t\tif ( Meta.UsesUv2 || Domain == ShaderDomain.Surface ) WriteDefine( \"S_UV2\", \"1\" );\r\n\r\n\t\tif ( Meta.ShadingModel == ShadingModel.Unlit && Domain == ShaderDomain.Surface )\r\n\t\t{\r\n\t\t\tWriteDefine( \"S_UNLIT\", \"1\" );\r\n\t\t}\r\n\r\n\t\t_builder.Blank();\r\n\t\t_builder.Write( $\"#include \\\"{SboxShaderTemplates.IncludeShared}\\\"\" );\r\n\r\n\t\tif ( IsSurface ) _builder.Write( $\"#include \\\"{SboxShaderTemplates.IncludeProcedural}\\\"\" );\r\n\r\n\t\tWriteModuleIncludes();\r\n\r\n\t\t_builder.Close();\r\n\t\t_builder.Blank();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Emit the module's includes plus every include its helpers asked for, deduplicated and in a\r\n\t/// stable order. Folding the helper includes in here means a helper that needs a header still\r\n\t/// compiles even if nothing upstream remembered to register it on the module.\r\n\t/// </summary>\r\n\tvoid WriteModuleIncludes()\r\n\t{\r\n\t\tvar seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\r\n\r\n\t\tforeach ( var include in Module.Includes ) WriteInclude( include );\r\n\r\n\t\tforeach ( var helper in _helpers )\r\n\t\t{\r\n\t\t\tforeach ( var include in helper.Includes ?? Array.Empty<string>() ) WriteInclude( include );\r\n\t\t}\r\n\r\n\t\tvoid WriteInclude( string include )\r\n\t\t{\r\n\t\t\tif ( string.IsNullOrWhiteSpace( include ) ) return;\r\n\t\t\tif ( IsImplicitInclude( include ) ) return;\r\n\t\t\tif ( !seen.Add( include ) ) return;\r\n\r\n\t\t\t_builder.Write( $\"#include \\\"{include}\\\"\" );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid WriteDefine( string name, string value )\r\n\t{\r\n\t\t_builder.Write( $\"#ifndef {name}\" );\r\n\t\t_builder.Write( $\"#define {name} {value}\" );\r\n\t\t_builder.Write( \"#endif\" );\r\n\t}\r\n\r\n\tstatic bool IsImplicitInclude( string include ) =>\r\n\t\tinclude is SboxShaderTemplates.IncludeShared or SboxShaderTemplates.IncludeProcedural or\r\n\t\t\tSboxShaderTemplates.IncludeSystem or SboxShaderTemplates.IncludePixel or\r\n\t\t\tSboxShaderTemplates.IncludeVertex or SboxShaderTemplates.IncludeFeatures or\r\n\t\t\tSboxShaderTemplates.IncludeVertexInput or SboxShaderTemplates.IncludePixelInput;\r\n\r\n\t// ---- structs ----------------------------------------------------------\r\n\r\n\tvoid WriteVertexInputStruct()\r\n\t{\r\n\t\t_builder.Write( $\"struct {SboxShaderTemplates.StructVertexInput}\" );\r\n\t\t_builder.Open();\r\n\t\t_builder.Write( $\"#include \\\"{SboxShaderTemplates.IncludeVertexInput}\\\"\" );\r\n\r\n\t\tif ( Domain == ShaderDomain.Surface ) _builder.WriteBlock( SboxShaderTemplates.SurfaceVertexInputExtras );\r\n\r\n\t\tWriteUserStructFields( SboxShaderTemplates.StructVertexInput );\r\n\r\n\t\t_builder.Close( \";\" );\r\n\t\t_builder.Blank();\r\n\t}\r\n\r\n\tvoid WritePixelInputStruct()\r\n\t{\r\n\t\t_builder.Write( $\"struct {SboxShaderTemplates.StructPixelInput}\" );\r\n\t\t_builder.Open();\r\n\t\t_builder.Write( $\"#include \\\"{SboxShaderTemplates.IncludePixelInput}\\\"\" );\r\n\r\n\t\tif ( Domain == ShaderDomain.Surface )\r\n\t\t{\r\n\t\t\t_builder.WriteBlock( SboxShaderTemplates.SurfacePixelInputExtras );\r\n\t\t}\r\n\r\n\t\tforeach ( var varying in Module.Varyings )\r\n\t\t{\r\n\t\t\tif ( varying is null ) continue;\r\n\r\n\t\t\tvar semantic = SboxShaderTemplates.VaryingSemantic( varying );\r\n\r\n\t\t\t_builder.Write(\r\n\t\t\t\t$\"{HlslBackend.Interpolation( varying.Interpolation )}{varying.Type.Hlsl} {varying.Name} : {semantic};\",\r\n\t\t\t\tvarying.Origin );\r\n\t\t}\r\n\r\n\t\tWriteUserStructFields( SboxShaderTemplates.StructPixelInput );\r\n\r\n\t\tif ( Domain == ShaderDomain.Surface ) _builder.WriteBlock( SboxShaderTemplates.PixelInputFrontFacing );\r\n\r\n\t\t_builder.Close( \";\" );\r\n\t\t_builder.Blank();\r\n\t}\r\n\r\n\tvoid WriteUserStructFields( string name )\r\n\t{\r\n\t\tvar structure = Module.FindStruct( name );\r\n\r\n\t\tif ( structure is null ) return;\r\n\r\n\t\tforeach ( var include in structure.Includes )\r\n\t\t{\r\n\t\t\tif ( string.IsNullOrWhiteSpace( include ) ) continue;\r\n\t\t\tif ( IsImplicitInclude( include ) ) continue;\r\n\r\n\t\t\t_builder.Write( $\"#include \\\"{include}\\\"\" );\r\n\t\t}\r\n\r\n\t\tforeach ( var field in structure.Fields )\r\n\t\t{\r\n\t\t\tif ( field is null ) continue;\r\n\r\n\t\t\tvar semantic = string.IsNullOrEmpty( field.Semantic ) ? string.Empty : $\" : {field.Semantic}\";\r\n\r\n\t\t\t_builder.Write(\r\n\t\t\t\t$\"{HlslBackend.Interpolation( field.Interpolation )}{field.Type.Hlsl} {field.Name}{semantic};\" );\r\n\t\t}\r\n\t}\r\n\r\n\t// ---- VS ---------------------------------------------------------------\r\n\r\n\tvoid WriteVertexBlock()\r\n\t{\r\n\t\t_emitter.Stage = ShaderStage.Vertex;\r\n\r\n\t\t_builder.Write( ShaderStage.Vertex.BlockName() );\r\n\t\t_builder.Open();\r\n\r\n\t\tif ( Domain == ShaderDomain.Surface )\r\n\t\t{\r\n\t\t\t_builder.Write( $\"#include \\\"{SboxShaderTemplates.IncludeVertex}\\\"\" );\r\n\t\t\t_builder.Blank();\r\n\t\t}\r\n\r\n\t\tWriteCombos( ShaderStage.Vertex );\r\n\t\tSboxMaterialBinding.WriteGlobals( _builder, _emitter, ShaderStage.Vertex );\r\n\t\t_emitter.WriteHelpers( _builder, ShaderStage.Vertex, _helpers );\r\n\t\t_emitter.WriteFunctions( _builder, ShaderStage.Vertex );\r\n\r\n\t\tvar entry = Module.EntryPoint( ShaderStage.Vertex );\r\n\r\n\t\t// SV_VertexID has no stream in common/vertexinput.hlsl, so it rides in as a second entry-point\r\n\t\t// parameter \u2014 and only when the graph reads it, so every other shader keeps the stock signature.\r\n\t\tvar parameters = $\"{SboxShaderTemplates.StructVertexInput} {SboxShaderTemplates.VertexInputLocal}\";\r\n\r\n\t\tif ( UsesBuiltin( entry?.Body, Builtin.VertexId ) )\r\n\t\t{\r\n\t\t\tparameters += $\", {SboxShaderTemplates.VertexIdParameterDeclaration}\";\r\n\t\t}\r\n\r\n\t\t_builder.Write(\r\n\t\t\t$\"{SboxShaderTemplates.StructPixelInput} {PrismConstants.EntryPointVertex}( {parameters} )\" );\r\n\t\t_builder.Open();\r\n\r\n\t\tif ( Domain == ShaderDomain.PostProcess )\r\n\t\t{\r\n\t\t\t_builder.WriteBlock( SboxShaderTemplates.PostProcessVertexPrologue );\r\n\r\n\t\t\t// The graph's own vertex statements go here, not nowhere. GraphCompiler.EmitRoots builds a\r\n\t\t\t// real vertex entry for a post-process domain and the pixel input struct declares every\r\n\t\t\t// varying, so dropping the body left every interpolated value reading zero.\r\n\t\t\tif ( entry is not null )\r\n\t\t\t{\r\n\t\t\t\t_builder.Blank();\r\n\t\t\t\tWriteStatements( entry.Body );\r\n\t\t\t}\r\n\r\n\t\t\t_builder.Blank();\r\n\t\t\t_builder.Write( SboxShaderTemplates.PostProcessVertexEpilogue );\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\t_builder.WriteBlock( SboxShaderTemplates.SurfaceVertexPrologue );\r\n\t\t\t_builder.Blank();\r\n\r\n\t\t\tif ( entry is not null )\r\n\t\t\t{\r\n\t\t\t\tWriteStatements( entry.Body );\r\n\r\n\t\t\t\tif ( WritesWorldPosition( entry.Body ) )\r\n\t\t\t\t{\r\n\t\t\t\t\t_builder.Write( SboxShaderTemplates.VertexPositionResync );\r\n\t\t\t\t}\r\n\r\n\t\t\t\t_builder.Blank();\r\n\t\t\t}\r\n\r\n\t\t\t_builder.Write( SboxShaderTemplates.SurfaceVertexEpilogue );\r\n\t\t}\r\n\r\n\t\t_builder.Close();\r\n\t\t_builder.Close();\r\n\t\t_builder.Blank();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// True when the vertex program moved the world-space position, so clip space has to be recomputed\r\n\t/// before <c>FinalizeVertex</c> subtracts the high-precision offset.\r\n\t/// </summary>\r\n\tstatic bool WritesWorldPosition( IrBlock block )\r\n\t{\r\n\t\tforeach ( var statement in IrWalk.Statements( block ) )\r\n\t\t{\r\n\t\t\tif ( statement is IrAssign assign && TouchesWorldPosition( assign.Target ) ) return true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\t/// <summary>True when any expression anywhere in a block reads a particular environment value.</summary>\r\n\tstatic bool UsesBuiltin( IrBlock block, Builtin id )\r\n\t{\r\n\t\tforeach ( var statement in IrWalk.Statements( block ) )\r\n\t\t{\r\n\t\t\tforeach ( var expression in IrWalk.Expressions( statement ) )\r\n\t\t\t{\r\n\t\t\t\tif ( Reads( expression, id ) ) return true;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tstatic bool Reads( IrExpr expr, Builtin id )\r\n\t{\r\n\t\tif ( expr is null ) return false;\r\n\r\n\t\tforeach ( var node in IrExprUtil.Walk( expr ) )\r\n\t\t{\r\n\t\t\tif ( node is IrBuiltinRef reference && reference.Id == id ) return true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tstatic bool TouchesWorldPosition( IrExpr target )\r\n\t{\r\n\t\tforeach ( var node in IrExprUtil.Walk( target ) )\r\n\t\t{\r\n\t\t\tif ( node is IrMember member && member.Field == \"vPositionWs\" ) return true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\t// ---- PS ---------------------------------------------------------------\r\n\r\n\tvoid WritePixelBlock()\r\n\t{\r\n\t\t_emitter.Stage = ShaderStage.Pixel;\r\n\r\n\t\t_builder.Write( ShaderStage.Pixel.BlockName() );\r\n\t\t_builder.Open();\r\n\t\t_builder.Write( $\"#include \\\"{SboxShaderTemplates.IncludePixel}\\\"\" );\r\n\r\n\t\tif ( Domain == ShaderDomain.PostProcess )\r\n\t\t{\r\n\t\t\t_builder.Write( $\"#include \\\"{SboxShaderTemplates.IncludePostProcessCommon}\\\"\" );\r\n\t\t\t_builder.Write( $\"#include \\\"{SboxShaderTemplates.IncludePostProcessFunctions}\\\"\" );\r\n\t\t}\r\n\r\n\t\t_builder.Blank();\r\n\r\n\t\tWriteCombos( ShaderStage.Pixel );\r\n\t\tWriteRenderState();\r\n\r\n\t\t// The colour buffer is boilerplate for a post-process pass, but a node that reads it declares it\r\n\t\t// too \u2014 and Slang rejects the second declaration outright rather than merging them, so a graph\r\n\t\t// that actually sampled the frame buffer used to fail to compile. The node's declaration wins:\r\n\t\t// it carries the node's own sRGB and attribute metadata, and it is emitted with the rest of the\r\n\t\t// module globals a few lines below.\r\n\t\tif ( Domain == ShaderDomain.PostProcess &&\r\n\t\t\tModule.FindGlobal( SboxShaderTemplates.PostProcessColorBufferSymbol ) is null )\r\n\t\t{\r\n\t\t\t_builder.WriteBlock( SboxShaderTemplates.PostProcessColorBuffer );\r\n\t\t\t_builder.Blank();\r\n\t\t}\r\n\r\n\t\tSboxMaterialBinding.WriteGlobals( _builder, _emitter, ShaderStage.Pixel );\r\n\t\tWriteInstrumentationDeclarations();\r\n\t\t_emitter.WriteHelpers( _builder, ShaderStage.Pixel, _helpers );\r\n\t\t_emitter.WriteFunctions( _builder, ShaderStage.Pixel );\r\n\r\n\t\t_builder.Write(\r\n\t\t\t$\"float4 {PrismConstants.EntryPointPixel}( {SboxShaderTemplates.StructPixelInput} {SboxShaderTemplates.PixelInputLocal} ) : SV_Target0\" );\r\n\t\t_builder.Open();\r\n\r\n\t\tvar entry = Module.EntryPoint( ShaderStage.Pixel );\r\n\t\tvar returned = SboxMaterialBinding.EndsWithReturn( entry?.Body );\r\n\r\n\t\tif ( !returned ) SboxMaterialBinding.WritePixelPrologue( _builder, Module );\r\n\r\n\t\tif ( entry is not null )\r\n\t\t{\r\n\t\t\tWritePixelBody( entry.Body );\r\n\t\t\t_builder.Blank();\r\n\t\t}\r\n\r\n\t\tif ( !returned ) WriteChannelTail();\r\n\r\n\t\tif ( !returned ) SboxMaterialBinding.WritePixelEpilogue( _builder, Module, _emitter );\r\n\r\n\t\t_builder.Close();\r\n\t\t_builder.Close();\r\n\t\t_builder.Blank();\r\n\t}\r\n\r\n\tvoid WriteRenderState()\r\n\t{\r\n\t\tvar wrote = false;\r\n\r\n\t\t// The engine only fills the frame-buffer copy for a material that asks for it, and the ask is a\r\n\t\t// PS-block attribute rather than anything a node can declare. Emitting it here means a graph that\r\n\t\t// reads scene colour gets a filled texture instead of last frame's stale contents.\r\n\t\tif ( WantsFrameBufferCopy() )\r\n\t\t{\r\n\t\t\t_builder.Write( $\"BoolAttribute( {SboxShaderTemplates.FrameBufferCopyFlag}, true );\" );\r\n\t\t\twrote = true;\r\n\t\t}\r\n\r\n\t\tif ( Meta.BlendMode == SurfaceBlendMode.Multiply )\r\n\t\t{\r\n\t\t\t_builder.WriteBlock( SboxShaderTemplates.MultiplyBlendState );\r\n\t\t\twrote = true;\r\n\t\t}\r\n\r\n\t\tif ( Options.Mode is CompileMode.Preview or CompileMode.Thumbnail )\r\n\t\t{\r\n\t\t\t// The preview toggles backface rendering without recompiling the material.\r\n\t\t\t_builder.WriteBlock( SboxShaderTemplates.CullModePreview );\r\n\t\t\twrote = true;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tvar cull = Meta.RenderBackfaces ? CullMode.None : Meta.CullMode;\r\n\r\n\t\t\tswitch ( cull )\r\n\t\t\t{\r\n\t\t\t\tcase CullMode.None:\r\n\t\t\t\t\t_builder.Write( \"RenderState( CullMode, NONE );\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase CullMode.Front:\r\n\t\t\t\t\t_builder.Write( \"RenderState( CullMode, FRONT );\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tdefault:\r\n\t\t\t\t\t_builder.Write( SboxShaderTemplates.CullModeFromFeature );\r\n\t\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\twrote = true;\r\n\t\t}\r\n\r\n\t\tif ( wrote ) _builder.Blank();\r\n\t}\r\n\r\n\t/// <summary>True when the module reads the frame-buffer copy and must therefore request it.</summary>\r\n\tbool WantsFrameBufferCopy()\r\n\t{\r\n\t\tif ( Module?.Globals is null ) return false;\r\n\r\n\t\tforeach ( var global in Module.Globals )\r\n\t\t{\r\n\t\t\tif ( global is null ) continue;\r\n\r\n\t\t\tif ( string.Equals( global.Name, SboxShaderTemplates.FrameBufferCopyTexture, StringComparison.Ordinal ) )\r\n\t\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\t// ---- CS ---------------------------------------------------------------\r\n\r\n\tvoid WriteComputeBlock()\r\n\t{\r\n\t\t_emitter.Stage = ShaderStage.Compute;\r\n\r\n\t\t_builder.Write( ShaderStage.Compute.BlockName() );\r\n\t\t_builder.Open();\r\n\r\n\t\tWriteCombos( ShaderStage.Compute );\r\n\t\tSboxMaterialBinding.WriteGlobals( _builder, _emitter, ShaderStage.Compute );\r\n\t\t_emitter.WriteHelpers( _builder, ShaderStage.Compute, _helpers );\r\n\t\t_emitter.WriteFunctions( _builder, ShaderStage.Compute );\r\n\r\n\t\tvar entry = Module.EntryPoint( ShaderStage.Compute );\r\n\t\tvar threads = entry?.Attributes.FirstOrDefault( x => x?.StartsWith( \"[numthreads\", StringComparison.OrdinalIgnoreCase ) == true );\r\n\r\n\t\t_builder.Write( threads ?? SboxShaderTemplates.ComputeDefaultNumThreads );\r\n\t\t_builder.Write( $\"void {PrismConstants.EntryPointCompute}( {SboxShaderTemplates.ComputeEntryParameters} )\" );\r\n\t\t_builder.Open();\r\n\r\n\t\tif ( entry is not null ) WriteStatements( entry.Body );\r\n\r\n\t\t_builder.Close();\r\n\t\t_builder.Close();\r\n\t\t_builder.Blank();\r\n\t}\r\n\r\n\t// ---- statements -------------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Write a block's statements.\r\n\t/// <para>\r\n\t/// Everything <see cref=\"HlslEmitter\"/> already knows how to write is handed straight back to it,\r\n\t/// character for character. This layer exists for the one statement the emitter cannot see \u2014\r\n\t/// <see cref=\"IrPreprocessorIf\"/>, which lowers to directives rather than to an expression \u2014 and\r\n\t/// the block-carrying statements are reproduced here only so that a preprocessor branch nested\r\n\t/// inside a loop or a conditional still reaches this writer.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tvoid WriteStatements( IrBlock block )\r\n\t{\r\n\t\tif ( block is null ) return;\r\n\r\n\t\tforeach ( var statement in block.Statements ) WriteStatement( statement );\r\n\t}\r\n\r\n\tvoid WriteStatement( IrStmt statement )\r\n\t{\r\n\t\tif ( statement is null ) return;\r\n\r\n\t\tvar previous = _emitter.CurrentOrigin;\r\n\r\n\t\tswitch ( statement )\r\n\t\t{\r\n\t\t\tcase IrPreprocessorIf guard:\r\n\t\t\t\t_emitter.CurrentOrigin = guard.Origin;\r\n\t\t\t\tWritePreprocessorIf( guard );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase IrIf branch:\r\n\t\t\t\t_emitter.CurrentOrigin = branch.Origin;\r\n\t\t\t\t_builder.Write( $\"if ( {_emitter.Expression( branch.Cond )} )\", branch.Origin );\r\n\t\t\t\tWriteBraced( branch.Then, branch.Origin );\r\n\r\n\t\t\t\tif ( branch.Else is { IsEmpty: false } )\r\n\t\t\t\t{\r\n\t\t\t\t\t_builder.Write( \"else\", branch.Origin );\r\n\t\t\t\t\tWriteBraced( branch.Else, branch.Origin );\r\n\t\t\t\t}\r\n\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase IrFor loop:\r\n\t\t\t\t_emitter.CurrentOrigin = loop.Origin;\r\n\r\n\t\t\t\tvar counter = string.IsNullOrEmpty( loop.Var ) ? \"n\" : loop.Var;\r\n\r\n\t\t\t\t_builder.Write(\r\n\t\t\t\t\t$\"for ( int {counter} = 0; {counter} < ( int )( {_emitter.Expression( loop.Count )} ); {counter}++ )\",\r\n\t\t\t\t\tloop.Origin );\r\n\t\t\t\tWriteBraced( loop.Body, loop.Origin );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase IrWhile loop:\r\n\t\t\t\t_emitter.CurrentOrigin = loop.Origin;\r\n\t\t\t\t_builder.Write( $\"while ( {_emitter.Expression( loop.Cond )} )\", loop.Origin );\r\n\t\t\t\tWriteBraced( loop.Body, loop.Origin );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase IrScope scope:\r\n\t\t\t\t_emitter.CurrentOrigin = scope.Origin;\r\n\t\t\t\tWriteBraced( scope.Body, scope.Origin );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tdefault:\r\n\t\t\t\t_emitter.WriteStatement( _builder, statement );\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\r\n\t\t_emitter.CurrentOrigin = previous;\r\n\t}\r\n\r\n\tvoid WriteBraced( IrBlock block, NodeId origin )\r\n\t{\r\n\t\t_builder.Open( origin );\r\n\t\tWriteStatements( block );\r\n\t\t_builder.Close( origin: origin );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Write a preprocessor branch as real <c>#if</c> / <c>#else</c> / <c>#endif</c> directives, so\r\n\t/// only the taken side ever reaches the compiler.\r\n\t/// <para>\r\n\t/// This is what a static combo is supposed to cost. A run-time <c>select</c> evaluates both sides\r\n\t/// and pays for the texture samples and the loops in the one that was never wanted; a\r\n\t/// preprocessor branch deletes them.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tvoid WritePreprocessorIf( IrPreprocessorIf guard )\r\n\t{\r\n\t\tvar directive = IrPreprocessor.OpenDirective( guard.Condition );\r\n\r\n\t\tif ( string.IsNullOrEmpty( directive ) )\r\n\t\t{\r\n\t\t\t// A condition we cannot spell must not become a directive the preprocessor rejects, because\r\n\t\t\t// a preprocessor error has no line we can map back to a node. Folding both sides in keeps\r\n\t\t\t// the shader compiling and costs only the exclusion.\r\n\t\t\t_emitter.Report( DiagnosticSeverity.Warning, DiagnosticCode.InvalidBlock,\r\n\t\t\t\t\"A compile-time branch had no usable combo condition, so both of its sides were emitted.\",\r\n\t\t\t\t\"Bind the node to a graph keyword. Without one there is nothing for the preprocessor to test, and the branch cannot be resolved at compile time.\" );\r\n\r\n\t\t\tWriteStatements( guard.Then );\r\n\t\t\tWriteStatements( guard.Else );\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_builder.Write( directive, guard.Origin );\r\n\t\tWriteStatements( guard.Then );\r\n\r\n\t\tif ( guard.HasElse )\r\n\t\t{\r\n\t\t\t_builder.Write( IrPreprocessor.ElseDirective, guard.Origin );\r\n\t\t\tWriteStatements( guard.Else );\r\n\t\t}\r\n\r\n\t\t_builder.Write( IrPreprocessor.EndDirective, guard.Origin );\r\n\t}\r\n\r\n\t// ---- preview instrumentation ------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Declare the two preview uniforms. They are attribute-bound and default to zero, so a shader\r\n\t/// built with instrumentation still renders normally until something pushes them, and switching\r\n\t/// what the viewport displays costs one attribute write rather than a recompile.\r\n\t/// </summary>\r\n\tvoid WriteInstrumentationDeclarations()\r\n\t{\r\n\t\tif ( _stageIds is null ) return;\r\n\r\n\t\tforeach ( var line in PreviewInstrumentation.Banner() ) _builder.Write( line );\r\n\t\tforeach ( var line in PreviewInstrumentation.HlslDeclarations() ) _builder.Write( line );\r\n\r\n\t\t_builder.Blank();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Write the pixel entry's body, interleaving the preview stage switch between statements.\r\n\t/// <para>\r\n\t/// The test sits next to the temp it reads rather than in a tail at the end of the function, and\r\n\t/// that is not a stylistic choice: a temp bound inside a loop or a branch has gone out of scope by\r\n\t/// the time the function ends. One test per node, after the last statement that node produced, so\r\n\t/// what the switch returns is the node's result rather than an intermediate.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tvoid WritePixelBody( IrBlock body )\r\n\t{\r\n\t\tif ( body is null ) return;\r\n\r\n\t\tif ( _stageIds is null )\r\n\t\t{\r\n\t\t\tWriteStatements( body );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar statements = body.Statements;\r\n\t\tvar cases = StageCases( statements );\r\n\r\n\t\tfor ( int i = 0; i < statements.Count; i++ )\r\n\t\t{\r\n\t\t\tWriteStatement( statements[i] );\r\n\r\n\t\t\tif ( !cases.TryGetValue( i, out var line ) ) continue;\r\n\r\n\t\t\t_builder.Write( line, statements[i].Origin );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The switch case for each top-level statement that ends a node's contribution to the pixel\r\n\t/// program. Nodes whose work the stage planner put in the vertex program, and values that cannot\r\n\t/// be shown as a colour at all, produce no case \u2014 selecting one of those shows the shaded result\r\n\t/// rather than a wrong one.\r\n\t/// </summary>\r\n\tDictionary<int, string> StageCases( IReadOnlyList<IrStmt> statements )\r\n\t{\r\n\t\tvar last = new Dictionary<NodeId, int>();\r\n\r\n\t\tfor ( int i = 0; i < statements.Count; i++ )\r\n\t\t{\r\n\t\t\tif ( statements[i] is not IrDecl decl || !decl.Origin.IsValid ) continue;\r\n\t\t\tif ( !PreviewInstrumentation.CanShow( decl.Type ) ) continue;\r\n\r\n\t\t\tlast[decl.Origin] = i;\r\n\t\t}\r\n\r\n\t\tvar cases = new Dictionary<int, string>();\r\n\r\n\t\tforeach ( var (origin, index) in last )\r\n\t\t{\r\n\t\t\tif ( statements[index] is not IrDecl decl ) continue;\r\n\r\n\t\t\tvar line = PreviewInstrumentation.StageCase(\r\n\t\t\t\tPreviewInstrumentation.StageIdOf( _stageIds, origin ), decl.Name, decl.Type );\r\n\r\n\t\t\tif ( line is not null ) cases[index] = line;\r\n\t\t}\r\n\r\n\t\treturn cases;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Write the debug-channel tail, just before the shading epilogue so the material the graph filled\r\n\t/// in is still in scope and still unclamped.\r\n\t/// </summary>\r\n\tvoid WriteChannelTail()\r\n\t{\r\n\t\tif ( _stageIds is null ) return;\r\n\r\n\t\tvar lines = PreviewInstrumentation.ChannelLines( ChannelEnvironment() );\r\n\r\n\t\tif ( lines.Count == 0 ) return;\r\n\r\n\t\tforeach ( var line in lines ) _builder.Write( line );\r\n\r\n\t\t_builder.Blank();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// What this shader can answer about itself, channel by channel. A null entry means the generated\r\n\t/// shader has no honest expression for that channel \u2014 no material struct under a custom shading\r\n\t/// model, no vertex colour outside a surface graph \u2014 and the channel is then simply absent, which\r\n\t/// the viewport reads as \"keep showing the shaded result\".\r\n\t/// </summary>\r\n\tPreviewChannelEnvironment ChannelEnvironment()\r\n\t{\r\n\t\tvar surface = Domain == ShaderDomain.Surface;\r\n\t\tvar input = SboxShaderTemplates.PixelInputLocal;\r\n\t\tvar material = SboxMaterialBinding.UsesMaterial( Module );\r\n\r\n\t\treturn new PreviewChannelEnvironment\r\n\t\t{\r\n\t\t\tAlbedo = MaterialField( material, \"Albedo\" ),\r\n\t\t\tOpacity = MaterialField( material, \"Opacity\" ),\r\n\t\t\tNormalTangent = MaterialField( material, \"Normal\" ),\r\n\r\n\t\t\t// The graph authors the normal in tangent space; the same conversion the shading epilogue\r\n\t\t\t// performs is what makes this channel comparable with the engine's own normal debug view.\r\n\t\t\tNormalWorld = material && surface\r\n\t\t\t\t? $\"TransformNormal( {SboxShaderTemplates.MaterialLocal}.Normal, {input}.vNormalWs, {input}.vTangentUWs, {input}.vTangentVWs )\"\r\n\t\t\t\t: null,\r\n\r\n\t\t\tRoughness = MaterialField( material, \"Roughness\" ),\r\n\t\t\tMetalness = MaterialField( material, \"Metalness\" ),\r\n\t\t\tAmbientOcclusion = MaterialField( material, \"AmbientOcclusion\" ),\r\n\t\t\tEmission = MaterialField( material, \"Emission\" ),\r\n\t\t\tTransmission = MaterialField( material, \"Transmission\" ),\r\n\t\t\tTintMask = MaterialField( material, \"TintMask\" ),\r\n\r\n\t\t\tUv0 = $\"{input}.vTextureCoords.xy\",\r\n\t\t\tUv1 = $\"{input}.vTextureCoords.zw\",\r\n\t\t\tVertexColor = surface ? $\"{input}.vColor\" : null,\r\n\t\t\tWorldPosition = surface\r\n\t\t\t\t? HlslIntrinsics.BuiltinExpression( Builtin.WorldPosition, ShaderStage.Pixel, Domain )\r\n\t\t\t\t: null,\r\n\r\n\t\t\tDerivativeSource = $\"{input}.vTextureCoords.xy\"\r\n\t\t};\r\n\t}\r\n\r\n\t/// <summary>The expression for one material field, or null when this shader has no material.</summary>\r\n\tstatic string MaterialField( bool material, string name ) =>\r\n\t\tmaterial && SboxMaterialBinding.TryGetField( name, out var field ) ? field.Reference : null;\r\n\r\n\t// ---- combos -----------------------------------------------------------\r\n\r\n\tvoid WriteCombos( ShaderStage stage )\r\n\t{\r\n\t\tvar wrote = false;\r\n\r\n\t\tforeach ( var combo in Meta.Combos )\r\n\t\t{\r\n\t\t\tif ( combo is null ) continue;\r\n\r\n\t\t\tswitch ( combo.Kind )\r\n\t\t\t{\r\n\t\t\t\tcase ComboKind.Feature:\r\n\t\t\t\t\t// A feature is only visible to a program through a static combo bound to it.\r\n\t\t\t\t\t_builder.Write( $\"StaticCombo( {StaticNameFor( combo.Name )}, {combo.Name}, Sys( ALL ) );\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase ComboKind.Static:\r\n\t\t\t\t\t_builder.Write( $\"StaticCombo( {combo.Name}, 0..{ComboMaximum( combo )}, Sys( ALL ) );\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tdefault:\r\n\t\t\t\t\t_builder.Write( $\"DynamicCombo( {combo.Name}, 0..{ComboMaximum( combo )}, Sys( ALL ) );\" );\r\n\t\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\twrote = true;\r\n\t\t}\r\n\r\n\t\tif ( wrote ) _builder.Blank();\r\n\t}\r\n\r\n\tstatic int ComboMaximum( ComboDecl combo )\r\n\t{\r\n\t\tvar count = combo.Values?.Count ?? 0;\r\n\r\n\t\treturn count < 2 ? 1 : count - 1;\r\n\t}\r\n\r\n\t/// <summary>The static-combo symbol a feature is bound to: <c>F_PUDDLES</c> becomes <c>S_PUDDLES</c>.</summary>\r\n\tpublic static string StaticNameFor( string featureName )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( featureName ) ) return \"S_UNNAMED\";\r\n\r\n\t\treturn featureName.StartsWith( \"F_\", StringComparison.Ordinal )\r\n\t\t\t? \"S_\" + featureName[2..]\r\n\t\t\t: \"S_\" + featureName;\r\n\t}\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Compiler/Backends/SlangRuntimeModule.cs",
"FileName": "SlangRuntimeModule.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Core;\r\n\r\nnamespace Editor.Prism.Compiler.Backends;\r\n\r\n/// <summary>\r\n/// The small <c>prism.core</c> Slang module every generated Prism module imports.\r\n/// <para>\r\n/// It carries the three things a standalone <c>.slang</c> artifact cannot get from the engine: the\r\n/// environment parameter block (camera, viewport, object transform, time), the handful of math and\r\n/// colour-space helpers the emitted code calls into, and the <c>PrismMaterial</c> struct a surface\r\n/// graph fills in. It is emitted as a <see cref=\"GeneratedArtifact\"/> beside the main module, so the\r\n/// pair compiles with nothing but <c>slangc</c> and an include path.\r\n/// </para>\r\n/// </summary>\r\npublic static class SlangRuntimeModule\r\n{\r\n\t/// <summary>The module name an emitted Prism module imports.</summary>\r\n\tpublic const string ModuleName = PrismConstants.SlangRuntimeModule;\r\n\r\n\t/// <summary>\r\n\t/// Path of the emitted file, relative to the main module. <c>import prism.core;</c> resolves a\r\n\t/// dotted module name to this path, so the directory is part of the contract.\r\n\t/// </summary>\r\n\tpublic const string FileName = \"prism/core.slang\";\r\n\r\n\t/// <summary>The import statement an emitted module writes.</summary>\r\n\tpublic const string ImportStatement = \"import \" + ModuleName + \";\";\r\n\r\n\t/// <summary>Name of the environment parameter block this module declares.</summary>\r\n\tpublic const string EnvironmentBlock = SlangIntrinsics.EnvironmentBlock;\r\n\r\n\t/// <summary>Name of the material struct a surface graph fills in.</summary>\r\n\tpublic const string MaterialStruct = \"PrismMaterial\";\r\n\r\n\t/// <summary>The prelude source, with CRLF line endings.</summary>\r\n\tpublic static string Source => SourceWith( \"\\r\\n\" );\r\n\r\n\t/// <summary>The prelude source with a chosen line ending.</summary>\r\n\tpublic static string SourceWith( string newLine )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( newLine ) ) newLine = \"\\r\\n\";\r\n\r\n\t\t// The literal below picks up whatever line ending this file happens to be saved with, so it is\r\n\t\t// normalised before substituting. Without this a CRLF source would emit CR CR LF.\r\n\t\tvar normalised = s_source.Replace( \"\\r\\n\", \"\\n\" ).Replace( '\\r', '\\n' );\r\n\r\n\t\treturn newLine == \"\\n\" ? normalised : normalised.Replace( \"\\n\", newLine );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The prelude packaged as an artifact the backend returns alongside its main result. It is\r\n\t/// written beside the saved document, which is also where the module's include path points.\r\n\t/// </summary>\r\n\tpublic static GeneratedArtifact Artifact( string newLine = \"\\r\\n\" ) =>\r\n\t\tnew( FileName, SourceWith( newLine ) ) { BesideDocument = true };\r\n\r\n\t// The source is stored with plain LF and normalised on the way out, so the literal below stays\r\n\t// readable and the emitted file still honours BackendEmitOptions.NewLine.\r\n\tconst string s_source = \"\"\"\r\n#language slang 2026\r\nmodule \"prism/core\";\r\n\r\n// =============================================================================\r\n// prism.core - the shared prelude for Prism-generated Slang modules\r\n//\r\n// Generated by Prism. Editing this file is fine, but regenerating a graph\r\n// overwrites it: keep local changes in a module of your own and import both.\r\n//\r\n// Contents\r\n// 1. Material-UI attributes - reflected into `-reflection-json` userAttribs\r\n// 2. Environment - camera, viewport, object transform, time\r\n// 3. Transforms - object/world/clip space conversions\r\n// 4. Math - the safe-by-default helpers emitted code calls\r\n// 5. Textures - value-returning wrappers over out-param methods\r\n// 6. Colour - sRGB, HSV and luminance\r\n// 7. PrismMaterial - what a surface graph fills in\r\n// =============================================================================\r\n\r\n\r\n// -----------------------------------------------------------------------------\r\n// 1. Material-UI attributes\r\n//\r\n// Prism annotates every generated shader parameter with these. They carry no\r\n// runtime cost: `slangc -reflection-json` reports them under \"userAttribs\",\r\n// which is how a host application rebuilds the material inspector.\r\n// -----------------------------------------------------------------------------\r\n\r\n/// Display name of a parameter.\r\n[__AttributeUsage( _AttributeTargets.Var )]\r\npublic struct UiLabelAttribute { string text; }\r\n\r\n/// Group heading and sort order in the material inspector.\r\n[__AttributeUsage( _AttributeTargets.Var )]\r\npublic struct UiGroupAttribute { string group; int order; }\r\n\r\n/// Inclusive numeric range of a slider.\r\n[__AttributeUsage( _AttributeTargets.Var )]\r\npublic struct UiRangeAttribute { float min; float max; }\r\n\r\n/// Which editor to show: slider, color, toggle, dropdown, vector, texture.\r\n[__AttributeUsage( _AttributeTargets.Var )]\r\npublic struct UiControlAttribute { string control; }\r\n\r\n/// Hover text.\r\n[__AttributeUsage( _AttributeTargets.Var )]\r\npublic struct UiTooltipAttribute { string text; }\r\n\r\n/// Default value, splatted across the parameter's components.\r\n[__AttributeUsage( _AttributeTargets.Var )]\r\npublic struct UiDefaultAttribute { float x; float y; float z; float w; }\r\n\r\n/// Default asset path for a texture parameter.\r\n[__AttributeUsage( _AttributeTargets.Var )]\r\npublic struct UiAssetAttribute { string path; }\r\n\r\n/// Render-attribute name, so a host can push a value without recompiling.\r\n[__AttributeUsage( _AttributeTargets.Var )]\r\npublic struct PrismAttributeAttribute { string name; }\r\n\r\n/// Non-zero when a texture's contents are sRGB encoded.\r\n[__AttributeUsage( _AttributeTargets.Var )]\r\npublic struct PrismSrgbAttribute { int srgb; }\r\n\r\n\r\n// -----------------------------------------------------------------------------\r\n// 2. Environment\r\n//\r\n// Everything a shader knows about the frame and the object it is drawing.\r\n// A ParameterBlock gets its own descriptor set / register space, so binding it\r\n// once per frame and once per object is the natural split for a host renderer.\r\n// -----------------------------------------------------------------------------\r\n\r\n/// Per-frame constants.\r\npublic struct PrismFrameParams\r\n{\r\n\tfloat4x4 WorldToView;\r\n\tfloat4x4 ViewToProjection;\r\n\tfloat4x4 WorldToProjection;\r\n\r\n\tfloat3 CameraPosition;\r\n\tfloat CameraNear;\r\n\tfloat3 CameraForward;\r\n\tfloat CameraFar;\r\n\r\n\tfloat2 ViewportSize;\r\n\tfloat2 ViewportInvSize;\r\n\tfloat2 ViewportOffset;\r\n\r\n\tfloat3 SunDirection;\r\n\tfloat3 SunColor;\r\n\r\n\tfloat Time;\r\n\tfloat DeltaTime;\r\n\tint FrameCount;\r\n}\r\n\r\n/// Per-object constants.\r\npublic struct PrismObjectParams\r\n{\r\n\tfloat4x4 ObjectToWorld;\r\n\tfloat4x4 WorldToObject;\r\n\r\n\tfloat3 ObjectOrigin;\r\n\tfloat3 ObjectScale;\r\n\tfloat4 TintColor;\r\n}\r\n\r\n/// The environment a Prism module is rendered in.\r\npublic struct PrismEnvironment\r\n{\r\n\tPrismFrameParams Frame;\r\n\tPrismObjectParams Object;\r\n}\r\n\r\n/// The one environment binding every generated module reads from.\r\npublic ParameterBlock<PrismEnvironment> gPrismEnv;\r\n\r\n\r\n// -----------------------------------------------------------------------------\r\n// 3. Transforms\r\n// -----------------------------------------------------------------------------\r\n\r\n/// Object space to world space, as a position.\r\npublic float3 PrismObjectToWorldPoint( float3 positionOs )\r\n{\r\n\treturn mul( gPrismEnv.Object.ObjectToWorld, float4( positionOs, 1.0 ) ).xyz;\r\n}\r\n\r\n/// Object space to world space, as a direction. Not normalised; scale is preserved.\r\npublic float3 PrismObjectToWorldDirection( float3 directionOs )\r\n{\r\n\treturn mul( gPrismEnv.Object.ObjectToWorld, float4( directionOs, 0.0 ) ).xyz;\r\n}\r\n\r\n/// Object space to world space, as a normal. Uses the inverse transpose, so non-uniform scale is safe.\r\npublic float3 PrismObjectToWorldNormal( float3 normalOs )\r\n{\r\n\treturn normalize( mul( float4( normalOs, 0.0 ), gPrismEnv.Object.WorldToObject ).xyz );\r\n}\r\n\r\n/// World space to object space, as a position.\r\npublic float3 PrismWorldToObjectPoint( float3 positionWs )\r\n{\r\n\treturn mul( gPrismEnv.Object.WorldToObject, float4( positionWs, 1.0 ) ).xyz;\r\n}\r\n\r\n/// World space to clip space.\r\npublic float4 PrismWorldToClip( float3 positionWs )\r\n{\r\n\treturn mul( gPrismEnv.Frame.WorldToProjection, float4( positionWs, 1.0 ) );\r\n}\r\n\r\n/// Clip space to a 0..1 screen UV, with the origin in the top left.\r\npublic float2 PrismScreenUvFromClip( float4 positionPs )\r\n{\r\n\tfloat2 ndc = positionPs.xy / max( abs( positionPs.w ), 1.0e-6 );\r\n\treturn ndc * float2( 0.5, -0.5 ) + 0.5;\r\n}\r\n\r\n\r\n// -----------------------------------------------------------------------------\r\n// 4. Math\r\n//\r\n// The emitted code prefers these over the raw intrinsics wherever a zero or a\r\n// denormal would otherwise produce a NaN that is invisible until it is not.\r\n// -----------------------------------------------------------------------------\r\n\r\n/// Normalise, returning a zero vector instead of a NaN for a zero-length input.\r\npublic float3 PrismSafeNormalize( float3 v )\r\n{\r\n\tfloat lengthSquared = dot( v, v );\r\n\treturn lengthSquared > 1.0e-12 ? v * rsqrt( lengthSquared ) : float3( 0.0 );\r\n}\r\n\r\n/// Normalise a 2D vector, returning zero instead of a NaN for a zero-length input.\r\npublic float2 PrismSafeNormalize( float2 v )\r\n{\r\n\tfloat lengthSquared = dot( v, v );\r\n\treturn lengthSquared > 1.0e-12 ? v * rsqrt( lengthSquared ) : float2( 0.0 );\r\n}\r\n\r\n/// Reciprocal that returns zero rather than an infinity at zero.\r\npublic float PrismSafeRcp( float v )\r\n{\r\n\treturn abs( v ) > 1.0e-12 ? 1.0 / v : 0.0;\r\n}\r\n\r\n/// Divide, returning zero rather than a NaN or an infinity when the denominator vanishes.\r\npublic float3 PrismSafeDivide( float3 a, float3 b )\r\n{\r\n\tbool3 ok = abs( b ) > float3( 1.0e-12 );\r\n\tfloat3 divisor = select( ok, b, float3( 1.0 ) );\r\n\treturn select( ok, a / divisor, float3( 0.0 ) );\r\n}\r\n\r\n/// Linear remap from one inclusive range to another. Both ranges are packed as (min, max).\r\npublic float PrismRemap( float value, float2 fromRange, float2 toRange )\r\n{\r\n\tfloat t = ( value - fromRange.x ) * PrismSafeRcp( fromRange.y - fromRange.x );\r\n\treturn lerp( toRange.x, toRange.y, t );\r\n}\r\n\r\n/// Build a tangent-to-world basis from an interpolated normal and tangent.\r\npublic float3x3 PrismTangentBasis( float3 normalWs, float3 tangentUWs, float3 tangentVWs )\r\n{\r\n\tfloat3 n = PrismSafeNormalize( normalWs );\r\n\tfloat3 t = PrismSafeNormalize( tangentUWs - n * dot( n, tangentUWs ) );\r\n\tfloat3 b = PrismSafeNormalize( tangentVWs );\r\n\treturn float3x3( t, b, n );\r\n}\r\n\r\n\r\n// -----------------------------------------------------------------------------\r\n// 5. Textures\r\n//\r\n// GetDimensions writes through out parameters and therefore cannot appear in an\r\n// expression. These wrappers give the graph a value it can feed into math.\r\n// -----------------------------------------------------------------------------\r\n\r\n/// Width and height of a 2D texture, in texels.\r\npublic float2 PrismTextureSize( Texture2D texture )\r\n{\r\n\tuint width, height;\r\n\ttexture.GetDimensions( width, height );\r\n\treturn float2( width, height );\r\n}\r\n\r\n/// Width, height and slice count of a 2D texture array, in texels.\r\npublic float3 PrismTextureSize( Texture2DArray texture )\r\n{\r\n\tuint width, height, slices;\r\n\ttexture.GetDimensions( width, height, slices );\r\n\treturn float3( width, height, slices );\r\n}\r\n\r\n/// Width, height and depth of a 3D texture, in texels.\r\npublic float3 PrismTextureSize( Texture3D texture )\r\n{\r\n\tuint width, height, depth;\r\n\ttexture.GetDimensions( width, height, depth );\r\n\treturn float3( width, height, depth );\r\n}\r\n\r\n/// Face width and height of a cube map, in texels.\r\npublic float2 PrismTextureSize( TextureCube texture )\r\n{\r\n\tuint width, height;\r\n\ttexture.GetDimensions( width, height );\r\n\treturn float2( width, height );\r\n}\r\n\r\n\r\n// -----------------------------------------------------------------------------\r\n// 6. Colour\r\n// -----------------------------------------------------------------------------\r\n\r\n/// sRGB to linear, using the exact piecewise transfer function.\r\npublic float3 PrismSrgbToLinear( float3 srgb )\r\n{\r\n\tfloat3 low = srgb / 12.92;\r\n\tfloat3 high = pow( max( ( srgb + 0.055 ) / 1.055, 0.0 ), 2.4 );\r\n\treturn select( srgb <= float3( 0.04045 ), low, high );\r\n}\r\n\r\n/// sRGB to linear, leaving alpha alone.\r\npublic float4 PrismSrgbToLinear( float4 srgb )\r\n{\r\n\treturn float4( PrismSrgbToLinear( srgb.rgb ), srgb.a );\r\n}\r\n\r\n/// Linear to sRGB, using the exact piecewise transfer function.\r\npublic float3 PrismLinearToSrgb( float3 linearColor )\r\n{\r\n\tfloat3 low = linearColor * 12.92;\r\n\tfloat3 high = 1.055 * pow( max( linearColor, 0.0 ), 1.0 / 2.4 ) - 0.055;\r\n\treturn select( linearColor <= float3( 0.0031308 ), low, high );\r\n}\r\n\r\n/// Linear to sRGB, leaving alpha alone.\r\npublic float4 PrismLinearToSrgb( float4 linearColor )\r\n{\r\n\treturn float4( PrismLinearToSrgb( linearColor.rgb ), linearColor.a );\r\n}\r\n\r\n/// Rec. 709 relative luminance of a linear colour.\r\npublic float PrismLuminance( float3 linearColor )\r\n{\r\n\treturn dot( linearColor, float3( 0.2126, 0.7152, 0.0722 ) );\r\n}\r\n\r\n/// RGB to HSV. Hue is 0..1, not degrees.\r\npublic float3 PrismRgbToHsv( float3 rgb )\r\n{\r\n\tconst float4 k = float4( 0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0 );\r\n\tconst float epsilon = 1.0e-10;\r\n\r\n\tfloat4 p = select( bool4( rgb.g < rgb.b ), float4( rgb.bg, k.wz ), float4( rgb.gb, k.xy ) );\r\n\tfloat4 q = select( bool4( rgb.r < p.x ), float4( p.xyw, rgb.r ), float4( rgb.r, p.yzx ) );\r\n\r\n\tfloat chroma = q.x - min( q.w, q.y );\r\n\treturn float3( abs( q.z + ( q.w - q.y ) / ( 6.0 * chroma + epsilon ) ), chroma / ( q.x + epsilon ), q.x );\r\n}\r\n\r\n/// HSV to RGB. Hue is 0..1, not degrees.\r\npublic float3 PrismHsvToRgb( float3 hsv )\r\n{\r\n\tconst float4 k = float4( 1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0 );\r\n\r\n\tfloat3 p = abs( frac( hsv.xxx + k.xyz ) * 6.0 - k.www );\r\n\treturn hsv.z * lerp( k.xxx, saturate( p - k.xxx ), hsv.y );\r\n}\r\n\r\n/// Blend two linear colours with the classic overlay operator.\r\npublic float3 PrismOverlay( float3 baseColor, float3 blend )\r\n{\r\n\tfloat3 low = 2.0 * baseColor * blend;\r\n\tfloat3 high = 1.0 - 2.0 * ( 1.0 - baseColor ) * ( 1.0 - blend );\r\n\treturn select( baseColor <= float3( 0.5 ), low, high );\r\n}\r\n\r\n\r\n// -----------------------------------------------------------------------------\r\n// 7. PrismMaterial\r\n//\r\n// What a surface graph produces. A host renderer reads these fields and runs\r\n// whatever shading model it likes; `ToUnlitColor` is the trivial one.\r\n// -----------------------------------------------------------------------------\r\n\r\n/// The surface description a Prism surface graph fills in.\r\npublic struct PrismMaterial\r\n{\r\n\t/// Linear base colour.\r\n\tfloat3 Albedo;\r\n\t/// Coverage. Compared against the alpha-test threshold for a masked material.\r\n\tfloat Opacity;\r\n\t/// Tangent-space normal, with the usual (0, 0, 1) meaning \"unperturbed\".\r\n\tfloat3 Normal;\r\n\t/// Perceptual roughness, 0 mirror to 1 fully rough.\r\n\tfloat Roughness;\r\n\t/// Metalness, 0 dielectric to 1 conductor.\r\n\tfloat Metalness;\r\n\t/// Baked ambient occlusion.\r\n\tfloat AmbientOcclusion;\r\n\t/// Linear emissive radiance.\r\n\tfloat3 Emission;\r\n\t/// Light transmitted through the surface.\r\n\tfloat3 Transmission;\r\n\t/// Where a per-instance tint applies.\r\n\tfloat TintMask;\r\n\r\n\t/// A sensible neutral surface: white, opaque, flat, rough, dielectric.\r\n\tpublic static PrismMaterial Init()\r\n\t{\r\n\t\tPrismMaterial m;\r\n\t\tm.Albedo = float3( 1.0 );\r\n\t\tm.Opacity = 1.0;\r\n\t\tm.Normal = float3( 0.0, 0.0, 1.0 );\r\n\t\tm.Roughness = 1.0;\r\n\t\tm.Metalness = 0.0;\r\n\t\tm.AmbientOcclusion = 1.0;\r\n\t\tm.Emission = float3( 0.0 );\r\n\t\tm.Transmission = float3( 0.0 );\r\n\t\tm.TintMask = 1.0;\r\n\t\treturn m;\r\n\t}\r\n\r\n\t/// Replace the tangent-space normal. Mutates, so it carries [mutating].\r\n\t[mutating]\r\n\tpublic void SetNormal( float3 tangentSpaceNormal )\r\n\t{\r\n\t\tNormal = PrismSafeNormalize( tangentSpaceNormal );\r\n\t}\r\n\r\n\t/// Kill the fragment when coverage falls below a threshold. Pixel stage only.\r\n\tpublic void AlphaTest( float threshold )\r\n\t{\r\n\t\tif ( Opacity < threshold ) discard;\r\n\t}\r\n\r\n\t/// The world-space normal implied by this material's tangent-space normal.\r\n\tpublic float3 WorldNormal( float3x3 tangentBasis )\r\n\t{\r\n\t\treturn PrismSafeNormalize( mul( Normal, tangentBasis ) );\r\n\t}\r\n\r\n\t/// The unlit resolve: albedo plus emission, with coverage in alpha.\r\n\tpublic float4 ToUnlitColor()\r\n\t{\r\n\t\treturn float4( Albedo + Emission, Opacity );\r\n\t}\r\n}\r\n\"\"\";\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Compiler/CompileMode.cs",
"FileName": "CompileMode.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "namespace Editor.Prism.Compiler;\r\n\r\n/// <summary>\r\n/// What a compile is for. The mode changes what the compiler emits, not just where it writes it.\r\n/// </summary>\r\npublic enum CompileMode\r\n{\r\n\t/// <summary>\r\n\t/// The artifact saved beside the document. Literals are baked, every declared mode and combo is\r\n\t/// emitted, and no preview instrumentation is added.\r\n\t/// </summary>\r\n\tFinal,\r\n\r\n\t/// <summary>\r\n\t/// The live viewport shader. Literals become named uniforms recorded in\r\n\t/// <c>CompileResult.PreviewAttributes</c>, so dragging a slider updates at frame rate with zero\r\n\t/// recompiles. The minimum combo set is declared to keep compile latency down.\r\n\t/// </summary>\r\n\tPreview,\r\n\r\n\t/// <summary>\r\n\t/// One shader containing every previewable node's expression behind a stage-id switch, used to\r\n\t/// render all node thumbnails from a single compile.\r\n\t/// </summary>\r\n\tThumbnail,\r\n\r\n\t/// <summary>\r\n\t/// Type-check and emit far enough to produce diagnostics, then stop. Used by the debounced\r\n\t/// validation pass and by the code panel's IR tab.\r\n\t/// </summary>\r\n\tSyntaxOnly\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Compiler/CompileResult.cs",
"FileName": "CompileResult.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Compiler.Backends;\r\nusing Editor.Prism.Compiler.Ir;\r\nusing Editor.Prism.Core;\r\n\r\nnamespace Editor.Prism.Compiler;\r\n\r\n/// <summary>\r\n/// A uniform the preview can push straight to the GPU. In <see cref=\"CompileMode.Preview\"/> every\r\n/// literal and every graph parameter becomes one of these, which is why dragging a slider costs zero\r\n/// compiles. Pushed with a dictionary indexer, never <c>Dictionary.Add</c> \u2014 the built-in editor's\r\n/// attribute helper throws on a duplicate name.\r\n/// </summary>\r\npublic sealed record PreviewAttribute( string Name, ShaderType Type, ConstValue Value )\r\n{\r\n\t/// <summary>The node whose literal this is, when it came from one.</summary>\r\n\tpublic NodeId Node { get; init; }\r\n\r\n\t/// <summary>The port whose literal this is, when it came from one.</summary>\r\n\tpublic PortId Port { get; init; }\r\n\r\n\t/// <summary>The blackboard parameter this came from, when it came from one.</summary>\r\n\tpublic ParamId Parameter { get; init; }\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override string ToString() => $\"{Type.Hlsl} {Name} = {Value}\";\r\n}\r\n\r\n/// <summary>\r\n/// A texture slot the preview has to fill itself, because nothing bakes it for a shader rendered\r\n/// without a material. See <c>NodeEmitter.PreviewTextureBinding</c> for why this exists.\r\n/// </summary>\r\n/// <param name=\"Name\">The render-attribute name the shader binds the slot to.</param>\r\n/// <param name=\"Asset\">Path of the source image the graph asked for.</param>\r\n/// <param name=\"Srgb\">True when the slot holds sRGB-encoded colour rather than linear data.</param>\r\npublic sealed record PreviewTexture( string Name, string Asset, bool Srgb )\r\n{\r\n\t/// <summary>The blackboard parameter behind the slot, when it came from one.</summary>\r\n\tpublic ParamId Parameter { get; init; }\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override string ToString() => $\"{Name} = \\\"{Asset}\\\"{( Srgb ? \" (srgb)\" : string.Empty )}\";\r\n}\r\n\r\n/// <summary>Counters for the status bar and for spotting performance regressions between builds.</summary>\r\npublic sealed record CompileStats\r\n{\r\n\t/// <summary>Nodes visited during emission.</summary>\r\n\tpublic int NodeCount { get; init; }\r\n\r\n\t/// <summary>Statements in the emitted module.</summary>\r\n\tpublic int StatementCount { get; init; }\r\n\r\n\t/// <summary>Temps bound by the emitter after CSE.</summary>\r\n\tpublic int TempCount { get; init; }\r\n\r\n\t/// <summary>Expressions removed by CSE, folding and dead-code elimination.</summary>\r\n\tpublic int OptimizedAway { get; init; }\r\n\r\n\t/// <summary>Module-level declarations emitted.</summary>\r\n\tpublic int GlobalCount { get; init; }\r\n\r\n\t/// <summary>Interpolators allocated.</summary>\r\n\tpublic int VaryingCount { get; init; }\r\n\r\n\t/// <summary>Helper functions emitted.</summary>\r\n\tpublic int HelperCount { get; init; }\r\n\r\n\t/// <summary>Milliseconds spent in validation, solving and stage planning.</summary>\r\n\tpublic double AnalysisMs { get; init; }\r\n\r\n\t/// <summary>Milliseconds spent building and optimising the IR.</summary>\r\n\tpublic double EmitMs { get; init; }\r\n\r\n\t/// <summary>Milliseconds spent in the backends.</summary>\r\n\tpublic double BackendMs { get; init; }\r\n\r\n\t/// <summary>Total wall time of the compile.</summary>\r\n\tpublic double TotalMs { get; init; }\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override string ToString() =>\r\n\t\t$\"{NodeCount} nodes, {StatementCount} statements, {TotalMs:0} ms\";\r\n}\r\n\r\n/// <summary>\r\n/// The result of a compile: one artifact per requested backend, everything that went wrong, the\r\n/// uniforms the preview can push live, and the counters for the status bar.\r\n/// </summary>\r\npublic sealed record CompileResult(\r\n\tbool Ok,\r\n\tIReadOnlyDictionary<string, BackendEmitResult> Artifacts,\r\n\tIReadOnlyList<Diagnostic> Diagnostics,\r\n\tIReadOnlyList<PreviewAttribute> PreviewAttributes,\r\n\tCompileStats Stats )\r\n{\r\n\t/// <summary>The IR the artifacts were generated from. Kept so the code panel can print it.</summary>\r\n\tpublic IrModule Module { get; init; }\r\n\r\n\t/// <summary>\r\n\t/// Texture slots the preview must push itself. Empty for every mode but\r\n\t/// <see cref=\"CompileMode.Preview\"/>, where a shipping <c>CreateInputTexture2D</c> slot would never\r\n\t/// be filled because nothing compiles a material for the preview.\r\n\t/// </summary>\r\n\tpublic IReadOnlyList<PreviewTexture> PreviewTextures { get; init; } = Array.Empty<PreviewTexture>();\r\n\r\n\t/// <summary>The request this result answers.</summary>\r\n\tpublic CompileRequest Request { get; init; }\r\n\r\n\t/// <summary>Number of errors reported.</summary>\r\n\tpublic int ErrorCount => Diagnostics?.Count( x => x.Severity == DiagnosticSeverity.Error ) ?? 0;\r\n\r\n\t/// <summary>Number of warnings reported.</summary>\r\n\tpublic int WarningCount => Diagnostics?.Count( x => x.Severity == DiagnosticSeverity.Warning ) ?? 0;\r\n\r\n\t/// <summary>The artifact produced by a backend, or null when that backend was not requested.</summary>\r\n\tpublic BackendEmitResult Artifact( string backendId ) =>\r\n\t\tArtifacts is not null && Artifacts.TryGetValue( backendId, out var result ) ? result : null;\r\n\r\n\t/// <summary>The generated <c>.shader</c> text, when the s&box backend ran.</summary>\r\n\tpublic string ShaderText => Artifact( PrismConstants.BackendHlsl )?.Text;\r\n\r\n\t/// <summary>The generated <c>.slang</c> text, when the Slang backend ran.</summary>\r\n\tpublic string SlangText => Artifact( PrismConstants.BackendSlang )?.Text;\r\n\r\n\t/// <summary>A failed result carrying only diagnostics.</summary>\r\n\tpublic static CompileResult Failed( IReadOnlyList<Diagnostic> diagnostics, CompileRequest request = null ) =>\r\n\t\tnew( false, new Dictionary<string, BackendEmitResult>(), diagnostics ?? Array.Empty<Diagnostic>(),\r\n\t\t\tArray.Empty<PreviewAttribute>(), new CompileStats() )\r\n\t\t{\r\n\t\t\tRequest = request\r\n\t\t};\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override string ToString() =>\r\n\t\t$\"{( Ok ? \"ok\" : \"failed\" )}: {ErrorCount} errors, {WarningCount} warnings, {Stats}\";\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Compiler/IrConversions.cs",
"FileName": "IrConversions.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Compiler.Ir;\r\nusing Editor.Prism.Core;\r\n\r\nnamespace Editor.Prism.Compiler;\r\n\r\n/// <summary>\r\n/// The one lowering of an implicit conversion into IR.\r\n/// <para>\r\n/// Two callers need it and used to spell it differently. <c>NodeEmitContext.Coerce</c> lowered a\r\n/// narrowing as a swizzle plus an optional convert; <c>GraphCompiler.Fit</c> lowered the identical\r\n/// conversion as a single <c>CastKind.Truncate</c>, which the HLSL backend only renders as a mask when\r\n/// the scalar kinds already agree and otherwise falls back to a C-style <c>( float3 )v</c>. Both are\r\n/// legal, but two structurally different expressions for one conversion never hash-cons against each\r\n/// other, so CSE missed and the generated text differed between two paths for no reason.\r\n/// </para>\r\n/// <para>\r\n/// This class holds no diagnostics on purpose: reporting a lossy or illegal conversion is the caller's\r\n/// job, because only the caller knows which port to attach it to. An unclassifiable conversion comes\r\n/// back as <see cref=\"IrValue.Invalid\"/>.\r\n/// </para>\r\n/// </summary>\r\npublic static class IrConversions\r\n{\r\n\t/// <summary>\r\n\t/// Emit the conversion of <paramref name=\"value\"/> to <paramref name=\"target\"/>, or\r\n\t/// <see cref=\"IrValue.Invalid\"/> when the two types cannot be converted at all.\r\n\t/// </summary>\r\n\t/// <param name=\"builder\">The builder to emit into.</param>\r\n\t/// <param name=\"value\">The value being converted.</param>\r\n\t/// <param name=\"target\">The type wanted.</param>\r\n\t/// <param name=\"fill\">What to pad a widening with, or null for <c>TypeRules.DefaultFill</c>.</param>\r\n\tpublic static IrValue Emit( IrBuilder builder, IrValue value, ShaderType target, float? fill = null )\r\n\t{\r\n\t\tif ( builder is null || !value.IsValid ) return IrValue.Invalid;\r\n\t\tif ( target.IsVoid || value.Type == target ) return value;\r\n\r\n\t\tvar from = value.Type;\r\n\r\n\t\tswitch ( TypeRules.Classify( from, target ) )\r\n\t\t{\r\n\t\t\tcase ConversionKind.Identity:\r\n\t\t\t\treturn value;\r\n\r\n\t\t\tcase ConversionKind.Splat:\r\n\t\t\t\treturn builder.Cast( target, value, CastKind.Splat );\r\n\r\n\t\t\tcase ConversionKind.Widen:\r\n\t\t\tcase ConversionKind.IntToFloat:\r\n\t\t\t\treturn builder.Cast( target, value, CastKind.Convert );\r\n\r\n\t\t\tcase ConversionKind.Pad:\r\n\t\t\t{\r\n\t\t\t\tvar actual = fill ?? TypeRules.DefaultFill( from, target, target.Components - 1 );\r\n\r\n\t\t\t\t// Convert the components before widening, so the pad literal and the existing lanes are\r\n\t\t\t\t// already the same scalar kind by the time the constructor is printed.\r\n\t\t\t\tvar widened = from.Scalar == target.Scalar\r\n\t\t\t\t\t? value\r\n\t\t\t\t\t: builder.Cast( from.WithScalar( target.Scalar ), value, CastKind.Convert );\r\n\r\n\t\t\t\treturn builder.Cast( target, widened, CastKind.Pad, actual );\r\n\t\t\t}\r\n\r\n\t\t\tcase ConversionKind.Truncate:\r\n\t\t\t{\r\n\t\t\t\tvar narrowed = value;\r\n\r\n\t\t\t\tif ( from.IsScalarOrVector && target.IsScalarOrVector && from.Components > target.Components )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar mask = \"xyzw\"[..Math.Clamp( target.Components, 1, 4 )];\r\n\t\t\t\t\tnarrowed = builder.Swizzle( ShaderType.Vec( from.Scalar, target.Components ), value, mask );\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif ( narrowed.Type == target ) return narrowed;\r\n\r\n\t\t\t\treturn builder.Cast( target, narrowed, CastKind.Convert );\r\n\t\t\t}\r\n\r\n\t\t\tdefault:\r\n\t\t\t\treturn IrValue.Invalid;\r\n\t\t}\r\n\t}\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Compiler/TypeSolver.cs",
"FileName": "TypeSolver.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Core;\r\nusing Editor.Prism.Model;\r\n\r\nnamespace Editor.Prism.Compiler;\r\n\r\n/// <summary>\r\n/// The result of running <see cref=\"TypeSolver\"/> over a graph: a concrete <see cref=\"ShaderType\"/>\r\n/// for every port, the conversion each edge performs, and a topological node order the rest of the\r\n/// pipeline can reuse.\r\n/// </summary>\r\npublic sealed class TypeSolution\r\n{\r\n\tinternal TypeSolution(\r\n\t\tIReadOnlyDictionary<PortRef, ShaderType> types,\r\n\t\tIReadOnlyDictionary<EdgeId, ConversionKind> conversions,\r\n\t\tIReadOnlyList<NodeId> order,\r\n\t\tint unresolved,\r\n\t\tbool ok )\r\n\t{\r\n\t\tTypes = types;\r\n\t\tConversions = conversions;\r\n\t\tTopologicalOrder = order;\r\n\t\tUnresolvedCount = unresolved;\r\n\t\tOk = ok;\r\n\t}\r\n\r\n\t/// <summary>An empty solution, used when there is nothing to solve.</summary>\r\n\tpublic static TypeSolution Empty { get; } = new(\r\n\t\tnew Dictionary<PortRef, ShaderType>(), new Dictionary<EdgeId, ConversionKind>(),\r\n\t\tArray.Empty<NodeId>(), 0, true );\r\n\r\n\t/// <summary>True when every port resolved and no unification failed.</summary>\r\n\tpublic bool Ok { get; }\r\n\r\n\t/// <summary>How many ports had to fall back to a default type.</summary>\r\n\tpublic int UnresolvedCount { get; }\r\n\r\n\t/// <summary>The solved type of every port in the graph.</summary>\r\n\tpublic IReadOnlyDictionary<PortRef, ShaderType> Types { get; }\r\n\r\n\t/// <summary>The conversion each edge performs, for wire markers and tooltips.</summary>\r\n\tpublic IReadOnlyDictionary<EdgeId, ConversionKind> Conversions { get; }\r\n\r\n\t/// <summary>\r\n\t/// Producers before consumers. Nodes caught in a cycle are appended at the end in document order,\r\n\t/// so this is always a total order even for a malformed graph.\r\n\t/// </summary>\r\n\tpublic IReadOnlyList<NodeId> TopologicalOrder { get; }\r\n\r\n\t/// <summary>The solved type of one port, or void when it is not in the solution.</summary>\r\n\tpublic ShaderType TypeOf( NodeId node, PortId port ) =>\r\n\t\tTypes.TryGetValue( new PortRef( node, port ), out var type ) ? type : ShaderType.Void;\r\n\r\n\t/// <summary>The solved type of one port.</summary>\r\n\tpublic ShaderType TypeOf( Port port ) =>\r\n\t\tport is null ? ShaderType.Void : TypeOf( port.Node?.Id ?? NodeId.None, port.Id );\r\n\r\n\t/// <summary>The conversion an edge performs, or <see cref=\"ConversionKind.Identity\"/> when unknown.</summary>\r\n\tpublic ConversionKind ConversionOn( EdgeId edge ) =>\r\n\t\tConversions.TryGetValue( edge, out var kind ) ? kind : ConversionKind.Identity;\r\n}\r\n\r\n/// <summary>\r\n/// Hindley\u2013Milner-lite unification over a whole graph.\r\n/// <para>\r\n/// A port's declared type is either a concrete spelling (<c>float3</c>, <c>Texture2D</c>) or a term in\r\n/// a small algebra: <c>T</c> \u2014 a variable shared by every port on the node that names it; <c>T.scalar</c>\r\n/// \u2014 the component type of <c>T</c>; <c>vecN</c> \u2014 a float vector whose width unifies; <c>float{N}</c> \u2014\r\n/// a float vector sharing the width variable <c>N</c>; <c>any</c> \u2014 a passthrough that adopts whatever\r\n/// reaches it. Every unrecognised spelling is treated as a fresh variable named after itself, so\r\n/// <c>U</c> and <c>Element</c> work exactly like <c>T</c>.\r\n/// </para>\r\n/// <para>\r\n/// The solver runs forward along the topological order, then backward for anything still open, then\r\n/// defaults what is left to <c>float</c>. Afterwards every <see cref=\"Port.ResolvedType\"/> is concrete\r\n/// and every edge has been classified, which is what lets the IR be built without a single type guess.\r\n/// </para>\r\n/// </summary>\r\npublic sealed class TypeSolver\r\n{\r\n\tconst string PassthroughGroup = \"\u0001passthrough\";\r\n\r\n\treadonly IPrismGraph _graph;\r\n\treadonly DiagnosticSink _diagnostics;\r\n\r\n\treadonly List<Slot> _slots = new();\r\n\treadonly Dictionary<(NodeId Node, string Name), int> _vars = new();\r\n\treadonly Dictionary<PortRef, Term> _terms = new();\r\n\r\n\tbool _failed;\r\n\r\n\t/// <summary>Build a solver for one graph.</summary>\r\n\tpublic TypeSolver( IPrismGraph graph, DiagnosticSink diagnostics )\r\n\t{\r\n\t\t_graph = graph;\r\n\t\t_diagnostics = diagnostics ?? new DiagnosticSink();\r\n\t}\r\n\r\n\t/// <summary>How many forward/backward sweeps to run before giving up on convergence.</summary>\r\n\tpublic int MaxIterations { get; set; } = 8;\r\n\r\n\t/// <summary>Write the solved types back onto <see cref=\"Port.ResolvedType\"/>. On by default.</summary>\r\n\tpublic bool ApplyToPorts { get; set; } = true;\r\n\r\n\t/// <summary>Report warnings for lossy and padded edge conversions. On by default.</summary>\r\n\tpublic bool ReportConversions { get; set; } = true;\r\n\r\n\t/// <summary>Solve a graph in one call.</summary>\r\n\tpublic static TypeSolution Solve( IPrismGraph graph, DiagnosticSink diagnostics ) =>\r\n\t\tnew TypeSolver( graph, diagnostics ).Solve();\r\n\r\n\t/// <summary>Run the solver.</summary>\r\n\tpublic TypeSolution Solve()\r\n\t{\r\n\t\tif ( _graph?.Nodes is null || _graph.Nodes.Count == 0 ) return TypeSolution.Empty;\r\n\r\n\t\tvar order = TopologicalOrder( _graph );\r\n\r\n\t\tSeed();\r\n\r\n\t\tvar edges = ValidEdges().ToArray();\r\n\r\n\t\tfor ( int pass = 0; pass < Math.Max( 1, MaxIterations ); pass++ )\r\n\t\t{\r\n\t\t\tvar changed = Forward( order, edges );\r\n\t\t\tchanged |= Backward( edges );\r\n\r\n\t\t\tif ( !changed ) break;\r\n\t\t}\r\n\r\n\t\tDefaultUnresolved();\r\n\r\n\t\tvar types = new Dictionary<PortRef, ShaderType>();\r\n\t\tvar unresolved = 0;\r\n\r\n\t\tforeach ( var node in _graph.Nodes )\r\n\t\t{\r\n\t\t\tif ( node is null ) continue;\r\n\r\n\t\t\tforeach ( var port in AllPorts( node ) )\r\n\t\t\t{\r\n\t\t\t\tvar key = new PortRef( node.Id, port.Id );\r\n\t\t\t\tvar type = Read( key );\r\n\r\n\t\t\t\tif ( type.IsVoid )\r\n\t\t\t\t{\r\n\t\t\t\t\ttype = ShaderType.Float;\r\n\t\t\t\t\tunresolved++;\r\n\r\n\t\t\t\t\tif ( !port.Def.IsGeneric )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\t// A concrete declaration that came back void means the spelling is unparseable.\r\n\t\t\t\t\t\t_diagnostics.Warn( DiagnosticCode.UnresolvedType,\r\n\t\t\t\t\t\t\t$\"Port '{port.DisplayName}' declares an unrecognised type '{port.DeclaredType}'; assuming float\",\r\n\t\t\t\t\t\t\tGraphRef.ForPort( node.Id, port.Id ) );\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\r\n\t\t\t\ttypes[key] = type;\r\n\r\n\t\t\t\tif ( ApplyToPorts ) port.ResolvedType = type;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tvar conversions = ClassifyEdges( edges, types );\r\n\r\n\t\treturn new TypeSolution( types, conversions, order, unresolved, !_failed );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Producers before consumers, cycles appended in document order. Kahn's algorithm, so a cyclic\r\n\t/// graph degrades into a stable-but-arbitrary order instead of hanging.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<NodeId> TopologicalOrder( IPrismGraph graph )\r\n\t{\r\n\t\tif ( graph?.Nodes is null ) return Array.Empty<NodeId>();\r\n\r\n\t\tvar indegree = new Dictionary<NodeId, int>();\r\n\t\tvar successors = new Dictionary<NodeId, List<NodeId>>();\r\n\r\n\t\tforeach ( var node in graph.Nodes )\r\n\t\t{\r\n\t\t\tif ( node is null ) continue;\r\n\r\n\t\t\tindegree.TryAdd( node.Id, 0 );\r\n\t\t\tsuccessors.TryAdd( node.Id, new List<NodeId>() );\r\n\t\t}\r\n\r\n\t\tforeach ( var edge in graph.Edges ?? Array.Empty<Edge>() )\r\n\t\t{\r\n\t\t\tif ( edge is null || !edge.IsValid ) continue;\r\n\t\t\tif ( !indegree.ContainsKey( edge.FromNode ) || !indegree.ContainsKey( edge.ToNode ) ) continue;\r\n\t\t\tif ( edge.FromNode == edge.ToNode ) continue;\r\n\r\n\t\t\tsuccessors[edge.FromNode].Add( edge.ToNode );\r\n\t\t\tindegree[edge.ToNode] = indegree[edge.ToNode] + 1;\r\n\t\t}\r\n\r\n\t\t// Seed in document order so the result is deterministic run to run.\r\n\t\tvar ready = new List<NodeId>();\r\n\r\n\t\tforeach ( var node in graph.Nodes )\r\n\t\t{\r\n\t\t\tif ( node is null ) continue;\r\n\t\t\tif ( indegree[node.Id] == 0 ) ready.Add( node.Id );\r\n\t\t}\r\n\r\n\t\tvar order = new List<NodeId>( indegree.Count );\r\n\t\tvar cursor = 0;\r\n\r\n\t\twhile ( cursor < ready.Count )\r\n\t\t{\r\n\t\t\tvar id = ready[cursor++];\r\n\t\t\torder.Add( id );\r\n\r\n\t\t\tforeach ( var next in successors[id] )\r\n\t\t\t{\r\n\t\t\t\tvar remaining = indegree[next] - 1;\r\n\t\t\t\tindegree[next] = remaining;\r\n\r\n\t\t\t\tif ( remaining == 0 ) ready.Add( next );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( order.Count < indegree.Count )\r\n\t\t{\r\n\t\t\tvar seen = new HashSet<NodeId>( order );\r\n\r\n\t\t\tforeach ( var node in graph.Nodes )\r\n\t\t\t{\r\n\t\t\t\tif ( node is null || seen.Contains( node.Id ) ) continue;\r\n\r\n\t\t\t\torder.Add( node.Id );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn order;\r\n\t}\r\n\r\n\t// ---- seeding ----------------------------------------------------------\r\n\r\n\tvoid Seed()\r\n\t{\r\n\t\tforeach ( var node in _graph.Nodes )\r\n\t\t{\r\n\t\t\tif ( node is null ) continue;\r\n\r\n\t\t\tforeach ( var port in AllPorts( node ) )\r\n\t\t\t{\r\n\t\t\t\t_terms[new PortRef( node.Id, port.Id )] = MakeTerm( node.Id, port );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tTerm MakeTerm( NodeId node, Port port )\r\n\t{\r\n\t\tvar declared = port.DeclaredType;\r\n\r\n\t\tif ( ( port.Flags & PortFlags.Passthrough ) != 0 )\r\n\t\t{\r\n\t\t\treturn Term.Variable( VarSlot( node, PassthroughGroup ), null );\r\n\t\t}\r\n\r\n\t\tif ( !TypeRules.IsTypeVariable( declared ) && ShaderType.TryParse( declared, out var concrete ) )\r\n\t\t{\r\n\t\t\treturn Term.Fixed( concrete );\r\n\t\t}\r\n\r\n\t\tvar text = ( declared ?? string.Empty ).Trim();\r\n\r\n\t\tif ( text.Length == 0 || text == TypeRules.TypeVarAny )\r\n\t\t{\r\n\t\t\treturn Term.Variable( VarSlot( node, PassthroughGroup ), null );\r\n\t\t}\r\n\r\n\t\tif ( text == TypeRules.TypeVarVecN )\r\n\t\t{\r\n\t\t\treturn Term.Variable( VarSlot( node, TypeRules.TypeVarVecN ), ScalarKind.Float );\r\n\t\t}\r\n\r\n\t\t// \"T.scalar\" \u2014 the component type of another variable on the same node.\r\n\t\tvar dot = text.IndexOf( '.' );\r\n\r\n\t\tif ( dot > 0 && text[( dot + 1 )..].Equals( \"scalar\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t{\r\n\t\t\treturn Term.ScalarOf( VarSlot( node, text[..dot] ) );\r\n\t\t}\r\n\r\n\t\t// \"float{N}\" \u2014 a vector of the shared width variable N, with the component kind pinned.\r\n\t\tvar open = text.IndexOf( '{' );\r\n\t\tvar close = text.IndexOf( '}' );\r\n\r\n\t\tif ( open > 0 && close > open + 1 )\r\n\t\t{\r\n\t\t\tvar prefix = text[..open];\r\n\t\t\tvar width = text[( open + 1 )..close];\r\n\t\t\tvar scalar = ShaderType.TryParse( prefix, out var prefixType ) && prefixType.IsNumeric\r\n\t\t\t\t? prefixType.Scalar\r\n\t\t\t\t: ScalarKind.Float;\r\n\r\n\t\t\treturn Term.Variable( VarSlot( node, width ), scalar );\r\n\t\t}\r\n\r\n\t\treturn Term.Variable( VarSlot( node, text ), null );\r\n\t}\r\n\r\n\tint VarSlot( NodeId node, string name )\r\n\t{\r\n\t\tvar key = (node, name ?? string.Empty);\r\n\r\n\t\tif ( _vars.TryGetValue( key, out var index ) ) return index;\r\n\r\n\t\tindex = _slots.Count;\r\n\t\t_slots.Add( new Slot() );\r\n\t\t_vars[key] = index;\r\n\r\n\t\treturn index;\r\n\t}\r\n\r\n\t// ---- propagation ------------------------------------------------------\r\n\r\n\tbool Forward( IReadOnlyList<NodeId> order, IReadOnlyList<Edge> edges )\r\n\t{\r\n\t\tvar incoming = new Dictionary<PortRef, List<Edge>>();\r\n\r\n\t\tforeach ( var edge in edges )\r\n\t\t{\r\n\t\t\tvar key = edge.To;\r\n\r\n\t\t\tif ( !incoming.TryGetValue( key, out var list ) )\r\n\t\t\t{\r\n\t\t\t\tlist = new List<Edge>();\r\n\t\t\t\tincoming[key] = list;\r\n\t\t\t}\r\n\r\n\t\t\tlist.Add( edge );\r\n\t\t}\r\n\r\n\t\tvar changed = false;\r\n\r\n\t\tforeach ( var id in order )\r\n\t\t{\r\n\t\t\tvar node = _graph.FindNode( id );\r\n\t\t\tif ( node is null ) continue;\r\n\r\n\t\t\tforeach ( var input in node.Inputs )\r\n\t\t\t{\r\n\t\t\t\tvar key = new PortRef( id, input.Id );\r\n\r\n\t\t\t\tif ( !incoming.TryGetValue( key, out var sources ) ) continue;\r\n\r\n\t\t\t\tforeach ( var edge in sources )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar produced = Read( edge.From );\r\n\t\t\t\t\tif ( produced.IsVoid ) continue;\r\n\r\n\t\t\t\t\tchanged |= Constrain( key, produced, GraphRef.ForPort( id, input.Id ), edge );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn changed;\r\n\t}\r\n\r\n\tbool Backward( IReadOnlyList<Edge> edges )\r\n\t{\r\n\t\tvar changed = false;\r\n\r\n\t\tfor ( int i = edges.Count - 1; i >= 0; i-- )\r\n\t\t{\r\n\t\t\tvar edge = edges[i];\r\n\t\t\tvar consumed = Read( edge.To );\r\n\r\n\t\t\tif ( consumed.IsVoid ) continue;\r\n\t\t\tif ( !Read( edge.From ).IsVoid ) continue;\r\n\r\n\t\t\tchanged |= Constrain( edge.From, consumed, GraphRef.ForPort( edge.FromNode, edge.FromPort ), edge );\r\n\t\t}\r\n\r\n\t\treturn changed;\r\n\t}\r\n\r\n\tvoid DefaultUnresolved()\r\n\t{\r\n\t\tfor ( int i = 0; i < _slots.Count; i++ )\r\n\t\t{\r\n\t\t\tvar root = _slots[i];\r\n\r\n\t\t\tif ( root.Type.IsVoid ) root.Type = ShaderType.Float;\r\n\t\t}\r\n\t}\r\n\r\n\t// ---- term access ------------------------------------------------------\r\n\r\n\tShaderType Read( PortRef port )\r\n\t{\r\n\t\tif ( !_terms.TryGetValue( port, out var term ) ) return ShaderType.Void;\r\n\r\n\t\tswitch ( term.Kind )\r\n\t\t{\r\n\t\t\tcase TermKind.Fixed:\r\n\t\t\t\treturn term.Concrete;\r\n\r\n\t\t\tcase TermKind.Variable:\r\n\t\t\t{\r\n\t\t\t\tvar type = _slots[term.Slot].Type;\r\n\r\n\t\t\t\tif ( type.IsVoid ) return ShaderType.Void;\r\n\r\n\t\t\t\treturn term.Force.HasValue && type.IsNumeric ? type.WithScalar( term.Force.Value ) : type;\r\n\t\t\t}\r\n\r\n\t\t\tcase TermKind.ScalarOf:\r\n\t\t\t{\r\n\t\t\t\tvar type = _slots[term.Slot].Type;\r\n\t\t\t\treturn type.IsVoid ? ShaderType.Void : type.ScalarType;\r\n\t\t\t}\r\n\r\n\t\t\tdefault:\r\n\t\t\t\treturn ShaderType.Void;\r\n\t\t}\r\n\t}\r\n\r\n\tbool Constrain( PortRef port, ShaderType incoming, GraphRef where, Edge edge )\r\n\t{\r\n\t\tif ( incoming.IsVoid ) return false;\r\n\t\tif ( !_terms.TryGetValue( port, out var term ) ) return false;\r\n\r\n\t\tswitch ( term.Kind )\r\n\t\t{\r\n\t\t\tcase TermKind.Fixed:\r\n\t\t\t\treturn false;\r\n\r\n\t\t\tcase TermKind.Variable:\r\n\t\t\t{\r\n\t\t\t\tvar wanted = term.Force.HasValue && incoming.IsNumeric\r\n\t\t\t\t\t? incoming.WithScalar( term.Force.Value )\r\n\t\t\t\t\t: incoming;\r\n\r\n\t\t\t\treturn Bind( term.Slot, wanted, where, edge );\r\n\t\t\t}\r\n\r\n\t\t\tcase TermKind.ScalarOf:\r\n\t\t\t{\r\n\t\t\t\tvar slot = _slots[term.Slot];\r\n\t\t\t\tvar wanted = slot.Type.IsVoid\r\n\t\t\t\t\t? incoming.ScalarType\r\n\t\t\t\t\t: slot.Type.WithScalar( TypeRules.PromoteScalar( slot.Type.Scalar, incoming.Scalar ) );\r\n\r\n\t\t\t\treturn Bind( term.Slot, wanted, where, edge );\r\n\t\t\t}\r\n\r\n\t\t\tdefault:\r\n\t\t\t\treturn false;\r\n\t\t}\r\n\t}\r\n\r\n\tbool Bind( int slotIndex, ShaderType incoming, GraphRef where, Edge edge )\r\n\t{\r\n\t\tif ( incoming.IsVoid ) return false;\r\n\r\n\t\tvar slot = _slots[slotIndex];\r\n\r\n\t\tif ( slot.Type.IsVoid )\r\n\t\t{\r\n\t\t\tslot.Type = incoming;\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tif ( slot.Type == incoming ) return false;\r\n\r\n\t\tif ( !TypeRules.Unify( slot.Type, incoming, out var unified ) )\r\n\t\t{\r\n\t\t\tif ( slot.Failed ) return false;\r\n\r\n\t\t\tslot.Failed = true;\r\n\t\t\t_failed = true;\r\n\r\n\t\t\tvar detail = edge is null ? null : $\"Connection {edge}\";\r\n\r\n\t\t\t_diagnostics.Error( DiagnosticCode.UnificationFailure,\r\n\t\t\t\t$\"Cannot reconcile {slot.Type.Hlsl} and {incoming.Hlsl} on the same generic port group\",\r\n\t\t\t\twhere, detail );\r\n\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tif ( unified == slot.Type ) return false;\r\n\r\n\t\tslot.Type = unified;\r\n\t\treturn true;\r\n\t}\r\n\r\n\t// ---- edges ------------------------------------------------------------\r\n\r\n\tIEnumerable<Edge> ValidEdges()\r\n\t{\r\n\t\tforeach ( var edge in _graph.Edges ?? Array.Empty<Edge>() )\r\n\t\t{\r\n\t\t\tif ( edge is null || !edge.IsValid ) continue;\r\n\t\t\tif ( !_terms.ContainsKey( edge.From ) || !_terms.ContainsKey( edge.To ) ) continue;\r\n\r\n\t\t\tyield return edge;\r\n\t\t}\r\n\t}\r\n\r\n\tDictionary<EdgeId, ConversionKind> ClassifyEdges( IReadOnlyList<Edge> edges,\r\n\t\tIReadOnlyDictionary<PortRef, ShaderType> types )\r\n\t{\r\n\t\tvar conversions = new Dictionary<EdgeId, ConversionKind>();\r\n\r\n\t\tforeach ( var edge in edges )\r\n\t\t{\r\n\t\t\tif ( !types.TryGetValue( edge.From, out var from ) ) continue;\r\n\t\t\tif ( !types.TryGetValue( edge.To, out var to ) ) continue;\r\n\r\n\t\t\tvar kind = TypeRules.Classify( from, to );\r\n\t\t\tconversions[edge.Id] = kind;\r\n\r\n\t\t\tif ( !ReportConversions ) continue;\r\n\r\n\t\t\tvar where = new GraphRef( edge.ToNode, edge.ToPort, edge.Id );\r\n\r\n\t\t\tswitch ( kind )\r\n\t\t\t{\r\n\t\t\t\tcase ConversionKind.Illegal:\r\n\t\t\t\t\t_failed = true;\r\n\t\t\t\t\t_diagnostics.Error( DiagnosticCode.IllegalConversion,\r\n\t\t\t\t\t\t$\"{from.Hlsl} cannot connect to {to.Hlsl}\", where,\r\n\t\t\t\t\t\tTypeRules.Describe( from, to, kind ) );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase ConversionKind.Truncate:\r\n\t\t\t\t\t_diagnostics.Warn( DiagnosticCode.LossyConversion,\r\n\t\t\t\t\t\t$\"{from.Hlsl} narrows to {to.Hlsl}\", where,\r\n\t\t\t\t\t\tTypeRules.Describe( from, to, kind ) );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase ConversionKind.Pad:\r\n\t\t\t\t\tvar fill = edge.Fill ?? TypeRules.DefaultFill( from, to, to.Components - 1 );\r\n\t\t\t\t\t_diagnostics.Warn( DiagnosticCode.PaddedConversion,\r\n\t\t\t\t\t\t$\"{from.Hlsl} widens to {to.Hlsl}, filling with {fill}\", where,\r\n\t\t\t\t\t\tTypeRules.Describe( from, to, kind ) );\r\n\t\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn conversions;\r\n\t}\r\n\r\n\tstatic IEnumerable<Port> AllPorts( PrismNode node )\r\n\t{\r\n\t\tforeach ( var input in node.Inputs ) yield return input;\r\n\t\tforeach ( var output in node.Outputs ) yield return output;\r\n\t}\r\n\r\n\tenum TermKind\r\n\t{\r\n\t\tFixed,\r\n\t\tVariable,\r\n\t\tScalarOf\r\n\t}\r\n\r\n\treadonly struct Term\r\n\t{\r\n\t\tTerm( TermKind kind, ShaderType concrete, int slot, ScalarKind? force )\r\n\t\t{\r\n\t\t\tKind = kind;\r\n\t\t\tConcrete = concrete;\r\n\t\t\tSlot = slot;\r\n\t\t\tForce = force;\r\n\t\t}\r\n\r\n\t\tpublic TermKind Kind { get; }\r\n\t\tpublic ShaderType Concrete { get; }\r\n\t\tpublic int Slot { get; }\r\n\t\tpublic ScalarKind? Force { get; }\r\n\r\n\t\tpublic static Term Fixed( ShaderType type ) => new( TermKind.Fixed, type, -1, null );\r\n\t\tpublic static Term Variable( int slot, ScalarKind? force ) => new( TermKind.Variable, ShaderType.Void, slot, force );\r\n\t\tpublic static Term ScalarOf( int slot ) => new( TermKind.ScalarOf, ShaderType.Void, slot, null );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// One type variable's current binding.\r\n\t/// <para>\r\n\t/// There is deliberately no union-find here. Prism's type algebra is per-(node, variable name):\r\n\t/// <c>VarSlot</c> mints one slot for each and nothing ever merges two, because a constraint that\r\n\t/// spans nodes is expressed by propagating a concrete type along an edge rather than by equating two\r\n\t/// variables. The class used to carry a <c>Parent</c> field and a path-compressing <c>Find</c> that\r\n\t/// could only ever return its own argument \u2014 it read as Hindley-Milner and behaved as a lookup, and\r\n\t/// a later pass adding a real cross-node constraint would have assumed the merging worked. If one is\r\n\t/// ever needed, add <c>Union</c> and <c>Find</c> together.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tsealed class Slot\r\n\t{\r\n\t\tpublic ShaderType Type;\r\n\t\tpublic bool Failed;\r\n\t}\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Integration/CodeFileEditor.cs",
"FileName": "CodeFileEditor.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Core;\r\nusing System.IO;\r\n\r\nnamespace Editor.Prism.Integration;\r\n\r\n/// <summary>\r\n/// Which files Prism's code window is willing to own.\r\n/// <para>\r\n/// Shader sources only. C#, Razor and SCSS belong to a real IDE and Prism never claims them, which is\r\n/// what makes it safe to let Prism act as the editor-wide code editor.\r\n/// </para>\r\n/// </summary>\r\npublic static class PrismShaderFiles\r\n{\r\n\t/// <summary>Extensions, without the leading dot, that Prism opens as shader source.</summary>\r\n\tpublic static readonly IReadOnlyList<string> Extensions = new[]\r\n\t{\r\n\t\tPrismConstants.ShaderExtension, // shader \u2014 the engine's VFX block format\r\n\t\tPrismConstants.HlslExtension, // hlsl\r\n\t\t\"hlsli\",\r\n\t\t\"fxc\",\r\n\t\tPrismConstants.SlangExtension, // slang\r\n\t\t\"slangh\",\r\n\t\t\"vfx\"\r\n\t};\r\n\r\n\t/// <summary>True when Prism's code window is the right place for this path.</summary>\r\n\tpublic static bool IsShaderSource( string path )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( path ) ) return false;\r\n\r\n\t\tvar extension = Path.GetExtension( path );\r\n\r\n\t\tif ( string.IsNullOrEmpty( extension ) ) return false;\r\n\r\n\t\textension = extension.TrimStart( '.' );\r\n\r\n\t\tforeach ( var candidate in Extensions )\r\n\t\t{\r\n\t\t\tif ( extension.Equals( candidate, StringComparison.OrdinalIgnoreCase ) ) return true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\t/// <summary>A name filter string suitable for <see cref=\"FileDialog.SetNameFilter\"/>.</summary>\r\n\tpublic static string NameFilter =>\r\n\t\t\"Shader Source (\" + string.Join( \" \", Extensions.Select( x => $\"*.{x}\" ) ) + \")\";\r\n}\r\n\r\n/// <summary>\r\n/// Prism as an editor-wide code editor, offered but never imposed.\r\n/// <para>\r\n/// Any type implementing <c>ICodeEditor</c> is listed in <i>Editor Settings \u25b8 General \u25b8 Code Editor</i>\r\n/// automatically, so this shows up as a choice the moment the assembly loads. Selecting it routes\r\n/// shader sources into Prism's code window; everything else \u2014 C#, Razor, SCSS, solutions, addons \u2014 is\r\n/// handed straight to whichever editor was selected before, so picking Prism never costs you your IDE.\r\n/// </para>\r\n/// </summary>\r\n[Title( \"Prism\" ), Icon( \"gradient\" )]\r\npublic sealed class PrismCodeEditor : ICodeEditor\r\n{\r\n\t/// <summary>\r\n\t/// Always available: it ships inside the editor assembly, so unlike an external IDE there is\r\n\t/// nothing to find on disk. Note that selecting it only takes over <em>shader</em> sources \u2014\r\n\t/// everything else is forwarded to <see cref=\"CodeFileEditor.Fallback\"/>, which is why this is not\r\n\t/// gated on one existing.\r\n\t/// </summary>\r\n\tpublic bool IsInstalled() => true;\r\n\r\n\t/// <summary>Shader sources open in Prism; everything else goes to the fallback editor.</summary>\r\n\tpublic void OpenFile( string path, int? line = null, int? column = null )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( path ) ) return;\r\n\r\n\t\tif ( PrismShaderFiles.IsShaderSource( path ) )\r\n\t\t{\r\n\t\t\tPrismLauncher.OpenCode( path, line ?? 0, column ?? 1 );\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar fallback = CodeFileEditor.Fallback;\r\n\r\n\t\tif ( fallback is null )\r\n\t\t{\r\n\t\t\t// Prism is a shader editor; a .cs file has to go somewhere else. Saying so beats a\r\n\t\t\t// double-click that appears to do nothing at all.\r\n\t\t\tPrismLog.Warn( $\"Prism cannot open '{Path.GetFileName( path )}' \u2014 it edits shader sources \" +\r\n\t\t\t\t\"only, and no other code editor is available to hand it to. Pick one in \" +\r\n\t\t\t\t\"Editor Settings \u25b8 Code Editor.\" );\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tfallback.OpenFile( path, line, column );\r\n\t}\r\n\r\n\t/// <summary>Prism has no notion of a solution. Delegated.</summary>\r\n\tpublic void OpenSolution() => CodeFileEditor.Fallback?.OpenSolution();\r\n\r\n\t/// <summary>Prism has no notion of an addon workspace. Delegated.</summary>\r\n\tpublic void OpenAddon( Project addon ) => CodeFileEditor.Fallback?.OpenAddon( addon );\r\n}\r\n\r\n/// <summary>\r\n/// Routes shader text files into Prism's code window.\r\n/// <para>\r\n/// Three separate paths reach a text file in this editor, and none of them can be intercepted the\r\n/// same way:\r\n/// </para>\r\n/// <list type=\"number\">\r\n/// <item><description><c>.shader</c> is a native asset type whose <c>OpenInEditor</c> short-circuits\r\n/// to <c>EditorEvent.Run( \"open.shader\", path )</c> before <c>IAssetEditor</c> is ever consulted, so\r\n/// the only hook is the event \u2014 which is multicast and uncancellable, meaning the tools addon still\r\n/// launches VS Code alongside us if it is installed. Hence the preference.</description></item>\r\n/// <item><description><c>.hlsl</c> and <c>.slang</c> cannot be registered as asset types at all; they\r\n/// arrive as plain files through the asset browser's <c>OnFileSelected</c> delegate, which we chain\r\n/// rather than replace.</description></item>\r\n/// <item><description>Anything routed through <c>CodeEditor.OpenFile</c> reaches\r\n/// <see cref=\"PrismCodeEditor\"/>, but only if the user opted in.</description></item>\r\n/// </list>\r\n/// </summary>\r\npublic static class CodeFileEditor\r\n{\r\n\tstatic ICodeEditor s_fallback;\r\n\tstatic Action<string> s_previousFileSelected;\r\n\tstatic AssetBrowser s_routedBrowser;\r\n\r\n\t/// <summary>\r\n\t/// The editor Prism hands non-shader files to. Resolved lazily, cached until hotload, and never\r\n\t/// resolves to Prism itself.\r\n\t/// </summary>\r\n\tpublic static ICodeEditor Fallback\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\ts_fallback ??= ResolveFallback();\r\n\r\n\t\t\treturn s_fallback;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Friendly name of the fallback editor, for the preferences page.</summary>\r\n\tpublic static string FallbackTitle =>\r\n\t\tPrismLog.Guard( \"Describing the fallback code editor\",\r\n\t\t\t() => Fallback?.Title, null ) ?? \"no external editor\";\r\n\r\n\t/// <summary>True when Prism is currently the editor-wide code editor.</summary>\r\n\tpublic static bool IsCurrentCodeEditor =>\r\n\t\tPrismLog.Guard( \"Reading the current code editor\",\r\n\t\t\t() => CodeEditor.Current is PrismCodeEditor, false );\r\n\r\n\t// ---- the .shader event ------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Double-clicking a <c>.shader</c> lands here. Runs early so Prism is up before any external\r\n\t/// editor steals focus.\r\n\t/// </summary>\r\n\t[Event( \"open.shader\", Priority = -100 )]\r\n\tpublic static void OnOpenShader( string absolutePath )\r\n\t{\r\n\t\tif ( !PrismCookies.ClaimShaderFiles ) return;\r\n\t\tif ( string.IsNullOrWhiteSpace( absolutePath ) ) return;\r\n\r\n\t\tPrismLauncher.OpenCode( absolutePath );\r\n\t}\r\n\r\n\t// ---- asset browser routing --------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Chain ourselves onto the asset browser's plain-file handler, so an unregistered\r\n\t/// <c>.hlsl</c>/<c>.slang</c> opens in Prism instead of the operating system's shell handler.\r\n\t/// <para>\r\n\t/// Idempotent, and safe to call every frame: it re-installs if the browser is recreated or if\r\n\t/// something else has overwritten the delegate since.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic static void EnsureAssetBrowserRouting()\r\n\t{\r\n\t\tPrismLog.Guard( \"Routing plain files through Prism\", () =>\r\n\t\t{\r\n\t\t\tvar local = MainAssetBrowser.Instance?.Local;\r\n\r\n\t\t\tif ( local is null || !local.IsValid ) return;\r\n\t\t\tif ( ReferenceEquals( s_routedBrowser, local ) && IsOurs( local.OnFileSelected ) ) return;\r\n\r\n\t\t\tvar previous = local.OnFileSelected;\r\n\r\n\t\t\t// Never chain to ourselves \u2014 after a hotload the delegate sitting there is our own\r\n\t\t\t// handler from the outgoing assembly, and chaining would grow a new link every reload.\r\n\t\t\ts_previousFileSelected = IsOurs( previous ) ? null : previous;\r\n\t\t\ts_routedBrowser = local;\r\n\t\t\tlocal.OnFileSelected = OnFileSelected;\r\n\t\t} );\r\n\t}\r\n\r\n\t/// <summary>Hand the plain-file handler back to whoever had it. Called when the preference goes off.</summary>\r\n\tpublic static void RemoveAssetBrowserRouting()\r\n\t{\r\n\t\tPrismLog.Guard( \"Restoring the asset browser file handler\", () =>\r\n\t\t{\r\n\t\t\tvar local = MainAssetBrowser.Instance?.Local;\r\n\r\n\t\t\tif ( local is null || !local.IsValid ) return;\r\n\t\t\tif ( !IsOurs( local.OnFileSelected ) ) return;\r\n\r\n\t\t\tlocal.OnFileSelected = s_previousFileSelected ?? ( f => EditorUtility.OpenFile( f ) );\r\n\t\t\ts_routedBrowser = null;\r\n\t\t\ts_previousFileSelected = null;\r\n\t\t} );\r\n\t}\r\n\r\n\tstatic void OnFileSelected( string absolutePath )\r\n\t{\r\n\t\tif ( PrismCookies.ClaimShaderFiles && PrismShaderFiles.IsShaderSource( absolutePath ) )\r\n\t\t{\r\n\t\t\tPrismLauncher.OpenCode( absolutePath );\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( PrismAssetEditor.IsPrismDocument( absolutePath ) )\r\n\t\t{\r\n\t\t\tPrismAssetEditor.Open( absolutePath );\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( s_previousFileSelected is not null )\r\n\t\t{\r\n\t\t\ts_previousFileSelected( absolutePath );\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Same behaviour MainAssetBrowser installs by default.\r\n\t\tPrismLog.Guard( \"Opening a file with the shell handler\", () => EditorUtility.OpenFile( absolutePath ) );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// A delegate is one of ours when it was declared on this type \u2014 compared by full name, so it\r\n\t/// still matches an instance left behind by the previous assembly.\r\n\t/// </summary>\r\n\tstatic bool IsOurs( Action<string> handler )\r\n\t{\r\n\t\tvar declaring = handler?.Method?.DeclaringType;\r\n\r\n\t\treturn declaring is not null\r\n\t\t\t&& string.Equals( declaring.FullName, typeof( CodeFileEditor ).FullName, StringComparison.Ordinal );\r\n\t}\r\n\r\n\t// ---- the editor-wide code editor preference ---------------------------\r\n\r\n\tstatic bool s_reconciling;\r\n\r\n\t/// <summary>\r\n\t/// Startup reconciliation.\r\n\t/// <para>\r\n\t/// If the user picked Prism directly in <i>Editor Settings \u25b8 Code Editor</i>, that choice wins and\r\n\t/// the preference is updated to match \u2014 reverting it would be the tool arguing with the person\r\n\t/// using it. Otherwise the preference is applied.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic static void ApplyCodeEditorPreference()\r\n\t{\r\n\t\tPrismLog.Guard( \"Applying the Prism code editor preference\", () =>\r\n\t\t{\r\n\t\t\t// Read the raw cookie rather than CodeEditor.Current: the getter instantiates the selected\r\n\t\t\t// editor and probes the filesystem and registry for it, and no editor session should pay\r\n\t\t\t// that at startup just because Prism happens to be installed.\r\n\t\t\tvar selected = EditorCookie?.GetString( CodeEditorCookie, null );\r\n\r\n\t\t\tif ( string.Equals( selected, nameof( PrismCodeEditor ), StringComparison.Ordinal ) )\r\n\t\t\t{\r\n\t\t\t\tif ( !PrismCookies.RouteCodeFiles ) PrismCookies.RouteCodeFiles = true;\r\n\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tif ( !PrismCookies.RouteCodeFiles ) return;\r\n\r\n\t\t\tReconcileCodeEditorPreference();\r\n\t\t} );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The engine's own key for the selected code editor. Hard-coded in <c>CodeEditor.Current</c>, and\r\n\t/// stored as the implementing type's short name.\r\n\t/// </summary>\r\n\tconst string CodeEditorCookie = \"CodeEditor\";\r\n\r\n\t/// <summary>\r\n\t/// Make <c>CodeEditor.Current</c> agree with <see cref=\"PrismCookies.RouteCodeFiles\"/>.\r\n\t/// <para>\r\n\t/// Turning it on remembers whatever was selected before, so turning it off puts that back rather\r\n\t/// than leaving the editor with no code editor at all. Subscribed to\r\n\t/// <see cref=\"PrismCookies.Changed\"/>, so flipping the toggle in the preferences page takes effect\r\n\t/// immediately.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic static void ReconcileCodeEditorPreference()\r\n\t{\r\n\t\tif ( s_reconciling ) return;\r\n\r\n\t\ts_reconciling = true;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tPrismLog.Guard( \"Reconciling the Prism code editor preference\", () =>\r\n\t\t\t{\r\n\t\t\t\tvar current = CodeEditor.Current;\r\n\t\t\t\tvar wanted = PrismCookies.RouteCodeFiles;\r\n\r\n\t\t\t\tif ( wanted )\r\n\t\t\t\t{\r\n\t\t\t\t\t// Compared by full name, not with `is`. CodeEditor.Current is cached in a private\r\n\t\t\t\t\t// static on Sandbox.Tools, which does not hotload, so after the editor assembly is\r\n\t\t\t\t\t// swapped that field still holds a PrismCodeEditor from the OUTGOING assembly \u2014 a\r\n\t\t\t\t\t// different Type identity, so `is` says false. The old code then recorded\r\n\t\t\t\t\t// \"PrismCodeEditor\" as the user's fallback IDE, permanently, and ResolveFallback\r\n\t\t\t\t\t// excludes PrismCodeEditor by type, so the remembered name could never match again\r\n\t\t\t\t\t// and the user silently got whichever of VisualStudio/VSCode/Rider probed first.\r\n\t\t\t\t\tif ( IsPrism( current ) ) return;\r\n\r\n\t\t\t\t\tif ( current is not null )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tPrismCookies.FallbackCodeEditor = current.GetType().Name;\r\n\t\t\t\t\t\ts_fallback = current;\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tCodeEditor.Current = new PrismCodeEditor();\r\n\r\n\t\t\t\t\treturn;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif ( !IsPrism( current ) ) return;\r\n\r\n\t\t\t\tvar restored = Fallback;\r\n\r\n\t\t\t\tif ( restored is not null ) CodeEditor.Current = restored;\r\n\t\t\t} );\r\n\t\t}\r\n\t\tfinally\r\n\t\t{\r\n\t\t\ts_reconciling = false;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Whether an <c>ICodeEditor</c> is Prism's, judged by full type name rather than by type identity.\r\n\t/// A hotload leaves an instance of the outgoing assembly's <c>PrismCodeEditor</c> in a static that\r\n\t/// does not hotload, and that instance fails <c>is PrismCodeEditor</c> against the new type.\r\n\t/// </summary>\r\n\tstatic bool IsPrism( ICodeEditor editor ) =>\r\n\t\teditor is not null &&\r\n\t\tstring.Equals( editor.GetType().FullName, typeof( PrismCodeEditor ).FullName, StringComparison.Ordinal );\r\n\r\n\t/// <summary>Drop the cached fallback so it is resolved again after a hotload or a settings change.</summary>\r\n\tpublic static void FlushFallback()\r\n\t{\r\n\t\ts_fallback = null;\r\n\t}\r\n\r\n\t/// <summary>Forget the chained delegate \u2014 it points into the outgoing assembly after a hotload.</summary>\r\n\tpublic static void ForgetRouting()\r\n\t{\r\n\t\ts_previousFileSelected = null;\r\n\t\ts_routedBrowser = null;\r\n\t}\r\n\r\n\tstatic ICodeEditor ResolveFallback()\r\n\t{\r\n\t\treturn PrismLog.Guard( \"Resolving the fallback code editor\", () =>\r\n\t\t{\r\n\t\t\tvar types = EditorTypeLibrary.GetTypes<ICodeEditor>()\r\n\t\t\t\t.Where( x => !x.IsInterface && !x.IsAbstract )\r\n\t\t\t\t.Where( x => x.TargetType != typeof( PrismCodeEditor ) )\r\n\t\t\t\t.ToList();\r\n\r\n\t\t\tICodeEditor Instantiate( TypeDescription type )\r\n\t\t\t{\r\n\t\t\t\tvar editor = type?.Create<ICodeEditor>();\r\n\r\n\t\t\t\treturn editor is not null && editor.IsInstalled() ? editor : null;\r\n\t\t\t}\r\n\r\n\t\t\tvar remembered = PrismCookies.FallbackCodeEditor;\r\n\r\n\t\t\tif ( !string.IsNullOrWhiteSpace( remembered ) )\r\n\t\t\t{\r\n\t\t\t\tvar match = Instantiate( types.FirstOrDefault( x => x.Name == remembered ) );\r\n\r\n\t\t\t\tif ( match is not null ) return match;\r\n\t\t\t}\r\n\r\n\t\t\tforeach ( var preferred in new[] { \"VisualStudio\", \"VisualStudioCode\", \"Rider\" } )\r\n\t\t\t{\r\n\t\t\t\tvar match = Instantiate( types.FirstOrDefault( x => x.Name == preferred ) );\r\n\r\n\t\t\t\tif ( match is not null ) return match;\r\n\t\t\t}\r\n\r\n\t\t\tforeach ( var type in types )\r\n\t\t\t{\r\n\t\t\t\tvar match = Instantiate( type );\r\n\r\n\t\t\t\tif ( match is not null ) return match;\r\n\t\t\t}\r\n\r\n\t\t\treturn null;\r\n\t\t}, null );\r\n\t}\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Model/GraphQueries.cs",
"FileName": "GraphQueries.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Core;\r\n\r\nnamespace Editor.Prism.Model;\r\n\r\n/// <summary>\r\n/// Read-only analysis of a document: reachability, topological order, real cycle detection,\r\n/// dependency subtrees and orphan detection.\r\n/// <para>\r\n/// Every traversal here is iterative rather than recursive, so a pathological graph produces a\r\n/// diagnostic instead of a stack overflow, and <b>every traversal includes reroute nodes</b>. The\r\n/// built-in editor exempts reroutes from its cycle check, which is why a reroute loop can hang it;\r\n/// treating every node identically is both simpler and correct.\r\n/// </para>\r\n/// </summary>\r\npublic static class GraphQueries\r\n{\r\n\t/// <summary>The node a port reference points at. Null when it does not resolve.</summary>\r\n\tpublic static PrismNode NodeOf( IPrismGraph graph, PortRef reference ) => graph?.FindNode( reference.Node );\r\n\r\n\t/// <summary>Resolve a port reference to a live port.</summary>\r\n\tpublic static bool TryGetPort( IPrismGraph graph, PortRef reference, out Port port )\r\n\t{\r\n\t\tport = graph?.FindNode( reference.Node )?.FindPort( reference.Port );\r\n\t\treturn port is not null;\r\n\t}\r\n\r\n\t/// <summary>Every edge terminating on a node.</summary>\r\n\tpublic static IEnumerable<Edge> IncomingEdges( IPrismGraph graph, NodeId node )\r\n\t{\r\n\t\tif ( graph?.Edges is null ) yield break;\r\n\r\n\t\tforeach ( var edge in graph.Edges )\r\n\t\t{\r\n\t\t\tif ( edge is not null && edge.ToNode == node ) yield return edge;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Every edge leaving a node.</summary>\r\n\tpublic static IEnumerable<Edge> OutgoingEdges( IPrismGraph graph, NodeId node )\r\n\t{\r\n\t\tif ( graph?.Edges is null ) yield break;\r\n\r\n\t\tforeach ( var edge in graph.Edges )\r\n\t\t{\r\n\t\t\tif ( edge is not null && edge.FromNode == node ) yield return edge;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Every node that directly feeds this one.</summary>\r\n\tpublic static IEnumerable<NodeId> Predecessors( IPrismGraph graph, NodeId node ) =>\r\n\t\tIncomingEdges( graph, node ).Select( x => x.FromNode ).Distinct();\r\n\r\n\t/// <summary>Every node this one directly feeds.</summary>\r\n\tpublic static IEnumerable<NodeId> Successors( IPrismGraph graph, NodeId node ) =>\r\n\t\tOutgoingEdges( graph, node ).Select( x => x.ToNode ).Distinct();\r\n\r\n\t/// <summary>\r\n\t/// The nodes a compile starts from: registered output nodes when there are any, otherwise every\r\n\t/// node with no outgoing edge. The fallback is what makes a half-built graph still previewable.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<NodeId> OutputNodes( IPrismGraph graph )\r\n\t{\r\n\t\tif ( graph?.Nodes is null ) return Array.Empty<NodeId>();\r\n\r\n\t\tvar outputs = new List<NodeId>();\r\n\r\n\t\tforeach ( var node in graph.Nodes )\r\n\t\t{\r\n\t\t\tif ( node is null ) continue;\r\n\t\t\tif ( !IsOutputNode( node ) ) continue;\r\n\r\n\t\t\toutputs.Add( node.Id );\r\n\t\t}\r\n\r\n\t\tif ( outputs.Count > 0 ) return outputs;\r\n\r\n\t\treturn TerminalNodes( graph );\r\n\t}\r\n\r\n\t/// <summary>True when a node looks like a graph terminal: an output-category node with no outputs.</summary>\r\n\tpublic static bool IsOutputNode( PrismNode node )\r\n\t{\r\n\t\tif ( node is null ) return false;\r\n\t\tif ( node.Outputs.Count > 0 ) return false;\r\n\r\n\t\tvar id = node.Descriptor?.Id;\r\n\r\n\t\tif ( !string.IsNullOrEmpty( id ) && id.StartsWith( \"prism.output.\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t{\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tvar category = node.Descriptor?.Category;\r\n\r\n\t\treturn !string.IsNullOrEmpty( category ) &&\r\n\t\t\tcategory.StartsWith( \"Output\", StringComparison.OrdinalIgnoreCase );\r\n\t}\r\n\r\n\t/// <summary>Every node with no outgoing edge.</summary>\r\n\tpublic static IReadOnlyList<NodeId> TerminalNodes( IPrismGraph graph )\r\n\t{\r\n\t\tif ( graph?.Nodes is null ) return Array.Empty<NodeId>();\r\n\r\n\t\tvar hasOutgoing = new HashSet<NodeId>();\r\n\r\n\t\tforeach ( var edge in graph.Edges ?? Array.Empty<Edge>() )\r\n\t\t{\r\n\t\t\tif ( edge is not null ) hasOutgoing.Add( edge.FromNode );\r\n\t\t}\r\n\r\n\t\tvar result = new List<NodeId>();\r\n\r\n\t\tforeach ( var node in graph.Nodes )\r\n\t\t{\r\n\t\t\tif ( node is null || hasOutgoing.Contains( node.Id ) ) continue;\r\n\r\n\t\t\tresult.Add( node.Id );\r\n\t\t}\r\n\r\n\t\treturn result;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Every node reachable by walking <em>backwards</em> from the given roots \u2014 that is, everything\r\n\t/// that contributes to the roots' values. Disabled nodes stop the walk, because a disabled node\r\n\t/// falls back to inline values and its inputs are not evaluated.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyCollection<NodeId> Reachable( IPrismGraph graph, IEnumerable<NodeId> roots,\r\n\t\tbool stopAtDisabled = true )\r\n\t{\r\n\t\tvar visited = new HashSet<NodeId>();\r\n\r\n\t\tif ( graph is null || roots is null ) return visited;\r\n\r\n\t\tvar stack = new Stack<NodeId>();\r\n\r\n\t\tforeach ( var root in roots )\r\n\t\t{\r\n\t\t\tif ( root.IsValid && visited.Add( root ) ) stack.Push( root );\r\n\t\t}\r\n\r\n\t\tvar incoming = BuildIncomingMap( graph );\r\n\r\n\t\twhile ( stack.Count > 0 )\r\n\t\t{\r\n\t\t\tvar current = stack.Pop();\r\n\r\n\t\t\tif ( stopAtDisabled && IsDisabled( graph, current ) ) continue;\r\n\t\t\tif ( !incoming.TryGetValue( current, out var sources ) ) continue;\r\n\r\n\t\t\tforeach ( var source in sources )\r\n\t\t\t{\r\n\t\t\t\tif ( visited.Add( source ) ) stack.Push( source );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn visited;\r\n\t}\r\n\r\n\t/// <summary>Every node reachable backwards from the graph's output nodes.</summary>\r\n\tpublic static IReadOnlyCollection<NodeId> ReachableFromOutputs( IPrismGraph graph ) =>\r\n\t\tReachable( graph, OutputNodes( graph ) );\r\n\r\n\t/// <summary>\r\n\t/// Nodes that contribute to nothing: not reachable backwards from any output and not an output\r\n\t/// themselves. Purely informational \u2014 an orphan is a perfectly legal work in progress.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<NodeId> Orphans( IPrismGraph graph, IEnumerable<NodeId> roots = null )\r\n\t{\r\n\t\tif ( graph?.Nodes is null ) return Array.Empty<NodeId>();\r\n\r\n\t\tvar reachable = Reachable( graph, roots ?? OutputNodes( graph ), false );\r\n\t\tvar result = new List<NodeId>();\r\n\r\n\t\tforeach ( var node in graph.Nodes )\r\n\t\t{\r\n\t\t\tif ( node is null || reachable.Contains( node.Id ) ) continue;\r\n\r\n\t\t\tresult.Add( node.Id );\r\n\t\t}\r\n\r\n\t\treturn result;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Every node the given node depends on, including itself, in dependency-first order. This is the\r\n\t/// subtree a \"compile just this node\" preview needs.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<NodeId> DependencySubtree( IPrismGraph graph, NodeId node )\r\n\t{\r\n\t\tvar subtree = Reachable( graph, new[] { node }, false );\r\n\r\n\t\treturn TopologicalOrder( graph, new[] { node } ).Where( subtree.Contains ).ToArray();\r\n\t}\r\n\r\n\t/// <summary>Every node that depends, directly or transitively, on the given node.</summary>\r\n\tpublic static IReadOnlyList<NodeId> Dependents( IPrismGraph graph, NodeId node )\r\n\t{\r\n\t\tvar visited = new HashSet<NodeId>();\r\n\r\n\t\tif ( graph is null || !node.IsValid ) return Array.Empty<NodeId>();\r\n\r\n\t\tvar outgoing = BuildOutgoingMap( graph );\r\n\t\tvar stack = new Stack<NodeId>();\r\n\t\tstack.Push( node );\r\n\r\n\t\twhile ( stack.Count > 0 )\r\n\t\t{\r\n\t\t\tvar current = stack.Pop();\r\n\r\n\t\t\tif ( !outgoing.TryGetValue( current, out var targets ) ) continue;\r\n\r\n\t\t\tforeach ( var target in targets )\r\n\t\t\t{\r\n\t\t\t\tif ( visited.Add( target ) ) stack.Push( target );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn visited.ToArray();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Dependency-first order over the nodes reachable from <paramref name=\"roots\"/>, or over the whole\r\n\t/// document when roots are omitted. Nodes involved in a cycle are appended at the end rather than\r\n\t/// dropped, so a cyclic graph still produces a usable ordering for the UI.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<NodeId> TopologicalOrder( IPrismGraph graph, IEnumerable<NodeId> roots = null )\r\n\t{\r\n\t\tif ( graph?.Nodes is null ) return Array.Empty<NodeId>();\r\n\r\n\t\tvar scope = roots is null\r\n\t\t\t? new HashSet<NodeId>( graph.Nodes.Where( x => x is not null ).Select( x => x.Id ) )\r\n\t\t\t: new HashSet<NodeId>( Reachable( graph, roots, false ) );\r\n\r\n\t\tif ( scope.Count == 0 ) return Array.Empty<NodeId>();\r\n\r\n\t\tvar incoming = BuildIncomingMap( graph );\r\n\t\tvar order = new List<NodeId>( scope.Count );\r\n\t\tvar state = new Dictionary<NodeId, byte>( scope.Count );\r\n\r\n\t\t// Iterative post-order DFS. 0 = unvisited, 1 = on the stack (grey), 2 = emitted (black).\r\n\t\tvar work = new Stack<(NodeId Node, int Index)>();\r\n\r\n\t\tforeach ( var root in Ordered( graph, scope ) )\r\n\t\t{\r\n\t\t\tif ( state.TryGetValue( root, out var seen ) && seen == 2 ) continue;\r\n\r\n\t\t\twork.Push( (root, 0) );\r\n\t\t\tstate[root] = 1;\r\n\r\n\t\t\twhile ( work.Count > 0 )\r\n\t\t\t{\r\n\t\t\t\tvar (node, index) = work.Pop();\r\n\t\t\t\tvar sources = incoming.TryGetValue( node, out var list ) ? list : s_noIds;\r\n\r\n\t\t\t\tif ( index < sources.Count )\r\n\t\t\t\t{\r\n\t\t\t\t\twork.Push( (node, index + 1) );\r\n\r\n\t\t\t\t\tvar source = sources[index];\r\n\r\n\t\t\t\t\tif ( !scope.Contains( source ) ) continue;\r\n\r\n\t\t\t\t\tstate.TryGetValue( source, out var sourceState );\r\n\r\n\t\t\t\t\tif ( sourceState == 0 )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tstate[source] = 1;\r\n\t\t\t\t\t\twork.Push( (source, 0) );\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tcontinue;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tstate[node] = 2;\r\n\t\t\t\torder.Add( node );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// Anything still grey belongs to a cycle: emit it so callers see every node exactly once.\r\n\t\tforeach ( var node in Ordered( graph, scope ) )\r\n\t\t{\r\n\t\t\tif ( state.TryGetValue( node, out var seen ) && seen == 2 ) continue;\r\n\r\n\t\t\torder.Add( node );\r\n\t\t\tstate[node] = 2;\r\n\t\t}\r\n\r\n\t\treturn order;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Find one cycle, reporting the full path in traversal order. Reroutes participate exactly like\r\n\t/// any other node. Returns false when the document is acyclic.\r\n\t/// </summary>\r\n\tpublic static bool TryFindCycle( IPrismGraph graph, out IReadOnlyList<NodeId> cycle )\r\n\t{\r\n\t\tvar cycles = FindCycles( graph, 1 );\r\n\r\n\t\tcycle = cycles.Count > 0 ? cycles[0] : Array.Empty<NodeId>();\r\n\r\n\t\treturn cycles.Count > 0;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Find up to <paramref name=\"limit\"/> distinct cycles, each reported as the full node path with\r\n\t/// the entry node repeated at the end so the loop reads naturally in a diagnostic.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<IReadOnlyList<NodeId>> FindCycles( IPrismGraph graph, int limit = 8 )\r\n\t{\r\n\t\tvar found = new List<IReadOnlyList<NodeId>>();\r\n\r\n\t\tif ( graph?.Nodes is null ) return found;\r\n\r\n\t\tvar outgoing = BuildOutgoingMap( graph );\r\n\t\tvar state = new Dictionary<NodeId, byte>();\r\n\t\tvar path = new List<NodeId>();\r\n\t\tvar onPath = new HashSet<NodeId>();\r\n\t\tvar seenCycles = new HashSet<string>();\r\n\r\n\t\tforeach ( var start in graph.Nodes.Where( x => x is not null ).Select( x => x.Id ) )\r\n\t\t{\r\n\t\t\tif ( state.TryGetValue( start, out var seen ) && seen == 2 ) continue;\r\n\t\t\tif ( found.Count >= limit ) break;\r\n\r\n\t\t\tvar work = new Stack<(NodeId Node, int Index)>();\r\n\t\t\twork.Push( (start, 0) );\r\n\r\n\t\t\twhile ( work.Count > 0 )\r\n\t\t\t{\r\n\t\t\t\tvar (node, index) = work.Pop();\r\n\r\n\t\t\t\tif ( index == 0 )\r\n\t\t\t\t{\r\n\t\t\t\t\tstate[node] = 1;\r\n\t\t\t\t\tpath.Add( node );\r\n\t\t\t\t\tonPath.Add( node );\r\n\t\t\t\t}\r\n\r\n\t\t\t\tvar targets = outgoing.TryGetValue( node, out var list ) ? list : s_noIds;\r\n\r\n\t\t\t\tif ( index < targets.Count )\r\n\t\t\t\t{\r\n\t\t\t\t\twork.Push( (node, index + 1) );\r\n\r\n\t\t\t\t\tvar next = targets[index];\r\n\r\n\t\t\t\t\tif ( onPath.Contains( next ) )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar at = path.LastIndexOf( next );\r\n\r\n\t\t\t\t\t\tif ( at >= 0 && found.Count < limit )\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tvar loop = new List<NodeId>( path.Count - at + 1 );\r\n\r\n\t\t\t\t\t\t\tfor ( int i = at; i < path.Count; i++ )\r\n\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\tloop.Add( path[i] );\r\n\t\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\t\tloop.Add( next );\r\n\r\n\t\t\t\t\t\t\tvar key = string.Join( \">\", loop.Select( x => x.Value ).OrderBy( x => x, StringComparer.Ordinal ) );\r\n\r\n\t\t\t\t\t\t\tif ( seenCycles.Add( key ) ) found.Add( loop );\r\n\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\tcontinue;\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tstate.TryGetValue( next, out var nextState );\r\n\r\n\t\t\t\t\tif ( nextState == 0 ) work.Push( (next, 0) );\r\n\r\n\t\t\t\t\tcontinue;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tstate[node] = 2;\r\n\t\t\t\tonPath.Remove( node );\r\n\r\n\t\t\t\tif ( path.Count > 0 && path[^1] == node ) path.RemoveAt( path.Count - 1 );\r\n\t\t\t}\r\n\r\n\t\t\tpath.Clear();\r\n\t\t\tonPath.Clear();\r\n\t\t}\r\n\r\n\t\treturn found;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Would adding this connection close a loop? Answered without mutating the document, so the plug\r\n\t/// setter can refuse a drop before anything changes.\r\n\t/// </summary>\r\n\tpublic static bool WouldCreateCycle( IPrismGraph graph, PortRef from, PortRef to )\r\n\t{\r\n\t\tif ( graph is null ) return false;\r\n\t\tif ( !from.IsValid || !to.IsValid ) return false;\r\n\t\tif ( from.Node == to.Node ) return true;\r\n\r\n\t\t// The new edge runs from.Node -> to.Node. It closes a loop when from.Node is already\r\n\t\t// reachable downstream of to.Node.\r\n\t\tvar outgoing = BuildOutgoingMap( graph );\r\n\t\tvar visited = new HashSet<NodeId> { to.Node };\r\n\t\tvar stack = new Stack<NodeId>();\r\n\t\tstack.Push( to.Node );\r\n\r\n\t\twhile ( stack.Count > 0 )\r\n\t\t{\r\n\t\t\tvar current = stack.Pop();\r\n\r\n\t\t\tif ( current == from.Node ) return true;\r\n\t\t\tif ( !outgoing.TryGetValue( current, out var targets ) ) continue;\r\n\r\n\t\t\tforeach ( var target in targets )\r\n\t\t\t{\r\n\t\t\t\tif ( visited.Add( target ) ) stack.Push( target );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// How many nodes of a cycle path are named before the description gives up and counts the rest.\r\n\t/// A cycle through a thousand nodes is not more informative than a cycle through twenty, and the\r\n\t/// text ends up in a diagnostic detail body, a tooltip and a log line.\r\n\t/// </summary>\r\n\tpublic const int MaxDescribedCycleNodes = 24;\r\n\r\n\t/// <summary>\r\n\t/// Render a cycle path as <c>Title #id \u2192 Title #id \u2192 \u2026</c> for a diagnostic detail body. Long cycles\r\n\t/// are elided in the middle: the two ends are what identifies the loop, and the length is stated.\r\n\t/// </summary>\r\n\tpublic static string DescribeCycle( IPrismGraph graph, IReadOnlyList<NodeId> cycle )\r\n\t{\r\n\t\tif ( cycle is null || cycle.Count == 0 ) return string.Empty;\r\n\r\n\t\tstring Name( NodeId id )\r\n\t\t{\r\n\t\t\tvar node = graph?.FindNode( id );\r\n\r\n\t\t\tvar title = node switch\r\n\t\t\t{\r\n\t\t\t\tUnknownNode unknown => unknown.DisplayTitle,\r\n\t\t\t\tnull => \"<missing>\",\r\n\t\t\t\t_ => node.Descriptor?.Title ?? node.GetType().Name\r\n\t\t\t};\r\n\r\n\t\t\treturn $\"{title} #{id}\";\r\n\t\t}\r\n\r\n\t\tif ( cycle.Count <= MaxDescribedCycleNodes )\r\n\t\t{\r\n\t\t\treturn string.Join( \" \u2192 \", cycle.Select( Name ) );\r\n\t\t}\r\n\r\n\t\tvar head = MaxDescribedCycleNodes / 2;\r\n\t\tvar tail = MaxDescribedCycleNodes - head;\r\n\r\n\t\tvar parts = cycle.Take( head ).Select( Name ).ToList();\r\n\r\n\t\tparts.Add( $\"\u2026 {cycle.Count - MaxDescribedCycleNodes} more \u2026\" );\r\n\t\tparts.AddRange( cycle.Skip( cycle.Count - tail ).Select( Name ) );\r\n\r\n\t\treturn string.Join( \" \u2192 \", parts );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The static checks that do not need the type solver: cycles, dangling edges, missing required\r\n\t/// inputs, unresolved parameter references and a missing output node. Never throws; a node whose\r\n\t/// <c>OnValidate</c> misbehaves is isolated and reported.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<Diagnostic> Validate( IPrismGraph graph, DiagnosticSink sink = null )\r\n\t{\r\n\t\tvar target = sink ?? new DiagnosticSink();\r\n\r\n\t\tif ( graph is null ) return target.All;\r\n\r\n\t\tforeach ( var cycle in FindCycles( graph ) )\r\n\t\t{\r\n\t\t\ttarget.Error( DiagnosticCode.Cycle, \"This graph contains a cycle\",\r\n\t\t\t\tGraphRef.ForNode( cycle.Count > 0 ? cycle[0] : NodeId.None ), DescribeCycle( graph, cycle ) );\r\n\t\t}\r\n\r\n\t\tforeach ( var edge in graph.Edges ?? Array.Empty<Edge>() )\r\n\t\t{\r\n\t\t\tif ( edge is null ) continue;\r\n\r\n\t\t\tif ( graph.FindNode( edge.FromNode ) is null || graph.FindNode( edge.ToNode ) is null )\r\n\t\t\t{\r\n\t\t\t\ttarget.Error( DiagnosticCode.DanglingEdge, \"Connection references a node that does not exist\",\r\n\t\t\t\t\tGraphRef.ForEdge( edge.Id ), edge.ToString() );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tforeach ( var node in graph.Nodes ?? Array.Empty<PrismNode>() )\r\n\t\t{\r\n\t\t\tif ( node is null ) continue;\r\n\r\n\t\t\tforeach ( var input in node.Inputs )\r\n\t\t\t{\r\n\t\t\t\tif ( !input.Required ) continue;\r\n\t\t\t\tif ( input.IsConnected ) continue;\r\n\t\t\t\tif ( input.InlineValue is not null ) continue;\r\n\r\n\t\t\t\ttarget.Error( DiagnosticCode.MissingInput,\r\n\t\t\t\t\t$\"'{input.DisplayName}' is required and has nothing connected\",\r\n\t\t\t\t\tGraphRef.ForPort( node.Id, input.Id ) );\r\n\t\t\t}\r\n\r\n\t\t\tvar scoped = target.Scoped( GraphRef.ForNode( node.Id ) );\r\n\r\n\t\t\tPrismLog.Try( $\"Validate node {node.Id}\",\r\n\t\t\t\t() => node.OnValidate( new ValidationContext( node, graph, scoped ) ),\r\n\t\t\t\ttarget, DiagnosticCode.NodeEmitFailed, GraphRef.ForNode( node.Id ) );\r\n\t\t}\r\n\r\n\t\tif ( OutputNodes( graph ).Count == 0 )\r\n\t\t{\r\n\t\t\ttarget.Error( DiagnosticCode.NoOutput, \"This graph has no output node\" );\r\n\t\t}\r\n\r\n\t\treturn target.All;\r\n\t}\r\n\r\n\tstatic bool IsDisabled( IPrismGraph graph, NodeId id ) =>\r\n\t\tgraph?.FindNode( id ) is { } node && ( node.Flags & NodeFlags.Disabled ) != 0;\r\n\r\n\tstatic IEnumerable<NodeId> Ordered( IPrismGraph graph, HashSet<NodeId> scope )\r\n\t{\r\n\t\t// Iterate in document order so the result is stable between runs, which is what makes\r\n\t\t// regenerated shader text byte-identical for an unchanged graph.\r\n\t\tforeach ( var node in graph.Nodes ?? Array.Empty<PrismNode>() )\r\n\t\t{\r\n\t\t\tif ( node is null || !scope.Contains( node.Id ) ) continue;\r\n\r\n\t\t\tyield return node.Id;\r\n\t\t}\r\n\t}\r\n\r\n\tstatic Dictionary<NodeId, List<NodeId>> BuildIncomingMap( IPrismGraph graph )\r\n\t{\r\n\t\tvar map = new Dictionary<NodeId, List<NodeId>>();\r\n\r\n\t\tforeach ( var edge in graph.Edges ?? Array.Empty<Edge>() )\r\n\t\t{\r\n\t\t\tif ( edge is null ) continue;\r\n\r\n\t\t\tif ( !map.TryGetValue( edge.ToNode, out var list ) )\r\n\t\t\t{\r\n\t\t\t\tlist = new List<NodeId>();\r\n\t\t\t\tmap[edge.ToNode] = list;\r\n\t\t\t}\r\n\r\n\t\t\tif ( !list.Contains( edge.FromNode ) ) list.Add( edge.FromNode );\r\n\t\t}\r\n\r\n\t\treturn map;\r\n\t}\r\n\r\n\tstatic Dictionary<NodeId, List<NodeId>> BuildOutgoingMap( IPrismGraph graph )\r\n\t{\r\n\t\tvar map = new Dictionary<NodeId, List<NodeId>>();\r\n\r\n\t\tforeach ( var edge in graph.Edges ?? Array.Empty<Edge>() )\r\n\t\t{\r\n\t\t\tif ( edge is null ) continue;\r\n\r\n\t\t\tif ( !map.TryGetValue( edge.FromNode, out var list ) )\r\n\t\t\t{\r\n\t\t\t\tlist = new List<NodeId>();\r\n\t\t\t\tmap[edge.FromNode] = list;\r\n\t\t\t}\r\n\r\n\t\t\tif ( !list.Contains( edge.ToNode ) ) list.Add( edge.ToNode );\r\n\t\t}\r\n\r\n\t\treturn map;\r\n\t}\r\n\r\n\tstatic readonly List<NodeId> s_noIds = new();\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/PrismHotload.cs",
"FileName": "PrismHotload.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Compiler;\r\nusing Editor.Prism.Core;\r\nusing Editor.Prism.Model;\r\nusing Editor.Prism.Nodes;\r\nusing Editor.Prism.Serialization;\r\nusing Editor.Prism.Text;\r\nusing Editor.Prism.Toolchain;\r\n\r\nnamespace Editor.Prism;\r\n\r\n/// <summary>\r\n/// The one place every static cache in Prism is dropped when the editor hotloads this assembly.\r\n/// <para>\r\n/// Almost every package caches something keyed by <see cref=\"Type\"/>, <c>PropertyInfo</c> or a live\r\n/// instance \u2014 the node registry, the port and property reflection tables, the backend list, the\r\n/// subgraph document cache, the legacy import table. Every one of those holds the outgoing assembly\r\n/// alive and hands out stale metadata after a reload, so they are all flushed together here rather\r\n/// than each package hoping someone else remembered.\r\n/// </para>\r\n/// <para>\r\n/// Order matters: the registry is flushed last because flushing it re-registers the descriptor\r\n/// provider, and nothing should be able to rebuild the catalogue from half-cleared tables.\r\n/// </para>\r\n/// </summary>\r\npublic static class PrismHotload\r\n{\r\n\t/// <summary>Raised after every cache has been dropped, so a window can rebuild whatever it holds.</summary>\r\n\tpublic static event Action Flushed;\r\n\r\n\t/// <summary>Drop every static cache in Prism. Safe to call at any time; never throws.</summary>\r\n\tpublic static void FlushAll()\r\n\t{\r\n\t\t// First, because a compile in flight is holding a graph, a backend and a pile of callbacks that\r\n\t\t// are all about to be replaced underneath it. Nothing below is safe while one is running.\r\n\t\tPrismLog.Guard( \"Cancel compiles in flight\", () => ShaderCompileService.CancelAll() );\r\n\r\n\t\tPrismLog.Guard( \"Flush subgraph documents\", SubgraphLibrary.Flush );\r\n\t\tPrismLog.Guard( \"Flush compiler backends\", GraphCompiler.FlushBackends );\r\n\t\tPrismLog.Guard( \"Flush the legacy import table\", LegacyShaderGraphImporter.Reset );\r\n\r\n\t\t// Migration steps are delegates, so an outgoing assembly's upgraders would otherwise stay\r\n\t\t// registered and run against documents loaded by the new one. Anything that registers steps must\r\n\t\t// do so again from <see cref=\"Flushed\"/>, which is raised at the end of this method.\r\n\t\t//\r\n\t\t// Both registries, not just the per-node one: a document-level upgrader is the same delegate held\r\n\t\t// the same way, and it runs on the path that turns an older file into the current schema \u2014 the one\r\n\t\t// place a stale function body would silently rewrite somebody's document.\r\n\t\tPrismLog.Guard( \"Flush node migrations\", NodeMigrations.Reset );\r\n\t\tPrismLog.Guard( \"Flush schema migrations\", SchemaMigrations.Reset );\r\n\r\n\t\tPrismLog.Guard( \"Flush port reflection\", PortBuilder.FlushCache );\r\n\t\tPrismLog.Guard( \"Flush node properties\", NodeProperties.Flush );\r\n\r\n\t\t// The lexers, the language databases, the include resolver and the header symbol tables. A\r\n\t\t// hotload that skipped these would leave every open document lexing against word tables and\r\n\t\t// delegate-backed lazies belonging to the assembly that just went away.\r\n\t\tPrismLog.Guard( \"Flush the text editor caches\", TextCaches.Flush );\r\n\r\n\t\tPrismLog.Guard( \"Flush the node registry\", NodeRegistry.Flush );\r\n\r\n\t\tPrismLog.Guard( \"Raising PrismHotload.Flushed\", () => Flushed?.Invoke() );\r\n\t}\r\n\r\n\t/// <summary>Drop every cache when the editor reloads this assembly.</summary>\r\n\t[EditorEvent.Hotload]\r\n\tstatic void OnHotload() => FlushAll();\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Serialization/ValueCodec.cs",
"FileName": "ValueCodec.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Compiler.Ir;\r\nusing Editor.Prism.Core;\r\nusing System.Globalization;\r\n\r\nnamespace Editor.Prism.Serialization;\r\n\r\n/// <summary>\r\n/// A texture reference as it appears in a document: an asset path plus the import intent that decides\r\n/// how the sampler is generated. Stored as an object rather than a bare string so colour space and\r\n/// processor survive a round-trip.\r\n/// </summary>\r\npublic sealed record TextureValue\r\n{\r\n\t/// <summary>Relative asset path, e.g. <c>materials/dev/white_color.tga</c>.</summary>\r\n\tpublic string Path { get; init; }\r\n\r\n\t/// <summary>How the texture is read: <c>Srgb</c> or <c>Linear</c>.</summary>\r\n\tpublic string ColorSpace { get; init; } = \"Srgb\";\r\n\r\n\t/// <summary>Import processor name, e.g. <c>None</c>, <c>NormalizeNormals</c>.</summary>\r\n\tpublic string Processor { get; init; } = \"None\";\r\n\r\n\t/// <summary>True when no asset is referenced.</summary>\r\n\tpublic bool IsEmpty => string.IsNullOrWhiteSpace( Path );\r\n\r\n\t/// <summary>True when the texture should be sampled through an sRGB view.</summary>\r\n\tpublic bool IsSrgb => string.Equals( ColorSpace, \"Srgb\", StringComparison.OrdinalIgnoreCase );\r\n\r\n\t/// <summary>Emit the document shape: <c>{ path, colorSpace, processor }</c>.</summary>\r\n\tpublic JsonObject ToJson()\r\n\t{\r\n\t\tvar json = new JsonObject { [\"path\"] = Path };\r\n\r\n\t\tif ( !string.IsNullOrEmpty( ColorSpace ) && ColorSpace != \"Srgb\" ) json[\"colorSpace\"] = ColorSpace;\r\n\t\tif ( !string.IsNullOrEmpty( Processor ) && Processor != \"None\" ) json[\"processor\"] = Processor;\r\n\r\n\t\treturn json;\r\n\t}\r\n\r\n\t/// <summary>Read the document shape. A bare string is accepted as a path-only descriptor.</summary>\r\n\tpublic static TextureValue From( JsonNode node )\r\n\t{\r\n\t\tif ( node is null ) return null;\r\n\r\n\t\tif ( node is JsonValue value && value.TryGetValue<string>( out var path ) )\r\n\t\t{\r\n\t\t\treturn new TextureValue { Path = path };\r\n\t\t}\r\n\r\n\t\tif ( node is not JsonObject obj ) return null;\r\n\r\n\t\treturn new TextureValue\r\n\t\t{\r\n\t\t\tPath = ValueCodec.StringOf( obj[\"path\"] ),\r\n\t\t\tColorSpace = ValueCodec.StringOf( obj[\"colorSpace\"] ) ?? \"Srgb\",\r\n\t\t\tProcessor = ValueCodec.StringOf( obj[\"processor\"] ) ?? \"None\"\r\n\t\t};\r\n\t}\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override string ToString() => IsEmpty ? \"(no texture)\" : Path;\r\n}\r\n\r\n/// <summary>\r\n/// Typed literal encoding: the bridge between the boxed values the model stores in port inline slots\r\n/// and parameter defaults, and the JSON a document holds.\r\n/// <para>\r\n/// Every float goes out through <see cref=\"Number(float)\"/>, which formats with <c>\"R\"</c> so a value\r\n/// read back is bit-identical to the one written. That is what makes \"save an unchanged graph and get\r\n/// a byte-identical file\" true, which in turn is what makes the text-diff short-circuit before a\r\n/// recompile trustworthy.\r\n/// </para>\r\n/// </summary>\r\npublic static class ValueCodec\r\n{\r\n\t// ---------------------------------------------------------------- writing ----\r\n\r\n\t/// <summary>\r\n\t/// Encode a boxed value using the shape implied by its CLR type. Colours become <c>\"r,g,b,a\"</c>,\r\n\t/// vectors become arrays, enums become their declared names, textures become objects.\r\n\t/// </summary>\r\n\tpublic static JsonNode Write( object value )\r\n\t{\r\n\t\tswitch ( value )\r\n\t\t{\r\n\t\t\tcase null:\r\n\t\t\t\treturn null;\r\n\t\t\tcase bool b:\r\n\t\t\t\treturn JsonValue.Create( b );\r\n\t\t\tcase int i:\r\n\t\t\t\treturn JsonValue.Create( i );\r\n\t\t\tcase uint u:\r\n\t\t\t\treturn JsonValue.Create( u );\r\n\t\t\tcase long l:\r\n\t\t\t\treturn JsonValue.Create( l );\r\n\t\t\tcase float f:\r\n\t\t\t\treturn Number( f );\r\n\t\t\tcase double d:\r\n\t\t\t\treturn Number( (float)d );\r\n\t\t\tcase string s:\r\n\t\t\t\treturn JsonValue.Create( s );\r\n\t\t\tcase Color c:\r\n\t\t\t\treturn JsonValue.Create( FormatColor( c ) );\r\n\t\t\tcase Vector2 v2:\r\n\t\t\t\treturn new JsonArray( Number( v2.x ), Number( v2.y ) );\r\n\t\t\tcase Vector3 v3:\r\n\t\t\t\treturn new JsonArray( Number( v3.x ), Number( v3.y ), Number( v3.z ) );\r\n\t\t\tcase Vector4 v4:\r\n\t\t\t\treturn new JsonArray( Number( v4.x ), Number( v4.y ), Number( v4.z ), Number( v4.w ) );\r\n\t\t\tcase TextureValue texture:\r\n\t\t\t\treturn texture.ToJson();\r\n\t\t\tcase Enum e:\r\n\t\t\t\treturn JsonValue.Create( e.ToString() );\r\n\t\t\tcase JsonNode json:\r\n\t\t\t\treturn json.DeepClone();\r\n\t\t\tcase float[] array:\r\n\t\t\t\treturn Vector( array );\r\n\t\t\tdefault:\r\n\t\t\t\treturn JsonValue.Create( PrismLog.Guard( \"encode value\", () => value.ToString(), string.Empty ) );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Encode a boxed value for a known shader type, coercing it into that type's canonical shape\r\n\t/// first. This is the form used for port inline literals and parameter defaults.\r\n\t/// </summary>\r\n\tpublic static JsonNode Write( ShaderType type, object value ) => Write( Coerce( value, type ) );\r\n\r\n\t/// <summary>Format a float exactly, so reading it back yields the same bits.</summary>\r\n\tpublic static JsonNode Number( float value )\r\n\t{\r\n\t\tif ( float.IsNaN( value ) ) return JsonValue.Create( \"NaN\" );\r\n\t\tif ( float.IsPositiveInfinity( value ) ) return JsonValue.Create( \"Infinity\" );\r\n\t\tif ( float.IsNegativeInfinity( value ) ) return JsonValue.Create( \"-Infinity\" );\r\n\r\n\t\tvar text = value.ToString( \"R\", CultureInfo.InvariantCulture );\r\n\r\n\t\treturn JsonNode.Parse( text ) ?? JsonValue.Create( 0 );\r\n\t}\r\n\r\n\t/// <summary>Format a component array as a JSON array of exact floats.</summary>\r\n\tpublic static JsonArray Vector( params float[] components )\r\n\t{\r\n\t\tvar array = new JsonArray();\r\n\r\n\t\tforeach ( var component in components ?? Array.Empty<float>() )\r\n\t\t{\r\n\t\t\tarray.Add( Number( component ) );\r\n\t\t}\r\n\r\n\t\treturn array;\r\n\t}\r\n\r\n\t/// <summary>Format a colour the way the engine does: four exact components separated by commas.</summary>\r\n\tpublic static string FormatColor( Color color ) =>\r\n\t\tstring.Join( \",\",\r\n\t\t\tcolor.r.ToString( \"R\", CultureInfo.InvariantCulture ),\r\n\t\t\tcolor.g.ToString( \"R\", CultureInfo.InvariantCulture ),\r\n\t\t\tcolor.b.ToString( \"R\", CultureInfo.InvariantCulture ),\r\n\t\t\tcolor.a.ToString( \"R\", CultureInfo.InvariantCulture ) );\r\n\r\n\t// ---------------------------------------------------------------- reading ----\r\n\r\n\t/// <summary>\r\n\t/// Decode a literal for a known shader type. Returns the type's default rather than throwing when\r\n\t/// the JSON is the wrong shape \u2014 a corrupt literal must never take a document down.\r\n\t/// </summary>\r\n\tpublic static object Read( ShaderType type, JsonNode node )\r\n\t{\r\n\t\tTryRead( type, node, out var value );\r\n\t\treturn value;\r\n\t}\r\n\r\n\t/// <summary>Decode a literal, reporting whether the JSON actually matched the requested type.</summary>\r\n\tpublic static bool TryRead( ShaderType type, JsonNode node, out object value )\r\n\t{\r\n\t\tvalue = Default( type );\r\n\r\n\t\tif ( node is null ) return false;\r\n\r\n\t\tif ( type.IsObject )\r\n\t\t{\r\n\t\t\tif ( type.IsTexture )\r\n\t\t\t{\r\n\t\t\t\tvar texture = TextureValue.From( node );\r\n\r\n\t\t\t\tif ( texture is null ) return false;\r\n\r\n\t\t\t\tvalue = texture;\r\n\t\t\t\treturn true;\r\n\t\t\t}\r\n\r\n\t\t\tvar path = StringOf( node );\r\n\r\n\t\t\tif ( path is null ) return false;\r\n\r\n\t\t\tvalue = path;\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tif ( type.IsBoolean && type.IsScalar )\r\n\t\t{\r\n\t\t\tif ( !TryNumbers( node, out var bits ) || bits.Length == 0 ) return false;\r\n\r\n\t\t\tvalue = bits[0] != 0f;\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tif ( type.IsScalar && type.IsIntegral )\r\n\t\t{\r\n\t\t\tif ( !TryNumbers( node, out var ints ) || ints.Length == 0 ) return false;\r\n\r\n\t\t\tvalue = (int)ints[0];\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tif ( type.IsScalar )\r\n\t\t{\r\n\t\t\tif ( !TryNumbers( node, out var scalars ) || scalars.Length == 0 ) return false;\r\n\r\n\t\t\tvalue = scalars[0];\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tif ( type.IsVector || type.IsMatrix )\r\n\t\t{\r\n\t\t\tvar wantsColor = node is JsonValue jv && jv.TryGetValue<string>( out var text ) &&\r\n\t\t\t\ttext.Contains( ',' );\r\n\r\n\t\t\tif ( wantsColor && TryParseColor( StringOf( node ), out var color ) )\r\n\t\t\t{\r\n\t\t\t\tvalue = type.Components == 4 ? color : ToComponents( color, type.Components );\r\n\t\t\t\treturn true;\r\n\t\t\t}\r\n\r\n\t\t\tif ( !TryNumbers( node, out var numbers ) ) return false;\r\n\r\n\t\t\tvalue = ToComponents( numbers, type.Components );\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Decode a literal with no declared type, guessing from the JSON shape. Used for the inline slots\r\n\t/// of an unregistered node, where we know nothing about the port.\r\n\t/// </summary>\r\n\tpublic static object ReadUntyped( JsonNode node )\r\n\t{\r\n\t\tswitch ( node )\r\n\t\t{\r\n\t\t\tcase null:\r\n\t\t\t\treturn null;\r\n\t\t\tcase JsonArray array:\r\n\t\t\t{\r\n\t\t\t\tvar numbers = new float[array.Count];\r\n\r\n\t\t\t\tfor ( int i = 0; i < array.Count; i++ )\r\n\t\t\t\t{\r\n\t\t\t\t\tnumbers[i] = NumberOf( array[i] );\r\n\t\t\t\t}\r\n\r\n\t\t\t\treturn ToComponents( numbers, numbers.Length );\r\n\t\t\t}\r\n\t\t\tcase JsonObject obj when obj.ContainsKey( \"path\" ):\r\n\t\t\t\treturn TextureValue.From( obj );\r\n\t\t\tcase JsonObject obj:\r\n\t\t\t\treturn obj.DeepClone();\r\n\t\t\tcase JsonValue value:\r\n\t\t\t{\r\n\t\t\t\tif ( value.TryGetValue<bool>( out var b ) ) return b;\r\n\t\t\t\tif ( value.TryGetValue<int>( out var i ) ) return i;\r\n\r\n\t\t\t\tif ( value.TryGetValue<string>( out var s ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( TryParseColor( s, out var color ) ) return color;\r\n\r\n\t\t\t\t\treturn s;\r\n\t\t\t\t}\r\n\r\n\t\t\t\t// Anything numeric that was not an int, whatever CLR type is behind it.\r\n\t\t\t\tif ( IsNumber( value ) ) return NumberOf( value );\r\n\r\n\t\t\t\treturn null;\r\n\t\t\t}\r\n\t\t\tdefault:\r\n\t\t\t\treturn null;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Parse the engine's <c>\"r,g,b,a\"</c> colour form. Accepts three or four components.</summary>\r\n\tpublic static bool TryParseColor( string text, out Color color )\r\n\t{\r\n\t\tcolor = Color.White;\r\n\r\n\t\tif ( string.IsNullOrWhiteSpace( text ) ) return false;\r\n\r\n\t\tvar parts = text.Split( ',', StringSplitOptions.TrimEntries );\r\n\r\n\t\tif ( parts.Length is < 3 or > 4 ) return false;\r\n\r\n\t\tvar values = new float[4];\r\n\t\tvalues[3] = 1f;\r\n\r\n\t\tfor ( int i = 0; i < parts.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( !float.TryParse( parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i] ) )\r\n\t\t\t{\r\n\t\t\t\treturn false;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tcolor = new Color( values[0], values[1], values[2], values[3] );\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// <summary>The string behind a JSON value, or null when it is not a string.</summary>\r\n\tpublic static string StringOf( JsonNode node )\r\n\t{\r\n\t\tif ( node is not JsonValue value ) return null;\r\n\r\n\t\treturn value.TryGetValue<string>( out var text ) ? text : null;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The number behind a JSON value, tolerating numbers written as strings.\r\n\t/// <para>\r\n\t/// Every numeric backing has to be tried by hand. A <see cref=\"JsonValue\"/> parsed from text wraps a\r\n\t/// <c>JsonElement</c> and converts to anything numeric, but one built in memory wraps the exact CLR\r\n\t/// type it was created from \u2014 and <c>TryGetValue<float></c> on a <c>JsonValue<int></c>\r\n\t/// returns <b>false</b>. Documents reach us both ways: parsed from disk, and handed over as a live\r\n\t/// <c>JsonObject</c> by the asset system or by a migration step that synthesised it. Asking for only\r\n\t/// one type would silently read every number in the second kind as zero.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic static float NumberOf( JsonNode node, float fallback = 0f )\r\n\t{\r\n\t\tif ( node is not JsonValue value ) return fallback;\r\n\r\n\t\tif ( value.TryGetValue<float>( out var f ) ) return f;\r\n\t\tif ( value.TryGetValue<double>( out var d ) ) return (float)d;\r\n\t\tif ( value.TryGetValue<int>( out var i ) ) return i;\r\n\t\tif ( value.TryGetValue<long>( out var l ) ) return l;\r\n\t\tif ( value.TryGetValue<uint>( out var u ) ) return u;\r\n\t\tif ( value.TryGetValue<ulong>( out var ul ) ) return ul;\r\n\t\tif ( value.TryGetValue<decimal>( out var m ) ) return (float)m;\r\n\t\tif ( value.TryGetValue<bool>( out var b ) ) return b ? 1f : 0f;\r\n\r\n\t\tif ( value.TryGetValue<string>( out var text ) )\r\n\t\t{\r\n\t\t\tif ( float.TryParse( text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed ) )\r\n\t\t\t{\r\n\t\t\t\treturn parsed;\r\n\t\t\t}\r\n\r\n\t\t\treturn text switch\r\n\t\t\t{\r\n\t\t\t\t\"NaN\" => float.NaN,\r\n\t\t\t\t\"Infinity\" => float.PositiveInfinity,\r\n\t\t\t\t\"-Infinity\" => float.NegativeInfinity,\r\n\t\t\t\t_ => fallback\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\treturn fallback;\r\n\t}\r\n\r\n\t// ---------------------------------------------------------------- shaping ----\r\n\r\n\t/// <summary>The zero value of a shader type, in the boxed shape the model stores.</summary>\r\n\tpublic static object Default( ShaderType type )\r\n\t{\r\n\t\tif ( type.IsTexture ) return new TextureValue();\r\n\t\tif ( type.IsObject ) return string.Empty;\r\n\t\tif ( type.IsBoolean && type.IsScalar ) return false;\r\n\t\tif ( type.IsScalar && type.IsIntegral ) return 0;\r\n\t\tif ( type.IsScalar ) return 0f;\r\n\r\n\t\treturn type.Components switch\r\n\t\t{\r\n\t\t\t2 => Vector2.Zero,\r\n\t\t\t3 => Vector3.Zero,\r\n\t\t\t4 => new Vector4( 0f, 0f, 0f, 0f ),\r\n\t\t\t_ => 0f\r\n\t\t};\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Reshape a boxed value into the canonical form for a type: widening a scalar into a vector by\r\n\t/// splat, truncating a wider vector, and converting between colours and vectors.\r\n\t/// </summary>\r\n\tpublic static object Coerce( object value, ShaderType type )\r\n\t{\r\n\t\t// There is no such thing as a value of type void, so there is nothing to keep. Saying so here\r\n\t\t// rather than letting it fall through the vector path keeps encoding a void slot idempotent.\r\n\t\tif ( type.IsVoid ) return Default( type );\r\n\r\n\t\tif ( value is null ) return Default( type );\r\n\r\n\t\tif ( type.IsTexture )\r\n\t\t{\r\n\t\t\treturn value switch\r\n\t\t\t{\r\n\t\t\t\tTextureValue texture => texture,\r\n\t\t\t\tstring path => new TextureValue { Path = path },\r\n\t\t\t\t_ => new TextureValue()\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\tif ( type.IsObject ) return value as string ?? string.Empty;\r\n\r\n\t\tif ( type.IsBoolean && type.IsScalar )\r\n\t\t{\r\n\t\t\treturn value switch\r\n\t\t\t{\r\n\t\t\t\tbool b => b,\r\n\t\t\t\tfloat f => f != 0f,\r\n\t\t\t\tint i => i != 0,\r\n\t\t\t\t_ => false\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\tif ( type.IsScalar && type.IsIntegral )\r\n\t\t{\r\n\t\t\treturn value switch\r\n\t\t\t{\r\n\t\t\t\tint i => i,\r\n\t\t\t\tfloat f => (int)f,\r\n\t\t\t\tbool b => b ? 1 : 0,\r\n\t\t\t\t_ => 0\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\tvar components = ToFloats( value );\r\n\r\n\t\tif ( components.Length == 0 ) return Default( type );\r\n\r\n\t\tif ( type.IsScalar ) return components[0];\r\n\r\n\t\t// Keep a colour a colour: it is what tells the writer to use the \"r,g,b,a\" form.\r\n\t\tif ( value is Color && type.Components == 4 ) return value;\r\n\r\n\t\treturn ToComponents( components, type.Components );\r\n\t}\r\n\r\n\t/// <summary>Flatten any supported boxed value into its float components.</summary>\r\n\tpublic static float[] ToFloats( object value ) => value switch\r\n\t{\r\n\t\tnull => Array.Empty<float>(),\r\n\t\tfloat f => new[] { f },\r\n\t\tdouble d => new[] { (float)d },\r\n\t\tint i => new[] { (float)i },\r\n\t\tbool b => new[] { b ? 1f : 0f },\r\n\t\tVector2 v2 => new[] { v2.x, v2.y },\r\n\t\tVector3 v3 => new[] { v3.x, v3.y, v3.z },\r\n\t\tVector4 v4 => new[] { v4.x, v4.y, v4.z, v4.w },\r\n\t\tColor c => new[] { c.r, c.g, c.b, c.a },\r\n\t\tfloat[] array => array,\r\n\t\tstring s => TryParseColor( s, out var parsed )\r\n\t\t\t? new[] { parsed.r, parsed.g, parsed.b, parsed.a }\r\n\t\t\t: Array.Empty<float>(),\r\n\t\t_ => Array.Empty<float>()\r\n\t};\r\n\r\n\t/// <summary>Box a component array as the vector or scalar type of that width, splatting when short.</summary>\r\n\tpublic static object ToComponents( float[] components, int width )\r\n\t{\r\n\t\tif ( components is null || components.Length == 0 ) components = new[] { 0f };\r\n\r\n\t\tfloat At( int index ) =>\r\n\t\t\tindex < components.Length ? components[index] : components.Length == 1 ? components[0] : 0f;\r\n\r\n\t\treturn width switch\r\n\t\t{\r\n\t\t\t<= 1 => At( 0 ),\r\n\t\t\t2 => new Vector2( At( 0 ), At( 1 ) ),\r\n\t\t\t3 => new Vector3( At( 0 ), At( 1 ), At( 2 ) ),\r\n\t\t\t_ => new Vector4( At( 0 ), At( 1 ), At( 2 ), components.Length > 3 ? At( 3 ) : 1f )\r\n\t\t};\r\n\t}\r\n\r\n\t/// <summary>Box a colour as the vector type of a given width.</summary>\r\n\tpublic static object ToComponents( Color color, int width ) =>\r\n\t\tToComponents( new[] { color.r, color.g, color.b, color.a }, width );\r\n\r\n\t/// <summary>Lower a boxed literal into the IR's constant representation.</summary>\r\n\tpublic static ConstValue ToConst( object value )\r\n\t{\r\n\t\tvar components = ToFloats( value );\r\n\r\n\t\treturn components.Length switch\r\n\t\t{\r\n\t\t\t0 => ConstValue.Zero,\r\n\t\t\t1 => new ConstValue( components[0], 0, 0, 0 ),\r\n\t\t\t2 => new ConstValue( components[0], components[1], 0, 0 ),\r\n\t\t\t3 => new ConstValue( components[0], components[1], components[2], 0 ),\r\n\t\t\t_ => new ConstValue( components[0], components[1], components[2], components[3] )\r\n\t\t};\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Value equality across the boxed shapes, so a \"did this literal change\" test does not report a\r\n\t/// change when a <c>float</c> and a one-element vector describe the same thing.\r\n\t/// </summary>\r\n\tpublic static bool Equal( object a, object b )\r\n\t{\r\n\t\tif ( ReferenceEquals( a, b ) ) return true;\r\n\t\tif ( a is null || b is null ) return false;\r\n\r\n\t\tif ( a is TextureValue ta && b is TextureValue tb ) return ta == tb;\r\n\t\tif ( a is string sa && b is string sb ) return string.Equals( sa, sb, StringComparison.Ordinal );\r\n\t\tif ( a is bool ba && b is bool bb ) return ba == bb;\r\n\r\n\t\tvar fa = ToFloats( a );\r\n\t\tvar fb = ToFloats( b );\r\n\r\n\t\tif ( fa.Length == 0 && fb.Length == 0 ) return Equals( a, b );\r\n\t\tif ( fa.Length != fb.Length ) return false;\r\n\r\n\t\tfor ( int i = 0; i < fa.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( !fa[i].Equals( fb[i] ) ) return false;\r\n\t\t}\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// <summary>A short, human-readable form for inline pills and tooltips.</summary>\r\n\tpublic static string Describe( object value )\r\n\t{\r\n\t\tswitch ( value )\r\n\t\t{\r\n\t\t\tcase null:\r\n\t\t\t\treturn \"-\";\r\n\t\t\tcase bool b:\r\n\t\t\t\treturn b ? \"true\" : \"false\";\r\n\t\t\tcase int i:\r\n\t\t\t\treturn i.ToString( CultureInfo.InvariantCulture );\r\n\t\t\tcase float f:\r\n\t\t\t\treturn f.ToString( \"0.###\", CultureInfo.InvariantCulture );\r\n\t\t\tcase string s:\r\n\t\t\t\treturn s;\r\n\t\t\tcase TextureValue texture:\r\n\t\t\t\treturn texture.ToString();\r\n\t\t\tcase Color c:\r\n\t\t\t\treturn $\"{c.r:0.##}, {c.g:0.##}, {c.b:0.##}, {c.a:0.##}\";\r\n\t\t\tdefault:\r\n\t\t\t{\r\n\t\t\t\tvar components = ToFloats( value );\r\n\r\n\t\t\t\tif ( components.Length == 0 ) return value.ToString();\r\n\r\n\t\t\t\treturn string.Join( \", \", components.Select( x => x.ToString( \"0.###\", CultureInfo.InvariantCulture ) ) );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>True when a JSON value holds a number, whatever CLR type is behind it.</summary>\r\n\tpublic static bool IsNumber( JsonNode node )\r\n\t{\r\n\t\tif ( node is not JsonValue value ) return false;\r\n\t\tif ( value.TryGetValue<bool>( out _ ) ) return false;\r\n\t\tif ( value.TryGetValue<string>( out _ ) ) return false;\r\n\r\n\t\treturn value.TryGetValue<float>( out _ ) || value.TryGetValue<double>( out _ ) ||\r\n\t\t\tvalue.TryGetValue<int>( out _ ) || value.TryGetValue<long>( out _ ) ||\r\n\t\t\tvalue.TryGetValue<uint>( out _ ) || value.TryGetValue<ulong>( out _ ) ||\r\n\t\t\tvalue.TryGetValue<decimal>( out _ );\r\n\t}\r\n\r\n\tstatic bool TryNumbers( JsonNode node, out float[] numbers )\r\n\t{\r\n\t\tswitch ( node )\r\n\t\t{\r\n\t\t\tcase JsonArray array:\r\n\t\t\t{\r\n\t\t\t\tnumbers = new float[array.Count];\r\n\r\n\t\t\t\tfor ( int i = 0; i < array.Count; i++ )\r\n\t\t\t\t{\r\n\t\t\t\t\tnumbers[i] = NumberOf( array[i] );\r\n\t\t\t\t}\r\n\r\n\t\t\t\treturn true;\r\n\t\t\t}\r\n\t\t\tcase JsonValue value when value.TryGetValue<string>( out var text ) && text.Contains( ',' ):\r\n\t\t\t{\r\n\t\t\t\tvar parts = text.Split( ',', StringSplitOptions.TrimEntries );\r\n\t\t\t\tnumbers = new float[parts.Length];\r\n\r\n\t\t\t\tfor ( int i = 0; i < parts.Length; i++ )\r\n\t\t\t\t{\r\n\t\t\t\t\tfloat.TryParse( parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out numbers[i] );\r\n\t\t\t\t}\r\n\r\n\t\t\t\treturn true;\r\n\t\t\t}\r\n\t\t\tcase JsonValue:\r\n\t\t\t\tnumbers = new[] { NumberOf( node ) };\r\n\t\t\t\treturn true;\r\n\t\t\tdefault:\r\n\t\t\t\tnumbers = Array.Empty<float>();\r\n\t\t\t\treturn false;\r\n\t\t}\r\n\t}\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Text/CodeWindow.cs",
"FileName": "CodeWindow.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Core;\r\nusing Editor.Prism.Integration;\r\nusing Editor.Prism.Ui;\r\nusing Margin = Sandbox.UI.Margin;\r\nusing System.IO;\r\nusing System.Text;\r\nusing PrismDiagnostic = Editor.Prism.Core.Diagnostic;\r\n\r\nnamespace Editor.Prism.Text;\r\n\r\n/// <summary>One open file or buffer in the <see cref=\"CodeWindow\"/>.</summary>\r\npublic sealed class CodeTab\r\n{\r\n\t/// <summary>The editor widget showing this buffer.</summary>\r\n\tpublic CodeEditorWidget Editor { get; init; }\r\n\r\n\t/// <summary>The document. Shorthand for <c>Editor.Document</c>.</summary>\r\n\tpublic TextDocument Document => Editor?.Document;\r\n\r\n\t/// <summary>Absolute path on disk, or null for a synthetic buffer such as generated code.</summary>\r\n\tpublic string FilePath { get; set; }\r\n\r\n\t/// <summary>Tab caption.</summary>\r\n\tpublic string Title { get; set; } = \"untitled\";\r\n\r\n\t/// <summary>Language id driving highlighting.</summary>\r\n\tpublic string Language { get; set; } = \"hlsl\";\r\n\r\n\t/// <summary>Whether the buffer can be edited.</summary>\r\n\tpublic bool ReadOnly { get; set; }\r\n\r\n\t/// <summary>Whether the buffer differs from disk.</summary>\r\n\tpublic bool IsModified => Document is { IsModified: true };\r\n\r\n\t/// <summary>\r\n\t/// Set when the file changed on disk while this buffer had unsaved edits, so the tab can say the\r\n\t/// two have diverged. Cleared by a reload or a save.\r\n\t/// </summary>\r\n\tpublic bool ChangedOnDisk { get; set; }\r\n\r\n\t/// <summary>Caption with the modified and diverged markers, as drawn on the tab.</summary>\r\n\tpublic string DisplayTitle => ChangedOnDisk ? Title + \" \u26a0\" : IsModified ? Title + \" \u2022\" : Title;\r\n\r\n\t/// <summary>\r\n\t/// Completion, hover and background validation for this buffer. Owned by the tab and disposed with\r\n\t/// it, because every part of it holds a reference to the editor widget.\r\n\t/// </summary>\r\n\tinternal Completion.CodeIntelligence Intelligence { get; set; }\r\n\r\n\t/// <summary>Cached tab width from the last paint, used for hit testing.</summary>\r\n\tinternal Rect TabRect { get; set; }\r\n\r\n\t/// <summary>Diagnostic rendering.</summary>\r\n\tpublic override string ToString() => Title;\r\n}\r\n\r\n/// <summary>One entry in the outline dock.</summary>\r\npublic sealed record CodeSymbol( string Name, string Detail, int Line, string Icon, int Depth );\r\n\r\n/// <summary>\r\n/// The code editor window: a tab strip over a stack of <see cref=\"CodeEditorWidget\"/>s, a find bar, a\r\n/// diagnostics dock and an outline dock. This is the shell WP-11 owns; the graph window docks its own\r\n/// generated-code panel separately.\r\n/// </summary>\r\npublic sealed class CodeWindow : DockWindow\r\n{\r\n\tstatic CodeWindow s_instance;\r\n\r\n\treadonly List<CodeTab> _tabs = new();\r\n\r\n\tCodeTabStrip _strip;\r\n\tFindReplaceBar _findBar;\r\n\tWidget _editorStack;\r\n\tListView _diagnosticsList;\r\n\tListView _outlineList;\r\n\tLineEdit _outlineFilter;\r\n\tLabel _statusPosition;\r\n\tLabel _statusSelection;\r\n\tLabel _statusLanguage;\r\n\tLabel _statusEncoding;\r\n\tWidget _diagnosticsPanel;\r\n\tWidget _outlinePanel;\r\n\r\n\tCodeTab _active;\r\n\tRealTimeSince _sinceOutlineRefresh;\r\n\tint _outlineVersion = -1;\r\n\r\n\t/// <summary>The live window, or null when it has never been opened or was closed.</summary>\r\n\tpublic static CodeWindow Instance => s_instance is { IsValid: true } ? s_instance : null;\r\n\r\n\t/// <summary>Opens the window, or raises it when it is already open.</summary>\r\n\tpublic static CodeWindow Open()\r\n\t{\r\n\t\tif ( Instance is not null )\r\n\t\t{\r\n\t\t\tInstance.Show();\r\n\t\t\tInstance.Focus();\r\n\t\t\treturn Instance;\r\n\t\t}\r\n\r\n\t\tvar window = new CodeWindow();\r\n\t\twindow.Show();\r\n\t\treturn window;\r\n\t}\r\n\r\n\t/// <summary>Opens a file in the window, creating the window if needed. Line and column are one-based.</summary>\r\n\tpublic static CodeWindow OpenFile( string absolutePath, int line = 0, int column = 1 )\r\n\t{\r\n\t\tvar window = Open();\r\n\r\n\t\tif ( window is null )\r\n\t\t\treturn null;\r\n\r\n\t\tvar tab = window.OpenDocument( absolutePath );\r\n\r\n\t\tif ( tab is not null && line > 0 )\r\n\t\t\ttab.Editor.GoToLine( line, Math.Max( 1, column ) );\r\n\r\n\t\treturn window;\r\n\t}\r\n\r\n\t/// <summary>Creates the window. Prefer <see cref=\"Open\"/>.</summary>\r\n\tpublic CodeWindow()\r\n\t{\r\n\t\ts_instance = this;\r\n\r\n\t\tDeleteOnClose = true;\r\n\t\tTitle = $\"{PrismConstants.ProductName} \u2014 Code\";\r\n\t\tSize = new Vector2( 1280, 820 );\r\n\r\n\t\tPrismLog.Guard( \"Prism.Text: window icon\", () => SetWindowIcon( \"code\" ) );\r\n\r\n\t\tBuildMenu();\r\n\t\tBuildStatusBar();\r\n\r\n\t\tvar host = BuildHost();\r\n\t\tDockManager.SetCentralWidget( host );\r\n\r\n\t\tBuildDocks();\r\n\r\n\t\t// Assigning the cookie restores window geometry and the saved dock layout, so every dock has\r\n\t\t// to exist by now or the restore has nothing to place.\r\n\t\tStateCookie = \"PrismCodeWindow\";\r\n\r\n\t\t// External-change detection. AssetHooks watches the content folder; without this subscriber a\r\n\t\t// file edited in another program stayed stale in the buffer here and was silently overwritten\r\n\t\t// by the next save. Nothing reloads behind the user's back \u2014 an unmodified buffer refreshes in\r\n\t\t// place, a modified one is marked and says so.\r\n\t\tAssetHooks.ShaderSourceChangedOnDisk += OnFileChangedOnDisk;\r\n\t\tAssetHooks.DocumentChangedOnDisk += OnFileChangedOnDisk;\r\n\r\n\t\tUpdateStatus();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// A file this window has open was changed by something else.\r\n\t/// <para>\r\n\t/// An untouched buffer is re-read on the spot: it has nothing to lose and showing stale text is\r\n\t/// strictly worse. A buffer with unsaved edits is left exactly as it is and the status bar says the\r\n\t/// file moved underneath it, because silently discarding the user's work \u2014 or silently keeping it\r\n\t/// and overwriting theirs \u2014 are both worse than telling them.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tvoid OnFileChangedOnDisk( string absolutePath )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( absolutePath ) || !this.IsValid() ) return;\r\n\r\n\t\tPrismLog.Guard( \"Handling an external file change\", () =>\r\n\t\t{\r\n\t\t\tvar full = Path.GetFullPath( absolutePath );\r\n\r\n\t\t\tforeach ( var tab in _tabs )\r\n\t\t\t{\r\n\t\t\t\tif ( tab?.FilePath is null ) continue;\r\n\t\t\t\tif ( !string.Equals( Path.GetFullPath( tab.FilePath ), full, StringComparison.OrdinalIgnoreCase ) ) continue;\r\n\r\n\t\t\t\tif ( tab.IsModified )\r\n\t\t\t\t{\r\n\t\t\t\t\ttab.ChangedOnDisk = true;\r\n\r\n\t\t\t\t\t_strip?.Update();\r\n\t\t\t\t\tStatusBar?.ShowMessage(\r\n\t\t\t\t\t\t$\"\\\"{tab.Title}\\\" changed on disk and has unsaved edits \u2014 File \u25b8 Reload From Disk to take theirs\" );\r\n\r\n\t\t\t\t\tcontinue;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tReloadTab( tab );\r\n\t\t\t}\r\n\t\t} );\r\n\t}\r\n\r\n\t/// <summary>Every open tab, in strip order.</summary>\r\n\tpublic IReadOnlyList<CodeTab> Tabs => _tabs;\r\n\r\n\t/// <summary>The tab currently showing, or null.</summary>\r\n\tpublic CodeTab ActiveTab => _active;\r\n\r\n\t/// <summary>The editor currently showing, or null.</summary>\r\n\tpublic CodeEditorWidget ActiveEditor => _active?.Editor;\r\n\r\n\t// ---- construction -----------------------------------------------------\r\n\r\n\tWidget BuildHost()\r\n\t{\r\n\t\tvar host = new Widget( null );\r\n\t\thost.Layout = Layout.Column();\r\n\t\thost.Layout.Margin = 0;\r\n\t\thost.Layout.Spacing = 0;\r\n\r\n\t\t_strip = new CodeTabStrip( host );\r\n\t\t_strip.TabSelected = SetActiveTab;\r\n\t\t_strip.TabClosed = tab => CloseTab( tab );\r\n\t\t_strip.NewTabRequested = () => NewDocument();\r\n\t\thost.Layout.Add( _strip );\r\n\r\n\t\t_findBar = new FindReplaceBar( host );\r\n\t\thost.Layout.Add( _findBar );\r\n\r\n\t\t_editorStack = new Widget( host );\r\n\t\t_editorStack.Layout = Layout.Column();\r\n\t\t_editorStack.Layout.Margin = 0;\r\n\t\thost.Layout.Add( _editorStack, 1 );\r\n\r\n\t\treturn host;\r\n\t}\r\n\r\n\tvoid BuildDocks()\r\n\t{\r\n\t\t_diagnosticsPanel = BuildDiagnosticsPanel();\r\n\t\t_outlinePanel = BuildOutlinePanel();\r\n\r\n\t\tDockManager.AddDock( \"Diagnostics\", \"error_outline\", _diagnosticsPanel, DockArea.Bottom );\r\n\t\tDockManager.AddDock( \"Outline\", \"list\", _outlinePanel, DockArea.Right );\r\n\t}\r\n\r\n\tWidget BuildDiagnosticsPanel()\r\n\t{\r\n\t\tvar panel = new Widget( null );\r\n\t\tpanel.Layout = Layout.Column();\r\n\t\tpanel.Layout.Margin = 0;\r\n\r\n\t\t_diagnosticsList = new ListView( panel )\r\n\t\t{\r\n\t\t\tItemSize = new Vector2( -1, 24 ),\r\n\t\t\tItemPaint = PaintDiagnosticRow,\r\n\t\t\tItemActivated = OnDiagnosticActivated,\r\n\t\t\tItemClicked = OnDiagnosticActivated\r\n\t\t};\r\n\r\n\t\tpanel.Layout.Add( _diagnosticsList, 1 );\r\n\t\treturn panel;\r\n\t}\r\n\r\n\tWidget BuildOutlinePanel()\r\n\t{\r\n\t\tvar panel = new Widget( null );\r\n\t\tpanel.Layout = Layout.Column();\r\n\t\tpanel.Layout.Margin = new Margin( 4, 4, 4, 4 );\r\n\t\tpanel.Layout.Spacing = 4;\r\n\r\n\t\t_outlineFilter = new LineEdit( panel ) { PlaceholderText = \"Filter symbols\" };\r\n\t\t_outlineFilter.TextEdited += _ => RefreshOutline( true );\r\n\t\tpanel.Layout.Add( _outlineFilter );\r\n\r\n\t\t_outlineList = new ListView( panel )\r\n\t\t{\r\n\t\t\tItemSize = new Vector2( -1, 22 ),\r\n\t\t\tItemPaint = PaintOutlineRow,\r\n\t\t\tItemActivated = OnOutlineActivated,\r\n\t\t\tItemClicked = OnOutlineActivated\r\n\t\t};\r\n\r\n\t\tpanel.Layout.Add( _outlineList, 1 );\r\n\t\treturn panel;\r\n\t}\r\n\r\n\t/// <summary>Places the docks in their default arrangement.</summary>\r\n\tprotected override void BuildDefaultLayout()\r\n\t{\r\n\t\tvar diagnostics = DockManager.OpenDock( \"Diagnostics\", DockArea.Bottom );\r\n\t\tvar outline = DockManager.OpenDock( \"Outline\", DockArea.Right );\r\n\r\n\t\tPrismLog.Guard( \"Prism.Text: default layout\", () =>\r\n\t\t{\r\n\t\t\tDockManager.SetSplitterProportions( outline, 0.78f, 0.22f );\r\n\t\t\tDockManager.SetSplitterProportions( diagnostics, 0.76f, 0.24f );\r\n\t\t} );\r\n\t}\r\n\r\n\tvoid BuildStatusBar()\r\n\t{\r\n\t\tStatusBar = new StatusBar( this );\r\n\r\n\t\t_statusPosition = new Label( \"Ln 1, Col 1\" ) { Color = PrismTheme.TextSecondary };\r\n\t\t_statusSelection = new Label( \"\" ) { Color = PrismTheme.TextMuted };\r\n\t\t_statusLanguage = new Label( \"\" ) { Color = PrismTheme.TextSecondary };\r\n\t\t_statusEncoding = new Label( \"\" ) { Color = PrismTheme.TextMuted };\r\n\r\n\t\tStatusBar.AddWidgetLeft( _statusPosition );\r\n\t\tStatusBar.AddWidgetLeft( _statusSelection );\r\n\t\tStatusBar.AddWidgetRight( _statusLanguage );\r\n\t\tStatusBar.AddWidgetRight( _statusEncoding );\r\n\t}\r\n\r\n\tvoid BuildMenu()\r\n\t{\r\n\t\tvar menu = new MenuBar( this );\r\n\t\tMenuBar = menu;\r\n\r\n\t\tmenu.AddOption( \"File/New\", \"note_add\", () => NewDocument(), \"Ctrl+N\" );\r\n\t\tmenu.AddOption( \"File/Open\u2026\", \"folder_open\", PromptOpen, \"Ctrl+O\" );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"File/Save\", \"save\", () => SaveActive(), \"Ctrl+S\" );\r\n\t\tmenu.AddOption( \"File/Save As\u2026\", \"save_as\", PromptSaveAs );\r\n\t\tmenu.AddOption( \"File/Save All\", \"done_all\", () => SaveAll(), \"Ctrl+Shift+S\" );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"File/Reload From Disk\", \"refresh\", () => ReloadTab( _active ) );\r\n\t\tmenu.AddOption( \"File/Open in External Editor\", \"open_in_new\", OpenExternally );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"File/Close Tab\", \"close\", () => { if ( _active is not null ) CloseTab( _active ); }, \"Ctrl+W\" );\r\n\t\tmenu.AddOption( \"File/Close Window\", \"logout\", Close );\r\n\r\n\t\tmenu.AddOption( \"Edit/Undo\", \"undo\", () => WithEditor( e => { e.Controller.PerformUndo(); e.EnsureCaretVisible(); } ), \"Ctrl+Z\" );\r\n\t\tmenu.AddOption( \"Edit/Redo\", \"redo\", () => WithEditor( e => { e.Controller.PerformRedo(); e.EnsureCaretVisible(); } ), \"Ctrl+Shift+Z\" );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"Edit/Cut\", \"content_cut\", () => WithEditor( e => e.Controller.Cut() ), \"Ctrl+X\" );\r\n\t\tmenu.AddOption( \"Edit/Copy\", \"content_copy\", () => WithEditor( e => e.Controller.Copy() ), \"Ctrl+C\" );\r\n\t\tmenu.AddOption( \"Edit/Paste\", \"content_paste\", () => WithEditor( e => e.Controller.Paste() ), \"Ctrl+V\" );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"Edit/Find\u2026\", \"search\", () => ShowFind( false ), \"Ctrl+F\" );\r\n\t\tmenu.AddOption( \"Edit/Replace\u2026\", \"find_replace\", () => ShowFind( true ), \"Ctrl+H\" );\r\n\t\tmenu.AddOption( \"Edit/Go To Line\u2026\", \"my_location\", ShowGoToLine, \"Ctrl+G\" );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"Edit/Toggle Comment\", \"comment\", () => WithEditor( e => e.Controller.ToggleLineComment() ), \"Ctrl+/\" );\r\n\t\tmenu.AddOption( \"Edit/Toggle Block Comment\", \"notes\", () => WithEditor( e => e.Controller.ToggleBlockComment() ) );\r\n\t\tmenu.AddOption( \"Edit/Trim Trailing Whitespace\", \"cleaning_services\", () => WithEditor( e => e.Controller.TrimTrailingWhitespace() ) );\r\n\r\n\t\tAddToggle( menu, \"View/Line Numbers\", () => ActiveEditor?.ShowLineNumbers ?? true, value => ForEachEditor( e => e.ShowLineNumbers = value ) );\r\n\t\tAddToggle( menu, \"View/Indent Guides\", () => ActiveEditor?.ShowIndentGuides ?? true, value => ForEachEditor( e => e.ShowIndentGuides = value ) );\r\n\t\tAddToggle( menu, \"View/Whitespace\", () => ActiveEditor?.ShowWhitespace ?? false, value => ForEachEditor( e => e.ShowWhitespace = value ) );\r\n\t\tAddToggle( menu, \"View/Current Line Highlight\", () => ActiveEditor?.HighlightCurrentLine ?? true, value => ForEachEditor( e => e.HighlightCurrentLine = value ) );\r\n\t\tAddToggle( menu, \"View/Occurrence Highlight\", () => ActiveEditor?.HighlightOccurrences ?? true, value => ForEachEditor( e => e.HighlightOccurrences = value ) );\r\n\t\tAddToggle( menu, \"View/Column Ruler\", () => ActiveEditor?.ShowRuler ?? false, value => ForEachEditor( e => e.ShowRuler = value ) );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"View/Zoom In\", \"zoom_in\", () => ForEachEditor( e => e.FontSize++ ), \"Ctrl++\" );\r\n\t\tmenu.AddOption( \"View/Zoom Out\", \"zoom_out\", () => ForEachEditor( e => e.FontSize-- ), \"Ctrl+-\" );\r\n\t\tmenu.AddOption( \"View/Reset Zoom\", \"search\", () => ForEachEditor( e => e.FontSize = PrismTheme.CodeSize ) );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"View/Fold All\", \"unfold_less\", () => WithEditor( e => { e.Folding?.CollapseAll(); e.LayoutScrollbars(); e.Update(); } ) );\r\n\t\tmenu.AddOption( \"View/Unfold All\", \"unfold_more\", () => WithEditor( e => { e.Folding?.ExpandAll(); e.LayoutScrollbars(); e.Update(); } ) );\r\n\r\n\t\tvar view = menu.FindOrCreateMenu( \"View\" );\r\n\r\n\t\tif ( view is not null )\r\n\t\t{\r\n\t\t\tview.AddSeparator();\r\n\t\t\tvar docks = view.AddMenu( \"Panels\", \"dashboard\" );\r\n\t\t\tdocks.AboutToShow += () => CreateDynamicViewMenu( docks );\r\n\t\t}\r\n\r\n\t\tmenu.AddOption( \"Go/Next Problem\", \"arrow_downward\", () => StepDiagnostic( 1 ), \"F8\" );\r\n\t\tmenu.AddOption( \"Go/Previous Problem\", \"arrow_upward\", () => StepDiagnostic( -1 ), \"Shift+F8\" );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"Go/Next Match\", \"navigate_next\", () => _findBar?.FindNext(), \"F3\" );\r\n\t\tmenu.AddOption( \"Go/Previous Match\", \"navigate_before\", () => _findBar?.FindNext( false ), \"Shift+F3\" );\r\n\t}\r\n\r\n\tstatic void AddToggle( MenuBar menu, string path, Func<bool> get, Action<bool> set )\r\n\t{\r\n\t\tvar option = menu.AddOption( path, null, null );\r\n\t\toption.Checkable = true;\r\n\t\toption.FetchCheckedState = get;\r\n\t\toption.Toggled += set;\r\n\t}\r\n\r\n\t// ---- tabs -------------------------------------------------------------\r\n\r\n\t/// <summary>Creates an empty buffer and focuses it.</summary>\r\n\tpublic CodeTab NewDocument( string language = \"hlsl\" )\r\n\t{\r\n\t\tvar tab = CreateTab( new TextDocument(), \"untitled\", null, language, false );\r\n\t\tSetActiveTab( tab );\r\n\t\treturn tab;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Opens a file, focusing the existing tab when it is already open. Returns null when the file\r\n\t/// could not be read.\r\n\t/// </summary>\r\n\tpublic CodeTab OpenDocument( string absolutePath )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( absolutePath ) )\r\n\t\t\treturn null;\r\n\r\n\t\tvar full = PrismLog.Guard( \"Prism.Text: resolve path\", () => Path.GetFullPath( absolutePath ), absolutePath );\r\n\r\n\t\tfor ( var i = 0; i < _tabs.Count; i++ )\r\n\t\t{\r\n\t\t\tif ( !string.Equals( _tabs[i].FilePath, full, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tSetActiveTab( _tabs[i] );\r\n\t\t\treturn _tabs[i];\r\n\t\t}\r\n\r\n\t\t// Breadcrumbs. Opening a file walks straight into the engine's shader compiler, and a native fault\r\n\t\t// there takes the process down with no managed exception and nothing in the log \u2014 so the log has\r\n\t\t// to say how far we got before it happened.\r\n\t\tPrismLog.Info( $\"Prism.Text: opening '{full}'\" );\r\n\r\n\t\tvar document = new TextDocument();\r\n\r\n\t\tif ( !document.Load( full ) )\r\n\t\t{\r\n\t\t\tStatusBar?.ShowMessage( $\"Could not open {Path.GetFileName( full )}: {document.LoadError}\" );\r\n\t\t\treturn null;\r\n\t\t}\r\n\r\n\t\tvar language = LanguageForPath( full );\r\n\r\n\t\tPrismLog.Info( $\"Prism.Text: loaded {document.LineCount} line(s), language '{language}' \u2014 building the tab\" );\r\n\r\n\t\tvar tab = CreateTab( document, Path.GetFileName( full ), full, language, false );\r\n\t\tSetActiveTab( tab );\r\n\r\n\t\tPrismLog.Info( $\"Prism.Text: '{Path.GetFileName( full )}' is open\" );\r\n\r\n\t\treturn tab;\r\n\t}\r\n\r\n\t/// <summary>Opens an in-memory buffer, such as generated shader text. Returns the new tab.</summary>\r\n\tpublic CodeTab OpenText( string title, string text, string language, bool readOnly = true )\r\n\t{\r\n\t\tfor ( var i = 0; i < _tabs.Count; i++ )\r\n\t\t{\r\n\t\t\tif ( _tabs[i].FilePath is not null || !string.Equals( _tabs[i].Title, title, StringComparison.Ordinal ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\t_tabs[i].Editor.SetText( text, language );\r\n\t\t\tSetActiveTab( _tabs[i] );\r\n\t\t\treturn _tabs[i];\r\n\t\t}\r\n\r\n\t\tvar document = new TextDocument( text ?? string.Empty );\r\n\t\tdocument.MarkSaved();\r\n\r\n\t\tvar tab = CreateTab( document, title, null, language, readOnly );\r\n\t\tSetActiveTab( tab );\r\n\t\treturn tab;\r\n\t}\r\n\r\n\tCodeTab CreateTab( TextDocument document, string title, string path, string language, bool readOnly )\r\n\t{\r\n\t\tvar editor = new CodeEditorWidget( _editorStack )\r\n\t\t{\r\n\t\t\tReadOnly = readOnly\r\n\t\t};\r\n\r\n\t\teditor.SetDocument( document, language );\r\n\t\teditor.ReadOnly = readOnly;\r\n\r\n\t\tvar tab = new CodeTab\r\n\t\t{\r\n\t\t\tEditor = editor,\r\n\t\t\tFilePath = path,\r\n\t\t\tTitle = string.IsNullOrEmpty( title ) ? \"untitled\" : title,\r\n\t\t\tLanguage = language,\r\n\t\t\tReadOnly = readOnly\r\n\t\t};\r\n\r\n\t\teditor.UnhandledKey = ( _, key ) => HandleWindowKey( key );\r\n\t\teditor.SaveRequested += _ => SaveTab( tab );\r\n\t\teditor.FindRequested += ( _, replace ) => ShowFind( replace );\r\n\t\teditor.GoToLineRequested += _ => ShowGoToLine();\r\n\t\teditor.FindStepRequested += ( _, direction ) => _findBar?.FindNext( direction >= 0 );\r\n\t\teditor.CaretMoved += _ => UpdateStatus();\r\n\t\teditor.TextChanged += _ =>\r\n\t\t{\r\n\t\t\t_strip?.Update();\r\n\t\t\tUpdateStatus();\r\n\t\t};\r\n\r\n\t\t// Completion, signature help, hover and background validation, all in one attach. A read-only\r\n\t\t// buffer still gets hover and highlighting, but not the compiler tier: it is generated text the\r\n\t\t// user cannot fix, and probe-compiling it on every keystroke it will never receive is waste.\r\n\t\ttab.Intelligence = PrismLog.Guard( \"Prism.Text: attach code intelligence\",\r\n\t\t\t() => Completion.CodeIntelligence.Attach( editor, path, !readOnly ), null );\r\n\r\n\t\t_tabs.Add( tab );\r\n\t\t_editorStack.Layout.Add( editor, 1 );\r\n\t\teditor.Visible = false;\r\n\r\n\t\t_strip.SetTabs( _tabs );\r\n\t\treturn tab;\r\n\t}\r\n\r\n\t/// <summary>Shows one tab and hides the rest.</summary>\r\n\tpublic void SetActiveTab( CodeTab tab )\r\n\t{\r\n\t\tif ( tab is null || !_tabs.Contains( tab ) )\r\n\t\t\treturn;\r\n\r\n\t\t_active = tab;\r\n\r\n\t\tfor ( var i = 0; i < _tabs.Count; i++ )\r\n\t\t\t_tabs[i].Editor.Visible = ReferenceEquals( _tabs[i], tab );\r\n\r\n\t\t_findBar.Editor = tab.Editor;\r\n\r\n\t\tif ( _findBar.Visible )\r\n\t\t\t_findBar.Refresh();\r\n\r\n\t\t_strip.SetActive( tab );\r\n\t\t_outlineVersion = -1;\r\n\r\n\t\tRefreshDiagnostics();\r\n\t\tRefreshOutline( true );\r\n\t\tUpdateStatus();\r\n\r\n\t\ttab.Editor.Focus();\r\n\t\ttab.Editor.Update();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Closes a tab. A modified buffer raises a non-blocking prompt and returns false; answering the\r\n\t/// prompt closes the tab. Pass <paramref name=\"force\"/> to skip the prompt.\r\n\t/// </summary>\r\n\tpublic bool CloseTab( CodeTab tab, bool force = false )\r\n\t{\r\n\t\tif ( tab is null || !_tabs.Contains( tab ) )\r\n\t\t\treturn false;\r\n\r\n\t\tif ( !force && tab.IsModified && !tab.ReadOnly )\r\n\t\t{\r\n\t\t\tPromptUnsaved( $\"\\\"{tab.Title}\\\" has unsaved changes.\",\r\n\t\t\t\t() => { if ( SaveTab( tab ) ) CloseTab( tab, true ); },\r\n\t\t\t\t() => CloseTab( tab, true ) );\r\n\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tvar index = _tabs.IndexOf( tab );\r\n\t\t_tabs.Remove( tab );\r\n\r\n\t\tPrismLog.Guard( \"Prism.Text: destroy editor\", () =>\r\n\t\t{\r\n\t\t\t// Before the widget, not after: the completion popup, the hover watcher and the validator\r\n\t\t\t// all hold the editor and all unsubscribe from it on dispose.\r\n\t\t\ttab.Intelligence?.Dispose();\r\n\t\t\ttab.Intelligence = null;\r\n\r\n\t\t\ttab.Editor.Teardown();\r\n\t\t\ttab.Editor.Destroy();\r\n\t\t} );\r\n\r\n\t\t_strip.SetTabs( _tabs );\r\n\r\n\t\tif ( ReferenceEquals( _active, tab ) )\r\n\t\t{\r\n\t\t\t_active = null;\r\n\r\n\t\t\tif ( _tabs.Count > 0 )\r\n\t\t\t\tSetActiveTab( _tabs[Math.Clamp( index, 0, _tabs.Count - 1 )] );\r\n\t\t\telse\r\n\t\t\t\tUpdateStatus();\r\n\t\t}\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// <summary>Saves the active tab.</summary>\r\n\tpublic bool SaveActive() => SaveTab( _active );\r\n\r\n\t/// <summary>Saves one tab, prompting for a path when it has none.</summary>\r\n\tpublic bool SaveTab( CodeTab tab )\r\n\t{\r\n\t\tif ( tab is null || tab.ReadOnly )\r\n\t\t\treturn false;\r\n\r\n\t\tif ( string.IsNullOrEmpty( tab.FilePath ) )\r\n\t\t\treturn SaveTabAs( tab );\r\n\r\n\t\tif ( !tab.Document.Save( tab.FilePath ) )\r\n\t\t{\r\n\t\t\tStatusBar?.ShowMessage( $\"Could not save {tab.Title}: {tab.Document.SaveError}\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\t// Whatever the file said before, this buffer is now what is on disk.\r\n\t\ttab.ChangedOnDisk = false;\r\n\r\n\t\tStatusBar?.ShowMessage( $\"Saved {tab.Title}\" );\r\n\t\t_strip?.Update();\r\n\t\tUpdateStatus();\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// <summary>Saves one tab to a path chosen by the user.</summary>\r\n\tpublic bool SaveTabAs( CodeTab tab )\r\n\t{\r\n\t\tif ( tab is null )\r\n\t\t\treturn false;\r\n\r\n\t\tvar dialog = new FileDialog( this ) { Title = \"Save Shader Source\" };\r\n\t\tdialog.SetModeSave();\r\n\t\tdialog.SetNameFilter( \"Shader source (*.hlsl *.slang *.shader *.fxc *.h *.txt)\" );\r\n\t\tdialog.DefaultSuffix = tab.Language == \"slang\" ? PrismConstants.SlangExtension : PrismConstants.HlslExtension;\r\n\r\n\t\tif ( !string.IsNullOrEmpty( tab.FilePath ) )\r\n\t\t\tdialog.SelectFile( tab.FilePath );\r\n\r\n\t\tif ( !dialog.Execute() )\r\n\t\t\treturn false;\r\n\r\n\t\tvar path = dialog.SelectedFile;\r\n\r\n\t\tif ( string.IsNullOrWhiteSpace( path ) )\r\n\t\t\treturn false;\r\n\r\n\t\tif ( !tab.Document.Save( path ) )\r\n\t\t{\r\n\t\t\tStatusBar?.ShowMessage( $\"Could not save: {tab.Document.SaveError}\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\ttab.FilePath = path;\r\n\t\ttab.Title = Path.GetFileName( path );\r\n\t\ttab.Language = LanguageForPath( path );\r\n\t\ttab.Editor.Language = tab.Language;\r\n\r\n\t\t// Include resolution is relative to the including file's own directory, so a buffer that just\r\n\t\t// moved resolves its includes from somewhere else now.\r\n\t\tif ( tab.Intelligence is not null ) tab.Intelligence.FilePath = path;\r\n\r\n\t\t_strip.SetTabs( _tabs );\r\n\t\tUpdateStatus();\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Re-reads a tab from disk, keeping the viewport. A modified buffer prompts first; answering the\r\n\t/// prompt performs the reload.\r\n\t/// </summary>\r\n\tpublic bool ReloadTab( CodeTab tab )\r\n\t{\r\n\t\tif ( tab?.FilePath is null )\r\n\t\t\treturn false;\r\n\r\n\t\tif ( tab.IsModified )\r\n\t\t{\r\n\t\t\tPromptUnsaved( $\"\\\"{tab.Title}\\\" has unsaved changes that reloading will discard.\",\r\n\t\t\t\t() => { if ( SaveTab( tab ) ) ReloadTab( tab ); },\r\n\t\t\t\t() => { tab.Document.MarkSaved(); ReloadTab( tab ); } );\r\n\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tvar fresh = new TextDocument();\r\n\r\n\t\tif ( !fresh.Load( tab.FilePath ) )\r\n\t\t{\r\n\t\t\tStatusBar?.ShowMessage( $\"Could not reload {tab.Title}: {fresh.LoadError}\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\ttab.Document.LineEnding = fresh.LineEnding;\r\n\t\ttab.Document.Encoding = fresh.Encoding;\r\n\t\ttab.Document.HasByteOrderMark = fresh.HasByteOrderMark;\r\n\t\ttab.Editor.SetText( fresh.Text, tab.Language );\r\n\t\ttab.ChangedOnDisk = false;\r\n\r\n\t\tStatusBar?.ShowMessage( $\"Reloaded {tab.Title}\" );\r\n\t\t_strip?.Update();\r\n\t\tUpdateStatus();\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// <summary>Saves every modified tab that has a path.</summary>\r\n\tpublic int SaveAll()\r\n\t{\r\n\t\tvar saved = 0;\r\n\r\n\t\tfor ( var i = 0; i < _tabs.Count; i++ )\r\n\t\t{\r\n\t\t\tif ( _tabs[i].IsModified && !_tabs[i].ReadOnly && SaveTab( _tabs[i] ) )\r\n\t\t\t\tsaved++;\r\n\t\t}\r\n\r\n\t\treturn saved;\r\n\t}\r\n\r\n\tvoid PromptOpen()\r\n\t{\r\n\t\tvar dialog = new FileDialog( this ) { Title = \"Open Shader Source\" };\r\n\t\tdialog.SetModeOpen();\r\n\t\tdialog.SetFindExistingFile();\r\n\t\tdialog.SetNameFilter( \"Shader source (*.hlsl *.slang *.shader *.fxc *.h *.txt)\" );\r\n\r\n\t\tif ( dialog.Execute() )\r\n\t\t\tOpenDocument( dialog.SelectedFile );\r\n\t}\r\n\r\n\tvoid PromptSaveAs() => SaveTabAs( _active );\r\n\r\n\tvoid OpenExternally()\r\n\t{\r\n\t\tvar tab = _active;\r\n\r\n\t\tif ( tab?.FilePath is null )\r\n\t\t\treturn;\r\n\r\n\t\tPrismLog.Guard( \"Prism.Text: external editor\",\r\n\t\t\t() => CodeEditor.OpenFile( tab.FilePath, tab.Editor.CaretPosition.Line + 1, tab.Editor.CaretPosition.Column + 1 ) );\r\n\t}\r\n\r\n\t/// <summary>Maps a file extension to a lexer language id.</summary>\r\n\tpublic static string LanguageForPath( string path )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( path ) )\r\n\t\t\treturn \"hlsl\";\r\n\r\n\t\tvar extension = Path.GetExtension( path ).ToLowerInvariant();\r\n\r\n\t\treturn extension switch\r\n\t\t{\r\n\t\t\t\".slang\" => \"slang\",\r\n\t\t\t\".shader\" => \"vfx\",\r\n\t\t\t\".vfx\" => \"vfx\",\r\n\t\t\t_ => \"hlsl\"\r\n\t\t};\r\n\t}\r\n\r\n\t// ---- find and navigation ----------------------------------------------\r\n\r\n\t/// <summary>Shows the find bar, optionally with the replace row.</summary>\r\n\tpublic void ShowFind( bool replace )\r\n\t{\r\n\t\tif ( _active is null )\r\n\t\t\treturn;\r\n\r\n\t\t_findBar.Editor = _active.Editor;\r\n\t\t_findBar.Open( replace );\r\n\t}\r\n\r\n\t/// <summary>Opens the go-to-line prompt.</summary>\r\n\tpublic void ShowGoToLine()\r\n\t{\r\n\t\tvar editor = ActiveEditor;\r\n\r\n\t\tif ( editor is null )\r\n\t\t\treturn;\r\n\r\n\t\tvar popup = new PopupWidget( this );\r\n\t\tpopup.Layout = Layout.Row();\r\n\t\tpopup.Layout.Margin = new Margin( 8, 6, 8, 6 );\r\n\t\tpopup.Layout.Spacing = 6;\r\n\r\n\t\tpopup.Layout.Add( new Label( $\"Go to line (1 \u2013 {editor.Document.LineCount}):\" ) );\r\n\r\n\t\tvar entry = new LineEdit( popup ) { PlaceholderText = \"line[:column]\" };\r\n\t\tentry.MinimumWidth = 120;\r\n\r\n\t\tentry.ReturnPressed += () =>\r\n\t\t{\r\n\t\t\tvar parts = (entry.Text ?? string.Empty).Split( ':', StringSplitOptions.RemoveEmptyEntries );\r\n\r\n\t\t\tif ( parts.Length > 0 && int.TryParse( parts[0].Trim(), out var line ) )\r\n\t\t\t{\r\n\t\t\t\tvar column = 1;\r\n\r\n\t\t\t\tif ( parts.Length > 1 )\r\n\t\t\t\t\tint.TryParse( parts[1].Trim(), out column );\r\n\r\n\t\t\t\teditor.GoToLine( line, Math.Max( 1, column ) );\r\n\t\t\t}\r\n\r\n\t\t\tpopup.Destroy();\r\n\t\t};\r\n\r\n\t\tpopup.Layout.Add( entry );\r\n\t\tpopup.OpenAtCursor();\r\n\t\tentry.Focus();\r\n\t}\r\n\r\n\tvoid StepDiagnostic( int direction )\r\n\t{\r\n\t\tvar editor = ActiveEditor;\r\n\r\n\t\tif ( editor is null || editor.Diagnostics.Count == 0 )\r\n\t\t\treturn;\r\n\r\n\t\tvar ordered = new List<CodeDiagnostic>( editor.Diagnostics );\r\n\t\tordered.Sort( static ( a, b ) => a.Range.Min.CompareTo( b.Range.Min ) );\r\n\r\n\t\tvar caret = editor.CaretPosition;\r\n\t\tvar target = -1;\r\n\r\n\t\tif ( direction >= 0 )\r\n\t\t{\r\n\t\t\tfor ( var i = 0; i < ordered.Count; i++ )\r\n\t\t\t{\r\n\t\t\t\tif ( ordered[i].Range.Min > caret )\r\n\t\t\t\t{\r\n\t\t\t\t\ttarget = i;\r\n\t\t\t\t\tbreak;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\tif ( target < 0 )\r\n\t\t\t\ttarget = 0;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tfor ( var i = ordered.Count - 1; i >= 0; i-- )\r\n\t\t\t{\r\n\t\t\t\tif ( ordered[i].Range.Min < caret )\r\n\t\t\t\t{\r\n\t\t\t\t\ttarget = i;\r\n\t\t\t\t\tbreak;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\tif ( target < 0 )\r\n\t\t\t\ttarget = ordered.Count - 1;\r\n\t\t}\r\n\r\n\t\teditor.Reveal( ordered[target].Range, true, 4 );\r\n\t\t_diagnosticsList?.SelectItem( ordered[target] );\r\n\t}\r\n\r\n\t// ---- diagnostics ------------------------------------------------------\r\n\r\n\t/// <summary>Pushes pipeline diagnostics onto a tab and refreshes the dock.</summary>\r\n\tpublic void SetDiagnostics( CodeTab tab, IEnumerable<PrismDiagnostic> diagnostics )\r\n\t{\r\n\t\tif ( tab is null )\r\n\t\t\treturn;\r\n\r\n\t\ttab.Editor.SetDiagnostics( diagnostics );\r\n\r\n\t\tif ( ReferenceEquals( tab, _active ) )\r\n\t\t\tRefreshDiagnostics();\r\n\t}\r\n\r\n\t/// <summary>Rebuilds the diagnostics dock from the active tab.</summary>\r\n\tpublic void RefreshDiagnostics()\r\n\t{\r\n\t\tif ( _diagnosticsList is not { IsValid: true } )\r\n\t\t\treturn;\r\n\r\n\t\tvar editor = ActiveEditor;\r\n\r\n\t\tif ( editor is null )\r\n\t\t{\r\n\t\t\t_diagnosticsList.SetItems( Array.Empty<object>() );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar items = new List<CodeDiagnostic>( editor.Diagnostics );\r\n\t\titems.Sort( static ( a, b ) =>\r\n\t\t{\r\n\t\t\tvar bySeverity = b.Severity.CompareTo( a.Severity );\r\n\t\t\treturn bySeverity != 0 ? bySeverity : a.Range.Min.CompareTo( b.Range.Min );\r\n\t\t} );\r\n\r\n\t\t_diagnosticsList.SetItems( items );\r\n\t}\r\n\r\n\tvoid OnDiagnosticActivated( object item )\r\n\t{\r\n\t\tif ( item is not CodeDiagnostic diagnostic || ActiveEditor is null )\r\n\t\t\treturn;\r\n\r\n\t\tActiveEditor.Reveal( diagnostic.Range, true, 4 );\r\n\t\tActiveEditor.Focus();\r\n\t}\r\n\r\n\tvoid PaintDiagnosticRow( VirtualWidget item )\r\n\t{\r\n\t\tif ( item.Object is not CodeDiagnostic diagnostic )\r\n\t\t\treturn;\r\n\r\n\t\tvar rect = item.Rect;\r\n\r\n\t\tif ( item.Selected )\r\n\t\t\titem.PaintBackground( PrismTheme.AccentSoft, 3f );\r\n\t\telse if ( item.Hovered )\r\n\t\t\titem.PaintBackground( PrismTheme.PanelAlt, 3f );\r\n\r\n\t\tvar color = PrismTheme.ForSeverity( diagnostic.Severity );\r\n\r\n\t\tPaint.SetPen( color );\r\n\t\tPaint.DrawIcon( new Rect( rect.Left + 4f, rect.Top, 18f, rect.Height ), IconFor( diagnostic.Severity ), 13f, TextFlag.Center );\r\n\r\n\t\tPaint.SetDefaultFont( PrismTheme.BodySize, 400, false, true );\r\n\r\n\t\tvar lineText = $\"{diagnostic.Range.Min.Line + 1}\";\r\n\t\tPaint.SetPen( PrismTheme.TextMuted );\r\n\t\tPaint.DrawText( new Rect( rect.Left + 24f, rect.Top, 42f, rect.Height ), lineText, TextFlag.LeftCenter | TextFlag.SingleLine );\r\n\r\n\t\tif ( !string.IsNullOrEmpty( diagnostic.Code ) )\r\n\t\t{\r\n\t\t\tPaint.SetPen( PrismTheme.TextDisabled );\r\n\t\t\tPaint.DrawText( new Rect( rect.Left + 66f, rect.Top, 54f, rect.Height ), diagnostic.Code, TextFlag.LeftCenter | TextFlag.SingleLine );\r\n\t\t}\r\n\r\n\t\tPaint.SetPen( item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );\r\n\t\tPaint.DrawText( new Rect( rect.Left + 124f, rect.Top, rect.Width - 130f, rect.Height ),\r\n\t\t\tdiagnostic.Message ?? string.Empty, TextFlag.LeftCenter | TextFlag.SingleLine );\r\n\t}\r\n\r\n\tstatic string IconFor( DiagnosticSeverity severity ) => severity switch\r\n\t{\r\n\t\tDiagnosticSeverity.Error => \"error\",\r\n\t\tDiagnosticSeverity.Warning => \"warning\",\r\n\t\t_ => \"info\"\r\n\t};\r\n\r\n\t// ---- outline ----------------------------------------------------------\r\n\r\n\t/// <summary>Rebuilds the outline dock from the active document.</summary>\r\n\tpublic void RefreshOutline( bool force = false )\r\n\t{\r\n\t\tif ( _outlineList is not { IsValid: true } )\r\n\t\t\treturn;\r\n\r\n\t\tvar editor = ActiveEditor;\r\n\r\n\t\tif ( editor is null )\r\n\t\t{\r\n\t\t\t_outlineList.SetItems( Array.Empty<object>() );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( !force && _outlineVersion == editor.Document.Version )\r\n\t\t\treturn;\r\n\r\n\t\t_outlineVersion = editor.Document.Version;\r\n\r\n\t\t// The real parser rather than the regex fallback: it runs over the token stream the lexer has\r\n\t\t// already produced, so it is not fooled by a declaration inside a comment or a string, and it\r\n\t\t// knows about containers and parameters the regex pass cannot see.\r\n\t\tvar symbols = PrismLog.Guard( \"Prism.Text: outline\",\r\n\t\t\t() => Completion.DocumentSymbols.For( editor.Document, editor.Language ).ToOutline(),\r\n\t\t\tnull ) ?? CodeOutline.Scan( editor.Document );\r\n\t\tvar filter = _outlineFilter?.Text;\r\n\r\n\t\tif ( !string.IsNullOrWhiteSpace( filter ) )\r\n\t\t{\r\n\t\t\tvar narrowed = new List<CodeSymbol>();\r\n\r\n\t\t\tfor ( var i = 0; i < symbols.Count; i++ )\r\n\t\t\t{\r\n\t\t\t\tif ( symbols[i].Name is not null &&\r\n\t\t\t\t symbols[i].Name.Contains( filter, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\t\tnarrowed.Add( symbols[i] );\r\n\t\t\t}\r\n\r\n\t\t\tsymbols = narrowed;\r\n\t\t}\r\n\r\n\t\t_outlineList.SetItems( symbols );\r\n\t}\r\n\r\n\tvoid OnOutlineActivated( object item )\r\n\t{\r\n\t\tif ( item is not CodeSymbol symbol || ActiveEditor is null )\r\n\t\t\treturn;\r\n\r\n\t\tActiveEditor.GoToLine( symbol.Line + 1 );\r\n\t}\r\n\r\n\tvoid PaintOutlineRow( VirtualWidget item )\r\n\t{\r\n\t\tif ( item.Object is not CodeSymbol symbol )\r\n\t\t\treturn;\r\n\r\n\t\tvar rect = item.Rect;\r\n\r\n\t\tif ( item.Selected )\r\n\t\t\titem.PaintBackground( PrismTheme.AccentSoft, 3f );\r\n\t\telse if ( item.Hovered )\r\n\t\t\titem.PaintBackground( PrismTheme.PanelAlt, 3f );\r\n\r\n\t\tvar indent = 6f + symbol.Depth * 12f;\r\n\r\n\t\tPaint.SetPen( PrismTheme.TextMuted );\r\n\t\tPaint.DrawIcon( new Rect( rect.Left + indent, rect.Top, 16f, rect.Height ), symbol.Icon, 12f, TextFlag.Center );\r\n\r\n\t\tPaint.SetDefaultFont( PrismTheme.BodySize, 400, false, true );\r\n\t\tPaint.SetPen( item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );\r\n\t\tPaint.DrawText( new Rect( rect.Left + indent + 20f, rect.Top, rect.Width - indent - 26f, rect.Height ),\r\n\t\t\tsymbol.Name ?? string.Empty, TextFlag.LeftCenter | TextFlag.SingleLine );\r\n\r\n\t\tif ( string.IsNullOrEmpty( symbol.Detail ) )\r\n\t\t\treturn;\r\n\r\n\t\tPaint.SetPen( PrismTheme.TextDisabled );\r\n\t\tPaint.DrawText( new Rect( rect.Left, rect.Top, rect.Width - 8f, rect.Height ),\r\n\t\t\tsymbol.Detail, TextFlag.RightCenter | TextFlag.SingleLine );\r\n\t}\r\n\r\n\t// ---- status -----------------------------------------------------------\r\n\r\n\tvoid UpdateStatus()\r\n\t{\r\n\t\tif ( _statusPosition is not { IsValid: true } )\r\n\t\t\treturn;\r\n\r\n\t\tvar editor = ActiveEditor;\r\n\r\n\t\tif ( editor is null )\r\n\t\t{\r\n\t\t\t_statusPosition.Text = \"\";\r\n\t\t\t_statusSelection.Text = \"\";\r\n\t\t\t_statusLanguage.Text = \"\";\r\n\t\t\t_statusEncoding.Text = \"\";\r\n\t\t\tTitle = $\"{PrismConstants.ProductName} \u2014 Code\";\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar caret = editor.CaretPosition;\r\n\t\t_statusPosition.Text = $\"Ln {caret.Line + 1}, Col {caret.Column + 1}\";\r\n\r\n\t\tvar selection = editor.Selection;\r\n\t\tvar selected = 0;\r\n\r\n\t\tfor ( var i = 0; i < selection.Count; i++ )\r\n\t\t\tselected += editor.Document.GetText( selection[i].Selection ).Length;\r\n\r\n\t\tif ( selection.Count > 1 )\r\n\t\t\t_statusSelection.Text = $\"{selection.Count} carets \u00b7 {selected} selected\";\r\n\t\telse if ( selected > 0 )\r\n\t\t\t_statusSelection.Text = $\"{selected} selected\";\r\n\t\telse\r\n\t\t\t_statusSelection.Text = \"\";\r\n\r\n\t\tvar indent = editor.Controller.UseTabs ? \"Tabs\" : $\"Spaces: {editor.Controller.IndentSize}\";\r\n\t\t_statusLanguage.Text = $\"{editor.Language.ToUpperInvariant()} \u00b7 {indent}\";\r\n\r\n\t\tvar ending = editor.Document.LineEnding switch\r\n\t\t{\r\n\t\t\tLineEndingStyle.Lf => \"LF\",\r\n\t\t\tLineEndingStyle.Cr => \"CR\",\r\n\t\t\t_ => \"CRLF\"\r\n\t\t};\r\n\r\n\t\t_statusEncoding.Text = $\"{ending} \u00b7 {(editor.Document.HasByteOrderMark ? \"UTF-8 BOM\" : \"UTF-8\")}\";\r\n\r\n\t\tvar title = _active?.DisplayTitle ?? string.Empty;\r\n\t\tTitle = string.IsNullOrEmpty( title )\r\n\t\t\t? $\"{PrismConstants.ProductName} \u2014 Code\"\r\n\t\t\t: $\"{title} \u2014 {PrismConstants.ProductName}\";\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Window-level accelerators, routed from the focused editor because it consumes every shortcut.\r\n\t/// Returns true when the key was consumed.\r\n\t/// </summary>\r\n\tbool HandleWindowKey( CodeKeyInfo key )\r\n\t{\r\n\t\tif ( key.Ctrl && !key.Alt )\r\n\t\t{\r\n\t\t\tswitch ( key.Key )\r\n\t\t\t{\r\n\t\t\t\tcase KeyCode.N when !key.Shift:\r\n\t\t\t\t\tNewDocument();\r\n\t\t\t\t\treturn true;\r\n\r\n\t\t\t\tcase KeyCode.O when !key.Shift:\r\n\t\t\t\t\tPromptOpen();\r\n\t\t\t\t\treturn true;\r\n\r\n\t\t\t\tcase KeyCode.W when !key.Shift:\r\n\t\t\t\t\tif ( _active is not null )\r\n\t\t\t\t\t\tCloseTab( _active );\r\n\r\n\t\t\t\t\treturn true;\r\n\r\n\t\t\t\tcase KeyCode.S when key.Shift:\r\n\t\t\t\t\tSaveAll();\r\n\t\t\t\t\treturn true;\r\n\r\n\t\t\t\tcase KeyCode.Tab:\r\n\t\t\t\tcase KeyCode.Backtab:\r\n\t\t\t\t\tStepTab( key.Shift ? -1 : 1 );\r\n\t\t\t\t\treturn true;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( key.Key == KeyCode.F8 )\r\n\t\t{\r\n\t\t\tStepDiagnostic( key.Shift ? -1 : 1 );\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\t/// <summary>Moves to the next or previous tab, wrapping around.</summary>\r\n\tpublic void StepTab( int direction )\r\n\t{\r\n\t\tif ( _tabs.Count < 2 || _active is null )\r\n\t\t\treturn;\r\n\r\n\t\tvar index = _tabs.IndexOf( _active );\r\n\r\n\t\tif ( index < 0 )\r\n\t\t\treturn;\r\n\r\n\t\tindex = (index + direction + _tabs.Count) % _tabs.Count;\r\n\t\tSetActiveTab( _tabs[index] );\r\n\t}\r\n\r\n\tvoid WithEditor( Action<CodeEditorWidget> action )\r\n\t{\r\n\t\tvar editor = ActiveEditor;\r\n\r\n\t\tif ( editor is null )\r\n\t\t\treturn;\r\n\r\n\t\tPrismLog.Guard( \"Prism.Text: command\", () => action( editor ) );\r\n\t\teditor.Update();\r\n\t}\r\n\r\n\tvoid ForEachEditor( Action<CodeEditorWidget> action )\r\n\t{\r\n\t\tfor ( var i = 0; i < _tabs.Count; i++ )\r\n\t\t{\r\n\t\t\tvar editor = _tabs[i].Editor;\r\n\r\n\t\t\tif ( editor is { IsValid: true } )\r\n\t\t\t\tPrismLog.Guard( \"Prism.Text: view option\", () => action( editor ) );\r\n\t\t}\r\n\t}\r\n\r\n\t[EditorEvent.Frame]\r\n\tvoid CodeWindowFrame()\r\n\t{\r\n\t\tif ( !IsValid || _sinceOutlineRefresh < 0.75f )\r\n\t\t\treturn;\r\n\r\n\t\t_sinceOutlineRefresh = 0;\r\n\t\tRefreshOutline();\r\n\t}\r\n\r\n\t/// <summary>Shows the non-blocking save / discard / cancel prompt.</summary>\r\n\tvoid PromptUnsaved( string message, Action onSave, Action onDiscard )\r\n\t{\r\n\t\tPrismLog.Guard( \"Prism.Text: unsaved prompt\", () =>\r\n\t\t{\r\n\t\t\tvar popup = new PopupDialogWidget( \"\u2753\" );\r\n\t\t\tpopup.WindowTitle = \"Unsaved Changes\";\r\n\t\t\tpopup.MessageLabel.Text = message;\r\n\r\n\t\t\tpopup.ButtonLayout.AddStretchCell();\r\n\t\t\tpopup.ButtonLayout.Add( new Button( \"Cancel\" ) { Clicked = () => popup.Destroy() } );\r\n\t\t\tpopup.ButtonLayout.Add( new Button( \"Discard\" ) { Clicked = () => { popup.Destroy(); onDiscard?.Invoke(); } } );\r\n\t\t\tpopup.ButtonLayout.Add( new Button.Primary( \"Save\" ) { Clicked = () => { popup.Destroy(); onSave?.Invoke(); } } );\r\n\r\n\t\t\tpopup.SetModal( true, true );\r\n\t\t\tpopup.Hide();\r\n\t\t\tpopup.Show();\r\n\t\t} );\r\n\t}\r\n\r\n\tbool _forceClose;\r\n\r\n\tprotected override bool OnClose()\r\n\t{\r\n\t\tif ( _forceClose )\r\n\t\t\treturn base.OnClose();\r\n\r\n\t\tvar modified = 0;\r\n\r\n\t\tfor ( var i = 0; i < _tabs.Count; i++ )\r\n\t\t{\r\n\t\t\tif ( _tabs[i].IsModified && !_tabs[i].ReadOnly )\r\n\t\t\t\tmodified++;\r\n\t\t}\r\n\r\n\t\tif ( modified == 0 )\r\n\t\t\treturn base.OnClose();\r\n\r\n\t\tPromptUnsaved( $\"{modified} file(s) have unsaved changes.\",\r\n\t\t\t() => { SaveAll(); _forceClose = true; Close(); },\r\n\t\t\t() => { _forceClose = true; Close(); } );\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tprotected override void OnClosed()\r\n\t{\r\n\t\t// AssetHooks is static and would otherwise pin this window, its tabs and every document in them\r\n\t\t// for the rest of the session.\r\n\t\tAssetHooks.ShaderSourceChangedOnDisk -= OnFileChangedOnDisk;\r\n\t\tAssetHooks.DocumentChangedOnDisk -= OnFileChangedOnDisk;\r\n\r\n\t\tif ( ReferenceEquals( s_instance, this ) )\r\n\t\t\ts_instance = null;\r\n\r\n\t\tbase.OnClosed();\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// The tab strip. Painted rather than composed, so it matches <see cref=\"PrismTheme\"/> exactly \u2014 the\r\n/// engine's <c>TabBar</c> binding exposes no managed API at all.\r\n/// </summary>\r\ninternal sealed class CodeTabStrip : Widget\r\n{\r\n\treadonly List<CodeTab> _tabs = new();\r\n\r\n\tCodeTab _active;\r\n\tCodeTab _hovered;\r\n\tbool _hoverClose;\r\n\tfloat _scroll;\r\n\r\n\tpublic CodeTabStrip( Widget parent ) : base( parent )\r\n\t{\r\n\t\tFixedHeight = 30f;\r\n\t\tMouseTracking = true;\r\n\t\tCursor = CursorShape.Finger;\r\n\t}\r\n\r\n\tpublic Action<CodeTab> TabSelected { get; set; }\r\n\tpublic Action<CodeTab> TabClosed { get; set; }\r\n\tpublic Action NewTabRequested { get; set; }\r\n\r\n\tpublic void SetTabs( IReadOnlyList<CodeTab> tabs )\r\n\t{\r\n\t\t_tabs.Clear();\r\n\r\n\t\tif ( tabs is not null )\r\n\t\t\t_tabs.AddRange( tabs );\r\n\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tpublic void SetActive( CodeTab tab )\r\n\t{\r\n\t\t_active = tab;\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( PrismTheme.Panel );\r\n\t\tPaint.DrawRect( LocalRect );\r\n\r\n\t\tPaint.SetPen( PrismTheme.BorderSubtle, 1f );\r\n\t\tPaint.DrawLine( new Vector2( 0f, LocalRect.Bottom - 0.5f ), new Vector2( LocalRect.Right, LocalRect.Bottom - 0.5f ) );\r\n\r\n\t\tPaint.SetDefaultFont( PrismTheme.BodySize, 400, false, true );\r\n\r\n\t\tvar x = 4f - _scroll;\r\n\r\n\t\tfor ( var i = 0; i < _tabs.Count; i++ )\r\n\t\t{\r\n\t\t\tvar tab = _tabs[i];\r\n\t\t\tvar caption = tab.DisplayTitle;\r\n\t\t\tvar width = Math.Clamp( Paint.MeasureText( caption ).x + 46f, 90f, 240f );\r\n\r\n\t\t\tvar rect = new Rect( x, 3f, width, LocalRect.Height - 3f );\r\n\t\t\ttab.TabRect = rect;\r\n\t\t\tx += width + 2f;\r\n\r\n\t\t\tif ( rect.Right < 0f || rect.Left > LocalRect.Right )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar isActive = ReferenceEquals( tab, _active );\r\n\t\t\tvar isHovered = ReferenceEquals( tab, _hovered );\r\n\r\n\t\t\tPaint.ClearPen();\r\n\t\t\tPaint.SetBrush( isActive ? PrismTheme.Code.Background : isHovered ? PrismTheme.PanelAlt : PrismTheme.Panel );\r\n\t\t\tPaint.DrawRect( rect, PrismTheme.RadiusChip );\r\n\r\n\t\t\tif ( isActive )\r\n\t\t\t{\r\n\t\t\t\tPaint.SetBrush( PrismTheme.Accent );\r\n\t\t\t\tPaint.DrawRect( new Rect( rect.Left, rect.Top, rect.Width, 2f ), 1f );\r\n\t\t\t}\r\n\r\n\t\t\tPaint.SetPen( isActive ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );\r\n\t\t\tPaint.DrawText( new Rect( rect.Left + 10f, rect.Top, rect.Width - 34f, rect.Height ),\r\n\t\t\t\tcaption, TextFlag.LeftCenter | TextFlag.SingleLine );\r\n\r\n\t\t\tvar closeRect = CloseRect( rect );\r\n\r\n\t\t\tPaint.SetPen( isHovered && _hoverClose ? PrismTheme.Error : PrismTheme.TextMuted );\r\n\t\t\tPaint.DrawIcon( closeRect, \"close\", 12f, TextFlag.Center );\r\n\t\t}\r\n\r\n\t\tvar plus = new Rect( x + 4f, 4f, 22f, LocalRect.Height - 8f );\r\n\r\n\t\tPaint.SetPen( PrismTheme.TextMuted );\r\n\t\tPaint.DrawIcon( plus, \"add\", 14f, TextFlag.Center );\r\n\t}\r\n\r\n\tstatic Rect CloseRect( Rect tabRect ) => new( tabRect.Right - 24f, tabRect.Top + 4f, 18f, tabRect.Height - 8f );\r\n\r\n\tprotected override void OnMouseMove( MouseEvent e )\r\n\t{\r\n\t\tvar previous = _hovered;\r\n\t\tvar previousClose = _hoverClose;\r\n\r\n\t\t_hovered = HitTest( e.LocalPosition, out _hoverClose );\r\n\r\n\t\tif ( !ReferenceEquals( previous, _hovered ) || previousClose != _hoverClose )\r\n\t\t\tUpdate();\r\n\t}\r\n\r\n\tprotected override void OnMouseLeave()\r\n\t{\r\n\t\t_hovered = null;\r\n\t\t_hoverClose = false;\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tprotected override void OnMousePress( MouseEvent e )\r\n\t{\r\n\t\tvar tab = HitTest( e.LocalPosition, out var onClose );\r\n\r\n\t\tif ( tab is null )\r\n\t\t{\r\n\t\t\tif ( e.LeftMouseButton && e.LocalPosition.x > LastTabRight() )\r\n\t\t\t\tNewTabRequested?.Invoke();\r\n\r\n\t\t\te.Accepted = true;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( e.MiddleMouseButton || (e.LeftMouseButton && onClose) )\r\n\t\t\tTabClosed?.Invoke( tab );\r\n\t\telse if ( e.LeftMouseButton )\r\n\t\t\tTabSelected?.Invoke( tab );\r\n\r\n\t\te.Accepted = true;\r\n\t}\r\n\r\n\tprotected override void OnMouseWheel( WheelEvent e )\r\n\t{\r\n\t\t_scroll = Math.Max( 0f, _scroll + (e.Delta > 0 ? -40f : 40f) );\r\n\t\tUpdate();\r\n\t\te.Accept();\r\n\t}\r\n\r\n\tfloat LastTabRight() => _tabs.Count == 0 ? 4f : _tabs[^1].TabRect.Right;\r\n\r\n\tCodeTab HitTest( Vector2 local, out bool onClose )\r\n\t{\r\n\t\tonClose = false;\r\n\r\n\t\tfor ( var i = 0; i < _tabs.Count; i++ )\r\n\t\t{\r\n\t\t\tif ( !_tabs[i].TabRect.IsInside( local ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tonClose = CloseRect( _tabs[i].TabRect ).IsInside( local );\r\n\t\t\treturn _tabs[i];\r\n\t\t}\r\n\r\n\t\treturn null;\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// A deliberately small structural scanner that feeds the outline dock: VFX block headers, macros,\r\n/// structs, constant buffers and top-level function definitions. It is a placeholder for the richer\r\n/// document-symbol parser the language-intelligence package owns; swapping it out only changes this\r\n/// file.\r\n/// </summary>\r\ninternal static class CodeOutline\r\n{\r\n\tstatic readonly string[] s_blocks =\r\n\t{\r\n\t\t\"HEADER\", \"MODES\", \"FEATURES\", \"COMMON\", \"VS\", \"PS\", \"GS\", \"CS\", \"PS_RENDER_STATE\", \"RTX\"\r\n\t};\r\n\r\n\tstatic readonly HashSet<string> s_notFunctions = new( StringComparer.Ordinal )\r\n\t{\r\n\t\t\"if\", \"for\", \"while\", \"switch\", \"return\", \"else\", \"do\", \"case\", \"sizeof\", \"defined\"\r\n\t};\r\n\r\n\tpublic static List<CodeSymbol> Scan( TextDocument document )\r\n\t{\r\n\t\tvar symbols = new List<CodeSymbol>();\r\n\r\n\t\tif ( document is null )\r\n\t\t\treturn symbols;\r\n\r\n\t\tvar depth = 0;\r\n\r\n\t\tfor ( var line = 0; line < document.LineCount; line++ )\r\n\t\t{\r\n\t\t\tvar raw = document.GetLine( line );\r\n\t\t\tvar text = raw.Trim();\r\n\t\t\tvar startDepth = depth;\r\n\r\n\t\t\tdepth += CountUnquoted( raw, '{' ) - CountUnquoted( raw, '}' );\r\n\r\n\t\t\tif ( text.Length == 0 || text.StartsWith( \"//\", StringComparison.Ordinal ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif ( startDepth == 0 && TryBlock( text, out var block ) )\r\n\t\t\t{\r\n\t\t\t\tsymbols.Add( new CodeSymbol( block, \"block\", line, \"widgets\", 0 ) );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tif ( text.StartsWith( \"#define \", StringComparison.Ordinal ) )\r\n\t\t\t{\r\n\t\t\t\tvar name = ReadIdentifier( text, 8 );\r\n\r\n\t\t\t\tif ( !string.IsNullOrEmpty( name ) )\r\n\t\t\t\t\tsymbols.Add( new CodeSymbol( name, \"define\", line, \"tag\", startDepth > 0 ? 1 : 0 ) );\r\n\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tif ( text.StartsWith( \"struct \", StringComparison.Ordinal ) )\r\n\t\t\t{\r\n\t\t\t\tvar name = ReadIdentifier( text, 7 );\r\n\r\n\t\t\t\tif ( !string.IsNullOrEmpty( name ) )\r\n\t\t\t\t\tsymbols.Add( new CodeSymbol( name, \"struct\", line, \"data_object\", startDepth > 0 ? 1 : 0 ) );\r\n\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tif ( text.StartsWith( \"cbuffer \", StringComparison.Ordinal ) )\r\n\t\t\t{\r\n\t\t\t\tvar name = ReadIdentifier( text, 8 );\r\n\r\n\t\t\t\tif ( !string.IsNullOrEmpty( name ) )\r\n\t\t\t\t\tsymbols.Add( new CodeSymbol( name, \"cbuffer\", line, \"view_list\", startDepth > 0 ? 1 : 0 ) );\r\n\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tif ( startDepth > 1 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif ( TryFunction( text, out var function, out var signature ) )\r\n\t\t\t\tsymbols.Add( new CodeSymbol( function, signature, line, \"functions\", startDepth > 0 ? 1 : 0 ) );\r\n\t\t}\r\n\r\n\t\treturn symbols;\r\n\t}\r\n\r\n\tstatic bool TryBlock( string text, out string block )\r\n\t{\r\n\t\tblock = null;\r\n\r\n\t\tvar candidate = text.EndsWith( \"{\", StringComparison.Ordinal ) ? text[..^1].Trim() : text;\r\n\r\n\t\tfor ( var i = 0; i < s_blocks.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( !string.Equals( candidate, s_blocks[i], StringComparison.Ordinal ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tblock = s_blocks[i];\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tstatic bool TryFunction( string text, out string name, out string signature )\r\n\t{\r\n\t\tname = null;\r\n\t\tsignature = null;\r\n\r\n\t\tif ( text.StartsWith( \"#\", StringComparison.Ordinal ) )\r\n\t\t\treturn false;\r\n\r\n\t\tvar open = text.IndexOf( '(' );\r\n\r\n\t\tif ( open <= 0 )\r\n\t\t\treturn false;\r\n\r\n\t\tif ( text.EndsWith( \";\", StringComparison.Ordinal ) )\r\n\t\t\treturn false;\r\n\r\n\t\tvar head = text[..open].Trim();\r\n\r\n\t\tif ( head.Length == 0 )\r\n\t\t\treturn false;\r\n\r\n\t\tvar lastSpace = head.LastIndexOfAny( new[] { ' ', '\\t', '*', '&', ':' } );\r\n\r\n\t\tif ( lastSpace <= 0 || lastSpace >= head.Length - 1 )\r\n\t\t\treturn false;\r\n\r\n\t\tvar candidate = head[(lastSpace + 1)..].Trim();\r\n\r\n\t\tif ( candidate.Length == 0 || s_notFunctions.Contains( candidate ) )\r\n\t\t\treturn false;\r\n\r\n\t\tfor ( var i = 0; i < candidate.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( !char.IsLetterOrDigit( candidate[i] ) && candidate[i] != '_' )\r\n\t\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tname = candidate;\r\n\t\tsignature = head[..lastSpace].Trim();\r\n\t\treturn true;\r\n\t}\r\n\r\n\tstatic string ReadIdentifier( string text, int start )\r\n\t{\r\n\t\tvar index = start;\r\n\r\n\t\twhile ( index < text.Length && char.IsWhiteSpace( text[index] ) )\r\n\t\t\tindex++;\r\n\r\n\t\tvar builder = new StringBuilder();\r\n\r\n\t\twhile ( index < text.Length && (char.IsLetterOrDigit( text[index] ) || text[index] == '_') )\r\n\t\t\tbuilder.Append( text[index++] );\r\n\r\n\t\treturn builder.ToString();\r\n\t}\r\n\r\n\tstatic int CountUnquoted( string text, char target )\r\n\t{\r\n\t\tvar count = 0;\r\n\t\tvar inString = false;\r\n\r\n\t\tfor ( var i = 0; i < text.Length; i++ )\r\n\t\t{\r\n\t\t\tvar c = text[i];\r\n\r\n\t\t\tif ( c == '\"' && (i == 0 || text[i - 1] != '\\\\') )\r\n\t\t\t{\r\n\t\t\t\tinString = !inString;\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tif ( inString )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif ( c == '/' && i + 1 < text.Length && text[i + 1] == '/' )\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tif ( c == target )\r\n\t\t\t\tcount++;\r\n\t\t}\r\n\r\n\t\treturn count;\r\n\t}\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Text/Diagnostics/TextDiagnosticService.cs",
"FileName": "TextDiagnosticService.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Core;\r\nusing Editor.Prism.Text.Completion;\r\nusing Editor.Prism.Text.Lexer;\r\nusing Editor.Prism.Text.LanguageDb;\r\nusing Editor.Prism.Toolchain;\r\nusing Sandbox.Engine.Shaders;\r\nusing PrismDiagnostic = Editor.Prism.Core.Diagnostic;\r\n\r\nnamespace Editor.Prism.Text.Diagnostics;\r\n\r\n/// <summary>\r\n/// Diagnostic codes produced by the text editor's own analysis, as opposed to the graph pipeline's.\r\n/// <para>\r\n/// <b>These are now forwarders.</b> The <c>PR6xxx</c> range was folded into\r\n/// <see cref=\"Core.DiagnosticCode\"/> so there is one table of codes rather than two lists of identical\r\n/// string literals that could drift apart. Every member below is defined as the corresponding\r\n/// <c>DiagnosticCode</c> constant, so the two agree by construction and not by coincidence. New\r\n/// text-tier codes go in <c>DiagnosticCode</c>; this type stays for the callers that already name it.\r\n/// </para>\r\n/// </summary>\r\npublic static class TextDiagnosticCode\r\n{\r\n\t/// <summary>A call to something nothing in scope declares.</summary>\r\n\tpublic const string UnknownIdentifier = DiagnosticCode.UnknownIdentifier;\r\n\r\n\t/// <summary>A Direct3D 9 sampler intrinsic DXC removed.</summary>\r\n\tpublic const string DeprecatedIntrinsic = DiagnosticCode.DeprecatedIntrinsic;\r\n\r\n\t/// <summary>Braces, parentheses or brackets do not balance.</summary>\r\n\tpublic const string Unbalanced = DiagnosticCode.Unbalanced;\r\n\r\n\t/// <summary>A string literal or block comment is never closed.</summary>\r\n\tpublic const string Unterminated = DiagnosticCode.Unterminated;\r\n\r\n\t/// <summary>A <c>#</c> directive the preprocessor does not know.</summary>\r\n\tpublic const string UnknownDirective = DiagnosticCode.UnknownDirective;\r\n\r\n\t/// <summary>An <c>#include</c> that resolves to no file on any search path.</summary>\r\n\tpublic const string MissingInclude = DiagnosticCode.MissingInclude;\r\n\r\n\t/// <summary>An <c>#include <\u2026></c>, which the engine's preprocessor never expands.</summary>\r\n\tpublic const string AngleBracketInclude = DiagnosticCode.AngleBracketInclude;\r\n\r\n\t/// <summary>An <c>#include</c> whose spacing the engine's regex does not match.</summary>\r\n\tpublic const string IncludeSpacing = DiagnosticCode.IncludeSpacing;\r\n\r\n\t/// <summary>A VFX block the engine's <c>.shader</c> parser throws on.</summary>\r\n\tpublic const string RejectedBlock = DiagnosticCode.RejectedBlock;\r\n\r\n\t/// <summary>A declaration that shadows an engine global.</summary>\r\n\tpublic const string ShadowedGlobal = DiagnosticCode.ShadowedGlobal;\r\n\r\n\t/// <summary>The Slang toolchain is absent, so a <c>.slang</c> buffer only gets local checks.</summary>\r\n\tpublic const string SlangNotValidated = DiagnosticCode.SlangNotValidated;\r\n}\r\n\r\n/// <summary>\r\n/// Runs the right validator for a buffer and hands back diagnostics, debounced and cancellable.\r\n/// <para>\r\n/// Three tiers, in the order they arrive. Local checks are instant, in-process and always on: unknown\r\n/// calls, DX9 intrinsics DXC removed, intrinsics above Shader Model 6.0, unbalanced brackets, unknown\r\n/// directives and unresolvable includes. They land on the editor before the user has stopped typing.\r\n/// Then the authoritative tier: a <c>.shader</c> is compiled for real by the engine, and a bare\r\n/// <c>.hlsl</c> is wrapped in the smallest legal shader that will hold it and compiled the same way \u2014\r\n/// which is how this editor gets real compiler errors for an include file, something nothing else in\r\n/// s&box does. A <c>.slang</c> goes to <c>slangc</c> when the user installed one, and quietly does\r\n/// without when they did not.\r\n/// </para>\r\n/// </summary>\r\npublic sealed class TextDiagnosticService : IDisposable\r\n{\r\n\t/// <summary>\r\n\t/// Ceiling on unknown-identifier warnings in one file. Past this the file is not wrong, it is\r\n\t/// <i>incomplete</i>, and the whole set is dropped \u2014 see <see cref=\"MaxDistinctUnknownIdentifiers\"/>.\r\n\t/// </summary>\r\n\tconst int MaxUnknownIdentifiers = 8;\r\n\r\n\t/// <summary>\r\n\t/// Ceiling on <i>distinct</i> unknown names in one file.\r\n\t/// <para>\r\n\t/// The check exists to catch an isolated mistake \u2014 a misspelled intrinsic, a helper that was renamed.\r\n\t/// Once five different names in one buffer are unresolved, the far likelier explanation is that the\r\n\t/// buffer is an include fragment whose scope its callers supply. The engine's own headers do exactly\r\n\t/// this: <c>ffx_fsr1.h</c> calls <c>ARcpF1</c> fourteen times and deliberately does not include\r\n\t/// <c>ffx_a.h</c>, and every <c>ffx_denoiser_reflections_*.h</c> calls twenty callbacks the including\r\n\t/// shader is required to define. Reporting those as mistakes is simply wrong, so nothing is reported.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tconst int MaxDistinctUnknownIdentifiers = 4;\r\n\r\n\t/// <summary>\r\n\t/// Ceiling on how often one unknown name may appear before the file is treated as incomplete. Nobody\r\n\t/// misspells the same identifier three times; a missing header goes wrong on every use.\r\n\t/// </summary>\r\n\tconst int MaxUsesOfOneUnknown = 2;\r\n\r\n\t/// <summary>\r\n\t/// How deep the include graph is walked when harvesting the names a buffer can see. Deeper than\r\n\t/// <see cref=\"CompletenessDepth\"/> on purpose: every extra name found can only remove a false\r\n\t/// positive, never create one.\r\n\t/// </summary>\r\n\tconst int IncludeDepth = 4;\r\n\r\n\t/// <summary>\r\n\t/// How deep the \"did every include resolve?\" test looks. Kept shallow deliberately: engine header\r\n\t/// trees fan out into the fourteen compiler-embedded includes within three or four hops, and a\r\n\t/// stricter test would simply stop checking two thirds of the shipped shaders.\r\n\t/// </summary>\r\n\tconst int CompletenessDepth = 2;\r\n\r\n\t/// <summary>Ceiling on files walked while harvesting, so a pathological graph cannot stall a check.</summary>\r\n\tconst int IncludeFiles = 96;\r\n\r\n\tstatic bool s_collected;\r\n\r\n\treadonly object _lock = new();\r\n\r\n\tCancellationTokenSource _inFlight;\r\n\tTempWorkspace _workspace;\r\n\tint _generation;\r\n\tbool _disposed;\r\n\r\n\tCodeEditorWidget _editor;\r\n\tAction<CodeEditorWidget> _settled;\r\n\r\n\t/// <summary>\r\n\t/// Creates a service with its own scratch folder. The folder is per instance rather than per\r\n\t/// session because two tabs editing files with the same name would otherwise compile over each\r\n\t/// other, and the output path the engine picks is derived from the file name.\r\n\t/// </summary>\r\n\tpublic TextDiagnosticService( string sessionId = null )\r\n\t{\r\n\t\tSessionId = string.IsNullOrWhiteSpace( sessionId )\r\n\t\t\t? $\"text-{Ids.NewShortId()}\"\r\n\t\t\t: sessionId;\r\n\r\n\t\t// A crashed editor leaves its scratch folders behind and nobody comes back for them. Once per\r\n\t\t// process is enough; every tab does not need to rescan the directory.\r\n\t\tif ( !s_collected )\r\n\t\t{\r\n\t\t\ts_collected = true;\r\n\r\n\t\t\tPrismLog.Guard( \"Prism.Text: collect stale scratch sessions\",\r\n\t\t\t\t() => TempWorkspace.CollectGarbage( PrismConstants.TempSessionLifetimeHours, SessionId ) );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Wires a service to an editor: validates when typing settles, pushes the result onto the editor's\r\n\t/// squiggles, and validates once immediately so a freshly opened file is not silently unchecked.\r\n\t/// </summary>\r\n\tpublic static TextDiagnosticService Attach( CodeEditorWidget editor, string filePath = null )\r\n\t{\r\n\t\tif ( editor is not { IsValid: true } )\r\n\t\t\treturn null;\r\n\r\n\t\tvar service = new TextDiagnosticService\r\n\t\t{\r\n\t\t\tFilePath = filePath ?? editor.Document?.FilePath,\r\n\t\t\t_editor = editor\r\n\t\t};\r\n\r\n\t\tservice._settled = _ => service.RequestFor( editor );\r\n\t\teditor.TextSettled += service._settled;\r\n\r\n\t\tservice.Completed += diagnostics =>\r\n\t\t{\r\n\t\t\tif ( editor is { IsValid: true } )\r\n\t\t\t\teditor.SetDiagnostics( diagnostics );\r\n\t\t};\r\n\r\n\t\tservice.RequestFor( editor );\r\n\r\n\t\treturn service;\r\n\t}\r\n\r\n\t/// <summary>The scratch session this service compiles through.</summary>\r\n\tpublic string SessionId { get; }\r\n\r\n\t/// <summary>Path of the buffer being validated. Keep it in step with Save As.</summary>\r\n\tpublic string FilePath { get; set; }\r\n\r\n\t/// <summary>Whether the authoritative compiler tier runs at all. Local checks always do.</summary>\r\n\tpublic bool UseCompiler { get; set; } = true;\r\n\r\n\t/// <summary>Whether unknown calls are reported. On by default; off for buffers full of generated macros.</summary>\r\n\tpublic bool ReportUnknownIdentifiers { get; set; } = true;\r\n\r\n\t/// <summary>How long after the last keystroke a validation starts.</summary>\r\n\tpublic int DebounceMs { get; set; } = PrismConstants.TextDebounceMs;\r\n\r\n\t/// <summary>True while a validation is running.</summary>\r\n\tpublic bool IsRunning { get; private set; }\r\n\r\n\t/// <summary>The most recent result. Never null.</summary>\r\n\tpublic IReadOnlyList<PrismDiagnostic> Last { get; private set; } = Array.Empty<PrismDiagnostic>();\r\n\r\n\t/// <summary>Raised on the main thread when a validation starts.</summary>\r\n\tpublic event Action Started;\r\n\r\n\t/// <summary>Raised on the main thread with every completed result, in order.</summary>\r\n\tpublic event Action<IReadOnlyList<PrismDiagnostic>> Completed;\r\n\r\n\t// ---- driving ----------------------------------------------------------\r\n\r\n\t/// <summary>Validates an editor's buffer. Safe to call on every keystroke.</summary>\r\n\tpublic void RequestFor( CodeEditorWidget editor )\r\n\t{\r\n\t\tif ( editor is not { IsValid: true } || editor.Document is null )\r\n\t\t\treturn;\r\n\r\n\t\tRequest( editor.Document.Text, FilePath ?? editor.Document.FilePath, editor.Language );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Validates a buffer after the debounce, cancelling whatever was already running. The local checks\r\n\t/// are published as soon as they are done so the editor never waits on the compiler to show an\r\n\t/// obviously broken line.\r\n\t/// </summary>\r\n\tpublic void Request( string text, string filePath, string language )\r\n\t{\r\n\t\tif ( _disposed )\r\n\t\t\treturn;\r\n\r\n\t\tvar generation = Interlocked.Increment( ref _generation );\r\n\r\n\t\t// Resolving includes enumerates mounted projects, which is editor state. Do it here, on the\r\n\t\t// thread the caller is on, rather than from the worker below.\r\n\t\tPrismLog.Guard( \"Prism.Text: warm include roots\", IncludeResolver.Warm );\r\n\r\n\t\tCancellationTokenSource cancellation;\r\n\r\n\t\tlock ( _lock )\r\n\t\t{\r\n\t\t\t_inFlight?.Cancel();\r\n\t\t\t_inFlight?.Dispose();\r\n\t\t\t_inFlight = new CancellationTokenSource();\r\n\t\t\tcancellation = _inFlight;\r\n\t\t}\r\n\r\n\t\tvar token = cancellation.Token;\r\n\r\n\t\t_ = Task.Run( async () =>\r\n\t\t{\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tawait Task.Delay( Math.Max( 0, DebounceMs ), token ).ConfigureAwait( false );\r\n\r\n\t\t\t\tif ( generation != Volatile.Read( ref _generation ) )\r\n\t\t\t\t\treturn;\r\n\r\n\t\t\t\tMainThread.Queue( () =>\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( generation != Volatile.Read( ref _generation ) )\r\n\t\t\t\t\t\treturn;\r\n\r\n\t\t\t\t\tIsRunning = true;\r\n\t\t\t\t\tPrismLog.Guard( \"Prism.Text: diagnostics started\", () => Started?.Invoke() );\r\n\t\t\t\t} );\r\n\r\n\t\t\t\tvar definition = LanguageDefinition.For( language );\r\n\t\t\t\tvar local = LocalChecks( text, filePath, definition, ReportUnknownIdentifiers );\r\n\r\n\t\t\t\tPublish( generation, local );\r\n\r\n\t\t\t\tif ( !UseCompiler )\r\n\t\t\t\t{\r\n\t\t\t\t\tFinish( generation );\r\n\t\t\t\t\treturn;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tvar deep = await Compile( text, filePath, definition, token ).ConfigureAwait( false );\r\n\r\n\t\t\t\tif ( token.IsCancellationRequested )\r\n\t\t\t\t\treturn;\r\n\r\n\t\t\t\tvar all = new List<PrismDiagnostic>( local );\r\n\r\n\t\t\t\tall.AddRange( deep );\r\n\r\n\t\t\t\tPublish( generation, all );\r\n\t\t\t\tFinish( generation );\r\n\t\t\t}\r\n\t\t\tcatch ( OperationCanceledException )\r\n\t\t\t{\r\n\t\t\t\t// Superseded by a newer request; the newer one publishes.\r\n\t\t\t}\r\n\t\t\tcatch ( Exception e )\r\n\t\t\t{\r\n\t\t\t\tPrismLog.Error( e, \"Prism.Text: validation failed\" );\r\n\t\t\t\tFinish( generation );\r\n\t\t\t}\r\n\t\t} );\r\n\t}\r\n\r\n\t/// <summary>Runs a validation right now, with no debounce, and hands back the result.</summary>\r\n\tpublic async Task<IReadOnlyList<PrismDiagnostic>> Validate( string text, string filePath, string language,\r\n\t\tCancellationToken ct )\r\n\t{\r\n\t\tvar definition = LanguageDefinition.For( language );\r\n\t\tvar results = new List<PrismDiagnostic>( LocalChecks( text, filePath, definition, ReportUnknownIdentifiers ) );\r\n\r\n\t\tif ( UseCompiler )\r\n\t\t\tresults.AddRange( await Compile( text, filePath, definition, ct ).ConfigureAwait( false ) );\r\n\r\n\t\treturn results;\r\n\t}\r\n\r\n\t/// <summary>Cancels whatever is running and leaves the last published result in place.</summary>\r\n\tpublic void Cancel()\r\n\t{\r\n\t\tInterlocked.Increment( ref _generation );\r\n\r\n\t\tlock ( _lock )\r\n\t\t{\r\n\t\t\t_inFlight?.Cancel();\r\n\t\t}\r\n\t}\r\n\r\n\tvoid Publish( int generation, IReadOnlyList<PrismDiagnostic> diagnostics )\r\n\t{\r\n\t\tMainThread.Queue( () =>\r\n\t\t{\r\n\t\t\tif ( _disposed || generation != Volatile.Read( ref _generation ) )\r\n\t\t\t\treturn;\r\n\r\n\t\t\tLast = diagnostics;\r\n\r\n\t\t\tPrismLog.Guard( \"Prism.Text: diagnostics published\", () => Completed?.Invoke( diagnostics ) );\r\n\t\t} );\r\n\t}\r\n\r\n\tvoid Finish( int generation )\r\n\t{\r\n\t\tMainThread.Queue( () =>\r\n\t\t{\r\n\t\t\tif ( generation != Volatile.Read( ref _generation ) )\r\n\t\t\t\treturn;\r\n\r\n\t\t\tIsRunning = false;\r\n\t\t} );\r\n\t}\r\n\r\n\t// ---- local checks -----------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Everything that can be decided without a compiler, in a single lexer pass. Fast enough to run on\r\n\t/// a keystroke and precise enough that the squiggle lands on the right token.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<PrismDiagnostic> LocalChecks( string text, string filePath,\r\n\t\tLanguageDefinition language, bool reportUnknownIdentifiers = true )\r\n\t{\r\n\t\tvar results = new List<PrismDiagnostic>();\r\n\r\n\t\tif ( string.IsNullOrEmpty( text ) )\r\n\t\t\treturn results;\r\n\r\n\t\tlanguage ??= LanguageDefinition.Hlsl;\r\n\r\n\t\tPrismLog.Guard( \"Prism.Text: local checks\",\r\n\t\t\t() => RunLocalChecks( text, filePath, language, reportUnknownIdentifiers, results ) );\r\n\r\n\t\treturn results;\r\n\t}\r\n\r\n\tstatic void RunLocalChecks( string text, string filePath, LanguageDefinition language,\r\n\t\tbool reportUnknownIdentifiers, List<PrismDiagnostic> results )\r\n\t{\r\n\t\tvar file = filePath ?? string.Empty;\r\n\t\tvar lines = text.Replace( \"\\r\\n\", \"\\n\" ).Replace( '\\r', '\\n' ).Split( '\\n' );\r\n\t\tvar lexer = Lexers.For( language.Id );\r\n\t\tvar state = LexState.Default;\r\n\t\tvar tokens = new List<Token>( 64 );\r\n\r\n\t\tvar symbols = DocumentSymbols.Parse( text, language.Id, filePath );\r\n\t\tvar declared = new HashSet<string>( StringComparer.Ordinal );\r\n\r\n\t\t// A name declared anywhere in the buffer counts, wherever the caret is and whichever branch of\r\n\t\t// the preprocessor it sits in: this pass answers \"does anything declare it\", not \"is it in scope\r\n\t\t// on line N\", and a forward reference to a function defined lower down is perfectly normal.\r\n\t\tforeach ( var symbol in symbols.All )\r\n\t\t\tdeclared.Add( symbol.Name );\r\n\r\n\t\tvar includedMacros = new HashSet<string>( StringComparer.Ordinal );\r\n\r\n\t\t// The same walk the completeness test does, so a name declared in a header we did read can never\r\n\t\t// be reported as unknown just because the harvest stopped one level shallower than the test.\r\n\t\tforeach ( var path in IncludeResolver.Transitive( text, filePath, IncludeDepth, IncludeFiles ) )\r\n\t\t{\r\n\t\t\tforeach ( var symbol in DocumentSymbols.ForFile( path, language.Id ).All )\r\n\t\t\t{\r\n\t\t\t\tdeclared.Add( symbol.Name );\r\n\r\n\t\t\t\tif ( symbol.Kind == DocumentSymbolKind.Macro )\r\n\t\t\t\t\tincludedMacros.Add( symbol.Name );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// If any include could not be read \u2014 because it is missing, or because it is one of the\r\n\t\t// fourteen that live inside the compiler and have no file at all \u2014 then we genuinely do not\r\n\t\t// know what is in scope, and every \"undeclared\" warning would be a guess. Almost every real\r\n\t\t// s&box shader reaches a compiler-embedded header eventually, so this is the common case, and\r\n\t\t// staying quiet is the only honest thing to do. The real compile still catches everything.\r\n\t\tif ( reportUnknownIdentifiers && !IncludeResolver.IsGraphComplete( text, filePath, CompletenessDepth, IncludeFiles ) )\r\n\t\t\treportUnknownIdentifiers = false;\r\n\r\n\t\tvar conditionals = reportUnknownIdentifiers ? new PreprocessorRegions( symbols, includedMacros ) : null;\r\n\t\tvar braces = new Stack<(char Kind, int Line, int Column)>();\r\n\t\tvar unknown = reportUnknownIdentifiers ? new List<PrismDiagnostic>() : null;\r\n\t\tvar unknownNames = reportUnknownIdentifiers ? new Dictionary<string, int>( StringComparer.Ordinal ) : null;\r\n\r\n\t\tfor ( var line = 0; line < lines.Length; line++ )\r\n\t\t{\r\n\t\t\tvar content = lines[line];\r\n\t\t\tvar continued = ( state.Flags & LexFlags.PreprocessorContinuation ) != 0;\r\n\r\n\t\t\ttokens.Clear();\r\n\t\t\tstate = lexer.Lex( content, state, tokens );\r\n\r\n\t\t\tif ( conditionals is not null && !continued )\r\n\t\t\t\tconditionals.Feed( content, line );\r\n\r\n\t\t\t// A directive is its own little language: `defined(X)` is not a call, and a macro body's\r\n\t\t\t// braces do not have to balance on the line they are written on.\r\n\t\t\tvar directive = continued || FirstKind( tokens ) == TokenKind.Preprocessor;\r\n\r\n\t\t\tfor ( var i = 0; i < tokens.Count; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar token = tokens[i];\r\n\r\n\t\t\t\tif ( token.Start < 0 || token.Length <= 0 || token.Start + token.Length > content.Length )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tif ( directive && token.Kind != TokenKind.Preprocessor )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar word = content.Substring( token.Start, token.Length );\r\n\r\n\t\t\t\tswitch ( token.Kind )\r\n\t\t\t\t{\r\n\t\t\t\t\tcase TokenKind.String:\r\n\t\t\t\t\t\t// A literal that runs to the end of the line without a closing quote never ends.\r\n\t\t\t\t\t\tif ( token.Start + token.Length == content.Length && token.Length >= 1 &&\r\n\t\t\t\t\t\t\t ( token.Length == 1 || content[^1] != word[0] ) )\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tresults.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,\r\n\t\t\t\t\t\t\t\tTextDiagnosticCode.Unterminated, \"Unterminated string literal\",\r\n\t\t\t\t\t\t\t\tSpan( file, line, token ) ) );\r\n\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tcase TokenKind.Comment:\r\n\t\t\t\t\tcase TokenKind.DocComment:\r\n\t\t\t\t\tcase TokenKind.Whitespace:\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tcase TokenKind.Punctuation:\r\n\t\t\t\t\t\tBalance( results, braces, word, file, line, token );\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tcase TokenKind.Preprocessor:\r\n\t\t\t\t\t\t// Only at the head of a line: `##` inside a macro body lexes the same way.\r\n\t\t\t\t\t\tif ( IsFirstOnLine( tokens, i ) )\r\n\t\t\t\t\t\t\tCheckDirective( results, language, content, line, token, tokens, i, file );\r\n\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tcase TokenKind.IncludePath:\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tcase TokenKind.BlockKeyword:\r\n\t\t\t\t\t\tif ( SboxSymbols.RejectedBlockNames.Contains( word ) )\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tresults.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,\r\n\t\t\t\t\t\t\t\tTextDiagnosticCode.RejectedBlock,\r\n\t\t\t\t\t\t\t\t$\"s&box cannot compile a {word} block\",\r\n\t\t\t\t\t\t\t\tSpan( file, line, token ),\r\n\t\t\t\t\t\t\t\t$\"The engine's .shader parser throws \\\"{word} does nothing!\\\" and the whole file \" +\r\n\t\t\t\t\t\t\t\t\"fails to load, with no diagnostic of its own.\" ) );\r\n\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\tcontinue;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif ( token.Kind is not ( TokenKind.Identifier or TokenKind.Intrinsic or TokenKind.FunctionName ) )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t// A member access is resolved by the compiler, not by us.\r\n\t\t\t\tif ( PrecededByAccess( content, token.Start ) )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tif ( IntrinsicDb.TryGet( word, out var intrinsic ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( intrinsic.Deprecated )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tresults.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,\r\n\t\t\t\t\t\t\tTextDiagnosticCode.DeprecatedIntrinsic,\r\n\t\t\t\t\t\t\t$\"'{word}' was removed by Shader Model 6\",\r\n\t\t\t\t\t\t\tSpan( file, line, token ), intrinsic.UnavailableReason ) );\r\n\r\n\t\t\t\t\t\tcontinue;\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tif ( intrinsic.NeedsHigherShaderModel )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tresults.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,\r\n\t\t\t\t\t\t\tDiagnosticCode.ShaderModelTooHigh, intrinsic.UnavailableReason,\r\n\t\t\t\t\t\t\tSpan( file, line, token ) ) );\r\n\r\n\t\t\t\t\t\tcontinue;\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tcontinue;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif ( !reportUnknownIdentifiers )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t// Code the preprocessor may never reach cannot be judged: `ffx_a.h` calls `fract` and\r\n\t\t\t\t// `mix` inside `#ifdef A_GLSL`, and whether that branch exists is decided by whoever\r\n\t\t\t\t// includes it. Only unconditional code, and code a condition we could actually evaluate\r\n\t\t\t\t// selected, is checked.\r\n\t\t\t\tif ( !conditionals.IsLive )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t// Only calls are reported. An unknown bare identifier is far more often a macro, a\r\n\t\t\t\t// combo or something a header we could not resolve declares than a real mistake.\r\n\t\t\t\tif ( !FollowedByCall( content, token.Start + token.Length ) )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tif ( declared.Contains( word ) || language.IsKnownIdentifier( word ) ||\r\n\t\t\t\t\t SboxSymbols.IsComboSymbol( word ) || SboxSymbols.IsEngineGlobal( word ) ||\r\n\t\t\t\t\t SboxSymbols.IsModeFunction( word ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tcontinue;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tunknownNames.TryGetValue( word, out var uses );\r\n\t\t\t\tunknownNames[word] = uses + 1;\r\n\r\n\t\t\t\tunknown.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Warning,\r\n\t\t\t\t\tTextDiagnosticCode.UnknownIdentifier,\r\n\t\t\t\t\t$\"Nothing in scope declares '{word}'\",\r\n\t\t\t\t\tSpan( file, line, token ),\r\n\t\t\t\t\t\"It is not an intrinsic, an s&box symbol, or declared in this file or any include \" +\r\n\t\t\t\t\t\"Prism could resolve. If it comes from a header, check the #include path.\" ) );\r\n\r\n\t\t\t\t// Past either ceiling the verdict is already \"incomplete file\", so stop collecting: a\r\n\t\t\t\t// generated header can otherwise pile up thousands of diagnostics nobody will ever see.\r\n\t\t\t\tif ( unknown.Count > MaxUnknownIdentifiers || unknownNames.Count > MaxDistinctUnknownIdentifiers )\r\n\t\t\t\t{\r\n\t\t\t\t\treportUnknownIdentifiers = false;\r\n\t\t\t\t\tunknown.Clear();\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( unknown is { Count: > 0 } && !LooksLikeFragment( unknown.Count, unknownNames ) )\r\n\t\t\tresults.AddRange( unknown );\r\n\r\n\t\tif ( ( state.Flags & LexFlags.BlockComment ) != 0 )\r\n\t\t{\r\n\t\t\tresults.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.Unterminated,\r\n\t\t\t\t\"Unterminated block comment\",\r\n\t\t\t\tSourceSpan.AtLine( file, Math.Max( 1, lines.Length ) ),\r\n\t\t\t\t\"Everything after the last /* is being treated as a comment.\" ) );\r\n\t\t}\r\n\r\n\t\twhile ( braces.Count > 0 )\r\n\t\t{\r\n\t\t\tvar (kind, line, column) = braces.Pop();\r\n\r\n\t\t\tresults.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.Unbalanced,\r\n\t\t\t\t$\"'{kind}' is never closed\",\r\n\t\t\t\tSourceSpan.At( file, line + 1, column + 1 ) ) );\r\n\t\t}\r\n\r\n\t\tresults.AddRange( IncludeResolver.Validate( text, filePath, language ) );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Whether the unknown names found in a buffer say \"this file has a mistake in it\" or \"this file is\r\n\t/// half of a translation unit\". See <see cref=\"MaxDistinctUnknownIdentifiers\"/> for the reasoning.\r\n\t/// </summary>\r\n\tstatic bool LooksLikeFragment( int total, Dictionary<string, int> names )\r\n\t{\r\n\t\tif ( total > MaxUnknownIdentifiers || names.Count > MaxDistinctUnknownIdentifiers )\r\n\t\t\treturn true;\r\n\r\n\t\tforeach ( var uses in names.Values )\r\n\t\t{\r\n\t\t\tif ( uses > MaxUsesOfOneUnknown )\r\n\t\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// A three-valued <c>#if</c> tracker: a region is <b>live</b>, <b>dead</b>, or \u2014 the case that\r\n\t/// matters \u2014 <b>undecidable</b>.\r\n\t/// <para>\r\n\t/// Only the conditions that can be settled from the buffer alone are evaluated: <c>#if 0</c>,\r\n\t/// <c>#if 1</c>, and <c>#ifdef</c> / <c>#ifndef</c> / <c>defined(X)</c> where <c>X</c> is\r\n\t/// <c>#define</c>d earlier in this file or in an include we read. The \"earlier\" matters: an include\r\n\t/// guard defines its own symbol <i>inside</i> the <c>#ifndef</c> it opens, and treating that as\r\n\t/// already-defined would mark every file dead. Everything else \u2014 <c>#ifdef A_GLSL</c>,\r\n\t/// <c>#if ( S_MODE == 2 )</c>, any arithmetic \u2014 stays undecidable, because the symbol may be defined\r\n\t/// by the shader that includes this one or by the engine's own preprocessor.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tsealed class PreprocessorRegions\r\n\t{\r\n\t\tenum Branch { Live, Dead, Unknown }\r\n\r\n\t\treadonly Dictionary<string, int> _defined = new( StringComparer.Ordinal );\r\n\t\treadonly List<Branch> _stack = new();\r\n\r\n\t\tstring _guard;\r\n\t\tint _guardDepth;\r\n\r\n\t\tpublic PreprocessorRegions( DocumentSymbols symbols, HashSet<string> fromIncludes )\r\n\t\t{\r\n\t\t\tif ( symbols is not null )\r\n\t\t\t{\r\n\t\t\t\tforeach ( var symbol in symbols.All )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( symbol.Kind != DocumentSymbolKind.Macro )\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tif ( !_defined.TryGetValue( symbol.Name, out var first ) || symbol.Line < first )\r\n\t\t\t\t\t\t_defined[symbol.Name] = symbol.Line;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\t// A macro from an include is in scope from the first line, so it gets a line number no\r\n\t\t\t// directive in this buffer can precede.\r\n\t\t\tif ( fromIncludes is null )\r\n\t\t\t\treturn;\r\n\r\n\t\t\tforeach ( var name in fromIncludes )\r\n\t\t\t\t_defined.TryAdd( name, int.MinValue );\r\n\t\t}\r\n\r\n\t\t/// <summary>True when nothing on the conditional stack is dead or undecidable.</summary>\r\n\t\tpublic bool IsLive\r\n\t\t{\r\n\t\t\tget\r\n\t\t\t{\r\n\t\t\t\tfor ( var i = 0; i < _stack.Count; i++ )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( _stack[i] != Branch.Live )\r\n\t\t\t\t\t\treturn false;\r\n\t\t\t\t}\r\n\r\n\t\t\t\treturn true;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t/// <summary>Feeds one physical line, which is a no-op unless it opens or closes a region.</summary>\r\n\t\tpublic void Feed( string line, int lineIndex )\r\n\t\t{\r\n\t\t\tvar i = 0;\r\n\r\n\t\t\twhile ( i < line.Length && ( line[i] == ' ' || line[i] == '\\t' ) )\r\n\t\t\t\ti++;\r\n\r\n\t\t\tif ( i >= line.Length || line[i] != '#' )\r\n\t\t\t\treturn;\r\n\r\n\t\t\ti++;\r\n\r\n\t\t\twhile ( i < line.Length && ( line[i] == ' ' || line[i] == '\\t' ) )\r\n\t\t\t\ti++;\r\n\r\n\t\t\tvar nameStart = i;\r\n\r\n\t\t\twhile ( i < line.Length && ( char.IsLetterOrDigit( line[i] ) || line[i] == '_' ) )\r\n\t\t\t\ti++;\r\n\r\n\t\t\tvar directive = line.Substring( nameStart, i - nameStart );\r\n\t\t\tvar rest = i < line.Length ? line.Substring( i ) : string.Empty;\r\n\t\t\tvar guard = _guard;\r\n\r\n\t\t\t_guard = null;\r\n\r\n\t\t\tswitch ( directive )\r\n\t\t\t{\r\n\t\t\t\tcase \"if\":\r\n\t\t\t\t\t_stack.Add( Evaluate( rest, lineIndex ) );\r\n\t\t\t\t\treturn;\r\n\r\n\t\t\t\tcase \"ifdef\":\r\n\t\t\t\t\t_stack.Add( DefinedBefore( FirstWord( rest ), lineIndex ) ? Branch.Live : Branch.Unknown );\r\n\t\t\t\t\treturn;\r\n\r\n\t\t\t\tcase \"ifndef\":\r\n\t\t\t\t\tvar undefined = FirstWord( rest );\r\n\r\n\t\t\t\t\t_stack.Add( DefinedBefore( undefined, lineIndex ) ? Branch.Dead : Branch.Unknown );\r\n\r\n\t\t\t\t\t// Remember it in case the next directive turns out to be its include guard.\r\n\t\t\t\t\t_guard = undefined;\r\n\t\t\t\t\t_guardDepth = _stack.Count;\r\n\t\t\t\t\treturn;\r\n\r\n\t\t\t\tcase \"define\":\r\n\t\t\t\t\t// `#ifndef FOO_H` / `#define FOO_H` is an include guard, and the first inclusion always\r\n\t\t\t\t\t// takes it. Without this, every guarded header would be one big undecidable region and\r\n\t\t\t\t\t// nothing in it would ever be checked.\r\n\t\t\t\t\tif ( guard is not null && guard.Length > 0 && _stack.Count == _guardDepth &&\r\n\t\t\t\t\t\t _stack[^1] == Branch.Unknown && FirstWord( rest ) == guard )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\t_stack[^1] = Branch.Live;\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\treturn;\r\n\r\n\t\t\t\tcase \"elif\":\r\n\t\t\t\t\t// The branch before this one was live, so this one cannot be; otherwise re-evaluate.\r\n\t\t\t\t\tif ( _stack.Count > 0 )\r\n\t\t\t\t\t\t_stack[^1] = _stack[^1] == Branch.Live ? Branch.Dead : Evaluate( rest, lineIndex );\r\n\r\n\t\t\t\t\treturn;\r\n\r\n\t\t\t\tcase \"else\":\r\n\t\t\t\t\tif ( _stack.Count > 0 )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\t_stack[^1] = _stack[^1] switch\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tBranch.Live => Branch.Dead,\r\n\t\t\t\t\t\t\tBranch.Dead => Branch.Live,\r\n\t\t\t\t\t\t\t_ => Branch.Unknown\r\n\t\t\t\t\t\t};\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\treturn;\r\n\r\n\t\t\t\tcase \"endif\":\r\n\t\t\t\t\tif ( _stack.Count > 0 )\r\n\t\t\t\t\t\t_stack.RemoveAt( _stack.Count - 1 );\r\n\r\n\t\t\t\t\treturn;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tBranch Evaluate( string expression, int lineIndex )\r\n\t\t{\r\n\t\t\tvar text = expression.Trim();\r\n\r\n\t\t\t// Strip one layer of wrapping parentheses: `#if ( 0 )` is written as often as `#if 0`.\r\n\t\t\twhile ( text.Length > 2 && text[0] == '(' && text[^1] == ')' )\r\n\t\t\t\ttext = text.Substring( 1, text.Length - 2 ).Trim();\r\n\r\n\t\t\tif ( text == \"0\" )\r\n\t\t\t\treturn Branch.Dead;\r\n\r\n\t\t\tif ( text == \"1\" )\r\n\t\t\t\treturn Branch.Live;\r\n\r\n\t\t\tvar negated = text.StartsWith( \"!\", StringComparison.Ordinal );\r\n\r\n\t\t\tif ( negated )\r\n\t\t\t\ttext = text.Substring( 1 ).TrimStart();\r\n\r\n\t\t\tif ( !text.StartsWith( \"defined\", StringComparison.Ordinal ) )\r\n\t\t\t\treturn Branch.Unknown;\r\n\r\n\t\t\tvar argument = text.Substring( \"defined\".Length ).Trim();\r\n\r\n\t\t\twhile ( argument.Length > 2 && argument[0] == '(' && argument[^1] == ')' )\r\n\t\t\t\targument = argument.Substring( 1, argument.Length - 2 ).Trim();\r\n\r\n\t\t\tvar name = FirstWord( argument );\r\n\r\n\t\t\t// `defined(A) && defined(B)` leaves a tail behind; anything left over is not decidable.\r\n\t\t\tif ( name.Length == 0 || name.Length != argument.Length )\r\n\t\t\t\treturn Branch.Unknown;\r\n\r\n\t\t\tif ( !DefinedBefore( name, lineIndex ) )\r\n\t\t\t\treturn Branch.Unknown;\r\n\r\n\t\t\treturn negated ? Branch.Dead : Branch.Live;\r\n\t\t}\r\n\r\n\t\tbool DefinedBefore( string name, int lineIndex ) =>\r\n\t\t\tname.Length > 0 && _defined.TryGetValue( name, out var line ) && line < lineIndex;\r\n\r\n\t\tstatic string FirstWord( string text )\r\n\t\t{\r\n\t\t\tvar i = 0;\r\n\r\n\t\t\twhile ( i < text.Length && ( text[i] == ' ' || text[i] == '\\t' || text[i] == '(' ) )\r\n\t\t\t\ti++;\r\n\r\n\t\t\tvar start = i;\r\n\r\n\t\t\twhile ( i < text.Length && ( char.IsLetterOrDigit( text[i] ) || text[i] == '_' ) )\r\n\t\t\t\ti++;\r\n\r\n\t\t\treturn text.Substring( start, i - start );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void Balance( List<PrismDiagnostic> results, Stack<(char, int, int)> braces, string word,\r\n\t\tstring file, int line, Token token )\r\n\t{\r\n\t\tif ( word.Length != 1 )\r\n\t\t\treturn;\r\n\r\n\t\tvar c = word[0];\r\n\r\n\t\tif ( c is '{' or '(' or '[' )\r\n\t\t{\r\n\t\t\tbraces.Push( (c, line, token.Start) );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( c is not ( '}' or ')' or ']' ) )\r\n\t\t\treturn;\r\n\r\n\t\tvar expected = c switch { '}' => '{', ')' => '(', _ => '[' };\r\n\r\n\t\tif ( braces.Count == 0 )\r\n\t\t{\r\n\t\t\tresults.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.Unbalanced,\r\n\t\t\t\t$\"'{c}' has no matching '{expected}'\", Span( file, line, token ) ) );\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar top = braces.Peek();\r\n\r\n\t\tif ( top.Item1 != expected )\r\n\t\t{\r\n\t\t\tresults.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.Unbalanced,\r\n\t\t\t\t$\"'{c}' closes a '{top.Item1}' opened on line {top.Item2 + 1}\", Span( file, line, token ) ) );\r\n\t\t}\r\n\r\n\t\tbraces.Pop();\r\n\t}\r\n\r\n\tstatic TokenKind FirstKind( List<Token> tokens )\r\n\t{\r\n\t\tfor ( var i = 0; i < tokens.Count; i++ )\r\n\t\t{\r\n\t\t\tif ( tokens[i].Kind != TokenKind.Whitespace )\r\n\t\t\t\treturn tokens[i].Kind;\r\n\t\t}\r\n\r\n\t\treturn TokenKind.None;\r\n\t}\r\n\r\n\tstatic bool IsFirstOnLine( List<Token> tokens, int index )\r\n\t{\r\n\t\tfor ( var i = 0; i < index; i++ )\r\n\t\t{\r\n\t\t\tif ( tokens[i].Kind != TokenKind.Whitespace )\r\n\t\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\tstatic void CheckDirective( List<PrismDiagnostic> results, LanguageDefinition language, string content,\r\n\t\tint line, Token hash, List<Token> tokens, int index, string file )\r\n\t{\r\n\t\t// The lexer emits `#include` as one token; a lexer that splits the hash off has to work too.\r\n\t\tvar name = content.Substring( hash.Start, hash.Length ).TrimStart( '#' ).Trim();\r\n\t\tvar end = hash.Start + hash.Length;\r\n\r\n\t\tif ( name.Length == 0 )\r\n\t\t{\r\n\t\t\tif ( index + 1 >= tokens.Count )\r\n\t\t\t\treturn;\r\n\r\n\t\t\tvar next = tokens[index + 1];\r\n\r\n\t\t\tif ( next.Start < 0 || next.Start + next.Length > content.Length )\r\n\t\t\t\treturn;\r\n\r\n\t\t\tname = content.Substring( next.Start, next.Length );\r\n\t\t\tend = next.Start + next.Length;\r\n\t\t}\r\n\r\n\t\tif ( language.IsDirective( name ) )\r\n\t\t\treturn;\r\n\r\n\t\t// `# 42 \"file\"` is a line marker the preprocessor emits; never a mistake in authored code.\r\n\t\tif ( name.Length == 0 || char.IsDigit( name[0] ) )\r\n\t\t\treturn;\r\n\r\n\t\tresults.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.UnknownDirective,\r\n\t\t\t$\"Unknown preprocessor directive '#{name}'\",\r\n\t\t\tnew SourceSpan( file, line + 1, hash.Start + 1, line + 1, end + 1 ) ) );\r\n\t}\r\n\r\n\tstatic bool PrecededByAccess( string content, int start )\r\n\t{\r\n\t\tvar i = start - 1;\r\n\r\n\t\twhile ( i >= 0 && content[i] == ' ' )\r\n\t\t\ti--;\r\n\r\n\t\tif ( i < 0 )\r\n\t\t\treturn false;\r\n\r\n\t\tif ( content[i] == '.' )\r\n\t\t\treturn true;\r\n\r\n\t\treturn i >= 1 && content[i] == ':' && content[i - 1] == ':';\r\n\t}\r\n\r\n\tstatic bool FollowedByCall( string content, int end )\r\n\t{\r\n\t\tfor ( var i = end; i < content.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( content[i] == ' ' || content[i] == '\\t' )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\treturn content[i] == '(';\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tstatic SourceSpan Span( string file, int line, Token token ) =>\r\n\t\tnew( file, line + 1, token.Start + 1, line + 1, token.Start + token.Length + 1 );\r\n\r\n\t// ---- compiler tier ----------------------------------------------------\r\n\r\n\tasync Task<IReadOnlyList<PrismDiagnostic>> Compile( string text, string filePath,\r\n\t\tLanguageDefinition definition, CancellationToken ct )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( text ) )\r\n\t\t\treturn Array.Empty<PrismDiagnostic>();\r\n\r\n\t\tvar kind = definition?.Id ?? \"hlsl\";\r\n\r\n\t\tif ( string.Equals( kind, \"slang\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\treturn await ValidateSlang( text, filePath, ct ).ConfigureAwait( false );\r\n\r\n\t\treturn await ValidateShader( text, filePath, kind, ct ).ConfigureAwait( false );\r\n\t}\r\n\r\n\tTempWorkspace Workspace\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tlock ( _lock )\r\n\t\t\t{\r\n\t\t\t\t_workspace ??= new TempWorkspace( SessionId );\r\n\t\t\t\treturn _workspace;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tasync Task<IReadOnlyList<PrismDiagnostic>> ValidateShader( string text, string filePath, string kind,\r\n\t\tCancellationToken ct )\r\n\t{\r\n\t\tvar results = new List<PrismDiagnostic>();\r\n\t\tvar stem = Stem( filePath );\r\n\r\n\t\t// A .shader is already a block file. A bare .hlsl is an include, and the engine refuses to\r\n\t\t// compile one, so it gets wrapped in the smallest legal shader that will hold it.\r\n\t\tvar probe = string.Equals( kind, \"vfx\", StringComparison.OrdinalIgnoreCase )\r\n\t\t\t? ShaderProbeBuilder.ForShaderFile( text, stem )\r\n\t\t\t: ShaderProbeBuilder.ForHlsl( text, new ShaderProbeOptions { Name = stem } );\r\n\r\n\t\tvar fileName = $\"{stem}.{PrismConstants.ShaderExtension}\";\r\n\t\tvar workspace = Workspace;\r\n\r\n\t\tif ( !workspace.IsValid || !workspace.Write( fileName, probe.Text ) )\r\n\t\t{\r\n\t\t\tresults.Add( PrismDiagnostic.Info( DiagnosticCode.CompilerRaw,\r\n\t\t\t\t\"Prism could not write its scratch shader, so only local checks ran\" ) );\r\n\r\n\t\t\treturn results;\r\n\t\t}\r\n\r\n\t\tvar relative = workspace.Relative( fileName );\r\n\r\n\t\tvar options = new ShaderCompileOptions\r\n\t\t{\r\n\t\t\tForceRecompile = false,\r\n\t\t\tConsoleOutput = false,\r\n\t\t\tSingleThreaded = false\r\n\t\t};\r\n\r\n\t\tShaderCompile.Results compiled = null;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\t// Back to the main thread before touching the engine compiler.\r\n\t\t\t//\r\n\t\t\t// EditorUtility.CompileShader reaches straight into native code: Shader.LoadFromSource, the\r\n\t\t\t// vfx_vulkan.dll interface (lazily loaded by ShaderCompile's static constructor, on whatever\r\n\t\t\t// thread happens to touch it first), FinalizeCompile, InitializeWrite and the native resource\r\n\t\t\t// compiler. None of that is thread-affine by contract, and none of it is documented as safe to\r\n\t\t\t// call from anywhere.\r\n\t\t\t//\r\n\t\t\t// Every call site in the engine invokes it from the main thread and simply awaits \u2014 see\r\n\t\t\t// ShaderGraph's MainWindow, ShaderHooks and StartupLoadProject. The engine offloads the part\r\n\t\t\t// that is actually parallel itself: ProgramSource.CompileCore wraps the combo loop in its own\r\n\t\t\t// Task.Run/Parallel.ForEach. Wrapping the whole call in Task.Run, as this used to, put the\r\n\t\t\t// serial native prologue and epilogue on a pool thread instead, which no engine code ever\r\n\t\t\t// does. Awaiting from the main thread does not block the editor \u2014 the await yields, and the\r\n\t\t\t// expensive combo loop still runs on the pool where the engine put it.\r\n\t\t\tawait MainThread.Wait();\r\n\r\n\t\t\tPrismLog.Info( $\"Prism.Text: compiling '{relative}' through the engine shader compiler\" );\r\n\r\n\t\t\tcompiled = await EditorUtility.CompileShader( Editor.FileSystem.Root, relative, options, ct );\r\n\r\n\t\t\tPrismLog.Info( $\"Prism.Text: engine compile of '{relative}' returned \" +\r\n\t\t\t\t$\"success={compiled?.Success}, programs={compiled?.Programs?.Count ?? 0}\" );\r\n\t\t}\r\n\t\tcatch ( OperationCanceledException )\r\n\t\t{\r\n\t\t\tthrow;\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tPrismLog.Error( e, \"Prism.Text: the engine shader compiler threw\" );\r\n\r\n\t\t\tresults.Add( PrismDiagnostic.Error( DiagnosticCode.CompilerRaw,\r\n\t\t\t\t\"The engine shader compiler failed\", null, e.Message ) );\r\n\r\n\t\t\treturn results;\r\n\t\t}\r\n\r\n\t\tif ( compiled is null )\r\n\t\t\treturn results;\r\n\r\n\t\tvar programs = compiled.Programs ?? new List<ShaderCompile.Results.Program>();\r\n\r\n\t\tif ( !compiled.Success && programs.Count == 0 )\r\n\t\t{\r\n\t\t\tresults.Add( probe.MapBack( CompilerOutputParser.BlockHeaderFailure( fileName ), filePath ) );\r\n\t\t\treturn results;\r\n\t\t}\r\n\r\n\t\tvar seen = new HashSet<string>( StringComparer.Ordinal );\r\n\r\n\t\tforeach ( var program in programs )\r\n\t\t{\r\n\t\t\tif ( program?.Output is not { Count: > 0 } )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar map = LineDirectiveMap.Build( program.Source, fileName ).Calibrate( probe.Text );\r\n\t\t\tvar parsed = CompilerOutputParser.Parse( program.Output, fileName );\r\n\t\t\tvar stage = CompilerOutputParser.Pretty( program.Name );\r\n\r\n\t\t\tforeach ( var diagnostic in map.RemapAll( parsed, null, fileName ) )\r\n\t\t\t{\r\n\t\t\t\tvar mapped = probe.MapBack( diagnostic, filePath );\r\n\r\n\t\t\t\tif ( mapped is null )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t// The same COMMON-block error is reported once per program; show it once.\r\n\t\t\t\tif ( !seen.Add( $\"{mapped.Severity}|{mapped.Code}|{mapped.Span}|{mapped.Message}\" ) )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tresults.Add( string.IsNullOrEmpty( stage )\r\n\t\t\t\t\t? mapped\r\n\t\t\t\t\t: mapped with\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tDetail = string.IsNullOrWhiteSpace( mapped.Detail )\r\n\t\t\t\t\t\t\t? $\"Reported while compiling {stage}.\"\r\n\t\t\t\t\t\t\t: $\"{mapped.Detail}\\nReported while compiling {stage}.\"\r\n\t\t\t\t\t} );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn results;\r\n\t}\r\n\r\n\tasync Task<IReadOnlyList<PrismDiagnostic>> ValidateSlang( string text, string filePath, CancellationToken ct )\r\n\t{\r\n\t\tvar validator = SlangToolchain.CreateValidator();\r\n\r\n\t\tif ( validator is null || !validator.Available )\r\n\t\t{\r\n\t\t\treturn new[]\r\n\t\t\t{\r\n\t\t\t\tPrismDiagnostic.Info( TextDiagnosticCode.SlangNotValidated,\r\n\t\t\t\t\t\"Slang is not validated: no slangc was found\",\r\n\t\t\t\t\tnull,\r\n\t\t\t\t\t\"Install the Slang toolchain from Preferences to have slangc check this file. Local \" +\r\n\t\t\t\t\t\"checks still run, and nothing else in Prism depends on it.\" )\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\tvar probe = ShaderProbeBuilder.ForSlang( text, new ShaderProbeOptions { Name = Stem( filePath ) } );\r\n\t\tvar entries = ShaderProbeBuilder.DiscoverSlangEntryPoints( probe.Text );\r\n\r\n\t\tvar request = new SlangValidationRequest\r\n\t\t{\r\n\t\t\tSource = probe.Text,\r\n\t\t\tEntryPoints = entries,\r\n\t\t\tDisplayName = probe.FileName,\r\n\t\t\tIncludePaths = IncludeResolver.SearchRoots.ToArray()\r\n\t\t};\r\n\r\n\t\tvar diagnostics = await validator.Validate( request, ct ).ConfigureAwait( false );\r\n\r\n\t\treturn probe.MapBack( diagnostics, filePath );\r\n\t}\r\n\r\n\tstatic string Stem( string filePath )\r\n\t{\r\n\t\tvar name = string.IsNullOrWhiteSpace( filePath )\r\n\t\t\t? \"buffer\"\r\n\t\t\t: System.IO.Path.GetFileNameWithoutExtension( filePath );\r\n\r\n\t\tif ( string.IsNullOrWhiteSpace( name ) )\r\n\t\t\tname = \"buffer\";\r\n\r\n\t\tvar clean = new System.Text.StringBuilder( name.Length );\r\n\r\n\t\tforeach ( var c in name )\r\n\t\t\tclean.Append( char.IsLetterOrDigit( c ) || c == '_' ? c : '_' );\r\n\r\n\t\treturn \"prism_text_\" + clean;\r\n\t}\r\n\r\n\t/// <summary>Stops any work, drops the scratch folder and detaches from the editor.</summary>\r\n\tpublic void Dispose()\r\n\t{\r\n\t\tif ( _disposed )\r\n\t\t\treturn;\r\n\r\n\t\t_disposed = true;\r\n\r\n\t\tCancel();\r\n\r\n\t\tif ( _editor is { IsValid: true } && _settled is not null )\r\n\t\t\t_editor.TextSettled -= _settled;\r\n\r\n\t\t_editor = null;\r\n\t\t_settled = null;\r\n\r\n\t\tlock ( _lock )\r\n\t\t{\r\n\t\t\tPrismLog.Guard( \"Prism.Text: drop scratch workspace\", () => _workspace?.Dispose() );\r\n\r\n\t\t\t_workspace = null;\r\n\r\n\t\t\t_inFlight?.Dispose();\r\n\t\t\t_inFlight = null;\r\n\t\t}\r\n\t}\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Ui/HistoryPanel.cs",
"FileName": "HistoryPanel.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Core;\r\nusing Editor.Prism.Undo;\r\nusing Margin = Sandbox.UI.Margin;\r\n\r\nnamespace Editor.Prism.Ui;\r\n\r\n/// <summary>One row of the History panel: a level in the undo stack.</summary>\r\ninternal sealed class HistoryRow\r\n{\r\n\t/// <summary>The stack level this row jumps to. Zero is the document as opened.</summary>\r\n\tpublic int Level { get; init; }\r\n\r\n\t/// <summary>The label of the edit.</summary>\r\n\tpublic string Name { get; init; }\r\n\r\n\t/// <summary>True when this is where the document currently sits.</summary>\r\n\tpublic bool IsCurrent { get; init; }\r\n\r\n\t/// <summary>True when this row is ahead of the current level, i.e. redoable.</summary>\r\n\tpublic bool IsFuture { get; init; }\r\n\r\n\t/// <summary>When the edit was committed.</summary>\r\n\tpublic DateTime Time { get; init; }\r\n\r\n\t/// <summary>How many characters the snapshot pair costs.</summary>\r\n\tpublic int Size { get; init; }\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override string ToString() => $\"{Level}. {Name}\";\r\n}\r\n\r\n/// <summary>\r\n/// The History dock: the undo stack, as a list you can click.\r\n/// <para>\r\n/// Prism records snapshots rather than commands, so every level is a complete, valid document and\r\n/// jumping to any of them is exactly as safe as jumping to the one next door. That makes a clickable\r\n/// history honest rather than a trap \u2014 which is why it gets a panel instead of two toolbar arrows.\r\n/// </para>\r\n/// </summary>\r\npublic sealed class HistoryPanel : Widget\r\n{\r\n\t/// <summary>The dock name this panel registers under. Frozen.</summary>\r\n\tpublic const string DockName = \"History\";\r\n\r\n\treadonly List<HistoryRow> _rows = new();\r\n\r\n\tPrismSession _session;\r\n\tPrismUndoStack _undo;\r\n\tListView _list;\r\n\tPrismEmptyState _empty;\r\n\tLabel _status;\r\n\r\n\tbool _rebuildQueued;\r\n\r\n\t/// <summary>Build the panel. A null session is legal and shows the empty state.</summary>\r\n\tpublic HistoryPanel( PrismSession session ) : base( null )\r\n\t{\r\n\t\tName = \"PrismHistory\";\r\n\t\tWindowTitle = DockName;\r\n\r\n\t\tLayout = Layout.Column();\r\n\t\tLayout.Margin = 0;\r\n\t\tLayout.Spacing = 0;\r\n\r\n\t\t_list = new ListView( this )\r\n\t\t{\r\n\t\t\tItemSize = new Vector2( -1, PrismPanelChrome.RowHeight ),\r\n\t\t\tItemPaint = PaintRow,\r\n\t\t\tItemClicked = OnRowClicked,\r\n\t\t\tItemActivated = OnRowClicked,\r\n\t\t\tItemContextMenu = OnRowContextMenu,\r\n\t\t\tMultiSelect = false\r\n\t\t};\r\n\r\n\t\tLayout.Add( _list, 1 );\r\n\r\n\t\t_empty = new PrismEmptyState( this, \"history\", \"Nothing to undo yet\",\r\n\t\t\t\"Every edit you make appears here. Click one to jump back to it.\" );\r\n\r\n\t\tLayout.Add( _empty, 1 );\r\n\r\n\t\t_status = new Label( string.Empty ) { Color = PrismTheme.TextMuted };\r\n\t\t_status.ContentMargins = new Margin( 8, 2, 8, 4 );\r\n\t\tLayout.Add( _status );\r\n\r\n\t\tBind( session );\r\n\t}\r\n\r\n\t/// <summary>Material icon shown on the dock tab.</summary>\r\n\tpublic string DockIcon => \"history\";\r\n\r\n\t/// <summary>The session this panel is bound to. Null is legal.</summary>\r\n\tpublic PrismSession Session => _session;\r\n\r\n\t// ---------------------------------------------------------------- binding ----\r\n\r\n\tvoid Bind( PrismSession session )\r\n\t{\r\n\t\tUnbind();\r\n\r\n\t\t_session = session;\r\n\r\n\t\tif ( _session is not null )\r\n\t\t{\r\n\t\t\t_session.DocumentReplaced += OnDocumentReplaced;\r\n\t\t\tAttach( _session.Undo );\r\n\t\t}\r\n\r\n\t\tRebuild();\r\n\t}\r\n\r\n\tvoid Attach( PrismUndoStack undo )\r\n\t{\r\n\t\tif ( ReferenceEquals( _undo, undo ) ) return;\r\n\r\n\t\tif ( _undo is not null )\r\n\t\t{\r\n\t\t\t_undo.Changed -= QueueRebuild;\r\n\t\t\t_undo.Restored -= QueueRebuild;\r\n\t\t}\r\n\r\n\t\t_undo = undo;\r\n\r\n\t\tif ( _undo is null ) return;\r\n\r\n\t\t_undo.Changed += QueueRebuild;\r\n\t\t_undo.Restored += QueueRebuild;\r\n\t}\r\n\r\n\tvoid Unbind()\r\n\t{\r\n\t\tAttach( null );\r\n\r\n\t\tif ( _session is null ) return;\r\n\r\n\t\t_session.DocumentReplaced -= OnDocumentReplaced;\r\n\t\t_session = null;\r\n\t}\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override void OnDestroyed()\r\n\t{\r\n\t\tUnbind();\r\n\t\tbase.OnDestroyed();\r\n\t}\r\n\r\n\tvoid OnDocumentReplaced()\r\n\t{\r\n\t\tAttach( _session?.Undo );\r\n\t\tQueueRebuild();\r\n\t}\r\n\r\n\tvoid QueueRebuild()\r\n\t{\r\n\t\tif ( _rebuildQueued ) return;\r\n\r\n\t\t_rebuildQueued = true;\r\n\r\n\t\tMainThread.Queue( () =>\r\n\t\t{\r\n\t\t\t_rebuildQueued = false;\r\n\r\n\t\t\tif ( !this.IsValid() ) return;\r\n\r\n\t\t\tRebuild();\r\n\t\t} );\r\n\t}\r\n\r\n\t// ---------------------------------------------------------------- model ----\r\n\r\n\tvoid Rebuild()\r\n\t{\r\n\t\t_rows.Clear();\r\n\r\n\t\tif ( _undo is null )\r\n\t\t{\r\n\t\t\t_empty.Set( \"No document\", \"Open a graph to see its edit history.\" );\r\n\t\t\t_empty.Visible = true;\r\n\t\t\t_list.Visible = false;\r\n\t\t\t_status.Text = string.Empty;\r\n\t\t\t_list.SetItems( _rows );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar level = _undo.Level;\r\n\r\n\t\tforeach ( var item in _undo.History )\r\n\t\t{\r\n\t\t\t_rows.Add( new HistoryRow\r\n\t\t\t{\r\n\t\t\t\tLevel = item.Level,\r\n\t\t\t\tName = item.Name,\r\n\t\t\t\tIsCurrent = item.IsCurrent,\r\n\t\t\t\tIsFuture = item.Level > level,\r\n\t\t\t\tTime = item.Time,\r\n\t\t\t\tSize = item.Size\r\n\t\t\t} );\r\n\t\t}\r\n\r\n\t\tvar meaningful = _undo.Count > 0;\r\n\r\n\t\t_empty.Visible = !meaningful;\r\n\t\t_list.Visible = meaningful;\r\n\r\n\t\tif ( !meaningful )\r\n\t\t{\r\n\t\t\t_empty.Set( \"Nothing to undo yet\", \"Every edit you make appears here. Click one to jump back to it.\" );\r\n\t\t}\r\n\r\n\t\t_list.SetItems( _rows );\r\n\r\n\t\tvar current = _rows.FirstOrDefault( x => x.IsCurrent );\r\n\r\n\t\tif ( current is not null )\r\n\t\t{\r\n\t\t\t_list.SelectItem( current, false, true );\r\n\t\t\tPrismLog.Guard( \"History: scroll to current\", () => _list.ScrollTo( current ) );\r\n\t\t}\r\n\r\n\t\t_status.Text = _undo.Describe();\r\n\t}\r\n\r\n\t// ---------------------------------------------------------------- painting ----\r\n\r\n\tvoid PaintRow( VirtualWidget item )\r\n\t{\r\n\t\tif ( item.Object is not HistoryRow row ) return;\r\n\r\n\t\tvar rect = item.Rect;\r\n\t\tvar index = _rows.IndexOf( row );\r\n\r\n\t\tPrismPanelChrome.PaintRow( rect, index, item.Hovered, row.IsCurrent );\r\n\r\n\t\tvar alpha = row.IsFuture ? 0.42f : 1f;\r\n\t\tvar inner = rect.Shrink( PrismPanelChrome.Pad, 0f, PrismPanelChrome.Pad, 0f );\r\n\r\n\t\tvar icon = IconFor( row );\r\n\t\tvar color = row.IsCurrent ? PrismTheme.Accent : PrismTheme.TextMuted;\r\n\r\n\t\tPaint.SetPen( color.WithAlpha( alpha ) );\r\n\t\tPaint.DrawIcon( new Rect( inner.Left, inner.Top, 16f, inner.Height ), icon, 13f, TextFlag.Center );\r\n\r\n\t\tvar right = inner.Right;\r\n\r\n\t\tif ( inner.Width > 170f )\r\n\t\t{\r\n\t\t\tvar time = row.Time == default ? string.Empty : row.Time.ToLocalTime().ToString( \"HH:mm:ss\" );\r\n\r\n\t\t\tif ( !string.IsNullOrEmpty( time ) )\r\n\t\t\t{\r\n\t\t\t\tPaint.SetFont( PrismTheme.FontFamily, 10, 400, false, true );\r\n\t\t\t\tPaint.SetPen( PrismTheme.TextDisabled.WithAlpha( alpha ) );\r\n\t\t\t\tPaint.DrawText( new Rect( right - 52f, inner.Top, 52f, inner.Height ), time,\r\n\t\t\t\t\tTextFlag.RightCenter | TextFlag.SingleLine );\r\n\r\n\t\t\t\tright -= 58f;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( row.IsCurrent && inner.Width > 220f )\r\n\t\t{\r\n\t\t\tPaint.SetFont( PrismTheme.FontFamily, 9, 600, false, true );\r\n\t\t\tPaint.SetPen( PrismTheme.Accent );\r\n\t\t\tPaint.DrawText( new Rect( right - 44f, inner.Top, 44f, inner.Height ), \"CURRENT\",\r\n\t\t\t\tTextFlag.RightCenter | TextFlag.SingleLine );\r\n\r\n\t\t\tright -= 50f;\r\n\t\t}\r\n\r\n\t\tvar nameRect = new Rect( inner.Left + 20f, inner.Top,\r\n\t\t\tMathF.Max( 20f, right - inner.Left - 20f ), inner.Height );\r\n\r\n\t\tPrismPaint.Text( nameRect, row.Name,\r\n\t\t\t( row.IsCurrent ? PrismTheme.TextPrimary : PrismTheme.TextSecondary ).WithAlpha( alpha ),\r\n\t\t\tPrismTheme.BodySize, row.IsCurrent ? 500 : 400 );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The glyph for an edit, matched on its label. Undo entries are labelled by the mutation API from\r\n\t/// a small, stable vocabulary, so a prefix match is reliable and one unknown label degrades to a\r\n\t/// generic pencil rather than a blank row.\r\n\t/// </summary>\r\n\tstatic string IconFor( HistoryRow row )\r\n\t{\r\n\t\tif ( row.Level == 0 ) return PrismIcons.Open;\r\n\r\n\t\tvar name = row.Name ?? string.Empty;\r\n\r\n\t\tif ( name.StartsWith( \"Add\", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Add;\r\n\t\tif ( name.StartsWith( \"Delete\", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Delete;\r\n\t\tif ( name.StartsWith( \"Move\", StringComparison.OrdinalIgnoreCase ) ) return \"open_with\";\r\n\t\tif ( name.StartsWith( \"Resize\", StringComparison.OrdinalIgnoreCase ) ) return \"aspect_ratio\";\r\n\t\tif ( name.StartsWith( \"Create Connection\", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Connect;\r\n\t\tif ( name.StartsWith( \"Disconnect\", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Disconnect;\r\n\t\tif ( name.StartsWith( \"Reroute\", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Reroute;\r\n\t\tif ( name.StartsWith( \"Route\", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Reroute;\r\n\t\tif ( name.StartsWith( \"Paste\", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Paste;\r\n\t\tif ( name.StartsWith( \"Cut\", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Cut;\r\n\t\tif ( name.StartsWith( \"Duplicate\", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Duplicate;\r\n\t\tif ( name.StartsWith( \"Rename\", StringComparison.OrdinalIgnoreCase ) ) return \"edit\";\r\n\t\tif ( name.StartsWith( \"Reorder\", StringComparison.OrdinalIgnoreCase ) ) return \"swap_vert\";\r\n\t\tif ( name.StartsWith( \"Change Settings\", StringComparison.OrdinalIgnoreCase ) ) return \"settings\";\r\n\t\tif ( name.StartsWith( \"Change Preview\", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Preview;\r\n\t\tif ( name.StartsWith( \"Set\", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Parameter;\r\n\t\tif ( name.StartsWith( \"Edit\", StringComparison.OrdinalIgnoreCase ) ) return \"edit\";\r\n\r\n\t\treturn \"edit_note\";\r\n\t}\r\n\r\n\t/// <inheritdoc/>\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( PrismTheme.Panel );\r\n\t\tPaint.DrawRect( LocalRect );\r\n\t}\r\n\r\n\t// ---------------------------------------------------------------- interaction ----\r\n\r\n\tvoid OnRowClicked( object item )\r\n\t{\r\n\t\tif ( item is not HistoryRow row || _undo is null ) return;\r\n\t\tif ( row.IsCurrent ) return;\r\n\r\n\t\tif ( !_undo.JumpTo( row.Level ) ) return;\r\n\r\n\t\t_session?.MarkDirty();\r\n\t\t_session?.Touch();\r\n\t}\r\n\r\n\tvoid OnRowContextMenu( object item )\r\n\t{\r\n\t\tvar menu = new Menu( this );\r\n\r\n\t\tif ( item is HistoryRow row && !row.IsCurrent )\r\n\t\t{\r\n\t\t\tmenu.AddOption( $\"Jump To \u201c{row.Name}\u201d\", \"history\", () => OnRowClicked( row ) );\r\n\t\t\tmenu.AddSeparator();\r\n\t\t}\r\n\r\n\t\tmenu.AddOption( \"Undo\", PrismIcons.Undo, () => { _undo?.Undo(); _session?.Touch(); } )\r\n\t\t\t.Enabled = _undo is { CanUndo: true };\r\n\r\n\t\tmenu.AddOption( \"Redo\", PrismIcons.Redo, () => { _undo?.Redo(); _session?.Touch(); } )\r\n\t\t\t.Enabled = _undo is { CanRedo: true };\r\n\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"Clear History\", PrismIcons.Delete, () => { _undo?.Clear(); Rebuild(); } );\r\n\t\tmenu.AddOption( \"Copy Stack Dump\", PrismIcons.Copy,\r\n\t\t\t() => EditorUtility.Clipboard.Copy( _undo?.Dump() ?? string.Empty ) );\r\n\r\n\t\tmenu.OpenAtCursor( false );\r\n\t}\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Compiler/Backends/HlslBackend.cs",
"FileName": "HlslBackend.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using System.Text;\r\nusing Editor.Prism.Compiler.Ir;\r\nusing Editor.Prism.Core;\r\n\r\nnamespace Editor.Prism.Compiler.Backends;\r\n\r\n/// <summary>\r\n/// A text buffer that remembers which graph node produced each line it wrote.\r\n/// <para>\r\n/// Every backend writes through this rather than a bare <see cref=\"StringBuilder\"/>, because the\r\n/// generated-line to <see cref=\"NodeId\"/> map is what turns a raw compiler error into a selected\r\n/// node. Losing it is losing the feature the built-in editor structurally cannot have.\r\n/// </para>\r\n/// </summary>\r\npublic sealed class HlslSourceBuilder\r\n{\r\n\treadonly StringBuilder _text = new();\r\n\treadonly SourceMap _map = new();\r\n\tint _line = 1;\r\n\tint _indent;\r\n\r\n\t/// <summary>Create a builder. Generated code uses hard tabs and CRLF, like everything else here.</summary>\r\n\tpublic HlslSourceBuilder( string indent = \"\\t\", string newLine = \"\\r\\n\" )\r\n\t{\r\n\t\tIndent = indent ?? \"\\t\";\r\n\t\tNewLine = string.IsNullOrEmpty( newLine ) ? \"\\r\\n\" : newLine;\r\n\t}\r\n\r\n\t/// <summary>One level of indentation.</summary>\r\n\tpublic string Indent { get; }\r\n\r\n\t/// <summary>The line ending written after every line.</summary>\r\n\tpublic string NewLine { get; }\r\n\r\n\t/// <summary>The map from emitted line to originating node.</summary>\r\n\tpublic SourceMap SourceMap => _map;\r\n\r\n\t/// <summary>The 1-based number of the line that will be written next.</summary>\r\n\tpublic int LineNumber => _line;\r\n\r\n\t/// <summary>Lines written so far.</summary>\r\n\tpublic int LineCount => _line - 1;\r\n\r\n\t/// <summary>Current indentation depth.</summary>\r\n\tpublic int IndentLevel\r\n\t{\r\n\t\tget => _indent;\r\n\t\tset => _indent = Math.Max( 0, value );\r\n\t}\r\n\r\n\t/// <summary>Indent until the returned scope is disposed.</summary>\r\n\tpublic IDisposable Indented() => new IndentScope( this );\r\n\r\n\t/// <summary>Write an empty line.</summary>\r\n\tpublic HlslSourceBuilder Blank()\r\n\t{\r\n\t\t_text.Append( NewLine );\r\n\t\t_line++;\r\n\t\treturn this;\r\n\t}\r\n\r\n\t/// <summary>Write one indented line with no origin.</summary>\r\n\tpublic HlslSourceBuilder Write( string text ) => Write( text, NodeId.None );\r\n\r\n\t/// <summary>Write one indented line and record which node produced it.</summary>\r\n\tpublic HlslSourceBuilder Write( string text, NodeId origin )\r\n\t{\r\n\t\tif ( text is null ) return this;\r\n\r\n\t\tif ( text.Length > 0 )\r\n\t\t{\r\n\t\t\tfor ( int i = 0; i < _indent; i++ ) _text.Append( Indent );\r\n\t\t\t_text.Append( text );\r\n\t\t}\r\n\r\n\t\t_text.Append( NewLine );\r\n\t\t_map.Add( _line, origin );\r\n\t\t_line++;\r\n\r\n\t\treturn this;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Write a multi-line chunk, stripping the shared leading whitespace so a template written at any\r\n\t/// C# indentation lands correctly. Every produced line is attributed to <paramref name=\"origin\"/>.\r\n\t/// </summary>\r\n\tpublic HlslSourceBuilder WriteBlock( string text, NodeId origin = default )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( text ) ) return this;\r\n\r\n\t\tforeach ( var line in SboxShaderTemplates.Dedent( text ).Split( '\\n' ) )\r\n\t\t{\r\n\t\t\tWrite( line.TrimEnd(), origin );\r\n\t\t}\r\n\r\n\t\treturn this;\r\n\t}\r\n\r\n\t/// <summary>Write an opening brace and indent.</summary>\r\n\tpublic HlslSourceBuilder Open( NodeId origin = default )\r\n\t{\r\n\t\tWrite( \"{\", origin );\r\n\t\t_indent++;\r\n\t\treturn this;\r\n\t}\r\n\r\n\t/// <summary>Outdent and write a closing brace.</summary>\r\n\tpublic HlslSourceBuilder Close( string suffix = null, NodeId origin = default )\r\n\t{\r\n\t\t_indent = Math.Max( 0, _indent - 1 );\r\n\t\tWrite( \"}\" + ( suffix ?? string.Empty ), origin );\r\n\t\treturn this;\r\n\t}\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override string ToString() => _text.ToString();\r\n\r\n\tsealed class IndentScope : IDisposable\r\n\t{\r\n\t\treadonly HlslSourceBuilder _owner;\r\n\r\n\t\tpublic IndentScope( HlslSourceBuilder owner )\r\n\t\t{\r\n\t\t\t_owner = owner;\r\n\t\t\t_owner._indent++;\r\n\t\t}\r\n\r\n\t\tpublic void Dispose() => _owner._indent = Math.Max( 0, _owner._indent - 1 );\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Lowers <see cref=\"IrModule\"/> expressions, statements, helpers and declarations into HLSL text.\r\n/// <para>\r\n/// Deliberately knows nothing about the VFX block file \u2014 that is <see cref=\"SboxShaderWriter\"/>'s\r\n/// job. The split is what lets the same HLSL feed a probe compile from the text editor, a\r\n/// <c>.shader</c>, or a future material-only target.\r\n/// </para>\r\n/// </summary>\r\npublic sealed class HlslEmitter\r\n{\r\n\tconst int PrecedencePrimary = 16;\r\n\tconst int PrecedencePostfix = 15;\r\n\tconst int PrecedenceUnary = 13;\r\n\tconst int PrecedenceLowest = 0;\r\n\r\n\treadonly HashSet<string> _reported = new();\r\n\r\n\t/// <summary>Create an emitter for one module.</summary>\r\n\tpublic HlslEmitter( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics )\r\n\t{\r\n\t\tModule = module;\r\n\t\tOptions = options ?? BackendEmitOptions.Default;\r\n\t\tDiagnostics = diagnostics ?? new DiagnosticSink();\r\n\t}\r\n\r\n\t/// <summary>The module being lowered.</summary>\r\n\tpublic IrModule Module { get; }\r\n\r\n\t/// <summary>Emission options.</summary>\r\n\tpublic BackendEmitOptions Options { get; }\r\n\r\n\t/// <summary>Where problems go. A backend never throws for user error.</summary>\r\n\tpublic DiagnosticSink Diagnostics { get; }\r\n\r\n\t/// <summary>Which HLSL flavour to write.</summary>\r\n\tpublic HlslDialect Dialect => Options.Dialect;\r\n\r\n\t/// <summary>The stage currently being written. Drives builtin lowering and stage legality.</summary>\r\n\tpublic ShaderStage Stage { get; set; }\r\n\r\n\t/// <summary>The domain the module targets.</summary>\r\n\tpublic ShaderDomain Domain => Module?.Meta?.Domain ?? ShaderDomain.Surface;\r\n\r\n\t/// <summary>True when comments should be written into the output.</summary>\r\n\tpublic bool WantsComments => Options.EmitComments || Options.DebugSymbols;\r\n\r\n\tNodeId _origin;\r\n\r\n\t// ---- expressions ------------------------------------------------------\r\n\r\n\t/// <summary>Render an expression as HLSL, parenthesised only where precedence requires it.</summary>\r\n\tpublic string Expression( IrExpr expr ) => Expression( expr, PrecedenceLowest );\r\n\r\n\tstring Expression( IrExpr expr, int minPrecedence )\r\n\t{\r\n\t\tif ( expr is null ) return \"0\";\r\n\r\n\t\tvar (text, precedence) = Render( expr );\r\n\r\n\t\treturn precedence < minPrecedence ? $\"( {text} )\" : text;\r\n\t}\r\n\r\n\t(string Text, int Precedence) Render( IrExpr expr )\r\n\t{\r\n\t\tswitch ( expr )\r\n\t\t{\r\n\t\t\tcase IrConst c:\r\n\t\t\t\treturn ( HlslIntrinsics.Literal( c.Type, c.Value ), PrecedencePrimary );\r\n\r\n\t\t\tcase IrVar v:\r\n\t\t\t\treturn ( v.Name ?? \"0\", PrecedencePrimary );\r\n\r\n\t\t\t// Sanitised to match SboxMaterialBinding.Declare: the block parser is ASCII only, so a\r\n\t\t\t// declaration and every reference to it have to agree on the same renamed spelling.\r\n\t\t\tcase IrGlobalRef g:\r\n\t\t\t\treturn ( SboxShaderTemplates.SafeIdentifier( g.Decl?.Name ) ?? \"0\", PrecedencePrimary );\r\n\r\n\t\t\tcase IrBuiltinRef b:\r\n\t\t\t\treturn ( RenderBuiltin( b ), PrecedencePrimary );\r\n\r\n\t\t\tcase IrCall call:\r\n\t\t\t\treturn ( RenderCall( call ), PrecedencePrimary );\r\n\r\n\t\t\tcase IrHelperCall helper:\r\n\t\t\t\treturn ( RenderHelperCall( helper ), PrecedencePrimary );\r\n\r\n\t\t\tcase IrBinary binary:\r\n\t\t\t\treturn RenderBinary( binary );\r\n\r\n\t\t\tcase IrUnary unary:\r\n\t\t\t{\r\n\t\t\t\tvar symbol = UnaryOps.Symbol( unary.Op );\r\n\t\t\t\tvar operand = Expression( unary.V, PrecedenceUnary );\r\n\r\n\t\t\t\t// A unary operand is not parenthesised \u2014 unary binds tighter than everything below it \u2014\r\n\t\t\t\t// so a nested negate would print `--x`, which DXC and Slang both lex as pre-decrement:\r\n\t\t\t\t// an error on a non-lvalue and a different program on one. `-(-1.0f)` has the same\r\n\t\t\t\t// shape. A single space separates the two tokens and costs nothing; `!!x` and `~~x` are\r\n\t\t\t\t// legal but read better spaced too. Reachable with folding off, which the docs\r\n\t\t\t\t// recommend for bug reports.\r\n\t\t\t\tvar gap = operand.Length > 0 && operand[0] == symbol[0] ? \" \" : string.Empty;\r\n\r\n\t\t\t\treturn ( $\"{symbol}{gap}{operand}\", PrecedenceUnary );\r\n\t\t\t}\r\n\r\n\t\t\tcase IrSwizzle swizzle:\r\n\t\t\t\treturn ( $\"{Expression( swizzle.V, PrecedencePostfix )}.{HlslIntrinsics.NormalizeSwizzle( swizzle.Mask )}\",\r\n\t\t\t\t\tPrecedencePostfix );\r\n\r\n\t\t\tcase IrConstruct construct:\r\n\t\t\t\treturn ( RenderConstruct( construct ), PrecedencePrimary );\r\n\r\n\t\t\tcase IrCast cast:\r\n\t\t\t\treturn RenderCast( cast );\r\n\r\n\t\t\tcase IrSelect select:\r\n\t\t\t\treturn ( $\"select( {Expression( select.C )}, {Expression( select.A )}, {Expression( select.B )} )\",\r\n\t\t\t\t\tPrecedencePrimary );\r\n\r\n\t\t\tcase IrIndex index:\r\n\t\t\t\treturn ( $\"{Expression( index.V, PrecedencePostfix )}[{Expression( index.I )}]\", PrecedencePostfix );\r\n\r\n\t\t\tcase IrMember member:\r\n\t\t\t\treturn ( $\"{Expression( member.V, PrecedencePostfix )}.{member.Field}\", PrecedencePostfix );\r\n\r\n\t\t\tdefault:\r\n\t\t\t\tReport( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed,\r\n\t\t\t\t\t$\"The HLSL backend does not know how to write a {expr.GetType().Name}.\" );\r\n\t\t\t\treturn ( HlslIntrinsics.Fallback( expr.Type ), PrecedencePrimary );\r\n\t\t}\r\n\t}\r\n\r\n\tstring RenderBuiltin( IrBuiltinRef builtin )\r\n\t{\r\n\t\tvar text = HlslIntrinsics.BuiltinExpression( builtin.Id, Stage, Domain );\r\n\r\n\t\tif ( !string.IsNullOrEmpty( text ) ) return text;\r\n\r\n\t\tReport( DiagnosticSeverity.Error, DiagnosticCode.BackendUnsupported,\r\n\t\t\t$\"'{builtin.Id}' has no representation in the {Stage.DisplayName().ToLowerInvariant()} stage of a {Domain} shader.\",\r\n\t\t\t$\"The s&box shader environment provides no expression for it here. Compute the value where it exists and pass it through a varying, or bind it as a render attribute.\" );\r\n\r\n\t\treturn HlslIntrinsics.Fallback( builtin.Type );\r\n\t}\r\n\r\n\tstring RenderCall( IrCall call )\r\n\t{\r\n\t\tvar id = call.Id;\r\n\t\tvar args = call.Args ?? Array.Empty<IrExpr>();\r\n\t\tvar rendered = new string[args.Length];\r\n\t\tvar types = new ShaderType[args.Length];\r\n\r\n\t\tfor ( int i = 0; i < args.Length; i++ )\r\n\t\t{\r\n\t\t\trendered[i] = Expression( args[i] );\r\n\t\t\ttypes[i] = args[i]?.Type ?? ShaderType.Void;\r\n\t\t}\r\n\r\n\t\tvar info = IntrinsicCatalog.Get( id );\r\n\r\n\t\tif ( !info.IsAvailableOnTarget )\r\n\t\t{\r\n\t\t\tReport( DiagnosticSeverity.Error, DiagnosticCode.ShaderModelTooHigh,\r\n\t\t\t\t$\"'{info.Name}' requires SM {info.MinShaderModel}; s&box compiles at SM {ShaderModel.Target} (Vulkan).\",\r\n\t\t\t\tinfo.Description );\r\n\t\t}\r\n\t\telse if ( !IntrinsicCatalog.IsLegalIn( id, Stage ) )\r\n\t\t{\r\n\t\t\tif ( HlslIntrinsics.TryLowerForStage( id, Stage, out var lowered ) )\r\n\t\t\t{\r\n\t\t\t\tReport( DiagnosticSeverity.Info, DiagnosticCode.SampleLowered,\r\n\t\t\t\t\t$\"'{info.Name}' was lowered to '{IntrinsicCatalog.Name( lowered )}' because the {Stage.DisplayName().ToLowerInvariant()} stage has no screen-space derivatives.\",\r\n\t\t\t\t\t\"Mip selection falls back to level 0. Feed an explicit LOD if that is not what you want.\" );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tReport( DiagnosticSeverity.Error, DiagnosticCode.StageViolation,\r\n\t\t\t\t\t$\"'{info.Name}' is not legal in the {Stage.DisplayName().ToLowerInvariant()} stage.\",\r\n\t\t\t\t\t\"There is no meaning-preserving substitute. Move the operation to the pixel stage.\" );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( !info.AcceptsArity( args.Length ) )\r\n\t\t{\r\n\t\t\tReport( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed,\r\n\t\t\t\t$\"'{info.Name}' takes {info.MinArgs}..{info.MaxArgs} arguments but was given {args.Length}.\" );\r\n\t\t}\r\n\r\n\t\treturn HlslIntrinsics.Call( id, rendered, types, Stage, Dialect );\r\n\t}\r\n\r\n\tstring RenderHelperCall( IrHelperCall call )\r\n\t{\r\n\t\tvar args = call.Args ?? Array.Empty<IrExpr>();\r\n\r\n\t\tif ( call.Fn is null )\r\n\t\t{\r\n\t\t\tReport( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed, \"A helper call has no helper attached.\" );\r\n\t\t\treturn HlslIntrinsics.Fallback( call.Type );\r\n\t\t}\r\n\r\n\t\tif ( args.Length == 0 ) return $\"{call.Fn.Name}()\";\r\n\r\n\t\tvar parts = new string[args.Length];\r\n\r\n\t\tfor ( int i = 0; i < args.Length; i++ ) parts[i] = Expression( args[i] );\r\n\r\n\t\treturn $\"{call.Fn.Name}( {string.Join( \", \", parts )} )\";\r\n\t}\r\n\r\n\t(string Text, int Precedence) RenderBinary( IrBinary binary )\r\n\t{\r\n\t\tvar precedence = BinaryOps.Precedence( binary.Op );\r\n\r\n\t\tif ( BinaryOps.IsShortCircuit( binary.Op ) && !( binary.L?.Type.IsScalar ?? true ) )\r\n\t\t{\r\n\t\t\t// && and || short-circuit and therefore never work component-wise. The IR is supposed to\r\n\t\t\t// use Intrinsic.AndFn / OrFn for vectors; recover instead of emitting silently wrong code.\r\n\t\t\tvar fn = binary.Op == BinaryOp.LogicalAnd ? Intrinsic.AndFn : Intrinsic.OrFn;\r\n\r\n\t\t\tReport( DiagnosticSeverity.Warning, DiagnosticCode.BackendUnsupported,\r\n\t\t\t\t$\"'{BinaryOps.Symbol( binary.Op )}' short-circuits and cannot be applied component-wise; emitted '{HlslIntrinsics.Spelling( fn )}' instead.\" );\r\n\r\n\t\t\treturn ( $\"{HlslIntrinsics.Spelling( fn )}( {Expression( binary.L )}, {Expression( binary.R )} )\",\r\n\t\t\t\tPrecedencePrimary );\r\n\t\t}\r\n\r\n\t\tvar left = Expression( binary.L, precedence );\r\n\t\tvar right = Expression( binary.R, precedence + 1 );\r\n\r\n\t\treturn ( $\"{left} {BinaryOps.Symbol( binary.Op )} {right}\", precedence );\r\n\t}\r\n\r\n\tstring RenderConstruct( IrConstruct construct )\r\n\t{\r\n\t\tvar parts = construct.Parts ?? Array.Empty<IrExpr>();\r\n\t\tvar rendered = new string[parts.Length];\r\n\r\n\t\tfor ( int i = 0; i < parts.Length; i++ ) rendered[i] = Expression( parts[i] );\r\n\r\n\t\tif ( construct.Type.IsStruct )\r\n\t\t{\r\n\t\t\tif ( parts.Length == 0 ) return $\"( {construct.Type.Hlsl} )0\";\r\n\r\n\t\t\t// Slang gives every struct a synthesised constructor, which composes anywhere an expression\r\n\t\t\t// can appear. Plain HLSL only has the initialiser list, which is legal solely as the\r\n\t\t\t// right-hand side of a declaration \u2014 the one place the IR ever builds a struct.\r\n\t\t\treturn Dialect == HlslDialect.SboxSlang\r\n\t\t\t\t? $\"{construct.Type.Hlsl}( {string.Join( \", \", rendered )} )\"\r\n\t\t\t\t: $\"{{ {string.Join( \", \", rendered )} }}\";\r\n\t\t}\r\n\r\n\t\tif ( parts.Length == 0 ) return HlslIntrinsics.Fallback( construct.Type );\r\n\r\n\t\treturn $\"{construct.Type.Hlsl}( {string.Join( \", \", rendered )} )\";\r\n\t}\r\n\r\n\t(string Text, int Precedence) RenderCast( IrCast cast )\r\n\t{\r\n\t\tvar source = cast.V?.Type ?? ShaderType.Void;\r\n\t\tvar target = cast.Type;\r\n\r\n\t\tswitch ( cast.Kind )\r\n\t\t{\r\n\t\t\tcase CastKind.Bitcast:\r\n\t\t\t\tvar reinterpret = target.Scalar switch\r\n\t\t\t\t{\r\n\t\t\t\t\tScalarKind.Int => Intrinsic.AsInt,\r\n\t\t\t\t\tScalarKind.UInt => Intrinsic.AsUint,\r\n\t\t\t\t\t_ => Intrinsic.AsFloat\r\n\t\t\t\t};\r\n\r\n\t\t\t\treturn ( $\"{IntrinsicCatalog.Name( reinterpret )}( {Expression( cast.V )} )\", PrecedencePrimary );\r\n\r\n\t\t\tcase CastKind.Truncate when source.IsScalarOrVector && target.IsScalarOrVector &&\r\n\t\t\t\t\t\t\t\t\t\ttarget.Components < source.Components &&\r\n\t\t\t\t\t\t\t\t\t\ttarget.Scalar == source.Scalar:\r\n\t\t\t\treturn ( $\"{Expression( cast.V, PrecedencePostfix )}.{HlslIntrinsics.LeadingMask( target.Components )}\",\r\n\t\t\t\t\tPrecedencePostfix );\r\n\r\n\t\t\tcase CastKind.Pad when source.IsScalarOrVector && target.IsScalarOrVector &&\r\n\t\t\t\t\t\t\t\t target.Components > source.Components:\r\n\t\t\t\tvar padded = new List<string>( target.Components ) { Expression( cast.V ) };\r\n\r\n\t\t\t\tfor ( int i = source.Components; i < target.Components; i++ )\r\n\t\t\t\t{\r\n\t\t\t\t\tpadded.Add( HlslIntrinsics.Number( cast.Fill, target.Scalar ) );\r\n\t\t\t\t}\r\n\r\n\t\t\t\treturn ( $\"{target.Hlsl}( {string.Join( \", \", padded )} )\", PrecedencePrimary );\r\n\r\n\t\t\tdefault:\r\n\t\t\t\treturn ( $\"( {target.Hlsl} ){Expression( cast.V, PrecedenceUnary )}\", PrecedenceUnary );\r\n\t\t}\r\n\t}\r\n\r\n\t// ---- statements -------------------------------------------------------\r\n\r\n\t/// <summary>Write a block's statements at the builder's current indentation.</summary>\r\n\tpublic void WriteStatements( HlslSourceBuilder builder, IrBlock block )\r\n\t{\r\n\t\tif ( builder is null || block is null ) return;\r\n\r\n\t\tforeach ( var statement in block.Statements ) WriteStatement( builder, statement );\r\n\t}\r\n\r\n\t/// <summary>Write a braced block.</summary>\r\n\tpublic void WriteBracedBlock( HlslSourceBuilder builder, IrBlock block, NodeId origin )\r\n\t{\r\n\t\tbuilder.Open( origin );\r\n\t\tWriteStatements( builder, block );\r\n\t\tbuilder.Close( origin: origin );\r\n\t}\r\n\r\n\t/// <summary>Write one statement, recording every line it produces against its originating node.</summary>\r\n\tpublic void WriteStatement( HlslSourceBuilder builder, IrStmt statement )\r\n\t{\r\n\t\tif ( builder is null || statement is null ) return;\r\n\r\n\t\tvar previous = _origin;\r\n\t\t_origin = statement.Origin;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tswitch ( statement )\r\n\t\t\t{\r\n\t\t\t\tcase IrDecl decl:\r\n\t\t\t\t\tbuilder.Write( decl.Init is null\r\n\t\t\t\t\t\t? $\"{decl.Type.Hlsl} {decl.Name};\"\r\n\t\t\t\t\t\t: $\"{decl.Type.Hlsl} {decl.Name} = {Expression( decl.Init )};\", decl.Origin );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrAssign assign:\r\n\t\t\t\t\tbuilder.Write( $\"{Expression( assign.Target )} = {Expression( assign.Value )};\", assign.Origin );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrIf branch:\r\n\t\t\t\t\tbuilder.Write( $\"if ( {Expression( branch.Cond )} )\", branch.Origin );\r\n\t\t\t\t\tWriteBracedBlock( builder, branch.Then, branch.Origin );\r\n\r\n\t\t\t\t\tif ( branch.Else is { IsEmpty: false } )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tbuilder.Write( \"else\", branch.Origin );\r\n\t\t\t\t\t\tWriteBracedBlock( builder, branch.Else, branch.Origin );\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrFor loop:\r\n\t\t\t\t\tvar counter = string.IsNullOrEmpty( loop.Var ) ? \"n\" : loop.Var;\r\n\r\n\t\t\t\t\tbuilder.Write(\r\n\t\t\t\t\t\t$\"for ( int {counter} = 0; {counter} < ( int )( {Expression( loop.Count )} ); {counter}++ )\",\r\n\t\t\t\t\t\tloop.Origin );\r\n\t\t\t\t\tWriteBracedBlock( builder, loop.Body, loop.Origin );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrWhile loop:\r\n\t\t\t\t\tbuilder.Write( $\"while ( {Expression( loop.Cond )} )\", loop.Origin );\r\n\t\t\t\t\tWriteBracedBlock( builder, loop.Body, loop.Origin );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrBreak:\r\n\t\t\t\t\tbuilder.Write( \"break;\", statement.Origin );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrContinue:\r\n\t\t\t\t\tbuilder.Write( \"continue;\", statement.Origin );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrReturn ret:\r\n\t\t\t\t\tbuilder.Write( ret.Value is null ? \"return;\" : $\"return {Expression( ret.Value )};\", ret.Origin );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrDiscard:\r\n\t\t\t\t\tif ( !Stage.CanDiscard() )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tReport( DiagnosticSeverity.Error, DiagnosticCode.StageViolation,\r\n\t\t\t\t\t\t\t$\"A fragment can only be discarded in the pixel stage, not in the {Stage.DisplayName().ToLowerInvariant()} stage.\" );\r\n\t\t\t\t\t\tbreak;\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tbuilder.Write( \"discard;\", statement.Origin );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrExprStmt expression:\r\n\t\t\t\t\tbuilder.Write( $\"{Expression( expression.Value )};\", expression.Origin );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrComment comment:\r\n\t\t\t\t\tif ( !WantsComments || string.IsNullOrWhiteSpace( comment.Text ) ) break;\r\n\r\n\t\t\t\t\tforeach ( var line in comment.Text.Replace( \"\\r\\n\", \"\\n\" ).Split( '\\n' ) )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tbuilder.Write( $\"// {line.Trim()}\", comment.Origin );\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrScope scope:\r\n\t\t\t\t\tWriteBracedBlock( builder, scope.Body, scope.Origin );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrPreprocessorIf guard:\r\n\t\t\t\t{\r\n\t\t\t\t\t// The .shader writer handles guards itself; this is the standalone emit the text\r\n\t\t\t\t\t// editor probe-compiles, and it has to produce the same directives rather than\r\n\t\t\t\t\t// reporting the statement as one the backend does not understand.\r\n\t\t\t\t\tvar directive = IrPreprocessor.OpenDirective( guard.Condition );\r\n\r\n\t\t\t\t\tif ( string.IsNullOrEmpty( directive ) )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\t// An unusable condition must not become a directive the preprocessor rejects: a\r\n\t\t\t\t\t\t// preprocessor error has no line that maps back to a node. Emitting both sides\r\n\t\t\t\t\t\t// unguarded keeps the shader compiling and costs only the exclusion.\r\n\t\t\t\t\t\tReport( DiagnosticSeverity.Warning, DiagnosticCode.InvalidBlock,\r\n\t\t\t\t\t\t\t\"A compile-time branch had no usable combo condition, so both of its sides were emitted.\",\r\n\t\t\t\t\t\t\t\"Bind the node to a graph keyword. Without one there is nothing for the preprocessor to test, and the branch cannot be resolved at compile time.\" );\r\n\r\n\t\t\t\t\t\tWriteStatements( builder, guard.Then );\r\n\t\t\t\t\t\tWriteStatements( builder, guard.Else );\r\n\r\n\t\t\t\t\t\tbreak;\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tbuilder.Write( directive, guard.Origin );\r\n\t\t\t\t\tWriteStatements( builder, guard.Then );\r\n\r\n\t\t\t\t\tif ( guard.HasElse )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tbuilder.Write( IrPreprocessor.ElseDirective, guard.Origin );\r\n\t\t\t\t\t\tWriteStatements( builder, guard.Else );\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tbuilder.Write( IrPreprocessor.EndDirective, guard.Origin );\r\n\t\t\t\t\tbreak;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tdefault:\r\n\t\t\t\t\tReport( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed,\r\n\t\t\t\t\t\t$\"The HLSL backend does not know how to write a {statement.GetType().Name}.\" );\r\n\t\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\t\tfinally\r\n\t\t{\r\n\t\t\t_origin = previous;\r\n\t\t}\r\n\t}\r\n\r\n\t// ---- declarations -----------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// The module's helpers in dependency order, deduplicated by name.\r\n\t/// <para>\r\n\t/// A same-name, different-body collision is a hard error naming both bodies, unlike the built-in\r\n\t/// editor's process-global function table which silently keeps whichever registered first.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic IReadOnlyList<HelperFunction> OrderedHelpers()\r\n\t{\r\n\t\tvar ordered = new List<HelperFunction>();\r\n\r\n\t\tif ( Module is null ) return ordered;\r\n\r\n\t\tvar accepted = new Dictionary<string, HelperFunction>( StringComparer.Ordinal );\r\n\t\tvar visiting = new HashSet<string>( StringComparer.Ordinal );\r\n\r\n\t\tforeach ( var helper in Module.Helpers ) Visit( helper );\r\n\r\n\t\treturn ordered;\r\n\r\n\t\tvoid Visit( HelperFunction helper )\r\n\t\t{\r\n\t\t\tif ( helper is null || string.IsNullOrWhiteSpace( helper.Name ) ) return;\r\n\r\n\t\t\tif ( accepted.TryGetValue( helper.Name, out var existing ) )\r\n\t\t\t{\r\n\t\t\t\tif ( existing.ConflictsWith( helper ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tReport( DiagnosticSeverity.Error, DiagnosticCode.HelperCollision,\r\n\t\t\t\t\t\t$\"Two different helper functions are both named '{helper.Name}'.\",\r\n\t\t\t\t\t\t$\"Signatures: '{existing.SignatureHlsl}' and '{helper.SignatureHlsl}'. Helper names are the deduplication key, so they must be unique per module.\" );\r\n\t\t\t\t}\r\n\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tif ( !visiting.Add( helper.Name ) )\r\n\t\t\t{\r\n\t\t\t\tReport( DiagnosticSeverity.Error, DiagnosticCode.HelperCollision,\r\n\t\t\t\t\t$\"Helper function '{helper.Name}' depends on itself.\",\r\n\t\t\t\t\t\"Helper requirement chains must form a directed acyclic graph.\" );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tforeach ( var requirement in helper.Requires ?? Array.Empty<HelperFunction>() ) Visit( requirement );\r\n\r\n\t\t\tvisiting.Remove( helper.Name );\r\n\t\t\taccepted[helper.Name] = helper;\r\n\t\t\tordered.Add( helper );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The helpers a stage's own code can actually reach, transitively.\r\n\t/// <para>\r\n\t/// <see cref=\"HelperFunction.Stages\"/> says where a helper <i>may</i> be written, not where it is\r\n\t/// wanted: a pure-maths helper declares <see cref=\"StageMask.All\"/> and would otherwise be emitted\r\n\t/// into the vertex program of every graph whose pixel program happens to call it. DXC drops the dead\r\n\t/// code, but Prism ships a viewer for the generated text, and hundreds of lines of functions the\r\n\t/// program never calls is the difference between a shader a person can read and one they cannot.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic HashSet<string> ReachableHelpers( ShaderStage stage, IReadOnlyList<HelperFunction> helpers )\r\n\t{\r\n\t\tvar reachable = new HashSet<string>( StringComparer.Ordinal );\r\n\r\n\t\tif ( Module is null || helpers is null || helpers.Count == 0 ) return reachable;\r\n\r\n\t\tvar byName = new Dictionary<string, HelperFunction>( StringComparer.Ordinal );\r\n\r\n\t\tforeach ( var helper in helpers )\r\n\t\t{\r\n\t\t\tif ( !string.IsNullOrEmpty( helper?.Name ) ) byName[helper.Name] = helper;\r\n\t\t}\r\n\r\n\t\tforeach ( var function in Module.Functions )\r\n\t\t{\r\n\t\t\tif ( function is null ) continue;\r\n\r\n\t\t\tvar belongs = function.IsEntryPoint\r\n\t\t\t\t? function.Stage == stage\r\n\t\t\t\t: function.Stage == ShaderStage.None || function.Stage == stage;\r\n\r\n\t\t\tif ( !belongs ) continue;\r\n\r\n\t\t\tforeach ( var statement in WalkStatements( function.Body ) )\r\n\t\t\t{\r\n\t\t\t\tforeach ( var expression in StatementExpressions( statement ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tforeach ( var node in IrExprUtil.Walk( expression ) )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tif ( node is IrHelperCall call && !string.IsNullOrEmpty( call.Fn?.Name ) ) Pull( call.Fn.Name );\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn reachable;\r\n\r\n\t\tvoid Pull( string name )\r\n\t\t{\r\n\t\t\tif ( !byName.TryGetValue( name, out var helper ) ) return;\r\n\t\t\tif ( !reachable.Add( name ) ) return;\r\n\r\n\t\t\tforeach ( var requirement in helper.Requires ?? Array.Empty<HelperFunction>() )\r\n\t\t\t{\r\n\t\t\t\tif ( !string.IsNullOrEmpty( requirement?.Name ) ) Pull( requirement.Name );\r\n\t\t\t}\r\n\r\n\t\t\t// A helper body is author-supplied text, so one helper calling another need not have been\r\n\t\t\t// declared through Requires. Naming another helper anywhere in the body pulls it in: over-\r\n\t\t\t// including costs one dead function, under-including costs a compile error.\r\n\t\t\tvar body = helper.BodyFor( PrismConstants.BackendHlsl );\r\n\r\n\t\t\tif ( string.IsNullOrEmpty( body ) ) return;\r\n\r\n\t\t\tforeach ( var candidate in byName.Keys.ToArray() )\r\n\t\t\t{\r\n\t\t\t\tif ( reachable.Contains( candidate ) ) continue;\r\n\t\t\t\tif ( body.Contains( candidate, StringComparison.Ordinal ) ) Pull( candidate );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Every statement in a block, including the ones nested inside control flow.</summary>\r\n\tstatic IEnumerable<IrStmt> WalkStatements( IrBlock block )\r\n\t{\r\n\t\tif ( block is null ) yield break;\r\n\r\n\t\tforeach ( var statement in block.Statements )\r\n\t\t{\r\n\t\t\tif ( statement is null ) continue;\r\n\r\n\t\t\tyield return statement;\r\n\r\n\t\t\tIrBlock[] nested = statement switch\r\n\t\t\t{\r\n\t\t\t\tIrIf branch => [branch.Then, branch.Else],\r\n\t\t\t\tIrFor loop => [loop.Body],\r\n\t\t\t\tIrWhile loop => [loop.Body],\r\n\t\t\t\tIrScope scope => [scope.Body],\r\n\t\t\t\t_ => null\r\n\t\t\t};\r\n\r\n\t\t\tif ( nested is null ) continue;\r\n\r\n\t\t\tforeach ( var child in nested )\r\n\t\t\t{\r\n\t\t\t\tforeach ( var inner in WalkStatements( child ) ) yield return inner;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The expressions one statement holds directly.</summary>\r\n\tstatic IEnumerable<IrExpr> StatementExpressions( IrStmt statement )\r\n\t{\r\n\t\tswitch ( statement )\r\n\t\t{\r\n\t\t\tcase IrDecl decl:\r\n\t\t\t\tyield return decl.Init;\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase IrAssign assign:\r\n\t\t\t\tyield return assign.Target;\r\n\t\t\t\tyield return assign.Value;\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase IrIf branch:\r\n\t\t\t\tyield return branch.Cond;\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase IrFor loop:\r\n\t\t\t\tyield return loop.Count;\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase IrWhile loop:\r\n\t\t\t\tyield return loop.Cond;\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase IrReturn returned:\r\n\t\t\t\tyield return returned.Value;\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase IrExprStmt expression:\r\n\t\t\t\tyield return expression.Value;\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Write the helper bodies a stage actually calls, in dependency order.</summary>\r\n\tpublic void WriteHelpers( HlslSourceBuilder builder, ShaderStage stage, IReadOnlyList<HelperFunction> helpers )\r\n\t{\r\n\t\tif ( builder is null || helpers is null ) return;\r\n\r\n\t\tvar reachable = ReachableHelpers( stage, helpers );\r\n\r\n\t\tforeach ( var helper in helpers )\r\n\t\t{\r\n\t\t\tif ( !helper.Stages.Contains( stage ) ) continue;\r\n\t\t\tif ( !reachable.Contains( helper.Name ) ) continue;\r\n\r\n\t\t\tvar body = helper.BodyFor( PrismConstants.BackendHlsl );\r\n\r\n\t\t\tif ( string.IsNullOrWhiteSpace( body ) )\r\n\t\t\t{\r\n\t\t\t\tReport( DiagnosticSeverity.Error, DiagnosticCode.HelperCollision,\r\n\t\t\t\t\t$\"Helper function '{helper.Name}' has no HLSL body.\" );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tif ( helper.MinShaderModel > ShaderModel.Target )\r\n\t\t\t{\r\n\t\t\t\tReport( DiagnosticSeverity.Error, DiagnosticCode.ShaderModelTooHigh,\r\n\t\t\t\t\t$\"Helper function '{helper.Name}' requires SM {helper.MinShaderModel}; s&box compiles at SM {ShaderModel.Target} (Vulkan).\" );\r\n\t\t\t}\r\n\r\n\t\t\tif ( Dialect == HlslDialect.StrictHlsl2021 ) CheckStrictDialect( helper, body );\r\n\r\n\t\t\tbuilder.WriteBlock( body );\r\n\t\t\tbuilder.Blank();\r\n\t\t}\r\n\t}\r\n\r\n\tstatic readonly string[] s_slangOnlySyntax =\r\n\t[\r\n\t\t\"[mutating]\", \"__init\", \"extension \", \"interface \", \"associatedtype\", \"no_diff\", \"__generic\",\r\n\t\t\"property \", \"[ForceInline]\", \"[Differentiable]\"\r\n\t];\r\n\r\n\t/// <summary>\r\n\t/// Warn about Slang-only syntax in a helper body when the graph asked for portable HLSL 2021.\r\n\t/// <para>\r\n\t/// Prism's own emission already avoids these constructs in the strict dialect; a helper's body is\r\n\t/// author-supplied text, so the best we can do is name the construct rather than let DXC reject it\r\n\t/// with a message pointing at a generated line.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tvoid CheckStrictDialect( HelperFunction helper, string body )\r\n\t{\r\n\t\tforeach ( var syntax in s_slangOnlySyntax )\r\n\t\t{\r\n\t\t\tif ( body.IndexOf( syntax, StringComparison.Ordinal ) < 0 ) continue;\r\n\r\n\t\t\tReport( DiagnosticSeverity.Warning, DiagnosticCode.BackendUnsupported,\r\n\t\t\t\t$\"Helper function '{helper.Name}' uses the Slang-only construct '{syntax.Trim()}', but this graph targets strict HLSL 2021.\",\r\n\t\t\t\t\"Either rewrite the helper in plain HLSL or switch the graph's dialect back to s&box Slang, which the engine's own headers already require.\" );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Write the non-entry-point functions the module carries for a stage.</summary>\r\n\tpublic void WriteFunctions( HlslSourceBuilder builder, ShaderStage stage )\r\n\t{\r\n\t\tif ( builder is null || Module is null ) return;\r\n\r\n\t\tvar previous = Stage;\r\n\t\tStage = stage;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tforeach ( var function in Module.Functions )\r\n\t\t\t{\r\n\t\t\t\tif ( function is null || function.IsEntryPoint ) continue;\r\n\t\t\t\tif ( function.Stage != ShaderStage.None && function.Stage != stage ) continue;\r\n\r\n\t\t\t\tforeach ( var attribute in function.Attributes ) builder.Write( attribute );\r\n\r\n\t\t\t\tbuilder.Write( function.SignatureHlsl );\r\n\t\t\t\tbuilder.Open();\r\n\t\t\t\tWriteStatements( builder, function.Body );\r\n\t\t\t\tbuilder.Close();\r\n\t\t\t\tbuilder.Blank();\r\n\t\t\t}\r\n\t\t}\r\n\t\tfinally\r\n\t\t{\r\n\t\t\tStage = previous;\r\n\t\t}\r\n\t}\r\n\r\n\t// ---- diagnostics ------------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Report a problem against the node currently being written, once per distinct message. A backend\r\n\t/// never throws for user error and never floods the panel with one repeated line.\r\n\t/// </summary>\r\n\tpublic void Report( DiagnosticSeverity severity, string code, string message, string detail = null )\r\n\t{\r\n\t\tvar key = $\"{code}|{message}|{_origin}\";\r\n\r\n\t\tif ( !_reported.Add( key ) ) return;\r\n\r\n\t\tGraphRef? graph = _origin.IsValid ? GraphRef.ForNode( _origin ) : null;\r\n\r\n\t\tDiagnostics.Report( new Diagnostic( severity, code, message, detail, null, graph ) );\r\n\t}\r\n\r\n\t/// <summary>The node whose statement is currently being written, for diagnostics attribution.</summary>\r\n\tpublic NodeId CurrentOrigin\r\n\t{\r\n\t\tget => _origin;\r\n\t\tset => _origin = value;\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// The s&box HLSL backend: turns an <see cref=\"IrModule\"/> into a complete VFX <c>.shader</c>\r\n/// file that the engine compiles and the preview renders.\r\n/// <para>\r\n/// The heavy lifting is split in two on purpose. <see cref=\"HlslEmitter\"/> lowers IR to HLSL\r\n/// declarations and function bodies; <see cref=\"SboxShaderWriter\"/> wraps those in the block file.\r\n/// That separation is what lets the same HLSL feed a probe compile, a <c>.shader</c>, or a future\r\n/// target, and it keeps the block-file knowledge in one auditable place.\r\n/// </para>\r\n/// </summary>\r\npublic sealed class HlslBackend : IShaderBackend\r\n{\r\n\t/// <inheritdoc/>\r\n\tpublic string Id => PrismConstants.BackendHlsl;\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic string DisplayName => \"s&box Shader (HLSL / VFX)\";\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic string FileExtension => PrismConstants.ShaderExtension;\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic BackendCapabilities Capabilities => BackendCapabilities.Sbox;\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic BackendEmitResult Emit( IrModule module, BackendEmitOptions options, DiagnosticSink diagnostics )\r\n\t{\r\n\t\tdiagnostics ??= new DiagnosticSink();\r\n\t\toptions ??= BackendEmitOptions.Default;\r\n\r\n\t\tif ( module is null )\r\n\t\t{\r\n\t\t\tdiagnostics.Error( DiagnosticCode.InvalidBlock, \"There is nothing to emit: the compiler produced no module.\" );\r\n\t\t\treturn BackendEmitResult.Empty( Id, FileExtension );\r\n\t\t}\r\n\r\n\t\treturn PrismLog.Guard( \"HlslBackend.Emit\",\r\n\t\t\t() => new SboxShaderWriter( module, options, diagnostics ).Write(),\r\n\t\t\tBackendEmitResult.Empty( Id, FileExtension ) );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Emit one stage as a plain HLSL translation unit, with no VFX blocks around it.\r\n\t/// <para>\r\n\t/// This is what the text editor's probe compiler and the IR debug view want: declarations, helper\r\n\t/// bodies and the entry point, in a form a bare DXC or slangc invocation can read.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic string EmitStandalone( IrModule module, ShaderStage stage, BackendEmitOptions options,\r\n\t\tDiagnosticSink diagnostics )\r\n\t{\r\n\t\tdiagnostics ??= new DiagnosticSink();\r\n\t\toptions ??= BackendEmitOptions.Default;\r\n\r\n\t\tif ( module is null ) return string.Empty;\r\n\r\n\t\treturn PrismLog.Guard( \"HlslBackend.EmitStandalone\", () =>\r\n\t\t{\r\n\t\t\tvar builder = new HlslSourceBuilder( options.Indent, options.NewLine );\r\n\t\t\tvar emitter = new HlslEmitter( module, options, diagnostics ) { Stage = stage };\r\n\r\n\t\t\tforeach ( var include in module.Includes ) builder.Write( $\"#include \\\"{include}\\\"\" );\r\n\r\n\t\t\tif ( module.Includes.Count > 0 ) builder.Blank();\r\n\r\n\t\t\tforeach ( var structure in module.Structs )\r\n\t\t\t{\r\n\t\t\t\tbuilder.Write( $\"struct {structure.Name}\" );\r\n\t\t\t\tbuilder.Open();\r\n\r\n\t\t\t\tforeach ( var include in structure.Includes ) builder.Write( $\"#include \\\"{include}\\\"\" );\r\n\r\n\t\t\t\tforeach ( var field in structure.Fields )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar semantic = string.IsNullOrEmpty( field.Semantic ) ? string.Empty : $\" : {field.Semantic}\";\r\n\t\t\t\t\tbuilder.Write( $\"{Interpolation( field.Interpolation )}{field.Type.Hlsl} {field.Name}{semantic};\" );\r\n\t\t\t\t}\r\n\r\n\t\t\t\tbuilder.Close( \";\" );\r\n\t\t\t\tbuilder.Blank();\r\n\t\t\t}\r\n\r\n\t\t\tSboxMaterialBinding.WriteGlobals( builder, emitter, stage );\r\n\t\t\temitter.WriteHelpers( builder, stage, emitter.OrderedHelpers() );\r\n\t\t\temitter.WriteFunctions( builder, stage );\r\n\r\n\t\t\tvar entry = module.EntryPoint( stage );\r\n\r\n\t\t\tif ( entry is not null ) WriteStandaloneEntry( builder, emitter, module, entry, stage );\r\n\r\n\t\t\treturn builder.ToString();\r\n\t\t}, string.Empty );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Write an entry point with the fixed signature the engine expects, plus the material prologue\r\n\t/// and tail. The IR's own signature is deliberately not used: entry-point names, parameters and\r\n\t/// semantics are fixed by the engine, and a probe compile is only useful if the locals the body\r\n\t/// refers to actually exist.\r\n\t/// </summary>\r\n\tstatic void WriteStandaloneEntry( HlslSourceBuilder builder, HlslEmitter emitter, IrModule module,\r\n\t\tIrFunction entry, ShaderStage stage )\r\n\t{\r\n\t\tforeach ( var attribute in entry.Attributes ) builder.Write( attribute );\r\n\r\n\t\tswitch ( stage )\r\n\t\t{\r\n\t\t\tcase ShaderStage.Vertex:\r\n\t\t\t\tbuilder.Write(\r\n\t\t\t\t\t$\"{SboxShaderTemplates.StructPixelInput} {PrismConstants.EntryPointVertex}( {SboxShaderTemplates.StructVertexInput} {SboxShaderTemplates.VertexInputLocal} )\" );\r\n\t\t\t\tbuilder.Open();\r\n\t\t\t\tbuilder.WriteBlock( SboxShaderTemplates.SurfaceVertexPrologue );\r\n\t\t\t\temitter.WriteStatements( builder, entry.Body );\r\n\t\t\t\tbuilder.Write( SboxShaderTemplates.SurfaceVertexEpilogue );\r\n\t\t\t\tbuilder.Close();\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase ShaderStage.Pixel:\r\n\t\t\t\tvar returned = SboxMaterialBinding.EndsWithReturn( entry.Body );\r\n\r\n\t\t\t\tbuilder.Write(\r\n\t\t\t\t\t$\"float4 {PrismConstants.EntryPointPixel}( {SboxShaderTemplates.StructPixelInput} {SboxShaderTemplates.PixelInputLocal} ) : SV_Target0\" );\r\n\t\t\t\tbuilder.Open();\r\n\r\n\t\t\t\tif ( !returned ) SboxMaterialBinding.WritePixelPrologue( builder, module );\r\n\r\n\t\t\t\temitter.WriteStatements( builder, entry.Body );\r\n\r\n\t\t\t\tif ( !returned ) SboxMaterialBinding.WritePixelEpilogue( builder, module, emitter );\r\n\r\n\t\t\t\tbuilder.Close();\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase ShaderStage.Compute:\r\n\t\t\t\tif ( entry.Attributes.Count == 0 ) builder.Write( SboxShaderTemplates.ComputeDefaultNumThreads );\r\n\r\n\t\t\t\tbuilder.Write(\r\n\t\t\t\t\t$\"void {PrismConstants.EntryPointCompute}( {SboxShaderTemplates.ComputeEntryParameters} )\" );\r\n\t\t\t\tbuilder.Open();\r\n\t\t\t\temitter.WriteStatements( builder, entry.Body );\r\n\t\t\t\tbuilder.Close();\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tdefault:\r\n\t\t\t\tbuilder.Write( entry.SignatureHlsl );\r\n\t\t\t\tbuilder.Open();\r\n\t\t\t\temitter.WriteStatements( builder, entry.Body );\r\n\t\t\t\tbuilder.Close();\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The HLSL interpolation modifier prefix for a field, including its trailing space.</summary>\r\n\tpublic static string Interpolation( IrInterpolation interpolation ) => interpolation switch\r\n\t{\r\n\t\tIrInterpolation.NoPerspective => \"noperspective \",\r\n\t\tIrInterpolation.NoInterpolation => \"nointerpolation \",\r\n\t\tIrInterpolation.Centroid => \"centroid \",\r\n\t\tIrInterpolation.Sample => \"sample \",\r\n\t\t_ => string.Empty\r\n\t};\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Compiler/Backends/SlangIntrinsics.cs",
"FileName": "SlangIntrinsics.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using System.Globalization;\r\nusing System.Text;\r\nusing Editor.Prism.Compiler.Ir;\r\nusing Editor.Prism.Core;\r\n\r\nnamespace Editor.Prism.Compiler.Backends;\r\n\r\n/// <summary>\r\n/// The spelling table that turns Prism's canonical, backend-independent operations into Slang syntax.\r\n/// <para>\r\n/// Everything the <see cref=\"SlangBackend\"/> writes goes through here: intrinsic names, operator\r\n/// symbols, type spellings, literals, identifiers and the per-stage lowering of every\r\n/// <see cref=\"Core.ShaderStage\"/>-dependent <see cref=\"Compiler.Builtin\"/>. Keeping it in one place is\r\n/// what makes the emitted module consistent, and what makes \"never emit <c>?:</c> on a vector\" a rule\r\n/// the backend cannot accidentally break.\r\n/// </para>\r\n/// </summary>\r\npublic static class SlangIntrinsics\r\n{\r\n\t// ---- well-known names the emitted module and the prelude agree on -------\r\n\r\n\t/// <summary>Name of the single parameter every graphics entry point takes.</summary>\r\n\tpublic const string InputParameter = \"i\";\r\n\r\n\t/// <summary>Name of the pixel entry point's <c>SV_IsFrontFace</c> parameter.</summary>\r\n\tpublic const string FrontFaceParameter = \"isFrontFace\";\r\n\r\n\t/// <summary>Name of the compute entry point's <c>SV_DispatchThreadID</c> parameter.</summary>\r\n\tpublic const string DispatchThreadIdParameter = \"dispatchThreadId\";\r\n\r\n\t/// <summary>Name of the compute entry point's <c>SV_GroupThreadID</c> parameter.</summary>\r\n\tpublic const string GroupThreadIdParameter = \"groupThreadId\";\r\n\r\n\t/// <summary>Name of the compute entry point's <c>SV_GroupID</c> parameter.</summary>\r\n\tpublic const string GroupIdParameter = \"groupId\";\r\n\r\n\t/// <summary>Name of the generated vertex input struct.</summary>\r\n\tpublic const string VertexInputStruct = \"VsIn\";\r\n\r\n\t/// <summary>Name of the generated vertex output / pixel input struct.</summary>\r\n\tpublic const string VertexOutputStruct = \"VsOut\";\r\n\r\n\t/// <summary>Name of the environment parameter block declared by the <c>prism.core</c> prelude.</summary>\r\n\tpublic const string EnvironmentBlock = \"gPrismEnv\";\r\n\r\n\t/// <summary>Prefix given to the locals an entry point prologue declares for builtins.</summary>\r\n\tpublic const string LocalPrefix = \"prism\";\r\n\r\n\t// ---- intrinsics --------------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// The Slang spelling of a canonical intrinsic. Falls back to the HLSL spelling in\r\n\t/// <see cref=\"IntrinsicCatalog\"/>, because Slang accepts the whole HLSL intrinsic surface.\r\n\t/// </summary>\r\n\tpublic static string Name( Intrinsic id ) => id switch\r\n\t{\r\n\t\t// Slang spells the centroid evaluator the DXC way.\r\n\t\tIntrinsic.EvaluateAttributeCentroid => \"EvaluateAttributeAtCentroid\",\r\n\r\n\t\t// GetDimensions is an out-parameter method in Slang and cannot appear in an expression,\r\n\t\t// so the prelude provides a value-returning wrapper instead.\r\n\t\tIntrinsic.TextureSize => \"PrismTextureSize\",\r\n\r\n\t\t// Component-wise logic. Never `&&` / `||`, which only short-circuit for scalars.\r\n\t\tIntrinsic.AndFn => \"and\",\r\n\t\tIntrinsic.OrFn => \"or\",\r\n\r\n\t\t_ => IntrinsicCatalog.Name( id )\r\n\t};\r\n\r\n\t/// <summary>\r\n\t/// True when the intrinsic is a method on its first argument \u2014 <c>tex.Sample( s, uv )</c> rather\r\n\t/// than <c>Sample( tex, s, uv )</c>.\r\n\t/// </summary>\r\n\tpublic static bool IsObjectMethod( Intrinsic id ) => id is\r\n\t\tIntrinsic.Sample or Intrinsic.SampleLevel or Intrinsic.SampleBias or Intrinsic.SampleGrad or\r\n\t\tIntrinsic.SampleCmp or Intrinsic.SampleCmpLevelZero or\r\n\t\tIntrinsic.Gather or Intrinsic.GatherRed or Intrinsic.GatherGreen or Intrinsic.GatherBlue or\r\n\t\tIntrinsic.GatherAlpha or Intrinsic.GatherCmp or\r\n\t\tIntrinsic.Load or\r\n\t\tIntrinsic.CalculateLevelOfDetail or Intrinsic.CalculateLevelOfDetailUnclamped;\r\n\r\n\t/// <summary>True when the operation is provided by the emitted <c>prism.core</c> prelude.</summary>\r\n\tpublic static bool IsPreludeHelper( Intrinsic id ) => id is Intrinsic.TextureSize;\r\n\r\n\t/// <summary>True when the intrinsic writes through an <c>out</c> parameter and is a statement, not a value.</summary>\r\n\tpublic static bool IsVoidResult( Intrinsic id ) => id is\r\n\t\tIntrinsic.SinCos or Intrinsic.Clip or\r\n\t\tIntrinsic.AllMemoryBarrier or Intrinsic.AllMemoryBarrierWithGroupSync or\r\n\t\tIntrinsic.DeviceMemoryBarrier or Intrinsic.DeviceMemoryBarrierWithGroupSync or\r\n\t\tIntrinsic.GroupMemoryBarrier or Intrinsic.GroupMemoryBarrierWithGroupSync or\r\n\t\tIntrinsic.InterlockedAdd or Intrinsic.InterlockedMin or Intrinsic.InterlockedMax or\r\n\t\tIntrinsic.InterlockedAnd or Intrinsic.InterlockedOr or Intrinsic.InterlockedXor or\r\n\t\tIntrinsic.InterlockedExchange or Intrinsic.InterlockedCompareExchange or\r\n\t\tIntrinsic.InterlockedCompareStore;\r\n\r\n\t// ---- operators ---------------------------------------------------------\r\n\r\n\t/// <summary>The Slang symbol for a binary operator.</summary>\r\n\tpublic static string Symbol( BinaryOp op ) => BinaryOps.Symbol( op );\r\n\r\n\t/// <summary>The Slang symbol for a unary operator.</summary>\r\n\tpublic static string Symbol( UnaryOp op ) => UnaryOps.Symbol( op );\r\n\r\n\t/// <summary>\r\n\t/// True when an operator must be written in function form instead of symbol form.\r\n\t/// <para>\r\n\t/// <c>&&</c> and <c>||</c> only short-circuit for scalar operands; on a vector Slang\r\n\t/// evaluates both sides and warns. The core module's <c>and()</c> / <c>or()</c> are the\r\n\t/// component-wise spellings, so that is what we emit.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic static bool RequiresFunctionForm( BinaryOp op, ShaderType operandType ) =>\r\n\t\tBinaryOps.IsShortCircuit( op ) && !operandType.IsScalar && !operandType.IsVoid;\r\n\r\n\t/// <summary>The function spelling of a short-circuit operator, for component-wise use.</summary>\r\n\tpublic static string FunctionForm( BinaryOp op ) => op == BinaryOp.LogicalOr ? \"or\" : \"and\";\r\n\r\n\t// ---- types -------------------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Element type given to a buffer whose element type the IR does not carry. Slang's buffer types\r\n\t/// are generic with no default, unlike its textures, so a spelling has to be chosen.\r\n\t/// </summary>\r\n\tpublic const string DefaultBufferElement = \"float4\";\r\n\r\n\t/// <summary>The Slang spelling of a type.</summary>\r\n\tpublic static string TypeName( ShaderType type )\r\n\t{\r\n\t\tif ( type.IsObject )\r\n\t\t{\r\n\t\t\tswitch ( type.Object )\r\n\t\t\t{\r\n\t\t\t\tcase ObjectKind.Buffer:\r\n\t\t\t\tcase ObjectKind.StructuredBuffer:\r\n\t\t\t\tcase ObjectKind.RWBuffer:\r\n\t\t\t\tcase ObjectKind.RWStructuredBuffer:\r\n\t\t\t\t\treturn $\"{ShaderType.ObjectName( type.Object )}<{DefaultBufferElement}>\";\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn type.Slang;\r\n\t}\r\n\r\n\t/// <summary>The Slang interpolation modifier, or an empty string for the default.</summary>\r\n\tpublic static string Interpolation( IrInterpolation interpolation ) => interpolation switch\r\n\t{\r\n\t\tIrInterpolation.NoPerspective => \"noperspective\",\r\n\t\tIrInterpolation.NoInterpolation => \"nointerpolation\",\r\n\t\tIrInterpolation.Centroid => \"centroid\",\r\n\t\tIrInterpolation.Sample => \"sample\",\r\n\t\t_ => string.Empty\r\n\t};\r\n\r\n\t/// <summary>The <c>[shader(\"...\")]</c> attribute for a stage, or null when the stage has none.</summary>\r\n\tpublic static string StageAttribute( ShaderStage stage )\r\n\t{\r\n\t\tvar name = stage.SlangStage();\r\n\t\treturn string.IsNullOrEmpty( name ) ? null : $\"[shader(\\\"{name}\\\")]\";\r\n\t}\r\n\r\n\t// ---- literals ----------------------------------------------------------\r\n\r\n\t/// <summary>Format one component of a literal according to the component type.</summary>\r\n\tpublic static string Scalar( double value, ScalarKind kind ) => kind switch\r\n\t{\r\n\t\tScalarKind.Bool => value != 0 ? \"true\" : \"false\",\r\n\t\tScalarKind.Int => ( (long)Math.Clamp( value, int.MinValue, int.MaxValue ) ).ToString( CultureInfo.InvariantCulture ),\r\n\t\tScalarKind.UInt => ( (ulong)Math.Clamp( value, 0, uint.MaxValue ) ).ToString( CultureInfo.InvariantCulture ) + \"u\",\r\n\t\t_ => Real( value )\r\n\t};\r\n\r\n\t/// <summary>\r\n\t/// Format a literal of any type. Vectors whose components are all equal collapse to the\r\n\t/// single-argument constructor, which is both shorter and how a human would write it.\r\n\t/// </summary>\r\n\tpublic static string Literal( ShaderType type, ConstValue value )\r\n\t{\r\n\t\tif ( type.IsVoid ) return \"0\";\r\n\r\n\t\tif ( type.IsScalar ) return Scalar( value[0], type.Scalar );\r\n\r\n\t\tif ( type.IsVector )\r\n\t\t{\r\n\t\t\tvar components = Math.Clamp( type.Components, 1, 4 );\r\n\r\n\t\t\tif ( value.AllEqual( value[0], components ) )\r\n\t\t\t{\r\n\t\t\t\treturn $\"{TypeName( type )}( {Scalar( value[0], type.Scalar )} )\";\r\n\t\t\t}\r\n\r\n\t\t\tvar parts = new string[components];\r\n\t\t\tfor ( int i = 0; i < components; i++ ) parts[i] = Scalar( value[i], type.Scalar );\r\n\r\n\t\t\treturn $\"{TypeName( type )}( {string.Join( \", \", parts )} )\";\r\n\t\t}\r\n\r\n\t\t// A matrix literal cannot be fully represented by four components, so a matrix constant is\r\n\t\t// always a broadcast of its first component. The IR builds real matrices with IrConstruct.\r\n\t\tif ( type.IsMatrix ) return $\"{TypeName( type )}( {Scalar( value[0], type.Scalar )} )\";\r\n\r\n\t\treturn $\"( {TypeName( type )} )0\";\r\n\t}\r\n\r\n\t/// <summary>Format a floating-point literal so it round-trips and always reads as a float.</summary>\r\n\tpublic static string Real( double value )\r\n\t{\r\n\t\tif ( double.IsNaN( value ) ) value = 0;\r\n\t\tif ( double.IsPositiveInfinity( value ) ) value = 3.402823466e+38;\r\n\t\tif ( double.IsNegativeInfinity( value ) ) value = -3.402823466e+38;\r\n\r\n\t\tvar text = ( (float)value ).ToString( \"R\", CultureInfo.InvariantCulture );\r\n\r\n\t\tif ( text.IndexOf( '.' ) < 0 && text.IndexOf( 'E' ) < 0 && text.IndexOf( 'e' ) < 0 )\r\n\t\t{\r\n\t\t\ttext += \".0\";\r\n\t\t}\r\n\r\n\t\treturn text;\r\n\t}\r\n\r\n\t/// <summary>Escape a string so it can appear inside a Slang string literal.</summary>\r\n\tpublic static string QuotedString( string value )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( value ) ) return \"\\\"\\\"\";\r\n\r\n\t\tvar builder = new StringBuilder( value.Length + 2 );\r\n\t\tbuilder.Append( '\"' );\r\n\r\n\t\tforeach ( var c in value )\r\n\t\t{\r\n\t\t\tswitch ( c )\r\n\t\t\t{\r\n\t\t\t\tcase '\"': builder.Append( \"\\\\\\\"\" ); break;\r\n\t\t\t\tcase '\\\\': builder.Append( \"\\\\\\\\\" ); break;\r\n\t\t\t\tcase '\\r': break;\r\n\t\t\t\tcase '\\n': builder.Append( ' ' ); break;\r\n\t\t\t\tcase '\\t': builder.Append( ' ' ); break;\r\n\t\t\t\tdefault: builder.Append( c ); break;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tbuilder.Append( '\"' );\r\n\t\treturn builder.ToString();\r\n\t}\r\n\r\n\t// ---- identifiers -------------------------------------------------------\r\n\r\n\t/// <summary>True when the identifier collides with a Slang keyword or modifier.</summary>\r\n\tpublic static bool IsReserved( string identifier ) =>\r\n\t\t!string.IsNullOrEmpty( identifier ) && s_reserved.Contains( identifier );\r\n\r\n\t/// <summary>\r\n\t/// Turn arbitrary text into a legal Slang identifier, preserving as much of the original as\r\n\t/// possible so the generated module still reads like the graph that produced it.\r\n\t/// </summary>\r\n\tpublic static string SanitizeIdentifier( string name, string fallback = \"prismValue\" )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( name ) ) return fallback;\r\n\r\n\t\tvar builder = new StringBuilder( name.Length );\r\n\r\n\t\tforeach ( var c in name )\r\n\t\t{\r\n\t\t\tif ( char.IsLetterOrDigit( c ) || c == '_' ) builder.Append( c );\r\n\t\t\telse if ( builder.Length > 0 && builder[^1] != '_' ) builder.Append( '_' );\r\n\t\t}\r\n\r\n\t\twhile ( builder.Length > 0 && builder[^1] == '_' ) builder.Length--;\r\n\r\n\t\tif ( builder.Length == 0 ) return fallback;\r\n\t\tif ( char.IsDigit( builder[0] ) ) builder.Insert( 0, '_' );\r\n\r\n\t\tvar result = builder.ToString();\r\n\t\treturn IsReserved( result ) ? result + \"_\" : result;\r\n\t}\r\n\r\n\t/// <summary>PascalCase an identifier, dropping the shader-world hungarian prefixes on the way.</summary>\r\n\tpublic static string PascalCase( string name )\r\n\t{\r\n\t\tvar identifier = SanitizeIdentifier( name, \"Value\" );\r\n\t\tidentifier = StripPrefix( identifier );\r\n\r\n\t\tif ( identifier.Length == 0 ) return \"Value\";\r\n\r\n\t\tvar builder = new StringBuilder( identifier.Length );\r\n\t\tvar upper = true;\r\n\r\n\t\tforeach ( var c in identifier )\r\n\t\t{\r\n\t\t\tif ( c == '_' )\r\n\t\t\t{\r\n\t\t\t\tupper = true;\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tbuilder.Append( upper ? char.ToUpperInvariant( c ) : c );\r\n\t\t\tupper = false;\r\n\t\t}\r\n\r\n\t\tif ( builder.Length == 0 ) return \"Value\";\r\n\t\tif ( char.IsDigit( builder[0] ) ) builder.Insert( 0, '_' );\r\n\r\n\t\tvar result = builder.ToString();\r\n\t\treturn IsReserved( result ) ? result + \"_\" : result;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Normalise an engine field spelling to the Slang-idiomatic name the generated interface structs\r\n\t/// use. Anything unrecognised passes through untouched, so a struct field the graph invented still\r\n\t/// resolves against the declaration we copied from the module.\r\n\t/// </summary>\r\n\tpublic static string FieldName( string name )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( name ) ) return name;\r\n\t\tif ( s_fieldAliases.TryGetValue( name, out var alias ) ) return alias;\r\n\r\n\t\treturn SanitizeIdentifier( name, \"Field\" );\r\n\t}\r\n\r\n\tstatic string StripPrefix( string identifier )\r\n\t{\r\n\t\t// g_flRoughness -> Roughness, g_vTint -> Tint, m_Foo -> Foo.\r\n\t\tforeach ( var prefix in s_symbolPrefixes )\r\n\t\t{\r\n\t\t\tif ( identifier.Length <= prefix.Length ) continue;\r\n\t\t\tif ( !identifier.StartsWith( prefix, StringComparison.Ordinal ) ) continue;\r\n\r\n\t\t\tvar tail = identifier[prefix.Length..];\r\n\t\t\tif ( tail.Length > 0 && ( char.IsLetter( tail[0] ) || tail[0] == '_' ) ) return tail.TrimStart( '_' );\r\n\t\t}\r\n\r\n\t\treturn identifier;\r\n\t}\r\n\r\n\t// ---- builtins ----------------------------------------------------------\r\n\r\n\t/// <summary>The name of the prologue local an entry point binds a builtin to.</summary>\r\n\tpublic static string BuiltinLocal( Builtin id ) => LocalPrefix + id;\r\n\r\n\t/// <summary>\r\n\t/// Builtins this one is derived from. The prologue emits dependencies first, so\r\n\t/// <c>ViewDirection</c> can be written in terms of the already-bound <c>WorldPosition</c> local.\r\n\t/// </summary>\r\n\tpublic static IReadOnlyList<Builtin> Dependencies( Builtin id, ShaderStage stage )\r\n\t{\r\n\t\tif ( stage != ShaderStage.Vertex )\r\n\t\t{\r\n\t\t\treturn id == Builtin.ViewDirection ? s_dependsWorldPosition : Array.Empty<Builtin>();\r\n\t\t}\r\n\r\n\t\treturn id switch\r\n\t\t{\r\n\t\t\tBuiltin.WorldTangentV => s_dependsTangentFrame,\r\n\t\t\tBuiltin.ClipPosition => s_dependsWorldPosition,\r\n\t\t\tBuiltin.ScreenUv => s_dependsClipPosition,\r\n\t\t\tBuiltin.ViewDirection => s_dependsWorldPosition,\r\n\t\t\t_ => Array.Empty<Builtin>()\r\n\t\t};\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// True when a builtin has to travel from the vertex stage to the pixel stage through an\r\n\t/// interpolator, and therefore becomes a field of the generated <c>VsOut</c> struct.\r\n\t/// </summary>\r\n\tpublic static bool IsInterpolated( Builtin id ) => InterpolantField( id ) is not null;\r\n\r\n\t/// <summary>The <c>VsOut</c> field that carries a builtin, or null when it is not interpolated.</summary>\r\n\tpublic static string InterpolantField( Builtin id ) => id switch\r\n\t{\r\n\t\tBuiltin.WorldPosition => \"WorldPosition\",\r\n\t\tBuiltin.ObjectPosition => \"ObjectPosition\",\r\n\t\tBuiltin.WorldNormal => \"WorldNormal\",\r\n\t\tBuiltin.ObjectNormal => \"ObjectNormal\",\r\n\t\tBuiltin.WorldTangentU => \"WorldTangentU\",\r\n\t\tBuiltin.WorldTangentV => \"WorldTangentV\",\r\n\t\tBuiltin.ObjectTangentU => \"ObjectTangentU\",\r\n\t\tBuiltin.VertexColor => \"Color\",\r\n\t\tBuiltin.TexCoord0 => \"Uv\",\r\n\t\tBuiltin.TexCoord1 => \"Uv2\",\r\n\t\tBuiltin.VertexId => \"VertexId\",\r\n\t\tBuiltin.InstanceId => \"InstanceId\",\r\n\t\t_ => null\r\n\t};\r\n\r\n\t/// <summary>\r\n\t/// The <c>VsIn</c> attribute a builtin is derived from in the vertex stage, or null when it needs\r\n\t/// none. A domain whose vertex input does not carry that attribute \u2014 a full-screen post-process\r\n\t/// pass, for instance \u2014 binds the builtin to zero instead of naming a field that does not exist.\r\n\t/// </summary>\r\n\tpublic static string VertexInputField( Builtin id ) => id switch\r\n\t{\r\n\t\tBuiltin.WorldPosition or Builtin.ObjectPosition or\r\n\t\tBuiltin.ClipPosition or Builtin.ScreenUv => \"Position\",\r\n\t\tBuiltin.WorldNormal or Builtin.ObjectNormal => \"Normal\",\r\n\t\tBuiltin.WorldTangentU or Builtin.WorldTangentV or Builtin.ObjectTangentU => \"Tangent\",\r\n\t\tBuiltin.VertexColor => \"Color\",\r\n\t\tBuiltin.TexCoord0 => \"Uv\",\r\n\t\tBuiltin.TexCoord1 => \"Uv2\",\r\n\t\tBuiltin.VertexId => \"VertexId\",\r\n\t\tBuiltin.InstanceId => \"InstanceId\",\r\n\t\t_ => null\r\n\t};\r\n\r\n\t/// <summary>How an interpolated builtin's field interpolates across a triangle.</summary>\r\n\tpublic static IrInterpolation InterpolantMode( Builtin id ) =>\r\n\t\tid is Builtin.VertexId or Builtin.InstanceId ? IrInterpolation.NoInterpolation : IrInterpolation.Linear;\r\n\r\n\t/// <summary>\r\n\t/// The Slang expression a builtin lowers to in a given stage.\r\n\t/// <para>\r\n\t/// Vertex-stage expressions are computed from the <c>VsIn</c> attributes and the environment\r\n\t/// parameter block; pixel-stage expressions read the interpolated <c>VsOut</c> field. This is the\r\n\t/// single place that knows a builtin is a different expression in each stage \u2014 nodes never do.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic static string BuiltinExpression( Builtin id, ShaderStage stage )\r\n\t{\r\n\t\tvar input = InputParameter;\r\n\t\tvar frame = EnvironmentBlock + \".Frame\";\r\n\t\tvar obj = EnvironmentBlock + \".Object\";\r\n\r\n\t\tswitch ( id )\r\n\t\t{\r\n\t\t\t// -- uniform: identical in every stage\r\n\t\t\tcase Builtin.ObjectOrigin: return $\"{obj}.ObjectOrigin\";\r\n\t\t\tcase Builtin.ObjectScale: return $\"{obj}.ObjectScale\";\r\n\t\t\tcase Builtin.TintColor: return $\"{obj}.TintColor\";\r\n\t\t\tcase Builtin.ObjectToWorld: return $\"{obj}.ObjectToWorld\";\r\n\t\t\tcase Builtin.WorldToObject: return $\"{obj}.WorldToObject\";\r\n\t\t\tcase Builtin.CameraPosition: return $\"{frame}.CameraPosition\";\r\n\t\t\tcase Builtin.CameraForward: return $\"{frame}.CameraForward\";\r\n\t\t\tcase Builtin.CameraNear: return $\"{frame}.CameraNear\";\r\n\t\t\tcase Builtin.CameraFar: return $\"{frame}.CameraFar\";\r\n\t\t\tcase Builtin.ViewportSize: return $\"{frame}.ViewportSize\";\r\n\t\t\tcase Builtin.ViewportInvSize: return $\"{frame}.ViewportInvSize\";\r\n\t\t\tcase Builtin.ViewportOffset: return $\"{frame}.ViewportOffset\";\r\n\t\t\tcase Builtin.SunDirection: return $\"{frame}.SunDirection\";\r\n\t\t\tcase Builtin.SunColor: return $\"{frame}.SunColor\";\r\n\t\t\tcase Builtin.Time: return $\"{frame}.Time\";\r\n\t\t\tcase Builtin.DeltaTime: return $\"{frame}.DeltaTime\";\r\n\t\t\tcase Builtin.FrameCount: return $\"{frame}.FrameCount\";\r\n\t\t\tcase Builtin.ViewMatrix: return $\"{frame}.WorldToView\";\r\n\t\t\tcase Builtin.ProjectionMatrix: return $\"{frame}.ViewToProjection\";\r\n\t\t\tcase Builtin.ViewProjectionMatrix: return $\"{frame}.WorldToProjection\";\r\n\r\n\t\t\t// -- compute\r\n\t\t\tcase Builtin.DispatchThreadId:\r\n\t\t\t\treturn stage == ShaderStage.Compute ? DispatchThreadIdParameter : \"uint3( 0 )\";\r\n\t\t\tcase Builtin.GroupThreadId:\r\n\t\t\t\treturn stage == ShaderStage.Compute ? GroupThreadIdParameter : \"uint3( 0 )\";\r\n\t\t\tcase Builtin.GroupId:\r\n\t\t\t\treturn stage == ShaderStage.Compute ? GroupIdParameter : \"uint3( 0 )\";\r\n\r\n\t\t\t// -- view dependent\r\n\t\t\tcase Builtin.ViewDirection:\r\n\t\t\t\treturn $\"PrismSafeNormalize( {frame}.CameraPosition - {BuiltinLocal( Builtin.WorldPosition )} )\";\r\n\t\t}\r\n\r\n\t\tif ( stage == ShaderStage.Vertex ) return VertexExpression( id, input );\r\n\t\tif ( stage == ShaderStage.Pixel ) return PixelExpression( id, input );\r\n\r\n\t\treturn Zero( Builtins.TypeOf( id ) );\r\n\t}\r\n\r\n\tstatic string VertexExpression( Builtin id, string input ) => id switch\r\n\t{\r\n\t\tBuiltin.WorldPosition => $\"PrismObjectToWorldPoint( {input}.Position )\",\r\n\t\tBuiltin.ObjectPosition => $\"{input}.Position\",\r\n\t\tBuiltin.WorldNormal => $\"PrismObjectToWorldNormal( {input}.Normal )\",\r\n\t\tBuiltin.ObjectNormal => $\"{input}.Normal\",\r\n\t\tBuiltin.WorldTangentU => $\"PrismObjectToWorldDirection( {input}.Tangent.xyz )\",\r\n\t\tBuiltin.WorldTangentV =>\r\n\t\t\t$\"cross( {BuiltinLocal( Builtin.WorldNormal )}, {BuiltinLocal( Builtin.WorldTangentU )} ) * {input}.Tangent.w\",\r\n\t\tBuiltin.ObjectTangentU => $\"{input}.Tangent.xyz\",\r\n\t\tBuiltin.VertexColor => $\"{input}.Color\",\r\n\t\tBuiltin.TexCoord0 => $\"{input}.Uv\",\r\n\t\tBuiltin.TexCoord1 => $\"{input}.Uv2\",\r\n\t\tBuiltin.ClipPosition => $\"PrismWorldToClip( {BuiltinLocal( Builtin.WorldPosition )} )\",\r\n\t\tBuiltin.ScreenUv => $\"PrismScreenUvFromClip( {BuiltinLocal( Builtin.ClipPosition )} )\",\r\n\t\tBuiltin.VertexId => $\"{input}.VertexId\",\r\n\t\tBuiltin.InstanceId => $\"{input}.InstanceId\",\r\n\t\tBuiltin.IsFrontFace => \"true\",\r\n\t\t_ => Zero( Builtins.TypeOf( id ) )\r\n\t};\r\n\r\n\tstatic string PixelExpression( Builtin id, string input ) => id switch\r\n\t{\r\n\t\tBuiltin.WorldPosition => $\"{input}.WorldPosition\",\r\n\t\tBuiltin.ObjectPosition => $\"{input}.ObjectPosition\",\r\n\t\tBuiltin.WorldNormal => $\"PrismSafeNormalize( {input}.WorldNormal )\",\r\n\t\tBuiltin.ObjectNormal => $\"PrismSafeNormalize( {input}.ObjectNormal )\",\r\n\t\tBuiltin.WorldTangentU => $\"PrismSafeNormalize( {input}.WorldTangentU )\",\r\n\t\tBuiltin.WorldTangentV => $\"PrismSafeNormalize( {input}.WorldTangentV )\",\r\n\t\tBuiltin.ObjectTangentU => $\"{input}.ObjectTangentU\",\r\n\t\tBuiltin.VertexColor => $\"{input}.Color\",\r\n\t\tBuiltin.TexCoord0 => $\"{input}.Uv\",\r\n\t\tBuiltin.TexCoord1 => $\"{input}.Uv2\",\r\n\t\tBuiltin.ClipPosition => $\"{input}.Position\",\r\n\t\tBuiltin.ScreenUv => $\"{input}.Position.xy * {EnvironmentBlock}.Frame.ViewportInvSize\",\r\n\t\tBuiltin.PixelPosition => $\"{input}.Position.xy\",\r\n\t\tBuiltin.FragmentDepth => $\"{input}.Position.z\",\r\n\t\tBuiltin.IsFrontFace => FrontFaceParameter,\r\n\t\tBuiltin.VertexId => $\"{input}.VertexId\",\r\n\t\tBuiltin.InstanceId => $\"{input}.InstanceId\",\r\n\t\t_ => Zero( Builtins.TypeOf( id ) )\r\n\t};\r\n\r\n\t/// <summary>A zero value of a type, used where a builtin has no meaning in the current stage.</summary>\r\n\tpublic static string Zero( ShaderType type )\r\n\t{\r\n\t\tif ( type.IsVoid ) return \"0\";\r\n\t\tif ( type.IsScalar ) return Scalar( 0, type.Scalar );\r\n\r\n\t\treturn $\"{TypeName( type )}( {Scalar( 0, type.Scalar )} )\";\r\n\t}\r\n\r\n\tstatic readonly Builtin[] s_dependsWorldPosition = [Builtin.WorldPosition];\r\n\tstatic readonly Builtin[] s_dependsClipPosition = [Builtin.WorldPosition, Builtin.ClipPosition];\r\n\tstatic readonly Builtin[] s_dependsTangentFrame = [Builtin.WorldNormal, Builtin.WorldTangentU];\r\n\r\n\tstatic readonly string[] s_symbolPrefixes =\r\n\t[\r\n\t\t\"g_fl\", \"g_v\", \"g_col\", \"g_b\", \"g_n\", \"g_i\", \"g_t\", \"g_m\", \"g_s\", \"g_\", \"m_\", \"s_\", \"_\"\r\n\t];\r\n\r\n\tstatic readonly Dictionary<string, string> s_fieldAliases = new( StringComparer.Ordinal )\r\n\t{\r\n\t\t[\"vPositionOs\"] = \"Position\",\r\n\t\t[\"vPositionWs\"] = \"WorldPosition\",\r\n\t\t[\"vPositionPs\"] = \"Position\",\r\n\t\t[\"vPositionSs\"] = \"Position\",\r\n\t\t[\"vPositionWithOffsetWs\"] = \"WorldPosition\",\r\n\t\t[\"vNormalOs\"] = \"Normal\",\r\n\t\t[\"vNormalWs\"] = \"WorldNormal\",\r\n\t\t[\"vTangentUOs_flTangentVSign\"] = \"Tangent\",\r\n\t\t[\"vTangentUWs\"] = \"WorldTangentU\",\r\n\t\t[\"vTangentVWs\"] = \"WorldTangentV\",\r\n\t\t[\"vTexCoord\"] = \"Uv\",\r\n\t\t[\"vTextureCoords\"] = \"Uv\",\r\n\t\t[\"vTexCoord2\"] = \"Uv2\",\r\n\t\t[\"vVertexColor\"] = \"Color\",\r\n\t\t[\"vColor\"] = \"Color\",\r\n\t\t[\"vBlendValues\"] = \"BlendValues\",\r\n\t\t[\"nInstanceTransformID\"] = \"InstanceId\",\r\n\t\t[\"nVertexIndex\"] = \"VertexId\",\r\n\t\t[\"vLightmapUVs\"] = \"LightmapUv\"\r\n\t};\r\n\r\n\tstatic readonly HashSet<string> s_reserved = new( StringComparer.Ordinal )\r\n\t{\r\n\t\t// control flow\r\n\t\t\"if\", \"else\", \"switch\", \"case\", \"default\", \"return\", \"try\", \"throw\", \"throws\", \"catch\",\r\n\t\t\"while\", \"for\", \"do\", \"break\", \"continue\", \"discard\", \"defer\",\r\n\t\t// declarations\r\n\t\t\"let\", \"var\", \"func\", \"typedef\", \"typealias\", \"property\", \"get\", \"set\",\r\n\t\t\"class\", \"struct\", \"interface\", \"enum\", \"extension\", \"associatedtype\",\r\n\t\t\"namespace\", \"using\", \"import\", \"module\", \"implementing\",\r\n\t\t\"cbuffer\", \"tbuffer\", \"where\", \"syntax\", \"semantic\", \"type_param\", \"typename\",\r\n\t\t// modifiers\r\n\t\t\"static\", \"const\", \"extern\", \"inline\", \"public\", \"private\", \"internal\", \"protected\",\r\n\t\t\"uniform\", \"groupshared\", \"shared\", \"volatile\", \"coherent\", \"restrict\",\r\n\t\t\"readonly\", \"writeonly\", \"export\", \"override\", \"param\", \"require\",\r\n\t\t\"row_major\", \"column_major\", \"nointerpolation\", \"noperspective\", \"linear\", \"sample\",\r\n\t\t\"centroid\", \"precise\", \"in\", \"out\", \"inout\", \"ref\", \"dyn\", \"some\", \"implicit\",\r\n\t\t\"noncopyable\", \"constexpr\", \"mutating\", \"point\", \"line\", \"triangle\", \"lineadj\",\r\n\t\t\"triangleadj\", \"vertices\", \"indices\", \"primitives\", \"payload\", \"layout\",\r\n\t\t// expressions and literals\r\n\t\t\"as\", \"is\", \"this\", \"This\", \"sizeof\", \"alignof\", \"countof\", \"each\", \"expand\",\r\n\t\t\"optional\", \"nonempty\", \"true\", \"false\", \"nullptr\", \"none\", \"no_diff\",\r\n\t\t// types\r\n\t\t\"void\", \"bool\", \"int\", \"uint\", \"half\", \"float\", \"double\", \"string\",\r\n\t\t\"vector\", \"matrix\", \"functype\", \"int8_t\", \"int16_t\", \"int32_t\", \"int64_t\",\r\n\t\t\"uint8_t\", \"uint16_t\", \"uint32_t\", \"uint64_t\", \"float16_t\", \"float32_t\", \"float64_t\"\r\n\t};\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Compiler/NodeEmitter.cs",
"FileName": "NodeEmitter.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Compiler.Ir;\r\nusing Editor.Prism.Core;\r\nusing Editor.Prism.Model;\r\nusing System.Globalization;\r\nusing System.Reflection;\r\n\r\nnamespace Editor.Prism.Compiler;\r\n\r\n/// <summary>Cheap lookup tables built once per compile so traversal never rescans the edge list.</summary>\r\npublic static class GraphIndex\r\n{\r\n\t/// <summary>Every edge terminating on each input port.</summary>\r\n\tpublic static Dictionary<PortRef, List<Edge>> IncomingEdges( IPrismGraph graph )\r\n\t{\r\n\t\tvar map = new Dictionary<PortRef, List<Edge>>();\r\n\r\n\t\tif ( graph?.Edges is null ) return map;\r\n\r\n\t\tforeach ( var edge in graph.Edges )\r\n\t\t{\r\n\t\t\tif ( edge is null || !edge.IsValid ) continue;\r\n\r\n\t\t\tvar key = edge.To;\r\n\r\n\t\t\tif ( !map.TryGetValue( key, out var list ) )\r\n\t\t\t{\r\n\t\t\t\tlist = new List<Edge>();\r\n\t\t\t\tmap[key] = list;\r\n\t\t\t}\r\n\r\n\t\t\tlist.Add( edge );\r\n\t\t}\r\n\r\n\t\treturn map;\r\n\t}\r\n\r\n\t/// <summary>Every edge leaving each output port.</summary>\r\n\tpublic static Dictionary<PortRef, List<Edge>> OutgoingEdges( IPrismGraph graph )\r\n\t{\r\n\t\tvar map = new Dictionary<PortRef, List<Edge>>();\r\n\r\n\t\tif ( graph?.Edges is null ) return map;\r\n\r\n\t\tforeach ( var edge in graph.Edges )\r\n\t\t{\r\n\t\t\tif ( edge is null || !edge.IsValid ) continue;\r\n\r\n\t\t\tvar key = edge.From;\r\n\r\n\t\t\tif ( !map.TryGetValue( key, out var list ) )\r\n\t\t\t{\r\n\t\t\t\tlist = new List<Edge>();\r\n\t\t\t\tmap[key] = list;\r\n\t\t\t}\r\n\r\n\t\t\tlist.Add( edge );\r\n\t\t}\r\n\r\n\t\treturn map;\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// The demand-driven, memoised, post-order traversal that turns a graph into IR.\r\n/// <para>\r\n/// A value is computed by asking for it. The memo key is <c>(NodeId, PortId, ShaderStage)</c>, so a\r\n/// node used in both stages is emitted twice \u2014 which is correct, because the expressions genuinely\r\n/// differ there \u2014 while a node used twice within one stage is emitted once.\r\n/// </para>\r\n/// <para>\r\n/// Three properties matter more than the traversal itself. Cycles are detected with an explicit\r\n/// visit-state map <em>including reroutes</em>, and reported with the full path rather than hanging.\r\n/// A node that throws inside <see cref=\"PrismNode.Emit\"/> is quarantined: the exception is logged, a\r\n/// <c>PR3001</c> diagnostic is attached to that node, its outputs become <see cref=\"IrValue.Invalid\"/>\r\n/// and traversal continues. And emission order is a deterministic function of the graph, which is what\r\n/// makes \"regenerate, compare text, skip the compile\" reliable.\r\n/// </para>\r\n/// </summary>\r\npublic sealed class NodeEmitter\r\n{\r\n\t/// <summary>The name of the pixel-stage input struct instance the backends emit.</summary>\r\n\tpublic const string PixelInputVariable = \"i\";\r\n\r\n\t/// <summary>The name of the pixel-stage input struct type the backends emit.</summary>\r\n\tpublic const string PixelInputStruct = \"PixelInput\";\r\n\r\n\treadonly Dictionary<ShaderStage, IrBuilder> _builders = new();\r\n\treadonly Dictionary<(NodeId Node, PortId Port, ShaderStage Stage), IrValue> _outputs = new();\r\n\treadonly Dictionary<(NodeId Node, ShaderStage Stage), VisitState> _visited = new();\r\n\treadonly Dictionary<(NodeId Node, string Name), IrValue> _varyingSources = new();\r\n\treadonly Dictionary<string, VaryingBinding> _varyingBindings = new( StringComparer.Ordinal );\r\n\treadonly List<(NodeId Node, ShaderStage Stage)> _path = new();\r\n\treadonly List<PreviewAttribute> _previewAttributes = new();\r\n\treadonly List<PreviewTexture> _previewTextures = new();\r\n\treadonly HashSet<string> _reportedCycles = new( StringComparer.Ordinal );\r\n\r\n\tDictionary<PortRef, List<Edge>> _incoming = new();\r\n\tint _previewSerial;\r\n\tint _depthExceeded;\r\n\r\n\t/// <summary>Build an emitter for one compile.</summary>\r\n\tpublic NodeEmitter(\r\n\t\tIPrismGraph graph,\r\n\t\tIrModule module,\r\n\t\tCompileRequest request,\r\n\t\tDiagnosticSink diagnostics,\r\n\t\tBackends.IShaderBackend backend,\r\n\t\tStagePlan plan,\r\n\t\tVaryingAllocator varyings )\r\n\t{\r\n\t\tGraph = graph;\r\n\t\tModule = module ?? new IrModule();\r\n\t\tRequest = request;\r\n\t\tDiagnostics = diagnostics ?? new DiagnosticSink();\r\n\t\tBackend = backend;\r\n\t\tPlan = plan ?? StagePlan.Empty;\r\n\t\tVaryings = varyings ?? new VaryingAllocator();\r\n\r\n\t\t_incoming = GraphIndex.IncomingEdges( graph );\r\n\t}\r\n\r\n\t/// <summary>The document being compiled.</summary>\r\n\tpublic IPrismGraph Graph { get; }\r\n\r\n\t/// <summary>The module being built.</summary>\r\n\tpublic IrModule Module { get; }\r\n\r\n\t/// <summary>The request this emission answers.</summary>\r\n\tpublic CompileRequest Request { get; }\r\n\r\n\t/// <summary>What the compile is for.</summary>\r\n\tpublic CompileMode Mode => Request?.Mode ?? CompileMode.Final;\r\n\r\n\t/// <summary>Where problems go.</summary>\r\n\tpublic DiagnosticSink Diagnostics { get; }\r\n\r\n\t/// <summary>The primary target backend, or null when the module serves more than one.</summary>\r\n\tpublic Backends.IShaderBackend Backend { get; }\r\n\r\n\t/// <summary>Where every node runs.</summary>\r\n\tpublic StagePlan Plan { get; }\r\n\r\n\t/// <summary>The interpolator budget.</summary>\r\n\tpublic VaryingAllocator Varyings { get; }\r\n\r\n\t/// <summary>\r\n\t/// A prefix that scopes every interpolator this emitter allocates.\r\n\t/// <para>\r\n\t/// Empty for the document's own emitter. A subgraph splice builds a <em>second</em> emitter over the\r\n\t/// inlined document while sharing the outer <see cref=\"VaryingAllocator\"/>, and the inner document's\r\n\t/// node ids are the same for every instance of the same <c>.prismfn</c> \u2014 so without a per-instance\r\n\t/// prefix two instances produce the same interpolator key, the allocator hands the second instance\r\n\t/// the first one's register, and the second instance's vertex-side write clobbers the first's. The\r\n\t/// shader compiles and renders wrong, which is why this is a prefix rather than a comment.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic string KeyPrefix { get; init; } = string.Empty;\r\n\r\n\t/// <summary>\r\n\t/// The interpolator name one logical key resolves to. Scoped by <see cref=\"KeyPrefix\"/> so keys\r\n\t/// minted by two emitters sharing one allocator cannot collide.\r\n\t/// </summary>\r\n\tstring ScopedKey( string key ) => string.IsNullOrEmpty( KeyPrefix ) ? key : KeyPrefix + key;\r\n\r\n\t/// <summary>Emit descriptive temp names and per-node comments.</summary>\r\n\tpublic bool DebugSymbols => Request?.DebugSymbols ?? false;\r\n\r\n\t/// <summary>Emit explanatory comments alongside the generated code.</summary>\r\n\tpublic bool EmitComments => Request?.EmitComments ?? false;\r\n\r\n\t/// <summary>True when literals should become live-pushable uniforms instead of constants.</summary>\r\n\tpublic bool PreviewUniforms => Mode == CompileMode.Preview;\r\n\r\n\t/// <summary>\r\n\t/// Promote <em>every</em> literal a node creates in preview mode, not just the inline port values and\r\n\t/// parameters a user can actually drag.\r\n\t/// <para>\r\n\t/// Off by default, and deliberately. Inline literals and blackboard parameters are the values a\r\n\t/// slider moves, and those are promoted unconditionally; a magic number baked into a noise node's\r\n\t/// hash is not, and turning it into a uniform would cost a register, defeat constant folding and\r\n\t/// make the preview shader slower for no benefit.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic bool PromoteAllConstants { get; set; }\r\n\r\n\t/// <summary>Uniforms the preview can push without a recompile.</summary>\r\n\tpublic IReadOnlyList<PreviewAttribute> PreviewAttributes => _previewAttributes;\r\n\r\n\t/// <summary>Texture slots the preview has to fill itself. See <see cref=\"PreviewTextureBinding\"/>.</summary>\r\n\tpublic IReadOnlyList<PreviewTexture> PreviewTextures => _previewTextures;\r\n\r\n\t/// <summary>How many node emissions ran.</summary>\r\n\tpublic int NodesEmitted { get; private set; }\r\n\r\n\t/// <summary>How many nodes were quarantined after throwing.</summary>\r\n\tpublic int FailedNodes { get; private set; }\r\n\r\n\t/// <summary>How many cycles were detected and cut.</summary>\r\n\tpublic int Cycles { get; private set; }\r\n\r\n\t/// <summary>The builder for one stage, created on first use.</summary>\r\n\tpublic IrBuilder Builder( ShaderStage stage )\r\n\t{\r\n\t\tif ( _builders.TryGetValue( stage, out var builder ) ) return builder;\r\n\r\n\t\tbuilder = new IrBuilder( stage, DebugSymbols );\r\n\t\t_builders[stage] = builder;\r\n\r\n\t\treturn builder;\r\n\t}\r\n\r\n\t/// <summary>Every stage that had code emitted into it, in stage order.</summary>\r\n\tpublic IEnumerable<ShaderStage> ActiveStages =>\r\n\t\tShaderStages.All.Where( x => _builders.ContainsKey( x ) && !_builders[x].Root.IsEmpty );\r\n\r\n\t/// <summary>Statements emitted across every stage.</summary>\r\n\tpublic int StatementCount => _builders.Values.Sum( x => Count( x.Root ) );\r\n\r\n\t/// <summary>Temps declared across every stage.</summary>\r\n\tpublic int TempCount => _builders.Values.Sum( x => x.TempCount );\r\n\r\n\t/// <summary>Expressions answered from an existing temp instead of being recomputed.</summary>\r\n\tpublic int CseHits => _builders.Values.Sum( x => x.CseHits );\r\n\r\n\t// ---- demand -----------------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// The value of one producer port in one stage, emitting whatever is needed to produce it.\r\n\t/// Returns <see cref=\"IrValue.Invalid\"/> for a disabled node, a cycle or a node that threw.\r\n\t/// </summary>\r\n\tpublic IrValue Demand( PortRef producer, ShaderStage stage )\r\n\t{\r\n\t\tif ( !producer.IsValid ) return IrValue.Invalid;\r\n\r\n\t\tvar key = (producer.Node, producer.Port, stage);\r\n\r\n\t\tif ( _outputs.TryGetValue( key, out var cached ) ) return cached;\r\n\r\n\t\t// Traversal is demand-driven and therefore recursive: one managed frame per node in the\r\n\t\t// dependency chain. A long enough chain \u2014 measured at around 290 chained add nodes \u2014 exhausts\r\n\t\t// the stack, and a .NET StackOverflowException cannot be caught: it bypasses the PrismLog.Guard\r\n\t\t// quarantine entirely and takes the whole editor process down, with no diagnostic and no\r\n\t\t// autosave. 290 nodes is a large graph but not an absurd one.\r\n\t\t//\r\n\t\t// TryEnsureSufficientExecutionStack asks the runtime whether there is room for another frame\r\n\t\t// rather than guessing a depth limit, so this stays correct whatever the stack size and whatever\r\n\t\t// the frames happen to cost in a given build. Failing here costs one wrong value and a\r\n\t\t// diagnostic that names the node.\r\n\t\tif ( !System.Runtime.CompilerServices.RuntimeHelpers.TryEnsureSufficientExecutionStack() )\r\n\t\t{\r\n\t\t\t// Reported once. The guard trips at whatever depth the stack ran out and then trips again on\r\n\t\t\t// every frame as the recursion unwinds and re-descends, which would bury the diagnostics\r\n\t\t\t// panel under hundreds of copies of the same sentence.\r\n\t\t\tif ( _depthExceeded == 0 )\r\n\t\t\t{\r\n\t\t\t\t_depthExceeded = 1;\r\n\r\n\t\t\t\tDiagnostics.Error( DiagnosticCode.NodeEmitFailed,\r\n\t\t\t\t\t\"This graph's dependency chain is too deep to compile\",\r\n\t\t\t\t\tGraphRef.ForPort( producer.Node, producer.Port ),\r\n\t\t\t\t\t\"Values are produced by walking backwards from the output, one step per node, and \" +\r\n\t\t\t\t\t\"this chain ran out of room. Break it up with a subgraph, or fold a run of \" +\r\n\t\t\t\t\t\"operations into one Custom Code node.\" );\r\n\t\t\t}\r\n\r\n\t\t\t_outputs[key] = IrValue.Invalid;\r\n\t\t\treturn IrValue.Invalid;\r\n\t\t}\r\n\r\n\t\tvar node = Graph?.FindNode( producer.Node );\r\n\r\n\t\tif ( node is null )\r\n\t\t{\r\n\t\t\tDiagnostics.Error( DiagnosticCode.DanglingEdge,\r\n\t\t\t\t$\"Connection reads from node '{producer.Node}', which is not in this document\",\r\n\t\t\t\tGraphRef.ForNode( producer.Node ) );\r\n\r\n\t\t\t_outputs[key] = IrValue.Invalid;\r\n\t\t\treturn IrValue.Invalid;\r\n\t\t}\r\n\r\n\t\tif ( ( node.Flags & NodeFlags.Disabled ) != 0 )\r\n\t\t{\r\n\t\t\t_outputs[key] = IrValue.Invalid;\r\n\t\t\treturn IrValue.Invalid;\r\n\t\t}\r\n\r\n\t\t// The stage planner decided this value is produced per vertex and interpolated. Honour that here\r\n\t\t// rather than in the node, so nodes never have to know which side of the boundary they are on.\r\n\t\tif ( stage == ShaderStage.Pixel && Plan.IsVarying( producer ) )\r\n\t\t{\r\n\t\t\tvar vertex = Demand( producer, ShaderStage.Vertex );\r\n\r\n\t\t\tif ( vertex.IsValid )\r\n\t\t\t{\r\n\t\t\t\t// \"port:\" namespaces this against the \"user:\" keys EmitContext.Varying mints, so a node\r\n\t\t\t\t// whose output port is called Result and which also calls Varying( \"Result\", \u2026 ) gets two\r\n\t\t\t\t// interpolators rather than one shared by accident.\r\n\t\t\t\tvar interpolated = Interpolate( $\"port:{producer.Node}.{producer.Port}\", vertex, producer.Node );\r\n\r\n\t\t\t\t_outputs[key] = interpolated;\r\n\t\t\t\treturn interpolated;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tEmitNode( node, stage );\r\n\r\n\t\tif ( _outputs.TryGetValue( key, out var produced ) ) return produced;\r\n\r\n\t\t// The node ran but never wrote this port.\r\n\t\tDiagnostics.Warn( DiagnosticCode.MissingInput,\r\n\t\t\t$\"'{Describe( node )}' produced no value for output '{producer.Port}'\",\r\n\t\t\tGraphRef.ForPort( producer.Node, producer.Port ) );\r\n\r\n\t\t_outputs[key] = IrValue.Invalid;\r\n\t\treturn IrValue.Invalid;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The value feeding one input port in one stage: the connected producer coerced to the port's\r\n\t/// resolved type, or the port's inline literal, or <see cref=\"IrValue.Invalid\"/>. Silent when the\r\n\t/// port is simply unconnected \u2014 reporting that is the caller's decision.\r\n\t/// </summary>\r\n\tpublic IrValue DemandInput( PrismNode node, InputPort port, ShaderStage stage, NodeEmitContext context )\r\n\t{\r\n\t\tif ( node is null || port is null ) return IrValue.Invalid;\r\n\r\n\t\tvar edges = IncomingEdges( node.Id, port.Id );\r\n\r\n\t\tif ( edges.Count > 0 )\r\n\t\t{\r\n\t\t\tvar edge = edges[0];\r\n\t\t\tvar value = Demand( edge.From, stage );\r\n\r\n\t\t\tif ( !value.IsValid ) return IrValue.Invalid;\r\n\r\n\t\t\tvar target = port.EffectiveType;\r\n\r\n\t\t\tif ( target.IsVoid || target == value.Type ) return value;\r\n\r\n\t\t\treturn context is null ? value : context.Coerce( value, target, edge.Fill, port.Id );\r\n\t\t}\r\n\r\n\t\t// The inline literal goes through the same coercion as a connected value. Without this a port\r\n\t\t// answers with a different type depending on whether anything is plugged into it: an\r\n\t\t// [In( \"float3\" )] port whose [InlineValue] property is a Color reads back as float4 when\r\n\t\t// unwired \u2014 TryReadConstant keeps the literal's own component count and only adopts the port's\r\n\t\t// scalar kind \u2014 and float3 once wired. EmitContext.Out then trusts the node and retypes the\r\n\t\t// output port, so the whole downstream chain widens on a port nobody connected.\r\n\t\tvar literal = InlineValue( node, port, stage );\r\n\r\n\t\tif ( !literal.IsValid || context is null ) return literal;\r\n\r\n\t\tvar wanted = port.EffectiveType;\r\n\r\n\t\tif ( wanted.IsVoid || wanted == literal.Type ) return literal;\r\n\r\n\t\treturn context.Coerce( literal, wanted, null, port.Id );\r\n\t}\r\n\r\n\t/// <summary>The literal a port falls back to when nothing is connected.</summary>\r\n\tpublic IrValue InlineValue( PrismNode node, InputPort port, ShaderStage stage )\r\n\t{\r\n\t\tif ( node is null || port is null ) return IrValue.Invalid;\r\n\r\n\t\tvar hint = port.EffectiveType;\r\n\r\n\t\tif ( hint.IsObject ) return IrValue.Invalid;\r\n\r\n\t\tobject raw = port.InlineValue;\r\n\r\n\t\tif ( raw is null && !string.IsNullOrEmpty( port.Def.InlineValueProperty ) )\r\n\t\t{\r\n\t\t\traw = ReadProperty( node, port.Def.InlineValueProperty );\r\n\t\t}\r\n\r\n\t\tif ( raw is null ) return IrValue.Invalid;\r\n\t\tif ( !TryReadConstant( raw, hint, out var type, out var value ) ) return IrValue.Invalid;\r\n\r\n\t\tif ( PreviewUniforms && type.IsNumeric && type.Components <= 4 )\r\n\t\t{\r\n\t\t\treturn PreviewUniform( node.Id, port.Id, type, value, stage );\r\n\t\t}\r\n\r\n\t\treturn Builder( stage ).Const( type, value );\r\n\t}\r\n\r\n\tIReadOnlyList<Edge> IncomingEdges( NodeId node, PortId port ) =>\r\n\t\t_incoming.TryGetValue( new PortRef( node, port ), out var edges ) ? edges : Array.Empty<Edge>();\r\n\r\n\t/// <summary>Publish the value of one output port. Called by <c>EmitContext.Out</c>.</summary>\r\n\tpublic void SetOutput( NodeId node, PortId port, ShaderStage stage, IrValue value ) =>\r\n\t\t_outputs[(node, port, stage)] = value;\r\n\r\n\t/// <summary>True when a value has already been published for this output.</summary>\r\n\tpublic bool HasOutput( NodeId node, PortId port, ShaderStage stage ) =>\r\n\t\t_outputs.ContainsKey( (node, port, stage) );\r\n\r\n\t// ---- node emission ----------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Run one node's <see cref=\"PrismNode.Emit\"/> for one stage, at most once. Cycles are cut and\r\n\t/// reported with their full path; exceptions are quarantined to the node that threw.\r\n\t/// </summary>\r\n\tpublic bool EmitNode( PrismNode node, ShaderStage stage )\r\n\t{\r\n\t\tif ( node is null ) return false;\r\n\r\n\t\tvar key = (node.Id, stage);\r\n\r\n\t\tif ( _visited.TryGetValue( key, out var state ) )\r\n\t\t{\r\n\t\t\tif ( state == VisitState.Done ) return true;\r\n\t\t\tif ( state == VisitState.Failed ) return false;\r\n\r\n\t\t\tReportCycle( node, stage );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\t_visited[key] = VisitState.Visiting;\r\n\t\t_path.Add( key );\r\n\r\n\t\tvar builder = Builder( stage );\r\n\t\tvar context = new NodeEmitContext( this, node, stage, builder );\r\n\r\n\t\tvar ok = true;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( EmitComments || DebugSymbols )\r\n\t\t\t{\r\n\t\t\t\tbuilder.Comment( node.Id, $\"{Describe( node )} #{node.Id}\" );\r\n\t\t\t}\r\n\r\n\t\t\tnode.Emit( context );\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tok = false;\r\n\t\t\tFailedNodes++;\r\n\r\n\t\t\tPrismLog.Error( e, $\"Node '{Describe( node )}' ({node.Id}) threw while emitting\" );\r\n\r\n\t\t\tDiagnostics.Report( new Diagnostic( DiagnosticSeverity.Error, DiagnosticCode.NodeEmitFailed,\r\n\t\t\t\t$\"'{Describe( node )}' failed to emit: {e.Message}\", e.ToString(), null,\r\n\t\t\t\tGraphRef.ForNode( node.Id ) ) );\r\n\t\t}\r\n\t\tfinally\r\n\t\t{\r\n\t\t\t_path.RemoveAt( _path.Count - 1 );\r\n\t\t\tNodesEmitted++;\r\n\t\t}\r\n\r\n\t\t// Every output the node did not write becomes invalid, so a partial failure degrades one wire\r\n\t\t// at a time rather than taking the compile down.\r\n\t\tforeach ( var output in node.Outputs )\r\n\t\t{\r\n\t\t\tvar outputKey = (node.Id, output.Id, stage);\r\n\r\n\t\t\tif ( _outputs.ContainsKey( outputKey ) ) continue;\r\n\t\t\tif ( ok ) continue;\r\n\r\n\t\t\t_outputs[outputKey] = IrValue.Invalid;\r\n\t\t}\r\n\r\n\t\t_visited[key] = ok ? VisitState.Done : VisitState.Failed;\r\n\t\treturn ok;\r\n\t}\r\n\r\n\tvoid ReportCycle( PrismNode node, ShaderStage stage )\r\n\t{\r\n\t\tCycles++;\r\n\r\n\t\tvar start = _path.FindIndex( x => x.Node == node.Id && x.Stage == stage );\r\n\t\tvar names = new List<string>();\r\n\r\n\t\tfor ( int i = Math.Max( 0, start ); i < _path.Count; i++ )\r\n\t\t{\r\n\t\t\tvar member = Graph?.FindNode( _path[i].Node );\r\n\t\t\tnames.Add( member is null ? _path[i].Node.ToString() : $\"{Describe( member )} #{_path[i].Node}\" );\r\n\t\t}\r\n\r\n\t\tnames.Add( $\"{Describe( node )} #{node.Id}\" );\r\n\r\n\t\tvar path = string.Join( \" -> \", names );\r\n\r\n\t\t// Only the first report per distinct cycle; a diamond above a cycle would otherwise repeat it.\r\n\t\tif ( !_reportedCycles.Add( path ) ) return;\r\n\r\n\t\tDiagnostics.Error( DiagnosticCode.Cycle,\r\n\t\t\t$\"'{Describe( node )}' is part of a feedback loop and cannot be compiled\",\r\n\t\t\tGraphRef.ForNode( node.Id ),\r\n\t\t\t$\"Cycle: {path}\" );\r\n\t}\r\n\r\n\t// ---- varyings ---------------------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Move a value across the vertex-to-pixel boundary.\r\n\t/// <para>\r\n\t/// Called from the vertex stage this only records the source expression and hands the value straight\r\n\t/// back. Called from the pixel stage it re-runs the owning node in the vertex stage \u2014 the emission\r\n\t/// is memoised per stage, so this costs nothing the second time \u2014 takes the value the node\r\n\t/// registered there, allocates an interpolator, emits the vertex-side write and returns the\r\n\t/// interpolated read.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic IrValue Varying( PrismNode node, string name, IrValue vsValue, ShaderStage consumerStage )\r\n\t{\r\n\t\tif ( node is null || string.IsNullOrWhiteSpace( name ) ) return vsValue;\r\n\r\n\t\tvar key = $\"user:{node.Id}.{name}\";\r\n\r\n\t\tif ( consumerStage == ShaderStage.Vertex )\r\n\t\t{\r\n\t\t\tif ( vsValue.IsValid ) _varyingSources[(node.Id, name)] = vsValue;\r\n\t\t\treturn vsValue;\r\n\t\t}\r\n\r\n\t\tif ( consumerStage != ShaderStage.Pixel )\r\n\t\t{\r\n\t\t\t// Geometry and compute have no interpolators of ours; the value stays where it was computed.\r\n\t\t\treturn vsValue;\r\n\t\t}\r\n\r\n\t\tif ( _varyingBindings.TryGetValue( ScopedKey( key ), out var known ) )\r\n\t\t{\r\n\t\t\treturn ReadVarying( known, consumerStage );\r\n\t\t}\r\n\r\n\t\t// Ask the vertex stage for the value. Guard against a node that calls Varying while it is\r\n\t\t// already being emitted in the vertex stage.\r\n\t\tif ( !_varyingSources.TryGetValue( (node.Id, name), out var source ) )\r\n\t\t{\r\n\t\t\tif ( !IsVisiting( node.Id, ShaderStage.Vertex ) ) EmitNode( node, ShaderStage.Vertex );\r\n\r\n\t\t\t_varyingSources.TryGetValue( (node.Id, name), out source );\r\n\t\t}\r\n\r\n\t\tif ( !source.IsValid )\r\n\t\t{\r\n\t\t\tDiagnostics.Info( DiagnosticCode.SampleLowered,\r\n\t\t\t\t$\"'{Describe( node )}' could not produce '{name}' in the vertex stage; it is computed per pixel instead\",\r\n\t\t\t\tGraphRef.ForNode( node.Id ) );\r\n\r\n\t\t\treturn vsValue;\r\n\t\t}\r\n\r\n\t\tvar interpolated = Interpolate( key, source, node.Id );\r\n\r\n\t\treturn interpolated.IsValid ? interpolated : vsValue;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Allocate an interpolator for a vertex-stage value, emit the vertex-side write and return the\r\n\t/// pixel-stage read. Idempotent per key, so demanding the same value twice costs one register.\r\n\t/// <para>\r\n\t/// <paramref name=\"key\"/> is a <em>logical</em> key. It is scoped by <see cref=\"KeyPrefix\"/> before\r\n\t/// it reaches the shared allocator, so a caller never has to know whether this emitter is the\r\n\t/// document's own or one splicing a subgraph into it.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic IrValue Interpolate( string key, IrValue source, NodeId origin )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( key ) || !source.IsValid ) return IrValue.Invalid;\r\n\r\n\t\tvar scoped = ScopedKey( key );\r\n\r\n\t\tif ( _varyingBindings.TryGetValue( scoped, out var known ) ) return ReadVarying( known, ShaderStage.Pixel );\r\n\r\n\t\tvar binding = Varyings.Allocate( scoped, source.Type, InterpolationFor( source.Type ), origin, Diagnostics );\r\n\r\n\t\tif ( !binding.IsValid ) return IrValue.Invalid;\r\n\r\n\t\t_varyingBindings[scoped] = binding;\r\n\r\n\t\tvar vertex = Builder( ShaderStage.Vertex );\r\n\r\n\t\tvertex.Assign( origin, VaryingAccess( vertex, binding ), source );\r\n\r\n\t\treturn ReadVarying( binding, ShaderStage.Pixel );\r\n\t}\r\n\r\n\tstatic IrInterpolation InterpolationFor( ShaderType type ) =>\r\n\t\ttype.IsFloatingPoint ? IrInterpolation.Linear : IrInterpolation.NoInterpolation;\r\n\r\n\t/// <summary>\r\n\t/// The expression that names one packed value inside its interpolator register.\r\n\t/// <para>\r\n\t/// The register is typed as a full four components on purpose. Its real width is not known until\r\n\t/// allocation has finished \u2014 another value may still be packed alongside this one \u2014 and a swizzle\r\n\t/// whose base claims the narrower width would look like an identity to the optimiser and be folded\r\n\t/// away, silently widening the read once the register grew.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tIrValue VaryingAccess( IrBuilder builder, VaryingBinding binding )\r\n\t{\r\n\t\tvar input = builder.Var( ShaderType.Struct( PixelInputStruct ), PixelInputVariable );\r\n\t\tvar register = ShaderType.Vec( binding.Type.Scalar, 4 );\r\n\t\tvar field = builder.Member( register, input, binding.Slot );\r\n\r\n\t\treturn binding.IsWholeSlot ? field : builder.Swizzle( binding.Type, field, binding.Swizzle );\r\n\t}\r\n\r\n\tIrValue ReadVarying( VaryingBinding binding, ShaderStage stage ) => VaryingAccess( Builder( stage ), binding );\r\n\r\n\tbool IsVisiting( NodeId node, ShaderStage stage ) =>\r\n\t\t_visited.TryGetValue( (node, stage), out var state ) && state == VisitState.Visiting;\r\n\r\n\t// ---- module registration ----------------------------------------------\r\n\r\n\t/// <summary>\r\n\t/// Add a global to the module, or reuse the identical declaration already there. A different\r\n\t/// declaration claiming the same name is reported rather than silently overwriting.\r\n\t/// </summary>\r\n\tpublic GlobalDecl RegisterGlobal( GlobalDecl decl, NodeId origin )\r\n\t{\r\n\t\tif ( decl is null || string.IsNullOrEmpty( decl.Name ) ) return null;\r\n\r\n\t\tdecl = PreviewTextureBinding( decl );\r\n\r\n\t\tvar existing = Module.FindGlobal( decl.Name );\r\n\r\n\t\tif ( existing is null )\r\n\t\t{\r\n\t\t\tModule.Globals.Add( decl );\r\n\r\n\t\t\t// Recorded here rather than in PreviewTextureBinding: four nodes sampling one slot register\r\n\t\t\t// the same declaration four times, and the preview only needs to be told about it once.\r\n\t\t\tRecordPreviewTexture( decl );\r\n\r\n\t\t\treturn decl;\r\n\t\t}\r\n\r\n\t\tif ( existing.ConflictsWith( decl ) )\r\n\t\t{\r\n\t\t\tDiagnostics.Error( DiagnosticCode.GlobalCollision,\r\n\t\t\t\t$\"Two different declarations both claim the name '{decl.Name}'\",\r\n\t\t\t\tGraphRef.ForNode( origin ),\r\n\t\t\t\t$\"{existing} vs {decl}\" );\r\n\t\t}\r\n\r\n\t\treturn existing;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Add a helper and everything it needs to the module, in dependency order. A same-name helper with\r\n\t/// a different body is a hard error naming both, not a silent first-one-wins.\r\n\t/// </summary>\r\n\tpublic HelperFunction RegisterHelper( HelperFunction fn, NodeId origin )\r\n\t{\r\n\t\tif ( fn is null || string.IsNullOrEmpty( fn.Name ) ) return null;\r\n\r\n\t\tvar existing = Module.Helpers.FirstOrDefault( x => x.Name == fn.Name );\r\n\r\n\t\tif ( existing is not null )\r\n\t\t{\r\n\t\t\tif ( existing.ConflictsWith( fn ) )\r\n\t\t\t{\r\n\t\t\t\tDiagnostics.Error( DiagnosticCode.HelperCollision,\r\n\t\t\t\t\t$\"Two different helper functions are both called '{fn.Name}'\",\r\n\t\t\t\t\tGraphRef.ForNode( origin ),\r\n\t\t\t\t\t\"Helpers are deduplicated by name per module. Rename one of them.\" );\r\n\t\t\t}\r\n\r\n\t\t\treturn existing;\r\n\t\t}\r\n\r\n\t\tforeach ( var required in fn.Requires ?? Array.Empty<HelperFunction>() )\r\n\t\t{\r\n\t\t\tif ( required is null || ReferenceEquals( required, fn ) ) continue;\r\n\r\n\t\t\tRegisterHelper( required, origin );\r\n\t\t}\r\n\r\n\t\tModule.Helpers.Add( fn );\r\n\r\n\t\tforeach ( var include in fn.Includes ?? Array.Empty<string>() ) Module.AddInclude( include );\r\n\r\n\t\tforeach ( var capability in fn.Capabilities ?? Array.Empty<Capability>() )\r\n\t\t{\r\n\t\t\tif ( capability != Capability.None ) Module.Meta.Capabilities.Add( capability );\r\n\t\t}\r\n\r\n\t\treturn fn;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Rebind a texture slot to a render attribute for the preview, and remember which asset belongs in\r\n\t/// it so the viewport can push the real texture.\r\n\t/// <remarks>\r\n\t/// A shipping shader declares a texture as <c>CreateInputTexture2D</c> plus a <c>Channel( \u2026 Box( \u2026 ) )</c>\r\n\t/// slot. That pair is resolved by the <em>resource compiler</em> when a material is built: the source\r\n\t/// image named by <c>DefaultFile</c> is baked into the material's own texture. The preview has no\r\n\t/// material \u2014 it renders the shader straight onto a scene object with render attributes \u2014 so nothing\r\n\t/// ever performs that bake and every sampler reads black. A graph whose output is multiplied by its\r\n\t/// textures then previews as a black surface, and any animation in it is invisible because it is\r\n\t/// being multiplied by zero.\r\n\t/// <para>\r\n\t/// Binding to an attribute instead is what the built-in editor does for exactly this reason (see its\r\n\t/// <c>GraphCompiler</c> preview branch), and <c>DeclareTexture</c> already emits that form for any\r\n\t/// declaration carrying an <c>AttributeName</c>. The asset path travels out on the compile result so\r\n\t/// the viewport can load it once and push it through <see cref=\"Preview.PreviewAttributeBus\"/>.\r\n\t/// </para>\r\n\t/// </remarks>\r\n\t/// </summary>\r\n\tGlobalDecl PreviewTextureBinding( GlobalDecl decl )\r\n\t{\r\n\t\tif ( Mode != CompileMode.Preview || decl.Kind != GlobalKind.Texture ) return decl;\r\n\r\n\t\t// Already attribute-bound: the graph asked for that itself, and whatever drives it owns the push.\r\n\t\tif ( !string.IsNullOrEmpty( decl.AttributeName ) ) return decl;\r\n\r\n\t\treturn decl with { AttributeName = decl.Name };\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Note a newly declared preview texture slot so the viewport can fill it. Only slots naming an\r\n\t/// asset are recorded; one with nothing to load would push white over a slot the user may be driving\r\n\t/// themselves through the parameter panel.\r\n\t/// </summary>\r\n\tvoid RecordPreviewTexture( GlobalDecl decl )\r\n\t{\r\n\t\tif ( Mode != CompileMode.Preview || decl.Kind != GlobalKind.Texture ) return;\r\n\t\tif ( string.IsNullOrWhiteSpace( decl.DefaultAsset ) || string.IsNullOrEmpty( decl.AttributeName ) ) return;\r\n\r\n\t\t_previewTextures.Add( new PreviewTexture( decl.AttributeName, decl.DefaultAsset, decl.Srgb )\r\n\t\t{\r\n\t\t\tParameter = decl.Parameter\r\n\t\t} );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Turn a literal into a uniform the preview can push straight to the GPU, so dragging a slider\r\n\t/// updates the frame without recompiling anything.\r\n\t/// </summary>\r\n\tpublic IrValue PreviewUniform( NodeId node, PortId port, ShaderType type, ConstValue value, ShaderStage stage )\r\n\t{\r\n\t\tvar tag = stage switch\r\n\t\t{\r\n\t\t\tShaderStage.Vertex => \"vs\",\r\n\t\t\tShaderStage.Pixel => \"ps\",\r\n\t\t\tShaderStage.Geometry => \"gs\",\r\n\t\t\tShaderStage.Compute => \"cs\",\r\n\t\t\t_ => \"any\"\r\n\t\t};\r\n\r\n\t\tvar name = $\"{PrismConstants.SymbolPrefix}_{tag}_{_previewSerial++}\";\r\n\r\n\t\tvar decl = new GlobalDecl( name, type, GlobalKind.Uniform )\r\n\t\t{\r\n\t\t\tAttributeName = name,\r\n\t\t\tDefault = value,\r\n\t\t\tPreviewOnly = true,\r\n\t\t\tStages = stage.ToMask()\r\n\t\t};\r\n\r\n\t\tRegisterGlobal( decl, node );\r\n\r\n\t\t_previewAttributes.Add( new PreviewAttribute( name, type, value )\r\n\t\t{\r\n\t\t\tNode = node,\r\n\t\t\tPort = port\r\n\t\t} );\r\n\r\n\t\treturn Builder( stage ).GlobalRef( decl );\r\n\t}\r\n\r\n\t// ---- literals ---------------------------------------------------------\r\n\r\n\t/// <summary>Read a node property by name, tolerating anything that is not there.</summary>\r\n\tpublic static object ReadProperty( PrismNode node, string name )\r\n\t{\r\n\t\tif ( node is null || string.IsNullOrEmpty( name ) ) return null;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar property = node.GetType().GetProperty( name,\r\n\t\t\t\tBindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy );\r\n\r\n\t\t\tif ( property is not null && property.CanRead ) return property.GetValue( node );\r\n\r\n\t\t\tvar field = node.GetType().GetField( name,\r\n\t\t\t\tBindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance );\r\n\r\n\t\t\treturn field?.GetValue( node );\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tPrismLog.Error( e, $\"Reading property '{name}' from {node.GetType().Name} failed\" );\r\n\t\t\treturn null;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Turn a boxed authored literal into a typed constant. Deliberately permissive: an inline value\r\n\t/// arrives from JSON, from a node property or from a paste, and none of those are trustworthy.\r\n\t/// </summary>\r\n\tpublic static bool TryReadConstant( object raw, ShaderType hint, out ShaderType type, out ConstValue value )\r\n\t{\r\n\t\ttype = ShaderType.Void;\r\n\t\tvalue = default;\r\n\r\n\t\tif ( raw is null ) return false;\r\n\r\n\t\tswitch ( raw )\r\n\t\t{\r\n\t\t\tcase bool b:\r\n\t\t\t\ttype = Shape( hint, ShaderType.Bool );\r\n\t\t\t\tvalue = ConstValue.From( b );\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase float f:\r\n\t\t\t\ttype = Shape( hint, ShaderType.Float );\r\n\t\t\t\tvalue = ConstValue.From( f );\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase double d:\r\n\t\t\t\ttype = Shape( hint, ShaderType.Float );\r\n\t\t\t\tvalue = ConstValue.From( (float)d );\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase int i:\r\n\t\t\t\ttype = Shape( hint, hint.IsFloatingPoint ? ShaderType.Float : ShaderType.Int );\r\n\t\t\t\tvalue = ConstValue.From( i );\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase long l:\r\n\t\t\t\ttype = Shape( hint, hint.IsFloatingPoint ? ShaderType.Float : ShaderType.Int );\r\n\t\t\t\tvalue = ConstValue.From( (int)l );\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase short s:\r\n\t\t\t\ttype = Shape( hint, ShaderType.Int );\r\n\t\t\t\tvalue = ConstValue.From( (int)s );\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase byte by:\r\n\t\t\t\ttype = Shape( hint, ShaderType.Int );\r\n\t\t\t\tvalue = ConstValue.From( (int)by );\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase Vector2 v2:\r\n\t\t\t\ttype = Shape( hint, ShaderType.Float2 );\r\n\t\t\t\tvalue = ConstValue.From( v2 );\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase Vector3 v3:\r\n\t\t\t\ttype = Shape( hint, ShaderType.Float3 );\r\n\t\t\t\tvalue = ConstValue.From( v3 );\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase Vector4 v4:\r\n\t\t\t\ttype = Shape( hint, ShaderType.Float4 );\r\n\t\t\t\tvalue = ConstValue.From( v4 );\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase Color color:\r\n\t\t\t\ttype = Shape( hint, ShaderType.Float4 );\r\n\t\t\t\tvalue = ConstValue.From( color );\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase Enum e:\r\n\t\t\t\ttype = Shape( hint, ShaderType.Int );\r\n\t\t\t\tvalue = ConstValue.From( Convert.ToInt32( e, CultureInfo.InvariantCulture ) );\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase ConstValue constant:\r\n\t\t\t\ttype = hint.IsNumeric ? hint : ShaderType.Float4;\r\n\t\t\t\tvalue = constant;\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase string text:\r\n\t\t\t\treturn TryParseText( text, hint, out type, out value );\r\n\r\n\t\t\tcase JsonNode json:\r\n\t\t\t\treturn TryReadJson( json, hint, out type, out value );\r\n\t\t}\r\n\r\n\t\tif ( raw is System.Collections.IEnumerable sequence and not string )\r\n\t\t{\r\n\t\t\tvar numbers = new List<double>( 4 );\r\n\r\n\t\t\tforeach ( var item in sequence )\r\n\t\t\t{\r\n\t\t\t\tif ( item is null ) continue;\r\n\r\n\t\t\t\ttry\r\n\t\t\t\t{\r\n\t\t\t\t\tnumbers.Add( Convert.ToDouble( item, CultureInfo.InvariantCulture ) );\r\n\t\t\t\t}\r\n\t\t\t\tcatch ( Exception )\r\n\t\t\t\t{\r\n\t\t\t\t\treturn false;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif ( numbers.Count == 4 ) break;\r\n\t\t\t}\r\n\r\n\t\t\tif ( numbers.Count == 0 ) return false;\r\n\r\n\t\t\ttype = Shape( hint, ShaderType.Vec( hint.IsNumeric ? hint.Scalar : ScalarKind.Float, numbers.Count ) );\r\n\t\t\tvalue = FromList( numbers );\r\n\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tstatic bool TryParseText( string text, ShaderType hint, out ShaderType type, out ConstValue value )\r\n\t{\r\n\t\ttype = ShaderType.Void;\r\n\t\tvalue = default;\r\n\r\n\t\tif ( string.IsNullOrWhiteSpace( text ) ) return false;\r\n\r\n\t\tif ( bool.TryParse( text, out var flag ) )\r\n\t\t{\r\n\t\t\ttype = Shape( hint, ShaderType.Bool );\r\n\t\t\tvalue = ConstValue.From( flag );\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tvar parts = text.Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );\r\n\t\tvar numbers = new List<double>( 4 );\r\n\r\n\t\tforeach ( var part in parts )\r\n\t\t{\r\n\t\t\tif ( !double.TryParse( part, NumberStyles.Float, CultureInfo.InvariantCulture, out var number ) )\r\n\t\t\t{\r\n\t\t\t\treturn false;\r\n\t\t\t}\r\n\r\n\t\t\tnumbers.Add( number );\r\n\r\n\t\t\tif ( numbers.Count == 4 ) break;\r\n\t\t}\r\n\r\n\t\tif ( numbers.Count == 0 ) return false;\r\n\r\n\t\ttype = Shape( hint, ShaderType.Vec( hint.IsNumeric ? hint.Scalar : ScalarKind.Float, numbers.Count ) );\r\n\t\tvalue = FromList( numbers );\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\tstatic bool TryReadJson( JsonNode json, ShaderType hint, out ShaderType type, out ConstValue value )\r\n\t{\r\n\t\ttype = ShaderType.Void;\r\n\t\tvalue = default;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( json is JsonArray array )\r\n\t\t\t{\r\n\t\t\t\tvar numbers = new List<double>( 4 );\r\n\r\n\t\t\t\tforeach ( var item in array )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( item is null ) continue;\r\n\r\n\t\t\t\t\tnumbers.Add( item.GetValue<double>() );\r\n\r\n\t\t\t\t\tif ( numbers.Count == 4 ) break;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif ( numbers.Count == 0 ) return false;\r\n\r\n\t\t\t\ttype = Shape( hint, ShaderType.Vec( hint.IsNumeric ? hint.Scalar : ScalarKind.Float, numbers.Count ) );\r\n\t\t\t\tvalue = FromList( numbers );\r\n\r\n\t\t\t\treturn true;\r\n\t\t\t}\r\n\r\n\t\t\tif ( json is JsonValue scalar )\r\n\t\t\t{\r\n\t\t\t\tif ( scalar.TryGetValue<bool>( out var flag ) )\r\n\t\t\t\t{\r\n\t\t\t\t\ttype = Shape( hint, ShaderType.Bool );\r\n\t\t\t\t\tvalue = ConstValue.From( flag );\r\n\t\t\t\t\treturn true;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif ( scalar.TryGetValue<double>( out var number ) )\r\n\t\t\t\t{\r\n\t\t\t\t\ttype = Shape( hint, hint.IsIntegral ? ShaderType.Int : ShaderType.Float );\r\n\t\t\t\t\tvalue = ConstValue.From( (float)number );\r\n\t\t\t\t\treturn true;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif ( scalar.TryGetValue<string>( out var text ) ) return TryParseText( text, hint, out type, out value );\r\n\t\t\t}\r\n\t\t}\r\n\t\tcatch ( Exception )\r\n\t\t{\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tstatic ConstValue FromList( IReadOnlyList<double> numbers )\r\n\t{\r\n\t\tdouble At( int index ) => index < numbers.Count ? numbers[index] : numbers.Count == 1 ? numbers[0] : 0;\r\n\r\n\t\treturn new ConstValue( At( 0 ), At( 1 ), At( 2 ), At( 3 ) );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Reconcile the shape the literal arrived in with the shape the port wants. A scalar feeding a\r\n\t/// vector port stays a scalar and is splatted by the conversion machinery; anything else adopts the\r\n\t/// port's component kind so an authored <c>1</c> on a float port is a float, not an int.\r\n\t/// </summary>\r\n\tstatic ShaderType Shape( ShaderType hint, ShaderType natural )\r\n\t{\r\n\t\tif ( !hint.IsNumeric ) return natural;\r\n\t\tif ( !natural.IsNumeric ) return natural;\r\n\r\n\t\tif ( natural.Components == 1 && hint.Components > 1 ) return ShaderType.Vec( hint.Scalar, 1 );\r\n\r\n\t\treturn ShaderType.Vec( hint.Scalar, natural.Components );\r\n\t}\r\n\r\n\tstatic int Count( IrBlock block )\r\n\t{\r\n\t\tif ( block is null ) return 0;\r\n\r\n\t\tvar total = 0;\r\n\r\n\t\tforeach ( var statement in block.Statements )\r\n\t\t{\r\n\t\t\ttotal++;\r\n\r\n\t\t\tswitch ( statement )\r\n\t\t\t{\r\n\t\t\t\tcase IrIf branch:\r\n\t\t\t\t\ttotal += Count( branch.Then ) + Count( branch.Else );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrFor loop:\r\n\t\t\t\t\ttotal += Count( loop.Body );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrWhile loop:\r\n\t\t\t\t\ttotal += Count( loop.Body );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase IrScope scope:\r\n\t\t\t\t\ttotal += Count( scope.Body );\r\n\t\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn total;\r\n\t}\r\n\r\n\t/// <summary>A node's display name, falling back to its type name.</summary>\r\n\tpublic static string Describe( PrismNode node )\r\n\t{\r\n\t\tif ( node is null ) return \"<missing node>\";\r\n\r\n\t\tvar title = PrismLog.Guard( \"Reading node descriptor\", () => node.Descriptor?.Title, null );\r\n\r\n\t\treturn string.IsNullOrEmpty( title ) ? node.GetType().Name : title;\r\n\t}\r\n\r\n\tenum VisitState\r\n\t{\r\n\t\tVisiting,\r\n\t\tDone,\r\n\t\tFailed\r\n\t}\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Integration/PrismAssetEditor.cs",
"FileName": "PrismAssetEditor.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Core;\r\nusing System.IO;\r\n\r\nnamespace Editor.Prism.Integration;\r\n\r\n/// <summary>\r\n/// Routes a double-clicked <c>.prism</c> or <c>.prismfn</c> into the Prism window.\r\n/// <para>\r\n/// These are deliberately <b>static method</b> handlers rather than an <c>IAssetEditor</c> window\r\n/// class. <c>IAssetEditor.OpenInEditor</c> runs <c>TryOpenUsingStaticMethod</c> first, so a static\r\n/// handler is the only registration that resolves deterministically \u2014 the class path picks a winner\r\n/// with <c>FirstOrDefault()</c> over an unordered type list. It also keeps us out of the two static\r\n/// dictionaries <c>IAssetEditor</c> keeps alive across hotloads, which are the usual source of\r\n/// \"double-clicking the asset does nothing after a reload\".\r\n/// </para>\r\n/// <para>\r\n/// The method must take exactly one <see cref=\"Asset\"/> parameter and be static, or the dispatcher\r\n/// silently ignores it.\r\n/// </para>\r\n/// </summary>\r\npublic static class PrismAssetEditor\r\n{\r\n\t/// <summary>Open a shader graph document. Bound to the <c>prism</c> extension.</summary>\r\n\t[EditorForAssetType( PrismConstants.GraphExtension )]\r\n\tpublic static void OpenGraph( Asset asset )\r\n\t{\r\n\t\tOpen( asset );\r\n\t}\r\n\r\n\t/// <summary>Open a subgraph document. Bound to the <c>prismfn</c> extension.</summary>\r\n\t[EditorForAssetType( PrismConstants.SubgraphExtension )]\r\n\tpublic static void OpenSubgraph( Asset asset )\r\n\t{\r\n\t\tOpen( asset );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Open an asset in Prism, reporting rather than throwing when it cannot be opened. Safe to call\r\n\t/// from a context menu, a drag-drop handler or a console command.\r\n\t/// </summary>\r\n\tpublic static bool Open( Asset asset )\r\n\t{\r\n\t\tif ( asset is null ) return false;\r\n\r\n\t\tif ( asset.IsDeleted )\r\n\t\t{\r\n\t\t\tPrismLog.Warn( $\"'{asset.Name}' has been deleted\" );\r\n\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\treturn PrismLauncher.OpenAsset( asset );\r\n\t}\r\n\r\n\t/// <summary>Open by absolute path, registering the file with the asset system first if we can.</summary>\r\n\tpublic static bool Open( string absolutePath )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( absolutePath ) ) return false;\r\n\r\n\t\tvar asset = PrismLog.Guard( \"Finding the asset for a Prism document\",\r\n\t\t\t() => AssetSystem.FindByPath( absolutePath ), null );\r\n\r\n\t\tif ( asset is not null ) return Open( asset );\r\n\r\n\t\t// Not registered \u2014 either it lives outside a mounted content path, or the asset system has\r\n\t\t// not caught up with a file we only just wrote. Opening by path always works.\r\n\t\treturn PrismLauncher.OpenDocument( absolutePath );\r\n\t}\r\n\r\n\t/// <summary>True when the path is a Prism document we own.</summary>\r\n\tpublic static bool IsPrismDocument( string path )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( path ) ) return false;\r\n\r\n\t\tvar extension = Path.GetExtension( path );\r\n\r\n\t\tif ( string.IsNullOrEmpty( extension ) ) return false;\r\n\r\n\t\textension = extension.TrimStart( '.' );\r\n\r\n\t\treturn extension.Equals( PrismConstants.GraphExtension, StringComparison.OrdinalIgnoreCase )\r\n\t\t\t|| extension.Equals( PrismConstants.SubgraphExtension, StringComparison.OrdinalIgnoreCase );\r\n\t}\r\n\r\n\t/// <summary>True when the path is specifically a subgraph.</summary>\r\n\tpublic static bool IsSubgraphDocument( string path )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( path ) ) return false;\r\n\r\n\t\treturn Path.GetExtension( path )\r\n\t\t\t.TrimStart( '.' )\r\n\t\t\t.Equals( PrismConstants.SubgraphExtension, StringComparison.OrdinalIgnoreCase );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Drop dead entries out of the two static maps <c>IAssetEditor</c> keeps.\r\n\t/// <para>\r\n\t/// Both survive a hotload because they are static fields on an interface, and both are keyed by\r\n\t/// strings that outlive the windows they point at. Entries whose window has been destroyed, or\r\n\t/// whose type came from an assembly that has since been swapped out, leave the asset browser\r\n\t/// believing a document is already open and silently doing nothing on double-click.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic static int PruneStaleEditors()\r\n\t{\r\n\t\tvar removed = 0;\r\n\r\n\t\tPrismLog.Guard( \"Pruning stale asset editors\", () =>\r\n\t\t{\r\n\t\t\tremoved += Prune( IAssetEditor.OpenSingleEditors );\r\n\t\t\tremoved += Prune( IAssetEditor.OpenMultiAssetEditors );\r\n\t\t} );\r\n\r\n\t\treturn removed;\r\n\t}\r\n\r\n\tstatic int Prune( Dictionary<string, IAssetEditor> map )\r\n\t{\r\n\t\tif ( map is null || map.Count == 0 ) return 0;\r\n\r\n\t\tvar dead = new List<string>();\r\n\r\n\t\tforeach ( var pair in map )\r\n\t\t{\r\n\t\t\tvar editor = pair.Value;\r\n\r\n\t\t\tif ( editor is null || !editor.IsValid )\r\n\t\t\t{\r\n\t\t\t\tdead.Add( pair.Key );\r\n\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\t// One of our windows left behind by a hotload is a zombie: the native widget is still\r\n\t\t\t// alive so IsValid answers true, but every delegate on it points into the old assembly.\r\n\t\t\t// Someone else's editor is none of our business, current or not.\r\n\t\t\tvar type = editor.GetType();\r\n\r\n\t\t\tif ( type.Assembly == typeof( PrismAssetEditor ).Assembly ) continue;\r\n\t\t\tif ( type.FullName is null ) continue;\r\n\t\t\tif ( !type.FullName.StartsWith( \"Editor.Prism\", StringComparison.Ordinal ) ) continue;\r\n\r\n\t\t\tdead.Add( pair.Key );\r\n\t\t}\r\n\r\n\t\tforeach ( var key in dead )\r\n\t\t{\r\n\t\t\tmap.Remove( key );\r\n\t\t}\r\n\r\n\t\treturn dead.Count;\r\n\t}\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Integration/PrismDocumentation.cs",
"FileName": "PrismDocumentation.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Core;\r\nusing Editor.Prism.Model;\r\nusing Editor.Prism.Ui;\r\nusing System.Text;\r\n\r\nnamespace Editor.Prism.Integration;\r\n\r\n/// <summary>\r\n/// Everything Prism knows how to explain about one node type, assembled from the same metadata the\r\n/// graph and the node library already use \u2014 never a second, drifting copy.\r\n/// </summary>\r\npublic sealed record PrismNodeHelp(\r\n\tstring Id, string Title, string Category, string Icon, string Summary,\r\n\tIReadOnlyList<string> Keywords, NodeTier Tier, string Since, string DeprecatedBy,\r\n\tIReadOnlyList<PortDef> Inputs, IReadOnlyList<PortDef> Outputs )\r\n{\r\n\t/// <summary>True when there is nothing useful to show.</summary>\r\n\tpublic bool IsEmpty => string.IsNullOrEmpty( Id );\r\n\r\n\t/// <summary>A one-line status for the header: tier, availability and replacement.</summary>\r\n\tpublic string Status\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar parts = new List<string>();\r\n\r\n\t\t\tif ( Tier != NodeTier.Common ) parts.Add( Tier.ToString() );\r\n\t\t\tif ( !string.IsNullOrWhiteSpace( Since ) ) parts.Add( $\"since {Since}\" );\r\n\t\t\tif ( !string.IsNullOrWhiteSpace( DeprecatedBy ) ) parts.Add( $\"replaced by {DeprecatedBy}\" );\r\n\r\n\t\t\treturn string.Join( \" \u00b7 \", parts );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Plain-text rendering, for a tooltip or the clipboard.</summary>\r\n\tpublic string ToPlainText()\r\n\t{\r\n\t\tvar builder = new StringBuilder();\r\n\r\n\t\tbuilder.AppendLine( Title );\r\n\r\n\t\tif ( !string.IsNullOrWhiteSpace( Category ) ) builder.AppendLine( Category );\r\n\r\n\t\tbuilder.AppendLine();\r\n\r\n\t\tif ( !string.IsNullOrWhiteSpace( Summary ) )\r\n\t\t{\r\n\t\t\tbuilder.AppendLine( Summary );\r\n\t\t\tbuilder.AppendLine();\r\n\t\t}\r\n\r\n\t\tAppend( builder, \"Inputs\", Inputs );\r\n\t\tAppend( builder, \"Outputs\", Outputs );\r\n\r\n\t\tbuilder.AppendLine( $\"Type id: {Id}\" );\r\n\r\n\t\treturn builder.ToString();\r\n\t}\r\n\r\n\tstatic void Append( StringBuilder builder, string heading, IReadOnlyList<PortDef> ports )\r\n\t{\r\n\t\tif ( ports is null || ports.Count == 0 ) return;\r\n\r\n\t\tbuilder.AppendLine( heading );\r\n\r\n\t\tforeach ( var port in ports )\r\n\t\t{\r\n\t\t\tbuilder.Append( \" \" ).Append( port.DisplayName ).Append( \" \" ).Append( port.DeclaredType );\r\n\r\n\t\t\tif ( !string.IsNullOrWhiteSpace( port.Tooltip ) ) builder.Append( \" \u2014 \" ).Append( port.Tooltip );\r\n\r\n\t\t\tbuilder.AppendLine();\r\n\t\t}\r\n\r\n\t\tbuilder.AppendLine();\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// In-editor help: the orientation panel a new user sees once, and the per-node reference every user\r\n/// reaches from the node library, the inspector or the <c>Prism</c> menu.\r\n/// </summary>\r\npublic static class PrismDocumentation\r\n{\r\n\tstatic PrismWelcomeWindow s_welcome;\r\n\tstatic PrismNodeReferenceWindow s_reference;\r\n\r\n\t/// <summary>Documentation for one registered node type, or an empty record when it is unknown.</summary>\r\n\tpublic static PrismNodeHelp Lookup( string typeId )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( typeId ) ) return Empty;\r\n\r\n\t\treturn PrismLog.Guard( \"Looking up node documentation\", () =>\r\n\t\t{\r\n\t\t\tNodeRegistry.EnsureBuilt();\r\n\r\n\t\t\treturn NodeRegistry.TryResolve( typeId, out var descriptor ) ? For( descriptor ) : Empty;\r\n\t\t}, Empty );\r\n\t}\r\n\r\n\t/// <summary>Documentation for a node instance.</summary>\r\n\tpublic static PrismNodeHelp For( PrismNode node ) => node is null ? Empty : For( node.Descriptor );\r\n\r\n\t/// <summary>Documentation built from a descriptor.</summary>\r\n\tpublic static PrismNodeHelp For( NodeDescriptor descriptor )\r\n\t{\r\n\t\tif ( descriptor is null ) return Empty;\r\n\r\n\t\treturn new PrismNodeHelp(\r\n\t\t\tdescriptor.Id,\r\n\t\t\tstring.IsNullOrWhiteSpace( descriptor.Title ) ? descriptor.Id : descriptor.Title,\r\n\t\t\tdescriptor.Category,\r\n\t\t\tstring.IsNullOrWhiteSpace( descriptor.Icon ) ? \"extension\" : descriptor.Icon,\r\n\t\t\tdescriptor.Description,\r\n\t\t\tdescriptor.Keywords ?? Array.Empty<string>(),\r\n\t\t\tdescriptor.Tier,\r\n\t\t\tdescriptor.Since,\r\n\t\t\tdescriptor.DeprecatedBy,\r\n\t\t\tdescriptor.Inputs ?? Array.Empty<PortDef>(),\r\n\t\t\tdescriptor.Outputs ?? Array.Empty<PortDef>() );\r\n\t}\r\n\r\n\t/// <summary>The \"nothing to show\" record.</summary>\r\n\tpublic static PrismNodeHelp Empty { get; } = new( null, null, null, null, null,\r\n\t\tArray.Empty<string>(), NodeTier.Common, null, null,\r\n\t\tArray.Empty<PortDef>(), Array.Empty<PortDef>() );\r\n\r\n\t// ---- windows -----------------------------------------------------------\r\n\r\n\t/// <summary>Open the node reference, optionally scrolled to one node.</summary>\r\n\tpublic static void ShowNodeReference( string typeId = null )\r\n\t{\r\n\t\tPrismLog.Guard( \"Opening the Prism node reference\", () =>\r\n\t\t{\r\n\t\t\tif ( s_reference is null || !s_reference.IsValid )\r\n\t\t\t{\r\n\t\t\t\ts_reference = new PrismNodeReferenceWindow();\r\n\t\t\t}\r\n\r\n\t\t\ts_reference.Show();\r\n\t\t\ts_reference.Focus();\r\n\r\n\t\t\tif ( !string.IsNullOrWhiteSpace( typeId ) ) s_reference.SelectNode( typeId );\r\n\t\t} );\r\n\t}\r\n\r\n\t/// <summary>Open the orientation panel on demand.</summary>\r\n\tpublic static void ShowWelcome()\r\n\t{\r\n\t\tPrismLog.Guard( \"Opening the Prism welcome panel\", () =>\r\n\t\t{\r\n\t\t\tif ( s_welcome is null || !s_welcome.IsValid )\r\n\t\t\t{\r\n\t\t\t\ts_welcome = new PrismWelcomeWindow();\r\n\t\t\t}\r\n\r\n\t\t\ts_welcome.Show();\r\n\t\t\ts_welcome.Focus();\r\n\t\t} );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Show the orientation panel the very first time Prism is opened, and never again unless it is\r\n\t/// asked for. Called from every path that opens a window.\r\n\t/// </summary>\r\n\tpublic static void ShowWelcomeIfFirstRun()\r\n\t{\r\n\t\tif ( PrismCookies.WelcomeShown ) return;\r\n\r\n\t\tPrismCookies.WelcomeShown = true;\r\n\r\n\t\tShowWelcome();\r\n\t}\r\n\r\n\t/// <summary>Drop the cached windows outright.</summary>\r\n\tpublic static void Reset()\r\n\t{\r\n\t\ts_welcome = null;\r\n\t\ts_reference = null;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// What hotload calls. A window that is still on screen is kept \u2014 the hotload system migrates the\r\n\t/// instance rather than destroying it, and dropping the reference here would leave the user with a\r\n\t/// second copy the next time they asked for one.\r\n\t/// </summary>\r\n\tpublic static void Revalidate()\r\n\t{\r\n\t\tif ( s_welcome is not null && !s_welcome.IsValid ) s_welcome = null;\r\n\t\tif ( s_reference is not null && !s_reference.IsValid ) s_reference = null;\r\n\r\n\t\tPrismLog.Guard( \"Reloading the Prism node reference\", () =>\r\n\t\t{\r\n\t\t\tif ( s_reference is not null && s_reference.IsValid ) s_reference.Reload();\r\n\t\t} );\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// The first-run orientation panel: what Prism is, what makes it different from the built-in shader\r\n/// graph, and the six things worth knowing before the first graph.\r\n/// </summary>\r\npublic sealed class PrismWelcomeWindow : BaseWindow\r\n{\r\n\t/// <summary>Build the panel.</summary>\r\n\tpublic PrismWelcomeWindow()\r\n\t{\r\n\t\tWindowTitle = \"What Is Prism?\";\r\n\t\tSetWindowIcon( \"gradient\" );\r\n\r\n\t\tSize = new Vector2( 720f, 660f );\r\n\t\tMinimumSize = new Vector2( 560f, 420f );\r\n\r\n\t\tLayout = Layout.Column();\r\n\t\tLayout.Margin = 0f;\r\n\r\n\t\tvar scroll = new ScrollArea( this );\r\n\r\n\t\tscroll.Canvas = new Widget( scroll );\r\n\t\tscroll.Canvas.Layout = Layout.Column();\r\n\t\tscroll.Canvas.Layout.Margin = 28f;\r\n\t\tscroll.Canvas.Layout.Spacing = 10f;\r\n\r\n\t\tBuild( scroll.Canvas.Layout );\r\n\r\n\t\tLayout.Add( scroll, 1 );\r\n\r\n\t\tvar footer = Layout.AddRow();\r\n\r\n\t\tfooter.Margin = new Sandbox.UI.Margin( 28f, 0f, 28f, 20f );\r\n\t\tfooter.Spacing = 8f;\r\n\r\n\t\tvar reference = footer.Add( new Button( \"Node Reference\", \"menu_book\", this ) );\r\n\r\n\t\treference.Clicked = () => PrismDocumentation.ShowNodeReference();\r\n\r\n\t\tfooter.AddStretchCell();\r\n\r\n\t\tvar close = footer.Add( new Button.Primary( \"Start Building\", \"arrow_forward\", this ) );\r\n\r\n\t\tclose.Clicked = Close;\r\n\t}\r\n\r\n\tvoid Build( Layout layout )\r\n\t{\r\n\t\tvar title = layout.Add( new Label.Title( \"Prism\" ) );\r\n\r\n\t\ttitle.Color = PrismTheme.TextPrimary;\r\n\r\n\t\tvar lead = layout.Add( new Label.Subtitle(\r\n\t\t\t\"A node-based shader editor for s&box that treats generated code as something you are meant to read.\" ) );\r\n\r\n\t\tlead.Color = PrismTheme.TextSecondary;\r\n\t\tlead.WordWrap = true;\r\n\r\n\t\tlayout.AddSpacingCell( 8f );\r\n\r\n\t\tSection( layout, \"gradient\", \"Graphs compile to real HLSL\",\r\n\t\t\t\"Everything you wire up becomes a readable .shader beside the document, with the same block \"\r\n\t\t\t+ \"structure a hand-written one has. The Code panel shows it live, and clicking a line \"\r\n\t\t\t+ \"selects the node that produced it.\" );\r\n\r\n\t\tSection( layout, \"rule\", \"Connections are type-checked\",\r\n\t\t\t\"Free conversions connect silently. A lossy or padded one connects, warns, and draws a marker \"\r\n\t\t\t+ \"on the wire telling you exactly what it did \u2014 the built-in editor pads float2 to float3 with \"\r\n\t\t\t+ \"zero and never says so. Illegal connections are refused at the drop.\" );\r\n\r\n\t\tSection( layout, \"history\", \"Nothing is quietly destroyed\",\r\n\t\t\t\"Node ids are minted once and never renumbered. A node whose plugin is missing survives as a \"\r\n\t\t\t+ \"placeholder and re-saves byte-identically. A connection that cannot resolve stays as a \"\r\n\t\t\t+ \"visible ghost instead of vanishing.\" );\r\n\r\n\t\tSection( layout, \"bolt\", \"The preview is the shader\",\r\n\t\t\t\"There is no separate preview path. Edits are debounced and recompiled with the minimum combo \"\r\n\t\t\t+ \"set, so the sphere shows the same code the material will use. The status strip tells you \"\r\n\t\t\t+ \"how long each compile took.\" );\r\n\r\n\t\tSection( layout, \"functions\", \"Subgraphs and custom code are first class\",\r\n\t\t\t\"A .prismfn is a reusable function with its own inputs and outputs. When a node does not exist \"\r\n\t\t\t+ \"yet, the Custom Code node takes HLSL directly \u2014 a missing node is an inconvenience, not a wall.\" );\r\n\r\n\t\tSection( layout, \"keyboard\", \"Worth learning early\",\r\n\t\t\t\"Space or double-click on empty canvas opens the node search. Dragging a wire into empty space \"\r\n\t\t\t+ \"opens it filtered by type. Ctrl+Z and Ctrl+Y are per-document. Ctrl+S saves the document and \"\r\n\t\t\t+ \"regenerates the shader beside it.\" );\r\n\r\n\t\tlayout.AddSpacingCell( 8f );\r\n\r\n\t\tvar footnote = layout.Add( new Label.Small(\r\n\t\t\t\"Prism never registers the built-in .shdrgrph or .shdrfunc extensions. To bring an existing graph \"\r\n\t\t\t+ \"across, right-click it and choose Import into Prism.\" ) );\r\n\r\n\t\tfootnote.Color = PrismTheme.TextMuted;\r\n\t\tfootnote.WordWrap = true;\r\n\r\n\t\tlayout.AddStretchCell();\r\n\t}\r\n\r\n\tvoid Section( Layout layout, string icon, string heading, string body )\r\n\t{\r\n\t\tlayout.AddSpacingCell( 12f );\r\n\r\n\t\tvar row = layout.AddRow();\r\n\r\n\t\trow.Spacing = 12f;\r\n\r\n\t\trow.Add( new PrismGlyph( this, icon, PrismTheme.Accent ) );\r\n\r\n\t\tvar column = row.AddColumn( 1 );\r\n\r\n\t\tcolumn.Spacing = 3f;\r\n\r\n\t\tvar header = column.Add( new Label.Header( heading ) );\r\n\r\n\t\theader.Color = PrismTheme.TextPrimary;\r\n\r\n\t\tvar text = column.Add( new Label.Body( body ) );\r\n\r\n\t\ttext.Color = PrismTheme.TextSecondary;\r\n\t\ttext.WordWrap = true;\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// A fixed-size material icon as a layout item. Qt labels cannot render one, and a whole\r\n/// <c>IconButton</c> would bring click behaviour and hover states nobody asked for.\r\n/// </summary>\r\ninternal sealed class PrismGlyph : Widget\r\n{\r\n\treadonly string _icon;\r\n\treadonly Color _color;\r\n\treadonly float _size;\r\n\r\n\t/// <summary>Build a glyph of the given size, in the given colour.</summary>\r\n\tpublic PrismGlyph( Widget parent, string icon, Color color, float size = 20f ) : base( parent )\r\n\t{\r\n\t\t_icon = string.IsNullOrWhiteSpace( icon ) ? \"circle\" : icon;\r\n\t\t_color = color;\r\n\t\t_size = size;\r\n\r\n\t\tFixedSize = new Vector2( size + 6f, size + 6f );\r\n\t}\r\n\r\n\t/// <summary>Draw the glyph, centred.</summary>\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.SetPen( _color );\r\n\t\tPaint.DrawIcon( LocalRect, _icon, _size, TextFlag.Center );\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Every registered node type, searchable, with the ports and description the compiler and the node\r\n/// library read from the same metadata.\r\n/// </summary>\r\npublic sealed class PrismNodeReferenceWindow : BaseWindow\r\n{\r\n\treadonly List<PrismNodeType> _all = new();\r\n\r\n\tListView _list;\r\n\tLineEdit _search;\r\n\tWidget _detail;\r\n\tLabel _count;\r\n\r\n\t/// <summary>Build the window and load the registry.</summary>\r\n\tpublic PrismNodeReferenceWindow()\r\n\t{\r\n\t\tWindowTitle = \"Prism Node Reference\";\r\n\t\tSetWindowIcon( \"menu_book\" );\r\n\r\n\t\tSize = new Vector2( 1040f, 700f );\r\n\t\tMinimumSize = new Vector2( 720f, 460f );\r\n\r\n\t\tLayout = Layout.Column();\r\n\t\tLayout.Margin = 16f;\r\n\t\tLayout.Spacing = 10f;\r\n\r\n\t\tBuildHeader();\r\n\t\tBuildBody();\r\n\r\n\t\tReload();\r\n\t}\r\n\r\n\tvoid BuildHeader()\r\n\t{\r\n\t\tvar row = Layout.AddRow();\r\n\r\n\t\trow.Spacing = 8f;\r\n\r\n\t\t_search = row.Add( new LineEdit( this ), 1 );\r\n\t\t_search.PlaceholderText = \"Search nodes by name, category or keyword\";\r\n\t\t_search.TextEdited += _ => Populate();\r\n\r\n\t\tvar refresh = row.Add( new Button( \"\", \"refresh\", this ) );\r\n\r\n\t\trefresh.Clicked = Reload;\r\n\t\trefresh.StatusTip = \"Rebuild the node registry\";\r\n\r\n\t\t_count = Layout.Add( new Label.Small( \"\" ) );\r\n\t\t_count.Color = PrismTheme.TextMuted;\r\n\t}\r\n\r\n\tvoid BuildBody()\r\n\t{\r\n\t\tvar row = Layout.AddRow( 1 );\r\n\r\n\t\trow.Spacing = 12f;\r\n\r\n\t\t_list = row.Add( new ListView( this ), 1 );\r\n\t\t_list.ItemSize = new Vector2( -1f, 34f );\r\n\t\t_list.ItemSpacing = new Vector2( 0f, 2f );\r\n\t\t_list.Margin = 2f;\r\n\t\t_list.ItemPaint = PaintRow;\r\n\t\t_list.ItemSelected = item => ShowDetail( item as PrismNodeType );\r\n\r\n\t\tvar scroll = new ScrollArea( this );\r\n\r\n\t\tscroll.Canvas = new Widget( scroll );\r\n\t\tscroll.Canvas.Layout = Layout.Column();\r\n\t\tscroll.Canvas.Layout.Margin = 4f;\r\n\t\tscroll.Canvas.Layout.Spacing = 6f;\r\n\r\n\t\t_detail = scroll.Canvas;\r\n\r\n\t\trow.Add( scroll, 2 );\r\n\r\n\t\tShowDetail( null );\r\n\t}\r\n\r\n\t/// <summary>Rebuild from the registry \u2014 useful after a hotload adds node types.</summary>\r\n\tpublic void Reload()\r\n\t{\r\n\t\tPrismLog.Guard( \"Loading the Prism node registry\", () =>\r\n\t\t{\r\n\t\t\tNodeRegistry.EnsureBuilt();\r\n\r\n\t\t\t_all.Clear();\r\n\t\t\t_all.AddRange( NodeRegistry.Types\r\n\t\t\t\t.OrderBy( x => x.Category ?? string.Empty, StringComparer.OrdinalIgnoreCase )\r\n\t\t\t\t.ThenBy( x => x.Title ?? string.Empty, StringComparer.OrdinalIgnoreCase ) );\r\n\t\t} );\r\n\r\n\t\tPopulate();\r\n\t}\r\n\r\n\t/// <summary>Select and reveal one node type by its stable id.</summary>\r\n\tpublic void SelectNode( string typeId )\r\n\t{\r\n\t\tPrismLog.Guard( \"Selecting a node in the reference\", () =>\r\n\t\t{\r\n\t\t\tvar match = _all.FirstOrDefault( x => string.Equals( x.Id, typeId, StringComparison.Ordinal ) );\r\n\r\n\t\t\tif ( match is null ) return;\r\n\r\n\t\t\t_list?.ScrollTo( match );\r\n\t\t\tShowDetail( match );\r\n\t\t} );\r\n\t}\r\n\r\n\tvoid Populate()\r\n\t{\r\n\t\tPrismLog.Guard( \"Filtering the Prism node reference\", () =>\r\n\t\t{\r\n\t\t\tvar text = _search?.Text ?? string.Empty;\r\n\r\n\t\t\tvar matches = string.IsNullOrWhiteSpace( text )\r\n\t\t\t\t? _all\r\n\t\t\t\t: NodeRegistry.Search( text ).ToList();\r\n\r\n\t\t\t_list?.SetItems( matches.Cast<object>() );\r\n\r\n\t\t\tif ( _count is not null )\r\n\t\t\t{\r\n\t\t\t\t_count.Text = matches.Count == _all.Count\r\n\t\t\t\t\t? $\"{_all.Count} node types\"\r\n\t\t\t\t\t: $\"{matches.Count} of {_all.Count} node types\";\r\n\t\t\t}\r\n\t\t} );\r\n\t}\r\n\r\n\tvoid PaintRow( VirtualWidget item )\r\n\t{\r\n\t\tif ( item?.Object is not PrismNodeType type ) return;\r\n\r\n\t\tvar rect = item.Rect;\r\n\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.ClearPen();\r\n\r\n\t\tif ( item.Selected ) Paint.SetBrush( PrismTheme.AccentSoft );\r\n\t\telse if ( item.Hovered ) Paint.SetBrush( PrismTheme.PanelAlt );\r\n\t\telse Paint.ClearBrush();\r\n\r\n\t\tif ( item.Selected || item.Hovered ) Paint.DrawRect( rect, PrismTheme.RadiusChip );\r\n\r\n\t\tvar iconRect = new Rect( rect.Left + 8f, rect.Top + ( rect.Height - 16f ) * 0.5f, 16f, 16f );\r\n\r\n\t\tPaint.SetPen( item.Selected ? PrismTheme.Accent : PrismTheme.TextMuted );\r\n\t\tPaint.DrawIcon( iconRect, string.IsNullOrWhiteSpace( type.Icon ) ? \"extension\" : type.Icon, 15f );\r\n\r\n\t\tvar textRect = new Rect( rect.Left + 32f, rect.Top, rect.Width - 40f, rect.Height );\r\n\r\n\t\tPaint.SetPen( item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );\r\n\t\tPaint.SetFont( PrismTheme.FontFamily, PrismTheme.BodySize, 500, false, false );\r\n\t\tPaint.DrawText( textRect, type.Title ?? type.Id, TextFlag.LeftCenter );\r\n\r\n\t\tPaint.SetPen( PrismTheme.TextDisabled );\r\n\t\tPaint.SetFont( PrismTheme.FontFamily, PrismTheme.PortLabelSize, 400, false, false );\r\n\t\tPaint.DrawText( textRect, type.Category ?? string.Empty, TextFlag.RightCenter );\r\n\t}\r\n\r\n\tvoid ShowDetail( PrismNodeType type )\r\n\t{\r\n\t\tif ( _detail is null ) return;\r\n\r\n\t\t_detail.Layout.Clear( true );\r\n\r\n\t\tif ( type is null )\r\n\t\t{\r\n\t\t\tvar empty = _detail.Layout.Add( new Label.Body(\r\n\t\t\t\t\"Pick a node on the left to see what it does, what it takes and what it returns.\" ) );\r\n\r\n\t\t\tempty.Color = PrismTheme.TextMuted;\r\n\t\t\tempty.WordWrap = true;\r\n\t\t\t_detail.Layout.AddStretchCell();\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar help = PrismDocumentation.For( type.Descriptor );\r\n\r\n\t\tvar title = _detail.Layout.Add( new Label.Title( help.Title ) );\r\n\r\n\t\ttitle.Color = PrismTheme.TextPrimary;\r\n\r\n\t\tvar subtitle = _detail.Layout.Add( new Label.Small(\r\n\t\t\tstring.Join( \" \u00b7 \", new[] { help.Category, help.Status }.Where( x => !string.IsNullOrWhiteSpace( x ) ) ) ) );\r\n\r\n\t\tsubtitle.Color = PrismTheme.TextMuted;\r\n\r\n\t\tif ( !string.IsNullOrWhiteSpace( help.Summary ) )\r\n\t\t{\r\n\t\t\t_detail.Layout.AddSpacingCell( 6f );\r\n\r\n\t\t\tvar summary = _detail.Layout.Add( new Label.Body( help.Summary ) );\r\n\r\n\t\t\tsummary.Color = PrismTheme.TextSecondary;\r\n\t\t\tsummary.WordWrap = true;\r\n\t\t}\r\n\r\n\t\tPorts( \"Inputs\", help.Inputs );\r\n\t\tPorts( \"Outputs\", help.Outputs );\r\n\r\n\t\tif ( help.Keywords.Count > 0 )\r\n\t\t{\r\n\t\t\t_detail.Layout.AddSpacingCell( 8f );\r\n\r\n\t\t\tvar keywords = _detail.Layout.Add( new Label.Small( \"Also found by: \" + string.Join( \", \", help.Keywords ) ) );\r\n\r\n\t\t\tkeywords.Color = PrismTheme.TextDisabled;\r\n\t\t\tkeywords.WordWrap = true;\r\n\t\t}\r\n\r\n\t\t_detail.Layout.AddSpacingCell( 8f );\r\n\r\n\t\tvar id = _detail.Layout.Add( new Label.Small( $\"Type id {help.Id}\" ) );\r\n\r\n\t\tid.Color = PrismTheme.TextDisabled;\r\n\t\tid.TextSelectable = true;\r\n\r\n\t\tvar copy = _detail.Layout.Add( new Button( \"Copy Documentation\", \"content_copy\", this ) );\r\n\r\n\t\tcopy.Clicked = () => PrismLog.Guard( \"Copying node documentation\",\r\n\t\t\t() => EditorUtility.Clipboard.Copy( help.ToPlainText() ) );\r\n\r\n\t\t_detail.Layout.AddStretchCell();\r\n\t}\r\n\r\n\tvoid Ports( string heading, IReadOnlyList<PortDef> ports )\r\n\t{\r\n\t\tif ( ports is null || ports.Count == 0 ) return;\r\n\r\n\t\t_detail.Layout.AddSpacingCell( 10f );\r\n\r\n\t\tvar header = _detail.Layout.Add( new Label.Header( heading ) );\r\n\r\n\t\theader.Color = PrismTheme.TextPrimary;\r\n\r\n\t\tforeach ( var port in ports )\r\n\t\t{\r\n\t\t\tvar row = _detail.Layout.AddRow();\r\n\r\n\t\t\trow.Spacing = 8f;\r\n\r\n\t\t\tvar name = row.Add( new Label( port.DisplayName ?? port.Id.ToString(), this ) );\r\n\r\n\t\t\tname.Color = PrismTheme.TextSecondary;\r\n\t\t\tname.MinimumWidth = 130f;\r\n\r\n\t\t\tvar declared = row.Add( new Label( port.DeclaredType ?? \"float\", this ) );\r\n\r\n\t\t\tdeclared.Color = PrismTheme.TypeGeneric;\r\n\t\t\tdeclared.MinimumWidth = 70f;\r\n\r\n\t\t\tvar tooltip = row.Add( new Label( port.Tooltip ?? string.Empty, this ), 1 );\r\n\r\n\t\t\ttooltip.Color = PrismTheme.TextMuted;\r\n\t\t\ttooltip.WordWrap = true;\r\n\t\t}\r\n\t}\r\n}\r\n"
},
{
"Ident": "f4industries.prism",
"Path": "Editor/Prism/Model/Port.cs",
"FileName": "Port.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 340919,
"Code": "using Editor.Prism.Core;\r\nusing System.ComponentModel;\r\nusing System.Reflection;\r\n\r\nnamespace Editor.Prism.Model;\r\n\r\n/// <summary>Which side of a node a port lives on.</summary>\r\npublic enum PortDirection\r\n{\r\n\t/// <summary>Consumes a value. At most one incoming edge.</summary>\r\n\tInput,\r\n\t/// <summary>Produces a value. Any number of outgoing edges.</summary>\r\n\tOutput\r\n}\r\n\r\n/// <summary>Behavioural flags on a port.</summary>\r\n[Flags]\r\npublic enum PortFlags\r\n{\r\n\t/// <summary>Nothing special.</summary>\r\n\tNone = 0,\r\n\t/// <summary>Leaving this input unconnected with no inline value is an error.</summary>\r\n\tRequired = 1 << 0,\r\n\t/// <summary>Not drawn on the card. Still connectable programmatically.</summary>\r\n\tHidden = 1 << 1,\r\n\t/// <summary>Never draw an inline value pill for this input.</summary>\r\n\tNoInlineEditor = 1 << 2,\r\n\t/// <summary>Part of a variadic group; the node grows another socket as this one is filled.</summary>\r\n\tVariadic = 1 << 3,\r\n\t/// <summary>Opts out of type inference \u2014 the value passes through unchanged (reroute, custom code).</summary>\r\n\tPassthrough = 1 << 4,\r\n\t/// <summary>Drawn in the node's title bar rather than a port row.</summary>\r\n\tInTitleBar = 1 << 5,\r\n\t/// <summary>This input accepts more than one incoming edge (variadic sums, subgraph fan-in).</summary>\r\n\tAllowMultiple = 1 << 6,\r\n\t/// <summary>Created by <see cref=\"PortBuilder\"/> at runtime rather than by an attribute.</summary>\r\n\tDynamic = 1 << 7\r\n}\r\n\r\n/// <summary>\r\n/// A reference to one port of one node. This is the property type used by <c>[In]</c> and\r\n/// <c>[Out]</c> declarations, and the shape both ends of an <see cref=\"Edge\"/> serialize as.\r\n/// </summary>\r\npublic readonly record struct PortRef( NodeId Node, PortId Port )\r\n{\r\n\t/// <summary>The unset reference.</summary>\r\n\tpublic static readonly PortRef None = default;\r\n\r\n\t/// <summary>True when both halves are set.</summary>\r\n\tpublic bool IsValid => Node.IsValid && Port.IsValid;\r\n\r\n\t/// <summary>Build a reference from raw strings.</summary>\r\n\tpublic static PortRef Parse( string node, string port ) => new( NodeId.Parse( node ), PortId.Parse( port ) );\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override string ToString() => IsValid ? $\"{Node}.{Port}\" : \"<none>\";\r\n}\r\n\r\n/// <summary>\r\n/// The immutable declaration of a port: what it is called, what type it claims to be and how it\r\n/// behaves. Produced by reflection over <c>[In]</c>/<c>[Out]</c> properties and then optionally\r\n/// amended by <see cref=\"PortBuilder\"/> inside <c>PrismNode.OnDefinePorts</c>.\r\n/// </summary>\r\npublic sealed record PortDef( PortId Id, string DisplayName, string DeclaredType, PortDirection Direction )\r\n{\r\n\t/// <summary>Optional collapsible group on the card.</summary>\r\n\tpublic string Group { get; init; }\r\n\r\n\t/// <summary>Tooltip shown on the handle and the label.</summary>\r\n\tpublic string Tooltip { get; init; }\r\n\r\n\t/// <summary>Behavioural flags.</summary>\r\n\tpublic PortFlags Flags { get; init; }\r\n\r\n\t/// <summary>Sort key within the node. Ties keep declaration order.</summary>\r\n\tpublic int Order { get; init; }\r\n\r\n\t/// <summary>Name of the <c>[In]</c>/<c>[Out]</c> property that declared this port, when there is one.</summary>\r\n\tpublic string PropertyName { get; init; }\r\n\r\n\t/// <summary>Name of the <c>[InlineValue]</c> property that supplies the unconnected value, when there is one.</summary>\r\n\tpublic string InlineValueProperty { get; init; }\r\n\r\n\t/// <summary>Former ids that must still deserialize into this port.</summary>\r\n\tpublic IReadOnlyList<string> FormerIds { get; init; }\r\n\r\n\t/// <summary>True when the declared type is a type variable rather than a concrete spelling.</summary>\r\n\tpublic bool IsGeneric => TypeRules.IsTypeVariable( DeclaredType );\r\n\r\n\t/// <summary>The concrete declared type, or <see cref=\"ShaderType.Void\"/> when the port is generic.</summary>\r\n\tpublic ShaderType FixedType => ShaderType.Parse( DeclaredType );\r\n\r\n\t/// <summary>True when leaving this input unconnected is an error.</summary>\r\n\tpublic bool Required => ( Flags & PortFlags.Required ) != 0;\r\n\r\n\t/// <summary>True when the port should not be drawn.</summary>\r\n\tpublic bool Hidden => ( Flags & PortFlags.Hidden ) != 0;\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override string ToString() => $\"{Direction} {Id}:{DeclaredType}\";\r\n}\r\n\r\n/// <summary>A live port on a live node. Carries the declaration plus everything the solver resolves.</summary>\r\npublic abstract class Port\r\n{\r\n\t/// <summary>Build a port from its declaration.</summary>\r\n\tprotected Port( PrismNode node, PortDef def )\r\n\t{\r\n\t\tNode = node;\r\n\t\tDef = def;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The node this port belongs to.\r\n\t/// <para>\r\n\t/// Hidden from reflection-driven UI: this is a back-reference, so a <c>SerializedObject</c> walk\r\n\t/// that reaches a port would loop <c>port -> Node -> Inputs -> port</c> forever. That is an\r\n\t/// uncatchable <c>StackOverflowException</c> inside engine code, which kills the whole editor.\r\n\t/// </para>\r\n\t/// </summary>\r\n\t[Hide, Browsable( false ), JsonIgnore]\r\n\tpublic PrismNode Node { get; }\r\n\r\n\t/// <summary>The declaration this port was built from.</summary>\r\n\tpublic PortDef Def { get; internal set; }\r\n\r\n\t/// <summary>Stable id, unique within the node.</summary>\r\n\tpublic PortId Id => Def.Id;\r\n\r\n\t/// <summary>Display label. May be empty for an unlabelled socket.</summary>\r\n\tpublic string DisplayName => Def.DisplayName;\r\n\r\n\t/// <summary>The declared type spelling, concrete or generic.</summary>\r\n\tpublic string DeclaredType => Def.DeclaredType;\r\n\r\n\t/// <summary>Optional port group.</summary>\r\n\tpublic string Group => Def.Group;\r\n\r\n\t/// <summary>Tooltip text.</summary>\r\n\tpublic string Tooltip => Def.Tooltip;\r\n\r\n\t/// <summary>Behavioural flags.</summary>\r\n\tpublic PortFlags Flags => Def.Flags;\r\n\r\n\t/// <summary>True when leaving this input unconnected is an error.</summary>\r\n\tpublic bool Required => Def.Required;\r\n\r\n\t/// <summary>Which side of the node this port is on.</summary>\r\n\tpublic abstract PortDirection Direction { get; }\r\n\r\n\t/// <summary>Position within the node's port list. Assigned when the collection is built.</summary>\r\n\tpublic int Index { get; internal set; }\r\n\r\n\t/// <summary>\r\n\t/// The concrete type assigned by the type solver. Void until the first successful solve;\r\n\t/// for a non-generic port it always ends up equal to <see cref=\"PortDef.FixedType\"/>.\r\n\t/// </summary>\r\n\tpublic ShaderType ResolvedType { get; set; }\r\n\r\n\t/// <summary>The best type we know: the resolved one when solved, otherwise the declared one.</summary>\r\n\tpublic ShaderType EffectiveType => ResolvedType.IsVoid ? Def.FixedType : ResolvedType;\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override string ToString() => $\"{Node?.Id}.{Id}\";\r\n}\r\n\r\n/// <summary>An input port. At most one incoming edge unless <see cref=\"PortFlags.AllowMultiple\"/> is set.</summary>\r\npublic sealed class InputPort : Port\r\n{\r\n\t/// <summary>Build an input port.</summary>\r\n\tpublic InputPort( PrismNode node, PortDef def ) : base( node, def ) { }\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override PortDirection Direction => PortDirection.Input;\r\n\r\n\t/// <summary>\r\n\t/// The literal used when nothing is connected. Boxed because it may be any of the value shapes\r\n\t/// <c>ValueCodec</c> understands; the node's <c>[InlineValue]</c> property is the authored source\r\n\t/// when <see cref=\"PortDef.InlineValueProperty\"/> is set.\r\n\t/// </summary>\r\n\tpublic object InlineValue { get; set; }\r\n\r\n\t/// <summary>True when an edge terminates on this port.</summary>\r\n\tpublic bool IsConnected =>\r\n\t\tNode?.Graph is { } graph && graph.TryGetIncomingEdge( Node.Id, Id, out _ );\r\n}\r\n\r\n/// <summary>An output port. May fan out to any number of inputs.</summary>\r\npublic sealed class OutputPort : Port\r\n{\r\n\t/// <summary>Build an output port.</summary>\r\n\tpublic OutputPort( PrismNode node, PortDef def ) : base( node, def ) { }\r\n\r\n\t/// <inheritdoc/>\r\n\tpublic override PortDirection Direction => PortDirection.Output;\r\n\r\n\t/// <summary>True when at least one edge starts at this port.</summary>\r\n\tpublic bool IsConnected =>\r\n\t\tNode?.Graph is { } graph && graph.GetOutgoingEdges( Node.Id, Id ).Any();\r\n}\r\n\r\n/// <summary>\r\n/// Builds the port list for a node: first from reflection over <c>[In]</c>/<c>[Out]</c> properties,\r\n/// then amended by the node's <c>OnDefinePorts</c> override. Ports that cannot be expressed as\r\n/// properties \u2014 variadic sockets, subgraph signatures, mode-dependent sets \u2014 are added here.\r\n/// </summary>\r\npublic sealed class PortBuilder\r\n{\r\n\treadonly List<PortDef> _inputs = new();\r\n\treadonly List<PortDef> _outputs = new();\r\n\r\n\t/// <summary>Input declarations, in socket order.</summary>\r\n\tpublic IReadOnlyList<PortDef> Inputs => _inputs;\r\n\r\n\t/// <summary>Output declarations, in socket order.</summary>\r\n\tpublic IReadOnlyList<PortDef> Outputs => _outputs;\r\n\r\n\t/// <summary>Append an input port.</summary>\r\n\tpublic PortBuilder Input( string id, string type = \"float\", string name = null, string group = null,\r\n\t\tPortFlags flags = PortFlags.None, string tooltip = null, int order = 0 )\r\n\t{\r\n\t\t_inputs.Add( new PortDef( PortId.Parse( id ), name ?? id, type ?? \"float\", PortDirection.Input )\r\n\t\t{\r\n\t\t\tGroup = group,\r\n\t\t\tTooltip = tooltip,\r\n\t\t\tFlags = flags | PortFlags.Dynamic,\r\n\t\t\tOrder = order\r\n\t\t} );\r\n\r\n\t\treturn this;\r\n\t}\r\n\r\n\t/// <summary>Append an output port.</summary>\r\n\tpublic PortBuilder Output( string id, string type = \"float\", string name = null, string group = null,\r\n\t\tPortFlags flags = PortFlags.None, string tooltip = null, int order = 0 )\r\n\t{\r\n\t\t_outputs.Add( new PortDef( PortId.Parse( id ), name ?? id, type ?? \"float\", PortDirection.Output )\r\n\t\t{\r\n\t\t\tGroup = group,\r\n\t\t\tTooltip = tooltip,\r\n\t\t\tFlags = flags | PortFlags.Dynamic,\r\n\t\t\tOrder = order\r\n\t\t} );\r\n\r\n\t\treturn this;\r\n\t}\r\n\r\n\t/// <summary>Append a declaration built elsewhere.</summary>\r\n\tpublic PortBuilder Add( PortDef def )\r\n\t{\r\n\t\tif ( def is null ) return this;\r\n\r\n\t\tif ( def.Direction == PortDirection.Input ) _inputs.Add( def );\r\n\t\telse _outputs.Add( def );\r\n\r\n\t\treturn this;\r\n\t}\r\n\r\n\t/// <summary>Remove a port by id from whichever side it is on.</summary>\r\n\tpublic PortBuilder Remove( string id )\r\n\t{\r\n\t\tvar portId = PortId.Parse( id );\r\n\t\t_inputs.RemoveAll( x => x.Id == portId );\r\n\t\t_outputs.RemoveAll( x => x.Id == portId );\r\n\t\treturn this;\r\n\t}\r\n\r\n\t/// <summary>Change a port's declared type.</summary>\r\n\tpublic PortBuilder Retype( string id, string declaredType )\r\n\t{\r\n\t\tMutate( id, def => def with { DeclaredType = declaredType } );\r\n\t\treturn this;\r\n\t}\r\n\r\n\t/// <summary>Change a port's display label.</summary>\r\n\tpublic PortBuilder Rename( string id, string displayName )\r\n\t{\r\n\t\tMutate( id, def => def with { DisplayName = displayName } );\r\n\t\treturn this;\r\n\t}\r\n\r\n\t/// <summary>Add flags to a port.</summary>\r\n\tpublic PortBuilder SetFlags( string id, PortFlags flags )\r\n\t{\r\n\t\tMutate( id, def => def with { Flags = def.Flags | flags } );\r\n\t\treturn this;\r\n\t}\r\n\r\n\t/// <summary>True when a port with this id exists on either side.</summary>\r\n\tpublic bool Has( string id )\r\n\t{\r\n\t\tvar portId = PortId.Parse( id );\r\n\t\treturn _inputs.Any( x => x.Id == portId ) || _outputs.Any( x => x.Id == portId );\r\n\t}\r\n\r\n\t/// <summary>Drop every declaration. Used by nodes that build their entire signature dynamically.</summary>\r\n\tpublic PortBuilder Clear()\r\n\t{\r\n\t\t_inputs.Clear();\r\n\t\t_outputs.Clear();\r\n\t\treturn this;\r\n\t}\r\n\r\n\tvoid Mutate( string id, Func<PortDef, PortDef> mutate )\r\n\t{\r\n\t\tvar portId = PortId.Parse( id );\r\n\r\n\t\tfor ( int i = 0; i < _inputs.Count; i++ )\r\n\t\t{\r\n\t\t\tif ( _inputs[i].Id == portId ) _inputs[i] = mutate( _inputs[i] );\r\n\t\t}\r\n\r\n\t\tfor ( int i = 0; i < _outputs.Count; i++ )\r\n\t\t{\r\n\t\t\tif ( _outputs[i].Id == portId ) _outputs[i] = mutate( _outputs[i] );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Build a builder pre-populated with the reflected declarations of a node type.</summary>\r\n\tpublic static PortBuilder FromReflection( Type nodeType )\r\n\t{\r\n\t\tvar builder = new PortBuilder();\r\n\t\tvar (inputs, outputs) = Reflect( nodeType );\r\n\r\n\t\tbuilder._inputs.AddRange( inputs );\r\n\t\tbuilder._outputs.AddRange( outputs );\r\n\r\n\t\treturn builder;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The port declarations implied by a node type's <c>[In]</c>/<c>[Out]</c> properties, in\r\n\t/// declaration order (base class first). Cached per type; call <see cref=\"FlushCache\"/> on hotload.\r\n\t/// </summary>\r\n\tpublic static (IReadOnlyList<PortDef> Inputs, IReadOnlyList<PortDef> Outputs) Reflect( Type nodeType )\r\n\t{\r\n\t\tif ( nodeType is null ) return ( Array.Empty<PortDef>(), Array.Empty<PortDef>() );\r\n\r\n\t\tlock ( s_cacheLock )\r\n\t\t{\r\n\t\t\tif ( s_cache.TryGetValue( nodeType, out var cached ) ) return cached;\r\n\t\t}\r\n\r\n\t\tvar inputs = new List<PortDef>();\r\n\t\tvar outputs = new List<PortDef>();\r\n\r\n\t\tvar properties = nodeType\r\n\t\t\t.GetProperties( BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy )\r\n\t\t\t.OrderBy( DeclarationDepth )\r\n\t\t\t.ThenBy( x => x.MetadataToken )\r\n\t\t\t.ToArray();\r\n\r\n\t\t// Map port name -> the [InlineValue] property that feeds it.\r\n\t\tvar inlineValues = new Dictionary<string, string>();\r\n\r\n\t\tforeach ( var property in properties )\r\n\t\t{\r\n\t\t\tvar inline = property.GetCustomAttribute<InlineValueAttribute>();\r\n\t\t\tif ( inline is null || string.IsNullOrEmpty( inline.PortName ) ) continue;\r\n\r\n\t\t\tinlineValues[inline.PortName] = property.Name;\r\n\t\t}\r\n\r\n\t\tforeach ( var property in properties )\r\n\t\t{\r\n\t\t\tvar formerly = property.GetCustomAttributes<FormerlyKnownAsAttribute>()\r\n\t\t\t\t.Select( x => x.OldName )\r\n\t\t\t\t.Where( x => !string.IsNullOrEmpty( x ) )\r\n\t\t\t\t.ToArray();\r\n\r\n\t\t\tif ( property.GetCustomAttribute<InAttribute>() is { } input )\r\n\t\t\t{\r\n\t\t\t\tinlineValues.TryGetValue( property.Name, out var inlineProperty );\r\n\r\n\t\t\t\tinputs.Add( new PortDef( PortId.Parse( property.Name ), input.Name ?? property.Name,\r\n\t\t\t\t\tinput.Type ?? \"float\", PortDirection.Input )\r\n\t\t\t\t{\r\n\t\t\t\t\tGroup = input.Group,\r\n\t\t\t\t\tTooltip = input.Tooltip,\r\n\t\t\t\t\tFlags = input.Required ? PortFlags.Required : PortFlags.None,\r\n\t\t\t\t\tOrder = input.Order,\r\n\t\t\t\t\tPropertyName = property.Name,\r\n\t\t\t\t\tInlineValueProperty = inlineProperty,\r\n\t\t\t\t\tFormerIds = formerly.Length > 0 ? formerly : null\r\n\t\t\t\t} );\r\n\t\t\t}\r\n\r\n\t\t\tif ( property.GetCustomAttribute<OutAttribute>() is { } output )\r\n\t\t\t{\r\n\t\t\t\toutputs.Add( new PortDef( PortId.Parse( property.Name ), output.Name ?? property.Name,\r\n\t\t\t\t\toutput.Type ?? \"float\", PortDirection.Output )\r\n\t\t\t\t{\r\n\t\t\t\t\tGroup = output.Group,\r\n\t\t\t\t\tTooltip = output.Tooltip,\r\n\t\t\t\t\tOrder = output.Order,\r\n\t\t\t\t\tPropertyName = property.Name,\r\n\t\t\t\t\tFormerIds = formerly.Length > 0 ? formerly : null\r\n\t\t\t\t} );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tvar result = ( (IReadOnlyList<PortDef>)StableSort( inputs ), (IReadOnlyList<PortDef>)StableSort( outputs ) );\r\n\r\n\t\tlock ( s_cacheLock )\r\n\t\t{\r\n\t\t\ts_cache[nodeType] = result;\r\n\t\t}\r\n\r\n\t\treturn result;\r\n\t}\r\n\r\n\t/// <summary>Drop the reflection cache. Must run on hotload \u2014 <see cref=\"PortDef\"/>s outlive the assembly otherwise.</summary>\r\n\tpublic static void FlushCache()\r\n\t{\r\n\t\tlock ( s_cacheLock )\r\n\t\t{\r\n\t\t\ts_cache.Clear();\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Sort by explicit order, keeping declaration order for ties, and drop duplicate ids \u2014 a derived\r\n\t/// class that redeclares a base port wins, because its declaration is the more specific one.\r\n\t/// </summary>\r\n\tstatic PortDef[] StableSort( List<PortDef> defs )\r\n\t{\r\n\t\tvar deduped = new List<PortDef>( defs.Count );\r\n\r\n\t\tfor ( int i = 0; i < defs.Count; i++ )\r\n\t\t{\r\n\t\t\tvar later = false;\r\n\r\n\t\t\tfor ( int j = i + 1; j < defs.Count; j++ )\r\n\t\t\t{\r\n\t\t\t\tif ( defs[j].Id != defs[i].Id ) continue;\r\n\r\n\t\t\t\tlater = true;\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tif ( !later ) deduped.Add( defs[i] );\r\n\t\t}\r\n\r\n\t\treturn deduped.OrderBy( x => x.Order ).ToArray();\r\n\t}\r\n\r\n\tstatic int DeclarationDepth( PropertyInfo property )\r\n\t{\r\n\t\tvar depth = 0;\r\n\t\tvar type = property.DeclaringType;\r\n\r\n\t\twhile ( type is not null && type != typeof( object ) )\r\n\t\t{\r\n\t\t\tdepth++;\r\n\t\t\ttype = type.BaseType;\r\n\t\t}\r\n\r\n\t\treturn depth;\r\n\t}\r\n\r\n\tstatic readonly Dictionary<Type, (IReadOnlyList<PortDef> Inputs, IReadOnlyList<PortDef> Outputs)> s_cache = new();\r\n\tstatic readonly object s_cacheLock = new();\r\n}\r\n\r\n/// <summary>\r\n/// The live ports of one node. Rebuilding preserves the resolved type and inline value of every\r\n/// port whose id survives; ports that disappear leave their edges to be converted into\r\n/// <see cref=\"BrokenEdge\"/> ghosts by the graph, never silently deleted.\r\n/// </summary>\r\npublic sealed class PortCollection\r\n{\r\n\treadonly List<InputPort> _inputs = new();\r\n\treadonly List<OutputPort> _outputs = new();\r\n\r\n\t/// <summary>Input ports, in socket order.</summary>\r\n\tpublic IReadOnlyList<InputPort> Inputs => _inputs;\r\n\r\n\t/// <summary>Output ports, in socket order.</summary>\r\n\tpublic IReadOnlyList<OutputPort> Outputs => _outputs;\r\n\r\n\t/// <summary>Find an input port by id.</summary>\r\n\tpublic InputPort FindInput( PortId id ) => _inputs.FirstOrDefault( x => x.Id == id );\r\n\r\n\t/// <summary>Find an output port by id.</summary>\r\n\tpublic OutputPort FindOutput( PortId id ) => _outputs.FirstOrDefault( x => x.Id == id );\r\n\r\n\t/// <summary>Find a port of either direction by id.</summary>\r\n\tpublic Port Find( PortId id ) => (Port)FindInput( id ) ?? FindOutput( id );\r\n\r\n\t/// <summary>\r\n\t/// Replace the port set with the declarations in <paramref name=\"builder\"/>, carrying over state\r\n\t/// from ports whose ids survive. Returns the ids that disappeared.\r\n\t/// <para>\r\n\t/// Duplicate ids are tolerated rather than fatal: a node whose <c>OnDefinePorts</c> re-declares a\r\n\t/// reflected port keeps the last declaration, matching the \"more specific wins\" rule the reflection\r\n\t/// pass already uses. A malformed node must never take the document down.\r\n\t/// </para>\r\n\t/// </summary>\r\n\tpublic IReadOnlyList<PortId> Apply( PrismNode node, PortBuilder builder )\r\n\t{\r\n\t\tvar removed = new List<PortId>();\r\n\r\n\t\tvar oldInputs = ToLookup( _inputs );\r\n\t\tvar oldOutputs = ToLookup( _outputs );\r\n\r\n\t\t_inputs.Clear();\r\n\t\t_outputs.Clear();\r\n\r\n\t\tforeach ( var def in Dedupe( builder.Inputs ) )\r\n\t\t{\r\n\t\t\tvar port = new InputPort( node, def ) { Index = _inputs.Count };\r\n\r\n\t\t\tif ( oldInputs.TryGetValue( def.Id, out var old ) )\r\n\t\t\t{\r\n\t\t\t\tport.ResolvedType = old.ResolvedType;\r\n\t\t\t\tport.InlineValue = old.InlineValue;\r\n\t\t\t\toldInputs.Remove( def.Id );\r\n\t\t\t}\r\n\r\n\t\t\t_inputs.Add( port );\r\n\t\t}\r\n\r\n\t\tforeach ( var def in Dedupe( builder.Outputs ) )\r\n\t\t{\r\n\t\t\tvar port = new OutputPort( node, def ) { Index = _outputs.Count };\r\n\r\n\t\t\tif ( oldOutputs.TryGetValue( def.Id, out var old ) )\r\n\t\t\t{\r\n\t\t\t\tport.ResolvedType = old.ResolvedType;\r\n\t\t\t\toldOutputs.Remove( def.Id );\r\n\t\t\t}\r\n\r\n\t\t\t_outputs.Add( port );\r\n\t\t}\r\n\r\n\t\tremoved.AddRange( oldInputs.Keys );\r\n\t\tremoved.AddRange( oldOutputs.Keys );\r\n\r\n\t\treturn removed;\r\n\t}\r\n\r\n\t/// <summary>Keep the last declaration for each id, preserving declaration order otherwise.</summary>\r\n\tstatic List<PortDef> Dedupe( IReadOnlyList<PortDef> defs )\r\n\t{\r\n\t\tvar result = new List<PortDef>( defs.Count );\r\n\r\n\t\tfor ( int i = 0; i < defs.Count; i++ )\r\n\t\t{\r\n\t\t\tvar later = false;\r\n\r\n\t\t\tfor ( int j = i + 1; j < defs.Count; j++ )\r\n\t\t\t{\r\n\t\t\t\tif ( defs[j].Id != defs[i].Id ) continue;\r\n\r\n\t\t\t\tlater = true;\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tif ( !later ) result.Add( defs[i] );\r\n\t\t}\r\n\r\n\t\treturn result;\r\n\t}\r\n\r\n\tstatic Dictionary<PortId, T> ToLookup<T>( List<T> ports ) where T : Port\r\n\t{\r\n\t\tvar map = new Dictionary<PortId, T>();\r\n\r\n\t\tforeach ( var port in ports )\r\n\t\t{\r\n\t\t\tmap[port.Id] = port;\r\n\t\t}\r\n\r\n\t\treturn map;\r\n\t}\r\n}\r\n"
}
]
}