Editor-side serialization for Prism graphs and node property reflection. Handles reading/writing .prism documents and fragments, JSON parsing with duplicate-key safety, node/edge/parameter/keyword/group/note read/write, schema migrations, per-node property reflection cache, inline literal syncing, hashing for structural/value changes, and a small self-test node type.
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Backends;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Undo;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Text;
namespace Editor.Prism.Serialization;
/// <summary>
/// Reflection over a node's serialized properties, and the cache that makes it cheap.
/// <para>
/// A node's <em>structural</em> properties are the ones that change generated code — enums, modes,
/// asset references — and go into the document's <c>props</c> object. Its <em>inline</em> properties
/// are the ones marked <c>[InlineValue]</c>, which supply the literal for an unconnected port and go
/// into <c>inline</c> keyed by port id. Splitting them is what lets the compile service tell a slider
/// drag (re-push a uniform) from an enum change (recompile).
/// </para>
/// </summary>
public static class NodeProperties
{
sealed class Reflected
{
public PropertyInfo[] Structural;
public PropertyInfo[] Inline;
public Dictionary<string, PropertyInfo> ByName;
public PrismNode Prototype;
}
static readonly object s_lock = new();
static readonly Dictionary<Type, Reflected> s_cache = new();
/// <summary>Drop the reflection cache. Must run on hotload.</summary>
public static void Flush()
{
lock ( s_lock )
{
s_cache.Clear();
}
}
/// <summary>Properties written to a node's <c>props</c> object, in a stable order.</summary>
public static IReadOnlyList<PropertyInfo> Structural( Type type ) => Describe( type ).Structural;
/// <summary>Properties marked <c>[InlineValue]</c>, which back a port's unconnected literal.</summary>
public static IReadOnlyList<PropertyInfo> Inline( Type type ) => Describe( type ).Inline;
/// <summary>A shared default-constructed instance, used to decide whether a value is worth writing.</summary>
public static PrismNode Prototype( Type type ) => Describe( type ).Prototype;
/// <summary>Find a serialized property by name, following <c>[FormerlyKnownAs]</c> and ignoring case.</summary>
public static PropertyInfo Find( Type type, string name )
{
if ( string.IsNullOrEmpty( name ) ) return null;
return Describe( type ).ByName.TryGetValue( name, out var property ) ? property : null;
}
/// <summary>Read a property value off a node. Returns null rather than throwing.</summary>
public static object Get( PrismNode node, string name )
{
if ( node is null ) return null;
var property = Find( node.GetType(), name );
if ( property is null || !property.CanRead ) return null;
return PrismLog.Guard<object>( $"Read property '{name}'", () => property.GetValue( node ) );
}
/// <summary>Write a property value onto a node, converting where it safely can. Never throws.</summary>
public static bool Set( PrismNode node, string name, object value )
{
if ( node is null ) return false;
var property = Find( node.GetType(), name );
if ( property is null || !property.CanWrite ) return false;
return PrismLog.Guard( $"Write property '{name}'", () =>
{
property.SetValue( node, Convert( value, property.PropertyType ) );
} );
}
/// <summary>Serialize a node's structural properties into a <c>props</c> object.</summary>
public static JsonObject Write( PrismNode node, DiagnosticSink sink = null )
{
var props = new JsonObject();
if ( node is null ) return props;
foreach ( var property in Structural( node.GetType() ) )
{
PrismLog.Try( $"Serialize '{node.GetType().Name}.{property.Name}'", () =>
{
var value = property.GetValue( node );
props[property.Name] = value is null
? null
: JsonSerializer.SerializeToNode( value, property.PropertyType, PrismJson.Options );
}, sink, DiagnosticCode.NodeReadFailed, GraphRef.ForNode( node.Id ) );
}
return props;
}
/// <summary>
/// Apply a <c>props</c> object to a node, one property at a time. A property that fails to convert
/// is reported and skipped — one bad field never costs the whole node.
/// </summary>
public static void Read( PrismNode node, JsonObject props, DiagnosticSink sink = null )
{
if ( node is null || props is null ) return;
foreach ( var pair in props )
{
var property = Find( node.GetType(), pair.Key );
if ( property is null || !property.CanWrite ) continue;
PrismLog.Try( $"Deserialize '{node.GetType().Name}.{pair.Key}'", () =>
{
if ( pair.Value is null )
{
if ( !property.PropertyType.IsValueType ) property.SetValue( node, null );
return;
}
var value = pair.Value.Deserialize( property.PropertyType, PrismJson.Options );
property.SetValue( node, value );
}, sink, DiagnosticCode.NodeReadFailed, GraphRef.ForNode( node.Id ) );
}
}
/// <summary>
/// Ask a node to rebuild its ports now that its properties have been deserialized. Nodes whose
/// port set depends on their properties need this or they load with the constructor's port set.
/// </summary>
public static void RebuildPorts( PrismNode node )
{
if ( node is null ) return;
if ( node is UnknownNode unknown )
{
unknown.Rebuild();
return;
}
PrismLog.Guard( $"Rebuild ports of '{node.GetType().Name}'", node.RebuildPortsAfterLoad );
}
/// <summary>The inline literal of a port: the bound property when there is one, otherwise the slot.</summary>
public static object GetInline( PrismNode node, InputPort port )
{
if ( node is null || port is null ) return null;
var bound = port.Def?.InlineValueProperty;
if ( !string.IsNullOrEmpty( bound ) )
{
var value = Get( node, bound );
if ( value is not null ) return value;
}
return port.InlineValue;
}
/// <summary>Set a port's inline literal, keeping the bound property and the port slot in step.</summary>
public static void SetInline( PrismNode node, InputPort port, object value )
{
if ( port is null ) return;
port.InlineValue = value;
var bound = port.Def?.InlineValueProperty;
if ( string.IsNullOrEmpty( bound ) ) return;
Set( node, bound, value );
}
/// <summary>
/// Put every inline literal back to what a freshly constructed node of this type would have.
/// <para>
/// Essential for undo restore, which reuses a live node instead of rebuilding it: inline values are
/// written to a document only when they differ from the default, so a snapshot that omits one means
/// "this is the default", not "leave whatever is there". Without this reset, undoing a slider drag
/// would leave the slider where it was.
/// </para>
/// </summary>
public static void ResetInline( PrismNode node )
{
if ( node is null || node is UnknownNode ) return;
var type = node.GetType();
var prototype = Prototype( type );
if ( prototype is not null )
{
foreach ( var property in Inline( type ) )
{
PrismLog.Guard( $"Reset '{type.Name}.{property.Name}'",
() => property.SetValue( node, property.GetValue( prototype ) ) );
}
}
foreach ( var port in node.Inputs )
{
port.InlineValue = null;
}
}
/// <summary>
/// Put every structural property of a node back to what a freshly constructed one would hold.
/// <para>
/// The mirror of <see cref="ResetInline"/>, and needed for the same reason: undo restore reuses the
/// live node object rather than building a new one, so a property the snapshot does not mention has
/// to go back to its default. Without this, applying a document that omits a property leaves
/// whatever the node happened to have — which is not what reading that same document into a fresh
/// node produces, and undo is only trustworthy while those two agree.
/// </para>
/// </summary>
public static void ResetProperties( PrismNode node )
{
if ( node is null || node is UnknownNode ) return;
var type = node.GetType();
var prototype = Prototype( type );
if ( prototype is null ) return;
foreach ( var property in Structural( type ) )
{
if ( !property.CanWrite ) continue;
PrismLog.Guard( $"Reset '{type.Name}.{property.Name}'",
() => property.SetValue( node, property.GetValue( prototype ) ) );
}
}
/// <summary>
/// Copy the inline-bound property values of a node into its port slots. Run after deserialization
/// so a node whose literals live in properties still reports them through its ports.
/// </summary>
public static void SyncInlineFromProperties( PrismNode node )
{
if ( node is null ) return;
foreach ( var port in node.Inputs )
{
var bound = port.Def?.InlineValueProperty;
if ( string.IsNullOrEmpty( bound ) ) continue;
if ( port.InlineValue is not null ) continue;
port.InlineValue = Get( node, bound );
}
}
/// <summary>
/// Push one property's value into the port literal it backs, when it backs one.
/// <para>
/// <c>NodeEmitter</c> reads the port slot before the bound property, so an edit that writes only the
/// property — which is what the Inspector's control sheet does — would not reach the generated
/// shader until the document was reloaded. Returns true when a slot was updated.
/// </para>
/// </summary>
public static bool SyncInlineFor( PrismNode node, string property )
{
if ( node is null || string.IsNullOrEmpty( property ) ) return false;
var resolved = Find( node.GetType(), property );
if ( resolved is null || !resolved.CanRead ) return false;
var updated = false;
foreach ( var port in node.Inputs )
{
if ( !string.Equals( port.Def?.InlineValueProperty, resolved.Name, StringComparison.Ordinal ) ) continue;
port.InlineValue = PrismLog.Guard<object>( $"Read property '{resolved.Name}'",
() => resolved.GetValue( node ) );
updated = true;
}
return updated;
}
/// <summary>Hash of everything about a node that changes generated code.</summary>
public static ulong StructuralHash( PrismNode node )
{
if ( node is null ) return 0UL;
if ( node is UnknownNode unknown )
{
return Fnv.Mix( Fnv.Of( unknown.TypeId ), unknown.Raw?.ToJsonString() ?? string.Empty );
}
var hash = Fnv.Offset;
foreach ( var property in Structural( node.GetType() ) )
{
hash = Fnv.Mix( hash, property.Name );
hash = Fnv.Mix( hash, Fnv.OfValue( PrismLog.Guard<object>( "hash property",
() => property.GetValue( node ) ) ) );
}
return hash;
}
/// <summary>Hash of everything about a node that only changes uniform values.</summary>
public static ulong ValueHash( PrismNode node )
{
if ( node is null ) return 0UL;
if ( node is UnknownNode ) return 0UL;
var hash = Fnv.Offset;
foreach ( var property in Inline( node.GetType() ) )
{
hash = Fnv.Mix( hash, property.Name );
hash = Fnv.Mix( hash, Fnv.OfValue( PrismLog.Guard<object>( "hash property",
() => property.GetValue( node ) ) ) );
}
return hash;
}
static object Convert( object value, Type target )
{
if ( value is null ) return target.IsValueType ? Activator.CreateInstance( target ) : null;
if ( target.IsInstanceOfType( value ) ) return value;
if ( target.IsEnum )
{
if ( value is string name && Enum.TryParse( target, name, true, out var parsed ) ) return parsed;
return Enum.ToObject( target, System.Convert.ToInt64( value ) );
}
if ( value is JsonNode json ) return json.Deserialize( target, PrismJson.Options );
// Prism's id structs are not IConvertible, so ChangeType would throw on both directions of the
// one conversion that matters: a caller handing a raw id string to a ParamId-typed property, or
// vice versa. Both shapes reach here from the UI, so both are handled.
if ( value is string text )
{
if ( target == typeof( ParamId ) ) return ParamId.Parse( text );
if ( target == typeof( NodeId ) ) return NodeId.Parse( text );
if ( target == typeof( PortId ) ) return PortId.Parse( text );
if ( target == typeof( EdgeId ) ) return EdgeId.Parse( text );
}
if ( target == typeof( string ) && value is ParamId or NodeId or PortId or EdgeId )
{
return value.ToString();
}
if ( TryReshape( value, target, out var reshaped ) ) return reshaped;
return System.Convert.ChangeType( value, target, CultureInfo.InvariantCulture );
}
/// <summary>
/// Convert between the numeric shapes the model actually stores: <c>float</c>, <c>Vector2/3/4</c> and
/// <c>Color</c>, in any direction.
/// <para>
/// This is not a nicety. A port declares a shader type and its <c>[InlineValue]</c> property declares
/// a CLR type, and the two do not have to be the same shape — a <c>float3</c> port bound to a
/// <c>Color</c> property is the single most common spelling in the node library. Reading a document
/// hands the literal back in the shape the <em>port</em> implies, and <c>ChangeType</c> cannot turn a
/// <c>Vector3</c> into a <c>Color</c>: it throws, the guard swallows it, and the property silently
/// keeps its default. The literal was written to disk correctly and thrown away on the way back in —
/// the user's colour reverts to white the first time they reopen the graph, with nothing said.
/// </para>
/// <para>
/// The reshaping rules are the wire's rules, so a value never changes meaning by crossing this
/// boundary: a scalar splats, a narrower value pads (with 1 in the fourth component, matching
/// <see cref="TypeRules.DefaultFill"/>, so a colour stays opaque), and a wider one truncates.
/// </para>
/// </summary>
static bool TryReshape( object value, Type target, out object result )
{
result = null;
var wantsWide = target == typeof( Color ) || target == typeof( Vector2 ) ||
target == typeof( Vector3 ) || target == typeof( Vector4 );
var isWide = value is Vector2 or Vector3 or Vector4 or Color or float[];
if ( !wantsWide && !isWide ) return false;
var components = ValueCodec.ToFloats( value );
if ( components.Length == 0 ) return false;
if ( wantsWide )
{
if ( target == typeof( Vector2 ) ) result = ValueCodec.ToComponents( components, 2 );
else if ( target == typeof( Vector3 ) ) result = ValueCodec.ToComponents( components, 3 );
else
{
var wide = (Vector4)ValueCodec.ToComponents( components, 4 );
result = target == typeof( Vector4 ) ? wide : new Color( wide.x, wide.y, wide.z, wide.w );
}
return true;
}
// A wide value handed to a scalar property keeps its first component, the same truncation the
// type rules apply on a wire.
if ( target == typeof( float ) ) result = components[0];
else if ( target == typeof( double ) ) result = (double)components[0];
else if ( target == typeof( int ) ) result = (int)components[0];
else if ( target == typeof( bool ) ) result = components[0] != 0f;
else return false;
return true;
}
static Reflected Describe( Type type )
{
if ( type is null ) return s_empty;
lock ( s_lock )
{
if ( s_cache.TryGetValue( type, out var cached ) ) return cached;
}
var structural = new List<PropertyInfo>();
var inline = new List<PropertyInfo>();
var byName = new Dictionary<string, PropertyInfo>( StringComparer.OrdinalIgnoreCase );
if ( !typeof( UnknownNode ).IsAssignableFrom( type ) )
{
var properties = type
.GetProperties( BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy )
.Where( IsSerializable )
.OrderBy( x => x.Name, StringComparer.Ordinal )
.ToArray();
foreach ( var property in properties )
{
if ( property.GetCustomAttribute<InlineValueAttribute>() is not null ) inline.Add( property );
else structural.Add( property );
byName[property.Name] = property;
foreach ( var former in property.GetCustomAttributes<FormerlyKnownAsAttribute>() )
{
if ( string.IsNullOrEmpty( former.OldName ) ) continue;
if ( byName.ContainsKey( former.OldName ) ) continue;
byName[former.OldName] = property;
}
}
}
var reflected = new Reflected
{
Structural = structural.ToArray(),
Inline = inline.ToArray(),
ByName = byName,
Prototype = PrismLog.Guard<PrismNode>( $"Prototype '{type.FullName}'",
() => Activator.CreateInstance( type ) as PrismNode )
};
lock ( s_lock )
{
s_cache[type] = reflected;
}
return reflected;
}
static bool IsSerializable( PropertyInfo property )
{
if ( property.GetIndexParameters().Length > 0 ) return false;
if ( !property.CanRead || !property.CanWrite ) return false;
if ( property.GetMethod is null || !property.GetMethod.IsPublic ) return false;
if ( property.SetMethod is null || !property.SetMethod.IsPublic ) return false;
if ( property.PropertyType == typeof( PortRef ) ) return false;
if ( property.GetCustomAttribute<JsonIgnoreAttribute>() is not null ) return false;
var declaring = property.DeclaringType;
if ( declaring is null ) return false;
if ( declaring == typeof( PrismNode ) || declaring == typeof( object ) ) return false;
return true;
}
static readonly Reflected s_empty = new()
{
Structural = Array.Empty<PropertyInfo>(),
Inline = Array.Empty<PropertyInfo>(),
ByName = new Dictionary<string, PropertyInfo>( StringComparer.OrdinalIgnoreCase )
};
}
/// <summary>An enum property, so the self-test covers enum round-tripping.</summary>
public enum SelfTestMode
{
/// <summary>First value.</summary>
Alpha,
/// <summary>Second value.</summary>
Beta,
/// <summary>Third value.</summary>
Gamma
}
/// <summary>
/// The node type <see cref="PrismSerializer.RunSelfTest"/> exercises the registered-node path with.
/// <para>
/// It exists because there is no test framework here and the alternative — verifying serialization
/// only against node types owned by other packages — would mean this package could not prove itself
/// until the whole editor was assembled. It declares one of everything the serializer has to handle:
/// a string, an int, an enum, a bool, two inline-bound literals of different widths, and a port set
/// that changes with a property.
/// </para>
/// <para>
/// It is tiered <see cref="NodeTier.Deprecated"/>, so it is registered and constructible but never
/// offered in the palette or the library tree.
/// </para>
/// </summary>
[NodeInfo( Id = PrismSelfTestNode.TypeId, Title = "Prism Self-Test Probe", Category = "Utility/Internal",
Icon = "science", Tier = NodeTier.Deprecated,
Description = "Internal probe used by the serializer self-test. Not intended for use in graphs." )]
[NodeVersion( 1 )]
public sealed class PrismSelfTestNode : PrismNode
{
/// <summary>The stable type id this probe is registered under.</summary>
public const string TypeId = "prism.internal.selftest";
/// <summary>A scalar input.</summary>
[In( "float", Name = "A" )] public PortRef A { get; set; }
/// <summary>A vector input.</summary>
[In( "float3", Name = "B" )] public PortRef B { get; set; }
/// <summary>
/// A second vector input, whose inline literal is stored as a <see cref="Color"/> rather than a
/// <c>Vector3</c>. The mismatch is the point: it is the commonest spelling in the node library and
/// the shape the literal comes back in from a document is the port's, not the property's.
/// </summary>
[In( "float3", Name = "C" )] public PortRef C { get; set; }
/// <summary>A vector output.</summary>
[Out( "float3", Name = "Out" )] public PortRef Out { get; set; }
/// <summary>The literal used when <c>A</c> is unconnected.</summary>
[InlineValue( nameof( A ) )] public float DefaultA { get; set; } = 0.5f;
/// <summary>The literal used when <c>B</c> is unconnected.</summary>
[InlineValue( nameof( B ) )] public Vector3 DefaultB { get; set; } = new( 1f, 2f, 3f );
/// <summary>The literal used when <c>C</c> is unconnected, deliberately a different shape to its port.</summary>
[InlineValue( nameof( C ) )] public Color DefaultC { get; set; } = Color.White;
/// <summary>A string property.</summary>
public string Label { get; set; } = "probe";
/// <summary>An integer property.</summary>
public int Channel { get; set; } = 2;
/// <summary>An enum property.</summary>
public SelfTestMode Mode { get; set; } = SelfTestMode.Beta;
/// <summary>A boolean property.</summary>
public bool Enabled { get; set; } = true;
/// <summary>How many extra input ports to grow. Drives a port rebuild after deserialization.</summary>
public int ExtraPorts { get; set; }
/// <summary>Drop the <c>B</c> port, to prove a vanished port produces a ghost rather than a deletion.</summary>
public bool HideB { get; set; }
/// <inheritdoc/>
protected override void OnDefinePorts( PortBuilder b )
{
if ( HideB ) b.Remove( nameof( B ) );
for ( int i = 0; i < ExtraPorts; i++ )
{
b.Input( $"Extra{i}", "float", $"Extra {i}" );
}
}
/// <inheritdoc/>
public override void Emit( EmitContext ctx )
{
ctx?.Out( nameof( Out ), ctx.Const( new Vector3( DefaultA, DefaultA, DefaultA ) ) );
}
}
/// <summary>
/// Reads and writes <c>.prism</c> / <c>.prismfn</c> documents.
/// <para>
/// Three rules govern everything here. <b>Reading is fault-isolated per node and per edge</b> — only
/// a JSON parse failure aborts, and every recovery emits a diagnostic. <b>Writing is deterministic</b>
/// — stable key order, two-space indent, exact float formatting — so regenerating an unchanged
/// document produces a byte-identical file. <b>Nothing is ever silently discarded</b> — unrecognised
/// node types round-trip verbatim, unresolvable edges become visible ghosts, and every section
/// carries an <c>x</c> bag for keys a future schema invents.
/// </para>
/// </summary>
public static class PrismSerializer
{
const string KeySchema = "schema";
const string KeyId = "id";
const string KeyKind = "kind";
static readonly JsonDocumentOptions s_documentOptions = new()
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip,
// Deliberately the same limit the writer uses: a document we can open has to be one we can save.
MaxDepth = PrismJson.MaxDepth
};
// ---------------------------------------------------------------- reading ----
/// <summary>
/// Read a document. Returns null only when the text is not JSON at all; every other failure
/// degrades to a diagnostic and a partially recovered document.
/// </summary>
public static PrismGraph Read( string json, DiagnosticSink sink = null )
{
TryRead( json, out var graph, sink );
return graph;
}
/// <summary>Read a document, reporting whether the text parsed.</summary>
public static bool TryRead( string json, out PrismGraph graph, DiagnosticSink sink = null )
{
graph = null;
if ( string.IsNullOrWhiteSpace( json ) )
{
sink?.Error( DiagnosticCode.SectionReadFailed, "The document is empty" );
return false;
}
JsonObject root = null;
try
{
// Parsed through JsonDocument and rebuilt rather than handed straight to JsonNode.Parse.
// A JsonNode tree materialises its property dictionaries lazily and throws
// ArgumentException the first time anything reads an object that declares the same key
// twice — from inside whichever reader happened to touch it first, long past any catch
// around the parse. A document with a duplicated key is what a bad three-way merge
// produces, which is exactly when it matters that the file still opens.
root = ParseObject( json );
}
catch ( Exception e )
{
sink?.Error( DiagnosticCode.SectionReadFailed, $"The document is not valid JSON: {e.Message}",
null, e.ToString() );
return false;
}
if ( root is null )
{
sink?.Error( DiagnosticCode.SectionReadFailed, "The document's root is not a JSON object" );
return false;
}
graph = ReadJson( root, sink );
return graph is not null;
}
/// <summary>
/// Read a document from an already-parsed JSON object.
/// <para>
/// The caller may have built that object any way at all — the asset system hands one over, a
/// migration step synthesises one — so a section that fails takes the sections after it with it
/// rather than the whole document: what has been recovered so far is still returned, with a
/// diagnostic saying where reading stopped.
/// </para>
/// </summary>
public static PrismGraph ReadJson( JsonObject root, DiagnosticSink sink = null )
{
if ( root is null ) return null;
root = PrismLog.Guard( "Upgrading the document schema", () => SchemaMigrations.Upgrade( root, sink ) ) ?? root;
var kind = PrismLog.Guard<string>( "Reading the document kind", () => ValueCodec.StringOf( root[KeyKind] ) );
var graph = new PrismGraph( string.Equals( kind, PrismConstants.DocumentKindSubgraph,
StringComparison.OrdinalIgnoreCase ) );
using ( graph.SuspendEvents() )
{
PrismLog.Try( "Reading the document", () =>
{
graph.DocumentId = ValueCodec.StringOf( root[KeyId] ) ?? Ids.NewShortId();
ReadMeta( graph, root["meta"] as JsonObject );
ReadSettings( graph, root["settings"] as JsonObject, sink );
ReadPreview( graph, root["preview"] as JsonObject );
ReadParameters( graph, root["parameters"] as JsonArray, sink );
ReadKeywords( graph, root["keywords"] as JsonArray, sink );
ReadNodes( graph, root["nodes"] as JsonArray, sink );
var edges = ParseEdges( root["edges"] as JsonArray, sink );
foreach ( var node in graph.Nodes )
{
if ( node is UnknownNode unknown ) unknown.RebuildFromEdges( edges.Select( x => x.Edge ) );
}
ResolveEdges( graph, edges, sink );
ReadGroups( graph, root["groups"] as JsonArray, sink );
ReadNotes( graph, root["notes"] as JsonArray, sink );
ReadView( graph, root["view"] as JsonObject );
graph.X = ExtraBag( root, s_documentKeys );
}, sink, DiagnosticCode.SectionReadFailed );
}
if ( string.IsNullOrEmpty( graph.DocumentId ) ) graph.DocumentId = Ids.NewShortId();
graph.IsDirty = false;
return graph;
}
/// <summary>
/// Parse a JSON object the safe way: through <see cref="JsonDocument"/> and
/// <see cref="Materialize"/>, so a duplicated property name resolves last-wins here instead of
/// throwing out of whichever reader touches it first. Every entry point that takes raw text goes
/// through this — the whole document, a clipboard fragment and an undo snapshot alike.
/// </summary>
static JsonObject ParseObject( string json )
{
using var document = JsonDocument.Parse( StripPreamble( json ), s_documentOptions );
return Materialize( document.RootElement, 0 ) as JsonObject;
}
/// <summary>
/// Rebuild a parsed element as a node tree, resolving a duplicated property name to its last value
/// rather than throwing — the same rule <c>JsonSerializer</c> applies, and the only one that lets a
/// hand-merged document open at all.
/// </summary>
static JsonNode Materialize( JsonElement element, int depth )
{
if ( depth > PrismJson.MaxDepth ) return null;
switch ( element.ValueKind )
{
case JsonValueKind.Object:
{
var json = new JsonObject();
foreach ( var property in element.EnumerateObject() )
{
json[property.Name] = Materialize( property.Value, depth + 1 );
}
return json;
}
case JsonValueKind.Array:
{
var array = new JsonArray();
foreach ( var item in element.EnumerateArray() )
{
array.Add( Materialize( item, depth + 1 ) );
}
return array;
}
case JsonValueKind.Null:
case JsonValueKind.Undefined:
return null;
default:
return JsonValue.Create( element.Clone() );
}
}
/// <summary>Read a document from disk. Returns null when the file is missing or unreadable.</summary>
public static PrismGraph ReadFile( string absolutePath, DiagnosticSink sink = null )
{
if ( string.IsNullOrWhiteSpace( absolutePath ) ) return null;
string text;
try
{
text = File.ReadAllText( absolutePath, Encoding.UTF8 );
}
catch ( Exception e )
{
sink?.Error( DiagnosticCode.SectionReadFailed, $"Could not read '{absolutePath}': {e.Message}",
null, e.ToString() );
return null;
}
var graph = Read( text, sink );
if ( graph is not null ) graph.AssetPath = absolutePath;
return graph;
}
// ---------------------------------------------------------------- writing ----
/// <summary>Serialize a document to its canonical text form.</summary>
public static string Write( PrismGraph graph ) => WriteJson( graph )?.ToJsonString( PrismJson.Options ) ?? "{}";
/// <summary>
/// Serialize a document to JSON. Key order is fixed by the schema, arrays keep document order and
/// every float is written exactly, so an unchanged document always produces identical text.
/// </summary>
public static JsonObject WriteJson( PrismGraph graph )
{
var root = new JsonObject();
if ( graph is null ) return root;
root[KeySchema] = PrismConstants.DocumentSchemaVersion;
root[KeyId] = graph.DocumentId;
root[KeyKind] = graph.Kind;
var meta = WriteMeta( graph.Meta );
if ( meta.Count > 0 ) root["meta"] = meta;
root["settings"] = WriteSettings( graph.Settings ?? new GraphSettings() );
root["preview"] = WritePreview( graph.Preview ?? new PreviewState() );
if ( graph.Parameters.Count > 0 )
{
var array = new JsonArray();
foreach ( var parameter in graph.Parameters )
{
array.Add( WriteParameter( parameter ) );
}
root["parameters"] = array;
}
if ( graph.Keywords.Count > 0 )
{
var array = new JsonArray();
foreach ( var keyword in graph.Keywords )
{
array.Add( WriteKeyword( keyword ) );
}
root["keywords"] = array;
}
var nodes = new JsonArray();
foreach ( var node in graph.Nodes )
{
nodes.Add( WriteNode( graph, node ) );
}
root["nodes"] = nodes;
var edges = new JsonArray();
foreach ( var edge in graph.Edges )
{
edges.Add( WriteEdge( edge ) );
}
foreach ( var broken in graph.BrokenEdges )
{
if ( broken?.Edge is null ) continue;
edges.Add( WriteEdge( broken.Edge ) );
}
if ( edges.Count > 0 ) root["edges"] = edges;
if ( graph.Groups.Count > 0 )
{
var array = new JsonArray();
foreach ( var group in graph.Groups )
{
array.Add( WriteGroup( group ) );
}
root["groups"] = array;
}
if ( graph.Notes.Count > 0 )
{
var array = new JsonArray();
foreach ( var note in graph.Notes )
{
array.Add( WriteNote( note ) );
}
root["notes"] = array;
}
if ( graph.View is not null )
{
root["view"] = new JsonObject
{
["center"] = ValueCodec.Vector( graph.View.Center.x, graph.View.Center.y ),
["zoom"] = ValueCodec.Number( graph.View.Zoom )
};
}
MergeExtra( root, graph.X );
return root;
}
/// <summary>
/// Write a document to disk atomically, with an integrity check.
/// <para>
/// The text is re-parsed before anything on disk is touched; if what we just produced does not
/// read back, the original file is left exactly as it was and the failure is reported. Then the
/// text goes to a sibling temp file and is moved over the original, so a crash mid-write cannot
/// leave a half-written document.
/// </para>
/// </summary>
public static bool Save( PrismGraph graph, string absolutePath, DiagnosticSink sink = null )
{
if ( graph is null || string.IsNullOrWhiteSpace( absolutePath ) ) return false;
graph.Meta ??= new GraphMeta();
graph.Meta.Created ??= DateTimeOffset.UtcNow;
graph.Meta.Modified = DateTimeOffset.UtcNow;
graph.Meta.EditorVersion = PrismConstants.EditorVersion;
var text = PrismLog.Guard( "Serialize document", () => Write( graph ) );
if ( string.IsNullOrEmpty( text ) )
{
sink?.Error( DiagnosticCode.RoundTripFailed, "The document could not be serialized" );
return false;
}
var check = new DiagnosticSink();
if ( !TryRead( text, out var reparsed, check ) || reparsed is null || check.HasErrors )
{
sink?.Error( DiagnosticCode.RoundTripFailed,
"The document did not survive its own integrity check and was NOT written",
null, string.Join( Environment.NewLine, check.All.Select( x => x.ToString() ) ) );
return false;
}
if ( reparsed.Nodes.Count != graph.Nodes.Count )
{
sink?.Error( DiagnosticCode.RoundTripFailed,
$"Integrity check lost nodes ({graph.Nodes.Count} written, {reparsed.Nodes.Count} read back); " +
"the file was NOT written" );
return false;
}
// Counting nodes proves the document survived; it does not prove its contents did. Writing what we
// just read has to reproduce the same bytes, and when it does not, something inside a node — a
// literal, a property, a flag — was written correctly and dropped on the way back in. That is the
// shape of every silent data loss this format can have: the file on disk is fine and the next open
// is not.
//
// This warns rather than refusing. A save the user cannot perform is worse than a save that loses
// one literal, and the only honest thing to do with a difference we did not anticipate is to write
// the file and say exactly what drifted.
var drift = PrismLog.Guard<string>( "Verifying the document round-trip", () => Diff( text, Write( reparsed ) ) );
if ( !string.IsNullOrEmpty( drift ) )
{
sink?.Warn( DiagnosticCode.RoundTripFailed,
"The document was saved, but part of it does not read back the way it was written",
null, $"Reopening this file will not reproduce it exactly. {drift}" );
}
var temp = absolutePath + ".tmp";
try
{
var directory = Path.GetDirectoryName( absolutePath );
if ( !string.IsNullOrEmpty( directory ) ) Directory.CreateDirectory( directory );
File.WriteAllText( temp, text, new UTF8Encoding( false ) );
if ( File.Exists( absolutePath ) ) File.Replace( temp, absolutePath, null, true );
else File.Move( temp, absolutePath );
}
catch ( Exception e )
{
sink?.Error( DiagnosticCode.RoundTripFailed, $"Could not write '{absolutePath}': {e.Message}",
null, e.ToString() );
PrismLog.Guard( "Delete temp file", () =>
{
if ( File.Exists( temp ) ) File.Delete( temp );
} );
return false;
}
graph.AssetPath = absolutePath;
graph.IsDirty = false;
return true;
}
// ---------------------------------------------------------------- fragments ----
/// <summary>
/// Serialize a subset of a document: the given nodes, the edges strictly between them, and any
/// blackboard parameter one of them references. This is the clipboard and undo-fragment form.
/// </summary>
public static string WriteNodes( PrismGraph graph, IEnumerable<PrismNode> nodes )
{
var root = new JsonObject
{
[KeySchema] = PrismConstants.DocumentSchemaVersion,
[KeyKind] = "fragment"
};
if ( graph is null || nodes is null ) return root.ToJsonString( PrismJson.Options );
var selected = nodes.Where( x => x is not null ).ToArray();
var ids = new HashSet<NodeId>( selected.Select( x => x.Id ) );
var nodeArray = new JsonArray();
foreach ( var node in selected )
{
nodeArray.Add( WriteNode( graph, node ) );
}
root["nodes"] = nodeArray;
var edgeArray = new JsonArray();
foreach ( var edge in graph.Edges )
{
if ( !ids.Contains( edge.FromNode ) || !ids.Contains( edge.ToNode ) ) continue;
edgeArray.Add( WriteEdge( edge ) );
}
if ( edgeArray.Count > 0 ) root["edges"] = edgeArray;
// Carry any parameter whose id appears in a copied node's properties, so pasting into another
// document does not produce dangling parameter references.
var referenced = new JsonArray();
var payload = nodeArray.ToJsonString();
foreach ( var parameter in graph.Parameters )
{
if ( !payload.Contains( $"\"{parameter.Id.Value}\"", StringComparison.Ordinal ) ) continue;
referenced.Add( WriteParameter( parameter ) );
}
if ( referenced.Count > 0 ) root["parameters"] = referenced;
return root.ToJsonString( PrismJson.Options );
}
/// <summary>
/// Read a fragment into an existing document. Node ids are re-minted when
/// <paramref name="remapIds"/> is set (paste and duplicate) and preserved otherwise (undo restore
/// of a deleted selection). Edges inside the fragment are rewired to the new ids.
/// </summary>
public static IReadOnlyList<PrismNode> ReadNodes( PrismGraph graph, string json, bool remapIds,
DiagnosticSink sink = null )
{
var added = new List<PrismNode>();
if ( graph is null || string.IsNullOrWhiteSpace( json ) ) return added;
JsonObject root;
try
{
root = ParseObject( json );
}
catch ( Exception e )
{
sink?.Error( DiagnosticCode.SectionReadFailed, $"The fragment is not valid JSON: {e.Message}" );
return added;
}
if ( root is null ) return added;
// A fragment carries a schema stamp — WriteNodes writes one on every payload — so it has to be
// upgraded like any other document before it is read. ReadNode runs the per-node migrations, but
// a document-level one (a section rename, a reshaped edge) would otherwise be skipped for
// exactly the clipboard payload that was copied before the upgrade. Dormant at schema v1, and
// the sort of thing that only shows up once, silently, on the day v2 ships.
root = PrismLog.Guard( "Upgrading the fragment schema", () => SchemaMigrations.Upgrade( root, sink ) ) ?? root;
var idMap = new Dictionary<NodeId, NodeId>();
var paramMap = new Dictionary<string, string>( StringComparer.Ordinal );
if ( root["parameters"] is JsonArray parameters )
{
foreach ( var element in parameters )
{
if ( element is not JsonObject parameterJson ) continue;
var parameter = ReadParameter( parameterJson );
if ( parameter is null ) continue;
var existing = graph.FindParameter( parameter.Id );
if ( existing is not null ) continue;
var byName = graph.FindParameterByName( parameter.Name );
if ( byName is not null )
{
paramMap[parameter.Id.Value] = byName.Id.Value;
continue;
}
var oldId = parameter.Id.Value;
graph.AddParameter( parameter );
if ( parameter.Id.Value != oldId ) paramMap[oldId] = parameter.Id.Value;
}
}
if ( root["nodes"] is JsonArray nodes )
{
foreach ( var element in nodes )
{
if ( element is not JsonObject nodeJson )
{
sink?.Warn( DiagnosticCode.NodeReadFailed, "A fragment entry was not an object and was skipped" );
continue;
}
if ( paramMap.Count > 0 ) RemapParameterIds( nodeJson, paramMap );
var original = NodeId.Parse( ValueCodec.StringOf( nodeJson[KeyId] ) );
var node = ReadNode( graph, nodeJson, sink, remapIds );
if ( node is null ) continue;
if ( original.IsValid ) idMap[original] = node.Id;
added.Add( node );
}
}
foreach ( var node in added )
{
if ( node is UnknownNode unknown ) unknown.Rebuild();
}
if ( root["edges"] is JsonArray edges )
{
foreach ( var parsed in ParseEdges( edges, sink ) )
{
var edge = parsed.Edge;
if ( idMap.TryGetValue( edge.FromNode, out var from ) ) edge = edge with { FromNode = from };
if ( idMap.TryGetValue( edge.ToNode, out var to ) ) edge = edge with { ToNode = to };
edge = edge with { Id = graph.NewEdgeId() };
if ( graph.FindOutput( edge.From ) is null || graph.FindInput( edge.To ) is null ) continue;
graph.AddEdge( edge );
}
}
return added;
}
/// <summary>
/// Reconcile a live document against a snapshot, in place.
/// <para>
/// This is what makes undo non-destructive to the UI: a node whose id and type survive keeps its
/// object identity, so the card, its selection state and its widget tree are reused rather than
/// rebuilt. Only nodes that genuinely appeared or disappeared are added or removed.
/// </para>
/// </summary>
public static bool Restore( PrismGraph graph, string json, DiagnosticSink sink = null )
{
if ( graph is null || string.IsNullOrWhiteSpace( json ) ) return false;
JsonObject root;
try
{
root = ParseObject( json );
}
catch ( Exception e )
{
sink?.Error( DiagnosticCode.SectionReadFailed, $"The snapshot is not valid JSON: {e.Message}" );
return false;
}
if ( root is null ) return false;
root = SchemaMigrations.Upgrade( root, sink ) ?? root;
using ( graph.SuspendEvents() )
{
// Identity is part of the snapshot. A restore that left it alone would be a restore that
// produced a different document from the one a fresh read of the same text produces, and
// undo is only trustworthy because those two are the same operation.
var documentId = ValueCodec.StringOf( root[KeyId] );
if ( !string.IsNullOrEmpty( documentId ) ) graph.DocumentId = documentId;
// Kind is unconditional, because an absent one means "shader" rather than "keep what you are".
graph.IsSubgraph = string.Equals( ValueCodec.StringOf( root[KeyKind] ),
PrismConstants.DocumentKindSubgraph, StringComparison.OrdinalIgnoreCase );
var wanted = new Dictionary<NodeId, JsonObject>();
var order = new List<NodeId>();
if ( root["nodes"] is JsonArray nodeArray )
{
foreach ( var element in nodeArray )
{
if ( element is not JsonObject nodeJson ) continue;
var id = NodeId.Parse( ValueCodec.StringOf( nodeJson[KeyId] ) );
if ( !id.IsValid || wanted.ContainsKey( id ) ) continue;
wanted[id] = nodeJson;
order.Add( id );
}
}
foreach ( var node in graph.Nodes.ToArray() )
{
if ( wanted.ContainsKey( node.Id ) && SameType( node, wanted[node.Id] ) ) continue;
graph.RemoveNode( node );
}
foreach ( var edge in graph.Edges.ToArray() )
{
graph.RemoveEdge( edge.Id );
}
foreach ( var ghost in graph.BrokenEdges.ToArray() )
{
graph.RemoveBrokenEdge( ghost.Id );
}
foreach ( var id in order )
{
var live = graph.FindNode( id );
if ( live is not null )
{
ApplyNode( graph, live, wanted[id], sink );
continue;
}
ReadNode( graph, wanted[id], sink, false );
}
// Restore document order exactly as the snapshot had it.
ReorderNodes( graph, order );
var edges = ParseEdges( root["edges"] as JsonArray, sink );
foreach ( var node in graph.Nodes )
{
if ( node is UnknownNode unknown ) unknown.RebuildFromEdges( edges.Select( x => x.Edge ) );
}
ResolveEdges( graph, edges, sink );
ReplaceParameters( graph, root["parameters"] as JsonArray, sink );
ReplaceKeywords( graph, root["keywords"] as JsonArray, sink );
ReplaceGroups( graph, root["groups"] as JsonArray, sink );
ReplaceNotes( graph, root["notes"] as JsonArray, sink );
ReadMeta( graph, root["meta"] as JsonObject );
ReadSettings( graph, root["settings"] as JsonObject, sink );
ReadPreview( graph, root["preview"] as JsonObject );
ReadView( graph, root["view"] as JsonObject );
graph.X = ExtraBag( root, s_documentKeys );
}
return true;
}
// ---------------------------------------------------------------- DTO bridge ----
/// <summary>
/// A typed view of a document, for callers that prefer the DTO records to raw JSON. Produced from
/// the canonical text form, so it can never disagree with what a save would write.
/// </summary>
public static PrismDocument ToDocument( PrismGraph graph ) =>
PrismLog.Guard<PrismDocument>( "Build PrismDocument",
() => JsonSerializer.Deserialize<PrismDocument>( Write( graph ), PrismJson.Options ) );
/// <summary>Build a document from the typed DTO view.</summary>
public static PrismGraph FromDocument( PrismDocument document, DiagnosticSink sink = null )
{
if ( document is null ) return null;
var json = PrismLog.Guard<string>( "Serialize PrismDocument",
() => JsonSerializer.Serialize( document, PrismJson.Options ) );
return string.IsNullOrEmpty( json ) ? null : Read( json, sink );
}
// ---------------------------------------------------------------- sections ----
/// <summary>
/// Apply the <c>meta</c> section. Every field is assigned even when the section is absent, because
/// this also runs against a live document during an undo restore: a section the snapshot does not
/// carry means "there is none", not "keep the one you have".
/// </summary>
static void ReadMeta( PrismGraph graph, JsonObject json )
{
graph.Meta ??= new GraphMeta();
json ??= new JsonObject();
// An empty string is normalised to null throughout, because the writer omits empty values: without
// this, `"title": ""` would survive one write and vanish on the next, and a document that changes
// on every save is a document that shows up dirty in source control forever.
graph.Meta.Title = Text( json["title"] );
graph.Meta.Description = Text( json["description"] );
graph.Meta.Category = Text( json["category"] );
graph.Meta.Icon = Text( json["icon"] );
graph.Meta.Author = Text( json["author"] );
graph.Meta.Created = ParseTime( json["created"] );
graph.Meta.Modified = ParseTime( json["modified"] );
graph.Meta.EditorVersion = Text( json["editorVersion"] ) ?? PrismConstants.EditorVersion;
}
static JsonObject WriteMeta( GraphMeta meta )
{
var json = new JsonObject();
if ( meta is null ) return json;
if ( !string.IsNullOrEmpty( meta.Title ) ) json["title"] = meta.Title;
if ( !string.IsNullOrEmpty( meta.Description ) ) json["description"] = meta.Description;
if ( !string.IsNullOrEmpty( meta.Category ) ) json["category"] = meta.Category;
if ( !string.IsNullOrEmpty( meta.Icon ) ) json["icon"] = meta.Icon;
if ( !string.IsNullOrEmpty( meta.Author ) ) json["author"] = meta.Author;
if ( meta.Created is { } created ) json["created"] = FormatTime( created );
if ( meta.Modified is { } modified ) json["modified"] = FormatTime( modified );
if ( !string.IsNullOrEmpty( meta.EditorVersion ) ) json["editorVersion"] = meta.EditorVersion;
return json;
}
/// <inheritdoc cref="ReadMeta"/>
static void ReadSettings( PrismGraph graph, JsonObject json, DiagnosticSink sink )
{
graph.Settings ??= new GraphSettings();
json ??= new JsonObject();
var settings = graph.Settings;
settings.Domain = ReadEnum( json["domain"], ShaderDomain.Surface );
settings.ShadingModel = ReadEnum( json["shadingModel"], ShadingModel.Lit );
settings.BlendMode = ReadEnum( json["blendMode"], SurfaceBlendMode.Opaque );
settings.CullMode = ReadEnum( json["cullMode"], CullMode.Back );
settings.HlslDialect = ReadEnum( json["hlslDialect"], HlslDialect.SboxSlang );
settings.Modes = ReadStrings( json["modes"] ) ?? new List<string>( GraphSettings.DefaultModes );
settings.Targets = ReadStrings( json["targets"] ) ?? new List<string> { PrismConstants.BackendHlsl };
settings.Uv2 = ReadBool( json["uv2"] );
settings.RenderBackfaces = ReadBool( json["renderBackfaces"] );
settings.DebugSymbols = ReadBool( json["debugSymbols"] );
settings.StrictTypes = ReadBool( json["strictTypes"] );
settings.X = ExtraBag( json, s_settingsKeys );
settings.Normalize( sink );
}
static JsonObject WriteSettings( GraphSettings settings )
{
var json = new JsonObject
{
["domain"] = settings.Domain.ToString(),
["shadingModel"] = settings.ShadingModel.ToString(),
["blendMode"] = settings.BlendMode.ToString(),
["cullMode"] = settings.CullMode.ToString(),
["modes"] = WriteStrings( settings.Modes ),
["targets"] = WriteStrings( settings.Targets ),
["hlslDialect"] = settings.HlslDialect.ToString(),
["uv2"] = settings.Uv2,
["renderBackfaces"] = settings.RenderBackfaces,
["debugSymbols"] = settings.DebugSymbols,
["strictTypes"] = settings.StrictTypes
};
MergeExtra( json, settings.X );
return json;
}
/// <inheritdoc cref="ReadMeta"/>
static void ReadPreview( PrismGraph graph, JsonObject json )
{
graph.Preview ??= new PreviewState();
json ??= new JsonObject();
var preview = graph.Preview;
var defaults = new PreviewState();
preview.Mesh = Text( json["mesh"] ) ?? defaults.Mesh;
preview.Model = Text( json["model"] );
preview.Envmap = Text( json["envmap"] );
preview.ShowGround = ReadBool( json["showGround"] );
preview.ShowSkybox = ReadBool( json["showSkybox"], true );
preview.Channel = Text( json["channel"] );
preview.Tint = ValueCodec.TryParseColor( ValueCodec.StringOf( json["tint"] ), out var tint )
? tint
: defaults.Tint;
preview.Background = ValueCodec.TryParseColor( ValueCodec.StringOf( json["background"] ), out var background )
? background
: defaults.Background;
var camera = json["camera"] as JsonObject ?? new JsonObject();
preview.Camera ??= new PreviewCamera();
preview.Camera.Yaw = Geometry( ValueCodec.NumberOf( camera["yaw"], defaults.Camera.Yaw ), defaults.Camera.Yaw );
preview.Camera.Pitch = Geometry( ValueCodec.NumberOf( camera["pitch"], defaults.Camera.Pitch ),
defaults.Camera.Pitch );
preview.Camera.Distance = Geometry( ValueCodec.NumberOf( camera["distance"], defaults.Camera.Distance ),
defaults.Camera.Distance );
}
static JsonObject WritePreview( PreviewState preview )
{
var camera = preview.Camera ?? new PreviewCamera();
var json = new JsonObject
{
["mesh"] = preview.Mesh,
["model"] = preview.Model,
["envmap"] = preview.Envmap,
["showGround"] = preview.ShowGround,
["showSkybox"] = preview.ShowSkybox,
["tint"] = ValueCodec.FormatColor( preview.Tint ),
["background"] = ValueCodec.FormatColor( preview.Background ),
["camera"] = new JsonObject
{
["yaw"] = ValueCodec.Number( camera.Yaw ),
["pitch"] = ValueCodec.Number( camera.Pitch ),
["distance"] = ValueCodec.Number( camera.Distance )
}
};
if ( !string.IsNullOrEmpty( preview.Channel ) ) json["channel"] = preview.Channel;
return json;
}
static void ReadParameters( PrismGraph graph, JsonArray array, DiagnosticSink sink )
{
if ( array is null ) return;
foreach ( var element in array )
{
if ( element is not JsonObject json )
{
sink?.Warn( DiagnosticCode.SectionReadFailed, "A parameter entry was not an object and was skipped" );
continue;
}
var parameter = PrismLog.Guard<Parameter>( "Read parameter", () => ReadParameter( json ) );
if ( parameter is null )
{
sink?.Warn( DiagnosticCode.SectionReadFailed, "A parameter could not be read and was skipped" );
continue;
}
graph.AddParameter( parameter );
}
}
static Parameter ReadParameter( JsonObject json )
{
if ( json is null ) return null;
var typeText = ValueCodec.StringOf( json["type"] ) ?? "float";
if ( !ShaderType.TryParse( typeText, out var type ) ) type = ShaderType.Float;
var parameter = new Parameter
{
Id = ParamId.Parse( ValueCodec.StringOf( json[KeyId] ) ),
Name = ValueCodec.StringOf( json["name"] ) ?? "Parameter",
Type = type,
AttributeName = ValueCodec.StringOf( json["attribute"] ),
X = ExtraBag( json, s_parameterKeys )
};
if ( !parameter.Id.IsValid ) parameter.Id = ParamId.New();
parameter.Default = json["default"] is { } defaultNode
? ValueCodec.Read( type, defaultNode )
: ValueCodec.Default( type );
if ( json["ui"] is JsonObject ui )
{
parameter.Ui = new ParameterUi
{
Control = ReadEnum( ui["control"], UiControl.Default ),
Min = ReadNullableFloat( ui["min"] ),
Max = ReadNullableFloat( ui["max"] ),
Step = ReadNullableFloat( ui["step"] ),
Group = ValueCodec.StringOf( ui["group"] ),
Order = (int)ValueCodec.NumberOf( ui["order"] ),
Tooltip = ValueCodec.StringOf( ui["tooltip"] ),
Options = ReadStrings( ui["options"] )
};
}
return parameter;
}
static JsonObject WriteParameter( Parameter parameter )
{
var json = new JsonObject
{
[KeyId] = parameter.Id.Value,
["name"] = parameter.Name,
["type"] = parameter.Type.ToString()
};
var value = ValueCodec.Write( parameter.Type, parameter.Default );
if ( value is not null ) json["default"] = value;
if ( !string.IsNullOrEmpty( parameter.AttributeName ) ) json["attribute"] = parameter.AttributeName;
var ui = parameter.Ui;
if ( ui is not null && !ui.IsEmpty )
{
var uiJson = new JsonObject();
if ( ui.Control != UiControl.Default ) uiJson["control"] = ui.Control.ToString();
if ( ui.Min is { } min ) uiJson["min"] = ValueCodec.Number( min );
if ( ui.Max is { } max ) uiJson["max"] = ValueCodec.Number( max );
if ( ui.Step is { } step ) uiJson["step"] = ValueCodec.Number( step );
if ( !string.IsNullOrEmpty( ui.Group ) ) uiJson["group"] = ui.Group;
if ( ui.Order != 0 ) uiJson["order"] = ui.Order;
if ( !string.IsNullOrEmpty( ui.Tooltip ) ) uiJson["tooltip"] = ui.Tooltip;
if ( ui.Options is { Count: > 0 } ) uiJson["options"] = WriteStrings( ui.Options );
json["ui"] = uiJson;
}
MergeExtra( json, parameter.X );
return json;
}
static void ReadKeywords( PrismGraph graph, JsonArray array, DiagnosticSink sink )
{
if ( array is null ) return;
foreach ( var element in array )
{
if ( element is not JsonObject json )
{
sink?.Warn( DiagnosticCode.SectionReadFailed, "A keyword entry was not an object and was skipped" );
continue;
}
var keyword = new Keyword
{
Id = ParamId.Parse( ValueCodec.StringOf( json[KeyId] ) ),
Name = ValueCodec.StringOf( json["name"] ) ?? "F_KEYWORD",
Kind = ReadEnum( json["kind"], ComboKind.Feature ),
Values = ReadStrings( json["values"] ) ?? new List<string> { "Off", "On" },
Default = (int)ValueCodec.NumberOf( json["default"] ),
Group = ValueCodec.StringOf( json["group"] )
};
if ( !keyword.Id.IsValid ) keyword.Id = ParamId.New();
graph.AddKeyword( keyword );
}
}
static JsonObject WriteKeyword( Keyword keyword )
{
var json = new JsonObject
{
[KeyId] = keyword.Id.Value,
["name"] = keyword.Name,
["kind"] = keyword.Kind.ToString(),
["values"] = WriteStrings( keyword.Values )
};
if ( keyword.Default != 0 ) json["default"] = keyword.Default;
if ( !string.IsNullOrEmpty( keyword.Group ) ) json["group"] = keyword.Group;
return json;
}
static void ReadNodes( PrismGraph graph, JsonArray array, DiagnosticSink sink )
{
if ( array is null ) return;
foreach ( var element in array )
{
if ( element is not JsonObject json )
{
sink?.Warn( DiagnosticCode.NodeReadFailed, "A node entry was not an object and was skipped" );
continue;
}
ReadNode( graph, json, sink, false );
}
}
static PrismNode ReadNode( PrismGraph graph, JsonObject json, DiagnosticSink sink, bool remapId )
{
var id = NodeId.Parse( ValueCodec.StringOf( json[KeyId] ) );
var rawType = ValueCodec.StringOf( json["type"] ) ?? string.Empty;
var version = ReadVersion( json["v"] );
var resolvedType = NodeMigrations.ResolveTypeId( rawType );
var registered = NodeRegistry.Find( resolvedType );
PrismNode node = null;
var applied = json;
if ( registered is not null )
{
applied = NodeMigrations.Upgrade( json.DeepClone() as JsonObject, registered.Id, version,
registered.Descriptor.Version, sink ) ?? json;
node = registered.Create();
if ( node is null )
{
sink?.Error( DiagnosticCode.NodeReadFailed,
$"Node type '{resolvedType}' could not be constructed; its data was preserved as a placeholder",
GraphRef.ForNode( id ) );
}
}
if ( node is null )
{
node = new UnknownNode( rawType, json, version )
{
IsMissingPlugin = registered is null,
Reason = registered is null
? "The node type is not registered. Install the addon that provides it and reopen the graph."
: "The node type is registered but could not be constructed."
};
if ( registered is null && !string.IsNullOrEmpty( rawType ) )
{
sink?.Warn( DiagnosticCode.UnknownNodeType,
$"Unknown node type '{rawType}'; it will be preserved exactly as written",
GraphRef.ForNode( id ) );
}
}
if ( id.IsValid && !remapId )
{
if ( graph.FindNode( id ) is not null )
{
sink?.Warn( DiagnosticCode.DuplicateNodeId,
$"Two nodes share the id '{id}'; the second was given a new one", GraphRef.ForNode( id ) );
}
else
{
node.Id = id;
}
}
graph.AddNode( node );
ApplyNode( graph, node, applied, sink );
return node;
}
static void ApplyNode( PrismGraph graph, PrismNode node, JsonObject json, DiagnosticSink sink )
{
if ( node is null || json is null ) return;
node.Position = ReadVector2( json["pos"] );
node.Flags = ReadFlags( json["flags"] as JsonObject );
if ( json["size"] is JsonArray ) graph.SetNodeSize( node.Id, ReadVector2( json["size"] ) );
else graph.SetNodeSize( node.Id, null );
if ( node is UnknownNode unknown )
{
unknown.Raw = json.DeepClone() as JsonObject;
unknown.TypeVersion = ReadVersion( json["v"], unknown.TypeVersion );
return;
}
graph.SetNodeExtra( node.Id, json["x"] as JsonObject );
// The node object may be a live one being reconciled against a snapshot rather than a fresh one,
// so anything the document does not mention has to go back to the default first.
NodeProperties.ResetProperties( node );
NodeProperties.Read( node, json["props"] as JsonObject, sink );
NodeProperties.RebuildPorts( node );
// A document only records a literal that differs from the type's default, so anything the
// document does not mention has to go back to the default before the recorded ones are applied.
NodeProperties.ResetInline( node );
NodeProperties.SyncInlineFromProperties( node );
if ( json["inline"] is JsonObject inline )
{
foreach ( var pair in inline )
{
var portId = PortId.Parse( pair.Key );
if ( !NodeMigrations.ResolvePort( node, portId, PortDirection.Input, out var resolved ) )
{
sink?.Warn( DiagnosticCode.NodeReadFailed,
$"Inline value for '{pair.Key}' has no matching port and was dropped",
GraphRef.ForNode( node.Id ) );
continue;
}
var port = node.FindInput( resolved );
if ( port is null ) continue;
var value = port.Def.IsGeneric
? ValueCodec.ReadUntyped( pair.Value )
: ValueCodec.Read( port.Def.FixedType, pair.Value );
NodeProperties.SetInline( node, port, value );
}
}
}
static JsonObject WriteNode( PrismGraph graph, PrismNode node )
{
if ( node is UnknownNode unknown )
{
var raw = unknown.ToJson();
raw[KeyId] = node.Id.Value;
raw["pos"] = ValueCodec.Vector( node.Position.x, node.Position.y );
// Position and size both live on the graph's side tables rather than on the node, so both
// have to be re-stamped onto the verbatim bag. Writing one and not the other lost a manual
// resize of a missing-plugin node on every save — and, because undo round-trips through this
// same writer, on every undo too.
if ( graph?.GetNodeSize( node.Id ) is { } unknownSize )
{
raw["size"] = ValueCodec.Vector( unknownSize.x, unknownSize.y );
}
else
{
raw.Remove( "size" );
}
return raw;
}
var descriptor = node.Descriptor;
var json = new JsonObject
{
[KeyId] = node.Id.Value,
["type"] = descriptor?.Id ?? node.GetType().FullName,
["v"] = descriptor?.Version ?? 1,
["pos"] = ValueCodec.Vector( node.Position.x, node.Position.y )
};
if ( graph?.GetNodeSize( node.Id ) is { } size )
{
json["size"] = ValueCodec.Vector( size.x, size.y );
}
json["props"] = NodeProperties.Write( node );
var inline = WriteInline( node );
if ( inline.Count > 0 ) json["inline"] = inline;
var flags = WriteFlags( node.Flags );
if ( flags.Count > 0 ) json["flags"] = flags;
MergeExtra( json, graph?.GetNodeExtra( node.Id ) );
return json;
}
static JsonObject WriteInline( PrismNode node )
{
var json = new JsonObject();
var prototype = NodeProperties.Prototype( node.GetType() );
foreach ( var port in node.Inputs )
{
var value = NodeProperties.GetInline( node, port );
if ( value is null ) continue;
// Only write a literal that differs from what a fresh node of this type would have, so
// documents stay small and a default change in a later version is picked up.
var bound = port.Def?.InlineValueProperty;
if ( prototype is not null && !string.IsNullOrEmpty( bound ) )
{
var original = NodeProperties.Get( prototype, bound );
if ( ValueCodec.Equal( original, value ) ) continue;
}
var encoded = port.Def.IsGeneric
? ValueCodec.Write( value )
: ValueCodec.Write( port.Def.FixedType, value );
if ( encoded is null ) continue;
json[port.Id.Value] = encoded;
}
return json;
}
static NodeFlags ReadFlags( JsonObject json )
{
if ( json is null ) return NodeFlags.None;
var flags = NodeFlags.None;
if ( ReadBool( json["preview"] ) ) flags |= NodeFlags.Preview;
if ( ReadBool( json["collapsed"] ) ) flags |= NodeFlags.Collapsed;
if ( ReadBool( json["disabled"] ) ) flags |= NodeFlags.Disabled;
if ( ReadBool( json["pinned"] ) ) flags |= NodeFlags.Pinned;
return flags;
}
static JsonObject WriteFlags( NodeFlags flags )
{
var json = new JsonObject();
if ( ( flags & NodeFlags.Preview ) != 0 ) json["preview"] = true;
if ( ( flags & NodeFlags.Collapsed ) != 0 ) json["collapsed"] = true;
if ( ( flags & NodeFlags.Disabled ) != 0 ) json["disabled"] = true;
if ( ( flags & NodeFlags.Pinned ) != 0 ) json["pinned"] = true;
return json;
}
readonly record struct ParsedEdge( Edge Edge, JsonObject Source );
static List<ParsedEdge> ParseEdges( JsonArray array, DiagnosticSink sink )
{
var result = new List<ParsedEdge>();
if ( array is null ) return result;
foreach ( var element in array )
{
if ( element is not JsonObject json )
{
sink?.Warn( DiagnosticCode.EdgeReadFailed, "A connection entry was not an object and was skipped" );
continue;
}
var from = json["from"] as JsonObject;
var to = json["to"] as JsonObject;
if ( from is null || to is null )
{
sink?.Warn( DiagnosticCode.EdgeReadFailed, "A connection is missing an endpoint and was skipped" );
continue;
}
var edge = new Edge(
EdgeId.Parse( ValueCodec.StringOf( json[KeyId] ) ),
NodeId.Parse( ValueCodec.StringOf( from["node"] ) ),
PortId.Parse( ValueCodec.StringOf( from["port"] ) ),
NodeId.Parse( ValueCodec.StringOf( to["node"] ) ),
PortId.Parse( ValueCodec.StringOf( to["port"] ) ),
ReadVia( json["via"] as JsonArray ) )
{
Fill = ReadNullableFloat( json["fill"] )
};
if ( !edge.Id.IsValid ) edge = edge with { Id = EdgeId.New() };
if ( !edge.IsValid )
{
sink?.Warn( DiagnosticCode.EdgeReadFailed, "A connection had an empty endpoint and was skipped",
GraphRef.ForEdge( edge.Id ) );
continue;
}
result.Add( new ParsedEdge( edge, json ) );
}
return result;
}
static void ResolveEdges( PrismGraph graph, List<ParsedEdge> edges, DiagnosticSink sink )
{
foreach ( var parsed in edges )
{
var edge = parsed.Edge;
var fromNode = graph.FindNode( edge.FromNode );
var toNode = graph.FindNode( edge.ToNode );
if ( fromNode is null )
{
Break( graph, sink, edge, BrokenEdgeReason.MissingFromNode,
$"No node with id '{edge.FromNode}' exists in this document." );
continue;
}
if ( toNode is null )
{
Break( graph, sink, edge, BrokenEdgeReason.MissingToNode,
$"No node with id '{edge.ToNode}' exists in this document." );
continue;
}
if ( NodeMigrations.ResolvePort( fromNode, edge.FromPort, PortDirection.Output, out var fromPort ) )
{
edge = edge with { FromPort = fromPort };
}
else
{
Break( graph, sink, edge, BrokenEdgeReason.MissingFromPort,
$"'{edge.FromPort}' is not an output of that node." );
continue;
}
if ( NodeMigrations.ResolvePort( toNode, edge.ToPort, PortDirection.Input, out var toPort ) )
{
edge = edge with { ToPort = toPort };
}
else
{
Break( graph, sink, edge, BrokenEdgeReason.MissingToPort,
$"'{edge.ToPort}' is not an input of that node." );
continue;
}
graph.AddEdge( edge );
}
}
static void Break( PrismGraph graph, DiagnosticSink sink, Edge edge, BrokenEdgeReason reason, string detail )
{
graph.AddBrokenEdge( edge, reason, detail );
sink?.Warn( DiagnosticCode.DanglingEdge,
$"Connection {edge} could not be resolved and is shown as a broken link",
GraphRef.ForEdge( edge.Id ), detail );
}
static JsonObject WriteEdge( Edge edge )
{
var json = new JsonObject
{
[KeyId] = edge.Id.Value,
["from"] = new JsonObject { ["node"] = edge.FromNode.Value, ["port"] = edge.FromPort.Value },
["to"] = new JsonObject { ["node"] = edge.ToNode.Value, ["port"] = edge.ToPort.Value }
};
if ( edge.Via is { Length: > 0 } )
{
var via = new JsonArray();
foreach ( var point in edge.Via )
{
via.Add( ValueCodec.Vector( point.x, point.y ) );
}
json["via"] = via;
}
if ( edge.Fill is { } fill ) json["fill"] = ValueCodec.Number( fill );
return json;
}
static void ReadGroups( PrismGraph graph, JsonArray array, DiagnosticSink sink )
{
if ( array is null ) return;
foreach ( var element in array )
{
if ( element is not JsonObject json )
{
sink?.Warn( DiagnosticCode.SectionReadFailed, "A group entry was not an object and was skipped" );
continue;
}
var rect = ReadFloats( json["rect"] );
var group = new GraphGroup
{
Id = ValueCodec.StringOf( json[KeyId] ) ?? Ids.NewShortId(),
Title = ValueCodec.StringOf( json["title"] ) ?? "Group",
Description = ValueCodec.StringOf( json["description"] ),
Position = new Vector2( At( rect, 0 ), At( rect, 1 ) ),
Size = new Vector2( At( rect, 2, 320f ), At( rect, 3, 200f ) ),
Color = ValueCodec.StringOf( json["color"] ) ?? "Blue",
Layer = (int)ValueCodec.NumberOf( json["layer"] )
};
graph.AddGroup( group );
}
}
static JsonObject WriteGroup( GraphGroup group )
{
var json = new JsonObject
{
[KeyId] = group.Id,
["title"] = group.Title
};
if ( !string.IsNullOrEmpty( group.Description ) ) json["description"] = group.Description;
json["rect"] = ValueCodec.Vector( group.Position.x, group.Position.y, group.Size.x, group.Size.y );
json["color"] = group.Color;
if ( group.Layer != 0 ) json["layer"] = group.Layer;
return json;
}
static void ReadNotes( PrismGraph graph, JsonArray array, DiagnosticSink sink )
{
if ( array is null ) return;
foreach ( var element in array )
{
if ( element is not JsonObject json )
{
sink?.Warn( DiagnosticCode.SectionReadFailed, "A note entry was not an object and was skipped" );
continue;
}
var note = new StickyNote
{
Id = ValueCodec.StringOf( json[KeyId] ) ?? Ids.NewShortId(),
Position = ReadVector2( json["pos"] ),
Size = json["size"] is null ? StickyNote.DefaultSize : ReadVector2( json["size"] ),
Color = ValueCodec.StringOf( json["color"] ) ?? "Yellow",
Text = ValueCodec.StringOf( json["text"] ) ?? string.Empty
};
graph.AddNote( note );
}
}
static JsonObject WriteNote( StickyNote note ) => new()
{
[KeyId] = note.Id,
["pos"] = ValueCodec.Vector( note.Position.x, note.Position.y ),
["size"] = ValueCodec.Vector( note.Size.x, note.Size.y ),
["color"] = note.Color,
["text"] = note.Text
};
/// <inheritdoc cref="ReadMeta"/>
static void ReadView( PrismGraph graph, JsonObject json )
{
graph.View ??= new ViewState();
json ??= new JsonObject();
graph.View.Center = ReadVector2( json["center"] );
graph.View.Zoom = Geometry( ValueCodec.NumberOf( json["zoom"], 1f ), 1f );
}
static void ReplaceParameters( PrismGraph graph, JsonArray array, DiagnosticSink sink )
{
foreach ( var parameter in graph.Parameters.ToArray() )
{
graph.RemoveParameter( parameter.Id );
}
ReadParameters( graph, array, sink );
}
static void ReplaceKeywords( PrismGraph graph, JsonArray array, DiagnosticSink sink )
{
foreach ( var keyword in graph.Keywords.ToArray() )
{
graph.RemoveKeyword( keyword.Id );
}
ReadKeywords( graph, array, sink );
}
static void ReplaceGroups( PrismGraph graph, JsonArray array, DiagnosticSink sink )
{
foreach ( var group in graph.Groups.ToArray() )
{
graph.RemoveGroup( group.Id );
}
ReadGroups( graph, array, sink );
}
static void ReplaceNotes( PrismGraph graph, JsonArray array, DiagnosticSink sink )
{
foreach ( var note in graph.Notes.ToArray() )
{
graph.RemoveNote( note.Id );
}
ReadNotes( graph, array, sink );
}
// ---------------------------------------------------------------- helpers ----
static bool SameType( PrismNode node, JsonObject json )
{
var wanted = NodeMigrations.ResolveTypeId( ValueCodec.StringOf( json["type"] ) ?? string.Empty );
var actual = node switch
{
UnknownNode unknown => unknown.TypeId,
_ => node.Descriptor?.Id
};
return string.Equals( wanted, actual, StringComparison.OrdinalIgnoreCase );
}
static void ReorderNodes( PrismGraph graph, IReadOnlyList<NodeId> wanted )
{
if ( wanted.Count != graph.Nodes.Count ) return;
var ordered = new List<PrismNode>( wanted.Count );
foreach ( var id in wanted )
{
var node = graph.FindNode( id );
if ( node is null ) return;
ordered.Add( node );
}
graph.ReorderNodes( ordered );
}
/// <summary>
/// Rewrite every reference to a parameter that was renumbered on paste.
/// <para>
/// A blackboard reference is a <see cref="ParamId"/>-typed property, which serializes as a bare id
/// string (see <c>PrismJson</c>), so this is a string rewrite by value. It recurses through nested
/// objects and arrays because a node may keep a table of them — a texture array's per-slice
/// parameter, say — rather than one property per reference.
/// </para>
/// </summary>
static void RemapParameterIds( JsonObject node, Dictionary<string, string> map )
{
if ( node["props"] is not JsonObject props ) return;
RemapIdStrings( props, map, 0 );
}
static void RemapIdStrings( JsonNode container, Dictionary<string, string> map, int depth )
{
// Documents are shallow; the cap is only there so a hand-edited file cannot spin this forever.
if ( container is null || depth > 16 ) return;
switch ( container )
{
case JsonObject obj:
{
foreach ( var key in obj.Select( x => x.Key ).ToArray() )
{
if ( Replace( obj[key], map ) is { } replacement )
{
obj[key] = replacement;
continue;
}
RemapIdStrings( obj[key], map, depth + 1 );
}
break;
}
case JsonArray array:
{
for ( int i = 0; i < array.Count; i++ )
{
if ( Replace( array[i], map ) is { } replacement )
{
array[i] = replacement;
continue;
}
RemapIdStrings( array[i], map, depth + 1 );
}
break;
}
}
}
static string Replace( JsonNode node, Dictionary<string, string> map )
{
if ( node is not JsonValue value ) return null;
if ( !value.TryGetValue<string>( out var text ) || string.IsNullOrEmpty( text ) ) return null;
return map.TryGetValue( text, out var replacement ) ? replacement : null;
}
static DateTimeOffset? ParseTime( JsonNode node )
{
var text = ValueCodec.StringOf( node );
if ( string.IsNullOrWhiteSpace( text ) ) return null;
return DateTimeOffset.TryParse( text, CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed )
? parsed
: null;
}
static string FormatTime( DateTimeOffset time ) =>
time.ToUniversalTime().ToString( "yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture );
static T ReadEnum<T>( JsonNode node, T fallback ) where T : struct, Enum
{
var text = ValueCodec.StringOf( node );
if ( !string.IsNullOrEmpty( text ) && Enum.TryParse<T>( text, true, out var parsed ) ) return parsed;
// A numeric enum has to survive whatever CLR type is behind the JSON value, so it goes through
// the same tolerant number reader as everything else rather than asking for int specifically.
if ( ValueCodec.IsNumber( node ) )
{
var number = ValueCodec.NumberOf( node );
if ( float.IsFinite( number ) && number == (int)number && Enum.IsDefined( typeof( T ), (int)number ) )
{
return (T)Enum.ToObject( typeof( T ), (int)number );
}
}
return fallback;
}
static bool ReadBool( JsonNode node, bool fallback = false )
{
if ( node is not JsonValue value ) return fallback;
if ( value.TryGetValue<bool>( out var b ) ) return b;
if ( value.TryGetValue<string>( out var s ) ) return bool.TryParse( s, out var parsed ) ? parsed : fallback;
if ( ValueCodec.IsNumber( value ) ) return ValueCodec.NumberOf( value ) != 0f;
return fallback;
}
static float? ReadNullableFloat( JsonNode node )
{
if ( node is not JsonValue ) return null;
var value = ValueCodec.NumberOf( node );
// A non-finite bound or pad fill is not a value the editor can do anything with: it would go
// straight into generated code as `float3( v, NaN )`. Treat it as "not specified".
return float.IsFinite( value ) ? value : null;
}
/// <summary>
/// A coordinate the editor can actually draw with.
/// <para>
/// <c>NaN</c> and the infinities are legal in a shader literal and are round-tripped as such by
/// <see cref="ValueCodec"/>, but as <em>geometry</em> they are poison: a card at NaN is invisible
/// and unselectable, and one of them turns every bounding box that unions it — fit-to-view, the
/// minimap, marquee selection, group auto-size — into NaN as well. A corrupt coordinate therefore
/// degrades to the origin, where the user can find the node and move it.
/// </para>
/// </summary>
static float Geometry( float value, float fallback = 0f ) => float.IsFinite( value ) ? value : fallback;
/// <summary>
/// A string field, with the empty string normalised to null. The writer omits empty values, so
/// without this an empty string read back as a present-but-empty key and the next write dropped it —
/// making the document change every time it was saved.
/// </summary>
/// <summary>
/// Drop a byte-order mark and any leading whitespace before the JSON starts.
/// <para>
/// <c>JsonNode.Parse( string )</c> rejects a leading U+FEFF outright, so a document that any other
/// editor saved with a BOM would otherwise fail to open at all — with "not valid JSON", pointing at
/// character zero, which is exactly as unhelpful as it sounds. Text arrives here from the clipboard,
/// from the asset system and from autosave recovery as well as from our own writer, and only the
/// last of those is guaranteed BOM-free.
/// </para>
/// </summary>
static string StripPreamble( string json )
{
const char ByteOrderMark = (char)0xFEFF;
const char ZeroWidthSpace = (char)0x200B;
if ( string.IsNullOrEmpty( json ) ) return json;
var start = 0;
while ( start < json.Length && ( json[start] == ByteOrderMark || json[start] == ZeroWidthSpace ||
char.IsWhiteSpace( json[start] ) ) )
{
start++;
}
return start == 0 ? json : json[start..];
}
static string Text( JsonNode node )
{
var value = ValueCodec.StringOf( node );
return string.IsNullOrEmpty( value ) ? null : value;
}
/// <summary>
/// The node-type version a document declares, clamped to something a build could plausibly have
/// written. A corrupt <c>v</c> — a float that cast to <see cref="int.MinValue"/>, say — would
/// otherwise be walked one version at a time by the migration chain.
/// </summary>
static int ReadVersion( JsonNode node, int fallback = 1 )
{
var raw = ValueCodec.NumberOf( node, fallback );
if ( !float.IsFinite( raw ) ) return fallback;
return (int)Math.Clamp( raw, 0f, 1_000_000f );
}
static List<string> ReadStrings( JsonNode node )
{
if ( node is not JsonArray array ) return null;
var result = new List<string>( array.Count );
foreach ( var element in array )
{
var text = ValueCodec.StringOf( element );
if ( text is null ) continue;
result.Add( text );
}
return result;
}
static JsonArray WriteStrings( IEnumerable<string> values )
{
var array = new JsonArray();
foreach ( var value in values ?? Array.Empty<string>() )
{
array.Add( JsonValue.Create( value ) );
}
return array;
}
static float[] ReadFloats( JsonNode node )
{
if ( node is not JsonArray array ) return Array.Empty<float>();
var result = new float[array.Count];
for ( int i = 0; i < array.Count; i++ )
{
result[i] = Geometry( ValueCodec.NumberOf( array[i] ) );
}
return result;
}
static Vector2 ReadVector2( JsonNode node )
{
var values = ReadFloats( node );
return new Vector2( At( values, 0 ), At( values, 1 ) );
}
static Vector2[] ReadVia( JsonArray array )
{
if ( array is null || array.Count == 0 ) return null;
var result = new List<Vector2>( array.Count );
foreach ( var element in array )
{
result.Add( ReadVector2( element ) );
}
return result.ToArray();
}
static float At( float[] values, int index, float fallback = 0f ) =>
values is not null && index < values.Length ? values[index] : fallback;
static JsonObject ExtraBag( JsonObject source, string[] known )
{
if ( source is null ) return null;
JsonObject bag = null;
if ( source["x"] is JsonObject declared ) bag = declared.DeepClone() as JsonObject;
foreach ( var pair in source )
{
if ( pair.Key == "x" ) continue;
if ( known.Contains( pair.Key, StringComparer.Ordinal ) ) continue;
bag ??= new JsonObject();
bag[pair.Key] = pair.Value?.DeepClone();
}
return bag;
}
static void MergeExtra( JsonObject target, JsonObject extra )
{
if ( target is null || extra is null || extra.Count == 0 ) return;
target["x"] = extra.DeepClone();
}
static readonly string[] s_documentKeys =
{
KeySchema, KeyId, KeyKind, "meta", "settings", "preview", "parameters", "keywords",
"nodes", "edges", "groups", "notes", "view"
};
static readonly string[] s_settingsKeys =
{
"domain", "shadingModel", "blendMode", "cullMode", "modes", "targets", "hlslDialect",
"uv2", "renderBackfaces", "debugSymbols", "strictTypes"
};
static readonly string[] s_parameterKeys = { KeyId, "name", "type", "default", "attribute", "ui" };
// ---------------------------------------------------------------- self test ----
/// <summary>
/// Round-trip the worked example from the architecture brief and report what held and what did
/// not, as plain text.
/// <para>
/// There is no test framework in this environment, so correctness has to be <em>inspectable</em>:
/// call this from a console command or a menu item and read the report. It checks the three things
/// that matter — ids are never renumbered, an unregistered node comes back byte-identical apart
/// from its position, and writing is idempotent — plus the value codec and the cycle detector.
/// </para>
/// </summary>
public static string RunSelfTest()
{
var report = new StringBuilder();
var passed = 0;
var failed = 0;
void Check( string what, bool ok, string detail = null )
{
if ( ok )
{
passed++;
report.AppendLine( $" PASS {what}" );
return;
}
failed++;
report.AppendLine( $" FAIL {what}{( string.IsNullOrEmpty( detail ) ? "" : $" — {detail}" )}" );
}
report.AppendLine( $"Prism serializer self-test — {DateTime.Now:HH:mm:ss}" );
report.AppendLine();
var sink = new DiagnosticSink();
var graph = Read( SelfTestDocument, sink );
Check( "document parses", graph is not null );
if ( graph is null )
{
report.AppendLine();
report.AppendLine( $"{passed} passed, {failed} failed." );
return report.ToString();
}
Check( "document id preserved", graph.DocumentId == "g4k2x9pq", graph.DocumentId );
Check( "kind is shader", !graph.IsSubgraph );
Check( "title preserved", graph.Meta.Title == "Wet Stone", graph.Meta.Title );
Check( "settings.domain", graph.Settings.Domain == ShaderDomain.Surface );
Check( "settings.blendMode", graph.Settings.BlendMode == SurfaceBlendMode.Opaque );
Check( "settings.uv2", graph.Settings.Uv2 );
Check( "settings.modes", graph.Settings.Modes.Count == 3, string.Join( ",", graph.Settings.Modes ) );
Check( "settings.targets", graph.Settings.WantsTarget( PrismConstants.BackendSlang ) );
Check( "preview mesh", graph.Preview.Mesh == "Sphere", graph.Preview.Mesh );
Check( "preview camera", Math.Abs( graph.Preview.Camera.Distance - 150f ) < 0.001f );
Check( "3 parameters", graph.Parameters.Count == 3, $"{graph.Parameters.Count}" );
Check( "parameter ids preserved", graph.FindParameter( ParamId.Parse( "p2" ) ) is not null );
var roughness = graph.FindParameter( ParamId.Parse( "p2" ) );
Check( "parameter type parsed", roughness is not null && roughness.Type == ShaderType.Float );
Check( "parameter default preserved",
roughness?.Default is float f && Math.Abs( f - 0.62f ) < 0.0001f, $"{roughness?.Default}" );
Check( "parameter ui slider", roughness?.Ui.Control == UiControl.Slider );
Check( "parameter ui group", roughness?.Ui.Group == "Surface" );
var baseColor = graph.FindParameter( ParamId.Parse( "p1" ) );
Check( "texture parameter type", baseColor is not null && baseColor.Type.IsTexture );
Check( "texture default is a descriptor",
baseColor?.Default is TextureValue { Path: "materials/dev/white_color.tga" } );
Check( "1 keyword", graph.Keywords.Count == 1 );
Check( "keyword normalized", graph.Keywords.Count == 1 && graph.Keywords[0].NormalizedName == "F_PUDDLES" );
Check( "9 nodes", graph.Nodes.Count == 9, $"{graph.Nodes.Count}" );
Check( "node ids preserved", graph.FindNode( NodeId.Parse( "n9" ) ) is not null );
Check( "7 edges resolved or ghosted",
graph.Edges.Count + graph.BrokenEdges.Count == 7,
$"{graph.Edges.Count} live, {graph.BrokenEdges.Count} broken" );
Check( "1 group", graph.Groups.Count == 1 );
Check( "1 note", graph.Notes.Count == 1 );
Check( "view zoom", Math.Abs( graph.View.Zoom - 0.9f ) < 0.0001f );
// The unknown-node guarantee: byte-identical apart from pos.
var unknown = graph.FindNode( NodeId.Parse( "n9" ) ) as UnknownNode;
Check( "n9 loaded as an unknown node", unknown is not null );
if ( unknown is not null )
{
var source = JsonNode.Parse( SelfTestDocument ) as JsonObject;
var original = ( source?["nodes"] as JsonArray )?
.OfType<JsonObject>()
.FirstOrDefault( x => ValueCodec.StringOf( x[KeyId] ) == "n9" );
var written = WriteNode( graph, unknown );
if ( original is not null )
{
var a = original.DeepClone() as JsonObject;
var b = written.DeepClone() as JsonObject;
a.Remove( "pos" );
b.Remove( "pos" );
Check( "n9 round-trips byte-identically apart from pos",
a.ToJsonString() == b.ToJsonString(),
$"{a.ToJsonString()} != {b.ToJsonString()}" );
}
Check( "n9 preserves its x bag",
ValueCodec.StringOf( unknown.Raw?["x"]?["vendor"] ) == "acme" );
Check( "n9 reconstructed no ports (it has no edges)",
unknown.Inputs.Count == 0 && unknown.Outputs.Count == 0 );
}
// Determinism: two writes of the same document must be identical, and a reparse must too.
var first = Write( graph );
var second = Write( graph );
Check( "writing is deterministic", first == second );
var reread = Read( first, new DiagnosticSink() );
Check( "reparse succeeds", reread is not null );
if ( reread is not null )
{
var third = Write( reread );
Check( "write is idempotent across a reparse", first == third,
$"{first.Length} vs {third.Length} chars" );
Check( "node ids survive a reparse",
reread.Nodes.Select( x => x.Id.Value ).SequenceEqual( graph.Nodes.Select( x => x.Id.Value ) ) );
Check( "edge ids survive a reparse",
reread.Edges.Select( x => x.Id.Value ).SequenceEqual( graph.Edges.Select( x => x.Id.Value ) ) );
}
// The value codec.
Check( "float round-trips exactly",
ValueCodec.Read( ShaderType.Float, ValueCodec.Write( ShaderType.Float, 0.1f ) ) is float rf &&
rf.Equals( 0.1f ) );
Check( "float3 round-trips",
ValueCodec.Read( ShaderType.Float3, ValueCodec.Write( ShaderType.Float3,
new Vector3( 1f, 2f, 3f ) ) ) is Vector3 v && v.z.Equals( 3f ) );
Check( "colour round-trips through r,g,b,a",
ValueCodec.TryParseColor( ValueCodec.FormatColor( new Color( 0.25f, 0.5f, 0.75f, 1f ) ),
out var colour ) && colour.g.Equals( 0.5f ) );
Check( "bool round-trips",
ValueCodec.Read( ShaderType.Bool, ValueCodec.Write( ShaderType.Bool, true ) ) is true );
// Malformed input must degrade, never throw.
var junkSink = new DiagnosticSink();
Check( "malformed JSON is reported, not thrown", Read( "{ this is not json", junkSink ) is null );
Check( "malformed JSON produced a diagnostic", junkSink.HasErrors );
var partialSink = new DiagnosticSink();
var partial = Read( "{ \"schema\": 1, \"nodes\": [ 5, { \"id\": \"a\" } ], \"edges\": [ {} ] }", partialSink );
Check( "a document with junk entries still loads", partial is not null );
Check( "junk entries are reported", partialSink.Count > 0 );
// Cycle detection, including through a node with no registered type.
if ( reread is not null )
{
Check( "the example graph is acyclic", !GraphQueries.TryFindCycle( reread, out _ ) );
}
report.AppendLine();
report.AppendLine( "Registered nodes, undo and import:" );
RegisteredNodeTests( Check );
CycleTests( Check );
UndoTests( Check );
LegacyImportTests( Check );
report.AppendLine();
report.AppendLine( "Fault injection:" );
DiagnosticCodeTests( Check );
ValueCodecStabilityTests( Check );
LiteralShapeTests( Check );
LibraryLiteralTests( Check );
KeywordNameTests( Check );
CorruptionTests( Check );
DocumentStormTests( Check );
MalformedDocumentTests( Check );
GraphIndexTests( Check );
MutationGuardTests( Check );
RestoreEquivalenceTests( Check );
UndoStormTests( Check );
InterleavedUndoTests( Check );
UndoCapacityTests( Check );
ScaleTests( Check );
SubgraphRecoveryTests( Check );
PasteSemanticTests( Check );
ClipboardFuzzTests( Check );
LegacyCorruptionTests( Check );
SaveIntegrityTests( Check );
GameResourceTests( Check );
CompileServiceTests( Check );
RecoveryTests( Check );
report.AppendLine();
report.AppendLine( $"{passed} passed, {failed} failed." );
if ( sink.Count > 0 )
{
report.AppendLine();
report.AppendLine( "Diagnostics from the initial read:" );
foreach ( var diagnostic in sink.All )
{
report.AppendLine( $" {diagnostic}" );
}
}
return report.ToString();
}
/// <summary>The first line where two documents diverge, so a failed comparison says something useful.</summary>
static string Diff( string expected, string actual )
{
if ( string.Equals( expected, actual, StringComparison.Ordinal ) ) return null;
var a = ( expected ?? string.Empty ).Replace( "\r\n", "\n" ).Split( '\n' );
var b = ( actual ?? string.Empty ).Replace( "\r\n", "\n" ).Split( '\n' );
for ( int i = 0; i < Math.Max( a.Length, b.Length ); i++ )
{
var left = i < a.Length ? a[i] : "<end>";
var right = i < b.Length ? b[i] : "<end>";
if ( string.Equals( left, right, StringComparison.Ordinal ) ) continue;
var context = string.Join( " | ", b.Skip( i ).Take( 8 ).Select( x => x.Trim() ) );
return $"line {i + 1}: expected `{left.Trim()}` got `{right.Trim()}` " +
$"({a.Length} vs {b.Length} lines) :: {context}";
}
return $"{a.Length} vs {b.Length} lines";
}
/// <summary>Exercise the registered-node path: properties, inline literals and dynamic ports.</summary>
static void RegisteredNodeTests( Action<string, bool, string> check )
{
var probe = NodeRegistry.Create( PrismSelfTestNode.TypeId ) as PrismSelfTestNode;
check( "the self-test probe node is registered", probe is not null, null );
if ( probe is null ) return;
var graph = new PrismGraph();
probe.Label = "hello";
probe.Channel = 7;
probe.Mode = SelfTestMode.Gamma;
probe.Enabled = false;
probe.ExtraPorts = 2;
probe.DefaultA = 0.125f;
probe.DefaultB = new Vector3( 4f, 5f, 6f );
probe.Position = new Vector2( 32f, -64f );
probe.Flags = NodeFlags.Preview | NodeFlags.Collapsed;
graph.AddNode( probe );
var second = NodeRegistry.Create( PrismSelfTestNode.TypeId );
second.Position = new Vector2( 256f, 0f );
graph.AddNode( second );
var edge = graph.Connect( new PortRef( second.Id, PortId.Parse( "Out" ) ),
new PortRef( probe.Id, PortId.Parse( "B" ) ) );
check( "a float3 output connects to a float3 input", edge is not null, null );
var illegal = graph.CanConnect( new PortRef( second.Id, PortId.Parse( "Out" ) ),
new PortRef( probe.Id, PortId.Parse( "Out" ) ), out _ );
check( "an output cannot be connected to an output", !illegal, null );
var text = Write( graph );
var loaded = Read( text, new DiagnosticSink() );
check( "a registered node round-trips", loaded is not null, null );
if ( loaded is null ) return;
var reloaded = loaded.FindNode( probe.Id ) as PrismSelfTestNode;
check( "the node came back as its own type", reloaded is not null, reloaded?.GetType().Name );
if ( reloaded is null ) return;
check( "string property survives", reloaded.Label == "hello", reloaded.Label );
check( "int property survives", reloaded.Channel == 7, $"{reloaded.Channel}" );
check( "enum property survives", reloaded.Mode == SelfTestMode.Gamma, $"{reloaded.Mode}" );
check( "bool property survives", !reloaded.Enabled, null );
check( "position survives", reloaded.Position == new Vector2( 32f, -64f ), $"{reloaded.Position}" );
check( "flags survive", reloaded.Flags == ( NodeFlags.Preview | NodeFlags.Collapsed ), null );
check( "inline float survives", reloaded.DefaultA.Equals( 0.125f ), $"{reloaded.DefaultA}" );
check( "inline vector survives", reloaded.DefaultB == new Vector3( 4f, 5f, 6f ), $"{reloaded.DefaultB}" );
check( "dynamic ports are rebuilt after load",
reloaded.FindInput( PortId.Parse( "Extra1" ) ) is not null,
string.Join( ",", reloaded.Inputs.Select( x => x.Id.Value ) ) );
check( "the port inline slot mirrors the bound property",
ValueCodec.Equal( reloaded.FindInput( PortId.Parse( "A" ) )?.InlineValue, 0.125f ), null );
check( "the connection survives", loaded.Edges.Count == 1, $"{loaded.Edges.Count}" );
// Default inline values are not written, so a document stays small.
var fresh = new PrismGraph();
fresh.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
var freshJson = JsonNode.Parse( Write( fresh ) ) as JsonObject;
var freshNode = ( freshJson?["nodes"] as JsonArray )?.OfType<JsonObject>().FirstOrDefault();
check( "default inline values are omitted", freshNode is not null && !freshNode.ContainsKey( "inline" ),
freshNode?.ToJsonString() );
// Removing a port turns its edge into a visible ghost rather than deleting it silently.
reloaded.ExtraPorts = 0;
NodeProperties.RebuildPorts( reloaded );
check( "shrinking the port set does not lose the surviving connection",
loaded.Edges.Count == 1 && loaded.BrokenEdges.Count == 0,
$"{loaded.Edges.Count} live, {loaded.BrokenEdges.Count} broken" );
var target = loaded.FindNode( probe.Id );
var ghostGraph = loaded;
var doomed = ghostGraph.Edges.FirstOrDefault();
if ( doomed is not null && target is PrismSelfTestNode typed )
{
typed.HideB = true;
NodeProperties.RebuildPorts( typed );
check( "removing a connected port produces a broken-edge ghost",
ghostGraph.BrokenEdges.Count == 1 && ghostGraph.Edges.Count == 0,
$"{ghostGraph.Edges.Count} live, {ghostGraph.BrokenEdges.Count} broken" );
check( "a ghost survives a save and reload",
Read( Write( ghostGraph ), new DiagnosticSink() )?.BrokenEdges.Count == 1, null );
typed.HideB = false;
NodeProperties.RebuildPorts( typed );
ghostGraph.RepairBrokenEdges();
check( "restoring the port repairs the ghost",
ghostGraph.Edges.Count == 1 && ghostGraph.BrokenEdges.Count == 0,
$"{ghostGraph.Edges.Count} live, {ghostGraph.BrokenEdges.Count} broken" );
}
}
/// <summary>Cycle detection has to see through every node, reroutes included.</summary>
static void CycleTests( Action<string, bool, string> check )
{
var graph = new PrismGraph();
var nodes = new List<PrismNode>();
for ( int i = 0; i < 3; i++ )
{
var node = NodeRegistry.Create( PrismSelfTestNode.TypeId );
if ( node is null ) return;
node.Position = new Vector2( i * 200f, 0f );
nodes.Add( graph.AddNode( node ) );
}
var outPort = PortId.Parse( "Out" );
var inPort = PortId.Parse( "B" );
graph.AddEdge( Edge.Between( graph.NewEdgeId(),
new PortRef( nodes[0].Id, outPort ), new PortRef( nodes[1].Id, inPort ) ) );
graph.AddEdge( Edge.Between( graph.NewEdgeId(),
new PortRef( nodes[1].Id, outPort ), new PortRef( nodes[2].Id, inPort ) ) );
check( "a chain is acyclic", !GraphQueries.TryFindCycle( graph, out _ ), null );
check( "closing the loop is refused at drop time",
!graph.CanConnect( new PortRef( nodes[2].Id, outPort ), new PortRef( nodes[0].Id, inPort ), out _ ),
null );
// Force it in the way a corrupt document would, and make sure the detector catches it.
graph.AddEdge( Edge.Between( graph.NewEdgeId(),
new PortRef( nodes[2].Id, outPort ), new PortRef( nodes[0].Id, inPort ) ) );
var found = GraphQueries.TryFindCycle( graph, out var cycle );
check( "a forced cycle is detected", found, null );
check( "the full cycle path is reported", found && cycle.Count == 4,
string.Join( " -> ", cycle.Select( x => x.Value ) ) );
check( "the cycle description names every node",
found && GraphQueries.DescribeCycle( graph, cycle ).Split( '→' ).Length == 4, null );
var order = GraphQueries.TopologicalOrder( graph );
check( "a cyclic graph still yields every node in some order", order.Count == 3, $"{order.Count}" );
var acyclic = new PrismGraph();
var a = acyclic.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
var b = acyclic.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
var c = acyclic.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
acyclic.AddEdge( Edge.Between( acyclic.NewEdgeId(),
new PortRef( a.Id, outPort ), new PortRef( b.Id, inPort ) ) );
acyclic.AddEdge( Edge.Between( acyclic.NewEdgeId(),
new PortRef( b.Id, outPort ), new PortRef( c.Id, inPort ) ) );
var sorted = GraphQueries.TopologicalOrder( acyclic );
check( "topological order puts dependencies first",
sorted.Count == 3 && sorted[0] == a.Id && sorted[1] == b.Id && sorted[2] == c.Id,
string.Join( ",", sorted.Select( x => x.Value ) ) );
check( "the dependency subtree of the tail is the whole chain",
GraphQueries.DependencySubtree( acyclic, c.Id ).Count == 3, null );
var orphan = acyclic.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
check( "an unconnected node is reported as an orphan",
GraphQueries.Orphans( acyclic, new[] { c.Id } ).Contains( orphan.Id ), null );
}
/// <summary>
/// The acceptance criterion for undo: a long run of randomised edits, undone all the way, must
/// leave the document byte-identical to where it started — and redone all the way, byte-identical
/// to where it ended.
/// </summary>
static void UndoTests( Action<string, bool, string> check )
{
if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;
var graph = new PrismGraph();
var undo = new PrismUndoStack( graph ) { CoalesceMs = 0 };
var mutations = new GraphMutations( graph, undo );
for ( int i = 0; i < 4; i++ )
{
mutations.AddNode( PrismSelfTestNode.TypeId, new Vector2( i * 192f, 0f ) );
}
mutations.AddParameter( "Roughness", ShaderType.Float );
undo.Clear();
var start = Write( graph );
var startIds = graph.Nodes.Select( x => x.Id ).ToArray();
var firstNode = graph.Nodes.FirstOrDefault();
var random = new Random( 20260810 );
var outPort = PortId.Parse( "Out" );
var inPort = PortId.Parse( "B" );
for ( int step = 0; step < 50; step++ )
{
var nodes = graph.Nodes.ToArray();
switch ( random.Next( 8 ) )
{
case 0:
mutations.AddNode( PrismSelfTestNode.TypeId,
new Vector2( random.Next( -512, 512 ), random.Next( -512, 512 ) ) );
break;
case 1 when nodes.Length > 2:
mutations.RemoveNode( nodes[random.Next( nodes.Length )].Id );
break;
case 2 when nodes.Length > 0:
mutations.Move( nodes[random.Next( nodes.Length )].Id,
new Vector2( random.Next( -512, 512 ), random.Next( -512, 512 ) ) );
break;
case 3 when nodes.Length > 1:
{
var from = nodes[random.Next( nodes.Length )];
var to = nodes[random.Next( nodes.Length )];
mutations.Connect( new PortRef( from.Id, outPort ), new PortRef( to.Id, inPort ) );
break;
}
case 4 when graph.Edges.Count > 0:
mutations.Disconnect( graph.Edges[random.Next( graph.Edges.Count )].Id );
break;
case 5 when nodes.Length > 0:
mutations.SetProperty( nodes[random.Next( nodes.Length )].Id, "Channel", random.Next( 16 ) );
break;
case 6 when nodes.Length > 0:
mutations.SetInlineValue( nodes[random.Next( nodes.Length )].Id, PortId.Parse( "A" ),
(float)random.NextDouble() );
break;
case 7 when graph.Parameters.Count > 0:
mutations.SetParameterDefault( graph.Parameters[random.Next( graph.Parameters.Count )].Id,
(float)random.NextDouble() );
break;
}
}
var end = Write( graph );
check( "50 randomised edits changed the document", start != end, null );
check( "every edit produced an undo entry", undo.Count > 0, $"{undo.Count} entries" );
var guard = 0;
while ( undo.CanUndo && guard++ < 500 )
{
undo.Undo();
}
check( "undoing everything restores the original document byte for byte", Write( graph ) == start,
Diff( start, Write( graph ) ) );
check( "node identity survives the restore",
firstNode is not null && ReferenceEquals( graph.FindNode( firstNode.Id ), firstNode ), null );
check( "every original node id is back",
startIds.All( id => graph.FindNode( id ) is not null ), null );
guard = 0;
while ( undo.CanRedo && guard++ < 500 )
{
undo.Redo();
}
check( "redoing everything restores the final document byte for byte", Write( graph ) == end, null );
check( "jumping to level zero works", undo.JumpTo( 0 ) && Write( graph ) == start, null );
check( "jumping to the top works", undo.JumpTo( undo.Count ) && Write( graph ) == end, null );
// Coalescing: rapid same-label edits become one step.
var coalescing = new PrismUndoStack( graph ) { CoalesceMs = 5000 };
var slider = new GraphMutations( graph, coalescing );
var target = graph.Nodes.FirstOrDefault();
if ( target is not null )
{
for ( int i = 1; i <= 10; i++ )
{
slider.SetInlineValue( target.Id, PortId.Parse( "A" ), i * 0.05f, "Set A" );
}
check( "ten rapid same-label edits coalesce into one step", coalescing.Count == 1,
$"{coalescing.Count} entries" );
coalescing.Undo();
check( "undoing the coalesced step reverses all ten", coalescing.Level == 0, null );
}
// A no-op edit must not create a step.
var quiet = new PrismUndoStack( graph );
var quietMutations = new GraphMutations( graph, quiet );
using ( quietMutations.Begin( "Nothing" ) ) { }
check( "an edit that changed nothing records no step", quiet.Count == 0, $"{quiet.Count}" );
// Balance: a stray commit is reported and survived rather than corrupting the stack.
quiet.Commit();
check( "an unbalanced commit does not corrupt the stack", !quiet.IsCapturing && quiet.Count == 0, null );
}
/// <summary>Import a representative built-in shader graph and check what came out.</summary>
static void LegacyImportTests( Action<string, bool, string> check )
{
var sink = new DiagnosticSink();
var graph = LegacyShaderGraphImporter.Import( LegacyDocument, false, sink );
check( "a legacy .shdrgrph imports", graph is not null, null );
if ( graph is null ) return;
check( "the legacy blend mode carries over", graph.Settings.BlendMode == SurfaceBlendMode.Masked,
$"{graph.Settings.BlendMode}" );
check( "every legacy node is imported", graph.Nodes.Count == 7, $"{graph.Nodes.Count}" );
check( "legacy ids are replaced with stable base36 ids",
graph.Nodes.All( x => Ids.IsWellFormed( x.Id.Value ) && x.Id.Value.Length == Ids.ShortIdLength ),
string.Join( ",", graph.Nodes.Select( x => x.Id.Value ) ) );
check( "connections became a real edge list",
graph.Edges.Count + graph.BrokenEdges.Count == 4,
$"{graph.Edges.Count} live, {graph.BrokenEdges.Count} broken" );
check( "a named legacy constant became a blackboard parameter",
graph.Parameters.Count == 1 && graph.Parameters[0].Name == "RimFudge",
string.Join( ",", graph.Parameters.Select( x => x.Name ) ) );
check( "the promoted parameter kept its default",
graph.Parameters.Count == 1 && graph.Parameters[0].Default is float f && f.Equals( 0.25f ),
$"{graph.Parameters.FirstOrDefault()?.Default}" );
// An unnamed legacy constant used to import as zero, because its Value landed on a property the
// constant node does not have. Nothing failed and nothing warned; the maths just quietly changed.
var constant = graph.Nodes.OfType<Nodes.ConstantNode>().FirstOrDefault();
check( "an unnamed legacy constant imports as a constant node", constant is not null, null );
check( "a legacy Float4 imports as a colour, not a float4",
constant?.Kind == Nodes.PrismConstantKind.Color, $"{constant?.Kind}" );
check( "the constant kept its value",
constant is not null && constant.ColorValue.r.AlmostEqual( 1f )
&& constant.ColorValue.g.AlmostEqual( 0.5f )
&& constant.ColorValue.b.AlmostEqual( 0.25f ),
$"{constant?.ColorValue}" );
// A Float4's component plugs are R/G/B/A in the built-in editor. They only line up if the kind
// is Color, so this edge is what proves the two fixes agree with each other.
check( "an edge leaving a constant's component plug survives",
graph.BrokenEdges.Count == 0, string.Join( ",", graph.BrokenEdges.Select( x => x.Edge.ToString() ) ) );
// A legacy TextureSampler is a parameter and a sampler in one node, so it stays a sampler and
// keeps its slot rather than being promoted to a bare blackboard reference. Every field it
// carried used to be dropped on the floor, taking the texture path with it.
var sampler = graph.Nodes.OfType<Nodes.TextureNode>().FirstOrDefault();
check( "a legacy texture sampler imports as a texture node", sampler is not null,
string.Join( ",", graph.Nodes.Select( x => x.Descriptor?.Id ?? "?" ) ) );
check( "the texture asset path survives the import",
sampler?.DefaultTexture == "textures/fixture/rust_albedo.png", sampler?.DefaultTexture );
check( "the texture slot keeps its name and group",
sampler?.TextureName == "RustAlbedo" && sampler?.TextureGroup == "Surface",
$"{sampler?.TextureName} / {sampler?.TextureGroup}" );
check( "the texture keeps its colour space", sampler is { Srgb: false }, $"{sampler?.Srgb}" );
check( "legacy sampler state is translated, not copied verbatim",
sampler is { Filter: Nodes.PrismTextureFilter.Anisotropic, AddressU: Nodes.PrismTextureAddress.Clamp,
AddressV: Nodes.PrismTextureAddress.Mirror },
$"{sampler?.Filter} / {sampler?.AddressU} / {sampler?.AddressV}" );
check( "a texture sampler is not promoted to a blackboard parameter",
graph.Parameters.All( x => x.Name != "RustAlbedo" ),
string.Join( ",", graph.Parameters.Select( x => x.Name ) ) );
var edges = graph.Edges.Concat( graph.BrokenEdges.Select( x => x.Edge ) ).ToArray();
check( "every imported connection pins the legacy zero pad fill",
edges.Length > 0 && edges.All( x => x.Fill is { } fill && fill.Equals( 0f ) ), null );
var unmapped = graph.Nodes.OfType<UnknownNode>()
.FirstOrDefault( x => x.TypeId.EndsWith( "TotallyMadeUpNode", StringComparison.Ordinal ) );
check( "an unmapped legacy node is preserved rather than dropped", unmapped is not null, null );
check( "the unmapped node kept its original properties",
unmapped?.Raw?["SecretSetting"] is not null, unmapped?.Raw?.ToJsonString() );
check( "the imported graph round-trips", Read( Write( graph ), new DiagnosticSink() ) is not null, null );
check( "the importer reported what it did", sink.Count > 0, null );
}
// ---------------------------------------------------------------- fault injection ----
/// <summary>
/// The code table itself. A duplicate or a malformed code means two different problems now report
/// the same thing in the UI and in bug reports, which is the one guarantee these constants make.
/// </summary>
static void DiagnosticCodeTests( Action<string, bool, string> check )
{
var codes = DiagnosticCode.All;
check( "the diagnostic code table is populated", codes.Count > 30, $"{codes.Count} codes" );
var duplicates = codes.GroupBy( x => x, StringComparer.Ordinal )
.Where( x => x.Count() > 1 ).Select( x => x.Key ).ToArray();
check( "no two diagnostic codes collide", duplicates.Length == 0, string.Join( ",", duplicates ) );
var malformed = codes.Where( x => x is null || x.Length != 6 || !x.StartsWith( "PR", StringComparison.Ordinal ) ||
!x[2..].All( char.IsDigit ) ).ToArray();
check( "every diagnostic code is PR####", malformed.Length == 0, string.Join( ",", malformed ) );
check( "the text tier folded into the code table",
DiagnosticCode.IsKnown( "PR6001" ) && DiagnosticCode.IsKnown( "PR6011" ), null );
check( "codes are grouped by tier",
DiagnosticCode.TierOf( DiagnosticCode.Cycle ) == "Graph" &&
DiagnosticCode.TierOf( DiagnosticCode.Unbalanced ) == "Text" &&
DiagnosticCode.TierOf( DiagnosticCode.RoundTripFailed ) == "Document", null );
}
/// <summary>
/// Encoding a literal has to be stable: encode, decode and encode again must produce the same JSON,
/// for every shader type against every shape of value a property or a hand-edited document can hold.
/// A value that decodes to something other than what was written is a value the user loses on the
/// next save, silently.
/// </summary>
static void ValueCodecStabilityTests( Action<string, bool, string> check )
{
var types = new[]
{
ShaderType.Void, ShaderType.Bool, ShaderType.Int, ShaderType.UInt, ShaderType.Half,
ShaderType.Float, ShaderType.Float2, ShaderType.Float3, ShaderType.Float4,
ShaderType.Int2, ShaderType.Bool3, ShaderType.Float4x4,
ShaderType.Texture2D, ShaderType.TextureCube, ShaderType.Sampler
};
var values = new object[]
{
null, 0, 1, -1, 0.5f, -0.5f, float.MaxValue, float.MinValue, float.NaN,
float.PositiveInfinity, float.NegativeInfinity, true, false, "text", string.Empty,
Vector2.One, new Vector3( 1f, 2f, 3f ), new Vector4( 1f, 2f, 3f, 4f ), Color.White,
int.MaxValue, int.MinValue, 1e30f, 1e-30f
};
var unstable = new List<string>();
var threw = new List<string>();
foreach ( var type in types )
{
foreach ( var value in values )
{
try
{
var written = ValueCodec.Write( type, ValueCodec.Coerce( value, type ) );
var again = ValueCodec.Write( type, ValueCodec.Read( type, written ) );
if ( ( written?.ToJsonString() ?? "null" ) != ( again?.ToJsonString() ?? "null" ) )
{
unstable.Add( $"{type} <- {value ?? "null"}: {written?.ToJsonString()} then {again?.ToJsonString()}" );
}
}
catch ( Exception e )
{
threw.Add( $"{type} <- {value ?? "null"}: {e.GetType().Name}" );
}
}
}
check( $"encoding {types.Length * values.Length} type/value combinations never throws",
threw.Count == 0, First( threw ) );
check( "and every one of them is a stable encoding", unstable.Count == 0, First( unstable ) );
// The trap that made every number read as zero: a JsonValue built in memory wraps the exact CLR
// type it was created from, and asking a JsonValue<int> for a float returns false.
check( "a number held as an int is read as a number",
ValueCodec.NumberOf( JsonValue.Create( 7 ) ).Equals( 7f ), null );
check( "a number held as a double is read as a number",
ValueCodec.NumberOf( JsonValue.Create( 7.5 ) ).Equals( 7.5f ), null );
check( "a number held as a long is read as a number",
ValueCodec.NumberOf( JsonValue.Create( 7L ) ).Equals( 7f ), null );
check( "an integer literal survives an in-memory round trip",
ValueCodec.Read( ShaderType.Int, ValueCodec.Write( ShaderType.Int, 7 ) ) is 7, null );
check( "a document handed over as a live JsonObject keeps its numbers",
ReadJson( WriteJson( Read( SelfTestDocument, new DiagnosticSink() ) ), new DiagnosticSink() )
is { } live && live.View.Zoom.Equals( 0.9f ), null );
}
/// <summary>
/// The edge indexes have to agree with the edge list after any sequence of raw mutations. They are
/// three separate lookup tables maintained by hand, and a stale entry in one of them is a connection
/// that is drawn but not compiled, or compiled but not drawn.
/// </summary>
static void GraphIndexTests( Action<string, bool, string> check )
{
if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;
var graph = new PrismGraph();
var random = new Random( 99 );
var outPort = PortId.Parse( "Out" );
var inPort = PortId.Parse( "B" );
for ( int i = 0; i < 8; i++ )
{
graph.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
}
string failure = null;
for ( int step = 0; step < 2000 && failure is null; step++ )
{
var nodes = graph.Nodes.ToArray();
try
{
switch ( random.Next( 10 ) )
{
case 0:
graph.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
break;
case 1 when nodes.Length > 2:
graph.RemoveNode( nodes[random.Next( nodes.Length )].Id );
break;
case 2 when nodes.Length > 1:
graph.Connect( new PortRef( nodes[random.Next( nodes.Length )].Id, outPort ),
new PortRef( nodes[random.Next( nodes.Length )].Id, inPort ) );
break;
case 3 when graph.Edges.Count > 0:
graph.RemoveEdge( graph.Edges[random.Next( graph.Edges.Count )].Id );
break;
case 4 when nodes.Length > 0:
graph.DisconnectPort( new PortRef( nodes[random.Next( nodes.Length )].Id, inPort ) );
break;
case 5 when graph.Edges.Count > 0:
graph.ReplaceEdge( graph.Edges[random.Next( graph.Edges.Count )] with
{
Fill = (float)random.NextDouble()
} );
break;
case 6 when nodes.Length > 1:
// Forced in the way a corrupt document would, bypassing every validity check.
graph.AddEdge( Edge.Between( graph.NewEdgeId(),
new PortRef( nodes[random.Next( nodes.Length )].Id, outPort ),
new PortRef( nodes[random.Next( nodes.Length )].Id, inPort ) ) );
break;
case 7 when nodes.Length > 0:
if ( nodes[random.Next( nodes.Length )] is PrismSelfTestNode grow )
{
grow.ExtraPorts = random.Next( 4 );
NodeProperties.RebuildPorts( grow );
}
break;
case 8 when nodes.Length > 0:
if ( nodes[random.Next( nodes.Length )] is PrismSelfTestNode hide )
{
hide.HideB = random.Next( 2 ) == 0;
NodeProperties.RebuildPorts( hide );
}
break;
case 9:
graph.RepairBrokenEdges();
break;
}
}
catch ( Exception e )
{
failure = $"step {step} threw {e.GetType().Name}: {e.Message}";
break;
}
if ( step % 25 != 0 ) continue;
failure = IndexProblem( graph ) is { } problem ? $"step {step}: {problem}" : null;
}
check( "2000 raw graph mutations keep every edge index consistent", failure is null, failure );
var text = Write( graph );
check( "and the mauled document still round-trips",
text == Write( Read( text, new DiagnosticSink() ) ), null );
graph.Clear();
check( "clearing a document empties it",
graph.Nodes.Count == 0 && graph.Edges.Count == 0 && graph.BrokenEdges.Count == 0, null );
check( "and leaves nothing behind in the indexes", IndexProblem( graph ) is null, IndexProblem( graph ) );
}
/// <summary>The first disagreement between the edge list and the lookup indexes, or null.</summary>
static string IndexProblem( PrismGraph graph )
{
foreach ( var edge in graph.Edges )
{
if ( graph.FindEdge( edge.Id ) is null ) return $"{edge.Id} is not findable by id";
if ( !graph.GetIncomingEdges( edge.ToNode, edge.ToPort ).Any( x => x.Id == edge.Id ) )
return $"{edge.Id} is missing from the incoming index";
if ( !graph.GetOutgoingEdges( edge.FromNode, edge.FromPort ).Any( x => x.Id == edge.Id ) )
return $"{edge.Id} is missing from the outgoing index";
if ( !graph.EdgesOf( edge.FromNode ).Any( x => x.Id == edge.Id ) )
return $"{edge.Id} is missing from its source node's index";
if ( !graph.EdgesOf( edge.ToNode ).Any( x => x.Id == edge.Id ) )
return $"{edge.Id} is missing from its target node's index";
if ( graph.FindNode( edge.FromNode ) is null ) return $"{edge.Id} leaves a node that is gone";
if ( graph.FindNode( edge.ToNode ) is null ) return $"{edge.Id} lands on a node that is gone";
}
var live = new HashSet<EdgeId>( graph.Edges.Select( x => x.Id ) );
foreach ( var node in graph.Nodes )
{
if ( !ReferenceEquals( node.Graph, graph ) ) return $"node {node.Id} does not point back at its document";
if ( graph.FindNode( node.Id ) is null ) return $"node {node.Id} is not findable by id";
foreach ( var edge in graph.EdgesOf( node.Id ) )
{
if ( !live.Contains( edge.Id ) ) return $"stale {edge.Id} still indexed on node {node.Id}";
}
foreach ( var port in node.Inputs )
{
foreach ( var edge in graph.GetIncomingEdges( node.Id, port.Id ) )
{
if ( !live.Contains( edge.Id ) ) return $"stale {edge.Id} still indexed on {node.Id}.{port.Id}";
}
}
foreach ( var port in node.Outputs )
{
foreach ( var edge in graph.GetOutgoingEdges( node.Id, port.Id ) )
{
if ( !live.Contains( edge.Id ) ) return $"stale {edge.Id} still indexed on {node.Id}.{port.Id}";
}
}
}
if ( graph.Edges.Select( x => x.Id ).Distinct().Count() != graph.Edges.Count ) return "duplicate edge ids";
if ( graph.Nodes.Select( x => x.Id ).Distinct().Count() != graph.Nodes.Count ) return "duplicate node ids";
return null;
}
/// <summary>
/// Every mutation asked to do something impossible has to refuse, say so, and leave no undo step
/// behind. A refused edit that still records a step is a history the user cannot reason about.
/// </summary>
static void MutationGuardTests( Action<string, bool, string> check )
{
if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;
var graph = new PrismGraph();
var undo = new PrismUndoStack( graph ) { CoalesceMs = 0 };
var mutations = new GraphMutations( graph, undo );
var first = mutations.AddNode( PrismSelfTestNode.TypeId, Vector2.Zero );
var second = mutations.AddNode( PrismSelfTestNode.TypeId, new Vector2( 200f, 0f ) );
var outPort = PortId.Parse( "Out" );
var inPort = PortId.Parse( "B" );
var missing = NodeId.New();
var steps = undo.Count;
var refusals = new List<string>();
void Refuses( string what, bool refused )
{
if ( !refused ) refusals.Add( what );
}
try
{
Refuses( "an unknown node type", mutations.AddNode( "no.such.type", Vector2.Zero ) is null );
Refuses( "a null node", mutations.AddNode( (PrismNode)null, Vector2.Zero ) is null );
Refuses( "removing a node that is not there", !mutations.RemoveNode( missing ) );
Refuses( "removing the default id", !mutations.RemoveNode( default ) );
Refuses( "moving a node that is not there", !mutations.Move( missing, Vector2.One ) );
Refuses( "setting a property on a node that is not there",
!mutations.SetProperty( missing, "Channel", 1 ) );
Refuses( "setting a property with no name", !mutations.SetProperty( first.Id, null, 1 ) );
Refuses( "setting a property that does not exist",
!mutations.SetProperty( first.Id, "NoSuchProperty", 1 ) );
Refuses( "setting a literal on a port that does not exist",
!mutations.SetInlineValue( first.Id, PortId.Parse( "Nope" ), 1f ) );
Refuses( "connecting from a node that is not there",
mutations.Connect( new PortRef( missing, outPort ), new PortRef( second.Id, inPort ) ) is null );
Refuses( "connecting to a port that is not there",
mutations.Connect( new PortRef( first.Id, outPort ),
new PortRef( second.Id, PortId.Parse( "Nope" ) ) ) is null );
Refuses( "connecting a node to itself",
mutations.Connect( new PortRef( first.Id, outPort ), new PortRef( first.Id, inPort ) ) is null );
Refuses( "connecting an input to an output",
mutations.Connect( new PortRef( first.Id, inPort ), new PortRef( second.Id, outPort ) ) is null );
Refuses( "disconnecting a connection that is not there", !mutations.Disconnect( EdgeId.New() ) );
Refuses( "removing a parameter that is not there", !mutations.RemoveParameter( ParamId.New() ) );
Refuses( "renaming a parameter that is not there", !mutations.RenameParameter( ParamId.New(), "x" ) );
Refuses( "editing a group that is not there", !mutations.EditGroup( "nope", x => { } ) );
Refuses( "editing with no callback", !mutations.EditGroup( "nope", null ) );
Refuses( "pasting nothing", !mutations.Paste( null, Vector2.Zero ).Ok );
Refuses( "duplicating nothing", !mutations.Duplicate( Array.Empty<NodeId>(), Vector2.Zero ).Ok );
Refuses( "copying nothing", mutations.Copy( Array.Empty<NodeId>() ) is null );
Refuses( "cutting nothing", mutations.Cut( Array.Empty<NodeId>() ) is null );
check( "every impossible mutation is refused", refusals.Count == 0, string.Join( "; ", refusals ) );
check( "and none of them recorded an undo step", undo.Count == steps, $"{undo.Count} vs {steps}" );
}
catch ( Exception e )
{
check( "an impossible mutation never throws", false, e.GetType().Name + ": " + e.Message );
}
// A callback that throws must leave the document and the stack exactly as they were.
var parameter = mutations.AddParameter( "Guarded", ShaderType.Float );
var before = Write( graph );
try
{
mutations.EditParameter( parameter.Id, x => throw new InvalidOperationException( "deliberate" ) );
check( "an edit callback that throws leaves the document alone", Write( graph ) == before, null );
check( "and does not leave the undo stack capturing", !undo.IsCapturing, null );
}
catch ( Exception e )
{
check( "an edit callback that throws is contained", false, e.GetType().Name );
}
// And the whole API has to be inert when it has nothing to work on.
try
{
var inert = new GraphMutations( null );
inert.AddNode( PrismSelfTestNode.TypeId, Vector2.Zero );
inert.RemoveNode( missing );
inert.Connect( new PortRef( missing, outPort ), new PortRef( missing, inPort ) );
inert.Paste( "x", Vector2.Zero );
inert.UpdateSettings( x => { } );
check( "a mutation API with no document is inert rather than fatal", true, null );
}
catch ( Exception e )
{
check( "a mutation API with no document is inert rather than fatal", false, e.GetType().Name );
}
}
/// <summary>Values injected into every field of the worked example, one at a time.</summary>
static JsonNode[] Poisons() => new JsonNode[]
{
null,
JsonValue.Create( "" ),
JsonValue.Create( "garbage" ),
JsonValue.Create( 0 ),
JsonValue.Create( -1 ),
JsonValue.Create( 999999999 ),
JsonValue.Create( true ),
JsonValue.Create( 1e30 ),
JsonValue.Create( -1e30 ),
JsonValue.Create( "NaN" ),
JsonValue.Create( "Infinity" ),
JsonValue.Create( "-Infinity" ),
new JsonArray(),
new JsonArray( 1, 2, 3, 4, 5, 6 ),
new JsonArray( JsonValue.Create( "NaN" ), JsonValue.Create( "Infinity" ) ),
new JsonObject(),
new JsonObject { ["nope"] = 1 }
};
/// <summary>
/// Corrupt every field of a saved document in turn — remove it, then replace it with each of
/// <see cref="Poisons"/> — and require four things of every single result: reading never throws,
/// writing is deterministic, writing is idempotent across a reparse, and no coordinate comes back
/// non-finite. That last one matters more than it looks: one NaN position poisons every bounding
/// box that unions it, so fit-to-view, the minimap and marquee selection all stop working and
/// nothing says why.
/// </summary>
static void CorruptionTests( Action<string, bool, string> check )
{
var paths = new List<string>();
CollectPaths( JsonNode.Parse( SelfTestDocument ), string.Empty, paths, 0 );
var poisons = Poisons();
var cases = 0;
var thrown = new List<string>();
var unstable = new List<string>();
var nonIdempotent = new List<string>();
var nonFinite = new List<string>();
foreach ( var path in paths )
{
for ( int p = -1; p < poisons.Length; p++ )
{
var document = JsonNode.Parse( SelfTestDocument ) as JsonObject;
var label = $"{path} := {( p < 0 ? "<removed>" : poisons[p]?.ToJsonString() ?? "null" )}";
if ( p < 0 )
{
if ( !RemoveAt( document, path ) ) continue;
}
else if ( !ReplaceAt( document, path, poisons[p]?.DeepClone() ) )
{
continue;
}
cases++;
try
{
var graph = Read( document.ToJsonString( PrismJson.Options ), new DiagnosticSink() );
if ( graph is null ) continue;
var first = Write( graph );
var second = Write( graph );
if ( !string.Equals( first, second, StringComparison.Ordinal ) )
{
unstable.Add( label );
continue;
}
var reread = Read( first, new DiagnosticSink() );
if ( reread is null || !string.Equals( first, Write( reread ), StringComparison.Ordinal ) )
{
nonIdempotent.Add( label );
continue;
}
if ( graph.Nodes.Any( x => !IsFinite( x.Position ) ) ||
graph.Groups.Any( x => !IsFinite( x.Position ) || !IsFinite( x.Size ) ) ||
graph.Notes.Any( x => !IsFinite( x.Position ) || !IsFinite( x.Size ) ) ||
graph.View is null || !IsFinite( graph.View.Center ) || !float.IsFinite( graph.View.Zoom ) )
{
nonFinite.Add( label );
}
}
catch ( Exception e )
{
thrown.Add( $"{label} -> {e.GetType().Name}: {e.Message}" );
}
}
}
check( $"{cases} field corruptions of the worked example were all survivable",
thrown.Count == 0, First( thrown ) );
check( "every corrupted document still wrote deterministically", unstable.Count == 0, First( unstable ) );
check( "every corrupted document still wrote idempotently", nonIdempotent.Count == 0, First( nonIdempotent ) );
check( "no corrupted document produced a non-finite coordinate", nonFinite.Count == 0, First( nonFinite ) );
}
/// <summary>The document-level failures: not JSON, not a document, or a structurally impossible one.</summary>
static void MalformedDocumentTests( Action<string, bool, string> check )
{
var refused = new[]
{
("text that is not JSON", "hello world"),
("an empty file", ""),
("a zero byte", "\0"),
("a truncated document", SelfTestDocument[..400]),
("an array at the root", "[1,2,3]"),
("a bare null", "null"),
("a bare number", "42"),
("300 levels of nesting", new string( '[', 300 ) + new string( ']', 300 ) )
};
foreach ( var (what, text) in refused )
{
var sink = new DiagnosticSink();
try
{
check( $"{what} is refused", Read( text, sink ) is null, null );
check( $"{what} says why", sink.HasErrors, null );
}
catch ( Exception e )
{
check( $"{what} does not throw", false, e.GetType().Name );
}
}
// A byte-order mark is the one that used to lose the whole document.
var bom = Read( '' + SelfTestDocument, new DiagnosticSink() );
check( "a document saved with a byte-order mark still opens", bom is not null, null );
check( "and comes back whole", bom is not null && bom.Nodes.Count == 9, $"{bom?.Nodes.Count}" );
check( "a document with Unix line endings opens",
Read( SelfTestDocument.Replace( "\r\n", "\n" ), new DiagnosticSink() )?.Nodes.Count == 9, null );
check( "a document with Windows line endings opens",
Read( SelfTestDocument.Replace( "\r\n", "\n" ).Replace( "\n", "\r\n" ), new DiagnosticSink() )?.Nodes.Count == 9,
null );
var survivable = new[]
{
("two nodes sharing an id", """
{ "schema":1, "nodes":[ {"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]},
{"id":"a","type":"prism.internal.selftest","v":1,"pos":[9,0]} ] }
"""),
("an edge from a node to itself", """
{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]}],
"edges":[{"id":"e","from":{"node":"a","port":"Out"},"to":{"node":"a","port":"B"}}] }
"""),
("an edge to a node that does not exist", """
{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]}],
"edges":[{"id":"e","from":{"node":"zz","port":"Out"},"to":{"node":"a","port":"B"}}] }
"""),
("an edge to a port that does not exist", """
{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]},
{"id":"b","type":"prism.internal.selftest","v":1,"pos":[9,0]}],
"edges":[{"id":"e","from":{"node":"a","port":"Nope"},"to":{"node":"b","port":"B"}}] }
"""),
("a cycle written straight into the file", """
{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]},
{"id":"b","type":"prism.internal.selftest","v":1,"pos":[9,0]}],
"edges":[{"id":"e1","from":{"node":"a","port":"Out"},"to":{"node":"b","port":"B"}},
{"id":"e2","from":{"node":"b","port":"Out"},"to":{"node":"a","port":"B"}}] }
"""),
("two connections sharing an id", """
{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]},
{"id":"b","type":"prism.internal.selftest","v":1,"pos":[9,0]}],
"edges":[{"id":"e","from":{"node":"a","port":"Out"},"to":{"node":"b","port":"B"}},
{"id":"e","from":{"node":"b","port":"Out"},"to":{"node":"a","port":"A"}}] }
"""),
("a node whose properties are all the wrong type", """
{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0],
"props":{"Label":[1,2],"Channel":"xyz","Mode":99,"Enabled":"maybe","ExtraPorts":-5}}] }
"""),
("a node version two billion behind", """
{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":-2147483648,"pos":[0,0]}] }
"""),
("a schema from the future", """{ "schema":999, "nodes":[], "somethingNew":{"a":1} }"""),
("a negative schema", """{ "schema":-3, "nodes":[] }"""),
// What a bad three-way merge produces. A lazily-materialised node tree throws
// ArgumentException the first time anything reads an object with a repeated key, from
// inside whichever reader happened to touch it, so this is the one corruption that used
// to escape as an exception rather than a diagnostic.
("a top-level key declared twice", """{ "schema":1, "schema":2, "nodes":[] }"""),
("a node key declared twice", """
{ "schema":1, "nodes":[{"id":"a","id":"b","type":"prism.internal.selftest","v":1,"pos":[0,0]}] }
"""),
("the node list declared twice", """
{ "schema":1, "nodes":[], "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0]}] }
"""),
("a property declared twice inside a node", """
{ "schema":1, "nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[0,0],
"props":{"Channel":1,"Channel":9}}] }
""")
};
foreach ( var (what, text) in survivable )
{
try
{
var clock = System.Diagnostics.Stopwatch.StartNew();
var graph = Read( text, new DiagnosticSink() );
check( $"{what} still loads", graph is not null, null );
if ( graph is null ) continue;
check( $"{what} loads promptly", clock.ElapsedMilliseconds < 2000, $"{clock.ElapsedMilliseconds} ms" );
var first = Write( graph );
check( $"{what} round-trips idempotently",
first == Write( Read( first, new DiagnosticSink() ) ), null );
GraphQueries.TryFindCycle( graph, out _ );
GraphQueries.TopologicalOrder( graph );
GraphQueries.Validate( graph );
}
catch ( Exception e )
{
check( $"{what} does not throw", false, e.GetType().Name + ": " + e.Message );
}
}
}
/// <summary>
/// <see cref="Restore"/> has to be observationally identical to reading the same text into a fresh
/// document. Undo is only trustworthy while that holds: a restore that leaves anything of the
/// previous state behind is a document that silently disagrees with its own history.
/// </summary>
static void RestoreEquivalenceTests( Action<string, bool, string> check )
{
var documents = new[]
{
SelfTestDocument,
"""{ "schema":1, "nodes":[] }""",
"""{ "schema":1, "id":"aaa","nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[3,4]}] }""",
"""{ "schema":1, "id":"bbb","nodes":[{"id":"a","type":"nope","v":1,"pos":[3,4],"props":{"Q":1}}] }""",
"""
{ "schema":1, "id":"ccc", "kind":"subgraph",
"meta":{"title":"T"},
"parameters":[{"id":"p","name":"A","type":"float3","default":[1,2,3]}],
"keywords":[{"id":"k","name":"F_X","kind":"Static","values":["a","b"],"default":1}],
"groups":[{"id":"g","title":"T","rect":[1,2,3,4],"color":"Red"}],
"notes":[{"id":"n","pos":[1,2],"size":[3,4],"color":"Blue","text":"hi"}],
"nodes":[{"id":"a","type":"prism.internal.selftest","v":1,"pos":[3,4],"props":{"ExtraPorts":2}},
{"id":"b","type":"prism.internal.selftest","v":1,"pos":[9,4]}],
"edges":[{"id":"e","from":{"node":"a","port":"Out"},"to":{"node":"b","port":"B"},"via":[[1,2]],"fill":1}],
"view":{"center":[5,6],"zoom":0.5} }
"""
};
var mismatches = new List<string>();
for ( int i = 0; i < documents.Length; i++ )
{
for ( int j = 0; j < documents.Length; j++ )
{
var live = Read( documents[i], new DiagnosticSink() );
var fresh = Read( documents[j], new DiagnosticSink() );
if ( live is null || fresh is null ) continue;
// A document that declares no id gets a fresh one on every read, and Restore is defined to
// keep the live one in that case, so identity is normalised away before comparing.
fresh.DocumentId = "-";
var expected = Write( fresh );
Restore( live, documents[j], new DiagnosticSink() );
live.DocumentId = "-";
if ( !string.Equals( expected, Write( live ), StringComparison.Ordinal ) )
{
mismatches.Add( $"{i}->{j}: {Diff( expected, Write( live ) )}" );
}
}
}
check( $"restoring any of {documents.Length} documents over any other matches a fresh read",
mismatches.Count == 0, First( mismatches ) );
// Restoring from garbage must leave a usable document, and the real snapshot must recover it.
var target = Read( SelfTestDocument, new DiagnosticSink() );
var original = Write( target );
var threw = false;
foreach ( var junk in new[] { null, "", "not json", "[]", "42", "{}", "{\"nodes\":5}" } )
{
try
{
Restore( target, junk, new DiagnosticSink() );
}
catch
{
threw = true;
}
}
check( "restoring from garbage never throws", !threw, null );
check( "and the real snapshot still recovers everything",
Restore( target, original, new DiagnosticSink() ) && Write( target ) == original,
Diff( original, Write( target ) ) );
}
/// <summary>
/// The acceptance gate for undo: five hundred randomised edits of every kind, undone all the way,
/// must leave the document byte-identical — and redone all the way, byte-identical to where it
/// ended. Then the same again after a random walk through the history panel.
/// </summary>
static void UndoStormTests( Action<string, bool, string> check )
{
if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;
var graph = new PrismGraph();
var undo = new PrismUndoStack( graph ) { CoalesceMs = 0, Capacity = 4096 };
var mutations = new GraphMutations( graph, undo );
for ( int i = 0; i < 6; i++ )
{
mutations.AddNode( PrismSelfTestNode.TypeId, new Vector2( i * 160f, 0f ) );
}
mutations.AddParameter( "Roughness", ShaderType.Float );
mutations.AddKeyword( "F_STORM", ComboKind.Feature );
undo.Clear();
var start = Write( graph );
var random = new Random( 20260811 );
var outPort = PortId.Parse( "Out" );
var inPort = PortId.Parse( "B" );
var clipboard = (string)null;
var failure = (string)null;
for ( int step = 0; step < 500 && failure is null; step++ )
{
var nodes = graph.Nodes.ToArray();
try
{
switch ( random.Next( 14 ) )
{
case 0:
mutations.AddNode( PrismSelfTestNode.TypeId,
new Vector2( random.Next( -900, 900 ), random.Next( -900, 900 ) ) );
break;
case 1 when nodes.Length > 2:
mutations.RemoveNode( nodes[random.Next( nodes.Length )].Id );
break;
case 2 when nodes.Length > 0:
mutations.Move( nodes[random.Next( nodes.Length )].Id,
new Vector2( random.Next( -900, 900 ), random.Next( -900, 900 ) ) );
break;
case 3 when nodes.Length > 1:
mutations.Connect( new PortRef( nodes[random.Next( nodes.Length )].Id, outPort ),
new PortRef( nodes[random.Next( nodes.Length )].Id, inPort ) );
break;
case 4 when graph.Edges.Count > 0:
mutations.Disconnect( graph.Edges[random.Next( graph.Edges.Count )].Id );
break;
case 5 when nodes.Length > 0:
mutations.SetProperty( nodes[random.Next( nodes.Length )].Id, "Channel", random.Next( 32 ) );
break;
case 6 when nodes.Length > 0:
mutations.SetInlineValue( nodes[random.Next( nodes.Length )].Id, PortId.Parse( "A" ),
(float)random.NextDouble() );
break;
case 7:
mutations.AddParameter( "P" + random.Next( 5 ), ShaderType.Float3 );
break;
case 8 when graph.Parameters.Count > 1:
mutations.RemoveParameter( graph.Parameters[random.Next( graph.Parameters.Count )].Id );
break;
case 9:
mutations.AddGroup( new GraphGroup
{
Id = Ids.NewShortId(),
Title = "Group " + step,
Position = new Vector2( random.Next( 400 ), random.Next( 400 ) ),
Size = new Vector2( 320f, 200f )
} );
break;
case 10 when nodes.Length > 0:
clipboard = mutations.Copy( nodes.Take( 2 ).Select( x => x.Id ) );
break;
case 11 when clipboard is not null:
mutations.Paste( clipboard, new Vector2( random.Next( 400 ), random.Next( 400 ) ) );
break;
case 12 when nodes.Length > 0:
mutations.SetProperty( nodes[random.Next( nodes.Length )].Id, "ExtraPorts", random.Next( 4 ) );
break;
case 13 when nodes.Length > 0:
mutations.SetFlag( nodes[random.Next( nodes.Length )].Id, NodeFlags.Preview,
random.Next( 2 ) == 0 );
break;
}
}
catch ( Exception e )
{
failure = $"step {step}: {e.GetType().Name}: {e.Message}";
}
}
check( "500 randomised edits of every kind ran without throwing", failure is null, failure );
if ( failure is not null ) return;
var end = Write( graph );
check( "the storm changed the document", start != end, null );
var guard = 0;
while ( undo.CanUndo && guard++ < 5000 ) undo.Undo();
check( "undoing 500 edits restores the original byte for byte", Write( graph ) == start,
Diff( start, Write( graph ) ) );
guard = 0;
while ( undo.CanRedo && guard++ < 5000 ) undo.Redo();
check( "redoing 500 edits restores the final document byte for byte", Write( graph ) == end,
Diff( end, Write( graph ) ) );
var walk = new Random( 7 );
for ( int i = 0; i < 200; i++ )
{
undo.JumpTo( walk.Next( -5, undo.Count + 5 ) );
}
check( "a random walk through the history panel is still exact at the bottom",
undo.JumpTo( 0 ) && Write( graph ) == start, Diff( start, Write( graph ) ) );
check( "and at the top", undo.JumpTo( undo.Count ) && Write( graph ) == end, null );
undo.Commit();
undo.Commit();
check( "stray commits leave the stack usable", !undo.IsCapturing && undo.CanUndo, null );
using ( undo.Scope( "unbalanced" ) )
{
undo.Push( "leaked" );
}
check( "an unbalanced push is repaired rather than poisoning the stack", !undo.IsCapturing, null );
}
/// <summary>
/// Deep and wide. The numbers are deliberately past anything a person would author: a traversal
/// that recurses instead of iterating dies at a few thousand, and it dies by taking the editor with
/// it rather than by reporting anything.
/// </summary>
static void ScaleTests( Action<string, bool, string> check )
{
if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;
var outPort = PortId.Parse( "Out" );
var inPort = PortId.Parse( "B" );
var wide = new PrismGraph();
for ( int i = 0; i < 5000; i++ )
{
var node = NodeRegistry.Create( PrismSelfTestNode.TypeId );
node.Position = new Vector2( i % 100 * 200f, i / 100 * 200f );
wide.AddNode( node );
}
check( "5000 nodes can be added", wide.Nodes.Count == 5000, $"{wide.Nodes.Count}" );
var text = Write( wide );
check( "5000 nodes round-trip", Read( text, new DiagnosticSink() )?.Nodes.Count == 5000, null );
var fan = new PrismGraph();
var source = fan.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
for ( int i = 0; i < 200; i++ )
{
var node = fan.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
fan.Connect( new PortRef( source.Id, outPort ), new PortRef( node.Id, inPort ) );
}
check( "one output drives 200 connections", fan.Edges.Count == 200, $"{fan.Edges.Count}" );
check( "200 outgoing connections round-trip",
Read( Write( fan ), new DiagnosticSink() )?.Edges.Count == 200, null );
var chain = new PrismGraph();
PrismNode previous = null;
for ( int i = 0; i < 5000; i++ )
{
var node = chain.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
if ( previous is not null )
{
chain.AddEdge( Edge.Between( chain.NewEdgeId(), new PortRef( previous.Id, outPort ),
new PortRef( node.Id, inPort ) ) );
}
previous = node;
}
try
{
check( "a 5000-deep chain is acyclic", !GraphQueries.TryFindCycle( chain, out _ ), null );
check( "a 5000-deep chain sorts topologically",
GraphQueries.TopologicalOrder( chain ).Count == 5000, null );
check( "a 5000-deep dependency subtree resolves",
GraphQueries.DependencySubtree( chain, previous.Id ).Count == 5000, null );
chain.AddEdge( Edge.Between( chain.NewEdgeId(), new PortRef( previous.Id, outPort ),
new PortRef( chain.Nodes[0].Id, inPort ) ) );
check( "a 5000-deep cycle is detected rather than overflowing the stack",
GraphQueries.TryFindCycle( chain, out var cycle ) && cycle.Count == 5001, null );
var described = GraphQueries.DescribeCycle( chain, GraphQueries.FindCycles( chain, 1 ).FirstOrDefault() );
check( "a huge cycle is described in a length a human and a tooltip can hold",
described.Length is > 0 and < 4096, $"{described.Length} chars" );
}
catch ( Exception e )
{
check( "5000-deep traversals do not throw", false, e.GetType().Name + ": " + e.Message );
}
// A connection with more routing waypoints than anyone could have placed by hand — one drag
// against a runaway auto-router is all it takes, and every one of them is read, written and
// hit-tested.
try
{
var via = new JsonArray();
for ( int i = 0; i < 10000; i++ ) via.Add( new JsonArray( i, i ) );
var document = new JsonObject
{
[KeySchema] = 1,
["nodes"] = new JsonArray(
new JsonObject
{
[KeyId] = "a", ["type"] = PrismSelfTestNode.TypeId, ["v"] = 1,
["pos"] = new JsonArray( 0, 0 )
},
new JsonObject
{
[KeyId] = "b", ["type"] = PrismSelfTestNode.TypeId, ["v"] = 1,
["pos"] = new JsonArray( 256, 0 )
} ),
["edges"] = new JsonArray( new JsonObject
{
[KeyId] = "e",
["from"] = new JsonObject { ["node"] = "a", ["port"] = "Out" },
["to"] = new JsonObject { ["node"] = "b", ["port"] = "B" },
["via"] = via
} )
};
var loaded = Read( document.ToJsonString( PrismJson.Options ), new DiagnosticSink() );
check( "a connection with ten thousand waypoints loads",
loaded is not null && loaded.Edges.Count == 1, $"{loaded?.Edges.Count}" );
check( "and keeps every one of them",
loaded?.Edges.FirstOrDefault()?.Via?.Length == 10000,
$"{loaded?.Edges.FirstOrDefault()?.Via?.Length}" );
check( "and round-trips", Write( loaded ) == Write( Read( Write( loaded ), new DiagnosticSink() ) ),
null );
}
catch ( Exception e )
{
check( "a huge waypoint list does not throw", false, $"{e.GetType().Name}: {e.Message}" );
}
// Nesting deep enough to blow a recursive walker, in the one place a document nests freely.
var nested = new JsonObject();
var cursor = nested;
for ( int i = 0; i < 200; i++ )
{
var next = new JsonObject();
cursor["x"] = next;
cursor = next;
}
try
{
var deep = new JsonObject { [KeySchema] = 1, ["nodes"] = new JsonArray(), ["x"] = nested };
var json = deep.ToJsonString( PrismJson.Options );
var loaded = Read( json, new DiagnosticSink() );
check( "a 200-level forward-compatibility bag survives a load", loaded is not null, null );
check( "and can be written back out", !string.IsNullOrEmpty( Write( loaded ) ), null );
}
catch ( Exception e )
{
check( "a deeply nested forward-compatibility bag does not throw", false, e.GetType().Name );
}
}
/// <summary>
/// Everything that can go missing between one session and the next: a node type that left the
/// assembly, a parameter deleted out from under the nodes that referenced it, a clipboard payload
/// from somewhere else, a destination that will not accept a write, and the scratch workspace.
/// </summary>
static void RecoveryTests( Action<string, bool, string> check )
{
if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;
// ---- a node type that vanished between save and load
var graph = new PrismGraph();
var a = graph.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
var b = graph.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
graph.Connect( new PortRef( a.Id, PortId.Parse( "Out" ) ), new PortRef( b.Id, PortId.Parse( "B" ) ) );
var vanished = Write( graph ).Replace( PrismSelfTestNode.TypeId, "vanished.node.type" );
var sink = new DiagnosticSink();
var loaded = Read( vanished, sink );
check( "a document whose node type left the assembly still loads",
loaded is not null && loaded.Nodes.Count == 2, null );
check( "every node of the missing type became a placeholder",
loaded.Nodes.All( x => x is UnknownNode ), null );
check( "the missing type was reported", sink.Count > 0, null );
check( "the connection between two placeholders survived",
loaded.Edges.Count + loaded.BrokenEdges.Count == 1,
$"{loaded.Edges.Count} live, {loaded.BrokenEdges.Count} broken" );
check( "and the document re-saves byte-identically", Write( loaded ) == vanished,
Diff( vanished, Write( loaded ) ) );
var reinstalled = Read( vanished.Replace( "vanished.node.type", PrismSelfTestNode.TypeId ),
new DiagnosticSink() );
check( "installing the missing addon again brings the real nodes back",
reinstalled is not null && reinstalled.Nodes.All( x => x is PrismSelfTestNode ), null );
check( "and brings the connection back live", reinstalled.Edges.Count == 1, null );
// ---- a parameter deleted while a node still references it
var referencing = new PrismGraph();
var parameter = referencing.AddParameter( new Parameter( "Rough", ShaderType.Float ) );
var user = referencing.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
NodeProperties.Set( user, "Label", parameter.Id.Value );
referencing.RemoveParameter( parameter.Id );
try
{
var written = Write( referencing );
check( "a dangling parameter reference round-trips",
written == Write( Read( written, new DiagnosticSink() ) ), null );
check( "and validation reports rather than throws",
GraphQueries.Validate( referencing ) is not null, null );
}
catch ( Exception e )
{
check( "a dangling parameter reference does not throw", false, e.GetType().Name );
}
// ---- foreign and truncated clipboard payloads
var host = Read( SelfTestDocument, new DiagnosticSink() );
var untouched = Write( host );
var accepted = 0;
var clipThrew = (string)null;
foreach ( var junk in new[] { null, "", " ", "hello", "prism.graph:", "prism.graph:!!!!",
"prism.graph:aGVsbG8=", "other.graph:abc" } )
{
try
{
var result = ClipboardCodec.Paste( host, junk, Vector2.Zero, new DiagnosticSink() );
if ( result is { Ok: true, Nodes.Count: > 0 } ) accepted++;
}
catch ( Exception e )
{
clipThrew = $"{junk}: {e.GetType().Name}";
}
}
check( "a foreign clipboard payload never throws", clipThrew is null, clipThrew );
check( "and is never pasted", accepted == 0, $"{accepted} accepted" );
check( "a refused paste leaves the document untouched", Write( host ) == untouched, null );
var payload = ClipboardCodec.Encode( host, host.Nodes.Take( 3 ).ToArray() );
var truncationThrew = (string)null;
for ( int cut = 1; cut < payload.Length; cut += Math.Max( 1, payload.Length / 25 ) )
{
try
{
ClipboardCodec.Paste( host, payload[..cut], Vector2.Zero, new DiagnosticSink() );
}
catch ( Exception e )
{
truncationThrew = $"cut at {cut}: {e.GetType().Name}";
break;
}
}
check( "a truncated clipboard payload never throws", truncationThrew is null, truncationThrew );
// The clipboard is the only input to the editor that comes from outside it entirely, so a payload
// that expands to more memory than the machine has must be refused rather than decompressed.
try
{
var bomb = new System.IO.MemoryStream();
using ( var zip = new System.IO.Compression.GZipStream( bomb,
System.IO.Compression.CompressionMode.Compress, true ) )
{
var chunk = new byte[1024 * 1024];
for ( int i = 0; i < ClipboardCodec.MaxDecodedBytes / chunk.Length + 8; i++ )
{
zip.Write( chunk, 0, chunk.Length );
}
}
var clock = System.Diagnostics.Stopwatch.StartNew();
var expanded = ClipboardCodec.TryDecode(
ClipboardCodec.Prefix + System.Convert.ToBase64String( bomb.ToArray() ), out _ );
check( "a clipboard payload that expands past the cap is refused", !expanded, null );
check( "and is refused promptly", clock.ElapsedMilliseconds < 5000, $"{clock.ElapsedMilliseconds} ms" );
}
catch ( Exception e )
{
check( "a decompression bomb never throws", false, e.GetType().Name );
}
// ---- saving somewhere that will not have it
var saveThrew = (string)null;
var accepting = new List<string>();
foreach ( var path in new[] { null, "", " ", Path.GetTempPath() } )
{
try
{
if ( Save( host, path, new DiagnosticSink() ) ) accepting.Add( path ?? "null" );
}
catch ( Exception e )
{
saveThrew = $"{path}: {e.GetType().Name}";
}
}
check( "saving to an impossible path never throws", saveThrew is null, saveThrew );
check( "and never claims to have succeeded", accepting.Count == 0, string.Join( ",", accepting ) );
// ---- the scratch workspace, which is where a killed compile leaves its litter
try
{
var workspace = new Toolchain.TempWorkspace( "self/../../test session" );
check( "a hostile scratch session id cannot escape its folder",
!workspace.SessionId.Contains( ".." ) && !workspace.SessionId.Contains( '/' ), workspace.SessionId );
check( "a hostile scratch file name cannot escape its folder",
!workspace.Relative( "../../boom.txt" ).Contains( ".." ), workspace.Relative( "../../boom.txt" ) );
workspace.SweepPending();
workspace.Sweep();
workspace.Dispose();
workspace.Dispose();
check( "a scratch workspace can be disposed twice", true, null );
}
catch ( Exception e )
{
check( "the scratch workspace never throws", false, e.GetType().Name + ": " + e.Message );
}
// ---- the compile service, cancelled and disposed out from under itself
try
{
var service = new Toolchain.ShaderCompileService( "prismselftest" );
check( "a fresh compile service is not compiling", !service.IsCompiling, null );
service.Cancel();
service.Cancel();
service.ForceRegenerate = true;
check( "the force flag latches", service.ForceRegenerate, null );
// Kill a compile mid-flight, over and over. Every one of these is cancelled before it can
// reach the engine, which is the path that used to be able to leave the flag set and a
// half-written shader behind for the next compile to trip over.
var killed = new PrismGraph();
killed.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
for ( int i = 0; i < 50; i++ )
{
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
var task = service.RequestNow( killed, Compiler.CompileMode.Preview, cancelled.Token );
PrismLog.Guard( "Awaiting a cancelled compile", () => task.Wait( 2000 ) );
}
check( "fifty compiles killed in flight leave nothing running", !service.IsCompiling, null );
check( "and leave no orphan files in the scratch folder",
service.Workspace.Files.Count == 0, string.Join( ",", service.Workspace.Files ) );
service.Dispose();
service.Dispose();
service.Request( new PrismGraph() );
service.Cancel();
check( "a disposed compile service is inert and not stuck compiling", !service.IsCompiling, null );
Toolchain.ShaderCompileService.CancelAll();
check( "cancelling every compile in the editor is safe with none running", true, null );
}
catch ( Exception e )
{
check( "the compile service never throws", false, e.GetType().Name + ": " + e.Message );
}
// ---- hotload, over and over
try
{
var rebuilt = true;
for ( int i = 0; i < 10 && rebuilt; i++ )
{
PrismHotload.FlushAll();
NodeRegistry.EnsureBuilt();
var probe = NodeRegistry.Create( PrismSelfTestNode.TypeId );
rebuilt = NodeRegistry.Count > 0 && probe is not null && probe.Inputs.Count > 0 &&
Read( Write( new PrismGraph() ), new DiagnosticSink() ) is not null;
}
check( "ten hotload flushes rebuild every registry", rebuilt, null );
var before = NodeDescriptors.Describe( typeof( PrismSelfTestNode ) );
// Both migration registries hold delegates compiled into the outgoing assembly, and the
// document-level one runs on the path that rewrites somebody's file.
SchemaMigrations.Register( 0, "self-test", document => document );
NodeMigrations.Register( PrismSelfTestNode.TypeId, 0, "self-test", node => node );
PrismHotload.FlushAll();
check( "a flush really does drop the descriptor cache",
!ReferenceEquals( before, NodeDescriptors.Describe( typeof( PrismSelfTestNode ) ) ), null );
check( "a flush drops every registered document upgrader",
SchemaMigrations.Registered.Count == 0, $"{SchemaMigrations.Registered.Count} left" );
check( "and every registered node upgrader",
NodeMigrations.Registered.Count == 0, $"{NodeMigrations.Registered.Count} left" );
// The text editor's word tables and lexers are behind the same umbrella flush, and they hold
// delegates and lazies belonging to whichever assembly built them.
Editor.Prism.Text.TextCaches.Flush();
check( "the text editor caches rebuild after being dropped",
Editor.Prism.Text.LanguageDb.IntrinsicDb.Names.Count > 0 &&
Editor.Prism.Text.Lexer.Lexers.For( "hlsl" ) is not null, null );
// Leave the editor as we found it: everything above deliberately emptied the caches.
NodeRegistry.EnsureBuilt();
}
catch ( Exception e )
{
check( "hotload recovery never throws", false, e.GetType().Name + ": " + e.Message );
}
}
/// <summary>
/// A port declares a shader type; its <c>[InlineValue]</c> property declares a CLR type; the two do
/// not have to be the same shape. A <c>float3</c> port whose literal lives in a <see cref="Color"/>
/// is the commonest spelling in the node library.
/// <para>
/// Reading a document hands the literal back in the shape the <em>port</em> implies, so every load
/// crosses that boundary. Getting it wrong is invisible in the worst way: the literal is written to
/// disk perfectly and dropped on the way back in, and the user's colour is white again the next time
/// they open the graph, with nothing said anywhere.
/// </para>
/// </summary>
static void LiteralShapeTests( Action<string, bool, string> check )
{
if ( NodeRegistry.Create( PrismSelfTestNode.TypeId ) is not PrismSelfTestNode probe ) return;
var graph = new PrismGraph();
graph.AddNode( probe );
var port = probe.FindInput( PortId.Parse( "C" ) );
var colour = new Color( 0.25f, 0.5f, 0.75f, 1f );
NodeProperties.SetInline( probe, port, colour );
check( "a colour literal reaches the property behind a float3 port",
probe.DefaultC.r.Equals( 0.25f ) && probe.DefaultC.b.Equals( 0.75f ), $"{probe.DefaultC}" );
var text = Write( graph );
var loaded = Read( text, new DiagnosticSink() );
var back = loaded?.Nodes.FirstOrDefault() as PrismSelfTestNode;
check( "and comes back after a save and reload",
back is not null && back.DefaultC.r.Equals( 0.25f ) && back.DefaultC.g.Equals( 0.5f ) &&
back.DefaultC.b.Equals( 0.75f ), $"{back?.DefaultC}" );
check( "and the document is idempotent across that reparse",
loaded is not null && Write( loaded ) == text, Diff( text, Write( loaded ) ) );
// The conversion matrix itself, in both directions and every width.
NodeProperties.Set( probe, nameof( PrismSelfTestNode.DefaultC ), new Vector3( 0.1f, 0.2f, 0.3f ) );
check( "a vector assigned to a colour property pads alpha opaque",
probe.DefaultC.b.Equals( 0.3f ) && probe.DefaultC.a.Equals( 1f ), $"{probe.DefaultC}" );
NodeProperties.Set( probe, nameof( PrismSelfTestNode.DefaultB ), new Color( 0.4f, 0.5f, 0.6f, 0.7f ) );
check( "a colour assigned to a vector property truncates",
probe.DefaultB == new Vector3( 0.4f, 0.5f, 0.6f ), $"{probe.DefaultB}" );
NodeProperties.Set( probe, nameof( PrismSelfTestNode.DefaultB ), 0.8f );
check( "a scalar assigned to a vector property splats",
probe.DefaultB == new Vector3( 0.8f, 0.8f, 0.8f ), $"{probe.DefaultB}" );
NodeProperties.Set( probe, nameof( PrismSelfTestNode.DefaultA ), new Vector3( 0.9f, 0f, 0f ) );
check( "a vector assigned to a scalar property keeps its first component",
probe.DefaultA.Equals( 0.9f ), $"{probe.DefaultA}" );
NodeProperties.Set( probe, nameof( PrismSelfTestNode.DefaultC ), "0.1,0.2,0.3,0.4" );
check( "an engine-formatted colour string assigned to a colour property parses",
probe.DefaultC.a.Equals( 0.4f ), $"{probe.DefaultC}" );
}
/// <summary>
/// The same guarantee across the whole node library, which is where the shapes actually vary: one
/// node of every registered type, a distinctive literal in every inline-bound input, one save, one
/// load, and every literal has to still be there.
/// </summary>
static void LibraryLiteralTests( Action<string, bool, string> check )
{
NodeRegistry.EnsureBuilt();
var graph = new PrismGraph();
var expected = new List<(NodeId Node, PortId Port, object Value)>();
var column = 0f;
object Literal( ShaderType type )
{
if ( type.IsBoolean && type.IsScalar ) return true;
if ( type.IsScalar && type.IsIntegral ) return 3;
return type.Components switch
{
2 => new Vector2( 0.125f, 0.25f ),
3 => new Vector3( 0.125f, 0.25f, 0.375f ),
4 => new Vector4( 0.125f, 0.25f, 0.375f, 0.5f ),
_ => 0.625f
};
}
foreach ( var descriptor in NodeRegistry.All.ToList() )
{
var node = PrismLog.Guard<PrismNode>( $"Creating '{descriptor.Id}'",
() => NodeRegistry.Create( descriptor.Id ) );
if ( node is null ) continue;
node.Position = new Vector2( column += 256f, 0f );
graph.AddNode( node );
foreach ( var input in node.Inputs.ToList() )
{
if ( string.IsNullOrEmpty( input.Def?.InlineValueProperty ) ) continue;
var type = input.Def.FixedType;
// Objects carry a path rather than a number, and a matrix literal has no inline editor.
if ( type.IsObject || type.IsStruct || type.IsMatrix || type.IsVoid ) continue;
if ( input.Def.IsGeneric ) continue;
PrismLog.Guard( $"Setting a literal on '{descriptor.Id}.{input.Id}'",
() => NodeProperties.SetInline( node, input, Literal( type ) ) );
expected.Add( (node.Id, input.Id, NodeProperties.GetInline( node, input )) );
}
}
check( $"{graph.Nodes.Count} node types carry {expected.Count} inline literals between them",
expected.Count > 0, null );
var text = Write( graph );
var loaded = Read( text, new DiagnosticSink() );
check( "a document holding one of every node type reloads", loaded is not null, null );
if ( loaded is null ) return;
var lost = new List<string>();
foreach ( var (nodeId, portId, value) in expected )
{
var node = loaded.FindNode( nodeId );
var port = node?.FindInput( portId );
var actual = port is null ? null : NodeProperties.GetInline( node, port );
if ( ValueCodec.Equal( value, actual ) ) continue;
lost.Add( $"{node?.Descriptor?.Id}.{portId} wrote {ValueCodec.Describe( value )} " +
$"read {ValueCodec.Describe( actual )}" );
}
check( "every inline literal in the library survives a save and reload", lost.Count == 0, First( lost ) );
check( "and the whole library document is idempotent across a reparse", Write( loaded ) == text,
Diff( text, Write( loaded ) ) );
}
/// <summary>
/// Two combos that lower to one name is a block-header parse failure — and that failure arrives with
/// an empty program list and no diagnostic at all, so it has to be impossible rather than reported.
/// Blank names matter for a second reason: a name the reader substitutes and the writer does not is
/// a document that changes every time it is opened.
/// </summary>
static void KeywordNameTests( Action<string, bool, string> check )
{
var graph = new PrismGraph();
var undo = new PrismUndoStack( graph ) { CoalesceMs = 0 };
var mutations = new GraphMutations( graph, undo );
var nameless = mutations.AddKeyword( (string)null, ComboKind.Feature );
check( "a keyword added with no name is given one", !string.IsNullOrWhiteSpace( nameless?.Name ),
nameless?.Name );
var blank = Write( graph );
check( "and its document is idempotent across a reparse",
Write( Read( blank, new DiagnosticSink() ) ) == blank,
Diff( blank, Write( Read( blank, new DiagnosticSink() ) ) ) );
mutations.AddKeyword( "F_PUDDLES", ComboKind.Feature );
mutations.AddKeyword( "f puddles", ComboKind.Feature );
mutations.AddKeyword( "PUDDLES", ComboKind.Feature );
bool Distinct( PrismGraph g ) =>
g.Keywords.Select( x => x.NormalizedName ).Distinct( StringComparer.OrdinalIgnoreCase ).Count() ==
g.Keywords.Count;
check( "three spellings of one combo name become three combos", Distinct( graph ),
string.Join( ",", graph.Keywords.Select( x => x.NormalizedName ) ) );
var statik = mutations.AddKeyword( "PUDDLES", ComboKind.Static );
check( "a static combo may reuse the authored name of a feature",
statik?.NormalizedName == "S_PUDDLES", statik?.NormalizedName );
mutations.EditKeyword( graph.Keywords[0].Id, x => x.Name = "F_PUDDLES" );
check( "renaming a keyword onto another one is repaired", Distinct( graph ),
string.Join( ",", graph.Keywords.Select( x => x.NormalizedName ) ) );
var many = Write( graph );
check( "a document of near-colliding keywords round-trips",
Write( Read( many, new DiagnosticSink() ) ) == many,
Diff( many, Write( Read( many, new DiagnosticSink() ) ) ) );
var guard = 0;
while ( undo.CanUndo && guard++ < 500 ) undo.Undo();
check( "and undoes back to a document with no keywords", graph.Keywords.Count == 0,
$"{graph.Keywords.Count}" );
// A document that declares the same combo twice by hand.
var duplicated = Read( """
{ "schema":1, "keywords":[ {"id":"k1","name":"F_X","kind":"Feature","values":["Off","On"]},
{"id":"k2","name":"F_X","kind":"Feature","values":["Off","On"]} ] }
""", new DiagnosticSink() );
check( "a document declaring one combo twice loads with both kept",
duplicated is not null && duplicated.Keywords.Count == 2, $"{duplicated?.Keywords.Count}" );
check( "and separated", duplicated is not null && Distinct( duplicated ),
string.Join( ",", duplicated?.Keywords.Select( x => x.NormalizedName ) ?? Array.Empty<string>() ) );
var repaired = Write( duplicated );
check( "and the repair is stable", Write( Read( repaired, new DiagnosticSink() ) ) == repaired,
Diff( repaired, Write( Read( repaired, new DiagnosticSink() ) ) ) );
}
/// <summary>
/// Keys the storm invents, chosen to shadow the ones every reader looks for.
/// </summary>
static readonly string[] s_stormKeys =
{
"x", "id", "schema", "kind", "nodes", "edges", "meta", "props", "inline", "flags",
"pos", "size", "type", "v", "from", "to", "via", "name", "values", "somethingNew"
};
/// <summary>
/// <see cref="CorruptionTests"/> poisons one field at a time, which is the shape of a bad migration.
/// A file damaged by a crash, a bad merge or a text editor is damaged in several places at once, and
/// combinations reach states a single edit cannot.
/// </summary>
static void DocumentStormTests( Action<string, bool, string> check )
{
var poisons = Poisons();
var random = new Random( 20260812 );
var thrown = new List<string>();
var unstable = new List<string>();
var nonIdempotent = new List<string>();
var queried = new List<string>();
var cases = 0;
for ( int iteration = 0; iteration < 400; iteration++ )
{
var document = JsonNode.Parse( SelfTestDocument ) as JsonObject;
var labels = new List<string>();
for ( int edit = 0; edit < 1 + random.Next( 4 ); edit++ )
{
var paths = new List<string>();
CollectPaths( document, string.Empty, paths, 0 );
if ( paths.Count == 0 ) break;
var path = paths[random.Next( paths.Count )];
var poison = random.Next( poisons.Length + 2 );
if ( poison == poisons.Length )
{
if ( RemoveAt( document, path ) ) labels.Add( $"{path} := <removed>" );
continue;
}
// Not only damaged fields: a key that should not be there. A key a future schema
// invents belongs in an x bag, and one that shadows a key the reader knows has to be
// survivable wherever it lands.
if ( poison == poisons.Length + 1 )
{
var (parent, _, _) = Locate( document, path );
if ( parent is not JsonObject owner ) continue;
var invented = s_stormKeys[random.Next( s_stormKeys.Length )];
owner[invented] = poisons[random.Next( poisons.Length )]?.DeepClone();
labels.Add( $"{path}/../{invented} := <invented>" );
continue;
}
if ( ReplaceAt( document, path, poisons[poison]?.DeepClone() ) )
{
labels.Add( $"{path} := {poisons[poison]?.ToJsonString() ?? "null"}" );
}
}
if ( labels.Count == 0 ) continue;
cases++;
var what = string.Join( " ; ", labels );
try
{
var graph = Read( document.ToJsonString( PrismJson.Options ), new DiagnosticSink() );
if ( graph is null ) continue;
var first = Write( graph );
if ( !string.Equals( first, Write( graph ), StringComparison.Ordinal ) )
{
unstable.Add( what );
continue;
}
var reread = Read( first, new DiagnosticSink() );
if ( reread is null || !string.Equals( first, Write( reread ), StringComparison.Ordinal ) )
{
nonIdempotent.Add( what );
continue;
}
try
{
GraphQueries.TryFindCycle( graph, out _ );
GraphQueries.TopologicalOrder( graph );
GraphQueries.Validate( graph );
GraphQueries.ReachableFromOutputs( graph );
_ = graph.StructureHash;
_ = graph.ValueHash;
}
catch ( Exception e )
{
queried.Add( $"{what} -> {e.GetType().Name}: {e.Message}" );
}
}
catch ( Exception e )
{
thrown.Add( $"{what} -> {e.GetType().Name}: {e.Message}" );
}
}
check( $"{cases} documents damaged in several places at once all loaded", thrown.Count == 0,
First( thrown ) );
check( "every one of them wrote deterministically", unstable.Count == 0, First( unstable ) );
check( "every one of them wrote idempotently", nonIdempotent.Count == 0, First( nonIdempotent ) );
check( "and every one of them could still be walked", queried.Count == 0, First( queried ) );
}
/// <summary>
/// <see cref="UndoStormTests"/> edits five hundred times and then unwinds. A session does not: it
/// undoes and redoes <em>while</em> it edits, which is what pushes the stack through the states where
/// a redo tail is discarded mid-flight. The indexes are checked after every single step, because an
/// index that drifts is invisible until something much later cannot find a node.
/// </summary>
static void InterleavedUndoTests( Action<string, bool, string> check )
{
if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;
var graph = new PrismGraph();
var undo = new PrismUndoStack( graph ) { CoalesceMs = 0, Capacity = 4096 };
var mutations = new GraphMutations( graph, undo );
for ( int i = 0; i < 6; i++ )
{
mutations.AddNode( PrismSelfTestNode.TypeId, new Vector2( i * 160f, 0f ) );
}
mutations.AddParameter( "Rough", ShaderType.Float );
mutations.AddKeyword( "F_A", ComboKind.Feature );
undo.Clear();
var start = Write( graph );
var random = new Random( 616 );
var outPort = PortId.Parse( "Out" );
var inPort = PortId.Parse( "B" );
var clipboard = (string)null;
var failure = (string)null;
var drift = (string)null;
for ( int step = 0; step < 500 && failure is null; step++ )
{
var nodes = graph.Nodes.ToArray();
try
{
switch ( random.Next( 18 ) )
{
case 0:
mutations.AddNode( PrismSelfTestNode.TypeId,
new Vector2( random.Next( -900, 900 ), random.Next( -900, 900 ) ) );
break;
case 1 when nodes.Length > 2:
mutations.RemoveNode( nodes[random.Next( nodes.Length )].Id );
break;
case 2 when nodes.Length > 0:
mutations.Move( nodes[random.Next( nodes.Length )].Id,
new Vector2( random.Next( -900, 900 ), random.Next( -900, 900 ) ) );
break;
case 3 when nodes.Length > 1:
mutations.Connect( new PortRef( nodes[random.Next( nodes.Length )].Id, outPort ),
new PortRef( nodes[random.Next( nodes.Length )].Id, inPort ) );
break;
case 4 when graph.Edges.Count > 0:
mutations.Disconnect( graph.Edges[random.Next( graph.Edges.Count )].Id );
break;
case 5 when nodes.Length > 0:
mutations.SetProperty( nodes[random.Next( nodes.Length )].Id, "Channel", random.Next( 32 ) );
break;
case 6 when nodes.Length > 0:
mutations.SetInlineValue( nodes[random.Next( nodes.Length )].Id, PortId.Parse( "C" ),
new Color( (float)random.NextDouble(), 0.5f, 0.25f, 1f ) );
break;
case 7:
mutations.AddParameter( "P" + random.Next( 5 ), ShaderType.Float3 );
break;
case 8 when graph.Parameters.Count > 1:
mutations.RemoveParameter( graph.Parameters[random.Next( graph.Parameters.Count )].Id );
break;
case 9 when graph.Edges.Count > 0:
mutations.InsertReroute( graph.Edges[random.Next( graph.Edges.Count )].Id,
new Vector2( random.Next( 200 ), random.Next( 200 ) ) );
break;
case 10 when nodes.Length > 0:
clipboard = mutations.Copy( nodes.Take( 3 ).Select( x => x.Id ) );
break;
case 11 when clipboard is not null:
mutations.Paste( clipboard, new Vector2( random.Next( 400 ), random.Next( 400 ) ) );
break;
case 12 when graph.Edges.Count > 0:
mutations.SetEdgeFill( graph.Edges[random.Next( graph.Edges.Count )].Id,
random.Next( 2 ) == 0 ? null : (float)random.NextDouble() );
break;
case 13 when graph.Edges.Count > 0:
mutations.SetEdgeVia( graph.Edges[random.Next( graph.Edges.Count )].Id,
new[] { new Vector2( random.Next( 200 ), random.Next( 200 ) ) } );
break;
case 14 when graph.Parameters.Count > 0:
mutations.RenameParameter( graph.Parameters[random.Next( graph.Parameters.Count )].Id,
"R" + random.Next( 6 ) );
break;
case 15:
mutations.AddKeyword( "F_K" + random.Next( 4 ), ComboKind.Feature );
break;
case 16 when nodes.Length > 1:
mutations.Duplicate( nodes.Take( 2 ).Select( x => x.Id ), new Vector2( 40f, 40f ) );
break;
case 17:
mutations.UpdateSettings( x => x.Uv2 = random.Next( 2 ) == 0 );
break;
}
// The part a storm that only unwinds at the end never reaches: undoing and redoing while
// the document is still being edited, so a new edit lands on a discarded redo tail.
if ( random.Next( 5 ) == 0 && undo.CanUndo ) undo.Undo();
if ( random.Next( 8 ) == 0 && undo.CanRedo ) undo.Redo();
drift ??= IndexProblem( graph );
}
catch ( Exception e )
{
failure = $"step {step}: {e.GetType().Name}: {e.Message}";
}
}
check( "500 edits interleaved with undo and redo ran without throwing", failure is null, failure );
check( "and the node and edge indexes never drifted", drift is null, drift );
if ( failure is not null ) return;
var end = Write( graph );
var guard = 0;
while ( undo.CanUndo && guard++ < 20000 ) undo.Undo();
check( "unwinding an interleaved history is still byte-exact", Write( graph ) == start,
Diff( start, Write( graph ) ) );
guard = 0;
while ( undo.CanRedo && guard++ < 20000 ) undo.Redo();
check( "and rewinding it is too", Write( graph ) == end, Diff( end, Write( graph ) ) );
check( "and the stack is not left capturing", !undo.IsCapturing, null );
}
/// <summary>
/// Subgraphs are the one place the document graph is not the whole graph, so they are the one place a
/// traversal can leave the document it started in and never come back. A subgraph that instances
/// itself has to be reported; fifty levels of nesting has to stop; and an asset that was renamed or
/// deleted has to leave a node that says so rather than a node that is gone.
/// </summary>
static void SubgraphRecoveryTests( Action<string, bool, string> check )
{
if ( NodeRegistry.Find( Nodes.SubgraphInstanceNode.TypeId ) is null ) return;
try
{
Nodes.SubgraphLibrary.Flush();
var self = new PrismGraph( true );
self.AddNode( NodeRegistry.Create( Nodes.SubgraphOutputNode.TypeId ) );
var instance = self.AddNode( NodeRegistry.Create( Nodes.SubgraphInstanceNode.TypeId ) )
as Nodes.SubgraphInstanceNode;
Nodes.SubgraphLibrary.Register( "prism/selftest/self.prismfn", self );
instance.SubgraphPath = "prism/selftest/self.prismfn";
var recursive = GraphCompiler.Compile( self, CompileMode.Final );
check( "a subgraph that instances itself is reported, not recursed into",
recursive is not null && recursive.Diagnostics.Any( x => x.Severity == DiagnosticSeverity.Error ),
recursive is null ? "no result" : $"{recursive.Diagnostics.Count} diagnostics" );
// Fifty documents, each instancing the next.
Nodes.SubgraphLibrary.Flush();
var chain = new List<PrismGraph>();
for ( int i = 0; i < 50; i++ )
{
var document = new PrismGraph( true );
document.AddNode( NodeRegistry.Create( Nodes.SubgraphOutputNode.TypeId ) );
chain.Add( document );
Nodes.SubgraphLibrary.Register( $"prism/selftest/chain{i}.prismfn", document );
}
for ( int i = 0; i < chain.Count - 1; i++ )
{
var link = chain[i].AddNode( NodeRegistry.Create( Nodes.SubgraphInstanceNode.TypeId ) )
as Nodes.SubgraphInstanceNode;
link.SubgraphPath = $"prism/selftest/chain{i + 1}.prismfn";
}
var clock = System.Diagnostics.Stopwatch.StartNew();
var deep = GraphCompiler.Compile( chain[0], CompileMode.Final );
check( "fifty levels of subgraph nesting terminates rather than overflowing the stack",
deep is not null && clock.ElapsedMilliseconds < 30000, $"{clock.ElapsedMilliseconds} ms" );
check( "and says so", deep is not null && deep.Diagnostics.Any( x => x.Severity == DiagnosticSeverity.Error ),
null );
// An asset that is not there, and one that came back under a new name.
Nodes.SubgraphLibrary.Flush();
var host = new PrismGraph();
var missing = host.AddNode( NodeRegistry.Create( Nodes.SubgraphInstanceNode.TypeId ) )
as Nodes.SubgraphInstanceNode;
missing.SubgraphPath = "prism/selftest/deleted.prismfn";
check( "an instance of a deleted subgraph reports rather than disappears", missing.IsBroken,
missing.LoadError );
var text = Write( host );
check( "and round-trips", Write( Read( text, new DiagnosticSink() ) ) == text,
Diff( text, Write( Read( text, new DiagnosticSink() ) ) ) );
check( "and compiling around it reports rather than throws",
GraphCompiler.Compile( host, CompileMode.Final ) is not null, null );
var renamed = new PrismGraph( true );
renamed.AddNode( NodeRegistry.Create( Nodes.SubgraphOutputNode.TypeId ) );
Nodes.SubgraphLibrary.Register( "prism/selftest/renamed.prismfn", renamed );
missing.SubgraphPath = "prism/selftest/renamed.prismfn";
check( "and repointing it at the renamed asset clears the error", !missing.IsBroken,
missing.LoadError );
}
catch ( Exception e )
{
check( "the subgraph recovery paths never throw", false, $"{e.GetType().Name}: {e.Message}" );
}
finally
{
PrismLog.Guard( "Clearing the self-test subgraphs", Nodes.SubgraphLibrary.Flush );
}
}
/// <summary>
/// The clipboard is the only input to the editor that comes from outside it entirely: another
/// application, an older build, or a string a user assembled by hand. A payload damaged anywhere in
/// its length must be refused, and refusing it must leave the document exactly as it was.
/// </summary>
static void ClipboardFuzzTests( Action<string, bool, string> check )
{
var host = Read( SelfTestDocument, new DiagnosticSink() );
if ( host is null ) return;
var before = Write( host );
var payload = ClipboardCodec.Encode( host, host.Nodes.Take( 4 ).ToArray() );
check( "a clipboard payload encodes", !string.IsNullOrEmpty( payload ), null );
if ( string.IsNullOrEmpty( payload ) ) return;
var random = new Random( 5150 );
var failure = (string)null;
for ( int iteration = 0; iteration < 1000 && failure is null; iteration++ )
{
var chars = payload.ToCharArray();
for ( int edit = 0; edit < 1 + random.Next( 3 ); edit++ )
{
chars[random.Next( chars.Length )] = (char)( 32 + random.Next( 90 ) );
}
try
{
ClipboardCodec.Paste( host, new string( chars ), Vector2.Zero, new DiagnosticSink() );
}
catch ( Exception e )
{
failure = $"{iteration}: {e.GetType().Name}: {e.Message}";
}
}
check( "a thousand damaged clipboard payloads never throw", failure is null, failure );
check( "and the document they were pasted into is still coherent", IndexProblem( host ) is null,
IndexProblem( host ) );
check( "and still round-trips", Write( Read( Write( host ), new DiagnosticSink() ) ) == Write( host ),
null );
_ = before;
}
/// <summary>
/// An undo stack is bounded, so a long session is always throwing its oldest states away. Trimming
/// has to move the current level with them — a level that still points at where an entry used to be
/// is a stack that restores the wrong document, and it does it silently.
/// </summary>
static void UndoCapacityTests( Action<string, bool, string> check )
{
if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;
var graph = new PrismGraph();
var undo = new PrismUndoStack( graph ) { CoalesceMs = 0, Capacity = 8 };
var mutations = new GraphMutations( graph, undo );
var states = new List<string> { Write( graph ) };
for ( int i = 0; i < 300; i++ )
{
mutations.AddNode( PrismSelfTestNode.TypeId, new Vector2( i * 16f, 0f ) );
states.Add( Write( graph ) );
}
check( "a bounded stack keeps exactly its capacity", undo.Count == 8, $"{undo.Count}" );
check( "and sits at the top of what it kept", undo.Level == undo.Count, $"{undo.Level}/{undo.Count}" );
var top = Write( graph );
var guard = 0;
while ( undo.CanUndo && guard++ < 100 ) undo.Undo();
check( "undoing everything it still holds reaches the oldest state it kept",
Write( graph ) == states[states.Count - 1 - 8], Diff( states[states.Count - 1 - 8], Write( graph ) ) );
check( "and there is nothing further back to reach", !undo.CanUndo, null );
guard = 0;
while ( undo.CanRedo && guard++ < 100 ) undo.Redo();
check( "and redoing returns to exactly where it was", Write( graph ) == top,
Diff( top, Write( graph ) ) );
// Trimming while the user is part-way down their history is the case that moves the level.
undo.Undo();
undo.Undo();
undo.Undo();
var midway = Write( graph );
check( "a new edit from part-way down discards the redo branch",
mutations.AddNode( PrismSelfTestNode.TypeId, Vector2.Zero ) is not null && !undo.CanRedo, null );
undo.Undo();
check( "and undoing it comes back to where the branch started", Write( graph ) == midway,
Diff( midway, Write( graph ) ) );
}
/// <summary>
/// Pasting is the one edit whose input the document did not produce: it may come from another graph,
/// another kind of graph, an older build, or a version of the addon with node types this one has
/// never heard of. None of those may lose anything or collide with what is already here.
/// </summary>
static void PasteSemanticTests( Action<string, bool, string> check )
{
if ( NodeRegistry.Find( PrismSelfTestNode.TypeId ) is null ) return;
// A fragment whose nodes reference a blackboard parameter the target has never seen.
var source = new PrismGraph();
var parameter = source.AddParameter( new Parameter( "Roughness", ShaderType.Float ) { Default = 0.4f } );
var user = source.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
NodeProperties.Set( user, "Label", parameter.Id.Value );
var payload = ClipboardCodec.Encode( source, source.Nodes );
var target = new PrismGraph();
var pasted = ClipboardCodec.Paste( target, payload, Vector2.Zero, new DiagnosticSink() );
check( "a fragment pastes into a document that never saw it", pasted is { Ok: true }, pasted?.Error );
check( "and brings the parameter its nodes referenced with it",
target.Parameters.Count == 1 && target.Parameters[0].Name == "Roughness",
string.Join( ",", target.Parameters.Select( x => x.Name ) ) );
// The same payload pasted many times: ids are minted fresh every time or the document breaks.
for ( int i = 0; i < 50; i++ )
{
ClipboardCodec.Paste( target, payload, new Vector2( i * 32f, 0f ), new DiagnosticSink() );
}
check( "fifty pastes of one payload never collide", IndexProblem( target ) is null, IndexProblem( target ) );
check( "and produce fifty-one copies", target.Nodes.Count == 51, $"{target.Nodes.Count}" );
check( "and the result round-trips",
Write( Read( Write( target ), new DiagnosticSink() ) ) == Write( target ), null );
// A fragment containing a node type this build does not have.
var foreign = ClipboardCodec.EncodeJson( """
{ "schema":1, "kind":"fragment",
"nodes":[ {"id":"a","type":"acme.nope.node","v":7,"pos":[0,0],
"props":{"Secret":[1,2,3]},"x":{"vendor":"acme"}} ] }
""" );
var host = new PrismGraph();
var strange = ClipboardCodec.Paste( host, foreign, Vector2.Zero, new DiagnosticSink() );
check( "a fragment of an unknown node type still pastes", strange is { Ok: true }, strange?.Error );
check( "and the unknown node arrives whole",
host.Nodes.FirstOrDefault() is UnknownNode unknown &&
unknown.Raw?["props"]?["Secret"] is not null, null );
check( "and survives being saved and reopened",
Read( Write( host ), new DiagnosticSink() )?.Nodes.FirstOrDefault() is UnknownNode, null );
// Something large enough that a per-node allocation would be noticeable.
var big = new PrismGraph();
for ( int i = 0; i < 500; i++ )
{
var node = big.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
node.Position = new Vector2( i * 32f, 0f );
}
var bulk = ClipboardCodec.Encode( big, big.Nodes );
var clock = System.Diagnostics.Stopwatch.StartNew();
var landed = ClipboardCodec.Paste( new PrismGraph(), bulk, Vector2.Zero, new DiagnosticSink() );
check( "a five hundred node paste lands", landed is { Ok: true } && landed.Nodes.Count == 500,
$"{landed?.Nodes.Count}" );
check( "and lands promptly", clock.ElapsedMilliseconds < 5000, $"{clock.ElapsedMilliseconds} ms" );
// Pasting a shader fragment into a subgraph, and the other way round.
var subgraph = new PrismGraph( true );
check( "a shader fragment pastes into a subgraph",
ClipboardCodec.Paste( subgraph, payload, Vector2.Zero, new DiagnosticSink() ) is { Ok: true }, null );
check( "and the subgraph is still a subgraph", subgraph.IsSubgraph, null );
}
/// <summary>
/// Importing somebody else's <c>.shdrgrph</c> is the one path where the input was produced by another
/// program entirely, so nothing about its shape can be assumed.
/// </summary>
static void LegacyCorruptionTests( Action<string, bool, string> check )
{
var paths = new List<string>();
CollectPaths( JsonNode.Parse( LegacyDocument ), string.Empty, paths, 0 );
var poisons = Poisons();
var thrown = new List<string>();
var lost = new List<string>();
var cases = 0;
foreach ( var path in paths )
{
for ( int p = -1; p < poisons.Length; p++ )
{
var document = JsonNode.Parse( LegacyDocument ) as JsonObject;
var label = $"{path} := {( p < 0 ? "<removed>" : poisons[p]?.ToJsonString() ?? "null" )}";
if ( p < 0 )
{
if ( !RemoveAt( document, path ) ) continue;
}
else if ( !ReplaceAt( document, path, poisons[p]?.DeepClone() ) )
{
continue;
}
cases++;
try
{
var imported = LegacyShaderGraphImporter.Import( document.ToJsonString( PrismJson.Options ),
false, new DiagnosticSink() );
if ( imported is null ) continue;
var text = Write( imported );
if ( !string.Equals( text, Write( Read( text, new DiagnosticSink() ) ), StringComparison.Ordinal ) )
{
lost.Add( label );
}
}
catch ( Exception e )
{
thrown.Add( $"{label} -> {e.GetType().Name}: {e.Message}" );
}
}
}
check( $"{cases} corruptions of a legacy document were all importable", thrown.Count == 0,
First( thrown ) );
check( "and everything they produced round-tripped", lost.Count == 0, First( lost ) );
foreach ( var junk in new[] { null, "", " ", "not json", "[]", "42", "{}", "{\"nodes\":5}" } )
{
try
{
LegacyShaderGraphImporter.Import( junk, false, new DiagnosticSink() );
}
catch ( Exception e )
{
check( "importing junk never throws", false, $"{e.GetType().Name}: {e.Message}" );
return;
}
}
check( "importing junk never throws", true, null );
}
/// <summary>
/// A <c>.prism</c> is a <c>GameResource</c>, and <c>GameResource.Serialize()</c> is what
/// <c>Asset.SaveToDisk</c> writes over the source file.
/// <para>
/// That path is reachable from the asset inspector's Save button, its <c>CTRL+S</c> shortcut, its
/// discard prompt and the editor's own quit dialog — none of which Prism controls. A resource that
/// serializes to less than it loaded therefore replaces the user's graph in place, with no undo. It
/// used to: <c>OnJsonSerialize</c> unconditionally wrote a one-node skeleton with a fresh random id.
/// </para>
/// <para>
/// This drives the real engine entry points — <c>LoadFromJson</c> and <c>Serialize</c> — rather than
/// Prism's own reader, because the thing being checked is precisely the boundary between the two.
/// Note that <c>[JsonExtensionData]</c> would not have worked here: s&box's <c>Json</c> helpers
/// are hand-written property walkers and implement no such thing.
/// </para>
/// </summary>
static void GameResourceTests( Action<string, bool, string> check )
{
const string document = """
{
"schema": 1,
"id": "a7f3k2b9",
"kind": "shader",
"meta": { "title": "Tinted" },
"settings": { "domain": "Surface" },
"parameters": [ { "id": "zwe5qxbe", "name": "Tint", "type": "float4" } ],
"nodes": [
{ "id": "i5epv2so", "type": "prism.output.surface", "v": 1, "pos": [ 320, 0 ] },
{ "id": "0k03jodr", "type": "prism.math.multiply", "v": 1, "pos": [ 0, 0 ] }
],
"edges": [
{ "id": "oumdfn0v", "from": { "node": "0k03jodr", "port": "Out" },
"to": { "node": "i5epv2so", "port": "Albedo" } }
]
}
""";
JsonObject first = null;
string text = null;
var loaded = PrismLog.Guard( "Round-tripping a Prism GameResource", () =>
{
var resource = new Sandbox.Prism.PrismGraphFile();
resource.LoadFromJson( document );
first = resource.Serialize();
first.Remove( "__version" );
text = first.ToJsonString();
var again = resource.Serialize();
again.Remove( "__version" );
return text == again.ToJsonString();
} );
check( "a .prism GameResource keeps its nodes through a serialize",
first?["nodes"] is JsonArray nodes && nodes.Count == 2,
first is null ? "the round trip threw" : $"got {( first["nodes"] as JsonArray )?.Count ?? 0} nodes" );
check( "a .prism GameResource keeps its edges through a serialize",
first?["edges"] is JsonArray edges && edges.Count == 1, null );
check( "a .prism GameResource keeps its parameters through a serialize",
first?["parameters"] is JsonArray parameters && parameters.Count == 1, null );
check( "a .prism GameResource keeps its document id through a serialize",
ValueCodec.StringOf( first?["id"] ) == "a7f3k2b9",
$"got '{ValueCodec.StringOf( first?["id"] )}'" );
// Non-deterministic serialization makes HasUnsavedChanges meaningless — the asset inspector
// hashes Serialize() to decide whether the resource is dirty, and a fresh random id every time
// would arm the Save button that overwrites the file.
check( "a .prism GameResource serializes deterministically", loaded, null );
// Everything that came back must still read as the document it started as.
var reread = PrismLog.Guard<PrismGraph>( "Reading back a serialized GameResource",
() => text is null ? null : Read( text ) );
check( "what the GameResource gave back still reads as the same graph",
reread is not null && reread.Nodes.Count() == 2 && reread.DocumentId == "a7f3k2b9", null );
// The New ▸ Shader path serializes a brand-new instance, and that IS the case a skeleton is for.
var skeleton = PrismLog.Guard( "Serializing a fresh Prism GameResource",
() => new Sandbox.Prism.PrismGraphFile().Serialize() );
check( "a brand-new .prism resource still serializes as a usable skeleton",
skeleton?["nodes"] is JsonArray seeded && seeded.Count == 1, null );
var subgraph = PrismLog.Guard( "Serializing a fresh Prism subgraph resource",
() => new Sandbox.Prism.PrismSubgraphFile().Serialize() );
check( "a brand-new .prismfn resource seeds an input and an output",
subgraph?["nodes"] is JsonArray pair && pair.Count == 2, null );
}
/// <summary>
/// Writing a document is the one operation that can destroy work that already exists, so it re-reads
/// what it produced before it touches the file on disk, and refuses rather than replacing an original
/// with something that will not open.
/// </summary>
static void SaveIntegrityTests( Action<string, bool, string> check )
{
var folder = Path.Combine( Path.GetTempPath(), "prism-selftest-" + Ids.NewShortId() );
var path = Path.Combine( folder, "document." + PrismConstants.GraphExtension );
try
{
var graph = Read( SelfTestDocument, new DiagnosticSink() );
if ( graph is null ) return;
var sink = new DiagnosticSink();
check( "a document saves to a path that did not exist yet", Save( graph, path, sink ), null );
check( "and says nothing while doing it", !sink.HasErrors,
string.Join( " | ", sink.All.Select( x => x.ToString() ) ) );
check( "and nothing drifted on the way through the integrity check",
!sink.All.Any( x => x.Code == DiagnosticCode.RoundTripFailed ),
string.Join( " | ", sink.All.Select( x => x.Detail ) ) );
check( "and the file is there", File.Exists( path ), path );
check( "and no temp file was left beside it", !File.Exists( path + ".tmp" ), null );
var reloaded = ReadFile( path, new DiagnosticSink() );
check( "and it reads back as the same document",
reloaded is not null && Write( reloaded ) == Write( graph ),
Diff( Write( graph ), reloaded is null ? "<null>" : Write( reloaded ) ) );
// Saving over an existing file goes through File.Replace rather than Move, which is the branch
// that only runs from the second save onwards.
graph.Meta.Title = "Saved Twice";
check( "and saving over it again works", Save( graph, path, new DiagnosticSink() ), null );
check( "and the second save is the one that is there",
ReadFile( path, new DiagnosticSink() )?.Meta.Title == "Saved Twice", null );
check( "and still left no temp file", !File.Exists( path + ".tmp" ), null );
// A destination that cannot be written has to fail without touching what is already there.
var before = File.ReadAllText( path );
check( "saving into a file as though it were a folder fails",
!Save( graph, Path.Combine( path, "nested.prism" ), new DiagnosticSink() ), null );
check( "and leaves the real document untouched", File.ReadAllText( path ) == before, null );
}
catch ( Exception e )
{
check( "saving a document never throws", false, $"{e.GetType().Name}: {e.Message}" );
}
finally
{
PrismLog.Guard( "Removing the self-test save folder", () =>
{
if ( Directory.Exists( folder ) ) Directory.Delete( folder, true );
} );
}
}
/// <summary>
/// Killing a compile is not an edge case: it is what every keystroke does. What must never survive
/// one is a latched flag, a running counter or a file nobody owns.
/// </summary>
static void CompileServiceTests( Action<string, bool, string> check )
{
Toolchain.ShaderCompileService service = null;
try
{
service = new Toolchain.ShaderCompileService( "prismselftest2" );
service.ReloadShaders = false;
check( "a force-regenerate request latches until something consumes it",
!service.ForceRegenerate, null );
service.ForceRegenerate = true;
service.Cancel();
check( "and cancelling a compile does not consume it", service.ForceRegenerate, null );
var graph = new PrismGraph();
graph.AddNode( NodeRegistry.Create( PrismSelfTestNode.TypeId ) );
// Twenty requests fired at once: half killed before they are issued, half killed after they
// are already inside the compile. The second half is the one that matters — a compile killed
// mid-flight is waiting on a main-thread hop, and until that wait became cancellable there
// was no way for it to ever come back and clear the running counter.
var tasks = new List<Task>();
var sources = new List<CancellationTokenSource>();
for ( int i = 0; i < 20; i++ )
{
var source = new CancellationTokenSource();
sources.Add( source );
if ( i % 2 == 0 ) source.Cancel();
tasks.Add( service.RequestNow( graph, CompileMode.Preview, source.Token ) );
}
foreach ( var source in sources ) source.Cancel();
PrismLog.Guard( "Awaiting twenty concurrent compiles",
() => Task.WaitAll( tasks.ToArray(), 20000 ) );
check( "twenty concurrent compiles, every one of them killed, all settle",
tasks.All( x => x.IsCompleted ), $"{tasks.Count( x => !x.IsCompleted )} still running" );
check( "and nothing is left compiling", !service.IsCompiling, null );
check( "and leave no file nobody owns in the scratch folder",
service.Workspace.Files.Count == 0, string.Join( ",", service.Workspace.Files ) );
foreach ( var source in sources ) PrismLog.Guard( "Disposing a self-test token", source.Dispose );
service.Dispose();
service.Dispose();
check( "a disposed compile service is inert", !service.IsCompiling, null );
}
catch ( Exception e )
{
check( "the compile service never throws", false, $"{e.GetType().Name}: {e.Message}" );
}
finally
{
PrismLog.Guard( "Disposing the self-test compile service", () => service?.Dispose() );
}
}
// ---------------------------------------------------------------- fault-injection helpers ----
static bool IsFinite( Vector2 value ) => float.IsFinite( value.x ) && float.IsFinite( value.y );
static string First( IReadOnlyList<string> failures ) =>
failures.Count == 0 ? null : $"{failures.Count} of them, e.g. {failures[0]}";
/// <summary>Every addressable location in a document, as <c>a.b[0].c</c> paths.</summary>
static void CollectPaths( JsonNode node, string path, List<string> into, int depth )
{
if ( node is null || depth > 6 ) return;
switch ( node )
{
case JsonObject obj:
foreach ( var pair in obj )
{
var child = path.Length == 0 ? pair.Key : $"{path}.{pair.Key}";
into.Add( child );
CollectPaths( pair.Value, child, into, depth + 1 );
}
break;
case JsonArray array:
for ( int i = 0; i < array.Count; i++ )
{
var child = $"{path}[{i}]";
into.Add( child );
CollectPaths( array[i], child, into, depth + 1 );
}
break;
}
}
/// <summary>Split <c>a.b[0].c</c> into its member and index steps.</summary>
static List<string> PathSteps( string path )
{
var steps = new List<string>();
var current = new StringBuilder();
for ( int i = 0; i < path.Length; i++ )
{
var c = path[i];
if ( c == '.' )
{
if ( current.Length > 0 ) { steps.Add( current.ToString() ); current.Clear(); }
continue;
}
if ( c == '[' )
{
if ( current.Length > 0 ) { steps.Add( current.ToString() ); current.Clear(); }
var close = path.IndexOf( ']', i );
if ( close < 0 ) break;
steps.Add( "#" + path[( i + 1 )..close] );
i = close;
continue;
}
current.Append( c );
}
if ( current.Length > 0 ) steps.Add( current.ToString() );
return steps;
}
static (JsonNode Parent, string Key, int Index) Locate( JsonObject root, string path )
{
JsonNode current = root;
var steps = PathSteps( path );
for ( int i = 0; i < steps.Count; i++ )
{
var step = steps[i];
var isIndex = step.StartsWith( "#", StringComparison.Ordinal );
var index = isIndex ? int.Parse( step[1..], CultureInfo.InvariantCulture ) : -1;
if ( i == steps.Count - 1 ) return isIndex ? (current, null, index) : (current, step, -1);
current = isIndex ? ( current as JsonArray )?[index] : ( current as JsonObject )?[step];
if ( current is null ) return (null, null, -1);
}
return (null, null, -1);
}
static bool RemoveAt( JsonObject root, string path )
{
var (parent, key, index) = Locate( root, path );
if ( parent is JsonObject obj && key is not null ) return obj.Remove( key );
if ( parent is JsonArray array && index >= 0 && index < array.Count )
{
array.RemoveAt( index );
return true;
}
return false;
}
static bool ReplaceAt( JsonObject root, string path, JsonNode value )
{
var (parent, key, index) = Locate( root, path );
if ( parent is JsonObject obj && key is not null )
{
obj[key] = value;
return true;
}
if ( parent is JsonArray array && index >= 0 && index < array.Count )
{
array[index] = value;
return true;
}
return false;
}
/// <summary>A small but representative built-in shader graph, in the legacy format.</summary>
const string LegacyDocument = """
{
"Model": null,
"Description": "legacy fixture",
"BlendMode": "Masked",
"nodes": [
{
"_class": "Result",
"DefaultOpacity": 1, "DefaultRoughness": 1, "DefaultMetalness": 0, "DefaultAmbientOcclusion": 1,
"Identifier": "0",
"Position": "-1212,-828",
"Albedo": { "Identifier": "1", "Output": "Result" },
"Roughness": { "Identifier": "2", "Output": "Result" }
},
{
"_class": "Multiply",
"DefaultA": 0, "DefaultB": 1,
"Identifier": "1", "Position": "-2688,684",
"A": { "Identifier": "3", "Output": "Result" }
},
{
"_class": "Float",
"Min": 0, "Max": 1, "Value": 0.25,
"Name": "RimFudge", "IsAttribute": false, "UI": {},
"Identifier": "2", "Position": "-2460,-1572"
},
{
"_class": "TotallyMadeUpNode",
"SecretSetting": 42,
"Identifier": "3", "Position": "-3000,-100"
},
{
"_class": "Float4",
"Value": "1,0.5,0.25,1", "Name": "", "IsAttribute": false, "UI": { "Type": "Color" },
"Identifier": "5", "Position": "-3600,-600"
},
{
"_class": "Subtract",
"DefaultA": 0, "DefaultB": 1,
"Identifier": "6", "Position": "-3900,-600",
"A": { "Identifier": "5", "Output": "R" }
},
{
"_class": "TextureSampler",
"Image": "textures/fixture/rust_albedo.png",
"Sampler": { "Filter": "Aniso", "AddressU": "Clamp", "AddressV": "Mirror_Once" },
"UI": {
"Name": "RustAlbedo", "IsAttribute": false, "SrgbRead": false,
"PrimaryGroup": { "Name": "Surface", "Priority": 0 }
},
"Identifier": "4", "Position": "-3300,-300"
}
]
}
""";
/// <summary>The worked example from section 3.3 of the architecture brief, verbatim.</summary>
public const string SelfTestDocument = """
{
"schema": 1,
"id": "g4k2x9pq",
"kind": "shader",
"meta": {
"title": "Wet Stone",
"description": "Layered stone with puddle wetness driven by a mask.",
"category": "Materials",
"icon": "water_drop",
"created": "2026-08-10T14:02:11Z",
"modified": "2026-08-10T16:44:02Z",
"editorVersion": "0.1.0"
},
"settings": {
"domain": "Surface",
"shadingModel": "Lit",
"blendMode": "Opaque",
"cullMode": "Back",
"modes": [ "Forward", "Depth", "ToolsShadingComplexity" ],
"targets": [ "sbox-hlsl", "slang" ],
"hlslDialect": "SboxSlang",
"uv2": true,
"renderBackfaces": false
},
"preview": {
"mesh": "Sphere",
"model": null,
"envmap": "textures/cubemaps/default2.vtex",
"showGround": false,
"showSkybox": true,
"tint": "1,1,1,1",
"background": "0.05,0.06,0.07,1",
"camera": { "yaw": 135, "pitch": 30, "distance": 150 }
},
"parameters": [
{
"id": "p1",
"name": "Base Color",
"type": "Texture2D",
"default": { "path": "materials/dev/white_color.tga", "colorSpace": "Srgb", "processor": "None" },
"ui": { "group": "Textures", "order": 10 }
},
{
"id": "p2",
"name": "Roughness",
"type": "float",
"default": 0.62,
"ui": { "control": "Slider", "min": 0, "max": 1, "step": 0.01, "group": "Surface", "order": 20 }
},
{
"id": "p3",
"name": "Wetness",
"type": "float",
"default": 0,
"attribute": "PrismWetness",
"ui": { "control": "Slider", "min": 0, "max": 1, "group": "Surface", "order": 30 }
}
],
"keywords": [
{ "id": "k1", "name": "F_PUDDLES", "kind": "Feature", "values": [ "Off", "On" ], "default": 0, "group": "Wetness" }
],
"nodes": [
{ "id": "n1", "type": "prism.output.surface", "v": 1, "pos": [ 768, 0 ],
"props": {}, "inline": { "Metalness": 0.0, "AmbientOcclusion": 1.0 } },
{ "id": "n2", "type": "prism.input.texcoord", "v": 1, "pos": [ -512, -32 ],
"props": { "Channel": 0 } },
{ "id": "n3", "type": "prism.uv.tileAndOffset", "v": 1, "pos": [ -288, -32 ],
"props": {}, "inline": { "Tile": [ 4, 4 ], "Offset": [ 0, 0 ] } },
{ "id": "n4", "type": "prism.texture.sample2d", "v": 2, "pos": [ -32, -48 ],
"props": {
"Parameter": "p1",
"Sampler": { "filter": "Aniso", "addressU": "Wrap", "addressV": "Wrap" },
"MipMode": "Auto"
},
"flags": { "preview": true } },
{ "id": "n5", "type": "prism.parameter.ref", "v": 1, "pos": [ -32, 176 ],
"props": { "Parameter": "p2" } },
{ "id": "n6", "type": "prism.parameter.ref", "v": 1, "pos": [ -32, 248 ],
"props": { "Parameter": "p3" } },
{ "id": "n7", "type": "prism.math.lerp", "v": 1, "pos": [ 288, 192 ],
"props": {}, "inline": { "B": 0.05 } },
{ "id": "n8", "type": "prism.util.reroute", "v": 1, "pos": [ 560, 200 ], "props": {} },
{ "id": "n9", "type": "acme.fancy.customNoise", "v": 3, "pos": [ -288, 384 ],
"props": { "Octaves": 5, "Lacunarity": 2.03, "SecretSauce": [ 1, 2, 3 ] },
"x": { "vendor": "acme", "license": "MIT" } }
],
"edges": [
{ "id": "e1", "from": { "node": "n2", "port": "UV" }, "to": { "node": "n3", "port": "UV" } },
{ "id": "e2", "from": { "node": "n3", "port": "Out" }, "to": { "node": "n4", "port": "UV" } },
{ "id": "e3", "from": { "node": "n4", "port": "RGB" }, "to": { "node": "n1", "port": "Albedo" } },
{ "id": "e4", "from": { "node": "n5", "port": "Out" }, "to": { "node": "n7", "port": "A" } },
{ "id": "e5", "from": { "node": "n6", "port": "Out" }, "to": { "node": "n7", "port": "T" } },
{ "id": "e6", "from": { "node": "n7", "port": "Out" }, "to": { "node": "n8", "port": "In" } },
{ "id": "e7", "from": { "node": "n8", "port": "Out" }, "to": { "node": "n1", "port": "Roughness" },
"via": [ [ 672, 200 ] ] }
],
"groups": [
{ "id": "gr1", "title": "Base Colour", "description": "UV -> tile -> sample",
"rect": [ -576, -128, 704, 224 ], "color": "Blue", "layer": 0 }
],
"notes": [
{ "id": "nt1", "pos": [ -576, 448 ], "size": [ 260, 96 ], "color": "Yellow",
"text": "acme.fancy.customNoise is a third-party node. It round-trips even if the plugin is missing." }
],
"view": { "center": [ 96, 48 ], "zoom": 0.9 }
}
""";
}