Utility that computes a canonical, stable fingerprint of an editor scene and optional scene settings. It serialises scene objects and JSON nodes into a deterministic text form (sorted object keys, ordered arrays, normalized numbers and escaped strings), hashes the UTF-8 bytes with SHA-256, and returns the first 16 hex chars.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace TeamCreate;
/// <summary>
/// A short fingerprint of a whole scene, small enough to ride on every ping. Each peer computes it
/// over its own copy; when the host sees a peer report a different digest than its own it knows the
/// two editors have drifted apart and can resend a snapshot.
///
/// The digest is only useful if two editors holding the same scene always agree, so it is computed
/// over a canonical text rather than over whatever the dictionaries and JSON trees happen to hold:
/// objects are sorted by id, object keys are sorted at every depth, arrays keep their order, and
/// numbers are normalised to what survives the engine's 32-bit float round trip (0.6 comes back as
/// 0.600000024). The text is JSON-shaped, with every string quoted and escaped, so no characters
/// moved between neighbouring fields can ever produce the same text.
///
/// Engine-independent on purpose (no Sandbox or Editor types), so CoreTests can exercise it.
/// </summary>
public static class SceneDigest
{
/// <summary>Hexadecimal characters kept from the SHA-256 hash. Sixty-four bits is plenty to notice drift, which is not an adversarial setting.</summary>
public const int Length = 16;
// 2^24: the largest magnitude below which every whole number is exactly a 32-bit float. Whole
// numbers beyond it can still be genuine 64-bit identifiers (seeds, hashes) that never passed
// through a float, so they are written exactly instead of being rounded.
private const long ExactFloatLimit = 16_777_216;
// System.Text.Json refuses to parse deeper than 64 levels, so no scene that arrived over the
// wire can exceed this. A tree built in memory can be arbitrarily deep, and recursing into one
// would overflow the stack, which cannot be caught; refusing it is the only safe answer.
private const int MaxDepth = 64;
/// <summary>
/// Computes the digest of <paramref name="scene"/>: the first <see cref="Length"/> lowercase
/// hexadecimal characters of the SHA-256 hash of its canonical text. An empty scene has a fixed
/// digest of its own.
/// </summary>
/// <exception cref="ArgumentException">
/// The scene is null, or it contains an object that cannot be fingerprinted faithfully (a null
/// object, a null id, an id that disagrees with its dictionary key, null data, a value of a type
/// JSON cannot express, or data nested deeper than any parsed scene could be). A digest of a
/// malformed scene must not look like a valid one, so these are refused rather than skipped.
/// </exception>
public static string Compute( IReadOnlyDictionary<string, ObjectState> scene )
{
return Compute( scene, null );
}
/// <summary>
/// The digest of a scene's objects together with its scene-level settings (<see cref="SettingsMerge"/>), so that a settings-only
/// difference is drift too. With null settings this is exactly <see cref="Compute(IReadOnlyDictionary{string, ObjectState})"/>.
/// Otherwise the canonical text is <c>{"objects":[...],"settings":{...}}</c>: it starts with a brace where the objects-only text starts
/// with a bracket, so no scene without settings can share its text, and the settings are canonicalised by the same rules as object data
/// (sorted keys, float-tolerant numbers).
/// </summary>
/// <exception cref="ArgumentException">As for the objects-only overload, and for settings that cannot be written canonically.</exception>
public static string Compute( IReadOnlyDictionary<string, ObjectState> scene, JsonObject settings )
{
if ( scene == null )
{
throw new ArgumentException( "A scene digest cannot be computed because the scene is null.", nameof( scene ) );
}
var objects = new List<ObjectState>( scene.Count );
foreach ( var pair in scene )
{
var state = pair.Value;
if ( state == null )
{
throw new ArgumentException( $"A scene digest cannot be computed because the entry '{pair.Key}' holds no object state.", nameof( scene ) );
}
if ( !string.Equals( state.Id, pair.Key, StringComparison.Ordinal ) )
{
throw new ArgumentException( $"A scene digest cannot be computed because the entry '{pair.Key}' holds an object whose id is '{state.Id ?? "(null)"}'. Each key must equal the id of its object.", nameof( scene ) );
}
if ( state.Data == null )
{
throw new ArgumentException( $"A scene digest cannot be computed because the object '{pair.Key}' has no data.", nameof( scene ) );
}
objects.Add( state );
}
// Ordinal, never culture-aware: two machines with different locales must sort alike.
objects.Sort( ( a, b ) => string.CompareOrdinal( a.Id, b.Id ) );
var text = new StringBuilder( 64 + objects.Count * 256 );
if ( settings != null ) text.Append( "{\"objects\":" );
text.Append( '[' );
for ( int i = 0; i < objects.Count; i++ )
{
if ( i > 0 ) text.Append( ',' );
var state = objects[i];
text.Append( "{\"id\":" );
AppendString( text, state.Id );
// A missing parent and an empty one are the same root-level state.
text.Append( ",\"parent\":" );
AppendString( text, state.Parent ?? string.Empty );
text.Append( ",\"data\":" );
AppendNode( text, state.Data, 0, state.Id );
text.Append( '}' );
}
text.Append( ']' );
if ( settings != null )
{
text.Append( ",\"settings\":" );
AppendNode( text, settings, 0, "scene settings" );
text.Append( '}' );
}
var hash = SHA256.HashData( Encoding.UTF8.GetBytes( text.ToString() ) );
return Convert.ToHexString( hash, 0, Length / 2 ).ToLowerInvariant();
}
private static void AppendNode( StringBuilder text, JsonNode node, int depth, string objectId )
{
if ( depth > MaxDepth )
{
throw new ArgumentException( $"A scene digest cannot be computed because the data of object '{objectId}' is nested more than {MaxDepth} levels deep.", "scene" );
}
switch ( node )
{
case null:
text.Append( "null" );
break;
case JsonObject obj:
{
// Sorted by key so two editors that built the same object in different orders agree.
var properties = new List<KeyValuePair<string, JsonNode>>( obj.Count );
foreach ( var property in obj ) properties.Add( property );
properties.Sort( ( a, b ) => string.CompareOrdinal( a.Key, b.Key ) );
text.Append( '{' );
for ( int i = 0; i < properties.Count; i++ )
{
if ( i > 0 ) text.Append( ',' );
AppendString( text, properties[i].Key );
text.Append( ':' );
AppendNode( text, properties[i].Value, depth + 1, objectId );
}
text.Append( '}' );
break;
}
case JsonArray array:
{
// Array order is meaningful (component order decides execution order), so it is kept.
text.Append( '[' );
for ( int i = 0; i < array.Count; i++ )
{
if ( i > 0 ) text.Append( ',' );
AppendNode( text, array[i], depth + 1, objectId );
}
text.Append( ']' );
break;
}
case JsonValue value:
AppendValue( text, value, objectId );
break;
default:
throw new ArgumentException( $"A scene digest cannot be computed because the data of object '{objectId}' holds an unsupported node of type {node.GetType().Name}.", "scene" );
}
}
private static void AppendValue( StringBuilder text, JsonValue value, string objectId )
{
switch ( value.GetValueKind() )
{
case JsonValueKind.True:
text.Append( "true" );
return;
case JsonValueKind.False:
text.Append( "false" );
return;
case JsonValueKind.Null:
text.Append( "null" );
return;
case JsonValueKind.Number:
if ( TryAppendNumber( text, value ) ) return;
break;
case JsonValueKind.String:
// A CLR-backed value that merely serialises as a string (a Guid, a DateTime) reports
// String but cannot be read back as one; it is refused below like any other stranger.
if ( value.TryGetValue<string>( out var s ) )
{
AppendString( text, s );
return;
}
break;
}
throw new ArgumentException( $"A scene digest cannot be computed because the data of object '{objectId}' holds a value that cannot be written canonically.", "scene" );
}
private static bool TryAppendNumber( StringBuilder text, JsonValue value )
{
// Whole numbers keep their exact text. Up to 2^24 that is also what the float rule below
// would produce, and beyond it the value is a real 64-bit quantity that must not be rounded.
if ( TryReadWhole( value, out long whole ) )
{
text.Append( whole.ToString( CultureInfo.InvariantCulture ) );
return true;
}
// Above long.MaxValue only an unsigned value is still a whole number.
if ( value.TryGetValue<ulong>( out ulong huge ) )
{
text.Append( huge.ToString( CultureInfo.InvariantCulture ) );
return true;
}
if ( !TryReadReal( value, out double real ) ) return false;
// The engine keeps 32-bit floats, so anything finer than a float is noise: 0.6 returns as
// 0.600000024, and 1 as 1.0 or 1.00000001. Rounding to float first makes all of those agree.
float rounded = (float)real;
if ( float.IsNaN( rounded ) )
{
text.Append( "NaN" );
}
else if ( float.IsPositiveInfinity( rounded ) )
{
text.Append( "Infinity" );
}
else if ( float.IsNegativeInfinity( rounded ) )
{
text.Append( "-Infinity" );
}
else if ( rounded == MathF.Floor( rounded ) && MathF.Abs( rounded ) <= ExactFloatLimit )
{
// Also folds -0 into 0 and 1.0 into 1.
text.Append( ( (long)rounded ).ToString( CultureInfo.InvariantCulture ) );
}
else
{
text.Append( rounded.ToString( "R", CultureInfo.InvariantCulture ) );
}
return true;
}
// A value parsed from JSON text is backed by a JsonElement and converts between numeric types,
// but a value built in memory holds one specific CLR type and answers only to that exact type:
// JsonValue.Create( 5 ).TryGetValue<long>() is false. Both origins reach this code, so each
// integral CLR type is tried in turn.
private static bool TryReadWhole( JsonValue value, out long whole )
{
if ( value.TryGetValue<long>( out whole ) ) return true;
if ( value.TryGetValue<int>( out int i ) ) { whole = i; return true; }
if ( value.TryGetValue<uint>( out uint ui ) ) { whole = ui; return true; }
if ( value.TryGetValue<short>( out short sh ) ) { whole = sh; return true; }
if ( value.TryGetValue<ushort>( out ushort ush ) ) { whole = ush; return true; }
if ( value.TryGetValue<byte>( out byte b ) ) { whole = b; return true; }
if ( value.TryGetValue<sbyte>( out sbyte sb ) ) { whole = sb; return true; }
if ( value.TryGetValue<ulong>( out ulong ul ) && ul <= long.MaxValue ) { whole = (long)ul; return true; }
whole = 0;
return false;
}
// The fractional counterpart: a parsed number answers to double, an in-memory one only to the
// exact CLR type it was built from.
private static bool TryReadReal( JsonValue value, out double real )
{
if ( value.TryGetValue<double>( out real ) ) return true;
if ( value.TryGetValue<float>( out float single ) ) { real = single; return true; }
if ( value.TryGetValue<decimal>( out decimal exact ) ) { real = (double)exact; return true; }
real = 0;
return false;
}
// JSON escaping, with one addition: an unpaired surrogate is written as \uXXXX, because UTF-8
// would encode every one of them as U+FFFD and distinct strings would hash alike.
private static void AppendString( StringBuilder text, string value )
{
text.Append( '"' );
for ( int i = 0; i < value.Length; i++ )
{
char c = value[i];
if ( c == '"' ) text.Append( "\\\"" );
else if ( c == '\\' ) text.Append( "\\\\" );
else if ( c < ' ' ) AppendEscape( text, c );
else if ( char.IsHighSurrogate( c ) && i + 1 < value.Length && char.IsLowSurrogate( value[i + 1] ) )
{
text.Append( c ).Append( value[i + 1] );
i++;
}
else if ( char.IsSurrogate( c ) ) AppendEscape( text, c );
else text.Append( c );
}
text.Append( '"' );
}
private static void AppendEscape( StringBuilder text, char c )
{
text.Append( "\\u" ).Append( ( (int)c ).ToString( "x4", CultureInfo.InvariantCulture ) );
}
}