s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=cyn.sbox_editor_collab&take=20
Showing code results for query:
*
(120 total matches found)
Editor
library
using System;
namespace TeamCreate;
/// <summary>
/// How far behind live to render remote motion. Rendering behind live gives the interpolation two samples to blend between; the delay has to be
/// at least as long as the network's irregularity or the newest sample is not there yet and the picture stalls. Longer than that only adds lag.
/// </summary>
/// <remarks>
/// The measure of irregularity is the gap between consecutive packet arrivals over the last <see cref="Window"/> packets. The target is
/// <c>p95(gap) + 0.5 * mean(gap)</c>: the 95th-percentile gap is how long the picture can wait for the next packet on all but one in twenty
/// arrivals, and half a mean gap is added as margin so that the sample after the render time has normally arrived. The target is clamped to
/// <see cref="FloorMs"/>..<see cref="CeilingMs"/>. With a 32-gap window the nearest-rank p95 is the 31st smallest value, so a single late
/// packet barely moves the target (only the mean); jitter that repeats does.
///
/// The delay follows that target asymmetrically. When the network gets worse the delay rises at once, because a late packet must not freeze
/// the picture. When the network improves, or a single packet was merely lost, the delay falls by at most <see cref="MaxFallMsPerSecond"/> of
/// elapsed time: the render clock then runs at 0.5% slower than real time while it settles, which no one can see, instead of lurching
/// forward, which everyone can.
///
/// Before any gap is observed the delay is <see cref="StartMs"/>. The floor (50 ms) would be too short for a first, possibly jittery, packet; 80 ms is
/// inside the 60 to 80 ms typical range and costs a few seconds of mild lag at worst: the delay then walks down to the real target at the fall rate.
/// </remarks>
public sealed class AdaptiveDelay
{
public const double FloorMs = 50;
public const double CeilingMs = 150;
public const double MaxFallMsPerSecond = 5;
/// <summary>The delay used until the first gap has been observed, and the value the delay falls from afterwards.</summary>
public const double StartMs = 80;
/// <summary>How many of the most recent arrival gaps take part: about one second at 30 packets a second.</summary>
public const int Window = 32;
private readonly double[] gaps = new double[Window];
private readonly double[] sorted = new double[Window];
private int count;
private int next;
private double targetMs;
private double currentMs = StartMs;
private double lastMs;
private bool hasLast;
/// <summary>
/// Records the time between two consecutive packet arrivals, read at local time <paramref name="nowMs"/>. A negative or non-finite gap
/// is ignored (a clock that stepped backwards, or a bad reading, says nothing about the network).
/// </summary>
public void Observe( double arrivalGapMs, double nowMs )
{
if ( !double.IsFinite( arrivalGapMs ) || arrivalGapMs < 0 )
{
return;
}
// Settle the delay up to now against the old target first, so that time already elapsed is not charged against the new one.
Follow( nowMs );
gaps[next] = arrivalGapMs;
next = (next + 1) % Window;
if ( count < Window )
{
count++;
}
targetMs = ComputeTarget();
Follow( nowMs );
}
/// <summary>The delay to render behind live at local time <paramref name="nowMs"/>. Reading it more or less often does not change the result.</summary>
public double DelayMs( double nowMs )
{
Follow( nowMs );
return currentMs;
}
/// <summary>
/// Moves the current delay toward the target for the time elapsed since the last reading: up at once, down at the limited rate. A reading
/// earlier than one already seen adds no time, so calls that arrive slightly out of order can never make the delay fall faster than its limit.
/// </summary>
private void Follow( double nowMs )
{
double elapsedMs = 0;
if ( double.IsFinite( nowMs ) )
{
if ( hasLast && nowMs > lastMs )
{
elapsedMs = nowMs - lastMs;
}
if ( !hasLast || nowMs > lastMs )
{
lastMs = nowMs;
hasLast = true;
}
}
if ( count == 0 )
{
return;
}
if ( targetMs >= currentMs )
{
currentMs = targetMs;
}
else
{
currentMs = Math.Max( targetMs, currentMs - MaxFallMsPerSecond * elapsedMs / 1000.0 );
}
}
private double ComputeTarget()
{
Array.Copy( gaps, sorted, count );
Array.Sort( sorted, 0, count );
// Nearest rank: the smallest value with at least 95% of the window at or below it, i.e. the ceil(0.95 * n)-th smallest. Integer
// arithmetic, so that 20 values give exactly the 19th rather than whatever 0.95 * 20 rounds to.
int rank = (95 * count + 99) / 100;
double p95 = sorted[rank - 1];
double sum = 0;
for ( int i = 0; i < count; i++ )
{
sum += gaps[i];
}
double mean = sum / count;
// A sum that overflows is +infinity and clamps to the ceiling, which is the right answer for such a gap.
return Math.Clamp( p95 + 0.5 * mean, FloorMs, CeilingMs );
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
namespace TeamCreate;
public sealed record PeerFacts(string SteamId, bool IdentityVerified, bool IsFriend, bool IsOrgMember, string PeerLibraryVersion, string PeerProjectManifest, string PeerOrgIdent, bool IsHostSelf = false);
public static class AdmissionRules
{
public const string VerifiedGate = "identity";
public const string FriendGate = "friend";
public const string OrgGate = "org";
public const string LibraryGate = "library";
public const string ManifestGate = "manifest";
private const int MaxPeerTextLength = 64;
public static string Evaluate(PeerFacts facts, CollaborationConfig config, string hostLibraryVersion, string hostManifest, out string failedGate, string peerName = null)
{
failedGate = null;
string text = SafePeerText(peerName, 64);
string text2 = ((text.Length == 0) ? string.Empty : (text + " "));
if ((object)facts == null || !facts.IdentityVerified)
{
failedGate = "identity";
return text2 + "could not be verified by s&box's auth service.";
}
if (config != null && config.RequireSteamFriend && !facts.IsFriend && !facts.IsHostSelf)
{
failedGate = "friend";
return text2 + "is not on your Steam friends list. Add them on Steam and reconnect.";
}
if (config != null && config.RequireOrgMember && (!facts.IsOrgMember || !string.Equals(facts.PeerOrgIdent, config.Org, StringComparison.OrdinalIgnoreCase)))
{
failedGate = "org";
return text2 + "is not a member of the organization " + config.Org + ".";
}
Version version = config?.LibraryVersion ?? ParseVersion(hostLibraryVersion);
if ((object)version != null)
{
string text3 = (string.IsNullOrWhiteSpace(facts.PeerLibraryVersion) ? null : facts.PeerLibraryVersion);
if (text3 == null || !Version.TryParse(text3, out Version result) || !VersionRules.IsAtLeast(result, version))
{
string text4 = SafePeerText(text3, 64);
failedGate = "library";
return text2 + $"runs collaboration library {((text4.Length == 0) ? "an unknown version" : text4)}; this session requires {version}. Update the library and reconnect.";
}
}
// File differences are reconciled after admission: assets are synchronized and code differences are reported, never received.
// The manifest therefore stops being a gate; the engine build still is (BaselineRefusal).
return null;
}
public const string BaselineRefusalMessage = "Project/scene baseline differs (engine, source, assets or metadata). Sync copies in Git before joining.";
public const string EngineRefusalMessage = "This peer runs a different s&box engine build from the host's. Update s&box to the same version on both computers and reconnect.";
public const string EngineUnknownRefusalMessage = "This peer's project differs from the host's and its collaboration library cannot synchronize project files. Update the library and reconnect.";
/// <summary>
/// The baseline gate of the hello. When the peer's files match the host's (or the host has no manifest to compare), the full baseline
/// must be one the host accepts, exactly as before file synchronization. When the files differ, the full baseline necessarily differs
/// too (it hashes every file), so only the engine fingerprint is compared: the files are reconciled after admission, the engine build
/// cannot be. A peer that sends no engine fingerprint predates file synchronization and is refused, because nothing would reconcile its
/// files; a host that has no fingerprint of its own falls back to the full baseline. Null when the peer passes.
/// </summary>
public static string BaselineRefusal(string peerManifest, string hostManifest, string peerBaseline, ICollection<string> compatibleBaselines, string peerEngine, string hostEngine)
{
bool sameFiles = string.IsNullOrWhiteSpace(hostManifest) || string.Equals(peerManifest, hostManifest, StringComparison.Ordinal);
if (sameFiles || string.IsNullOrWhiteSpace(hostEngine))
{
return (compatibleBaselines != null && peerBaseline != null && compatibleBaselines.Contains(peerBaseline)) ? null : BaselineRefusalMessage;
}
if (string.IsNullOrWhiteSpace(peerEngine))
{
return EngineUnknownRefusalMessage;
}
return string.Equals(peerEngine, hostEngine, StringComparison.Ordinal) ? null : EngineRefusalMessage;
}
/// <summary>
/// A fingerprint of the engine build from the module version ids of the engine assemblies (the same ids SceneAdapter.Baseline starts
/// with), in the order given. Lowercase SHA-256 hex.
/// </summary>
public static string EngineFingerprint(IEnumerable<string> moduleVersionIds)
{
string text = "teamcreate-engine-1\n" + string.Join("\n", moduleVersionIds ?? Array.Empty<string>());
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant();
}
private static Version ParseVersion(string value)
{
Version result;
return (!string.IsNullOrWhiteSpace(value) && Version.TryParse(value, out result)) ? result : null;
}
private static string SafePeerText(string value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
string text = CollaborationDiagnostics.Bounded(value, maxLength);
StringBuilder stringBuilder = new StringBuilder(text.Length);
string text2 = text;
foreach (char c in text2)
{
if (!char.IsControl(c))
{
stringBuilder.Append(c);
}
}
return stringBuilder.ToString();
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
namespace TeamCreate;
public enum BlobOffer
{
/// <summary>Nothing the packet names is missing and nothing is waiting ahead of it: the caller handles it now.</summary>
Process,
/// <summary>The packet waits for geometry; <see cref="BlobExchange.TryTakeReady"/> returns it, in order, once it has arrived.</summary>
Held,
/// <summary>The packet was refused (a bound was reached); <see cref="BlobExchange.TakeDropped"/> says why.</summary>
Dropped
}
/// <summary>The bytes of incomplete geometry one side may hold in total. Shared by every member of a host, so sixteen guests cannot each take the whole allowance.</summary>
public sealed class BlobBudget
{
private readonly long limit;
public BlobBudget( long limit = BlobLimits.MaxPendingBytes )
{
this.limit = limit;
}
public long Used { get; private set; }
public bool TryReserve( long bytes )
{
if ( bytes < 0 || Used + bytes > limit ) return false;
Used += bytes;
return true;
}
public void Release( long bytes )
{
Used = Math.Max( 0, Used - bytes );
}
}
/// <summary>
/// One end of the geometry channel of a connection. It does both halves: it serves the geometry the other end asks for (a queue of jobs
/// drained through a window of eight unacknowledged chunks, the same discipline as file synchronization), and it holds the scene packets
/// that name geometry not yet present, asks for it, assembles it from untrusted chunks (every rule of <see cref="FileChunkAssembler"/>, plus
/// the SHA-256 the packet named) and releases the packets in their original order when everything they name has arrived.
/// Nothing here throws on hostile input: a refusal drops the packet it concerns, says why, and the scene is synchronized again.
/// </summary>
public sealed class BlobExchange
{
private sealed class HeldPacket
{
public Packet Packet;
public List<string> Hashes;
public DateTime At;
/// <summary>The hashes this packet added to the wanted table, so releasing it removes exactly those.</summary>
public List<string> Counted = new();
}
private sealed class SendJob
{
public string Hash;
public byte[] Bytes;
public int Offset;
public bool Unavailable;
}
private sealed class Assembly
{
public long Total;
public byte[] Bytes;
public readonly HashSet<long> Received = new();
public long ReceivedBytes;
}
private readonly BlobStore store;
private readonly Action<Packet> send;
private readonly Func<DateTime> clock;
private readonly BlobBudget budget;
private readonly List<HeldPacket> held = new();
// How many held packets name each missing hash. A chunk is accepted only for a hash that is in this table: unsolicited data is ignored.
private readonly Dictionary<string, int> wanted = new( StringComparer.Ordinal );
private readonly HashSet<string> requested = new( StringComparer.Ordinal );
private readonly Dictionary<string, Assembly> assemblies = new( StringComparer.Ordinal );
private readonly Queue<SendJob> jobs = new();
private readonly FileChunkWindow window = new();
private readonly List<(Packet Packet, string Reason)> dropped = new();
private readonly List<string> notices = new();
public BlobExchange( BlobStore store, Action<Packet> send, Func<DateTime> clock, BlobBudget budget = null )
{
this.store = store;
this.send = send;
this.clock = clock;
this.budget = budget ?? new BlobBudget();
}
public int HeldCount => held.Count;
/// <summary>Bytes reserved for geometry that has started to arrive on this connection.</summary>
public long PendingBytes { get; private set; }
/// <summary>Geometry the other end has asked for and this end has not finished sending.</summary>
public int QueuedBlobs => jobs.Count;
/// <summary>Gives back the pending-bytes reservation of everything half-assembled; the connection is gone and nothing will complete.</summary>
public void Release()
{
foreach ( Assembly assembly in assemblies.Values ) budget.Release( assembly.Total );
assemblies.Clear();
PendingBytes = 0;
held.Clear();
wanted.Clear();
requested.Clear();
jobs.Clear();
}
public static bool IsBlobPacket( string kind ) => kind is "blob-need" or "blob-data" or "blob-ack";
// Only these carry object states. Everything else (presence, pings, files, settings) is never held, so live cursors stay live.
private static bool CarriesState( string kind ) => kind is "snapshot" or "accepted" or "edit" or "subscribe";
public static List<string> HashesOf( Packet packet )
{
List<string> hashes = new List<string>();
void Add( ObjectState state )
{
if ( (object)state?.Data == null ) return;
foreach ( string hash in BlobRules.Referenced( state.Data ) )
{
if ( !hashes.Contains( hash ) ) hashes.Add( hash );
}
}
if ( packet.Objects != null )
{
foreach ( ObjectState state in packet.Objects ) Add( state );
}
if ( packet.Changes != null )
{
// Only After is applied; Before is the state the receiver already holds and is read as plain text by the merge.
foreach ( Change change in packet.Changes ) Add( change?.After );
}
return hashes;
}
public BlobOffer Offer( Packet packet )
{
if ( !CarriesState( packet.Kind ) ) return BlobOffer.Process;
List<string> hashes = HashesOf( packet );
if ( held.Count == 0 && hashes.All( store.Has ) ) return BlobOffer.Process;
if ( held.Count >= BlobLimits.MaxPendingPackets )
{
Drop( packet, "More than " + BlobLimits.MaxPendingPackets + " scene updates are waiting for mesh geometry that has not arrived; this update was discarded and the scene will be synchronized again." );
return BlobOffer.Dropped;
}
HeldPacket entry = new HeldPacket { Packet = packet, Hashes = hashes, At = clock() };
foreach ( string hash in hashes.Where( x => !store.Has( x ) ) )
{
wanted[hash] = wanted.TryGetValue( hash, out int count ) ? count + 1 : 1;
entry.Counted.Add( hash );
}
held.Add( entry );
return BlobOffer.Held;
}
/// <summary>The oldest held packet, once everything it names is present. Later packets wait behind it, so edits keep the order the host accepted them in.</summary>
public bool TryTakeReady( out Packet packet )
{
packet = null;
if ( held.Count == 0 ) return false;
HeldPacket head = held[0];
if ( !head.Hashes.All( store.Has ) ) return false;
held.RemoveAt( 0 );
Unwant( head );
packet = head.Packet;
return true;
}
public List<(Packet Packet, string Reason)> TakeDropped()
{
List<(Packet Packet, string Reason)> result = dropped.ToList();
dropped.Clear();
return result;
}
public List<string> TakeNotices()
{
List<string> result = notices.ToList();
notices.Clear();
return result;
}
/// <summary>Handles blob-need, blob-data and blob-ack. Malformed input is ignored or drops the packet it concerns; it never throws.</summary>
public void Handle( Packet packet )
{
switch ( packet.Kind )
{
case "blob-need":
Enqueue( packet.Hashes );
break;
case "blob-ack":
window.Release();
break;
case "blob-data":
Receive( packet );
break;
}
}
/// <summary>Per frame: expires waiting packets, asks for what is missing, sends the next chunks the window allows.</summary>
public void Pump()
{
Expire();
Request();
Serve();
}
private void Enqueue( List<string> hashes )
{
if ( hashes == null ) return;
foreach ( string hash in hashes.Take( BlobLimits.MaxHashesPerRequest ) )
{
if ( !BlobStore.ValidHash( hash ) || jobs.Count >= BlobLimits.MaxHashesPerRequest || jobs.Any( x => x.Hash == hash ) ) continue;
jobs.Enqueue( new SendJob { Hash = hash } );
}
}
private void Serve()
{
while ( jobs.Count > 0 )
{
SendJob job = jobs.Peek();
if ( job.Bytes == null && !job.Unavailable )
{
if ( store.TryGet( job.Hash, out byte[] bytes ) ) job.Bytes = bytes;
else job.Unavailable = true;
}
if ( job.Unavailable )
{
// Answered at once so the asker does not wait out the timeout for geometry that will never come.
send( new Packet { Kind = "blob-data", Sha256 = job.Hash } );
jobs.Dequeue();
continue;
}
if ( !window.TryAcquire() ) return;
int length = Math.Min( FileSyncLimits.ChunkSize, job.Bytes.Length - job.Offset );
byte[] data = new byte[length];
Array.Copy( job.Bytes, job.Offset, data, 0, length );
send( new Packet { Kind = "blob-data", Sha256 = job.Hash, Offset = job.Offset, Total = job.Bytes.Length, Data = data } );
job.Offset += length;
if ( job.Offset >= job.Bytes.Length ) jobs.Dequeue();
}
}
private void Request()
{
List<string> missing = wanted.Keys.Where( x => !requested.Contains( x ) && !store.Has( x ) ).ToList();
for ( int i = 0; i < missing.Count; i += BlobLimits.MaxHashesPerRequest )
{
List<string> batch = missing.Skip( i ).Take( BlobLimits.MaxHashesPerRequest ).ToList();
send( new Packet { Kind = "blob-need", Hashes = batch } );
foreach ( string hash in batch ) requested.Add( hash );
}
}
private void Expire()
{
DateTime now = clock();
foreach ( HeldPacket packet in held.Where( x => ( now - x.At ).TotalSeconds >= BlobLimits.WaitSeconds ).ToList() )
{
held.Remove( packet );
Unwant( packet );
Drop( packet.Packet, "The mesh geometry of a scene update did not arrive within " + BlobLimits.WaitSeconds + " seconds; the update was discarded and the scene will be synchronized again." );
}
}
private void Receive( Packet packet )
{
string hash = packet.Sha256;
// Unsolicited, malformed or already satisfied: ignored. Only geometry a held packet is waiting for is ever accepted.
if ( !BlobStore.ValidHash( hash ) || !wanted.ContainsKey( hash ) || store.Has( hash ) ) return;
if ( packet.Total == 0 )
{
Fail( hash, "The sender no longer has the mesh geometry that a scene update names; the update was discarded and the scene will be synchronized again." );
return;
}
if ( packet.Total < 0 || packet.Total > BlobLimits.MaxBlobBytes )
{
Fail( hash, "Mesh geometry larger than the 32 MiB collaboration limit was offered and refused; the update was discarded." );
return;
}
if ( !assemblies.TryGetValue( hash, out Assembly assembly ) )
{
if ( !budget.TryReserve( packet.Total ) )
{
Fail( hash, "More than 64 MiB of mesh geometry is arriving at once; the update was discarded and the scene will be synchronized again." );
return;
}
PendingBytes += packet.Total;
assemblies[hash] = assembly = new Assembly { Total = packet.Total, Bytes = new byte[packet.Total] };
}
else if ( assembly.Total != packet.Total )
{
Fail( hash, "Mesh geometry arrived with inconsistent sizes and was discarded; the scene will be synchronized again." );
return;
}
byte[] data = packet.Data ?? Array.Empty<byte>();
if ( !FileChunkAssembler.IsValidChunk( assembly.Total, packet.Offset, data.Length ) )
{
Fail( hash, "Mesh geometry arrived in a form that was not valid and was discarded; the scene will be synchronized again." );
return;
}
// Always acknowledged, a repeat included: the sender spent a credit on it.
send( new Packet { Kind = "blob-ack", Sha256 = hash, Offset = packet.Offset } );
if ( !assembly.Received.Add( packet.Offset ) ) return;
Array.Copy( data, 0, assembly.Bytes, packet.Offset, data.Length );
assembly.ReceivedBytes += data.Length;
if ( assembly.ReceivedBytes < assembly.Total ) return;
Finish( hash, assembly );
if ( !store.Put( hash, assembly.Bytes, out string error ) )
{
Fail( hash, error );
}
}
private void Finish( string hash, Assembly assembly )
{
assemblies.Remove( hash );
PendingBytes -= assembly.Total;
budget.Release( assembly.Total );
requested.Remove( hash );
}
// The geometry named by `hash` will not arrive intact: every packet waiting for it is dropped, because applying part of an update
// would leave a scene nobody had.
private void Fail( string hash, string reason )
{
if ( assemblies.TryGetValue( hash, out Assembly assembly ) ) Finish( hash, assembly );
requested.Remove( hash );
foreach ( HeldPacket packet in held.Where( x => x.Hashes.Contains( hash ) ).ToList() )
{
held.Remove( packet );
Unwant( packet );
Drop( packet.Packet, reason );
}
wanted.Remove( hash );
}
// A held packet leaves the queue (released or dropped): hashes nobody waits for any more are no longer wanted, and their half-built
// assemblies give their reservation back.
private void Unwant( HeldPacket packet )
{
foreach ( string hash in packet.Counted )
{
if ( !wanted.TryGetValue( hash, out int count ) ) continue;
if ( count > 1 )
{
wanted[hash] = count - 1;
continue;
}
wanted.Remove( hash );
requested.Remove( hash );
if ( assemblies.TryGetValue( hash, out Assembly assembly ) ) Finish( hash, assembly );
}
}
private void Drop( Packet packet, string reason )
{
dropped.Add( (packet, reason) );
if ( !notices.Contains( reason ) ) notices.Add( reason );
}
}
Editor
library
using System.Text.Json.Nodes;
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using System.Linq;
namespace TeamCreate;
public sealed record ObjectState(string Id, string Parent, JsonObject Data);
public sealed record Change(string Id, ObjectState Before, ObjectState After);
/// <summary>
/// One message on the wire. Every property that is unset (null, zero or false) is omitted from the JSON: a presence packet used to carry about twenty
/// <c>"Field":null</c> entries, almost half of its 778 bytes, and presence is the one message sent thirty times a second by every editor. Receivers read
/// a missing field as the same default, so older peers are unaffected in both directions.
/// </summary>
public sealed class Packet
{
// Project file synchronization (FileSync*.cs). Every member is additive and omitted from the wire when unset, so presence and edits
// stay as small as before and peers that predate file sync ignore them.
/// <summary>A project-relative file path with "/" separators. Untrusted: the receiver validates it with FilePathRules before any disk access.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Path { get; set; }
/// <summary>SHA-256 (lowercase hex) of the file as ProjectManifest hashes it (a .meta file through MetaFileRules.Canonical).</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Sha256 { get; set; }
/// <summary>On file-put and file-delete: the version the sender last agreed with the session, null for a file it believes is new.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string BaseSha256 { get; set; }
/// <summary>Byte offset of a file-chunk (or of the chunk a file-ack acknowledges).</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long Offset { get; set; }
/// <summary>The whole file's size in bytes on file-chunk, file-put and file-changed.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long Total { get; set; }
/// <summary>
/// On blob-need: the SHA-256 digests (lowercase hex) of the mesh geometry the sender is missing, at most BlobLimits.MaxHashesPerRequest.
/// Untrusted: only exactly 64 lowercase hex digits are ever looked up. Chunks of that geometry travel as blob-data, reusing Sha256, Offset,
/// Total and Data; blob-ack returns a chunk's window credit. All additive and omitted when unset (see BlobExchange).
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string> Hashes { get; set; }
/// <summary>One chunk of file content (base64 on the wire, at most FileSyncLimits.ChunkSize bytes before encoding).</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public byte[] Data { get; set; }
/// <summary>One page of the host's file index on a files-index reply; Final marks the last page.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<FileIndexEntry> FileEntries { get; set; }
/// <summary>
/// On the final files-index page: the package identifiers the host's .sbproj lists (PackageReferenceDiff.Idents, at most 50). The project
/// file itself is Code and never travels; only these validated names do, so a guest can be told what its project does not list.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string> Packages { get; set; }
/// <summary>
/// What the host supports, sent on welcome. A guest starts file synchronization only when it holds FileSyncLimits.Feature: a newer
/// guest is admitted by an older host, which would answer an unknown message kind with a fatal error.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string> Features { get; set; }
/// <summary>
/// On hello: the fingerprint of the engine build alone (AdmissionRules.EngineFingerprint). The full Baseline also covers every project
/// file, so it necessarily differs for a guest whose files are about to be synchronized; this keeps the engine check for that guest.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string EngineBaseline { get; set; }
public string Kind { get; set; }
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Peer { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Name { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Project { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Baseline { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Token { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Session { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Scene { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Error { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long Revision { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool Final { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<ObjectState> Objects { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<Change> Changes { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonObject Presence { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? ProtocolVersion { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public CollaborationMode? Mode { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string RuntimeRunId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? RuntimeTick { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? RuntimeSequence { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string RuntimeOperationKind { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string SteamId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string DisplayName { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string SteamToken { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string OrgIdent { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string LibraryVersion { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string ProjectManifest { get; set; }
/// <summary>
/// A formal sentence for the people in the session when the host combined concurrent edits (or let one edit replace another);
/// null when nothing needs saying. Additive: omitted from the wire when unset, ignored by peers that predate it.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Notice { get; set; }
/// <summary>
/// True on an accepted packet when the host applied something different from what the sender sent, so the sender knows to
/// take the host's state rather than keep its own. Additive: omitted from the wire when false.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool Merged { get; set; }
/// <summary>
/// Scene-level settings (<see cref="SettingsMerge"/>): on a subscribe, the subscriber's settings for the scene; on a snapshot, the host's;
/// on a client's <c>settings</c> packet, what the sender has now; on the host's <c>settings</c> broadcast, the merged result every member
/// adopts. Additive: omitted from the wire when unset, ignored by peers that predate it.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonObject Settings { get; set; }
/// <summary>On a client's <c>settings</c> packet: the settings the sender last had confirmed by the host, the base of the three-way merge. Additive.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonObject SettingsBefore { get; set; }
/// <summary>
/// On a ping: the fingerprint (<see cref="SceneDigest"/>) of the scene state this client last had confirmed by the host, so the
/// host can notice an editor that has silently drifted. Additive: omitted from the wire when unset, and a host that predates it ignores it.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Digest { get; set; }
/// <summary>On a ping: the last revision this client had applied when it computed <see cref="Digest"/>. The host compares digests only at equal revisions.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? DigestRevision { get; set; }
/// <summary>On a ping: the scene <see cref="Digest"/> describes.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string DigestScene { get; set; }
/// <summary>
/// On a presence packet: the sender's own monotonic milliseconds (<see cref="SessionClock"/>) when it was sent, so receivers can place the
/// sample in time and draw a smooth path instead of the latest raw value. Not wall-clock time, and meaningful only to the same sender's
/// later stamps. The host relays it untouched. Additive: omitted from the wire when 0, which is also how a peer that predates the field reads.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public double SentMs { get; set; }
/// <summary>
/// Shared play (<see cref="PlayFrame"/>): on a <c>play-frame</c>, the frame; on <c>play-start</c> and <c>play-stop</c>, unused. Additive: omitted
/// from the wire when unset, and only ever sent to a peer that opted in with <c>play-optin</c>, so an older peer never sees it.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonObject Play { get; set; }
}
public static class Protocol
{
public const int CurrentVersion = 4;
public const string CurrentVersionName = "teamcreate-4";
public const string VersionName = "teamcreate-4";
public const string OlderLibraryMessage = "This peer runs an older collaboration library. Update the collaboration library on both machines and reconnect.";
public const string MissingProjectRevisionMessage = "This peer did not report its project revision. Update the collaboration library on both machines and reconnect.";
public static bool IsValidMode(CollaborationMode? mode)
{
return mode == CollaborationMode.Source || mode == CollaborationMode.RuntimeExperimental;
}
public static bool ValidateSourceHandshake(Packet p)
{
return GetHandshakeError(p) == null && p != null && p.Mode == CollaborationMode.Source;
}
public static string GetHandshakeError(Packet p, bool requireValidPeer = false)
{
if (p == null || p.Kind != "hello" || (requireValidPeer && !Guid.TryParse(p.Peer, out var _)))
{
return "Invalid collaboration handshake";
}
if (p.ProtocolVersion != 4)
{
return $"Unsupported collaboration protocol version. This client requires protocol {4} ({"teamcreate-4"}).";
}
if (!IsValidMode(p.Mode))
{
return "Invalid collaboration mode in hello. Source or runtime collaboration mode is required.";
}
if (p.Mode == CollaborationMode.RuntimeExperimental && !RuntimeStateRules.IsValidRunId(p.RuntimeRunId))
{
return "Runtime collaboration handshake requires a valid runtime run ID.";
}
if (!SteamIdentityRules.TryParse(p.SteamId, out var _))
{
return "Collaboration handshake requires a valid Steam account ID. Sign in to Steam and reconnect.";
}
if (!SteamTokenRules.IsPlausibleToken(p.SteamToken))
{
return "Collaboration handshake requires a Steam identity token. Reconnect to obtain one.";
}
if (string.IsNullOrWhiteSpace(p.OrgIdent) || string.IsNullOrWhiteSpace(p.LibraryVersion) || !Version.TryParse(p.LibraryVersion, out Version _))
{
return "This peer runs an older collaboration library. Update the collaboration library on both machines and reconnect.";
}
if (string.IsNullOrWhiteSpace(p.ProjectManifest))
{
return "This peer did not report its project revision. Update the collaboration library on both machines and reconnect.";
}
return null;
}
public static bool ValidateHandshake(Packet p)
{
return GetHandshakeError(p) == null;
}
public static bool ValidateRuntimeEnvelope(Packet p, string activeRunId, string activeScenePath = null, long? lastAcceptedSequence = null)
{
if (p == null || p.ProtocolVersion != 4 || p.Mode != CollaborationMode.RuntimeExperimental)
{
return false;
}
if (!IsValidMode(p.Mode) || !RuntimeStateRules.IsValidRunId(activeRunId) || !RuntimeStateRules.IsValidRunId(p.RuntimeRunId))
{
return false;
}
if (!string.Equals(p.RuntimeRunId, activeRunId, StringComparison.Ordinal))
{
return false;
}
if (!p.RuntimeSequence.HasValue || p.RuntimeSequence.Value <= 0)
{
return false;
}
if (lastAcceptedSequence.HasValue && p.RuntimeSequence.Value <= lastAcceptedSequence.Value)
{
return false;
}
return activeScenePath == null || string.Equals(p.Scene, activeScenePath, StringComparison.Ordinal);
}
}
/// <summary>A change the scan loop did not send, with the formal sentence telling the user why.</summary>
public sealed record HeldChange( Change Change, string Reason );
public static class StateRules
{
public const string Version = "teamcreate-1";
// The structural limits on one object, in one place. Nothing here asks whether a property or component type is on a list:
// every edit a person can make is synchronised, and these bounds only protect the host, the peers and the relay from a
// hostile or runaway payload. Each limit is inclusive (an object at exactly the limit passes).
/// <summary>Largest serialised <see cref="ObjectState"/>, in UTF-8 bytes of its JSON.</summary>
public const int MaxObjectBytes = 1024 * 1024;
/// <summary>
/// Deepest JSON nesting inside an object's data, counting the data object itself as level 1. System.Text.Json refuses to
/// write or read beyond 64 levels, and the data travels inside envelopes: a packet adds four levels (packet, change list,
/// change, After), the outbox file one more and the failure report two more. 56 leaves those envelopes room with a margin
/// of two, so an object that passes here can always be sent, stored and reported.
/// </summary>
public const int MaxDataDepth = 56;
/// <summary>Most components on one object.</summary>
public const int MaxComponents = 256;
/// <summary>Largest single text value, in UTF-8 bytes.</summary>
public const int MaxStringBytes = 256 * 1024;
/// <summary>Longest property name, in characters.</summary>
public const int MaxKeyLength = 200;
/// <summary>Longest component type name, in characters.</summary>
public const int MaxTypeNameLength = 200;
/// <summary>
/// The reserved (<c>__</c>) property names the engine and this library are known to write. Observed in the scenes and
/// prefabs installed with s&box: <c>__guid</c>, <c>__version</c>, <c>__type</c>, <c>__enabled</c>, <c>__references</c>,
/// <c>__Prefab</c>, <c>__PrefabInstancePatch</c>, <c>__PrefabIdToInstanceId</c> (scenes) and <c>__properties</c>,
/// <c>__variables</c> (prefabs). Read or written by the engine's own serializer (GameObject and Component in
/// Sandbox.Engine): <c>__PrefabVariables</c>, <c>__EditorPrefabNestedInstance</c>, <c>__EditorSkipPrefabBreakOnRefresh</c>,
/// <c>__NetworkedPrefaInstance</c> (the engine's spelling), <c>__snapshot</c>. <c>__PrefabVariant</c> is listed because
/// the engine is expected to write it, though no installed sample carries it. <see cref="SiblingOrder.Key"/> is the library's own.
/// A name outside this set is not refused (a newer engine may add one, and refusing it would stop the session's work);
/// <see cref="FindUnknownReservedKeys"/> reports it so it can be counted.
/// </summary>
public static readonly IReadOnlySet<string> KnownReservedKeys = new HashSet<string>( StringComparer.Ordinal )
{
"__guid", "__version", "__type", "__enabled", "__references", "__Prefab", "__PrefabInstancePatch", "__PrefabIdToInstanceId",
"__properties", "__variables", "__PrefabVariables", "__EditorPrefabNestedInstance", "__EditorSkipPrefabBreakOnRefresh",
"__NetworkedPrefaInstance", "__snapshot", "__PrefabVariant", SiblingOrder.Key
};
public static bool Equal(ObjectState a, ObjectState b)
{
return ((object)a == null) ? ((object)b == null) : ((object)b != null && a.Id == b.Id && a.Parent == b.Parent && ValuesEqual(a.Data, b.Data));
}
/// <summary>
/// Structural equality of two scene values, tolerant of the engine's 32-bit float round trip. It matches
/// <see cref="JsonNode.DeepEquals"/> (objects equal whatever their key order but only with the same key set, arrays element by element in
/// order, null equal to null, strings and booleans exact) except for numbers. Two numbers that both read as a
/// <c>long</c> are compared as longs, so integers above 2^53 stay exact; otherwise two numbers are equal when they are
/// the same 32-bit float, which makes <c>0.6</c> equal to <c>0.600000024</c> (what the editor writes back) and <c>1</c>
/// equal to <c>1.0</c>. Without this every echo of a float property looked like an edit by somebody else.
/// </summary>
public static bool ValuesEqual( JsonNode a, JsonNode b )
{
if ( (object)a == (object)b )
{
return true;
}
if ( a is null || b is null )
{
return false;
}
if ( a is JsonObject objectA )
{
if ( b is not JsonObject objectB || objectA.Count != objectB.Count )
{
return false;
}
foreach ( KeyValuePair<string, JsonNode> member in objectA )
{
if ( !objectB.TryGetPropertyValue( member.Key, out JsonNode other ) || !ValuesEqual( member.Value, other ) )
{
return false;
}
}
return true;
}
if ( a is JsonArray arrayA )
{
if ( b is not JsonArray arrayB || arrayA.Count != arrayB.Count )
{
return false;
}
for ( int i = 0; i < arrayA.Count; i++ )
{
if ( !ValuesEqual( arrayA[i], arrayB[i] ) )
{
return false;
}
}
return true;
}
if ( b is JsonObject || b is JsonArray )
{
return false;
}
if ( a.GetValueKind() == System.Text.Json.JsonValueKind.Number && b.GetValueKind() == System.Text.Json.JsonValueKind.Number )
{
return NumbersEqual( a, b );
}
return JsonNode.DeepEquals( a, b );
}
// Reads the numbers through their JSON text, which is the same whether a node was parsed off the wire or built in code
// (a JsonValue made from an int does not report TryGetValue<long>). Exactly equal numbers are settled first, so the
// common unchanged case never parses anything.
private static bool NumbersEqual( JsonNode a, JsonNode b )
{
if ( JsonNode.DeepEquals( a, b ) )
{
return true;
}
string textA = a.ToJsonString();
string textB = b.ToJsonString();
System.Globalization.CultureInfo invariant = System.Globalization.CultureInfo.InvariantCulture;
if ( long.TryParse( textA, System.Globalization.NumberStyles.AllowLeadingSign, invariant, out long longA ) && long.TryParse( textB, System.Globalization.NumberStyles.AllowLeadingSign, invariant, out long longB ) )
{
return longA == longB;
}
if ( double.TryParse( textA, System.Globalization.NumberStyles.Float, invariant, out double doubleA ) && double.TryParse( textB, System.Globalization.NumberStyles.Float, invariant, out double doubleB ) )
{
return (float)doubleA == (float)doubleB;
}
return false;
}
public static Dictionary<string, ObjectState> Map(IEnumerable<ObjectState> objects)
{
return objects.ToDictionary((ObjectState x) => x.Id);
}
public static List<Change> Diff(Dictionary<string, ObjectState> before, Dictionary<string, ObjectState> after)
{
return (from id in before.Keys.Union(after.Keys)
orderby id
select new Change(id, before.GetValueOrDefault(id), after.GetValueOrDefault(id)) into x
where !Equal(x.Before, x.After)
select x).ToList();
}
/// <summary>Throws the formal refusal of <see cref="RefusalOf"/> when there is one.</summary>
public static void ValidateChange( Change c )
{
string refusal = RefusalOf( c );
if ( refusal != null )
{
throw new InvalidOperationException( refusal );
}
}
/// <summary>
/// Why <paramref name="c"/> may not be sent or applied, as a formal sentence naming the object, or null when it may. Only
/// structure is checked: the change is a creation (no Before), an edit or a deletion (no After) of one identified object;
/// an edit keeps its identity; and the state that would be sent (After) is a well-formed object within the size limits.
/// Any property, any component type and any component property are allowed, as are adding, removing and reordering
/// components. Before is held to identity only: it is the state the host already holds, so an object that is already too
/// large can still be shrunk or deleted.
/// </summary>
public static string RefusalOf( Change c )
{
if ( c is null || ( (object)c.Before == null && (object)c.After == null ) )
{
return "An empty change was not sent.";
}
foreach ( ObjectState state in new[] { c.Before, c.After } )
{
if ( (object)state != null && !HasIdentity( c.Id, state ) )
{
return "The object \"" + NameOf( state, c.Id ) + "\" does not have a valid identifier and was not sent.";
}
}
return (object)c.After == null ? null : RefusalOfState( c.After );
}
// The id is a GUID, the state carries that id and its data names the same one in __guid, so no edit changes an identity.
private static bool HasIdentity( string id, ObjectState state )
{
return Guid.TryParse( id, out _ )
&& string.Equals( state.Id, id, StringComparison.Ordinal )
&& state.Data is not null
&& state.Data["__guid"] is JsonValue guid && guid.TryGetValue( out string text ) && string.Equals( text, id, StringComparison.Ordinal );
}
// The structural rules on one state that is about to be sent. The tree walk comes first: it bounds the depth, so the
// serialisation that measures the size cannot fail on a payload nested too deep to write.
private static string RefusalOfState( ObjectState state )
{
string name = "The object \"" + NameOf( state, state.Id ) + "\" ";
string walk = RefusalOfTree( state.Data, 1 );
if ( walk != null )
{
return name + walk;
}
if ( state.Data.TryGetPropertyValue( "Components", out JsonNode list ) )
{
if ( list is not JsonArray components )
{
return name + "has a component list that is not a list and was not sent.";
}
if ( components.Count > MaxComponents )
{
return name + "has more than " + MaxComponents + " components, beyond the collaboration limit, and was not sent.";
}
HashSet<string> guids = new HashSet<string>( StringComparer.Ordinal );
foreach ( JsonNode entry in components )
{
if ( entry is not JsonObject component )
{
return name + "has a component entry that is not an object and was not sent.";
}
if ( component["__type"] is not JsonValue typeValue || !typeValue.TryGetValue( out string type ) || string.IsNullOrEmpty( type ) )
{
return name + "has a component without a type name and was not sent.";
}
if ( type.Length > MaxTypeNameLength )
{
return name + "has a component type name longer than " + MaxTypeNameLength + " characters and was not sent.";
}
if ( component["__guid"] is not JsonValue guidValue || !guidValue.TryGetValue( out string guid ) || !Guid.TryParse( guid, out _ ) )
{
return name + "has a component without a valid identifier and was not sent.";
}
if ( !guids.Add( guid ) )
{
return name + "has two components with the identifier " + guid + " and was not sent.";
}
}
}
if ( System.Text.Json.JsonSerializer.SerializeToUtf8Bytes( state ).Length > MaxObjectBytes )
{
return name + "is larger than the " + MaxObjectBytes / ( 1024 * 1024 ) + " MiB collaboration limit and was not sent.";
}
return null;
}
// Depth, property names and text sizes, everywhere in the tree. `depth` is the level of `node` (the data object is 1).
private static string RefusalOfTree( JsonNode node, int depth )
{
if ( node is JsonObject obj )
{
if ( depth > MaxDataDepth )
{
return DepthRefusal();
}
foreach ( KeyValuePair<string, JsonNode> member in obj )
{
if ( member.Key.Length == 0 )
{
return "contains an empty property name and was not sent.";
}
if ( member.Key.Length > MaxKeyLength )
{
return "contains a property name longer than " + MaxKeyLength + " characters and was not sent.";
}
string inner = RefusalOfTree( member.Value, depth + 1 );
if ( inner != null )
{
return inner;
}
}
return null;
}
if ( node is JsonArray array )
{
if ( depth > MaxDataDepth )
{
return DepthRefusal();
}
foreach ( JsonNode item in array )
{
string inner = RefusalOfTree( item, depth + 1 );
if ( inner != null )
{
return inner;
}
}
return null;
}
if ( node is JsonValue value && value.TryGetValue( out string text ) && System.Text.Encoding.UTF8.GetByteCount( text ) > MaxStringBytes )
{
// Geometry is moved out of the state before it gets here (BlobRules.Externalize); what is still inline is over the 32 MiB
// limit, and the person is told that, not that "a text value" is too long.
if ( BlobRules.IsCanonicalBase64( text ) && text.Length / 4 * 3 > BlobLimits.MaxBlobBytes )
{
return "has geometry larger than the " + BlobLimits.MaxBlobBytes / ( 1024 * 1024 ) + " MiB collaboration limit and was not sent.";
}
return "contains a text value larger than the " + MaxStringBytes / 1024 + " KiB collaboration limit and was not sent.";
}
return null;
}
private static string DepthRefusal() => "is nested more than " + MaxDataDepth + " levels deep, beyond the collaboration limit, and was not sent.";
/// <summary>
/// The reserved (<c>__</c>) property names anywhere in <paramref name="data"/> that are not in <see cref="KnownReservedKeys"/>,
/// each once, in the order they are first met. They are allowed; the caller counts or logs them.
/// </summary>
public static List<string> FindUnknownReservedKeys( JsonObject data )
{
List<string> found = new List<string>();
Visit( data );
return found;
void Visit( JsonNode node )
{
if ( node is JsonObject obj )
{
foreach ( KeyValuePair<string, JsonNode> member in obj )
{
if ( member.Key.StartsWith( "__", StringComparison.Ordinal ) && !KnownReservedKeys.Contains( member.Key ) && !found.Contains( member.Key ) )
{
found.Add( member.Key );
}
Visit( member.Value );
}
}
else if ( node is JsonArray array )
{
foreach ( JsonNode item in array )
{
Visit( item );
}
}
}
}
/// <summary>
/// Splits the scan loop's batch into what is sent and what is held. A change that <see cref="RefusalOf"/> refuses is held
/// with its refusal, and so is every change that would leave the host's scene inconsistent without it: an object whose
/// parent is held (or otherwise absent), and the deletion of an object that a held state still names as its parent. The
/// rest is returned in the original order and, applied to <paramref name="known"/>, always passes <see cref="ValidateGraph"/>.
/// Holding one object's change keeps the session running: the others are sent, and the held object stays as the session
/// last confirmed it until its edit fits again.
/// </summary>
public static List<Change> SendableChanges( IReadOnlyDictionary<string, ObjectState> known, IEnumerable<Change> changes, List<HeldChange> held )
{
List<Change> all = changes.ToList();
Dictionary<string, string> reasons = new Dictionary<string, string>( StringComparer.Ordinal );
List<string> order = new List<string>();
void Hold( Change change, string reason )
{
reasons[change.Id] = reason;
order.Add( change.Id );
}
foreach ( Change change in all )
{
string refusal = RefusalOf( change );
if ( refusal != null )
{
Hold( change, refusal );
}
}
Dictionary<string, ObjectState> result = null;
bool settled = false;
while ( !settled )
{
settled = true;
result = Result( known, all, reasons );
foreach ( Change change in all.Where( x => !reasons.ContainsKey( x.Id ) ) )
{
if ( (object)change.After != null && !string.IsNullOrEmpty( change.After.Parent ) && !result.ContainsKey( change.After.Parent ) )
{
Hold( change, "The object \"" + NameOf( change.After, change.Id ) + "\" was not sent because its parent \"" + NameIn( all, known, change.After.Parent ) + "\" was not sent." );
settled = false;
}
else if ( (object)change.After == null && result.Values.FirstOrDefault( x => string.Equals( x.Parent, change.Id, StringComparison.Ordinal ) ) is { } child )
{
Hold( change, "The deletion of \"" + NameOf( change.Before, change.Id ) + "\" was not sent because its child \"" + NameOf( child, child.Id ) + "\" was not sent." );
settled = false;
}
}
}
// Anything the rules above did not foresee (a component identity used by two objects, a parenting cycle) holds the
// whole batch rather than letting the host refuse it.
try
{
ValidateGraph( result );
}
catch ( InvalidOperationException ex )
{
foreach ( Change change in all.Where( x => !reasons.ContainsKey( x.Id ) ).ToList() )
{
Hold( change, "The change to \"" + NameOf( change.After ?? change.Before, change.Id ) + "\" was not sent because the scene would not stay consistent (" + ex.Message + ")." );
}
}
foreach ( string id in order )
{
held.Add( new HeldChange( all.First( x => x.Id == id ), reasons[id] ) );
}
return all.Where( x => !reasons.ContainsKey( x.Id ) ).ToList();
}
private static Dictionary<string, ObjectState> Result( IReadOnlyDictionary<string, ObjectState> known, List<Change> all, Dictionary<string, string> reasons )
{
Dictionary<string, ObjectState> result = known.ToDictionary( x => x.Key, x => x.Value );
foreach ( Change change in all.Where( x => !reasons.ContainsKey( x.Id ) ) )
{
if ( (object)change.After == null )
{
result.Remove( change.Id );
}
else
{
result[change.Id] = change.After;
}
}
return result;
}
// The name of an object the batch or the session knows, for a sentence about it.
private static string NameIn( List<Change> all, IReadOnlyDictionary<string, ObjectState> known, string id )
{
Change change = all.FirstOrDefault( x => x.Id == id );
ObjectState state = change?.After ?? change?.Before ?? known.GetValueOrDefault( id );
return (object)state == null ? id : NameOf( state, id );
}
/// <summary>The name a person recognises an object by (its Name, shortened when very long), or its id when it has none.</summary>
public static string NameOf( ObjectState state, string id )
{
if ( state?.Data?["Name"] is JsonValue value && value.TryGetValue( out string name ) && !string.IsNullOrEmpty( name ) )
{
return name.Length <= 100 ? name : name.Substring( 0, 100 ) + "...";
}
return id ?? state?.Id;
}
/// <summary>The notice for a component type the sender has and this project lacks; shown once per type.</summary>
public static string MissingComponentTypeNotice( string type ) =>
"The component type \"" + type + "\" is not available in your project. Update your code to see it.";
/// <summary>The notice for an object that stopped being saved with the scene (for example, marked as not saved).</summary>
public static string NoLongerSavedNotice( string name ) =>
"The object \"" + name + "\" can no longer be saved with the scene, so it was removed from the shared scene.";
public static void ValidateGraph(Dictionary<string, ObjectState> objects)
{
HashSet<string> hashSet = new HashSet<string>(objects.Keys);
foreach (ObjectState value in objects.Values)
{
if (value.Data["Components"] is JsonArray jsonArray)
{
foreach (JsonNode item in jsonArray)
{
string text = item?["__guid"]?.GetValue<string>();
if (text == null || !Guid.TryParse(text, out var _) || !hashSet.Add(text))
{
throw new InvalidOperationException("Duplicate/invalid component identity");
}
}
}
HashSet<string> hashSet2 = new HashSet<string> { value.Id };
string parent = value.Parent;
while (!string.IsNullOrEmpty(parent))
{
if (!objects.ContainsKey(parent))
{
throw new InvalidOperationException("Parent does not exist");
}
if (!hashSet2.Add(parent))
{
throw new InvalidOperationException("Parenting cycle");
}
parent = objects[parent].Parent;
}
}
}
}
public sealed class Authority
{
private readonly Dictionary<string, Packet> receipts = new Dictionary<string, Packet>();
private readonly Action<Packet> persist;
public Dictionary<string, Dictionary<string, ObjectState>> Scenes { get; } = new Dictionary<string, Dictionary<string, ObjectState>>();
/// <summary>
/// The host's scene-level settings per scene, taken from the first subscribe that created the scene and changed only through
/// <see cref="ApplySettings"/>. A scene whose first subscriber sent none has no entry.
/// </summary>
public Dictionary<string, JsonObject> SceneSettings { get; } = new Dictionary<string, JsonObject>();
public long Revision { get; private set; }
public Authority(Action<Packet> persist)
{
this.persist = persist;
}
public Packet Subscribe(Packet p)
{
if (!Scenes.ContainsKey(p.Scene))
{
Dictionary<string, ObjectState> dictionary = StateRules.Map(p.Objects);
StateRules.ValidateGraph(dictionary);
persist(new Packet
{
Kind = "seed",
Scene = p.Scene,
Objects = p.Objects,
Settings = p.Settings
});
Scenes.Add(p.Scene, dictionary);
// No size bound here: the subscribe packet has already arrived whole, and refusing the settings would leave the scene with
// none, which every later change would then replace wholesale. The bound applies to changes, which are rebroadcast.
if (p.Settings != null)
{
SceneSettings[p.Scene] = SettingsMerge.Clone(p.Settings);
}
}
// The snapshot is a copy: the same packet goes to a drift resync and, in-process, to tests that may change it.
return new Packet
{
Kind = "snapshot",
Scene = p.Scene,
Objects = Scenes[p.Scene].Values.ToList(),
Settings = SettingsMerge.Clone(SceneSettings.GetValueOrDefault(p.Scene)),
Revision = Revision
};
}
/// <summary>
/// Applies one member's change to a scene's settings (<see cref="SettingsMerge"/>): merged per top-level key against what the host holds,
/// later change wins on a clash with the key named in <see cref="Packet.Notice"/>. Returns the <c>settings</c> packet to broadcast (the
/// merged settings, sender, id and new revision, also what the journal records) or a <c>reject</c> for the sender alone. A resent packet
/// gets the receipt it got the first time, as <see cref="Apply"/> does.
/// </summary>
public Packet ApplySettings(Packet p)
{
string key = p.Peer + ":" + p.Id;
if (receipts.TryGetValue(key, out var value))
{
return value;
}
try
{
if (p.Scene == null || !Scenes.ContainsKey(p.Scene))
{
throw new InvalidOperationException("Subscribe to scene first");
}
if (p.Settings == null || p.SettingsBefore == null)
{
throw new InvalidOperationException("A scene settings change must carry the settings before and after the change.");
}
string sizeError = SettingsMerge.SizeError(p.Settings) ?? SettingsMerge.SizeError(p.SettingsBefore);
if (sizeError != null)
{
throw new InvalidOperationException(sizeError);
}
JsonObject merged;
List<string> clashed;
if (!SceneSettings.TryGetValue(p.Scene, out JsonObject current))
{
// Nothing to merge with: the scene's first subscriber reported no settings. The change is adopted whole.
merged = SettingsMerge.Clone(p.Settings);
clashed = new List<string>();
}
else if (!SettingsMerge.TryMerge(current, p.SettingsBefore, p.Settings, out merged, out clashed))
{
throw new InvalidOperationException("The scene settings change could not be merged with the host's settings.");
}
sizeError = SettingsMerge.SizeError(merged);
if (sizeError != null)
{
throw new InvalidOperationException(sizeError);
}
value = new Packet
{
Kind = SettingsMerge.PacketKind,
Id = p.Id,
Peer = p.Peer,
Scene = p.Scene,
Settings = SettingsMerge.Clone(merged),
Revision = Revision + 1,
Merged = clashed.Count > 0 || !StateRules.ValuesEqual(merged, p.Settings),
Notice = SettingsMerge.ClashNotice(clashed)
};
persist(value);
Revision++;
SceneSettings[p.Scene] = merged;
}
catch (Exception ex)
{
value = new Packet
{
Kind = "reject",
Id = p.Id,
Scene = p.Scene,
Error = ex.Message
};
}
receipts[key] = value;
return value;
}
public Packet Apply(Packet p)
{
string key = p.Peer + ":" + p.Id;
if (receipts.TryGetValue(key, out var value))
{
return value;
}
try
{
if (!Scenes.TryGetValue(p.Scene, out var value2))
{
throw new InvalidOperationException("Subscribe to scene first");
}
if (p.Changes == null || (p.Changes.Count == 0 && !p.Final))
{
throw new InvalidOperationException("Empty operation");
}
if (p.Changes.Select((Change x) => x.Id).Distinct().Count() != p.Changes.Count)
{
throw new InvalidOperationException("Duplicate object in operation");
}
Dictionary<string, ObjectState> dictionary = new Dictionary<string, ObjectState>(value2);
List<Change> applied = new List<Change>();
List<string> notices = new List<string>();
foreach (Change change in p.Changes)
{
StateRules.ValidateChange(change);
Change change2 = Reconcile(change, value2.GetValueOrDefault(change.Id), notices);
if ((object)change2.After == null)
{
dictionary.Remove(change2.Id);
}
else
{
dictionary[change2.Id] = change2.After;
}
applied.Add(change2);
}
StateRules.ValidateGraph(dictionary);
// The packet carries what the host applied, not what the sender sent: every follower's Before is then the state it
// already holds, and the journal replays to the host's scene. Merged is set when the host's result is not simply the
// sender's edit: some applied After differs from the one sent, or the outcome needed a notice (a clash resolved in the
// sender's favour still leaves After equal to the one sent, yet the people in the session must be told).
bool merged = notices.Count > 0;
for (int i = 0; i < applied.Count; i++)
{
merged |= !StateRules.Equal(applied[i].After, p.Changes[i].After);
}
value = new Packet
{
Kind = "accepted",
Id = p.Id,
Peer = p.Peer,
Scene = p.Scene,
Changes = applied,
Revision = Revision + 1,
Final = p.Final,
Merged = merged,
Notice = (notices.Count == 0) ? null : string.Join(" ", notices)
};
persist(value);
Revision++;
Scenes[p.Scene] = dictionary;
}
catch (Exception ex)
{
value = new Packet
{
Kind = "reject",
Id = p.Id,
Scene = p.Scene,
Error = ex.Message
};
}
receipts[key] = value;
return value;
}
// Decides what the host applies for one incoming change, given the object as the host holds it now (null if absent). An edit
// built on the host's current state is applied as sent. Otherwise somebody else changed the object in the meantime, and the
// rule is: the later edit (the one processed second) wins, per property, and a clash is reported in `notices` rather than
// refused. Only the unambiguous cases are resolved; anything else keeps the conflict refusal.
private static Change Reconcile(Change change, ObjectState current, List<string> notices)
{
if (StateRules.Equal(current, change.Before))
{
return change;
}
// A creation of an id that already exists is two objects claiming one identity, not an edit to merge.
if ((object)change.Before == null)
{
throw Conflict(change);
}
Change applied;
if ((object)change.After == null)
{
// Deleting an object that someone edited meanwhile: the deletion wins. Deleting one that is already gone has
// nothing to apply, so it stays a conflict.
if ((object)current == null)
{
throw Conflict(change);
}
applied = new Change(change.Id, current, null);
notices.Add("The later deletion of \"" + DisplayName(current) + "\" was kept; a concurrent edit to the same object was discarded.");
}
else if ((object)current == null)
{
// Editing an object that was deleted meanwhile: the later edit wins and the object comes back as the editor left it.
applied = new Change(change.Id, null, change.After);
notices.Add("A concurrent deletion of \"" + DisplayName(change.After) + "\" was overridden; the later edit was kept and the object was restored.");
}
else
{
if (!PropertyMerge.TryMerge(current, change.Before, change.After, out ObjectState merged, out List<string> clashed))
{
throw Conflict(change);
}
applied = new Change(change.Id, current, merged);
if (clashed.Count > 0)
{
notices.Add("Concurrent edits to \"" + DisplayName(current) + "\" were combined; the later edit was kept for: " + string.Join(", ", clashed) + ".");
}
}
// The applied change is what every peer will see, so it passes the same rules as an edit sent in one piece.
StateRules.ValidateChange(applied);
return applied;
}
private static InvalidOperationException Conflict(Change change)
{
return new InvalidOperationException("Conflict on " + change.Id + ": first accepted edit wins. Your edit is retained in recovery.");
}
// The name a person recognises the object by; the id when it has no readable name.
private static string DisplayName(ObjectState state)
{
return (state.Data?["Name"] is JsonValue jsonValue && jsonValue.TryGetValue(out string value) && !string.IsNullOrEmpty(value)) ? value : state.Id;
}
}
Editor
library
using System;
using System.Collections.Generic;
namespace TeamCreate;
public enum ReadinessLevel
{
Ok,
Warning,
Blocked
}
public sealed record ReadinessItem(string Key, string Title, ReadinessLevel Level, string Detail, string Fix);
public sealed record ReadinessInput(bool SteamSignedIn, string SteamPersona, bool SceneSupported, bool ProjectPublished, bool PackageReachable, bool LibraryInstalled, Version LibraryInstalledVersion, Version LibraryRequiredVersion, bool IsOrgMember, CollaborationConfig Config, string ScenePath = null);
public static class ReadinessRules
{
public static List<ReadinessItem> Evaluate(ReadinessInput input)
{
CollaborationConfig config = input.Config ?? CollaborationConfig.Defaults();
return new List<ReadinessItem>
{
EvaluateSteam(input),
EvaluateScene(input),
EvaluatePublished(input, config),
EvaluateLibrary(input, config),
EvaluateOrganization(input, config)
};
}
public static bool CanStart(IReadOnlyList<ReadinessItem> items)
{
return items != null && items.Count > 0 && (object)FirstBlocked(items) == null;
}
public static string BlockReason(IReadOnlyList<ReadinessItem> items)
{
ReadinessItem readinessItem = FirstBlocked(items);
if ((object)readinessItem == null)
{
return null;
}
string text = readinessItem.Title ?? string.Empty;
string text2 = ((text.Length == 0) ? text : (char.ToLowerInvariant(text[0]) + text.Substring(1)));
return "Hosting is unavailable: " + text2 + ".";
}
private static ReadinessItem FirstBlocked(IReadOnlyList<ReadinessItem> items)
{
if (items == null)
{
return null;
}
foreach (ReadinessItem item in items)
{
if ((object)item != null && item.Level == ReadinessLevel.Blocked)
{
return item;
}
}
return null;
}
private static ReadinessItem EvaluateSteam(ReadinessInput input)
{
return input.SteamSignedIn ? new ReadinessItem("steam", "Steam sign-in", ReadinessLevel.Ok, input.SteamPersona, null) : new ReadinessItem("steam", "Steam sign-in required", ReadinessLevel.Blocked, "Sign in to Steam to identify this editor.", null);
}
private static ReadinessItem EvaluateScene(ReadinessInput input)
{
return input.SceneSupported ? new ReadinessItem("scene", "Active scene", ReadinessLevel.Ok, input.ScenePath, null) : new ReadinessItem("scene", "Saved scene required", ReadinessLevel.Blocked, "Open a saved scene under scenes/ before starting a session.", null);
}
private static ReadinessItem EvaluatePublished(ReadinessInput input, CollaborationConfig config)
{
if (!config.RequirePublished)
{
return new ReadinessItem("published", "Project publication", ReadinessLevel.Ok, "Publication is not required for this project.", null);
}
if (!input.ProjectPublished)
{
return new ReadinessItem("published", "Project publication required", ReadinessLevel.Blocked, "Publish this project to s&box (hidden or public) before hosting.", null);
}
if (!input.PackageReachable)
{
return new ReadinessItem("published", "Project publication", ReadinessLevel.Warning, "The published copy could not be fetched from Asset Party; peers may not be able to install it.", null);
}
return new ReadinessItem("published", "Project publication", ReadinessLevel.Ok, "The published copy is available from Asset Party.", null);
}
private static ReadinessItem EvaluateLibrary(ReadinessInput input, CollaborationConfig config)
{
if (!input.LibraryInstalled)
{
return new ReadinessItem("library", "Library installation required", ReadinessLevel.Blocked, "Install the collaboration library from Asset Party.", null);
}
Version version = input.LibraryRequiredVersion ?? config.LibraryVersion;
Version libraryInstalledVersion = input.LibraryInstalledVersion;
if ((object)version != null && ((object)libraryInstalledVersion == null || VersionRules.IsOlder(libraryInstalledVersion, version)))
{
return new ReadinessItem("library", "Library update required", ReadinessLevel.Blocked, $"Installed version {libraryInstalledVersion?.ToString() ?? "unknown"} is older than the required version {version}.", "Update");
}
return new ReadinessItem("library", "Collaboration library", ReadinessLevel.Ok, ((object)libraryInstalledVersion == null) ? "The collaboration library is installed." : $"Version {libraryInstalledVersion} installed.", null);
}
private static ReadinessItem EvaluateOrganization(ReadinessInput input, CollaborationConfig config)
{
if (!config.RequireOrgMember)
{
return new ReadinessItem("org", "Organization membership", ReadinessLevel.Ok, "Organization membership is not required for this project.", null);
}
return input.IsOrgMember ? new ReadinessItem("org", "Organization membership", ReadinessLevel.Ok, "You are a member of the organization " + config.Org + ".", null) : new ReadinessItem("org", "Organization membership required", ReadinessLevel.Blocked, "You are not a member of the organization " + config.Org + ".", null);
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
namespace TeamCreate;
public sealed record DeployResult(bool Ok, string RelayUrl, string Error, bool Cancelled = false);
/// <summary>
/// Puts the relay on the person's own free Cloudflare account, from inside the editor, with the token they created once. It follows the same calls
/// Wrangler makes: find the account, make sure it has a workers.dev name, upload the Worker, switch its workers.dev address on, and wait until that address
/// answers. <see cref="DeployResult.Ok"/> is true only after the address answered, so a half-finished deploy is never reported as a relay to use.
/// Running it again on an account that already has the relay updates it in place and keeps any access key the owner set.
/// </summary>
public sealed class RelayDeployer
{
public const int Steps = 6;
private const int HealthAttempts = 10;
private const int SubdomainAttempts = 5;
private readonly ICloudflareTransport transport;
private readonly string workerSource;
private readonly Func<string, string> healthUrlFor;
private readonly Func<TimeSpan, CancellationToken, Task> delay;
private readonly Func<string> newSubdomainName;
private readonly string apiBase;
/// <param name="healthUrlFor">Maps the workers.dev host to the address to check. The editor checks the real address; a test points it at a stand-in.</param>
/// <param name="delay">How to wait between health checks. A test passes one that does not wait.</param>
/// <param name="newSubdomainName">Makes a candidate workers.dev name for an account that has none.</param>
/// <param name="apiBase">Cloudflare's API address. Only a test in a disposable copy ever passes another.</param>
public RelayDeployer(ICloudflareTransport transport, string workerSource, Func<string, string> healthUrlFor = null,
Func<TimeSpan, CancellationToken, Task> delay = null, Func<string> newSubdomainName = null, string apiBase = null)
{
this.apiBase = apiBase;
this.transport = transport ?? throw new ArgumentNullException(nameof(transport));
this.workerSource = string.IsNullOrEmpty(workerSource) ? throw new ArgumentException("The relay source is empty.", nameof(workerSource)) : workerSource;
this.healthUrlFor = healthUrlFor ?? (host => "https://" + host + "/health");
this.delay = delay ?? ((span, token) => Task.Delay(span, token));
this.newSubdomainName = newSubdomainName ?? DefaultSubdomainName;
}
public async Task<DeployResult> RunAsync(string token, Action<string> progress, CancellationToken cancellationToken)
{
progress ??= _ => { };
try
{
return await Deploy(token, progress, cancellationToken);
}
catch (OperationCanceledException)
{
return new DeployResult(false, null, "Setup was cancelled. Nothing was changed on this computer.", Cancelled: true);
}
}
private async Task<DeployResult> Deploy(string token, Action<string> progress, CancellationToken ct)
{
// 1. The account the token can use.
progress(Step(1, "finding the Cloudflare account"));
CloudflareResponse accounts = await Send(CloudflareApi.Request("GET", "/accounts?per_page=50", token, apiBase: apiBase), ct);
if (!(IsOk(accounts) && CloudflareApi.ResultOf(accounts.Body) is JsonArray list))
{
return Fail("finding the account", accounts, token);
}
string accountId = null;
string accountName = null;
foreach (JsonNode item in list)
{
if (item is JsonObject account && account["id"] is JsonValue id && id.TryGetValue(out string idText) && !string.IsNullOrWhiteSpace(idText))
{
accountId = idText;
accountName = account["name"] is JsonValue name && name.TryGetValue(out string nameText) ? nameText : null;
break;
}
}
if (accountId == null)
{
return new DeployResult(false, null, "The token cannot see a Cloudflare account. Create it from the page the Open Cloudflare button shows and choose your account when it asks.");
}
string root = "/accounts/" + Uri.EscapeDataString(accountId);
progress(Step(1, "using the account" + (string.IsNullOrWhiteSpace(accountName) ? string.Empty : " \"" + accountName + "\"")));
// 2. The account's workers.dev name, made if the account has none yet.
progress(Step(2, "checking the workers.dev name"));
CloudflareResponse subdomain = await Send(CloudflareApi.Request("GET", root + "/workers/subdomain", token, apiBase: apiBase), ct);
string host = null;
if (IsOk(subdomain))
{
host = SubdomainHost(CloudflareApi.ResultOf(subdomain.Body));
}
else if (subdomain.Status != null && CloudflareApi.ErrorCode(subdomain.Body) == 10007)
{
SubdomainOutcome registered = await RegisterSubdomain(root, token, progress, ct);
if (registered.Host == null)
{
return Fail("creating the workers.dev name", registered.Failure, token);
}
host = registered.Host;
}
else
{
return Fail("checking the workers.dev name", subdomain, token);
}
if (host == null)
{
return new DeployResult(false, null, "Cloudflare did not report a workers.dev name for this account. Open the Workers page on dash.cloudflare.com once, choose a name, and try again.");
}
// 3. Is the relay already there? Its migration tag decides whether the upload carries the one-time Durable Object migration.
progress(Step(3, "checking for an existing relay"));
CloudflareResponse existing = await Send(CloudflareApi.Request("GET", root + "/workers/services/" + CloudflareApi.ScriptName, token, apiBase: apiBase), ct);
bool exists = false;
string appliedTag = null;
if (IsOk(existing))
{
exists = true;
appliedTag = TagOf(CloudflareApi.ResultOf(existing.Body));
}
else if (existing.Status == null || existing.Status != 404)
{
return Fail("checking for an existing relay", existing, token);
}
bool sendMigrations = !string.Equals(appliedTag, CloudflareApi.MigrationTag, StringComparison.Ordinal);
// 4. The Worker itself.
progress(Step(4, exists ? "updating the relay" : "uploading the relay"));
using (var content = CloudflareApi.UploadContent(workerSource, sendMigrations, keepSecrets: exists))
{
CloudflareResponse upload = await Send(CloudflareApi.Request("PUT", root + "/workers/scripts/" + CloudflareApi.ScriptName + CloudflareApi.UploadQuery, token, content, apiBase: apiBase), ct);
if (!IsOk(upload))
{
return Fail("uploading the relay", upload, token);
}
}
// 5. Switch its workers.dev address on.
progress(Step(5, "switching the relay's address on"));
using (var body = CloudflareApi.Json(new JsonObject { ["enabled"] = true }))
{
CloudflareResponse route = await Send(CloudflareApi.Request("POST", root + "/workers/scripts/" + CloudflareApi.ScriptName + "/subdomain", token, body, withScriptApiDate: true, apiBase: apiBase), ct);
if (!IsOk(route))
{
return Fail("switching the relay's address on", route, token);
}
}
// 6. Wait until the address answers. A new Worker takes a few seconds to appear.
string workerHost = CloudflareApi.ScriptName + "." + host;
string healthUrl = healthUrlFor(workerHost);
for (int attempt = 1; attempt <= HealthAttempts; attempt++)
{
progress(Step(6, "waiting for the relay to answer (" + attempt + " of " + HealthAttempts + ")"));
CloudflareResponse health = await Send(new CloudflareRequest { Method = "GET", Url = healthUrl }, ct);
if (health.Status == 200 && IsRelayHealth(health.Body))
{
return new DeployResult(true, CloudflareApi.RelayUrlFor(workerHost), null);
}
if (attempt < HealthAttempts)
{
await delay(TimeSpan.FromSeconds(3), ct);
}
}
return new DeployResult(false, null, "The relay was uploaded but " + workerHost + " did not answer within about thirty seconds. It is not in use. Try again in a minute; nothing needs to be cleaned up.");
}
private sealed record SubdomainOutcome(string Host, CloudflareResponse Failure);
private async Task<SubdomainOutcome> RegisterSubdomain(string root, string token, Action<string> progress, CancellationToken ct)
{
CloudflareResponse failure = null;
for (int attempt = 0; attempt < SubdomainAttempts; attempt++)
{
string candidate = newSubdomainName();
progress(Step(2, "creating the workers.dev name \"" + candidate + "\""));
using var body = CloudflareApi.Json(new JsonObject { ["subdomain"] = candidate });
CloudflareResponse created = await Send(CloudflareApi.Request("PUT", root + "/workers/subdomain", token, body, apiBase: apiBase), ct);
if (IsOk(created))
{
return new SubdomainOutcome(SubdomainHost(CloudflareApi.ResultOf(created.Body)) ?? candidate + ".workers.dev", null);
}
failure = created;
// 10031: the name is taken. Any other answer will not change with another name.
if (created.Status == null || CloudflareApi.ErrorCode(created.Body) != 10031)
{
return new SubdomainOutcome(null, failure);
}
}
return new SubdomainOutcome(null, failure);
}
private async Task<CloudflareResponse> Send(CloudflareRequest request, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
CloudflareResponse response = await transport.SendAsync(request, ct);
ct.ThrowIfCancellationRequested();
return response ?? new CloudflareResponse(null, null, "no answer");
}
private static bool IsOk(CloudflareResponse response)
{
return response != null && response.Status is >= 200 and < 300 && CloudflareApi.ResultOf(response.Body) != null;
}
private static DeployResult Fail(string step, CloudflareResponse response, string token)
{
return new DeployResult(false, null, CloudflareApi.Explain(step, response, token));
}
private static string Step(int number, string text)
{
return "Step " + number + " of " + Steps + ": " + text + "…";
}
private static string SubdomainHost(JsonNode result)
{
if (result is JsonObject obj && obj["subdomain"] is JsonValue value && value.TryGetValue(out string name) && !string.IsNullOrWhiteSpace(name))
{
return name.Trim() + ".workers.dev";
}
return null;
}
private static string TagOf(JsonNode result)
{
if (result is JsonObject obj && obj["default_environment"] is JsonObject environment && environment["script"] is JsonObject script
&& script["migration_tag"] is JsonValue tag && tag.TryGetValue(out string text))
{
return text;
}
return null;
}
private static bool IsRelayHealth(string body)
{
try
{
return JsonNode.Parse(body ?? string.Empty) is JsonObject obj && obj["status"]?.GetValue<string>() == "ok" && obj["service"]?.GetValue<string>() == "collab-relay";
}
catch (Exception ex) when (ex is System.Text.Json.JsonException || ex is InvalidOperationException || ex is FormatException)
{
return false;
}
}
/// <summary>A name like <c>collab-k3x9q2</c>: readable, unlikely to collide, and changeable later in the Cloudflare dashboard.</summary>
private static string DefaultSubdomainName()
{
const string alphabet = "abcdefghijkmnpqrstuvwxyz23456789";
var bytes = new byte[6];
System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
var chars = new char[bytes.Length];
for (int i = 0; i < bytes.Length; i++)
{
chars[i] = alphabet[bytes[i] % alphabet.Length];
}
return "collab-" + new string(chars);
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Linq;
namespace TeamCreate;
/// <summary>
/// Shared play on the host (one run, many spectators). The host is a relay and a referee, nothing more: it keeps the <see cref="PlayRoster"/>, tells
/// the opted-in members who plays, and forwards the player's frames to the spectators. It never reads a frame's content (every spectator checks it
/// again) and never touches the game's networking: these are collaboration packets on the collaboration connection.
/// </summary>
public sealed partial class SessionServer
{
private readonly PlayRoster playRoster = new PlayRoster();
private readonly HashSet<Member> playOptIn = new HashSet<Member>();
private readonly Dictionary<Member, bool> playBehind = new Dictionary<Member, bool>();
private readonly System.Diagnostics.Stopwatch playClock = System.Diagnostics.Stopwatch.StartNew();
private double lastKeyframeAskMs = double.NegativeInfinity;
/// <summary>
/// The host's own setting. Off (the default) the host does not advertise shared play, ignores its messages without an error, and behaves exactly as
/// before. On, it relays; a guest still takes part only after it sends <c>play-optin</c>.
/// </summary>
public bool SharedPlayEnabled { get; set; }
/// <summary>The peer id of the member playing, or null. For the host's own dock.</summary>
public string PlayingPeer => playRoster.Player;
/// <summary>The queued backlog of a peer's connection, or null to read the wire's own counters. Tests replace it to simulate a slow spectator.</summary>
public Func<string, (long Bytes, int Messages)> PlayBacklogProbe { get; set; }
private bool HandlePlayPacket( Member member, Packet packet )
{
if ( packet.Kind is not ( "play-optin" or "play-start" or "play-stop" or "play-frame" or "play-key" ) )
{
return false;
}
if ( !SharedPlayEnabled )
{
return true;
}
switch ( packet.Kind )
{
case "play-optin":
playOptIn.Add( member );
if ( playRoster.IsPlaying )
{
SendQuietly( member, StartPacket() );
AskForKeyframe();
}
break;
case "play-start":
if ( playOptIn.Contains( member ) )
{
StartRun( member, packet );
}
break;
case "play-stop":
{
string run = playRoster.RunId;
if ( playRoster.Stop( member.Peer, packet.RuntimeRunId ) )
{
BroadcastPlay( StopPacket( member.Peer, run ), except: null );
}
break;
}
case "play-frame":
if ( playOptIn.Contains( member ) && packet.Play != null && playRoster.IsPlayer( member.Peer, packet.RuntimeRunId ) )
{
RelayFrame( member, packet );
}
break;
case "play-key":
if ( playOptIn.Contains( member ) && playRoster.IsPlaying )
{
AskForKeyframe();
}
break;
}
return true;
}
private void StartRun( Member member, Packet request )
{
PlayStartResult result = playRoster.TryStart( member.Peer, member.Name, request.Scene, () => Guid.NewGuid().ToString( "N" ) );
switch ( result.Verdict )
{
case PlayStartVerdict.Started:
foreach ( Member behind in playBehind.Keys.ToList() ) playBehind[behind] = false;
BroadcastPlay( StartPacket(), except: null );
break;
case PlayStartVerdict.Refused:
SendQuietly( member, new Packet { Kind = "play-refused", Notice = result.Notice, Peer = result.Player } );
break;
default:
SendQuietly( member, new Packet { Kind = "play-refused", Notice = "The play request was not valid. Save the scene under Assets/scenes and try again. Your run is local and is not shown to the others." } );
break;
}
}
private Packet StartPacket() => new Packet
{
Kind = "play-start", Peer = playRoster.Player, Name = playRoster.PlayerName, Scene = playRoster.Scene, RuntimeRunId = playRoster.RunId,
};
private static Packet StopPacket( string peer, string run ) => new Packet { Kind = "play-stop", Peer = peer, RuntimeRunId = run };
private void BroadcastPlay( Packet packet, Member except )
{
foreach ( Member target in playOptIn.Where( m => m != except && members.Contains( m ) ).ToList() )
{
SendQuietly( target, packet );
}
}
/// <summary>A play message must never cost a member its session or the host a broadcast: a failed send is left for the pump's own failure handling.</summary>
private static void SendQuietly( Member target, Packet packet )
{
try
{
target.Wire.Send( packet );
}
catch ( Exception )
{
}
}
private void RelayFrame( Member player, Packet frame )
{
bool isKey = frame.Play["k"] is System.Text.Json.Nodes.JsonValue k && k.TryGetValue( out bool key ) && key;
foreach ( Member spectator in playOptIn.Where( m => m != player && members.Contains( m ) ).ToList() )
{
(long bytes, int messages) = PlayBacklogProbe != null ? PlayBacklogProbe( spectator.Peer ) : (spectator.Wire.PendingBytes, spectator.Wire.PendingMessages);
bool wasBehind = playBehind.TryGetValue( spectator, out bool b ) && b;
bool send = PlayRelayPolicy.ShouldSend( wasBehind, isKey, bytes, messages, out bool nowBehind );
playBehind[spectator] = nowBehind;
if ( nowBehind && !wasBehind )
{
AskForKeyframe();
}
if ( send )
{
SendQuietly( spectator, frame );
}
}
}
// Spectators that ask at the same moment (a late joiner, a gap on two screens) cost the player one keyframe, not one each.
private void AskForKeyframe()
{
double now = playClock.Elapsed.TotalMilliseconds;
if ( !playRoster.IsPlaying || now - lastKeyframeAskMs < PlayRelayPolicy.KeyframeAskIntervalMs )
{
return;
}
Member player = members.FirstOrDefault( m => m.Peer == playRoster.Player );
if ( player == null )
{
return;
}
lastKeyframeAskMs = now;
SendQuietly( player, new Packet { Kind = "play-key", RuntimeRunId = playRoster.RunId } );
}
/// <summary>A member left or dropped: it no longer relays, and when it was the player the run ends for everyone.</summary>
private void ForgetPlayMember( Member member )
{
playOptIn.Remove( member );
playBehind.Remove( member );
if ( member.Peer == null )
{
return;
}
string run = playRoster.RunId;
if ( playRoster.PeerLeft( member.Peer ) )
{
BroadcastPlay( StopPacket( member.Peer, run ), except: null );
}
}
}
Editor
library
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Editor;
namespace TeamCreate;
/// <summary>Opens the disposable copy in a second editor window. It only ever launches a folder that <see cref="SoloCopy"/> made.</summary>
public static class SoloTestLauncher
{
private static string ExecutablePath()
{
try
{
string self = Process.GetCurrentProcess().MainModule?.FileName;
return !string.IsNullOrEmpty(self) && File.Exists(self) ? self : null;
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Memory the system can hand out right now: physical memory not in use, including the standby cache. Read from the runtime's own view of system
/// memory load, so it needs no native call. Null when the runtime cannot say.
/// </summary>
public static long? AvailableMemoryBytes()
{
try
{
GCMemoryInfo info = GC.GetGCMemoryInfo();
if (info.TotalAvailableMemoryBytes <= 0 || info.MemoryLoadBytes <= 0)
{
return null;
}
return Math.Max(0, info.TotalAvailableMemoryBytes - info.MemoryLoadBytes);
}
catch (Exception)
{
return null;
}
}
public const string HeartbeatFile = "solo-alive.txt";
/// <summary>
/// True when the copy's own editor has reported in recently. The copy writes its process id and the time every few seconds
/// (<see cref="Beat"/>); a stale file, or an id that is no longer a running process, means the window is closed.
/// </summary>
public static bool IsRunning(string copyRoot)
{
try
{
string path = Path.Combine(copyRoot, ".collaboration", HeartbeatFile);
if (!File.Exists(path))
{
return false;
}
string[] lines = File.ReadAllLines(path);
if (lines.Length < 2 || !int.TryParse(lines[0], out int pid) || !DateTime.TryParse(lines[1], null, System.Globalization.DateTimeStyles.RoundtripKind, out DateTime at))
{
return false;
}
if ((DateTime.UtcNow - at).TotalSeconds > 20)
{
return false;
}
using (Process process = Process.GetProcessById(pid))
{
return !process.HasExited;
}
}
catch (Exception)
{
return false;
}
}
private static DateTime nextBeat;
/// <summary>Called from the copy's frame tick.</summary>
public static void Beat(string root)
{
if (DateTime.UtcNow < nextBeat)
{
return;
}
nextBeat = DateTime.UtcNow.AddSeconds(5);
try
{
File.WriteAllLines(Path.Combine(root, ".collaboration", HeartbeatFile), new[] { Environment.ProcessId.ToString(), DateTime.UtcNow.ToString("o") });
}
catch (Exception)
{
}
}
/// <summary>Returns null on success, or a sentence saying what went wrong.</summary>
public static string Launch(string copyRoot)
{
if (!TestHarnessRules.IsDisposableCopy(copyRoot, SoloCopy.HasMarker(copyRoot)))
{
return "That folder is not a solo test copy, so it was not opened.";
}
string exe = ExecutablePath();
if (exe == null)
{
return "The editor's program file could not be found, so the copy could not be opened. Open " + copyRoot + " from the s&box launcher instead; the test starts by itself.";
}
string project = Directory.EnumerateFiles(copyRoot, "*.sbproj", SearchOption.TopDirectoryOnly).FirstOrDefault();
if (project == null)
{
return "The copy has no project file.";
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = "-project \"" + project + "\" -novid",
UseShellExecute = false
});
return null;
}
catch (Exception ex)
{
return "The solo test window could not be opened: " + ex.Message + " Open " + copyRoot + " from the s&box launcher instead; the test starts by itself.";
}
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using Sandbox.Services;
namespace TeamCreate;
/// <summary>
/// The one-click solo test. It runs only inside a disposable copy of the project (see <see cref="SoloCopy"/>), where one person with one Steam account can
/// host a session and have a scripted collaborator join it over the real transport. The steps exercise the real host session and this editor's real scene:
/// the host admits the collaborator through its normal checks, the collaborator's edits arrive in this editor, this editor's edits reach the collaborator,
/// per-user undo, refused stale edits, play pause, saving, and the relay when one is configured.
///
/// Everything runs from the frame tick. Nothing here starts a thread or a timer, because hotloaded editor code cannot own one; each step is a begin action and a
/// poll that is called once per frame until it settles or times out.
/// </summary>
public static class SoloTestRunner
{
private enum Phase
{
Idle,
Steps,
Demo,
Stopping,
Done
}
private sealed class Step
{
public SoloStep View;
public double TimeoutSeconds = 20;
public bool Critical;
public Action Begin;
/// <summary>Runs once when the step settles, whatever the outcome, so a step that timed out cannot leave a collaborator holding the test identity.</summary>
public Action Cleanup;
/// <summary>Returns the step's state: Running keeps waiting, anything else settles it.</summary>
public Func<(SoloStepState State, string Detail)> Poll;
}
public const string ReportFile = "solo-report.json";
private const double OrbitRadius = 230;
private static readonly (double X, double Y, double Z) OrbitCenter = (0, 0, 90);
private static readonly List<Step> steps = new List<Step>();
private static readonly List<SoloStep> views = new List<SoloStep>();
private static Phase phase = Phase.Idle;
private static int index;
private static DateTime stepStarted;
private static DateTime startedAt;
private static DateTime nextPresence;
private static DateTime nextDemoEdit;
private static DateTime processStarted = DateTime.UtcNow;
private static bool autorunHandled;
private static string runToken;
private static string scenePath;
private static string headline = "Not run yet.";
private static string handshakeProject;
private static InviteData invite;
private static byte[] sessionKey;
private static SoloBot bot;
private static SoloBot relayBot;
private static SoloBot demoBot;
private static readonly List<SoloBot> impostors = new List<SoloBot>();
private static Task<string> tokenTask;
private static bool hostStarted;
private static ObjectState cube;
private static ObjectState stopObject;
private static DateTime stopBegan;
private static bool stopWasDemo;
private static string stopState;
private static string stopHeadline;
private static ObjectState demoCube;
private static double demoAngle;
public static IReadOnlyList<SoloStep> Steps => views;
public static string Headline => headline;
public static bool Running => phase == Phase.Steps;
public static bool Watching => phase == Phase.Demo;
public static bool Finished => phase == Phase.Done;
public static bool Active => phase != Phase.Idle && phase != Phase.Done;
public static string Root
{
get
{
try
{
return SceneAdapter.Root;
}
catch (Exception)
{
return null;
}
}
}
/// <summary>True only inside a folder the launcher made (right path and marker file); the same test the session applies.</summary>
public static bool IsSoloCopy => TestHarnessRules.IsDisposableCopy(Root, SoloCopy.HasMarker(Root));
private static CollaborationSession Collab => CollaborationSession.Current;
[EditorEvent.Frame]
public static void Tick()
{
try
{
if (Project.Current == null)
{
return;
}
if (IsSoloCopy)
{
SoloTestLauncher.Beat(Root);
}
if (!autorunHandled && (DateTime.UtcNow - processStarted).TotalSeconds > 10)
{
autorunHandled = true;
if (IsSoloCopy && SoloCopy.WantsAutorun(Root))
{
SoloCopy.ConsumeAutorun(Root);
Start();
}
}
if (IsSoloCopy)
{
PollCommand();
}
if (phase == Phase.Idle || phase == Phase.Done)
{
return;
}
bot?.Pump();
relayBot?.Pump();
demoBot?.Pump();
foreach (SoloBot impostor in impostors)
{
impostor.Pump();
}
DriveOrbit();
if (phase == Phase.Steps)
{
RunCurrentStep();
}
else if (phase == Phase.Demo)
{
RunDemo();
}
else if (phase == Phase.Stopping)
{
PollStopping();
}
}
catch (Exception ex)
{
Abort("The solo test stopped on an unexpected error: " + ex.Message);
}
}
public const string CommandFile = "solo-command.txt";
private static DateTime nextCommandCheck;
/// <summary>
/// Lets Tools/Run-SoloTest.ps1 -Stop end the test from outside the window. A one-word file, read and deleted here; it is honoured only inside a
/// disposable copy, which is the only place this code runs at all.
/// </summary>
private static void PollCommand()
{
if (DateTime.UtcNow < nextCommandCheck)
{
return;
}
nextCommandCheck = DateTime.UtcNow.AddSeconds(1);
try
{
string path = Path.Combine(Root, ".collaboration", CommandFile);
if (!File.Exists(path))
{
return;
}
string word = File.ReadAllText(path).Trim().ToLowerInvariant();
File.Delete(path);
if (word == "stop")
{
Stop();
}
else if (word == "start")
{
Start();
}
}
catch (Exception)
{
}
}
public static void Start()
{
if (!IsSoloCopy)
{
throw new InvalidOperationException("The solo test only runs inside a disposable solo test copy. Use Run solo test in the Collaboration dock to make one.");
}
if (Active)
{
return;
}
CleanupParticipants();
OpenDock();
BuildSteps();
index = 0;
startedAt = DateTime.UtcNow;
phase = Phase.Steps;
BeginStep();
headline = "Running…";
WriteReport("running");
}
/// <summary>
/// Ends the test in two phases. First the collaborators go and the test's object is removed; the session stays up until the host has been told, because
/// leaving a session checkpoints the scene and would save the object into the file. Then the session ends. Safe to call at any time.
/// </summary>
public static void Stop()
{
if (phase == Phase.Idle || phase == Phase.Done || phase == Phase.Stopping)
{
return;
}
stopWasDemo = phase == Phase.Demo;
stopObject = demoCube ?? cube;
try
{
RemoveTestObject(stopObject);
}
catch (Exception)
{
}
CleanupParticipants();
stopBegan = DateTime.UtcNow;
phase = Phase.Stopping;
}
private static void PollStopping()
{
bool present = false;
try
{
present = stopObject != null && Find(stopObject) != null;
}
catch (Exception)
{
}
bool pending = false;
try
{
pending = Collab.Connected && !Collab.CanSave();
}
catch (Exception)
{
pending = false;
}
if (SoloScript.StopDecision(present, pending, Collab.Connected, (DateTime.UtcNow - stopBegan).TotalSeconds) == SoloStopDecision.Proceed)
{
FinishStop();
}
}
private static void FinishStop()
{
try
{
Collab.Leave();
}
catch (Exception)
{
}
CollaborationSession.SoloGuestVoucher = null;
foreach (Step step in steps)
{
if (step.View.State == SoloStepState.Running || step.View.State == SoloStepState.Waiting)
{
step.View.State = SoloStepState.Skipped;
step.View.Detail = "Stopped before this ran.";
}
}
phase = Phase.Done;
headline = stopHeadline ?? SoloReport.Summary(views, true) + (stopWasDemo ? " Session ended." : string.Empty);
WriteReport(stopState ?? (stopWasDemo ? "stopped" : "aborted"));
stopState = null;
stopHeadline = null;
}
private static void Abort(string message)
{
if (index < steps.Count && steps[index].View.State == SoloStepState.Running)
{
steps[index].View.State = SoloStepState.Failed;
steps[index].View.Detail = message;
}
stopState = "aborted";
stopHeadline = message;
Stop();
}
private static void CleanupParticipants()
{
bot?.Leave();
relayBot?.Leave();
demoBot?.Leave();
foreach (SoloBot impostor in impostors)
{
impostor.Dispose();
}
impostors.Clear();
bot = null;
relayBot = null;
demoBot = null;
}
// ---- stepping ------------------------------------------------------------------------------------------------------
private static void RunCurrentStep()
{
if (index >= steps.Count)
{
FinishChecks();
return;
}
Step step = steps[index];
(SoloStepState state, string detail) = step.Poll();
if (state == SoloStepState.Running)
{
if ((DateTime.UtcNow - stepStarted).TotalSeconds > step.TimeoutSeconds)
{
step.View.State = SoloStepState.Failed;
step.View.Detail = "Timed out after " + step.TimeoutSeconds.ToString("0", CultureInfo.InvariantCulture) + " seconds. " + (string.IsNullOrEmpty(step.View.Detail) ? string.Empty : step.View.Detail);
Settled(step);
}
else if (!string.IsNullOrEmpty(detail))
{
step.View.Detail = detail;
}
return;
}
step.View.State = state;
step.View.Detail = detail;
Settled(step);
}
private static void Settled(Step step)
{
try
{
step.Cleanup?.Invoke();
}
catch (Exception)
{
}
if (step.View.State == SoloStepState.Failed && step.Critical)
{
for (int i = index + 1; i < steps.Count; i++)
{
steps[i].View.State = SoloStepState.Skipped;
steps[i].View.Detail = "Not run because \"" + step.View.Title + "\" failed.";
}
index = steps.Count;
FinishChecks();
return;
}
index++;
if (index < steps.Count)
{
BeginStep();
}
WriteReport("running");
}
private static void BeginStep()
{
Step step = steps[index];
if (step.View.Id == "demo" && !SoloReport.DemoAllowed(views))
{
step.View.State = SoloStepState.Skipped;
step.View.Detail = "Not started because a check failed.";
index++;
if (index < steps.Count)
{
BeginStep();
}
return;
}
step.View.State = SoloStepState.Running;
step.View.Detail = null;
stepStarted = DateTime.UtcNow;
try
{
step.Begin?.Invoke();
}
catch (Exception ex)
{
step.View.State = SoloStepState.Failed;
step.View.Detail = ex.Message;
Settled(step);
}
}
private static void FinishChecks()
{
bool ok = SoloReport.AllPassed(views.Where((SoloStep s) => s.State != SoloStepState.Skipped).ToList()) && !views.Any((SoloStep s) => s.State == SoloStepState.Failed);
headline = SoloReport.Summary(views, true);
if (!ok)
{
stopState = "checks-failed";
stopHeadline = headline;
phase = Phase.Steps;
Stop();
return;
}
phase = Phase.Demo;
headline += " The collaborator is now orbiting your scene. Press Stop to end.";
WriteReport("checks-passed");
}
private static void RemoveTestObject(ObjectState state)
{
if (state == null)
{
return;
}
GameObject go = Find(state);
if (go != null && go.IsValid())
{
go.Destroy();
}
}
// ---- helpers -------------------------------------------------------------------------------------------------------
private static SceneEditorSession SceneSession()
{
return SceneEditorSession.All.FirstOrDefault((SceneEditorSession x) => !(x is GameEditorSession) && SceneAdapter.PathOf(x) == scenePath);
}
private static GameObject Find(ObjectState state)
{
SceneEditorSession session = SceneSession();
if (session == null || state == null)
{
return null;
}
return SceneAdapter.Walk(session.Scene).FirstOrDefault((GameObject x) => x.IsValid() && string.Equals(x.Id.ToString(), state.Id, StringComparison.Ordinal));
}
private static Vector3 ParseVector(string text)
{
float[] n = text.Split(',').Select((string x) => float.Parse(x, CultureInfo.InvariantCulture)).ToArray();
return new Vector3(n[0], n[1], n[2]);
}
private static bool Near(Vector3 a, Vector3 b)
{
return (a - b).Length < 0.5f;
}
private static string PositionOf(SoloBot who)
{
return who != null && cube != null && who.Known(scenePath).TryGetValue(cube.Id, out ObjectState s) ? s.Data["Position"]?.GetValue<string>() : null;
}
private static SoloHandshake Handshake(string steamId, string name, string token)
{
(string ProjectId, string Baseline, string Manifest, string OrgIdent, string LibraryVersion) facts = Collab.HandshakeFacts;
return new SoloHandshake
{
Project = facts.ProjectId,
Baseline = facts.Baseline,
Manifest = facts.Manifest,
Org = facts.OrgIdent,
LibraryVersion = facts.LibraryVersion,
SteamId = steamId,
SteamToken = token,
Name = name,
SessionToken = Convert.ToHexString(sessionKey)
};
}
private static SoloBot NewDirectBot(string steamId, string name, string token)
{
SoloBot made = new SoloBot(Handshake(steamId, name, token));
int port = invite.Port;
byte[] key = sessionKey;
made.Connect(() => Wire.Connect("127.0.0.1", port, key, true));
return made;
}
private static SoloBot NewRelayBot()
{
SoloBot made = new SoloBot(Handshake(TestHarnessRules.SoloGuestSteamId, TestHarnessRules.SoloGuestName, runToken));
string url = invite.Relay;
byte[] secret = invite.Secret;
byte[] key = sessionKey;
made.Connect(() => RelayGuest.ConnectAsync(() => new SandboxRelaySocket(), url, secret, key));
return made;
}
private static (double X, double Y, double Z) OrbitPoint(double seconds)
{
return SoloScript.Orbit(OrbitCenter, OrbitRadius, seconds * 0.55, 28);
}
/// <summary>The collaborator's marker circles the scene and looks at its middle, so the sphere is easy to find and to watch.</summary>
private static void DriveOrbit()
{
SoloBot who = demoBot ?? bot ?? relayBot;
if (who == null || !who.IsLive || scenePath == null || DateTime.UtcNow < nextPresence)
{
return;
}
nextPresence = DateTime.UtcNow.AddMilliseconds(100);
double t = (DateTime.UtcNow - startedAt).TotalSeconds;
(double X, double Y, double Z) p = OrbitPoint(t);
who.SendPresence(scenePath, SoloScript.Presence(p, SoloScript.LookAt(p, OrbitCenter), phase == Phase.Demo ? "editing" : "editing"));
}
private static void RunDemo()
{
if (demoBot == null || !demoBot.IsLive || demoCube == null || DateTime.UtcNow < nextDemoEdit)
{
return;
}
nextDemoEdit = DateTime.UtcNow.AddMilliseconds(400);
demoAngle += 0.16;
(double X, double Y, double Z) c = SoloScript.Orbit((OrbitCenter.X, OrbitCenter.Y, 45), 90, demoAngle, 14);
ObjectState next = SoloScript.WithTint(SoloScript.WithPosition(demoCube, SoloScript.SnapVec(c.X, c.Y, c.Z)), SoloScript.TintAt(demoAngle / 6.0));
if (demoBot.PendingCount == 0)
{
demoBot.SendEdit(scenePath, new[] { SoloScript.Update(demoCube, next) });
demoCube = next;
}
}
/// <summary>
/// The checklist lives in the Collaboration dock, and a fresh copy starts with the default layout, where that dock is closed. A test that
/// starts by itself and shows its progress somewhere nobody is looking would appear to do nothing.
/// </summary>
private static void OpenDock()
{
try
{
Sandbox.Internal.GlobalToolsNamespace.EditorWindow.DockManager.OpenDock("Collaboration", DockArea.Right);
}
catch (Exception)
{
}
}
private static void PlaceCamera()
{
try
{
SceneViewportWidget widget = SceneViewWidget.Current?.LastSelectedViewportWidget;
if (widget == null)
{
return;
}
Vector3 target = new Vector3((float)OrbitCenter.X, (float)OrbitCenter.Y, (float)OrbitCenter.Z - 20f);
Vector3 from = new Vector3(-520f, -520f, 300f);
widget.State.CameraPosition = from;
widget.State.CameraRotation = Rotation.LookAt((target - from).Normal);
}
catch (Exception)
{
}
}
// ---- the script ----------------------------------------------------------------------------------------------------
private static void Add(string id, string title, double timeout, bool critical, Action begin, Func<(SoloStepState, string)> poll, Action cleanup = null)
{
SoloStep view = new SoloStep { Id = id, Title = title };
views.Add(view);
steps.Add(new Step { View = view, TimeoutSeconds = timeout, Critical = critical, Begin = begin, Poll = poll, Cleanup = cleanup });
}
private static (SoloStepState, string) Wait(string detail = null)
{
return (SoloStepState.Running, detail);
}
private static (SoloStepState, string) Pass(string detail)
{
return (SoloStepState.Passed, detail);
}
private static (SoloStepState, string) Fail(string detail)
{
return (SoloStepState.Failed, detail);
}
private static void BuildSteps()
{
steps.Clear();
views.Clear();
cube = null;
demoCube = null;
demoAngle = 0;
hostStarted = false;
tokenTask = null;
scenePath = null;
invite = null;
Add("scene", "A saved scene is open and fully loaded", 60, true, () =>
{
foreach (SceneEditorSession open in SceneEditorSession.All.Where((SceneEditorSession x) => !(x is GameEditorSession)).ToArray())
{
if (open.HasUnsavedChanges)
{
// A disposable copy: unsaved edits here are leftovers, and a session cannot start over them.
open.Reload();
open.HasUnsavedChanges = false;
}
}
string active = SceneAdapter.PathOf(SceneEditorSession.Active);
if (active == null || !active.StartsWith("scenes/", StringComparison.Ordinal))
{
string folder = Path.Combine(SceneAdapter.Root, "Assets", "scenes");
string first = Directory.Exists(folder) ? Directory.EnumerateFiles(folder, "*.scene").OrderBy((string x) => x, StringComparer.OrdinalIgnoreCase).FirstOrDefault() : null;
if (first == null)
{
throw new InvalidOperationException("The copy has no scene under Assets/scenes. Save one in the project first.");
}
SceneEditorSession.CreateFromPath("scenes/" + Path.GetFileName(first))?.MakeActive();
}
}, () =>
{
string active = SceneAdapter.PathOf(SceneEditorSession.Active);
if (active == null || !active.StartsWith("scenes/", StringComparison.Ordinal) || SceneEditorSession.Active is GameEditorSession)
{
return Wait("Opening a scene…");
}
scenePath = active;
// The editor may still be restoring the scene when this runs. Starting then makes the host publish a partial scene, and the save step
// would write it back over the copy's file. Wait until the editor holds at least as many top-level objects as the file does.
int? expected = SoloScript.TopLevelObjectCount(Path.Combine(Root, "Assets", active.Replace('/', Path.DirectorySeparatorChar)));
SceneEditorSession session = SceneSession();
int loaded = session?.Scene != null && session.Scene.IsValid() ? session.Scene.Children.Count((GameObject x) => x.IsValid()) : 0;
SoloSceneLoad state = SoloScript.SceneLoadState(expected, loaded);
string detail = SoloScript.SceneLoadDetail(active, expected, loaded);
if (state == SoloSceneLoad.Loading)
{
return Wait(detail);
}
PlaceCamera();
return Pass(detail);
});
Add("host", "Host session starts", 150, true, () =>
{
runToken = TestHarnessRules.NewRunToken();
string token = runToken;
CollaborationSession.SoloGuestVoucher = (Packet hello) => TestHarnessRules.MayVouchFor(hello.SteamId, hello.SteamToken, token);
if (Collab.Busy)
{
Collab.Leave();
}
tokenTask = Auth.GetToken(LocalSteamIdentity.ServiceName);
}, () =>
{
if (!hostStarted)
{
// The readiness checks answer asynchronously after the editor loads; hosting before they have is refused for a reason that is gone a
// moment later. Wait for them, and only call a row blocked once the grace period has passed.
SoloHostReadiness readiness = SoloScript.HostReadiness(Collab.Readiness, (DateTime.UtcNow - stepStarted).TotalSeconds);
if (readiness == SoloHostReadiness.Waiting)
{
return Wait("Waiting for the readiness checks (Steam, publication, organization)…");
}
if (readiness == SoloHostReadiness.Blocked)
{
return Fail(ReadinessRules.BlockReason(Collab.Readiness) ?? "The readiness checklist never completed. Open the Collaboration dock and check the Readiness card.");
}
if (!tokenTask.IsCompleted)
{
return Wait("Waiting for a fresh Steam identity token…");
}
if (tokenTask.IsFaulted || tokenTask.IsCanceled || string.IsNullOrWhiteSpace(tokenTask.Result))
{
return Fail("A Steam identity token could not be obtained. Sign in to Steam and try again.");
}
if (!LocalSteamIdentity.TryGet(out SteamIdentity identity, out string persona, out string failure))
{
return Fail(failure ?? "No signed-in Steam account is available.");
}
Collab.StartHosted(27845, persona, SteamIdentityRules.Canonical(identity), tokenTask.Result, allowUnverifiedPeers: false);
hostStarted = true;
return Wait("Starting the host…");
}
if (Collab.Error != null)
{
return Fail(Collab.Error);
}
if (!Collab.SoloHarness)
{
return Fail("The test identity is not active in this folder, so a collaborator could not be admitted.");
}
if (Collab.Connected && Collab.IsHost && Collab.InviteCode != null)
{
return Pass("Hosting with the signed-in Steam account.");
}
return Wait(Collab.Status);
});
Add("live", "The host's scene is live", 25, true, null, () =>
{
if (Collab.Error != null)
{
return Fail(Collab.Error);
}
return Collab.ReadyScenes.Contains(scenePath) ? Pass(scenePath + " is synchronized.") : Wait("Waiting for the host to publish the scene…");
});
Add("relay", "Relay is registered", 20, false, null, () =>
{
string status = Collab.RelayStatus;
if (status == null)
{
return (SoloStepState.Skipped, "No relay is configured for this project (RelayUrl), so only direct routes are tested.");
}
if (Collab.RelayFailed)
{
return Fail(status);
}
return status.StartsWith("Relay connected", StringComparison.Ordinal) ? Pass("The relay accepted this session's room.") : Wait(status);
});
Add("join", "A collaborator joins and passes the host's checks", 30, true, () =>
{
(string ProjectId, string Baseline, string Manifest, string OrgIdent, string LibraryVersion) facts = Collab.HandshakeFacts;
handshakeProject = facts.ProjectId;
if (!InviteCode.TryDecode(Collab.InviteCode, facts.ProjectId, out invite, out string error))
{
throw new InvalidOperationException("The host's own invite code did not decode: " + error);
}
sessionKey = InviteCode.DeriveKey(invite.Secret, facts.ProjectId);
bot = NewDirectBot(TestHarnessRules.SoloGuestSteamId, TestHarnessRules.SoloGuestName, runToken);
}, () =>
{
if (bot.State == SoloBotState.Refused || bot.State == SoloBotState.Failed)
{
return Fail("The host refused the collaborator: " + bot.Refusal);
}
if (bot.IsLive)
{
bot.Subscribe(scenePath);
return Pass("Admitted as \"" + TestHarnessRules.SoloGuestName + "\" after the host's normal project, baseline, library and manifest checks.");
}
return Wait("Connecting…");
});
Add("marker", "Its floating marker appears in your scene view", 20, false, null, () =>
{
bool inRoster = Collab.Roster.Values.Any((Packet x) => x.SteamId == TestHarnessRules.SoloGuestSteamId && x.Presence != null);
if (!inRoster)
{
return Wait("Waiting for the collaborator's presence…");
}
return Collab.DrawnMarkers > 0 ? Pass("The blue sphere is drawn in the scene view, orbiting the middle of the scene.") : Wait("The collaborator is in the roster; waiting for the scene view to draw its marker. Open a scene view if none is visible.");
});
Add("create", "Its new object appears in your scene", 20, true, () =>
{
cube = SoloScript.NewCube(Guid.NewGuid(), Guid.NewGuid(), SoloScript.SnapVec(OrbitCenter.X, OrbitCenter.Y, 45), SoloScript.Color(1.0, 0.34375, 0.25));
bot.SendEdit(scenePath, new[] { SoloScript.Create(cube) });
}, () => Find(cube) != null ? Pass("\"" + SoloScript.ObjectName + "\" appeared in this editor.") : Wait());
Vector3 movedTo = new Vector3(70, -50, 80);
Add("move", "Its move appears in your scene as it happens", 20, true, () =>
{
ObjectState next = SoloScript.WithPosition(cube, SoloScript.SnapVec(movedTo.x, movedTo.y, movedTo.z));
bot.SendEdit(scenePath, new[] { SoloScript.Update(cube, next) });
cube = next;
}, () =>
{
GameObject go = Find(cube);
return go != null && Near(go.WorldPosition, movedTo) ? Pass("Position changed to " + go.WorldPosition + ".") : Wait();
});
string tintText = SoloScript.TintAt(0.62);
Add("property", "Its property change appears in your scene", 20, true, () =>
{
ObjectState next = SoloScript.WithTint(cube, tintText);
bot.SendEdit(scenePath, new[] { SoloScript.Update(cube, next) });
cube = next;
}, () =>
{
GameObject go = Find(cube);
ModelRenderer renderer = go?.Components.Get<ModelRenderer>();
if (renderer == null)
{
return Wait();
}
float[] want = tintText.Split(',').Select((string x) => float.Parse(x, CultureInfo.InvariantCulture)).ToArray();
Color have = renderer.Tint;
return Math.Abs(have.r - want[0]) < 0.02f && Math.Abs(have.g - want[1]) < 0.02f && Math.Abs(have.b - want[2]) < 0.02f ? Pass("The renderer tint changed.") : Wait();
});
Vector3 hostMoved = new Vector3(-90, 60, 100);
Add("reverse", "Your move reaches the collaborator as it happens", 20, true, () =>
{
GameObject go = Find(cube);
if (go == null)
{
throw new InvalidOperationException("The test object is not in this scene.");
}
go.LocalPosition = hostMoved;
}, () =>
{
string seen = PositionOf(bot);
return seen != null && Near(ParseVector(seen), hostMoved) ? Pass("The collaborator saw the position change to " + seen + ".") : Wait();
});
bool undone = false;
Add("undo", "Undo reverses only your own move", 25, false, () => undone = false, () =>
{
if (!undone)
{
try
{
Collab.Undo();
undone = true;
}
catch (InvalidOperationException)
{
return Wait("Waiting for your move to be acknowledged…");
}
}
string seen = PositionOf(bot);
bool botsTintKept = bot.Known(scenePath).TryGetValue(cube.Id, out ObjectState s) && (s.Data["Components"] as JsonArray)?[0]?["Tint"]?.GetValue<string>() == tintText;
return seen != null && Near(ParseVector(seen), movedTo) && botsTintKept ? Pass("Back to " + seen + "; the collaborator's own change to the object was not undone.") : Wait();
});
int rejectsBefore = 0;
Add("conflict", "A stale edit is refused, not silently applied", 20, false, () =>
{
rejectsBefore = bot.Rejects.Count;
// Built on a position the object left long ago, so it is out of date by construction.
ObjectState old = SoloScript.WithPosition(cube, "1,1,1");
bot.SendEdit(scenePath, new[] { SoloScript.Update(old, SoloScript.WithPosition(old, "5,5,5")) });
}, () =>
{
if (bot.Rejects.Count <= rejectsBefore)
{
return Wait();
}
GameObject go = Find(cube);
return go != null && Near(go.WorldPosition, movedTo) ? Pass("The host answered: " + Truncate(bot.Rejects.Last().Error, 110)) : Fail("The refused edit still changed the scene.");
});
Vector3 whilePlaying = new Vector3(10, 20, 110);
int playPhase = 0;
Add("play", "Playing pauses scene edits; changes wait until you stop", 60, false, () =>
{
playPhase = 0;
EditorScene.Play();
}, () =>
{
if (Collab.Error != null)
{
return Fail(Collab.Error);
}
if (playPhase == 0)
{
if (Collab.PlayDecision != PlaySpectateDecision.PausedForPlay)
{
return Wait("Starting the game…");
}
ObjectState next = SoloScript.WithPosition(cube, SoloScript.SnapVec(whilePlaying.x, whilePlaying.y, whilePlaying.z));
bot.SendEdit(scenePath, new[] { SoloScript.Update(cube, next) });
cube = next;
playPhase = 1;
return Wait("Playing; the collaborator changes the object…");
}
if (playPhase == 1)
{
GameObject go = Find(cube);
if (Collab.HeldSceneChanges < 1)
{
return Wait("Waiting for the collaborator's change to reach this editor…");
}
if (go != null && Near(go.WorldPosition, whilePlaying))
{
return Fail("The change was applied while playing instead of being held.");
}
EditorScene.Stop();
playPhase = 2;
return Wait("Held " + Collab.HeldSceneChanges + " change(s); stopping the game…");
}
GameObject after = Find(cube);
return Collab.PlayDecision == PlaySpectateDecision.Normal && after != null && Near(after.WorldPosition, whilePlaying) ? Pass("The change made while you were playing applied when you stopped.") : Wait("Applying held changes…");
});
Add("delete", "Its delete appears in your scene", 20, false, () =>
{
bot.SendEdit(scenePath, new[] { SoloScript.Delete(cube) });
}, () => Find(cube) == null ? Pass("The object is gone from this editor.") : Wait());
DateTime saveAt = default;
Add("save", "Saving works while a collaborator is connected", 30, false, () => saveAt = default, () =>
{
if (Collab.Error != null)
{
return Fail(Collab.Error);
}
if (saveAt == default)
{
if (!Collab.CanSave())
{
return Wait("Waiting to save: " + (Collab.WhyCannotSave() ?? "a check is still settling") + ".");
}
Collab.Save();
saveAt = DateTime.UtcNow;
return Wait("Saving…");
}
if ((DateTime.UtcNow - saveAt).TotalSeconds < 1.5)
{
return Wait("Saving…");
}
return Collab.Error == null && bot.IsLive ? Pass("Saved the synchronized scene; the collaborator stayed connected.") : Fail(Collab.Error ?? "The collaborator was dropped by the save.");
});
Add("leave", "The collaborator leaves cleanly", 15, false, () => bot.Leave(), () =>
{
bool gone = !Collab.Roster.Values.Any((Packet x) => x.SteamId == TestHarnessRules.SoloGuestSteamId);
if (gone)
{
bot = null;
}
return gone ? Pass("Its row and marker were removed at once.") : Wait();
});
int relayPhase = 0;
Add("via-relay", "A collaborator joins and edits through the relay", 45, false, () =>
{
relayPhase = 0;
if (Collab.RelayStatus == null || Collab.RelayFailed || string.IsNullOrEmpty(invite?.Relay))
{
return;
}
relayBot = NewRelayBot();
}, () =>
{
if (relayBot == null)
{
return (SoloStepState.Skipped, "No working relay for this session, so this route was not tested.");
}
if (relayBot.State == SoloBotState.Refused || relayBot.State == SoloBotState.Failed)
{
return Fail("The relay route failed: " + relayBot.Refusal);
}
if (relayPhase == 0)
{
if (!relayBot.IsLive)
{
return Wait("Dialling the relay…");
}
relayBot.Subscribe(scenePath);
relayPhase = 1;
return Wait("Admitted through the relay; subscribing…");
}
if (relayPhase == 1)
{
if (!relayBot.HasScene(scenePath))
{
return Wait();
}
cube = SoloScript.NewCube(Guid.NewGuid(), Guid.NewGuid(), SoloScript.SnapVec(OrbitCenter.X, OrbitCenter.Y, 45), SoloScript.Color(0.3125, 0.59375, 1.0));
relayBot.SendEdit(scenePath, new[] { SoloScript.Create(cube) });
relayPhase = 2;
return Wait("Sending an edit through the relay…");
}
if (relayPhase == 2)
{
if (Find(cube) == null)
{
return Wait();
}
relayBot.SendEdit(scenePath, new[] { SoloScript.Delete(cube) });
relayPhase = 3;
return Wait("The object arrived; removing it…");
}
if (Find(cube) != null)
{
return Wait();
}
relayBot.Leave();
relayBot = null;
return Pass("Joined, subscribed, created and deleted an object, all through the relay.");
}, () =>
{
relayBot?.Leave();
relayBot = null;
});
int closedPhase = 0;
Add("closed", "The test identity opens nothing else", 30, false, () => closedPhase = 0, () =>
{
if (closedPhase == 0)
{
// The earlier steps' collaborators say goodbye and the host relays it; the reserved account must be gone from the roster before this check
// starts, or "already connected" would be the refusal and would prove nothing about the token.
if (Collab.Roster.Values.Any((Packet x) => x.SteamId == TestHarnessRules.SoloGuestSteamId))
{
return Wait("Waiting for the earlier collaborator to finish leaving…");
}
impostors.Add(NewDirectBot(TestHarnessRules.SoloGuestSteamId, "Wrong token", TestHarnessRules.NewRunToken()));
impostors.Add(NewDirectBot("76561198000000002", "Other account", runToken));
closedPhase = 1;
return Wait("Trying a wrong token and a different account…");
}
if (impostors.Any((SoloBot x) => x.IsLive))
{
return Fail("A collaborator without the test token was admitted.");
}
if (impostors.Any((SoloBot x) => x.State == SoloBotState.Connecting || x.State == SoloBotState.AwaitingWelcome))
{
return Wait();
}
// Each must be refused BECAUSE its identity token was rejected. A refusal for any other reason (for example "already connected") would
// pass a weaker check while proving nothing about the token.
SoloBot wrong = impostors.FirstOrDefault();
foreach (SoloBot x in impostors)
{
if (x.State != SoloBotState.Refused || x.Refusal == null || x.Refusal.IndexOf("rejected", StringComparison.OrdinalIgnoreCase) < 0)
{
return Fail("Refused for the wrong reason, or not refused: " + x.State + " " + Truncate(x.Refusal, 100));
}
}
return Pass("A wrong token and a different account were both refused as: " + Truncate(wrong?.Refusal, 90));
}, () =>
{
foreach (SoloBot x in impostors)
{
x.Dispose();
}
impostors.Clear();
});
Add("demo", "Live demo: the collaborator moves an object in your scene", 45, false, () =>
{
// Pressing Play and Stop earlier resets the scene view, so frame the orbit again for the part meant to be watched.
PlaceCamera();
demoBot = NewDirectBot(TestHarnessRules.SoloGuestSteamId, TestHarnessRules.SoloGuestName, runToken);
}, () =>
{
if (demoBot.State == SoloBotState.Refused || demoBot.State == SoloBotState.Failed)
{
return Fail("The demo collaborator could not join: " + demoBot.Refusal);
}
if (!demoBot.IsLive)
{
return Wait("Connecting…");
}
if (!demoBot.HasScene(scenePath))
{
demoBot.Subscribe(scenePath);
return Wait("Subscribing…");
}
if (demoCube == null)
{
demoCube = SoloScript.NewCube(Guid.NewGuid(), Guid.NewGuid(), SoloScript.SnapVec(OrbitCenter.X, OrbitCenter.Y, 45), SoloScript.Color(1, 0.40625, 0.21875));
cube = demoCube;
demoBot.SendEdit(scenePath, new[] { SoloScript.Create(demoCube) });
return Wait();
}
return Find(demoCube) != null ? Pass("Running. Watch the blue sphere and the cube; press Stop to end.") : Wait();
});
}
private static string Truncate(string text, int max)
{
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
return text.Length <= max ? text : text.Substring(0, max - 1) + "…";
}
// ---- report --------------------------------------------------------------------------------------------------------
private static void WriteReport(string state)
{
try
{
string path = Path.Combine(Root, ".collaboration", ReportFile);
Directory.CreateDirectory(Path.GetDirectoryName(path));
string json = JsonSerializer.Serialize(new
{
state,
summary = SoloReport.Summary(views, state != "running"),
headline,
updatedUtc = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
steps = views.Select((SoloStep s) => new { id = s.Id, title = s.Title, state = s.State.ToString(), detail = s.Detail }).ToArray()
}, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(path + ".tmp", json);
File.Move(path + ".tmp", path, true);
}
catch (Exception)
{
}
}
/// <summary>The last written report, for the dock's "last run" line. Null when there is none.</summary>
public static string LastRunSummary(string root)
{
try
{
string path = Path.Combine(root, ".collaboration", ReportFile);
if (!File.Exists(path))
{
return null;
}
JsonNode node = JsonNode.Parse(File.ReadAllText(path));
return node?["summary"]?.GetValue<string>();
}
catch (Exception)
{
return null;
}
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using Sandbox.Helpers;
using Sandbox.Internal;
using Sandbox.Services;
using Sandbox.Utility;
namespace TeamCreate;
public enum SessionState
{
Offline,
Ready,
Hosting,
Joining,
Connected,
Degraded,
Recovering
}
public sealed class CollaborationSession : EditorEvent.ISceneView, EditorEvent.IEventListener
{
private sealed class Tracked
{
public SceneEditorSession Session;
public Dictionary<string, ObjectState> Known;
public Packet Pending;
public bool Ready;
public Dictionary<string, Change> Group = new Dictionary<string, Change>();
public Stack<List<Change>> Undo = new Stack<List<Change>>();
public bool UndoPending;
// The scene's own settings as the host last confirmed them (SettingsMerge). Null when the host holds none for the scene.
public JsonObject Settings;
// The one settings change awaiting the host's answer, like Pending for edits.
public Packet SettingsPending;
// What this editor last wrote into the scene's settings and what the engine reported right after: the difference is what the engine
// could not take, which SettingsMerge.LocalChange must never mistake for the user's change. Null until the first write.
public JsonObject SettingsApplied;
public JsonObject SettingsEcho;
public DateTime DiskStamp;
public DateTime StampMismatchSince;
public ApplyRecovery Recovery = new ApplyRecovery();
// Objects this editor could not show or capture: their host JSON stays in Known and is never sent back (ApplyRecovery).
public HashSet<string> SkippedObjects = new HashSet<string>(StringComparer.Ordinal);
// The person's state to replay after a resync, when the live scene could not be put back after a failed apply.
public Dictionary<string, ObjectState> HeldLocal;
// The resync replaces the local state instead of replaying it (the host refused it; it is in the recovery copy).
public bool DiscardLocalOnResync;
public bool DiskWarningShown;
}
private sealed class StartPlan
{
public bool Host;
public string Address;
public int Port;
public string Name;
public string Token;
public string SteamId;
public string SteamToken;
public bool AllowUnverifiedPeers;
public bool ZeroConfig;
public byte[] SessionKey;
public byte[] Secret;
public List<EndpointResolver.EndpointCandidate> Candidates;
}
private enum HotloadArchiveOutcome
{
NotApplicable,
Archived,
Failed
}
private sealed class EditScope(CollaborationSession owner) : IDisposable
{
private bool disposed;
public void Dispose()
{
if (!disposed)
{
disposed = true;
owner.continuousEdits--;
}
}
}
private readonly Dictionary<string, Tracked> scenes = new Dictionary<string, Tracked>();
// Remote receipts arrive at drag rates, and every one used to reset the whole native undo history. The scene is still applied per receipt;
// only the history reset (Initialize + MirrorNativeUndo) is coalesced by UndoResetScheduler, and the scenes awaiting it are kept here.
private readonly UndoResetScheduler undoReset = new UndoResetScheduler();
private readonly HashSet<Tracked> undoResetScenes = new HashSet<Tracked>();
private static readonly System.Diagnostics.Stopwatch undoResetClock = System.Diagnostics.Stopwatch.StartNew();
private Wire wire;
private SessionServer server;
private Task<Wire> connecting;
private byte[] activeSessionKey;
private string activeAddress;
private int activePort;
private Task<string> reconnectTokenTask;
private bool reconnectQueued;
private string reconnectAddress;
private int reconnectPort;
private bool reconnectRecovery;
private bool reconnectRetrySpent;
private bool endedByLeave;
private LanDiscovery discovery;
private StunClient stun;
private RelayHost relayHost;
// The one-click internet relay (Core/HostingPlan.cs), used only when no relay is named anywhere: the loopback relay this host runs, the Quick Tunnel
// that exposes it, and the reachability probe in flight. All of it is polled from PollConnectivity and released with the rest of the connectivity.
private HostingPlan hostingPlan;
private EmbeddedRelay embeddedRelay;
private string embeddedRelayError;
private CloudflaredTool quickTunnel;
private Task<string> reachabilityTask;
private CancellationTokenSource reachabilityCancel;
// The public address the invite carries while the plan is Ready, and null otherwise; the invite never falls back to the loopback address.
private string publicRelayUrl;
private static readonly TimeSpan ReachabilityProbeTimeout = TimeSpan.FromSeconds(5L);
private static double HostingSeconds => Environment.TickCount64 / 1000.0;
private bool soloHarness;
private readonly HeldPacketBuffer heldPackets = new HeldPacketBuffer();
// Shared play (Core/PlayRoster.cs and friends): null unless this computer opted in and the host advertised it.
private SharedPlayClient play;
// Scenes whose host updates were discarded (play-mode buffer overflow) or could not be routed (the scene is playing): each is
// resynchronized from the host once it can take a snapshot again.
private readonly HashSet<string> resyncWhenRoutable = new HashSet<string>(StringComparer.Ordinal);
private readonly HashSet<string> closedPacketNotices = new HashSet<string>(StringComparer.Ordinal);
private readonly HashSet<string> onceNotices = new HashSet<string>(StringComparer.Ordinal);
private bool inviteCarriesRelay;
/// <summary>The relay this session uses: the project's own if it names one, none if it says "off", otherwise the one built into the library.</summary>
private string EffectiveRelayUrl()
{
return RelayDefaults.Resolve(sessionConfig?.RelayUrl, RelaySetupRules.Fallback(RelaySetup.LocalRelay, RelayDefaults.DefaultUrl));
}
/// <summary>What the project's own settings say about the relay (an address, "off", or nothing), for the Relay card.</summary>
public string ConfiguredRelayUrl => sessionConfig?.RelayUrl;
private byte[] activeSecret;
private List<EndpointResolver.EndpointCandidate> activeCandidates;
private UpnpPortMapper upnp;
private string inviteCodeValue;
private string projectIdValue;
private bool projectIdResolved;
private byte[] hostSecret;
private int hostPort;
private bool stunEndpointFolded;
private CancellationTokenSource hostVerificationLifetime = new CancellationTokenSource();
private readonly ConnectionTeardownCoordinator lifecycle = new ConnectionTeardownCoordinator();
private string baseline;
private string project;
private string userName;
private string sessionToken;
private string sourceBaseline;
private string hostSession;
private string steamIdValue;
private string steamTokenValue;
private DateTime nextScan;
private DateTime nextPresence;
private DateTime lastPresenceSent;
private DateTime lastPingSent;
// The newest host revision this editor has seen, from every accepted and snapshot packet. A ping reports it next to the scene digest so
// the host compares digests only when this editor is up to date (see DriftDetector).
private long lastRevision;
private string driftNoticeShown;
private string lastPresenceFingerprint;
private DateTime lastReceive;
private CollaborationConfig sessionConfig;
private OrgDirectory orgDirectory;
private string orgIdentValue;
private string libraryVersionValue;
private string projectManifestValue;
private Task<Package> packageFetch;
private bool? packageReachable;
private DateTime nextReadiness;
private DateTime nextPackageFetch;
private int readinessOrgRevision = -1;
private const int PackageRefreshSeconds = 30;
private const int AutoSaveCheckSeconds = 1;
private bool restoring;
private int continuousEdits;
private bool saving;
private string focusedPeer;
private DateTime focusedUntil;
private DateTime focusNoticeExpires;
private DateTime saveNoticeExpires;
private DateTime lastAutoSave;
private DateTime lastLocalEdit;
private DateTime nextAutoSaveCheck;
private bool autoRecover;
private DateTime undoNoticeExpires;
private DateTime actionNoticeExpires;
private SessionFailureClass errorClass = SessionFailureClass.Terminal;
private int recoverAttempts;
private DateTime recoverNextAttempt;
private bool recoverPromptShown;
private bool recoverArmed;
private bool recoverDeclined;
private bool recoverArming;
private string startAddress;
private int startPort;
private readonly HashSet<string> seen = new HashSet<string>();
// Ids of every settings change this editor sent: a reject for one of them is the host refusing that change, never a reason to fail.
private readonly HashSet<string> sentSettings = new HashSet<string>();
private const float PromptToastSeconds = 15f;
private const float OutcomeToastSeconds = 6f;
public const string NativeUndoEntryName = "Collaboration Undo";
private const int ToolTextLimit = 40;
private const int StateTextLimit = 32;
private const int SceneTextLimit = 96;
private const int ObjectNameTextLimit = 64;
public static CollaborationSession Current { get; } = new CollaborationSession();
public string Status { get; private set; } = "Offline";
// Project file synchronization (FileSyncClient): created at welcome when the host advertises it, polled from Tick, fed from Receive.
// Its progress is shown through Status (the dock's session line) and its notices through SetActionNotice and the log, the two ways
// this session already reports to the person.
private FileSyncClient projectFiles;
private string lastHostFileNotice;
// The geometry channel of this editor's connection (null while the host does not advertise it). It serves what the host asks for, holds
// scene packets whose geometry has not arrived and releases them in order (docs/BLOB-SYNC.md).
private BlobExchange blobs;
private string lastHostBlobNotice;
// The next packet to handle: one released by the exchange first, then the wire. Geometry packets are consumed here.
private bool NextIncoming(out Packet packet)
{
while (true)
{
if (blobs != null && blobs.TryTakeReady(out packet))
{
return true;
}
if (!wire.Incoming.TryDequeue(out packet))
{
return false;
}
if (blobs == null)
{
return true;
}
if (BlobExchange.IsBlobPacket(packet.Kind))
{
lastReceive = DateTime.UtcNow;
blobs.Handle(packet);
continue;
}
if (blobs.Offer(packet) == BlobOffer.Process)
{
return true;
}
lastReceive = DateTime.UtcNow;
}
}
// Once per frame: asks for missing geometry, serves requests, and for every scene update that had to be discarded (a bound was reached,
// the geometry did not arrive or did not verify) asks the host for the scene again, with the reason shown to the person.
private void PumpBlobs()
{
if (blobs == null)
{
return;
}
blobs.Pump();
foreach ((Packet Packet, string Reason) item in blobs.TakeDropped())
{
Tracked affected = item.Packet.Scene == null ? null : TrackedFor(item.Packet.Scene);
if (affected != null && affected.Session.Scene.IsValid() && !affected.Recovery.Paused)
{
affected.Recovery.Requested();
RequestSceneResync(item.Packet.Scene, affected);
}
}
foreach (string notice in blobs.TakeNotices())
{
GlobalGameNamespace.Log.Info("Collaboration: " + notice);
SetActionNotice(notice);
}
string hostNotice = server?.BlobNotice;
if (hostNotice != null && !string.Equals(hostNotice, lastHostBlobNotice, StringComparison.Ordinal))
{
lastHostBlobNotice = hostNotice;
GlobalGameNamespace.Log.Info("Collaboration: " + hostNotice);
}
}
// Scene files open in this editor are owned by the live scene sync, so file sync neither fetches nor sends them. PathOf is lowercase
// and relative to Assets; the comparison in FileSyncClient is case-insensitive.
private static string[] OpenFileScenes() => SceneEditorSession.All.Where( x => !(x is GameEditorSession) && !x.IsPrefabSession ).Select( x => SceneAdapter.PathOf( x ) ).Where( x => x != null ).Select( x => "Assets/" + x ).ToArray();
// Makes the editor notice a file synchronization wrote or replaced: AssetSystem.FindByPath / RegisterFile ("If you just created an asset,
// you probably want to immediately register it", Sandbox.Tools.xml) and Asset.Compile(false), the call the editor's own MCP
// AssetSystem.cs makes after writing a source file. A deleted file is left to the engine's own file watching.
private static void RefreshAsset( string relativePath )
{
try
{
string absolute = Path.Combine( Project.Current.RootDirectory.FullName, relativePath.Replace( '/', Path.DirectorySeparatorChar ) );
if ( !File.Exists( absolute ) ) return;
Asset asset = AssetSystem.FindByPath( absolute ) ?? AssetSystem.RegisterFile( absolute );
if ( asset != null && asset.CanRecompile ) asset.Compile( false );
}
catch ( Exception ex )
{
GlobalGameNamespace.Log.Warning( "Collaboration: the asset \"" + relativePath + "\" was received but the editor could not refresh it: " + ex.Message );
}
}
private void ShowFileNotices()
{
for ( string notice = projectFiles?.TakeNotice(); notice != null; notice = projectFiles.TakeNotice() )
{
GlobalGameNamespace.Log.Info( "Collaboration: " + notice );
SetActionNotice( notice );
}
string hostNotice = server?.FileNotice;
if ( hostNotice != null && !string.Equals( hostNotice, lastHostFileNotice, StringComparison.Ordinal ) )
{
lastHostFileNotice = hostNotice;
GlobalGameNamespace.Log.Info( "Collaboration: " + hostNotice );
}
}
public string Error { get; private set; }
public bool Connected { get; private set; }
public SessionState State
{
get
{
string terminalTeardownError = TerminalTeardownError;
if (Connected)
{
if (Error != null || terminalTeardownError != null)
{
return SessionState.Degraded;
}
return IsHost ? SessionState.Hosting : SessionState.Connected;
}
if (terminalTeardownError != null)
{
return SessionState.Degraded;
}
if (Status.StartsWith("Reconnecting", StringComparison.Ordinal))
{
return SessionState.Recovering;
}
if (Busy)
{
return IsHost ? SessionState.Hosting : SessionState.Joining;
}
return ReadinessRules.CanStart(Readiness) ? SessionState.Ready : SessionState.Offline;
}
}
public bool IsHost => server != null;
public CollaborationConfig Config => sessionConfig;
public OrgDirectory OrganizationDirectory => orgDirectory;
public string InviteCode => inviteCodeValue;
public IReadOnlyList<DiscoveredHost> DiscoveredHosts => discovery?.Hosts;
public string PublicEndpointStatus { get; private set; }
/// <summary>How this editor is connected: the route that actually carried the join, for the dock to show.</summary>
public string RouteDescription { get; private set; }
/// <summary>
/// TEST-ONLY SEAM. Nothing in this library sets it; only the workspace's editor probe does. When set, and ONLY inside a
/// disposable test copy (a folder under <c>.collaboration/test-copies/</c> that carries the marker file the solo-test
/// launcher writes), the host lets it vouch for a guest hello instead of asking Steam's service, and treats that guest as
/// a friend and organization member. It exists so one person with one Steam account can run a full two-editor session.
/// In any other folder it is ignored, so a real project cannot be weakened by it.
/// </summary>
public static Func<Packet, bool> SoloGuestVoucher { get; set; }
/// <summary>
/// The facts a guest must present in its hello, read back so an out-of-process test collaborator can present the same ones.
/// They are not secrets: the project id, baseline hash and manifest hash travel in every hello, and the organization and library
/// version are printed in the dock.
/// </summary>
public (string ProjectId, string Baseline, string Manifest, string OrgIdent, string LibraryVersion) HandshakeFacts => (project, baseline, projectManifestValue, orgIdentValue, libraryVersionValue);
/// <summary>Scene paths this session has subscribed and received a snapshot for. Empty until the host's scene is live.</summary>
public IReadOnlyList<string> ReadyScenes => scenes.Where((KeyValuePair<string, Tracked> x) => x.Value.Ready).Select((KeyValuePair<string, Tracked> x) => x.Key).ToList();
/// <summary>True in a disposable test copy with the voucher installed. Shown to the user, so it is never a silent state.</summary>
public bool SoloHarness => soloHarness;
/// <summary>The visible banner for a session running with the test seam active. Null otherwise.</summary>
public string SoloHarnessNotice => soloHarness ? "Solo test copy: one reserved test identity is accepted here in place of a second Steam account. This never applies to a real project." : null;
private static bool ComputeSoloHarness()
{
if (SoloGuestVoucher == null)
{
return false;
}
string root = SceneAdapter.Root;
bool marker = false;
try
{
marker = !string.IsNullOrEmpty(root) && File.Exists(Path.Combine(root, ".collaboration", TestHarnessRules.MarkerFile));
}
catch (Exception)
{
marker = false;
}
return TestHarnessRules.IsDisposableCopy(root, marker);
}
private bool SoloMayVouch(Packet hello)
{
try
{
return soloHarness && hello != null && SoloGuestVoucher != null && SoloGuestVoucher(hello);
}
catch (Exception)
{
return false;
}
}
/// <summary>The host's relay registration, worded for the dock: the internet relay's progress when the host runs its own, else the configured relay's. Null when there is none.</summary>
public string RelayStatus
{
get
{
if (hostingPlan != null)
{
return hostingPlan.Status;
}
RelayHost host = relayHost;
if (host == null || host.State == RelayHostState.Stopped || host.State == RelayHostState.Idle)
{
return null;
}
switch (host.State)
{
case RelayHostState.Ready:
return "Relay connected — friends can join from any network.";
case RelayHostState.Failed:
return "Relay unavailable — " + host.Failure;
default:
return host.Status ?? "Connecting to the relay…";
}
}
}
public bool RelayFailed => relayHost?.State == RelayHostState.Failed;
/// <summary>True while the internet relay has failed or is restarting, so the dock can colour its sentence as a problem.</summary>
public bool InternetRelayTrouble => hostingPlan != null && (hostingPlan.Phase == HostingPhase.Failed || hostingPlan.Phase == HostingPhase.Degraded);
/// <summary>Set while hosting with no relay a friend elsewhere could reach, so the dock can say so instead of leaving a silent gap. Null otherwise.</summary>
public string NoRelayNotice => IsHost && hostingPlan == null && (relayHost == null || relayHost.State == RelayHostState.Stopped)
? HostingPlan.NoRelayNotice(effectiveRelay: EffectiveRelayUrl(), configuredRelay: sessionConfig?.RelayUrl, internetRelayEnabled: RelaySetup.InternetRelayEnabled)
: null;
/// <summary>What source synchronization is doing this frame; the dock reads it to explain a pause.</summary>
public PlaySpectateDecision PlayDecision { get; private set; }
public bool PausedForPlay => Connected && PlayDecision == PlaySpectateDecision.PausedForPlay;
public int HeldSceneChanges => heldPackets.Count;
/// <summary>The sentence shown while the local editor is playing and scene edits are held back. Null otherwise.</summary>
public string PlayNotice
{
get
{
if (PausedForPlay && play?.EditsPausedNotice != null)
{
return play.EditsPausedNotice;
}
if (!PausedForPlay)
{
return null;
}
int held = heldPackets.Count;
return held == 0
? "Playing: scene edits are paused. Your collaborators can still see you; changes resume when you stop."
: "Playing: scene edits are paused. " + held + " change" + (held == 1 ? "" : "s") + " from collaborators will apply when you stop.";
}
}
public string PortMappingStatus => upnp?.Status;
public bool PortMappingFailed
{
get
{
UpnpPortMapper upnpPortMapper = upnp;
return upnpPortMapper != null && upnpPortMapper.Current == UpnpPortMapper.State.Failed;
}
}
public string DiscoveryStatus => discovery?.Failure;
public bool Busy => CollaborationSessionResourceRules.IsBusy(wire != null, connecting != null, server != null, lifecycle.IsFailStop);
public PlayModeLifecycle RuntimeLifecycle { get; } = new PlayModeLifecycle(optIn: false, "source-known-state");
public PlayModeLifecycleEventState RuntimeLifecycleEvents { get; } = new PlayModeLifecycleEventState();
public bool ExperimentalRuntimeOptIn
{
get
{
return RuntimeLifecycle.OptIn;
}
set
{
PlayModeTransitionResult playModeTransitionResult = RuntimeLifecycle.SetOptIn(value);
if (!playModeTransitionResult.Accepted)
{
SetLifecycleError(playModeTransitionResult.ActionableError);
}
}
}
public string PlayModeStatus
{
get
{
PlayModeLifecycle runtimeLifecycle = RuntimeLifecycle;
if (1 == 0)
{
}
if (runtimeLifecycle == null)
{
goto IL_0059;
}
switch (runtimeLifecycle.State)
{
case PlayModeLifecycleState.Source:
break;
case PlayModeLifecycleState.RuntimeDisabled:
goto IL_0039;
case PlayModeLifecycleState.RuntimeStarting:
goto IL_0041;
case PlayModeLifecycleState.RuntimeActive:
goto IL_0049;
case PlayModeLifecycleState.RuntimeBlocked:
goto IL_0051;
default:
goto IL_0059;
}
string result = "Source collaboration";
goto IL_0061;
IL_0051:
result = "Runtime collaboration blocked • recovery required";
goto IL_0061;
IL_0059:
result = "Source collaboration";
goto IL_0061;
IL_0061:
if (1 == 0)
{
}
return result;
IL_0039:
result = "Source collaboration • runtime disabled";
goto IL_0061;
IL_0041:
result = "Source collaboration • starting runtime";
goto IL_0061;
IL_0049:
result = "Experimental runtime collaboration enabled • transport and apply unsupported";
goto IL_0061;
}
}
public string RuntimeLifecycleDiagnostic => RuntimeLifecycle.LastDiagnostic;
public string RuntimeLifecycleError => RuntimeLifecycle.LastActionableError;
public bool HasPendingLifecycleCandidate => (object)RuntimeLifecycleEvents.Pending != null;
public Dictionary<string, Packet> Roster { get; } = new Dictionary<string, Packet>();
public string Peer { get; } = Guid.NewGuid().ToString();
public long AcceptedOperations { get; private set; }
public int DrawnMarkers { get; private set; }
public int DrawnSelections { get; private set; }
public string FocusNotice { get; private set; }
public string RecoveryPath { get; private set; }
public IReadOnlyList<ReadinessItem> Readiness { get; private set; } = Array.Empty<ReadinessItem>();
public string ActiveSceneSupport
{
get
{
SceneEditorSession supportedActiveScene = SupportedActiveScene;
return (supportedActiveScene != null) ? ("Active scene: " + SceneAdapter.PathOf(supportedActiveScene)) : "This view is outside the supported edit set (use a saved scene under Assets/scenes).";
}
}
private static SceneEditorSession SupportedActiveScene
{
get
{
SceneEditorSession active = SceneEditorSession.Active;
object result;
if (active != null && !active.IsPrefabSession && !active.IsPlaying)
{
string text = SceneAdapter.PathOf(active);
if (text != null && text.StartsWith("scenes/"))
{
result = active;
goto IL_0036;
}
}
result = null;
goto IL_0036;
IL_0036:
return (SceneEditorSession)result;
}
}
public string RecoveryNotice { get; private set; }
public string SaveNotice { get; private set; }
/// <summary>
/// A formal sentence about somebody else's edit meeting this editor's own (the host combined them, a clash was settled in favour of
/// one side, or an unsent change could not be kept). Null when there is nothing to say; cleared by <see cref="Frame"/> about eight
/// seconds after <see cref="MergeNoticeAt"/>. The Controls card shows it.
/// </summary>
public string MergeNotice { get; private set; }
/// <summary>When <see cref="MergeNotice"/> was last set (UTC); meaningful only while the notice is non-null.</summary>
public DateTime MergeNoticeAt { get; private set; }
private DateTime mergeNoticeExpires;
public bool AutoSaveIdle { get; set; }
public bool AutoRecover
{
get
{
return autoRecover;
}
set
{
if (autoRecover != value)
{
autoRecover = value;
if (value)
{
recoverAttempts = 0;
recoverNextAttempt = default;
recoverDeclined = false;
}
}
}
}
public string UndoNotice { get; private set; }
public string ActionNotice { get; private set; }
/// <summary>
/// On the host: a formal sentence when a guest's editor keeps drifting from the host's scene although it was resynchronised repeatedly,
/// otherwise null. Always null on a guest.
/// </summary>
public string DriftNotice => server?.DriftNotice;
public string TerminalTeardownError => lifecycle.TerminalDiagnostic;
public bool ReconnectRecovery => reconnectRecovery;
private CollaborationSession()
{
EditorEvent.Register(this);
}
private void SetLifecycleError(string error)
{
if (!string.IsNullOrWhiteSpace(error))
{
RecoveryNotice = BoundedDiagnostic(error);
GlobalGameNamespace.Log.Error("Collaboration lifecycle: " + RecoveryNotice);
}
}
private void ApplyLifecycleResult(PlayModeTransitionResult result)
{
if (!result.Accepted)
{
SetLifecycleError(result.ActionableError);
}
}
[Event("scene.startplay")]
public static void OnStartPlay()
{
CollaborationSession current = Current;
current.Checkpoint();
SceneEditorSession active = SceneEditorSession.Active;
SceneEditorSession sceneEditorSession = ((active != null && !active.IsPrefabSession) ? active : null);
string text = ((sceneEditorSession == null) ? null : SceneAdapter.PathOf(sceneEditorSession));
if (sceneEditorSession == null || !RuntimeStateRules.IsSafeScenePath(text))
{
current.SetLifecycleError("Play mode could not identify a saved source scene; source collaboration is unchanged.");
}
else
{
current.ApplyLifecycleResult(current.RuntimeLifecycleEvents.RecordStartIntent(current.RuntimeLifecycle, sceneEditorSession, text, Guid.NewGuid().ToString("N")));
}
}
[Event("scene.play")]
public static void OnPlay()
{
CollaborationSession current = Current;
current.Checkpoint();
GameEditorSession[] array = SceneEditorSession.All.OfType<GameEditorSession>().ToArray();
if (array.Length != 1)
{
current.SetLifecycleError((array.Length == 0) ? "Play-mode lifecycle could not find a GameEditorSession in SceneEditorSession.All; source collaboration is unchanged." : "Multiple GameEditorSession objects are active; runtime collaboration was not enabled. Stop play mode and retry.");
return;
}
GameEditorSession gameEditorSession = array[0];
SceneEditorSession parent = gameEditorSession.Parent;
string a = SceneAdapter.PathOf(parent) ?? parent?.Scene?.Source?.ResourcePath;
PlayModePendingCandidate pending = current.RuntimeLifecycleEvents.Pending;
if ((object)pending == null || parent != pending.SourceSessionToken || !string.Equals(a, pending.SourceScenePath, StringComparison.OrdinalIgnoreCase))
{
current.SetLifecycleError("Runtime GameEditorSession parent/path did not match the source captured by scene.startplay; runtime collaboration was not enabled.");
return;
}
PlayModeTransitionResult result = current.RuntimeLifecycleEvents.AttachRuntime(current.RuntimeLifecycle, RuntimeSceneAdapter.Describe(gameEditorSession), pending.RuntimeRunId);
current.ApplyLifecycleResult(result);
}
[Event("scene.stop")]
public static void OnStop()
{
CollaborationSession current = Current;
current.ApplyLifecycleResult(current.RuntimeLifecycleEvents.RecordStop(current.RuntimeLifecycle));
}
[Event("scene.saved")]
public static void OnSceneSaved(Scene scene)
{
Current.ReconcileSavedScene(scene);
}
private void ReconcileSavedScene(Scene scene)
{
SceneEditorSession sceneEditorSession = SceneEditorSession.All.FirstOrDefault((SceneEditorSession x) => x.Scene == scene) ?? SceneEditorSession.Active;
string text = SceneAdapter.PathOf(sceneEditorSession);
Tracked tracked = null;
DateTime dateTime = default;
if (text != null && scenes.TryGetValue(text, out var value) && value.Session == sceneEditorSession)
{
tracked = value;
dateTime = SceneAdapter.DiskStamp(text);
}
bool flag = Connected && Error == null;
switch (SceneSaveRules.Decide(saving || (tracked != null && tracked.DiskStamp == dateTime), flag, tracked != null, flag && tracked != null && CanSave()))
{
case SceneSaveOutcome.Absorb:
tracked.DiskStamp = dateTime;
server?.AllowSavedBaseline(SceneAdapter.Baseline());
SetSaveNotice("The scene was saved outside the session; the session accepted the save.");
break;
case SceneSaveOutcome.Warn:
tracked.DiskStamp = dateTime;
SetSaveNotice("The scene was saved outside the session while edits were in flight; disk and session state may differ.");
break;
}
}
private void SetSaveNotice(string text)
{
string text2 = BoundedDiagnostic(text);
if (!string.Equals(SaveNotice, text2, StringComparison.Ordinal) || !(DateTime.UtcNow < saveNoticeExpires))
{
SaveNotice = text2;
saveNoticeExpires = DateTime.UtcNow.AddSeconds(8.0);
}
}
private void SetMergeNotice(string text)
{
// `text` is already joined, cleaned and bounded by ChangeRebase.JoinNotices; a repeated sentence only extends the display time.
if (string.IsNullOrEmpty(text))
{
return;
}
MergeNotice = text;
MergeNoticeAt = DateTime.UtcNow;
mergeNoticeExpires = MergeNoticeAt.AddSeconds(8.0);
}
[EditorEvent.Hotload]
public static void CodeChanged()
{
CollaborationSession current = Current;
PlayModeTransitionResult playModeTransitionResult = current.RuntimeLifecycle.BlockForHotload("hotloaded");
current.ApplyLifecycleResult(playModeTransitionResult);
HotloadArchiveOutcome hotloadArchiveOutcome = current.ArchiveHotloadLifecycle(current.RuntimeLifecycle.ArchiveReason ?? "hotloaded");
bool busy = current.Busy;
HotloadCodeChangeReport hotloadCodeChangeReport = HotloadCodeChangeRules.Decide(new HotloadCodeChangeRequest(current.RuntimeLifecycle.State, playModeTransitionResult.Accepted, hotloadArchiveOutcome == HotloadArchiveOutcome.Archived, busy, playModeTransitionResult.Accepted ? null : playModeTransitionResult.ActionableError));
if (hotloadCodeChangeReport.SurfaceError)
{
current.Fail(hotloadCodeChangeReport.Message);
}
if (!hotloadCodeChangeReport.ArchiveClaimed)
{
current.RecoveryNotice = CollaborationDiagnostics.Bounded(hotloadCodeChangeReport.LifecycleRejectionReason ?? current.RuntimeLifecycle.LastDiagnostic);
}
if (hotloadCodeChangeReport.TeardownTransport)
{
current.DisposeActiveResources();
}
current.BeginConnectionTeardown(hotloadCodeChangeReport.RequiresHotloadRecoveryAfterLeave);
}
private static string BoundedDiagnostic(string value)
{
return CollaborationDiagnostics.Bounded(value);
}
public void Start(bool host, string address, int port, string name, string token, string steamId = null, string steamToken = null, bool allowUnverifiedPeers = false)
{
StartCore(new StartPlan
{
Host = host,
Address = address,
Port = port,
Name = name,
Token = token,
SteamId = steamId,
SteamToken = steamToken,
AllowUnverifiedPeers = allowUnverifiedPeers
});
}
private void StartCore(StartPlan plan)
{
activeAddress = null;
activePort = 0;
soloHarness = ComputeSoloHarness();
if (soloHarness && LocalSteamIdentity.TryGet(out var soloLocal, out var _, out var _))
{
// The reserved guest borrows the signed-in account's picture so its marker is not an empty ring.
AvatarCache.SyntheticAvatarAlias = SteamIdentityRules.Canonical(soloLocal);
}
else
{
AvatarCache.SyntheticAvatarAlias = null;
}
PlayModeTransitionResult playModeTransitionResult = lifecycle.ValidateStart(new SessionStartGuardRequest(RuntimeLifecycle.ValidateSessionStart(), Busy, lifecycle.IsPending, lifecycle.HasTerminalFailure, lifecycle.TerminalDiagnostic));
if (!playModeTransitionResult.Accepted)
{
throw new InvalidOperationException(playModeTransitionResult.ActionableError);
}
if (!plan.ZeroConfig && (string.IsNullOrWhiteSpace(plan.Token) || plan.Token.Length < 12))
{
throw new InvalidOperationException("Use a session key of at least 12 characters");
}
if (plan.ZeroConfig && !plan.Host && plan.SessionKey == null)
{
throw new InvalidOperationException("A zero-config join requires the session key derived from the invite code.");
}
if (string.IsNullOrWhiteSpace(plan.Name) || plan.Name.Length > 64)
{
throw new InvalidOperationException("No display name is available for the signed-in Steam account");
}
foreach (SceneEditorSession item in SceneEditorSession.All.Where((SceneEditorSession x) => !(x is GameEditorSession)))
{
if (item.HasUnsavedChanges)
{
throw new InvalidOperationException("Save or revert local scenes before starting a new collaboration session");
}
}
string path = Path.Combine(SceneAdapter.Root, "ProjectSettings", "collaboration.json");
if (plan.Host)
{
// The first Host click on a new project writes the shared settings itself: a fresh project id and the library's own gates, all on.
// Only the host does this; a friend joining a project that has no settings is told to get the host's copy, since a project id they
// invented would never match.
SetupResult setup = CollaborationSetup.EnsureConfig(path, Project.Current?.Config?.Org, LibraryInfo.DefaultLibraryIdent(Project.Current?.Config?.Org), LibraryInfo.LibraryVersion);
if (setup.Error != null)
{
throw new InvalidOperationException(setup.Error);
}
if (setup.Created)
{
ActionNotice = "Created ProjectSettings/collaboration.json for this project. Your friend needs the same file: send them the project as it is now.";
actionNoticeExpires = DateTime.UtcNow.AddSeconds(30.0);
}
}
if (!File.Exists(path))
{
throw new InvalidOperationException("This project has no ProjectSettings/collaboration.json. Ask the host for a copy of the project, so both of you have the same file.");
}
JsonNode jsonNode = JsonNode.Parse(File.ReadAllText(path))?["ProjectId"];
if (jsonNode == null || !Guid.TryParse(jsonNode.GetValue<string>(), out var _))
{
throw new InvalidOperationException("ProjectId must be a GUID");
}
string preparedProject = jsonNode.GetValue<string>();
string preparedBaseline = SceneAdapter.Baseline();
string text = SceneAdapter.Baseline(includeScenes: false);
CollaborationConfig collaborationConfig = CollaborationConfig.LoadFrom(path, out var _);
UseConfig(collaborationConfig);
if (plan.ZeroConfig && plan.Host)
{
PrepareHostSecret(plan, collaborationConfig);
}
string org = collaborationConfig.Org;
string preparedLibraryVersion = LibraryInfo.LibraryVersion;
string preparedManifest = ProjectManifest.Compute(Project.Current.RootDirectory.FullName);
try
{
RefreshReadiness();
}
catch (Exception error2)
{
Fail("Readiness could not be evaluated: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error2));
return;
}
string text2 = (ReadinessRules.CanStart(Readiness) ? null : (ReadinessRules.BlockReason(Readiness) ?? "Hosting is unavailable: the readiness checklist could not be evaluated."));
if (text2 != null)
{
Fail(text2);
return;
}
SessionServer preparedServer = null;
Task<Wire> preparedConnect = null;
int boundPort = plan.Port;
string dialAddress = ((plan.Host && plan.Address == "0.0.0.0") ? "127.0.0.1" : plan.Address);
PlayModeTransitionResult playModeTransitionResult2 = lifecycle.BeginStart(new SessionStartCommitRequest(playModeTransitionResult, () =>
{
if (plan.Host)
{
preparedServer = OpenHostServer(plan, preparedProject, preparedBaseline, out boundPort);
preparedServer.AllowUnverifiedPeers = plan.AllowUnverifiedPeers;
preparedServer.peerFacts = BuildPeerFacts;
preparedServer.config = sessionConfig;
preparedServer.hostLibraryVersion = preparedLibraryVersion;
preparedServer.hostManifest = preparedManifest;
}
List<EndpointResolver.EndpointCandidate> candidates2 = plan.Candidates;
preparedConnect = ((candidates2 != null && candidates2.Count > 0) ? ConnectCandidates(plan.Candidates, plan.SessionKey, plan.Secret) : Wire.Connect(dialAddress, boundPort, plan.SessionKey, plan.SessionKey != null));
return true;
}));
if (!playModeTransitionResult2.Accepted)
{
if (preparedServer != null)
{
ResourceDisposalResult resourceDisposalResult = ResourceDisposalRules.DisposePair(null, "collaboration transport", preparedServer, "collaboration host server");
if (!resourceDisposalResult.Succeeded)
{
RecordTerminalTeardownFailure(resourceDisposalResult.Failure);
}
}
connecting = preparedConnect;
BeginConnectionTeardown(recoverWhenComplete: false);
throw new InvalidOperationException(playModeTransitionResult2.ActionableError);
}
project = preparedProject;
baseline = preparedBaseline;
userName = plan.Name;
sessionToken = plan.Token;
activeSessionKey = plan.SessionKey;
activeSecret = plan.Secret;
activeCandidates = plan.Candidates;
RouteDescription = null;
List<EndpointResolver.EndpointCandidate> candidates = plan.Candidates;
if (candidates == null || candidates.Count <= 0)
{
activeAddress = dialAddress;
activePort = boundPort;
}
startAddress = dialAddress;
startPort = boundPort;
steamIdValue = plan.SteamId;
steamTokenValue = plan.SteamToken;
orgIdentValue = org;
libraryVersionValue = preparedLibraryVersion;
projectManifestValue = preparedManifest;
sourceBaseline = text;
hostSession = null;
reconnectQueued = false;
reconnectTokenTask = null;
reconnectRecovery = false;
reconnectRetrySpent = false;
scenes.Clear();
Roster.Clear();
seen.Clear();
sentSettings.Clear();
lastRevision = 0;
driftNoticeShown = null;
Error = null;
RecoveryNotice = null;
// A path from an earlier session would otherwise sit under a new failure and point at an archive that has
// nothing to do with it.
RecoveryPath = null;
restoring = false;
errorClass = SessionFailureClass.Terminal;
recoverAttempts = 0;
recoverNextAttempt = default;
recoverPromptShown = false;
recoverArmed = false;
recoverDeclined = false;
SaveNotice = null;
saveNoticeExpires = default;
UndoNotice = null;
undoNoticeExpires = default;
ActionNotice = null;
actionNoticeExpires = default;
MergeNotice = null;
mergeNoticeExpires = default;
lastAutoSave = default;
lastLocalEdit = default;
nextAutoSaveCheck = default;
endedByLeave = false;
server = preparedServer;
connecting = preparedConnect;
if (plan.ZeroConfig && plan.Host)
{
BeginHostConnectivity(plan, boundPort);
}
Status = "Connecting…";
}
private void PrepareHostSecret(StartPlan plan, CollaborationConfig config)
{
if (string.IsNullOrEmpty(config.ProjectId))
{
throw new InvalidOperationException("ProjectSettings/collaboration.json must carry a shared ProjectId before a session can be hosted.");
}
plan.Secret = TeamCreate.InviteCode.NewSecret();
plan.SessionKey = TeamCreate.InviteCode.DeriveKey(plan.Secret, config.ProjectId);
plan.Token = Convert.ToHexString(plan.SessionKey);
}
private SessionServer OpenHostServer(StartPlan plan, string project, string baseline, out int boundPort)
{
int num = ((!plan.ZeroConfig) ? 1 : 5);
SocketException error = null;
for (int i = 0; i < num; i++)
{
int num2 = plan.Port + i;
if (num2 > 65535)
{
break;
}
string text = Path.Combine(SceneAdapter.Root, ".collaboration", "journal-" + Guid.NewGuid().ToString() + ".jsonl");
try
{
SessionServer result = new SessionServer(plan.Address, num2, project, baseline, plan.Token, text, null, (Packet hello) => SoloMayVouch(hello) ? Task.FromResult(SteamTokenVerdict.Verified) : new SteamTokenHttpVerifier(SandboxHttpTokenTransport.Default).VerifyAsync(hello, hostVerificationLifetime.Token), plan.SessionKey);
result.FileProjectRoot = Project.Current.RootDirectory.FullName;
BlobHub.Store.AttachDisk(SceneAdapter.Root, BlobLimits.DiskCacheBytes);
result.BlobStore = BlobHub.Store;
result.SharedPlayEnabled = SharedPlayPreference.Enabled;
result.HostEngineBaseline = SceneAdapter.EngineBaseline();
result.FileChanged = RefreshAsset;
boundPort = num2;
return result;
}
catch (SocketException ex)
{
error = ex;
try
{
File.Delete(text);
}
catch
{
}
}
}
boundPort = plan.Port;
throw new IOException(CollaborationDiagnostics.DescribeExternalFailure((num == 1) ? $"Port {plan.Port} could not be opened: " : $"None of the {num} ports from {plan.Port} could be opened: ", error));
}
private async Task<Wire> ConnectCandidates(IReadOnlyList<EndpointResolver.EndpointCandidate> candidates, byte[] sessionKey, byte[] secret)
{
List<RouteAttempt> attempts = new List<RouteAttempt>();
foreach (EndpointResolver.EndpointCandidate candidate in candidates)
{
try
{
Wire reached;
if (candidate.Source == RelayProtocol.RouteSource)
{
if (secret == null || sessionKey == null)
{
throw new InvalidOperationException("The relay route needs the invite code's secret.");
}
if (!soloHarness && !RelayProtocol.IsPublicRelay(candidate.Address) && !IsHost)
{
// A code naming localhost was made on the host's own computer. Dialling it here reaches THIS computer, so say so instead of
// reporting a raw socket error that sends the person looking for a network problem that does not exist.
throw new RelayRefusedException(RelayProtocol.LoopbackCode, "the code points to a relay on the host's own computer");
}
reached = await RelayGuest.ConnectAsync(() => new SandboxRelaySocket(), candidate.Address, secret, sessionKey);
RouteDescription = "Connected through the relay (" + RelayHostName(candidate.Address) + ")";
}
else
{
reached = await Wire.Connect(candidate.Address, candidate.Port, sessionKey, sessionKey != null);
RouteDescription = candidate.Source == "lan" ? "Connected on the local network" : "Connected directly to " + candidate.Address + ":" + candidate.Port;
}
activeAddress = candidate.Address;
activePort = candidate.Port;
return reached;
}
catch (Exception ex)
{
string target = candidate.Source == RelayProtocol.RouteSource ? candidate.Address : candidate.Address + ":" + candidate.Port;
attempts.Add(new RouteAttempt(candidate.Source, target, ex.Message, (ex as RelayRefusedException)?.Code));
}
}
string summary = RouteFailureRules.Summarize(attempts);
// A wrong invite secret is refused by the relay and cannot succeed on a second try; every other
// route failure is a network fault that recovery may legitimately retry.
bool terminal = attempts.Any((RouteAttempt a) => a.RelayCode != null && RelayProtocol.IsTerminal(a.RelayCode));
if (terminal)
{
throw new InvalidOperationException(summary);
}
throw new IOException(summary);
}
private static string RelayHostName(string url)
{
return Uri.TryCreate(url, UriKind.Absolute, out Uri uri) ? uri.Host : "relay";
}
private void BeginHostConnectivity(StartPlan plan, int port)
{
hostSecret = plan.Secret;
hostPort = port;
PublicEndpointStatus = "Public endpoint pending…";
stunEndpointFolded = false;
stun = new StunClient();
inviteCodeValue = BuildInviteCode(port);
StartDiscovery();
if (discovery != null)
{
string text = projectManifestValue;
if (text != null && text.Length == 8)
{
try
{
discovery.StartHost(port, Convert.ToHexString(hostSecret).ToLowerInvariant(), projectManifestValue);
}
catch (Exception error)
{
GlobalGameNamespace.Log.Error("Collaboration discovery: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));
}
}
}
upnp = new UpnpPortMapper();
try
{
upnp.Map(port);
}
catch (Exception error2)
{
GlobalGameNamespace.Log.Error("Collaboration port mapping: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error2));
}
StartRelayHost(plan, port);
}
private void StartRelayHost(StartPlan plan, int port)
{
string relayUrl = EffectiveRelayUrl();
// No relay named by the project or this computer, and nobody opted out: the host runs the relay itself and exposes it through an account-free
// Quick Tunnel. Its own RelayHost dials the loopback relay now; the invite gains the public address only when the plan has seen it answer.
if (HostingPlan.Applies(effectiveRelay: relayUrl, configuredRelay: sessionConfig?.RelayUrl, internetRelayEnabled: RelaySetup.InternetRelayEnabled))
{
hostingPlan = new HostingPlan();
HostingDecision first = hostingPlan.Pump(HostingInputsNow(probeResult: null));
relayUrl = first.Action == HostingAction.StartRelay ? StartEmbeddedRelay() : null;
}
if (string.IsNullOrWhiteSpace(relayUrl) || plan.Secret == null || plan.SessionKey == null)
{
return;
}
// The embedded relay is this host's own and takes no access key; the room's proofs, derived from the invite secret, keep strangers out of it.
string accessKey = embeddedRelay != null ? null : ReadRelayAccessKey();
try
{
relayHost = new RelayHost(() => new SandboxRelaySocket(), relayUrl, plan.Secret, plan.SessionKey, accessKey);
if (server != null)
{
server.Relay = relayHost;
}
relayHost.Start();
// A configured relay goes into the invite at once, as it always has; the embedded one only when the plan publishes its public address.
inviteCarriesRelay = embeddedRelay == null;
inviteCodeValue = BuildInviteCode(port);
}
catch (Exception error)
{
relayHost?.Dispose();
relayHost = null;
inviteCarriesRelay = false;
if (embeddedRelay != null)
{
embeddedRelayError = "The host could not connect to its own local relay.";
}
GlobalGameNamespace.Log.Error("Collaboration relay: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));
}
}
// Takes the first loopback port the engine lets the host's own WebSocket dial (HostingPlan.LocalRelayPorts). A free port picked by the system would
// be refused by the engine's ws:// policy, so the choice is limited to those four. Returns the local relay address, or null with embeddedRelayError
// set for the plan to report on its next pump.
private string StartEmbeddedRelay()
{
foreach (int candidate in HostingPlan.LocalRelayPorts)
{
EmbeddedRelay relay = new EmbeddedRelay(candidate);
try
{
relay.Start();
embeddedRelay = relay;
return HostingPlan.LocalRelayUrl(candidate);
}
catch (InvalidOperationException)
{
relay.Dispose();
}
}
embeddedRelayError = "The local relay could not listen on any of the ports the editor permits (" + string.Join(", ", HostingPlan.LocalRelayPorts) + "); close the program that uses them and host again.";
return null;
}
private HostingInputs HostingInputsNow(string probeResult)
{
CloudflaredTool tunnel = quickTunnel;
string relayError = embeddedRelayError;
if (relayError == null && embeddedRelay != null && !embeddedRelay.IsListening)
{
relayError = "The local relay stopped listening.";
}
if (relayError == null && embeddedRelay != null && relayHost?.State == RelayHostState.Failed)
{
relayError = "The host's connection to its own local relay failed: " + relayHost.Failure;
}
bool running = tunnel?.State == CloudflaredTool.Phase.Running;
return new HostingInputs(
Now: HostingSeconds,
RelayStarted: embeddedRelay?.IsListening == true,
RelayError: relayError,
LocalHostReady: relayHost?.State == RelayHostState.Ready,
TunnelRunning: running,
TunnelRelayUrl: running ? tunnel.RelayUrl : null,
TunnelRegistered: tunnel?.RegisteredConnectionSeen == true,
TunnelError: tunnel?.State == CloudflaredTool.Phase.Failed ? (tunnel.Error ?? "The tunnel stopped.") : null,
TunnelProgress: tunnel?.State == CloudflaredTool.Phase.Downloading ? tunnel.Status : null,
ProbeResult: probeResult);
}
// The access key is a credential, so it comes from an untracked local file and never from the shared,
// committed project settings. A missing file is normal (a relay that needs no key); an unusable one is logged
// by name and treated as absent, which the relay then answers with its own refusal.
private static string ReadRelayAccessKey()
{
try
{
string path = Path.Combine(SceneAdapter.Root, ".collaboration", RelayAccessKeyFile.FileName);
if (!File.Exists(path))
{
return null;
}
string key = RelayAccessKeyFile.Parse(File.ReadAllText(path), out string problem);
if (problem != null)
{
GlobalGameNamespace.Log.Error("Collaboration relay: " + problem);
}
return key;
}
catch (Exception error)
{
GlobalGameNamespace.Log.Error("Collaboration relay: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic("The access key file could not be read: ", error));
return null;
}
}
private string BuildInviteCode(int port)
{
return TeamCreate.InviteCode.Encode(new InviteData
{
Port = port,
ProjectId = sessionConfig.ProjectId,
Secret = hostSecret,
Endpoints = EndpointsFor(stun?.PublicEndPoint),
Relay = HostingPlan.InviteRelay(inviteCarriesRelay: inviteCarriesRelay, embeddedRelayInUse: embeddedRelay != null, publicRelayUrl: publicRelayUrl, effectiveRelayUrl: EffectiveRelayUrl(), soloHarness: soloHarness)
});
}
private static List<byte[]> EndpointsFor(string address)
{
if (!IPAddress.TryParse(address, out IPAddress address2) || address2.AddressFamily != AddressFamily.InterNetwork)
{
return null;
}
return new List<byte[]> { address2.GetAddressBytes() };
}
public void StartFromInvite(string code, string name, string steamId = null, string steamToken = null, bool allowUnverifiedPeers = false, string advancedAddress = null, int advancedPort = 0)
{
string text = CurrentProjectId();
if (string.IsNullOrEmpty(text))
{
throw new InvalidOperationException("Create ProjectSettings/collaboration.json with a shared ProjectId, then commit it to Git");
}
if (!TeamCreate.InviteCode.TryDecode(code, text, out var data, out var error))
{
throw new InvalidOperationException(error);
}
StartPlan startPlan = JoinPlan(data, text, name, steamId, steamToken, allowUnverifiedPeers, advancedAddress, advancedPort);
if (startPlan.Candidates.Count == 0)
{
throw new InvalidOperationException("No address to try: this code carries no public address and discovery has not found that host on your network. Type the host's address under Advanced.");
}
StartCore(startPlan);
}
public void StartFromDiscovery(DiscoveredHost host, string name, string steamId = null, string steamToken = null, bool allowUnverifiedPeers = false)
{
if ((object)host == null)
{
throw new InvalidOperationException("Select a discovered host to join.");
}
string text = CurrentProjectId();
if (string.IsNullOrEmpty(text))
{
throw new InvalidOperationException("Create ProjectSettings/collaboration.json with a shared ProjectId, then commit it to Git");
}
byte[] array;
try
{
array = Convert.FromHexString(host.SecretHex ?? string.Empty);
}
catch (FormatException)
{
array = null;
}
if (array == null || array.Length != 16)
{
throw new InvalidOperationException("That host did not report a usable session secret.");
}
byte[] array2 = TeamCreate.InviteCode.DeriveKey(array, text);
StartCore(new StartPlan
{
Host = false,
Address = host.EndPoint,
Port = host.Port,
Name = name,
Token = Convert.ToHexString(array2),
SteamId = steamId,
SteamToken = steamToken,
AllowUnverifiedPeers = allowUnverifiedPeers,
ZeroConfig = true,
SessionKey = array2,
Secret = array,
Candidates = new List<EndpointResolver.EndpointCandidate>
{
new EndpointResolver.EndpointCandidate(host.EndPoint, host.Port, "lan")
}
});
}
public void StartHosted(int port, string name, string steamId = null, string steamToken = null, bool allowUnverifiedPeers = false)
{
StartCore(new StartPlan
{
Host = true,
Address = "0.0.0.0",
Port = port,
Name = name,
SteamId = steamId,
SteamToken = steamToken,
AllowUnverifiedPeers = allowUnverifiedPeers,
ZeroConfig = true
});
}
private StartPlan JoinPlan(InviteData invite, string projectId, string name, string steamId, string steamToken, bool allowUnverifiedPeers, string advancedAddress, int advancedPort)
{
byte[] array = TeamCreate.InviteCode.DeriveKey(invite.Secret, projectId);
return new StartPlan
{
Host = false,
Address = advancedAddress,
Port = invite.Port,
Name = name,
Token = Convert.ToHexString(array),
SteamId = steamId,
SteamToken = steamToken,
AllowUnverifiedPeers = allowUnverifiedPeers,
ZeroConfig = true,
SessionKey = array,
Secret = invite.Secret,
Candidates = EndpointResolver.Order(invite, DiscoveredHosts, advancedAddress, advancedPort).ToList()
};
}
private string CurrentProjectId()
{
if (!string.IsNullOrEmpty(sessionConfig?.ProjectId))
{
return sessionConfig.ProjectId;
}
if (projectIdResolved)
{
return projectIdValue;
}
projectIdResolved = true;
return projectIdValue = LoadConfig().ProjectId;
}
public void StartDiscovery()
{
if (discovery != null)
{
return;
}
try
{
string text = CurrentProjectId();
if (!string.IsNullOrEmpty(text))
{
discovery = new LanDiscovery(DiscoveryProtocol.ProjectHash8(text));
}
}
catch (Exception error)
{
GlobalGameNamespace.Log.Error("Collaboration discovery: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));
}
}
public void StopDiscovery()
{
discovery?.Dispose();
discovery = null;
}
private void PollConnectivity()
{
PollInternetHosting();
discovery?.Poll();
if (discovery != null && server == null && wire == null && connecting == null)
{
discovery.SendQuery();
}
stun?.Poll();
FoldPublicEndpoint();
if (relayHost != null && inviteCarriesRelay && relayHost.State == RelayHostState.Failed && inviteCodeValue != null)
{
// A code that names a relay this host has given up on would send friends to a room that will
// never exist. Re-issue it without the relay so the direct routes are all it promises.
inviteCarriesRelay = false;
inviteCodeValue = BuildInviteCode(hostPort);
}
upnp?.Poll();
if (upnp != null && upnp.Finished)
{
upnp.Dispose();
upnp = null;
}
}
// One step of the internet relay per editor frame. Everything slow (the download, cloudflared's start, the HTTP probe) is a task or a process that is
// polled here and never waited on; the relay itself is pumped first so a frame's traffic is forwarded before anything else is decided.
private void PollInternetHosting()
{
if (hostingPlan == null)
{
return;
}
embeddedRelay?.Pump();
if (hostingPlan.Phase == HostingPhase.Failed)
{
return;
}
quickTunnel?.Pump();
string probe = null;
if (reachabilityTask != null && reachabilityTask.IsCompleted)
{
// Completed, so reading it cannot block. CheckReachabilityAsync turns every failure into a sentence; a faulted task is not expected.
probe = reachabilityTask.IsCompletedSuccessfully ? reachabilityTask.GetAwaiter().GetResult() : HostingPlan.ProbeVerdict(null, null);
reachabilityTask = null;
reachabilityCancel?.Dispose();
reachabilityCancel = null;
}
HostingDecision decision = hostingPlan.Pump(HostingInputsNow(probe));
// The plan has now seen the failed tool. Releasing it kills cloudflared's process tree, so a restart begins from a clean one.
if (quickTunnel?.State == CloudflaredTool.Phase.Failed)
{
quickTunnel.Dispose();
quickTunnel = null;
}
switch (decision.Action)
{
case HostingAction.StartTunnel:
quickTunnel?.Dispose();
quickTunnel = null;
if (embeddedRelay != null)
{
quickTunnel = new CloudflaredTool();
quickTunnel.Begin(embeddedRelay.Port);
}
break;
case HostingAction.ProbeReachability:
{
string health = HostingPlan.HealthUrl(decision.PublicUrl);
reachabilityCancel = new CancellationTokenSource(ReachabilityProbeTimeout);
reachabilityTask = health != null ? CheckReachabilityAsync(health, reachabilityCancel.Token) : Task.FromResult(HostingPlan.ProbeVerdict(null, null));
break;
}
case HostingAction.PublishRelayInvite:
publicRelayUrl = decision.PublicUrl;
inviteCarriesRelay = true;
inviteCodeValue = BuildInviteCode(hostPort);
break;
case HostingAction.WithdrawRelayInvite:
WithdrawPublicRelay();
break;
}
// Whatever the action, an invite names the public relay only while the plan is Ready.
if (hostingPlan.Phase != HostingPhase.Ready && publicRelayUrl != null)
{
WithdrawPublicRelay();
}
if (hostingPlan.Phase == HostingPhase.Failed)
{
// Final for this session: nothing is left to probe or restart. The loopback relay stays until the session ends, as the host's RelayHost uses it.
reachabilityCancel?.Cancel();
reachabilityCancel?.Dispose();
reachabilityCancel = null;
reachabilityTask = null;
quickTunnel?.Dispose();
quickTunnel = null;
}
}
private void WithdrawPublicRelay()
{
publicRelayUrl = null;
inviteCarriesRelay = false;
if (inviteCodeValue != null)
{
inviteCodeValue = BuildInviteCode(hostPort);
}
}
// Through the engine's HTTP client, the only one an editor script may use, as SandboxCloudflareTransport does. Every outcome becomes the plan's
// verdict sentence; the time budget is the token's (ReachabilityProbeTimeout).
private static async Task<string> CheckReachabilityAsync(string healthUrl, CancellationToken token)
{
try
{
using var response = await Http.RequestAsync(healthUrl, "GET", null, null, token);
string body = await response.Content.ReadAsStringAsync(token);
return HostingPlan.ProbeVerdict((int)response.StatusCode, body);
}
catch (OperationCanceledException)
{
return "The public relay address did not answer within " + ReachabilityProbeTimeout.TotalSeconds + " seconds.";
}
catch (Exception)
{
return HostingPlan.ProbeVerdict(null, null);
}
}
private void FoldPublicEndpoint()
{
if (stun != null && inviteCodeValue != null && !stunEndpointFolded && (stun.PublicEndPoint != null || stun.Failure != null))
{
stunEndpointFolded = true;
if (stun.PublicEndPoint != null)
{
PublicEndpointStatus = "Public endpoint: " + stun.PublicEndPoint;
inviteCodeValue = BuildInviteCode(hostPort);
}
else
{
PublicEndpointStatus = CollaborationDiagnostics.Bounded("No public address found; invitees on this network join by code or LAN discovery, and a remote invitee needs the host's address under Advanced.");
}
}
}
private void TeardownConnectivity()
{
StopAdvertising();
upnp?.Dispose();
upnp = null;
stun?.Dispose();
stun = null;
relayHost?.Dispose();
relayHost = null;
inviteCarriesRelay = false;
inviteCodeValue = null;
hostSecret = null;
hostPort = 0;
PublicEndpointStatus = null;
stunEndpointFolded = false;
StopDiscovery();
}
private void StopAdvertising()
{
discovery?.StopHost();
upnp?.Unmap();
// The internet relay closes with the session that opened it: the probe is cancelled, cloudflared's process tree is killed (it also dies on its
// own at hotload and editor exit, see CloudflaredTool), and the loopback relay stops listening.
reachabilityCancel?.Cancel();
reachabilityCancel?.Dispose();
reachabilityCancel = null;
reachabilityTask = null;
quickTunnel?.Dispose();
quickTunnel = null;
embeddedRelay?.Dispose();
embeddedRelay = null;
embeddedRelayError = null;
hostingPlan = null;
publicRelayUrl = null;
// A failed or ended session must not keep taking guests through the relay either.
relayHost?.Dispose();
}
private PlayModeTransitionResult ReconnectGuard()
{
CollaborationTransportState value = CollaborationTransportStateRules.Detect(wire != null, Connected && Error == null, wire?.StoredFailure != null || Error != null, connecting != null);
return lifecycle.ValidateReconnect(new SessionReconnectGuardRequest(RuntimeLifecycle, baseline != null, connecting != null, wire != null, lifecycle.IsPending, lifecycle.HasTerminalFailure, lifecycle.TerminalDiagnostic, Connected && Error == null, value, sourceBaseline == SceneAdapter.Baseline(includeScenes: false), endedByLeave, IsHost));
}
public void Reconnect(string address, int port)
{
if (!recoverArming)
{
recoverArmed = false;
}
if (!reconnectQueued && reconnectTokenTask == null)
{
PlayModeTransitionResult playModeTransitionResult = ReconnectGuard();
if (!playModeTransitionResult.Accepted)
{
throw new ActionRefusal(playModeTransitionResult.ActionableError ?? "Recovery is not possible in the session's current state.");
}
reconnectRecovery = true;
reconnectRetrySpent = false;
reconnectAddress = activeAddress ?? address;
reconnectPort = ((activePort != 0) ? activePort : port);
reconnectQueued = true;
Status = "Reconnecting; preparing identity…";
RequestReconnectToken();
}
}
private void RequestReconnectToken()
{
if (reconnectTokenTask != null)
{
return;
}
try
{
reconnectTokenTask = Auth.GetToken("sbox-editor-collaboration");
}
catch (Exception)
{
AbandonReconnect("A Steam identity token could not be requested.");
}
}
private void PollReconnect()
{
if (reconnectTokenTask != null)
{
if (!reconnectTokenTask.IsCompleted)
{
return;
}
string text = null;
try
{
text = reconnectTokenTask.GetAwaiter().GetResult();
}
catch (Exception)
{
}
finally
{
reconnectTokenTask = null;
}
if (string.IsNullOrWhiteSpace(text))
{
AbandonReconnect("A Steam identity token could not be obtained.");
}
else if (reconnectQueued)
{
RunReconnect(text);
}
}
else if (reconnectQueued)
{
AbandonReconnect("A Steam identity token could not be obtained.");
}
else if (reconnectRecovery && !reconnectRetrySpent && Error != null && SteamIdentityAdmission.IsTokenRejection(Error))
{
reconnectRetrySpent = true;
ClearRecordedFailure();
reconnectQueued = true;
Status = "Reconnecting; preparing identity…";
RequestReconnectToken();
}
}
private void AbandonReconnect(string reason)
{
reconnectQueued = false;
reconnectTokenTask = null;
reconnectRecovery = false;
ClearRecordedFailure();
Fail(reason);
}
private void RunReconnect(string freshToken)
{
reconnectQueued = false;
PlayModeTransitionResult playModeTransitionResult = ReconnectGuard();
if (!playModeTransitionResult.Accepted)
{
AbandonReconnect(playModeTransitionResult.ActionableError ?? "Reconnect is not possible in the session's current state.");
return;
}
SessionReconnectCommitOutcome sessionReconnectCommitOutcome = lifecycle.BeginReconnect(new SessionReconnectCommitRequest(playModeTransitionResult, () =>
{
Preserve("Reconnect: local state before authoritative recovery");
return DisposeActiveResources();
}));
if (!sessionReconnectCommitOutcome.Accepted)
{
AbandonReconnect((sessionReconnectCommitOutcome.Stage == ReconnectCommitStage.FailedDuringPrepare) ? (sessionReconnectCommitOutcome.ActionableError ?? "Reconnect preparation failed after the previous connection was closed.") : (sessionReconnectCommitOutcome.ActionableError ?? "Reconnect was refused before any connection was closed."));
return;
}
Error = null;
Connected = false;
restoring = true;
foreach (Tracked value in scenes.Values)
{
value.Ready = false;
if (value.Session.Scene.IsValid())
{
value.Session.Reload();
value.Settings = SceneSettingsAdapter.Capture(value.Session.Scene);
value.SettingsApplied = null;
value.SettingsEcho = null;
value.DiskStamp = SceneAdapter.DiskStamp(SceneAdapter.PathOf(value.Session));
}
}
steamTokenValue = freshToken;
connecting = (activeCandidates != null && activeCandidates.Count > 0)
? ConnectCandidates(activeCandidates, activeSessionKey, activeSecret)
: Wire.Connect(reconnectAddress, reconnectPort, activeSessionKey, activeSessionKey != null);
Status = "Reconnecting; recovering authoritative state…";
}
public void Leave()
{
PlayModeTransitionResult playModeTransitionResult = lifecycle.ValidateLeave(new SessionLeaveGuardRequest(RuntimeLifecycle.IsTransitionInProgress));
if (!playModeTransitionResult.Accepted)
{
throw new InvalidOperationException(playModeTransitionResult.ActionableError);
}
SceneEditorSession active = SceneEditorSession.Active;
if (active == null || !active.IsPlaying)
{
Checkpoint();
}
bool archiveFailed = RuntimeLifecycle.State == PlayModeLifecycleState.RuntimeBlocked && ArchiveHotloadLifecycle(RuntimeLifecycle.ArchiveReason ?? "hotloaded") == HotloadArchiveOutcome.Failed;
SessionLeaveOutcome sessionLeaveOutcome = SessionLeaveRules.Run(new SessionLeaveRequest(lifecycle, RuntimeLifecycle.IsTransitionInProgress, () => !archiveFailed && scenes.Values.Any((Tracked x) => x.Pending != null || x.SettingsPending != null || (x.Ready && x.Session.Scene.IsValid() && StateRules.Diff(x.Known, SceneAdapter.Capture(x.Session.Scene)).Count > 0)), (string reason) =>
{
Preserve(reason);
return true;
}, () =>
{
wire?.Send(new Packet
{
Kind = "leave"
});
}, DisposeActiveResources));
if (!sessionLeaveOutcome.Accepted)
{
throw new InvalidOperationException(sessionLeaveOutcome.ActionableError);
}
reconnectQueued = false;
reconnectTokenTask = null;
reconnectRecovery = false;
reconnectRetrySpent = false;
heldPackets.Clear();
PlayDecision = PlaySpectateDecision.Normal;
endedByLeave = true;
ClearRecordedFailure();
ActionNotice = null;
actionNoticeExpires = default;
ClearNativeUndoMirrors();
undoReset.Flush();
undoResetScenes.Clear();
recoverArmed = false;
recoverAttempts = 0;
recoverNextAttempt = default;
recoverPromptShown = false;
recoverDeclined = false;
bool disposalFailed = sessionLeaveOutcome.DisposalFailed;
if (sessionLeaveOutcome.PreserveFailed && !disposalFailed)
{
RecoveryNotice = sessionLeaveOutcome.Failure;
GlobalGameNamespace.Log.Error("Collaboration lifecycle: " + RecoveryNotice);
}
Connected = false;
Roster.Clear();
foreach (Tracked value in scenes.Values)
{
value.Ready = false;
}
bool requiresHotloadRecoveryAfterLeave = RuntimeLifecycle.RequiresHotloadRecoveryAfterLeave;
if (connecting != null)
{
BeginConnectionTeardown(requiresHotloadRecoveryAfterLeave);
}
if (disposalFailed)
{
Status = "Offline • connection teardown failed; session remains fail-stop";
RecordTerminalTeardownFailure(lifecycle.TerminalDiagnostic);
return;
}
if (archiveFailed)
{
Status = "Offline • hotload recovery failed";
RecoveryNotice = BoundedDiagnostic(RuntimeLifecycle.LastDiagnostic);
GlobalGameNamespace.Log.Error("Collaboration lifecycle: " + RecoveryNotice);
return;
}
if (lifecycle.IsPending)
{
Status = (requiresHotloadRecoveryAfterLeave ? "Offline • waiting for connection teardown before hotload recovery" : "Offline • waiting for pending connection teardown");
return;
}
if (lifecycle.HasTerminalFailure)
{
Status = "Offline • connection teardown failed; runtime remains blocked";
return;
}
Status = "Offline";
string error = null;
if (requiresHotloadRecoveryAfterLeave)
{
CompleteHotloadRecovery(out error);
}
if (error != null)
{
SetLifecycleError(error);
}
}
public void Fail(string error, SessionFailureClass classification = SessionFailureClass.Terminal)
{
if (Error != null)
{
return;
}
string recoveryWriteFailure = null;
bool everConnected = hostSession != null;
if (CollaborationFailureRules.ShouldArchive(everConnected, scenes.Values.Any((Tracked x) => x.Pending != null || x.SettingsPending != null)))
{
try
{
Preserve(error);
}
catch (Exception error2)
{
recoveryWriteFailure = CollaborationTransportDiagnostics.BoundedExceptionDiagnostic("", error2);
}
}
CollaborationFailureOutcome collaborationFailureOutcome = CollaborationFailureRules.Compose(Error, error, recoveryWriteFailure, everConnected);
Error = collaborationFailureOutcome.Error;
Status = collaborationFailureOutcome.Status;
errorClass = classification;
GlobalGameNamespace.Log.Error("Collaboration: " + Error);
try
{
StopAdvertising();
}
catch (Exception error3)
{
GlobalGameNamespace.Log.Error("Collaboration connectivity: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error3));
}
}
public void ReportAction(Exception error)
{
if (error == null)
{
return;
}
// A failure of one user action (save, undo, redo, play toggle, a dock button) is reported and the session continues; only the integrity
// failures docs/FAILURE-MODES.md keeps fail-closed still end it (ContentFailureRules.ClassifyActionFailure).
if (ContentFailureRules.ClassifyActionFailure(error) == ActionFailureAction.Fail)
{
Fail(error.Message);
return;
}
if (!ActionRefusalRules.IsRefusal(error))
{
GlobalGameNamespace.Log.Warning("Collaboration action: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));
}
SetActionNotice(ContentFailureRules.ActionFailureNotice(error));
}
public void ClearRecordedFailure()
{
if (Error != null && lifecycle.TerminalDiagnostic == null)
{
Error = null;
errorClass = SessionFailureClass.Terminal;
}
}
private HotloadArchiveOutcome ArchiveHotloadLifecycle(string reason)
{
if (RuntimeLifecycle.State != PlayModeLifecycleState.RuntimeBlocked)
{
return HotloadArchiveOutcome.NotApplicable;
}
try
{
Preserve("Runtime lifecycle hotload marker: " + reason);
PlayModeTransitionResult playModeTransitionResult = lifecycle.ApplyArchiveMarker(RuntimeLifecycle.RecordHotloadArchive());
if (!playModeTransitionResult.Accepted)
{
RecoveryNotice = BoundedDiagnostic(RuntimeLifecycle.LastActionableError);
GlobalGameNamespace.Log.Error("Collaboration lifecycle: " + RecoveryNotice);
return HotloadArchiveOutcome.Failed;
}
RecoveryNotice = BoundedDiagnostic("Runtime lifecycle blocked; recovery archived before connections were disposed. Leave, sync source in Git, and start a new session.");
return HotloadArchiveOutcome.Archived;
}
catch (Exception error)
{
RuntimeLifecycle.RecordHotloadArchiveFailure(error);
lifecycle.ApplyArchiveMarker(PlayModeTransitionResult.Rejected("archive failed"));
RecoveryNotice = BoundedDiagnostic(RuntimeLifecycle.LastDiagnostic);
GlobalGameNamespace.Log.Error("Collaboration lifecycle: " + RecoveryNotice);
return HotloadArchiveOutcome.Failed;
}
}
private bool DisposeActiveResources()
{
CancellationTokenSource cancellationTokenSource = hostVerificationLifetime;
hostVerificationLifetime = new CancellationTokenSource();
try
{
cancellationTokenSource.Cancel();
}
catch (ObjectDisposedException)
{
}
try
{
cancellationTokenSource.Dispose();
}
catch (ObjectDisposedException)
{
}
ResourceDisposalResult resourceDisposalResult = ResourceDisposalRules.DisposePair(wire, "collaboration transport", server, "collaboration host server");
wire = null;
server = null;
projectFiles?.Dispose();
projectFiles = null;
blobs?.Release();
blobs = null;
BlobHub.Enabled = false;
play?.Dispose();
play = null;
Connected = false;
try
{
TeardownConnectivity();
}
catch (Exception error)
{
GlobalGameNamespace.Log.Error("Collaboration connectivity: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));
}
if (resourceDisposalResult.Succeeded)
{
return true;
}
lifecycle.RecordTerminalTeardownFailure(resourceDisposalResult.Failure);
return false;
}
private void BeginConnectionTeardown(bool recoverWhenComplete)
{
Task<Wire> pendingConnect = connecting;
connecting = null;
ConnectionTeardownResult connectionTeardownResult = lifecycle.BeginDetachedTeardown(pendingConnect, recoverWhenComplete);
if (connectionTeardownResult.Outcome == ConnectionTeardownOutcome.TerminalFailure)
{
RecordTerminalTeardownFailure(connectionTeardownResult.Failure);
}
}
private void RecordTerminalTeardownFailure(string failure)
{
bool requiresHotloadRecoveryAfterLeave = RuntimeLifecycle.RequiresHotloadRecoveryAfterLeave;
bool flag = !string.IsNullOrWhiteSpace(failure) && failure.StartsWith("Connection teardown failed;", StringComparison.Ordinal);
string text = (string.IsNullOrWhiteSpace(failure) ? "unknown teardown error" : BoundedDiagnostic(failure));
if (requiresHotloadRecoveryAfterLeave && !flag)
{
RuntimeLifecycle.RecordHotloadTeardownFailure(text);
}
string text2 = (RecoveryNotice = BoundedDiagnostic(requiresHotloadRecoveryAfterLeave ? (RuntimeLifecycle.LastActionableError ?? text) : (flag ? text : ("Connection teardown failed; this editor session remains offline and fail-stop: " + text))));
if (requiresHotloadRecoveryAfterLeave)
{
SetLifecycleError(text2);
}
else
{
GlobalGameNamespace.Log.Error("Collaboration lifecycle: " + text2);
}
}
private void CompleteHotloadRecovery(out string error)
{
error = null;
if (!RuntimeLifecycle.RequiresHotloadRecoveryAfterLeave || !lifecycle.LeaveRequested)
{
return;
}
TeardownCompletionObservation teardownCompletionObservation = lifecycle.ObserveTeardownCompletion(requiresHotloadRecovery: true, connecting != null);
if (!teardownCompletionObservation.Observed)
{
return;
}
if (teardownCompletionObservation.TeardownFailed)
{
RecordTerminalTeardownFailure(teardownCompletionObservation.TeardownFailure);
return;
}
if (!teardownCompletionObservation.AttemptRecovery)
{
if (teardownCompletionObservation.Status == HotloadTeardownRecoveryStatus.ArchiveFailed && teardownCompletionObservation.Error != null)
{
RecoveryNotice = BoundedDiagnostic(teardownCompletionObservation.Error);
GlobalGameNamespace.Log.Error("Collaboration lifecycle: " + RecoveryNotice);
}
return;
}
if (!RuntimeLifecycle.HotloadArchiveRecorded && RuntimeLifecycle.State == PlayModeLifecycleState.RuntimeBlocked && ArchiveHotloadLifecycle(RuntimeLifecycle.ArchiveReason ?? "hotloaded") == HotloadArchiveOutcome.Failed)
{
error = RecoveryNotice;
return;
}
PlayModeTransitionResult playModeTransitionResult = RuntimeLifecycleEvents.RecoverFromHotload(RuntimeLifecycle);
if (playModeTransitionResult.Accepted)
{
lifecycle.ClearDeferredHotloadRecovery();
RecoveryNotice = "Hotload recovery reset completed after transport teardown and Leave. Sync source in Git, then start a new collaboration session.";
}
else
{
error = playModeTransitionResult.ActionableError;
}
}
private void Preserve(string reason)
{
string text = Path.Combine(SceneAdapter.Root, ".collaboration", "recovery");
Directory.CreateDirectory(text);
string path = RecoveryPathRules.CreatePath(text, DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fffffff"));
var captures = scenes.Where((KeyValuePair<string, Tracked> x) => x.Value.Session.Scene.IsValid()).ToDictionary((KeyValuePair<string, Tracked> x) => x.Key, (KeyValuePair<string, Tracked> x) => new
{
Live = x.Value.Session.Scene.Serialize(),
Pending = x.Value.Pending,
SettingsPending = x.Value.SettingsPending,
Known = x.Value.Known
});
Packet[] pending = (from x in scenes.Values
where x.Pending != null
select x.Pending).ToArray();
RecoveryPathOutcome recoveryPathOutcome = RecoveryPathRules.PublishAfterWrite(path, () =>
{
File.WriteAllText(path, JsonSerializer.Serialize(new { reason, captures, pending }));
});
if (!recoveryPathOutcome.Succeeded)
{
throw new IOException("Recovery archive could not be written to " + path);
}
RecoveryPath = recoveryPathOutcome.Published;
}
[EditorEvent.Frame]
public static void PumpCurrent()
{
Current.Frame();
}
public void Frame()
{
if (focusNoticeExpires != default(DateTime) && DateTime.UtcNow >= focusNoticeExpires)
{
FocusNotice = null;
focusNoticeExpires = default;
}
if (saveNoticeExpires != default(DateTime) && DateTime.UtcNow >= saveNoticeExpires)
{
SaveNotice = null;
saveNoticeExpires = default;
}
if (undoNoticeExpires != default(DateTime) && DateTime.UtcNow >= undoNoticeExpires)
{
UndoNotice = null;
undoNoticeExpires = default;
}
if (actionNoticeExpires != default(DateTime) && DateTime.UtcNow >= actionNoticeExpires)
{
ActionNotice = null;
actionNoticeExpires = default;
}
if (mergeNoticeExpires != default(DateTime) && DateTime.UtcNow >= mergeNoticeExpires)
{
MergeNotice = null;
mergeNoticeExpires = default;
}
try
{
try
{
PollReconnect();
}
catch (Exception error)
{
AbandonReconnect(CollaborationTransportDiagnostics.BoundedExceptionDiagnostic("Reconnect could not continue: ", error));
}
server?.Pump();
RunUndoResetIfDue();
try
{
orgDirectory?.Poll();
PollPackageFetch();
if (DateTime.UtcNow >= nextReadiness || (orgDirectory?.Revision ?? (-1)) != readinessOrgRevision)
{
RefreshReadiness();
}
}
catch (Exception error2)
{
GlobalGameNamespace.Log.Error("Collaboration readiness: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error2));
}
try
{
PollConnectivity();
}
catch (Exception error3)
{
GlobalGameNamespace.Log.Error("Collaboration connectivity: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error3));
}
try
{
TickIdleAutoSave();
}
catch (Exception error4)
{
GlobalGameNamespace.Log.Error("Collaboration auto-save: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error4));
}
try
{
TickAutoRecover();
}
catch (Exception error5)
{
GlobalGameNamespace.Log.Error("Collaboration auto-recover: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error5));
}
TeardownCompletionObservation teardownCompletionObservation = lifecycle.ObserveTeardownCompletion(RuntimeLifecycle.RequiresHotloadRecoveryAfterLeave, connecting != null);
if (teardownCompletionObservation.Observed)
{
if (teardownCompletionObservation.TeardownFailed)
{
RecordTerminalTeardownFailure(teardownCompletionObservation.TeardownFailure);
}
else if (teardownCompletionObservation.AttemptRecovery)
{
CompleteHotloadRecovery(out var error6);
if (error6 != null)
{
SetLifecycleError(error6);
}
}
else if (teardownCompletionObservation.Error != null && RuntimeLifecycle.RequiresHotloadRecoveryAfterLeave)
{
RecoveryNotice = BoundedDiagnostic(teardownCompletionObservation.Error);
GlobalGameNamespace.Log.Error("Collaboration lifecycle: " + RecoveryNotice);
}
}
Task<Wire> task = connecting;
if (task != null && task.IsCompleted)
{
SceneEditorSession sceneEditorSession = AuthoringSource();
string sourceScenePath = SceneAdapter.PathOf(sceneEditorSession);
bool flag = PlayModeSourceCoexistence.CanProcessSourcePackets(sceneEditorSession?.IsPlaying ?? false, sceneEditorSession, sourceScenePath, RuntimeLifecycle, CurrentRuntimeFor(sceneEditorSession));
// Playing, or having no scene open, only pauses source synchronization (PlaySpectateRules): the connection completes,
// presence flows and scene packets are held until play stops. Only a runtime a hotload interrupted still refuses.
PlaySpectateDecision promoteDecision = PlaySpectateRules.Decide(IsLocalPlaying(sceneEditorSession), RuntimeLifecycle.State, flag, PlayModeSourceCoexistence.IsUsablePathKey(sourceScenePath), play?.SpectatingRun ?? false);
if (!ContentFailureRules.MayPromoteConnection(promoteDecision, lifecycle.CanPromoteConnection))
{
BeginConnectionTeardown(recoverWhenComplete: false);
if (promoteDecision == PlaySpectateDecision.FailStop)
{
throw new InvalidOperationException("Stop play mode or complete the approved runtime lifecycle before promoting a source connection");
}
return;
}
Task<Wire> task2 = connecting;
connecting = null;
wire = task2.GetAwaiter().GetResult();
wire.Send(new Packet
{
Kind = "hello",
ProtocolVersion = 4,
Mode = CollaborationMode.Source,
Peer = Peer,
Project = project,
Baseline = baseline,
Name = userName,
Token = sessionToken,
SteamId = steamIdValue,
SteamToken = steamTokenValue,
OrgIdent = orgIdentValue,
LibraryVersion = libraryVersionValue,
ProjectManifest = projectManifestValue,
EngineBaseline = SceneAdapter.EngineBaseline()
});
lastReceive = DateTime.UtcNow;
}
if (wire == null)
{
return;
}
if (wire.StoredFailure != null)
{
Connected = false;
throw new IOException("Disconnected: " + wire.StoredFailure);
}
SceneEditorSession sceneEditorSession2 = AuthoringSource();
string sourceScenePath2 = SceneAdapter.PathOf(sceneEditorSession2);
bool sourceAdmissible = PlayModeSourceCoexistence.CanProcessSourcePackets(sceneEditorSession2?.IsPlaying ?? false, sceneEditorSession2, sourceScenePath2, RuntimeLifecycle, CurrentRuntimeFor(sceneEditorSession2));
PlaySpectateDecision decision = PlaySpectateRules.Decide(IsLocalPlaying(sceneEditorSession2), RuntimeLifecycle.State, sourceAdmissible, PlayModeSourceCoexistence.IsUsablePathKey(sourceScenePath2), play?.SpectatingRun ?? false);
if (decision != PlayDecision && decision == PlaySpectateDecision.Normal && !heldPackets.IsEmpty)
{
Status = "Connected • applying changes made while you were playing";
}
PlayDecision = decision;
if (decision == PlaySpectateDecision.Normal && resyncWhenRoutable.Count > 0)
{
// Updates for these scenes were discarded while they could not take them: ask the host for each again (uncounted).
foreach (string scene in resyncWhenRoutable.ToArray())
{
Tracked affected = TrackedFor(scene);
if (affected != null && affected.Session.IsPlaying)
{
continue;
}
resyncWhenRoutable.Remove(scene);
if (affected != null && affected.Session.Scene.IsValid() && !affected.Recovery.Paused)
{
affected.Recovery.Requested();
RequestSceneResync(scene, affected);
}
}
}
if (decision == PlaySpectateDecision.FailStop)
{
throw new InvalidOperationException("Stop play mode or complete the approved runtime lifecycle before applying source packets");
}
Packet result;
PumpBlobs();
if (decision == PlaySpectateDecision.Normal)
{
// Changes held while playing come first so they land in the order the host accepted them.
while (heldPackets.TryTake(out result))
{
ProcessIncoming(result);
}
}
while (NextIncoming(out result))
{
if (decision != PlaySpectateDecision.Normal && HeldPacketBuffer.IsSceneChange(result.Kind))
{
if (result.Scene != null && resyncWhenRoutable.Contains(result.Scene))
{
// That scene is resynchronized when play ends; the snapshot will contain this update.
}
else if (!heldPackets.TryHold(result))
{
// Applying some of the held updates and not others would leave scenes in a state nobody had: all are discarded
// and every affected scene is synchronized from the host when play ends (ContentFailureRules.DrainOverflow).
resyncWhenRoutable.UnionWith(ContentFailureRules.DrainOverflow(heldPackets, result));
SetActionNotice("More scene updates arrived while you were playing than this editor holds. The affected scenes will be synchronized from the host when play ends.");
}
lastReceive = DateTime.UtcNow;
continue;
}
ProcessIncoming(result);
}
if (DateTime.UtcNow - lastReceive > TimeSpan.FromSeconds(15L))
{
Connected = false;
throw new IOException("Host heartbeat timed out");
}
if (!Connected)
{
return;
}
play?.Tick();
if (DateTime.UtcNow >= nextPresence)
{
// Polled every SendCadence.PresenceMinimum (33 ms), but only SENT when something changed or a refresh is due (SendCadence): the
// fast floor exists so remote markers can be drawn from interpolated samples, and an idle session still sends almost nothing.
nextPresence = DateTime.UtcNow.AddMilliseconds(SendCadence.PresenceMinimum.TotalMilliseconds);
SendPresence(force: false);
if (SendCadence.ShouldSendPing(DateTime.UtcNow - lastPingSent))
{
lastPingSent = DateTime.UtcNow;
Packet ping = new Packet
{
Kind = "ping"
};
AttachSceneDigest(ping);
wire.Send(ping);
ShowDriftNoticeOnce();
}
}
if (projectFiles != null)
{
projectFiles.Poll( p => wire.Send( p ), OpenFileScenes() );
ShowFileNotices();
if (!projectFiles.JoinReady)
{
// Scenes are subscribed only once the files they reference have arrived (the scan below subscribes them).
Status = projectFiles.Progress;
return;
}
if (Status.StartsWith( "Receiving project files:", StringComparison.Ordinal )) Status = "Connected • live";
}
if (Error != null || DateTime.UtcNow < nextScan)
{
return;
}
if (decision != PlaySpectateDecision.Normal)
{
// Playing runs a copy of the scene. Sampling it would report the game's own state changes as edits,
// so nothing is sampled until play stops; presence and the heartbeat above keep flowing.
return;
}
nextScan = DateTime.UtcNow.AddMilliseconds(50.0);
SceneEditorSession[] array = SceneEditorSession.All.Where((SceneEditorSession x) => !(x is GameEditorSession) && !x.IsPrefabSession).ToArray();
foreach (SceneEditorSession sceneEditorSession3 in array)
{
string text = SceneAdapter.PathOf(sceneEditorSession3);
if (sceneEditorSession3.IsPlaying && !PlayModeSourceCoexistence.CanSampleAuthoringSource(sceneEditorSession3, text, RuntimeLifecycle, CurrentRuntimeFor(sceneEditorSession3)))
{
// Another source is playing: do not sample runtime mutations as authoring edits.
continue;
}
if (text == null || !text.StartsWith("scenes/"))
{
continue;
}
if (!scenes.TryGetValue(text, out var value) || value.Session != sceneEditorSession3)
{
if (value?.Pending != null)
{
// Keep the durable outbox until the host answers; replacing the tracked scene here would orphan it.
NoticeOnce("reopened-pending:" + text, "\"" + text + "\" is synchronized again once the host answers the edit you sent before it was closed. Your edit remains in the recovery outbox.");
continue;
}
if (sceneEditorSession3.HasUnsavedChanges)
{
try
{
WriteSceneBackup(text, new Tracked { Session = sceneEditorSession3 });
SetActionNotice("Unsent changes to the reopened scene were backed up before host synchronization.");
}
catch (Exception error)
{
NoticeOnce("reopened-backup:" + text, "\"" + text + "\" is not synchronized because a recovery copy of its unsaved changes could not be written (" +
ApplyRecovery.ShortReason(error.Message) + "). Save the scene, then reopen it.");
continue;
}
}
value = new Tracked
{
Session = sceneEditorSession3,
Known = SceneAdapter.Capture(sceneEditorSession3.Scene),
Settings = SceneSettingsAdapter.Capture(sceneEditorSession3.Scene),
DiskStamp = SceneAdapter.DiskStamp(text)
};
scenes[text] = value;
// The settings seed the host's copy when this editor is the scene's first subscriber; otherwise the snapshot replaces them.
wire.Send(new Packet
{
Kind = "subscribe",
Scene = text,
Objects = value.Known.Values.ToList(),
Settings = value.Settings
});
}
else
{
if (!value.Ready || value.Pending != null || value.Recovery.Resyncing || value.Recovery.Paused)
{
continue;
}
DateTime dateTime = SceneAdapter.DiskStamp(text);
bool flag2 = dateTime == value.DiskStamp;
TimeSpan mismatchFor;
if (flag2)
{
mismatchFor = TimeSpan.Zero;
value.StampMismatchSince = default;
}
else if (value.StampMismatchSince == default(DateTime))
{
value.StampMismatchSince = DateTime.UtcNow;
mismatchFor = TimeSpan.Zero;
}
else
{
mismatchFor = DateTime.UtcNow - value.StampMismatchSince;
}
bool stampFlagged = SceneSaveRules.ShouldFailOnDiskStamp(flag2, mismatchFor);
string diskBackup = null;
string diskError = null;
if (stampFlagged)
{
// The file on disk changed outside a collaboration save. The open, live scene is what everyone shares; the disk
// version is copied to the recovery folder so nothing written there is lost.
try
{
diskBackup = CopyDiskVersion(text);
}
catch (Exception error)
{
diskError = error.Message;
}
}
DiskStampAction stampAction = ContentFailureRules.OnDiskStampMismatch(stampFlagged, diskBackup != null);
if (stampAction == DiskStampAction.KeepLiveScene)
{
value.DiskStamp = dateTime;
value.StampMismatchSince = default;
if (!value.DiskWarningShown)
{
value.DiskWarningShown = true;
SetActionNotice("The file of \"" + text + "\" was changed outside collaboration. The open scene stays as it is for everyone; the file's version was copied to " + diskBackup + ".");
}
}
else if (stampAction == DiskStampAction.PauseScene)
{
PauseScene(text, value, "Synchronization of \"" + text + "\" is paused because its file was changed outside collaboration and a copy of that file could not be written (" +
ApplyRecovery.ShortReason(diskError) + "). Your changes to this scene are kept locally. Reopen the scene to try again.");
continue;
}
// Scene settings are host-authoritative state like objects (SettingsMerge): a local change is sent whole, one packet per scene
// at a time, and the host merges it per top-level key. LocalChange compares float-tolerantly and ignores what the engine
// itself refused to take, so neither noise nor residue is ever sent.
// Reading the scene is the engine's work: if it throws for the scene as a whole (an object that throws alone is kept
// by CaptureTolerant), only this scene stops synchronizing.
JsonObject settingsChange;
Dictionary<string, ObjectState> dictionary;
try
{
settingsChange = value.SettingsPending == null ? SettingsMerge.LocalChange(value.Settings, value.SettingsApplied, value.SettingsEcho, SceneSettingsAdapter.Capture(sceneEditorSession3.Scene)) : null;
dictionary = CaptureTolerant(text, value);
}
catch (Exception error)
{
PauseScene(text, value, "Synchronization of \"" + text + "\" is paused because this editor could not read the scene (" + ApplyRecovery.ShortReason(error.Message) +
"). Your changes to this scene are kept locally. Reopen the scene to try again.");
continue;
}
if (settingsChange != null)
{
SendSettings(text, value, settingsChange);
}
List<string> scanNotices = new List<string> { SceneAdapter.TakeNotices() };
// An object that is still in the scene but no longer saved with it (for example marked Not Saved) is absent from
// the capture, so the diff below deletes it from the shared scene; the person is told once.
foreach (string item in value.Known.Keys.Except(dictionary.Keys))
{
GameObject gameObject = sceneEditorSession3.Scene.Directory.FindByGuid(Guid.Parse(item));
if (gameObject != null && gameObject.IsValid && !gameObject.IsDestroyed)
{
scanNotices.Add(StateRules.NoLongerSavedNotice(StateRules.NameOf(value.Known[item], item)));
}
}
// Sibling order is part of each object's state (SiblingOrder.Key), so a reorder is an ordinary change below.
List<Change> list = StateRules.Diff(value.Known, dictionary);
if (list.Count > 0)
{
lastLocalEdit = DateTime.UtcNow;
}
// A change that breaks a structural limit is held back instead of ending the session: the rest of the batch is
// sent, the held object stays as the session last confirmed it, and the reason is shown once.
List<HeldChange> held = new List<HeldChange>();
List<Change> sendable = StateRules.SendableChanges(value.Known, list, held);
NoteHeldChanges(text, held, scanNotices);
CountUnknownReservedKeys(sendable);
SetMergeNotice(ChangeRebase.JoinNotices(scanNotices.ToArray()));
bool flag3 = continuousEdits == 0 && !Editor.Application.MouseButtons.HasFlag(MouseButtons.Left);
if (sendable.Count == 0 && (!flag3 || value.Group.Count == 0))
{
continue;
}
SendEdit(text, value, sendable, flag3);
}
}
}
catch (Exception ex)
{
Fail(ex.Message, AutoRecoveryRules.Classify(ex));
}
}
private void ProcessIncoming(Packet result)
{
string kind = result.Kind;
if (HeldPacketBuffer.IsSceneChange(kind))
{
Dictionary<string, object> trackedByScene = ((IEnumerable<KeyValuePair<string, Tracked>>)scenes).ToDictionary((Func<KeyValuePair<string, Tracked>, string>)((KeyValuePair<string, Tracked> pair) => pair.Key), (Func<KeyValuePair<string, Tracked>, object>)((KeyValuePair<string, Tracked> pair) => pair.Value));
ScenePacketRouteResult scenePacketRouteResult = ScenePacketRouting.RouteTrackedPacket(result.Kind, result.Scene, trackedByScene, (object target) => ((Tracked)target).Session.IsPlaying, (object target) => SceneAdapter.PathOf(((Tracked)target).Session), RuntimeLifecycle, (object target) => CurrentRuntimeFor(((Tracked)target).Session));
if (!scenePacketRouteResult.Admitted)
{
Tracked routed = TrackedFor(result.Scene);
string canonicalKey = ScenePacketRouting.CanonicalSceneKey(result.Scene);
bool targetOpen = routed != null && routed.Session.Scene.IsValid() && SceneEditorSession.All.Contains(routed.Session) &&
ScenePacketRouting.CanonicalSceneKey(SceneAdapter.PathOf(routed.Session)) == canonicalKey;
switch (ContentFailureRules.ClassifyRouteFailure(canonicalKey != null, routed != null, targetOpen))
{
case RouteFailureAction.IgnoreClosed:
NoticeClosedPacket(result.Scene);
return;
case RouteFailureAction.ResyncWhenRoutable:
// The scene is open but cannot take host state now (it is playing). The update is not lost: the scene is
// synchronized from the host once it can.
resyncWhenRoutable.Add(result.Scene);
return;
default:
throw new InvalidOperationException((scenePacketRouteResult.Error ?? "A scene update named no valid scene.") +
" The host is not speaking this protocol. Leave the session and reconnect to the correct host.");
}
}
}
lastReceive = DateTime.UtcNow;
// Recorded here and not in Receive so that every packet that carries a revision is counted, whichever branch handles it.
if (result.Kind == "snapshot")
{
lastRevision = result.Revision;
}
else if ((result.Kind == "accepted" || result.Kind == SettingsMerge.PacketKind) && result.Revision > lastRevision)
{
lastRevision = result.Revision;
}
Receive(result);
}
// Play can be running in the editor's own game session or as a flag on the source scene session; either means
// the scene in front of the user is a copy being played, not the one being authored.
private static bool IsLocalPlaying(SceneEditorSession source)
{
return (source?.IsPlaying ?? false) || SceneEditorSession.All.Any((SceneEditorSession x) => x is GameEditorSession);
}
private static CollaborationConfig LoadConfig()
{
string error;
return CollaborationConfig.LoadFrom(Path.Combine(SceneAdapter.Root, "ProjectSettings", "collaboration.json"), out error);
}
private void UseConfig(CollaborationConfig config)
{
sessionConfig = config;
projectIdResolved = false;
if (orgDirectory == null || !string.Equals(orgDirectory.Org, config.Org, StringComparison.OrdinalIgnoreCase))
{
orgDirectory = new OrgDirectory(config);
}
}
private void RefreshReadiness()
{
nextReadiness = DateTime.UtcNow.AddSeconds(1.0);
CollaborationConfig collaborationConfig = LoadConfig();
UseConfig(collaborationConfig);
readinessOrgRevision = orgDirectory.Revision;
orgDirectory.Poll();
PollPackageFetch();
bool flag = LocalSteamIdentity.TryGet(out var identity, out var personaName, out var _);
string text = SceneAdapter.PathOf(SupportedActiveScene);
(bool, Version) tuple = InstalledLibrary(collaborationConfig);
Readiness = ReadinessRules.Evaluate(new ReadinessInput(flag, flag ? personaName : null, text != null, Project.Current?.IsPublished ?? false, packageReachable == true, tuple.Item1, tuple.Item2, collaborationConfig.LibraryVersion, flag && orgDirectory.Contains(SteamIdentityRules.Canonical(identity)), collaborationConfig, text));
}
private static (bool Installed, Version Version) InstalledLibrary(CollaborationConfig config)
{
foreach (LibraryProject item in LibrarySystem.All)
{
if (!LibraryInfo.IsThisLibrary(config?.LibraryIdent, item.Project?.Config?.Ident))
{
continue;
}
// Asset Party stamps its own build number into the installed copy's .version file (1.0.<build number>), so item.Version is a build id and can
// never be compared with the release version the handshake and the settings file use. The code running here is the installed copy.
Version.TryParse(LibraryInfo.LibraryVersion, out Version result);
return (Installed: true, Version: result);
}
return (Installed: false, Version: null);
}
private void PollPackageFetch()
{
if (packageFetch != null)
{
if (packageFetch.IsCompleted)
{
try
{
packageReachable = packageFetch.GetAwaiter().GetResult() != null;
}
catch (Exception)
{
packageReachable = false;
}
packageFetch = null;
nextPackageFetch = DateTime.UtcNow.AddSeconds(30.0);
}
return;
}
bool? flag = packageReachable;
if (flag.HasValue && DateTime.UtcNow < nextPackageFetch)
{
return;
}
if (string.IsNullOrWhiteSpace(Project.Current?.Config?.FullIdent))
{
packageReachable = false;
return;
}
try
{
packageFetch = Package.FetchAsync(Project.Current.Config.FullIdent, partial: false);
}
catch (Exception)
{
packageReachable = false;
nextPackageFetch = DateTime.UtcNow.AddSeconds(30.0);
}
}
private PeerFacts BuildPeerFacts(Packet hello)
{
string steamId = (SteamIdentityRules.TryParse(hello.SteamId, out var identity) ? SteamIdentityRules.Canonical(identity) : hello.SteamId);
bool isFriend = false;
if (SteamIdentityRules.TryParse(hello.SteamId, out var identity2))
{
try
{
isFriend = Steam.IsFriend(identity2.Value);
}
catch (Exception)
{
isFriend = false;
}
}
bool isHostSelf = SteamIdentityRules.TryParse(hello.SteamId, out var identity3) && LocalSteamIdentity.TryGet(out var identity4, out var _, out var _) && string.Equals(SteamIdentityRules.Canonical(identity3), SteamIdentityRules.Canonical(identity4), StringComparison.Ordinal);
bool isOrgMember = orgDirectory?.Contains(steamId) ?? false;
if (SoloMayVouch(hello))
{
// The reserved test identity is neither a Steam friend nor a member of the organization; in a
// disposable copy the host treats it as both so the remaining gates (project, baseline, library
// version, manifest, duplicate account) still run for real.
isFriend = true;
isOrgMember = true;
}
return new PeerFacts(steamId, IdentityVerified: false, isFriend, isOrgMember, hello.LibraryVersion, hello.ProjectManifest, hello.OrgIdent, isHostSelf);
}
private static SceneEditorSession AuthoringSource()
{
SceneEditorSession active = SceneEditorSession.Active;
if (1 == 0)
{
}
SceneEditorSession result;
if (active is GameEditorSession { Parent: var parent })
{
result = ((parent != null && !parent.IsPrefabSession) ? parent : null);
}
else
{
result = ((active == null || active.IsPrefabSession) ? null : active);
}
if (1 == 0)
{
}
return result;
}
private RuntimeSessionDescriptor CurrentRuntimeFor(SceneEditorSession source)
{
if (source == null || !source.IsPlaying)
{
return null;
}
GameEditorSession[] array = SceneEditorSession.All.OfType<GameEditorSession>().ToArray();
if (array.Length != 1)
{
return null;
}
GameEditorSession gameEditorSession = array[0];
if (gameEditorSession.Parent != source)
{
return null;
}
return RuntimeSceneAdapter.Describe(gameEditorSession);
}
// The reason last shown for each held object, per scene, so a change held on every scan is announced once and again only
// when its reason changes. An object that is sent again is forgotten.
private readonly Dictionary<string, Dictionary<string, string>> heldReasons = new Dictionary<string, Dictionary<string, string>>();
private void NoteHeldChanges(string scene, List<HeldChange> held, List<string> notices)
{
if (!heldReasons.TryGetValue(scene, out Dictionary<string, string> shown))
{
shown = new Dictionary<string, string>();
heldReasons[scene] = shown;
}
foreach (string id in shown.Keys.Except(held.Select((HeldChange x) => x.Change.Id)).ToList())
{
shown.Remove(id);
}
foreach (HeldChange change in held)
{
if (!shown.TryGetValue(change.Change.Id, out string reason) || reason != change.Reason)
{
shown[change.Change.Id] = change.Reason;
notices.Add(change.Reason);
GlobalGameNamespace.Log.Warning("Collaboration: " + change.Reason);
}
}
}
// Reserved (__) property names outside StateRules.KnownReservedKeys are allowed, because a newer engine may write new ones;
// each distinct name is counted and logged once so it can be added to the known set.
private readonly HashSet<string> unknownReservedKeys = new HashSet<string>(StringComparer.Ordinal);
/// <summary>How many distinct reserved (<c>__</c>) property names outside the known set this editor has sent.</summary>
public int UnknownReservedKeyCount => unknownReservedKeys.Count;
private void CountUnknownReservedKeys(List<Change> changes)
{
foreach (Change change in changes)
{
if ((object)change.After == null)
{
continue;
}
foreach (string key in StateRules.FindUnknownReservedKeys(change.After.Data))
{
if (unknownReservedKeys.Add(key))
{
GlobalGameNamespace.Log.Info("Collaboration: the scene uses the reserved property name \"" + key + "\", which is not in the known set; it is synchronised as is.");
}
}
}
}
private void SendEdit(string scene, Tracked tracked, List<Change> changes, bool final)
{
tracked.Pending = new Packet
{
Kind = "edit",
Scene = scene,
Peer = Peer,
Changes = changes,
Final = final
};
StorePending(tracked.Pending);
wire.Send(tracked.Pending);
Status = "Connected • edit awaiting acknowledgement";
}
// Sends the scene's settings as they are now, with the confirmed settings they were changed from. A change above the size bound is not
// sent at all: the host would refuse it, and the refusal is the same either way.
private void SendSettings(string scene, Tracked tracked, JsonObject after)
{
string sizeError = SettingsMerge.SizeError(after);
if (sizeError != null)
{
RefuseSettings(tracked, sizeError);
return;
}
tracked.SettingsPending = new Packet
{
Kind = SettingsMerge.PacketKind,
Scene = scene,
Peer = Peer,
SettingsBefore = SettingsMerge.Clone(tracked.Settings),
Settings = after
};
sentSettings.Add(tracked.SettingsPending.Id);
wire.Send(tracked.SettingsPending);
Status = "Connected • scene settings awaiting acknowledgement";
}
// A refused settings change is not retried: the scene takes back the host's settings, which this editor already holds as the confirmed
// settings (the same settings the next snapshot would carry), and the user is told why. Without the revert the scan would find the
// same change again and resend it every 50 ms.
private void RefuseSettings(Tracked tracked, string reason)
{
tracked.SettingsPending = null;
SetActionNotice(reason);
ShowToast("Scene settings change refused", reason, Theme.Primary, 15f);
if (tracked.Settings != null)
{
WriteSettings(tracked, tracked.Settings);
}
}
private bool HasUnsentSettings(Tracked tracked)
{
return tracked.Session.Scene.IsValid() && SettingsMerge.LocalChange(tracked.Settings, tracked.SettingsApplied, tracked.SettingsEcho, SceneSettingsAdapter.Capture(tracked.Session.Scene)) != null;
}
// Writes settings into the live scene when they differ from what it has, and records what was written and what the engine then reported.
private void WriteSettings(Tracked tracked, JsonObject settings)
{
if (!tracked.Session.Scene.IsValid() || !SceneEditorSession.All.Contains(tracked.Session))
{
return;
}
if (!StateRules.ValuesEqual(SceneSettingsAdapter.Capture(tracked.Session.Scene), settings))
{
List<string> skipped = SceneSettingsAdapter.Apply(tracked.Session.Scene, settings);
if (skipped.Count > 0)
{
SetActionNotice("Some scene settings could not be applied in this editor and were left unchanged: " + string.Join("; ", skipped));
}
}
tracked.SettingsApplied = SettingsMerge.Clone(settings);
tracked.SettingsEcho = SceneSettingsAdapter.Capture(tracked.Session.Scene);
}
// A settings broadcast from the host: the merged settings become the confirmed settings. A change of this editor's that the host has not
// seen yet (made after the last send, or not sent yet) is kept on top of them, key by key, exactly as an unsent object edit is kept on
// top of a remote change, and the next scan sends it.
private void ReceiveSettings(Tracked tracked, Packet p)
{
if (!seen.Add(p.Peer + ":" + p.Id))
{
// A receipt repeated after a resend was applied the first time; applying it again would undo whatever was accepted since.
if (tracked.SettingsPending?.Id == p.Id)
{
tracked.SettingsPending = null;
}
return;
}
if (p.Settings == null)
{
throw new InvalidOperationException("The host sent a scene settings change without settings.");
}
bool own = p.Peer == Peer;
if (own && tracked.SettingsPending?.Id == p.Id)
{
tracked.SettingsPending = null;
}
List<string> notices = new List<string> { p.Notice };
JsonObject target = p.Settings;
if (tracked.Session.Scene.IsValid() && SceneEditorSession.All.Contains(tracked.Session))
{
JsonObject local = SettingsMerge.LocalChange(tracked.Settings, tracked.SettingsApplied, tracked.SettingsEcho, SceneSettingsAdapter.Capture(tracked.Session.Scene));
if (local != null && tracked.Settings != null && SettingsMerge.TryMerge(p.Settings, tracked.Settings, local, out JsonObject kept, out List<string> clashed))
{
target = kept;
if (!own)
{
notices.Add(SettingsMerge.LocalKeptNotice(clashed));
}
}
}
tracked.Settings = SettingsMerge.Clone(p.Settings);
WriteSettings(tracked, target);
SetMergeNotice(ChangeRebase.JoinNotices(notices.ToArray()));
Status = "Connected • revision " + p.Revision;
}
private Tracked TrackedFor(string scene)
{
Dictionary<string, object> trackedByScene = scenes.ToDictionary((KeyValuePair<string, Tracked> pair) => pair.Key, (KeyValuePair<string, Tracked> pair) => (object)pair.Value);
return (scene != null && ScenePacketRouting.TryResolveTracked(scene, trackedByScene, out string _, out object target)) ? target as Tracked : null;
}
private string PendingPath(string id)
{
return Path.Combine(SceneAdapter.Root, ".collaboration", "outbox", Peer + "-" + id + ".json");
}
private void StorePending(Packet packet)
{
string text = PendingPath(packet.Id);
Directory.CreateDirectory(Path.GetDirectoryName(text));
byte[] bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { project, baseline, packet }));
using (FileStream fileStream = new FileStream(text + ".tmp", FileMode.Create, FileAccess.Write, FileShare.None))
{
fileStream.Write(bytes);
fileStream.Flush(flushToDisk: true);
}
File.Move(text + ".tmp", text, overwrite: true);
}
private void ForgetPending(Tracked tracked, string id)
{
if (!(tracked.Pending?.Id != id))
{
File.Delete(PendingPath(id));
tracked.Pending = null;
}
}
// Adds the scene digest to a ping for the active scene, when there is something trustworthy to report. The digest is of Known, the state the
// host last confirmed, and not of the live scene: a drag that has not been sent yet is an edit in progress, never drift. Nothing is reported
// while an edit awaits acknowledgement, while the scene is still joining, or during play, when the scene on screen is a copy.
private void AttachSceneDigest(Packet ping)
{
try
{
SceneEditorSession active = SceneEditorSession.Active;
string path = SceneAdapter.PathOf(active);
if (path == null || !scenes.TryGetValue(path, out var tracked) || tracked.Session != active)
{
return;
}
if (!tracked.Ready || tracked.Pending != null || tracked.SettingsPending != null || IsLocalPlaying(active))
{
return;
}
// The host's digest covers its settings for the scene too (SessionServer), so this one covers the confirmed settings.
string digest = SceneDigest.Compute(tracked.Known, tracked.Settings);
ping.Digest = digest;
ping.DigestRevision = lastRevision;
ping.DigestScene = path;
}
catch (Exception)
{
// The digest is a courtesy on top of the heartbeat. A scene it cannot fingerprint just goes unreported; the ping itself must still go out.
ping.Digest = null;
ping.DigestRevision = null;
ping.DigestScene = null;
}
}
// Tells the host's user once when a guest keeps drifting despite resyncs; nothing is shown again until the notice changes or clears.
private void ShowDriftNoticeOnce()
{
string notice = server?.DriftNotice;
if (notice == null)
{
driftNoticeShown = null;
return;
}
if (!string.Equals(notice, driftNoticeShown, StringComparison.Ordinal))
{
driftNoticeShown = notice;
SetActionNotice(notice);
ShowToast("A collaborator's scene keeps drifting", notice, Theme.Red, 15f);
}
}
// Writes the live text of a scene to .collaboration/recovery before the host's snapshot replaces it, and returns the file's path. The folder,
// the engine's own scene serialisation and the write wrapper are the ones Preserve uses; the name is RecoveryBackup's. A copy is never
// overwritten (two snapshots in one second get a counter), and a failed write throws the specific message so that the caller applies nothing.
private string WriteSceneBackup(string scene, Tracked tracked)
{
string path = null;
try
{
string text = tracked.Session.Scene.Serialize().ToJsonString(new JsonSerializerOptions
{
WriteIndented = true
});
string folder = Path.Combine(SceneAdapter.Root, ".collaboration", "recovery");
Directory.CreateDirectory(folder);
string name = RecoveryBackup.BuildFileName(scene, DateTime.UtcNow);
path = Path.Combine(folder, name);
for (int counter = 2; File.Exists(path) && counter < 100; counter++)
{
path = Path.Combine(folder, name.Substring(0, name.Length - ".scene".Length) + "-" + counter + ".scene");
}
string target = path;
RecoveryPathOutcome outcome = RecoveryPathRules.PublishAfterWrite(target, () =>
{
using FileStream stream = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None);
stream.Write(Encoding.UTF8.GetBytes(text));
stream.Flush(flushToDisk: true);
});
if (outcome.Succeeded)
{
return outcome.Published;
}
}
catch (Exception)
{
}
throw new InvalidOperationException(RecoveryBackup.BackupFailedMessage);
}
// ---- Content failures heal one scene (ApplyRecovery, ContentFailureRules, docs/FAILURE-MODES.md) ----------------------------------------------------
// Logged once per scene: a packet for a scene this editor does not have open (closed meanwhile). Reopening it subscribes again.
private void NoticeClosedPacket(string scene)
{
if (closedPacketNotices.Add(scene ?? ""))
{
GlobalGameNamespace.Log.Info("Collaboration: an update for \"" + scene + "\", which is not open in this editor, was ignored. Reopening the scene synchronizes it again.");
}
}
// A notice that would otherwise be set on every scan (50 ms) is shown once per key.
private void NoticeOnce(string key, string notice)
{
if (onceNotices.Add(key))
{
SetActionNotice(notice);
}
}
// Copies a scene's file on disk to the recovery folder under a name never used before and returns the copy's path. Nothing to
// copy (the file was removed) counts as preserved.
private string CopyDiskVersion(string scene)
{
string disk = Path.Combine(SceneAdapter.Root, "Assets", scene);
if (!File.Exists(disk))
{
return "(no file on disk)";
}
string folder = Path.Combine(SceneAdapter.Root, ".collaboration", "recovery");
Directory.CreateDirectory(folder);
string name = RecoveryBackup.BuildFileName(scene, DateTime.UtcNow);
string stem = name.EndsWith(".scene", StringComparison.Ordinal) ? name.Substring(0, name.Length - ".scene".Length) : name;
string path = Path.Combine(folder, stem + "-disk.scene");
for (int counter = 2; File.Exists(path) && counter < 100; counter++)
{
path = Path.Combine(folder, stem + "-disk-" + counter + ".scene");
}
File.Copy(disk, path, overwrite: false);
return path;
}
// Stops synchronizing one scene; the session, presence and every other scene continue. Reopening the scene creates a new tracked
// scene and so clears the pause.
private void PauseScene(string scene, Tracked tracked, string notice)
{
tracked.Recovery.Pause();
tracked.Ready = false;
tracked.HeldLocal = null;
SetActionNotice(notice);
ShowToast("Scene synchronization paused", notice, Theme.Primary, 15f);
}
private static string BackupFailedPauseNotice(string scene, string reason)
{
return "Synchronization of \"" + scene + "\" is paused because a recovery copy of the scene could not be written (" + ApplyRecovery.ShortReason(reason) +
"). Your changes to this scene are kept locally. Free disk space or check folder permissions, then reopen the scene to try again.";
}
// Asks the host for the scene again: the same subscribe a reconnect sends. The answer is an ordinary snapshot.
private void RequestSceneResync(string scene, Tracked tracked)
{
if (tracked.Recovery.Paused || !tracked.Session.Scene.IsValid())
{
return;
}
tracked.Ready = false;
wire.Send(new Packet
{
Kind = "subscribe",
Scene = scene,
Objects = tracked.Known.Values.ToList(),
Settings = tracked.Settings
});
}
// The live scene as a scan sees it: an object whose capture throws keeps its confirmed state, and so does every object this editor
// could not show. Neither is ever sent as this person's change or deletion.
private Dictionary<string, ObjectState> CaptureTolerant(string scene, Tracked tracked)
{
HashSet<string> failures = new HashSet<string>(StringComparer.Ordinal);
Dictionary<string, ObjectState> live = SceneAdapter.Capture(tracked.Session.Scene, tracked.Known, failures, limitGeometryRate: true);
foreach (string id in failures)
{
if (tracked.SkippedObjects.Add(id))
{
string name = tracked.Known.TryGetValue(id, out ObjectState known) ? StateRules.NameOf(known, id) : id;
SetActionNotice("\"" + name + "\" in \"" + scene + "\" could not be read by this editor. Its last synchronized state is kept for everyone and it is not sent as a change.");
}
}
return ApplyRecovery.PreserveSkipped(tracked.Known, live, tracked.SkippedObjects);
}
// The live scene as the editor shows it, for building an apply target: a skipped object keeps the version shown here, so applying
// the target never touches it again. (An object whose capture throws is listed with its confirmed state.)
private Dictionary<string, ObjectState> CaptureShown(string scene, Tracked tracked)
{
return SceneAdapter.Capture(tracked.Session.Scene, tracked.Known, new HashSet<string>(StringComparer.Ordinal));
}
private static Func<Dictionary<string, ObjectState>, Exception> TryApply(Tracked tracked)
{
return candidate =>
{
try
{
SceneAdapter.Apply(tracked.Session, candidate);
return null;
}
catch (Exception error)
{
return error;
}
};
}
// Applying a host packet threw in this editor. The decision is ApplyRecovery's: resync (the live scene is put back where it was when
// that is known, backed up, and the host is asked for the scene again), isolate (returned to the caller, which owns the snapshot), or
// pause. Never Fail: the cause is this editor's engine, not the session.
private ApplyRecoveryDecision RecoverScene(string scene, Tracked tracked, Exception error, Dictionary<string, ObjectState> before, bool snapshot)
{
ApplyRecoveryDecision decision = tracked.Recovery.Failed(DateTime.UtcNow, snapshot);
GlobalGameNamespace.Log.Warning("Collaboration: applying a host update to \"" + scene + "\" failed (" + decision + "): " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic("", error));
if (decision == ApplyRecoveryDecision.SkipObjects)
{
return decision;
}
if (before != null)
{
// A half-applied packet must not be read back as this person's edits. If the scene cannot be put back, the state from before
// the packet is what gets replayed after the snapshot.
if (TryApply(tracked)(before) != null)
{
tracked.HeldLocal ??= before;
}
}
string backup;
try
{
backup = WriteSceneBackup(scene, tracked);
}
catch (Exception backupError)
{
PauseScene(scene, tracked, BackupFailedPauseNotice(scene, backupError.Message));
return ApplyRecoveryDecision.Pause;
}
if (decision == ApplyRecoveryDecision.Pause)
{
PauseScene(scene, tracked, ApplyRecovery.PausedNotice(scene));
return decision;
}
RequestSceneResync(scene, tracked);
SetActionNotice("An update to \"" + scene + "\" could not be applied in your editor (" + ApplyRecovery.ShortReason(error?.Message) + "). A copy of your scene was saved to " +
backup + ". The scene is being synchronized again; your unsent changes are kept and sent afterwards.");
return decision;
}
// Applies a snapshot. Returns false when the scene did not take it (resync requested, paused); the caller then leaves Known as it is.
// While the scene resyncs, the edits held since the failure are replayed on top of the fresh state (ApplyRecovery.ReplayHeld) and the next
// scan sends them as ordinary edits.
private bool ApplyHostScene(string scene, Tracked tracked, Dictionary<string, ObjectState> target)
{
if (tracked.Recovery.Paused)
{
return false;
}
// A malformed host graph is not an engine limitation of this editor: it stays a protocol failure.
StateRules.ValidateGraph(target);
bool resyncing = tracked.Recovery.Resyncing;
// live: what the editor shows (the starting point of isolation and of a put-back); held: the person's edits as a scan reads them.
Dictionary<string, ObjectState> live = CaptureShown(scene, tracked);
Dictionary<string, ObjectState> heldBase = new Dictionary<string, ObjectState>(tracked.Known);
Dictionary<string, ObjectState> held = tracked.HeldLocal ?? CaptureTolerant(scene, tracked);
Dictionary<string, ObjectState> shown = target;
try
{
SceneAdapter.Apply(tracked.Session, target);
tracked.SkippedObjects.Clear();
}
catch (Exception error)
{
if (RecoverScene(scene, tracked, error, live, snapshot: true) != ApplyRecoveryDecision.SkipObjects)
{
return false;
}
// The fresh snapshot fails as well: show what this editor can, leave out what it cannot. The host's JSON of every skipped object
// becomes Known with the rest, so nothing is lost and nothing is sent back.
Dictionary<string, string> reasons = new Dictionary<string, string>(StringComparer.Ordinal);
shown = ApplyRecovery.IsolateObjects(target, live, TryApply(tracked), reasons);
Exception final = TryApply(tracked)(shown);
if (final != null)
{
PauseScene(scene, tracked, ApplyRecovery.PausedNotice(scene));
return false;
}
List<string> skippedNotices = new List<string>();
foreach (KeyValuePair<string, string> skipped in reasons)
{
ObjectState state = target.GetValueOrDefault(skipped.Key) ?? live.GetValueOrDefault(skipped.Key);
if (!tracked.SkippedObjects.Contains(skipped.Key))
{
skippedNotices.Add(ApplyRecovery.SkippedNotice(StateRules.NameOf(state, skipped.Key), skipped.Value));
}
}
tracked.SkippedObjects = new HashSet<string>(reasons.Keys, StringComparer.Ordinal);
string joined = ChangeRebase.JoinNotices(skippedNotices.ToArray());
if (joined != null)
{
SetActionNotice(joined);
ShowToast("Scene objects not shown", joined, Theme.Primary, 15f);
}
}
if (resyncing && !tracked.DiscardLocalOnResync)
{
List<string> notices = new List<string>();
Dictionary<string, ObjectState> replayed = ApplyRecovery.ReplayHeld(heldBase, held, target, shown, tracked.SkippedObjects, notices);
if (StateRules.Diff(shown, replayed).Count > 0)
{
Exception replayError = TryApply(tracked)(replayed);
if (replayError != null)
{
// The edits cannot be shown on top of the host's scene in this editor. They are in the recovery copy written before
// the snapshot; the scene follows the host.
TryApply(tracked)(shown);
notices.Add("Your unsent changes to \"" + scene + "\" could not be reapplied on top of the host's scene (" + ApplyRecovery.ShortReason(replayError.Message) +
"). They are kept in the recovery copy.");
}
}
SetMergeNotice(ChangeRebase.JoinNotices(notices.ToArray()));
}
tracked.HeldLocal = null;
tracked.DiscardLocalOnResync = false;
return true;
}
private void Receive(Packet p)
{
if (projectFiles != null && projectFiles.Handle( p, packet => wire.Send( packet ), OpenFileScenes() ))
{
return;
}
if (play != null && play.Handle(p))
{
return;
}
if (p.Kind == "welcome")
{
if (restoring && hostSession != null && hostSession != p.Session)
{
Fail("Host session ended or restarted. Preserve recovery data, sync saved scenes and start a new session.");
return;
}
hostSession = p.Session;
projectFiles?.Dispose();
projectFiles = null;
// Geometry is replaced by placeholders only for a host that advertises the channel that carries it; an older host would take the
// placeholder for the mesh.
blobs?.Release();
blobs = null;
BlobHub.Enabled = false;
if (p.Features != null && p.Features.Contains( BlobLimits.Feature ))
{
BlobHub.Store.AttachDisk( SceneAdapter.Root, BlobLimits.DiskCacheBytes );
blobs = new BlobExchange( BlobHub.Store, packet => wire.Send( packet ), () => DateTime.UtcNow );
BlobHub.Enabled = true;
}
// Only a host that advertises file synchronization is asked for its index; an older host gets the session as before.
if (p.Features != null && p.Features.Contains( FileSyncLimits.Feature ))
{
projectFiles = new FileSyncClient( Project.Current.RootDirectory.FullName ) { FileChanged = RefreshAsset };
projectFiles.Begin( packet => wire.Send( packet ) );
}
// Shared play is opt-in on both sides: this computer chose it, and the host says it relays it. Otherwise nothing of it exists here.
play?.Dispose();
play = SharedPlayPreference.Enabled && p.Features != null && p.Features.Contains(PlayRelayPolicy.Feature)
? new SharedPlayClient(packet => wire.Send(packet), Peer, SetActionNotice)
: null;
Connected = true;
Status = "Connected • live";
reconnectRecovery = false;
if (!restoring)
{
return;
}
{
foreach (KeyValuePair<string, Tracked> scene in scenes)
{
if (scene.Value.Pending != null)
{
wire.Send(scene.Value.Pending);
}
if (scene.Value.SettingsPending != null)
{
// The same id: a host that already applied it answers with the receipt it gave the first time.
wire.Send(scene.Value.SettingsPending);
}
if (scene.Value.Session.Scene.IsValid())
{
wire.Send(new Packet
{
Kind = "subscribe",
Scene = scene.Key,
Objects = scene.Value.Known.Values.ToList(),
Settings = scene.Value.Settings
});
}
}
return;
}
}
string canonical;
if (p.Kind == "fatal" || p.Kind == "reject")
{
string text = CollaborationTransportDiagnostics.BoundedStoredDiagnostic(p.Error);
if (p.Kind == "reject" && p.Id != null && sentSettings.Contains(p.Id))
{
// The host refused a settings change (for instance above the size bound). That is the host deciding, not a broken session.
Tracked refusedScene = TrackedFor(p.Scene);
if (refusedScene?.SettingsPending != null && refusedScene.SettingsPending.Id == p.Id)
{
RefuseSettings(refusedScene, "The host refused your change to the scene settings: " + text);
}
return;
}
if (p.Kind == "reject" && p.Scene == null)
{
Fail(text);
return;
}
if (restoring && p.Kind == "reject")
{
RecoveryNotice = CollaborationDiagnostics.Bounded("Rejected offline edit retained in " + RecoveryPath + ": " + text);
}
else if (p.Kind == "fatal")
{
Fail(text);
}
if (p.Kind == "reject")
{
Tracked tracked = TrackedFor(p.Scene);
if (tracked == null)
{
NoticeClosedPacket(p.Scene);
return;
}
if (tracked.Pending?.Id != p.Id)
{
return;
}
// The host refused this edit. That is the host deciding about content, not a broken session: the scene is copied to the
// recovery folder, the edit is dropped from the outbox, and the scene is synchronized from the host, replacing the refused
// state (re-sending it would only be refused again).
ForgetPending(tracked, p.Id);
if (!tracked.Session.Scene.IsValid() || tracked.Recovery.Paused)
{
return;
}
string refusedBackup;
try
{
refusedBackup = WriteSceneBackup(p.Scene, tracked);
}
catch (Exception error)
{
PauseScene(p.Scene, tracked, BackupFailedPauseNotice(p.Scene, error.Message));
return;
}
tracked.Recovery.Requested();
tracked.DiscardLocalOnResync = true;
RequestSceneResync(p.Scene, tracked);
string refused = "The host refused your change to \"" + p.Scene + "\" (" + text + "). The scene is synchronized from the host again; your version was saved to " + refusedBackup + ".";
SetActionNotice(refused);
ShowToast("Change refused by the host", refused, Theme.Primary, 15f);
}
}
else if (p.Kind == "presence")
{
if (p.Peer != Peer)
{
Roster[p.Peer] = p;
FeedPeerSmoothing(p);
}
}
else if (p.Kind == "left")
{
Roster.Remove(p.Peer);
peerSmoothing.Remove(p.Peer);
}
else
{
if (p.Kind == "pong")
{
return;
}
if (p.Scene == null)
{
throw new InvalidOperationException("The host sent a scene update without a scene. The host is not speaking this protocol. Leave the session and reconnect to the correct host.");
}
if (!ScenePacketRouting.TryResolveTracked(p.Scene, ((IEnumerable<KeyValuePair<string, Tracked>>)scenes).ToDictionary((Func<KeyValuePair<string, Tracked>, string>)((KeyValuePair<string, Tracked> pair) => pair.Key), (Func<KeyValuePair<string, Tracked>, object>)((KeyValuePair<string, Tracked> pair) => pair.Value)), out canonical, out object target2) || !(target2 is Tracked tracked2))
{
NoticeClosedPacket(p.Scene);
return;
}
if (Error != null)
{
return;
}
if (p.Kind == "snapshot")
{
Dictionary<string, ObjectState> dictionary = StateRules.Map(p.Objects);
if (!tracked2.Session.Scene.IsValid())
{
tracked2.Known = dictionary;
tracked2.Settings = SettingsMerge.Clone(p.Settings);
return;
}
if (tracked2.Recovery.Paused)
{
// A paused scene follows the host on paper only: nothing is applied or sent until it is reopened.
tracked2.Known = dictionary;
tracked2.Settings = SettingsMerge.Clone(p.Settings);
return;
}
string restoredNotice = null;
if (!restoring)
{
if (!tracked2.Recovery.Resyncing && tracked2.Ready && (tracked2.Pending != null || tracked2.SettingsPending != null))
{
// A resync that arrives while an edit awaits the host's answer would replace the scene under that edit, and the answer
// would then be applied to a scene it was not made against. Nothing is lost by waiting: the digest is not sent while
// an edit is pending, and the next ping after the answer finds the drift again if it is still there.
return;
}
// Unsent scene settings count as much as unsent objects: the copy is Scene.Serialize, which holds the scene's Properties
// (its settings) next to its GameObjects. Settings that cannot even be read are treated as unsent.
bool unsentSettings;
try
{
unsentSettings = tracked2.Settings != null && HasUnsentSettings(tracked2);
}
catch (Exception)
{
unsentSettings = true;
}
if (ApplyRecovery.NeedsBackup(StateRules.Diff(tracked2.Known, CaptureTolerant(p.Scene, tracked2)).Count > 0, unsentSettings))
{
// Unsent edits are about to be replaced by the host's scene: they are written to a file first, and if that fails
// the host's scene is not applied, because unsaved work is never replaced without a copy. Only this scene pauses.
try
{
restoredNotice = RecoveryBackup.BuildNotice(p.Scene, WriteSceneBackup(p.Scene, tracked2));
}
catch (Exception error)
{
PauseScene(p.Scene, tracked2, BackupFailedPauseNotice(p.Scene, error.Message));
return;
}
}
}
if (!ApplyHostScene(p.Scene, tracked2, dictionary)) return;
tracked2.Known = dictionary;
if (tracked2.Recovery.Resyncing && tracked2.Pending != null)
{
ForgetPending(tracked2, tracked2.Pending.Id);
tracked2.Group.Clear();
}
if (tracked2.Recovery.Resyncing) tracked2.SettingsPending = null;
tracked2.Recovery.Succeeded();
// The host's settings replace this editor's, as its objects do. A host holding none (its first subscriber predates settings
// sync) leaves the scene's settings alone, and with nothing confirmed no local change is ever sent for the scene.
tracked2.Settings = SettingsMerge.Clone(p.Settings);
tracked2.SettingsApplied = null;
tracked2.SettingsEcho = null;
if (p.Settings != null)
{
try
{
WriteSettings(tracked2, p.Settings);
}
catch (Exception error)
{
// The objects are in; the settings are not. The next snapshot writes them again.
tracked2.Recovery.Succeeded();
RecoverScene(p.Scene, tracked2, error, null, snapshot: false);
return;
}
}
tracked2.Ready = true;
tracked2.Session.UndoSystem.Initialize();
tracked2.Group.Clear();
tracked2.Undo.Clear();
MirrorNativeUndo(tracked2);
if (scenes.Values.All((Tracked x) => x.Ready || x.Recovery.Paused || !x.Session.Scene.IsValid()))
{
restoring = false;
}
if (restoredNotice != null)
{
SetActionNotice(restoredNotice);
ShowToast("Scene restored from the host", restoredNotice, Theme.Primary, 15f);
}
}
else if (p.Kind == SettingsMerge.PacketKind)
{
if (p.Settings == null)
{
// Protocol integrity: a settings packet always carries the merged settings.
throw new InvalidOperationException("The host sent a scene settings change without settings. The host is not speaking this protocol. Leave the session and reconnect to the correct host.");
}
if (!tracked2.Recovery.AppliesIncoming)
{
// Resyncing: the snapshot carries these settings. Paused: they are recorded, not applied.
seen.Add(p.Peer + ":" + p.Id);
if (tracked2.Recovery.Paused)
{
tracked2.Settings = SettingsMerge.Clone(p.Settings);
}
if (tracked2.SettingsPending?.Id == p.Id)
{
tracked2.SettingsPending = null;
}
return;
}
try
{
ReceiveSettings(tracked2, p);
}
catch (Exception error)
{
RecoverScene(p.Scene, tracked2, error, null, snapshot: false);
}
}
else
{
if (p.Kind != "accepted")
{
return;
}
if (tracked2.Recovery.Resyncing)
{
// The snapshot requested for this scene contains this change; the outbox is settled when it arrives.
return;
}
if (tracked2.Recovery.Paused)
{
// Recorded, not applied: the scene follows the host on paper until it is reopened.
if (seen.Add(p.Peer + ":" + p.Id))
{
ApplyRecovery.Record(tracked2.Known, p.Changes);
}
ForgetPending(tracked2, p.Id);
return;
}
if (!seen.Add(p.Peer + ":" + p.Id))
{
ForgetPending(tracked2, p.Id);
return;
}
AcceptedOperations++;
// Sentences for the user gathered while this packet is processed: the host's own notice first, then ours.
List<string> rebaseNotices = new List<string> { p.Notice };
if (p.Peer == Peer)
{
// What this editor sent for the packet being answered, when it is still the pending one. A receipt that outlived it
// (an outbox replay after a reconnect) has no sent edit to compare with.
Packet sentPacket = (tracked2.Pending != null && tracked2.Pending.Id == p.Id) ? tracked2.Pending : null;
bool liveScene = tracked2.Session.Scene.IsValid() && SceneEditorSession.All.Contains(tracked2.Session);
Dictionary<string, ObjectState> rebased = null;
bool rebaseChangesScene = false;
foreach (Change change in p.Changes)
{
if (liveScene)
{
ObjectState sentAfter = sentPacket?.Changes?.FirstOrDefault((Change x) => x.Id == change.Id)?.After;
if (ChangeRebase.NeedsOwnReceiptRebase(p.Merged, sentPacket != null, sentAfter, change.After))
{
// The host kept something other than what was sent. Take its result into the live scene before Known
// moves on, otherwise the next scan sees scene != Known and sends the old local state back over it.
rebased ??= SceneAdapter.Capture(tracked2.Session.Scene);
ObjectState local = rebased.GetValueOrDefault(change.Id);
RebaseResult result = ChangeRebase.RebaseOntoOwnReceipt(change.Id, sentAfter, local, change.After);
rebaseNotices.Add(result.Notice);
if (!StateRules.Equal(result.NewLocal, local))
{
rebaseChangesScene = true;
}
if ((object)result.NewLocal == null)
{
rebased.Remove(change.Id);
}
else
{
rebased[change.Id] = result.NewLocal;
}
}
}
if ((object)change.After == null)
{
tracked2.Known.Remove(change.Id);
}
else
{
tracked2.Known[change.Id] = change.After;
}
ObjectState before = (tracked2.Group.TryGetValue(change.Id, out var value) ? value.Before : change.Before);
tracked2.Group[change.Id] = new Change(change.Id, before, change.After);
}
if (rebaseChangesScene)
{
Exception applyError = TryApply(tracked2)(rebased);
if (applyError != null)
{
// Known now holds what the host kept; rebased is the person's scene on top of it, which is what is replayed.
ForgetPending(tracked2, p.Id);
tracked2.HeldLocal = rebased;
RecoverScene(p.Scene, tracked2, applyError, null, snapshot: false);
return;
}
tracked2.Session.UndoSystem.Initialize();
MirrorNativeUndo(tracked2);
}
ForgetPending(tracked2, p.Id);
if (p.Final)
{
if (!tracked2.UndoPending)
{
tracked2.Undo.Push(tracked2.Group.Values.ToList());
}
tracked2.Group.Clear();
tracked2.UndoPending = false;
if (tracked2.Session.Scene.IsValid())
{
tracked2.Session.UndoSystem.Initialize();
}
MirrorNativeUndo(tracked2);
}
}
else
{
bool flag = tracked2.Session.Scene.IsValid() && SceneEditorSession.All.Contains(tracked2.Session);
Dictionary<string, ObjectState> dictionary2 = (flag ? CaptureShown(p.Scene, tracked2) : new Dictionary<string, ObjectState>(tracked2.Known));
// What the editor showed and what was confirmed before this change, to put both back if the engine refuses it.
Dictionary<string, ObjectState> shownBefore = new Dictionary<string, ObjectState>(dictionary2);
Dictionary<string, ObjectState> knownPrior = new Dictionary<string, ObjectState>(StringComparer.Ordinal);
foreach (Change change2 in p.Changes)
{
if (!knownPrior.ContainsKey(change2.Id))
{
knownPrior[change2.Id] = tracked2.Known.GetValueOrDefault(change2.Id);
}
if ((object)change2.After != null && tracked2.SkippedObjects.Contains(change2.Id))
{
// An object this editor cannot show stays as it is shown here; the host's version is recorded, never applied
// (applying it is what failed) and never sent back.
tracked2.Known[change2.Id] = change2.After;
continue;
}
// The user may hold an edit the host has not seen (mid-drag, or not yet sent). It is kept on top of the host's
// change instead of failing the session; ChangeRebase never throws on divergence because the host is the authority.
// A skipped object has no edit of this person's: its local side is what the host last confirmed.
ObjectState localSide = tracked2.SkippedObjects.Contains(change2.Id) ? tracked2.Known.GetValueOrDefault(change2.Id) : dictionary2.GetValueOrDefault(change2.Id);
RebaseResult rebase = ChangeRebase.RebaseOntoRemoteChange(change2.Id, tracked2.Known.GetValueOrDefault(change2.Id), localSide, change2.Before, change2.After);
rebaseNotices.Add(rebase.Notice);
if ((object)rebase.NewLocal == null)
{
dictionary2.Remove(change2.Id);
}
else
{
dictionary2[change2.Id] = rebase.NewLocal;
}
if ((object)change2.After == null)
{
tracked2.Known.Remove(change2.Id);
}
else
{
tracked2.Known[change2.Id] = change2.After;
}
}
if (flag)
{
Exception applyError = TryApply(tracked2)(dictionary2);
if (applyError != null)
{
// Undo the bookkeeping too: the scene shows what it showed before, Known is what it was, and the snapshot
// brings this change in. The person's unsent edits are then exactly Known against the scene, and are replayed.
tracked2.Known = ApplyRecovery.WithPrior(tracked2.Known, knownPrior);
RecoverScene(p.Scene, tracked2, applyError, shownBefore, snapshot: false);
return;
}
foreach (Change applied in p.Changes)
{
if ((object)applied.After == null)
{
tracked2.SkippedObjects.Remove(applied.Id);
}
}
// The scene above is already applied. Only the history reset is coalesced (UndoResetScheduler): a drag of another
// person would otherwise reset the whole native undo history on every packet.
RequestUndoReset(tracked2);
}
}
SetMergeNotice(ChangeRebase.JoinNotices(rebaseNotices.ToArray()));
Status = "Connected • revision " + p.Revision;
}
}
}
public IDisposable BeginContinuousEdit()
{
if (!Connected || Error != null)
{
throw new InvalidOperationException("Connect before starting a continuous edit");
}
continuousEdits++;
return new EditScope(this);
}
public bool CanSave()
{
if (sourceBaseline != SceneAdapter.Baseline(includeScenes: false))
{
return false;
}
if (!Connected || Error != null)
{
return false;
}
string text = SceneAdapter.PathOf(SceneEditorSession.Active);
if (text == null || !scenes.TryGetValue(text, out var value))
{
return false;
}
if (!value.Ready || value.Pending != null || value.Group.Count > 0)
{
return false;
}
if (value.SettingsPending != null || HasUnsentSettings(value))
{
return false;
}
return StateRules.Diff(value.Known, SceneAdapter.Capture(value.Session.Scene)).Count == 0;
}
/// <summary>Why a save is not possible right now, in words, or null when it is possible. Mirrors <see cref="CanSave"/> condition for condition.</summary>
public string WhyCannotSave()
{
if (sourceBaseline != SceneAdapter.Baseline(includeScenes: false))
{
return "the project's source or assets changed since the session started";
}
if (!Connected || Error != null)
{
return "the session is not connected";
}
string text = SceneAdapter.PathOf(SceneEditorSession.Active);
if (text == null || !scenes.TryGetValue(text, out var value))
{
return "the active scene is not part of the session (active: " + (text ?? "none") + ")";
}
if (!value.Ready)
{
return "the host has not finished sending the scene";
}
if (value.Pending != null)
{
return "an edit is still waiting for the host to acknowledge it";
}
if (value.Group.Count > 0)
{
return "a multi-part edit is still being grouped";
}
if (value.SettingsPending != null)
{
return "a change to the scene settings is still waiting for the host to acknowledge it";
}
if (HasUnsentSettings(value))
{
return "a change to the scene settings has not been sent to the host yet";
}
List<Change> difference = StateRules.Diff(value.Known, SceneAdapter.Capture(value.Session.Scene));
if (difference.Count > 0)
{
Change first = difference[0];
string name = (first.After ?? first.Before)?.Data?["Name"]?.GetValue<string>() ?? first.Id;
return difference.Count + " object" + (difference.Count == 1 ? "" : "s") + " differ from the host's copy (first: " + name + ")";
}
return null;
}
private string SaveRefusal()
{
if (sourceBaseline != SceneAdapter.Baseline(includeScenes: false))
{
return "Source/assets changed during collaboration; end the session and sync in Git before saving a checkpoint";
}
return "Wait for all edits to be acknowledged before saving";
}
public void Save()
{
if (!CanSave())
{
throw new InvalidOperationException(SaveRefusal());
}
string text = SceneAdapter.PathOf(SceneEditorSession.Active);
if (text == null || !scenes.TryGetValue(text, out var value))
{
throw new InvalidOperationException(SaveRefusal());
}
saving = true;
try
{
SendPresence();
value.Session.Save(saveAs: false);
value.DiskStamp = SceneAdapter.DiskStamp(text);
server?.AllowSavedBaseline(SceneAdapter.Baseline());
}
finally
{
saving = false;
}
}
private void Checkpoint()
{
if (AutoSaveRules.CheckpointEligible(SceneEditorSession.Active?.HasUnsavedChanges ?? false, CanSave))
{
RunAutoSave();
}
}
private void RunAutoSave()
{
lastAutoSave = DateTime.UtcNow;
try
{
Save();
SetSaveNotice("Auto-saved the synchronized scene at " + DateTime.Now.ToString("HH:mm") + ".");
}
catch (Exception ex)
{
GlobalGameNamespace.Log.Error("Collaboration auto-save: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, ex));
SetSaveNotice("The automatic save did not run: " + ex.Message);
}
}
private void TickIdleAutoSave()
{
if (!AutoSaveIdle)
{
return;
}
DateTime utcNow = DateTime.UtcNow;
if (!(utcNow < nextAutoSaveCheck))
{
nextAutoSaveCheck = utcNow.AddSeconds(1.0);
if (AutoSaveRules.Eligible(new IdleSaveInput(Enabled: true, SceneEditorSession.Active?.HasUnsavedChanges ?? false, Roster.Values.Any((Packet x) => string.Equals(PresenceText(x.Presence, "state", 32), "editing", StringComparison.Ordinal)), SceneEditorSession.All.Any((SceneEditorSession x) => x.IsPlaying), lastLocalEdit, lastAutoSave, utcNow, CanSave)))
{
RunAutoSave();
}
}
}
private void TickAutoRecover()
{
if (Connected)
{
bool flag = recoverArmed;
recoverArmed = false;
recoverDeclined = false;
recoverAttempts = 0;
recoverNextAttempt = default;
recoverPromptShown = false;
if (flag)
{
ShowToast("Connection restored", "Automatic recovery reconnected this session.", Theme.Green, 6f);
}
}
else
{
if (Error == null)
{
return;
}
bool flag2 = TerminalTeardownError != null || RuntimeLifecycle.HasTerminalTeardownFailure;
if ((errorClass != SessionFailureClass.Transient) | flag2)
{
DeclineAutoRecover("This editor session remains fail-stop; automatic recovery cannot continue.");
}
else if (!AutoRecover)
{
if (!recoverPromptShown)
{
bool flag3 = AutoRecoveryRules.PromptOffered(AutoRecover, Error != null, errorClass, flag2, RecoveryOffered(), recoverPromptShown);
recoverPromptShown = true;
if (flag3)
{
ShowToast("Connection lost", "Select Recover & reconnect, or press CTRL+ALT+R, to restore this session.", Theme.Primary, 15f);
}
}
}
else
{
if (recoverDeclined || (recoverNextAttempt != default(DateTime) && DateTime.UtcNow < recoverNextAttempt))
{
return;
}
if (recoverAttempts >= 3)
{
DeclineAutoRecover("Three automatic attempts did not reconnect this session.");
return;
}
bool isHost = IsHost;
bool flag4 = !isHost && HasUnsavedTrackedChanges();
bool flag5 = reconnectQueued || reconnectTokenTask != null || connecting != null;
if (isHost)
{
DeclineAutoRecover("A hosted session is never recovered automatically.");
}
else
{
if (flag4 | flag5)
{
return;
}
string text = activeAddress ?? startAddress;
int num = ((activePort != 0) ? activePort : startPort);
if (string.IsNullOrWhiteSpace(text) || num <= 0)
{
DeclineAutoRecover("Automatic recovery has no endpoint to redial.");
return;
}
bool reconnectAccepted = RecoveryOffered();
if (AutoRecoveryRules.Eligible(new AutoRecoveryInput(AutoRecover, Error != null, errorClass, flag2, isHost, flag4, reconnectAccepted, flag5, recoverAttempts, DateTime.UtcNow, recoverNextAttempt)))
{
recoverAttempts++;
recoverArmed = true;
recoverNextAttempt = DateTime.UtcNow.Add(AutoRecoveryRules.Backoff(recoverAttempts - 1));
try
{
recoverArming = true;
Reconnect(text, num);
return;
}
catch (Exception ex)
{
recoverArmed = false;
recoverDeclined = true;
ShowToast("Automatic recovery stopped", ex.Message, Theme.Red, 6f);
GlobalGameNamespace.Log.Error("Collaboration auto-recover: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, ex));
return;
}
finally
{
recoverArming = false;
}
}
DeclineAutoRecover("Automatic recovery is not possible in this session's current state.");
}
}
}
}
private bool HasUnsavedTrackedChanges()
{
foreach (Tracked value in scenes.Values)
{
SceneEditorSession sceneEditorSession = value?.Session;
if (sceneEditorSession != null && sceneEditorSession.HasUnsavedChanges)
{
return true;
}
}
return false;
}
private void DeclineAutoRecover(string reason)
{
if (!recoverDeclined || recoverArmed)
{
bool flag = recoverArmed;
recoverArmed = false;
recoverDeclined = true;
if (flag)
{
ShowToast("Automatic recovery stopped", reason, Theme.Red, 6f);
}
}
}
private bool RecoveryOffered()
{
if (Error == null)
{
return false;
}
try
{
return ReconnectGuard().Accepted;
}
catch (Exception)
{
return false;
}
}
private static void ShowToast(string title, string subtitle, Color border, float seconds)
{
try
{
ToastWidget widget = new ToastWidget
{
Title = title,
Subtitle = subtitle,
BorderColor = border,
DrawTimer = false,
IsRunning = false,
FixedWidth = 360f
};
ToastManager.Remove(widget, seconds);
}
catch (Exception error)
{
GlobalGameNamespace.Log.Error("Collaboration notice: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));
}
}
public void Undo()
{
// A coalesced reset still pending would leave a stale snapshot under this undo; run it first.
FlushUndoReset();
string text = SceneAdapter.PathOf(SceneEditorSession.Active);
if (!Connected || Error != null || text == null || !scenes.TryGetValue(text, out var value) || !value.Ready || value.Pending != null || value.Undo.Count == 0)
{
throw new InvalidOperationException("No acknowledged local operation available to undo");
}
List<Change> list = (from c in value.Undo.Peek()
select new Change(c.Id, c.After, c.Before)).ToList();
Dictionary<string, ObjectState> dictionary = SceneAdapter.Capture(value.Session.Scene);
foreach (Change item in list)
{
if (!StateRules.Equal(dictionary.GetValueOrDefault(item.Id), item.Before))
{
throw new InvalidOperationException("Undo refused: object changed since your edit");
}
if ((object)item.After == null)
{
dictionary.Remove(item.Id);
}
else
{
dictionary[item.Id] = item.After;
}
}
SceneAdapter.Apply(value.Session, dictionary);
value.Undo.Pop();
value.UndoPending = true;
SendEdit(text, value, list, final: true);
}
// The coalesced half of "apply, capture, Initialize, MirrorNativeUndo". Runs the first request of a burst at once and holds the rest;
// Frame() releases what is held, and Undo/TryRedo flush it before they act (see UndoResetScheduler for the bounded stale window).
private void RequestUndoReset(Tracked tracked)
{
undoResetScenes.Add(tracked);
undoReset.Request(undoResetClock.Elapsed.TotalMilliseconds);
RunUndoResetIfDue();
}
private void RunUndoResetIfDue()
{
if (undoReset.Pending && undoReset.ShouldRunNow(undoResetClock.Elapsed.TotalMilliseconds))
{
RunPendingUndoResets();
}
}
private void FlushUndoReset()
{
if (undoReset.Flush())
{
RunPendingUndoResets();
}
}
private void RunPendingUndoResets()
{
Tracked[] pendingScenes = undoResetScenes.ToArray();
undoResetScenes.Clear();
foreach (Tracked tracked in pendingScenes)
{
try
{
// A scene that was closed or replaced since the request has nothing to reset.
if (tracked.Session != null && tracked.Session.Scene.IsValid() && scenes.ContainsValue(tracked))
{
tracked.Session.UndoSystem.Initialize();
MirrorNativeUndo(tracked);
}
}
catch (Exception error)
{
GlobalGameNamespace.Log.Error("Collaboration undo reset: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));
}
}
}
private void MirrorNativeUndo(Tracked tracked)
{
try
{
if (tracked == null || tracked.Session == null || tracked.Undo.Count == 0)
{
return;
}
UndoSystem undoSystem = tracked.Session.UndoSystem;
if (undoSystem == null)
{
return;
}
List<Change> changes = tracked.Undo.Peek();
if (changes != null && changes.Count != 0)
{
undoSystem.Insert("Collaboration Undo", () =>
{
TryUndo();
}, () =>
{
TryRedo(tracked, changes);
});
}
}
catch (Exception error)
{
GlobalGameNamespace.Log.Error("Collaboration undo mirror: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));
}
}
private void ClearNativeUndoMirrors()
{
try
{
UndoSystem undoSystem = SceneEditorSession.Active?.UndoSystem;
if (IsNativeUndoMirror(undoSystem))
{
undoSystem.Initialize();
}
foreach (Tracked value in scenes.Values)
{
UndoSystem undoSystem2 = value?.Session?.UndoSystem;
if (IsNativeUndoMirror(undoSystem2))
{
undoSystem2.Initialize();
}
}
}
catch (Exception error)
{
GlobalGameNamespace.Log.Error("Collaboration undo mirror: " + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));
}
}
private static bool IsNativeUndoMirror(UndoSystem undoSystem)
{
if (undoSystem == null)
{
return false;
}
UndoSystem.Entry result;
return undoSystem.Back.TryPeek(out result) && string.Equals(result?.Name, "Collaboration Undo", StringComparison.Ordinal);
}
private bool TryUndo()
{
try
{
Undo();
UndoNotice = null;
undoNoticeExpires = default;
return true;
}
catch (Exception ex)
{
SetUndoNotice(ex.Message);
return false;
}
}
private bool TryRedo(Tracked tracked, List<Change> changes)
{
try
{
FlushUndoReset();
if (changes == null || changes.Count == 0 || !Connected || Error != null || tracked == null || tracked.Session == null || !tracked.Session.Scene.IsValid())
{
SetUndoNotice("Redo is not available for this collaboration operation.");
return false;
}
string text = SceneAdapter.PathOf(tracked.Session);
if (text == null || !scenes.TryGetValue(text, out var value) || value != tracked || !value.Ready || value.Pending != null || value.UndoPending)
{
SetUndoNotice("Redo is not available for this collaboration operation.");
return false;
}
Dictionary<string, ObjectState> dictionary = SceneAdapter.Capture(value.Session.Scene);
foreach (Change change in changes)
{
if (StateRules.Equal(dictionary.GetValueOrDefault(change.Id), change.Before))
{
continue;
}
if (StateRules.Equal(dictionary.GetValueOrDefault(change.Id), change.After))
{
SetUndoNotice("Nothing to redo: this collaboration operation is still applied.");
return false;
}
SetUndoNotice("Redo refused: the scene changed since your edit.");
return false;
}
foreach (Change change2 in changes)
{
if ((object)change2.After == null)
{
dictionary.Remove(change2.Id);
}
else
{
dictionary[change2.Id] = change2.After;
}
}
SceneAdapter.Apply(value.Session, dictionary);
value.Undo.Push(changes);
value.UndoPending = true;
SendEdit(text, value, changes, final: true);
UndoNotice = null;
undoNoticeExpires = default;
return true;
}
catch (Exception ex)
{
SetUndoNotice(ex.Message);
return false;
}
}
private void SetUndoNotice(string text)
{
string text2 = BoundedDiagnostic(text);
if (!string.Equals(UndoNotice, text2, StringComparison.Ordinal) || !(DateTime.UtcNow < undoNoticeExpires))
{
UndoNotice = text2;
undoNoticeExpires = DateTime.UtcNow.AddSeconds(8.0);
}
}
private void SetActionNotice(string text)
{
string text2 = BoundedDiagnostic(text);
if (!string.Equals(ActionNotice, text2, StringComparison.Ordinal) || !(DateTime.UtcNow < actionNoticeExpires))
{
ActionNotice = text2;
actionNoticeExpires = DateTime.UtcNow.AddSeconds(8.0);
}
}
// Presence smoothing: one PeerSmoothing per remote peer, fed from every received presence and read by DrawGizmos. presenceClock is the
// monotonic clock this session stamps its own presence with (SentMs) and also reads as "local now" for arrival times; the two uses never
// meet, because a receiver only compares one sender's stamps with its own arrival times.
private readonly SessionClock presenceClock = new SessionClock();
private readonly Dictionary<string, PeerSmoothing> peerSmoothing = new Dictionary<string, PeerSmoothing>();
/// <summary>
/// Feeds a received presence to its peer's smoother. A presence without a usable SentMs (a peer that predates the field) or without a camera
/// is not fed, and that peer keeps being drawn from its raw packets.
/// </summary>
private void FeedPeerSmoothing(Packet p)
{
PrunePeerSmoothing();
if (!PeerSmoothing.CanSmooth(p.SentMs) || !TryReadPresenceCamera(p.Presence, out var position, out var rotation, out var _))
{
return;
}
if (!peerSmoothing.TryGetValue(p.Peer, out var smoothing))
{
smoothing = new PeerSmoothing();
peerSmoothing[p.Peer] = smoothing;
}
smoothing.OnPresence(p.SentMs, presenceClock.NowMs, PresenceMarker.PoseOf(position, rotation));
}
/// <summary>Forgets smoothers of peers that are no longer in the roster (the roster is cleared on leave and reconnect).</summary>
private void PrunePeerSmoothing()
{
if (peerSmoothing.Count <= Roster.Count)
{
return;
}
foreach (string gone in peerSmoothing.Keys.Where((string key) => !Roster.ContainsKey(key)).ToList())
{
peerSmoothing.Remove(gone);
}
}
private void SendPresence(bool force = true)
{
if (wire == null)
{
return;
}
SceneEditorSession active = SceneEditorSession.Active;
SceneViewportWidget sceneViewportWidget = SceneViewWidget.Current?.LastSelectedViewportWidget;
JsonObject jsonObject = new JsonObject();
SceneEditorSession source = AuthoringSource();
GameEditorSession game = SceneEditorSession.All.OfType<GameEditorSession>().FirstOrDefault();
bool playing = IsLocalPlaying(source);
CameraComponent gameCamera = null;
try
{
gameCamera = game?.Scene?.Camera;
}
catch (Exception)
{
gameCamera = null;
}
if (playing && gameCamera != null && gameCamera.IsValid())
{
// While playing, the collaborator's marker follows the game camera, so someone still in the editor
// sees where the player is and which way they look. The scene view is not what they are looking at.
jsonObject["position"] = JsonSerializer.SerializeToNode(gameCamera.WorldPosition);
jsonObject["rotation"] = JsonSerializer.SerializeToNode(gameCamera.WorldRotation);
float gameFov = gameCamera.FieldOfView;
if (gameFov > 0f && gameFov < 180f)
{
jsonObject["fov"] = gameFov;
}
}
else if (!playing && sceneViewportWidget != null && SceneViewWidget.Current.Session == active)
{
jsonObject["position"] = JsonSerializer.SerializeToNode(sceneViewportWidget.State.CameraPosition);
jsonObject["rotation"] = JsonSerializer.SerializeToNode(sceneViewportWidget.State.CameraRotation);
float? num = sceneViewportWidget.Renderer?.Camera?.FieldOfView;
if (num.HasValue)
{
float valueOrDefault = num.GetValueOrDefault();
if (valueOrDefault > 0f && valueOrDefault < 180f)
{
jsonObject["fov"] = num.Value;
}
}
}
GameObject[] array = (playing ? null : active?.Selection.OfType<GameObject>().ToArray()) ?? Array.Empty<GameObject>();
jsonObject["selection"] = JsonSerializer.SerializeToNode(array.Select((GameObject x) => x.Id.ToString()).ToArray());
// The left mouse button is held down for gameplay too, so it only means "editing" outside play.
bool flag = !playing && (continuousEdits > 0 || Editor.Application.MouseButtons.HasFlag(MouseButtons.Left));
JsonObject jsonObject2 = jsonObject;
string text = PlaySpectateRules.PresenceState(saving, playing, flag);
jsonObject2["state"] = text;
if (flag && array.Length != 0)
{
jsonObject["editing"] = array[0].Id.ToString();
}
string text2 = BoundPresenceText(EditorToolManager.CurrentModeName, 40);
if (text2 != null)
{
jsonObject["tool"] = text2;
}
string presenceScene = SceneAdapter.PathOf(playing && source != null ? source : active);
// What "changed" means: the same presence body and scene as the last one sent. The state word and selection are part of the body, so a
// selection or a status change is sent at once; only an identical packet is held back.
string fingerprint = presenceScene + "|" + jsonObject.ToJsonString();
TimeSpan sinceLast = DateTime.UtcNow - lastPresenceSent;
bool changed = !string.Equals(fingerprint, lastPresenceFingerprint, StringComparison.Ordinal);
if (!force && !SendCadence.ShouldSendPresence(changed, sinceLast, text == "editing"))
{
return;
}
lastPresenceFingerprint = fingerprint;
lastPresenceSent = DateTime.UtcNow;
wire.Send(new Packet
{
Kind = "presence",
Scene = presenceScene,
Peer = Peer,
Presence = jsonObject,
SentMs = presenceClock.NowMs
});
}
private static string BoundPresenceText(string value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
string text = value.Trim();
return (text.Length <= maxLength) ? text : text.Substring(0, maxLength);
}
private static string PresenceText(JsonObject presence, string key, int maxLength)
{
try
{
string value;
return (presence?[key] is JsonValue jsonValue && jsonValue.TryGetValue<string>(out value)) ? BoundPresenceText(value, maxLength) : null;
}
catch (Exception)
{
return null;
}
}
private static string[] ReadSelection(JsonObject presence)
{
try
{
return presence?["selection"]?.Deserialize<string[]>() ?? Array.Empty<string>();
}
catch (Exception)
{
return Array.Empty<string>();
}
}
private static bool TryReadPresenceCamera(JsonObject presence, out Vector3 position, out Rotation rotation, out float? fieldOfView)
{
position = default;
rotation = default;
fieldOfView = null;
if (presence?["position"] == null || presence["rotation"] == null)
{
return false;
}
try
{
position = presence["position"].Deserialize<Vector3>();
rotation = presence["rotation"].Deserialize<Rotation>();
if (presence["fov"] is JsonValue jsonValue && jsonValue.TryGetValue<float>(out var value) && value > 0f && value < 180f)
{
fieldOfView = value;
}
}
catch (Exception)
{
return false;
}
return true;
}
private static Frustum PeerFrustum(Vector3 position, Rotation rotation, float fieldOfView)
{
float num = MathF.Tan(fieldOfView * 0.5f * ((float)Math.PI / 180f)) * 8f;
float num2 = num * 1.7777778f;
Vector3 vector = position + rotation.Forward * 8f;
Vector3 vector2 = rotation.Up * num;
Vector3 vector3 = rotation.Right * num2;
return Frustum.FromCorners(new Ray(position, (vector + vector2 - vector3 - position).Normal), new Ray(position, (vector + vector2 + vector3 - position).Normal), new Ray(position, (vector - vector2 + vector3 - position).Normal), new Ray(position, (vector - vector2 - vector3 - position).Normal), 8f, 300f);
}
private static string StatusLineFor(Packet peer, string localScene, string edited, GameObject[] objects)
{
if (!string.Equals(peer.Scene, localScene, StringComparison.Ordinal))
{
return "scene: " + (BoundPresenceText(peer.Scene, 96) ?? "no saved scene");
}
string text = PresenceText(peer.Presence, "state", 32) ?? "idle";
if (string.Equals(text, "playing", StringComparison.Ordinal))
{
return PlaySpectateRules.StatusText(text);
}
if (!string.Equals(text, "editing", StringComparison.Ordinal) || edited == null || objects == null)
{
return text;
}
GameObject gameObject = Array.Find(objects, (GameObject x) => string.Equals(x.Id.ToString(), edited, StringComparison.Ordinal));
string text2 = ((gameObject == null) ? null : BoundPresenceText(gameObject.Name, 64));
return (text2 == null) ? text : (text2 + " · " + text);
}
public void DrawGizmos(Scene scene)
{
DrawnMarkers = 0;
DrawnSelections = 0;
string text = scene.Source?.ResourcePath?.ToLowerInvariant();
if (!Connected || text == null)
{
return;
}
AvatarCache.Poll();
DateTime utcNow = DateTime.UtcNow;
Vector3 vector = Gizmo.Camera?.Position ?? Vector3.Zero;
GameObject[] array = null;
foreach (KeyValuePair<string, Packet> item in Roster)
{
Packet value = item.Value;
if (value.Presence == null)
{
continue;
}
bool flag = string.Equals(value.Scene, text, StringComparison.Ordinal);
Color color = DockTheme.PeerColor(value.Peer);
string text2 = BoundPresenceText(value.Name, 64) ?? "(unknown name)";
string[] array2 = ReadSelection(value.Presence);
string text3 = PresenceText(value.Presence, "editing", 64);
if ((array2.Length != 0 || (flag && text3 != null)) && array == null)
{
array = SceneAdapter.Walk(scene).ToArray();
}
using (Gizmo.Scope("collaboration-" + value.Peer, Transform.Zero))
{
bool flag2 = (utcNow < focusedUntil && string.Equals(focusedPeer, item.Key, StringComparison.Ordinal)) || Gizmo.IsHovered;
if (TryReadPresenceCamera(value.Presence, out var position, out var rotation, out var fieldOfView))
{
// The smoothed pose replaces the raw packet once that peer's smoother is ready; until then (and for a peer that sends no SentMs)
// the raw value above is drawn, so a marker may pop in once at warm-up but never stutters.
if (peerSmoothing.TryGetValue(item.Key, out var smoothing))
{
PresenceMarker.ApplySmoothing(smoothing, presenceClock.NowMs, ref position, ref rotation);
}
float length = (position - vector).Length;
bool flag3 = length <= PresenceStyle.FarDistance;
string presenceState = PresenceText(value.Presence, "state", 32) ?? "idle";
PresenceMarker.Draw(new PresenceMarker.Subject
{
Position = position,
ViewRotation = rotation,
FieldOfView = fieldOfView,
Name = text2,
State = presenceState,
StatusLine = flag3 ? StatusLineFor(value, text, text3, array) : null,
Avatar = AvatarCache.GetTexture(value.SteamId),
SameScene = flag,
Emphasized = flag2,
ShowFrustum = fieldOfView.HasValue & flag,
Frustum = (fieldOfView.HasValue & flag) ? PeerFrustum(position, rotation, fieldOfView.Value) : null
});
DrawnMarkers++;
}
if (array2.Length == 0 || array == null)
{
continue;
}
GameObject[] array3 = array;
foreach (GameObject gameObject in array3)
{
if (array2.Contains(gameObject.Id.ToString()))
{
Gizmo.Draw.Color = color;
Gizmo.Draw.LineThickness = (flag2 ? 2f : 1f);
Gizmo.Draw.LineBBox(gameObject.GetBounds());
Gizmo.Draw.Text(BoundPresenceText(gameObject.Name, 64) ?? "(unnamed)", new Transform(gameObject.WorldPosition + Vector3.Up * 16f), "Inter");
Texture texture2 = AvatarCache.GetTexture(value.SteamId);
if (texture2 != null)
{
Gizmo.Draw.Color = Color.White;
Gizmo.Draw.Sprite(gameObject.WorldPosition + Vector3.Up * 24f, 3.0, texture2, worldspace: true);
}
DrawnSelections++;
}
}
Gizmo.Draw.LineThickness = 1f;
}
}
}
public void FocusCamera(string peer)
{
FocusNotice = null;
focusNoticeExpires = default;
if (string.IsNullOrWhiteSpace(peer) || !Roster.TryGetValue(peer, out var value))
{
RefuseFocus("That collaborator is no longer connected.");
return;
}
if (!string.Equals(value.Scene, SceneAdapter.PathOf(SceneEditorSession.Active), StringComparison.Ordinal))
{
RefuseFocus("That collaborator is in " + (BoundPresenceText(value.Scene, 96) ?? "no saved scene") + ". Open the scene first.");
return;
}
SceneViewportWidget sceneViewportWidget = SceneViewWidget.Current?.LastSelectedViewportWidget;
if (sceneViewportWidget?.State == null)
{
RefuseFocus("No scene view is open to move.");
return;
}
if (value.Presence?["position"] == null || value.Presence["rotation"] == null)
{
RefuseFocus("That collaborator has not reported a camera position yet.");
return;
}
Vector3 cameraPosition;
Rotation cameraRotation;
try
{
cameraPosition = value.Presence["position"].Deserialize<Vector3>();
cameraRotation = value.Presence["rotation"].Deserialize<Rotation>();
}
catch (Exception)
{
RefuseFocus("That collaborator reported a camera position this build cannot read.");
return;
}
sceneViewportWidget.State.CameraPosition = cameraPosition;
sceneViewportWidget.State.CameraRotation = cameraRotation;
focusedPeer = peer;
focusedUntil = DateTime.UtcNow.AddSeconds(1.0);
}
private void RefuseFocus(string reason)
{
FocusNotice = BoundedDiagnostic(reason);
focusNoticeExpires = DateTime.UtcNow.AddSeconds(5.0);
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace TeamCreate;
public sealed record DiscoveredHost(string EndPoint, int Port, string HostName, string SecretHex, string Manifest8, DateTime LastSeenUtc);
public sealed class LanDiscovery : IDisposable
{
public static readonly TimeSpan QueryInterval = TimeSpan.FromMilliseconds(1500L);
public static readonly TimeSpan HostExpiry = TimeSpan.FromSeconds(5L);
public const int MaxDatagramsPerPoll = 32;
public const int MaxTrackedHosts = 16;
private readonly string projectIdHash8;
private readonly Dictionary<string, DiscoveredHost> hosts = new Dictionary<string, DiscoveredHost>(StringComparer.Ordinal);
private UdpClient socket;
private bool boundToRendezvousPort;
private bool hosting;
private bool disposed;
private int sessionPort;
private string hostSecretHex;
private string hostManifest8;
private DateTime nextQueryUtc;
private string failure;
public IReadOnlyList<DiscoveredHost> Hosts
{
get
{
Prune(hosts, DateTime.UtcNow, HostExpiry);
return new List<DiscoveredHost>(hosts.Values);
}
}
public string Failure => failure;
public bool CanHost => socket != null && boundToRendezvousPort && !disposed;
public LanDiscovery(string projectIdHash8)
{
DiscoveryProtocol.BuildQuery(projectIdHash8);
this.projectIdHash8 = projectIdHash8;
Open();
}
public void Poll()
{
if (socket == null || disposed)
{
return;
}
try
{
DateTime now = DateTime.UtcNow;
Drain(32, () => socket.Client.Poll(0, SelectMode.SelectRead) && socket.Available > 0, () =>
{
IPEndPoint remoteEP = null;
byte[] item = socket.Receive(ref remoteEP);
return (Bytes: item, From: remoteEP);
}, ((byte[] Bytes, IPEndPoint From) datagram) =>
{
Handle(datagram.Bytes, datagram.From, now);
});
Prune(hosts, now, HostExpiry);
}
catch (Exception error)
{
failure = CollaborationDiagnostics.Bounded(Describe(error));
}
}
public void SendQuery()
{
if (socket == null || disposed || hosting)
{
return;
}
DateTime utcNow = DateTime.UtcNow;
if (utcNow < nextQueryUtc)
{
return;
}
nextQueryUtc = utcNow + QueryInterval;
try
{
byte[] bytes = Encoding.ASCII.GetBytes(DiscoveryProtocol.BuildQuery(projectIdHash8));
socket.Send(bytes, bytes.Length, new IPEndPoint(IPAddress.Broadcast, 27846));
}
catch (Exception error)
{
failure = CollaborationDiagnostics.Bounded(Describe(error));
}
}
public void StartHost(int sessionPort, string secretHex, string manifest8)
{
DiscoveryProtocol.BuildReply(projectIdHash8, sessionPort, "host", secretHex, manifest8);
if (socket == null || disposed)
{
failure = CollaborationDiagnostics.Bounded("LAN discovery is unavailable: the rendezvous socket could not be opened on this machine.");
}
else if (!boundToRendezvousPort)
{
failure = CollaborationDiagnostics.Bounded($"LAN discovery cannot answer on this machine: UDP port {27846} is already in use.");
}
else
{
this.sessionPort = sessionPort;
hostSecretHex = secretHex;
hostManifest8 = manifest8;
hosts.Clear();
hosting = true;
}
}
public void StopHost()
{
hosting = false;
}
private void Open()
{
try
{
UdpClient udpClient;
try
{
udpClient = new UdpClient(new IPEndPoint(IPAddress.Any, 27846));
}
catch (SocketException)
{
udpClient = new UdpClient(new IPEndPoint(IPAddress.Any, 0));
}
udpClient.EnableBroadcast = true;
socket = udpClient;
boundToRendezvousPort = ((IPEndPoint)udpClient.Client.LocalEndPoint).Port == 27846;
}
catch (Exception error)
{
socket = null;
failure = CollaborationDiagnostics.Bounded(Describe(error));
}
}
private void Handle(byte[] bytes, IPEndPoint from, DateTime now)
{
if (bytes == null || from == null || bytes.Length > 512)
{
return;
}
string text = Encoding.ASCII.GetString(bytes);
DiscoveryReply reply;
if (DiscoveryProtocol.TryParseQuery(text, out var text2))
{
if (hosting && text2 == projectIdHash8)
{
Reply(from);
}
}
else if (DiscoveryProtocol.TryParseReply(text, out reply) && !(reply.ProjectIdHash8 != projectIdHash8) && !hosting)
{
string text3 = from.Address.ToString();
if (hosts.Count >= 16 && !hosts.ContainsKey(text3))
{
DropOldest();
}
hosts[text3] = new DiscoveredHost(text3, reply.SessionPort, reply.HostName, reply.SecretHex, reply.Manifest8, now);
}
}
private void Reply(IPEndPoint to)
{
byte[] bytes = Encoding.ASCII.GetBytes(DiscoveryProtocol.BuildReply(projectIdHash8, sessionPort, Environment.MachineName, hostSecretHex, hostManifest8));
socket.Send(bytes, bytes.Length, to);
}
private void DropOldest()
{
DiscoveredHost discoveredHost = null;
foreach (KeyValuePair<string, DiscoveredHost> host in hosts)
{
if ((object)discoveredHost == null || host.Value.LastSeenUtc < discoveredHost.LastSeenUtc)
{
discoveredHost = host.Value;
}
}
if ((object)discoveredHost != null)
{
hosts.Remove(discoveredHost.EndPoint);
}
}
internal static int Drain<T>(int cap, Func<bool> available, Func<T> receive, Action<T> handle)
{
int i;
for (i = 0; i < cap; i++)
{
if (!available())
{
break;
}
handle(receive());
}
return i;
}
internal static int Prune(Dictionary<string, DiscoveredHost> hosts, DateTime nowUtc, TimeSpan maxAge)
{
if (hosts == null || hosts.Count == 0)
{
return 0;
}
List<string> list = null;
foreach (KeyValuePair<string, DiscoveredHost> host in hosts)
{
if (nowUtc - host.Value.LastSeenUtc > maxAge)
{
(list ?? (list = new List<string>())).Add(host.Key);
}
}
if (list == null)
{
return 0;
}
foreach (string item in list)
{
hosts.Remove(item);
}
return list.Count;
}
private static string Describe(Exception error)
{
return CollaborationDiagnostics.DescribeExternalFailure("LAN discovery: ", error);
}
public void Dispose()
{
if (!disposed)
{
disposed = true;
hosting = false;
try
{
socket?.Close();
}
catch
{
}
socket = null;
hosts.Clear();
}
}
}
Editor
library
using System;
namespace TeamCreate;
public sealed record IdleSaveInput(bool Enabled, bool HasUnsavedChanges, bool PeerEditing, bool InPlayMode, DateTime LastLocalEdit, DateTime LastAutoSave, DateTime Now, Func<bool> CanSave);
public static class AutoSaveRules
{
public const int MinIntervalSeconds = 30;
public const int IdleSeconds = 30;
public static bool CheckpointEligible(bool hasUnsavedChanges, Func<bool> canSave)
{
if (!hasUnsavedChanges)
{
return false;
}
return canSave?.Invoke() ?? false;
}
public static bool Eligible(IdleSaveInput input)
{
if (!input.Enabled)
{
return false;
}
if (input.PeerEditing || input.InPlayMode)
{
return false;
}
if (input.LastLocalEdit == default(DateTime))
{
return false;
}
if (input.Now - input.LastLocalEdit < TimeSpan.FromSeconds(30L))
{
return false;
}
if (input.LastAutoSave != default(DateTime) && input.Now - input.LastAutoSave < TimeSpan.FromSeconds(30L))
{
return false;
}
return CheckpointEligible(input.HasUnsavedChanges, input.CanSave);
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
namespace TeamCreate;
/// <summary>
/// The fixed limits of mapping-tool geometry synchronization (docs/BLOB-SYNC.md). They are part of the protocol: a receiver refuses what
/// exceeds them, so a sender that respected them is never refused for size.
/// </summary>
public static class BlobLimits
{
/// <summary>Advertised by the host on welcome. A guest sends geometry placeholders only to a host that holds this feature.</summary>
public const string Feature = "blobs-1";
/// <summary>The largest geometry (decoded bytes) that is synchronized. The largest mesh in the installed sample scenes is 576 KB.</summary>
public const int MaxBlobBytes = 32 * 1024 * 1024;
/// <summary>Geometry that has started to arrive and is not complete yet, summed over one endpoint (every member on a host).</summary>
public const long MaxPendingBytes = 64L * 1024 * 1024;
/// <summary>Scene packets one endpoint holds while the geometry they name is on its way.</summary>
public const int MaxPendingPackets = 32;
/// <summary>How long a held packet waits for its geometry before it is discarded and the scene is synchronized again.</summary>
public const int WaitSeconds = 60;
/// <summary>Hashes one blob-need may name; the remainder of a longer request is ignored.</summary>
public const int MaxHashesPerRequest = 64;
/// <summary>
/// Shortest text value that is moved out of an object's state (4 KiB). Below this the inline text costs less than the placeholder
/// round trip, and nothing in the engine writes geometry that small except an empty mesh.
/// </summary>
public const int MinExternalChars = 4096;
/// <summary>One new version of an object's geometry per this many milliseconds, so dragging a vertex does not flood the session.</summary>
public const int VersionIntervalMs = 500;
public const long MemoryCacheBytes = 256L * 1024 * 1024;
public const long DiskCacheBytes = 256L * 1024 * 1024;
}
/// <summary>
/// Content-addressed geometry bytes, keyed by the lowercase hex SHA-256 of the bytes. A bounded in-memory cache (least recently used out)
/// sits in front of an optional disk cache in <c>.collaboration/blobs/<sha256></c>. Nothing is stored under a key its bytes do not
/// hash to, and a key that is not exactly 64 lowercase hex digits never reaches the file system, so a hostile peer cannot name a path.
/// </summary>
public sealed class BlobStore
{
private sealed class Entry
{
public byte[] Bytes;
public long Touched;
}
private readonly object gate = new();
private readonly Dictionary<string, Entry> memory = new( StringComparer.Ordinal );
private readonly long memoryCap;
private long diskCap;
private string directory;
private long clock;
public BlobStore( string root, long memoryCap, long diskCap )
{
this.memoryCap = memoryCap;
if ( root != null ) AttachDisk( root, diskCap );
}
public int MemoryCount
{
get { lock ( gate ) return memory.Count; }
}
public long MemoryBytes { get; private set; }
public static string HashOf( byte[] bytes )
{
return Convert.ToHexString( SHA256.HashData( bytes ) ).ToLowerInvariant();
}
public static bool ValidHash( string hash )
{
if ( hash == null || hash.Length != 64 ) return false;
foreach ( char c in hash )
{
if ( !( ( c >= '0' && c <= '9' ) || ( c >= 'a' && c <= 'f' ) ) ) return false;
}
return true;
}
/// <summary>
/// Starts keeping blobs on disk under <paramref name="root"/>, and writes the ones already in memory, so an object captured before the
/// session began hosting can still be served to a late joiner. A folder that cannot be created leaves the store memory-only.
/// </summary>
public void AttachDisk( string root, long cap )
{
string folder;
try
{
folder = Path.Combine( root, ".collaboration", "blobs" );
Directory.CreateDirectory( folder );
}
catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException )
{
return;
}
lock ( gate )
{
directory = folder;
diskCap = cap;
foreach ( KeyValuePair<string, Entry> pair in memory ) WriteFile( pair.Key, pair.Value.Bytes );
}
}
/// <summary>Stores <paramref name="bytes"/> under their own hash. Null (and <paramref name="error"/>) when they are empty or over the limit.</summary>
public string Put( byte[] bytes, out string error )
{
error = null;
if ( bytes == null || bytes.Length == 0 )
{
error = "Empty geometry is not stored.";
return null;
}
if ( bytes.Length > BlobLimits.MaxBlobBytes )
{
error = TooLarge();
return null;
}
string hash = HashOf( bytes );
return Store( hash, bytes ) ? hash : null;
}
/// <summary>Stores <paramref name="bytes"/> only if they hash to <paramref name="hash"/>; anything else is refused and nothing is written.</summary>
public bool Put( string hash, byte[] bytes, out string error )
{
error = null;
if ( !ValidHash( hash ) )
{
error = "The geometry was offered under a name that is not a SHA-256 digest.";
return false;
}
if ( bytes == null || bytes.Length == 0 )
{
error = "Empty geometry is not stored.";
return false;
}
if ( bytes.Length > BlobLimits.MaxBlobBytes )
{
error = TooLarge();
return false;
}
if ( HashOf( bytes ) != hash )
{
error = "The geometry did not match the digest it was offered under and was discarded.";
return false;
}
return Store( hash, bytes );
}
/// <summary>
/// Records bytes the caller has just hashed itself (<see cref="BlobRules.Externalize"/> does, to name them), without hashing again. This runs for every
/// mesh on every scan, so a blob already in memory is only touched: no copy, no disk check.
/// </summary>
public void AddTrusted( string hash, byte[] bytes )
{
lock ( gate )
{
if ( memory.TryGetValue( hash, out Entry known ) )
{
known.Touched = ++clock;
return;
}
if ( bytes.Length > 0 && bytes.Length <= BlobLimits.MaxBlobBytes ) Store( hash, bytes );
}
}
public static string TooLarge() => "The geometry is larger than the 32 MiB collaboration limit and was not synchronized.";
public bool Has( string hash )
{
if ( !ValidHash( hash ) ) return false;
lock ( gate )
{
if ( memory.ContainsKey( hash ) ) return true;
return directory != null && File.Exists( Path.Combine( directory, hash ) );
}
}
public bool TryGet( string hash, out byte[] bytes )
{
bytes = null;
if ( !ValidHash( hash ) ) return false;
lock ( gate )
{
if ( memory.TryGetValue( hash, out Entry entry ) )
{
entry.Touched = ++clock;
bytes = entry.Bytes;
return true;
}
if ( directory == null ) return false;
string file = Path.Combine( directory, hash );
byte[] read;
try
{
if ( !File.Exists( file ) ) return false;
read = File.ReadAllBytes( file );
}
catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
{
return false;
}
// A file that does not hash to its own name (a torn write, a disk fault, a person's edit) is never served: it is removed so
// the geometry is fetched again.
if ( read.Length == 0 || read.Length > BlobLimits.MaxBlobBytes || HashOf( read ) != hash )
{
TryDelete( file );
return false;
}
Remember( hash, read );
try
{
File.SetLastWriteTimeUtc( file, DateTime.UtcNow );
}
catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
{
}
bytes = read;
return true;
}
}
/// <summary>Deletes the oldest files until the disk cache is within its cap. Called by the session from time to time, never on the capture path.</summary>
public void Prune()
{
lock ( gate )
{
if ( directory == null || diskCap <= 0 ) return;
List<FileInfo> files;
try
{
files = new DirectoryInfo( directory ).GetFiles().Where( x => ValidHash( x.Name ) ).ToList();
}
catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
{
return;
}
long total = files.Sum( x => x.Length );
foreach ( FileInfo file in files.OrderBy( x => x.LastWriteTimeUtc ) )
{
if ( total <= diskCap ) break;
total -= file.Length;
TryDelete( file.FullName );
}
}
}
private bool Store( string hash, byte[] bytes )
{
lock ( gate )
{
if ( memory.TryGetValue( hash, out Entry known ) )
{
known.Touched = ++clock;
if ( directory != null && !File.Exists( Path.Combine( directory, hash ) ) ) WriteFile( hash, bytes );
return true;
}
Remember( hash, bytes );
if ( directory != null ) WriteFile( hash, bytes );
return true;
}
}
// Adds to the memory cache and evicts the least recently used entries until it fits. The entry just added is never the one evicted.
private void Remember( string hash, byte[] bytes )
{
memory[hash] = new Entry { Bytes = bytes, Touched = ++clock };
MemoryBytes += bytes.Length;
while ( MemoryBytes > memoryCap && memory.Count > 1 )
{
string oldest = null;
long oldestTouch = long.MaxValue;
foreach ( KeyValuePair<string, Entry> pair in memory )
{
if ( pair.Key != hash && pair.Value.Touched < oldestTouch )
{
oldest = pair.Key;
oldestTouch = pair.Value.Touched;
}
}
if ( oldest == null ) break;
MemoryBytes -= memory[oldest].Bytes.Length;
memory.Remove( oldest );
}
}
// Written to a temporary name and renamed, so a reader never sees half a file. A failure leaves the memory copy as the only one.
private void WriteFile( string hash, byte[] bytes )
{
string target = Path.Combine( directory, hash );
string temporary = Path.Combine( directory, hash + "." + Guid.NewGuid().ToString( "N" ) + ".tmp" );
try
{
File.WriteAllBytes( temporary, bytes );
File.Move( temporary, target, true );
}
catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
{
TryDelete( temporary );
}
}
private static void TryDelete( string path )
{
try
{
File.Delete( path );
}
catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
{
}
}
}
Editor
library
using System.Collections.Generic;
using System.Text;
namespace TeamCreate;
/// <summary>
/// The words under the Host and Join buttons. One sentence says what to do next; further lines appear only when something needs the person's attention (a relay that is
/// missing, a router that did not answer, a scene that is playing). The host's public address is deliberately not shown: the invite code already carries it, and it
/// is the one detail a person pastes into a chat or a screenshot without meaning to.
/// </summary>
public static class ConnectCopy
{
public const string Idle = "Host a session, or paste a friend's code to join.";
public const string ShareCode = "Share this code with your friend. They paste it under Join.";
public const string ReadyToJoin = "Ready to join with this code. The local network is tried first, then the relay in the code, then the host's address.";
public static string Build(bool connected, bool isHost, bool hosting, string route, string typedCode, string relayLine, string portMappingFailure, string playNotice, string soloNotice, string discoveryFailure)
{
var lines = new List<string>();
if (connected && !isHost && !string.IsNullOrWhiteSpace(route))
{
lines.Add(route);
}
else if (!connected && !string.IsNullOrWhiteSpace(typedCode))
{
lines.Add(ReadyToJoin);
}
else if (isHost && hosting)
{
lines.Add(ShareCode);
Add(lines, relayLine);
Add(lines, portMappingFailure);
}
else
{
lines.Add(Idle);
}
Add(lines, playNotice);
Add(lines, soloNotice);
Add(lines, discoveryFailure);
return string.Join("\n", lines);
}
private static void Add(List<string> lines, string line)
{
if (!string.IsNullOrWhiteSpace(line))
{
lines.Add(line.Trim());
}
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace TeamCreate;
public static class RuntimeStateRules
{
public const int MaxRuntimeRunIdLength = 128;
private static readonly HashSet<string> GameObjectFields = new HashSet<string>(StringComparer.Ordinal) { "__guid", "Name", "Position", "Rotation", "Scale", "Enabled", "Tags", "Components" };
private static readonly Dictionary<string, HashSet<string>> ComponentFields = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal)
{
["Sandbox.ModelRenderer"] = new HashSet<string>(StringComparer.Ordinal) { "Tint" },
["Sandbox.CameraComponent"] = new HashSet<string>(StringComparer.Ordinal) { "FieldOfView", "Orthographic", "OrthographicHeight", "ZNear", "ZFar", "BackgroundColor" },
["Sandbox.DirectionalLight"] = new HashSet<string>(StringComparer.Ordinal) { "LightColor", "SkyColor", "Shadows" },
["Sandbox.PointLight"] = new HashSet<string>(StringComparer.Ordinal) { "LightColor", "Radius", "Shadows" },
["Sandbox.BoxCollider"] = new HashSet<string>(StringComparer.Ordinal) { "Center", "Scale", "IsTrigger" }
};
public static bool IsValidRunId(string runId)
{
return !string.IsNullOrWhiteSpace(runId) && runId.Length <= 128 && runId == runId.Trim() && !runId.Any(char.IsControl);
}
public static void ValidateObject(ObjectState state)
{
if ((object)state == null)
{
throw InvalidIdentity("object is missing");
}
if (!string.IsNullOrEmpty(state.Parent) && !TryGetCanonicalId(state.Parent, out var canonicalId))
{
throw InvalidIdentity("parent id " + state.Parent + " is not a GUID");
}
if (!TryGetCanonicalId(state.Id, out var canonicalId2) || state.Data == null || !TryGetString(state.Data, "__guid", out var value) || !TryGetCanonicalId(value, out var canonicalId3) || !string.Equals(canonicalId2, canonicalId3, StringComparison.Ordinal))
{
throw InvalidIdentity(state.Id + " does not match its serialized __guid");
}
if (state.Data.Any((KeyValuePair<string, JsonNode> field) => field.Key.Contains("Prefab", StringComparison.OrdinalIgnoreCase)))
{
throw NotAllowlisted("GameObject field '" + FindPrefabField(state.Data) + "' is prefab data");
}
bool value2;
foreach (KeyValuePair<string, JsonNode> datum in state.Data)
{
if (!GameObjectFields.Contains(datum.Key))
{
throw NotAllowlisted("GameObject field '" + datum.Key + "'");
}
switch (datum.Key)
{
case "__guid":
case "Name":
case "Components":
value2 = true;
break;
default:
value2 = false;
break;
}
if (!value2 && !IsScalar(datum.Value))
{
throw NotAllowlisted("GameObject field '" + datum.Key + "' is not a scalar JsonValue");
}
}
if (!TryGetString(state.Data, "Name", out canonicalId))
{
throw InvalidShape("GameObject field 'Name' must be a non-null JSON string");
}
if (state.Data.TryGetPropertyValue("Enabled", out JsonNode jsonNode) && (!(jsonNode is JsonValue jsonValue) || !jsonValue.TryGetValue<bool>(out value2)))
{
throw InvalidShape("GameObject field 'Enabled' must be a JSON boolean");
}
if (state.Data.TryGetPropertyValue("Tags", out JsonNode _) && !TryGetString(state.Data, "Tags", out canonicalId))
{
throw InvalidShape("GameObject field 'Tags' must be a JSON string");
}
if (!(state.Data["Components"] is JsonArray jsonArray))
{
throw InvalidShape("GameObject field 'Components' must be a JSON array");
}
HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal);
foreach (JsonNode item in jsonArray)
{
if (!(item is JsonObject jsonObject))
{
throw InvalidShape("Components entries must be JSON objects");
}
if (!TryGetString(jsonObject, "__type", out var value3) || string.IsNullOrWhiteSpace(value3))
{
throw InvalidShape("Component field '__type' must be a non-empty JSON string");
}
if (!ComponentFields.TryGetValue(value3, out var value4))
{
throw new InvalidOperationException("Runtime component type is not allowlisted: " + value3);
}
if (!TryGetString(jsonObject, "__guid", out var value5) || !TryGetCanonicalId(value5, out var canonicalId4))
{
throw InvalidIdentity("component " + value3 + " has an invalid __guid");
}
if (string.Equals(canonicalId4, canonicalId2, StringComparison.Ordinal))
{
throw InvalidIdentity("component " + value3 + " reuses its object __guid " + value5);
}
if (!hashSet.Add(canonicalId4))
{
throw InvalidIdentity("component " + value3 + " duplicates __guid " + value5);
}
foreach (KeyValuePair<string, JsonNode> item2 in jsonObject)
{
canonicalId = item2.Key;
if ((!(canonicalId == "__type") && !(canonicalId == "__guid")) || 1 == 0)
{
if (item2.Key.Contains("Prefab", StringComparison.OrdinalIgnoreCase))
{
throw NotAllowlisted($"component field '{value3}.{item2.Key}' is prefab data");
}
if (!value4.Contains(item2.Key))
{
throw NotAllowlisted($"component field '{value3}.{item2.Key}'");
}
if (!IsScalar(item2.Value))
{
throw NotAllowlisted($"component field '{value3}.{item2.Key}' is not a scalar JsonValue");
}
}
}
}
}
public static void ValidateSnapshot(string scenePath, IReadOnlyDictionary<string, ObjectState> objects)
{
if (!IsSafeScenePath(scenePath))
{
throw new InvalidOperationException("Runtime scene path is invalid: " + (scenePath ?? "<null>"));
}
if (objects == null)
{
throw InvalidIdentity("snapshot is missing");
}
HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal);
HashSet<string> hashSet2 = new HashSet<string>(StringComparer.Ordinal);
Dictionary<string, ObjectState> dictionary = new Dictionary<string, ObjectState>(StringComparer.Ordinal);
foreach (KeyValuePair<string, ObjectState> @object in objects)
{
if ((object)@object.Value == null)
{
throw InvalidIdentity("snapshot contains missing object id " + @object.Key);
}
ValidateObject(@object.Value);
if (!TryGetCanonicalId(@object.Key, out var canonicalId) || !TryGetCanonicalId(@object.Value.Id, out var canonicalId2) || !string.Equals(canonicalId, canonicalId2, StringComparison.Ordinal))
{
throw InvalidIdentity("snapshot key " + @object.Key + " does not match object id " + @object.Value.Id);
}
if (!hashSet.Add(canonicalId))
{
throw InvalidIdentity("snapshot contains duplicate object id " + @object.Key);
}
if (!hashSet2.Add(canonicalId2))
{
throw InvalidIdentity("snapshot contains duplicate object id " + @object.Key);
}
if (!dictionary.TryAdd(canonicalId2, @object.Value))
{
throw InvalidIdentity("snapshot contains duplicate object id " + @object.Key);
}
JsonArray jsonArray = @object.Value.Data["Components"].AsArray();
foreach (JsonNode item in jsonArray)
{
string value = item["__guid"].GetValue<string>();
if (!TryGetCanonicalId(value, out var canonicalId3))
{
throw InvalidIdentity("component " + value + " is not a GUID");
}
if (!hashSet2.Add(canonicalId3))
{
throw InvalidIdentity("snapshot contains duplicate object/component id " + value);
}
}
}
foreach (KeyValuePair<string, ObjectState> object2 in objects)
{
if (!TryGetCanonicalId(object2.Key, out var canonicalId4))
{
throw InvalidIdentity("snapshot key " + object2.Key + " is not a GUID");
}
HashSet<string> hashSet3 = new HashSet<string>(StringComparer.Ordinal) { canonicalId4 };
string parent = object2.Value.Parent;
while (!string.IsNullOrEmpty(parent))
{
if (!TryGetCanonicalId(parent, out var canonicalId5) || !dictionary.TryGetValue(canonicalId5, out var value2))
{
throw new InvalidOperationException("Runtime parent does not exist: " + parent);
}
if ((object)value2 == null)
{
throw InvalidIdentity("Runtime parent is missing: " + parent);
}
if (!hashSet3.Add(canonicalId5))
{
throw new InvalidOperationException("Runtime parent cycle detected at " + parent);
}
parent = value2.Parent;
}
}
}
public static bool IsAllowedComponentType(string type)
{
return ComponentFields.ContainsKey(type);
}
public static bool IsAllowedComponentField(string type, string field)
{
HashSet<string> value;
return ComponentFields.TryGetValue(type, out value) && value.Contains(field);
}
public static bool IsSafeScenePath(string scenePath)
{
if (string.IsNullOrWhiteSpace(scenePath) || !scenePath.StartsWith("scenes/", StringComparison.Ordinal) || scenePath.Contains('\\') || scenePath.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(scenePath) || scenePath.Any(char.IsControl))
{
return false;
}
string[] source = scenePath.Split('/');
if (source.Any((string segment) =>
{
bool flag = segment.Length == 0;
bool flag2 = flag;
if (!flag2)
{
bool flag3 = ((segment == "." || segment == "..") ? true : false);
flag2 = flag3;
}
return flag2;
}))
{
return false;
}
return scenePath.EndsWith(".scene", StringComparison.OrdinalIgnoreCase);
}
private static bool TryGetCanonicalId(string value, out string canonicalId)
{
if (Guid.TryParse(value, out var result))
{
canonicalId = result.ToString("D");
return true;
}
canonicalId = null;
return false;
}
private static string FindPrefabField(JsonObject data)
{
return data.First((KeyValuePair<string, JsonNode> field) => field.Key.Contains("Prefab", StringComparison.OrdinalIgnoreCase)).Key;
}
private static bool IsScalar(JsonNode node)
{
JsonValue jsonValue = node as JsonValue;
bool flag = jsonValue != null;
bool flag2 = flag;
if (flag2)
{
JsonValueKind valueKind = jsonValue.GetValueKind();
bool flag3 = valueKind - 3 <= JsonValueKind.Number;
flag2 = flag3;
}
return flag2;
}
private static bool TryGetString(JsonObject data, string key, out string value)
{
value = null;
JsonNode jsonNode;
return data.TryGetPropertyValue(key, out jsonNode) && jsonNode is JsonValue jsonValue && jsonValue.TryGetValue<string>(out value);
}
private static InvalidOperationException NotAllowlisted(string detail)
{
return new InvalidOperationException("Runtime state is not allowlisted: " + detail);
}
private static InvalidOperationException InvalidIdentity(string detail)
{
return new InvalidOperationException("Runtime object identity is invalid: " + detail);
}
private static InvalidOperationException InvalidShape(string detail)
{
return new InvalidOperationException("Runtime state shape is invalid: " + detail);
}
}
public enum RuntimeSessionKind
{
Source,
Game
}
Editor
library
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Editor;
using Sandbox;
namespace TeamCreate;
public sealed class ReadinessCard : DockCard
{
private sealed class ItemBody : Widget
{
private readonly ReadinessItem item;
public ItemBody(Widget parent, ReadinessItem item)
: base(parent)
{
this.item = item;
FixedHeight = DockTheme.RowHeight;
ToolTip = item.Detail ?? string.Empty;
OnPaintOverride = PaintItem;
}
private bool PaintItem()
{
Rect localRect = LocalRect;
Paint.SetPen(DockTheme.StatusColor(item.Level), 0f, PenStyle.Solid);
Paint.DrawIcon(new Rect(localRect.Left, localRect.Top, 16f, localRect.Height), DockTheme.LevelIcon(item.Level), 14f, TextFlag.LeftCenter);
Paint.SetPen(Theme.Text.WithAlpha(0.55f), 0f, PenStyle.Solid);
Paint.DrawIcon(new Rect(localRect.Left + 18f, localRect.Top, 14f, localRect.Height), DockTheme.ReadinessIcon(item.Key), 12f, TextFlag.LeftCenter);
string text = item.Title ?? string.Empty;
float width = ((text.Length == 0) ? 0f : (Paint.MeasureText(text).x + 8f));
float x = localRect.Left + 36f;
Paint.SetPen(in Theme.Text, 0f, PenStyle.Solid);
if (width > 0f)
{
Paint.DrawText(new Rect(in x, localRect.Top, in width, localRect.Height), text, TextFlag.LeftCenter | TextFlag.SingleLine);
}
string text2 = item.Detail ?? string.Empty;
float x2 = x + width;
float width2 = localRect.Right - x2 - 4f;
if (text2.Length > 0 && width2 > 8f)
{
string elidedText = Paint.GetElidedText(text2, width2);
Paint.SetPen(Theme.Text.WithAlpha(0.6f), 0f, PenStyle.Solid);
Paint.DrawText(new Rect(in x2, localRect.Top, in width2, localRect.Height), elidedText, TextFlag.LeftCenter | TextFlag.SingleLine);
}
return false;
}
}
private readonly Widget rows;
private readonly Label notice;
private IReadOnlyList<ReadinessItem> lastList;
private string signature;
protected override bool FieldsAssigned => rows != null && notice != null;
public ReadinessCard(Widget parent)
: base(parent, "Readiness")
{
rows = Layout.Add(new Widget(this));
rows.Layout = Layout.Column();
rows.Layout.Spacing = 4f;
notice = Layout.Add(new Label("", this)
{
WordWrap = true,
Color = DockTheme.WarningColor
});
notice.Visible = false;
Refresh();
}
public void Refresh()
{
IReadOnlyList<ReadinessItem> readiness = CollaborationSession.Current.Readiness;
if (readiness != lastList)
{
lastList = readiness;
string a = Signature(readiness);
if (!string.Equals(a, signature, StringComparison.Ordinal))
{
signature = a;
Rebuild(readiness);
}
}
}
private static string Signature(IReadOnlyList<ReadinessItem> list)
{
if (list == null || list.Count == 0)
{
return string.Empty;
}
StringBuilder stringBuilder = new StringBuilder(list.Count * 64);
foreach (ReadinessItem item in list)
{
if ((object)item != null)
{
stringBuilder.Append(item.Key).Append('|').Append(item.Level)
.Append('|')
.Append(item.Title)
.Append('|')
.Append(item.Detail)
.Append('|')
.Append(item.Fix)
.Append('\n');
}
}
return stringBuilder.ToString();
}
private void Rebuild(IReadOnlyList<ReadinessItem> list)
{
rows.Layout.Clear(deleteWidgets: true);
if (list == null || list.Count == 0)
{
rows.Layout.Add(new Label("The readiness checklist has not been evaluated yet.", this)
{
WordWrap = true,
Color = DockTheme.MutedColor
});
return;
}
foreach (ReadinessItem item in list)
{
if ((object)item != null)
{
Widget widget = rows.Layout.Add(new Widget(rows));
widget.Layout = Layout.Row();
widget.Layout.Spacing = 4f;
widget.Layout.Add(new ItemBody(widget, item), 1);
Widget widget2 = FixButton(widget, item);
if (widget2 != null)
{
widget.Layout.Add(widget2);
}
}
}
}
private Widget FixButton(Widget parent, ReadinessItem item)
{
if (string.IsNullOrWhiteSpace(item.Fix))
{
return null;
}
if (string.Equals(item.Fix, "Update", StringComparison.Ordinal))
{
return new Button("Update", parent)
{
ToolTip = "Install the current copy of the collaboration library from Asset Party.",
Clicked = () =>
{
_ = UpdateLibrary();
}
};
}
return new Button(item.Fix, parent)
{
Enabled = false,
ToolTip = "This build has no action for this item."
};
}
private async Task UpdateLibrary()
{
try
{
CollaborationConfig config = CollaborationSession.Current.Config;
string ident = FullIdent(config);
if (ident == null)
{
SetNotice("The collaboration library ident is not configured in ProjectSettings/collaboration.json.");
return;
}
SetNotice("Requesting the current copy of " + ident + " from Asset Party…");
Package package = await Package.FetchAsync(ident, partial: false);
if (package?.Revision == null)
{
SetNotice("The package " + ident + " could not be fetched from Asset Party.");
return;
}
await LibrarySystem.Install(ident, package.Revision.VersionId);
SetNotice("The collaboration library is being installed. The checklist reports the installed version when it finishes.");
}
catch (Exception ex)
{
Exception e = ex;
SetNotice(CollaborationDiagnostics.Bounded(e.Message));
}
}
private static string FullIdent(CollaborationConfig config)
{
string text = config?.LibraryIdent;
if (string.IsNullOrWhiteSpace(text))
{
// A project with no settings file yet still knows which package to fetch: the published one under its own organization.
return LibraryInfo.DefaultLibraryIdent(config?.Org);
}
if (text.Contains('.'))
{
return text;
}
string org = config.Org;
return string.IsNullOrWhiteSpace(org) ? text : (org + "." + text);
}
private void SetNotice(string text)
{
notice.Text = text ?? string.Empty;
notice.Visible = notice.Text.Length > 0;
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using Sandbox.Internal;
namespace TeamCreate;
/// <summary>Sends Cloudflare requests through the engine's HTTP client, which is the only network access an editor script has.</summary>
public sealed class SandboxCloudflareTransport : ICloudflareTransport
{
private const int MaxBodyChars = 262144;
public async Task<CloudflareResponse> SendAsync(CloudflareRequest request, CancellationToken cancellationToken)
{
HttpResponseMessage response = null;
try
{
// Content-Type travels on the content itself; the engine refuses to set some headers by name, and everything the deploy needs is allowed.
response = await Http.RequestAsync(request.Url, request.Method, request.Content, request.Headers.Count == 0 ? null : request.Headers, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new CloudflareResponse(null, null, ex.GetType().Name + ": " + ex.Message);
}
using (response)
{
int status = (int)response.StatusCode;
string body = null;
try
{
string text = await response.Content.ReadAsStringAsync(cancellationToken);
body = text != null && text.Length <= MaxBodyChars ? text : null;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception)
{
body = null;
}
return new CloudflareResponse(status, body);
}
}
}
/// <summary>
/// The Relay card's machinery. Two buttons drive it: one opens Cloudflare's token page, the other reads the token from the clipboard, deploys the relay to the
/// person's own free account, and remembers the address on this computer. The token is held only for the length of the deploy: it is never written to disk,
/// never logged, and the clipboard is cleared afterwards if it still holds it. All state is polled from the frame, never from a thread of its own.
/// </summary>
public static class RelaySetup
{
private static Task<DeployResult> task;
private static CancellationTokenSource cancel;
private static volatile string progress;
private static string tokenInUse;
private static bool localLoaded;
// The internet relay preference (Core/LocalTunnelSetting.cs): read once, written when the Relay card's checkbox changes. It sits next to the local
// relay file in .collaboration/, which Git ignores and the manifest skips, because it is one person's choice and not the project's.
private static bool tunnelSettingLoaded;
private static bool tunnelEnabled = true;
private static string TunnelSettingPath => Path.Combine(SceneAdapter.Root, ".collaboration", LocalTunnelSetting.FileName);
/// <summary>
/// Whether hosting opens the internet relay. On unless this computer turned it off; a file that cannot be read or says something unexpected keeps
/// it off (and says so in the log), because a public address must never be opened by accident.
/// </summary>
public static bool InternetRelayEnabled
{
get
{
if (!tunnelSettingLoaded)
{
tunnelSettingLoaded = true;
string problem;
try
{
string path = TunnelSettingPath;
tunnelEnabled = LocalTunnelSetting.Parse(File.Exists(path) ? File.ReadAllText(path) : null, out problem);
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
tunnelEnabled = false;
problem = "The internet relay preference in .collaboration/" + LocalTunnelSetting.FileName + " could not be read (" + ex.Message + "), so the internet relay stays off.";
}
if (problem != null)
{
GlobalGameNamespace.Log.Error("Collaboration relay: " + CollaborationDiagnostics.Bounded(problem));
}
}
return tunnelEnabled;
}
set
{
string path = TunnelSettingPath;
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, LocalTunnelSetting.Format(value));
tunnelEnabled = value;
tunnelSettingLoaded = true;
}
}
private static string localRelay;
private static string localProblem;
public static DeployResult LastResult { get; private set; }
public static bool Working => task != null && !task.IsCompleted;
public static string Progress => progress;
/// <summary>The relay this computer set up, or null. Read once and kept, because the dock asks every frame.</summary>
public static string LocalRelay
{
get
{
EnsureLocalLoaded();
return localRelay;
}
}
/// <summary>Why the local file could not be used, or null.</summary>
public static string LocalProblem
{
get
{
EnsureLocalLoaded();
return localProblem;
}
}
private static string LocalFilePath => Path.Combine(SceneAdapter.Root, ".collaboration", LocalRelayFile.FileName);
private static void EnsureLocalLoaded()
{
if (localLoaded)
{
return;
}
localLoaded = true;
localRelay = null;
localProblem = null;
try
{
string path = LocalFilePath;
if (File.Exists(path))
{
localRelay = LocalRelayFile.Parse(File.ReadAllText(path), out localProblem);
}
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
localProblem = "The relay address file could not be read: " + ex.Message;
}
if (localProblem != null)
{
GlobalGameNamespace.Log.Error("Collaboration relay: " + CollaborationDiagnostics.Bounded(localProblem));
}
}
public static RelaySetupView Describe(string projectRelay)
{
RelaySetupView view = RelaySetupRules.Describe(new RelaySetupInput(projectRelay, LocalRelay, RelayDefaults.DefaultUrl, Working, progress, LastResult, InternetRelayEnabled));
if (localProblem != null && view.Mode == RelaySetupMode.Missing)
{
return view with { Text = localProblem + " " + view.Text, IsProblem = true };
}
return view;
}
/// <summary>Opens the Cloudflare page that creates the token, with the two permissions the relay needs already chosen.</summary>
public static void OpenTokenPage()
{
// The engine's own tools open web addresses through this call (Project.EditUrl, Package.Url); MenuUtility.OpenUrl belongs to the menu assembly, which editor scripts cannot reference.
EditorUtility.OpenFolder(CloudflareApi.TokenPageUrl());
}
/// <summary>Reads the token from the clipboard and deploys. The clipboard is checked before anything is sent anywhere.</summary>
public static void Start()
{
if (Working)
{
return;
}
string clipboard = null;
try
{
clipboard = EditorUtility.Clipboard.Paste();
}
catch (Exception)
{
clipboard = null;
}
if (!CloudflareApi.TryAcceptToken(clipboard, out string token, out string problem))
{
LastResult = new DeployResult(false, null, problem);
progress = null;
return;
}
LastResult = null;
tokenInUse = token;
cancel = new CancellationTokenSource();
progress = "Starting…";
var deployer = new RelayDeployer(new SandboxCloudflareTransport(), RelayWorkerSource.Text);
task = deployer.RunAsync(token, text => progress = text, cancel.Token);
}
public static void Cancel()
{
cancel?.Cancel();
}
[EditorEvent.Frame]
public static void Pump()
{
if (task == null || !task.IsCompleted)
{
return;
}
Task<DeployResult> finished = task;
task = null;
string token = tokenInUse;
tokenInUse = null;
cancel?.Dispose();
cancel = null;
progress = null;
DeployResult result;
try
{
result = finished.IsFaulted || finished.IsCanceled
? new DeployResult(false, null, "Setup stopped unexpectedly: " + CloudflareApi.Redact(finished.Exception?.GetBaseException().Message, token) + " Nothing was changed on this computer.")
: finished.Result;
}
catch (Exception ex)
{
result = new DeployResult(false, null, "Setup stopped unexpectedly: " + CloudflareApi.Redact(ex.Message, token));
}
ClearClipboardIfStillHolds(token);
if (result.Ok)
{
string saveProblem = Save(result.RelayUrl);
if (saveProblem != null)
{
result = new DeployResult(false, null, saveProblem);
}
}
LastResult = result;
if (!result.Ok && !result.Cancelled)
{
GlobalGameNamespace.Log.Error("Collaboration relay setup: " + CollaborationDiagnostics.Bounded(result.Error));
}
}
private static string Save(string relayUrl)
{
try
{
string path = LocalFilePath;
Directory.CreateDirectory(Path.GetDirectoryName(path));
string temp = path + ".tmp";
File.WriteAllText(temp, LocalRelayFile.Format(relayUrl));
File.Move(temp, path, true);
localLoaded = false;
return null;
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
return "The relay is running at " + RelaySetupRules.HostOf(relayUrl) + ", but its address could not be saved on this computer (" + ex.Message + "). Put it in .collaboration/" + LocalRelayFile.FileName + " yourself, or set RelayUrl in ProjectSettings/collaboration.json.";
}
}
/// <summary>The token has done its job; if it is still the thing on the clipboard, take it off so it is not pasted somewhere by accident.</summary>
private static void ClearClipboardIfStillHolds(string token)
{
if (string.IsNullOrEmpty(token))
{
return;
}
try
{
string now = EditorUtility.Clipboard.Paste();
if (now != null && string.Equals(now.Trim(), token, StringComparison.Ordinal))
{
EditorUtility.Clipboard.Copy(string.Empty);
}
}
catch (Exception)
{
// Not being able to clear the clipboard changes nothing about the deploy.
}
}
}
Editor
library
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
namespace TeamCreate;
/// <summary>
/// An editor-only Cloudflare Quick Tunnel launcher. Begin only while hosting and Dispose when hosting ends: the tool requires no administrator rights,
/// downloads from one pinned URL, and runs the binary only after checking its pinned SHA-256. The tunnel carries only the relay's encrypted frames;
/// its public address is unguessable but NOT a secret, so the relay must apply its own authentication and resource limits.
///
/// Each editor-frame Pump polls tasks; no explicit thread is started and no task is synchronously waited on. Process asynchronous output events do only
/// ConcurrentQueue.Enqueue on the runtime's I/O callbacks, never editor work, so even a partial line cannot block the editor. Pump drains the queue.
/// (Polling ReadLineAsync was the alternative; it needs one outstanding read per stream and a cancelled read cannot be abandoned cleanly, whereas the
/// event pattern owns the pipe readers itself and stops them when the process is disposed.)
///
/// Running means cloudflared printed its address, NOT that the name resolves yet. Measured in the smoke test: a lookup 0.3 s after the banner got
/// NXDOMAIN, and trycloudflare.com's negative-caching TTL is 60 s, so a friend who dials in the first seconds can be locked out for a minute by their
/// own resolver. Whoever publishes the invite should wait for cloudflared's "Registered tunnel connection" line or a successful self-check first.
/// </summary>
public sealed class CloudflaredTool : IDisposable
{
public enum Phase { Idle, Downloading, Verifying, Starting, WaitingForAddress, Running, Failed }
private const int MaxBytes = 120 * 1024 * 1024;
private static readonly TimeSpan DownloadTimeout = TimeSpan.FromMinutes( 5 );
private static readonly TimeSpan AddressTimeout = TimeSpan.FromSeconds( 25 );
// Live instances are released at editor hotload and process shutdown as well as by their host's Dispose call.
private static readonly object LiveLock = new();
private static readonly System.Collections.Generic.HashSet<CloudflaredTool> Live = new();
private readonly ConcurrentQueue<string> output = new();
private CancellationTokenSource downloadCancel;
private Task downloadTask;
private Task<byte[]> readTask;
private Process process;
private FileStream executableLock;
private DateTime startedAt;
private long received;
private long shownMegabytes;
private string toolPath;
private string partPath;
private int port;
private bool disposed;
static CloudflaredTool() => AppDomain.CurrentDomain.ProcessExit += ( _, _ ) => CloseAll();
[EditorEvent.Hotload]
private static void OnHotload() => CloseAll();
private static void CloseAll()
{
CloudflaredTool[] items;
lock ( LiveLock ) items = new System.Collections.Generic.List<CloudflaredTool>( Live ).ToArray();
foreach ( CloudflaredTool item in items ) item.Dispose();
}
public Phase State { get; private set; } = Phase.Idle;
public string Status { get; private set; } = "The tunnel is not running.";
public string RelayUrl { get; private set; }
/// <summary>
/// True once cloudflared reported a registered edge connection. The address is printed before that, and asking for it then gets "no such name"
/// cached by resolvers for a minute, so HostingPlan probes only after this has been seen.
/// </summary>
public bool RegisteredConnectionSeen { get; private set; }
public string Error { get; private set; }
/// <summary>Idempotent while starting or running. A failed attempt can be retried after Dispose by constructing a new tool.</summary>
public void Begin( int localPort )
{
if ( disposed || State != Phase.Idle ) return;
try
{
// Reject invalid ports before downloading anything.
_ = CloudflaredRules.BuildArguments( localPort );
port = localPort;
toolPath = Path.Combine( SceneAdapter.Root, ".collaboration", "tools", "cloudflared.exe" );
partPath = toolPath + ".part";
Directory.CreateDirectory( Path.GetDirectoryName( toolPath ) );
lock ( LiveLock ) Live.Add( this );
if ( File.Exists( toolPath ) ) BeginVerify();
else BeginDownload();
}
catch ( Exception ) { Fail( "The tunnel tool could not be prepared. Check that the project folder is writable and retry hosting." ); }
}
private void BeginDownload()
{
State = Phase.Downloading;
Interlocked.Exchange( ref received, 0 );
shownMegabytes = 0;
Status = DownloadStatus( 0 );
downloadCancel = new CancellationTokenSource( DownloadTimeout );
downloadTask = DownloadAsync( downloadCancel.Token );
}
// RequestStreamAsync completes on headers, unlike RequestBytesAsync. Read chunks with cancellation and cap the body BEFORE allocation or write.
// Progress is the count of bytes actually received over the pinned size GitHub lists; that size is a denominator for the dock only, never a
// verification substitute. The counter is written with Interlocked because the continuation's thread is the engine's choice, not ours.
private async Task DownloadAsync( CancellationToken token )
{
using Stream response = await Http.RequestStreamAsync( CloudflaredPin.Url, "GET", null, null, token );
using FileStream file = new( partPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true );
byte[] chunk = new byte[81920];
int total = 0;
while ( true )
{
int count = await response.ReadAsync( chunk.AsMemory(), token );
if ( count == 0 ) break;
if ( count > MaxBytes - total ) throw new InvalidDataException( "The tunnel tool download exceeded 120 MB." );
await file.WriteAsync( chunk.AsMemory( 0, count ), token );
total += count;
Interlocked.Exchange( ref received, total );
}
}
// Megabytes received so far, against the listed size while the download is within it. Past it, the denominator is dropped rather than shown wrong.
private static string DownloadStatus( long bytes )
{
long mb = bytes / ( 1024 * 1024 );
long of = CloudflaredPin.SizeBytes / ( 1024 * 1024 );
return bytes <= CloudflaredPin.SizeBytes
? $"Downloading the tunnel tool ({mb} of {of} MB)."
: $"Downloading the tunnel tool ({mb} MB).";
}
private void BeginVerify()
{
State = Phase.Verifying;
Status = "Verifying the tunnel tool.";
// Re-read on EVERY Begin. Keep a read-only sharing lock from opening through launch so a swapped binary cannot run between check and start.
// A .part download is first checked before replacement and checked again after replacement under this lock.
if ( File.Exists( toolPath ) )
{
executableLock = new FileStream( toolPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true );
if ( executableLock.Length == 0 || executableLock.Length > MaxBytes )
{
executableLock.Dispose();
executableLock = null;
File.Delete( toolPath );
BeginDownload();
return;
}
readTask = ReadBoundedAsync( executableLock );
}
else
{
// A leftover .part is untrusted too, even though our downloader bounds its own writes.
long length = new FileInfo( partPath ).Length;
if ( length == 0 || length > MaxBytes )
{
File.Delete( partPath );
Fail( "The tunnel tool download had an invalid size. Retry hosting to download it again." );
return;
}
readTask = File.ReadAllBytesAsync( partPath );
}
}
private static async Task<byte[]> ReadBoundedAsync( FileStream stream )
{
var bytes = new byte[(int)stream.Length];
await stream.ReadExactlyAsync( bytes );
return bytes;
}
public void Pump()
{
if ( disposed || State is Phase.Idle or Phase.Failed ) return;
try
{
if ( State == Phase.Downloading && !downloadTask.IsCompleted )
{
// Rebuild the sentence only when the whole-megabyte count moves, so a waiting frame formats nothing.
long bytes = Interlocked.Read( ref received );
if ( bytes / ( 1024 * 1024 ) != shownMegabytes )
{
shownMegabytes = bytes / ( 1024 * 1024 );
Status = DownloadStatus( bytes );
}
}
if ( State == Phase.Downloading && downloadTask.IsCompleted )
{
downloadTask.GetAwaiter().GetResult(); // completed task; never waits on the editor frame
downloadCancel.Dispose();
downloadCancel = null;
downloadTask = null;
BeginVerify();
}
if ( State == Phase.Verifying && readTask.IsCompleted )
{
byte[] bytes = readTask.GetAwaiter().GetResult(); // completed task
readTask = null;
string problem = CloudflaredRules.GetDownloadError( bytes, CloudflaredPin.Sha256 );
if ( problem != null )
{
if ( executableLock != null )
{
executableLock.Dispose();
executableLock = null;
File.Delete( toolPath );
BeginDownload();
}
else { File.Delete( partPath ); Fail( problem + " Retry hosting to download it again." ); }
return;
}
if ( executableLock == null )
{
File.Move( partPath, toolPath, true );
BeginVerify();
return;
}
State = Phase.Starting;
}
if ( State == Phase.Starting ) Start();
if ( State is Phase.WaitingForAddress or Phase.Running )
{
while ( output.TryDequeue( out string line ) )
{
if ( CloudflaredRules.IsRegisteredLine( line ) ) RegisteredConnectionSeen = true;
if ( State != Phase.WaitingForAddress ) continue;
string address = CloudflaredRules.ParseTunnelAddress( line );
if ( address == null ) continue;
RelayUrl = CloudflaredRules.ToRelayUrl( address );
State = Phase.Running;
Status = "The tunnel is running.";
}
if ( process.HasExited ) Fail( "The tunnel stopped unexpectedly. Retry hosting to restart it." );
else if ( State == Phase.WaitingForAddress && DateTime.UtcNow - startedAt > AddressTimeout )
Fail( "The tunnel did not provide a public address within 25 seconds. Check the network connection and retry hosting." );
}
}
catch ( OperationCanceledException ) { Fail( "The tunnel tool download timed out after five minutes. Check the network connection and retry hosting." ); }
catch ( InvalidDataException ) { Fail( "The tunnel tool download exceeded 120 MB and was refused. Retry hosting after checking the network connection." ); }
catch ( Exception ) { Fail( "The tunnel could not start. Check the network and project folder permissions, then retry hosting." ); }
}
private void Start()
{
process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = toolPath,
Arguments = CloudflaredRules.BuildEmbeddedArguments( port ),
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
}
};
// Avoid inheriting editor/account tokens in the environment. Windows needs SystemRoot for its own runtime services.
process.StartInfo.Environment.Clear();
string systemRoot = Environment.GetEnvironmentVariable( "SystemRoot" );
if ( !string.IsNullOrEmpty( systemRoot ) ) process.StartInfo.Environment["SystemRoot"] = systemRoot;
// Never echo cloudflared's output into the dock: it may include machine-specific paths or diagnostic material.
process.OutputDataReceived += ( _, e ) => { if ( e.Data != null ) output.Enqueue( e.Data ); };
process.ErrorDataReceived += ( _, e ) => { if ( e.Data != null ) output.Enqueue( e.Data ); };
if ( !process.Start() ) throw new InvalidOperationException();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
startedAt = DateTime.UtcNow;
State = Phase.WaitingForAddress;
Status = "Waiting for the tunnel address.";
}
private void Fail( string message )
{
Error = message;
Status = message;
State = Phase.Failed;
RelayUrl = null;
Stop();
}
private void Stop()
{
downloadCancel?.Cancel();
downloadCancel?.Dispose();
downloadCancel = null;
if ( process != null )
{
try { if ( !process.HasExited ) process.Kill( true ); } catch ( InvalidOperationException ) { } catch ( System.ComponentModel.Win32Exception ) { }
process.Dispose();
process = null;
}
executableLock?.Dispose();
executableLock = null;
// A canceled async download may still own .part until its cancellation completes. Never reuse it; BeginDownload truncates it on the next run.
try { if ( partPath != null ) File.Delete( partPath ); } catch ( IOException ) { } catch ( UnauthorizedAccessException ) { }
}
public void Dispose()
{
if ( disposed ) return;
disposed = true;
Stop();
lock ( LiveLock ) Live.Remove( this );
}
}
Editor
library
using System;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Threading.Tasks;
namespace TeamCreate;
public sealed class StunClient : IDisposable
{
private const uint MagicCookie = 554869826u;
private const int HeaderBytes = 20;
private const ushort BindingRequest = 1;
private const ushort BindingSuccess = 257;
private const ushort MappedAddress = 1;
private const ushort XorMappedAddress = 32;
private static readonly (string Host, int Port)[] Servers = new (string, int)[2]
{
("stun.l.google.com", 19302),
("stun1.l.google.com", 19302)
};
private static readonly TimeSpan OverallDeadline = TimeSpan.FromSeconds(2L);
private static readonly TimeSpan ServerBudget = TimeSpan.FromSeconds(1L);
private readonly object gate = new object();
private int index;
private bool started;
private bool disposed;
private DateTime startedAt;
private DateTime sentAt;
private Task<IPAddress[]> resolution;
private UdpClient socket;
private byte[] transactionId;
public string PublicEndPoint { get; private set; }
public string Failure { get; private set; }
public int ServerCount => Servers.Length;
public int TriedCount { get; private set; }
public void Poll()
{
if (PublicEndPoint != null || Failure != null || disposed)
{
return;
}
try
{
Step();
}
catch (Exception error)
{
Fail(CollaborationDiagnostics.DescribeExternalFailure("Public address lookup: ", error));
}
}
private void Step()
{
DateTime utcNow = DateTime.UtcNow;
if (!started)
{
started = true;
startedAt = utcNow;
}
if (utcNow - startedAt >= OverallDeadline || index >= Servers.Length)
{
Exhaust();
}
else if (resolution == null)
{
resolution = Dns.GetHostAddressesAsync(Servers[index].Host);
}
else
{
if (!resolution.IsCompleted)
{
return;
}
if (socket == null)
{
if (!Begin(utcNow))
{
Advance();
}
}
else if (!Receive() && utcNow - sentAt >= ServerBudget)
{
Advance();
}
}
}
private bool Begin(DateTime now)
{
IPAddress iPAddress = null;
try
{
IPAddress[] result = resolution.GetAwaiter().GetResult();
foreach (IPAddress iPAddress2 in result)
{
if (iPAddress2.AddressFamily == AddressFamily.InterNetwork)
{
iPAddress = iPAddress2;
break;
}
}
}
catch (Exception)
{
iPAddress = null;
}
resolution = null;
if (iPAddress == null)
{
return false;
}
byte[] array = BuildBindingRequest(out var array2);
UdpClient udpClient = new UdpClient();
udpClient.Connect(new IPEndPoint(iPAddress, Servers[index].Port));
udpClient.Send(array, array.Length);
transactionId = array2;
socket = udpClient;
sentAt = now;
TriedCount++;
return true;
}
private bool Receive()
{
try
{
if (socket == null || !socket.Client.Poll(0, SelectMode.SelectRead) || socket.Available == 0)
{
return false;
}
IPEndPoint remoteEP = null;
byte[] array = socket.Receive(ref remoteEP);
if (!HasTransactionId(array))
{
return false;
}
if (!TryReadMappedAddress(array, out var address, out var _))
{
return false;
}
PublicEndPoint = address;
return true;
}
catch (SocketException)
{
return false;
}
}
private bool HasTransactionId(byte[] bytes)
{
if (transactionId == null || bytes == null || bytes.Length < 20)
{
return false;
}
for (int i = 0; i < 12; i++)
{
if (bytes[8 + i] != transactionId[i])
{
return false;
}
}
return true;
}
private void Advance()
{
try
{
socket?.Close();
}
catch
{
}
socket = null;
resolution = null;
transactionId = null;
index++;
if (index >= Servers.Length)
{
Exhaust();
}
}
private void Exhaust()
{
if (PublicEndPoint == null)
{
Fail((TriedCount == 0) ? "The public address could not be determined: the STUN servers could not be resolved on this network." : "The public address could not be determined: no STUN server answered within two seconds (UDP may be blocked).");
}
}
private void Fail(string message)
{
lock (gate)
{
if (Failure == null)
{
string text = (Failure = CollaborationDiagnostics.Bounded(message));
}
}
try
{
socket?.Close();
}
catch
{
}
socket = null;
}
internal static byte[] BuildBindingRequest(out byte[] transactionId)
{
transactionId = RandomNumberGenerator.GetBytes(12);
byte[] array = new byte[20]
{
0, 1, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0
};
uint num = 554869826u;
array[4] = (byte)(num >> 24);
array[5] = (byte)(num >> 16);
array[6] = (byte)(num >> 8);
array[7] = (byte)num;
Array.Copy(transactionId, 0, array, 8, 12);
return array;
}
internal static bool TryReadMappedAddress(byte[] response, out string address, out string error)
{
address = null;
error = null;
if (response == null || response.Length < 20)
{
error = "The datagram is shorter than a STUN header.";
return false;
}
if (ReadUInt16(response, 0) != 257)
{
error = "The datagram is not a STUN Binding Success Response.";
return false;
}
if (ReadUInt32(response, 4) != 554869826)
{
error = "The datagram does not carry the STUN magic cookie.";
return false;
}
ushort num = ReadUInt16(response, 2);
if (num != response.Length - 20)
{
error = "The STUN response declares a length its datagram does not have.";
return false;
}
int i = 20;
ushort num4;
for (int num2 = 20 + num; i + 4 <= num2; i += 4 + ((num4 + 3) & -4))
{
ushort num3 = ReadUInt16(response, i);
num4 = ReadUInt16(response, i + 2);
if (i + 4 + num4 > num2)
{
error = "The STUN response carries a truncated attribute.";
return false;
}
bool flag = ((num3 == 1 || num3 == 32) ? true : false);
if (flag && num4 >= 8 && response[i + 5] == 1)
{
byte[] array = new byte[4];
Array.Copy(response, i + 8, array, 0, 4);
if (num3 == 32)
{
uint num5 = 554869826u;
array[0] ^= (byte)(num5 >> 24);
array[1] ^= (byte)(num5 >> 16);
array[2] ^= (byte)(num5 >> 8);
array[3] ^= (byte)num5;
}
address = array[0] + "." + array[1] + "." + array[2] + "." + array[3];
return true;
}
}
error = "The STUN response carries no IPv4 mapped address.";
return false;
}
private static ushort ReadUInt16(byte[] bytes, int offset)
{
return (ushort)((bytes[offset] << 8) | bytes[offset + 1]);
}
private static uint ReadUInt32(byte[] bytes, int offset)
{
return (uint)((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]);
}
public void Dispose()
{
if (!disposed)
{
disposed = true;
try
{
socket?.Close();
}
catch
{
}
socket = null;
}
}
}
Editor
library
using System;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace TeamCreate;
/// <summary>
/// What a project file contributes to the baseline and the manifest. Everything is its own bytes except the engine's <c>.meta</c> files, which carry
/// bookkeeping the engine rewrites by itself: it sets <c>sceneCompileDirty</c> the moment a scene is edited. That flag says nothing about the project, but
/// hashed as raw bytes it made a collaborator's first edit change the baseline, so Save refused with "Source/assets changed during collaboration" and two
/// machines could disagree about whether they had the same project. It is left out; every other field of a .meta still counts.
/// </summary>
public static class MetaFileRules
{
private static readonly string[] IgnoredFields = { "sceneCompileDirty" };
/// <summary>The bytes to hash for a file. Anything that is not a readable JSON-object .meta file is returned exactly as given.</summary>
public static byte[] Canonical(string path, byte[] content)
{
if (content == null || string.IsNullOrEmpty(path) || !path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))
{
return content;
}
try
{
ReadOnlySpan<byte> body = content;
if (body.Length >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF)
{
body = body.Slice(3);
}
if (!(JsonNode.Parse(body) is JsonObject document))
{
return content;
}
foreach (string field in IgnoredFields)
{
document.Remove(field);
}
// Sorted by field name so the order the engine happened to write them in does not matter, and compact so whitespace does not either.
SortedObject(document, out JsonObject sorted);
return Encoding.UTF8.GetBytes(sorted.ToJsonString(new JsonSerializerOptions { WriteIndented = false }));
}
catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException || ex is ArgumentException)
{
return content;
}
}
private static void SortedObject(JsonObject source, out JsonObject result)
{
result = new JsonObject();
foreach (var pair in System.Linq.Enumerable.OrderBy(source, (System.Collections.Generic.KeyValuePair<string, JsonNode> x) => x.Key, StringComparer.Ordinal))
{
result[pair.Key] = pair.Value == null ? null : JsonNode.Parse(pair.Value.ToJsonString());
}
}
}
Debug: View Raw JSON Response
{
"TotalCount": 120,
"Files": [
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Core/AdaptiveDelay.cs",
"FileName": "AdaptiveDelay.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\n\nnamespace TeamCreate;\n\n/// <summary>\n/// How far behind live to render remote motion. Rendering behind live gives the interpolation two samples to blend between; the delay has to be\n/// at least as long as the network's irregularity or the newest sample is not there yet and the picture stalls. Longer than that only adds lag.\n/// </summary>\n/// <remarks>\n/// The measure of irregularity is the gap between consecutive packet arrivals over the last <see cref=\"Window\"/> packets. The target is\n/// <c>p95(gap) + 0.5 * mean(gap)</c>: the 95th-percentile gap is how long the picture can wait for the next packet on all but one in twenty\n/// arrivals, and half a mean gap is added as margin so that the sample after the render time has normally arrived. The target is clamped to\n/// <see cref=\"FloorMs\"/>..<see cref=\"CeilingMs\"/>. With a 32-gap window the nearest-rank p95 is the 31st smallest value, so a single late\n/// packet barely moves the target (only the mean); jitter that repeats does.\n///\n/// The delay follows that target asymmetrically. When the network gets worse the delay rises at once, because a late packet must not freeze\n/// the picture. When the network improves, or a single packet was merely lost, the delay falls by at most <see cref=\"MaxFallMsPerSecond\"/> of\n/// elapsed time: the render clock then runs at 0.5% slower than real time while it settles, which no one can see, instead of lurching\n/// forward, which everyone can.\n///\n/// Before any gap is observed the delay is <see cref=\"StartMs\"/>. The floor (50 ms) would be too short for a first, possibly jittery, packet; 80 ms is\n/// inside the 60 to 80 ms typical range and costs a few seconds of mild lag at worst: the delay then walks down to the real target at the fall rate.\n/// </remarks>\npublic sealed class AdaptiveDelay\n{\n public const double FloorMs = 50;\n public const double CeilingMs = 150;\n public const double MaxFallMsPerSecond = 5;\n\n /// <summary>The delay used until the first gap has been observed, and the value the delay falls from afterwards.</summary>\n public const double StartMs = 80;\n\n /// <summary>How many of the most recent arrival gaps take part: about one second at 30 packets a second.</summary>\n public const int Window = 32;\n\n private readonly double[] gaps = new double[Window];\n private readonly double[] sorted = new double[Window];\n private int count;\n private int next;\n private double targetMs;\n private double currentMs = StartMs;\n private double lastMs;\n private bool hasLast;\n\n /// <summary>\n /// Records the time between two consecutive packet arrivals, read at local time <paramref name=\"nowMs\"/>. A negative or non-finite gap\n /// is ignored (a clock that stepped backwards, or a bad reading, says nothing about the network).\n /// </summary>\n public void Observe( double arrivalGapMs, double nowMs )\n {\n if ( !double.IsFinite( arrivalGapMs ) || arrivalGapMs < 0 )\n {\n return;\n }\n // Settle the delay up to now against the old target first, so that time already elapsed is not charged against the new one.\n Follow( nowMs );\n gaps[next] = arrivalGapMs;\n next = (next + 1) % Window;\n if ( count < Window )\n {\n count++;\n }\n targetMs = ComputeTarget();\n Follow( nowMs );\n }\n\n /// <summary>The delay to render behind live at local time <paramref name=\"nowMs\"/>. Reading it more or less often does not change the result.</summary>\n public double DelayMs( double nowMs )\n {\n Follow( nowMs );\n return currentMs;\n }\n\n /// <summary>\n /// Moves the current delay toward the target for the time elapsed since the last reading: up at once, down at the limited rate. A reading\n /// earlier than one already seen adds no time, so calls that arrive slightly out of order can never make the delay fall faster than its limit.\n /// </summary>\n private void Follow( double nowMs )\n {\n double elapsedMs = 0;\n if ( double.IsFinite( nowMs ) )\n {\n if ( hasLast && nowMs > lastMs )\n {\n elapsedMs = nowMs - lastMs;\n }\n if ( !hasLast || nowMs > lastMs )\n {\n lastMs = nowMs;\n hasLast = true;\n }\n }\n if ( count == 0 )\n {\n return;\n }\n if ( targetMs >= currentMs )\n {\n currentMs = targetMs;\n }\n else\n {\n currentMs = Math.Max( targetMs, currentMs - MaxFallMsPerSecond * elapsedMs / 1000.0 );\n }\n }\n\n private double ComputeTarget()\n {\n Array.Copy( gaps, sorted, count );\n Array.Sort( sorted, 0, count );\n // Nearest rank: the smallest value with at least 95% of the window at or below it, i.e. the ceil(0.95 * n)-th smallest. Integer\n // arithmetic, so that 20 values give exactly the 19th rather than whatever 0.95 * 20 rounds to.\n int rank = (95 * count + 99) / 100;\n double p95 = sorted[rank - 1];\n double sum = 0;\n for ( int i = 0; i < count; i++ )\n {\n sum += gaps[i];\n }\n double mean = sum / count;\n // A sum that overflows is +infinity and clamps to the ceiling, which is the right answer for such a gap.\n return Math.Clamp( p95 + 0.5 * mean, FloorMs, CeilingMs );\n }\n}\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Core/AdmissionRules.cs",
"FileName": "AdmissionRules.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Security.Cryptography;\r\nusing System.Text;\r\n\r\nnamespace TeamCreate;\r\n\r\npublic sealed record PeerFacts(string SteamId, bool IdentityVerified, bool IsFriend, bool IsOrgMember, string PeerLibraryVersion, string PeerProjectManifest, string PeerOrgIdent, bool IsHostSelf = false);\r\n\r\npublic static class AdmissionRules\r\n{\r\n public const string VerifiedGate = \"identity\";\r\n\r\n public const string FriendGate = \"friend\";\r\n\r\n public const string OrgGate = \"org\";\r\n\r\n public const string LibraryGate = \"library\";\r\n\r\n public const string ManifestGate = \"manifest\";\r\n\r\n private const int MaxPeerTextLength = 64;\r\n\r\n public static string Evaluate(PeerFacts facts, CollaborationConfig config, string hostLibraryVersion, string hostManifest, out string failedGate, string peerName = null)\r\n {\r\n failedGate = null;\r\n string text = SafePeerText(peerName, 64);\r\n string text2 = ((text.Length == 0) ? string.Empty : (text + \" \"));\r\n if ((object)facts == null || !facts.IdentityVerified)\r\n {\r\n failedGate = \"identity\";\r\n return text2 + \"could not be verified by s&box's auth service.\";\r\n }\r\n if (config != null && config.RequireSteamFriend && !facts.IsFriend && !facts.IsHostSelf)\r\n {\r\n failedGate = \"friend\";\r\n return text2 + \"is not on your Steam friends list. Add them on Steam and reconnect.\";\r\n }\r\n if (config != null && config.RequireOrgMember && (!facts.IsOrgMember || !string.Equals(facts.PeerOrgIdent, config.Org, StringComparison.OrdinalIgnoreCase)))\r\n {\r\n failedGate = \"org\";\r\n return text2 + \"is not a member of the organization \" + config.Org + \".\";\r\n }\r\n Version version = config?.LibraryVersion ?? ParseVersion(hostLibraryVersion);\r\n if ((object)version != null)\r\n {\r\n string text3 = (string.IsNullOrWhiteSpace(facts.PeerLibraryVersion) ? null : facts.PeerLibraryVersion);\r\n if (text3 == null || !Version.TryParse(text3, out Version result) || !VersionRules.IsAtLeast(result, version))\r\n {\r\n string text4 = SafePeerText(text3, 64);\r\n failedGate = \"library\";\r\n return text2 + $\"runs collaboration library {((text4.Length == 0) ? \"an unknown version\" : text4)}; this session requires {version}. Update the library and reconnect.\";\r\n }\r\n }\r\n // File differences are reconciled after admission: assets are synchronized and code differences are reported, never received.\r\n // The manifest therefore stops being a gate; the engine build still is (BaselineRefusal).\r\n return null;\r\n }\r\n\r\n public const string BaselineRefusalMessage = \"Project/scene baseline differs (engine, source, assets or metadata). Sync copies in Git before joining.\";\r\n\r\n public const string EngineRefusalMessage = \"This peer runs a different s&box engine build from the host's. Update s&box to the same version on both computers and reconnect.\";\r\n\r\n public const string EngineUnknownRefusalMessage = \"This peer's project differs from the host's and its collaboration library cannot synchronize project files. Update the library and reconnect.\";\r\n\r\n /// <summary>\r\n /// The baseline gate of the hello. When the peer's files match the host's (or the host has no manifest to compare), the full baseline\r\n /// must be one the host accepts, exactly as before file synchronization. When the files differ, the full baseline necessarily differs\r\n /// too (it hashes every file), so only the engine fingerprint is compared: the files are reconciled after admission, the engine build\r\n /// cannot be. A peer that sends no engine fingerprint predates file synchronization and is refused, because nothing would reconcile its\r\n /// files; a host that has no fingerprint of its own falls back to the full baseline. Null when the peer passes.\r\n /// </summary>\r\n public static string BaselineRefusal(string peerManifest, string hostManifest, string peerBaseline, ICollection<string> compatibleBaselines, string peerEngine, string hostEngine)\r\n {\r\n bool sameFiles = string.IsNullOrWhiteSpace(hostManifest) || string.Equals(peerManifest, hostManifest, StringComparison.Ordinal);\r\n if (sameFiles || string.IsNullOrWhiteSpace(hostEngine))\r\n {\r\n return (compatibleBaselines != null && peerBaseline != null && compatibleBaselines.Contains(peerBaseline)) ? null : BaselineRefusalMessage;\r\n }\r\n if (string.IsNullOrWhiteSpace(peerEngine))\r\n {\r\n return EngineUnknownRefusalMessage;\r\n }\r\n return string.Equals(peerEngine, hostEngine, StringComparison.Ordinal) ? null : EngineRefusalMessage;\r\n }\r\n\r\n /// <summary>\r\n /// A fingerprint of the engine build from the module version ids of the engine assemblies (the same ids SceneAdapter.Baseline starts\r\n /// with), in the order given. Lowercase SHA-256 hex.\r\n /// </summary>\r\n public static string EngineFingerprint(IEnumerable<string> moduleVersionIds)\r\n {\r\n string text = \"teamcreate-engine-1\\n\" + string.Join(\"\\n\", moduleVersionIds ?? Array.Empty<string>());\r\n return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant();\r\n }\r\n\r\n private static Version ParseVersion(string value)\r\n {\r\n Version result;\r\n return (!string.IsNullOrWhiteSpace(value) && Version.TryParse(value, out result)) ? result : null;\r\n }\r\n\r\n private static string SafePeerText(string value, int maxLength)\r\n {\r\n if (string.IsNullOrWhiteSpace(value))\r\n {\r\n return string.Empty;\r\n }\r\n string text = CollaborationDiagnostics.Bounded(value, maxLength);\r\n StringBuilder stringBuilder = new StringBuilder(text.Length);\r\n string text2 = text;\r\n foreach (char c in text2)\r\n {\r\n if (!char.IsControl(c))\r\n {\r\n stringBuilder.Append(c);\r\n }\r\n }\r\n return stringBuilder.ToString();\r\n }\r\n}\r\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Core/BlobExchange.cs",
"FileName": "BlobExchange.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Security.Cryptography;\n\nnamespace TeamCreate;\n\npublic enum BlobOffer\n{\n /// <summary>Nothing the packet names is missing and nothing is waiting ahead of it: the caller handles it now.</summary>\n Process,\n\n /// <summary>The packet waits for geometry; <see cref=\"BlobExchange.TryTakeReady\"/> returns it, in order, once it has arrived.</summary>\n Held,\n\n /// <summary>The packet was refused (a bound was reached); <see cref=\"BlobExchange.TakeDropped\"/> says why.</summary>\n Dropped\n}\n\n/// <summary>The bytes of incomplete geometry one side may hold in total. Shared by every member of a host, so sixteen guests cannot each take the whole allowance.</summary>\npublic sealed class BlobBudget\n{\n private readonly long limit;\n\n public BlobBudget( long limit = BlobLimits.MaxPendingBytes )\n {\n this.limit = limit;\n }\n\n public long Used { get; private set; }\n\n public bool TryReserve( long bytes )\n {\n if ( bytes < 0 || Used + bytes > limit ) return false;\n Used += bytes;\n return true;\n }\n\n public void Release( long bytes )\n {\n Used = Math.Max( 0, Used - bytes );\n }\n}\n\n/// <summary>\n/// One end of the geometry channel of a connection. It does both halves: it serves the geometry the other end asks for (a queue of jobs\n/// drained through a window of eight unacknowledged chunks, the same discipline as file synchronization), and it holds the scene packets\n/// that name geometry not yet present, asks for it, assembles it from untrusted chunks (every rule of <see cref=\"FileChunkAssembler\"/>, plus\n/// the SHA-256 the packet named) and releases the packets in their original order when everything they name has arrived.\n/// Nothing here throws on hostile input: a refusal drops the packet it concerns, says why, and the scene is synchronized again.\n/// </summary>\npublic sealed class BlobExchange\n{\n private sealed class HeldPacket\n {\n public Packet Packet;\n\n public List<string> Hashes;\n\n public DateTime At;\n\n /// <summary>The hashes this packet added to the wanted table, so releasing it removes exactly those.</summary>\n public List<string> Counted = new();\n }\n\n private sealed class SendJob\n {\n public string Hash;\n\n public byte[] Bytes;\n\n public int Offset;\n\n public bool Unavailable;\n }\n\n private sealed class Assembly\n {\n public long Total;\n\n public byte[] Bytes;\n\n public readonly HashSet<long> Received = new();\n\n public long ReceivedBytes;\n }\n\n private readonly BlobStore store;\n\n private readonly Action<Packet> send;\n\n private readonly Func<DateTime> clock;\n\n private readonly BlobBudget budget;\n\n private readonly List<HeldPacket> held = new();\n\n // How many held packets name each missing hash. A chunk is accepted only for a hash that is in this table: unsolicited data is ignored.\n private readonly Dictionary<string, int> wanted = new( StringComparer.Ordinal );\n\n private readonly HashSet<string> requested = new( StringComparer.Ordinal );\n\n private readonly Dictionary<string, Assembly> assemblies = new( StringComparer.Ordinal );\n\n private readonly Queue<SendJob> jobs = new();\n\n private readonly FileChunkWindow window = new();\n\n private readonly List<(Packet Packet, string Reason)> dropped = new();\n\n private readonly List<string> notices = new();\n\n public BlobExchange( BlobStore store, Action<Packet> send, Func<DateTime> clock, BlobBudget budget = null )\n {\n this.store = store;\n this.send = send;\n this.clock = clock;\n this.budget = budget ?? new BlobBudget();\n }\n\n public int HeldCount => held.Count;\n\n /// <summary>Bytes reserved for geometry that has started to arrive on this connection.</summary>\n public long PendingBytes { get; private set; }\n\n /// <summary>Geometry the other end has asked for and this end has not finished sending.</summary>\n public int QueuedBlobs => jobs.Count;\n\n /// <summary>Gives back the pending-bytes reservation of everything half-assembled; the connection is gone and nothing will complete.</summary>\n public void Release()\n {\n foreach ( Assembly assembly in assemblies.Values ) budget.Release( assembly.Total );\n assemblies.Clear();\n PendingBytes = 0;\n held.Clear();\n wanted.Clear();\n requested.Clear();\n jobs.Clear();\n }\n\n public static bool IsBlobPacket( string kind ) => kind is \"blob-need\" or \"blob-data\" or \"blob-ack\";\n\n // Only these carry object states. Everything else (presence, pings, files, settings) is never held, so live cursors stay live.\n private static bool CarriesState( string kind ) => kind is \"snapshot\" or \"accepted\" or \"edit\" or \"subscribe\";\n\n public static List<string> HashesOf( Packet packet )\n {\n List<string> hashes = new List<string>();\n void Add( ObjectState state )\n {\n if ( (object)state?.Data == null ) return;\n foreach ( string hash in BlobRules.Referenced( state.Data ) )\n {\n if ( !hashes.Contains( hash ) ) hashes.Add( hash );\n }\n }\n if ( packet.Objects != null )\n {\n foreach ( ObjectState state in packet.Objects ) Add( state );\n }\n if ( packet.Changes != null )\n {\n // Only After is applied; Before is the state the receiver already holds and is read as plain text by the merge.\n foreach ( Change change in packet.Changes ) Add( change?.After );\n }\n return hashes;\n }\n\n public BlobOffer Offer( Packet packet )\n {\n if ( !CarriesState( packet.Kind ) ) return BlobOffer.Process;\n List<string> hashes = HashesOf( packet );\n if ( held.Count == 0 && hashes.All( store.Has ) ) return BlobOffer.Process;\n if ( held.Count >= BlobLimits.MaxPendingPackets )\n {\n Drop( packet, \"More than \" + BlobLimits.MaxPendingPackets + \" scene updates are waiting for mesh geometry that has not arrived; this update was discarded and the scene will be synchronized again.\" );\n return BlobOffer.Dropped;\n }\n HeldPacket entry = new HeldPacket { Packet = packet, Hashes = hashes, At = clock() };\n foreach ( string hash in hashes.Where( x => !store.Has( x ) ) )\n {\n wanted[hash] = wanted.TryGetValue( hash, out int count ) ? count + 1 : 1;\n entry.Counted.Add( hash );\n }\n held.Add( entry );\n return BlobOffer.Held;\n }\n\n /// <summary>The oldest held packet, once everything it names is present. Later packets wait behind it, so edits keep the order the host accepted them in.</summary>\n public bool TryTakeReady( out Packet packet )\n {\n packet = null;\n if ( held.Count == 0 ) return false;\n HeldPacket head = held[0];\n if ( !head.Hashes.All( store.Has ) ) return false;\n held.RemoveAt( 0 );\n Unwant( head );\n packet = head.Packet;\n return true;\n }\n\n public List<(Packet Packet, string Reason)> TakeDropped()\n {\n List<(Packet Packet, string Reason)> result = dropped.ToList();\n dropped.Clear();\n return result;\n }\n\n public List<string> TakeNotices()\n {\n List<string> result = notices.ToList();\n notices.Clear();\n return result;\n }\n\n /// <summary>Handles blob-need, blob-data and blob-ack. Malformed input is ignored or drops the packet it concerns; it never throws.</summary>\n public void Handle( Packet packet )\n {\n switch ( packet.Kind )\n {\n case \"blob-need\":\n Enqueue( packet.Hashes );\n break;\n case \"blob-ack\":\n window.Release();\n break;\n case \"blob-data\":\n Receive( packet );\n break;\n }\n }\n\n /// <summary>Per frame: expires waiting packets, asks for what is missing, sends the next chunks the window allows.</summary>\n public void Pump()\n {\n Expire();\n Request();\n Serve();\n }\n\n private void Enqueue( List<string> hashes )\n {\n if ( hashes == null ) return;\n foreach ( string hash in hashes.Take( BlobLimits.MaxHashesPerRequest ) )\n {\n if ( !BlobStore.ValidHash( hash ) || jobs.Count >= BlobLimits.MaxHashesPerRequest || jobs.Any( x => x.Hash == hash ) ) continue;\n jobs.Enqueue( new SendJob { Hash = hash } );\n }\n }\n\n private void Serve()\n {\n while ( jobs.Count > 0 )\n {\n SendJob job = jobs.Peek();\n if ( job.Bytes == null && !job.Unavailable )\n {\n if ( store.TryGet( job.Hash, out byte[] bytes ) ) job.Bytes = bytes;\n else job.Unavailable = true;\n }\n if ( job.Unavailable )\n {\n // Answered at once so the asker does not wait out the timeout for geometry that will never come.\n send( new Packet { Kind = \"blob-data\", Sha256 = job.Hash } );\n jobs.Dequeue();\n continue;\n }\n if ( !window.TryAcquire() ) return;\n int length = Math.Min( FileSyncLimits.ChunkSize, job.Bytes.Length - job.Offset );\n byte[] data = new byte[length];\n Array.Copy( job.Bytes, job.Offset, data, 0, length );\n send( new Packet { Kind = \"blob-data\", Sha256 = job.Hash, Offset = job.Offset, Total = job.Bytes.Length, Data = data } );\n job.Offset += length;\n if ( job.Offset >= job.Bytes.Length ) jobs.Dequeue();\n }\n }\n\n private void Request()\n {\n List<string> missing = wanted.Keys.Where( x => !requested.Contains( x ) && !store.Has( x ) ).ToList();\n for ( int i = 0; i < missing.Count; i += BlobLimits.MaxHashesPerRequest )\n {\n List<string> batch = missing.Skip( i ).Take( BlobLimits.MaxHashesPerRequest ).ToList();\n send( new Packet { Kind = \"blob-need\", Hashes = batch } );\n foreach ( string hash in batch ) requested.Add( hash );\n }\n }\n\n private void Expire()\n {\n DateTime now = clock();\n foreach ( HeldPacket packet in held.Where( x => ( now - x.At ).TotalSeconds >= BlobLimits.WaitSeconds ).ToList() )\n {\n held.Remove( packet );\n Unwant( packet );\n Drop( packet.Packet, \"The mesh geometry of a scene update did not arrive within \" + BlobLimits.WaitSeconds + \" seconds; the update was discarded and the scene will be synchronized again.\" );\n }\n }\n\n private void Receive( Packet packet )\n {\n string hash = packet.Sha256;\n // Unsolicited, malformed or already satisfied: ignored. Only geometry a held packet is waiting for is ever accepted.\n if ( !BlobStore.ValidHash( hash ) || !wanted.ContainsKey( hash ) || store.Has( hash ) ) return;\n if ( packet.Total == 0 )\n {\n Fail( hash, \"The sender no longer has the mesh geometry that a scene update names; the update was discarded and the scene will be synchronized again.\" );\n return;\n }\n if ( packet.Total < 0 || packet.Total > BlobLimits.MaxBlobBytes )\n {\n Fail( hash, \"Mesh geometry larger than the 32 MiB collaboration limit was offered and refused; the update was discarded.\" );\n return;\n }\n if ( !assemblies.TryGetValue( hash, out Assembly assembly ) )\n {\n if ( !budget.TryReserve( packet.Total ) )\n {\n Fail( hash, \"More than 64 MiB of mesh geometry is arriving at once; the update was discarded and the scene will be synchronized again.\" );\n return;\n }\n PendingBytes += packet.Total;\n assemblies[hash] = assembly = new Assembly { Total = packet.Total, Bytes = new byte[packet.Total] };\n }\n else if ( assembly.Total != packet.Total )\n {\n Fail( hash, \"Mesh geometry arrived with inconsistent sizes and was discarded; the scene will be synchronized again.\" );\n return;\n }\n byte[] data = packet.Data ?? Array.Empty<byte>();\n if ( !FileChunkAssembler.IsValidChunk( assembly.Total, packet.Offset, data.Length ) )\n {\n Fail( hash, \"Mesh geometry arrived in a form that was not valid and was discarded; the scene will be synchronized again.\" );\n return;\n }\n // Always acknowledged, a repeat included: the sender spent a credit on it.\n send( new Packet { Kind = \"blob-ack\", Sha256 = hash, Offset = packet.Offset } );\n if ( !assembly.Received.Add( packet.Offset ) ) return;\n Array.Copy( data, 0, assembly.Bytes, packet.Offset, data.Length );\n assembly.ReceivedBytes += data.Length;\n if ( assembly.ReceivedBytes < assembly.Total ) return;\n\n Finish( hash, assembly );\n if ( !store.Put( hash, assembly.Bytes, out string error ) )\n {\n Fail( hash, error );\n }\n }\n\n private void Finish( string hash, Assembly assembly )\n {\n assemblies.Remove( hash );\n PendingBytes -= assembly.Total;\n budget.Release( assembly.Total );\n requested.Remove( hash );\n }\n\n // The geometry named by `hash` will not arrive intact: every packet waiting for it is dropped, because applying part of an update\n // would leave a scene nobody had.\n private void Fail( string hash, string reason )\n {\n if ( assemblies.TryGetValue( hash, out Assembly assembly ) ) Finish( hash, assembly );\n requested.Remove( hash );\n foreach ( HeldPacket packet in held.Where( x => x.Hashes.Contains( hash ) ).ToList() )\n {\n held.Remove( packet );\n Unwant( packet );\n Drop( packet.Packet, reason );\n }\n wanted.Remove( hash );\n }\n\n // A held packet leaves the queue (released or dropped): hashes nobody waits for any more are no longer wanted, and their half-built\n // assemblies give their reservation back.\n private void Unwant( HeldPacket packet )\n {\n foreach ( string hash in packet.Counted )\n {\n if ( !wanted.TryGetValue( hash, out int count ) ) continue;\n if ( count > 1 )\n {\n wanted[hash] = count - 1;\n continue;\n }\n wanted.Remove( hash );\n requested.Remove( hash );\n if ( assemblies.TryGetValue( hash, out Assembly assembly ) ) Finish( hash, assembly );\n }\n }\n\n private void Drop( Packet packet, string reason )\n {\n dropped.Add( (packet, reason) );\n if ( !notices.Contains( reason ) ) notices.Add( reason );\n }\n}\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Core/Protocol.cs",
"FileName": "Protocol.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System.Text.Json.Nodes;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Text.Json.Serialization;\r\nusing System.Linq;\r\n\r\nnamespace TeamCreate;\r\n\r\npublic sealed record ObjectState(string Id, string Parent, JsonObject Data);\r\n\r\npublic sealed record Change(string Id, ObjectState Before, ObjectState After);\r\n\r\n/// <summary>\r\n/// One message on the wire. Every property that is unset (null, zero or false) is omitted from the JSON: a presence packet used to carry about twenty\r\n/// <c>\"Field\":null</c> entries, almost half of its 778 bytes, and presence is the one message sent thirty times a second by every editor. Receivers read\r\n/// a missing field as the same default, so older peers are unaffected in both directions.\r\n/// </summary>\r\npublic sealed class Packet\r\n{\r\n // Project file synchronization (FileSync*.cs). Every member is additive and omitted from the wire when unset, so presence and edits\r\n // stay as small as before and peers that predate file sync ignore them.\r\n\r\n /// <summary>A project-relative file path with \"/\" separators. Untrusted: the receiver validates it with FilePathRules before any disk access.</summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string Path { get; set; }\r\n\r\n /// <summary>SHA-256 (lowercase hex) of the file as ProjectManifest hashes it (a .meta file through MetaFileRules.Canonical).</summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string Sha256 { get; set; }\r\n\r\n /// <summary>On file-put and file-delete: the version the sender last agreed with the session, null for a file it believes is new.</summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string BaseSha256 { get; set; }\r\n\r\n /// <summary>Byte offset of a file-chunk (or of the chunk a file-ack acknowledges).</summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]\r\n public long Offset { get; set; }\r\n\r\n /// <summary>The whole file's size in bytes on file-chunk, file-put and file-changed.</summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]\r\n public long Total { get; set; }\r\n\r\n /// <summary>\r\n /// On blob-need: the SHA-256 digests (lowercase hex) of the mesh geometry the sender is missing, at most BlobLimits.MaxHashesPerRequest.\r\n /// Untrusted: only exactly 64 lowercase hex digits are ever looked up. Chunks of that geometry travel as blob-data, reusing Sha256, Offset,\r\n /// Total and Data; blob-ack returns a chunk's window credit. All additive and omitted when unset (see BlobExchange).\r\n /// </summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public List<string> Hashes { get; set; }\r\n\r\n /// <summary>One chunk of file content (base64 on the wire, at most FileSyncLimits.ChunkSize bytes before encoding).</summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public byte[] Data { get; set; }\r\n\r\n /// <summary>One page of the host's file index on a files-index reply; Final marks the last page.</summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public List<FileIndexEntry> FileEntries { get; set; }\r\n\r\n /// <summary>\r\n /// On the final files-index page: the package identifiers the host's .sbproj lists (PackageReferenceDiff.Idents, at most 50). The project\r\n /// file itself is Code and never travels; only these validated names do, so a guest can be told what its project does not list.\r\n /// </summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public List<string> Packages { get; set; }\r\n\r\n /// <summary>\r\n /// What the host supports, sent on welcome. A guest starts file synchronization only when it holds FileSyncLimits.Feature: a newer\r\n /// guest is admitted by an older host, which would answer an unknown message kind with a fatal error.\r\n /// </summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public List<string> Features { get; set; }\r\n\r\n /// <summary>\r\n /// On hello: the fingerprint of the engine build alone (AdmissionRules.EngineFingerprint). The full Baseline also covers every project\r\n /// file, so it necessarily differs for a guest whose files are about to be synchronized; this keeps the engine check for that guest.\r\n /// </summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string EngineBaseline { get; set; }\r\n\r\n public string Kind { get; set; }\r\n\r\n public string Id { get; set; } = Guid.NewGuid().ToString();\r\n\r\n public string Peer { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string Name { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string Project { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string Baseline { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string Token { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string Session { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string Scene { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string Error { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]\r\n public long Revision { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]\r\n public bool Final { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public List<ObjectState> Objects { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public List<Change> Changes { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public JsonObject Presence { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public int? ProtocolVersion { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public CollaborationMode? Mode { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string RuntimeRunId { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public long? RuntimeTick { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public long? RuntimeSequence { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string RuntimeOperationKind { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string SteamId { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string DisplayName { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string SteamToken { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string OrgIdent { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string LibraryVersion { get; set; }\r\n\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string ProjectManifest { get; set; }\r\n\r\n /// <summary>\r\n /// A formal sentence for the people in the session when the host combined concurrent edits (or let one edit replace another);\r\n /// null when nothing needs saying. Additive: omitted from the wire when unset, ignored by peers that predate it.\r\n /// </summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string Notice { get; set; }\r\n\r\n /// <summary>\r\n /// True on an accepted packet when the host applied something different from what the sender sent, so the sender knows to\r\n /// take the host's state rather than keep its own. Additive: omitted from the wire when false.\r\n /// </summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]\r\n public bool Merged { get; set; }\r\n\r\n /// <summary>\r\n /// Scene-level settings (<see cref=\"SettingsMerge\"/>): on a subscribe, the subscriber's settings for the scene; on a snapshot, the host's;\r\n /// on a client's <c>settings</c> packet, what the sender has now; on the host's <c>settings</c> broadcast, the merged result every member\r\n /// adopts. Additive: omitted from the wire when unset, ignored by peers that predate it.\r\n /// </summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public JsonObject Settings { get; set; }\r\n\r\n /// <summary>On a client's <c>settings</c> packet: the settings the sender last had confirmed by the host, the base of the three-way merge. Additive.</summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public JsonObject SettingsBefore { get; set; }\r\n\r\n /// <summary>\r\n /// On a ping: the fingerprint (<see cref=\"SceneDigest\"/>) of the scene state this client last had confirmed by the host, so the\r\n /// host can notice an editor that has silently drifted. Additive: omitted from the wire when unset, and a host that predates it ignores it.\r\n /// </summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string Digest { get; set; }\r\n\r\n /// <summary>On a ping: the last revision this client had applied when it computed <see cref=\"Digest\"/>. The host compares digests only at equal revisions.</summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public long? DigestRevision { get; set; }\r\n\r\n /// <summary>On a ping: the scene <see cref=\"Digest\"/> describes.</summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public string DigestScene { get; set; }\r\n\r\n /// <summary>\r\n /// On a presence packet: the sender's own monotonic milliseconds (<see cref=\"SessionClock\"/>) when it was sent, so receivers can place the\r\n /// sample in time and draw a smooth path instead of the latest raw value. Not wall-clock time, and meaningful only to the same sender's\r\n /// later stamps. The host relays it untouched. Additive: omitted from the wire when 0, which is also how a peer that predates the field reads.\r\n /// </summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]\r\n public double SentMs { get; set; }\r\n\r\n /// <summary>\r\n /// Shared play (<see cref=\"PlayFrame\"/>): on a <c>play-frame</c>, the frame; on <c>play-start</c> and <c>play-stop</c>, unused. Additive: omitted\r\n /// from the wire when unset, and only ever sent to a peer that opted in with <c>play-optin</c>, so an older peer never sees it.\r\n /// </summary>\r\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n public JsonObject Play { get; set; }\r\n}\r\n\r\npublic static class Protocol\r\n{\r\n public const int CurrentVersion = 4;\r\n\r\n public const string CurrentVersionName = \"teamcreate-4\";\r\n\r\n public const string VersionName = \"teamcreate-4\";\r\n\r\n public const string OlderLibraryMessage = \"This peer runs an older collaboration library. Update the collaboration library on both machines and reconnect.\";\r\n\r\n public const string MissingProjectRevisionMessage = \"This peer did not report its project revision. Update the collaboration library on both machines and reconnect.\";\r\n\r\n public static bool IsValidMode(CollaborationMode? mode)\r\n {\r\n return mode == CollaborationMode.Source || mode == CollaborationMode.RuntimeExperimental;\r\n }\r\n\r\n public static bool ValidateSourceHandshake(Packet p)\r\n {\r\n return GetHandshakeError(p) == null && p != null && p.Mode == CollaborationMode.Source;\r\n }\r\n\r\n public static string GetHandshakeError(Packet p, bool requireValidPeer = false)\r\n {\r\n if (p == null || p.Kind != \"hello\" || (requireValidPeer && !Guid.TryParse(p.Peer, out var _)))\r\n {\r\n return \"Invalid collaboration handshake\";\r\n }\r\n if (p.ProtocolVersion != 4)\r\n {\r\n return $\"Unsupported collaboration protocol version. This client requires protocol {4} ({\"teamcreate-4\"}).\";\r\n }\r\n if (!IsValidMode(p.Mode))\r\n {\r\n return \"Invalid collaboration mode in hello. Source or runtime collaboration mode is required.\";\r\n }\r\n if (p.Mode == CollaborationMode.RuntimeExperimental && !RuntimeStateRules.IsValidRunId(p.RuntimeRunId))\r\n {\r\n return \"Runtime collaboration handshake requires a valid runtime run ID.\";\r\n }\r\n if (!SteamIdentityRules.TryParse(p.SteamId, out var _))\r\n {\r\n return \"Collaboration handshake requires a valid Steam account ID. Sign in to Steam and reconnect.\";\r\n }\r\n if (!SteamTokenRules.IsPlausibleToken(p.SteamToken))\r\n {\r\n return \"Collaboration handshake requires a Steam identity token. Reconnect to obtain one.\";\r\n }\r\n if (string.IsNullOrWhiteSpace(p.OrgIdent) || string.IsNullOrWhiteSpace(p.LibraryVersion) || !Version.TryParse(p.LibraryVersion, out Version _))\r\n {\r\n return \"This peer runs an older collaboration library. Update the collaboration library on both machines and reconnect.\";\r\n }\r\n if (string.IsNullOrWhiteSpace(p.ProjectManifest))\r\n {\r\n return \"This peer did not report its project revision. Update the collaboration library on both machines and reconnect.\";\r\n }\r\n return null;\r\n }\r\n\r\n public static bool ValidateHandshake(Packet p)\r\n {\r\n return GetHandshakeError(p) == null;\r\n }\r\n\r\n public static bool ValidateRuntimeEnvelope(Packet p, string activeRunId, string activeScenePath = null, long? lastAcceptedSequence = null)\r\n {\r\n if (p == null || p.ProtocolVersion != 4 || p.Mode != CollaborationMode.RuntimeExperimental)\r\n {\r\n return false;\r\n }\r\n if (!IsValidMode(p.Mode) || !RuntimeStateRules.IsValidRunId(activeRunId) || !RuntimeStateRules.IsValidRunId(p.RuntimeRunId))\r\n {\r\n return false;\r\n }\r\n if (!string.Equals(p.RuntimeRunId, activeRunId, StringComparison.Ordinal))\r\n {\r\n return false;\r\n }\r\n if (!p.RuntimeSequence.HasValue || p.RuntimeSequence.Value <= 0)\r\n {\r\n return false;\r\n }\r\n if (lastAcceptedSequence.HasValue && p.RuntimeSequence.Value <= lastAcceptedSequence.Value)\r\n {\r\n return false;\r\n }\r\n return activeScenePath == null || string.Equals(p.Scene, activeScenePath, StringComparison.Ordinal);\r\n }\r\n}\r\n\r\n/// <summary>A change the scan loop did not send, with the formal sentence telling the user why.</summary>\r\npublic sealed record HeldChange( Change Change, string Reason );\r\n\r\npublic static class StateRules\r\n{\r\n public const string Version = \"teamcreate-1\";\r\n\r\n // The structural limits on one object, in one place. Nothing here asks whether a property or component type is on a list:\r\n // every edit a person can make is synchronised, and these bounds only protect the host, the peers and the relay from a\r\n // hostile or runaway payload. Each limit is inclusive (an object at exactly the limit passes).\r\n\r\n /// <summary>Largest serialised <see cref=\"ObjectState\"/>, in UTF-8 bytes of its JSON.</summary>\r\n public const int MaxObjectBytes = 1024 * 1024;\r\n\r\n /// <summary>\r\n /// Deepest JSON nesting inside an object's data, counting the data object itself as level 1. System.Text.Json refuses to\r\n /// write or read beyond 64 levels, and the data travels inside envelopes: a packet adds four levels (packet, change list,\r\n /// change, After), the outbox file one more and the failure report two more. 56 leaves those envelopes room with a margin\r\n /// of two, so an object that passes here can always be sent, stored and reported.\r\n /// </summary>\r\n public const int MaxDataDepth = 56;\r\n\r\n /// <summary>Most components on one object.</summary>\r\n public const int MaxComponents = 256;\r\n\r\n /// <summary>Largest single text value, in UTF-8 bytes.</summary>\r\n public const int MaxStringBytes = 256 * 1024;\r\n\r\n /// <summary>Longest property name, in characters.</summary>\r\n public const int MaxKeyLength = 200;\r\n\r\n /// <summary>Longest component type name, in characters.</summary>\r\n public const int MaxTypeNameLength = 200;\r\n\r\n /// <summary>\r\n /// The reserved (<c>__</c>) property names the engine and this library are known to write. Observed in the scenes and\r\n /// prefabs installed with s&box: <c>__guid</c>, <c>__version</c>, <c>__type</c>, <c>__enabled</c>, <c>__references</c>,\r\n /// <c>__Prefab</c>, <c>__PrefabInstancePatch</c>, <c>__PrefabIdToInstanceId</c> (scenes) and <c>__properties</c>,\r\n /// <c>__variables</c> (prefabs). Read or written by the engine's own serializer (GameObject and Component in\r\n /// Sandbox.Engine): <c>__PrefabVariables</c>, <c>__EditorPrefabNestedInstance</c>, <c>__EditorSkipPrefabBreakOnRefresh</c>,\r\n /// <c>__NetworkedPrefaInstance</c> (the engine's spelling), <c>__snapshot</c>. <c>__PrefabVariant</c> is listed because\r\n /// the engine is expected to write it, though no installed sample carries it. <see cref=\"SiblingOrder.Key\"/> is the library's own.\r\n /// A name outside this set is not refused (a newer engine may add one, and refusing it would stop the session's work);\r\n /// <see cref=\"FindUnknownReservedKeys\"/> reports it so it can be counted.\r\n /// </summary>\r\n public static readonly IReadOnlySet<string> KnownReservedKeys = new HashSet<string>( StringComparer.Ordinal )\r\n {\r\n \"__guid\", \"__version\", \"__type\", \"__enabled\", \"__references\", \"__Prefab\", \"__PrefabInstancePatch\", \"__PrefabIdToInstanceId\",\r\n \"__properties\", \"__variables\", \"__PrefabVariables\", \"__EditorPrefabNestedInstance\", \"__EditorSkipPrefabBreakOnRefresh\",\r\n \"__NetworkedPrefaInstance\", \"__snapshot\", \"__PrefabVariant\", SiblingOrder.Key\r\n };\r\n\r\n public static bool Equal(ObjectState a, ObjectState b)\r\n {\r\n return ((object)a == null) ? ((object)b == null) : ((object)b != null && a.Id == b.Id && a.Parent == b.Parent && ValuesEqual(a.Data, b.Data));\r\n }\r\n\r\n /// <summary>\r\n /// Structural equality of two scene values, tolerant of the engine's 32-bit float round trip. It matches\r\n /// <see cref=\"JsonNode.DeepEquals\"/> (objects equal whatever their key order but only with the same key set, arrays element by element in\r\n /// order, null equal to null, strings and booleans exact) except for numbers. Two numbers that both read as a\r\n /// <c>long</c> are compared as longs, so integers above 2^53 stay exact; otherwise two numbers are equal when they are\r\n /// the same 32-bit float, which makes <c>0.6</c> equal to <c>0.600000024</c> (what the editor writes back) and <c>1</c>\r\n /// equal to <c>1.0</c>. Without this every echo of a float property looked like an edit by somebody else.\r\n /// </summary>\r\n public static bool ValuesEqual( JsonNode a, JsonNode b )\r\n {\r\n if ( (object)a == (object)b )\r\n {\r\n return true;\r\n }\r\n if ( a is null || b is null )\r\n {\r\n return false;\r\n }\r\n if ( a is JsonObject objectA )\r\n {\r\n if ( b is not JsonObject objectB || objectA.Count != objectB.Count )\r\n {\r\n return false;\r\n }\r\n foreach ( KeyValuePair<string, JsonNode> member in objectA )\r\n {\r\n if ( !objectB.TryGetPropertyValue( member.Key, out JsonNode other ) || !ValuesEqual( member.Value, other ) )\r\n {\r\n return false;\r\n }\r\n }\r\n return true;\r\n }\r\n if ( a is JsonArray arrayA )\r\n {\r\n if ( b is not JsonArray arrayB || arrayA.Count != arrayB.Count )\r\n {\r\n return false;\r\n }\r\n for ( int i = 0; i < arrayA.Count; i++ )\r\n {\r\n if ( !ValuesEqual( arrayA[i], arrayB[i] ) )\r\n {\r\n return false;\r\n }\r\n }\r\n return true;\r\n }\r\n if ( b is JsonObject || b is JsonArray )\r\n {\r\n return false;\r\n }\r\n if ( a.GetValueKind() == System.Text.Json.JsonValueKind.Number && b.GetValueKind() == System.Text.Json.JsonValueKind.Number )\r\n {\r\n return NumbersEqual( a, b );\r\n }\r\n return JsonNode.DeepEquals( a, b );\r\n }\r\n\r\n // Reads the numbers through their JSON text, which is the same whether a node was parsed off the wire or built in code\r\n // (a JsonValue made from an int does not report TryGetValue<long>). Exactly equal numbers are settled first, so the\r\n // common unchanged case never parses anything.\r\n private static bool NumbersEqual( JsonNode a, JsonNode b )\r\n {\r\n if ( JsonNode.DeepEquals( a, b ) )\r\n {\r\n return true;\r\n }\r\n string textA = a.ToJsonString();\r\n string textB = b.ToJsonString();\r\n System.Globalization.CultureInfo invariant = System.Globalization.CultureInfo.InvariantCulture;\r\n if ( long.TryParse( textA, System.Globalization.NumberStyles.AllowLeadingSign, invariant, out long longA ) && long.TryParse( textB, System.Globalization.NumberStyles.AllowLeadingSign, invariant, out long longB ) )\r\n {\r\n return longA == longB;\r\n }\r\n if ( double.TryParse( textA, System.Globalization.NumberStyles.Float, invariant, out double doubleA ) && double.TryParse( textB, System.Globalization.NumberStyles.Float, invariant, out double doubleB ) )\r\n {\r\n return (float)doubleA == (float)doubleB;\r\n }\r\n return false;\r\n }\r\n\r\n public static Dictionary<string, ObjectState> Map(IEnumerable<ObjectState> objects)\r\n {\r\n return objects.ToDictionary((ObjectState x) => x.Id);\r\n }\r\n\r\n public static List<Change> Diff(Dictionary<string, ObjectState> before, Dictionary<string, ObjectState> after)\r\n {\r\n return (from id in before.Keys.Union(after.Keys)\r\n orderby id\r\n select new Change(id, before.GetValueOrDefault(id), after.GetValueOrDefault(id)) into x\r\n where !Equal(x.Before, x.After)\r\n select x).ToList();\r\n }\r\n\r\n /// <summary>Throws the formal refusal of <see cref=\"RefusalOf\"/> when there is one.</summary>\r\n public static void ValidateChange( Change c )\r\n {\r\n string refusal = RefusalOf( c );\r\n if ( refusal != null )\r\n {\r\n throw new InvalidOperationException( refusal );\r\n }\r\n }\r\n\r\n /// <summary>\r\n /// Why <paramref name=\"c\"/> may not be sent or applied, as a formal sentence naming the object, or null when it may. Only\r\n /// structure is checked: the change is a creation (no Before), an edit or a deletion (no After) of one identified object;\r\n /// an edit keeps its identity; and the state that would be sent (After) is a well-formed object within the size limits.\r\n /// Any property, any component type and any component property are allowed, as are adding, removing and reordering\r\n /// components. Before is held to identity only: it is the state the host already holds, so an object that is already too\r\n /// large can still be shrunk or deleted.\r\n /// </summary>\r\n public static string RefusalOf( Change c )\r\n {\r\n if ( c is null || ( (object)c.Before == null && (object)c.After == null ) )\r\n {\r\n return \"An empty change was not sent.\";\r\n }\r\n foreach ( ObjectState state in new[] { c.Before, c.After } )\r\n {\r\n if ( (object)state != null && !HasIdentity( c.Id, state ) )\r\n {\r\n return \"The object \\\"\" + NameOf( state, c.Id ) + \"\\\" does not have a valid identifier and was not sent.\";\r\n }\r\n }\r\n return (object)c.After == null ? null : RefusalOfState( c.After );\r\n }\r\n\r\n // The id is a GUID, the state carries that id and its data names the same one in __guid, so no edit changes an identity.\r\n private static bool HasIdentity( string id, ObjectState state )\r\n {\r\n return Guid.TryParse( id, out _ )\r\n && string.Equals( state.Id, id, StringComparison.Ordinal )\r\n && state.Data is not null\r\n && state.Data[\"__guid\"] is JsonValue guid && guid.TryGetValue( out string text ) && string.Equals( text, id, StringComparison.Ordinal );\r\n }\r\n\r\n // The structural rules on one state that is about to be sent. The tree walk comes first: it bounds the depth, so the\r\n // serialisation that measures the size cannot fail on a payload nested too deep to write.\r\n private static string RefusalOfState( ObjectState state )\r\n {\r\n string name = \"The object \\\"\" + NameOf( state, state.Id ) + \"\\\" \";\r\n string walk = RefusalOfTree( state.Data, 1 );\r\n if ( walk != null )\r\n {\r\n return name + walk;\r\n }\r\n if ( state.Data.TryGetPropertyValue( \"Components\", out JsonNode list ) )\r\n {\r\n if ( list is not JsonArray components )\r\n {\r\n return name + \"has a component list that is not a list and was not sent.\";\r\n }\r\n if ( components.Count > MaxComponents )\r\n {\r\n return name + \"has more than \" + MaxComponents + \" components, beyond the collaboration limit, and was not sent.\";\r\n }\r\n HashSet<string> guids = new HashSet<string>( StringComparer.Ordinal );\r\n foreach ( JsonNode entry in components )\r\n {\r\n if ( entry is not JsonObject component )\r\n {\r\n return name + \"has a component entry that is not an object and was not sent.\";\r\n }\r\n if ( component[\"__type\"] is not JsonValue typeValue || !typeValue.TryGetValue( out string type ) || string.IsNullOrEmpty( type ) )\r\n {\r\n return name + \"has a component without a type name and was not sent.\";\r\n }\r\n if ( type.Length > MaxTypeNameLength )\r\n {\r\n return name + \"has a component type name longer than \" + MaxTypeNameLength + \" characters and was not sent.\";\r\n }\r\n if ( component[\"__guid\"] is not JsonValue guidValue || !guidValue.TryGetValue( out string guid ) || !Guid.TryParse( guid, out _ ) )\r\n {\r\n return name + \"has a component without a valid identifier and was not sent.\";\r\n }\r\n if ( !guids.Add( guid ) )\r\n {\r\n return name + \"has two components with the identifier \" + guid + \" and was not sent.\";\r\n }\r\n }\r\n }\r\n if ( System.Text.Json.JsonSerializer.SerializeToUtf8Bytes( state ).Length > MaxObjectBytes )\r\n {\r\n return name + \"is larger than the \" + MaxObjectBytes / ( 1024 * 1024 ) + \" MiB collaboration limit and was not sent.\";\r\n }\r\n return null;\r\n }\r\n\r\n // Depth, property names and text sizes, everywhere in the tree. `depth` is the level of `node` (the data object is 1).\r\n private static string RefusalOfTree( JsonNode node, int depth )\r\n {\r\n if ( node is JsonObject obj )\r\n {\r\n if ( depth > MaxDataDepth )\r\n {\r\n return DepthRefusal();\r\n }\r\n foreach ( KeyValuePair<string, JsonNode> member in obj )\r\n {\r\n if ( member.Key.Length == 0 )\r\n {\r\n return \"contains an empty property name and was not sent.\";\r\n }\r\n if ( member.Key.Length > MaxKeyLength )\r\n {\r\n return \"contains a property name longer than \" + MaxKeyLength + \" characters and was not sent.\";\r\n }\r\n string inner = RefusalOfTree( member.Value, depth + 1 );\r\n if ( inner != null )\r\n {\r\n return inner;\r\n }\r\n }\r\n return null;\r\n }\r\n if ( node is JsonArray array )\r\n {\r\n if ( depth > MaxDataDepth )\r\n {\r\n return DepthRefusal();\r\n }\r\n foreach ( JsonNode item in array )\r\n {\r\n string inner = RefusalOfTree( item, depth + 1 );\r\n if ( inner != null )\r\n {\r\n return inner;\r\n }\r\n }\r\n return null;\r\n }\r\n if ( node is JsonValue value && value.TryGetValue( out string text ) && System.Text.Encoding.UTF8.GetByteCount( text ) > MaxStringBytes )\r\n {\r\n // Geometry is moved out of the state before it gets here (BlobRules.Externalize); what is still inline is over the 32 MiB\r\n // limit, and the person is told that, not that \"a text value\" is too long.\r\n if ( BlobRules.IsCanonicalBase64( text ) && text.Length / 4 * 3 > BlobLimits.MaxBlobBytes )\r\n {\r\n return \"has geometry larger than the \" + BlobLimits.MaxBlobBytes / ( 1024 * 1024 ) + \" MiB collaboration limit and was not sent.\";\r\n }\r\n return \"contains a text value larger than the \" + MaxStringBytes / 1024 + \" KiB collaboration limit and was not sent.\";\r\n }\r\n return null;\r\n }\r\n\r\n private static string DepthRefusal() => \"is nested more than \" + MaxDataDepth + \" levels deep, beyond the collaboration limit, and was not sent.\";\r\n\r\n /// <summary>\r\n /// The reserved (<c>__</c>) property names anywhere in <paramref name=\"data\"/> that are not in <see cref=\"KnownReservedKeys\"/>,\r\n /// each once, in the order they are first met. They are allowed; the caller counts or logs them.\r\n /// </summary>\r\n public static List<string> FindUnknownReservedKeys( JsonObject data )\r\n {\r\n List<string> found = new List<string>();\r\n Visit( data );\r\n return found;\r\n\r\n void Visit( JsonNode node )\r\n {\r\n if ( node is JsonObject obj )\r\n {\r\n foreach ( KeyValuePair<string, JsonNode> member in obj )\r\n {\r\n if ( member.Key.StartsWith( \"__\", StringComparison.Ordinal ) && !KnownReservedKeys.Contains( member.Key ) && !found.Contains( member.Key ) )\r\n {\r\n found.Add( member.Key );\r\n }\r\n Visit( member.Value );\r\n }\r\n }\r\n else if ( node is JsonArray array )\r\n {\r\n foreach ( JsonNode item in array )\r\n {\r\n Visit( item );\r\n }\r\n }\r\n }\r\n }\r\n\r\n /// <summary>\r\n /// Splits the scan loop's batch into what is sent and what is held. A change that <see cref=\"RefusalOf\"/> refuses is held\r\n /// with its refusal, and so is every change that would leave the host's scene inconsistent without it: an object whose\r\n /// parent is held (or otherwise absent), and the deletion of an object that a held state still names as its parent. The\r\n /// rest is returned in the original order and, applied to <paramref name=\"known\"/>, always passes <see cref=\"ValidateGraph\"/>.\r\n /// Holding one object's change keeps the session running: the others are sent, and the held object stays as the session\r\n /// last confirmed it until its edit fits again.\r\n /// </summary>\r\n public static List<Change> SendableChanges( IReadOnlyDictionary<string, ObjectState> known, IEnumerable<Change> changes, List<HeldChange> held )\r\n {\r\n List<Change> all = changes.ToList();\r\n Dictionary<string, string> reasons = new Dictionary<string, string>( StringComparer.Ordinal );\r\n List<string> order = new List<string>();\r\n void Hold( Change change, string reason )\r\n {\r\n reasons[change.Id] = reason;\r\n order.Add( change.Id );\r\n }\r\n\r\n foreach ( Change change in all )\r\n {\r\n string refusal = RefusalOf( change );\r\n if ( refusal != null )\r\n {\r\n Hold( change, refusal );\r\n }\r\n }\r\n\r\n Dictionary<string, ObjectState> result = null;\r\n bool settled = false;\r\n while ( !settled )\r\n {\r\n settled = true;\r\n result = Result( known, all, reasons );\r\n foreach ( Change change in all.Where( x => !reasons.ContainsKey( x.Id ) ) )\r\n {\r\n if ( (object)change.After != null && !string.IsNullOrEmpty( change.After.Parent ) && !result.ContainsKey( change.After.Parent ) )\r\n {\r\n Hold( change, \"The object \\\"\" + NameOf( change.After, change.Id ) + \"\\\" was not sent because its parent \\\"\" + NameIn( all, known, change.After.Parent ) + \"\\\" was not sent.\" );\r\n settled = false;\r\n }\r\n else if ( (object)change.After == null && result.Values.FirstOrDefault( x => string.Equals( x.Parent, change.Id, StringComparison.Ordinal ) ) is { } child )\r\n {\r\n Hold( change, \"The deletion of \\\"\" + NameOf( change.Before, change.Id ) + \"\\\" was not sent because its child \\\"\" + NameOf( child, child.Id ) + \"\\\" was not sent.\" );\r\n settled = false;\r\n }\r\n }\r\n }\r\n\r\n // Anything the rules above did not foresee (a component identity used by two objects, a parenting cycle) holds the\r\n // whole batch rather than letting the host refuse it.\r\n try\r\n {\r\n ValidateGraph( result );\r\n }\r\n catch ( InvalidOperationException ex )\r\n {\r\n foreach ( Change change in all.Where( x => !reasons.ContainsKey( x.Id ) ).ToList() )\r\n {\r\n Hold( change, \"The change to \\\"\" + NameOf( change.After ?? change.Before, change.Id ) + \"\\\" was not sent because the scene would not stay consistent (\" + ex.Message + \").\" );\r\n }\r\n }\r\n\r\n foreach ( string id in order )\r\n {\r\n held.Add( new HeldChange( all.First( x => x.Id == id ), reasons[id] ) );\r\n }\r\n return all.Where( x => !reasons.ContainsKey( x.Id ) ).ToList();\r\n }\r\n\r\n private static Dictionary<string, ObjectState> Result( IReadOnlyDictionary<string, ObjectState> known, List<Change> all, Dictionary<string, string> reasons )\r\n {\r\n Dictionary<string, ObjectState> result = known.ToDictionary( x => x.Key, x => x.Value );\r\n foreach ( Change change in all.Where( x => !reasons.ContainsKey( x.Id ) ) )\r\n {\r\n if ( (object)change.After == null )\r\n {\r\n result.Remove( change.Id );\r\n }\r\n else\r\n {\r\n result[change.Id] = change.After;\r\n }\r\n }\r\n return result;\r\n }\r\n\r\n // The name of an object the batch or the session knows, for a sentence about it.\r\n private static string NameIn( List<Change> all, IReadOnlyDictionary<string, ObjectState> known, string id )\r\n {\r\n Change change = all.FirstOrDefault( x => x.Id == id );\r\n ObjectState state = change?.After ?? change?.Before ?? known.GetValueOrDefault( id );\r\n return (object)state == null ? id : NameOf( state, id );\r\n }\r\n\r\n /// <summary>The name a person recognises an object by (its Name, shortened when very long), or its id when it has none.</summary>\r\n public static string NameOf( ObjectState state, string id )\r\n {\r\n if ( state?.Data?[\"Name\"] is JsonValue value && value.TryGetValue( out string name ) && !string.IsNullOrEmpty( name ) )\r\n {\r\n return name.Length <= 100 ? name : name.Substring( 0, 100 ) + \"...\";\r\n }\r\n return id ?? state?.Id;\r\n }\r\n\r\n /// <summary>The notice for a component type the sender has and this project lacks; shown once per type.</summary>\r\n public static string MissingComponentTypeNotice( string type ) =>\r\n \"The component type \\\"\" + type + \"\\\" is not available in your project. Update your code to see it.\";\r\n\r\n /// <summary>The notice for an object that stopped being saved with the scene (for example, marked as not saved).</summary>\r\n public static string NoLongerSavedNotice( string name ) =>\r\n \"The object \\\"\" + name + \"\\\" can no longer be saved with the scene, so it was removed from the shared scene.\";\r\n\r\n public static void ValidateGraph(Dictionary<string, ObjectState> objects)\r\n {\r\n HashSet<string> hashSet = new HashSet<string>(objects.Keys);\r\n foreach (ObjectState value in objects.Values)\r\n {\r\n if (value.Data[\"Components\"] is JsonArray jsonArray)\r\n {\r\n foreach (JsonNode item in jsonArray)\r\n {\r\n string text = item?[\"__guid\"]?.GetValue<string>();\r\n if (text == null || !Guid.TryParse(text, out var _) || !hashSet.Add(text))\r\n {\r\n throw new InvalidOperationException(\"Duplicate/invalid component identity\");\r\n }\r\n }\r\n }\r\n HashSet<string> hashSet2 = new HashSet<string> { value.Id };\r\n string parent = value.Parent;\r\n while (!string.IsNullOrEmpty(parent))\r\n {\r\n if (!objects.ContainsKey(parent))\r\n {\r\n throw new InvalidOperationException(\"Parent does not exist\");\r\n }\r\n if (!hashSet2.Add(parent))\r\n {\r\n throw new InvalidOperationException(\"Parenting cycle\");\r\n }\r\n parent = objects[parent].Parent;\r\n }\r\n }\r\n }\r\n}\r\n\r\npublic sealed class Authority\r\n{\r\n private readonly Dictionary<string, Packet> receipts = new Dictionary<string, Packet>();\r\n\r\n private readonly Action<Packet> persist;\r\n\r\n public Dictionary<string, Dictionary<string, ObjectState>> Scenes { get; } = new Dictionary<string, Dictionary<string, ObjectState>>();\r\n\r\n /// <summary>\r\n /// The host's scene-level settings per scene, taken from the first subscribe that created the scene and changed only through\r\n /// <see cref=\"ApplySettings\"/>. A scene whose first subscriber sent none has no entry.\r\n /// </summary>\r\n public Dictionary<string, JsonObject> SceneSettings { get; } = new Dictionary<string, JsonObject>();\r\n\r\n public long Revision { get; private set; }\r\n\r\n public Authority(Action<Packet> persist)\r\n {\r\n this.persist = persist;\r\n }\r\n\r\n public Packet Subscribe(Packet p)\r\n {\r\n if (!Scenes.ContainsKey(p.Scene))\r\n {\r\n Dictionary<string, ObjectState> dictionary = StateRules.Map(p.Objects);\r\n StateRules.ValidateGraph(dictionary);\r\n persist(new Packet\r\n {\r\n Kind = \"seed\",\r\n Scene = p.Scene,\r\n Objects = p.Objects,\r\n Settings = p.Settings\r\n });\r\n Scenes.Add(p.Scene, dictionary);\r\n // No size bound here: the subscribe packet has already arrived whole, and refusing the settings would leave the scene with\r\n // none, which every later change would then replace wholesale. The bound applies to changes, which are rebroadcast.\r\n if (p.Settings != null)\r\n {\r\n SceneSettings[p.Scene] = SettingsMerge.Clone(p.Settings);\r\n }\r\n }\r\n // The snapshot is a copy: the same packet goes to a drift resync and, in-process, to tests that may change it.\r\n return new Packet\r\n {\r\n Kind = \"snapshot\",\r\n Scene = p.Scene,\r\n Objects = Scenes[p.Scene].Values.ToList(),\r\n Settings = SettingsMerge.Clone(SceneSettings.GetValueOrDefault(p.Scene)),\r\n Revision = Revision\r\n };\r\n }\r\n\r\n /// <summary>\r\n /// Applies one member's change to a scene's settings (<see cref=\"SettingsMerge\"/>): merged per top-level key against what the host holds,\r\n /// later change wins on a clash with the key named in <see cref=\"Packet.Notice\"/>. Returns the <c>settings</c> packet to broadcast (the\r\n /// merged settings, sender, id and new revision, also what the journal records) or a <c>reject</c> for the sender alone. A resent packet\r\n /// gets the receipt it got the first time, as <see cref=\"Apply\"/> does.\r\n /// </summary>\r\n public Packet ApplySettings(Packet p)\r\n {\r\n string key = p.Peer + \":\" + p.Id;\r\n if (receipts.TryGetValue(key, out var value))\r\n {\r\n return value;\r\n }\r\n try\r\n {\r\n if (p.Scene == null || !Scenes.ContainsKey(p.Scene))\r\n {\r\n throw new InvalidOperationException(\"Subscribe to scene first\");\r\n }\r\n if (p.Settings == null || p.SettingsBefore == null)\r\n {\r\n throw new InvalidOperationException(\"A scene settings change must carry the settings before and after the change.\");\r\n }\r\n string sizeError = SettingsMerge.SizeError(p.Settings) ?? SettingsMerge.SizeError(p.SettingsBefore);\r\n if (sizeError != null)\r\n {\r\n throw new InvalidOperationException(sizeError);\r\n }\r\n JsonObject merged;\r\n List<string> clashed;\r\n if (!SceneSettings.TryGetValue(p.Scene, out JsonObject current))\r\n {\r\n // Nothing to merge with: the scene's first subscriber reported no settings. The change is adopted whole.\r\n merged = SettingsMerge.Clone(p.Settings);\r\n clashed = new List<string>();\r\n }\r\n else if (!SettingsMerge.TryMerge(current, p.SettingsBefore, p.Settings, out merged, out clashed))\r\n {\r\n throw new InvalidOperationException(\"The scene settings change could not be merged with the host's settings.\");\r\n }\r\n sizeError = SettingsMerge.SizeError(merged);\r\n if (sizeError != null)\r\n {\r\n throw new InvalidOperationException(sizeError);\r\n }\r\n value = new Packet\r\n {\r\n Kind = SettingsMerge.PacketKind,\r\n Id = p.Id,\r\n Peer = p.Peer,\r\n Scene = p.Scene,\r\n Settings = SettingsMerge.Clone(merged),\r\n Revision = Revision + 1,\r\n Merged = clashed.Count > 0 || !StateRules.ValuesEqual(merged, p.Settings),\r\n Notice = SettingsMerge.ClashNotice(clashed)\r\n };\r\n persist(value);\r\n Revision++;\r\n SceneSettings[p.Scene] = merged;\r\n }\r\n catch (Exception ex)\r\n {\r\n value = new Packet\r\n {\r\n Kind = \"reject\",\r\n Id = p.Id,\r\n Scene = p.Scene,\r\n Error = ex.Message\r\n };\r\n }\r\n receipts[key] = value;\r\n return value;\r\n }\r\n\r\n public Packet Apply(Packet p)\r\n {\r\n string key = p.Peer + \":\" + p.Id;\r\n if (receipts.TryGetValue(key, out var value))\r\n {\r\n return value;\r\n }\r\n try\r\n {\r\n if (!Scenes.TryGetValue(p.Scene, out var value2))\r\n {\r\n throw new InvalidOperationException(\"Subscribe to scene first\");\r\n }\r\n if (p.Changes == null || (p.Changes.Count == 0 && !p.Final))\r\n {\r\n throw new InvalidOperationException(\"Empty operation\");\r\n }\r\n if (p.Changes.Select((Change x) => x.Id).Distinct().Count() != p.Changes.Count)\r\n {\r\n throw new InvalidOperationException(\"Duplicate object in operation\");\r\n }\r\n Dictionary<string, ObjectState> dictionary = new Dictionary<string, ObjectState>(value2);\r\n List<Change> applied = new List<Change>();\r\n List<string> notices = new List<string>();\r\n foreach (Change change in p.Changes)\r\n {\r\n StateRules.ValidateChange(change);\r\n Change change2 = Reconcile(change, value2.GetValueOrDefault(change.Id), notices);\r\n if ((object)change2.After == null)\r\n {\r\n dictionary.Remove(change2.Id);\r\n }\r\n else\r\n {\r\n dictionary[change2.Id] = change2.After;\r\n }\r\n applied.Add(change2);\r\n }\r\n StateRules.ValidateGraph(dictionary);\r\n // The packet carries what the host applied, not what the sender sent: every follower's Before is then the state it\r\n // already holds, and the journal replays to the host's scene. Merged is set when the host's result is not simply the\r\n // sender's edit: some applied After differs from the one sent, or the outcome needed a notice (a clash resolved in the\r\n // sender's favour still leaves After equal to the one sent, yet the people in the session must be told).\r\n bool merged = notices.Count > 0;\r\n for (int i = 0; i < applied.Count; i++)\r\n {\r\n merged |= !StateRules.Equal(applied[i].After, p.Changes[i].After);\r\n }\r\n value = new Packet\r\n {\r\n Kind = \"accepted\",\r\n Id = p.Id,\r\n Peer = p.Peer,\r\n Scene = p.Scene,\r\n Changes = applied,\r\n Revision = Revision + 1,\r\n Final = p.Final,\r\n Merged = merged,\r\n Notice = (notices.Count == 0) ? null : string.Join(\" \", notices)\r\n };\r\n persist(value);\r\n Revision++;\r\n Scenes[p.Scene] = dictionary;\r\n }\r\n catch (Exception ex)\r\n {\r\n value = new Packet\r\n {\r\n Kind = \"reject\",\r\n Id = p.Id,\r\n Scene = p.Scene,\r\n Error = ex.Message\r\n };\r\n }\r\n receipts[key] = value;\r\n return value;\r\n }\r\n\r\n // Decides what the host applies for one incoming change, given the object as the host holds it now (null if absent). An edit\r\n // built on the host's current state is applied as sent. Otherwise somebody else changed the object in the meantime, and the\r\n // rule is: the later edit (the one processed second) wins, per property, and a clash is reported in `notices` rather than\r\n // refused. Only the unambiguous cases are resolved; anything else keeps the conflict refusal.\r\n private static Change Reconcile(Change change, ObjectState current, List<string> notices)\r\n {\r\n if (StateRules.Equal(current, change.Before))\r\n {\r\n return change;\r\n }\r\n // A creation of an id that already exists is two objects claiming one identity, not an edit to merge.\r\n if ((object)change.Before == null)\r\n {\r\n throw Conflict(change);\r\n }\r\n Change applied;\r\n if ((object)change.After == null)\r\n {\r\n // Deleting an object that someone edited meanwhile: the deletion wins. Deleting one that is already gone has\r\n // nothing to apply, so it stays a conflict.\r\n if ((object)current == null)\r\n {\r\n throw Conflict(change);\r\n }\r\n applied = new Change(change.Id, current, null);\r\n notices.Add(\"The later deletion of \\\"\" + DisplayName(current) + \"\\\" was kept; a concurrent edit to the same object was discarded.\");\r\n }\r\n else if ((object)current == null)\r\n {\r\n // Editing an object that was deleted meanwhile: the later edit wins and the object comes back as the editor left it.\r\n applied = new Change(change.Id, null, change.After);\r\n notices.Add(\"A concurrent deletion of \\\"\" + DisplayName(change.After) + \"\\\" was overridden; the later edit was kept and the object was restored.\");\r\n }\r\n else\r\n {\r\n if (!PropertyMerge.TryMerge(current, change.Before, change.After, out ObjectState merged, out List<string> clashed))\r\n {\r\n throw Conflict(change);\r\n }\r\n applied = new Change(change.Id, current, merged);\r\n if (clashed.Count > 0)\r\n {\r\n notices.Add(\"Concurrent edits to \\\"\" + DisplayName(current) + \"\\\" were combined; the later edit was kept for: \" + string.Join(\", \", clashed) + \".\");\r\n }\r\n }\r\n // The applied change is what every peer will see, so it passes the same rules as an edit sent in one piece.\r\n StateRules.ValidateChange(applied);\r\n return applied;\r\n }\r\n\r\n private static InvalidOperationException Conflict(Change change)\r\n {\r\n return new InvalidOperationException(\"Conflict on \" + change.Id + \": first accepted edit wins. Your edit is retained in recovery.\");\r\n }\r\n\r\n // The name a person recognises the object by; the id when it has no readable name.\r\n private static string DisplayName(ObjectState state)\r\n {\r\n return (state.Data?[\"Name\"] is JsonValue jsonValue && jsonValue.TryGetValue(out string value) && !string.IsNullOrEmpty(value)) ? value : state.Id;\r\n }\r\n}\r\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Core/ReadinessRules.cs",
"FileName": "ReadinessRules.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Generic;\r\n\r\nnamespace TeamCreate;\r\n\r\npublic enum ReadinessLevel\r\n{\r\n Ok,\r\n Warning,\r\n Blocked\r\n}\r\n\r\npublic sealed record ReadinessItem(string Key, string Title, ReadinessLevel Level, string Detail, string Fix);\r\n\r\npublic sealed record ReadinessInput(bool SteamSignedIn, string SteamPersona, bool SceneSupported, bool ProjectPublished, bool PackageReachable, bool LibraryInstalled, Version LibraryInstalledVersion, Version LibraryRequiredVersion, bool IsOrgMember, CollaborationConfig Config, string ScenePath = null);\r\n\r\npublic static class ReadinessRules\r\n{\r\n public static List<ReadinessItem> Evaluate(ReadinessInput input)\r\n {\r\n CollaborationConfig config = input.Config ?? CollaborationConfig.Defaults();\r\n return new List<ReadinessItem>\r\n {\r\n EvaluateSteam(input),\r\n EvaluateScene(input),\r\n EvaluatePublished(input, config),\r\n EvaluateLibrary(input, config),\r\n EvaluateOrganization(input, config)\r\n };\r\n }\r\n\r\n public static bool CanStart(IReadOnlyList<ReadinessItem> items)\r\n {\r\n return items != null && items.Count > 0 && (object)FirstBlocked(items) == null;\r\n }\r\n\r\n public static string BlockReason(IReadOnlyList<ReadinessItem> items)\r\n {\r\n ReadinessItem readinessItem = FirstBlocked(items);\r\n if ((object)readinessItem == null)\r\n {\r\n return null;\r\n }\r\n string text = readinessItem.Title ?? string.Empty;\r\n string text2 = ((text.Length == 0) ? text : (char.ToLowerInvariant(text[0]) + text.Substring(1)));\r\n return \"Hosting is unavailable: \" + text2 + \".\";\r\n }\r\n\r\n private static ReadinessItem FirstBlocked(IReadOnlyList<ReadinessItem> items)\r\n {\r\n if (items == null)\r\n {\r\n return null;\r\n }\r\n foreach (ReadinessItem item in items)\r\n {\r\n if ((object)item != null && item.Level == ReadinessLevel.Blocked)\r\n {\r\n return item;\r\n }\r\n }\r\n return null;\r\n }\r\n\r\n private static ReadinessItem EvaluateSteam(ReadinessInput input)\r\n {\r\n return input.SteamSignedIn ? new ReadinessItem(\"steam\", \"Steam sign-in\", ReadinessLevel.Ok, input.SteamPersona, null) : new ReadinessItem(\"steam\", \"Steam sign-in required\", ReadinessLevel.Blocked, \"Sign in to Steam to identify this editor.\", null);\r\n }\r\n\r\n private static ReadinessItem EvaluateScene(ReadinessInput input)\r\n {\r\n return input.SceneSupported ? new ReadinessItem(\"scene\", \"Active scene\", ReadinessLevel.Ok, input.ScenePath, null) : new ReadinessItem(\"scene\", \"Saved scene required\", ReadinessLevel.Blocked, \"Open a saved scene under scenes/ before starting a session.\", null);\r\n }\r\n\r\n private static ReadinessItem EvaluatePublished(ReadinessInput input, CollaborationConfig config)\r\n {\r\n if (!config.RequirePublished)\r\n {\r\n return new ReadinessItem(\"published\", \"Project publication\", ReadinessLevel.Ok, \"Publication is not required for this project.\", null);\r\n }\r\n if (!input.ProjectPublished)\r\n {\r\n return new ReadinessItem(\"published\", \"Project publication required\", ReadinessLevel.Blocked, \"Publish this project to s&box (hidden or public) before hosting.\", null);\r\n }\r\n if (!input.PackageReachable)\r\n {\r\n return new ReadinessItem(\"published\", \"Project publication\", ReadinessLevel.Warning, \"The published copy could not be fetched from Asset Party; peers may not be able to install it.\", null);\r\n }\r\n return new ReadinessItem(\"published\", \"Project publication\", ReadinessLevel.Ok, \"The published copy is available from Asset Party.\", null);\r\n }\r\n\r\n private static ReadinessItem EvaluateLibrary(ReadinessInput input, CollaborationConfig config)\r\n {\r\n if (!input.LibraryInstalled)\r\n {\r\n return new ReadinessItem(\"library\", \"Library installation required\", ReadinessLevel.Blocked, \"Install the collaboration library from Asset Party.\", null);\r\n }\r\n Version version = input.LibraryRequiredVersion ?? config.LibraryVersion;\r\n Version libraryInstalledVersion = input.LibraryInstalledVersion;\r\n if ((object)version != null && ((object)libraryInstalledVersion == null || VersionRules.IsOlder(libraryInstalledVersion, version)))\r\n {\r\n return new ReadinessItem(\"library\", \"Library update required\", ReadinessLevel.Blocked, $\"Installed version {libraryInstalledVersion?.ToString() ?? \"unknown\"} is older than the required version {version}.\", \"Update\");\r\n }\r\n return new ReadinessItem(\"library\", \"Collaboration library\", ReadinessLevel.Ok, ((object)libraryInstalledVersion == null) ? \"The collaboration library is installed.\" : $\"Version {libraryInstalledVersion} installed.\", null);\r\n }\r\n\r\n private static ReadinessItem EvaluateOrganization(ReadinessInput input, CollaborationConfig config)\r\n {\r\n if (!config.RequireOrgMember)\r\n {\r\n return new ReadinessItem(\"org\", \"Organization membership\", ReadinessLevel.Ok, \"Organization membership is not required for this project.\", null);\r\n }\r\n return input.IsOrgMember ? new ReadinessItem(\"org\", \"Organization membership\", ReadinessLevel.Ok, \"You are a member of the organization \" + config.Org + \".\", null) : new ReadinessItem(\"org\", \"Organization membership required\", ReadinessLevel.Blocked, \"You are not a member of the organization \" + config.Org + \".\", null);\r\n }\r\n}\r\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Core/RelayDeployer.cs",
"FileName": "RelayDeployer.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Text.Json.Nodes;\nusing System.Threading;\nusing System.Threading.Tasks;\n\nnamespace TeamCreate;\n\npublic sealed record DeployResult(bool Ok, string RelayUrl, string Error, bool Cancelled = false);\n\n/// <summary>\n/// Puts the relay on the person's own free Cloudflare account, from inside the editor, with the token they created once. It follows the same calls\n/// Wrangler makes: find the account, make sure it has a workers.dev name, upload the Worker, switch its workers.dev address on, and wait until that address\n/// answers. <see cref=\"DeployResult.Ok\"/> is true only after the address answered, so a half-finished deploy is never reported as a relay to use.\n/// Running it again on an account that already has the relay updates it in place and keeps any access key the owner set.\n/// </summary>\npublic sealed class RelayDeployer\n{\n public const int Steps = 6;\n\n private const int HealthAttempts = 10;\n\n private const int SubdomainAttempts = 5;\n\n private readonly ICloudflareTransport transport;\n\n private readonly string workerSource;\n\n private readonly Func<string, string> healthUrlFor;\n\n private readonly Func<TimeSpan, CancellationToken, Task> delay;\n\n private readonly Func<string> newSubdomainName;\n\n private readonly string apiBase;\n\n /// <param name=\"healthUrlFor\">Maps the workers.dev host to the address to check. The editor checks the real address; a test points it at a stand-in.</param>\n /// <param name=\"delay\">How to wait between health checks. A test passes one that does not wait.</param>\n /// <param name=\"newSubdomainName\">Makes a candidate workers.dev name for an account that has none.</param>\n /// <param name=\"apiBase\">Cloudflare's API address. Only a test in a disposable copy ever passes another.</param>\n public RelayDeployer(ICloudflareTransport transport, string workerSource, Func<string, string> healthUrlFor = null,\n Func<TimeSpan, CancellationToken, Task> delay = null, Func<string> newSubdomainName = null, string apiBase = null)\n {\n this.apiBase = apiBase;\n this.transport = transport ?? throw new ArgumentNullException(nameof(transport));\n this.workerSource = string.IsNullOrEmpty(workerSource) ? throw new ArgumentException(\"The relay source is empty.\", nameof(workerSource)) : workerSource;\n this.healthUrlFor = healthUrlFor ?? (host => \"https://\" + host + \"/health\");\n this.delay = delay ?? ((span, token) => Task.Delay(span, token));\n this.newSubdomainName = newSubdomainName ?? DefaultSubdomainName;\n }\n\n public async Task<DeployResult> RunAsync(string token, Action<string> progress, CancellationToken cancellationToken)\n {\n progress ??= _ => { };\n try\n {\n return await Deploy(token, progress, cancellationToken);\n }\n catch (OperationCanceledException)\n {\n return new DeployResult(false, null, \"Setup was cancelled. Nothing was changed on this computer.\", Cancelled: true);\n }\n }\n\n private async Task<DeployResult> Deploy(string token, Action<string> progress, CancellationToken ct)\n {\n // 1. The account the token can use.\n progress(Step(1, \"finding the Cloudflare account\"));\n CloudflareResponse accounts = await Send(CloudflareApi.Request(\"GET\", \"/accounts?per_page=50\", token, apiBase: apiBase), ct);\n if (!(IsOk(accounts) && CloudflareApi.ResultOf(accounts.Body) is JsonArray list))\n {\n return Fail(\"finding the account\", accounts, token);\n }\n string accountId = null;\n string accountName = null;\n foreach (JsonNode item in list)\n {\n if (item is JsonObject account && account[\"id\"] is JsonValue id && id.TryGetValue(out string idText) && !string.IsNullOrWhiteSpace(idText))\n {\n accountId = idText;\n accountName = account[\"name\"] is JsonValue name && name.TryGetValue(out string nameText) ? nameText : null;\n break;\n }\n }\n if (accountId == null)\n {\n return new DeployResult(false, null, \"The token cannot see a Cloudflare account. Create it from the page the Open Cloudflare button shows and choose your account when it asks.\");\n }\n string root = \"/accounts/\" + Uri.EscapeDataString(accountId);\n progress(Step(1, \"using the account\" + (string.IsNullOrWhiteSpace(accountName) ? string.Empty : \" \\\"\" + accountName + \"\\\"\")));\n\n // 2. The account's workers.dev name, made if the account has none yet.\n progress(Step(2, \"checking the workers.dev name\"));\n CloudflareResponse subdomain = await Send(CloudflareApi.Request(\"GET\", root + \"/workers/subdomain\", token, apiBase: apiBase), ct);\n string host = null;\n if (IsOk(subdomain))\n {\n host = SubdomainHost(CloudflareApi.ResultOf(subdomain.Body));\n }\n else if (subdomain.Status != null && CloudflareApi.ErrorCode(subdomain.Body) == 10007)\n {\n SubdomainOutcome registered = await RegisterSubdomain(root, token, progress, ct);\n if (registered.Host == null)\n {\n return Fail(\"creating the workers.dev name\", registered.Failure, token);\n }\n host = registered.Host;\n }\n else\n {\n return Fail(\"checking the workers.dev name\", subdomain, token);\n }\n if (host == null)\n {\n return new DeployResult(false, null, \"Cloudflare did not report a workers.dev name for this account. Open the Workers page on dash.cloudflare.com once, choose a name, and try again.\");\n }\n\n // 3. Is the relay already there? Its migration tag decides whether the upload carries the one-time Durable Object migration.\n progress(Step(3, \"checking for an existing relay\"));\n CloudflareResponse existing = await Send(CloudflareApi.Request(\"GET\", root + \"/workers/services/\" + CloudflareApi.ScriptName, token, apiBase: apiBase), ct);\n bool exists = false;\n string appliedTag = null;\n if (IsOk(existing))\n {\n exists = true;\n appliedTag = TagOf(CloudflareApi.ResultOf(existing.Body));\n }\n else if (existing.Status == null || existing.Status != 404)\n {\n return Fail(\"checking for an existing relay\", existing, token);\n }\n bool sendMigrations = !string.Equals(appliedTag, CloudflareApi.MigrationTag, StringComparison.Ordinal);\n\n // 4. The Worker itself.\n progress(Step(4, exists ? \"updating the relay\" : \"uploading the relay\"));\n using (var content = CloudflareApi.UploadContent(workerSource, sendMigrations, keepSecrets: exists))\n {\n CloudflareResponse upload = await Send(CloudflareApi.Request(\"PUT\", root + \"/workers/scripts/\" + CloudflareApi.ScriptName + CloudflareApi.UploadQuery, token, content, apiBase: apiBase), ct);\n if (!IsOk(upload))\n {\n return Fail(\"uploading the relay\", upload, token);\n }\n }\n\n // 5. Switch its workers.dev address on.\n progress(Step(5, \"switching the relay's address on\"));\n using (var body = CloudflareApi.Json(new JsonObject { [\"enabled\"] = true }))\n {\n CloudflareResponse route = await Send(CloudflareApi.Request(\"POST\", root + \"/workers/scripts/\" + CloudflareApi.ScriptName + \"/subdomain\", token, body, withScriptApiDate: true, apiBase: apiBase), ct);\n if (!IsOk(route))\n {\n return Fail(\"switching the relay's address on\", route, token);\n }\n }\n\n // 6. Wait until the address answers. A new Worker takes a few seconds to appear.\n string workerHost = CloudflareApi.ScriptName + \".\" + host;\n string healthUrl = healthUrlFor(workerHost);\n for (int attempt = 1; attempt <= HealthAttempts; attempt++)\n {\n progress(Step(6, \"waiting for the relay to answer (\" + attempt + \" of \" + HealthAttempts + \")\"));\n CloudflareResponse health = await Send(new CloudflareRequest { Method = \"GET\", Url = healthUrl }, ct);\n if (health.Status == 200 && IsRelayHealth(health.Body))\n {\n return new DeployResult(true, CloudflareApi.RelayUrlFor(workerHost), null);\n }\n if (attempt < HealthAttempts)\n {\n await delay(TimeSpan.FromSeconds(3), ct);\n }\n }\n return new DeployResult(false, null, \"The relay was uploaded but \" + workerHost + \" did not answer within about thirty seconds. It is not in use. Try again in a minute; nothing needs to be cleaned up.\");\n }\n\n private sealed record SubdomainOutcome(string Host, CloudflareResponse Failure);\n\n private async Task<SubdomainOutcome> RegisterSubdomain(string root, string token, Action<string> progress, CancellationToken ct)\n {\n CloudflareResponse failure = null;\n for (int attempt = 0; attempt < SubdomainAttempts; attempt++)\n {\n string candidate = newSubdomainName();\n progress(Step(2, \"creating the workers.dev name \\\"\" + candidate + \"\\\"\"));\n using var body = CloudflareApi.Json(new JsonObject { [\"subdomain\"] = candidate });\n CloudflareResponse created = await Send(CloudflareApi.Request(\"PUT\", root + \"/workers/subdomain\", token, body, apiBase: apiBase), ct);\n if (IsOk(created))\n {\n return new SubdomainOutcome(SubdomainHost(CloudflareApi.ResultOf(created.Body)) ?? candidate + \".workers.dev\", null);\n }\n failure = created;\n // 10031: the name is taken. Any other answer will not change with another name.\n if (created.Status == null || CloudflareApi.ErrorCode(created.Body) != 10031)\n {\n return new SubdomainOutcome(null, failure);\n }\n }\n return new SubdomainOutcome(null, failure);\n }\n\n private async Task<CloudflareResponse> Send(CloudflareRequest request, CancellationToken ct)\n {\n ct.ThrowIfCancellationRequested();\n CloudflareResponse response = await transport.SendAsync(request, ct);\n ct.ThrowIfCancellationRequested();\n return response ?? new CloudflareResponse(null, null, \"no answer\");\n }\n\n private static bool IsOk(CloudflareResponse response)\n {\n return response != null && response.Status is >= 200 and < 300 && CloudflareApi.ResultOf(response.Body) != null;\n }\n\n private static DeployResult Fail(string step, CloudflareResponse response, string token)\n {\n return new DeployResult(false, null, CloudflareApi.Explain(step, response, token));\n }\n\n private static string Step(int number, string text)\n {\n return \"Step \" + number + \" of \" + Steps + \": \" + text + \"\u2026\";\n }\n\n private static string SubdomainHost(JsonNode result)\n {\n if (result is JsonObject obj && obj[\"subdomain\"] is JsonValue value && value.TryGetValue(out string name) && !string.IsNullOrWhiteSpace(name))\n {\n return name.Trim() + \".workers.dev\";\n }\n return null;\n }\n\n private static string TagOf(JsonNode result)\n {\n if (result is JsonObject obj && obj[\"default_environment\"] is JsonObject environment && environment[\"script\"] is JsonObject script\n && script[\"migration_tag\"] is JsonValue tag && tag.TryGetValue(out string text))\n {\n return text;\n }\n return null;\n }\n\n private static bool IsRelayHealth(string body)\n {\n try\n {\n return JsonNode.Parse(body ?? string.Empty) is JsonObject obj && obj[\"status\"]?.GetValue<string>() == \"ok\" && obj[\"service\"]?.GetValue<string>() == \"collab-relay\";\n }\n catch (Exception ex) when (ex is System.Text.Json.JsonException || ex is InvalidOperationException || ex is FormatException)\n {\n return false;\n }\n }\n\n /// <summary>A name like <c>collab-k3x9q2</c>: readable, unlikely to collide, and changeable later in the Cloudflare dashboard.</summary>\n private static string DefaultSubdomainName()\n {\n const string alphabet = \"abcdefghijkmnpqrstuvwxyz23456789\";\n var bytes = new byte[6];\n System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);\n var chars = new char[bytes.Length];\n for (int i = 0; i < bytes.Length; i++)\n {\n chars[i] = alphabet[bytes[i] % alphabet.Length];\n }\n return \"collab-\" + new string(chars);\n }\n}\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Core/SessionServer.Play.cs",
"FileName": "SessionServer.Play.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace TeamCreate;\n\n/// <summary>\n/// Shared play on the host (one run, many spectators). The host is a relay and a referee, nothing more: it keeps the <see cref=\"PlayRoster\"/>, tells\n/// the opted-in members who plays, and forwards the player's frames to the spectators. It never reads a frame's content (every spectator checks it\n/// again) and never touches the game's networking: these are collaboration packets on the collaboration connection.\n/// </summary>\npublic sealed partial class SessionServer\n{\n private readonly PlayRoster playRoster = new PlayRoster();\n\n private readonly HashSet<Member> playOptIn = new HashSet<Member>();\n\n private readonly Dictionary<Member, bool> playBehind = new Dictionary<Member, bool>();\n\n private readonly System.Diagnostics.Stopwatch playClock = System.Diagnostics.Stopwatch.StartNew();\n\n private double lastKeyframeAskMs = double.NegativeInfinity;\n\n /// <summary>\n /// The host's own setting. Off (the default) the host does not advertise shared play, ignores its messages without an error, and behaves exactly as\n /// before. On, it relays; a guest still takes part only after it sends <c>play-optin</c>.\n /// </summary>\n public bool SharedPlayEnabled { get; set; }\n\n /// <summary>The peer id of the member playing, or null. For the host's own dock.</summary>\n public string PlayingPeer => playRoster.Player;\n\n /// <summary>The queued backlog of a peer's connection, or null to read the wire's own counters. Tests replace it to simulate a slow spectator.</summary>\n public Func<string, (long Bytes, int Messages)> PlayBacklogProbe { get; set; }\n\n private bool HandlePlayPacket( Member member, Packet packet )\n {\n if ( packet.Kind is not ( \"play-optin\" or \"play-start\" or \"play-stop\" or \"play-frame\" or \"play-key\" ) )\n {\n return false;\n }\n if ( !SharedPlayEnabled )\n {\n return true;\n }\n switch ( packet.Kind )\n {\n case \"play-optin\":\n playOptIn.Add( member );\n if ( playRoster.IsPlaying )\n {\n SendQuietly( member, StartPacket() );\n AskForKeyframe();\n }\n break;\n case \"play-start\":\n if ( playOptIn.Contains( member ) )\n {\n StartRun( member, packet );\n }\n break;\n case \"play-stop\":\n {\n string run = playRoster.RunId;\n if ( playRoster.Stop( member.Peer, packet.RuntimeRunId ) )\n {\n BroadcastPlay( StopPacket( member.Peer, run ), except: null );\n }\n break;\n }\n case \"play-frame\":\n if ( playOptIn.Contains( member ) && packet.Play != null && playRoster.IsPlayer( member.Peer, packet.RuntimeRunId ) )\n {\n RelayFrame( member, packet );\n }\n break;\n case \"play-key\":\n if ( playOptIn.Contains( member ) && playRoster.IsPlaying )\n {\n AskForKeyframe();\n }\n break;\n }\n return true;\n }\n\n private void StartRun( Member member, Packet request )\n {\n PlayStartResult result = playRoster.TryStart( member.Peer, member.Name, request.Scene, () => Guid.NewGuid().ToString( \"N\" ) );\n switch ( result.Verdict )\n {\n case PlayStartVerdict.Started:\n foreach ( Member behind in playBehind.Keys.ToList() ) playBehind[behind] = false;\n BroadcastPlay( StartPacket(), except: null );\n break;\n case PlayStartVerdict.Refused:\n SendQuietly( member, new Packet { Kind = \"play-refused\", Notice = result.Notice, Peer = result.Player } );\n break;\n default:\n SendQuietly( member, new Packet { Kind = \"play-refused\", Notice = \"The play request was not valid. Save the scene under Assets/scenes and try again. Your run is local and is not shown to the others.\" } );\n break;\n }\n }\n\n private Packet StartPacket() => new Packet\n {\n Kind = \"play-start\", Peer = playRoster.Player, Name = playRoster.PlayerName, Scene = playRoster.Scene, RuntimeRunId = playRoster.RunId,\n };\n\n private static Packet StopPacket( string peer, string run ) => new Packet { Kind = \"play-stop\", Peer = peer, RuntimeRunId = run };\n\n private void BroadcastPlay( Packet packet, Member except )\n {\n foreach ( Member target in playOptIn.Where( m => m != except && members.Contains( m ) ).ToList() )\n {\n SendQuietly( target, packet );\n }\n }\n\n /// <summary>A play message must never cost a member its session or the host a broadcast: a failed send is left for the pump's own failure handling.</summary>\n private static void SendQuietly( Member target, Packet packet )\n {\n try\n {\n target.Wire.Send( packet );\n }\n catch ( Exception )\n {\n }\n }\n\n private void RelayFrame( Member player, Packet frame )\n {\n bool isKey = frame.Play[\"k\"] is System.Text.Json.Nodes.JsonValue k && k.TryGetValue( out bool key ) && key;\n foreach ( Member spectator in playOptIn.Where( m => m != player && members.Contains( m ) ).ToList() )\n {\n (long bytes, int messages) = PlayBacklogProbe != null ? PlayBacklogProbe( spectator.Peer ) : (spectator.Wire.PendingBytes, spectator.Wire.PendingMessages);\n bool wasBehind = playBehind.TryGetValue( spectator, out bool b ) && b;\n bool send = PlayRelayPolicy.ShouldSend( wasBehind, isKey, bytes, messages, out bool nowBehind );\n playBehind[spectator] = nowBehind;\n if ( nowBehind && !wasBehind )\n {\n AskForKeyframe();\n }\n if ( send )\n {\n SendQuietly( spectator, frame );\n }\n }\n }\n\n // Spectators that ask at the same moment (a late joiner, a gap on two screens) cost the player one keyframe, not one each.\n private void AskForKeyframe()\n {\n double now = playClock.Elapsed.TotalMilliseconds;\n if ( !playRoster.IsPlaying || now - lastKeyframeAskMs < PlayRelayPolicy.KeyframeAskIntervalMs )\n {\n return;\n }\n Member player = members.FirstOrDefault( m => m.Peer == playRoster.Player );\n if ( player == null )\n {\n return;\n }\n lastKeyframeAskMs = now;\n SendQuietly( player, new Packet { Kind = \"play-key\", RuntimeRunId = playRoster.RunId } );\n }\n\n /// <summary>A member left or dropped: it no longer relays, and when it was the player the run ends for everyone.</summary>\n private void ForgetPlayMember( Member member )\n {\n playOptIn.Remove( member );\n playBehind.Remove( member );\n if ( member.Peer == null )\n {\n return;\n }\n string run = playRoster.RunId;\n if ( playRoster.PeerLeft( member.Peer ) )\n {\n BroadcastPlay( StopPacket( member.Peer, run ), except: null );\n }\n }\n}\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/SoloTestLauncher.cs",
"FileName": "SoloTestLauncher.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\nusing System.Diagnostics;\nusing System.IO;\nusing System.Linq;\nusing Editor;\n\nnamespace TeamCreate;\n\n/// <summary>Opens the disposable copy in a second editor window. It only ever launches a folder that <see cref=\"SoloCopy\"/> made.</summary>\npublic static class SoloTestLauncher\n{\n private static string ExecutablePath()\n {\n try\n {\n string self = Process.GetCurrentProcess().MainModule?.FileName;\n return !string.IsNullOrEmpty(self) && File.Exists(self) ? self : null;\n }\n catch (Exception)\n {\n return null;\n }\n }\n\n /// <summary>\n /// Memory the system can hand out right now: physical memory not in use, including the standby cache. Read from the runtime's own view of system\n /// memory load, so it needs no native call. Null when the runtime cannot say.\n /// </summary>\n public static long? AvailableMemoryBytes()\n {\n try\n {\n GCMemoryInfo info = GC.GetGCMemoryInfo();\n if (info.TotalAvailableMemoryBytes <= 0 || info.MemoryLoadBytes <= 0)\n {\n return null;\n }\n return Math.Max(0, info.TotalAvailableMemoryBytes - info.MemoryLoadBytes);\n }\n catch (Exception)\n {\n return null;\n }\n }\n\n public const string HeartbeatFile = \"solo-alive.txt\";\n\n /// <summary>\n /// True when the copy's own editor has reported in recently. The copy writes its process id and the time every few seconds\n /// (<see cref=\"Beat\"/>); a stale file, or an id that is no longer a running process, means the window is closed.\n /// </summary>\n public static bool IsRunning(string copyRoot)\n {\n try\n {\n string path = Path.Combine(copyRoot, \".collaboration\", HeartbeatFile);\n if (!File.Exists(path))\n {\n return false;\n }\n string[] lines = File.ReadAllLines(path);\n if (lines.Length < 2 || !int.TryParse(lines[0], out int pid) || !DateTime.TryParse(lines[1], null, System.Globalization.DateTimeStyles.RoundtripKind, out DateTime at))\n {\n return false;\n }\n if ((DateTime.UtcNow - at).TotalSeconds > 20)\n {\n return false;\n }\n using (Process process = Process.GetProcessById(pid))\n {\n return !process.HasExited;\n }\n }\n catch (Exception)\n {\n return false;\n }\n }\n\n private static DateTime nextBeat;\n\n /// <summary>Called from the copy's frame tick.</summary>\n public static void Beat(string root)\n {\n if (DateTime.UtcNow < nextBeat)\n {\n return;\n }\n nextBeat = DateTime.UtcNow.AddSeconds(5);\n try\n {\n File.WriteAllLines(Path.Combine(root, \".collaboration\", HeartbeatFile), new[] { Environment.ProcessId.ToString(), DateTime.UtcNow.ToString(\"o\") });\n }\n catch (Exception)\n {\n }\n }\n\n /// <summary>Returns null on success, or a sentence saying what went wrong.</summary>\n public static string Launch(string copyRoot)\n {\n if (!TestHarnessRules.IsDisposableCopy(copyRoot, SoloCopy.HasMarker(copyRoot)))\n {\n return \"That folder is not a solo test copy, so it was not opened.\";\n }\n string exe = ExecutablePath();\n if (exe == null)\n {\n return \"The editor's program file could not be found, so the copy could not be opened. Open \" + copyRoot + \" from the s&box launcher instead; the test starts by itself.\";\n }\n string project = Directory.EnumerateFiles(copyRoot, \"*.sbproj\", SearchOption.TopDirectoryOnly).FirstOrDefault();\n if (project == null)\n {\n return \"The copy has no project file.\";\n }\n try\n {\n Process.Start(new ProcessStartInfo\n {\n FileName = exe,\n Arguments = \"-project \\\"\" + project + \"\\\" -novid\",\n UseShellExecute = false\n });\n return null;\n }\n catch (Exception ex)\n {\n return \"The solo test window could not be opened: \" + ex.Message + \" Open \" + copyRoot + \" from the s&box launcher instead; the test starts by itself.\";\n }\n }\n}\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/SoloTestRunner.cs",
"FileName": "SoloTestRunner.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.IO;\nusing System.Linq;\nusing System.Text.Json;\nusing System.Text.Json.Nodes;\nusing System.Threading.Tasks;\nusing Editor;\nusing Sandbox;\nusing Sandbox.Services;\n\nnamespace TeamCreate;\n\n/// <summary>\n/// The one-click solo test. It runs only inside a disposable copy of the project (see <see cref=\"SoloCopy\"/>), where one person with one Steam account can\n/// host a session and have a scripted collaborator join it over the real transport. The steps exercise the real host session and this editor's real scene:\n/// the host admits the collaborator through its normal checks, the collaborator's edits arrive in this editor, this editor's edits reach the collaborator,\n/// per-user undo, refused stale edits, play pause, saving, and the relay when one is configured.\n///\n/// Everything runs from the frame tick. Nothing here starts a thread or a timer, because hotloaded editor code cannot own one; each step is a begin action and a\n/// poll that is called once per frame until it settles or times out.\n/// </summary>\npublic static class SoloTestRunner\n{\n private enum Phase\n {\n Idle,\n Steps,\n Demo,\n Stopping,\n Done\n }\n\n private sealed class Step\n {\n public SoloStep View;\n\n public double TimeoutSeconds = 20;\n\n public bool Critical;\n\n public Action Begin;\n\n /// <summary>Runs once when the step settles, whatever the outcome, so a step that timed out cannot leave a collaborator holding the test identity.</summary>\n public Action Cleanup;\n\n /// <summary>Returns the step's state: Running keeps waiting, anything else settles it.</summary>\n public Func<(SoloStepState State, string Detail)> Poll;\n }\n\n public const string ReportFile = \"solo-report.json\";\n\n private const double OrbitRadius = 230;\n\n private static readonly (double X, double Y, double Z) OrbitCenter = (0, 0, 90);\n\n private static readonly List<Step> steps = new List<Step>();\n\n private static readonly List<SoloStep> views = new List<SoloStep>();\n\n private static Phase phase = Phase.Idle;\n\n private static int index;\n\n private static DateTime stepStarted;\n\n private static DateTime startedAt;\n\n private static DateTime nextPresence;\n\n private static DateTime nextDemoEdit;\n\n private static DateTime processStarted = DateTime.UtcNow;\n\n private static bool autorunHandled;\n\n private static string runToken;\n\n private static string scenePath;\n\n private static string headline = \"Not run yet.\";\n\n private static string handshakeProject;\n\n private static InviteData invite;\n\n private static byte[] sessionKey;\n\n private static SoloBot bot;\n\n private static SoloBot relayBot;\n\n private static SoloBot demoBot;\n\n private static readonly List<SoloBot> impostors = new List<SoloBot>();\n\n private static Task<string> tokenTask;\n\n private static bool hostStarted;\n\n private static ObjectState cube;\n\n private static ObjectState stopObject;\n\n private static DateTime stopBegan;\n\n private static bool stopWasDemo;\n\n private static string stopState;\n\n private static string stopHeadline;\n\n private static ObjectState demoCube;\n\n private static double demoAngle;\n\n public static IReadOnlyList<SoloStep> Steps => views;\n\n public static string Headline => headline;\n\n public static bool Running => phase == Phase.Steps;\n\n public static bool Watching => phase == Phase.Demo;\n\n public static bool Finished => phase == Phase.Done;\n\n public static bool Active => phase != Phase.Idle && phase != Phase.Done;\n\n public static string Root\n {\n get\n {\n try\n {\n return SceneAdapter.Root;\n }\n catch (Exception)\n {\n return null;\n }\n }\n }\n\n /// <summary>True only inside a folder the launcher made (right path and marker file); the same test the session applies.</summary>\n public static bool IsSoloCopy => TestHarnessRules.IsDisposableCopy(Root, SoloCopy.HasMarker(Root));\n\n private static CollaborationSession Collab => CollaborationSession.Current;\n\n [EditorEvent.Frame]\n public static void Tick()\n {\n try\n {\n if (Project.Current == null)\n {\n return;\n }\n if (IsSoloCopy)\n {\n SoloTestLauncher.Beat(Root);\n }\n if (!autorunHandled && (DateTime.UtcNow - processStarted).TotalSeconds > 10)\n {\n autorunHandled = true;\n if (IsSoloCopy && SoloCopy.WantsAutorun(Root))\n {\n SoloCopy.ConsumeAutorun(Root);\n Start();\n }\n }\n if (IsSoloCopy)\n {\n PollCommand();\n }\n if (phase == Phase.Idle || phase == Phase.Done)\n {\n return;\n }\n bot?.Pump();\n relayBot?.Pump();\n demoBot?.Pump();\n foreach (SoloBot impostor in impostors)\n {\n impostor.Pump();\n }\n DriveOrbit();\n if (phase == Phase.Steps)\n {\n RunCurrentStep();\n }\n else if (phase == Phase.Demo)\n {\n RunDemo();\n }\n else if (phase == Phase.Stopping)\n {\n PollStopping();\n }\n }\n catch (Exception ex)\n {\n Abort(\"The solo test stopped on an unexpected error: \" + ex.Message);\n }\n }\n\n public const string CommandFile = \"solo-command.txt\";\n\n private static DateTime nextCommandCheck;\n\n /// <summary>\n /// Lets Tools/Run-SoloTest.ps1 -Stop end the test from outside the window. A one-word file, read and deleted here; it is honoured only inside a\n /// disposable copy, which is the only place this code runs at all.\n /// </summary>\n private static void PollCommand()\n {\n if (DateTime.UtcNow < nextCommandCheck)\n {\n return;\n }\n nextCommandCheck = DateTime.UtcNow.AddSeconds(1);\n try\n {\n string path = Path.Combine(Root, \".collaboration\", CommandFile);\n if (!File.Exists(path))\n {\n return;\n }\n string word = File.ReadAllText(path).Trim().ToLowerInvariant();\n File.Delete(path);\n if (word == \"stop\")\n {\n Stop();\n }\n else if (word == \"start\")\n {\n Start();\n }\n }\n catch (Exception)\n {\n }\n }\n\n public static void Start()\n {\n if (!IsSoloCopy)\n {\n throw new InvalidOperationException(\"The solo test only runs inside a disposable solo test copy. Use Run solo test in the Collaboration dock to make one.\");\n }\n if (Active)\n {\n return;\n }\n CleanupParticipants();\n OpenDock();\n BuildSteps();\n index = 0;\n startedAt = DateTime.UtcNow;\n phase = Phase.Steps;\n BeginStep();\n headline = \"Running\u2026\";\n WriteReport(\"running\");\n }\n\n /// <summary>\n /// Ends the test in two phases. First the collaborators go and the test's object is removed; the session stays up until the host has been told, because\n /// leaving a session checkpoints the scene and would save the object into the file. Then the session ends. Safe to call at any time.\n /// </summary>\n public static void Stop()\n {\n if (phase == Phase.Idle || phase == Phase.Done || phase == Phase.Stopping)\n {\n return;\n }\n stopWasDemo = phase == Phase.Demo;\n stopObject = demoCube ?? cube;\n try\n {\n RemoveTestObject(stopObject);\n }\n catch (Exception)\n {\n }\n CleanupParticipants();\n stopBegan = DateTime.UtcNow;\n phase = Phase.Stopping;\n }\n\n private static void PollStopping()\n {\n bool present = false;\n try\n {\n present = stopObject != null && Find(stopObject) != null;\n }\n catch (Exception)\n {\n }\n bool pending = false;\n try\n {\n pending = Collab.Connected && !Collab.CanSave();\n }\n catch (Exception)\n {\n pending = false;\n }\n if (SoloScript.StopDecision(present, pending, Collab.Connected, (DateTime.UtcNow - stopBegan).TotalSeconds) == SoloStopDecision.Proceed)\n {\n FinishStop();\n }\n }\n\n private static void FinishStop()\n {\n try\n {\n Collab.Leave();\n }\n catch (Exception)\n {\n }\n CollaborationSession.SoloGuestVoucher = null;\n foreach (Step step in steps)\n {\n if (step.View.State == SoloStepState.Running || step.View.State == SoloStepState.Waiting)\n {\n step.View.State = SoloStepState.Skipped;\n step.View.Detail = \"Stopped before this ran.\";\n }\n }\n phase = Phase.Done;\n headline = stopHeadline ?? SoloReport.Summary(views, true) + (stopWasDemo ? \" Session ended.\" : string.Empty);\n WriteReport(stopState ?? (stopWasDemo ? \"stopped\" : \"aborted\"));\n stopState = null;\n stopHeadline = null;\n }\n\n private static void Abort(string message)\n {\n if (index < steps.Count && steps[index].View.State == SoloStepState.Running)\n {\n steps[index].View.State = SoloStepState.Failed;\n steps[index].View.Detail = message;\n }\n stopState = \"aborted\";\n stopHeadline = message;\n Stop();\n }\n\n private static void CleanupParticipants()\n {\n bot?.Leave();\n relayBot?.Leave();\n demoBot?.Leave();\n foreach (SoloBot impostor in impostors)\n {\n impostor.Dispose();\n }\n impostors.Clear();\n bot = null;\n relayBot = null;\n demoBot = null;\n }\n\n // ---- stepping ------------------------------------------------------------------------------------------------------\n\n private static void RunCurrentStep()\n {\n if (index >= steps.Count)\n {\n FinishChecks();\n return;\n }\n Step step = steps[index];\n (SoloStepState state, string detail) = step.Poll();\n if (state == SoloStepState.Running)\n {\n if ((DateTime.UtcNow - stepStarted).TotalSeconds > step.TimeoutSeconds)\n {\n step.View.State = SoloStepState.Failed;\n step.View.Detail = \"Timed out after \" + step.TimeoutSeconds.ToString(\"0\", CultureInfo.InvariantCulture) + \" seconds. \" + (string.IsNullOrEmpty(step.View.Detail) ? string.Empty : step.View.Detail);\n Settled(step);\n }\n else if (!string.IsNullOrEmpty(detail))\n {\n step.View.Detail = detail;\n }\n return;\n }\n step.View.State = state;\n step.View.Detail = detail;\n Settled(step);\n }\n\n private static void Settled(Step step)\n {\n try\n {\n step.Cleanup?.Invoke();\n }\n catch (Exception)\n {\n }\n if (step.View.State == SoloStepState.Failed && step.Critical)\n {\n for (int i = index + 1; i < steps.Count; i++)\n {\n steps[i].View.State = SoloStepState.Skipped;\n steps[i].View.Detail = \"Not run because \\\"\" + step.View.Title + \"\\\" failed.\";\n }\n index = steps.Count;\n FinishChecks();\n return;\n }\n index++;\n if (index < steps.Count)\n {\n BeginStep();\n }\n WriteReport(\"running\");\n }\n\n private static void BeginStep()\n {\n Step step = steps[index];\n if (step.View.Id == \"demo\" && !SoloReport.DemoAllowed(views))\n {\n step.View.State = SoloStepState.Skipped;\n step.View.Detail = \"Not started because a check failed.\";\n index++;\n if (index < steps.Count)\n {\n BeginStep();\n }\n return;\n }\n step.View.State = SoloStepState.Running;\n step.View.Detail = null;\n stepStarted = DateTime.UtcNow;\n try\n {\n step.Begin?.Invoke();\n }\n catch (Exception ex)\n {\n step.View.State = SoloStepState.Failed;\n step.View.Detail = ex.Message;\n Settled(step);\n }\n }\n\n private static void FinishChecks()\n {\n bool ok = SoloReport.AllPassed(views.Where((SoloStep s) => s.State != SoloStepState.Skipped).ToList()) && !views.Any((SoloStep s) => s.State == SoloStepState.Failed);\n headline = SoloReport.Summary(views, true);\n if (!ok)\n {\n stopState = \"checks-failed\";\n stopHeadline = headline;\n phase = Phase.Steps;\n Stop();\n return;\n }\n phase = Phase.Demo;\n headline += \" The collaborator is now orbiting your scene. Press Stop to end.\";\n WriteReport(\"checks-passed\");\n }\n\n private static void RemoveTestObject(ObjectState state)\n {\n if (state == null)\n {\n return;\n }\n GameObject go = Find(state);\n if (go != null && go.IsValid())\n {\n go.Destroy();\n }\n }\n\n // ---- helpers -------------------------------------------------------------------------------------------------------\n\n private static SceneEditorSession SceneSession()\n {\n return SceneEditorSession.All.FirstOrDefault((SceneEditorSession x) => !(x is GameEditorSession) && SceneAdapter.PathOf(x) == scenePath);\n }\n\n private static GameObject Find(ObjectState state)\n {\n SceneEditorSession session = SceneSession();\n if (session == null || state == null)\n {\n return null;\n }\n return SceneAdapter.Walk(session.Scene).FirstOrDefault((GameObject x) => x.IsValid() && string.Equals(x.Id.ToString(), state.Id, StringComparison.Ordinal));\n }\n\n private static Vector3 ParseVector(string text)\n {\n float[] n = text.Split(',').Select((string x) => float.Parse(x, CultureInfo.InvariantCulture)).ToArray();\n return new Vector3(n[0], n[1], n[2]);\n }\n\n private static bool Near(Vector3 a, Vector3 b)\n {\n return (a - b).Length < 0.5f;\n }\n\n private static string PositionOf(SoloBot who)\n {\n return who != null && cube != null && who.Known(scenePath).TryGetValue(cube.Id, out ObjectState s) ? s.Data[\"Position\"]?.GetValue<string>() : null;\n }\n\n private static SoloHandshake Handshake(string steamId, string name, string token)\n {\n (string ProjectId, string Baseline, string Manifest, string OrgIdent, string LibraryVersion) facts = Collab.HandshakeFacts;\n return new SoloHandshake\n {\n Project = facts.ProjectId,\n Baseline = facts.Baseline,\n Manifest = facts.Manifest,\n Org = facts.OrgIdent,\n LibraryVersion = facts.LibraryVersion,\n SteamId = steamId,\n SteamToken = token,\n Name = name,\n SessionToken = Convert.ToHexString(sessionKey)\n };\n }\n\n private static SoloBot NewDirectBot(string steamId, string name, string token)\n {\n SoloBot made = new SoloBot(Handshake(steamId, name, token));\n int port = invite.Port;\n byte[] key = sessionKey;\n made.Connect(() => Wire.Connect(\"127.0.0.1\", port, key, true));\n return made;\n }\n\n private static SoloBot NewRelayBot()\n {\n SoloBot made = new SoloBot(Handshake(TestHarnessRules.SoloGuestSteamId, TestHarnessRules.SoloGuestName, runToken));\n string url = invite.Relay;\n byte[] secret = invite.Secret;\n byte[] key = sessionKey;\n made.Connect(() => RelayGuest.ConnectAsync(() => new SandboxRelaySocket(), url, secret, key));\n return made;\n }\n\n private static (double X, double Y, double Z) OrbitPoint(double seconds)\n {\n return SoloScript.Orbit(OrbitCenter, OrbitRadius, seconds * 0.55, 28);\n }\n\n /// <summary>The collaborator's marker circles the scene and looks at its middle, so the sphere is easy to find and to watch.</summary>\n private static void DriveOrbit()\n {\n SoloBot who = demoBot ?? bot ?? relayBot;\n if (who == null || !who.IsLive || scenePath == null || DateTime.UtcNow < nextPresence)\n {\n return;\n }\n nextPresence = DateTime.UtcNow.AddMilliseconds(100);\n double t = (DateTime.UtcNow - startedAt).TotalSeconds;\n (double X, double Y, double Z) p = OrbitPoint(t);\n who.SendPresence(scenePath, SoloScript.Presence(p, SoloScript.LookAt(p, OrbitCenter), phase == Phase.Demo ? \"editing\" : \"editing\"));\n }\n\n private static void RunDemo()\n {\n if (demoBot == null || !demoBot.IsLive || demoCube == null || DateTime.UtcNow < nextDemoEdit)\n {\n return;\n }\n nextDemoEdit = DateTime.UtcNow.AddMilliseconds(400);\n demoAngle += 0.16;\n (double X, double Y, double Z) c = SoloScript.Orbit((OrbitCenter.X, OrbitCenter.Y, 45), 90, demoAngle, 14);\n ObjectState next = SoloScript.WithTint(SoloScript.WithPosition(demoCube, SoloScript.SnapVec(c.X, c.Y, c.Z)), SoloScript.TintAt(demoAngle / 6.0));\n if (demoBot.PendingCount == 0)\n {\n demoBot.SendEdit(scenePath, new[] { SoloScript.Update(demoCube, next) });\n demoCube = next;\n }\n }\n\n /// <summary>\n /// The checklist lives in the Collaboration dock, and a fresh copy starts with the default layout, where that dock is closed. A test that\n /// starts by itself and shows its progress somewhere nobody is looking would appear to do nothing.\n /// </summary>\n private static void OpenDock()\n {\n try\n {\n Sandbox.Internal.GlobalToolsNamespace.EditorWindow.DockManager.OpenDock(\"Collaboration\", DockArea.Right);\n }\n catch (Exception)\n {\n }\n }\n\n private static void PlaceCamera()\n {\n try\n {\n SceneViewportWidget widget = SceneViewWidget.Current?.LastSelectedViewportWidget;\n if (widget == null)\n {\n return;\n }\n Vector3 target = new Vector3((float)OrbitCenter.X, (float)OrbitCenter.Y, (float)OrbitCenter.Z - 20f);\n Vector3 from = new Vector3(-520f, -520f, 300f);\n widget.State.CameraPosition = from;\n widget.State.CameraRotation = Rotation.LookAt((target - from).Normal);\n }\n catch (Exception)\n {\n }\n }\n\n // ---- the script ----------------------------------------------------------------------------------------------------\n\n private static void Add(string id, string title, double timeout, bool critical, Action begin, Func<(SoloStepState, string)> poll, Action cleanup = null)\n {\n SoloStep view = new SoloStep { Id = id, Title = title };\n views.Add(view);\n steps.Add(new Step { View = view, TimeoutSeconds = timeout, Critical = critical, Begin = begin, Poll = poll, Cleanup = cleanup });\n }\n\n private static (SoloStepState, string) Wait(string detail = null)\n {\n return (SoloStepState.Running, detail);\n }\n\n private static (SoloStepState, string) Pass(string detail)\n {\n return (SoloStepState.Passed, detail);\n }\n\n private static (SoloStepState, string) Fail(string detail)\n {\n return (SoloStepState.Failed, detail);\n }\n\n private static void BuildSteps()\n {\n steps.Clear();\n views.Clear();\n cube = null;\n demoCube = null;\n demoAngle = 0;\n hostStarted = false;\n tokenTask = null;\n scenePath = null;\n invite = null;\n\n Add(\"scene\", \"A saved scene is open and fully loaded\", 60, true, () =>\n {\n foreach (SceneEditorSession open in SceneEditorSession.All.Where((SceneEditorSession x) => !(x is GameEditorSession)).ToArray())\n {\n if (open.HasUnsavedChanges)\n {\n // A disposable copy: unsaved edits here are leftovers, and a session cannot start over them.\n open.Reload();\n open.HasUnsavedChanges = false;\n }\n }\n string active = SceneAdapter.PathOf(SceneEditorSession.Active);\n if (active == null || !active.StartsWith(\"scenes/\", StringComparison.Ordinal))\n {\n string folder = Path.Combine(SceneAdapter.Root, \"Assets\", \"scenes\");\n string first = Directory.Exists(folder) ? Directory.EnumerateFiles(folder, \"*.scene\").OrderBy((string x) => x, StringComparer.OrdinalIgnoreCase).FirstOrDefault() : null;\n if (first == null)\n {\n throw new InvalidOperationException(\"The copy has no scene under Assets/scenes. Save one in the project first.\");\n }\n SceneEditorSession.CreateFromPath(\"scenes/\" + Path.GetFileName(first))?.MakeActive();\n }\n }, () =>\n {\n string active = SceneAdapter.PathOf(SceneEditorSession.Active);\n if (active == null || !active.StartsWith(\"scenes/\", StringComparison.Ordinal) || SceneEditorSession.Active is GameEditorSession)\n {\n return Wait(\"Opening a scene\u2026\");\n }\n scenePath = active;\n // The editor may still be restoring the scene when this runs. Starting then makes the host publish a partial scene, and the save step\n // would write it back over the copy's file. Wait until the editor holds at least as many top-level objects as the file does.\n int? expected = SoloScript.TopLevelObjectCount(Path.Combine(Root, \"Assets\", active.Replace('/', Path.DirectorySeparatorChar)));\n SceneEditorSession session = SceneSession();\n int loaded = session?.Scene != null && session.Scene.IsValid() ? session.Scene.Children.Count((GameObject x) => x.IsValid()) : 0;\n SoloSceneLoad state = SoloScript.SceneLoadState(expected, loaded);\n string detail = SoloScript.SceneLoadDetail(active, expected, loaded);\n if (state == SoloSceneLoad.Loading)\n {\n return Wait(detail);\n }\n PlaceCamera();\n return Pass(detail);\n });\n\n Add(\"host\", \"Host session starts\", 150, true, () =>\n {\n runToken = TestHarnessRules.NewRunToken();\n string token = runToken;\n CollaborationSession.SoloGuestVoucher = (Packet hello) => TestHarnessRules.MayVouchFor(hello.SteamId, hello.SteamToken, token);\n if (Collab.Busy)\n {\n Collab.Leave();\n }\n tokenTask = Auth.GetToken(LocalSteamIdentity.ServiceName);\n }, () =>\n {\n if (!hostStarted)\n {\n // The readiness checks answer asynchronously after the editor loads; hosting before they have is refused for a reason that is gone a\n // moment later. Wait for them, and only call a row blocked once the grace period has passed.\n SoloHostReadiness readiness = SoloScript.HostReadiness(Collab.Readiness, (DateTime.UtcNow - stepStarted).TotalSeconds);\n if (readiness == SoloHostReadiness.Waiting)\n {\n return Wait(\"Waiting for the readiness checks (Steam, publication, organization)\u2026\");\n }\n if (readiness == SoloHostReadiness.Blocked)\n {\n return Fail(ReadinessRules.BlockReason(Collab.Readiness) ?? \"The readiness checklist never completed. Open the Collaboration dock and check the Readiness card.\");\n }\n if (!tokenTask.IsCompleted)\n {\n return Wait(\"Waiting for a fresh Steam identity token\u2026\");\n }\n if (tokenTask.IsFaulted || tokenTask.IsCanceled || string.IsNullOrWhiteSpace(tokenTask.Result))\n {\n return Fail(\"A Steam identity token could not be obtained. Sign in to Steam and try again.\");\n }\n if (!LocalSteamIdentity.TryGet(out SteamIdentity identity, out string persona, out string failure))\n {\n return Fail(failure ?? \"No signed-in Steam account is available.\");\n }\n Collab.StartHosted(27845, persona, SteamIdentityRules.Canonical(identity), tokenTask.Result, allowUnverifiedPeers: false);\n hostStarted = true;\n return Wait(\"Starting the host\u2026\");\n }\n if (Collab.Error != null)\n {\n return Fail(Collab.Error);\n }\n if (!Collab.SoloHarness)\n {\n return Fail(\"The test identity is not active in this folder, so a collaborator could not be admitted.\");\n }\n if (Collab.Connected && Collab.IsHost && Collab.InviteCode != null)\n {\n return Pass(\"Hosting with the signed-in Steam account.\");\n }\n return Wait(Collab.Status);\n });\n\n Add(\"live\", \"The host's scene is live\", 25, true, null, () =>\n {\n if (Collab.Error != null)\n {\n return Fail(Collab.Error);\n }\n return Collab.ReadyScenes.Contains(scenePath) ? Pass(scenePath + \" is synchronized.\") : Wait(\"Waiting for the host to publish the scene\u2026\");\n });\n\n Add(\"relay\", \"Relay is registered\", 20, false, null, () =>\n {\n string status = Collab.RelayStatus;\n if (status == null)\n {\n return (SoloStepState.Skipped, \"No relay is configured for this project (RelayUrl), so only direct routes are tested.\");\n }\n if (Collab.RelayFailed)\n {\n return Fail(status);\n }\n return status.StartsWith(\"Relay connected\", StringComparison.Ordinal) ? Pass(\"The relay accepted this session's room.\") : Wait(status);\n });\n\n Add(\"join\", \"A collaborator joins and passes the host's checks\", 30, true, () =>\n {\n (string ProjectId, string Baseline, string Manifest, string OrgIdent, string LibraryVersion) facts = Collab.HandshakeFacts;\n handshakeProject = facts.ProjectId;\n if (!InviteCode.TryDecode(Collab.InviteCode, facts.ProjectId, out invite, out string error))\n {\n throw new InvalidOperationException(\"The host's own invite code did not decode: \" + error);\n }\n sessionKey = InviteCode.DeriveKey(invite.Secret, facts.ProjectId);\n bot = NewDirectBot(TestHarnessRules.SoloGuestSteamId, TestHarnessRules.SoloGuestName, runToken);\n }, () =>\n {\n if (bot.State == SoloBotState.Refused || bot.State == SoloBotState.Failed)\n {\n return Fail(\"The host refused the collaborator: \" + bot.Refusal);\n }\n if (bot.IsLive)\n {\n bot.Subscribe(scenePath);\n return Pass(\"Admitted as \\\"\" + TestHarnessRules.SoloGuestName + \"\\\" after the host's normal project, baseline, library and manifest checks.\");\n }\n return Wait(\"Connecting\u2026\");\n });\n\n Add(\"marker\", \"Its floating marker appears in your scene view\", 20, false, null, () =>\n {\n bool inRoster = Collab.Roster.Values.Any((Packet x) => x.SteamId == TestHarnessRules.SoloGuestSteamId && x.Presence != null);\n if (!inRoster)\n {\n return Wait(\"Waiting for the collaborator's presence\u2026\");\n }\n return Collab.DrawnMarkers > 0 ? Pass(\"The blue sphere is drawn in the scene view, orbiting the middle of the scene.\") : Wait(\"The collaborator is in the roster; waiting for the scene view to draw its marker. Open a scene view if none is visible.\");\n });\n\n Add(\"create\", \"Its new object appears in your scene\", 20, true, () =>\n {\n cube = SoloScript.NewCube(Guid.NewGuid(), Guid.NewGuid(), SoloScript.SnapVec(OrbitCenter.X, OrbitCenter.Y, 45), SoloScript.Color(1.0, 0.34375, 0.25));\n bot.SendEdit(scenePath, new[] { SoloScript.Create(cube) });\n }, () => Find(cube) != null ? Pass(\"\\\"\" + SoloScript.ObjectName + \"\\\" appeared in this editor.\") : Wait());\n\n Vector3 movedTo = new Vector3(70, -50, 80);\n Add(\"move\", \"Its move appears in your scene as it happens\", 20, true, () =>\n {\n ObjectState next = SoloScript.WithPosition(cube, SoloScript.SnapVec(movedTo.x, movedTo.y, movedTo.z));\n bot.SendEdit(scenePath, new[] { SoloScript.Update(cube, next) });\n cube = next;\n }, () =>\n {\n GameObject go = Find(cube);\n return go != null && Near(go.WorldPosition, movedTo) ? Pass(\"Position changed to \" + go.WorldPosition + \".\") : Wait();\n });\n\n string tintText = SoloScript.TintAt(0.62);\n Add(\"property\", \"Its property change appears in your scene\", 20, true, () =>\n {\n ObjectState next = SoloScript.WithTint(cube, tintText);\n bot.SendEdit(scenePath, new[] { SoloScript.Update(cube, next) });\n cube = next;\n }, () =>\n {\n GameObject go = Find(cube);\n ModelRenderer renderer = go?.Components.Get<ModelRenderer>();\n if (renderer == null)\n {\n return Wait();\n }\n float[] want = tintText.Split(',').Select((string x) => float.Parse(x, CultureInfo.InvariantCulture)).ToArray();\n Color have = renderer.Tint;\n return Math.Abs(have.r - want[0]) < 0.02f && Math.Abs(have.g - want[1]) < 0.02f && Math.Abs(have.b - want[2]) < 0.02f ? Pass(\"The renderer tint changed.\") : Wait();\n });\n\n Vector3 hostMoved = new Vector3(-90, 60, 100);\n Add(\"reverse\", \"Your move reaches the collaborator as it happens\", 20, true, () =>\n {\n GameObject go = Find(cube);\n if (go == null)\n {\n throw new InvalidOperationException(\"The test object is not in this scene.\");\n }\n go.LocalPosition = hostMoved;\n }, () =>\n {\n string seen = PositionOf(bot);\n return seen != null && Near(ParseVector(seen), hostMoved) ? Pass(\"The collaborator saw the position change to \" + seen + \".\") : Wait();\n });\n\n bool undone = false;\n Add(\"undo\", \"Undo reverses only your own move\", 25, false, () => undone = false, () =>\n {\n if (!undone)\n {\n try\n {\n Collab.Undo();\n undone = true;\n }\n catch (InvalidOperationException)\n {\n return Wait(\"Waiting for your move to be acknowledged\u2026\");\n }\n }\n string seen = PositionOf(bot);\n bool botsTintKept = bot.Known(scenePath).TryGetValue(cube.Id, out ObjectState s) && (s.Data[\"Components\"] as JsonArray)?[0]?[\"Tint\"]?.GetValue<string>() == tintText;\n return seen != null && Near(ParseVector(seen), movedTo) && botsTintKept ? Pass(\"Back to \" + seen + \"; the collaborator's own change to the object was not undone.\") : Wait();\n });\n\n int rejectsBefore = 0;\n Add(\"conflict\", \"A stale edit is refused, not silently applied\", 20, false, () =>\n {\n rejectsBefore = bot.Rejects.Count;\n // Built on a position the object left long ago, so it is out of date by construction.\n ObjectState old = SoloScript.WithPosition(cube, \"1,1,1\");\n bot.SendEdit(scenePath, new[] { SoloScript.Update(old, SoloScript.WithPosition(old, \"5,5,5\")) });\n }, () =>\n {\n if (bot.Rejects.Count <= rejectsBefore)\n {\n return Wait();\n }\n GameObject go = Find(cube);\n return go != null && Near(go.WorldPosition, movedTo) ? Pass(\"The host answered: \" + Truncate(bot.Rejects.Last().Error, 110)) : Fail(\"The refused edit still changed the scene.\");\n });\n\n Vector3 whilePlaying = new Vector3(10, 20, 110);\n int playPhase = 0;\n Add(\"play\", \"Playing pauses scene edits; changes wait until you stop\", 60, false, () =>\n {\n playPhase = 0;\n EditorScene.Play();\n }, () =>\n {\n if (Collab.Error != null)\n {\n return Fail(Collab.Error);\n }\n if (playPhase == 0)\n {\n if (Collab.PlayDecision != PlaySpectateDecision.PausedForPlay)\n {\n return Wait(\"Starting the game\u2026\");\n }\n ObjectState next = SoloScript.WithPosition(cube, SoloScript.SnapVec(whilePlaying.x, whilePlaying.y, whilePlaying.z));\n bot.SendEdit(scenePath, new[] { SoloScript.Update(cube, next) });\n cube = next;\n playPhase = 1;\n return Wait(\"Playing; the collaborator changes the object\u2026\");\n }\n if (playPhase == 1)\n {\n GameObject go = Find(cube);\n if (Collab.HeldSceneChanges < 1)\n {\n return Wait(\"Waiting for the collaborator's change to reach this editor\u2026\");\n }\n if (go != null && Near(go.WorldPosition, whilePlaying))\n {\n return Fail(\"The change was applied while playing instead of being held.\");\n }\n EditorScene.Stop();\n playPhase = 2;\n return Wait(\"Held \" + Collab.HeldSceneChanges + \" change(s); stopping the game\u2026\");\n }\n GameObject after = Find(cube);\n return Collab.PlayDecision == PlaySpectateDecision.Normal && after != null && Near(after.WorldPosition, whilePlaying) ? Pass(\"The change made while you were playing applied when you stopped.\") : Wait(\"Applying held changes\u2026\");\n });\n\n Add(\"delete\", \"Its delete appears in your scene\", 20, false, () =>\n {\n bot.SendEdit(scenePath, new[] { SoloScript.Delete(cube) });\n }, () => Find(cube) == null ? Pass(\"The object is gone from this editor.\") : Wait());\n\n DateTime saveAt = default;\n Add(\"save\", \"Saving works while a collaborator is connected\", 30, false, () => saveAt = default, () =>\n {\n if (Collab.Error != null)\n {\n return Fail(Collab.Error);\n }\n if (saveAt == default)\n {\n if (!Collab.CanSave())\n {\n return Wait(\"Waiting to save: \" + (Collab.WhyCannotSave() ?? \"a check is still settling\") + \".\");\n }\n Collab.Save();\n saveAt = DateTime.UtcNow;\n return Wait(\"Saving\u2026\");\n }\n if ((DateTime.UtcNow - saveAt).TotalSeconds < 1.5)\n {\n return Wait(\"Saving\u2026\");\n }\n return Collab.Error == null && bot.IsLive ? Pass(\"Saved the synchronized scene; the collaborator stayed connected.\") : Fail(Collab.Error ?? \"The collaborator was dropped by the save.\");\n });\n\n Add(\"leave\", \"The collaborator leaves cleanly\", 15, false, () => bot.Leave(), () =>\n {\n bool gone = !Collab.Roster.Values.Any((Packet x) => x.SteamId == TestHarnessRules.SoloGuestSteamId);\n if (gone)\n {\n bot = null;\n }\n return gone ? Pass(\"Its row and marker were removed at once.\") : Wait();\n });\n\n int relayPhase = 0;\n Add(\"via-relay\", \"A collaborator joins and edits through the relay\", 45, false, () =>\n {\n relayPhase = 0;\n if (Collab.RelayStatus == null || Collab.RelayFailed || string.IsNullOrEmpty(invite?.Relay))\n {\n return;\n }\n relayBot = NewRelayBot();\n }, () =>\n {\n if (relayBot == null)\n {\n return (SoloStepState.Skipped, \"No working relay for this session, so this route was not tested.\");\n }\n if (relayBot.State == SoloBotState.Refused || relayBot.State == SoloBotState.Failed)\n {\n return Fail(\"The relay route failed: \" + relayBot.Refusal);\n }\n if (relayPhase == 0)\n {\n if (!relayBot.IsLive)\n {\n return Wait(\"Dialling the relay\u2026\");\n }\n relayBot.Subscribe(scenePath);\n relayPhase = 1;\n return Wait(\"Admitted through the relay; subscribing\u2026\");\n }\n if (relayPhase == 1)\n {\n if (!relayBot.HasScene(scenePath))\n {\n return Wait();\n }\n cube = SoloScript.NewCube(Guid.NewGuid(), Guid.NewGuid(), SoloScript.SnapVec(OrbitCenter.X, OrbitCenter.Y, 45), SoloScript.Color(0.3125, 0.59375, 1.0));\n relayBot.SendEdit(scenePath, new[] { SoloScript.Create(cube) });\n relayPhase = 2;\n return Wait(\"Sending an edit through the relay\u2026\");\n }\n if (relayPhase == 2)\n {\n if (Find(cube) == null)\n {\n return Wait();\n }\n relayBot.SendEdit(scenePath, new[] { SoloScript.Delete(cube) });\n relayPhase = 3;\n return Wait(\"The object arrived; removing it\u2026\");\n }\n if (Find(cube) != null)\n {\n return Wait();\n }\n relayBot.Leave();\n relayBot = null;\n return Pass(\"Joined, subscribed, created and deleted an object, all through the relay.\");\n }, () =>\n {\n relayBot?.Leave();\n relayBot = null;\n });\n\n int closedPhase = 0;\n Add(\"closed\", \"The test identity opens nothing else\", 30, false, () => closedPhase = 0, () =>\n {\n if (closedPhase == 0)\n {\n // The earlier steps' collaborators say goodbye and the host relays it; the reserved account must be gone from the roster before this check\n // starts, or \"already connected\" would be the refusal and would prove nothing about the token.\n if (Collab.Roster.Values.Any((Packet x) => x.SteamId == TestHarnessRules.SoloGuestSteamId))\n {\n return Wait(\"Waiting for the earlier collaborator to finish leaving\u2026\");\n }\n impostors.Add(NewDirectBot(TestHarnessRules.SoloGuestSteamId, \"Wrong token\", TestHarnessRules.NewRunToken()));\n impostors.Add(NewDirectBot(\"76561198000000002\", \"Other account\", runToken));\n closedPhase = 1;\n return Wait(\"Trying a wrong token and a different account\u2026\");\n }\n if (impostors.Any((SoloBot x) => x.IsLive))\n {\n return Fail(\"A collaborator without the test token was admitted.\");\n }\n if (impostors.Any((SoloBot x) => x.State == SoloBotState.Connecting || x.State == SoloBotState.AwaitingWelcome))\n {\n return Wait();\n }\n // Each must be refused BECAUSE its identity token was rejected. A refusal for any other reason (for example \"already connected\") would\n // pass a weaker check while proving nothing about the token.\n SoloBot wrong = impostors.FirstOrDefault();\n foreach (SoloBot x in impostors)\n {\n if (x.State != SoloBotState.Refused || x.Refusal == null || x.Refusal.IndexOf(\"rejected\", StringComparison.OrdinalIgnoreCase) < 0)\n {\n return Fail(\"Refused for the wrong reason, or not refused: \" + x.State + \" \" + Truncate(x.Refusal, 100));\n }\n }\n return Pass(\"A wrong token and a different account were both refused as: \" + Truncate(wrong?.Refusal, 90));\n }, () =>\n {\n foreach (SoloBot x in impostors)\n {\n x.Dispose();\n }\n impostors.Clear();\n });\n\n Add(\"demo\", \"Live demo: the collaborator moves an object in your scene\", 45, false, () =>\n {\n // Pressing Play and Stop earlier resets the scene view, so frame the orbit again for the part meant to be watched.\n PlaceCamera();\n demoBot = NewDirectBot(TestHarnessRules.SoloGuestSteamId, TestHarnessRules.SoloGuestName, runToken);\n }, () =>\n {\n if (demoBot.State == SoloBotState.Refused || demoBot.State == SoloBotState.Failed)\n {\n return Fail(\"The demo collaborator could not join: \" + demoBot.Refusal);\n }\n if (!demoBot.IsLive)\n {\n return Wait(\"Connecting\u2026\");\n }\n if (!demoBot.HasScene(scenePath))\n {\n demoBot.Subscribe(scenePath);\n return Wait(\"Subscribing\u2026\");\n }\n if (demoCube == null)\n {\n demoCube = SoloScript.NewCube(Guid.NewGuid(), Guid.NewGuid(), SoloScript.SnapVec(OrbitCenter.X, OrbitCenter.Y, 45), SoloScript.Color(1, 0.40625, 0.21875));\n cube = demoCube;\n demoBot.SendEdit(scenePath, new[] { SoloScript.Create(demoCube) });\n return Wait();\n }\n return Find(demoCube) != null ? Pass(\"Running. Watch the blue sphere and the cube; press Stop to end.\") : Wait();\n });\n }\n\n private static string Truncate(string text, int max)\n {\n if (string.IsNullOrEmpty(text))\n {\n return string.Empty;\n }\n return text.Length <= max ? text : text.Substring(0, max - 1) + \"\u2026\";\n }\n\n // ---- report --------------------------------------------------------------------------------------------------------\n\n private static void WriteReport(string state)\n {\n try\n {\n string path = Path.Combine(Root, \".collaboration\", ReportFile);\n Directory.CreateDirectory(Path.GetDirectoryName(path));\n string json = JsonSerializer.Serialize(new\n {\n state,\n summary = SoloReport.Summary(views, state != \"running\"),\n headline,\n updatedUtc = DateTime.UtcNow.ToString(\"o\", CultureInfo.InvariantCulture),\n steps = views.Select((SoloStep s) => new { id = s.Id, title = s.Title, state = s.State.ToString(), detail = s.Detail }).ToArray()\n }, new JsonSerializerOptions { WriteIndented = true });\n File.WriteAllText(path + \".tmp\", json);\n File.Move(path + \".tmp\", path, true);\n }\n catch (Exception)\n {\n }\n }\n\n /// <summary>The last written report, for the dock's \"last run\" line. Null when there is none.</summary>\n public static string LastRunSummary(string root)\n {\n try\n {\n string path = Path.Combine(root, \".collaboration\", ReportFile);\n if (!File.Exists(path))\n {\n return null;\n }\n JsonNode node = JsonNode.Parse(File.ReadAllText(path));\n return node?[\"summary\"]?.GetValue<string>();\n }\n catch (Exception)\n {\n return null;\n }\n }\n}\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/CollaborationSession.cs",
"FileName": "CollaborationSession.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Net;\r\nusing System.Net.Sockets;\r\nusing System.Text;\r\nusing System.Text.Json;\r\nusing System.Text.Json.Nodes;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\nusing Editor;\r\nusing Sandbox;\r\nusing Sandbox.Helpers;\r\nusing Sandbox.Internal;\r\nusing Sandbox.Services;\r\nusing Sandbox.Utility;\r\n\r\nnamespace TeamCreate;\r\n\r\npublic enum SessionState\r\n{\r\n Offline,\r\n Ready,\r\n Hosting,\r\n Joining,\r\n Connected,\r\n Degraded,\r\n Recovering\r\n}\r\n\r\npublic sealed class CollaborationSession : EditorEvent.ISceneView, EditorEvent.IEventListener\r\n{\r\n private sealed class Tracked\r\n {\r\n public SceneEditorSession Session;\r\n\r\n public Dictionary<string, ObjectState> Known;\r\n\r\n public Packet Pending;\r\n\r\n public bool Ready;\r\n\r\n public Dictionary<string, Change> Group = new Dictionary<string, Change>();\r\n\r\n public Stack<List<Change>> Undo = new Stack<List<Change>>();\r\n\r\n public bool UndoPending;\r\n\r\n // The scene's own settings as the host last confirmed them (SettingsMerge). Null when the host holds none for the scene.\r\n public JsonObject Settings;\r\n\r\n // The one settings change awaiting the host's answer, like Pending for edits.\r\n public Packet SettingsPending;\r\n\r\n // What this editor last wrote into the scene's settings and what the engine reported right after: the difference is what the engine\r\n // could not take, which SettingsMerge.LocalChange must never mistake for the user's change. Null until the first write.\r\n public JsonObject SettingsApplied;\r\n\r\n public JsonObject SettingsEcho;\r\n\r\n public DateTime DiskStamp;\r\n\r\n public DateTime StampMismatchSince;\r\n public ApplyRecovery Recovery = new ApplyRecovery();\r\n // Objects this editor could not show or capture: their host JSON stays in Known and is never sent back (ApplyRecovery).\r\n public HashSet<string> SkippedObjects = new HashSet<string>(StringComparer.Ordinal);\r\n // The person's state to replay after a resync, when the live scene could not be put back after a failed apply.\r\n public Dictionary<string, ObjectState> HeldLocal;\r\n // The resync replaces the local state instead of replaying it (the host refused it; it is in the recovery copy).\r\n public bool DiscardLocalOnResync;\r\n public bool DiskWarningShown;\r\n }\r\n\r\n private sealed class StartPlan\r\n {\r\n public bool Host;\r\n\r\n public string Address;\r\n\r\n public int Port;\r\n\r\n public string Name;\r\n\r\n public string Token;\r\n\r\n public string SteamId;\r\n\r\n public string SteamToken;\r\n\r\n public bool AllowUnverifiedPeers;\r\n\r\n public bool ZeroConfig;\r\n\r\n public byte[] SessionKey;\r\n\r\n public byte[] Secret;\r\n\r\n public List<EndpointResolver.EndpointCandidate> Candidates;\r\n }\r\n\r\n private enum HotloadArchiveOutcome\r\n {\r\n NotApplicable,\r\n Archived,\r\n Failed\r\n }\r\n\r\n private sealed class EditScope(CollaborationSession owner) : IDisposable\r\n {\r\n private bool disposed;\r\n\r\n public void Dispose()\r\n {\r\n if (!disposed)\r\n {\r\n disposed = true;\r\n owner.continuousEdits--;\r\n }\r\n }\r\n }\r\n\r\n private readonly Dictionary<string, Tracked> scenes = new Dictionary<string, Tracked>();\r\n\r\n // Remote receipts arrive at drag rates, and every one used to reset the whole native undo history. The scene is still applied per receipt;\r\n // only the history reset (Initialize + MirrorNativeUndo) is coalesced by UndoResetScheduler, and the scenes awaiting it are kept here.\r\n private readonly UndoResetScheduler undoReset = new UndoResetScheduler();\r\n\r\n private readonly HashSet<Tracked> undoResetScenes = new HashSet<Tracked>();\r\n\r\n private static readonly System.Diagnostics.Stopwatch undoResetClock = System.Diagnostics.Stopwatch.StartNew();\r\n\r\n private Wire wire;\r\n\r\n private SessionServer server;\r\n\r\n private Task<Wire> connecting;\r\n\r\n private byte[] activeSessionKey;\r\n\r\n private string activeAddress;\r\n\r\n private int activePort;\r\n\r\n private Task<string> reconnectTokenTask;\r\n\r\n private bool reconnectQueued;\r\n\r\n private string reconnectAddress;\r\n\r\n private int reconnectPort;\r\n\r\n private bool reconnectRecovery;\r\n\r\n private bool reconnectRetrySpent;\r\n\r\n private bool endedByLeave;\r\n\r\n private LanDiscovery discovery;\r\n\r\n private StunClient stun;\r\n\r\n private RelayHost relayHost;\r\n\r\n // The one-click internet relay (Core/HostingPlan.cs), used only when no relay is named anywhere: the loopback relay this host runs, the Quick Tunnel\r\n // that exposes it, and the reachability probe in flight. All of it is polled from PollConnectivity and released with the rest of the connectivity.\r\n private HostingPlan hostingPlan;\r\n\r\n private EmbeddedRelay embeddedRelay;\r\n\r\n private string embeddedRelayError;\r\n\r\n private CloudflaredTool quickTunnel;\r\n\r\n private Task<string> reachabilityTask;\r\n\r\n private CancellationTokenSource reachabilityCancel;\r\n\r\n // The public address the invite carries while the plan is Ready, and null otherwise; the invite never falls back to the loopback address.\r\n private string publicRelayUrl;\r\n\r\n private static readonly TimeSpan ReachabilityProbeTimeout = TimeSpan.FromSeconds(5L);\r\n\r\n private static double HostingSeconds => Environment.TickCount64 / 1000.0;\r\n\r\n private bool soloHarness;\r\n\r\n private readonly HeldPacketBuffer heldPackets = new HeldPacketBuffer();\r\n\r\n // Shared play (Core/PlayRoster.cs and friends): null unless this computer opted in and the host advertised it.\r\n private SharedPlayClient play;\r\n // Scenes whose host updates were discarded (play-mode buffer overflow) or could not be routed (the scene is playing): each is\r\n // resynchronized from the host once it can take a snapshot again.\r\n private readonly HashSet<string> resyncWhenRoutable = new HashSet<string>(StringComparer.Ordinal);\r\n private readonly HashSet<string> closedPacketNotices = new HashSet<string>(StringComparer.Ordinal);\r\n private readonly HashSet<string> onceNotices = new HashSet<string>(StringComparer.Ordinal);\r\n\r\n private bool inviteCarriesRelay;\r\n\r\n /// <summary>The relay this session uses: the project's own if it names one, none if it says \"off\", otherwise the one built into the library.</summary>\r\n private string EffectiveRelayUrl()\r\n {\r\n return RelayDefaults.Resolve(sessionConfig?.RelayUrl, RelaySetupRules.Fallback(RelaySetup.LocalRelay, RelayDefaults.DefaultUrl));\r\n }\r\n\r\n /// <summary>What the project's own settings say about the relay (an address, \"off\", or nothing), for the Relay card.</summary>\r\n public string ConfiguredRelayUrl => sessionConfig?.RelayUrl;\r\n\r\n private byte[] activeSecret;\r\n\r\n private List<EndpointResolver.EndpointCandidate> activeCandidates;\r\n\r\n private UpnpPortMapper upnp;\r\n\r\n private string inviteCodeValue;\r\n\r\n private string projectIdValue;\r\n\r\n private bool projectIdResolved;\r\n\r\n private byte[] hostSecret;\r\n\r\n private int hostPort;\r\n\r\n private bool stunEndpointFolded;\r\n\r\n private CancellationTokenSource hostVerificationLifetime = new CancellationTokenSource();\r\n\r\n private readonly ConnectionTeardownCoordinator lifecycle = new ConnectionTeardownCoordinator();\r\n\r\n private string baseline;\r\n\r\n private string project;\r\n\r\n private string userName;\r\n\r\n private string sessionToken;\r\n\r\n private string sourceBaseline;\r\n\r\n private string hostSession;\r\n\r\n private string steamIdValue;\r\n\r\n private string steamTokenValue;\r\n\r\n private DateTime nextScan;\r\n\r\n private DateTime nextPresence;\r\n\r\n private DateTime lastPresenceSent;\r\n\r\n private DateTime lastPingSent;\r\n\r\n // The newest host revision this editor has seen, from every accepted and snapshot packet. A ping reports it next to the scene digest so\r\n // the host compares digests only when this editor is up to date (see DriftDetector).\r\n private long lastRevision;\r\n\r\n private string driftNoticeShown;\r\n\r\n private string lastPresenceFingerprint;\r\n\r\n private DateTime lastReceive;\r\n\r\n private CollaborationConfig sessionConfig;\r\n\r\n private OrgDirectory orgDirectory;\r\n\r\n private string orgIdentValue;\r\n\r\n private string libraryVersionValue;\r\n\r\n private string projectManifestValue;\r\n\r\n private Task<Package> packageFetch;\r\n\r\n private bool? packageReachable;\r\n\r\n private DateTime nextReadiness;\r\n\r\n private DateTime nextPackageFetch;\r\n\r\n private int readinessOrgRevision = -1;\r\n\r\n private const int PackageRefreshSeconds = 30;\r\n\r\n private const int AutoSaveCheckSeconds = 1;\r\n\r\n private bool restoring;\r\n\r\n private int continuousEdits;\r\n\r\n private bool saving;\r\n\r\n private string focusedPeer;\r\n\r\n private DateTime focusedUntil;\r\n\r\n private DateTime focusNoticeExpires;\r\n\r\n private DateTime saveNoticeExpires;\r\n\r\n private DateTime lastAutoSave;\r\n\r\n private DateTime lastLocalEdit;\r\n\r\n private DateTime nextAutoSaveCheck;\r\n\r\n private bool autoRecover;\r\n\r\n private DateTime undoNoticeExpires;\r\n\r\n private DateTime actionNoticeExpires;\r\n\r\n private SessionFailureClass errorClass = SessionFailureClass.Terminal;\r\n\r\n private int recoverAttempts;\r\n\r\n private DateTime recoverNextAttempt;\r\n\r\n private bool recoverPromptShown;\r\n\r\n private bool recoverArmed;\r\n\r\n private bool recoverDeclined;\r\n\r\n private bool recoverArming;\r\n\r\n private string startAddress;\r\n\r\n private int startPort;\r\n\r\n private readonly HashSet<string> seen = new HashSet<string>();\r\n\r\n // Ids of every settings change this editor sent: a reject for one of them is the host refusing that change, never a reason to fail.\r\n private readonly HashSet<string> sentSettings = new HashSet<string>();\r\n\r\n private const float PromptToastSeconds = 15f;\r\n\r\n private const float OutcomeToastSeconds = 6f;\r\n\r\n public const string NativeUndoEntryName = \"Collaboration Undo\";\r\n\r\n private const int ToolTextLimit = 40;\r\n\r\n private const int StateTextLimit = 32;\r\n\r\n private const int SceneTextLimit = 96;\r\n\r\n private const int ObjectNameTextLimit = 64;\r\n\r\n public static CollaborationSession Current { get; } = new CollaborationSession();\r\n\r\n public string Status { get; private set; } = \"Offline\";\r\n\r\n // Project file synchronization (FileSyncClient): created at welcome when the host advertises it, polled from Tick, fed from Receive.\r\n // Its progress is shown through Status (the dock's session line) and its notices through SetActionNotice and the log, the two ways\r\n // this session already reports to the person.\r\n private FileSyncClient projectFiles;\r\n\r\n private string lastHostFileNotice;\r\n\r\n // The geometry channel of this editor's connection (null while the host does not advertise it). It serves what the host asks for, holds\r\n // scene packets whose geometry has not arrived and releases them in order (docs/BLOB-SYNC.md).\r\n private BlobExchange blobs;\r\n\r\n private string lastHostBlobNotice;\r\n\r\n // The next packet to handle: one released by the exchange first, then the wire. Geometry packets are consumed here.\r\n private bool NextIncoming(out Packet packet)\r\n {\r\n while (true)\r\n {\r\n if (blobs != null && blobs.TryTakeReady(out packet))\r\n {\r\n return true;\r\n }\r\n if (!wire.Incoming.TryDequeue(out packet))\r\n {\r\n return false;\r\n }\r\n if (blobs == null)\r\n {\r\n return true;\r\n }\r\n if (BlobExchange.IsBlobPacket(packet.Kind))\r\n {\r\n lastReceive = DateTime.UtcNow;\r\n blobs.Handle(packet);\r\n continue;\r\n }\r\n if (blobs.Offer(packet) == BlobOffer.Process)\r\n {\r\n return true;\r\n }\r\n lastReceive = DateTime.UtcNow;\r\n }\r\n }\r\n\r\n // Once per frame: asks for missing geometry, serves requests, and for every scene update that had to be discarded (a bound was reached,\r\n // the geometry did not arrive or did not verify) asks the host for the scene again, with the reason shown to the person.\r\n private void PumpBlobs()\r\n {\r\n if (blobs == null)\r\n {\r\n return;\r\n }\r\n blobs.Pump();\r\n foreach ((Packet Packet, string Reason) item in blobs.TakeDropped())\r\n {\r\n Tracked affected = item.Packet.Scene == null ? null : TrackedFor(item.Packet.Scene);\r\n if (affected != null && affected.Session.Scene.IsValid() && !affected.Recovery.Paused)\r\n {\r\n affected.Recovery.Requested();\r\n RequestSceneResync(item.Packet.Scene, affected);\r\n }\r\n }\r\n foreach (string notice in blobs.TakeNotices())\r\n {\r\n GlobalGameNamespace.Log.Info(\"Collaboration: \" + notice);\r\n SetActionNotice(notice);\r\n }\r\n string hostNotice = server?.BlobNotice;\r\n if (hostNotice != null && !string.Equals(hostNotice, lastHostBlobNotice, StringComparison.Ordinal))\r\n {\r\n lastHostBlobNotice = hostNotice;\r\n GlobalGameNamespace.Log.Info(\"Collaboration: \" + hostNotice);\r\n }\r\n }\r\n\r\n // Scene files open in this editor are owned by the live scene sync, so file sync neither fetches nor sends them. PathOf is lowercase\r\n // and relative to Assets; the comparison in FileSyncClient is case-insensitive.\r\n private static string[] OpenFileScenes() => SceneEditorSession.All.Where( x => !(x is GameEditorSession) && !x.IsPrefabSession ).Select( x => SceneAdapter.PathOf( x ) ).Where( x => x != null ).Select( x => \"Assets/\" + x ).ToArray();\r\n\r\n // Makes the editor notice a file synchronization wrote or replaced: AssetSystem.FindByPath / RegisterFile (\"If you just created an asset,\r\n // you probably want to immediately register it\", Sandbox.Tools.xml) and Asset.Compile(false), the call the editor's own MCP\r\n // AssetSystem.cs makes after writing a source file. A deleted file is left to the engine's own file watching.\r\n private static void RefreshAsset( string relativePath )\r\n {\r\n try\r\n {\r\n string absolute = Path.Combine( Project.Current.RootDirectory.FullName, relativePath.Replace( '/', Path.DirectorySeparatorChar ) );\r\n if ( !File.Exists( absolute ) ) return;\r\n Asset asset = AssetSystem.FindByPath( absolute ) ?? AssetSystem.RegisterFile( absolute );\r\n if ( asset != null && asset.CanRecompile ) asset.Compile( false );\r\n }\r\n catch ( Exception ex )\r\n {\r\n GlobalGameNamespace.Log.Warning( \"Collaboration: the asset \\\"\" + relativePath + \"\\\" was received but the editor could not refresh it: \" + ex.Message );\r\n }\r\n }\r\n\r\n private void ShowFileNotices()\r\n {\r\n for ( string notice = projectFiles?.TakeNotice(); notice != null; notice = projectFiles.TakeNotice() )\r\n {\r\n GlobalGameNamespace.Log.Info( \"Collaboration: \" + notice );\r\n SetActionNotice( notice );\r\n }\r\n string hostNotice = server?.FileNotice;\r\n if ( hostNotice != null && !string.Equals( hostNotice, lastHostFileNotice, StringComparison.Ordinal ) )\r\n {\r\n lastHostFileNotice = hostNotice;\r\n GlobalGameNamespace.Log.Info( \"Collaboration: \" + hostNotice );\r\n }\r\n }\r\n\r\n public string Error { get; private set; }\r\n\r\n public bool Connected { get; private set; }\r\n\r\n public SessionState State\r\n {\r\n get\r\n {\r\n string terminalTeardownError = TerminalTeardownError;\r\n if (Connected)\r\n {\r\n if (Error != null || terminalTeardownError != null)\r\n {\r\n return SessionState.Degraded;\r\n }\r\n return IsHost ? SessionState.Hosting : SessionState.Connected;\r\n }\r\n if (terminalTeardownError != null)\r\n {\r\n return SessionState.Degraded;\r\n }\r\n if (Status.StartsWith(\"Reconnecting\", StringComparison.Ordinal))\r\n {\r\n return SessionState.Recovering;\r\n }\r\n if (Busy)\r\n {\r\n return IsHost ? SessionState.Hosting : SessionState.Joining;\r\n }\r\n return ReadinessRules.CanStart(Readiness) ? SessionState.Ready : SessionState.Offline;\r\n }\r\n }\r\n\r\n public bool IsHost => server != null;\r\n\r\n public CollaborationConfig Config => sessionConfig;\r\n\r\n public OrgDirectory OrganizationDirectory => orgDirectory;\r\n\r\n public string InviteCode => inviteCodeValue;\r\n\r\n public IReadOnlyList<DiscoveredHost> DiscoveredHosts => discovery?.Hosts;\r\n\r\n public string PublicEndpointStatus { get; private set; }\r\n\r\n /// <summary>How this editor is connected: the route that actually carried the join, for the dock to show.</summary>\r\n public string RouteDescription { get; private set; }\r\n\r\n /// <summary>\r\n /// TEST-ONLY SEAM. Nothing in this library sets it; only the workspace's editor probe does. When set, and ONLY inside a\r\n /// disposable test copy (a folder under <c>.collaboration/test-copies/</c> that carries the marker file the solo-test\r\n /// launcher writes), the host lets it vouch for a guest hello instead of asking Steam's service, and treats that guest as\r\n /// a friend and organization member. It exists so one person with one Steam account can run a full two-editor session.\r\n /// In any other folder it is ignored, so a real project cannot be weakened by it.\r\n /// </summary>\r\n public static Func<Packet, bool> SoloGuestVoucher { get; set; }\r\n\r\n /// <summary>\r\n /// The facts a guest must present in its hello, read back so an out-of-process test collaborator can present the same ones.\r\n /// They are not secrets: the project id, baseline hash and manifest hash travel in every hello, and the organization and library\r\n /// version are printed in the dock.\r\n /// </summary>\r\n public (string ProjectId, string Baseline, string Manifest, string OrgIdent, string LibraryVersion) HandshakeFacts => (project, baseline, projectManifestValue, orgIdentValue, libraryVersionValue);\r\n\r\n /// <summary>Scene paths this session has subscribed and received a snapshot for. Empty until the host's scene is live.</summary>\r\n public IReadOnlyList<string> ReadyScenes => scenes.Where((KeyValuePair<string, Tracked> x) => x.Value.Ready).Select((KeyValuePair<string, Tracked> x) => x.Key).ToList();\r\n\r\n /// <summary>True in a disposable test copy with the voucher installed. Shown to the user, so it is never a silent state.</summary>\r\n public bool SoloHarness => soloHarness;\r\n\r\n /// <summary>The visible banner for a session running with the test seam active. Null otherwise.</summary>\r\n public string SoloHarnessNotice => soloHarness ? \"Solo test copy: one reserved test identity is accepted here in place of a second Steam account. This never applies to a real project.\" : null;\r\n\r\n private static bool ComputeSoloHarness()\r\n {\r\n if (SoloGuestVoucher == null)\r\n {\r\n return false;\r\n }\r\n string root = SceneAdapter.Root;\r\n bool marker = false;\r\n try\r\n {\r\n marker = !string.IsNullOrEmpty(root) && File.Exists(Path.Combine(root, \".collaboration\", TestHarnessRules.MarkerFile));\r\n }\r\n catch (Exception)\r\n {\r\n marker = false;\r\n }\r\n return TestHarnessRules.IsDisposableCopy(root, marker);\r\n }\r\n\r\n private bool SoloMayVouch(Packet hello)\r\n {\r\n try\r\n {\r\n return soloHarness && hello != null && SoloGuestVoucher != null && SoloGuestVoucher(hello);\r\n }\r\n catch (Exception)\r\n {\r\n return false;\r\n }\r\n }\r\n\r\n /// <summary>The host's relay registration, worded for the dock: the internet relay's progress when the host runs its own, else the configured relay's. Null when there is none.</summary>\r\n public string RelayStatus\r\n {\r\n get\r\n {\r\n if (hostingPlan != null)\r\n {\r\n return hostingPlan.Status;\r\n }\r\n RelayHost host = relayHost;\r\n if (host == null || host.State == RelayHostState.Stopped || host.State == RelayHostState.Idle)\r\n {\r\n return null;\r\n }\r\n switch (host.State)\r\n {\r\n case RelayHostState.Ready:\r\n return \"Relay connected \u2014 friends can join from any network.\";\r\n case RelayHostState.Failed:\r\n return \"Relay unavailable \u2014 \" + host.Failure;\r\n default:\r\n return host.Status ?? \"Connecting to the relay\u2026\";\r\n }\r\n }\r\n }\r\n\r\n public bool RelayFailed => relayHost?.State == RelayHostState.Failed;\r\n\r\n /// <summary>True while the internet relay has failed or is restarting, so the dock can colour its sentence as a problem.</summary>\r\n public bool InternetRelayTrouble => hostingPlan != null && (hostingPlan.Phase == HostingPhase.Failed || hostingPlan.Phase == HostingPhase.Degraded);\r\n\r\n /// <summary>Set while hosting with no relay a friend elsewhere could reach, so the dock can say so instead of leaving a silent gap. Null otherwise.</summary>\r\n public string NoRelayNotice => IsHost && hostingPlan == null && (relayHost == null || relayHost.State == RelayHostState.Stopped)\r\n ? HostingPlan.NoRelayNotice(effectiveRelay: EffectiveRelayUrl(), configuredRelay: sessionConfig?.RelayUrl, internetRelayEnabled: RelaySetup.InternetRelayEnabled)\r\n : null;\r\n\r\n /// <summary>What source synchronization is doing this frame; the dock reads it to explain a pause.</summary>\r\n public PlaySpectateDecision PlayDecision { get; private set; }\r\n\r\n public bool PausedForPlay => Connected && PlayDecision == PlaySpectateDecision.PausedForPlay;\r\n\r\n public int HeldSceneChanges => heldPackets.Count;\r\n\r\n /// <summary>The sentence shown while the local editor is playing and scene edits are held back. Null otherwise.</summary>\r\n public string PlayNotice\r\n {\r\n get\r\n {\r\n if (PausedForPlay && play?.EditsPausedNotice != null)\r\n {\r\n return play.EditsPausedNotice;\r\n }\r\n if (!PausedForPlay)\r\n {\r\n return null;\r\n }\r\n int held = heldPackets.Count;\r\n return held == 0\r\n ? \"Playing: scene edits are paused. Your collaborators can still see you; changes resume when you stop.\"\r\n : \"Playing: scene edits are paused. \" + held + \" change\" + (held == 1 ? \"\" : \"s\") + \" from collaborators will apply when you stop.\";\r\n }\r\n }\r\n\r\n public string PortMappingStatus => upnp?.Status;\r\n\r\n public bool PortMappingFailed\r\n {\r\n get\r\n {\r\n UpnpPortMapper upnpPortMapper = upnp;\r\n return upnpPortMapper != null && upnpPortMapper.Current == UpnpPortMapper.State.Failed;\r\n }\r\n }\r\n\r\n public string DiscoveryStatus => discovery?.Failure;\r\n\r\n public bool Busy => CollaborationSessionResourceRules.IsBusy(wire != null, connecting != null, server != null, lifecycle.IsFailStop);\r\n\r\n public PlayModeLifecycle RuntimeLifecycle { get; } = new PlayModeLifecycle(optIn: false, \"source-known-state\");\r\n\r\n public PlayModeLifecycleEventState RuntimeLifecycleEvents { get; } = new PlayModeLifecycleEventState();\r\n\r\n public bool ExperimentalRuntimeOptIn\r\n {\r\n get\r\n {\r\n return RuntimeLifecycle.OptIn;\r\n }\r\n set\r\n {\r\n PlayModeTransitionResult playModeTransitionResult = RuntimeLifecycle.SetOptIn(value);\r\n if (!playModeTransitionResult.Accepted)\r\n {\r\n SetLifecycleError(playModeTransitionResult.ActionableError);\r\n }\r\n }\r\n }\r\n\r\n public string PlayModeStatus\r\n {\r\n get\r\n {\r\n PlayModeLifecycle runtimeLifecycle = RuntimeLifecycle;\r\n if (1 == 0)\r\n {\r\n }\r\n if (runtimeLifecycle == null)\r\n {\r\n goto IL_0059;\r\n }\r\n switch (runtimeLifecycle.State)\r\n {\r\n case PlayModeLifecycleState.Source:\r\n break;\r\n case PlayModeLifecycleState.RuntimeDisabled:\r\n goto IL_0039;\r\n case PlayModeLifecycleState.RuntimeStarting:\r\n goto IL_0041;\r\n case PlayModeLifecycleState.RuntimeActive:\r\n goto IL_0049;\r\n case PlayModeLifecycleState.RuntimeBlocked:\r\n goto IL_0051;\r\n default:\r\n goto IL_0059;\r\n }\r\n string result = \"Source collaboration\";\r\n goto IL_0061;\r\n IL_0051:\r\n result = \"Runtime collaboration blocked \u2022 recovery required\";\r\n goto IL_0061;\r\n IL_0059:\r\n result = \"Source collaboration\";\r\n goto IL_0061;\r\n IL_0061:\r\n if (1 == 0)\r\n {\r\n }\r\n return result;\r\n IL_0039:\r\n result = \"Source collaboration \u2022 runtime disabled\";\r\n goto IL_0061;\r\n IL_0041:\r\n result = \"Source collaboration \u2022 starting runtime\";\r\n goto IL_0061;\r\n IL_0049:\r\n result = \"Experimental runtime collaboration enabled \u2022 transport and apply unsupported\";\r\n goto IL_0061;\r\n }\r\n }\r\n\r\n public string RuntimeLifecycleDiagnostic => RuntimeLifecycle.LastDiagnostic;\r\n\r\n public string RuntimeLifecycleError => RuntimeLifecycle.LastActionableError;\r\n\r\n public bool HasPendingLifecycleCandidate => (object)RuntimeLifecycleEvents.Pending != null;\r\n\r\n public Dictionary<string, Packet> Roster { get; } = new Dictionary<string, Packet>();\r\n\r\n public string Peer { get; } = Guid.NewGuid().ToString();\r\n\r\n public long AcceptedOperations { get; private set; }\r\n\r\n public int DrawnMarkers { get; private set; }\r\n\r\n public int DrawnSelections { get; private set; }\r\n\r\n public string FocusNotice { get; private set; }\r\n\r\n public string RecoveryPath { get; private set; }\r\n\r\n public IReadOnlyList<ReadinessItem> Readiness { get; private set; } = Array.Empty<ReadinessItem>();\r\n\r\n public string ActiveSceneSupport\r\n {\r\n get\r\n {\r\n SceneEditorSession supportedActiveScene = SupportedActiveScene;\r\n return (supportedActiveScene != null) ? (\"Active scene: \" + SceneAdapter.PathOf(supportedActiveScene)) : \"This view is outside the supported edit set (use a saved scene under Assets/scenes).\";\r\n }\r\n }\r\n\r\n private static SceneEditorSession SupportedActiveScene\r\n {\r\n get\r\n {\r\n SceneEditorSession active = SceneEditorSession.Active;\r\n object result;\r\n if (active != null && !active.IsPrefabSession && !active.IsPlaying)\r\n {\r\n string text = SceneAdapter.PathOf(active);\r\n if (text != null && text.StartsWith(\"scenes/\"))\r\n {\r\n result = active;\r\n goto IL_0036;\r\n }\r\n }\r\n result = null;\r\n goto IL_0036;\r\n IL_0036:\r\n return (SceneEditorSession)result;\r\n }\r\n }\r\n\r\n public string RecoveryNotice { get; private set; }\r\n\r\n public string SaveNotice { get; private set; }\r\n\r\n /// <summary>\r\n /// A formal sentence about somebody else's edit meeting this editor's own (the host combined them, a clash was settled in favour of\r\n /// one side, or an unsent change could not be kept). Null when there is nothing to say; cleared by <see cref=\"Frame\"/> about eight\r\n /// seconds after <see cref=\"MergeNoticeAt\"/>. The Controls card shows it.\r\n /// </summary>\r\n public string MergeNotice { get; private set; }\r\n\r\n /// <summary>When <see cref=\"MergeNotice\"/> was last set (UTC); meaningful only while the notice is non-null.</summary>\r\n public DateTime MergeNoticeAt { get; private set; }\r\n\r\n private DateTime mergeNoticeExpires;\r\n\r\n public bool AutoSaveIdle { get; set; }\r\n\r\n public bool AutoRecover\r\n {\r\n get\r\n {\r\n return autoRecover;\r\n }\r\n set\r\n {\r\n if (autoRecover != value)\r\n {\r\n autoRecover = value;\r\n if (value)\r\n {\r\n recoverAttempts = 0;\r\n recoverNextAttempt = default;\r\n recoverDeclined = false;\r\n }\r\n }\r\n }\r\n }\r\n\r\n public string UndoNotice { get; private set; }\r\n\r\n public string ActionNotice { get; private set; }\r\n\r\n /// <summary>\r\n /// On the host: a formal sentence when a guest's editor keeps drifting from the host's scene although it was resynchronised repeatedly,\r\n /// otherwise null. Always null on a guest.\r\n /// </summary>\r\n public string DriftNotice => server?.DriftNotice;\r\n\r\n public string TerminalTeardownError => lifecycle.TerminalDiagnostic;\r\n\r\n public bool ReconnectRecovery => reconnectRecovery;\r\n\r\n private CollaborationSession()\r\n {\r\n EditorEvent.Register(this);\r\n }\r\n\r\n private void SetLifecycleError(string error)\r\n {\r\n if (!string.IsNullOrWhiteSpace(error))\r\n {\r\n RecoveryNotice = BoundedDiagnostic(error);\r\n GlobalGameNamespace.Log.Error(\"Collaboration lifecycle: \" + RecoveryNotice);\r\n }\r\n }\r\n\r\n private void ApplyLifecycleResult(PlayModeTransitionResult result)\r\n {\r\n if (!result.Accepted)\r\n {\r\n SetLifecycleError(result.ActionableError);\r\n }\r\n }\r\n\r\n [Event(\"scene.startplay\")]\r\n public static void OnStartPlay()\r\n {\r\n CollaborationSession current = Current;\r\n current.Checkpoint();\r\n SceneEditorSession active = SceneEditorSession.Active;\r\n SceneEditorSession sceneEditorSession = ((active != null && !active.IsPrefabSession) ? active : null);\r\n string text = ((sceneEditorSession == null) ? null : SceneAdapter.PathOf(sceneEditorSession));\r\n if (sceneEditorSession == null || !RuntimeStateRules.IsSafeScenePath(text))\r\n {\r\n current.SetLifecycleError(\"Play mode could not identify a saved source scene; source collaboration is unchanged.\");\r\n }\r\n else\r\n {\r\n current.ApplyLifecycleResult(current.RuntimeLifecycleEvents.RecordStartIntent(current.RuntimeLifecycle, sceneEditorSession, text, Guid.NewGuid().ToString(\"N\")));\r\n }\r\n }\r\n\r\n [Event(\"scene.play\")]\r\n public static void OnPlay()\r\n {\r\n CollaborationSession current = Current;\r\n current.Checkpoint();\r\n GameEditorSession[] array = SceneEditorSession.All.OfType<GameEditorSession>().ToArray();\r\n if (array.Length != 1)\r\n {\r\n current.SetLifecycleError((array.Length == 0) ? \"Play-mode lifecycle could not find a GameEditorSession in SceneEditorSession.All; source collaboration is unchanged.\" : \"Multiple GameEditorSession objects are active; runtime collaboration was not enabled. Stop play mode and retry.\");\r\n return;\r\n }\r\n GameEditorSession gameEditorSession = array[0];\r\n SceneEditorSession parent = gameEditorSession.Parent;\r\n string a = SceneAdapter.PathOf(parent) ?? parent?.Scene?.Source?.ResourcePath;\r\n PlayModePendingCandidate pending = current.RuntimeLifecycleEvents.Pending;\r\n if ((object)pending == null || parent != pending.SourceSessionToken || !string.Equals(a, pending.SourceScenePath, StringComparison.OrdinalIgnoreCase))\r\n {\r\n current.SetLifecycleError(\"Runtime GameEditorSession parent/path did not match the source captured by scene.startplay; runtime collaboration was not enabled.\");\r\n return;\r\n }\r\n PlayModeTransitionResult result = current.RuntimeLifecycleEvents.AttachRuntime(current.RuntimeLifecycle, RuntimeSceneAdapter.Describe(gameEditorSession), pending.RuntimeRunId);\r\n current.ApplyLifecycleResult(result);\r\n }\r\n\r\n [Event(\"scene.stop\")]\r\n public static void OnStop()\r\n {\r\n CollaborationSession current = Current;\r\n current.ApplyLifecycleResult(current.RuntimeLifecycleEvents.RecordStop(current.RuntimeLifecycle));\r\n }\r\n\r\n [Event(\"scene.saved\")]\r\n public static void OnSceneSaved(Scene scene)\r\n {\r\n Current.ReconcileSavedScene(scene);\r\n }\r\n\r\n private void ReconcileSavedScene(Scene scene)\r\n {\r\n SceneEditorSession sceneEditorSession = SceneEditorSession.All.FirstOrDefault((SceneEditorSession x) => x.Scene == scene) ?? SceneEditorSession.Active;\r\n string text = SceneAdapter.PathOf(sceneEditorSession);\r\n Tracked tracked = null;\r\n DateTime dateTime = default;\r\n if (text != null && scenes.TryGetValue(text, out var value) && value.Session == sceneEditorSession)\r\n {\r\n tracked = value;\r\n dateTime = SceneAdapter.DiskStamp(text);\r\n }\r\n bool flag = Connected && Error == null;\r\n switch (SceneSaveRules.Decide(saving || (tracked != null && tracked.DiskStamp == dateTime), flag, tracked != null, flag && tracked != null && CanSave()))\r\n {\r\n case SceneSaveOutcome.Absorb:\r\n tracked.DiskStamp = dateTime;\r\n server?.AllowSavedBaseline(SceneAdapter.Baseline());\r\n SetSaveNotice(\"The scene was saved outside the session; the session accepted the save.\");\r\n break;\r\n case SceneSaveOutcome.Warn:\r\n tracked.DiskStamp = dateTime;\r\n SetSaveNotice(\"The scene was saved outside the session while edits were in flight; disk and session state may differ.\");\r\n break;\r\n }\r\n }\r\n\r\n private void SetSaveNotice(string text)\r\n {\r\n string text2 = BoundedDiagnostic(text);\r\n if (!string.Equals(SaveNotice, text2, StringComparison.Ordinal) || !(DateTime.UtcNow < saveNoticeExpires))\r\n {\r\n SaveNotice = text2;\r\n saveNoticeExpires = DateTime.UtcNow.AddSeconds(8.0);\r\n }\r\n }\r\n\r\n private void SetMergeNotice(string text)\r\n {\r\n // `text` is already joined, cleaned and bounded by ChangeRebase.JoinNotices; a repeated sentence only extends the display time.\r\n if (string.IsNullOrEmpty(text))\r\n {\r\n return;\r\n }\r\n MergeNotice = text;\r\n MergeNoticeAt = DateTime.UtcNow;\r\n mergeNoticeExpires = MergeNoticeAt.AddSeconds(8.0);\r\n }\r\n\r\n [EditorEvent.Hotload]\r\n public static void CodeChanged()\r\n {\r\n CollaborationSession current = Current;\r\n PlayModeTransitionResult playModeTransitionResult = current.RuntimeLifecycle.BlockForHotload(\"hotloaded\");\r\n current.ApplyLifecycleResult(playModeTransitionResult);\r\n HotloadArchiveOutcome hotloadArchiveOutcome = current.ArchiveHotloadLifecycle(current.RuntimeLifecycle.ArchiveReason ?? \"hotloaded\");\r\n bool busy = current.Busy;\r\n HotloadCodeChangeReport hotloadCodeChangeReport = HotloadCodeChangeRules.Decide(new HotloadCodeChangeRequest(current.RuntimeLifecycle.State, playModeTransitionResult.Accepted, hotloadArchiveOutcome == HotloadArchiveOutcome.Archived, busy, playModeTransitionResult.Accepted ? null : playModeTransitionResult.ActionableError));\r\n if (hotloadCodeChangeReport.SurfaceError)\r\n {\r\n current.Fail(hotloadCodeChangeReport.Message);\r\n }\r\n if (!hotloadCodeChangeReport.ArchiveClaimed)\r\n {\r\n current.RecoveryNotice = CollaborationDiagnostics.Bounded(hotloadCodeChangeReport.LifecycleRejectionReason ?? current.RuntimeLifecycle.LastDiagnostic);\r\n }\r\n if (hotloadCodeChangeReport.TeardownTransport)\r\n {\r\n current.DisposeActiveResources();\r\n }\r\n current.BeginConnectionTeardown(hotloadCodeChangeReport.RequiresHotloadRecoveryAfterLeave);\r\n }\r\n\r\n private static string BoundedDiagnostic(string value)\r\n {\r\n return CollaborationDiagnostics.Bounded(value);\r\n }\r\n\r\n public void Start(bool host, string address, int port, string name, string token, string steamId = null, string steamToken = null, bool allowUnverifiedPeers = false)\r\n {\r\n StartCore(new StartPlan\r\n {\r\n Host = host,\r\n Address = address,\r\n Port = port,\r\n Name = name,\r\n Token = token,\r\n SteamId = steamId,\r\n SteamToken = steamToken,\r\n AllowUnverifiedPeers = allowUnverifiedPeers\r\n });\r\n }\r\n\r\n private void StartCore(StartPlan plan)\r\n {\r\n activeAddress = null;\r\n activePort = 0;\r\n soloHarness = ComputeSoloHarness();\r\n if (soloHarness && LocalSteamIdentity.TryGet(out var soloLocal, out var _, out var _))\r\n {\r\n // The reserved guest borrows the signed-in account's picture so its marker is not an empty ring.\r\n AvatarCache.SyntheticAvatarAlias = SteamIdentityRules.Canonical(soloLocal);\r\n }\r\n else\r\n {\r\n AvatarCache.SyntheticAvatarAlias = null;\r\n }\r\n PlayModeTransitionResult playModeTransitionResult = lifecycle.ValidateStart(new SessionStartGuardRequest(RuntimeLifecycle.ValidateSessionStart(), Busy, lifecycle.IsPending, lifecycle.HasTerminalFailure, lifecycle.TerminalDiagnostic));\r\n if (!playModeTransitionResult.Accepted)\r\n {\r\n throw new InvalidOperationException(playModeTransitionResult.ActionableError);\r\n }\r\n if (!plan.ZeroConfig && (string.IsNullOrWhiteSpace(plan.Token) || plan.Token.Length < 12))\r\n {\r\n throw new InvalidOperationException(\"Use a session key of at least 12 characters\");\r\n }\r\n if (plan.ZeroConfig && !plan.Host && plan.SessionKey == null)\r\n {\r\n throw new InvalidOperationException(\"A zero-config join requires the session key derived from the invite code.\");\r\n }\r\n if (string.IsNullOrWhiteSpace(plan.Name) || plan.Name.Length > 64)\r\n {\r\n throw new InvalidOperationException(\"No display name is available for the signed-in Steam account\");\r\n }\r\n foreach (SceneEditorSession item in SceneEditorSession.All.Where((SceneEditorSession x) => !(x is GameEditorSession)))\r\n {\r\n if (item.HasUnsavedChanges)\r\n {\r\n throw new InvalidOperationException(\"Save or revert local scenes before starting a new collaboration session\");\r\n }\r\n }\r\n string path = Path.Combine(SceneAdapter.Root, \"ProjectSettings\", \"collaboration.json\");\r\n if (plan.Host)\r\n {\r\n // The first Host click on a new project writes the shared settings itself: a fresh project id and the library's own gates, all on.\r\n // Only the host does this; a friend joining a project that has no settings is told to get the host's copy, since a project id they\r\n // invented would never match.\r\n SetupResult setup = CollaborationSetup.EnsureConfig(path, Project.Current?.Config?.Org, LibraryInfo.DefaultLibraryIdent(Project.Current?.Config?.Org), LibraryInfo.LibraryVersion);\r\n if (setup.Error != null)\r\n {\r\n throw new InvalidOperationException(setup.Error);\r\n }\r\n if (setup.Created)\r\n {\r\n ActionNotice = \"Created ProjectSettings/collaboration.json for this project. Your friend needs the same file: send them the project as it is now.\";\r\n actionNoticeExpires = DateTime.UtcNow.AddSeconds(30.0);\r\n }\r\n }\r\n if (!File.Exists(path))\r\n {\r\n throw new InvalidOperationException(\"This project has no ProjectSettings/collaboration.json. Ask the host for a copy of the project, so both of you have the same file.\");\r\n }\r\n JsonNode jsonNode = JsonNode.Parse(File.ReadAllText(path))?[\"ProjectId\"];\r\n if (jsonNode == null || !Guid.TryParse(jsonNode.GetValue<string>(), out var _))\r\n {\r\n throw new InvalidOperationException(\"ProjectId must be a GUID\");\r\n }\r\n string preparedProject = jsonNode.GetValue<string>();\r\n string preparedBaseline = SceneAdapter.Baseline();\r\n string text = SceneAdapter.Baseline(includeScenes: false);\r\n CollaborationConfig collaborationConfig = CollaborationConfig.LoadFrom(path, out var _);\r\n UseConfig(collaborationConfig);\r\n if (plan.ZeroConfig && plan.Host)\r\n {\r\n PrepareHostSecret(plan, collaborationConfig);\r\n }\r\n string org = collaborationConfig.Org;\r\n string preparedLibraryVersion = LibraryInfo.LibraryVersion;\r\n string preparedManifest = ProjectManifest.Compute(Project.Current.RootDirectory.FullName);\r\n try\r\n {\r\n RefreshReadiness();\r\n }\r\n catch (Exception error2)\r\n {\r\n Fail(\"Readiness could not be evaluated: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error2));\r\n return;\r\n }\r\n string text2 = (ReadinessRules.CanStart(Readiness) ? null : (ReadinessRules.BlockReason(Readiness) ?? \"Hosting is unavailable: the readiness checklist could not be evaluated.\"));\r\n if (text2 != null)\r\n {\r\n Fail(text2);\r\n return;\r\n }\r\n SessionServer preparedServer = null;\r\n Task<Wire> preparedConnect = null;\r\n int boundPort = plan.Port;\r\n string dialAddress = ((plan.Host && plan.Address == \"0.0.0.0\") ? \"127.0.0.1\" : plan.Address);\r\n PlayModeTransitionResult playModeTransitionResult2 = lifecycle.BeginStart(new SessionStartCommitRequest(playModeTransitionResult, () =>\r\n {\r\n if (plan.Host)\r\n {\r\n preparedServer = OpenHostServer(plan, preparedProject, preparedBaseline, out boundPort);\r\n preparedServer.AllowUnverifiedPeers = plan.AllowUnverifiedPeers;\r\n preparedServer.peerFacts = BuildPeerFacts;\r\n preparedServer.config = sessionConfig;\r\n preparedServer.hostLibraryVersion = preparedLibraryVersion;\r\n preparedServer.hostManifest = preparedManifest;\r\n }\r\n List<EndpointResolver.EndpointCandidate> candidates2 = plan.Candidates;\r\n preparedConnect = ((candidates2 != null && candidates2.Count > 0) ? ConnectCandidates(plan.Candidates, plan.SessionKey, plan.Secret) : Wire.Connect(dialAddress, boundPort, plan.SessionKey, plan.SessionKey != null));\r\n return true;\r\n }));\r\n if (!playModeTransitionResult2.Accepted)\r\n {\r\n if (preparedServer != null)\r\n {\r\n ResourceDisposalResult resourceDisposalResult = ResourceDisposalRules.DisposePair(null, \"collaboration transport\", preparedServer, \"collaboration host server\");\r\n if (!resourceDisposalResult.Succeeded)\r\n {\r\n RecordTerminalTeardownFailure(resourceDisposalResult.Failure);\r\n }\r\n }\r\n connecting = preparedConnect;\r\n BeginConnectionTeardown(recoverWhenComplete: false);\r\n throw new InvalidOperationException(playModeTransitionResult2.ActionableError);\r\n }\r\n project = preparedProject;\r\n baseline = preparedBaseline;\r\n userName = plan.Name;\r\n sessionToken = plan.Token;\r\n activeSessionKey = plan.SessionKey;\r\n activeSecret = plan.Secret;\r\n activeCandidates = plan.Candidates;\r\n RouteDescription = null;\r\n List<EndpointResolver.EndpointCandidate> candidates = plan.Candidates;\r\n if (candidates == null || candidates.Count <= 0)\r\n {\r\n activeAddress = dialAddress;\r\n activePort = boundPort;\r\n }\r\n startAddress = dialAddress;\r\n startPort = boundPort;\r\n steamIdValue = plan.SteamId;\r\n steamTokenValue = plan.SteamToken;\r\n orgIdentValue = org;\r\n libraryVersionValue = preparedLibraryVersion;\r\n projectManifestValue = preparedManifest;\r\n sourceBaseline = text;\r\n hostSession = null;\r\n reconnectQueued = false;\r\n reconnectTokenTask = null;\r\n reconnectRecovery = false;\r\n reconnectRetrySpent = false;\r\n scenes.Clear();\r\n Roster.Clear();\r\n seen.Clear();\r\n sentSettings.Clear();\r\n lastRevision = 0;\r\n driftNoticeShown = null;\r\n Error = null;\r\n RecoveryNotice = null;\r\n // A path from an earlier session would otherwise sit under a new failure and point at an archive that has\r\n // nothing to do with it.\r\n RecoveryPath = null;\r\n restoring = false;\r\n errorClass = SessionFailureClass.Terminal;\r\n recoverAttempts = 0;\r\n recoverNextAttempt = default;\r\n recoverPromptShown = false;\r\n recoverArmed = false;\r\n recoverDeclined = false;\r\n SaveNotice = null;\r\n saveNoticeExpires = default;\r\n UndoNotice = null;\r\n undoNoticeExpires = default;\r\n ActionNotice = null;\r\n actionNoticeExpires = default;\r\n MergeNotice = null;\r\n mergeNoticeExpires = default;\r\n lastAutoSave = default;\r\n lastLocalEdit = default;\r\n nextAutoSaveCheck = default;\r\n endedByLeave = false;\r\n server = preparedServer;\r\n connecting = preparedConnect;\r\n if (plan.ZeroConfig && plan.Host)\r\n {\r\n BeginHostConnectivity(plan, boundPort);\r\n }\r\n Status = \"Connecting\u2026\";\r\n }\r\n\r\n private void PrepareHostSecret(StartPlan plan, CollaborationConfig config)\r\n {\r\n if (string.IsNullOrEmpty(config.ProjectId))\r\n {\r\n throw new InvalidOperationException(\"ProjectSettings/collaboration.json must carry a shared ProjectId before a session can be hosted.\");\r\n }\r\n plan.Secret = TeamCreate.InviteCode.NewSecret();\r\n plan.SessionKey = TeamCreate.InviteCode.DeriveKey(plan.Secret, config.ProjectId);\r\n plan.Token = Convert.ToHexString(plan.SessionKey);\r\n }\r\n\r\n private SessionServer OpenHostServer(StartPlan plan, string project, string baseline, out int boundPort)\r\n {\r\n int num = ((!plan.ZeroConfig) ? 1 : 5);\r\n SocketException error = null;\r\n for (int i = 0; i < num; i++)\r\n {\r\n int num2 = plan.Port + i;\r\n if (num2 > 65535)\r\n {\r\n break;\r\n }\r\n string text = Path.Combine(SceneAdapter.Root, \".collaboration\", \"journal-\" + Guid.NewGuid().ToString() + \".jsonl\");\r\n try\r\n {\r\n SessionServer result = new SessionServer(plan.Address, num2, project, baseline, plan.Token, text, null, (Packet hello) => SoloMayVouch(hello) ? Task.FromResult(SteamTokenVerdict.Verified) : new SteamTokenHttpVerifier(SandboxHttpTokenTransport.Default).VerifyAsync(hello, hostVerificationLifetime.Token), plan.SessionKey);\r\n result.FileProjectRoot = Project.Current.RootDirectory.FullName;\r\n BlobHub.Store.AttachDisk(SceneAdapter.Root, BlobLimits.DiskCacheBytes);\r\n result.BlobStore = BlobHub.Store;\r\n result.SharedPlayEnabled = SharedPlayPreference.Enabled;\r\n result.HostEngineBaseline = SceneAdapter.EngineBaseline();\r\n result.FileChanged = RefreshAsset;\r\n boundPort = num2;\r\n return result;\r\n }\r\n catch (SocketException ex)\r\n {\r\n error = ex;\r\n try\r\n {\r\n File.Delete(text);\r\n }\r\n catch\r\n {\r\n }\r\n }\r\n }\r\n boundPort = plan.Port;\r\n throw new IOException(CollaborationDiagnostics.DescribeExternalFailure((num == 1) ? $\"Port {plan.Port} could not be opened: \" : $\"None of the {num} ports from {plan.Port} could be opened: \", error));\r\n }\r\n\r\n private async Task<Wire> ConnectCandidates(IReadOnlyList<EndpointResolver.EndpointCandidate> candidates, byte[] sessionKey, byte[] secret)\r\n {\r\n List<RouteAttempt> attempts = new List<RouteAttempt>();\r\n foreach (EndpointResolver.EndpointCandidate candidate in candidates)\r\n {\r\n try\r\n {\r\n Wire reached;\r\n if (candidate.Source == RelayProtocol.RouteSource)\r\n {\r\n if (secret == null || sessionKey == null)\r\n {\r\n throw new InvalidOperationException(\"The relay route needs the invite code's secret.\");\r\n }\r\n if (!soloHarness && !RelayProtocol.IsPublicRelay(candidate.Address) && !IsHost)\r\n {\r\n // A code naming localhost was made on the host's own computer. Dialling it here reaches THIS computer, so say so instead of\r\n // reporting a raw socket error that sends the person looking for a network problem that does not exist.\r\n throw new RelayRefusedException(RelayProtocol.LoopbackCode, \"the code points to a relay on the host's own computer\");\r\n }\r\n reached = await RelayGuest.ConnectAsync(() => new SandboxRelaySocket(), candidate.Address, secret, sessionKey);\r\n RouteDescription = \"Connected through the relay (\" + RelayHostName(candidate.Address) + \")\";\r\n }\r\n else\r\n {\r\n reached = await Wire.Connect(candidate.Address, candidate.Port, sessionKey, sessionKey != null);\r\n RouteDescription = candidate.Source == \"lan\" ? \"Connected on the local network\" : \"Connected directly to \" + candidate.Address + \":\" + candidate.Port;\r\n }\r\n activeAddress = candidate.Address;\r\n activePort = candidate.Port;\r\n return reached;\r\n }\r\n catch (Exception ex)\r\n {\r\n string target = candidate.Source == RelayProtocol.RouteSource ? candidate.Address : candidate.Address + \":\" + candidate.Port;\r\n attempts.Add(new RouteAttempt(candidate.Source, target, ex.Message, (ex as RelayRefusedException)?.Code));\r\n }\r\n }\r\n string summary = RouteFailureRules.Summarize(attempts);\r\n // A wrong invite secret is refused by the relay and cannot succeed on a second try; every other\r\n // route failure is a network fault that recovery may legitimately retry.\r\n bool terminal = attempts.Any((RouteAttempt a) => a.RelayCode != null && RelayProtocol.IsTerminal(a.RelayCode));\r\n if (terminal)\r\n {\r\n throw new InvalidOperationException(summary);\r\n }\r\n throw new IOException(summary);\r\n }\r\n\r\n private static string RelayHostName(string url)\r\n {\r\n return Uri.TryCreate(url, UriKind.Absolute, out Uri uri) ? uri.Host : \"relay\";\r\n }\r\n\r\n private void BeginHostConnectivity(StartPlan plan, int port)\r\n {\r\n hostSecret = plan.Secret;\r\n hostPort = port;\r\n PublicEndpointStatus = \"Public endpoint pending\u2026\";\r\n stunEndpointFolded = false;\r\n stun = new StunClient();\r\n inviteCodeValue = BuildInviteCode(port);\r\n StartDiscovery();\r\n if (discovery != null)\r\n {\r\n string text = projectManifestValue;\r\n if (text != null && text.Length == 8)\r\n {\r\n try\r\n {\r\n discovery.StartHost(port, Convert.ToHexString(hostSecret).ToLowerInvariant(), projectManifestValue);\r\n }\r\n catch (Exception error)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration discovery: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));\r\n }\r\n }\r\n }\r\n upnp = new UpnpPortMapper();\r\n try\r\n {\r\n upnp.Map(port);\r\n }\r\n catch (Exception error2)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration port mapping: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error2));\r\n }\r\n StartRelayHost(plan, port);\r\n }\r\n\r\n private void StartRelayHost(StartPlan plan, int port)\r\n {\r\n string relayUrl = EffectiveRelayUrl();\r\n // No relay named by the project or this computer, and nobody opted out: the host runs the relay itself and exposes it through an account-free\r\n // Quick Tunnel. Its own RelayHost dials the loopback relay now; the invite gains the public address only when the plan has seen it answer.\r\n if (HostingPlan.Applies(effectiveRelay: relayUrl, configuredRelay: sessionConfig?.RelayUrl, internetRelayEnabled: RelaySetup.InternetRelayEnabled))\r\n {\r\n hostingPlan = new HostingPlan();\r\n HostingDecision first = hostingPlan.Pump(HostingInputsNow(probeResult: null));\r\n relayUrl = first.Action == HostingAction.StartRelay ? StartEmbeddedRelay() : null;\r\n }\r\n if (string.IsNullOrWhiteSpace(relayUrl) || plan.Secret == null || plan.SessionKey == null)\r\n {\r\n return;\r\n }\r\n // The embedded relay is this host's own and takes no access key; the room's proofs, derived from the invite secret, keep strangers out of it.\r\n string accessKey = embeddedRelay != null ? null : ReadRelayAccessKey();\r\n try\r\n {\r\n relayHost = new RelayHost(() => new SandboxRelaySocket(), relayUrl, plan.Secret, plan.SessionKey, accessKey);\r\n if (server != null)\r\n {\r\n server.Relay = relayHost;\r\n }\r\n relayHost.Start();\r\n // A configured relay goes into the invite at once, as it always has; the embedded one only when the plan publishes its public address.\r\n inviteCarriesRelay = embeddedRelay == null;\r\n inviteCodeValue = BuildInviteCode(port);\r\n }\r\n catch (Exception error)\r\n {\r\n relayHost?.Dispose();\r\n relayHost = null;\r\n inviteCarriesRelay = false;\r\n if (embeddedRelay != null)\r\n {\r\n embeddedRelayError = \"The host could not connect to its own local relay.\";\r\n }\r\n GlobalGameNamespace.Log.Error(\"Collaboration relay: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));\r\n }\r\n }\r\n\r\n // Takes the first loopback port the engine lets the host's own WebSocket dial (HostingPlan.LocalRelayPorts). A free port picked by the system would\r\n // be refused by the engine's ws:// policy, so the choice is limited to those four. Returns the local relay address, or null with embeddedRelayError\r\n // set for the plan to report on its next pump.\r\n private string StartEmbeddedRelay()\r\n {\r\n foreach (int candidate in HostingPlan.LocalRelayPorts)\r\n {\r\n EmbeddedRelay relay = new EmbeddedRelay(candidate);\r\n try\r\n {\r\n relay.Start();\r\n embeddedRelay = relay;\r\n return HostingPlan.LocalRelayUrl(candidate);\r\n }\r\n catch (InvalidOperationException)\r\n {\r\n relay.Dispose();\r\n }\r\n }\r\n embeddedRelayError = \"The local relay could not listen on any of the ports the editor permits (\" + string.Join(\", \", HostingPlan.LocalRelayPorts) + \"); close the program that uses them and host again.\";\r\n return null;\r\n }\r\n\r\n private HostingInputs HostingInputsNow(string probeResult)\r\n {\r\n CloudflaredTool tunnel = quickTunnel;\r\n string relayError = embeddedRelayError;\r\n if (relayError == null && embeddedRelay != null && !embeddedRelay.IsListening)\r\n {\r\n relayError = \"The local relay stopped listening.\";\r\n }\r\n if (relayError == null && embeddedRelay != null && relayHost?.State == RelayHostState.Failed)\r\n {\r\n relayError = \"The host's connection to its own local relay failed: \" + relayHost.Failure;\r\n }\r\n bool running = tunnel?.State == CloudflaredTool.Phase.Running;\r\n return new HostingInputs(\r\n Now: HostingSeconds,\r\n RelayStarted: embeddedRelay?.IsListening == true,\r\n RelayError: relayError,\r\n LocalHostReady: relayHost?.State == RelayHostState.Ready,\r\n TunnelRunning: running,\r\n TunnelRelayUrl: running ? tunnel.RelayUrl : null,\r\n TunnelRegistered: tunnel?.RegisteredConnectionSeen == true,\r\n TunnelError: tunnel?.State == CloudflaredTool.Phase.Failed ? (tunnel.Error ?? \"The tunnel stopped.\") : null,\r\n TunnelProgress: tunnel?.State == CloudflaredTool.Phase.Downloading ? tunnel.Status : null,\r\n ProbeResult: probeResult);\r\n }\r\n\r\n // The access key is a credential, so it comes from an untracked local file and never from the shared,\r\n // committed project settings. A missing file is normal (a relay that needs no key); an unusable one is logged\r\n // by name and treated as absent, which the relay then answers with its own refusal.\r\n private static string ReadRelayAccessKey()\r\n {\r\n try\r\n {\r\n string path = Path.Combine(SceneAdapter.Root, \".collaboration\", RelayAccessKeyFile.FileName);\r\n if (!File.Exists(path))\r\n {\r\n return null;\r\n }\r\n string key = RelayAccessKeyFile.Parse(File.ReadAllText(path), out string problem);\r\n if (problem != null)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration relay: \" + problem);\r\n }\r\n return key;\r\n }\r\n catch (Exception error)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration relay: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(\"The access key file could not be read: \", error));\r\n return null;\r\n }\r\n }\r\n\r\n private string BuildInviteCode(int port)\r\n {\r\n return TeamCreate.InviteCode.Encode(new InviteData\r\n {\r\n Port = port,\r\n ProjectId = sessionConfig.ProjectId,\r\n Secret = hostSecret,\r\n Endpoints = EndpointsFor(stun?.PublicEndPoint),\r\n Relay = HostingPlan.InviteRelay(inviteCarriesRelay: inviteCarriesRelay, embeddedRelayInUse: embeddedRelay != null, publicRelayUrl: publicRelayUrl, effectiveRelayUrl: EffectiveRelayUrl(), soloHarness: soloHarness)\r\n });\r\n }\r\n\r\n private static List<byte[]> EndpointsFor(string address)\r\n {\r\n if (!IPAddress.TryParse(address, out IPAddress address2) || address2.AddressFamily != AddressFamily.InterNetwork)\r\n {\r\n return null;\r\n }\r\n return new List<byte[]> { address2.GetAddressBytes() };\r\n }\r\n\r\n public void StartFromInvite(string code, string name, string steamId = null, string steamToken = null, bool allowUnverifiedPeers = false, string advancedAddress = null, int advancedPort = 0)\r\n {\r\n string text = CurrentProjectId();\r\n if (string.IsNullOrEmpty(text))\r\n {\r\n throw new InvalidOperationException(\"Create ProjectSettings/collaboration.json with a shared ProjectId, then commit it to Git\");\r\n }\r\n if (!TeamCreate.InviteCode.TryDecode(code, text, out var data, out var error))\r\n {\r\n throw new InvalidOperationException(error);\r\n }\r\n StartPlan startPlan = JoinPlan(data, text, name, steamId, steamToken, allowUnverifiedPeers, advancedAddress, advancedPort);\r\n if (startPlan.Candidates.Count == 0)\r\n {\r\n throw new InvalidOperationException(\"No address to try: this code carries no public address and discovery has not found that host on your network. Type the host's address under Advanced.\");\r\n }\r\n StartCore(startPlan);\r\n }\r\n\r\n public void StartFromDiscovery(DiscoveredHost host, string name, string steamId = null, string steamToken = null, bool allowUnverifiedPeers = false)\r\n {\r\n if ((object)host == null)\r\n {\r\n throw new InvalidOperationException(\"Select a discovered host to join.\");\r\n }\r\n string text = CurrentProjectId();\r\n if (string.IsNullOrEmpty(text))\r\n {\r\n throw new InvalidOperationException(\"Create ProjectSettings/collaboration.json with a shared ProjectId, then commit it to Git\");\r\n }\r\n byte[] array;\r\n try\r\n {\r\n array = Convert.FromHexString(host.SecretHex ?? string.Empty);\r\n }\r\n catch (FormatException)\r\n {\r\n array = null;\r\n }\r\n if (array == null || array.Length != 16)\r\n {\r\n throw new InvalidOperationException(\"That host did not report a usable session secret.\");\r\n }\r\n byte[] array2 = TeamCreate.InviteCode.DeriveKey(array, text);\r\n StartCore(new StartPlan\r\n {\r\n Host = false,\r\n Address = host.EndPoint,\r\n Port = host.Port,\r\n Name = name,\r\n Token = Convert.ToHexString(array2),\r\n SteamId = steamId,\r\n SteamToken = steamToken,\r\n AllowUnverifiedPeers = allowUnverifiedPeers,\r\n ZeroConfig = true,\r\n SessionKey = array2,\r\n Secret = array,\r\n Candidates = new List<EndpointResolver.EndpointCandidate>\r\n {\r\n new EndpointResolver.EndpointCandidate(host.EndPoint, host.Port, \"lan\")\r\n }\r\n });\r\n }\r\n\r\n public void StartHosted(int port, string name, string steamId = null, string steamToken = null, bool allowUnverifiedPeers = false)\r\n {\r\n StartCore(new StartPlan\r\n {\r\n Host = true,\r\n Address = \"0.0.0.0\",\r\n Port = port,\r\n Name = name,\r\n SteamId = steamId,\r\n SteamToken = steamToken,\r\n AllowUnverifiedPeers = allowUnverifiedPeers,\r\n ZeroConfig = true\r\n });\r\n }\r\n\r\n private StartPlan JoinPlan(InviteData invite, string projectId, string name, string steamId, string steamToken, bool allowUnverifiedPeers, string advancedAddress, int advancedPort)\r\n {\r\n byte[] array = TeamCreate.InviteCode.DeriveKey(invite.Secret, projectId);\r\n return new StartPlan\r\n {\r\n Host = false,\r\n Address = advancedAddress,\r\n Port = invite.Port,\r\n Name = name,\r\n Token = Convert.ToHexString(array),\r\n SteamId = steamId,\r\n SteamToken = steamToken,\r\n AllowUnverifiedPeers = allowUnverifiedPeers,\r\n ZeroConfig = true,\r\n SessionKey = array,\r\n Secret = invite.Secret,\r\n Candidates = EndpointResolver.Order(invite, DiscoveredHosts, advancedAddress, advancedPort).ToList()\r\n };\r\n }\r\n\r\n private string CurrentProjectId()\r\n {\r\n if (!string.IsNullOrEmpty(sessionConfig?.ProjectId))\r\n {\r\n return sessionConfig.ProjectId;\r\n }\r\n if (projectIdResolved)\r\n {\r\n return projectIdValue;\r\n }\r\n projectIdResolved = true;\r\n return projectIdValue = LoadConfig().ProjectId;\r\n }\r\n\r\n public void StartDiscovery()\r\n {\r\n if (discovery != null)\r\n {\r\n return;\r\n }\r\n try\r\n {\r\n string text = CurrentProjectId();\r\n if (!string.IsNullOrEmpty(text))\r\n {\r\n discovery = new LanDiscovery(DiscoveryProtocol.ProjectHash8(text));\r\n }\r\n }\r\n catch (Exception error)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration discovery: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));\r\n }\r\n }\r\n\r\n public void StopDiscovery()\r\n {\r\n discovery?.Dispose();\r\n discovery = null;\r\n }\r\n\r\n private void PollConnectivity()\r\n {\r\n PollInternetHosting();\r\n discovery?.Poll();\r\n if (discovery != null && server == null && wire == null && connecting == null)\r\n {\r\n discovery.SendQuery();\r\n }\r\n stun?.Poll();\r\n FoldPublicEndpoint();\r\n if (relayHost != null && inviteCarriesRelay && relayHost.State == RelayHostState.Failed && inviteCodeValue != null)\r\n {\r\n // A code that names a relay this host has given up on would send friends to a room that will\r\n // never exist. Re-issue it without the relay so the direct routes are all it promises.\r\n inviteCarriesRelay = false;\r\n inviteCodeValue = BuildInviteCode(hostPort);\r\n }\r\n upnp?.Poll();\r\n if (upnp != null && upnp.Finished)\r\n {\r\n upnp.Dispose();\r\n upnp = null;\r\n }\r\n }\r\n\r\n // One step of the internet relay per editor frame. Everything slow (the download, cloudflared's start, the HTTP probe) is a task or a process that is\r\n // polled here and never waited on; the relay itself is pumped first so a frame's traffic is forwarded before anything else is decided.\r\n private void PollInternetHosting()\r\n {\r\n if (hostingPlan == null)\r\n {\r\n return;\r\n }\r\n embeddedRelay?.Pump();\r\n if (hostingPlan.Phase == HostingPhase.Failed)\r\n {\r\n return;\r\n }\r\n quickTunnel?.Pump();\r\n string probe = null;\r\n if (reachabilityTask != null && reachabilityTask.IsCompleted)\r\n {\r\n // Completed, so reading it cannot block. CheckReachabilityAsync turns every failure into a sentence; a faulted task is not expected.\r\n probe = reachabilityTask.IsCompletedSuccessfully ? reachabilityTask.GetAwaiter().GetResult() : HostingPlan.ProbeVerdict(null, null);\r\n reachabilityTask = null;\r\n reachabilityCancel?.Dispose();\r\n reachabilityCancel = null;\r\n }\r\n HostingDecision decision = hostingPlan.Pump(HostingInputsNow(probe));\r\n // The plan has now seen the failed tool. Releasing it kills cloudflared's process tree, so a restart begins from a clean one.\r\n if (quickTunnel?.State == CloudflaredTool.Phase.Failed)\r\n {\r\n quickTunnel.Dispose();\r\n quickTunnel = null;\r\n }\r\n switch (decision.Action)\r\n {\r\n case HostingAction.StartTunnel:\r\n quickTunnel?.Dispose();\r\n quickTunnel = null;\r\n if (embeddedRelay != null)\r\n {\r\n quickTunnel = new CloudflaredTool();\r\n quickTunnel.Begin(embeddedRelay.Port);\r\n }\r\n break;\r\n case HostingAction.ProbeReachability:\r\n {\r\n string health = HostingPlan.HealthUrl(decision.PublicUrl);\r\n reachabilityCancel = new CancellationTokenSource(ReachabilityProbeTimeout);\r\n reachabilityTask = health != null ? CheckReachabilityAsync(health, reachabilityCancel.Token) : Task.FromResult(HostingPlan.ProbeVerdict(null, null));\r\n break;\r\n }\r\n case HostingAction.PublishRelayInvite:\r\n publicRelayUrl = decision.PublicUrl;\r\n inviteCarriesRelay = true;\r\n inviteCodeValue = BuildInviteCode(hostPort);\r\n break;\r\n case HostingAction.WithdrawRelayInvite:\r\n WithdrawPublicRelay();\r\n break;\r\n }\r\n // Whatever the action, an invite names the public relay only while the plan is Ready.\r\n if (hostingPlan.Phase != HostingPhase.Ready && publicRelayUrl != null)\r\n {\r\n WithdrawPublicRelay();\r\n }\r\n if (hostingPlan.Phase == HostingPhase.Failed)\r\n {\r\n // Final for this session: nothing is left to probe or restart. The loopback relay stays until the session ends, as the host's RelayHost uses it.\r\n reachabilityCancel?.Cancel();\r\n reachabilityCancel?.Dispose();\r\n reachabilityCancel = null;\r\n reachabilityTask = null;\r\n quickTunnel?.Dispose();\r\n quickTunnel = null;\r\n }\r\n }\r\n\r\n private void WithdrawPublicRelay()\r\n {\r\n publicRelayUrl = null;\r\n inviteCarriesRelay = false;\r\n if (inviteCodeValue != null)\r\n {\r\n inviteCodeValue = BuildInviteCode(hostPort);\r\n }\r\n }\r\n\r\n // Through the engine's HTTP client, the only one an editor script may use, as SandboxCloudflareTransport does. Every outcome becomes the plan's\r\n // verdict sentence; the time budget is the token's (ReachabilityProbeTimeout).\r\n private static async Task<string> CheckReachabilityAsync(string healthUrl, CancellationToken token)\r\n {\r\n try\r\n {\r\n using var response = await Http.RequestAsync(healthUrl, \"GET\", null, null, token);\r\n string body = await response.Content.ReadAsStringAsync(token);\r\n return HostingPlan.ProbeVerdict((int)response.StatusCode, body);\r\n }\r\n catch (OperationCanceledException)\r\n {\r\n return \"The public relay address did not answer within \" + ReachabilityProbeTimeout.TotalSeconds + \" seconds.\";\r\n }\r\n catch (Exception)\r\n {\r\n return HostingPlan.ProbeVerdict(null, null);\r\n }\r\n }\r\n\r\n private void FoldPublicEndpoint()\r\n {\r\n if (stun != null && inviteCodeValue != null && !stunEndpointFolded && (stun.PublicEndPoint != null || stun.Failure != null))\r\n {\r\n stunEndpointFolded = true;\r\n if (stun.PublicEndPoint != null)\r\n {\r\n PublicEndpointStatus = \"Public endpoint: \" + stun.PublicEndPoint;\r\n inviteCodeValue = BuildInviteCode(hostPort);\r\n }\r\n else\r\n {\r\n PublicEndpointStatus = CollaborationDiagnostics.Bounded(\"No public address found; invitees on this network join by code or LAN discovery, and a remote invitee needs the host's address under Advanced.\");\r\n }\r\n }\r\n }\r\n\r\n private void TeardownConnectivity()\r\n {\r\n StopAdvertising();\r\n upnp?.Dispose();\r\n upnp = null;\r\n stun?.Dispose();\r\n stun = null;\r\n relayHost?.Dispose();\r\n relayHost = null;\r\n inviteCarriesRelay = false;\r\n inviteCodeValue = null;\r\n hostSecret = null;\r\n hostPort = 0;\r\n PublicEndpointStatus = null;\r\n stunEndpointFolded = false;\r\n StopDiscovery();\r\n }\r\n\r\n private void StopAdvertising()\r\n {\r\n discovery?.StopHost();\r\n upnp?.Unmap();\r\n // The internet relay closes with the session that opened it: the probe is cancelled, cloudflared's process tree is killed (it also dies on its\r\n // own at hotload and editor exit, see CloudflaredTool), and the loopback relay stops listening.\r\n reachabilityCancel?.Cancel();\r\n reachabilityCancel?.Dispose();\r\n reachabilityCancel = null;\r\n reachabilityTask = null;\r\n quickTunnel?.Dispose();\r\n quickTunnel = null;\r\n embeddedRelay?.Dispose();\r\n embeddedRelay = null;\r\n embeddedRelayError = null;\r\n hostingPlan = null;\r\n publicRelayUrl = null;\r\n // A failed or ended session must not keep taking guests through the relay either.\r\n relayHost?.Dispose();\r\n }\r\n\r\n private PlayModeTransitionResult ReconnectGuard()\r\n {\r\n CollaborationTransportState value = CollaborationTransportStateRules.Detect(wire != null, Connected && Error == null, wire?.StoredFailure != null || Error != null, connecting != null);\r\n return lifecycle.ValidateReconnect(new SessionReconnectGuardRequest(RuntimeLifecycle, baseline != null, connecting != null, wire != null, lifecycle.IsPending, lifecycle.HasTerminalFailure, lifecycle.TerminalDiagnostic, Connected && Error == null, value, sourceBaseline == SceneAdapter.Baseline(includeScenes: false), endedByLeave, IsHost));\r\n }\r\n\r\n public void Reconnect(string address, int port)\r\n {\r\n if (!recoverArming)\r\n {\r\n recoverArmed = false;\r\n }\r\n if (!reconnectQueued && reconnectTokenTask == null)\r\n {\r\n PlayModeTransitionResult playModeTransitionResult = ReconnectGuard();\r\n if (!playModeTransitionResult.Accepted)\r\n {\r\n throw new ActionRefusal(playModeTransitionResult.ActionableError ?? \"Recovery is not possible in the session's current state.\");\r\n }\r\n reconnectRecovery = true;\r\n reconnectRetrySpent = false;\r\n reconnectAddress = activeAddress ?? address;\r\n reconnectPort = ((activePort != 0) ? activePort : port);\r\n reconnectQueued = true;\r\n Status = \"Reconnecting; preparing identity\u2026\";\r\n RequestReconnectToken();\r\n }\r\n }\r\n\r\n private void RequestReconnectToken()\r\n {\r\n if (reconnectTokenTask != null)\r\n {\r\n return;\r\n }\r\n try\r\n {\r\n reconnectTokenTask = Auth.GetToken(\"sbox-editor-collaboration\");\r\n }\r\n catch (Exception)\r\n {\r\n AbandonReconnect(\"A Steam identity token could not be requested.\");\r\n }\r\n }\r\n\r\n private void PollReconnect()\r\n {\r\n if (reconnectTokenTask != null)\r\n {\r\n if (!reconnectTokenTask.IsCompleted)\r\n {\r\n return;\r\n }\r\n string text = null;\r\n try\r\n {\r\n text = reconnectTokenTask.GetAwaiter().GetResult();\r\n }\r\n catch (Exception)\r\n {\r\n }\r\n finally\r\n {\r\n reconnectTokenTask = null;\r\n }\r\n if (string.IsNullOrWhiteSpace(text))\r\n {\r\n AbandonReconnect(\"A Steam identity token could not be obtained.\");\r\n }\r\n else if (reconnectQueued)\r\n {\r\n RunReconnect(text);\r\n }\r\n }\r\n else if (reconnectQueued)\r\n {\r\n AbandonReconnect(\"A Steam identity token could not be obtained.\");\r\n }\r\n else if (reconnectRecovery && !reconnectRetrySpent && Error != null && SteamIdentityAdmission.IsTokenRejection(Error))\r\n {\r\n reconnectRetrySpent = true;\r\n ClearRecordedFailure();\r\n reconnectQueued = true;\r\n Status = \"Reconnecting; preparing identity\u2026\";\r\n RequestReconnectToken();\r\n }\r\n }\r\n\r\n private void AbandonReconnect(string reason)\r\n {\r\n reconnectQueued = false;\r\n reconnectTokenTask = null;\r\n reconnectRecovery = false;\r\n ClearRecordedFailure();\r\n Fail(reason);\r\n }\r\n\r\n private void RunReconnect(string freshToken)\r\n {\r\n reconnectQueued = false;\r\n PlayModeTransitionResult playModeTransitionResult = ReconnectGuard();\r\n if (!playModeTransitionResult.Accepted)\r\n {\r\n AbandonReconnect(playModeTransitionResult.ActionableError ?? \"Reconnect is not possible in the session's current state.\");\r\n return;\r\n }\r\n SessionReconnectCommitOutcome sessionReconnectCommitOutcome = lifecycle.BeginReconnect(new SessionReconnectCommitRequest(playModeTransitionResult, () =>\r\n {\r\n Preserve(\"Reconnect: local state before authoritative recovery\");\r\n return DisposeActiveResources();\r\n }));\r\n if (!sessionReconnectCommitOutcome.Accepted)\r\n {\r\n AbandonReconnect((sessionReconnectCommitOutcome.Stage == ReconnectCommitStage.FailedDuringPrepare) ? (sessionReconnectCommitOutcome.ActionableError ?? \"Reconnect preparation failed after the previous connection was closed.\") : (sessionReconnectCommitOutcome.ActionableError ?? \"Reconnect was refused before any connection was closed.\"));\r\n return;\r\n }\r\n Error = null;\r\n Connected = false;\r\n restoring = true;\r\n foreach (Tracked value in scenes.Values)\r\n {\r\n value.Ready = false;\r\n if (value.Session.Scene.IsValid())\r\n {\r\n value.Session.Reload();\r\n value.Settings = SceneSettingsAdapter.Capture(value.Session.Scene);\r\n value.SettingsApplied = null;\r\n value.SettingsEcho = null;\r\n value.DiskStamp = SceneAdapter.DiskStamp(SceneAdapter.PathOf(value.Session));\r\n }\r\n }\r\n steamTokenValue = freshToken;\r\n connecting = (activeCandidates != null && activeCandidates.Count > 0)\r\n ? ConnectCandidates(activeCandidates, activeSessionKey, activeSecret)\r\n : Wire.Connect(reconnectAddress, reconnectPort, activeSessionKey, activeSessionKey != null);\r\n Status = \"Reconnecting; recovering authoritative state\u2026\";\r\n }\r\n\r\n public void Leave()\r\n {\r\n PlayModeTransitionResult playModeTransitionResult = lifecycle.ValidateLeave(new SessionLeaveGuardRequest(RuntimeLifecycle.IsTransitionInProgress));\r\n if (!playModeTransitionResult.Accepted)\r\n {\r\n throw new InvalidOperationException(playModeTransitionResult.ActionableError);\r\n }\r\n SceneEditorSession active = SceneEditorSession.Active;\r\n if (active == null || !active.IsPlaying)\r\n {\r\n Checkpoint();\r\n }\r\n bool archiveFailed = RuntimeLifecycle.State == PlayModeLifecycleState.RuntimeBlocked && ArchiveHotloadLifecycle(RuntimeLifecycle.ArchiveReason ?? \"hotloaded\") == HotloadArchiveOutcome.Failed;\r\n SessionLeaveOutcome sessionLeaveOutcome = SessionLeaveRules.Run(new SessionLeaveRequest(lifecycle, RuntimeLifecycle.IsTransitionInProgress, () => !archiveFailed && scenes.Values.Any((Tracked x) => x.Pending != null || x.SettingsPending != null || (x.Ready && x.Session.Scene.IsValid() && StateRules.Diff(x.Known, SceneAdapter.Capture(x.Session.Scene)).Count > 0)), (string reason) =>\r\n {\r\n Preserve(reason);\r\n return true;\r\n }, () =>\r\n {\r\n wire?.Send(new Packet\r\n {\r\n Kind = \"leave\"\r\n });\r\n }, DisposeActiveResources));\r\n if (!sessionLeaveOutcome.Accepted)\r\n {\r\n throw new InvalidOperationException(sessionLeaveOutcome.ActionableError);\r\n }\r\n reconnectQueued = false;\r\n reconnectTokenTask = null;\r\n reconnectRecovery = false;\r\n reconnectRetrySpent = false;\r\n heldPackets.Clear();\r\n PlayDecision = PlaySpectateDecision.Normal;\r\n endedByLeave = true;\r\n ClearRecordedFailure();\r\n ActionNotice = null;\r\n actionNoticeExpires = default;\r\n ClearNativeUndoMirrors();\r\n undoReset.Flush();\r\n undoResetScenes.Clear();\r\n recoverArmed = false;\r\n recoverAttempts = 0;\r\n recoverNextAttempt = default;\r\n recoverPromptShown = false;\r\n recoverDeclined = false;\r\n bool disposalFailed = sessionLeaveOutcome.DisposalFailed;\r\n if (sessionLeaveOutcome.PreserveFailed && !disposalFailed)\r\n {\r\n RecoveryNotice = sessionLeaveOutcome.Failure;\r\n GlobalGameNamespace.Log.Error(\"Collaboration lifecycle: \" + RecoveryNotice);\r\n }\r\n Connected = false;\r\n Roster.Clear();\r\n foreach (Tracked value in scenes.Values)\r\n {\r\n value.Ready = false;\r\n }\r\n bool requiresHotloadRecoveryAfterLeave = RuntimeLifecycle.RequiresHotloadRecoveryAfterLeave;\r\n if (connecting != null)\r\n {\r\n BeginConnectionTeardown(requiresHotloadRecoveryAfterLeave);\r\n }\r\n if (disposalFailed)\r\n {\r\n Status = \"Offline \u2022 connection teardown failed; session remains fail-stop\";\r\n RecordTerminalTeardownFailure(lifecycle.TerminalDiagnostic);\r\n return;\r\n }\r\n if (archiveFailed)\r\n {\r\n Status = \"Offline \u2022 hotload recovery failed\";\r\n RecoveryNotice = BoundedDiagnostic(RuntimeLifecycle.LastDiagnostic);\r\n GlobalGameNamespace.Log.Error(\"Collaboration lifecycle: \" + RecoveryNotice);\r\n return;\r\n }\r\n if (lifecycle.IsPending)\r\n {\r\n Status = (requiresHotloadRecoveryAfterLeave ? \"Offline \u2022 waiting for connection teardown before hotload recovery\" : \"Offline \u2022 waiting for pending connection teardown\");\r\n return;\r\n }\r\n if (lifecycle.HasTerminalFailure)\r\n {\r\n Status = \"Offline \u2022 connection teardown failed; runtime remains blocked\";\r\n return;\r\n }\r\n Status = \"Offline\";\r\n string error = null;\r\n if (requiresHotloadRecoveryAfterLeave)\r\n {\r\n CompleteHotloadRecovery(out error);\r\n }\r\n if (error != null)\r\n {\r\n SetLifecycleError(error);\r\n }\r\n }\r\n\r\n public void Fail(string error, SessionFailureClass classification = SessionFailureClass.Terminal)\r\n {\r\n if (Error != null)\r\n {\r\n return;\r\n }\r\n string recoveryWriteFailure = null;\r\n bool everConnected = hostSession != null;\r\n if (CollaborationFailureRules.ShouldArchive(everConnected, scenes.Values.Any((Tracked x) => x.Pending != null || x.SettingsPending != null)))\r\n {\r\n try\r\n {\r\n Preserve(error);\r\n }\r\n catch (Exception error2)\r\n {\r\n recoveryWriteFailure = CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(\"\", error2);\r\n }\r\n }\r\n CollaborationFailureOutcome collaborationFailureOutcome = CollaborationFailureRules.Compose(Error, error, recoveryWriteFailure, everConnected);\r\n Error = collaborationFailureOutcome.Error;\r\n Status = collaborationFailureOutcome.Status;\r\n errorClass = classification;\r\n GlobalGameNamespace.Log.Error(\"Collaboration: \" + Error);\r\n try\r\n {\r\n StopAdvertising();\r\n }\r\n catch (Exception error3)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration connectivity: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error3));\r\n }\r\n }\r\n\r\n public void ReportAction(Exception error)\r\n {\r\n if (error == null)\r\n {\r\n return;\r\n }\r\n // A failure of one user action (save, undo, redo, play toggle, a dock button) is reported and the session continues; only the integrity\r\n // failures docs/FAILURE-MODES.md keeps fail-closed still end it (ContentFailureRules.ClassifyActionFailure).\r\n if (ContentFailureRules.ClassifyActionFailure(error) == ActionFailureAction.Fail)\r\n {\r\n Fail(error.Message);\r\n return;\r\n }\r\n if (!ActionRefusalRules.IsRefusal(error))\r\n {\r\n GlobalGameNamespace.Log.Warning(\"Collaboration action: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));\r\n }\r\n SetActionNotice(ContentFailureRules.ActionFailureNotice(error));\r\n }\r\n\r\n public void ClearRecordedFailure()\r\n {\r\n if (Error != null && lifecycle.TerminalDiagnostic == null)\r\n {\r\n Error = null;\r\n errorClass = SessionFailureClass.Terminal;\r\n }\r\n }\r\n\r\n private HotloadArchiveOutcome ArchiveHotloadLifecycle(string reason)\r\n {\r\n if (RuntimeLifecycle.State != PlayModeLifecycleState.RuntimeBlocked)\r\n {\r\n return HotloadArchiveOutcome.NotApplicable;\r\n }\r\n try\r\n {\r\n Preserve(\"Runtime lifecycle hotload marker: \" + reason);\r\n PlayModeTransitionResult playModeTransitionResult = lifecycle.ApplyArchiveMarker(RuntimeLifecycle.RecordHotloadArchive());\r\n if (!playModeTransitionResult.Accepted)\r\n {\r\n RecoveryNotice = BoundedDiagnostic(RuntimeLifecycle.LastActionableError);\r\n GlobalGameNamespace.Log.Error(\"Collaboration lifecycle: \" + RecoveryNotice);\r\n return HotloadArchiveOutcome.Failed;\r\n }\r\n RecoveryNotice = BoundedDiagnostic(\"Runtime lifecycle blocked; recovery archived before connections were disposed. Leave, sync source in Git, and start a new session.\");\r\n return HotloadArchiveOutcome.Archived;\r\n }\r\n catch (Exception error)\r\n {\r\n RuntimeLifecycle.RecordHotloadArchiveFailure(error);\r\n lifecycle.ApplyArchiveMarker(PlayModeTransitionResult.Rejected(\"archive failed\"));\r\n RecoveryNotice = BoundedDiagnostic(RuntimeLifecycle.LastDiagnostic);\r\n GlobalGameNamespace.Log.Error(\"Collaboration lifecycle: \" + RecoveryNotice);\r\n return HotloadArchiveOutcome.Failed;\r\n }\r\n }\r\n\r\n private bool DisposeActiveResources()\r\n {\r\n CancellationTokenSource cancellationTokenSource = hostVerificationLifetime;\r\n hostVerificationLifetime = new CancellationTokenSource();\r\n try\r\n {\r\n cancellationTokenSource.Cancel();\r\n }\r\n catch (ObjectDisposedException)\r\n {\r\n }\r\n try\r\n {\r\n cancellationTokenSource.Dispose();\r\n }\r\n catch (ObjectDisposedException)\r\n {\r\n }\r\n ResourceDisposalResult resourceDisposalResult = ResourceDisposalRules.DisposePair(wire, \"collaboration transport\", server, \"collaboration host server\");\r\n wire = null;\r\n server = null;\r\n projectFiles?.Dispose();\r\n projectFiles = null;\r\n blobs?.Release();\r\n blobs = null;\r\n BlobHub.Enabled = false;\r\n play?.Dispose();\r\n play = null;\r\n Connected = false;\r\n try\r\n {\r\n TeardownConnectivity();\r\n }\r\n catch (Exception error)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration connectivity: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));\r\n }\r\n if (resourceDisposalResult.Succeeded)\r\n {\r\n return true;\r\n }\r\n lifecycle.RecordTerminalTeardownFailure(resourceDisposalResult.Failure);\r\n return false;\r\n }\r\n\r\n private void BeginConnectionTeardown(bool recoverWhenComplete)\r\n {\r\n Task<Wire> pendingConnect = connecting;\r\n connecting = null;\r\n ConnectionTeardownResult connectionTeardownResult = lifecycle.BeginDetachedTeardown(pendingConnect, recoverWhenComplete);\r\n if (connectionTeardownResult.Outcome == ConnectionTeardownOutcome.TerminalFailure)\r\n {\r\n RecordTerminalTeardownFailure(connectionTeardownResult.Failure);\r\n }\r\n }\r\n\r\n private void RecordTerminalTeardownFailure(string failure)\r\n {\r\n bool requiresHotloadRecoveryAfterLeave = RuntimeLifecycle.RequiresHotloadRecoveryAfterLeave;\r\n bool flag = !string.IsNullOrWhiteSpace(failure) && failure.StartsWith(\"Connection teardown failed;\", StringComparison.Ordinal);\r\n string text = (string.IsNullOrWhiteSpace(failure) ? \"unknown teardown error\" : BoundedDiagnostic(failure));\r\n if (requiresHotloadRecoveryAfterLeave && !flag)\r\n {\r\n RuntimeLifecycle.RecordHotloadTeardownFailure(text);\r\n }\r\n string text2 = (RecoveryNotice = BoundedDiagnostic(requiresHotloadRecoveryAfterLeave ? (RuntimeLifecycle.LastActionableError ?? text) : (flag ? text : (\"Connection teardown failed; this editor session remains offline and fail-stop: \" + text))));\r\n if (requiresHotloadRecoveryAfterLeave)\r\n {\r\n SetLifecycleError(text2);\r\n }\r\n else\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration lifecycle: \" + text2);\r\n }\r\n }\r\n\r\n private void CompleteHotloadRecovery(out string error)\r\n {\r\n error = null;\r\n if (!RuntimeLifecycle.RequiresHotloadRecoveryAfterLeave || !lifecycle.LeaveRequested)\r\n {\r\n return;\r\n }\r\n TeardownCompletionObservation teardownCompletionObservation = lifecycle.ObserveTeardownCompletion(requiresHotloadRecovery: true, connecting != null);\r\n if (!teardownCompletionObservation.Observed)\r\n {\r\n return;\r\n }\r\n if (teardownCompletionObservation.TeardownFailed)\r\n {\r\n RecordTerminalTeardownFailure(teardownCompletionObservation.TeardownFailure);\r\n return;\r\n }\r\n if (!teardownCompletionObservation.AttemptRecovery)\r\n {\r\n if (teardownCompletionObservation.Status == HotloadTeardownRecoveryStatus.ArchiveFailed && teardownCompletionObservation.Error != null)\r\n {\r\n RecoveryNotice = BoundedDiagnostic(teardownCompletionObservation.Error);\r\n GlobalGameNamespace.Log.Error(\"Collaboration lifecycle: \" + RecoveryNotice);\r\n }\r\n return;\r\n }\r\n if (!RuntimeLifecycle.HotloadArchiveRecorded && RuntimeLifecycle.State == PlayModeLifecycleState.RuntimeBlocked && ArchiveHotloadLifecycle(RuntimeLifecycle.ArchiveReason ?? \"hotloaded\") == HotloadArchiveOutcome.Failed)\r\n {\r\n error = RecoveryNotice;\r\n return;\r\n }\r\n PlayModeTransitionResult playModeTransitionResult = RuntimeLifecycleEvents.RecoverFromHotload(RuntimeLifecycle);\r\n if (playModeTransitionResult.Accepted)\r\n {\r\n lifecycle.ClearDeferredHotloadRecovery();\r\n RecoveryNotice = \"Hotload recovery reset completed after transport teardown and Leave. Sync source in Git, then start a new collaboration session.\";\r\n }\r\n else\r\n {\r\n error = playModeTransitionResult.ActionableError;\r\n }\r\n }\r\n\r\n private void Preserve(string reason)\r\n {\r\n string text = Path.Combine(SceneAdapter.Root, \".collaboration\", \"recovery\");\r\n Directory.CreateDirectory(text);\r\n string path = RecoveryPathRules.CreatePath(text, DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss-fffffff\"));\r\n var captures = scenes.Where((KeyValuePair<string, Tracked> x) => x.Value.Session.Scene.IsValid()).ToDictionary((KeyValuePair<string, Tracked> x) => x.Key, (KeyValuePair<string, Tracked> x) => new\r\n {\r\n Live = x.Value.Session.Scene.Serialize(),\r\n Pending = x.Value.Pending,\r\n SettingsPending = x.Value.SettingsPending,\r\n Known = x.Value.Known\r\n });\r\n Packet[] pending = (from x in scenes.Values\r\n where x.Pending != null\r\n select x.Pending).ToArray();\r\n RecoveryPathOutcome recoveryPathOutcome = RecoveryPathRules.PublishAfterWrite(path, () =>\r\n {\r\n File.WriteAllText(path, JsonSerializer.Serialize(new { reason, captures, pending }));\r\n });\r\n if (!recoveryPathOutcome.Succeeded)\r\n {\r\n throw new IOException(\"Recovery archive could not be written to \" + path);\r\n }\r\n RecoveryPath = recoveryPathOutcome.Published;\r\n }\r\n\r\n [EditorEvent.Frame]\r\n public static void PumpCurrent()\r\n {\r\n Current.Frame();\r\n }\r\n\r\n public void Frame()\r\n {\r\n if (focusNoticeExpires != default(DateTime) && DateTime.UtcNow >= focusNoticeExpires)\r\n {\r\n FocusNotice = null;\r\n focusNoticeExpires = default;\r\n }\r\n if (saveNoticeExpires != default(DateTime) && DateTime.UtcNow >= saveNoticeExpires)\r\n {\r\n SaveNotice = null;\r\n saveNoticeExpires = default;\r\n }\r\n if (undoNoticeExpires != default(DateTime) && DateTime.UtcNow >= undoNoticeExpires)\r\n {\r\n UndoNotice = null;\r\n undoNoticeExpires = default;\r\n }\r\n if (actionNoticeExpires != default(DateTime) && DateTime.UtcNow >= actionNoticeExpires)\r\n {\r\n ActionNotice = null;\r\n actionNoticeExpires = default;\r\n }\r\n if (mergeNoticeExpires != default(DateTime) && DateTime.UtcNow >= mergeNoticeExpires)\r\n {\r\n MergeNotice = null;\r\n mergeNoticeExpires = default;\r\n }\r\n try\r\n {\r\n try\r\n {\r\n PollReconnect();\r\n }\r\n catch (Exception error)\r\n {\r\n AbandonReconnect(CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(\"Reconnect could not continue: \", error));\r\n }\r\n server?.Pump();\r\n RunUndoResetIfDue();\r\n try\r\n {\r\n orgDirectory?.Poll();\r\n PollPackageFetch();\r\n if (DateTime.UtcNow >= nextReadiness || (orgDirectory?.Revision ?? (-1)) != readinessOrgRevision)\r\n {\r\n RefreshReadiness();\r\n }\r\n }\r\n catch (Exception error2)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration readiness: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error2));\r\n }\r\n try\r\n {\r\n PollConnectivity();\r\n }\r\n catch (Exception error3)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration connectivity: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error3));\r\n }\r\n try\r\n {\r\n TickIdleAutoSave();\r\n }\r\n catch (Exception error4)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration auto-save: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error4));\r\n }\r\n try\r\n {\r\n TickAutoRecover();\r\n }\r\n catch (Exception error5)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration auto-recover: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error5));\r\n }\r\n TeardownCompletionObservation teardownCompletionObservation = lifecycle.ObserveTeardownCompletion(RuntimeLifecycle.RequiresHotloadRecoveryAfterLeave, connecting != null);\r\n if (teardownCompletionObservation.Observed)\r\n {\r\n if (teardownCompletionObservation.TeardownFailed)\r\n {\r\n RecordTerminalTeardownFailure(teardownCompletionObservation.TeardownFailure);\r\n }\r\n else if (teardownCompletionObservation.AttemptRecovery)\r\n {\r\n CompleteHotloadRecovery(out var error6);\r\n if (error6 != null)\r\n {\r\n SetLifecycleError(error6);\r\n }\r\n }\r\n else if (teardownCompletionObservation.Error != null && RuntimeLifecycle.RequiresHotloadRecoveryAfterLeave)\r\n {\r\n RecoveryNotice = BoundedDiagnostic(teardownCompletionObservation.Error);\r\n GlobalGameNamespace.Log.Error(\"Collaboration lifecycle: \" + RecoveryNotice);\r\n }\r\n }\r\n Task<Wire> task = connecting;\r\n if (task != null && task.IsCompleted)\r\n {\r\n SceneEditorSession sceneEditorSession = AuthoringSource();\r\n string sourceScenePath = SceneAdapter.PathOf(sceneEditorSession);\r\n bool flag = PlayModeSourceCoexistence.CanProcessSourcePackets(sceneEditorSession?.IsPlaying ?? false, sceneEditorSession, sourceScenePath, RuntimeLifecycle, CurrentRuntimeFor(sceneEditorSession));\r\n // Playing, or having no scene open, only pauses source synchronization (PlaySpectateRules): the connection completes,\r\n // presence flows and scene packets are held until play stops. Only a runtime a hotload interrupted still refuses.\r\n PlaySpectateDecision promoteDecision = PlaySpectateRules.Decide(IsLocalPlaying(sceneEditorSession), RuntimeLifecycle.State, flag, PlayModeSourceCoexistence.IsUsablePathKey(sourceScenePath), play?.SpectatingRun ?? false);\r\n if (!ContentFailureRules.MayPromoteConnection(promoteDecision, lifecycle.CanPromoteConnection))\r\n {\r\n BeginConnectionTeardown(recoverWhenComplete: false);\r\n if (promoteDecision == PlaySpectateDecision.FailStop)\r\n {\r\n throw new InvalidOperationException(\"Stop play mode or complete the approved runtime lifecycle before promoting a source connection\");\r\n }\r\n return;\r\n }\r\n Task<Wire> task2 = connecting;\r\n connecting = null;\r\n wire = task2.GetAwaiter().GetResult();\r\n wire.Send(new Packet\r\n {\r\n Kind = \"hello\",\r\n ProtocolVersion = 4,\r\n Mode = CollaborationMode.Source,\r\n Peer = Peer,\r\n Project = project,\r\n Baseline = baseline,\r\n Name = userName,\r\n Token = sessionToken,\r\n SteamId = steamIdValue,\r\n SteamToken = steamTokenValue,\r\n OrgIdent = orgIdentValue,\r\n LibraryVersion = libraryVersionValue,\r\n ProjectManifest = projectManifestValue,\r\n EngineBaseline = SceneAdapter.EngineBaseline()\r\n });\r\n lastReceive = DateTime.UtcNow;\r\n }\r\n if (wire == null)\r\n {\r\n return;\r\n }\r\n if (wire.StoredFailure != null)\r\n {\r\n Connected = false;\r\n throw new IOException(\"Disconnected: \" + wire.StoredFailure);\r\n }\r\n SceneEditorSession sceneEditorSession2 = AuthoringSource();\r\n string sourceScenePath2 = SceneAdapter.PathOf(sceneEditorSession2);\r\n bool sourceAdmissible = PlayModeSourceCoexistence.CanProcessSourcePackets(sceneEditorSession2?.IsPlaying ?? false, sceneEditorSession2, sourceScenePath2, RuntimeLifecycle, CurrentRuntimeFor(sceneEditorSession2));\r\n PlaySpectateDecision decision = PlaySpectateRules.Decide(IsLocalPlaying(sceneEditorSession2), RuntimeLifecycle.State, sourceAdmissible, PlayModeSourceCoexistence.IsUsablePathKey(sourceScenePath2), play?.SpectatingRun ?? false);\r\n if (decision != PlayDecision && decision == PlaySpectateDecision.Normal && !heldPackets.IsEmpty)\r\n {\r\n Status = \"Connected \u2022 applying changes made while you were playing\";\r\n }\r\n PlayDecision = decision;\r\n if (decision == PlaySpectateDecision.Normal && resyncWhenRoutable.Count > 0)\r\n {\r\n // Updates for these scenes were discarded while they could not take them: ask the host for each again (uncounted).\r\n foreach (string scene in resyncWhenRoutable.ToArray())\r\n {\r\n Tracked affected = TrackedFor(scene);\r\n if (affected != null && affected.Session.IsPlaying)\r\n {\r\n continue;\r\n }\r\n resyncWhenRoutable.Remove(scene);\r\n if (affected != null && affected.Session.Scene.IsValid() && !affected.Recovery.Paused)\r\n {\r\n affected.Recovery.Requested();\r\n RequestSceneResync(scene, affected);\r\n }\r\n }\r\n }\r\n if (decision == PlaySpectateDecision.FailStop)\r\n {\r\n throw new InvalidOperationException(\"Stop play mode or complete the approved runtime lifecycle before applying source packets\");\r\n }\r\n Packet result;\r\n PumpBlobs();\r\n if (decision == PlaySpectateDecision.Normal)\r\n {\r\n // Changes held while playing come first so they land in the order the host accepted them.\r\n while (heldPackets.TryTake(out result))\r\n {\r\n ProcessIncoming(result);\r\n }\r\n }\r\n while (NextIncoming(out result))\r\n {\r\n if (decision != PlaySpectateDecision.Normal && HeldPacketBuffer.IsSceneChange(result.Kind))\r\n {\r\n if (result.Scene != null && resyncWhenRoutable.Contains(result.Scene))\r\n {\r\n // That scene is resynchronized when play ends; the snapshot will contain this update.\r\n }\r\n else if (!heldPackets.TryHold(result))\r\n {\r\n // Applying some of the held updates and not others would leave scenes in a state nobody had: all are discarded\r\n // and every affected scene is synchronized from the host when play ends (ContentFailureRules.DrainOverflow).\r\n resyncWhenRoutable.UnionWith(ContentFailureRules.DrainOverflow(heldPackets, result));\r\n SetActionNotice(\"More scene updates arrived while you were playing than this editor holds. The affected scenes will be synchronized from the host when play ends.\");\r\n }\r\n lastReceive = DateTime.UtcNow;\r\n continue;\r\n }\r\n ProcessIncoming(result);\r\n }\r\n if (DateTime.UtcNow - lastReceive > TimeSpan.FromSeconds(15L))\r\n {\r\n Connected = false;\r\n throw new IOException(\"Host heartbeat timed out\");\r\n }\r\n if (!Connected)\r\n {\r\n return;\r\n }\r\n play?.Tick();\r\n if (DateTime.UtcNow >= nextPresence)\r\n {\r\n // Polled every SendCadence.PresenceMinimum (33 ms), but only SENT when something changed or a refresh is due (SendCadence): the\r\n // fast floor exists so remote markers can be drawn from interpolated samples, and an idle session still sends almost nothing.\r\n nextPresence = DateTime.UtcNow.AddMilliseconds(SendCadence.PresenceMinimum.TotalMilliseconds);\r\n SendPresence(force: false);\r\n if (SendCadence.ShouldSendPing(DateTime.UtcNow - lastPingSent))\r\n {\r\n lastPingSent = DateTime.UtcNow;\r\n Packet ping = new Packet\r\n {\r\n Kind = \"ping\"\r\n };\r\n AttachSceneDigest(ping);\r\n wire.Send(ping);\r\n ShowDriftNoticeOnce();\r\n }\r\n }\r\n if (projectFiles != null)\r\n {\r\n projectFiles.Poll( p => wire.Send( p ), OpenFileScenes() );\r\n ShowFileNotices();\r\n if (!projectFiles.JoinReady)\r\n {\r\n // Scenes are subscribed only once the files they reference have arrived (the scan below subscribes them).\r\n Status = projectFiles.Progress;\r\n return;\r\n }\r\n if (Status.StartsWith( \"Receiving project files:\", StringComparison.Ordinal )) Status = \"Connected \u2022 live\";\r\n }\r\n if (Error != null || DateTime.UtcNow < nextScan)\r\n {\r\n return;\r\n }\r\n if (decision != PlaySpectateDecision.Normal)\r\n {\r\n // Playing runs a copy of the scene. Sampling it would report the game's own state changes as edits,\r\n // so nothing is sampled until play stops; presence and the heartbeat above keep flowing.\r\n return;\r\n }\r\n nextScan = DateTime.UtcNow.AddMilliseconds(50.0);\r\n SceneEditorSession[] array = SceneEditorSession.All.Where((SceneEditorSession x) => !(x is GameEditorSession) && !x.IsPrefabSession).ToArray();\r\n foreach (SceneEditorSession sceneEditorSession3 in array)\r\n {\r\n string text = SceneAdapter.PathOf(sceneEditorSession3);\r\n if (sceneEditorSession3.IsPlaying && !PlayModeSourceCoexistence.CanSampleAuthoringSource(sceneEditorSession3, text, RuntimeLifecycle, CurrentRuntimeFor(sceneEditorSession3)))\r\n {\r\n // Another source is playing: do not sample runtime mutations as authoring edits.\r\n continue;\r\n }\r\n if (text == null || !text.StartsWith(\"scenes/\"))\r\n {\r\n continue;\r\n }\r\n if (!scenes.TryGetValue(text, out var value) || value.Session != sceneEditorSession3)\r\n {\r\n if (value?.Pending != null)\r\n {\r\n // Keep the durable outbox until the host answers; replacing the tracked scene here would orphan it.\r\n NoticeOnce(\"reopened-pending:\" + text, \"\\\"\" + text + \"\\\" is synchronized again once the host answers the edit you sent before it was closed. Your edit remains in the recovery outbox.\");\r\n continue;\r\n }\r\n if (sceneEditorSession3.HasUnsavedChanges)\r\n {\r\n try\r\n {\r\n WriteSceneBackup(text, new Tracked { Session = sceneEditorSession3 });\r\n SetActionNotice(\"Unsent changes to the reopened scene were backed up before host synchronization.\");\r\n }\r\n catch (Exception error)\r\n {\r\n NoticeOnce(\"reopened-backup:\" + text, \"\\\"\" + text + \"\\\" is not synchronized because a recovery copy of its unsaved changes could not be written (\" +\r\n ApplyRecovery.ShortReason(error.Message) + \"). Save the scene, then reopen it.\");\r\n continue;\r\n }\r\n }\r\n value = new Tracked\r\n {\r\n Session = sceneEditorSession3,\r\n Known = SceneAdapter.Capture(sceneEditorSession3.Scene),\r\n Settings = SceneSettingsAdapter.Capture(sceneEditorSession3.Scene),\r\n DiskStamp = SceneAdapter.DiskStamp(text)\r\n };\r\n scenes[text] = value;\r\n // The settings seed the host's copy when this editor is the scene's first subscriber; otherwise the snapshot replaces them.\r\n wire.Send(new Packet\r\n {\r\n Kind = \"subscribe\",\r\n Scene = text,\r\n Objects = value.Known.Values.ToList(),\r\n Settings = value.Settings\r\n });\r\n }\r\n else\r\n {\r\n if (!value.Ready || value.Pending != null || value.Recovery.Resyncing || value.Recovery.Paused)\r\n {\r\n continue;\r\n }\r\n DateTime dateTime = SceneAdapter.DiskStamp(text);\r\n bool flag2 = dateTime == value.DiskStamp;\r\n TimeSpan mismatchFor;\r\n if (flag2)\r\n {\r\n mismatchFor = TimeSpan.Zero;\r\n value.StampMismatchSince = default;\r\n }\r\n else if (value.StampMismatchSince == default(DateTime))\r\n {\r\n value.StampMismatchSince = DateTime.UtcNow;\r\n mismatchFor = TimeSpan.Zero;\r\n }\r\n else\r\n {\r\n mismatchFor = DateTime.UtcNow - value.StampMismatchSince;\r\n }\r\n bool stampFlagged = SceneSaveRules.ShouldFailOnDiskStamp(flag2, mismatchFor);\r\n string diskBackup = null;\r\n string diskError = null;\r\n if (stampFlagged)\r\n {\r\n // The file on disk changed outside a collaboration save. The open, live scene is what everyone shares; the disk\r\n // version is copied to the recovery folder so nothing written there is lost.\r\n try\r\n {\r\n diskBackup = CopyDiskVersion(text);\r\n }\r\n catch (Exception error)\r\n {\r\n diskError = error.Message;\r\n }\r\n }\r\n DiskStampAction stampAction = ContentFailureRules.OnDiskStampMismatch(stampFlagged, diskBackup != null);\r\n if (stampAction == DiskStampAction.KeepLiveScene)\r\n {\r\n value.DiskStamp = dateTime;\r\n value.StampMismatchSince = default;\r\n if (!value.DiskWarningShown)\r\n {\r\n value.DiskWarningShown = true;\r\n SetActionNotice(\"The file of \\\"\" + text + \"\\\" was changed outside collaboration. The open scene stays as it is for everyone; the file's version was copied to \" + diskBackup + \".\");\r\n }\r\n }\r\n else if (stampAction == DiskStampAction.PauseScene)\r\n {\r\n PauseScene(text, value, \"Synchronization of \\\"\" + text + \"\\\" is paused because its file was changed outside collaboration and a copy of that file could not be written (\" +\r\n ApplyRecovery.ShortReason(diskError) + \"). Your changes to this scene are kept locally. Reopen the scene to try again.\");\r\n continue;\r\n }\r\n // Scene settings are host-authoritative state like objects (SettingsMerge): a local change is sent whole, one packet per scene\r\n // at a time, and the host merges it per top-level key. LocalChange compares float-tolerantly and ignores what the engine\r\n // itself refused to take, so neither noise nor residue is ever sent.\r\n // Reading the scene is the engine's work: if it throws for the scene as a whole (an object that throws alone is kept\r\n // by CaptureTolerant), only this scene stops synchronizing.\r\n JsonObject settingsChange;\r\n Dictionary<string, ObjectState> dictionary;\r\n try\r\n {\r\n settingsChange = value.SettingsPending == null ? SettingsMerge.LocalChange(value.Settings, value.SettingsApplied, value.SettingsEcho, SceneSettingsAdapter.Capture(sceneEditorSession3.Scene)) : null;\r\n dictionary = CaptureTolerant(text, value);\r\n }\r\n catch (Exception error)\r\n {\r\n PauseScene(text, value, \"Synchronization of \\\"\" + text + \"\\\" is paused because this editor could not read the scene (\" + ApplyRecovery.ShortReason(error.Message) +\r\n \"). Your changes to this scene are kept locally. Reopen the scene to try again.\");\r\n continue;\r\n }\r\n if (settingsChange != null)\r\n {\r\n SendSettings(text, value, settingsChange);\r\n }\r\n List<string> scanNotices = new List<string> { SceneAdapter.TakeNotices() };\r\n // An object that is still in the scene but no longer saved with it (for example marked Not Saved) is absent from\r\n // the capture, so the diff below deletes it from the shared scene; the person is told once.\r\n foreach (string item in value.Known.Keys.Except(dictionary.Keys))\r\n {\r\n GameObject gameObject = sceneEditorSession3.Scene.Directory.FindByGuid(Guid.Parse(item));\r\n if (gameObject != null && gameObject.IsValid && !gameObject.IsDestroyed)\r\n {\r\n scanNotices.Add(StateRules.NoLongerSavedNotice(StateRules.NameOf(value.Known[item], item)));\r\n }\r\n }\r\n // Sibling order is part of each object's state (SiblingOrder.Key), so a reorder is an ordinary change below.\r\n List<Change> list = StateRules.Diff(value.Known, dictionary);\r\n if (list.Count > 0)\r\n {\r\n lastLocalEdit = DateTime.UtcNow;\r\n }\r\n // A change that breaks a structural limit is held back instead of ending the session: the rest of the batch is\r\n // sent, the held object stays as the session last confirmed it, and the reason is shown once.\r\n List<HeldChange> held = new List<HeldChange>();\r\n List<Change> sendable = StateRules.SendableChanges(value.Known, list, held);\r\n NoteHeldChanges(text, held, scanNotices);\r\n CountUnknownReservedKeys(sendable);\r\n SetMergeNotice(ChangeRebase.JoinNotices(scanNotices.ToArray()));\r\n bool flag3 = continuousEdits == 0 && !Editor.Application.MouseButtons.HasFlag(MouseButtons.Left);\r\n if (sendable.Count == 0 && (!flag3 || value.Group.Count == 0))\r\n {\r\n continue;\r\n }\r\n SendEdit(text, value, sendable, flag3);\r\n }\r\n }\r\n }\r\n catch (Exception ex)\r\n {\r\n Fail(ex.Message, AutoRecoveryRules.Classify(ex));\r\n }\r\n }\r\n\r\n private void ProcessIncoming(Packet result)\r\n {\r\n string kind = result.Kind;\r\n if (HeldPacketBuffer.IsSceneChange(kind))\r\n {\r\n Dictionary<string, object> trackedByScene = ((IEnumerable<KeyValuePair<string, Tracked>>)scenes).ToDictionary((Func<KeyValuePair<string, Tracked>, string>)((KeyValuePair<string, Tracked> pair) => pair.Key), (Func<KeyValuePair<string, Tracked>, object>)((KeyValuePair<string, Tracked> pair) => pair.Value));\r\n ScenePacketRouteResult scenePacketRouteResult = ScenePacketRouting.RouteTrackedPacket(result.Kind, result.Scene, trackedByScene, (object target) => ((Tracked)target).Session.IsPlaying, (object target) => SceneAdapter.PathOf(((Tracked)target).Session), RuntimeLifecycle, (object target) => CurrentRuntimeFor(((Tracked)target).Session));\r\n if (!scenePacketRouteResult.Admitted)\r\n {\r\n Tracked routed = TrackedFor(result.Scene);\r\n string canonicalKey = ScenePacketRouting.CanonicalSceneKey(result.Scene);\r\n bool targetOpen = routed != null && routed.Session.Scene.IsValid() && SceneEditorSession.All.Contains(routed.Session) &&\r\n ScenePacketRouting.CanonicalSceneKey(SceneAdapter.PathOf(routed.Session)) == canonicalKey;\r\n switch (ContentFailureRules.ClassifyRouteFailure(canonicalKey != null, routed != null, targetOpen))\r\n {\r\n case RouteFailureAction.IgnoreClosed:\r\n NoticeClosedPacket(result.Scene);\r\n return;\r\n case RouteFailureAction.ResyncWhenRoutable:\r\n // The scene is open but cannot take host state now (it is playing). The update is not lost: the scene is\r\n // synchronized from the host once it can.\r\n resyncWhenRoutable.Add(result.Scene);\r\n return;\r\n default:\r\n throw new InvalidOperationException((scenePacketRouteResult.Error ?? \"A scene update named no valid scene.\") +\r\n \" The host is not speaking this protocol. Leave the session and reconnect to the correct host.\");\r\n }\r\n }\r\n }\r\n lastReceive = DateTime.UtcNow;\r\n // Recorded here and not in Receive so that every packet that carries a revision is counted, whichever branch handles it.\r\n if (result.Kind == \"snapshot\")\r\n {\r\n lastRevision = result.Revision;\r\n }\r\n else if ((result.Kind == \"accepted\" || result.Kind == SettingsMerge.PacketKind) && result.Revision > lastRevision)\r\n {\r\n lastRevision = result.Revision;\r\n }\r\n Receive(result);\r\n }\r\n\r\n // Play can be running in the editor's own game session or as a flag on the source scene session; either means\r\n // the scene in front of the user is a copy being played, not the one being authored.\r\n private static bool IsLocalPlaying(SceneEditorSession source)\r\n {\r\n return (source?.IsPlaying ?? false) || SceneEditorSession.All.Any((SceneEditorSession x) => x is GameEditorSession);\r\n }\r\n\r\n private static CollaborationConfig LoadConfig()\r\n {\r\n string error;\r\n return CollaborationConfig.LoadFrom(Path.Combine(SceneAdapter.Root, \"ProjectSettings\", \"collaboration.json\"), out error);\r\n }\r\n\r\n private void UseConfig(CollaborationConfig config)\r\n {\r\n sessionConfig = config;\r\n projectIdResolved = false;\r\n if (orgDirectory == null || !string.Equals(orgDirectory.Org, config.Org, StringComparison.OrdinalIgnoreCase))\r\n {\r\n orgDirectory = new OrgDirectory(config);\r\n }\r\n }\r\n\r\n private void RefreshReadiness()\r\n {\r\n nextReadiness = DateTime.UtcNow.AddSeconds(1.0);\r\n CollaborationConfig collaborationConfig = LoadConfig();\r\n UseConfig(collaborationConfig);\r\n readinessOrgRevision = orgDirectory.Revision;\r\n orgDirectory.Poll();\r\n PollPackageFetch();\r\n bool flag = LocalSteamIdentity.TryGet(out var identity, out var personaName, out var _);\r\n string text = SceneAdapter.PathOf(SupportedActiveScene);\r\n (bool, Version) tuple = InstalledLibrary(collaborationConfig);\r\n Readiness = ReadinessRules.Evaluate(new ReadinessInput(flag, flag ? personaName : null, text != null, Project.Current?.IsPublished ?? false, packageReachable == true, tuple.Item1, tuple.Item2, collaborationConfig.LibraryVersion, flag && orgDirectory.Contains(SteamIdentityRules.Canonical(identity)), collaborationConfig, text));\r\n }\r\n\r\n private static (bool Installed, Version Version) InstalledLibrary(CollaborationConfig config)\r\n {\r\n foreach (LibraryProject item in LibrarySystem.All)\r\n {\r\n if (!LibraryInfo.IsThisLibrary(config?.LibraryIdent, item.Project?.Config?.Ident))\r\n {\r\n continue;\r\n }\r\n // Asset Party stamps its own build number into the installed copy's .version file (1.0.<build number>), so item.Version is a build id and can\r\n // never be compared with the release version the handshake and the settings file use. The code running here is the installed copy.\r\n Version.TryParse(LibraryInfo.LibraryVersion, out Version result);\r\n return (Installed: true, Version: result);\r\n }\r\n return (Installed: false, Version: null);\r\n }\r\n\r\n private void PollPackageFetch()\r\n {\r\n if (packageFetch != null)\r\n {\r\n if (packageFetch.IsCompleted)\r\n {\r\n try\r\n {\r\n packageReachable = packageFetch.GetAwaiter().GetResult() != null;\r\n }\r\n catch (Exception)\r\n {\r\n packageReachable = false;\r\n }\r\n packageFetch = null;\r\n nextPackageFetch = DateTime.UtcNow.AddSeconds(30.0);\r\n }\r\n return;\r\n }\r\n bool? flag = packageReachable;\r\n if (flag.HasValue && DateTime.UtcNow < nextPackageFetch)\r\n {\r\n return;\r\n }\r\n if (string.IsNullOrWhiteSpace(Project.Current?.Config?.FullIdent))\r\n {\r\n packageReachable = false;\r\n return;\r\n }\r\n try\r\n {\r\n packageFetch = Package.FetchAsync(Project.Current.Config.FullIdent, partial: false);\r\n }\r\n catch (Exception)\r\n {\r\n packageReachable = false;\r\n nextPackageFetch = DateTime.UtcNow.AddSeconds(30.0);\r\n }\r\n }\r\n\r\n private PeerFacts BuildPeerFacts(Packet hello)\r\n {\r\n string steamId = (SteamIdentityRules.TryParse(hello.SteamId, out var identity) ? SteamIdentityRules.Canonical(identity) : hello.SteamId);\r\n bool isFriend = false;\r\n if (SteamIdentityRules.TryParse(hello.SteamId, out var identity2))\r\n {\r\n try\r\n {\r\n isFriend = Steam.IsFriend(identity2.Value);\r\n }\r\n catch (Exception)\r\n {\r\n isFriend = false;\r\n }\r\n }\r\n bool isHostSelf = SteamIdentityRules.TryParse(hello.SteamId, out var identity3) && LocalSteamIdentity.TryGet(out var identity4, out var _, out var _) && string.Equals(SteamIdentityRules.Canonical(identity3), SteamIdentityRules.Canonical(identity4), StringComparison.Ordinal);\r\n bool isOrgMember = orgDirectory?.Contains(steamId) ?? false;\r\n if (SoloMayVouch(hello))\r\n {\r\n // The reserved test identity is neither a Steam friend nor a member of the organization; in a\r\n // disposable copy the host treats it as both so the remaining gates (project, baseline, library\r\n // version, manifest, duplicate account) still run for real.\r\n isFriend = true;\r\n isOrgMember = true;\r\n }\r\n return new PeerFacts(steamId, IdentityVerified: false, isFriend, isOrgMember, hello.LibraryVersion, hello.ProjectManifest, hello.OrgIdent, isHostSelf);\r\n }\r\n\r\n private static SceneEditorSession AuthoringSource()\r\n {\r\n SceneEditorSession active = SceneEditorSession.Active;\r\n if (1 == 0)\r\n {\r\n }\r\n SceneEditorSession result;\r\n if (active is GameEditorSession { Parent: var parent })\r\n {\r\n result = ((parent != null && !parent.IsPrefabSession) ? parent : null);\r\n }\r\n else\r\n {\r\n result = ((active == null || active.IsPrefabSession) ? null : active);\r\n }\r\n if (1 == 0)\r\n {\r\n }\r\n return result;\r\n }\r\n\r\n private RuntimeSessionDescriptor CurrentRuntimeFor(SceneEditorSession source)\r\n {\r\n if (source == null || !source.IsPlaying)\r\n {\r\n return null;\r\n }\r\n GameEditorSession[] array = SceneEditorSession.All.OfType<GameEditorSession>().ToArray();\r\n if (array.Length != 1)\r\n {\r\n return null;\r\n }\r\n GameEditorSession gameEditorSession = array[0];\r\n if (gameEditorSession.Parent != source)\r\n {\r\n return null;\r\n }\r\n return RuntimeSceneAdapter.Describe(gameEditorSession);\r\n }\r\n\r\n // The reason last shown for each held object, per scene, so a change held on every scan is announced once and again only\r\n // when its reason changes. An object that is sent again is forgotten.\r\n private readonly Dictionary<string, Dictionary<string, string>> heldReasons = new Dictionary<string, Dictionary<string, string>>();\r\n\r\n private void NoteHeldChanges(string scene, List<HeldChange> held, List<string> notices)\r\n {\r\n if (!heldReasons.TryGetValue(scene, out Dictionary<string, string> shown))\r\n {\r\n shown = new Dictionary<string, string>();\r\n heldReasons[scene] = shown;\r\n }\r\n foreach (string id in shown.Keys.Except(held.Select((HeldChange x) => x.Change.Id)).ToList())\r\n {\r\n shown.Remove(id);\r\n }\r\n foreach (HeldChange change in held)\r\n {\r\n if (!shown.TryGetValue(change.Change.Id, out string reason) || reason != change.Reason)\r\n {\r\n shown[change.Change.Id] = change.Reason;\r\n notices.Add(change.Reason);\r\n GlobalGameNamespace.Log.Warning(\"Collaboration: \" + change.Reason);\r\n }\r\n }\r\n }\r\n\r\n // Reserved (__) property names outside StateRules.KnownReservedKeys are allowed, because a newer engine may write new ones;\r\n // each distinct name is counted and logged once so it can be added to the known set.\r\n private readonly HashSet<string> unknownReservedKeys = new HashSet<string>(StringComparer.Ordinal);\r\n\r\n /// <summary>How many distinct reserved (<c>__</c>) property names outside the known set this editor has sent.</summary>\r\n public int UnknownReservedKeyCount => unknownReservedKeys.Count;\r\n\r\n private void CountUnknownReservedKeys(List<Change> changes)\r\n {\r\n foreach (Change change in changes)\r\n {\r\n if ((object)change.After == null)\r\n {\r\n continue;\r\n }\r\n foreach (string key in StateRules.FindUnknownReservedKeys(change.After.Data))\r\n {\r\n if (unknownReservedKeys.Add(key))\r\n {\r\n GlobalGameNamespace.Log.Info(\"Collaboration: the scene uses the reserved property name \\\"\" + key + \"\\\", which is not in the known set; it is synchronised as is.\");\r\n }\r\n }\r\n }\r\n }\r\n\r\n private void SendEdit(string scene, Tracked tracked, List<Change> changes, bool final)\r\n {\r\n tracked.Pending = new Packet\r\n {\r\n Kind = \"edit\",\r\n Scene = scene,\r\n Peer = Peer,\r\n Changes = changes,\r\n Final = final\r\n };\r\n StorePending(tracked.Pending);\r\n wire.Send(tracked.Pending);\r\n Status = \"Connected \u2022 edit awaiting acknowledgement\";\r\n }\r\n\r\n // Sends the scene's settings as they are now, with the confirmed settings they were changed from. A change above the size bound is not\r\n // sent at all: the host would refuse it, and the refusal is the same either way.\r\n private void SendSettings(string scene, Tracked tracked, JsonObject after)\r\n {\r\n string sizeError = SettingsMerge.SizeError(after);\r\n if (sizeError != null)\r\n {\r\n RefuseSettings(tracked, sizeError);\r\n return;\r\n }\r\n tracked.SettingsPending = new Packet\r\n {\r\n Kind = SettingsMerge.PacketKind,\r\n Scene = scene,\r\n Peer = Peer,\r\n SettingsBefore = SettingsMerge.Clone(tracked.Settings),\r\n Settings = after\r\n };\r\n sentSettings.Add(tracked.SettingsPending.Id);\r\n wire.Send(tracked.SettingsPending);\r\n Status = \"Connected \u2022 scene settings awaiting acknowledgement\";\r\n }\r\n\r\n // A refused settings change is not retried: the scene takes back the host's settings, which this editor already holds as the confirmed\r\n // settings (the same settings the next snapshot would carry), and the user is told why. Without the revert the scan would find the\r\n // same change again and resend it every 50 ms.\r\n private void RefuseSettings(Tracked tracked, string reason)\r\n {\r\n tracked.SettingsPending = null;\r\n SetActionNotice(reason);\r\n ShowToast(\"Scene settings change refused\", reason, Theme.Primary, 15f);\r\n if (tracked.Settings != null)\r\n {\r\n WriteSettings(tracked, tracked.Settings);\r\n }\r\n }\r\n\r\n private bool HasUnsentSettings(Tracked tracked)\r\n {\r\n return tracked.Session.Scene.IsValid() && SettingsMerge.LocalChange(tracked.Settings, tracked.SettingsApplied, tracked.SettingsEcho, SceneSettingsAdapter.Capture(tracked.Session.Scene)) != null;\r\n }\r\n\r\n // Writes settings into the live scene when they differ from what it has, and records what was written and what the engine then reported.\r\n private void WriteSettings(Tracked tracked, JsonObject settings)\r\n {\r\n if (!tracked.Session.Scene.IsValid() || !SceneEditorSession.All.Contains(tracked.Session))\r\n {\r\n return;\r\n }\r\n if (!StateRules.ValuesEqual(SceneSettingsAdapter.Capture(tracked.Session.Scene), settings))\r\n {\r\n List<string> skipped = SceneSettingsAdapter.Apply(tracked.Session.Scene, settings);\r\n if (skipped.Count > 0)\r\n {\r\n SetActionNotice(\"Some scene settings could not be applied in this editor and were left unchanged: \" + string.Join(\"; \", skipped));\r\n }\r\n }\r\n tracked.SettingsApplied = SettingsMerge.Clone(settings);\r\n tracked.SettingsEcho = SceneSettingsAdapter.Capture(tracked.Session.Scene);\r\n }\r\n\r\n // A settings broadcast from the host: the merged settings become the confirmed settings. A change of this editor's that the host has not\r\n // seen yet (made after the last send, or not sent yet) is kept on top of them, key by key, exactly as an unsent object edit is kept on\r\n // top of a remote change, and the next scan sends it.\r\n private void ReceiveSettings(Tracked tracked, Packet p)\r\n {\r\n if (!seen.Add(p.Peer + \":\" + p.Id))\r\n {\r\n // A receipt repeated after a resend was applied the first time; applying it again would undo whatever was accepted since.\r\n if (tracked.SettingsPending?.Id == p.Id)\r\n {\r\n tracked.SettingsPending = null;\r\n }\r\n return;\r\n }\r\n if (p.Settings == null)\r\n {\r\n throw new InvalidOperationException(\"The host sent a scene settings change without settings.\");\r\n }\r\n bool own = p.Peer == Peer;\r\n if (own && tracked.SettingsPending?.Id == p.Id)\r\n {\r\n tracked.SettingsPending = null;\r\n }\r\n List<string> notices = new List<string> { p.Notice };\r\n JsonObject target = p.Settings;\r\n if (tracked.Session.Scene.IsValid() && SceneEditorSession.All.Contains(tracked.Session))\r\n {\r\n JsonObject local = SettingsMerge.LocalChange(tracked.Settings, tracked.SettingsApplied, tracked.SettingsEcho, SceneSettingsAdapter.Capture(tracked.Session.Scene));\r\n if (local != null && tracked.Settings != null && SettingsMerge.TryMerge(p.Settings, tracked.Settings, local, out JsonObject kept, out List<string> clashed))\r\n {\r\n target = kept;\r\n if (!own)\r\n {\r\n notices.Add(SettingsMerge.LocalKeptNotice(clashed));\r\n }\r\n }\r\n }\r\n tracked.Settings = SettingsMerge.Clone(p.Settings);\r\n WriteSettings(tracked, target);\r\n SetMergeNotice(ChangeRebase.JoinNotices(notices.ToArray()));\r\n Status = \"Connected \u2022 revision \" + p.Revision;\r\n }\r\n\r\n private Tracked TrackedFor(string scene)\r\n {\r\n Dictionary<string, object> trackedByScene = scenes.ToDictionary((KeyValuePair<string, Tracked> pair) => pair.Key, (KeyValuePair<string, Tracked> pair) => (object)pair.Value);\r\n return (scene != null && ScenePacketRouting.TryResolveTracked(scene, trackedByScene, out string _, out object target)) ? target as Tracked : null;\r\n }\r\n\r\n private string PendingPath(string id)\r\n {\r\n return Path.Combine(SceneAdapter.Root, \".collaboration\", \"outbox\", Peer + \"-\" + id + \".json\");\r\n }\r\n\r\n private void StorePending(Packet packet)\r\n {\r\n string text = PendingPath(packet.Id);\r\n Directory.CreateDirectory(Path.GetDirectoryName(text));\r\n byte[] bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { project, baseline, packet }));\r\n using (FileStream fileStream = new FileStream(text + \".tmp\", FileMode.Create, FileAccess.Write, FileShare.None))\r\n {\r\n fileStream.Write(bytes);\r\n fileStream.Flush(flushToDisk: true);\r\n }\r\n File.Move(text + \".tmp\", text, overwrite: true);\r\n }\r\n\r\n private void ForgetPending(Tracked tracked, string id)\r\n {\r\n if (!(tracked.Pending?.Id != id))\r\n {\r\n File.Delete(PendingPath(id));\r\n tracked.Pending = null;\r\n }\r\n }\r\n\r\n // Adds the scene digest to a ping for the active scene, when there is something trustworthy to report. The digest is of Known, the state the\r\n // host last confirmed, and not of the live scene: a drag that has not been sent yet is an edit in progress, never drift. Nothing is reported\r\n // while an edit awaits acknowledgement, while the scene is still joining, or during play, when the scene on screen is a copy.\r\n private void AttachSceneDigest(Packet ping)\r\n {\r\n try\r\n {\r\n SceneEditorSession active = SceneEditorSession.Active;\r\n string path = SceneAdapter.PathOf(active);\r\n if (path == null || !scenes.TryGetValue(path, out var tracked) || tracked.Session != active)\r\n {\r\n return;\r\n }\r\n if (!tracked.Ready || tracked.Pending != null || tracked.SettingsPending != null || IsLocalPlaying(active))\r\n {\r\n return;\r\n }\r\n // The host's digest covers its settings for the scene too (SessionServer), so this one covers the confirmed settings.\r\n string digest = SceneDigest.Compute(tracked.Known, tracked.Settings);\r\n ping.Digest = digest;\r\n ping.DigestRevision = lastRevision;\r\n ping.DigestScene = path;\r\n }\r\n catch (Exception)\r\n {\r\n // The digest is a courtesy on top of the heartbeat. A scene it cannot fingerprint just goes unreported; the ping itself must still go out.\r\n ping.Digest = null;\r\n ping.DigestRevision = null;\r\n ping.DigestScene = null;\r\n }\r\n }\r\n\r\n // Tells the host's user once when a guest keeps drifting despite resyncs; nothing is shown again until the notice changes or clears.\r\n private void ShowDriftNoticeOnce()\r\n {\r\n string notice = server?.DriftNotice;\r\n if (notice == null)\r\n {\r\n driftNoticeShown = null;\r\n return;\r\n }\r\n if (!string.Equals(notice, driftNoticeShown, StringComparison.Ordinal))\r\n {\r\n driftNoticeShown = notice;\r\n SetActionNotice(notice);\r\n ShowToast(\"A collaborator's scene keeps drifting\", notice, Theme.Red, 15f);\r\n }\r\n }\r\n\r\n // Writes the live text of a scene to .collaboration/recovery before the host's snapshot replaces it, and returns the file's path. The folder,\r\n // the engine's own scene serialisation and the write wrapper are the ones Preserve uses; the name is RecoveryBackup's. A copy is never\r\n // overwritten (two snapshots in one second get a counter), and a failed write throws the specific message so that the caller applies nothing.\r\n private string WriteSceneBackup(string scene, Tracked tracked)\r\n {\r\n string path = null;\r\n try\r\n {\r\n string text = tracked.Session.Scene.Serialize().ToJsonString(new JsonSerializerOptions\r\n {\r\n WriteIndented = true\r\n });\r\n string folder = Path.Combine(SceneAdapter.Root, \".collaboration\", \"recovery\");\r\n Directory.CreateDirectory(folder);\r\n string name = RecoveryBackup.BuildFileName(scene, DateTime.UtcNow);\r\n path = Path.Combine(folder, name);\r\n for (int counter = 2; File.Exists(path) && counter < 100; counter++)\r\n {\r\n path = Path.Combine(folder, name.Substring(0, name.Length - \".scene\".Length) + \"-\" + counter + \".scene\");\r\n }\r\n string target = path;\r\n RecoveryPathOutcome outcome = RecoveryPathRules.PublishAfterWrite(target, () =>\r\n {\r\n using FileStream stream = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None);\r\n stream.Write(Encoding.UTF8.GetBytes(text));\r\n stream.Flush(flushToDisk: true);\r\n });\r\n if (outcome.Succeeded)\r\n {\r\n return outcome.Published;\r\n }\r\n }\r\n catch (Exception)\r\n {\r\n }\r\n throw new InvalidOperationException(RecoveryBackup.BackupFailedMessage);\r\n }\r\n\r\n // ---- Content failures heal one scene (ApplyRecovery, ContentFailureRules, docs/FAILURE-MODES.md) ----------------------------------------------------\r\n\r\n // Logged once per scene: a packet for a scene this editor does not have open (closed meanwhile). Reopening it subscribes again.\r\n private void NoticeClosedPacket(string scene)\r\n {\r\n if (closedPacketNotices.Add(scene ?? \"\"))\r\n {\r\n GlobalGameNamespace.Log.Info(\"Collaboration: an update for \\\"\" + scene + \"\\\", which is not open in this editor, was ignored. Reopening the scene synchronizes it again.\");\r\n }\r\n }\r\n\r\n // A notice that would otherwise be set on every scan (50 ms) is shown once per key.\r\n private void NoticeOnce(string key, string notice)\r\n {\r\n if (onceNotices.Add(key))\r\n {\r\n SetActionNotice(notice);\r\n }\r\n }\r\n\r\n // Copies a scene's file on disk to the recovery folder under a name never used before and returns the copy's path. Nothing to\r\n // copy (the file was removed) counts as preserved.\r\n private string CopyDiskVersion(string scene)\r\n {\r\n string disk = Path.Combine(SceneAdapter.Root, \"Assets\", scene);\r\n if (!File.Exists(disk))\r\n {\r\n return \"(no file on disk)\";\r\n }\r\n string folder = Path.Combine(SceneAdapter.Root, \".collaboration\", \"recovery\");\r\n Directory.CreateDirectory(folder);\r\n string name = RecoveryBackup.BuildFileName(scene, DateTime.UtcNow);\r\n string stem = name.EndsWith(\".scene\", StringComparison.Ordinal) ? name.Substring(0, name.Length - \".scene\".Length) : name;\r\n string path = Path.Combine(folder, stem + \"-disk.scene\");\r\n for (int counter = 2; File.Exists(path) && counter < 100; counter++)\r\n {\r\n path = Path.Combine(folder, stem + \"-disk-\" + counter + \".scene\");\r\n }\r\n File.Copy(disk, path, overwrite: false);\r\n return path;\r\n }\r\n\r\n // Stops synchronizing one scene; the session, presence and every other scene continue. Reopening the scene creates a new tracked\r\n // scene and so clears the pause.\r\n private void PauseScene(string scene, Tracked tracked, string notice)\r\n {\r\n tracked.Recovery.Pause();\r\n tracked.Ready = false;\r\n tracked.HeldLocal = null;\r\n SetActionNotice(notice);\r\n ShowToast(\"Scene synchronization paused\", notice, Theme.Primary, 15f);\r\n }\r\n\r\n private static string BackupFailedPauseNotice(string scene, string reason)\r\n {\r\n return \"Synchronization of \\\"\" + scene + \"\\\" is paused because a recovery copy of the scene could not be written (\" + ApplyRecovery.ShortReason(reason) +\r\n \"). Your changes to this scene are kept locally. Free disk space or check folder permissions, then reopen the scene to try again.\";\r\n }\r\n\r\n // Asks the host for the scene again: the same subscribe a reconnect sends. The answer is an ordinary snapshot.\r\n private void RequestSceneResync(string scene, Tracked tracked)\r\n {\r\n if (tracked.Recovery.Paused || !tracked.Session.Scene.IsValid())\r\n {\r\n return;\r\n }\r\n tracked.Ready = false;\r\n wire.Send(new Packet\r\n {\r\n Kind = \"subscribe\",\r\n Scene = scene,\r\n Objects = tracked.Known.Values.ToList(),\r\n Settings = tracked.Settings\r\n });\r\n }\r\n\r\n // The live scene as a scan sees it: an object whose capture throws keeps its confirmed state, and so does every object this editor\r\n // could not show. Neither is ever sent as this person's change or deletion.\r\n private Dictionary<string, ObjectState> CaptureTolerant(string scene, Tracked tracked)\r\n {\r\n HashSet<string> failures = new HashSet<string>(StringComparer.Ordinal);\r\n Dictionary<string, ObjectState> live = SceneAdapter.Capture(tracked.Session.Scene, tracked.Known, failures, limitGeometryRate: true);\r\n foreach (string id in failures)\r\n {\r\n if (tracked.SkippedObjects.Add(id))\r\n {\r\n string name = tracked.Known.TryGetValue(id, out ObjectState known) ? StateRules.NameOf(known, id) : id;\r\n SetActionNotice(\"\\\"\" + name + \"\\\" in \\\"\" + scene + \"\\\" could not be read by this editor. Its last synchronized state is kept for everyone and it is not sent as a change.\");\r\n }\r\n }\r\n return ApplyRecovery.PreserveSkipped(tracked.Known, live, tracked.SkippedObjects);\r\n }\r\n\r\n // The live scene as the editor shows it, for building an apply target: a skipped object keeps the version shown here, so applying\r\n // the target never touches it again. (An object whose capture throws is listed with its confirmed state.)\r\n private Dictionary<string, ObjectState> CaptureShown(string scene, Tracked tracked)\r\n {\r\n return SceneAdapter.Capture(tracked.Session.Scene, tracked.Known, new HashSet<string>(StringComparer.Ordinal));\r\n }\r\n\r\n private static Func<Dictionary<string, ObjectState>, Exception> TryApply(Tracked tracked)\r\n {\r\n return candidate =>\r\n {\r\n try\r\n {\r\n SceneAdapter.Apply(tracked.Session, candidate);\r\n return null;\r\n }\r\n catch (Exception error)\r\n {\r\n return error;\r\n }\r\n };\r\n }\r\n\r\n // Applying a host packet threw in this editor. The decision is ApplyRecovery's: resync (the live scene is put back where it was when\r\n // that is known, backed up, and the host is asked for the scene again), isolate (returned to the caller, which owns the snapshot), or\r\n // pause. Never Fail: the cause is this editor's engine, not the session.\r\n private ApplyRecoveryDecision RecoverScene(string scene, Tracked tracked, Exception error, Dictionary<string, ObjectState> before, bool snapshot)\r\n {\r\n ApplyRecoveryDecision decision = tracked.Recovery.Failed(DateTime.UtcNow, snapshot);\r\n GlobalGameNamespace.Log.Warning(\"Collaboration: applying a host update to \\\"\" + scene + \"\\\" failed (\" + decision + \"): \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(\"\", error));\r\n if (decision == ApplyRecoveryDecision.SkipObjects)\r\n {\r\n return decision;\r\n }\r\n if (before != null)\r\n {\r\n // A half-applied packet must not be read back as this person's edits. If the scene cannot be put back, the state from before\r\n // the packet is what gets replayed after the snapshot.\r\n if (TryApply(tracked)(before) != null)\r\n {\r\n tracked.HeldLocal ??= before;\r\n }\r\n }\r\n string backup;\r\n try\r\n {\r\n backup = WriteSceneBackup(scene, tracked);\r\n }\r\n catch (Exception backupError)\r\n {\r\n PauseScene(scene, tracked, BackupFailedPauseNotice(scene, backupError.Message));\r\n return ApplyRecoveryDecision.Pause;\r\n }\r\n if (decision == ApplyRecoveryDecision.Pause)\r\n {\r\n PauseScene(scene, tracked, ApplyRecovery.PausedNotice(scene));\r\n return decision;\r\n }\r\n RequestSceneResync(scene, tracked);\r\n SetActionNotice(\"An update to \\\"\" + scene + \"\\\" could not be applied in your editor (\" + ApplyRecovery.ShortReason(error?.Message) + \"). A copy of your scene was saved to \" +\r\n backup + \". The scene is being synchronized again; your unsent changes are kept and sent afterwards.\");\r\n return decision;\r\n }\r\n\r\n // Applies a snapshot. Returns false when the scene did not take it (resync requested, paused); the caller then leaves Known as it is.\r\n // While the scene resyncs, the edits held since the failure are replayed on top of the fresh state (ApplyRecovery.ReplayHeld) and the next\r\n // scan sends them as ordinary edits.\r\n private bool ApplyHostScene(string scene, Tracked tracked, Dictionary<string, ObjectState> target)\r\n {\r\n if (tracked.Recovery.Paused)\r\n {\r\n return false;\r\n }\r\n // A malformed host graph is not an engine limitation of this editor: it stays a protocol failure.\r\n StateRules.ValidateGraph(target);\r\n bool resyncing = tracked.Recovery.Resyncing;\r\n // live: what the editor shows (the starting point of isolation and of a put-back); held: the person's edits as a scan reads them.\r\n Dictionary<string, ObjectState> live = CaptureShown(scene, tracked);\r\n Dictionary<string, ObjectState> heldBase = new Dictionary<string, ObjectState>(tracked.Known);\r\n Dictionary<string, ObjectState> held = tracked.HeldLocal ?? CaptureTolerant(scene, tracked);\r\n Dictionary<string, ObjectState> shown = target;\r\n try\r\n {\r\n SceneAdapter.Apply(tracked.Session, target);\r\n tracked.SkippedObjects.Clear();\r\n }\r\n catch (Exception error)\r\n {\r\n if (RecoverScene(scene, tracked, error, live, snapshot: true) != ApplyRecoveryDecision.SkipObjects)\r\n {\r\n return false;\r\n }\r\n // The fresh snapshot fails as well: show what this editor can, leave out what it cannot. The host's JSON of every skipped object\r\n // becomes Known with the rest, so nothing is lost and nothing is sent back.\r\n Dictionary<string, string> reasons = new Dictionary<string, string>(StringComparer.Ordinal);\r\n shown = ApplyRecovery.IsolateObjects(target, live, TryApply(tracked), reasons);\r\n Exception final = TryApply(tracked)(shown);\r\n if (final != null)\r\n {\r\n PauseScene(scene, tracked, ApplyRecovery.PausedNotice(scene));\r\n return false;\r\n }\r\n List<string> skippedNotices = new List<string>();\r\n foreach (KeyValuePair<string, string> skipped in reasons)\r\n {\r\n ObjectState state = target.GetValueOrDefault(skipped.Key) ?? live.GetValueOrDefault(skipped.Key);\r\n if (!tracked.SkippedObjects.Contains(skipped.Key))\r\n {\r\n skippedNotices.Add(ApplyRecovery.SkippedNotice(StateRules.NameOf(state, skipped.Key), skipped.Value));\r\n }\r\n }\r\n tracked.SkippedObjects = new HashSet<string>(reasons.Keys, StringComparer.Ordinal);\r\n string joined = ChangeRebase.JoinNotices(skippedNotices.ToArray());\r\n if (joined != null)\r\n {\r\n SetActionNotice(joined);\r\n ShowToast(\"Scene objects not shown\", joined, Theme.Primary, 15f);\r\n }\r\n }\r\n if (resyncing && !tracked.DiscardLocalOnResync)\r\n {\r\n List<string> notices = new List<string>();\r\n Dictionary<string, ObjectState> replayed = ApplyRecovery.ReplayHeld(heldBase, held, target, shown, tracked.SkippedObjects, notices);\r\n if (StateRules.Diff(shown, replayed).Count > 0)\r\n {\r\n Exception replayError = TryApply(tracked)(replayed);\r\n if (replayError != null)\r\n {\r\n // The edits cannot be shown on top of the host's scene in this editor. They are in the recovery copy written before\r\n // the snapshot; the scene follows the host.\r\n TryApply(tracked)(shown);\r\n notices.Add(\"Your unsent changes to \\\"\" + scene + \"\\\" could not be reapplied on top of the host's scene (\" + ApplyRecovery.ShortReason(replayError.Message) +\r\n \"). They are kept in the recovery copy.\");\r\n }\r\n }\r\n SetMergeNotice(ChangeRebase.JoinNotices(notices.ToArray()));\r\n }\r\n tracked.HeldLocal = null;\r\n tracked.DiscardLocalOnResync = false;\r\n return true;\r\n }\r\n\r\n private void Receive(Packet p)\r\n {\r\n if (projectFiles != null && projectFiles.Handle( p, packet => wire.Send( packet ), OpenFileScenes() ))\r\n {\r\n return;\r\n }\r\n if (play != null && play.Handle(p))\r\n {\r\n return;\r\n }\r\n if (p.Kind == \"welcome\")\r\n {\r\n if (restoring && hostSession != null && hostSession != p.Session)\r\n {\r\n Fail(\"Host session ended or restarted. Preserve recovery data, sync saved scenes and start a new session.\");\r\n return;\r\n }\r\n hostSession = p.Session;\r\n projectFiles?.Dispose();\r\n projectFiles = null;\r\n // Geometry is replaced by placeholders only for a host that advertises the channel that carries it; an older host would take the\r\n // placeholder for the mesh.\r\n blobs?.Release();\r\n blobs = null;\r\n BlobHub.Enabled = false;\r\n if (p.Features != null && p.Features.Contains( BlobLimits.Feature ))\r\n {\r\n BlobHub.Store.AttachDisk( SceneAdapter.Root, BlobLimits.DiskCacheBytes );\r\n blobs = new BlobExchange( BlobHub.Store, packet => wire.Send( packet ), () => DateTime.UtcNow );\r\n BlobHub.Enabled = true;\r\n }\r\n // Only a host that advertises file synchronization is asked for its index; an older host gets the session as before.\r\n if (p.Features != null && p.Features.Contains( FileSyncLimits.Feature ))\r\n {\r\n projectFiles = new FileSyncClient( Project.Current.RootDirectory.FullName ) { FileChanged = RefreshAsset };\r\n projectFiles.Begin( packet => wire.Send( packet ) );\r\n }\r\n // Shared play is opt-in on both sides: this computer chose it, and the host says it relays it. Otherwise nothing of it exists here.\r\n play?.Dispose();\r\n play = SharedPlayPreference.Enabled && p.Features != null && p.Features.Contains(PlayRelayPolicy.Feature)\r\n ? new SharedPlayClient(packet => wire.Send(packet), Peer, SetActionNotice)\r\n : null;\r\n Connected = true;\r\n Status = \"Connected \u2022 live\";\r\n reconnectRecovery = false;\r\n if (!restoring)\r\n {\r\n return;\r\n }\r\n {\r\n foreach (KeyValuePair<string, Tracked> scene in scenes)\r\n {\r\n if (scene.Value.Pending != null)\r\n {\r\n wire.Send(scene.Value.Pending);\r\n }\r\n if (scene.Value.SettingsPending != null)\r\n {\r\n // The same id: a host that already applied it answers with the receipt it gave the first time.\r\n wire.Send(scene.Value.SettingsPending);\r\n }\r\n if (scene.Value.Session.Scene.IsValid())\r\n {\r\n wire.Send(new Packet\r\n {\r\n Kind = \"subscribe\",\r\n Scene = scene.Key,\r\n Objects = scene.Value.Known.Values.ToList(),\r\n Settings = scene.Value.Settings\r\n });\r\n }\r\n }\r\n return;\r\n }\r\n }\r\n string canonical;\r\n if (p.Kind == \"fatal\" || p.Kind == \"reject\")\r\n {\r\n string text = CollaborationTransportDiagnostics.BoundedStoredDiagnostic(p.Error);\r\n if (p.Kind == \"reject\" && p.Id != null && sentSettings.Contains(p.Id))\r\n {\r\n // The host refused a settings change (for instance above the size bound). That is the host deciding, not a broken session.\r\n Tracked refusedScene = TrackedFor(p.Scene);\r\n if (refusedScene?.SettingsPending != null && refusedScene.SettingsPending.Id == p.Id)\r\n {\r\n RefuseSettings(refusedScene, \"The host refused your change to the scene settings: \" + text);\r\n }\r\n return;\r\n }\r\n if (p.Kind == \"reject\" && p.Scene == null)\r\n {\r\n Fail(text);\r\n return;\r\n }\r\n if (restoring && p.Kind == \"reject\")\r\n {\r\n RecoveryNotice = CollaborationDiagnostics.Bounded(\"Rejected offline edit retained in \" + RecoveryPath + \": \" + text);\r\n }\r\n else if (p.Kind == \"fatal\")\r\n {\r\n Fail(text);\r\n }\r\n if (p.Kind == \"reject\")\r\n {\r\n Tracked tracked = TrackedFor(p.Scene);\r\n if (tracked == null)\r\n {\r\n NoticeClosedPacket(p.Scene);\r\n return;\r\n }\r\n if (tracked.Pending?.Id != p.Id)\r\n {\r\n return;\r\n }\r\n // The host refused this edit. That is the host deciding about content, not a broken session: the scene is copied to the\r\n // recovery folder, the edit is dropped from the outbox, and the scene is synchronized from the host, replacing the refused\r\n // state (re-sending it would only be refused again).\r\n ForgetPending(tracked, p.Id);\r\n if (!tracked.Session.Scene.IsValid() || tracked.Recovery.Paused)\r\n {\r\n return;\r\n }\r\n string refusedBackup;\r\n try\r\n {\r\n refusedBackup = WriteSceneBackup(p.Scene, tracked);\r\n }\r\n catch (Exception error)\r\n {\r\n PauseScene(p.Scene, tracked, BackupFailedPauseNotice(p.Scene, error.Message));\r\n return;\r\n }\r\n tracked.Recovery.Requested();\r\n tracked.DiscardLocalOnResync = true;\r\n RequestSceneResync(p.Scene, tracked);\r\n string refused = \"The host refused your change to \\\"\" + p.Scene + \"\\\" (\" + text + \"). The scene is synchronized from the host again; your version was saved to \" + refusedBackup + \".\";\r\n SetActionNotice(refused);\r\n ShowToast(\"Change refused by the host\", refused, Theme.Primary, 15f);\r\n }\r\n }\r\n else if (p.Kind == \"presence\")\r\n {\r\n if (p.Peer != Peer)\r\n {\r\n Roster[p.Peer] = p;\r\n FeedPeerSmoothing(p);\r\n }\r\n }\r\n else if (p.Kind == \"left\")\r\n {\r\n Roster.Remove(p.Peer);\r\n peerSmoothing.Remove(p.Peer);\r\n }\r\n else\r\n {\r\n if (p.Kind == \"pong\")\r\n {\r\n return;\r\n }\r\n if (p.Scene == null)\r\n {\r\n throw new InvalidOperationException(\"The host sent a scene update without a scene. The host is not speaking this protocol. Leave the session and reconnect to the correct host.\");\r\n }\r\n if (!ScenePacketRouting.TryResolveTracked(p.Scene, ((IEnumerable<KeyValuePair<string, Tracked>>)scenes).ToDictionary((Func<KeyValuePair<string, Tracked>, string>)((KeyValuePair<string, Tracked> pair) => pair.Key), (Func<KeyValuePair<string, Tracked>, object>)((KeyValuePair<string, Tracked> pair) => pair.Value)), out canonical, out object target2) || !(target2 is Tracked tracked2))\r\n {\r\n NoticeClosedPacket(p.Scene);\r\n return;\r\n }\r\n if (Error != null)\r\n {\r\n return;\r\n }\r\n if (p.Kind == \"snapshot\")\r\n {\r\n Dictionary<string, ObjectState> dictionary = StateRules.Map(p.Objects);\r\n if (!tracked2.Session.Scene.IsValid())\r\n {\r\n tracked2.Known = dictionary;\r\n tracked2.Settings = SettingsMerge.Clone(p.Settings);\r\n return;\r\n }\r\n if (tracked2.Recovery.Paused)\r\n {\r\n // A paused scene follows the host on paper only: nothing is applied or sent until it is reopened.\r\n tracked2.Known = dictionary;\r\n tracked2.Settings = SettingsMerge.Clone(p.Settings);\r\n return;\r\n }\r\n string restoredNotice = null;\r\n if (!restoring)\r\n {\r\n if (!tracked2.Recovery.Resyncing && tracked2.Ready && (tracked2.Pending != null || tracked2.SettingsPending != null))\r\n {\r\n // A resync that arrives while an edit awaits the host's answer would replace the scene under that edit, and the answer\r\n // would then be applied to a scene it was not made against. Nothing is lost by waiting: the digest is not sent while\r\n // an edit is pending, and the next ping after the answer finds the drift again if it is still there.\r\n return;\r\n }\r\n // Unsent scene settings count as much as unsent objects: the copy is Scene.Serialize, which holds the scene's Properties\r\n // (its settings) next to its GameObjects. Settings that cannot even be read are treated as unsent.\r\n bool unsentSettings;\r\n try\r\n {\r\n unsentSettings = tracked2.Settings != null && HasUnsentSettings(tracked2);\r\n }\r\n catch (Exception)\r\n {\r\n unsentSettings = true;\r\n }\r\n if (ApplyRecovery.NeedsBackup(StateRules.Diff(tracked2.Known, CaptureTolerant(p.Scene, tracked2)).Count > 0, unsentSettings))\r\n {\r\n // Unsent edits are about to be replaced by the host's scene: they are written to a file first, and if that fails\r\n // the host's scene is not applied, because unsaved work is never replaced without a copy. Only this scene pauses.\r\n try\r\n {\r\n restoredNotice = RecoveryBackup.BuildNotice(p.Scene, WriteSceneBackup(p.Scene, tracked2));\r\n }\r\n catch (Exception error)\r\n {\r\n PauseScene(p.Scene, tracked2, BackupFailedPauseNotice(p.Scene, error.Message));\r\n return;\r\n }\r\n }\r\n }\r\n if (!ApplyHostScene(p.Scene, tracked2, dictionary)) return;\r\n tracked2.Known = dictionary;\r\n if (tracked2.Recovery.Resyncing && tracked2.Pending != null)\r\n {\r\n ForgetPending(tracked2, tracked2.Pending.Id);\r\n tracked2.Group.Clear();\r\n }\r\n if (tracked2.Recovery.Resyncing) tracked2.SettingsPending = null;\r\n tracked2.Recovery.Succeeded();\r\n // The host's settings replace this editor's, as its objects do. A host holding none (its first subscriber predates settings\r\n // sync) leaves the scene's settings alone, and with nothing confirmed no local change is ever sent for the scene.\r\n tracked2.Settings = SettingsMerge.Clone(p.Settings);\r\n tracked2.SettingsApplied = null;\r\n tracked2.SettingsEcho = null;\r\n if (p.Settings != null)\r\n {\r\n try\r\n {\r\n WriteSettings(tracked2, p.Settings);\r\n }\r\n catch (Exception error)\r\n {\r\n // The objects are in; the settings are not. The next snapshot writes them again.\r\n tracked2.Recovery.Succeeded();\r\n RecoverScene(p.Scene, tracked2, error, null, snapshot: false);\r\n return;\r\n }\r\n }\r\n tracked2.Ready = true;\r\n tracked2.Session.UndoSystem.Initialize();\r\n tracked2.Group.Clear();\r\n tracked2.Undo.Clear();\r\n MirrorNativeUndo(tracked2);\r\n if (scenes.Values.All((Tracked x) => x.Ready || x.Recovery.Paused || !x.Session.Scene.IsValid()))\r\n {\r\n restoring = false;\r\n }\r\n if (restoredNotice != null)\r\n {\r\n SetActionNotice(restoredNotice);\r\n ShowToast(\"Scene restored from the host\", restoredNotice, Theme.Primary, 15f);\r\n }\r\n }\r\n else if (p.Kind == SettingsMerge.PacketKind)\r\n {\r\n if (p.Settings == null)\r\n {\r\n // Protocol integrity: a settings packet always carries the merged settings.\r\n throw new InvalidOperationException(\"The host sent a scene settings change without settings. The host is not speaking this protocol. Leave the session and reconnect to the correct host.\");\r\n }\r\n if (!tracked2.Recovery.AppliesIncoming)\r\n {\r\n // Resyncing: the snapshot carries these settings. Paused: they are recorded, not applied.\r\n seen.Add(p.Peer + \":\" + p.Id);\r\n if (tracked2.Recovery.Paused)\r\n {\r\n tracked2.Settings = SettingsMerge.Clone(p.Settings);\r\n }\r\n if (tracked2.SettingsPending?.Id == p.Id)\r\n {\r\n tracked2.SettingsPending = null;\r\n }\r\n return;\r\n }\r\n try\r\n {\r\n ReceiveSettings(tracked2, p);\r\n }\r\n catch (Exception error)\r\n {\r\n RecoverScene(p.Scene, tracked2, error, null, snapshot: false);\r\n }\r\n }\r\n else\r\n {\r\n if (p.Kind != \"accepted\")\r\n {\r\n return;\r\n }\r\n if (tracked2.Recovery.Resyncing)\r\n {\r\n // The snapshot requested for this scene contains this change; the outbox is settled when it arrives.\r\n return;\r\n }\r\n if (tracked2.Recovery.Paused)\r\n {\r\n // Recorded, not applied: the scene follows the host on paper until it is reopened.\r\n if (seen.Add(p.Peer + \":\" + p.Id))\r\n {\r\n ApplyRecovery.Record(tracked2.Known, p.Changes);\r\n }\r\n ForgetPending(tracked2, p.Id);\r\n return;\r\n }\r\n if (!seen.Add(p.Peer + \":\" + p.Id))\r\n {\r\n ForgetPending(tracked2, p.Id);\r\n return;\r\n }\r\n AcceptedOperations++;\r\n // Sentences for the user gathered while this packet is processed: the host's own notice first, then ours.\r\n List<string> rebaseNotices = new List<string> { p.Notice };\r\n if (p.Peer == Peer)\r\n {\r\n // What this editor sent for the packet being answered, when it is still the pending one. A receipt that outlived it\r\n // (an outbox replay after a reconnect) has no sent edit to compare with.\r\n Packet sentPacket = (tracked2.Pending != null && tracked2.Pending.Id == p.Id) ? tracked2.Pending : null;\r\n bool liveScene = tracked2.Session.Scene.IsValid() && SceneEditorSession.All.Contains(tracked2.Session);\r\n Dictionary<string, ObjectState> rebased = null;\r\n bool rebaseChangesScene = false;\r\n foreach (Change change in p.Changes)\r\n {\r\n if (liveScene)\r\n {\r\n ObjectState sentAfter = sentPacket?.Changes?.FirstOrDefault((Change x) => x.Id == change.Id)?.After;\r\n if (ChangeRebase.NeedsOwnReceiptRebase(p.Merged, sentPacket != null, sentAfter, change.After))\r\n {\r\n // The host kept something other than what was sent. Take its result into the live scene before Known\r\n // moves on, otherwise the next scan sees scene != Known and sends the old local state back over it.\r\n rebased ??= SceneAdapter.Capture(tracked2.Session.Scene);\r\n ObjectState local = rebased.GetValueOrDefault(change.Id);\r\n RebaseResult result = ChangeRebase.RebaseOntoOwnReceipt(change.Id, sentAfter, local, change.After);\r\n rebaseNotices.Add(result.Notice);\r\n if (!StateRules.Equal(result.NewLocal, local))\r\n {\r\n rebaseChangesScene = true;\r\n }\r\n if ((object)result.NewLocal == null)\r\n {\r\n rebased.Remove(change.Id);\r\n }\r\n else\r\n {\r\n rebased[change.Id] = result.NewLocal;\r\n }\r\n }\r\n }\r\n if ((object)change.After == null)\r\n {\r\n tracked2.Known.Remove(change.Id);\r\n }\r\n else\r\n {\r\n tracked2.Known[change.Id] = change.After;\r\n }\r\n ObjectState before = (tracked2.Group.TryGetValue(change.Id, out var value) ? value.Before : change.Before);\r\n tracked2.Group[change.Id] = new Change(change.Id, before, change.After);\r\n }\r\n if (rebaseChangesScene)\r\n {\r\n Exception applyError = TryApply(tracked2)(rebased);\r\n if (applyError != null)\r\n {\r\n // Known now holds what the host kept; rebased is the person's scene on top of it, which is what is replayed.\r\n ForgetPending(tracked2, p.Id);\r\n tracked2.HeldLocal = rebased;\r\n RecoverScene(p.Scene, tracked2, applyError, null, snapshot: false);\r\n return;\r\n }\r\n tracked2.Session.UndoSystem.Initialize();\r\n MirrorNativeUndo(tracked2);\r\n }\r\n ForgetPending(tracked2, p.Id);\r\n if (p.Final)\r\n {\r\n if (!tracked2.UndoPending)\r\n {\r\n tracked2.Undo.Push(tracked2.Group.Values.ToList());\r\n }\r\n tracked2.Group.Clear();\r\n tracked2.UndoPending = false;\r\n if (tracked2.Session.Scene.IsValid())\r\n {\r\n tracked2.Session.UndoSystem.Initialize();\r\n }\r\n MirrorNativeUndo(tracked2);\r\n }\r\n }\r\n else\r\n {\r\n bool flag = tracked2.Session.Scene.IsValid() && SceneEditorSession.All.Contains(tracked2.Session);\r\n Dictionary<string, ObjectState> dictionary2 = (flag ? CaptureShown(p.Scene, tracked2) : new Dictionary<string, ObjectState>(tracked2.Known));\r\n // What the editor showed and what was confirmed before this change, to put both back if the engine refuses it.\r\n Dictionary<string, ObjectState> shownBefore = new Dictionary<string, ObjectState>(dictionary2);\r\n Dictionary<string, ObjectState> knownPrior = new Dictionary<string, ObjectState>(StringComparer.Ordinal);\r\n foreach (Change change2 in p.Changes)\r\n {\r\n if (!knownPrior.ContainsKey(change2.Id))\r\n {\r\n knownPrior[change2.Id] = tracked2.Known.GetValueOrDefault(change2.Id);\r\n }\r\n if ((object)change2.After != null && tracked2.SkippedObjects.Contains(change2.Id))\r\n {\r\n // An object this editor cannot show stays as it is shown here; the host's version is recorded, never applied\r\n // (applying it is what failed) and never sent back.\r\n tracked2.Known[change2.Id] = change2.After;\r\n continue;\r\n }\r\n // The user may hold an edit the host has not seen (mid-drag, or not yet sent). It is kept on top of the host's\r\n // change instead of failing the session; ChangeRebase never throws on divergence because the host is the authority.\r\n // A skipped object has no edit of this person's: its local side is what the host last confirmed.\r\n ObjectState localSide = tracked2.SkippedObjects.Contains(change2.Id) ? tracked2.Known.GetValueOrDefault(change2.Id) : dictionary2.GetValueOrDefault(change2.Id);\r\n RebaseResult rebase = ChangeRebase.RebaseOntoRemoteChange(change2.Id, tracked2.Known.GetValueOrDefault(change2.Id), localSide, change2.Before, change2.After);\r\n rebaseNotices.Add(rebase.Notice);\r\n if ((object)rebase.NewLocal == null)\r\n {\r\n dictionary2.Remove(change2.Id);\r\n }\r\n else\r\n {\r\n dictionary2[change2.Id] = rebase.NewLocal;\r\n }\r\n if ((object)change2.After == null)\r\n {\r\n tracked2.Known.Remove(change2.Id);\r\n }\r\n else\r\n {\r\n tracked2.Known[change2.Id] = change2.After;\r\n }\r\n }\r\n if (flag)\r\n {\r\n Exception applyError = TryApply(tracked2)(dictionary2);\r\n if (applyError != null)\r\n {\r\n // Undo the bookkeeping too: the scene shows what it showed before, Known is what it was, and the snapshot\r\n // brings this change in. The person's unsent edits are then exactly Known against the scene, and are replayed.\r\n tracked2.Known = ApplyRecovery.WithPrior(tracked2.Known, knownPrior);\r\n RecoverScene(p.Scene, tracked2, applyError, shownBefore, snapshot: false);\r\n return;\r\n }\r\n foreach (Change applied in p.Changes)\r\n {\r\n if ((object)applied.After == null)\r\n {\r\n tracked2.SkippedObjects.Remove(applied.Id);\r\n }\r\n }\r\n // The scene above is already applied. Only the history reset is coalesced (UndoResetScheduler): a drag of another\r\n // person would otherwise reset the whole native undo history on every packet.\r\n RequestUndoReset(tracked2);\r\n }\r\n }\r\n SetMergeNotice(ChangeRebase.JoinNotices(rebaseNotices.ToArray()));\r\n Status = \"Connected \u2022 revision \" + p.Revision;\r\n }\r\n }\r\n }\r\n\r\n public IDisposable BeginContinuousEdit()\r\n {\r\n if (!Connected || Error != null)\r\n {\r\n throw new InvalidOperationException(\"Connect before starting a continuous edit\");\r\n }\r\n continuousEdits++;\r\n return new EditScope(this);\r\n }\r\n\r\n public bool CanSave()\r\n {\r\n if (sourceBaseline != SceneAdapter.Baseline(includeScenes: false))\r\n {\r\n return false;\r\n }\r\n if (!Connected || Error != null)\r\n {\r\n return false;\r\n }\r\n string text = SceneAdapter.PathOf(SceneEditorSession.Active);\r\n if (text == null || !scenes.TryGetValue(text, out var value))\r\n {\r\n return false;\r\n }\r\n if (!value.Ready || value.Pending != null || value.Group.Count > 0)\r\n {\r\n return false;\r\n }\r\n if (value.SettingsPending != null || HasUnsentSettings(value))\r\n {\r\n return false;\r\n }\r\n return StateRules.Diff(value.Known, SceneAdapter.Capture(value.Session.Scene)).Count == 0;\r\n }\r\n\r\n /// <summary>Why a save is not possible right now, in words, or null when it is possible. Mirrors <see cref=\"CanSave\"/> condition for condition.</summary>\r\n public string WhyCannotSave()\r\n {\r\n if (sourceBaseline != SceneAdapter.Baseline(includeScenes: false))\r\n {\r\n return \"the project's source or assets changed since the session started\";\r\n }\r\n if (!Connected || Error != null)\r\n {\r\n return \"the session is not connected\";\r\n }\r\n string text = SceneAdapter.PathOf(SceneEditorSession.Active);\r\n if (text == null || !scenes.TryGetValue(text, out var value))\r\n {\r\n return \"the active scene is not part of the session (active: \" + (text ?? \"none\") + \")\";\r\n }\r\n if (!value.Ready)\r\n {\r\n return \"the host has not finished sending the scene\";\r\n }\r\n if (value.Pending != null)\r\n {\r\n return \"an edit is still waiting for the host to acknowledge it\";\r\n }\r\n if (value.Group.Count > 0)\r\n {\r\n return \"a multi-part edit is still being grouped\";\r\n }\r\n if (value.SettingsPending != null)\r\n {\r\n return \"a change to the scene settings is still waiting for the host to acknowledge it\";\r\n }\r\n if (HasUnsentSettings(value))\r\n {\r\n return \"a change to the scene settings has not been sent to the host yet\";\r\n }\r\n List<Change> difference = StateRules.Diff(value.Known, SceneAdapter.Capture(value.Session.Scene));\r\n if (difference.Count > 0)\r\n {\r\n Change first = difference[0];\r\n string name = (first.After ?? first.Before)?.Data?[\"Name\"]?.GetValue<string>() ?? first.Id;\r\n return difference.Count + \" object\" + (difference.Count == 1 ? \"\" : \"s\") + \" differ from the host's copy (first: \" + name + \")\";\r\n }\r\n return null;\r\n }\r\n\r\n private string SaveRefusal()\r\n {\r\n if (sourceBaseline != SceneAdapter.Baseline(includeScenes: false))\r\n {\r\n return \"Source/assets changed during collaboration; end the session and sync in Git before saving a checkpoint\";\r\n }\r\n return \"Wait for all edits to be acknowledged before saving\";\r\n }\r\n\r\n public void Save()\r\n {\r\n if (!CanSave())\r\n {\r\n throw new InvalidOperationException(SaveRefusal());\r\n }\r\n string text = SceneAdapter.PathOf(SceneEditorSession.Active);\r\n if (text == null || !scenes.TryGetValue(text, out var value))\r\n {\r\n throw new InvalidOperationException(SaveRefusal());\r\n }\r\n saving = true;\r\n try\r\n {\r\n SendPresence();\r\n value.Session.Save(saveAs: false);\r\n value.DiskStamp = SceneAdapter.DiskStamp(text);\r\n server?.AllowSavedBaseline(SceneAdapter.Baseline());\r\n }\r\n finally\r\n {\r\n saving = false;\r\n }\r\n }\r\n\r\n private void Checkpoint()\r\n {\r\n if (AutoSaveRules.CheckpointEligible(SceneEditorSession.Active?.HasUnsavedChanges ?? false, CanSave))\r\n {\r\n RunAutoSave();\r\n }\r\n }\r\n\r\n private void RunAutoSave()\r\n {\r\n lastAutoSave = DateTime.UtcNow;\r\n try\r\n {\r\n Save();\r\n SetSaveNotice(\"Auto-saved the synchronized scene at \" + DateTime.Now.ToString(\"HH:mm\") + \".\");\r\n }\r\n catch (Exception ex)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration auto-save: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, ex));\r\n SetSaveNotice(\"The automatic save did not run: \" + ex.Message);\r\n }\r\n }\r\n\r\n private void TickIdleAutoSave()\r\n {\r\n if (!AutoSaveIdle)\r\n {\r\n return;\r\n }\r\n DateTime utcNow = DateTime.UtcNow;\r\n if (!(utcNow < nextAutoSaveCheck))\r\n {\r\n nextAutoSaveCheck = utcNow.AddSeconds(1.0);\r\n if (AutoSaveRules.Eligible(new IdleSaveInput(Enabled: true, SceneEditorSession.Active?.HasUnsavedChanges ?? false, Roster.Values.Any((Packet x) => string.Equals(PresenceText(x.Presence, \"state\", 32), \"editing\", StringComparison.Ordinal)), SceneEditorSession.All.Any((SceneEditorSession x) => x.IsPlaying), lastLocalEdit, lastAutoSave, utcNow, CanSave)))\r\n {\r\n RunAutoSave();\r\n }\r\n }\r\n }\r\n\r\n private void TickAutoRecover()\r\n {\r\n if (Connected)\r\n {\r\n bool flag = recoverArmed;\r\n recoverArmed = false;\r\n recoverDeclined = false;\r\n recoverAttempts = 0;\r\n recoverNextAttempt = default;\r\n recoverPromptShown = false;\r\n if (flag)\r\n {\r\n ShowToast(\"Connection restored\", \"Automatic recovery reconnected this session.\", Theme.Green, 6f);\r\n }\r\n }\r\n else\r\n {\r\n if (Error == null)\r\n {\r\n return;\r\n }\r\n bool flag2 = TerminalTeardownError != null || RuntimeLifecycle.HasTerminalTeardownFailure;\r\n if ((errorClass != SessionFailureClass.Transient) | flag2)\r\n {\r\n DeclineAutoRecover(\"This editor session remains fail-stop; automatic recovery cannot continue.\");\r\n }\r\n else if (!AutoRecover)\r\n {\r\n if (!recoverPromptShown)\r\n {\r\n bool flag3 = AutoRecoveryRules.PromptOffered(AutoRecover, Error != null, errorClass, flag2, RecoveryOffered(), recoverPromptShown);\r\n recoverPromptShown = true;\r\n if (flag3)\r\n {\r\n ShowToast(\"Connection lost\", \"Select Recover & reconnect, or press CTRL+ALT+R, to restore this session.\", Theme.Primary, 15f);\r\n }\r\n }\r\n }\r\n else\r\n {\r\n if (recoverDeclined || (recoverNextAttempt != default(DateTime) && DateTime.UtcNow < recoverNextAttempt))\r\n {\r\n return;\r\n }\r\n if (recoverAttempts >= 3)\r\n {\r\n DeclineAutoRecover(\"Three automatic attempts did not reconnect this session.\");\r\n return;\r\n }\r\n bool isHost = IsHost;\r\n bool flag4 = !isHost && HasUnsavedTrackedChanges();\r\n bool flag5 = reconnectQueued || reconnectTokenTask != null || connecting != null;\r\n if (isHost)\r\n {\r\n DeclineAutoRecover(\"A hosted session is never recovered automatically.\");\r\n }\r\n else\r\n {\r\n if (flag4 | flag5)\r\n {\r\n return;\r\n }\r\n string text = activeAddress ?? startAddress;\r\n int num = ((activePort != 0) ? activePort : startPort);\r\n if (string.IsNullOrWhiteSpace(text) || num <= 0)\r\n {\r\n DeclineAutoRecover(\"Automatic recovery has no endpoint to redial.\");\r\n return;\r\n }\r\n bool reconnectAccepted = RecoveryOffered();\r\n if (AutoRecoveryRules.Eligible(new AutoRecoveryInput(AutoRecover, Error != null, errorClass, flag2, isHost, flag4, reconnectAccepted, flag5, recoverAttempts, DateTime.UtcNow, recoverNextAttempt)))\r\n {\r\n recoverAttempts++;\r\n recoverArmed = true;\r\n recoverNextAttempt = DateTime.UtcNow.Add(AutoRecoveryRules.Backoff(recoverAttempts - 1));\r\n try\r\n {\r\n recoverArming = true;\r\n Reconnect(text, num);\r\n return;\r\n }\r\n catch (Exception ex)\r\n {\r\n recoverArmed = false;\r\n recoverDeclined = true;\r\n ShowToast(\"Automatic recovery stopped\", ex.Message, Theme.Red, 6f);\r\n GlobalGameNamespace.Log.Error(\"Collaboration auto-recover: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, ex));\r\n return;\r\n }\r\n finally\r\n {\r\n recoverArming = false;\r\n }\r\n }\r\n DeclineAutoRecover(\"Automatic recovery is not possible in this session's current state.\");\r\n }\r\n }\r\n }\r\n }\r\n\r\n private bool HasUnsavedTrackedChanges()\r\n {\r\n foreach (Tracked value in scenes.Values)\r\n {\r\n SceneEditorSession sceneEditorSession = value?.Session;\r\n if (sceneEditorSession != null && sceneEditorSession.HasUnsavedChanges)\r\n {\r\n return true;\r\n }\r\n }\r\n return false;\r\n }\r\n\r\n private void DeclineAutoRecover(string reason)\r\n {\r\n if (!recoverDeclined || recoverArmed)\r\n {\r\n bool flag = recoverArmed;\r\n recoverArmed = false;\r\n recoverDeclined = true;\r\n if (flag)\r\n {\r\n ShowToast(\"Automatic recovery stopped\", reason, Theme.Red, 6f);\r\n }\r\n }\r\n }\r\n\r\n private bool RecoveryOffered()\r\n {\r\n if (Error == null)\r\n {\r\n return false;\r\n }\r\n try\r\n {\r\n return ReconnectGuard().Accepted;\r\n }\r\n catch (Exception)\r\n {\r\n return false;\r\n }\r\n }\r\n\r\n private static void ShowToast(string title, string subtitle, Color border, float seconds)\r\n {\r\n try\r\n {\r\n ToastWidget widget = new ToastWidget\r\n {\r\n Title = title,\r\n Subtitle = subtitle,\r\n BorderColor = border,\r\n DrawTimer = false,\r\n IsRunning = false,\r\n FixedWidth = 360f\r\n };\r\n ToastManager.Remove(widget, seconds);\r\n }\r\n catch (Exception error)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration notice: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));\r\n }\r\n }\r\n\r\n public void Undo()\r\n {\r\n // A coalesced reset still pending would leave a stale snapshot under this undo; run it first.\r\n FlushUndoReset();\r\n string text = SceneAdapter.PathOf(SceneEditorSession.Active);\r\n if (!Connected || Error != null || text == null || !scenes.TryGetValue(text, out var value) || !value.Ready || value.Pending != null || value.Undo.Count == 0)\r\n {\r\n throw new InvalidOperationException(\"No acknowledged local operation available to undo\");\r\n }\r\n List<Change> list = (from c in value.Undo.Peek()\r\n select new Change(c.Id, c.After, c.Before)).ToList();\r\n Dictionary<string, ObjectState> dictionary = SceneAdapter.Capture(value.Session.Scene);\r\n foreach (Change item in list)\r\n {\r\n if (!StateRules.Equal(dictionary.GetValueOrDefault(item.Id), item.Before))\r\n {\r\n throw new InvalidOperationException(\"Undo refused: object changed since your edit\");\r\n }\r\n if ((object)item.After == null)\r\n {\r\n dictionary.Remove(item.Id);\r\n }\r\n else\r\n {\r\n dictionary[item.Id] = item.After;\r\n }\r\n }\r\n SceneAdapter.Apply(value.Session, dictionary);\r\n value.Undo.Pop();\r\n value.UndoPending = true;\r\n SendEdit(text, value, list, final: true);\r\n }\r\n\r\n // The coalesced half of \"apply, capture, Initialize, MirrorNativeUndo\". Runs the first request of a burst at once and holds the rest;\r\n // Frame() releases what is held, and Undo/TryRedo flush it before they act (see UndoResetScheduler for the bounded stale window).\r\n private void RequestUndoReset(Tracked tracked)\r\n {\r\n undoResetScenes.Add(tracked);\r\n undoReset.Request(undoResetClock.Elapsed.TotalMilliseconds);\r\n RunUndoResetIfDue();\r\n }\r\n\r\n private void RunUndoResetIfDue()\r\n {\r\n if (undoReset.Pending && undoReset.ShouldRunNow(undoResetClock.Elapsed.TotalMilliseconds))\r\n {\r\n RunPendingUndoResets();\r\n }\r\n }\r\n\r\n private void FlushUndoReset()\r\n {\r\n if (undoReset.Flush())\r\n {\r\n RunPendingUndoResets();\r\n }\r\n }\r\n\r\n private void RunPendingUndoResets()\r\n {\r\n Tracked[] pendingScenes = undoResetScenes.ToArray();\r\n undoResetScenes.Clear();\r\n foreach (Tracked tracked in pendingScenes)\r\n {\r\n try\r\n {\r\n // A scene that was closed or replaced since the request has nothing to reset.\r\n if (tracked.Session != null && tracked.Session.Scene.IsValid() && scenes.ContainsValue(tracked))\r\n {\r\n tracked.Session.UndoSystem.Initialize();\r\n MirrorNativeUndo(tracked);\r\n }\r\n }\r\n catch (Exception error)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration undo reset: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));\r\n }\r\n }\r\n }\r\n\r\n private void MirrorNativeUndo(Tracked tracked)\r\n {\r\n try\r\n {\r\n if (tracked == null || tracked.Session == null || tracked.Undo.Count == 0)\r\n {\r\n return;\r\n }\r\n UndoSystem undoSystem = tracked.Session.UndoSystem;\r\n if (undoSystem == null)\r\n {\r\n return;\r\n }\r\n List<Change> changes = tracked.Undo.Peek();\r\n if (changes != null && changes.Count != 0)\r\n {\r\n undoSystem.Insert(\"Collaboration Undo\", () =>\r\n {\r\n TryUndo();\r\n }, () =>\r\n {\r\n TryRedo(tracked, changes);\r\n });\r\n }\r\n }\r\n catch (Exception error)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration undo mirror: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));\r\n }\r\n }\r\n\r\n private void ClearNativeUndoMirrors()\r\n {\r\n try\r\n {\r\n UndoSystem undoSystem = SceneEditorSession.Active?.UndoSystem;\r\n if (IsNativeUndoMirror(undoSystem))\r\n {\r\n undoSystem.Initialize();\r\n }\r\n foreach (Tracked value in scenes.Values)\r\n {\r\n UndoSystem undoSystem2 = value?.Session?.UndoSystem;\r\n if (IsNativeUndoMirror(undoSystem2))\r\n {\r\n undoSystem2.Initialize();\r\n }\r\n }\r\n }\r\n catch (Exception error)\r\n {\r\n GlobalGameNamespace.Log.Error(\"Collaboration undo mirror: \" + CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));\r\n }\r\n }\r\n\r\n private static bool IsNativeUndoMirror(UndoSystem undoSystem)\r\n {\r\n if (undoSystem == null)\r\n {\r\n return false;\r\n }\r\n UndoSystem.Entry result;\r\n return undoSystem.Back.TryPeek(out result) && string.Equals(result?.Name, \"Collaboration Undo\", StringComparison.Ordinal);\r\n }\r\n\r\n private bool TryUndo()\r\n {\r\n try\r\n {\r\n Undo();\r\n UndoNotice = null;\r\n undoNoticeExpires = default;\r\n return true;\r\n }\r\n catch (Exception ex)\r\n {\r\n SetUndoNotice(ex.Message);\r\n return false;\r\n }\r\n }\r\n\r\n private bool TryRedo(Tracked tracked, List<Change> changes)\r\n {\r\n try\r\n {\r\n FlushUndoReset();\r\n if (changes == null || changes.Count == 0 || !Connected || Error != null || tracked == null || tracked.Session == null || !tracked.Session.Scene.IsValid())\r\n {\r\n SetUndoNotice(\"Redo is not available for this collaboration operation.\");\r\n return false;\r\n }\r\n string text = SceneAdapter.PathOf(tracked.Session);\r\n if (text == null || !scenes.TryGetValue(text, out var value) || value != tracked || !value.Ready || value.Pending != null || value.UndoPending)\r\n {\r\n SetUndoNotice(\"Redo is not available for this collaboration operation.\");\r\n return false;\r\n }\r\n Dictionary<string, ObjectState> dictionary = SceneAdapter.Capture(value.Session.Scene);\r\n foreach (Change change in changes)\r\n {\r\n if (StateRules.Equal(dictionary.GetValueOrDefault(change.Id), change.Before))\r\n {\r\n continue;\r\n }\r\n if (StateRules.Equal(dictionary.GetValueOrDefault(change.Id), change.After))\r\n {\r\n SetUndoNotice(\"Nothing to redo: this collaboration operation is still applied.\");\r\n return false;\r\n }\r\n SetUndoNotice(\"Redo refused: the scene changed since your edit.\");\r\n return false;\r\n }\r\n foreach (Change change2 in changes)\r\n {\r\n if ((object)change2.After == null)\r\n {\r\n dictionary.Remove(change2.Id);\r\n }\r\n else\r\n {\r\n dictionary[change2.Id] = change2.After;\r\n }\r\n }\r\n SceneAdapter.Apply(value.Session, dictionary);\r\n value.Undo.Push(changes);\r\n value.UndoPending = true;\r\n SendEdit(text, value, changes, final: true);\r\n UndoNotice = null;\r\n undoNoticeExpires = default;\r\n return true;\r\n }\r\n catch (Exception ex)\r\n {\r\n SetUndoNotice(ex.Message);\r\n return false;\r\n }\r\n }\r\n\r\n private void SetUndoNotice(string text)\r\n {\r\n string text2 = BoundedDiagnostic(text);\r\n if (!string.Equals(UndoNotice, text2, StringComparison.Ordinal) || !(DateTime.UtcNow < undoNoticeExpires))\r\n {\r\n UndoNotice = text2;\r\n undoNoticeExpires = DateTime.UtcNow.AddSeconds(8.0);\r\n }\r\n }\r\n\r\n private void SetActionNotice(string text)\r\n {\r\n string text2 = BoundedDiagnostic(text);\r\n if (!string.Equals(ActionNotice, text2, StringComparison.Ordinal) || !(DateTime.UtcNow < actionNoticeExpires))\r\n {\r\n ActionNotice = text2;\r\n actionNoticeExpires = DateTime.UtcNow.AddSeconds(8.0);\r\n }\r\n }\r\n\r\n // Presence smoothing: one PeerSmoothing per remote peer, fed from every received presence and read by DrawGizmos. presenceClock is the\r\n // monotonic clock this session stamps its own presence with (SentMs) and also reads as \"local now\" for arrival times; the two uses never\r\n // meet, because a receiver only compares one sender's stamps with its own arrival times.\r\n private readonly SessionClock presenceClock = new SessionClock();\r\n\r\n private readonly Dictionary<string, PeerSmoothing> peerSmoothing = new Dictionary<string, PeerSmoothing>();\r\n\r\n /// <summary>\r\n /// Feeds a received presence to its peer's smoother. A presence without a usable SentMs (a peer that predates the field) or without a camera\r\n /// is not fed, and that peer keeps being drawn from its raw packets.\r\n /// </summary>\r\n private void FeedPeerSmoothing(Packet p)\r\n {\r\n PrunePeerSmoothing();\r\n if (!PeerSmoothing.CanSmooth(p.SentMs) || !TryReadPresenceCamera(p.Presence, out var position, out var rotation, out var _))\r\n {\r\n return;\r\n }\r\n if (!peerSmoothing.TryGetValue(p.Peer, out var smoothing))\r\n {\r\n smoothing = new PeerSmoothing();\r\n peerSmoothing[p.Peer] = smoothing;\r\n }\r\n smoothing.OnPresence(p.SentMs, presenceClock.NowMs, PresenceMarker.PoseOf(position, rotation));\r\n }\r\n\r\n /// <summary>Forgets smoothers of peers that are no longer in the roster (the roster is cleared on leave and reconnect).</summary>\r\n private void PrunePeerSmoothing()\r\n {\r\n if (peerSmoothing.Count <= Roster.Count)\r\n {\r\n return;\r\n }\r\n foreach (string gone in peerSmoothing.Keys.Where((string key) => !Roster.ContainsKey(key)).ToList())\r\n {\r\n peerSmoothing.Remove(gone);\r\n }\r\n }\r\n\r\n private void SendPresence(bool force = true)\r\n {\r\n if (wire == null)\r\n {\r\n return;\r\n }\r\n SceneEditorSession active = SceneEditorSession.Active;\r\n SceneViewportWidget sceneViewportWidget = SceneViewWidget.Current?.LastSelectedViewportWidget;\r\n JsonObject jsonObject = new JsonObject();\r\n SceneEditorSession source = AuthoringSource();\r\n GameEditorSession game = SceneEditorSession.All.OfType<GameEditorSession>().FirstOrDefault();\r\n bool playing = IsLocalPlaying(source);\r\n CameraComponent gameCamera = null;\r\n try\r\n {\r\n gameCamera = game?.Scene?.Camera;\r\n }\r\n catch (Exception)\r\n {\r\n gameCamera = null;\r\n }\r\n if (playing && gameCamera != null && gameCamera.IsValid())\r\n {\r\n // While playing, the collaborator's marker follows the game camera, so someone still in the editor\r\n // sees where the player is and which way they look. The scene view is not what they are looking at.\r\n jsonObject[\"position\"] = JsonSerializer.SerializeToNode(gameCamera.WorldPosition);\r\n jsonObject[\"rotation\"] = JsonSerializer.SerializeToNode(gameCamera.WorldRotation);\r\n float gameFov = gameCamera.FieldOfView;\r\n if (gameFov > 0f && gameFov < 180f)\r\n {\r\n jsonObject[\"fov\"] = gameFov;\r\n }\r\n }\r\n else if (!playing && sceneViewportWidget != null && SceneViewWidget.Current.Session == active)\r\n {\r\n jsonObject[\"position\"] = JsonSerializer.SerializeToNode(sceneViewportWidget.State.CameraPosition);\r\n jsonObject[\"rotation\"] = JsonSerializer.SerializeToNode(sceneViewportWidget.State.CameraRotation);\r\n float? num = sceneViewportWidget.Renderer?.Camera?.FieldOfView;\r\n if (num.HasValue)\r\n {\r\n float valueOrDefault = num.GetValueOrDefault();\r\n if (valueOrDefault > 0f && valueOrDefault < 180f)\r\n {\r\n jsonObject[\"fov\"] = num.Value;\r\n }\r\n }\r\n }\r\n GameObject[] array = (playing ? null : active?.Selection.OfType<GameObject>().ToArray()) ?? Array.Empty<GameObject>();\r\n jsonObject[\"selection\"] = JsonSerializer.SerializeToNode(array.Select((GameObject x) => x.Id.ToString()).ToArray());\r\n // The left mouse button is held down for gameplay too, so it only means \"editing\" outside play.\r\n bool flag = !playing && (continuousEdits > 0 || Editor.Application.MouseButtons.HasFlag(MouseButtons.Left));\r\n JsonObject jsonObject2 = jsonObject;\r\n string text = PlaySpectateRules.PresenceState(saving, playing, flag);\r\n jsonObject2[\"state\"] = text;\r\n if (flag && array.Length != 0)\r\n {\r\n jsonObject[\"editing\"] = array[0].Id.ToString();\r\n }\r\n string text2 = BoundPresenceText(EditorToolManager.CurrentModeName, 40);\r\n if (text2 != null)\r\n {\r\n jsonObject[\"tool\"] = text2;\r\n }\r\n string presenceScene = SceneAdapter.PathOf(playing && source != null ? source : active);\r\n // What \"changed\" means: the same presence body and scene as the last one sent. The state word and selection are part of the body, so a\r\n // selection or a status change is sent at once; only an identical packet is held back.\r\n string fingerprint = presenceScene + \"|\" + jsonObject.ToJsonString();\r\n TimeSpan sinceLast = DateTime.UtcNow - lastPresenceSent;\r\n bool changed = !string.Equals(fingerprint, lastPresenceFingerprint, StringComparison.Ordinal);\r\n if (!force && !SendCadence.ShouldSendPresence(changed, sinceLast, text == \"editing\"))\r\n {\r\n return;\r\n }\r\n lastPresenceFingerprint = fingerprint;\r\n lastPresenceSent = DateTime.UtcNow;\r\n wire.Send(new Packet\r\n {\r\n Kind = \"presence\",\r\n Scene = presenceScene,\r\n Peer = Peer,\r\n Presence = jsonObject,\r\n SentMs = presenceClock.NowMs\r\n });\r\n }\r\n\r\n private static string BoundPresenceText(string value, int maxLength)\r\n {\r\n if (string.IsNullOrWhiteSpace(value))\r\n {\r\n return null;\r\n }\r\n string text = value.Trim();\r\n return (text.Length <= maxLength) ? text : text.Substring(0, maxLength);\r\n }\r\n\r\n private static string PresenceText(JsonObject presence, string key, int maxLength)\r\n {\r\n try\r\n {\r\n string value;\r\n return (presence?[key] is JsonValue jsonValue && jsonValue.TryGetValue<string>(out value)) ? BoundPresenceText(value, maxLength) : null;\r\n }\r\n catch (Exception)\r\n {\r\n return null;\r\n }\r\n }\r\n\r\n private static string[] ReadSelection(JsonObject presence)\r\n {\r\n try\r\n {\r\n return presence?[\"selection\"]?.Deserialize<string[]>() ?? Array.Empty<string>();\r\n }\r\n catch (Exception)\r\n {\r\n return Array.Empty<string>();\r\n }\r\n }\r\n\r\n private static bool TryReadPresenceCamera(JsonObject presence, out Vector3 position, out Rotation rotation, out float? fieldOfView)\r\n {\r\n position = default;\r\n rotation = default;\r\n fieldOfView = null;\r\n if (presence?[\"position\"] == null || presence[\"rotation\"] == null)\r\n {\r\n return false;\r\n }\r\n try\r\n {\r\n position = presence[\"position\"].Deserialize<Vector3>();\r\n rotation = presence[\"rotation\"].Deserialize<Rotation>();\r\n if (presence[\"fov\"] is JsonValue jsonValue && jsonValue.TryGetValue<float>(out var value) && value > 0f && value < 180f)\r\n {\r\n fieldOfView = value;\r\n }\r\n }\r\n catch (Exception)\r\n {\r\n return false;\r\n }\r\n return true;\r\n }\r\n\r\n private static Frustum PeerFrustum(Vector3 position, Rotation rotation, float fieldOfView)\r\n {\r\n float num = MathF.Tan(fieldOfView * 0.5f * ((float)Math.PI / 180f)) * 8f;\r\n float num2 = num * 1.7777778f;\r\n Vector3 vector = position + rotation.Forward * 8f;\r\n Vector3 vector2 = rotation.Up * num;\r\n Vector3 vector3 = rotation.Right * num2;\r\n return Frustum.FromCorners(new Ray(position, (vector + vector2 - vector3 - position).Normal), new Ray(position, (vector + vector2 + vector3 - position).Normal), new Ray(position, (vector - vector2 + vector3 - position).Normal), new Ray(position, (vector - vector2 - vector3 - position).Normal), 8f, 300f);\r\n }\r\n\r\n private static string StatusLineFor(Packet peer, string localScene, string edited, GameObject[] objects)\r\n {\r\n if (!string.Equals(peer.Scene, localScene, StringComparison.Ordinal))\r\n {\r\n return \"scene: \" + (BoundPresenceText(peer.Scene, 96) ?? \"no saved scene\");\r\n }\r\n string text = PresenceText(peer.Presence, \"state\", 32) ?? \"idle\";\r\n if (string.Equals(text, \"playing\", StringComparison.Ordinal))\r\n {\r\n return PlaySpectateRules.StatusText(text);\r\n }\r\n if (!string.Equals(text, \"editing\", StringComparison.Ordinal) || edited == null || objects == null)\r\n {\r\n return text;\r\n }\r\n GameObject gameObject = Array.Find(objects, (GameObject x) => string.Equals(x.Id.ToString(), edited, StringComparison.Ordinal));\r\n string text2 = ((gameObject == null) ? null : BoundPresenceText(gameObject.Name, 64));\r\n return (text2 == null) ? text : (text2 + \" \u00b7 \" + text);\r\n }\r\n\r\n public void DrawGizmos(Scene scene)\r\n {\r\n DrawnMarkers = 0;\r\n DrawnSelections = 0;\r\n string text = scene.Source?.ResourcePath?.ToLowerInvariant();\r\n if (!Connected || text == null)\r\n {\r\n return;\r\n }\r\n AvatarCache.Poll();\r\n DateTime utcNow = DateTime.UtcNow;\r\n Vector3 vector = Gizmo.Camera?.Position ?? Vector3.Zero;\r\n GameObject[] array = null;\r\n foreach (KeyValuePair<string, Packet> item in Roster)\r\n {\r\n Packet value = item.Value;\r\n if (value.Presence == null)\r\n {\r\n continue;\r\n }\r\n bool flag = string.Equals(value.Scene, text, StringComparison.Ordinal);\r\n Color color = DockTheme.PeerColor(value.Peer);\r\n string text2 = BoundPresenceText(value.Name, 64) ?? \"(unknown name)\";\r\n string[] array2 = ReadSelection(value.Presence);\r\n string text3 = PresenceText(value.Presence, \"editing\", 64);\r\n if ((array2.Length != 0 || (flag && text3 != null)) && array == null)\r\n {\r\n array = SceneAdapter.Walk(scene).ToArray();\r\n }\r\n using (Gizmo.Scope(\"collaboration-\" + value.Peer, Transform.Zero))\r\n {\r\n bool flag2 = (utcNow < focusedUntil && string.Equals(focusedPeer, item.Key, StringComparison.Ordinal)) || Gizmo.IsHovered;\r\n if (TryReadPresenceCamera(value.Presence, out var position, out var rotation, out var fieldOfView))\r\n {\r\n // The smoothed pose replaces the raw packet once that peer's smoother is ready; until then (and for a peer that sends no SentMs)\r\n // the raw value above is drawn, so a marker may pop in once at warm-up but never stutters.\r\n if (peerSmoothing.TryGetValue(item.Key, out var smoothing))\r\n {\r\n PresenceMarker.ApplySmoothing(smoothing, presenceClock.NowMs, ref position, ref rotation);\r\n }\r\n float length = (position - vector).Length;\r\n bool flag3 = length <= PresenceStyle.FarDistance;\r\n string presenceState = PresenceText(value.Presence, \"state\", 32) ?? \"idle\";\r\n PresenceMarker.Draw(new PresenceMarker.Subject\r\n {\r\n Position = position,\r\n ViewRotation = rotation,\r\n FieldOfView = fieldOfView,\r\n Name = text2,\r\n State = presenceState,\r\n StatusLine = flag3 ? StatusLineFor(value, text, text3, array) : null,\r\n Avatar = AvatarCache.GetTexture(value.SteamId),\r\n SameScene = flag,\r\n Emphasized = flag2,\r\n ShowFrustum = fieldOfView.HasValue & flag,\r\n Frustum = (fieldOfView.HasValue & flag) ? PeerFrustum(position, rotation, fieldOfView.Value) : null\r\n });\r\n DrawnMarkers++;\r\n }\r\n if (array2.Length == 0 || array == null)\r\n {\r\n continue;\r\n }\r\n GameObject[] array3 = array;\r\n foreach (GameObject gameObject in array3)\r\n {\r\n if (array2.Contains(gameObject.Id.ToString()))\r\n {\r\n Gizmo.Draw.Color = color;\r\n Gizmo.Draw.LineThickness = (flag2 ? 2f : 1f);\r\n Gizmo.Draw.LineBBox(gameObject.GetBounds());\r\n Gizmo.Draw.Text(BoundPresenceText(gameObject.Name, 64) ?? \"(unnamed)\", new Transform(gameObject.WorldPosition + Vector3.Up * 16f), \"Inter\");\r\n Texture texture2 = AvatarCache.GetTexture(value.SteamId);\r\n if (texture2 != null)\r\n {\r\n Gizmo.Draw.Color = Color.White;\r\n Gizmo.Draw.Sprite(gameObject.WorldPosition + Vector3.Up * 24f, 3.0, texture2, worldspace: true);\r\n }\r\n DrawnSelections++;\r\n }\r\n }\r\n Gizmo.Draw.LineThickness = 1f;\r\n }\r\n }\r\n }\r\n\r\n public void FocusCamera(string peer)\r\n {\r\n FocusNotice = null;\r\n focusNoticeExpires = default;\r\n if (string.IsNullOrWhiteSpace(peer) || !Roster.TryGetValue(peer, out var value))\r\n {\r\n RefuseFocus(\"That collaborator is no longer connected.\");\r\n return;\r\n }\r\n if (!string.Equals(value.Scene, SceneAdapter.PathOf(SceneEditorSession.Active), StringComparison.Ordinal))\r\n {\r\n RefuseFocus(\"That collaborator is in \" + (BoundPresenceText(value.Scene, 96) ?? \"no saved scene\") + \". Open the scene first.\");\r\n return;\r\n }\r\n SceneViewportWidget sceneViewportWidget = SceneViewWidget.Current?.LastSelectedViewportWidget;\r\n if (sceneViewportWidget?.State == null)\r\n {\r\n RefuseFocus(\"No scene view is open to move.\");\r\n return;\r\n }\r\n if (value.Presence?[\"position\"] == null || value.Presence[\"rotation\"] == null)\r\n {\r\n RefuseFocus(\"That collaborator has not reported a camera position yet.\");\r\n return;\r\n }\r\n Vector3 cameraPosition;\r\n Rotation cameraRotation;\r\n try\r\n {\r\n cameraPosition = value.Presence[\"position\"].Deserialize<Vector3>();\r\n cameraRotation = value.Presence[\"rotation\"].Deserialize<Rotation>();\r\n }\r\n catch (Exception)\r\n {\r\n RefuseFocus(\"That collaborator reported a camera position this build cannot read.\");\r\n return;\r\n }\r\n sceneViewportWidget.State.CameraPosition = cameraPosition;\r\n sceneViewportWidget.State.CameraRotation = cameraRotation;\r\n focusedPeer = peer;\r\n focusedUntil = DateTime.UtcNow.AddSeconds(1.0);\r\n }\r\n\r\n private void RefuseFocus(string reason)\r\n {\r\n FocusNotice = BoundedDiagnostic(reason);\r\n focusNoticeExpires = DateTime.UtcNow.AddSeconds(5.0);\r\n }\r\n}\r\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Connect/LanDiscovery.cs",
"FileName": "LanDiscovery.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Net;\r\nusing System.Net.Sockets;\r\nusing System.Text;\r\n\r\nnamespace TeamCreate;\r\n\r\npublic sealed record DiscoveredHost(string EndPoint, int Port, string HostName, string SecretHex, string Manifest8, DateTime LastSeenUtc);\r\n\r\npublic sealed class LanDiscovery : IDisposable\r\n{\r\n public static readonly TimeSpan QueryInterval = TimeSpan.FromMilliseconds(1500L);\r\n\r\n public static readonly TimeSpan HostExpiry = TimeSpan.FromSeconds(5L);\r\n\r\n public const int MaxDatagramsPerPoll = 32;\r\n\r\n public const int MaxTrackedHosts = 16;\r\n\r\n private readonly string projectIdHash8;\r\n\r\n private readonly Dictionary<string, DiscoveredHost> hosts = new Dictionary<string, DiscoveredHost>(StringComparer.Ordinal);\r\n\r\n private UdpClient socket;\r\n\r\n private bool boundToRendezvousPort;\r\n\r\n private bool hosting;\r\n\r\n private bool disposed;\r\n\r\n private int sessionPort;\r\n\r\n private string hostSecretHex;\r\n\r\n private string hostManifest8;\r\n\r\n private DateTime nextQueryUtc;\r\n\r\n private string failure;\r\n\r\n public IReadOnlyList<DiscoveredHost> Hosts\r\n {\r\n get\r\n {\r\n Prune(hosts, DateTime.UtcNow, HostExpiry);\r\n return new List<DiscoveredHost>(hosts.Values);\r\n }\r\n }\r\n\r\n public string Failure => failure;\r\n\r\n public bool CanHost => socket != null && boundToRendezvousPort && !disposed;\r\n\r\n public LanDiscovery(string projectIdHash8)\r\n {\r\n DiscoveryProtocol.BuildQuery(projectIdHash8);\r\n this.projectIdHash8 = projectIdHash8;\r\n Open();\r\n }\r\n\r\n public void Poll()\r\n {\r\n if (socket == null || disposed)\r\n {\r\n return;\r\n }\r\n try\r\n {\r\n DateTime now = DateTime.UtcNow;\r\n Drain(32, () => socket.Client.Poll(0, SelectMode.SelectRead) && socket.Available > 0, () =>\r\n {\r\n IPEndPoint remoteEP = null;\r\n byte[] item = socket.Receive(ref remoteEP);\r\n return (Bytes: item, From: remoteEP);\r\n }, ((byte[] Bytes, IPEndPoint From) datagram) =>\r\n {\r\n Handle(datagram.Bytes, datagram.From, now);\r\n });\r\n Prune(hosts, now, HostExpiry);\r\n }\r\n catch (Exception error)\r\n {\r\n failure = CollaborationDiagnostics.Bounded(Describe(error));\r\n }\r\n }\r\n\r\n public void SendQuery()\r\n {\r\n if (socket == null || disposed || hosting)\r\n {\r\n return;\r\n }\r\n DateTime utcNow = DateTime.UtcNow;\r\n if (utcNow < nextQueryUtc)\r\n {\r\n return;\r\n }\r\n nextQueryUtc = utcNow + QueryInterval;\r\n try\r\n {\r\n byte[] bytes = Encoding.ASCII.GetBytes(DiscoveryProtocol.BuildQuery(projectIdHash8));\r\n socket.Send(bytes, bytes.Length, new IPEndPoint(IPAddress.Broadcast, 27846));\r\n }\r\n catch (Exception error)\r\n {\r\n failure = CollaborationDiagnostics.Bounded(Describe(error));\r\n }\r\n }\r\n\r\n public void StartHost(int sessionPort, string secretHex, string manifest8)\r\n {\r\n DiscoveryProtocol.BuildReply(projectIdHash8, sessionPort, \"host\", secretHex, manifest8);\r\n if (socket == null || disposed)\r\n {\r\n failure = CollaborationDiagnostics.Bounded(\"LAN discovery is unavailable: the rendezvous socket could not be opened on this machine.\");\r\n }\r\n else if (!boundToRendezvousPort)\r\n {\r\n failure = CollaborationDiagnostics.Bounded($\"LAN discovery cannot answer on this machine: UDP port {27846} is already in use.\");\r\n }\r\n else\r\n {\r\n this.sessionPort = sessionPort;\r\n hostSecretHex = secretHex;\r\n hostManifest8 = manifest8;\r\n hosts.Clear();\r\n hosting = true;\r\n }\r\n }\r\n\r\n public void StopHost()\r\n {\r\n hosting = false;\r\n }\r\n\r\n private void Open()\r\n {\r\n try\r\n {\r\n UdpClient udpClient;\r\n try\r\n {\r\n udpClient = new UdpClient(new IPEndPoint(IPAddress.Any, 27846));\r\n }\r\n catch (SocketException)\r\n {\r\n udpClient = new UdpClient(new IPEndPoint(IPAddress.Any, 0));\r\n }\r\n udpClient.EnableBroadcast = true;\r\n socket = udpClient;\r\n boundToRendezvousPort = ((IPEndPoint)udpClient.Client.LocalEndPoint).Port == 27846;\r\n }\r\n catch (Exception error)\r\n {\r\n socket = null;\r\n failure = CollaborationDiagnostics.Bounded(Describe(error));\r\n }\r\n }\r\n\r\n private void Handle(byte[] bytes, IPEndPoint from, DateTime now)\r\n {\r\n if (bytes == null || from == null || bytes.Length > 512)\r\n {\r\n return;\r\n }\r\n string text = Encoding.ASCII.GetString(bytes);\r\n DiscoveryReply reply;\r\n if (DiscoveryProtocol.TryParseQuery(text, out var text2))\r\n {\r\n if (hosting && text2 == projectIdHash8)\r\n {\r\n Reply(from);\r\n }\r\n }\r\n else if (DiscoveryProtocol.TryParseReply(text, out reply) && !(reply.ProjectIdHash8 != projectIdHash8) && !hosting)\r\n {\r\n string text3 = from.Address.ToString();\r\n if (hosts.Count >= 16 && !hosts.ContainsKey(text3))\r\n {\r\n DropOldest();\r\n }\r\n hosts[text3] = new DiscoveredHost(text3, reply.SessionPort, reply.HostName, reply.SecretHex, reply.Manifest8, now);\r\n }\r\n }\r\n\r\n private void Reply(IPEndPoint to)\r\n {\r\n byte[] bytes = Encoding.ASCII.GetBytes(DiscoveryProtocol.BuildReply(projectIdHash8, sessionPort, Environment.MachineName, hostSecretHex, hostManifest8));\r\n socket.Send(bytes, bytes.Length, to);\r\n }\r\n\r\n private void DropOldest()\r\n {\r\n DiscoveredHost discoveredHost = null;\r\n foreach (KeyValuePair<string, DiscoveredHost> host in hosts)\r\n {\r\n if ((object)discoveredHost == null || host.Value.LastSeenUtc < discoveredHost.LastSeenUtc)\r\n {\r\n discoveredHost = host.Value;\r\n }\r\n }\r\n if ((object)discoveredHost != null)\r\n {\r\n hosts.Remove(discoveredHost.EndPoint);\r\n }\r\n }\r\n\r\n internal static int Drain<T>(int cap, Func<bool> available, Func<T> receive, Action<T> handle)\r\n {\r\n int i;\r\n for (i = 0; i < cap; i++)\r\n {\r\n if (!available())\r\n {\r\n break;\r\n }\r\n handle(receive());\r\n }\r\n return i;\r\n }\r\n\r\n internal static int Prune(Dictionary<string, DiscoveredHost> hosts, DateTime nowUtc, TimeSpan maxAge)\r\n {\r\n if (hosts == null || hosts.Count == 0)\r\n {\r\n return 0;\r\n }\r\n List<string> list = null;\r\n foreach (KeyValuePair<string, DiscoveredHost> host in hosts)\r\n {\r\n if (nowUtc - host.Value.LastSeenUtc > maxAge)\r\n {\r\n (list ?? (list = new List<string>())).Add(host.Key);\r\n }\r\n }\r\n if (list == null)\r\n {\r\n return 0;\r\n }\r\n foreach (string item in list)\r\n {\r\n hosts.Remove(item);\r\n }\r\n return list.Count;\r\n }\r\n\r\n private static string Describe(Exception error)\r\n {\r\n return CollaborationDiagnostics.DescribeExternalFailure(\"LAN discovery: \", error);\r\n }\r\n\r\n public void Dispose()\r\n {\r\n if (!disposed)\r\n {\r\n disposed = true;\r\n hosting = false;\r\n try\r\n {\r\n socket?.Close();\r\n }\r\n catch\r\n {\r\n }\r\n socket = null;\r\n hosts.Clear();\r\n }\r\n }\r\n}\r\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Core/AutoSaveRules.cs",
"FileName": "AutoSaveRules.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\r\n\r\nnamespace TeamCreate;\r\n\r\npublic sealed record IdleSaveInput(bool Enabled, bool HasUnsavedChanges, bool PeerEditing, bool InPlayMode, DateTime LastLocalEdit, DateTime LastAutoSave, DateTime Now, Func<bool> CanSave);\r\n\r\npublic static class AutoSaveRules\r\n{\r\n public const int MinIntervalSeconds = 30;\r\n\r\n public const int IdleSeconds = 30;\r\n\r\n public static bool CheckpointEligible(bool hasUnsavedChanges, Func<bool> canSave)\r\n {\r\n if (!hasUnsavedChanges)\r\n {\r\n return false;\r\n }\r\n return canSave?.Invoke() ?? false;\r\n }\r\n\r\n public static bool Eligible(IdleSaveInput input)\r\n {\r\n if (!input.Enabled)\r\n {\r\n return false;\r\n }\r\n if (input.PeerEditing || input.InPlayMode)\r\n {\r\n return false;\r\n }\r\n if (input.LastLocalEdit == default(DateTime))\r\n {\r\n return false;\r\n }\r\n if (input.Now - input.LastLocalEdit < TimeSpan.FromSeconds(30L))\r\n {\r\n return false;\r\n }\r\n if (input.LastAutoSave != default(DateTime) && input.Now - input.LastAutoSave < TimeSpan.FromSeconds(30L))\r\n {\r\n return false;\r\n }\r\n return CheckpointEligible(input.HasUnsavedChanges, input.CanSave);\r\n }\r\n}\r\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Core/BlobStore.cs",
"FileName": "BlobStore.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Security.Cryptography;\n\nnamespace TeamCreate;\n\n/// <summary>\n/// The fixed limits of mapping-tool geometry synchronization (docs/BLOB-SYNC.md). They are part of the protocol: a receiver refuses what\n/// exceeds them, so a sender that respected them is never refused for size.\n/// </summary>\npublic static class BlobLimits\n{\n /// <summary>Advertised by the host on welcome. A guest sends geometry placeholders only to a host that holds this feature.</summary>\n public const string Feature = \"blobs-1\";\n\n /// <summary>The largest geometry (decoded bytes) that is synchronized. The largest mesh in the installed sample scenes is 576 KB.</summary>\n public const int MaxBlobBytes = 32 * 1024 * 1024;\n\n /// <summary>Geometry that has started to arrive and is not complete yet, summed over one endpoint (every member on a host).</summary>\n public const long MaxPendingBytes = 64L * 1024 * 1024;\n\n /// <summary>Scene packets one endpoint holds while the geometry they name is on its way.</summary>\n public const int MaxPendingPackets = 32;\n\n /// <summary>How long a held packet waits for its geometry before it is discarded and the scene is synchronized again.</summary>\n public const int WaitSeconds = 60;\n\n /// <summary>Hashes one blob-need may name; the remainder of a longer request is ignored.</summary>\n public const int MaxHashesPerRequest = 64;\n\n /// <summary>\n /// Shortest text value that is moved out of an object's state (4 KiB). Below this the inline text costs less than the placeholder\n /// round trip, and nothing in the engine writes geometry that small except an empty mesh.\n /// </summary>\n public const int MinExternalChars = 4096;\n\n /// <summary>One new version of an object's geometry per this many milliseconds, so dragging a vertex does not flood the session.</summary>\n public const int VersionIntervalMs = 500;\n\n public const long MemoryCacheBytes = 256L * 1024 * 1024;\n\n public const long DiskCacheBytes = 256L * 1024 * 1024;\n}\n\n/// <summary>\n/// Content-addressed geometry bytes, keyed by the lowercase hex SHA-256 of the bytes. A bounded in-memory cache (least recently used out)\n/// sits in front of an optional disk cache in <c>.collaboration/blobs/<sha256></c>. Nothing is stored under a key its bytes do not\n/// hash to, and a key that is not exactly 64 lowercase hex digits never reaches the file system, so a hostile peer cannot name a path.\n/// </summary>\npublic sealed class BlobStore\n{\n private sealed class Entry\n {\n public byte[] Bytes;\n\n public long Touched;\n }\n\n private readonly object gate = new();\n\n private readonly Dictionary<string, Entry> memory = new( StringComparer.Ordinal );\n\n private readonly long memoryCap;\n\n private long diskCap;\n\n private string directory;\n\n private long clock;\n\n public BlobStore( string root, long memoryCap, long diskCap )\n {\n this.memoryCap = memoryCap;\n if ( root != null ) AttachDisk( root, diskCap );\n }\n\n public int MemoryCount\n {\n get { lock ( gate ) return memory.Count; }\n }\n\n public long MemoryBytes { get; private set; }\n\n public static string HashOf( byte[] bytes )\n {\n return Convert.ToHexString( SHA256.HashData( bytes ) ).ToLowerInvariant();\n }\n\n public static bool ValidHash( string hash )\n {\n if ( hash == null || hash.Length != 64 ) return false;\n foreach ( char c in hash )\n {\n if ( !( ( c >= '0' && c <= '9' ) || ( c >= 'a' && c <= 'f' ) ) ) return false;\n }\n return true;\n }\n\n /// <summary>\n /// Starts keeping blobs on disk under <paramref name=\"root\"/>, and writes the ones already in memory, so an object captured before the\n /// session began hosting can still be served to a late joiner. A folder that cannot be created leaves the store memory-only.\n /// </summary>\n public void AttachDisk( string root, long cap )\n {\n string folder;\n try\n {\n folder = Path.Combine( root, \".collaboration\", \"blobs\" );\n Directory.CreateDirectory( folder );\n }\n catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException )\n {\n return;\n }\n lock ( gate )\n {\n directory = folder;\n diskCap = cap;\n foreach ( KeyValuePair<string, Entry> pair in memory ) WriteFile( pair.Key, pair.Value.Bytes );\n }\n }\n\n /// <summary>Stores <paramref name=\"bytes\"/> under their own hash. Null (and <paramref name=\"error\"/>) when they are empty or over the limit.</summary>\n public string Put( byte[] bytes, out string error )\n {\n error = null;\n if ( bytes == null || bytes.Length == 0 )\n {\n error = \"Empty geometry is not stored.\";\n return null;\n }\n if ( bytes.Length > BlobLimits.MaxBlobBytes )\n {\n error = TooLarge();\n return null;\n }\n string hash = HashOf( bytes );\n return Store( hash, bytes ) ? hash : null;\n }\n\n /// <summary>Stores <paramref name=\"bytes\"/> only if they hash to <paramref name=\"hash\"/>; anything else is refused and nothing is written.</summary>\n public bool Put( string hash, byte[] bytes, out string error )\n {\n error = null;\n if ( !ValidHash( hash ) )\n {\n error = \"The geometry was offered under a name that is not a SHA-256 digest.\";\n return false;\n }\n if ( bytes == null || bytes.Length == 0 )\n {\n error = \"Empty geometry is not stored.\";\n return false;\n }\n if ( bytes.Length > BlobLimits.MaxBlobBytes )\n {\n error = TooLarge();\n return false;\n }\n if ( HashOf( bytes ) != hash )\n {\n error = \"The geometry did not match the digest it was offered under and was discarded.\";\n return false;\n }\n return Store( hash, bytes );\n }\n\n /// <summary>\n /// Records bytes the caller has just hashed itself (<see cref=\"BlobRules.Externalize\"/> does, to name them), without hashing again. This runs for every\n /// mesh on every scan, so a blob already in memory is only touched: no copy, no disk check.\n /// </summary>\n public void AddTrusted( string hash, byte[] bytes )\n {\n lock ( gate )\n {\n if ( memory.TryGetValue( hash, out Entry known ) )\n {\n known.Touched = ++clock;\n return;\n }\n if ( bytes.Length > 0 && bytes.Length <= BlobLimits.MaxBlobBytes ) Store( hash, bytes );\n }\n }\n\n public static string TooLarge() => \"The geometry is larger than the 32 MiB collaboration limit and was not synchronized.\";\n\n public bool Has( string hash )\n {\n if ( !ValidHash( hash ) ) return false;\n lock ( gate )\n {\n if ( memory.ContainsKey( hash ) ) return true;\n return directory != null && File.Exists( Path.Combine( directory, hash ) );\n }\n }\n\n public bool TryGet( string hash, out byte[] bytes )\n {\n bytes = null;\n if ( !ValidHash( hash ) ) return false;\n lock ( gate )\n {\n if ( memory.TryGetValue( hash, out Entry entry ) )\n {\n entry.Touched = ++clock;\n bytes = entry.Bytes;\n return true;\n }\n if ( directory == null ) return false;\n string file = Path.Combine( directory, hash );\n byte[] read;\n try\n {\n if ( !File.Exists( file ) ) return false;\n read = File.ReadAllBytes( file );\n }\n catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )\n {\n return false;\n }\n // A file that does not hash to its own name (a torn write, a disk fault, a person's edit) is never served: it is removed so\n // the geometry is fetched again.\n if ( read.Length == 0 || read.Length > BlobLimits.MaxBlobBytes || HashOf( read ) != hash )\n {\n TryDelete( file );\n return false;\n }\n Remember( hash, read );\n try\n {\n File.SetLastWriteTimeUtc( file, DateTime.UtcNow );\n }\n catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )\n {\n }\n bytes = read;\n return true;\n }\n }\n\n /// <summary>Deletes the oldest files until the disk cache is within its cap. Called by the session from time to time, never on the capture path.</summary>\n public void Prune()\n {\n lock ( gate )\n {\n if ( directory == null || diskCap <= 0 ) return;\n List<FileInfo> files;\n try\n {\n files = new DirectoryInfo( directory ).GetFiles().Where( x => ValidHash( x.Name ) ).ToList();\n }\n catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )\n {\n return;\n }\n long total = files.Sum( x => x.Length );\n foreach ( FileInfo file in files.OrderBy( x => x.LastWriteTimeUtc ) )\n {\n if ( total <= diskCap ) break;\n total -= file.Length;\n TryDelete( file.FullName );\n }\n }\n }\n\n private bool Store( string hash, byte[] bytes )\n {\n lock ( gate )\n {\n if ( memory.TryGetValue( hash, out Entry known ) )\n {\n known.Touched = ++clock;\n if ( directory != null && !File.Exists( Path.Combine( directory, hash ) ) ) WriteFile( hash, bytes );\n return true;\n }\n Remember( hash, bytes );\n if ( directory != null ) WriteFile( hash, bytes );\n return true;\n }\n }\n\n // Adds to the memory cache and evicts the least recently used entries until it fits. The entry just added is never the one evicted.\n private void Remember( string hash, byte[] bytes )\n {\n memory[hash] = new Entry { Bytes = bytes, Touched = ++clock };\n MemoryBytes += bytes.Length;\n while ( MemoryBytes > memoryCap && memory.Count > 1 )\n {\n string oldest = null;\n long oldestTouch = long.MaxValue;\n foreach ( KeyValuePair<string, Entry> pair in memory )\n {\n if ( pair.Key != hash && pair.Value.Touched < oldestTouch )\n {\n oldest = pair.Key;\n oldestTouch = pair.Value.Touched;\n }\n }\n if ( oldest == null ) break;\n MemoryBytes -= memory[oldest].Bytes.Length;\n memory.Remove( oldest );\n }\n }\n\n // Written to a temporary name and renamed, so a reader never sees half a file. A failure leaves the memory copy as the only one.\n private void WriteFile( string hash, byte[] bytes )\n {\n string target = Path.Combine( directory, hash );\n string temporary = Path.Combine( directory, hash + \".\" + Guid.NewGuid().ToString( \"N\" ) + \".tmp\" );\n try\n {\n File.WriteAllBytes( temporary, bytes );\n File.Move( temporary, target, true );\n }\n catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )\n {\n TryDelete( temporary );\n }\n }\n\n private static void TryDelete( string path )\n {\n try\n {\n File.Delete( path );\n }\n catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )\n {\n }\n }\n}\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Core/ConnectCopy.cs",
"FileName": "ConnectCopy.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System.Collections.Generic;\nusing System.Text;\n\nnamespace TeamCreate;\n\n/// <summary>\n/// The words under the Host and Join buttons. One sentence says what to do next; further lines appear only when something needs the person's attention (a relay that is\n/// missing, a router that did not answer, a scene that is playing). The host's public address is deliberately not shown: the invite code already carries it, and it\n/// is the one detail a person pastes into a chat or a screenshot without meaning to.\n/// </summary>\npublic static class ConnectCopy\n{\n public const string Idle = \"Host a session, or paste a friend's code to join.\";\n\n public const string ShareCode = \"Share this code with your friend. They paste it under Join.\";\n\n public const string ReadyToJoin = \"Ready to join with this code. The local network is tried first, then the relay in the code, then the host's address.\";\n\n public static string Build(bool connected, bool isHost, bool hosting, string route, string typedCode, string relayLine, string portMappingFailure, string playNotice, string soloNotice, string discoveryFailure)\n {\n var lines = new List<string>();\n if (connected && !isHost && !string.IsNullOrWhiteSpace(route))\n {\n lines.Add(route);\n }\n else if (!connected && !string.IsNullOrWhiteSpace(typedCode))\n {\n lines.Add(ReadyToJoin);\n }\n else if (isHost && hosting)\n {\n lines.Add(ShareCode);\n Add(lines, relayLine);\n Add(lines, portMappingFailure);\n }\n else\n {\n lines.Add(Idle);\n }\n Add(lines, playNotice);\n Add(lines, soloNotice);\n Add(lines, discoveryFailure);\n return string.Join(\"\\n\", lines);\n }\n\n private static void Add(List<string> lines, string line)\n {\n if (!string.IsNullOrWhiteSpace(line))\n {\n lines.Add(line.Trim());\n }\n }\n}\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Core/RuntimeProtocol.cs",
"FileName": "RuntimeProtocol.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing System.Text.Json.Nodes;\r\n\r\nnamespace TeamCreate;\r\n\r\npublic static class RuntimeStateRules\r\n{\r\n public const int MaxRuntimeRunIdLength = 128;\r\n\r\n private static readonly HashSet<string> GameObjectFields = new HashSet<string>(StringComparer.Ordinal) { \"__guid\", \"Name\", \"Position\", \"Rotation\", \"Scale\", \"Enabled\", \"Tags\", \"Components\" };\r\n\r\n private static readonly Dictionary<string, HashSet<string>> ComponentFields = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal)\r\n {\r\n [\"Sandbox.ModelRenderer\"] = new HashSet<string>(StringComparer.Ordinal) { \"Tint\" },\r\n [\"Sandbox.CameraComponent\"] = new HashSet<string>(StringComparer.Ordinal) { \"FieldOfView\", \"Orthographic\", \"OrthographicHeight\", \"ZNear\", \"ZFar\", \"BackgroundColor\" },\r\n [\"Sandbox.DirectionalLight\"] = new HashSet<string>(StringComparer.Ordinal) { \"LightColor\", \"SkyColor\", \"Shadows\" },\r\n [\"Sandbox.PointLight\"] = new HashSet<string>(StringComparer.Ordinal) { \"LightColor\", \"Radius\", \"Shadows\" },\r\n [\"Sandbox.BoxCollider\"] = new HashSet<string>(StringComparer.Ordinal) { \"Center\", \"Scale\", \"IsTrigger\" }\r\n };\r\n\r\n public static bool IsValidRunId(string runId)\r\n {\r\n return !string.IsNullOrWhiteSpace(runId) && runId.Length <= 128 && runId == runId.Trim() && !runId.Any(char.IsControl);\r\n }\r\n\r\n public static void ValidateObject(ObjectState state)\r\n {\r\n if ((object)state == null)\r\n {\r\n throw InvalidIdentity(\"object is missing\");\r\n }\r\n if (!string.IsNullOrEmpty(state.Parent) && !TryGetCanonicalId(state.Parent, out var canonicalId))\r\n {\r\n throw InvalidIdentity(\"parent id \" + state.Parent + \" is not a GUID\");\r\n }\r\n if (!TryGetCanonicalId(state.Id, out var canonicalId2) || state.Data == null || !TryGetString(state.Data, \"__guid\", out var value) || !TryGetCanonicalId(value, out var canonicalId3) || !string.Equals(canonicalId2, canonicalId3, StringComparison.Ordinal))\r\n {\r\n throw InvalidIdentity(state.Id + \" does not match its serialized __guid\");\r\n }\r\n if (state.Data.Any((KeyValuePair<string, JsonNode> field) => field.Key.Contains(\"Prefab\", StringComparison.OrdinalIgnoreCase)))\r\n {\r\n throw NotAllowlisted(\"GameObject field '\" + FindPrefabField(state.Data) + \"' is prefab data\");\r\n }\r\n bool value2;\r\n foreach (KeyValuePair<string, JsonNode> datum in state.Data)\r\n {\r\n if (!GameObjectFields.Contains(datum.Key))\r\n {\r\n throw NotAllowlisted(\"GameObject field '\" + datum.Key + \"'\");\r\n }\r\n switch (datum.Key)\r\n {\r\n case \"__guid\":\r\n case \"Name\":\r\n case \"Components\":\r\n value2 = true;\r\n break;\r\n default:\r\n value2 = false;\r\n break;\r\n }\r\n if (!value2 && !IsScalar(datum.Value))\r\n {\r\n throw NotAllowlisted(\"GameObject field '\" + datum.Key + \"' is not a scalar JsonValue\");\r\n }\r\n }\r\n if (!TryGetString(state.Data, \"Name\", out canonicalId))\r\n {\r\n throw InvalidShape(\"GameObject field 'Name' must be a non-null JSON string\");\r\n }\r\n if (state.Data.TryGetPropertyValue(\"Enabled\", out JsonNode jsonNode) && (!(jsonNode is JsonValue jsonValue) || !jsonValue.TryGetValue<bool>(out value2)))\r\n {\r\n throw InvalidShape(\"GameObject field 'Enabled' must be a JSON boolean\");\r\n }\r\n if (state.Data.TryGetPropertyValue(\"Tags\", out JsonNode _) && !TryGetString(state.Data, \"Tags\", out canonicalId))\r\n {\r\n throw InvalidShape(\"GameObject field 'Tags' must be a JSON string\");\r\n }\r\n if (!(state.Data[\"Components\"] is JsonArray jsonArray))\r\n {\r\n throw InvalidShape(\"GameObject field 'Components' must be a JSON array\");\r\n }\r\n HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal);\r\n foreach (JsonNode item in jsonArray)\r\n {\r\n if (!(item is JsonObject jsonObject))\r\n {\r\n throw InvalidShape(\"Components entries must be JSON objects\");\r\n }\r\n if (!TryGetString(jsonObject, \"__type\", out var value3) || string.IsNullOrWhiteSpace(value3))\r\n {\r\n throw InvalidShape(\"Component field '__type' must be a non-empty JSON string\");\r\n }\r\n if (!ComponentFields.TryGetValue(value3, out var value4))\r\n {\r\n throw new InvalidOperationException(\"Runtime component type is not allowlisted: \" + value3);\r\n }\r\n if (!TryGetString(jsonObject, \"__guid\", out var value5) || !TryGetCanonicalId(value5, out var canonicalId4))\r\n {\r\n throw InvalidIdentity(\"component \" + value3 + \" has an invalid __guid\");\r\n }\r\n if (string.Equals(canonicalId4, canonicalId2, StringComparison.Ordinal))\r\n {\r\n throw InvalidIdentity(\"component \" + value3 + \" reuses its object __guid \" + value5);\r\n }\r\n if (!hashSet.Add(canonicalId4))\r\n {\r\n throw InvalidIdentity(\"component \" + value3 + \" duplicates __guid \" + value5);\r\n }\r\n foreach (KeyValuePair<string, JsonNode> item2 in jsonObject)\r\n {\r\n canonicalId = item2.Key;\r\n if ((!(canonicalId == \"__type\") && !(canonicalId == \"__guid\")) || 1 == 0)\r\n {\r\n if (item2.Key.Contains(\"Prefab\", StringComparison.OrdinalIgnoreCase))\r\n {\r\n throw NotAllowlisted($\"component field '{value3}.{item2.Key}' is prefab data\");\r\n }\r\n if (!value4.Contains(item2.Key))\r\n {\r\n throw NotAllowlisted($\"component field '{value3}.{item2.Key}'\");\r\n }\r\n if (!IsScalar(item2.Value))\r\n {\r\n throw NotAllowlisted($\"component field '{value3}.{item2.Key}' is not a scalar JsonValue\");\r\n }\r\n }\r\n }\r\n }\r\n }\r\n\r\n public static void ValidateSnapshot(string scenePath, IReadOnlyDictionary<string, ObjectState> objects)\r\n {\r\n if (!IsSafeScenePath(scenePath))\r\n {\r\n throw new InvalidOperationException(\"Runtime scene path is invalid: \" + (scenePath ?? \"<null>\"));\r\n }\r\n if (objects == null)\r\n {\r\n throw InvalidIdentity(\"snapshot is missing\");\r\n }\r\n HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal);\r\n HashSet<string> hashSet2 = new HashSet<string>(StringComparer.Ordinal);\r\n Dictionary<string, ObjectState> dictionary = new Dictionary<string, ObjectState>(StringComparer.Ordinal);\r\n foreach (KeyValuePair<string, ObjectState> @object in objects)\r\n {\r\n if ((object)@object.Value == null)\r\n {\r\n throw InvalidIdentity(\"snapshot contains missing object id \" + @object.Key);\r\n }\r\n ValidateObject(@object.Value);\r\n if (!TryGetCanonicalId(@object.Key, out var canonicalId) || !TryGetCanonicalId(@object.Value.Id, out var canonicalId2) || !string.Equals(canonicalId, canonicalId2, StringComparison.Ordinal))\r\n {\r\n throw InvalidIdentity(\"snapshot key \" + @object.Key + \" does not match object id \" + @object.Value.Id);\r\n }\r\n if (!hashSet.Add(canonicalId))\r\n {\r\n throw InvalidIdentity(\"snapshot contains duplicate object id \" + @object.Key);\r\n }\r\n if (!hashSet2.Add(canonicalId2))\r\n {\r\n throw InvalidIdentity(\"snapshot contains duplicate object id \" + @object.Key);\r\n }\r\n if (!dictionary.TryAdd(canonicalId2, @object.Value))\r\n {\r\n throw InvalidIdentity(\"snapshot contains duplicate object id \" + @object.Key);\r\n }\r\n JsonArray jsonArray = @object.Value.Data[\"Components\"].AsArray();\r\n foreach (JsonNode item in jsonArray)\r\n {\r\n string value = item[\"__guid\"].GetValue<string>();\r\n if (!TryGetCanonicalId(value, out var canonicalId3))\r\n {\r\n throw InvalidIdentity(\"component \" + value + \" is not a GUID\");\r\n }\r\n if (!hashSet2.Add(canonicalId3))\r\n {\r\n throw InvalidIdentity(\"snapshot contains duplicate object/component id \" + value);\r\n }\r\n }\r\n }\r\n foreach (KeyValuePair<string, ObjectState> object2 in objects)\r\n {\r\n if (!TryGetCanonicalId(object2.Key, out var canonicalId4))\r\n {\r\n throw InvalidIdentity(\"snapshot key \" + object2.Key + \" is not a GUID\");\r\n }\r\n HashSet<string> hashSet3 = new HashSet<string>(StringComparer.Ordinal) { canonicalId4 };\r\n string parent = object2.Value.Parent;\r\n while (!string.IsNullOrEmpty(parent))\r\n {\r\n if (!TryGetCanonicalId(parent, out var canonicalId5) || !dictionary.TryGetValue(canonicalId5, out var value2))\r\n {\r\n throw new InvalidOperationException(\"Runtime parent does not exist: \" + parent);\r\n }\r\n if ((object)value2 == null)\r\n {\r\n throw InvalidIdentity(\"Runtime parent is missing: \" + parent);\r\n }\r\n if (!hashSet3.Add(canonicalId5))\r\n {\r\n throw new InvalidOperationException(\"Runtime parent cycle detected at \" + parent);\r\n }\r\n parent = value2.Parent;\r\n }\r\n }\r\n }\r\n\r\n public static bool IsAllowedComponentType(string type)\r\n {\r\n return ComponentFields.ContainsKey(type);\r\n }\r\n\r\n public static bool IsAllowedComponentField(string type, string field)\r\n {\r\n HashSet<string> value;\r\n return ComponentFields.TryGetValue(type, out value) && value.Contains(field);\r\n }\r\n\r\n public static bool IsSafeScenePath(string scenePath)\r\n {\r\n if (string.IsNullOrWhiteSpace(scenePath) || !scenePath.StartsWith(\"scenes/\", StringComparison.Ordinal) || scenePath.Contains('\\\\') || scenePath.Contains(\"..\", StringComparison.Ordinal) || Path.IsPathRooted(scenePath) || scenePath.Any(char.IsControl))\r\n {\r\n return false;\r\n }\r\n string[] source = scenePath.Split('/');\r\n if (source.Any((string segment) =>\r\n {\r\n bool flag = segment.Length == 0;\r\n bool flag2 = flag;\r\n if (!flag2)\r\n {\r\n bool flag3 = ((segment == \".\" || segment == \"..\") ? true : false);\r\n flag2 = flag3;\r\n }\r\n return flag2;\r\n }))\r\n {\r\n return false;\r\n }\r\n return scenePath.EndsWith(\".scene\", StringComparison.OrdinalIgnoreCase);\r\n }\r\n\r\n private static bool TryGetCanonicalId(string value, out string canonicalId)\r\n {\r\n if (Guid.TryParse(value, out var result))\r\n {\r\n canonicalId = result.ToString(\"D\");\r\n return true;\r\n }\r\n canonicalId = null;\r\n return false;\r\n }\r\n\r\n private static string FindPrefabField(JsonObject data)\r\n {\r\n return data.First((KeyValuePair<string, JsonNode> field) => field.Key.Contains(\"Prefab\", StringComparison.OrdinalIgnoreCase)).Key;\r\n }\r\n\r\n private static bool IsScalar(JsonNode node)\r\n {\r\n JsonValue jsonValue = node as JsonValue;\r\n bool flag = jsonValue != null;\r\n bool flag2 = flag;\r\n if (flag2)\r\n {\r\n JsonValueKind valueKind = jsonValue.GetValueKind();\r\n bool flag3 = valueKind - 3 <= JsonValueKind.Number;\r\n flag2 = flag3;\r\n }\r\n return flag2;\r\n }\r\n\r\n private static bool TryGetString(JsonObject data, string key, out string value)\r\n {\r\n value = null;\r\n JsonNode jsonNode;\r\n return data.TryGetPropertyValue(key, out jsonNode) && jsonNode is JsonValue jsonValue && jsonValue.TryGetValue<string>(out value);\r\n }\r\n\r\n private static InvalidOperationException NotAllowlisted(string detail)\r\n {\r\n return new InvalidOperationException(\"Runtime state is not allowlisted: \" + detail);\r\n }\r\n\r\n private static InvalidOperationException InvalidIdentity(string detail)\r\n {\r\n return new InvalidOperationException(\"Runtime object identity is invalid: \" + detail);\r\n }\r\n\r\n private static InvalidOperationException InvalidShape(string detail)\r\n {\r\n return new InvalidOperationException(\"Runtime state shape is invalid: \" + detail);\r\n }\r\n}\r\n\r\npublic enum RuntimeSessionKind\r\n{\r\n Source,\r\n Game\r\n}\r\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Dock/ReadinessCard.cs",
"FileName": "ReadinessCard.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Text;\r\nusing System.Threading.Tasks;\r\nusing Editor;\r\nusing Sandbox;\r\n\r\nnamespace TeamCreate;\r\n\r\npublic sealed class ReadinessCard : DockCard\r\n{\r\n private sealed class ItemBody : Widget\r\n {\r\n private readonly ReadinessItem item;\r\n\r\n public ItemBody(Widget parent, ReadinessItem item)\r\n : base(parent)\r\n {\r\n this.item = item;\r\n FixedHeight = DockTheme.RowHeight;\r\n ToolTip = item.Detail ?? string.Empty;\r\n OnPaintOverride = PaintItem;\r\n }\r\n\r\n private bool PaintItem()\r\n {\r\n Rect localRect = LocalRect;\r\n Paint.SetPen(DockTheme.StatusColor(item.Level), 0f, PenStyle.Solid);\r\n Paint.DrawIcon(new Rect(localRect.Left, localRect.Top, 16f, localRect.Height), DockTheme.LevelIcon(item.Level), 14f, TextFlag.LeftCenter);\r\n Paint.SetPen(Theme.Text.WithAlpha(0.55f), 0f, PenStyle.Solid);\r\n Paint.DrawIcon(new Rect(localRect.Left + 18f, localRect.Top, 14f, localRect.Height), DockTheme.ReadinessIcon(item.Key), 12f, TextFlag.LeftCenter);\r\n string text = item.Title ?? string.Empty;\r\n float width = ((text.Length == 0) ? 0f : (Paint.MeasureText(text).x + 8f));\r\n float x = localRect.Left + 36f;\r\n Paint.SetPen(in Theme.Text, 0f, PenStyle.Solid);\r\n if (width > 0f)\r\n {\r\n Paint.DrawText(new Rect(in x, localRect.Top, in width, localRect.Height), text, TextFlag.LeftCenter | TextFlag.SingleLine);\r\n }\r\n string text2 = item.Detail ?? string.Empty;\r\n float x2 = x + width;\r\n float width2 = localRect.Right - x2 - 4f;\r\n if (text2.Length > 0 && width2 > 8f)\r\n {\r\n string elidedText = Paint.GetElidedText(text2, width2);\r\n Paint.SetPen(Theme.Text.WithAlpha(0.6f), 0f, PenStyle.Solid);\r\n Paint.DrawText(new Rect(in x2, localRect.Top, in width2, localRect.Height), elidedText, TextFlag.LeftCenter | TextFlag.SingleLine);\r\n }\r\n return false;\r\n }\r\n }\r\n\r\n private readonly Widget rows;\r\n\r\n private readonly Label notice;\r\n\r\n private IReadOnlyList<ReadinessItem> lastList;\r\n\r\n private string signature;\r\n\r\n protected override bool FieldsAssigned => rows != null && notice != null;\r\n\r\n public ReadinessCard(Widget parent)\r\n : base(parent, \"Readiness\")\r\n {\r\n rows = Layout.Add(new Widget(this));\r\n rows.Layout = Layout.Column();\r\n rows.Layout.Spacing = 4f;\r\n notice = Layout.Add(new Label(\"\", this)\r\n {\r\n WordWrap = true,\r\n Color = DockTheme.WarningColor\r\n });\r\n notice.Visible = false;\r\n Refresh();\r\n }\r\n\r\n public void Refresh()\r\n {\r\n IReadOnlyList<ReadinessItem> readiness = CollaborationSession.Current.Readiness;\r\n if (readiness != lastList)\r\n {\r\n lastList = readiness;\r\n string a = Signature(readiness);\r\n if (!string.Equals(a, signature, StringComparison.Ordinal))\r\n {\r\n signature = a;\r\n Rebuild(readiness);\r\n }\r\n }\r\n }\r\n\r\n private static string Signature(IReadOnlyList<ReadinessItem> list)\r\n {\r\n if (list == null || list.Count == 0)\r\n {\r\n return string.Empty;\r\n }\r\n StringBuilder stringBuilder = new StringBuilder(list.Count * 64);\r\n foreach (ReadinessItem item in list)\r\n {\r\n if ((object)item != null)\r\n {\r\n stringBuilder.Append(item.Key).Append('|').Append(item.Level)\r\n .Append('|')\r\n .Append(item.Title)\r\n .Append('|')\r\n .Append(item.Detail)\r\n .Append('|')\r\n .Append(item.Fix)\r\n .Append('\\n');\r\n }\r\n }\r\n return stringBuilder.ToString();\r\n }\r\n\r\n private void Rebuild(IReadOnlyList<ReadinessItem> list)\r\n {\r\n rows.Layout.Clear(deleteWidgets: true);\r\n if (list == null || list.Count == 0)\r\n {\r\n rows.Layout.Add(new Label(\"The readiness checklist has not been evaluated yet.\", this)\r\n {\r\n WordWrap = true,\r\n Color = DockTheme.MutedColor\r\n });\r\n return;\r\n }\r\n foreach (ReadinessItem item in list)\r\n {\r\n if ((object)item != null)\r\n {\r\n Widget widget = rows.Layout.Add(new Widget(rows));\r\n widget.Layout = Layout.Row();\r\n widget.Layout.Spacing = 4f;\r\n widget.Layout.Add(new ItemBody(widget, item), 1);\r\n Widget widget2 = FixButton(widget, item);\r\n if (widget2 != null)\r\n {\r\n widget.Layout.Add(widget2);\r\n }\r\n }\r\n }\r\n }\r\n\r\n private Widget FixButton(Widget parent, ReadinessItem item)\r\n {\r\n if (string.IsNullOrWhiteSpace(item.Fix))\r\n {\r\n return null;\r\n }\r\n if (string.Equals(item.Fix, \"Update\", StringComparison.Ordinal))\r\n {\r\n return new Button(\"Update\", parent)\r\n {\r\n ToolTip = \"Install the current copy of the collaboration library from Asset Party.\",\r\n Clicked = () =>\r\n {\r\n _ = UpdateLibrary();\r\n }\r\n };\r\n }\r\n return new Button(item.Fix, parent)\r\n {\r\n Enabled = false,\r\n ToolTip = \"This build has no action for this item.\"\r\n };\r\n }\r\n\r\n private async Task UpdateLibrary()\r\n {\r\n try\r\n {\r\n CollaborationConfig config = CollaborationSession.Current.Config;\r\n string ident = FullIdent(config);\r\n if (ident == null)\r\n {\r\n SetNotice(\"The collaboration library ident is not configured in ProjectSettings/collaboration.json.\");\r\n return;\r\n }\r\n SetNotice(\"Requesting the current copy of \" + ident + \" from Asset Party\u2026\");\r\n Package package = await Package.FetchAsync(ident, partial: false);\r\n if (package?.Revision == null)\r\n {\r\n SetNotice(\"The package \" + ident + \" could not be fetched from Asset Party.\");\r\n return;\r\n }\r\n await LibrarySystem.Install(ident, package.Revision.VersionId);\r\n SetNotice(\"The collaboration library is being installed. The checklist reports the installed version when it finishes.\");\r\n }\r\n catch (Exception ex)\r\n {\r\n Exception e = ex;\r\n SetNotice(CollaborationDiagnostics.Bounded(e.Message));\r\n }\r\n }\r\n\r\n private static string FullIdent(CollaborationConfig config)\r\n {\r\n string text = config?.LibraryIdent;\r\n if (string.IsNullOrWhiteSpace(text))\r\n {\r\n // A project with no settings file yet still knows which package to fetch: the published one under its own organization.\r\n return LibraryInfo.DefaultLibraryIdent(config?.Org);\r\n }\r\n if (text.Contains('.'))\r\n {\r\n return text;\r\n }\r\n string org = config.Org;\r\n return string.IsNullOrWhiteSpace(org) ? text : (org + \".\" + text);\r\n }\r\n\r\n private void SetNotice(string text)\r\n {\r\n notice.Text = text ?? string.Empty;\r\n notice.Visible = notice.Text.Length > 0;\r\n }\r\n}\r\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/RelaySetup.cs",
"FileName": "RelaySetup.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Net.Http;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing Editor;\nusing Sandbox;\nusing Sandbox.Internal;\n\nnamespace TeamCreate;\n\n/// <summary>Sends Cloudflare requests through the engine's HTTP client, which is the only network access an editor script has.</summary>\npublic sealed class SandboxCloudflareTransport : ICloudflareTransport\n{\n private const int MaxBodyChars = 262144;\n\n public async Task<CloudflareResponse> SendAsync(CloudflareRequest request, CancellationToken cancellationToken)\n {\n HttpResponseMessage response = null;\n try\n {\n // Content-Type travels on the content itself; the engine refuses to set some headers by name, and everything the deploy needs is allowed.\n response = await Http.RequestAsync(request.Url, request.Method, request.Content, request.Headers.Count == 0 ? null : request.Headers, cancellationToken);\n }\n catch (OperationCanceledException)\n {\n throw;\n }\n catch (Exception ex)\n {\n return new CloudflareResponse(null, null, ex.GetType().Name + \": \" + ex.Message);\n }\n using (response)\n {\n int status = (int)response.StatusCode;\n string body = null;\n try\n {\n string text = await response.Content.ReadAsStringAsync(cancellationToken);\n body = text != null && text.Length <= MaxBodyChars ? text : null;\n }\n catch (OperationCanceledException)\n {\n throw;\n }\n catch (Exception)\n {\n body = null;\n }\n return new CloudflareResponse(status, body);\n }\n }\n}\n\n/// <summary>\n/// The Relay card's machinery. Two buttons drive it: one opens Cloudflare's token page, the other reads the token from the clipboard, deploys the relay to the\n/// person's own free account, and remembers the address on this computer. The token is held only for the length of the deploy: it is never written to disk,\n/// never logged, and the clipboard is cleared afterwards if it still holds it. All state is polled from the frame, never from a thread of its own.\n/// </summary>\npublic static class RelaySetup\n{\n private static Task<DeployResult> task;\n\n private static CancellationTokenSource cancel;\n\n private static volatile string progress;\n\n private static string tokenInUse;\n\n private static bool localLoaded;\n // The internet relay preference (Core/LocalTunnelSetting.cs): read once, written when the Relay card's checkbox changes. It sits next to the local\n // relay file in .collaboration/, which Git ignores and the manifest skips, because it is one person's choice and not the project's.\n private static bool tunnelSettingLoaded;\n\n private static bool tunnelEnabled = true;\n\n private static string TunnelSettingPath => Path.Combine(SceneAdapter.Root, \".collaboration\", LocalTunnelSetting.FileName);\n\n /// <summary>\n /// Whether hosting opens the internet relay. On unless this computer turned it off; a file that cannot be read or says something unexpected keeps\n /// it off (and says so in the log), because a public address must never be opened by accident.\n /// </summary>\n public static bool InternetRelayEnabled\n {\n get\n {\n if (!tunnelSettingLoaded)\n {\n tunnelSettingLoaded = true;\n string problem;\n try\n {\n string path = TunnelSettingPath;\n tunnelEnabled = LocalTunnelSetting.Parse(File.Exists(path) ? File.ReadAllText(path) : null, out problem);\n }\n catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)\n {\n tunnelEnabled = false;\n problem = \"The internet relay preference in .collaboration/\" + LocalTunnelSetting.FileName + \" could not be read (\" + ex.Message + \"), so the internet relay stays off.\";\n }\n if (problem != null)\n {\n GlobalGameNamespace.Log.Error(\"Collaboration relay: \" + CollaborationDiagnostics.Bounded(problem));\n }\n }\n return tunnelEnabled;\n }\n set\n {\n string path = TunnelSettingPath;\n Directory.CreateDirectory(Path.GetDirectoryName(path));\n File.WriteAllText(path, LocalTunnelSetting.Format(value));\n tunnelEnabled = value;\n tunnelSettingLoaded = true;\n }\n }\n\n private static string localRelay;\n\n private static string localProblem;\n\n public static DeployResult LastResult { get; private set; }\n\n public static bool Working => task != null && !task.IsCompleted;\n\n public static string Progress => progress;\n\n /// <summary>The relay this computer set up, or null. Read once and kept, because the dock asks every frame.</summary>\n public static string LocalRelay\n {\n get\n {\n EnsureLocalLoaded();\n return localRelay;\n }\n }\n\n /// <summary>Why the local file could not be used, or null.</summary>\n public static string LocalProblem\n {\n get\n {\n EnsureLocalLoaded();\n return localProblem;\n }\n }\n\n private static string LocalFilePath => Path.Combine(SceneAdapter.Root, \".collaboration\", LocalRelayFile.FileName);\n\n private static void EnsureLocalLoaded()\n {\n if (localLoaded)\n {\n return;\n }\n localLoaded = true;\n localRelay = null;\n localProblem = null;\n try\n {\n string path = LocalFilePath;\n if (File.Exists(path))\n {\n localRelay = LocalRelayFile.Parse(File.ReadAllText(path), out localProblem);\n }\n }\n catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)\n {\n localProblem = \"The relay address file could not be read: \" + ex.Message;\n }\n if (localProblem != null)\n {\n GlobalGameNamespace.Log.Error(\"Collaboration relay: \" + CollaborationDiagnostics.Bounded(localProblem));\n }\n }\n\n public static RelaySetupView Describe(string projectRelay)\n {\n RelaySetupView view = RelaySetupRules.Describe(new RelaySetupInput(projectRelay, LocalRelay, RelayDefaults.DefaultUrl, Working, progress, LastResult, InternetRelayEnabled));\n if (localProblem != null && view.Mode == RelaySetupMode.Missing)\n {\n return view with { Text = localProblem + \" \" + view.Text, IsProblem = true };\n }\n return view;\n }\n\n /// <summary>Opens the Cloudflare page that creates the token, with the two permissions the relay needs already chosen.</summary>\n public static void OpenTokenPage()\n {\n // The engine's own tools open web addresses through this call (Project.EditUrl, Package.Url); MenuUtility.OpenUrl belongs to the menu assembly, which editor scripts cannot reference.\n EditorUtility.OpenFolder(CloudflareApi.TokenPageUrl());\n }\n\n /// <summary>Reads the token from the clipboard and deploys. The clipboard is checked before anything is sent anywhere.</summary>\n public static void Start()\n {\n if (Working)\n {\n return;\n }\n string clipboard = null;\n try\n {\n clipboard = EditorUtility.Clipboard.Paste();\n }\n catch (Exception)\n {\n clipboard = null;\n }\n if (!CloudflareApi.TryAcceptToken(clipboard, out string token, out string problem))\n {\n LastResult = new DeployResult(false, null, problem);\n progress = null;\n return;\n }\n LastResult = null;\n tokenInUse = token;\n cancel = new CancellationTokenSource();\n progress = \"Starting\u2026\";\n var deployer = new RelayDeployer(new SandboxCloudflareTransport(), RelayWorkerSource.Text);\n task = deployer.RunAsync(token, text => progress = text, cancel.Token);\n }\n\n public static void Cancel()\n {\n cancel?.Cancel();\n }\n\n [EditorEvent.Frame]\n public static void Pump()\n {\n if (task == null || !task.IsCompleted)\n {\n return;\n }\n Task<DeployResult> finished = task;\n task = null;\n string token = tokenInUse;\n tokenInUse = null;\n cancel?.Dispose();\n cancel = null;\n progress = null;\n DeployResult result;\n try\n {\n result = finished.IsFaulted || finished.IsCanceled\n ? new DeployResult(false, null, \"Setup stopped unexpectedly: \" + CloudflareApi.Redact(finished.Exception?.GetBaseException().Message, token) + \" Nothing was changed on this computer.\")\n : finished.Result;\n }\n catch (Exception ex)\n {\n result = new DeployResult(false, null, \"Setup stopped unexpectedly: \" + CloudflareApi.Redact(ex.Message, token));\n }\n ClearClipboardIfStillHolds(token);\n if (result.Ok)\n {\n string saveProblem = Save(result.RelayUrl);\n if (saveProblem != null)\n {\n result = new DeployResult(false, null, saveProblem);\n }\n }\n LastResult = result;\n if (!result.Ok && !result.Cancelled)\n {\n GlobalGameNamespace.Log.Error(\"Collaboration relay setup: \" + CollaborationDiagnostics.Bounded(result.Error));\n }\n }\n\n private static string Save(string relayUrl)\n {\n try\n {\n string path = LocalFilePath;\n Directory.CreateDirectory(Path.GetDirectoryName(path));\n string temp = path + \".tmp\";\n File.WriteAllText(temp, LocalRelayFile.Format(relayUrl));\n File.Move(temp, path, true);\n localLoaded = false;\n return null;\n }\n catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)\n {\n return \"The relay is running at \" + RelaySetupRules.HostOf(relayUrl) + \", but its address could not be saved on this computer (\" + ex.Message + \"). Put it in .collaboration/\" + LocalRelayFile.FileName + \" yourself, or set RelayUrl in ProjectSettings/collaboration.json.\";\n }\n }\n\n /// <summary>The token has done its job; if it is still the thing on the clipboard, take it off so it is not pasted somewhere by accident.</summary>\n private static void ClearClipboardIfStillHolds(string token)\n {\n if (string.IsNullOrEmpty(token))\n {\n return;\n }\n try\n {\n string now = EditorUtility.Clipboard.Paste();\n if (now != null && string.Equals(now.Trim(), token, StringComparison.Ordinal))\n {\n EditorUtility.Clipboard.Copy(string.Empty);\n }\n }\n catch (Exception)\n {\n // Not being able to clear the clipboard changes nothing about the deploy.\n }\n }\n}\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/CloudflaredTool.cs",
"FileName": "CloudflaredTool.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Concurrent;\nusing System.Diagnostics;\nusing System.IO;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing Editor;\nusing Sandbox;\n\nnamespace TeamCreate;\n\n/// <summary>\n/// An editor-only Cloudflare Quick Tunnel launcher. Begin only while hosting and Dispose when hosting ends: the tool requires no administrator rights,\n/// downloads from one pinned URL, and runs the binary only after checking its pinned SHA-256. The tunnel carries only the relay's encrypted frames;\n/// its public address is unguessable but NOT a secret, so the relay must apply its own authentication and resource limits.\n///\n/// Each editor-frame Pump polls tasks; no explicit thread is started and no task is synchronously waited on. Process asynchronous output events do only\n/// ConcurrentQueue.Enqueue on the runtime's I/O callbacks, never editor work, so even a partial line cannot block the editor. Pump drains the queue.\n/// (Polling ReadLineAsync was the alternative; it needs one outstanding read per stream and a cancelled read cannot be abandoned cleanly, whereas the\n/// event pattern owns the pipe readers itself and stops them when the process is disposed.)\n///\n/// Running means cloudflared printed its address, NOT that the name resolves yet. Measured in the smoke test: a lookup 0.3 s after the banner got\n/// NXDOMAIN, and trycloudflare.com's negative-caching TTL is 60 s, so a friend who dials in the first seconds can be locked out for a minute by their\n/// own resolver. Whoever publishes the invite should wait for cloudflared's \"Registered tunnel connection\" line or a successful self-check first.\n/// </summary>\npublic sealed class CloudflaredTool : IDisposable\n{\n public enum Phase { Idle, Downloading, Verifying, Starting, WaitingForAddress, Running, Failed }\n\n private const int MaxBytes = 120 * 1024 * 1024;\n private static readonly TimeSpan DownloadTimeout = TimeSpan.FromMinutes( 5 );\n private static readonly TimeSpan AddressTimeout = TimeSpan.FromSeconds( 25 );\n\n // Live instances are released at editor hotload and process shutdown as well as by their host's Dispose call.\n private static readonly object LiveLock = new();\n private static readonly System.Collections.Generic.HashSet<CloudflaredTool> Live = new();\n\n private readonly ConcurrentQueue<string> output = new();\n private CancellationTokenSource downloadCancel;\n private Task downloadTask;\n private Task<byte[]> readTask;\n private Process process;\n private FileStream executableLock;\n private DateTime startedAt;\n private long received;\n private long shownMegabytes;\n private string toolPath;\n private string partPath;\n private int port;\n private bool disposed;\n\n static CloudflaredTool() => AppDomain.CurrentDomain.ProcessExit += ( _, _ ) => CloseAll();\n\n [EditorEvent.Hotload]\n private static void OnHotload() => CloseAll();\n\n private static void CloseAll()\n {\n CloudflaredTool[] items;\n lock ( LiveLock ) items = new System.Collections.Generic.List<CloudflaredTool>( Live ).ToArray();\n foreach ( CloudflaredTool item in items ) item.Dispose();\n }\n\n public Phase State { get; private set; } = Phase.Idle;\n public string Status { get; private set; } = \"The tunnel is not running.\";\n public string RelayUrl { get; private set; }\n /// <summary>\n /// True once cloudflared reported a registered edge connection. The address is printed before that, and asking for it then gets \"no such name\"\n /// cached by resolvers for a minute, so HostingPlan probes only after this has been seen.\n /// </summary>\n public bool RegisteredConnectionSeen { get; private set; }\n public string Error { get; private set; }\n\n /// <summary>Idempotent while starting or running. A failed attempt can be retried after Dispose by constructing a new tool.</summary>\n public void Begin( int localPort )\n {\n if ( disposed || State != Phase.Idle ) return;\n try\n {\n // Reject invalid ports before downloading anything.\n _ = CloudflaredRules.BuildArguments( localPort );\n port = localPort;\n toolPath = Path.Combine( SceneAdapter.Root, \".collaboration\", \"tools\", \"cloudflared.exe\" );\n partPath = toolPath + \".part\";\n Directory.CreateDirectory( Path.GetDirectoryName( toolPath ) );\n lock ( LiveLock ) Live.Add( this );\n if ( File.Exists( toolPath ) ) BeginVerify();\n else BeginDownload();\n }\n catch ( Exception ) { Fail( \"The tunnel tool could not be prepared. Check that the project folder is writable and retry hosting.\" ); }\n }\n\n private void BeginDownload()\n {\n State = Phase.Downloading;\n Interlocked.Exchange( ref received, 0 );\n shownMegabytes = 0;\n Status = DownloadStatus( 0 );\n downloadCancel = new CancellationTokenSource( DownloadTimeout );\n downloadTask = DownloadAsync( downloadCancel.Token );\n }\n\n // RequestStreamAsync completes on headers, unlike RequestBytesAsync. Read chunks with cancellation and cap the body BEFORE allocation or write.\n // Progress is the count of bytes actually received over the pinned size GitHub lists; that size is a denominator for the dock only, never a\n // verification substitute. The counter is written with Interlocked because the continuation's thread is the engine's choice, not ours.\n private async Task DownloadAsync( CancellationToken token )\n {\n using Stream response = await Http.RequestStreamAsync( CloudflaredPin.Url, \"GET\", null, null, token );\n using FileStream file = new( partPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true );\n byte[] chunk = new byte[81920];\n int total = 0;\n while ( true )\n {\n int count = await response.ReadAsync( chunk.AsMemory(), token );\n if ( count == 0 ) break;\n if ( count > MaxBytes - total ) throw new InvalidDataException( \"The tunnel tool download exceeded 120 MB.\" );\n await file.WriteAsync( chunk.AsMemory( 0, count ), token );\n total += count;\n Interlocked.Exchange( ref received, total );\n }\n }\n\n // Megabytes received so far, against the listed size while the download is within it. Past it, the denominator is dropped rather than shown wrong.\n private static string DownloadStatus( long bytes )\n {\n long mb = bytes / ( 1024 * 1024 );\n long of = CloudflaredPin.SizeBytes / ( 1024 * 1024 );\n return bytes <= CloudflaredPin.SizeBytes\n ? $\"Downloading the tunnel tool ({mb} of {of} MB).\"\n : $\"Downloading the tunnel tool ({mb} MB).\";\n }\n\n private void BeginVerify()\n {\n State = Phase.Verifying;\n Status = \"Verifying the tunnel tool.\";\n // Re-read on EVERY Begin. Keep a read-only sharing lock from opening through launch so a swapped binary cannot run between check and start.\n // A .part download is first checked before replacement and checked again after replacement under this lock.\n if ( File.Exists( toolPath ) )\n {\n executableLock = new FileStream( toolPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true );\n if ( executableLock.Length == 0 || executableLock.Length > MaxBytes )\n {\n executableLock.Dispose();\n executableLock = null;\n File.Delete( toolPath );\n BeginDownload();\n return;\n }\n readTask = ReadBoundedAsync( executableLock );\n }\n else\n {\n // A leftover .part is untrusted too, even though our downloader bounds its own writes.\n long length = new FileInfo( partPath ).Length;\n if ( length == 0 || length > MaxBytes )\n {\n File.Delete( partPath );\n Fail( \"The tunnel tool download had an invalid size. Retry hosting to download it again.\" );\n return;\n }\n readTask = File.ReadAllBytesAsync( partPath );\n }\n }\n\n private static async Task<byte[]> ReadBoundedAsync( FileStream stream )\n {\n var bytes = new byte[(int)stream.Length];\n await stream.ReadExactlyAsync( bytes );\n return bytes;\n }\n\n public void Pump()\n {\n if ( disposed || State is Phase.Idle or Phase.Failed ) return;\n try\n {\n if ( State == Phase.Downloading && !downloadTask.IsCompleted )\n {\n // Rebuild the sentence only when the whole-megabyte count moves, so a waiting frame formats nothing.\n long bytes = Interlocked.Read( ref received );\n if ( bytes / ( 1024 * 1024 ) != shownMegabytes )\n {\n shownMegabytes = bytes / ( 1024 * 1024 );\n Status = DownloadStatus( bytes );\n }\n }\n if ( State == Phase.Downloading && downloadTask.IsCompleted )\n {\n downloadTask.GetAwaiter().GetResult(); // completed task; never waits on the editor frame\n downloadCancel.Dispose();\n downloadCancel = null;\n downloadTask = null;\n BeginVerify();\n }\n if ( State == Phase.Verifying && readTask.IsCompleted )\n {\n byte[] bytes = readTask.GetAwaiter().GetResult(); // completed task\n readTask = null;\n string problem = CloudflaredRules.GetDownloadError( bytes, CloudflaredPin.Sha256 );\n if ( problem != null )\n {\n if ( executableLock != null )\n {\n executableLock.Dispose();\n executableLock = null;\n File.Delete( toolPath );\n BeginDownload();\n }\n else { File.Delete( partPath ); Fail( problem + \" Retry hosting to download it again.\" ); }\n return;\n }\n if ( executableLock == null )\n {\n File.Move( partPath, toolPath, true );\n BeginVerify();\n return;\n }\n State = Phase.Starting;\n }\n if ( State == Phase.Starting ) Start();\n if ( State is Phase.WaitingForAddress or Phase.Running )\n {\n while ( output.TryDequeue( out string line ) )\n {\n if ( CloudflaredRules.IsRegisteredLine( line ) ) RegisteredConnectionSeen = true;\n if ( State != Phase.WaitingForAddress ) continue;\n string address = CloudflaredRules.ParseTunnelAddress( line );\n if ( address == null ) continue;\n RelayUrl = CloudflaredRules.ToRelayUrl( address );\n State = Phase.Running;\n Status = \"The tunnel is running.\";\n }\n if ( process.HasExited ) Fail( \"The tunnel stopped unexpectedly. Retry hosting to restart it.\" );\n else if ( State == Phase.WaitingForAddress && DateTime.UtcNow - startedAt > AddressTimeout )\n Fail( \"The tunnel did not provide a public address within 25 seconds. Check the network connection and retry hosting.\" );\n }\n }\n catch ( OperationCanceledException ) { Fail( \"The tunnel tool download timed out after five minutes. Check the network connection and retry hosting.\" ); }\n catch ( InvalidDataException ) { Fail( \"The tunnel tool download exceeded 120 MB and was refused. Retry hosting after checking the network connection.\" ); }\n catch ( Exception ) { Fail( \"The tunnel could not start. Check the network and project folder permissions, then retry hosting.\" ); }\n }\n\n private void Start()\n {\n process = new Process\n {\n StartInfo = new ProcessStartInfo\n {\n FileName = toolPath,\n Arguments = CloudflaredRules.BuildEmbeddedArguments( port ),\n UseShellExecute = false,\n CreateNoWindow = true,\n RedirectStandardOutput = true,\n RedirectStandardError = true\n }\n };\n // Avoid inheriting editor/account tokens in the environment. Windows needs SystemRoot for its own runtime services.\n process.StartInfo.Environment.Clear();\n string systemRoot = Environment.GetEnvironmentVariable( \"SystemRoot\" );\n if ( !string.IsNullOrEmpty( systemRoot ) ) process.StartInfo.Environment[\"SystemRoot\"] = systemRoot;\n // Never echo cloudflared's output into the dock: it may include machine-specific paths or diagnostic material.\n process.OutputDataReceived += ( _, e ) => { if ( e.Data != null ) output.Enqueue( e.Data ); };\n process.ErrorDataReceived += ( _, e ) => { if ( e.Data != null ) output.Enqueue( e.Data ); };\n if ( !process.Start() ) throw new InvalidOperationException();\n process.BeginOutputReadLine();\n process.BeginErrorReadLine();\n startedAt = DateTime.UtcNow;\n State = Phase.WaitingForAddress;\n Status = \"Waiting for the tunnel address.\";\n }\n\n private void Fail( string message )\n {\n Error = message;\n Status = message;\n State = Phase.Failed;\n RelayUrl = null;\n Stop();\n }\n\n private void Stop()\n {\n downloadCancel?.Cancel();\n downloadCancel?.Dispose();\n downloadCancel = null;\n if ( process != null )\n {\n try { if ( !process.HasExited ) process.Kill( true ); } catch ( InvalidOperationException ) { } catch ( System.ComponentModel.Win32Exception ) { }\n process.Dispose();\n process = null;\n }\n executableLock?.Dispose();\n executableLock = null;\n // A canceled async download may still own .part until its cancellation completes. Never reuse it; BeginDownload truncates it on the next run.\n try { if ( partPath != null ) File.Delete( partPath ); } catch ( IOException ) { } catch ( UnauthorizedAccessException ) { }\n }\n\n public void Dispose()\n {\n if ( disposed ) return;\n disposed = true;\n Stop();\n lock ( LiveLock ) Live.Remove( this );\n }\n}\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Connect/StunClient.cs",
"FileName": "StunClient.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Net;\r\nusing System.Net.Sockets;\r\nusing System.Security.Cryptography;\r\nusing System.Threading.Tasks;\r\n\r\nnamespace TeamCreate;\r\n\r\npublic sealed class StunClient : IDisposable\r\n{\r\n private const uint MagicCookie = 554869826u;\r\n\r\n private const int HeaderBytes = 20;\r\n\r\n private const ushort BindingRequest = 1;\r\n\r\n private const ushort BindingSuccess = 257;\r\n\r\n private const ushort MappedAddress = 1;\r\n\r\n private const ushort XorMappedAddress = 32;\r\n\r\n private static readonly (string Host, int Port)[] Servers = new (string, int)[2]\r\n {\r\n (\"stun.l.google.com\", 19302),\r\n (\"stun1.l.google.com\", 19302)\r\n };\r\n\r\n private static readonly TimeSpan OverallDeadline = TimeSpan.FromSeconds(2L);\r\n\r\n private static readonly TimeSpan ServerBudget = TimeSpan.FromSeconds(1L);\r\n\r\n private readonly object gate = new object();\r\n\r\n private int index;\r\n\r\n private bool started;\r\n\r\n private bool disposed;\r\n\r\n private DateTime startedAt;\r\n\r\n private DateTime sentAt;\r\n\r\n private Task<IPAddress[]> resolution;\r\n\r\n private UdpClient socket;\r\n\r\n private byte[] transactionId;\r\n\r\n public string PublicEndPoint { get; private set; }\r\n\r\n public string Failure { get; private set; }\r\n\r\n public int ServerCount => Servers.Length;\r\n\r\n public int TriedCount { get; private set; }\r\n\r\n public void Poll()\r\n {\r\n if (PublicEndPoint != null || Failure != null || disposed)\r\n {\r\n return;\r\n }\r\n try\r\n {\r\n Step();\r\n }\r\n catch (Exception error)\r\n {\r\n Fail(CollaborationDiagnostics.DescribeExternalFailure(\"Public address lookup: \", error));\r\n }\r\n }\r\n\r\n private void Step()\r\n {\r\n DateTime utcNow = DateTime.UtcNow;\r\n if (!started)\r\n {\r\n started = true;\r\n startedAt = utcNow;\r\n }\r\n if (utcNow - startedAt >= OverallDeadline || index >= Servers.Length)\r\n {\r\n Exhaust();\r\n }\r\n else if (resolution == null)\r\n {\r\n resolution = Dns.GetHostAddressesAsync(Servers[index].Host);\r\n }\r\n else\r\n {\r\n if (!resolution.IsCompleted)\r\n {\r\n return;\r\n }\r\n if (socket == null)\r\n {\r\n if (!Begin(utcNow))\r\n {\r\n Advance();\r\n }\r\n }\r\n else if (!Receive() && utcNow - sentAt >= ServerBudget)\r\n {\r\n Advance();\r\n }\r\n }\r\n }\r\n\r\n private bool Begin(DateTime now)\r\n {\r\n IPAddress iPAddress = null;\r\n try\r\n {\r\n IPAddress[] result = resolution.GetAwaiter().GetResult();\r\n foreach (IPAddress iPAddress2 in result)\r\n {\r\n if (iPAddress2.AddressFamily == AddressFamily.InterNetwork)\r\n {\r\n iPAddress = iPAddress2;\r\n break;\r\n }\r\n }\r\n }\r\n catch (Exception)\r\n {\r\n iPAddress = null;\r\n }\r\n resolution = null;\r\n if (iPAddress == null)\r\n {\r\n return false;\r\n }\r\n byte[] array = BuildBindingRequest(out var array2);\r\n UdpClient udpClient = new UdpClient();\r\n udpClient.Connect(new IPEndPoint(iPAddress, Servers[index].Port));\r\n udpClient.Send(array, array.Length);\r\n transactionId = array2;\r\n socket = udpClient;\r\n sentAt = now;\r\n TriedCount++;\r\n return true;\r\n }\r\n\r\n private bool Receive()\r\n {\r\n try\r\n {\r\n if (socket == null || !socket.Client.Poll(0, SelectMode.SelectRead) || socket.Available == 0)\r\n {\r\n return false;\r\n }\r\n IPEndPoint remoteEP = null;\r\n byte[] array = socket.Receive(ref remoteEP);\r\n if (!HasTransactionId(array))\r\n {\r\n return false;\r\n }\r\n if (!TryReadMappedAddress(array, out var address, out var _))\r\n {\r\n return false;\r\n }\r\n PublicEndPoint = address;\r\n return true;\r\n }\r\n catch (SocketException)\r\n {\r\n return false;\r\n }\r\n }\r\n\r\n private bool HasTransactionId(byte[] bytes)\r\n {\r\n if (transactionId == null || bytes == null || bytes.Length < 20)\r\n {\r\n return false;\r\n }\r\n for (int i = 0; i < 12; i++)\r\n {\r\n if (bytes[8 + i] != transactionId[i])\r\n {\r\n return false;\r\n }\r\n }\r\n return true;\r\n }\r\n\r\n private void Advance()\r\n {\r\n try\r\n {\r\n socket?.Close();\r\n }\r\n catch\r\n {\r\n }\r\n socket = null;\r\n resolution = null;\r\n transactionId = null;\r\n index++;\r\n if (index >= Servers.Length)\r\n {\r\n Exhaust();\r\n }\r\n }\r\n\r\n private void Exhaust()\r\n {\r\n if (PublicEndPoint == null)\r\n {\r\n Fail((TriedCount == 0) ? \"The public address could not be determined: the STUN servers could not be resolved on this network.\" : \"The public address could not be determined: no STUN server answered within two seconds (UDP may be blocked).\");\r\n }\r\n }\r\n\r\n private void Fail(string message)\r\n {\r\n lock (gate)\r\n {\r\n if (Failure == null)\r\n {\r\n string text = (Failure = CollaborationDiagnostics.Bounded(message));\r\n }\r\n }\r\n try\r\n {\r\n socket?.Close();\r\n }\r\n catch\r\n {\r\n }\r\n socket = null;\r\n }\r\n\r\n internal static byte[] BuildBindingRequest(out byte[] transactionId)\r\n {\r\n transactionId = RandomNumberGenerator.GetBytes(12);\r\n byte[] array = new byte[20]\r\n {\r\n 0, 1, 0, 0, 0, 0, 0, 0, 0, 0,\r\n 0, 0, 0, 0, 0, 0, 0, 0, 0, 0\r\n };\r\n uint num = 554869826u;\r\n array[4] = (byte)(num >> 24);\r\n array[5] = (byte)(num >> 16);\r\n array[6] = (byte)(num >> 8);\r\n array[7] = (byte)num;\r\n Array.Copy(transactionId, 0, array, 8, 12);\r\n return array;\r\n }\r\n\r\n internal static bool TryReadMappedAddress(byte[] response, out string address, out string error)\r\n {\r\n address = null;\r\n error = null;\r\n if (response == null || response.Length < 20)\r\n {\r\n error = \"The datagram is shorter than a STUN header.\";\r\n return false;\r\n }\r\n if (ReadUInt16(response, 0) != 257)\r\n {\r\n error = \"The datagram is not a STUN Binding Success Response.\";\r\n return false;\r\n }\r\n if (ReadUInt32(response, 4) != 554869826)\r\n {\r\n error = \"The datagram does not carry the STUN magic cookie.\";\r\n return false;\r\n }\r\n ushort num = ReadUInt16(response, 2);\r\n if (num != response.Length - 20)\r\n {\r\n error = \"The STUN response declares a length its datagram does not have.\";\r\n return false;\r\n }\r\n int i = 20;\r\n ushort num4;\r\n for (int num2 = 20 + num; i + 4 <= num2; i += 4 + ((num4 + 3) & -4))\r\n {\r\n ushort num3 = ReadUInt16(response, i);\r\n num4 = ReadUInt16(response, i + 2);\r\n if (i + 4 + num4 > num2)\r\n {\r\n error = \"The STUN response carries a truncated attribute.\";\r\n return false;\r\n }\r\n bool flag = ((num3 == 1 || num3 == 32) ? true : false);\r\n if (flag && num4 >= 8 && response[i + 5] == 1)\r\n {\r\n byte[] array = new byte[4];\r\n Array.Copy(response, i + 8, array, 0, 4);\r\n if (num3 == 32)\r\n {\r\n uint num5 = 554869826u;\r\n array[0] ^= (byte)(num5 >> 24);\r\n array[1] ^= (byte)(num5 >> 16);\r\n array[2] ^= (byte)(num5 >> 8);\r\n array[3] ^= (byte)num5;\r\n }\r\n address = array[0] + \".\" + array[1] + \".\" + array[2] + \".\" + array[3];\r\n return true;\r\n }\r\n }\r\n error = \"The STUN response carries no IPv4 mapped address.\";\r\n return false;\r\n }\r\n\r\n private static ushort ReadUInt16(byte[] bytes, int offset)\r\n {\r\n return (ushort)((bytes[offset] << 8) | bytes[offset + 1]);\r\n }\r\n\r\n private static uint ReadUInt32(byte[] bytes, int offset)\r\n {\r\n return (uint)((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]);\r\n }\r\n\r\n public void Dispose()\r\n {\r\n if (!disposed)\r\n {\r\n disposed = true;\r\n try\r\n {\r\n socket?.Close();\r\n }\r\n catch\r\n {\r\n }\r\n socket = null;\r\n }\r\n }\r\n}\r\n"
},
{
"Ident": "cyn.sbox_editor_collab",
"Path": "Editor/Core/MetaFileRules.cs",
"FileName": "MetaFileRules.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 410152,
"IsPrivate": false,
"Code": "using System;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.Json.Nodes;\n\nnamespace TeamCreate;\n\n/// <summary>\n/// What a project file contributes to the baseline and the manifest. Everything is its own bytes except the engine's <c>.meta</c> files, which carry\n/// bookkeeping the engine rewrites by itself: it sets <c>sceneCompileDirty</c> the moment a scene is edited. That flag says nothing about the project, but\n/// hashed as raw bytes it made a collaborator's first edit change the baseline, so Save refused with \"Source/assets changed during collaboration\" and two\n/// machines could disagree about whether they had the same project. It is left out; every other field of a .meta still counts.\n/// </summary>\npublic static class MetaFileRules\n{\n private static readonly string[] IgnoredFields = { \"sceneCompileDirty\" };\n\n /// <summary>The bytes to hash for a file. Anything that is not a readable JSON-object .meta file is returned exactly as given.</summary>\n public static byte[] Canonical(string path, byte[] content)\n {\n if (content == null || string.IsNullOrEmpty(path) || !path.EndsWith(\".meta\", StringComparison.OrdinalIgnoreCase))\n {\n return content;\n }\n try\n {\n ReadOnlySpan<byte> body = content;\n if (body.Length >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF)\n {\n body = body.Slice(3);\n }\n if (!(JsonNode.Parse(body) is JsonObject document))\n {\n return content;\n }\n foreach (string field in IgnoredFields)\n {\n document.Remove(field);\n }\n // Sorted by field name so the order the engine happened to write them in does not matter, and compact so whitespace does not either.\n SortedObject(document, out JsonObject sorted);\n return Encoding.UTF8.GetBytes(sorted.ToJsonString(new JsonSerializerOptions { WriteIndented = false }));\n }\n catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException || ex is ArgumentException)\n {\n return content;\n }\n }\n\n private static void SortedObject(JsonObject source, out JsonObject result)\n {\n result = new JsonObject();\n foreach (var pair in System.Linq.Enumerable.OrderBy(source, (System.Collections.Generic.KeyValuePair<string, JsonNode> x) => x.Key, StringComparer.Ordinal))\n {\n result[pair.Key] = pair.Value == null ? null : JsonNode.Parse(pair.Value.ToJsonString());\n }\n }\n}\n"
}
]
}