Code/Prism/PrismGraphFile.cs

Asset type and serializer for Prism shader graph files (.prism). Declares a GameResource that stores each top-level Prism document section as JsonNode properties so the asset inspector can load and save the file verbatim, and provides normalization and skeleton-writing logic to ensure safe round-trip serialization and canonical ordering.

File Access
using System;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

namespace Sandbox.Prism;

/// <summary>
/// Registers the <c>.prism</c> extension with the asset system.
/// <para>
/// A <c>.prism</c> file is a schema-v1 Prism document authored by the Prism editor, not a property
/// bag, and nothing loads it at runtime — the generated <c>.shader</c> beside it is what the game
/// consumes. The type exists so the asset browser knows the extension: it supplies the icon, the
/// colour strip, the <i>New ▸ Shader</i> entry and the double-click route into <c>IAssetEditor</c>.
/// </para>
/// <para>
/// It must live in the <b>game</b> assembly. <c>ResourceLibrary.TryGetType</c> resolves asset types
/// through <c>Game.TypeLibrary</c>, which only contains game assemblies, so an <c>[AssetType]</c>
/// declared in <c>Editor/</c> registers the extension but then fails every load and save silently.
/// </para>
/// <para>
/// <b>Why the section properties below exist.</b> <c>GameResource.Serialize()</c> is what
/// <c>Asset.SaveToDisk( GameResource )</c> writes over the source file, and it is reachable from the
/// asset inspector's Save button, its <c>CTRL+S</c> shortcut, its discard prompt and the editor's
/// quit dialog. A resource that serializes to less than it loaded therefore destroys the user's
/// document in place, with no undo. Each top-level section of the document is captured verbatim as a
/// <see cref="JsonNode"/> so that load → save is byte-stable and a save from outside Prism is a
/// no-op rather than a truncation.
/// </para>
/// <para>
/// The sections are round-tripped rather than parsed: this assembly cannot reference the editor's
/// serializer, so it deliberately knows only the <i>names</i> of the sections and never their shape.
/// They are non-public with <c>[Property]</c> because s&amp;box's <c>Json.SerializeAsObject</c> and
/// <c>Json.DeserializeToObject</c> are hand-written property walkers that honour <c>[Property]</c>
/// and <c>[JsonPropertyName]</c> — note that <c>[JsonExtensionData]</c> would <i>not</i> work here,
/// as neither walker implements it — while the asset inspector's own property filter skips anything
/// non-public, so none of this shows up as an editable field.
/// </para>
/// </summary>
[AssetType( Name = "Prism Shader Graph", Extension = "prism", Category = "Shader",
	Flags = AssetTypeFlags.NoEmbedding, IconColor = "#7C5CFF" )]
public sealed class PrismGraphFile : GameResource
{
	/// <summary>Document schema version.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeySchema )]
	internal JsonNode SchemaSection { get; set; }

	/// <summary>Stable document id.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyId )]
	internal JsonNode IdSection { get; set; }

	/// <summary>Document kind — <c>shader</c> or <c>subgraph</c>.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyKind )]
	internal JsonNode KindSection { get; set; }

	/// <summary>Authoring metadata.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyMeta )]
	internal JsonNode MetaSection { get; set; }

	/// <summary>Graph settings: domain, blend mode, shading model and the rest.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeySettings )]
	internal JsonNode SettingsSection { get; set; }

	/// <summary>Preview state.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyPreview )]
	internal JsonNode PreviewSection { get; set; }

	/// <summary>Blackboard parameters.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyParameters )]
	internal JsonNode ParametersSection { get; set; }

	/// <summary>Blackboard keywords.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyKeywords )]
	internal JsonNode KeywordsSection { get; set; }

	/// <summary>Every node in the graph.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyNodes )]
	internal JsonNode NodesSection { get; set; }

	/// <summary>Every connection.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyEdges )]
	internal JsonNode EdgesSection { get; set; }

	/// <summary>Node groups.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyGroups )]
	internal JsonNode GroupsSection { get; set; }

	/// <summary>Sticky notes.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyNotes )]
	internal JsonNode NotesSection { get; set; }

	/// <summary>Saved camera position and zoom.</summary>
	[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyView )]
	internal JsonNode ViewSection { get; set; }

	/// <summary>
	/// Normalise the serialized form: drop the empty sections, and fall back to an empty document only
	/// when there is genuinely nothing to write.
	/// <para>
	/// The engine's generic <i>New ▸ Shader ▸ Prism Shader Graph</i> entry creates the file through
	/// <c>AssetSystem.CreateResource</c>, which serializes a brand-new instance — every section null.
	/// That is the one case where a skeleton is correct. Every other call is a re-save of a document
	/// that was loaded from disk, and must give back exactly what it was given.
	/// </para>
	/// </summary>
	protected override void OnJsonSerialize( JsonObject node )
	{
		PrismDocumentSkeleton.Normalize( node, false );
	}

	/// <summary>Asset-browser icon: the Prism violet with a gradient glyph.</summary>
	protected override Bitmap CreateAssetTypeIcon( int width, int height )
	{
		return CreateSimpleAssetTypeIcon( "gradient", width, height, "#7C5CFF", "#F2EEFF" );
	}
}

/// <summary>
/// The smallest document that reads back as a usable graph, and the canonical section order.
/// <para>
/// Only the keys that cannot be defaulted are written: schema, id, kind and one output node. Every
/// other section is filled in by the reader from its defaults, and the editor rewrites the file in
/// full canonical form the first time it is saved. Keeping this minimal is deliberate — the game
/// assembly cannot reference the serializer, so anything written here is duplicated knowledge.
/// </para>
/// </summary>
internal static class PrismDocumentSkeleton
{
	/// <summary>Mirror of <c>PrismConstants.DocumentSchemaVersion</c>, which lives in the editor assembly.</summary>
	public const int SchemaVersion = 1;

