Asset type and serializer for Prism shader graph files (.prism). Declares a GameResource subclass that exposes the top-level Prism document sections as JsonNode properties so the asset browser recognizes the extension and round-trips the raw sections. Also includes a helper static PrismDocumentSkeleton that defines canonical section keys, creates minimal skeleton documents, normalizes serialized JsonObjects (removes nulls, orders known keys, preserves unknown keys) and generates new document ids and minimal node entries.
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&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 );
}
}