	/// <summary>Mirror of <c>PrismConstants.EditorVersion</c>.</summary>
	public const string EditorVersion = "0.1.0";

	/// <summary>Stable type id of the surface output node.</summary>
	public const string SurfaceOutputTypeId = "prism.output.surface";

	/// <summary>Stable type id of the subgraph input node.</summary>
	public const string SubgraphInputTypeId = "prism.subgraph.input";

	/// <summary>Stable type id of the subgraph output node.</summary>
	public const string SubgraphOutputTypeId = "prism.subgraph.output";

	// The top-level section names, mirroring the editor serializer's own key constants. These are the
	// wire format and are frozen; the reader tolerates any of them being absent.

	/// <summary>Schema version key.</summary>
	public const string KeySchema = "schema";

	/// <summary>Document id key.</summary>
	public const string KeyId = "id";

	/// <summary>Document kind key.</summary>
	public const string KeyKind = "kind";

	/// <summary>Metadata key.</summary>
	public const string KeyMeta = "meta";

	/// <summary>Settings key.</summary>
	public const string KeySettings = "settings";

	/// <summary>Preview-state key.</summary>
	public const string KeyPreview = "preview";

	/// <summary>Parameters key.</summary>
	public const string KeyParameters = "parameters";

	/// <summary>Keywords key.</summary>
	public const string KeyKeywords = "keywords";

	/// <summary>Nodes key.</summary>
	public const string KeyNodes = "nodes";

	/// <summary>Edges key.</summary>
	public const string KeyEdges = "edges";

	/// <summary>Groups key.</summary>
	public const string KeyGroups = "groups";

	/// <summary>Notes key.</summary>
	public const string KeyNotes = "notes";

	/// <summary>View key.</summary>
	public const string KeyView = "view";

	/// <summary>Every top-level section, in the order the editor writes them.</summary>
	public static readonly string[] Sections =
	{
		KeySchema, KeyId, KeyKind, KeyMeta, KeySettings, KeyPreview,
		KeyParameters, KeyKeywords, KeyNodes, KeyEdges, KeyGroups, KeyNotes, KeyView
	};

	/// <summary>
	/// Make a serialized document safe to write over its own source file.
	/// <para>
	/// Sections that round-tripped as null are removed — the property walker emits one per declared
	/// section whether or not the document had it, and a file full of <c>null</c>s is noise the reader
	/// would have to step over. If nothing survives that has a node in it, the document is empty and
	/// gets a skeleton; otherwise it is returned exactly as it came in, reordered canonically so the
	/// same document always serializes to the same bytes.
	/// </para>
	/// </summary>
	public static void Normalize( JsonObject node, bool subgraph )
	{
		if ( node is null ) return;

		var ordered = new JsonObject();

		foreach ( var key in Sections )
		{
			if ( !node.TryGetPropertyValue( key, out var value ) ) continue;
			if ( value is null ) continue;

			node.Remove( key );
			ordered[key] = value;
		}

		// Anything left is not ours — a base-class field or a stray key. Keep it rather than drop it:
		// losing a key we do not understand is the exact failure this method exists to prevent.
		foreach ( var extra in System.Linq.Enumerable.ToArray( node ) )
		{
			if ( extra.Value is null ) continue;

			node.Remove( extra.Key );
			ordered[extra.Key] = extra.Value;
		}

		node.Clear();

		if ( !HasContent( ordered ) )
		{
			Write( node, subgraph );
			return;
		}

		foreach ( var entry in System.Linq.Enumerable.ToArray( ordered ) )
		{
			ordered.Remove( entry.Key );
			node[entry.Key] = entry.Value;
		}
	}

	/// <summary>True when the document carries at least one node, i.e. it is a real graph.</summary>
	static bool HasContent( JsonObject node )
	{
		return node[KeyNodes] is JsonArray nodes && nodes.Count > 0;
	}

	/// <summary>Overwrite <paramref name="node"/> with an empty shader graph or subgraph document.</summary>
	public static void Write( JsonObject node, bool subgraph )
	{
		if ( node is null ) return;

		node.Clear();

		node[KeySchema] = SchemaVersion;
		node[KeyId] = NewDocumentId();
		node[KeyKind] = subgraph ? "subgraph" : "shader";

		node[KeyMeta] = new JsonObject
		{
			["editorVersion"] = EditorVersion
		};

		node[KeySettings] = new JsonObject
		{
			["domain"] = subgraph ? "Subgraph" : "Surface"
		};

		var nodes = new JsonArray();

		if ( subgraph )
		{
			nodes.Add( NodeEntry( "sgin0001", SubgraphInputTypeId, -320f, 0f ) );
			nodes.Add( NodeEntry( "sgout001", SubgraphOutputTypeId, 320f, 0f ) );
		}
		else
		{
			nodes.Add( NodeEntry( "out00001", SurfaceOutputTypeId, 0f, 0f ) );
		}

		node[KeyNodes] = nodes;
	}

	/// <summary>One entry of the <c>nodes</c> array.</summary>
	static JsonObject NodeEntry( string id, string typeId, float x, float y )
	{
		return new JsonObject
		{
			["id"] = id,
			["type"] = typeId,
			["v"] = 1,
			["pos"] = new JsonArray { x, y }
		};
	}

	/// <summary>
	/// Eight lowercase base36 characters, matching <c>Ids.NewShortId</c>. Guid hex digits are a
	/// subset of base36, so this stays well-formed without duplicating the generator.
	/// </summary>
	static string NewDocumentId()
	{
		return Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );
	}
}