{"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/// \u003Csummary\u003E\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\u0027s irregularity or the newest sample is not there yet and the picture stalls. Longer than that only adds lag.\n/// \u003C/summary\u003E\n/// \u003Cremarks\u003E\n/// The measure of irregularity is the gap between consecutive packet arrivals over the last \u003Csee cref=\u0022Window\u0022/\u003E packets. The target is\n/// \u003Cc\u003Ep95(gap) \u002B 0.5 * mean(gap)\u003C/c\u003E: 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/// \u003Csee cref=\u0022FloorMs\u0022/\u003E..\u003Csee cref=\u0022CeilingMs\u0022/\u003E. 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 \u003Csee cref=\u0022MaxFallMsPerSecond\u0022/\u003E 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 \u003Csee cref=\u0022StartMs\u0022/\u003E. 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/// \u003C/remarks\u003E\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    /// \u003Csummary\u003EThe delay used until the first gap has been observed, and the value the delay falls from afterwards.\u003C/summary\u003E\n    public const double StartMs = 80;\n\n    /// \u003Csummary\u003EHow many of the most recent arrival gaps take part: about one second at 30 packets a second.\u003C/summary\u003E\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    /// \u003Csummary\u003E\n    /// Records the time between two consecutive packet arrivals, read at local time \u003Cparamref name=\u0022nowMs\u0022/\u003E. A negative or non-finite gap\n    /// is ignored (a clock that stepped backwards, or a bad reading, says nothing about the network).\n    /// \u003C/summary\u003E\n    public void Observe( double arrivalGapMs, double nowMs )\n    {\n        if ( !double.IsFinite( arrivalGapMs ) || arrivalGapMs \u003C 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 \u002B 1) % Window;\n        if ( count \u003C Window )\n        {\n            count\u002B\u002B;\n        }\n        targetMs = ComputeTarget();\n        Follow( nowMs );\n    }\n\n    /// \u003Csummary\u003EThe delay to render behind live at local time \u003Cparamref name=\u0022nowMs\u0022/\u003E. Reading it more or less often does not change the result.\u003C/summary\u003E\n    public double DelayMs( double nowMs )\n    {\n        Follow( nowMs );\n        return currentMs;\n    }\n\n    /// \u003Csummary\u003E\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    /// \u003C/summary\u003E\n    private void Follow( double nowMs )\n    {\n        double elapsedMs = 0;\n        if ( double.IsFinite( nowMs ) )\n        {\n            if ( hasLast \u0026\u0026 nowMs \u003E lastMs )\n            {\n                elapsedMs = nowMs - lastMs;\n            }\n            if ( !hasLast || nowMs \u003E lastMs )\n            {\n                lastMs = nowMs;\n                hasLast = true;\n            }\n        }\n        if ( count == 0 )\n        {\n            return;\n        }\n        if ( targetMs \u003E= 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 \u002B 99) / 100;\n        double p95 = sorted[rank - 1];\n        double sum = 0;\n        for ( int i = 0; i \u003C count; i\u002B\u002B )\n        {\n            sum \u002B= gaps[i];\n        }\n        double mean = sum / count;\n        // A sum that overflows is \u002Binfinity and clamps to the ceiling, which is the right answer for such a gap.\n        return Math.Clamp( p95 \u002B 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 = \u0022identity\u0022;\r\n\r\n    public const string FriendGate = \u0022friend\u0022;\r\n\r\n    public const string OrgGate = \u0022org\u0022;\r\n\r\n    public const string LibraryGate = \u0022library\u0022;\r\n\r\n    public const string ManifestGate = \u0022manifest\u0022;\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 \u002B \u0022 \u0022));\r\n        if ((object)facts == null || !facts.IdentityVerified)\r\n        {\r\n            failedGate = \u0022identity\u0022;\r\n            return text2 \u002B \u0022could not be verified by s\u0026box\u0027s auth service.\u0022;\r\n        }\r\n        if (config != null \u0026\u0026 config.RequireSteamFriend \u0026\u0026 !facts.IsFriend \u0026\u0026 !facts.IsHostSelf)\r\n        {\r\n            failedGate = \u0022friend\u0022;\r\n            return text2 \u002B \u0022is not on your Steam friends list. Add them on Steam and reconnect.\u0022;\r\n        }\r\n        if (config != null \u0026\u0026 config.RequireOrgMember \u0026\u0026 (!facts.IsOrgMember || !string.Equals(facts.PeerOrgIdent, config.Org, StringComparison.OrdinalIgnoreCase)))\r\n        {\r\n            failedGate = \u0022org\u0022;\r\n            return text2 \u002B \u0022is not a member of the organization \u0022 \u002B config.Org \u002B \u0022.\u0022;\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 = \u0022library\u0022;\r\n                return text2 \u002B $\u0022runs collaboration library {((text4.Length == 0) ? \u0022an unknown version\u0022 : text4)}; this session requires {version}. Update the library and reconnect.\u0022;\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 = \u0022Project/scene baseline differs (engine, source, assets or metadata). Sync copies in Git before joining.\u0022;\r\n\r\n    public const string EngineRefusalMessage = \u0022This peer runs a different s\u0026box engine build from the host\u0027s. Update s\u0026box to the same version on both computers and reconnect.\u0022;\r\n\r\n    public const string EngineUnknownRefusalMessage = \u0022This peer\u0027s project differs from the host\u0027s and its collaboration library cannot synchronize project files. Update the library and reconnect.\u0022;\r\n\r\n    /// \u003Csummary\u003E\r\n    /// The baseline gate of the hello. When the peer\u0027s files match the host\u0027s (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    /// \u003C/summary\u003E\r\n    public static string BaselineRefusal(string peerManifest, string hostManifest, string peerBaseline, ICollection\u003Cstring\u003E 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 \u0026\u0026 peerBaseline != null \u0026\u0026 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    /// \u003Csummary\u003E\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    /// \u003C/summary\u003E\r\n    public static string EngineFingerprint(IEnumerable\u003Cstring\u003E moduleVersionIds)\r\n    {\r\n        string text = \u0022teamcreate-engine-1\\n\u0022 \u002B string.Join(\u0022\\n\u0022, moduleVersionIds ?? Array.Empty\u003Cstring\u003E());\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) \u0026\u0026 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    /// \u003Csummary\u003ENothing the packet names is missing and nothing is waiting ahead of it: the caller handles it now.\u003C/summary\u003E\n    Process,\n\n    /// \u003Csummary\u003EThe packet waits for geometry; \u003Csee cref=\u0022BlobExchange.TryTakeReady\u0022/\u003E returns it, in order, once it has arrived.\u003C/summary\u003E\n    Held,\n\n    /// \u003Csummary\u003EThe packet was refused (a bound was reached); \u003Csee cref=\u0022BlobExchange.TakeDropped\u0022/\u003E says why.\u003C/summary\u003E\n    Dropped\n}\n\n/// \u003Csummary\u003EThe 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.\u003C/summary\u003E\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 \u003C 0 || Used \u002B bytes \u003E limit ) return false;\n        Used \u002B= bytes;\n        return true;\n    }\n\n    public void Release( long bytes )\n    {\n        Used = Math.Max( 0, Used - bytes );\n    }\n}\n\n/// \u003Csummary\u003E\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 \u003Csee cref=\u0022FileChunkAssembler\u0022/\u003E, 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/// \u003C/summary\u003E\npublic sealed class BlobExchange\n{\n    private sealed class HeldPacket\n    {\n        public Packet Packet;\n\n        public List\u003Cstring\u003E Hashes;\n\n        public DateTime At;\n\n        /// \u003Csummary\u003EThe hashes this packet added to the wanted table, so releasing it removes exactly those.\u003C/summary\u003E\n        public List\u003Cstring\u003E 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\u003Clong\u003E Received = new();\n\n        public long ReceivedBytes;\n    }\n\n    private readonly BlobStore store;\n\n    private readonly Action\u003CPacket\u003E send;\n\n    private readonly Func\u003CDateTime\u003E clock;\n\n    private readonly BlobBudget budget;\n\n    private readonly List\u003CHeldPacket\u003E 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\u003Cstring, int\u003E wanted = new( StringComparer.Ordinal );\n\n    private readonly HashSet\u003Cstring\u003E requested = new( StringComparer.Ordinal );\n\n    private readonly Dictionary\u003Cstring, Assembly\u003E assemblies = new( StringComparer.Ordinal );\n\n    private readonly Queue\u003CSendJob\u003E jobs = new();\n\n    private readonly FileChunkWindow window = new();\n\n    private readonly List\u003C(Packet Packet, string Reason)\u003E dropped = new();\n\n    private readonly List\u003Cstring\u003E notices = new();\n\n    public BlobExchange( BlobStore store, Action\u003CPacket\u003E send, Func\u003CDateTime\u003E 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 =\u003E held.Count;\n\n    /// \u003Csummary\u003EBytes reserved for geometry that has started to arrive on this connection.\u003C/summary\u003E\n    public long PendingBytes { get; private set; }\n\n    /// \u003Csummary\u003EGeometry the other end has asked for and this end has not finished sending.\u003C/summary\u003E\n    public int QueuedBlobs =\u003E jobs.Count;\n\n    /// \u003Csummary\u003EGives back the pending-bytes reservation of everything half-assembled; the connection is gone and nothing will complete.\u003C/summary\u003E\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 ) =\u003E kind is \u0022blob-need\u0022 or \u0022blob-data\u0022 or \u0022blob-ack\u0022;\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 ) =\u003E kind is \u0022snapshot\u0022 or \u0022accepted\u0022 or \u0022edit\u0022 or \u0022subscribe\u0022;\n\n    public static List\u003Cstring\u003E HashesOf( Packet packet )\n    {\n        List\u003Cstring\u003E hashes = new List\u003Cstring\u003E();\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\u003Cstring\u003E hashes = HashesOf( packet );\n        if ( held.Count == 0 \u0026\u0026 hashes.All( store.Has ) ) return BlobOffer.Process;\n        if ( held.Count \u003E= BlobLimits.MaxPendingPackets )\n        {\n            Drop( packet, \u0022More than \u0022 \u002B BlobLimits.MaxPendingPackets \u002B \u0022 scene updates are waiting for mesh geometry that has not arrived; this update was discarded and the scene will be synchronized again.\u0022 );\n            return BlobOffer.Dropped;\n        }\n        HeldPacket entry = new HeldPacket { Packet = packet, Hashes = hashes, At = clock() };\n        foreach ( string hash in hashes.Where( x =\u003E !store.Has( x ) ) )\n        {\n            wanted[hash] = wanted.TryGetValue( hash, out int count ) ? count \u002B 1 : 1;\n            entry.Counted.Add( hash );\n        }\n        held.Add( entry );\n        return BlobOffer.Held;\n    }\n\n    /// \u003Csummary\u003EThe oldest held packet, once everything it names is present. Later packets wait behind it, so edits keep the order the host accepted them in.\u003C/summary\u003E\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\u003C(Packet Packet, string Reason)\u003E TakeDropped()\n    {\n        List\u003C(Packet Packet, string Reason)\u003E result = dropped.ToList();\n        dropped.Clear();\n        return result;\n    }\n\n    public List\u003Cstring\u003E TakeNotices()\n    {\n        List\u003Cstring\u003E result = notices.ToList();\n        notices.Clear();\n        return result;\n    }\n\n    /// \u003Csummary\u003EHandles blob-need, blob-data and blob-ack. Malformed input is ignored or drops the packet it concerns; it never throws.\u003C/summary\u003E\n    public void Handle( Packet packet )\n    {\n        switch ( packet.Kind )\n        {\n        case \u0022blob-need\u0022:\n            Enqueue( packet.Hashes );\n            break;\n        case \u0022blob-ack\u0022:\n            window.Release();\n            break;\n        case \u0022blob-data\u0022:\n            Receive( packet );\n            break;\n        }\n    }\n\n    /// \u003Csummary\u003EPer frame: expires waiting packets, asks for what is missing, sends the next chunks the window allows.\u003C/summary\u003E\n    public void Pump()\n    {\n        Expire();\n        Request();\n        Serve();\n    }\n\n    private void Enqueue( List\u003Cstring\u003E hashes )\n    {\n        if ( hashes == null ) return;\n        foreach ( string hash in hashes.Take( BlobLimits.MaxHashesPerRequest ) )\n        {\n            if ( !BlobStore.ValidHash( hash ) || jobs.Count \u003E= BlobLimits.MaxHashesPerRequest || jobs.Any( x =\u003E x.Hash == hash ) ) continue;\n            jobs.Enqueue( new SendJob { Hash = hash } );\n        }\n    }\n\n    private void Serve()\n    {\n        while ( jobs.Count \u003E 0 )\n        {\n            SendJob job = jobs.Peek();\n            if ( job.Bytes == null \u0026\u0026 !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 = \u0022blob-data\u0022, 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 = \u0022blob-data\u0022, Sha256 = job.Hash, Offset = job.Offset, Total = job.Bytes.Length, Data = data } );\n            job.Offset \u002B= length;\n            if ( job.Offset \u003E= job.Bytes.Length ) jobs.Dequeue();\n        }\n    }\n\n    private void Request()\n    {\n        List\u003Cstring\u003E missing = wanted.Keys.Where( x =\u003E !requested.Contains( x ) \u0026\u0026 !store.Has( x ) ).ToList();\n        for ( int i = 0; i \u003C missing.Count; i \u002B= BlobLimits.MaxHashesPerRequest )\n        {\n            List\u003Cstring\u003E batch = missing.Skip( i ).Take( BlobLimits.MaxHashesPerRequest ).ToList();\n            send( new Packet { Kind = \u0022blob-need\u0022, 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 =\u003E ( now - x.At ).TotalSeconds \u003E= BlobLimits.WaitSeconds ).ToList() )\n        {\n            held.Remove( packet );\n            Unwant( packet );\n            Drop( packet.Packet, \u0022The mesh geometry of a scene update did not arrive within \u0022 \u002B BlobLimits.WaitSeconds \u002B \u0022 seconds; the update was discarded and the scene will be synchronized again.\u0022 );\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, \u0022The sender no longer has the mesh geometry that a scene update names; the update was discarded and the scene will be synchronized again.\u0022 );\n            return;\n        }\n        if ( packet.Total \u003C 0 || packet.Total \u003E BlobLimits.MaxBlobBytes )\n        {\n            Fail( hash, \u0022Mesh geometry larger than the 32 MiB collaboration limit was offered and refused; the update was discarded.\u0022 );\n            return;\n        }\n        if ( !assemblies.TryGetValue( hash, out Assembly assembly ) )\n        {\n            if ( !budget.TryReserve( packet.Total ) )\n            {\n                Fail( hash, \u0022More than 64 MiB of mesh geometry is arriving at once; the update was discarded and the scene will be synchronized again.\u0022 );\n                return;\n            }\n            PendingBytes \u002B= 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, \u0022Mesh geometry arrived with inconsistent sizes and was discarded; the scene will be synchronized again.\u0022 );\n            return;\n        }\n        byte[] data = packet.Data ?? Array.Empty\u003Cbyte\u003E();\n        if ( !FileChunkAssembler.IsValidChunk( assembly.Total, packet.Offset, data.Length ) )\n        {\n            Fail( hash, \u0022Mesh geometry arrived in a form that was not valid and was discarded; the scene will be synchronized again.\u0022 );\n            return;\n        }\n        // Always acknowledged, a repeat included: the sender spent a credit on it.\n        send( new Packet { Kind = \u0022blob-ack\u0022, 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 \u002B= data.Length;\n        if ( assembly.ReceivedBytes \u003C 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 \u0060hash\u0060 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 =\u003E 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 \u003E 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/// \u003Csummary\u003E\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/// \u003Cc\u003E\u0022Field\u0022:null\u003C/c\u003E 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/// \u003C/summary\u003E\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    /// \u003Csummary\u003EA project-relative file path with \u0022/\u0022 separators. Untrusted: the receiver validates it with FilePathRules before any disk access.\u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public string Path { get; set; }\r\n\r\n    /// \u003Csummary\u003ESHA-256 (lowercase hex) of the file as ProjectManifest hashes it (a .meta file through MetaFileRules.Canonical).\u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public string Sha256 { get; set; }\r\n\r\n    /// \u003Csummary\u003EOn file-put and file-delete: the version the sender last agreed with the session, null for a file it believes is new.\u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public string BaseSha256 { get; set; }\r\n\r\n    /// \u003Csummary\u003EByte offset of a file-chunk (or of the chunk a file-ack acknowledges).\u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]\r\n    public long Offset { get; set; }\r\n\r\n    /// \u003Csummary\u003EThe whole file\u0027s size in bytes on file-chunk, file-put and file-changed.\u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]\r\n    public long Total { get; set; }\r\n\r\n    /// \u003Csummary\u003E\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\u0027s window credit. All additive and omitted when unset (see BlobExchange).\r\n    /// \u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public List\u003Cstring\u003E Hashes { get; set; }\r\n\r\n    /// \u003Csummary\u003EOne chunk of file content (base64 on the wire, at most FileSyncLimits.ChunkSize bytes before encoding).\u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public byte[] Data { get; set; }\r\n\r\n    /// \u003Csummary\u003EOne page of the host\u0027s file index on a files-index reply; Final marks the last page.\u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public List\u003CFileIndexEntry\u003E FileEntries { get; set; }\r\n\r\n    /// \u003Csummary\u003E\r\n    /// On the final files-index page: the package identifiers the host\u0027s .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    /// \u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public List\u003Cstring\u003E Packages { get; set; }\r\n\r\n    /// \u003Csummary\u003E\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    /// \u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public List\u003Cstring\u003E Features { get; set; }\r\n\r\n    /// \u003Csummary\u003E\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    /// \u003C/summary\u003E\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\u003CObjectState\u003E Objects { get; set; }\r\n\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public List\u003CChange\u003E 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    /// \u003Csummary\u003E\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    /// \u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public string Notice { get; set; }\r\n\r\n    /// \u003Csummary\u003E\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\u0027s state rather than keep its own. Additive: omitted from the wire when false.\r\n    /// \u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]\r\n    public bool Merged { get; set; }\r\n\r\n    /// \u003Csummary\u003E\r\n    /// Scene-level settings (\u003Csee cref=\u0022SettingsMerge\u0022/\u003E): on a subscribe, the subscriber\u0027s settings for the scene; on a snapshot, the host\u0027s;\r\n    /// on a client\u0027s \u003Cc\u003Esettings\u003C/c\u003E packet, what the sender has now; on the host\u0027s \u003Cc\u003Esettings\u003C/c\u003E broadcast, the merged result every member\r\n    /// adopts. Additive: omitted from the wire when unset, ignored by peers that predate it.\r\n    /// \u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public JsonObject Settings { get; set; }\r\n\r\n    /// \u003Csummary\u003EOn a client\u0027s \u003Cc\u003Esettings\u003C/c\u003E packet: the settings the sender last had confirmed by the host, the base of the three-way merge. Additive.\u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public JsonObject SettingsBefore { get; set; }\r\n\r\n    /// \u003Csummary\u003E\r\n    /// On a ping: the fingerprint (\u003Csee cref=\u0022SceneDigest\u0022/\u003E) 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    /// \u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public string Digest { get; set; }\r\n\r\n    /// \u003Csummary\u003EOn a ping: the last revision this client had applied when it computed \u003Csee cref=\u0022Digest\u0022/\u003E. The host compares digests only at equal revisions.\u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public long? DigestRevision { get; set; }\r\n\r\n    /// \u003Csummary\u003EOn a ping: the scene \u003Csee cref=\u0022Digest\u0022/\u003E describes.\u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\r\n    public string DigestScene { get; set; }\r\n\r\n    /// \u003Csummary\u003E\r\n    /// On a presence packet: the sender\u0027s own monotonic milliseconds (\u003Csee cref=\u0022SessionClock\u0022/\u003E) 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\u0027s\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    /// \u003C/summary\u003E\r\n    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]\r\n    public double SentMs { get; set; }\r\n\r\n    /// \u003Csummary\u003E\r\n    /// Shared play (\u003Csee cref=\u0022PlayFrame\u0022/\u003E): on a \u003Cc\u003Eplay-frame\u003C/c\u003E, the frame; on \u003Cc\u003Eplay-start\u003C/c\u003E and \u003Cc\u003Eplay-stop\u003C/c\u003E, unused. Additive: omitted\r\n    /// from the wire when unset, and only ever sent to a peer that opted in with \u003Cc\u003Eplay-optin\u003C/c\u003E, so an older peer never sees it.\r\n    /// \u003C/summary\u003E\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 = \u0022teamcreate-4\u0022;\r\n\r\n    public const string VersionName = \u0022teamcreate-4\u0022;\r\n\r\n    public const string OlderLibraryMessage = \u0022This peer runs an older collaboration library. Update the collaboration library on both machines and reconnect.\u0022;\r\n\r\n    public const string MissingProjectRevisionMessage = \u0022This peer did not report its project revision. Update the collaboration library on both machines and reconnect.\u0022;\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 \u0026\u0026 p != null \u0026\u0026 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 != \u0022hello\u0022 || (requireValidPeer \u0026\u0026 !Guid.TryParse(p.Peer, out var _)))\r\n        {\r\n            return \u0022Invalid collaboration handshake\u0022;\r\n        }\r\n        if (p.ProtocolVersion != 4)\r\n        {\r\n            return $\u0022Unsupported collaboration protocol version. This client requires protocol {4} ({\u0022teamcreate-4\u0022}).\u0022;\r\n        }\r\n        if (!IsValidMode(p.Mode))\r\n        {\r\n            return \u0022Invalid collaboration mode in hello. Source or runtime collaboration mode is required.\u0022;\r\n        }\r\n        if (p.Mode == CollaborationMode.RuntimeExperimental \u0026\u0026 !RuntimeStateRules.IsValidRunId(p.RuntimeRunId))\r\n        {\r\n            return \u0022Runtime collaboration handshake requires a valid runtime run ID.\u0022;\r\n        }\r\n        if (!SteamIdentityRules.TryParse(p.SteamId, out var _))\r\n        {\r\n            return \u0022Collaboration handshake requires a valid Steam account ID. Sign in to Steam and reconnect.\u0022;\r\n        }\r\n        if (!SteamTokenRules.IsPlausibleToken(p.SteamToken))\r\n        {\r\n            return \u0022Collaboration handshake requires a Steam identity token. Reconnect to obtain one.\u0022;\r\n        }\r\n        if (string.IsNullOrWhiteSpace(p.OrgIdent) || string.IsNullOrWhiteSpace(p.LibraryVersion) || !Version.TryParse(p.LibraryVersion, out Version _))\r\n        {\r\n            return \u0022This peer runs an older collaboration library. Update the collaboration library on both machines and reconnect.\u0022;\r\n        }\r\n        if (string.IsNullOrWhiteSpace(p.ProjectManifest))\r\n        {\r\n            return \u0022This peer did not report its project revision. Update the collaboration library on both machines and reconnect.\u0022;\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 \u003C= 0)\r\n        {\r\n            return false;\r\n        }\r\n        if (lastAcceptedSequence.HasValue \u0026\u0026 p.RuntimeSequence.Value \u003C= 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/// \u003Csummary\u003EA change the scan loop did not send, with the formal sentence telling the user why.\u003C/summary\u003E\r\npublic sealed record HeldChange( Change Change, string Reason );\r\n\r\npublic static class StateRules\r\n{\r\n    public const string Version = \u0022teamcreate-1\u0022;\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    /// \u003Csummary\u003ELargest serialised \u003Csee cref=\u0022ObjectState\u0022/\u003E, in UTF-8 bytes of its JSON.\u003C/summary\u003E\r\n    public const int MaxObjectBytes = 1024 * 1024;\r\n\r\n    /// \u003Csummary\u003E\r\n    /// Deepest JSON nesting inside an object\u0027s 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    /// \u003C/summary\u003E\r\n    public const int MaxDataDepth = 56;\r\n\r\n    /// \u003Csummary\u003EMost components on one object.\u003C/summary\u003E\r\n    public const int MaxComponents = 256;\r\n\r\n    /// \u003Csummary\u003ELargest single text value, in UTF-8 bytes.\u003C/summary\u003E\r\n    public const int MaxStringBytes = 256 * 1024;\r\n\r\n    /// \u003Csummary\u003ELongest property name, in characters.\u003C/summary\u003E\r\n    public const int MaxKeyLength = 200;\r\n\r\n    /// \u003Csummary\u003ELongest component type name, in characters.\u003C/summary\u003E\r\n    public const int MaxTypeNameLength = 200;\r\n\r\n    /// \u003Csummary\u003E\r\n    /// The reserved (\u003Cc\u003E__\u003C/c\u003E) property names the engine and this library are known to write. Observed in the scenes and\r\n    /// prefabs installed with s\u0026amp;box: \u003Cc\u003E__guid\u003C/c\u003E, \u003Cc\u003E__version\u003C/c\u003E, \u003Cc\u003E__type\u003C/c\u003E, \u003Cc\u003E__enabled\u003C/c\u003E, \u003Cc\u003E__references\u003C/c\u003E,\r\n    /// \u003Cc\u003E__Prefab\u003C/c\u003E, \u003Cc\u003E__PrefabInstancePatch\u003C/c\u003E, \u003Cc\u003E__PrefabIdToInstanceId\u003C/c\u003E (scenes) and \u003Cc\u003E__properties\u003C/c\u003E,\r\n    /// \u003Cc\u003E__variables\u003C/c\u003E (prefabs). Read or written by the engine\u0027s own serializer (GameObject and Component in\r\n    /// Sandbox.Engine): \u003Cc\u003E__PrefabVariables\u003C/c\u003E, \u003Cc\u003E__EditorPrefabNestedInstance\u003C/c\u003E, \u003Cc\u003E__EditorSkipPrefabBreakOnRefresh\u003C/c\u003E,\r\n    /// \u003Cc\u003E__NetworkedPrefaInstance\u003C/c\u003E (the engine\u0027s spelling), \u003Cc\u003E__snapshot\u003C/c\u003E. \u003Cc\u003E__PrefabVariant\u003C/c\u003E is listed because\r\n    /// the engine is expected to write it, though no installed sample carries it. \u003Csee cref=\u0022SiblingOrder.Key\u0022/\u003E is the library\u0027s own.\r\n    /// A name outside this set is not refused (a newer engine may add one, and refusing it would stop the session\u0027s work);\r\n    /// \u003Csee cref=\u0022FindUnknownReservedKeys\u0022/\u003E reports it so it can be counted.\r\n    /// \u003C/summary\u003E\r\n    public static readonly IReadOnlySet\u003Cstring\u003E KnownReservedKeys = new HashSet\u003Cstring\u003E( StringComparer.Ordinal )\r\n    {\r\n        \u0022__guid\u0022, \u0022__version\u0022, \u0022__type\u0022, \u0022__enabled\u0022, \u0022__references\u0022, \u0022__Prefab\u0022, \u0022__PrefabInstancePatch\u0022, \u0022__PrefabIdToInstanceId\u0022,\r\n        \u0022__properties\u0022, \u0022__variables\u0022, \u0022__PrefabVariables\u0022, \u0022__EditorPrefabNestedInstance\u0022, \u0022__EditorSkipPrefabBreakOnRefresh\u0022,\r\n        \u0022__NetworkedPrefaInstance\u0022, \u0022__snapshot\u0022, \u0022__PrefabVariant\u0022, 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 \u0026\u0026 a.Id == b.Id \u0026\u0026 a.Parent == b.Parent \u0026\u0026 ValuesEqual(a.Data, b.Data));\r\n    }\r\n\r\n    /// \u003Csummary\u003E\r\n    /// Structural equality of two scene values, tolerant of the engine\u0027s 32-bit float round trip. It matches\r\n    /// \u003Csee cref=\u0022JsonNode.DeepEquals\u0022/\u003E (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    /// \u003Cc\u003Elong\u003C/c\u003E 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 \u003Cc\u003E0.6\u003C/c\u003E equal to \u003Cc\u003E0.600000024\u003C/c\u003E (what the editor writes back) and \u003Cc\u003E1\u003C/c\u003E\r\n    /// equal to \u003Cc\u003E1.0\u003C/c\u003E. Without this every echo of a float property looked like an edit by somebody else.\r\n    /// \u003C/summary\u003E\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\u003Cstring, JsonNode\u003E 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 \u003C arrayA.Count; i\u002B\u002B )\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 \u0026\u0026 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\u003Clong\u003E). 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 ) \u0026\u0026 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 ) \u0026\u0026 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\u003Cstring, ObjectState\u003E Map(IEnumerable\u003CObjectState\u003E objects)\r\n    {\r\n        return objects.ToDictionary((ObjectState x) =\u003E x.Id);\r\n    }\r\n\r\n    public static List\u003CChange\u003E Diff(Dictionary\u003Cstring, ObjectState\u003E before, Dictionary\u003Cstring, ObjectState\u003E 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    /// \u003Csummary\u003EThrows the formal refusal of \u003Csee cref=\u0022RefusalOf\u0022/\u003E when there is one.\u003C/summary\u003E\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    /// \u003Csummary\u003E\r\n    /// Why \u003Cparamref name=\u0022c\u0022/\u003E 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    /// \u003C/summary\u003E\r\n    public static string RefusalOf( Change c )\r\n    {\r\n        if ( c is null || ( (object)c.Before == null \u0026\u0026 (object)c.After == null ) )\r\n        {\r\n            return \u0022An empty change was not sent.\u0022;\r\n        }\r\n        foreach ( ObjectState state in new[] { c.Before, c.After } )\r\n        {\r\n            if ( (object)state != null \u0026\u0026 !HasIdentity( c.Id, state ) )\r\n            {\r\n                return \u0022The object \\\u0022\u0022 \u002B NameOf( state, c.Id ) \u002B \u0022\\\u0022 does not have a valid identifier and was not sent.\u0022;\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            \u0026\u0026 string.Equals( state.Id, id, StringComparison.Ordinal )\r\n            \u0026\u0026 state.Data is not null\r\n            \u0026\u0026 state.Data[\u0022__guid\u0022] is JsonValue guid \u0026\u0026 guid.TryGetValue( out string text ) \u0026\u0026 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 = \u0022The object \\\u0022\u0022 \u002B NameOf( state, state.Id ) \u002B \u0022\\\u0022 \u0022;\r\n        string walk = RefusalOfTree( state.Data, 1 );\r\n        if ( walk != null )\r\n        {\r\n            return name \u002B walk;\r\n        }\r\n        if ( state.Data.TryGetPropertyValue( \u0022Components\u0022, out JsonNode list ) )\r\n        {\r\n            if ( list is not JsonArray components )\r\n            {\r\n                return name \u002B \u0022has a component list that is not a list and was not sent.\u0022;\r\n            }\r\n            if ( components.Count \u003E MaxComponents )\r\n            {\r\n                return name \u002B \u0022has more than \u0022 \u002B MaxComponents \u002B \u0022 components, beyond the collaboration limit, and was not sent.\u0022;\r\n            }\r\n            HashSet\u003Cstring\u003E guids = new HashSet\u003Cstring\u003E( 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 \u002B \u0022has a component entry that is not an object and was not sent.\u0022;\r\n                }\r\n                if ( component[\u0022__type\u0022] is not JsonValue typeValue || !typeValue.TryGetValue( out string type ) || string.IsNullOrEmpty( type ) )\r\n                {\r\n                    return name \u002B \u0022has a component without a type name and was not sent.\u0022;\r\n                }\r\n                if ( type.Length \u003E MaxTypeNameLength )\r\n                {\r\n                    return name \u002B \u0022has a component type name longer than \u0022 \u002B MaxTypeNameLength \u002B \u0022 characters and was not sent.\u0022;\r\n                }\r\n                if ( component[\u0022__guid\u0022] is not JsonValue guidValue || !guidValue.TryGetValue( out string guid ) || !Guid.TryParse( guid, out _ ) )\r\n                {\r\n                    return name \u002B \u0022has a component without a valid identifier and was not sent.\u0022;\r\n                }\r\n                if ( !guids.Add( guid ) )\r\n                {\r\n                    return name \u002B \u0022has two components with the identifier \u0022 \u002B guid \u002B \u0022 and was not sent.\u0022;\r\n                }\r\n            }\r\n        }\r\n        if ( System.Text.Json.JsonSerializer.SerializeToUtf8Bytes( state ).Length \u003E MaxObjectBytes )\r\n        {\r\n            return name \u002B \u0022is larger than the \u0022 \u002B MaxObjectBytes / ( 1024 * 1024 ) \u002B \u0022 MiB collaboration limit and was not sent.\u0022;\r\n        }\r\n        return null;\r\n    }\r\n\r\n    // Depth, property names and text sizes, everywhere in the tree. \u0060depth\u0060 is the level of \u0060node\u0060 (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 \u003E MaxDataDepth )\r\n            {\r\n                return DepthRefusal();\r\n            }\r\n            foreach ( KeyValuePair\u003Cstring, JsonNode\u003E member in obj )\r\n            {\r\n                if ( member.Key.Length == 0 )\r\n                {\r\n                    return \u0022contains an empty property name and was not sent.\u0022;\r\n                }\r\n                if ( member.Key.Length \u003E MaxKeyLength )\r\n                {\r\n                    return \u0022contains a property name longer than \u0022 \u002B MaxKeyLength \u002B \u0022 characters and was not sent.\u0022;\r\n                }\r\n                string inner = RefusalOfTree( member.Value, depth \u002B 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 \u003E 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 \u002B 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 \u0026\u0026 value.TryGetValue( out string text ) \u0026\u0026 System.Text.Encoding.UTF8.GetByteCount( text ) \u003E 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 \u0022a text value\u0022 is too long.\r\n            if ( BlobRules.IsCanonicalBase64( text ) \u0026\u0026 text.Length / 4 * 3 \u003E BlobLimits.MaxBlobBytes )\r\n            {\r\n                return \u0022has geometry larger than the \u0022 \u002B BlobLimits.MaxBlobBytes / ( 1024 * 1024 ) \u002B \u0022 MiB collaboration limit and was not sent.\u0022;\r\n            }\r\n            return \u0022contains a text value larger than the \u0022 \u002B MaxStringBytes / 1024 \u002B \u0022 KiB collaboration limit and was not sent.\u0022;\r\n        }\r\n        return null;\r\n    }\r\n\r\n    private static string DepthRefusal() =\u003E \u0022is nested more than \u0022 \u002B MaxDataDepth \u002B \u0022 levels deep, beyond the collaboration limit, and was not sent.\u0022;\r\n\r\n    /// \u003Csummary\u003E\r\n    /// The reserved (\u003Cc\u003E__\u003C/c\u003E) property names anywhere in \u003Cparamref name=\u0022data\u0022/\u003E that are not in \u003Csee cref=\u0022KnownReservedKeys\u0022/\u003E,\r\n    /// each once, in the order they are first met. They are allowed; the caller counts or logs them.\r\n    /// \u003C/summary\u003E\r\n    public static List\u003Cstring\u003E FindUnknownReservedKeys( JsonObject data )\r\n    {\r\n        List\u003Cstring\u003E found = new List\u003Cstring\u003E();\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\u003Cstring, JsonNode\u003E member in obj )\r\n                {\r\n                    if ( member.Key.StartsWith( \u0022__\u0022, StringComparison.Ordinal ) \u0026\u0026 !KnownReservedKeys.Contains( member.Key ) \u0026\u0026 !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    /// \u003Csummary\u003E\r\n    /// Splits the scan loop\u0027s batch into what is sent and what is held. A change that \u003Csee cref=\u0022RefusalOf\u0022/\u003E refuses is held\r\n    /// with its refusal, and so is every change that would leave the host\u0027s 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 \u003Cparamref name=\u0022known\u0022/\u003E, always passes \u003Csee cref=\u0022ValidateGraph\u0022/\u003E.\r\n    /// Holding one object\u0027s 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    /// \u003C/summary\u003E\r\n    public static List\u003CChange\u003E SendableChanges( IReadOnlyDictionary\u003Cstring, ObjectState\u003E known, IEnumerable\u003CChange\u003E changes, List\u003CHeldChange\u003E held )\r\n    {\r\n        List\u003CChange\u003E all = changes.ToList();\r\n        Dictionary\u003Cstring, string\u003E reasons = new Dictionary\u003Cstring, string\u003E( StringComparer.Ordinal );\r\n        List\u003Cstring\u003E order = new List\u003Cstring\u003E();\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\u003Cstring, ObjectState\u003E 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 =\u003E !reasons.ContainsKey( x.Id ) ) )\r\n            {\r\n                if ( (object)change.After != null \u0026\u0026 !string.IsNullOrEmpty( change.After.Parent ) \u0026\u0026 !result.ContainsKey( change.After.Parent ) )\r\n                {\r\n                    Hold( change, \u0022The object \\\u0022\u0022 \u002B NameOf( change.After, change.Id ) \u002B \u0022\\\u0022 was not sent because its parent \\\u0022\u0022 \u002B NameIn( all, known, change.After.Parent ) \u002B \u0022\\\u0022 was not sent.\u0022 );\r\n                    settled = false;\r\n                }\r\n                else if ( (object)change.After == null \u0026\u0026 result.Values.FirstOrDefault( x =\u003E string.Equals( x.Parent, change.Id, StringComparison.Ordinal ) ) is { } child )\r\n                {\r\n                    Hold( change, \u0022The deletion of \\\u0022\u0022 \u002B NameOf( change.Before, change.Id ) \u002B \u0022\\\u0022 was not sent because its child \\\u0022\u0022 \u002B NameOf( child, child.Id ) \u002B \u0022\\\u0022 was not sent.\u0022 );\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 =\u003E !reasons.ContainsKey( x.Id ) ).ToList() )\r\n            {\r\n                Hold( change, \u0022The change to \\\u0022\u0022 \u002B NameOf( change.After ?? change.Before, change.Id ) \u002B \u0022\\\u0022 was not sent because the scene would not stay consistent (\u0022 \u002B ex.Message \u002B \u0022).\u0022 );\r\n            }\r\n        }\r\n\r\n        foreach ( string id in order )\r\n        {\r\n            held.Add( new HeldChange( all.First( x =\u003E x.Id == id ), reasons[id] ) );\r\n        }\r\n        return all.Where( x =\u003E !reasons.ContainsKey( x.Id ) ).ToList();\r\n    }\r\n\r\n    private static Dictionary\u003Cstring, ObjectState\u003E Result( IReadOnlyDictionary\u003Cstring, ObjectState\u003E known, List\u003CChange\u003E all, Dictionary\u003Cstring, string\u003E reasons )\r\n    {\r\n        Dictionary\u003Cstring, ObjectState\u003E result = known.ToDictionary( x =\u003E x.Key, x =\u003E x.Value );\r\n        foreach ( Change change in all.Where( x =\u003E !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\u003CChange\u003E all, IReadOnlyDictionary\u003Cstring, ObjectState\u003E known, string id )\r\n    {\r\n        Change change = all.FirstOrDefault( x =\u003E 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    /// \u003Csummary\u003EThe name a person recognises an object by (its Name, shortened when very long), or its id when it has none.\u003C/summary\u003E\r\n    public static string NameOf( ObjectState state, string id )\r\n    {\r\n        if ( state?.Data?[\u0022Name\u0022] is JsonValue value \u0026\u0026 value.TryGetValue( out string name ) \u0026\u0026 !string.IsNullOrEmpty( name ) )\r\n        {\r\n            return name.Length \u003C= 100 ? name : name.Substring( 0, 100 ) \u002B \u0022...\u0022;\r\n        }\r\n        return id ?? state?.Id;\r\n    }\r\n\r\n    /// \u003Csummary\u003EThe notice for a component type the sender has and this project lacks; shown once per type.\u003C/summary\u003E\r\n    public static string MissingComponentTypeNotice( string type ) =\u003E\r\n        \u0022The component type \\\u0022\u0022 \u002B type \u002B \u0022\\\u0022 is not available in your project. Update your code to see it.\u0022;\r\n\r\n    /// \u003Csummary\u003EThe notice for an object that stopped being saved with the scene (for example, marked as not saved).\u003C/summary\u003E\r\n    public static string NoLongerSavedNotice( string name ) =\u003E\r\n        \u0022The object \\\u0022\u0022 \u002B name \u002B \u0022\\\u0022 can no longer be saved with the scene, so it was removed from the shared scene.\u0022;\r\n\r\n    public static void ValidateGraph(Dictionary\u003Cstring, ObjectState\u003E objects)\r\n    {\r\n        HashSet\u003Cstring\u003E hashSet = new HashSet\u003Cstring\u003E(objects.Keys);\r\n        foreach (ObjectState value in objects.Values)\r\n        {\r\n            if (value.Data[\u0022Components\u0022] is JsonArray jsonArray)\r\n            {\r\n                foreach (JsonNode item in jsonArray)\r\n                {\r\n                    string text = item?[\u0022__guid\u0022]?.GetValue\u003Cstring\u003E();\r\n                    if (text == null || !Guid.TryParse(text, out var _) || !hashSet.Add(text))\r\n                    {\r\n                        throw new InvalidOperationException(\u0022Duplicate/invalid component identity\u0022);\r\n                    }\r\n                }\r\n            }\r\n            HashSet\u003Cstring\u003E hashSet2 = new HashSet\u003Cstring\u003E { 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(\u0022Parent does not exist\u0022);\r\n                }\r\n                if (!hashSet2.Add(parent))\r\n                {\r\n                    throw new InvalidOperationException(\u0022Parenting cycle\u0022);\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\u003Cstring, Packet\u003E receipts = new Dictionary\u003Cstring, Packet\u003E();\r\n\r\n    private readonly Action\u003CPacket\u003E persist;\r\n\r\n    public Dictionary\u003Cstring, Dictionary\u003Cstring, ObjectState\u003E\u003E Scenes { get; } = new Dictionary\u003Cstring, Dictionary\u003Cstring, ObjectState\u003E\u003E();\r\n\r\n    /// \u003Csummary\u003E\r\n    /// The host\u0027s scene-level settings per scene, taken from the first subscribe that created the scene and changed only through\r\n    /// \u003Csee cref=\u0022ApplySettings\u0022/\u003E. A scene whose first subscriber sent none has no entry.\r\n    /// \u003C/summary\u003E\r\n    public Dictionary\u003Cstring, JsonObject\u003E SceneSettings { get; } = new Dictionary\u003Cstring, JsonObject\u003E();\r\n\r\n    public long Revision { get; private set; }\r\n\r\n    public Authority(Action\u003CPacket\u003E 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\u003Cstring, ObjectState\u003E dictionary = StateRules.Map(p.Objects);\r\n            StateRules.ValidateGraph(dictionary);\r\n            persist(new Packet\r\n            {\r\n                Kind = \u0022seed\u0022,\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 = \u0022snapshot\u0022,\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    /// \u003Csummary\u003E\r\n    /// Applies one member\u0027s change to a scene\u0027s settings (\u003Csee cref=\u0022SettingsMerge\u0022/\u003E): merged per top-level key against what the host holds,\r\n    /// later change wins on a clash with the key named in \u003Csee cref=\u0022Packet.Notice\u0022/\u003E. Returns the \u003Cc\u003Esettings\u003C/c\u003E packet to broadcast (the\r\n    /// merged settings, sender, id and new revision, also what the journal records) or a \u003Cc\u003Ereject\u003C/c\u003E for the sender alone. A resent packet\r\n    /// gets the receipt it got the first time, as \u003Csee cref=\u0022Apply\u0022/\u003E does.\r\n    /// \u003C/summary\u003E\r\n    public Packet ApplySettings(Packet p)\r\n    {\r\n        string key = p.Peer \u002B \u0022:\u0022 \u002B 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(\u0022Subscribe to scene first\u0022);\r\n            }\r\n            if (p.Settings == null || p.SettingsBefore == null)\r\n            {\r\n                throw new InvalidOperationException(\u0022A scene settings change must carry the settings before and after the change.\u0022);\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\u003Cstring\u003E clashed;\r\n            if (!SceneSettings.TryGetValue(p.Scene, out JsonObject current))\r\n            {\r\n                // Nothing to merge with: the scene\u0027s first subscriber reported no settings. The change is adopted whole.\r\n                merged = SettingsMerge.Clone(p.Settings);\r\n                clashed = new List\u003Cstring\u003E();\r\n            }\r\n            else if (!SettingsMerge.TryMerge(current, p.SettingsBefore, p.Settings, out merged, out clashed))\r\n            {\r\n                throw new InvalidOperationException(\u0022The scene settings change could not be merged with the host\u0027s settings.\u0022);\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 \u002B 1,\r\n                Merged = clashed.Count \u003E 0 || !StateRules.ValuesEqual(merged, p.Settings),\r\n                Notice = SettingsMerge.ClashNotice(clashed)\r\n            };\r\n            persist(value);\r\n            Revision\u002B\u002B;\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 = \u0022reject\u0022,\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 \u002B \u0022:\u0022 \u002B 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(\u0022Subscribe to scene first\u0022);\r\n            }\r\n            if (p.Changes == null || (p.Changes.Count == 0 \u0026\u0026 !p.Final))\r\n            {\r\n                throw new InvalidOperationException(\u0022Empty operation\u0022);\r\n            }\r\n            if (p.Changes.Select((Change x) =\u003E x.Id).Distinct().Count() != p.Changes.Count)\r\n            {\r\n                throw new InvalidOperationException(\u0022Duplicate object in operation\u0022);\r\n            }\r\n            Dictionary\u003Cstring, ObjectState\u003E dictionary = new Dictionary\u003Cstring, ObjectState\u003E(value2);\r\n            List\u003CChange\u003E applied = new List\u003CChange\u003E();\r\n            List\u003Cstring\u003E notices = new List\u003Cstring\u003E();\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\u0027s Before is then the state it\r\n            // already holds, and the journal replays to the host\u0027s scene. Merged is set when the host\u0027s result is not simply the\r\n            // sender\u0027s edit: some applied After differs from the one sent, or the outcome needed a notice (a clash resolved in the\r\n            // sender\u0027s favour still leaves After equal to the one sent, yet the people in the session must be told).\r\n            bool merged = notices.Count \u003E 0;\r\n            for (int i = 0; i \u003C applied.Count; i\u002B\u002B)\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 = \u0022accepted\u0022,\r\n                Id = p.Id,\r\n                Peer = p.Peer,\r\n                Scene = p.Scene,\r\n                Changes = applied,\r\n                Revision = Revision \u002B 1,\r\n                Final = p.Final,\r\n                Merged = merged,\r\n                Notice = (notices.Count == 0) ? null : string.Join(\u0022 \u0022, notices)\r\n            };\r\n            persist(value);\r\n            Revision\u002B\u002B;\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 = \u0022reject\u0022,\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\u0027s 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 \u0060notices\u0060 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\u003Cstring\u003E 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(\u0022The later deletion of \\\u0022\u0022 \u002B DisplayName(current) \u002B \u0022\\\u0022 was kept; a concurrent edit to the same object was discarded.\u0022);\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(\u0022A concurrent deletion of \\\u0022\u0022 \u002B DisplayName(change.After) \u002B \u0022\\\u0022 was overridden; the later edit was kept and the object was restored.\u0022);\r\n        }\r\n        else\r\n        {\r\n            if (!PropertyMerge.TryMerge(current, change.Before, change.After, out ObjectState merged, out List\u003Cstring\u003E clashed))\r\n            {\r\n                throw Conflict(change);\r\n            }\r\n            applied = new Change(change.Id, current, merged);\r\n            if (clashed.Count \u003E 0)\r\n            {\r\n                notices.Add(\u0022Concurrent edits to \\\u0022\u0022 \u002B DisplayName(current) \u002B \u0022\\\u0022 were combined; the later edit was kept for: \u0022 \u002B string.Join(\u0022, \u0022, clashed) \u002B \u0022.\u0022);\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(\u0022Conflict on \u0022 \u002B change.Id \u002B \u0022: first accepted edit wins. Your edit is retained in recovery.\u0022);\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?[\u0022Name\u0022] is JsonValue jsonValue \u0026\u0026 jsonValue.TryGetValue(out string value) \u0026\u0026 !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\u003CReadinessItem\u003E Evaluate(ReadinessInput input)\r\n    {\r\n        CollaborationConfig config = input.Config ?? CollaborationConfig.Defaults();\r\n        return new List\u003CReadinessItem\u003E\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\u003CReadinessItem\u003E items)\r\n    {\r\n        return items != null \u0026\u0026 items.Count \u003E 0 \u0026\u0026 (object)FirstBlocked(items) == null;\r\n    }\r\n\r\n    public static string BlockReason(IReadOnlyList\u003CReadinessItem\u003E 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]) \u002B text.Substring(1)));\r\n        return \u0022Hosting is unavailable: \u0022 \u002B text2 \u002B \u0022.\u0022;\r\n    }\r\n\r\n    private static ReadinessItem FirstBlocked(IReadOnlyList\u003CReadinessItem\u003E 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 \u0026\u0026 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(\u0022steam\u0022, \u0022Steam sign-in\u0022, ReadinessLevel.Ok, input.SteamPersona, null) : new ReadinessItem(\u0022steam\u0022, \u0022Steam sign-in required\u0022, ReadinessLevel.Blocked, \u0022Sign in to Steam to identify this editor.\u0022, null);\r\n    }\r\n\r\n    private static ReadinessItem EvaluateScene(ReadinessInput input)\r\n    {\r\n        return input.SceneSupported ? new ReadinessItem(\u0022scene\u0022, \u0022Active scene\u0022, ReadinessLevel.Ok, input.ScenePath, null) : new ReadinessItem(\u0022scene\u0022, \u0022Saved scene required\u0022, ReadinessLevel.Blocked, \u0022Open a saved scene under scenes/ before starting a session.\u0022, 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(\u0022published\u0022, \u0022Project publication\u0022, ReadinessLevel.Ok, \u0022Publication is not required for this project.\u0022, null);\r\n        }\r\n        if (!input.ProjectPublished)\r\n        {\r\n            return new ReadinessItem(\u0022published\u0022, \u0022Project publication required\u0022, ReadinessLevel.Blocked, \u0022Publish this project to s\u0026box (hidden or public) before hosting.\u0022, null);\r\n        }\r\n        if (!input.PackageReachable)\r\n        {\r\n            return new ReadinessItem(\u0022published\u0022, \u0022Project publication\u0022, ReadinessLevel.Warning, \u0022The published copy could not be fetched from Asset Party; peers may not be able to install it.\u0022, null);\r\n        }\r\n        return new ReadinessItem(\u0022published\u0022, \u0022Project publication\u0022, ReadinessLevel.Ok, \u0022The published copy is available from Asset Party.\u0022, 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(\u0022library\u0022, \u0022Library installation required\u0022, ReadinessLevel.Blocked, \u0022Install the collaboration library from Asset Party.\u0022, null);\r\n        }\r\n        Version version = input.LibraryRequiredVersion ?? config.LibraryVersion;\r\n        Version libraryInstalledVersion = input.LibraryInstalledVersion;\r\n        if ((object)version != null \u0026\u0026 ((object)libraryInstalledVersion == null || VersionRules.IsOlder(libraryInstalledVersion, version)))\r\n        {\r\n            return new ReadinessItem(\u0022library\u0022, \u0022Library update required\u0022, ReadinessLevel.Blocked, $\u0022Installed version {libraryInstalledVersion?.ToString() ?? \u0022unknown\u0022} is older than the required version {version}.\u0022, \u0022Update\u0022);\r\n        }\r\n        return new ReadinessItem(\u0022library\u0022, \u0022Collaboration library\u0022, ReadinessLevel.Ok, ((object)libraryInstalledVersion == null) ? \u0022The collaboration library is installed.\u0022 : $\u0022Version {libraryInstalledVersion} installed.\u0022, 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(\u0022org\u0022, \u0022Organization membership\u0022, ReadinessLevel.Ok, \u0022Organization membership is not required for this project.\u0022, null);\r\n        }\r\n        return input.IsOrgMember ? new ReadinessItem(\u0022org\u0022, \u0022Organization membership\u0022, ReadinessLevel.Ok, \u0022You are a member of the organization \u0022 \u002B config.Org \u002B \u0022.\u0022, null) : new ReadinessItem(\u0022org\u0022, \u0022Organization membership required\u0022, ReadinessLevel.Blocked, \u0022You are not a member of the organization \u0022 \u002B config.Org \u002B \u0022.\u0022, 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/// \u003Csummary\u003E\n/// Puts the relay on the person\u0027s 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. \u003Csee cref=\u0022DeployResult.Ok\u0022/\u003E 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/// \u003C/summary\u003E\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\u003Cstring, string\u003E healthUrlFor;\n\n    private readonly Func\u003CTimeSpan, CancellationToken, Task\u003E delay;\n\n    private readonly Func\u003Cstring\u003E newSubdomainName;\n\n    private readonly string apiBase;\n\n    /// \u003Cparam name=\u0022healthUrlFor\u0022\u003EMaps the workers.dev host to the address to check. The editor checks the real address; a test points it at a stand-in.\u003C/param\u003E\n    /// \u003Cparam name=\u0022delay\u0022\u003EHow to wait between health checks. A test passes one that does not wait.\u003C/param\u003E\n    /// \u003Cparam name=\u0022newSubdomainName\u0022\u003EMakes a candidate workers.dev name for an account that has none.\u003C/param\u003E\n    /// \u003Cparam name=\u0022apiBase\u0022\u003ECloudflare\u0027s API address. Only a test in a disposable copy ever passes another.\u003C/param\u003E\n    public RelayDeployer(ICloudflareTransport transport, string workerSource, Func\u003Cstring, string\u003E healthUrlFor = null,\n        Func\u003CTimeSpan, CancellationToken, Task\u003E delay = null, Func\u003Cstring\u003E 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(\u0022The relay source is empty.\u0022, nameof(workerSource)) : workerSource;\n        this.healthUrlFor = healthUrlFor ?? (host =\u003E \u0022https://\u0022 \u002B host \u002B \u0022/health\u0022);\n        this.delay = delay ?? ((span, token) =\u003E Task.Delay(span, token));\n        this.newSubdomainName = newSubdomainName ?? DefaultSubdomainName;\n    }\n\n    public async Task\u003CDeployResult\u003E RunAsync(string token, Action\u003Cstring\u003E progress, CancellationToken cancellationToken)\n    {\n        progress ??= _ =\u003E { };\n        try\n        {\n            return await Deploy(token, progress, cancellationToken);\n        }\n        catch (OperationCanceledException)\n        {\n            return new DeployResult(false, null, \u0022Setup was cancelled. Nothing was changed on this computer.\u0022, Cancelled: true);\n        }\n    }\n\n    private async Task\u003CDeployResult\u003E Deploy(string token, Action\u003Cstring\u003E progress, CancellationToken ct)\n    {\n        // 1. The account the token can use.\n        progress(Step(1, \u0022finding the Cloudflare account\u0022));\n        CloudflareResponse accounts = await Send(CloudflareApi.Request(\u0022GET\u0022, \u0022/accounts?per_page=50\u0022, token, apiBase: apiBase), ct);\n        if (!(IsOk(accounts) \u0026\u0026 CloudflareApi.ResultOf(accounts.Body) is JsonArray list))\n        {\n            return Fail(\u0022finding the account\u0022, accounts, token);\n        }\n        string accountId = null;\n        string accountName = null;\n        foreach (JsonNode item in list)\n        {\n            if (item is JsonObject account \u0026\u0026 account[\u0022id\u0022] is JsonValue id \u0026\u0026 id.TryGetValue(out string idText) \u0026\u0026 !string.IsNullOrWhiteSpace(idText))\n            {\n                accountId = idText;\n                accountName = account[\u0022name\u0022] is JsonValue name \u0026\u0026 name.TryGetValue(out string nameText) ? nameText : null;\n                break;\n            }\n        }\n        if (accountId == null)\n        {\n            return new DeployResult(false, null, \u0022The token cannot see a Cloudflare account. Create it from the page the Open Cloudflare button shows and choose your account when it asks.\u0022);\n        }\n        string root = \u0022/accounts/\u0022 \u002B Uri.EscapeDataString(accountId);\n        progress(Step(1, \u0022using the account\u0022 \u002B (string.IsNullOrWhiteSpace(accountName) ? string.Empty : \u0022 \\\u0022\u0022 \u002B accountName \u002B \u0022\\\u0022\u0022)));\n\n        // 2. The account\u0027s workers.dev name, made if the account has none yet.\n        progress(Step(2, \u0022checking the workers.dev name\u0022));\n        CloudflareResponse subdomain = await Send(CloudflareApi.Request(\u0022GET\u0022, root \u002B \u0022/workers/subdomain\u0022, 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 \u0026\u0026 CloudflareApi.ErrorCode(subdomain.Body) == 10007)\n        {\n            SubdomainOutcome registered = await RegisterSubdomain(root, token, progress, ct);\n            if (registered.Host == null)\n            {\n                return Fail(\u0022creating the workers.dev name\u0022, registered.Failure, token);\n            }\n            host = registered.Host;\n        }\n        else\n        {\n            return Fail(\u0022checking the workers.dev name\u0022, subdomain, token);\n        }\n        if (host == null)\n        {\n            return new DeployResult(false, null, \u0022Cloudflare 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.\u0022);\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, \u0022checking for an existing relay\u0022));\n        CloudflareResponse existing = await Send(CloudflareApi.Request(\u0022GET\u0022, root \u002B \u0022/workers/services/\u0022 \u002B 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(\u0022checking for an existing relay\u0022, existing, token);\n        }\n        bool sendMigrations = !string.Equals(appliedTag, CloudflareApi.MigrationTag, StringComparison.Ordinal);\n\n        // 4. The Worker itself.\n        progress(Step(4, exists ? \u0022updating the relay\u0022 : \u0022uploading the relay\u0022));\n        using (var content = CloudflareApi.UploadContent(workerSource, sendMigrations, keepSecrets: exists))\n        {\n            CloudflareResponse upload = await Send(CloudflareApi.Request(\u0022PUT\u0022, root \u002B \u0022/workers/scripts/\u0022 \u002B CloudflareApi.ScriptName \u002B CloudflareApi.UploadQuery, token, content, apiBase: apiBase), ct);\n            if (!IsOk(upload))\n            {\n                return Fail(\u0022uploading the relay\u0022, upload, token);\n            }\n        }\n\n        // 5. Switch its workers.dev address on.\n        progress(Step(5, \u0022switching the relay\u0027s address on\u0022));\n        using (var body = CloudflareApi.Json(new JsonObject { [\u0022enabled\u0022] = true }))\n        {\n            CloudflareResponse route = await Send(CloudflareApi.Request(\u0022POST\u0022, root \u002B \u0022/workers/scripts/\u0022 \u002B CloudflareApi.ScriptName \u002B \u0022/subdomain\u0022, token, body, withScriptApiDate: true, apiBase: apiBase), ct);\n            if (!IsOk(route))\n            {\n                return Fail(\u0022switching the relay\u0027s address on\u0022, 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 \u002B \u0022.\u0022 \u002B host;\n        string healthUrl = healthUrlFor(workerHost);\n        for (int attempt = 1; attempt \u003C= HealthAttempts; attempt\u002B\u002B)\n        {\n            progress(Step(6, \u0022waiting for the relay to answer (\u0022 \u002B attempt \u002B \u0022 of \u0022 \u002B HealthAttempts \u002B \u0022)\u0022));\n            CloudflareResponse health = await Send(new CloudflareRequest { Method = \u0022GET\u0022, Url = healthUrl }, ct);\n            if (health.Status == 200 \u0026\u0026 IsRelayHealth(health.Body))\n            {\n                return new DeployResult(true, CloudflareApi.RelayUrlFor(workerHost), null);\n            }\n            if (attempt \u003C HealthAttempts)\n            {\n                await delay(TimeSpan.FromSeconds(3), ct);\n            }\n        }\n        return new DeployResult(false, null, \u0022The relay was uploaded but \u0022 \u002B workerHost \u002B \u0022 did not answer within about thirty seconds. It is not in use. Try again in a minute; nothing needs to be cleaned up.\u0022);\n    }\n\n    private sealed record SubdomainOutcome(string Host, CloudflareResponse Failure);\n\n    private async Task\u003CSubdomainOutcome\u003E RegisterSubdomain(string root, string token, Action\u003Cstring\u003E progress, CancellationToken ct)\n    {\n        CloudflareResponse failure = null;\n        for (int attempt = 0; attempt \u003C SubdomainAttempts; attempt\u002B\u002B)\n        {\n            string candidate = newSubdomainName();\n            progress(Step(2, \u0022creating the workers.dev name \\\u0022\u0022 \u002B candidate \u002B \u0022\\\u0022\u0022));\n            using var body = CloudflareApi.Json(new JsonObject { [\u0022subdomain\u0022] = candidate });\n            CloudflareResponse created = await Send(CloudflareApi.Request(\u0022PUT\u0022, root \u002B \u0022/workers/subdomain\u0022, token, body, apiBase: apiBase), ct);\n            if (IsOk(created))\n            {\n                return new SubdomainOutcome(SubdomainHost(CloudflareApi.ResultOf(created.Body)) ?? candidate \u002B \u0022.workers.dev\u0022, 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\u003CCloudflareResponse\u003E 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, \u0022no answer\u0022);\n    }\n\n    private static bool IsOk(CloudflareResponse response)\n    {\n        return response != null \u0026\u0026 response.Status is \u003E= 200 and \u003C 300 \u0026\u0026 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 \u0022Step \u0022 \u002B number \u002B \u0022 of \u0022 \u002B Steps \u002B \u0022: \u0022 \u002B text \u002B \u0022\u2026\u0022;\n    }\n\n    private static string SubdomainHost(JsonNode result)\n    {\n        if (result is JsonObject obj \u0026\u0026 obj[\u0022subdomain\u0022] is JsonValue value \u0026\u0026 value.TryGetValue(out string name) \u0026\u0026 !string.IsNullOrWhiteSpace(name))\n        {\n            return name.Trim() \u002B \u0022.workers.dev\u0022;\n        }\n        return null;\n    }\n\n    private static string TagOf(JsonNode result)\n    {\n        if (result is JsonObject obj \u0026\u0026 obj[\u0022default_environment\u0022] is JsonObject environment \u0026\u0026 environment[\u0022script\u0022] is JsonObject script\n            \u0026\u0026 script[\u0022migration_tag\u0022] is JsonValue tag \u0026\u0026 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 \u0026\u0026 obj[\u0022status\u0022]?.GetValue\u003Cstring\u003E() == \u0022ok\u0022 \u0026\u0026 obj[\u0022service\u0022]?.GetValue\u003Cstring\u003E() == \u0022collab-relay\u0022;\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    /// \u003Csummary\u003EA name like \u003Cc\u003Ecollab-k3x9q2\u003C/c\u003E: readable, unlikely to collide, and changeable later in the Cloudflare dashboard.\u003C/summary\u003E\n    private static string DefaultSubdomainName()\n    {\n        const string alphabet = \u0022abcdefghijkmnpqrstuvwxyz23456789\u0022;\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 \u003C bytes.Length; i\u002B\u002B)\n        {\n            chars[i] = alphabet[bytes[i] % alphabet.Length];\n        }\n        return \u0022collab-\u0022 \u002B 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/// \u003Csummary\u003E\n/// Shared play on the host (one run, many spectators). The host is a relay and a referee, nothing more: it keeps the \u003Csee cref=\u0022PlayRoster\u0022/\u003E, tells\n/// the opted-in members who plays, and forwards the player\u0027s frames to the spectators. It never reads a frame\u0027s content (every spectator checks it\n/// again) and never touches the game\u0027s networking: these are collaboration packets on the collaboration connection.\n/// \u003C/summary\u003E\npublic sealed partial class SessionServer\n{\n    private readonly PlayRoster playRoster = new PlayRoster();\n\n    private readonly HashSet\u003CMember\u003E playOptIn = new HashSet\u003CMember\u003E();\n\n    private readonly Dictionary\u003CMember, bool\u003E playBehind = new Dictionary\u003CMember, bool\u003E();\n\n    private readonly System.Diagnostics.Stopwatch playClock = System.Diagnostics.Stopwatch.StartNew();\n\n    private double lastKeyframeAskMs = double.NegativeInfinity;\n\n    /// \u003Csummary\u003E\n    /// The host\u0027s 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 \u003Cc\u003Eplay-optin\u003C/c\u003E.\n    /// \u003C/summary\u003E\n    public bool SharedPlayEnabled { get; set; }\n\n    /// \u003Csummary\u003EThe peer id of the member playing, or null. For the host\u0027s own dock.\u003C/summary\u003E\n    public string PlayingPeer =\u003E playRoster.Player;\n\n    /// \u003Csummary\u003EThe queued backlog of a peer\u0027s connection, or null to read the wire\u0027s own counters. Tests replace it to simulate a slow spectator.\u003C/summary\u003E\n    public Func\u003Cstring, (long Bytes, int Messages)\u003E PlayBacklogProbe { get; set; }\n\n    private bool HandlePlayPacket( Member member, Packet packet )\n    {\n        if ( packet.Kind is not ( \u0022play-optin\u0022 or \u0022play-start\u0022 or \u0022play-stop\u0022 or \u0022play-frame\u0022 or \u0022play-key\u0022 ) )\n        {\n            return false;\n        }\n        if ( !SharedPlayEnabled )\n        {\n            return true;\n        }\n        switch ( packet.Kind )\n        {\n        case \u0022play-optin\u0022:\n            playOptIn.Add( member );\n            if ( playRoster.IsPlaying )\n            {\n                SendQuietly( member, StartPacket() );\n                AskForKeyframe();\n            }\n            break;\n        case \u0022play-start\u0022:\n            if ( playOptIn.Contains( member ) )\n            {\n                StartRun( member, packet );\n            }\n            break;\n        case \u0022play-stop\u0022:\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 \u0022play-frame\u0022:\n            if ( playOptIn.Contains( member ) \u0026\u0026 packet.Play != null \u0026\u0026 playRoster.IsPlayer( member.Peer, packet.RuntimeRunId ) )\n            {\n                RelayFrame( member, packet );\n            }\n            break;\n        case \u0022play-key\u0022:\n            if ( playOptIn.Contains( member ) \u0026\u0026 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, () =\u003E Guid.NewGuid().ToString( \u0022N\u0022 ) );\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 = \u0022play-refused\u0022, Notice = result.Notice, Peer = result.Player } );\n            break;\n        default:\n            SendQuietly( member, new Packet { Kind = \u0022play-refused\u0022, Notice = \u0022The 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.\u0022 } );\n            break;\n        }\n    }\n\n    private Packet StartPacket() =\u003E new Packet\n    {\n        Kind = \u0022play-start\u0022, Peer = playRoster.Player, Name = playRoster.PlayerName, Scene = playRoster.Scene, RuntimeRunId = playRoster.RunId,\n    };\n\n    private static Packet StopPacket( string peer, string run ) =\u003E new Packet { Kind = \u0022play-stop\u0022, Peer = peer, RuntimeRunId = run };\n\n    private void BroadcastPlay( Packet packet, Member except )\n    {\n        foreach ( Member target in playOptIn.Where( m =\u003E m != except \u0026\u0026 members.Contains( m ) ).ToList() )\n        {\n            SendQuietly( target, packet );\n        }\n    }\n\n    /// \u003Csummary\u003EA play message must never cost a member its session or the host a broadcast: a failed send is left for the pump\u0027s own failure handling.\u003C/summary\u003E\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[\u0022k\u0022] is System.Text.Json.Nodes.JsonValue k \u0026\u0026 k.TryGetValue( out bool key ) \u0026\u0026 key;\n        foreach ( Member spectator in playOptIn.Where( m =\u003E m != player \u0026\u0026 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 ) \u0026\u0026 b;\n            bool send = PlayRelayPolicy.ShouldSend( wasBehind, isKey, bytes, messages, out bool nowBehind );\n            playBehind[spectator] = nowBehind;\n            if ( nowBehind \u0026\u0026 !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 \u003C PlayRelayPolicy.KeyframeAskIntervalMs )\n        {\n            return;\n        }\n        Member player = members.FirstOrDefault( m =\u003E m.Peer == playRoster.Player );\n        if ( player == null )\n        {\n            return;\n        }\n        lastKeyframeAskMs = now;\n        SendQuietly( player, new Packet { Kind = \u0022play-key\u0022, RuntimeRunId = playRoster.RunId } );\n    }\n\n    /// \u003Csummary\u003EA member left or dropped: it no longer relays, and when it was the player the run ends for everyone.\u003C/summary\u003E\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/// \u003Csummary\u003EOpens the disposable copy in a second editor window. It only ever launches a folder that \u003Csee cref=\u0022SoloCopy\u0022/\u003E made.\u003C/summary\u003E\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) \u0026\u0026 File.Exists(self) ? self : null;\n        }\n        catch (Exception)\n        {\n            return null;\n        }\n    }\n\n    /// \u003Csummary\u003E\n    /// Memory the system can hand out right now: physical memory not in use, including the standby cache. Read from the runtime\u0027s own view of system\n    /// memory load, so it needs no native call. Null when the runtime cannot say.\n    /// \u003C/summary\u003E\n    public static long? AvailableMemoryBytes()\n    {\n        try\n        {\n            GCMemoryInfo info = GC.GetGCMemoryInfo();\n            if (info.TotalAvailableMemoryBytes \u003C= 0 || info.MemoryLoadBytes \u003C= 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 = \u0022solo-alive.txt\u0022;\n\n    /// \u003Csummary\u003E\n    /// True when the copy\u0027s own editor has reported in recently. The copy writes its process id and the time every few seconds\n    /// (\u003Csee cref=\u0022Beat\u0022/\u003E); a stale file, or an id that is no longer a running process, means the window is closed.\n    /// \u003C/summary\u003E\n    public static bool IsRunning(string copyRoot)\n    {\n        try\n        {\n            string path = Path.Combine(copyRoot, \u0022.collaboration\u0022, HeartbeatFile);\n            if (!File.Exists(path))\n            {\n                return false;\n            }\n            string[] lines = File.ReadAllLines(path);\n            if (lines.Length \u003C 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 \u003E 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    /// \u003Csummary\u003ECalled from the copy\u0027s frame tick.\u003C/summary\u003E\n    public static void Beat(string root)\n    {\n        if (DateTime.UtcNow \u003C nextBeat)\n        {\n            return;\n        }\n        nextBeat = DateTime.UtcNow.AddSeconds(5);\n        try\n        {\n            File.WriteAllLines(Path.Combine(root, \u0022.collaboration\u0022, HeartbeatFile), new[] { Environment.ProcessId.ToString(), DateTime.UtcNow.ToString(\u0022o\u0022) });\n        }\n        catch (Exception)\n        {\n        }\n    }\n\n    /// \u003Csummary\u003EReturns null on success, or a sentence saying what went wrong.\u003C/summary\u003E\n    public static string Launch(string copyRoot)\n    {\n        if (!TestHarnessRules.IsDisposableCopy(copyRoot, SoloCopy.HasMarker(copyRoot)))\n        {\n            return \u0022That folder is not a solo test copy, so it was not opened.\u0022;\n        }\n        string exe = ExecutablePath();\n        if (exe == null)\n        {\n            return \u0022The editor\u0027s program file could not be found, so the copy could not be opened. Open \u0022 \u002B copyRoot \u002B \u0022 from the s\u0026box launcher instead; the test starts by itself.\u0022;\n        }\n        string project = Directory.EnumerateFiles(copyRoot, \u0022*.sbproj\u0022, SearchOption.TopDirectoryOnly).FirstOrDefault();\n        if (project == null)\n        {\n            return \u0022The copy has no project file.\u0022;\n        }\n        try\n        {\n            Process.Start(new ProcessStartInfo\n            {\n                FileName = exe,\n                Arguments = \u0022-project \\\u0022\u0022 \u002B project \u002B \u0022\\\u0022 -novid\u0022,\n                UseShellExecute = false\n            });\n            return null;\n        }\n        catch (Exception ex)\n        {\n            return \u0022The solo test window could not be opened: \u0022 \u002B ex.Message \u002B \u0022 Open \u0022 \u002B copyRoot \u002B \u0022 from the s\u0026box launcher instead; the test starts by itself.\u0022;\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/// \u003Csummary\u003E\n/// The one-click solo test. It runs only inside a disposable copy of the project (see \u003Csee cref=\u0022SoloCopy\u0022/\u003E), 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\u0027s real scene:\n/// the host admits the collaborator through its normal checks, the collaborator\u0027s edits arrive in this editor, this editor\u0027s 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/// \u003C/summary\u003E\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        /// \u003Csummary\u003ERuns once when the step settles, whatever the outcome, so a step that timed out cannot leave a collaborator holding the test identity.\u003C/summary\u003E\n        public Action Cleanup;\n\n        /// \u003Csummary\u003EReturns the step\u0027s state: Running keeps waiting, anything else settles it.\u003C/summary\u003E\n        public Func\u003C(SoloStepState State, string Detail)\u003E Poll;\n    }\n\n    public const string ReportFile = \u0022solo-report.json\u0022;\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\u003CStep\u003E steps = new List\u003CStep\u003E();\n\n    private static readonly List\u003CSoloStep\u003E views = new List\u003CSoloStep\u003E();\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 = \u0022Not run yet.\u0022;\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\u003CSoloBot\u003E impostors = new List\u003CSoloBot\u003E();\n\n    private static Task\u003Cstring\u003E 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\u003CSoloStep\u003E Steps =\u003E views;\n\n    public static string Headline =\u003E headline;\n\n    public static bool Running =\u003E phase == Phase.Steps;\n\n    public static bool Watching =\u003E phase == Phase.Demo;\n\n    public static bool Finished =\u003E phase == Phase.Done;\n\n    public static bool Active =\u003E phase != Phase.Idle \u0026\u0026 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    /// \u003Csummary\u003ETrue only inside a folder the launcher made (right path and marker file); the same test the session applies.\u003C/summary\u003E\n    public static bool IsSoloCopy =\u003E TestHarnessRules.IsDisposableCopy(Root, SoloCopy.HasMarker(Root));\n\n    private static CollaborationSession Collab =\u003E 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 \u0026\u0026 (DateTime.UtcNow - processStarted).TotalSeconds \u003E 10)\n            {\n                autorunHandled = true;\n                if (IsSoloCopy \u0026\u0026 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(\u0022The solo test stopped on an unexpected error: \u0022 \u002B ex.Message);\n        }\n    }\n\n    public const string CommandFile = \u0022solo-command.txt\u0022;\n\n    private static DateTime nextCommandCheck;\n\n    /// \u003Csummary\u003E\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    /// \u003C/summary\u003E\n    private static void PollCommand()\n    {\n        if (DateTime.UtcNow \u003C nextCommandCheck)\n        {\n            return;\n        }\n        nextCommandCheck = DateTime.UtcNow.AddSeconds(1);\n        try\n        {\n            string path = Path.Combine(Root, \u0022.collaboration\u0022, 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 == \u0022stop\u0022)\n            {\n                Stop();\n            }\n            else if (word == \u0022start\u0022)\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(\u0022The solo test only runs inside a disposable solo test copy. Use Run solo test in the Collaboration dock to make one.\u0022);\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 = \u0022Running\u2026\u0022;\n        WriteReport(\u0022running\u0022);\n    }\n\n    /// \u003Csummary\u003E\n    /// Ends the test in two phases. First the collaborators go and the test\u0027s 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    /// \u003C/summary\u003E\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 \u0026\u0026 Find(stopObject) != null;\n        }\n        catch (Exception)\n        {\n        }\n        bool pending = false;\n        try\n        {\n            pending = Collab.Connected \u0026\u0026 !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 = \u0022Stopped before this ran.\u0022;\n            }\n        }\n        phase = Phase.Done;\n        headline = stopHeadline ?? SoloReport.Summary(views, true) \u002B (stopWasDemo ? \u0022 Session ended.\u0022 : string.Empty);\n        WriteReport(stopState ?? (stopWasDemo ? \u0022stopped\u0022 : \u0022aborted\u0022));\n        stopState = null;\n        stopHeadline = null;\n    }\n\n    private static void Abort(string message)\n    {\n        if (index \u003C steps.Count \u0026\u0026 steps[index].View.State == SoloStepState.Running)\n        {\n            steps[index].View.State = SoloStepState.Failed;\n            steps[index].View.Detail = message;\n        }\n        stopState = \u0022aborted\u0022;\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 \u003E= 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 \u003E step.TimeoutSeconds)\n            {\n                step.View.State = SoloStepState.Failed;\n                step.View.Detail = \u0022Timed out after \u0022 \u002B step.TimeoutSeconds.ToString(\u00220\u0022, CultureInfo.InvariantCulture) \u002B \u0022 seconds. \u0022 \u002B (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 \u0026\u0026 step.Critical)\n        {\n            for (int i = index \u002B 1; i \u003C steps.Count; i\u002B\u002B)\n            {\n                steps[i].View.State = SoloStepState.Skipped;\n                steps[i].View.Detail = \u0022Not run because \\\u0022\u0022 \u002B step.View.Title \u002B \u0022\\\u0022 failed.\u0022;\n            }\n            index = steps.Count;\n            FinishChecks();\n            return;\n        }\n        index\u002B\u002B;\n        if (index \u003C steps.Count)\n        {\n            BeginStep();\n        }\n        WriteReport(\u0022running\u0022);\n    }\n\n    private static void BeginStep()\n    {\n        Step step = steps[index];\n        if (step.View.Id == \u0022demo\u0022 \u0026\u0026 !SoloReport.DemoAllowed(views))\n        {\n            step.View.State = SoloStepState.Skipped;\n            step.View.Detail = \u0022Not started because a check failed.\u0022;\n            index\u002B\u002B;\n            if (index \u003C 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) =\u003E s.State != SoloStepState.Skipped).ToList()) \u0026\u0026 !views.Any((SoloStep s) =\u003E s.State == SoloStepState.Failed);\n        headline = SoloReport.Summary(views, true);\n        if (!ok)\n        {\n            stopState = \u0022checks-failed\u0022;\n            stopHeadline = headline;\n            phase = Phase.Steps;\n            Stop();\n            return;\n        }\n        phase = Phase.Demo;\n        headline \u002B= \u0022 The collaborator is now orbiting your scene. Press Stop to end.\u0022;\n        WriteReport(\u0022checks-passed\u0022);\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 \u0026\u0026 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) =\u003E !(x is GameEditorSession) \u0026\u0026 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) =\u003E x.IsValid() \u0026\u0026 string.Equals(x.Id.ToString(), state.Id, StringComparison.Ordinal));\n    }\n\n    private static Vector3 ParseVector(string text)\n    {\n        float[] n = text.Split(\u0027,\u0027).Select((string x) =\u003E 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 \u003C 0.5f;\n    }\n\n    private static string PositionOf(SoloBot who)\n    {\n        return who != null \u0026\u0026 cube != null \u0026\u0026 who.Known(scenePath).TryGetValue(cube.Id, out ObjectState s) ? s.Data[\u0022Position\u0022]?.GetValue\u003Cstring\u003E() : 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(() =\u003E Wire.Connect(\u0022127.0.0.1\u0022, 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(() =\u003E RelayGuest.ConnectAsync(() =\u003E 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    /// \u003Csummary\u003EThe collaborator\u0027s marker circles the scene and looks at its middle, so the sphere is easy to find and to watch.\u003C/summary\u003E\n    private static void DriveOrbit()\n    {\n        SoloBot who = demoBot ?? bot ?? relayBot;\n        if (who == null || !who.IsLive || scenePath == null || DateTime.UtcNow \u003C 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 ? \u0022editing\u0022 : \u0022editing\u0022));\n    }\n\n    private static void RunDemo()\n    {\n        if (demoBot == null || !demoBot.IsLive || demoCube == null || DateTime.UtcNow \u003C nextDemoEdit)\n        {\n            return;\n        }\n        nextDemoEdit = DateTime.UtcNow.AddMilliseconds(400);\n        demoAngle \u002B= 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    /// \u003Csummary\u003E\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    /// \u003C/summary\u003E\n    private static void OpenDock()\n    {\n        try\n        {\n            Sandbox.Internal.GlobalToolsNamespace.EditorWindow.DockManager.OpenDock(\u0022Collaboration\u0022, 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\u003C(SoloStepState, string)\u003E 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(\u0022scene\u0022, \u0022A saved scene is open and fully loaded\u0022, 60, true, () =\u003E\n        {\n            foreach (SceneEditorSession open in SceneEditorSession.All.Where((SceneEditorSession x) =\u003E !(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(\u0022scenes/\u0022, StringComparison.Ordinal))\n            {\n                string folder = Path.Combine(SceneAdapter.Root, \u0022Assets\u0022, \u0022scenes\u0022);\n                string first = Directory.Exists(folder) ? Directory.EnumerateFiles(folder, \u0022*.scene\u0022).OrderBy((string x) =\u003E x, StringComparer.OrdinalIgnoreCase).FirstOrDefault() : null;\n                if (first == null)\n                {\n                    throw new InvalidOperationException(\u0022The copy has no scene under Assets/scenes. Save one in the project first.\u0022);\n                }\n                SceneEditorSession.CreateFromPath(\u0022scenes/\u0022 \u002B Path.GetFileName(first))?.MakeActive();\n            }\n        }, () =\u003E\n        {\n            string active = SceneAdapter.PathOf(SceneEditorSession.Active);\n            if (active == null || !active.StartsWith(\u0022scenes/\u0022, StringComparison.Ordinal) || SceneEditorSession.Active is GameEditorSession)\n            {\n                return Wait(\u0022Opening a scene\u2026\u0022);\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\u0027s 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, \u0022Assets\u0022, active.Replace(\u0027/\u0027, Path.DirectorySeparatorChar)));\n            SceneEditorSession session = SceneSession();\n            int loaded = session?.Scene != null \u0026\u0026 session.Scene.IsValid() ? session.Scene.Children.Count((GameObject x) =\u003E 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(\u0022host\u0022, \u0022Host session starts\u0022, 150, true, () =\u003E\n        {\n            runToken = TestHarnessRules.NewRunToken();\n            string token = runToken;\n            CollaborationSession.SoloGuestVoucher = (Packet hello) =\u003E TestHarnessRules.MayVouchFor(hello.SteamId, hello.SteamToken, token);\n            if (Collab.Busy)\n            {\n                Collab.Leave();\n            }\n            tokenTask = Auth.GetToken(LocalSteamIdentity.ServiceName);\n        }, () =\u003E\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(\u0022Waiting for the readiness checks (Steam, publication, organization)\u2026\u0022);\n                }\n                if (readiness == SoloHostReadiness.Blocked)\n                {\n                    return Fail(ReadinessRules.BlockReason(Collab.Readiness) ?? \u0022The readiness checklist never completed. Open the Collaboration dock and check the Readiness card.\u0022);\n                }\n                if (!tokenTask.IsCompleted)\n                {\n                    return Wait(\u0022Waiting for a fresh Steam identity token\u2026\u0022);\n                }\n                if (tokenTask.IsFaulted || tokenTask.IsCanceled || string.IsNullOrWhiteSpace(tokenTask.Result))\n                {\n                    return Fail(\u0022A Steam identity token could not be obtained. Sign in to Steam and try again.\u0022);\n                }\n                if (!LocalSteamIdentity.TryGet(out SteamIdentity identity, out string persona, out string failure))\n                {\n                    return Fail(failure ?? \u0022No signed-in Steam account is available.\u0022);\n                }\n                Collab.StartHosted(27845, persona, SteamIdentityRules.Canonical(identity), tokenTask.Result, allowUnverifiedPeers: false);\n                hostStarted = true;\n                return Wait(\u0022Starting the host\u2026\u0022);\n            }\n            if (Collab.Error != null)\n            {\n                return Fail(Collab.Error);\n            }\n            if (!Collab.SoloHarness)\n            {\n                return Fail(\u0022The test identity is not active in this folder, so a collaborator could not be admitted.\u0022);\n            }\n            if (Collab.Connected \u0026\u0026 Collab.IsHost \u0026\u0026 Collab.InviteCode != null)\n            {\n                return Pass(\u0022Hosting with the signed-in Steam account.\u0022);\n            }\n            return Wait(Collab.Status);\n        });\n\n        Add(\u0022live\u0022, \u0022The host\u0027s scene is live\u0022, 25, true, null, () =\u003E\n        {\n            if (Collab.Error != null)\n            {\n                return Fail(Collab.Error);\n            }\n            return Collab.ReadyScenes.Contains(scenePath) ? Pass(scenePath \u002B \u0022 is synchronized.\u0022) : Wait(\u0022Waiting for the host to publish the scene\u2026\u0022);\n        });\n\n        Add(\u0022relay\u0022, \u0022Relay is registered\u0022, 20, false, null, () =\u003E\n        {\n            string status = Collab.RelayStatus;\n            if (status == null)\n            {\n                return (SoloStepState.Skipped, \u0022No relay is configured for this project (RelayUrl), so only direct routes are tested.\u0022);\n            }\n            if (Collab.RelayFailed)\n            {\n                return Fail(status);\n            }\n            return status.StartsWith(\u0022Relay connected\u0022, StringComparison.Ordinal) ? Pass(\u0022The relay accepted this session\u0027s room.\u0022) : Wait(status);\n        });\n\n        Add(\u0022join\u0022, \u0022A collaborator joins and passes the host\u0027s checks\u0022, 30, true, () =\u003E\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(\u0022The host\u0027s own invite code did not decode: \u0022 \u002B error);\n            }\n            sessionKey = InviteCode.DeriveKey(invite.Secret, facts.ProjectId);\n            bot = NewDirectBot(TestHarnessRules.SoloGuestSteamId, TestHarnessRules.SoloGuestName, runToken);\n        }, () =\u003E\n        {\n            if (bot.State == SoloBotState.Refused || bot.State == SoloBotState.Failed)\n            {\n                return Fail(\u0022The host refused the collaborator: \u0022 \u002B bot.Refusal);\n            }\n            if (bot.IsLive)\n            {\n                bot.Subscribe(scenePath);\n                return Pass(\u0022Admitted as \\\u0022\u0022 \u002B TestHarnessRules.SoloGuestName \u002B \u0022\\\u0022 after the host\u0027s normal project, baseline, library and manifest checks.\u0022);\n            }\n            return Wait(\u0022Connecting\u2026\u0022);\n        });\n\n        Add(\u0022marker\u0022, \u0022Its floating marker appears in your scene view\u0022, 20, false, null, () =\u003E\n        {\n            bool inRoster = Collab.Roster.Values.Any((Packet x) =\u003E x.SteamId == TestHarnessRules.SoloGuestSteamId \u0026\u0026 x.Presence != null);\n            if (!inRoster)\n            {\n                return Wait(\u0022Waiting for the collaborator\u0027s presence\u2026\u0022);\n            }\n            return Collab.DrawnMarkers \u003E 0 ? Pass(\u0022The blue sphere is drawn in the scene view, orbiting the middle of the scene.\u0022) : Wait(\u0022The collaborator is in the roster; waiting for the scene view to draw its marker. Open a scene view if none is visible.\u0022);\n        });\n\n        Add(\u0022create\u0022, \u0022Its new object appears in your scene\u0022, 20, true, () =\u003E\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        }, () =\u003E Find(cube) != null ? Pass(\u0022\\\u0022\u0022 \u002B SoloScript.ObjectName \u002B \u0022\\\u0022 appeared in this editor.\u0022) : Wait());\n\n        Vector3 movedTo = new Vector3(70, -50, 80);\n        Add(\u0022move\u0022, \u0022Its move appears in your scene as it happens\u0022, 20, true, () =\u003E\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        }, () =\u003E\n        {\n            GameObject go = Find(cube);\n            return go != null \u0026\u0026 Near(go.WorldPosition, movedTo) ? Pass(\u0022Position changed to \u0022 \u002B go.WorldPosition \u002B \u0022.\u0022) : Wait();\n        });\n\n        string tintText = SoloScript.TintAt(0.62);\n        Add(\u0022property\u0022, \u0022Its property change appears in your scene\u0022, 20, true, () =\u003E\n        {\n            ObjectState next = SoloScript.WithTint(cube, tintText);\n            bot.SendEdit(scenePath, new[] { SoloScript.Update(cube, next) });\n            cube = next;\n        }, () =\u003E\n        {\n            GameObject go = Find(cube);\n            ModelRenderer renderer = go?.Components.Get\u003CModelRenderer\u003E();\n            if (renderer == null)\n            {\n                return Wait();\n            }\n            float[] want = tintText.Split(\u0027,\u0027).Select((string x) =\u003E float.Parse(x, CultureInfo.InvariantCulture)).ToArray();\n            Color have = renderer.Tint;\n            return Math.Abs(have.r - want[0]) \u003C 0.02f \u0026\u0026 Math.Abs(have.g - want[1]) \u003C 0.02f \u0026\u0026 Math.Abs(have.b - want[2]) \u003C 0.02f ? Pass(\u0022The renderer tint changed.\u0022) : Wait();\n        });\n\n        Vector3 hostMoved = new Vector3(-90, 60, 100);\n        Add(\u0022reverse\u0022, \u0022Your move reaches the collaborator as it happens\u0022, 20, true, () =\u003E\n        {\n            GameObject go = Find(cube);\n            if (go == null)\n            {\n                throw new InvalidOperationException(\u0022The test object is not in this scene.\u0022);\n            }\n            go.LocalPosition = hostMoved;\n        }, () =\u003E\n        {\n            string seen = PositionOf(bot);\n            return seen != null \u0026\u0026 Near(ParseVector(seen), hostMoved) ? Pass(\u0022The collaborator saw the position change to \u0022 \u002B seen \u002B \u0022.\u0022) : Wait();\n        });\n\n        bool undone = false;\n        Add(\u0022undo\u0022, \u0022Undo reverses only your own move\u0022, 25, false, () =\u003E undone = false, () =\u003E\n        {\n            if (!undone)\n            {\n                try\n                {\n                    Collab.Undo();\n                    undone = true;\n                }\n                catch (InvalidOperationException)\n                {\n                    return Wait(\u0022Waiting for your move to be acknowledged\u2026\u0022);\n                }\n            }\n            string seen = PositionOf(bot);\n            bool botsTintKept = bot.Known(scenePath).TryGetValue(cube.Id, out ObjectState s) \u0026\u0026 (s.Data[\u0022Components\u0022] as JsonArray)?[0]?[\u0022Tint\u0022]?.GetValue\u003Cstring\u003E() == tintText;\n            return seen != null \u0026\u0026 Near(ParseVector(seen), movedTo) \u0026\u0026 botsTintKept ? Pass(\u0022Back to \u0022 \u002B seen \u002B \u0022; the collaborator\u0027s own change to the object was not undone.\u0022) : Wait();\n        });\n\n        int rejectsBefore = 0;\n        Add(\u0022conflict\u0022, \u0022A stale edit is refused, not silently applied\u0022, 20, false, () =\u003E\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, \u00221,1,1\u0022);\n            bot.SendEdit(scenePath, new[] { SoloScript.Update(old, SoloScript.WithPosition(old, \u00225,5,5\u0022)) });\n        }, () =\u003E\n        {\n            if (bot.Rejects.Count \u003C= rejectsBefore)\n            {\n                return Wait();\n            }\n            GameObject go = Find(cube);\n            return go != null \u0026\u0026 Near(go.WorldPosition, movedTo) ? Pass(\u0022The host answered: \u0022 \u002B Truncate(bot.Rejects.Last().Error, 110)) : Fail(\u0022The refused edit still changed the scene.\u0022);\n        });\n\n        Vector3 whilePlaying = new Vector3(10, 20, 110);\n        int playPhase = 0;\n        Add(\u0022play\u0022, \u0022Playing pauses scene edits; changes wait until you stop\u0022, 60, false, () =\u003E\n        {\n            playPhase = 0;\n            EditorScene.Play();\n        }, () =\u003E\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(\u0022Starting the game\u2026\u0022);\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(\u0022Playing; the collaborator changes the object\u2026\u0022);\n            }\n            if (playPhase == 1)\n            {\n                GameObject go = Find(cube);\n                if (Collab.HeldSceneChanges \u003C 1)\n                {\n                    return Wait(\u0022Waiting for the collaborator\u0027s change to reach this editor\u2026\u0022);\n                }\n                if (go != null \u0026\u0026 Near(go.WorldPosition, whilePlaying))\n                {\n                    return Fail(\u0022The change was applied while playing instead of being held.\u0022);\n                }\n                EditorScene.Stop();\n                playPhase = 2;\n                return Wait(\u0022Held \u0022 \u002B Collab.HeldSceneChanges \u002B \u0022 change(s); stopping the game\u2026\u0022);\n            }\n            GameObject after = Find(cube);\n            return Collab.PlayDecision == PlaySpectateDecision.Normal \u0026\u0026 after != null \u0026\u0026 Near(after.WorldPosition, whilePlaying) ? Pass(\u0022The change made while you were playing applied when you stopped.\u0022) : Wait(\u0022Applying held changes\u2026\u0022);\n        });\n\n        Add(\u0022delete\u0022, \u0022Its delete appears in your scene\u0022, 20, false, () =\u003E\n        {\n            bot.SendEdit(scenePath, new[] { SoloScript.Delete(cube) });\n        }, () =\u003E Find(cube) == null ? Pass(\u0022The object is gone from this editor.\u0022) : Wait());\n\n        DateTime saveAt = default;\n        Add(\u0022save\u0022, \u0022Saving works while a collaborator is connected\u0022, 30, false, () =\u003E saveAt = default, () =\u003E\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(\u0022Waiting to save: \u0022 \u002B (Collab.WhyCannotSave() ?? \u0022a check is still settling\u0022) \u002B \u0022.\u0022);\n                }\n                Collab.Save();\n                saveAt = DateTime.UtcNow;\n                return Wait(\u0022Saving\u2026\u0022);\n            }\n            if ((DateTime.UtcNow - saveAt).TotalSeconds \u003C 1.5)\n            {\n                return Wait(\u0022Saving\u2026\u0022);\n            }\n            return Collab.Error == null \u0026\u0026 bot.IsLive ? Pass(\u0022Saved the synchronized scene; the collaborator stayed connected.\u0022) : Fail(Collab.Error ?? \u0022The collaborator was dropped by the save.\u0022);\n        });\n\n        Add(\u0022leave\u0022, \u0022The collaborator leaves cleanly\u0022, 15, false, () =\u003E bot.Leave(), () =\u003E\n        {\n            bool gone = !Collab.Roster.Values.Any((Packet x) =\u003E x.SteamId == TestHarnessRules.SoloGuestSteamId);\n            if (gone)\n            {\n                bot = null;\n            }\n            return gone ? Pass(\u0022Its row and marker were removed at once.\u0022) : Wait();\n        });\n\n        int relayPhase = 0;\n        Add(\u0022via-relay\u0022, \u0022A collaborator joins and edits through the relay\u0022, 45, false, () =\u003E\n        {\n            relayPhase = 0;\n            if (Collab.RelayStatus == null || Collab.RelayFailed || string.IsNullOrEmpty(invite?.Relay))\n            {\n                return;\n            }\n            relayBot = NewRelayBot();\n        }, () =\u003E\n        {\n            if (relayBot == null)\n            {\n                return (SoloStepState.Skipped, \u0022No working relay for this session, so this route was not tested.\u0022);\n            }\n            if (relayBot.State == SoloBotState.Refused || relayBot.State == SoloBotState.Failed)\n            {\n                return Fail(\u0022The relay route failed: \u0022 \u002B relayBot.Refusal);\n            }\n            if (relayPhase == 0)\n            {\n                if (!relayBot.IsLive)\n                {\n                    return Wait(\u0022Dialling the relay\u2026\u0022);\n                }\n                relayBot.Subscribe(scenePath);\n                relayPhase = 1;\n                return Wait(\u0022Admitted through the relay; subscribing\u2026\u0022);\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(\u0022Sending an edit through the relay\u2026\u0022);\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(\u0022The object arrived; removing it\u2026\u0022);\n            }\n            if (Find(cube) != null)\n            {\n                return Wait();\n            }\n            relayBot.Leave();\n            relayBot = null;\n            return Pass(\u0022Joined, subscribed, created and deleted an object, all through the relay.\u0022);\n        }, () =\u003E\n        {\n            relayBot?.Leave();\n            relayBot = null;\n        });\n\n        int closedPhase = 0;\n        Add(\u0022closed\u0022, \u0022The test identity opens nothing else\u0022, 30, false, () =\u003E closedPhase = 0, () =\u003E\n        {\n            if (closedPhase == 0)\n            {\n                // The earlier steps\u0027 collaborators say goodbye and the host relays it; the reserved account must be gone from the roster before this check\n                // starts, or \u0022already connected\u0022 would be the refusal and would prove nothing about the token.\n                if (Collab.Roster.Values.Any((Packet x) =\u003E x.SteamId == TestHarnessRules.SoloGuestSteamId))\n                {\n                    return Wait(\u0022Waiting for the earlier collaborator to finish leaving\u2026\u0022);\n                }\n                impostors.Add(NewDirectBot(TestHarnessRules.SoloGuestSteamId, \u0022Wrong token\u0022, TestHarnessRules.NewRunToken()));\n                impostors.Add(NewDirectBot(\u002276561198000000002\u0022, \u0022Other account\u0022, runToken));\n                closedPhase = 1;\n                return Wait(\u0022Trying a wrong token and a different account\u2026\u0022);\n            }\n            if (impostors.Any((SoloBot x) =\u003E x.IsLive))\n            {\n                return Fail(\u0022A collaborator without the test token was admitted.\u0022);\n            }\n            if (impostors.Any((SoloBot x) =\u003E 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 \u0022already connected\u0022) 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(\u0022rejected\u0022, StringComparison.OrdinalIgnoreCase) \u003C 0)\n                {\n                    return Fail(\u0022Refused for the wrong reason, or not refused: \u0022 \u002B x.State \u002B \u0022 \u0022 \u002B Truncate(x.Refusal, 100));\n                }\n            }\n            return Pass(\u0022A wrong token and a different account were both refused as: \u0022 \u002B Truncate(wrong?.Refusal, 90));\n        }, () =\u003E\n        {\n            foreach (SoloBot x in impostors)\n            {\n                x.Dispose();\n            }\n            impostors.Clear();\n        });\n\n        Add(\u0022demo\u0022, \u0022Live demo: the collaborator moves an object in your scene\u0022, 45, false, () =\u003E\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        }, () =\u003E\n        {\n            if (demoBot.State == SoloBotState.Refused || demoBot.State == SoloBotState.Failed)\n            {\n                return Fail(\u0022The demo collaborator could not join: \u0022 \u002B demoBot.Refusal);\n            }\n            if (!demoBot.IsLive)\n            {\n                return Wait(\u0022Connecting\u2026\u0022);\n            }\n            if (!demoBot.HasScene(scenePath))\n            {\n                demoBot.Subscribe(scenePath);\n                return Wait(\u0022Subscribing\u2026\u0022);\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(\u0022Running. Watch the blue sphere and the cube; press Stop to end.\u0022) : 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 \u003C= max ? text : text.Substring(0, max - 1) \u002B \u0022\u2026\u0022;\n    }\n\n    // ---- report --------------------------------------------------------------------------------------------------------\n\n    private static void WriteReport(string state)\n    {\n        try\n        {\n            string path = Path.Combine(Root, \u0022.collaboration\u0022, ReportFile);\n            Directory.CreateDirectory(Path.GetDirectoryName(path));\n            string json = JsonSerializer.Serialize(new\n            {\n                state,\n                summary = SoloReport.Summary(views, state != \u0022running\u0022),\n                headline,\n                updatedUtc = DateTime.UtcNow.ToString(\u0022o\u0022, CultureInfo.InvariantCulture),\n                steps = views.Select((SoloStep s) =\u003E new { id = s.Id, title = s.Title, state = s.State.ToString(), detail = s.Detail }).ToArray()\n            }, new JsonSerializerOptions { WriteIndented = true });\n            File.WriteAllText(path \u002B \u0022.tmp\u0022, json);\n            File.Move(path \u002B \u0022.tmp\u0022, path, true);\n        }\n        catch (Exception)\n        {\n        }\n    }\n\n    /// \u003Csummary\u003EThe last written report, for the dock\u0027s \u0022last run\u0022 line. Null when there is none.\u003C/summary\u003E\n    public static string LastRunSummary(string root)\n    {\n        try\n        {\n            string path = Path.Combine(root, \u0022.collaboration\u0022, ReportFile);\n            if (!File.Exists(path))\n            {\n                return null;\n            }\n            JsonNode node = JsonNode.Parse(File.ReadAllText(path));\n            return node?[\u0022summary\u0022]?.GetValue\u003Cstring\u003E();\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\u003Cstring, ObjectState\u003E Known;\r\n\r\n        public Packet Pending;\r\n\r\n        public bool Ready;\r\n\r\n        public Dictionary\u003Cstring, Change\u003E Group = new Dictionary\u003Cstring, Change\u003E();\r\n\r\n        public Stack\u003CList\u003CChange\u003E\u003E Undo = new Stack\u003CList\u003CChange\u003E\u003E();\r\n\r\n        public bool UndoPending;\r\n\r\n        // The scene\u0027s 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\u0027s answer, like Pending for edits.\r\n        public Packet SettingsPending;\r\n\r\n        // What this editor last wrote into the scene\u0027s 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\u0027s 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\u003Cstring\u003E SkippedObjects = new HashSet\u003Cstring\u003E(StringComparer.Ordinal);\r\n        // The person\u0027s state to replay after a resync, when the live scene could not be put back after a failed apply.\r\n        public Dictionary\u003Cstring, ObjectState\u003E 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\u003CEndpointResolver.EndpointCandidate\u003E 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\u003Cstring, Tracked\u003E scenes = new Dictionary\u003Cstring, Tracked\u003E();\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 \u002B 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\u003CTracked\u003E undoResetScenes = new HashSet\u003CTracked\u003E();\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\u003CWire\u003E 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\u003Cstring\u003E 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\u003Cstring\u003E 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 =\u003E 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\u003Cstring\u003E resyncWhenRoutable = new HashSet\u003Cstring\u003E(StringComparer.Ordinal);\r\n    private readonly HashSet\u003Cstring\u003E closedPacketNotices = new HashSet\u003Cstring\u003E(StringComparer.Ordinal);\r\n    private readonly HashSet\u003Cstring\u003E onceNotices = new HashSet\u003Cstring\u003E(StringComparer.Ordinal);\r\n\r\n    private bool inviteCarriesRelay;\r\n\r\n    /// \u003Csummary\u003EThe relay this session uses: the project\u0027s own if it names one, none if it says \u0022off\u0022, otherwise the one built into the library.\u003C/summary\u003E\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    /// \u003Csummary\u003EWhat the project\u0027s own settings say about the relay (an address, \u0022off\u0022, or nothing), for the Relay card.\u003C/summary\u003E\r\n    public string ConfiguredRelayUrl =\u003E sessionConfig?.RelayUrl;\r\n\r\n    private byte[] activeSecret;\r\n\r\n    private List\u003CEndpointResolver.EndpointCandidate\u003E 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\u003CPackage\u003E 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\u003Cstring\u003E seen = new HashSet\u003Cstring\u003E();\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\u003Cstring\u003E sentSettings = new HashSet\u003Cstring\u003E();\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 = \u0022Collaboration Undo\u0022;\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; } = \u0022Offline\u0022;\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\u0027s 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\u0027s 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 \u0026\u0026 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 \u0026\u0026 affected.Session.Scene.IsValid() \u0026\u0026 !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(\u0022Collaboration: \u0022 \u002B notice);\r\n            SetActionNotice(notice);\r\n        }\r\n        string hostNotice = server?.BlobNotice;\r\n        if (hostNotice != null \u0026\u0026 !string.Equals(hostNotice, lastHostBlobNotice, StringComparison.Ordinal))\r\n        {\r\n            lastHostBlobNotice = hostNotice;\r\n            GlobalGameNamespace.Log.Info(\u0022Collaboration: \u0022 \u002B 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() =\u003E SceneEditorSession.All.Where( x =\u003E !(x is GameEditorSession) \u0026\u0026 !x.IsPrefabSession ).Select( x =\u003E SceneAdapter.PathOf( x ) ).Where( x =\u003E x != null ).Select( x =\u003E \u0022Assets/\u0022 \u002B x ).ToArray();\r\n\r\n    // Makes the editor notice a file synchronization wrote or replaced: AssetSystem.FindByPath / RegisterFile (\u0022If you just created an asset,\r\n    // you probably want to immediately register it\u0022, Sandbox.Tools.xml) and Asset.Compile(false), the call the editor\u0027s own MCP\r\n    // AssetSystem.cs makes after writing a source file. A deleted file is left to the engine\u0027s 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( \u0027/\u0027, Path.DirectorySeparatorChar ) );\r\n            if ( !File.Exists( absolute ) ) return;\r\n            Asset asset = AssetSystem.FindByPath( absolute ) ?? AssetSystem.RegisterFile( absolute );\r\n            if ( asset != null \u0026\u0026 asset.CanRecompile ) asset.Compile( false );\r\n        }\r\n        catch ( Exception ex )\r\n        {\r\n            GlobalGameNamespace.Log.Warning( \u0022Collaboration: the asset \\\u0022\u0022 \u002B relativePath \u002B \u0022\\\u0022 was received but the editor could not refresh it: \u0022 \u002B 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( \u0022Collaboration: \u0022 \u002B notice );\r\n            SetActionNotice( notice );\r\n        }\r\n        string hostNotice = server?.FileNotice;\r\n        if ( hostNotice != null \u0026\u0026 !string.Equals( hostNotice, lastHostFileNotice, StringComparison.Ordinal ) )\r\n        {\r\n            lastHostFileNotice = hostNotice;\r\n            GlobalGameNamespace.Log.Info( \u0022Collaboration: \u0022 \u002B 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(\u0022Reconnecting\u0022, 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 =\u003E server != null;\r\n\r\n    public CollaborationConfig Config =\u003E sessionConfig;\r\n\r\n    public OrgDirectory OrganizationDirectory =\u003E orgDirectory;\r\n\r\n    public string InviteCode =\u003E inviteCodeValue;\r\n\r\n    public IReadOnlyList\u003CDiscoveredHost\u003E DiscoveredHosts =\u003E discovery?.Hosts;\r\n\r\n    public string PublicEndpointStatus { get; private set; }\r\n\r\n    /// \u003Csummary\u003EHow this editor is connected: the route that actually carried the join, for the dock to show.\u003C/summary\u003E\r\n    public string RouteDescription { get; private set; }\r\n\r\n    /// \u003Csummary\u003E\r\n    /// TEST-ONLY SEAM. Nothing in this library sets it; only the workspace\u0027s editor probe does. When set, and ONLY inside a\r\n    /// disposable test copy (a folder under \u003Cc\u003E.collaboration/test-copies/\u003C/c\u003E 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\u0027s 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    /// \u003C/summary\u003E\r\n    public static Func\u003CPacket, bool\u003E SoloGuestVoucher { get; set; }\r\n\r\n    /// \u003Csummary\u003E\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    /// \u003C/summary\u003E\r\n    public (string ProjectId, string Baseline, string Manifest, string OrgIdent, string LibraryVersion) HandshakeFacts =\u003E (project, baseline, projectManifestValue, orgIdentValue, libraryVersionValue);\r\n\r\n    /// \u003Csummary\u003EScene paths this session has subscribed and received a snapshot for. Empty until the host\u0027s scene is live.\u003C/summary\u003E\r\n    public IReadOnlyList\u003Cstring\u003E ReadyScenes =\u003E scenes.Where((KeyValuePair\u003Cstring, Tracked\u003E x) =\u003E x.Value.Ready).Select((KeyValuePair\u003Cstring, Tracked\u003E x) =\u003E x.Key).ToList();\r\n\r\n    /// \u003Csummary\u003ETrue in a disposable test copy with the voucher installed. Shown to the user, so it is never a silent state.\u003C/summary\u003E\r\n    public bool SoloHarness =\u003E soloHarness;\r\n\r\n    /// \u003Csummary\u003EThe visible banner for a session running with the test seam active. Null otherwise.\u003C/summary\u003E\r\n    public string SoloHarnessNotice =\u003E soloHarness ? \u0022Solo test copy: one reserved test identity is accepted here in place of a second Steam account. This never applies to a real project.\u0022 : 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) \u0026\u0026 File.Exists(Path.Combine(root, \u0022.collaboration\u0022, 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 \u0026\u0026 hello != null \u0026\u0026 SoloGuestVoucher != null \u0026\u0026 SoloGuestVoucher(hello);\r\n        }\r\n        catch (Exception)\r\n        {\r\n            return false;\r\n        }\r\n    }\r\n\r\n    /// \u003Csummary\u003EThe host\u0027s relay registration, worded for the dock: the internet relay\u0027s progress when the host runs its own, else the configured relay\u0027s. Null when there is none.\u003C/summary\u003E\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 \u0022Relay connected \u2014 friends can join from any network.\u0022;\r\n                case RelayHostState.Failed:\r\n                    return \u0022Relay unavailable \u2014 \u0022 \u002B host.Failure;\r\n                default:\r\n                    return host.Status ?? \u0022Connecting to the relay\u2026\u0022;\r\n            }\r\n        }\r\n    }\r\n\r\n    public bool RelayFailed =\u003E relayHost?.State == RelayHostState.Failed;\r\n\r\n    /// \u003Csummary\u003ETrue while the internet relay has failed or is restarting, so the dock can colour its sentence as a problem.\u003C/summary\u003E\r\n    public bool InternetRelayTrouble =\u003E hostingPlan != null \u0026\u0026 (hostingPlan.Phase == HostingPhase.Failed || hostingPlan.Phase == HostingPhase.Degraded);\r\n\r\n    /// \u003Csummary\u003ESet while hosting with no relay a friend elsewhere could reach, so the dock can say so instead of leaving a silent gap. Null otherwise.\u003C/summary\u003E\r\n    public string NoRelayNotice =\u003E IsHost \u0026\u0026 hostingPlan == null \u0026\u0026 (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    /// \u003Csummary\u003EWhat source synchronization is doing this frame; the dock reads it to explain a pause.\u003C/summary\u003E\r\n    public PlaySpectateDecision PlayDecision { get; private set; }\r\n\r\n    public bool PausedForPlay =\u003E Connected \u0026\u0026 PlayDecision == PlaySpectateDecision.PausedForPlay;\r\n\r\n    public int HeldSceneChanges =\u003E heldPackets.Count;\r\n\r\n    /// \u003Csummary\u003EThe sentence shown while the local editor is playing and scene edits are held back. Null otherwise.\u003C/summary\u003E\r\n    public string PlayNotice\r\n    {\r\n        get\r\n        {\r\n            if (PausedForPlay \u0026\u0026 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                ? \u0022Playing: scene edits are paused. Your collaborators can still see you; changes resume when you stop.\u0022\r\n                : \u0022Playing: scene edits are paused. \u0022 \u002B held \u002B \u0022 change\u0022 \u002B (held == 1 ? \u0022\u0022 : \u0022s\u0022) \u002B \u0022 from collaborators will apply when you stop.\u0022;\r\n        }\r\n    }\r\n\r\n    public string PortMappingStatus =\u003E 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 \u0026\u0026 upnpPortMapper.Current == UpnpPortMapper.State.Failed;\r\n        }\r\n    }\r\n\r\n    public string DiscoveryStatus =\u003E discovery?.Failure;\r\n\r\n    public bool Busy =\u003E CollaborationSessionResourceRules.IsBusy(wire != null, connecting != null, server != null, lifecycle.IsFailStop);\r\n\r\n    public PlayModeLifecycle RuntimeLifecycle { get; } = new PlayModeLifecycle(optIn: false, \u0022source-known-state\u0022);\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 = \u0022Source collaboration\u0022;\r\n            goto IL_0061;\r\n            IL_0051:\r\n            result = \u0022Runtime collaboration blocked \u2022 recovery required\u0022;\r\n            goto IL_0061;\r\n            IL_0059:\r\n            result = \u0022Source collaboration\u0022;\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 = \u0022Source collaboration \u2022 runtime disabled\u0022;\r\n            goto IL_0061;\r\n            IL_0041:\r\n            result = \u0022Source collaboration \u2022 starting runtime\u0022;\r\n            goto IL_0061;\r\n            IL_0049:\r\n            result = \u0022Experimental runtime collaboration enabled \u2022 transport and apply unsupported\u0022;\r\n            goto IL_0061;\r\n        }\r\n    }\r\n\r\n    public string RuntimeLifecycleDiagnostic =\u003E RuntimeLifecycle.LastDiagnostic;\r\n\r\n    public string RuntimeLifecycleError =\u003E RuntimeLifecycle.LastActionableError;\r\n\r\n    public bool HasPendingLifecycleCandidate =\u003E (object)RuntimeLifecycleEvents.Pending != null;\r\n\r\n    public Dictionary\u003Cstring, Packet\u003E Roster { get; } = new Dictionary\u003Cstring, Packet\u003E();\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\u003CReadinessItem\u003E Readiness { get; private set; } = Array.Empty\u003CReadinessItem\u003E();\r\n\r\n    public string ActiveSceneSupport\r\n    {\r\n        get\r\n        {\r\n            SceneEditorSession supportedActiveScene = SupportedActiveScene;\r\n            return (supportedActiveScene != null) ? (\u0022Active scene: \u0022 \u002B SceneAdapter.PathOf(supportedActiveScene)) : \u0022This view is outside the supported edit set (use a saved scene under Assets/scenes).\u0022;\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 \u0026\u0026 !active.IsPrefabSession \u0026\u0026 !active.IsPlaying)\r\n            {\r\n                string text = SceneAdapter.PathOf(active);\r\n                if (text != null \u0026\u0026 text.StartsWith(\u0022scenes/\u0022))\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    /// \u003Csummary\u003E\r\n    /// A formal sentence about somebody else\u0027s edit meeting this editor\u0027s 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 \u003Csee cref=\u0022Frame\u0022/\u003E about eight\r\n    /// seconds after \u003Csee cref=\u0022MergeNoticeAt\u0022/\u003E. The Controls card shows it.\r\n    /// \u003C/summary\u003E\r\n    public string MergeNotice { get; private set; }\r\n\r\n    /// \u003Csummary\u003EWhen \u003Csee cref=\u0022MergeNotice\u0022/\u003E was last set (UTC); meaningful only while the notice is non-null.\u003C/summary\u003E\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    /// \u003Csummary\u003E\r\n    /// On the host: a formal sentence when a guest\u0027s editor keeps drifting from the host\u0027s scene although it was resynchronised repeatedly,\r\n    /// otherwise null. Always null on a guest.\r\n    /// \u003C/summary\u003E\r\n    public string DriftNotice =\u003E server?.DriftNotice;\r\n\r\n    public string TerminalTeardownError =\u003E lifecycle.TerminalDiagnostic;\r\n\r\n    public bool ReconnectRecovery =\u003E 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(\u0022Collaboration lifecycle: \u0022 \u002B 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(\u0022scene.startplay\u0022)]\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 \u0026\u0026 !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(\u0022Play mode could not identify a saved source scene; source collaboration is unchanged.\u0022);\r\n        }\r\n        else\r\n        {\r\n            current.ApplyLifecycleResult(current.RuntimeLifecycleEvents.RecordStartIntent(current.RuntimeLifecycle, sceneEditorSession, text, Guid.NewGuid().ToString(\u0022N\u0022)));\r\n        }\r\n    }\r\n\r\n    [Event(\u0022scene.play\u0022)]\r\n    public static void OnPlay()\r\n    {\r\n        CollaborationSession current = Current;\r\n        current.Checkpoint();\r\n        GameEditorSession[] array = SceneEditorSession.All.OfType\u003CGameEditorSession\u003E().ToArray();\r\n        if (array.Length != 1)\r\n        {\r\n            current.SetLifecycleError((array.Length == 0) ? \u0022Play-mode lifecycle could not find a GameEditorSession in SceneEditorSession.All; source collaboration is unchanged.\u0022 : \u0022Multiple GameEditorSession objects are active; runtime collaboration was not enabled. Stop play mode and retry.\u0022);\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(\u0022Runtime GameEditorSession parent/path did not match the source captured by scene.startplay; runtime collaboration was not enabled.\u0022);\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(\u0022scene.stop\u0022)]\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(\u0022scene.saved\u0022)]\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) =\u003E 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 \u0026\u0026 scenes.TryGetValue(text, out var value) \u0026\u0026 value.Session == sceneEditorSession)\r\n        {\r\n            tracked = value;\r\n            dateTime = SceneAdapter.DiskStamp(text);\r\n        }\r\n        bool flag = Connected \u0026\u0026 Error == null;\r\n        switch (SceneSaveRules.Decide(saving || (tracked != null \u0026\u0026 tracked.DiskStamp == dateTime), flag, tracked != null, flag \u0026\u0026 tracked != null \u0026\u0026 CanSave()))\r\n        {\r\n        case SceneSaveOutcome.Absorb:\r\n            tracked.DiskStamp = dateTime;\r\n            server?.AllowSavedBaseline(SceneAdapter.Baseline());\r\n            SetSaveNotice(\u0022The scene was saved outside the session; the session accepted the save.\u0022);\r\n            break;\r\n        case SceneSaveOutcome.Warn:\r\n            tracked.DiskStamp = dateTime;\r\n            SetSaveNotice(\u0022The scene was saved outside the session while edits were in flight; disk and session state may differ.\u0022);\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 \u003C 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        // \u0060text\u0060 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(\u0022hotloaded\u0022);\r\n        current.ApplyLifecycleResult(playModeTransitionResult);\r\n        HotloadArchiveOutcome hotloadArchiveOutcome = current.ArchiveHotloadLifecycle(current.RuntimeLifecycle.ArchiveReason ?? \u0022hotloaded\u0022);\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 \u0026\u0026 LocalSteamIdentity.TryGet(out var soloLocal, out var _, out var _))\r\n        {\r\n            // The reserved guest borrows the signed-in account\u0027s 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 \u0026\u0026 (string.IsNullOrWhiteSpace(plan.Token) || plan.Token.Length \u003C 12))\r\n        {\r\n            throw new InvalidOperationException(\u0022Use a session key of at least 12 characters\u0022);\r\n        }\r\n        if (plan.ZeroConfig \u0026\u0026 !plan.Host \u0026\u0026 plan.SessionKey == null)\r\n        {\r\n            throw new InvalidOperationException(\u0022A zero-config join requires the session key derived from the invite code.\u0022);\r\n        }\r\n        if (string.IsNullOrWhiteSpace(plan.Name) || plan.Name.Length \u003E 64)\r\n        {\r\n            throw new InvalidOperationException(\u0022No display name is available for the signed-in Steam account\u0022);\r\n        }\r\n        foreach (SceneEditorSession item in SceneEditorSession.All.Where((SceneEditorSession x) =\u003E !(x is GameEditorSession)))\r\n        {\r\n            if (item.HasUnsavedChanges)\r\n            {\r\n                throw new InvalidOperationException(\u0022Save or revert local scenes before starting a new collaboration session\u0022);\r\n            }\r\n        }\r\n        string path = Path.Combine(SceneAdapter.Root, \u0022ProjectSettings\u0022, \u0022collaboration.json\u0022);\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\u0027s 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\u0027s 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 = \u0022Created ProjectSettings/collaboration.json for this project. Your friend needs the same file: send them the project as it is now.\u0022;\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(\u0022This project has no ProjectSettings/collaboration.json. Ask the host for a copy of the project, so both of you have the same file.\u0022);\r\n        }\r\n        JsonNode jsonNode = JsonNode.Parse(File.ReadAllText(path))?[\u0022ProjectId\u0022];\r\n        if (jsonNode == null || !Guid.TryParse(jsonNode.GetValue\u003Cstring\u003E(), out var _))\r\n        {\r\n            throw new InvalidOperationException(\u0022ProjectId must be a GUID\u0022);\r\n        }\r\n        string preparedProject = jsonNode.GetValue\u003Cstring\u003E();\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 \u0026\u0026 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(\u0022Readiness could not be evaluated: \u0022 \u002B CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error2));\r\n            return;\r\n        }\r\n        string text2 = (ReadinessRules.CanStart(Readiness) ? null : (ReadinessRules.BlockReason(Readiness) ?? \u0022Hosting is unavailable: the readiness checklist could not be evaluated.\u0022));\r\n        if (text2 != null)\r\n        {\r\n            Fail(text2);\r\n            return;\r\n        }\r\n        SessionServer preparedServer = null;\r\n        Task\u003CWire\u003E preparedConnect = null;\r\n        int boundPort = plan.Port;\r\n        string dialAddress = ((plan.Host \u0026\u0026 plan.Address == \u00220.0.0.0\u0022) ? \u0022127.0.0.1\u0022 : plan.Address);\r\n        PlayModeTransitionResult playModeTransitionResult2 = lifecycle.BeginStart(new SessionStartCommitRequest(playModeTransitionResult, () =\u003E\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\u003CEndpointResolver.EndpointCandidate\u003E candidates2 = plan.Candidates;\r\n            preparedConnect = ((candidates2 != null \u0026\u0026 candidates2.Count \u003E 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, \u0022collaboration transport\u0022, preparedServer, \u0022collaboration host server\u0022);\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\u003CEndpointResolver.EndpointCandidate\u003E candidates = plan.Candidates;\r\n        if (candidates == null || candidates.Count \u003C= 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 \u0026\u0026 plan.Host)\r\n        {\r\n            BeginHostConnectivity(plan, boundPort);\r\n        }\r\n        Status = \u0022Connecting\u2026\u0022;\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(\u0022ProjectSettings/collaboration.json must carry a shared ProjectId before a session can be hosted.\u0022);\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 \u003C num; i\u002B\u002B)\r\n        {\r\n            int num2 = plan.Port \u002B i;\r\n            if (num2 \u003E 65535)\r\n            {\r\n                break;\r\n            }\r\n            string text = Path.Combine(SceneAdapter.Root, \u0022.collaboration\u0022, \u0022journal-\u0022 \u002B Guid.NewGuid().ToString() \u002B \u0022.jsonl\u0022);\r\n            try\r\n            {\r\n                SessionServer result = new SessionServer(plan.Address, num2, project, baseline, plan.Token, text, null, (Packet hello) =\u003E 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) ? $\u0022Port {plan.Port} could not be opened: \u0022 : $\u0022None of the {num} ports from {plan.Port} could be opened: \u0022, error));\r\n    }\r\n\r\n    private async Task\u003CWire\u003E ConnectCandidates(IReadOnlyList\u003CEndpointResolver.EndpointCandidate\u003E candidates, byte[] sessionKey, byte[] secret)\r\n    {\r\n        List\u003CRouteAttempt\u003E attempts = new List\u003CRouteAttempt\u003E();\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(\u0022The relay route needs the invite code\u0027s secret.\u0022);\r\n                    }\r\n                    if (!soloHarness \u0026\u0026 !RelayProtocol.IsPublicRelay(candidate.Address) \u0026\u0026 !IsHost)\r\n                    {\r\n                        // A code naming localhost was made on the host\u0027s 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, \u0022the code points to a relay on the host\u0027s own computer\u0022);\r\n                    }\r\n                    reached = await RelayGuest.ConnectAsync(() =\u003E new SandboxRelaySocket(), candidate.Address, secret, sessionKey);\r\n                    RouteDescription = \u0022Connected through the relay (\u0022 \u002B RelayHostName(candidate.Address) \u002B \u0022)\u0022;\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 == \u0022lan\u0022 ? \u0022Connected on the local network\u0022 : \u0022Connected directly to \u0022 \u002B candidate.Address \u002B \u0022:\u0022 \u002B 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 \u002B \u0022:\u0022 \u002B 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) =\u003E a.RelayCode != null \u0026\u0026 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 : \u0022relay\u0022;\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 = \u0022Public endpoint pending\u2026\u0022;\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 \u0026\u0026 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(\u0022Collaboration discovery: \u0022 \u002B 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(\u0022Collaboration port mapping: \u0022 \u002B 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\u0027s own and takes no access key; the room\u0027s 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(() =\u003E 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 = \u0022The host could not connect to its own local relay.\u0022;\r\n            }\r\n            GlobalGameNamespace.Log.Error(\u0022Collaboration relay: \u0022 \u002B CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, error));\r\n        }\r\n    }\r\n\r\n    // Takes the first loopback port the engine lets the host\u0027s own WebSocket dial (HostingPlan.LocalRelayPorts). A free port picked by the system would\r\n    // be refused by the engine\u0027s 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 = \u0022The local relay could not listen on any of the ports the editor permits (\u0022 \u002B string.Join(\u0022, \u0022, HostingPlan.LocalRelayPorts) \u002B \u0022); close the program that uses them and host again.\u0022;\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 \u0026\u0026 embeddedRelay != null \u0026\u0026 !embeddedRelay.IsListening)\r\n        {\r\n            relayError = \u0022The local relay stopped listening.\u0022;\r\n        }\r\n        if (relayError == null \u0026\u0026 embeddedRelay != null \u0026\u0026 relayHost?.State == RelayHostState.Failed)\r\n        {\r\n            relayError = \u0022The host\u0027s connection to its own local relay failed: \u0022 \u002B 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 ?? \u0022The tunnel stopped.\u0022) : 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, \u0022.collaboration\u0022, 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(\u0022Collaboration relay: \u0022 \u002B problem);\r\n            }\r\n            return key;\r\n        }\r\n        catch (Exception error)\r\n        {\r\n            GlobalGameNamespace.Log.Error(\u0022Collaboration relay: \u0022 \u002B CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(\u0022The access key file could not be read: \u0022, 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\u003Cbyte[]\u003E 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\u003Cbyte[]\u003E { 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(\u0022Create ProjectSettings/collaboration.json with a shared ProjectId, then commit it to Git\u0022);\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(\u0022No address to try: this code carries no public address and discovery has not found that host on your network. Type the host\u0027s address under Advanced.\u0022);\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(\u0022Select a discovered host to join.\u0022);\r\n        }\r\n        string text = CurrentProjectId();\r\n        if (string.IsNullOrEmpty(text))\r\n        {\r\n            throw new InvalidOperationException(\u0022Create ProjectSettings/collaboration.json with a shared ProjectId, then commit it to Git\u0022);\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(\u0022That host did not report a usable session secret.\u0022);\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\u003CEndpointResolver.EndpointCandidate\u003E\r\n            {\r\n                new EndpointResolver.EndpointCandidate(host.EndPoint, host.Port, \u0022lan\u0022)\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 = \u00220.0.0.0\u0022,\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(\u0022Collaboration discovery: \u0022 \u002B 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 \u0026\u0026 server == null \u0026\u0026 wire == null \u0026\u0026 connecting == null)\r\n        {\r\n            discovery.SendQuery();\r\n        }\r\n        stun?.Poll();\r\n        FoldPublicEndpoint();\r\n        if (relayHost != null \u0026\u0026 inviteCarriesRelay \u0026\u0026 relayHost.State == RelayHostState.Failed \u0026\u0026 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 \u0026\u0026 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\u0027s 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\u0027s 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 \u0026\u0026 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\u0027s 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 \u0026\u0026 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\u0027s 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\u0027s HTTP client, the only one an editor script may use, as SandboxCloudflareTransport does. Every outcome becomes the plan\u0027s\r\n    // verdict sentence; the time budget is the token\u0027s (ReachabilityProbeTimeout).\r\n    private static async Task\u003Cstring\u003E CheckReachabilityAsync(string healthUrl, CancellationToken token)\r\n    {\r\n        try\r\n        {\r\n            using var response = await Http.RequestAsync(healthUrl, \u0022GET\u0022, 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 \u0022The public relay address did not answer within \u0022 \u002B ReachabilityProbeTimeout.TotalSeconds \u002B \u0022 seconds.\u0022;\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 \u0026\u0026 inviteCodeValue != null \u0026\u0026 !stunEndpointFolded \u0026\u0026 (stun.PublicEndPoint != null || stun.Failure != null))\r\n        {\r\n            stunEndpointFolded = true;\r\n            if (stun.PublicEndPoint != null)\r\n            {\r\n                PublicEndpointStatus = \u0022Public endpoint: \u0022 \u002B stun.PublicEndPoint;\r\n                inviteCodeValue = BuildInviteCode(hostPort);\r\n            }\r\n            else\r\n            {\r\n                PublicEndpointStatus = CollaborationDiagnostics.Bounded(\u0022No public address found; invitees on this network join by code or LAN discovery, and a remote invitee needs the host\u0027s address under Advanced.\u0022);\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\u0027s 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 \u0026\u0026 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 \u0026\u0026 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 \u0026\u0026 reconnectTokenTask == null)\r\n        {\r\n            PlayModeTransitionResult playModeTransitionResult = ReconnectGuard();\r\n            if (!playModeTransitionResult.Accepted)\r\n            {\r\n                throw new ActionRefusal(playModeTransitionResult.ActionableError ?? \u0022Recovery is not possible in the session\u0027s current state.\u0022);\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 = \u0022Reconnecting; preparing identity\u2026\u0022;\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(\u0022sbox-editor-collaboration\u0022);\r\n        }\r\n        catch (Exception)\r\n        {\r\n            AbandonReconnect(\u0022A Steam identity token could not be requested.\u0022);\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(\u0022A Steam identity token could not be obtained.\u0022);\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(\u0022A Steam identity token could not be obtained.\u0022);\r\n        }\r\n        else if (reconnectRecovery \u0026\u0026 !reconnectRetrySpent \u0026\u0026 Error != null \u0026\u0026 SteamIdentityAdmission.IsTokenRejection(Error))\r\n        {\r\n            reconnectRetrySpent = true;\r\n            ClearRecordedFailure();\r\n            reconnectQueued = true;\r\n            Status = \u0022Reconnecting; preparing identity\u2026\u0022;\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 ?? \u0022Reconnect is not possible in the session\u0027s current state.\u0022);\r\n            return;\r\n        }\r\n        SessionReconnectCommitOutcome sessionReconnectCommitOutcome = lifecycle.BeginReconnect(new SessionReconnectCommitRequest(playModeTransitionResult, () =\u003E\r\n        {\r\n            Preserve(\u0022Reconnect: local state before authoritative recovery\u0022);\r\n            return DisposeActiveResources();\r\n        }));\r\n        if (!sessionReconnectCommitOutcome.Accepted)\r\n        {\r\n            AbandonReconnect((sessionReconnectCommitOutcome.Stage == ReconnectCommitStage.FailedDuringPrepare) ? (sessionReconnectCommitOutcome.ActionableError ?? \u0022Reconnect preparation failed after the previous connection was closed.\u0022) : (sessionReconnectCommitOutcome.ActionableError ?? \u0022Reconnect was refused before any connection was closed.\u0022));\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 \u0026\u0026 activeCandidates.Count \u003E 0)\r\n            ? ConnectCandidates(activeCandidates, activeSessionKey, activeSecret)\r\n            : Wire.Connect(reconnectAddress, reconnectPort, activeSessionKey, activeSessionKey != null);\r\n        Status = \u0022Reconnecting; recovering authoritative state\u2026\u0022;\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 \u0026\u0026 ArchiveHotloadLifecycle(RuntimeLifecycle.ArchiveReason ?? \u0022hotloaded\u0022) == HotloadArchiveOutcome.Failed;\r\n        SessionLeaveOutcome sessionLeaveOutcome = SessionLeaveRules.Run(new SessionLeaveRequest(lifecycle, RuntimeLifecycle.IsTransitionInProgress, () =\u003E !archiveFailed \u0026\u0026 scenes.Values.Any((Tracked x) =\u003E x.Pending != null || x.SettingsPending != null || (x.Ready \u0026\u0026 x.Session.Scene.IsValid() \u0026\u0026 StateRules.Diff(x.Known, SceneAdapter.Capture(x.Session.Scene)).Count \u003E 0)), (string reason) =\u003E\r\n        {\r\n            Preserve(reason);\r\n            return true;\r\n        }, () =\u003E\r\n        {\r\n            wire?.Send(new Packet\r\n            {\r\n                Kind = \u0022leave\u0022\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 \u0026\u0026 !disposalFailed)\r\n        {\r\n            RecoveryNotice = sessionLeaveOutcome.Failure;\r\n            GlobalGameNamespace.Log.Error(\u0022Collaboration lifecycle: \u0022 \u002B 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 = \u0022Offline \u2022 connection teardown failed; session remains fail-stop\u0022;\r\n            RecordTerminalTeardownFailure(lifecycle.TerminalDiagnostic);\r\n            return;\r\n        }\r\n        if (archiveFailed)\r\n        {\r\n            Status = \u0022Offline \u2022 hotload recovery failed\u0022;\r\n            RecoveryNotice = BoundedDiagnostic(RuntimeLifecycle.LastDiagnostic);\r\n            GlobalGameNamespace.Log.Error(\u0022Collaboration lifecycle: \u0022 \u002B RecoveryNotice);\r\n            return;\r\n        }\r\n        if (lifecycle.IsPending)\r\n        {\r\n            Status = (requiresHotloadRecoveryAfterLeave ? \u0022Offline \u2022 waiting for connection teardown before hotload recovery\u0022 : \u0022Offline \u2022 waiting for pending connection teardown\u0022);\r\n            return;\r\n        }\r\n        if (lifecycle.HasTerminalFailure)\r\n        {\r\n            Status = \u0022Offline \u2022 connection teardown failed; runtime remains blocked\u0022;\r\n            return;\r\n        }\r\n        Status = \u0022Offline\u0022;\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) =\u003E 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(\u0022\u0022, 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(\u0022Collaboration: \u0022 \u002B Error);\r\n        try\r\n        {\r\n            StopAdvertising();\r\n        }\r\n        catch (Exception error3)\r\n        {\r\n            GlobalGameNamespace.Log.Error(\u0022Collaboration connectivity: \u0022 \u002B 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(\u0022Collaboration action: \u0022 \u002B 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 \u0026\u0026 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(\u0022Runtime lifecycle hotload marker: \u0022 \u002B 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(\u0022Collaboration lifecycle: \u0022 \u002B RecoveryNotice);\r\n                return HotloadArchiveOutcome.Failed;\r\n            }\r\n            RecoveryNotice = BoundedDiagnostic(\u0022Runtime lifecycle blocked; recovery archived before connections were disposed. Leave, sync source in Git, and start a new session.\u0022);\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(\u0022archive failed\u0022));\r\n            RecoveryNotice = BoundedDiagnostic(RuntimeLifecycle.LastDiagnostic);\r\n            GlobalGameNamespace.Log.Error(\u0022Collaboration lifecycle: \u0022 \u002B 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, \u0022collaboration transport\u0022, server, \u0022collaboration host server\u0022);\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(\u0022Collaboration connectivity: \u0022 \u002B 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\u003CWire\u003E 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) \u0026\u0026 failure.StartsWith(\u0022Connection teardown failed;\u0022, StringComparison.Ordinal);\r\n        string text = (string.IsNullOrWhiteSpace(failure) ? \u0022unknown teardown error\u0022 : BoundedDiagnostic(failure));\r\n        if (requiresHotloadRecoveryAfterLeave \u0026\u0026 !flag)\r\n        {\r\n            RuntimeLifecycle.RecordHotloadTeardownFailure(text);\r\n        }\r\n        string text2 = (RecoveryNotice = BoundedDiagnostic(requiresHotloadRecoveryAfterLeave ? (RuntimeLifecycle.LastActionableError ?? text) : (flag ? text : (\u0022Connection teardown failed; this editor session remains offline and fail-stop: \u0022 \u002B text))));\r\n        if (requiresHotloadRecoveryAfterLeave)\r\n        {\r\n            SetLifecycleError(text2);\r\n        }\r\n        else\r\n        {\r\n            GlobalGameNamespace.Log.Error(\u0022Collaboration lifecycle: \u0022 \u002B 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 \u0026\u0026 teardownCompletionObservation.Error != null)\r\n            {\r\n                RecoveryNotice = BoundedDiagnostic(teardownCompletionObservation.Error);\r\n                GlobalGameNamespace.Log.Error(\u0022Collaboration lifecycle: \u0022 \u002B RecoveryNotice);\r\n            }\r\n            return;\r\n        }\r\n        if (!RuntimeLifecycle.HotloadArchiveRecorded \u0026\u0026 RuntimeLifecycle.State == PlayModeLifecycleState.RuntimeBlocked \u0026\u0026 ArchiveHotloadLifecycle(RuntimeLifecycle.ArchiveReason ?? \u0022hotloaded\u0022) == 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 = \u0022Hotload recovery reset completed after transport teardown and Leave. Sync source in Git, then start a new collaboration session.\u0022;\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, \u0022.collaboration\u0022, \u0022recovery\u0022);\r\n        Directory.CreateDirectory(text);\r\n        string path = RecoveryPathRules.CreatePath(text, DateTime.UtcNow.ToString(\u0022yyyyMMdd-HHmmss-fffffff\u0022));\r\n        var captures = scenes.Where((KeyValuePair\u003Cstring, Tracked\u003E x) =\u003E x.Value.Session.Scene.IsValid()).ToDictionary((KeyValuePair\u003Cstring, Tracked\u003E x) =\u003E x.Key, (KeyValuePair\u003Cstring, Tracked\u003E x) =\u003E 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, () =\u003E\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(\u0022Recovery archive could not be written to \u0022 \u002B 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) \u0026\u0026 DateTime.UtcNow \u003E= focusNoticeExpires)\r\n        {\r\n            FocusNotice = null;\r\n            focusNoticeExpires = default;\r\n        }\r\n        if (saveNoticeExpires != default(DateTime) \u0026\u0026 DateTime.UtcNow \u003E= saveNoticeExpires)\r\n        {\r\n            SaveNotice = null;\r\n            saveNoticeExpires = default;\r\n        }\r\n        if (undoNoticeExpires != default(DateTime) \u0026\u0026 DateTime.UtcNow \u003E= undoNoticeExpires)\r\n        {\r\n            UndoNotice = null;\r\n            undoNoticeExpires = default;\r\n        }\r\n        if (actionNoticeExpires != default(DateTime) \u0026\u0026 DateTime.UtcNow \u003E= actionNoticeExpires)\r\n        {\r\n            ActionNotice = null;\r\n            actionNoticeExpires = default;\r\n        }\r\n        if (mergeNoticeExpires != default(DateTime) \u0026\u0026 DateTime.UtcNow \u003E= 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(\u0022Reconnect could not continue: \u0022, 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 \u003E= 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(\u0022Collaboration readiness: \u0022 \u002B 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(\u0022Collaboration connectivity: \u0022 \u002B 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(\u0022Collaboration auto-save: \u0022 \u002B 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(\u0022Collaboration auto-recover: \u0022 \u002B 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 \u0026\u0026 RuntimeLifecycle.RequiresHotloadRecoveryAfterLeave)\r\n                {\r\n                    RecoveryNotice = BoundedDiagnostic(teardownCompletionObservation.Error);\r\n                    GlobalGameNamespace.Log.Error(\u0022Collaboration lifecycle: \u0022 \u002B RecoveryNotice);\r\n                }\r\n            }\r\n            Task\u003CWire\u003E task = connecting;\r\n            if (task != null \u0026\u0026 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(\u0022Stop play mode or complete the approved runtime lifecycle before promoting a source connection\u0022);\r\n                    }\r\n                    return;\r\n                }\r\n                Task\u003CWire\u003E task2 = connecting;\r\n                connecting = null;\r\n                wire = task2.GetAwaiter().GetResult();\r\n                wire.Send(new Packet\r\n                {\r\n                    Kind = \u0022hello\u0022,\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(\u0022Disconnected: \u0022 \u002B 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 \u0026\u0026 decision == PlaySpectateDecision.Normal \u0026\u0026 !heldPackets.IsEmpty)\r\n            {\r\n                Status = \u0022Connected \u2022 applying changes made while you were playing\u0022;\r\n            }\r\n            PlayDecision = decision;\r\n            if (decision == PlaySpectateDecision.Normal \u0026\u0026 resyncWhenRoutable.Count \u003E 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 \u0026\u0026 affected.Session.IsPlaying)\r\n                    {\r\n                        continue;\r\n                    }\r\n                    resyncWhenRoutable.Remove(scene);\r\n                    if (affected != null \u0026\u0026 affected.Session.Scene.IsValid() \u0026\u0026 !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(\u0022Stop play mode or complete the approved runtime lifecycle before applying source packets\u0022);\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 \u0026\u0026 HeldPacketBuffer.IsSceneChange(result.Kind))\r\n                {\r\n                    if (result.Scene != null \u0026\u0026 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(\u0022More scene updates arrived while you were playing than this editor holds. The affected scenes will be synchronized from the host when play ends.\u0022);\r\n                    }\r\n                    lastReceive = DateTime.UtcNow;\r\n                    continue;\r\n                }\r\n                ProcessIncoming(result);\r\n            }\r\n            if (DateTime.UtcNow - lastReceive \u003E TimeSpan.FromSeconds(15L))\r\n            {\r\n                Connected = false;\r\n                throw new IOException(\u0022Host heartbeat timed out\u0022);\r\n            }\r\n            if (!Connected)\r\n            {\r\n                return;\r\n            }\r\n            play?.Tick();\r\n            if (DateTime.UtcNow \u003E= 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 = \u0022ping\u0022\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 =\u003E 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( \u0022Receiving project files:\u0022, StringComparison.Ordinal )) Status = \u0022Connected \u2022 live\u0022;\r\n            }\r\n            if (Error != null || DateTime.UtcNow \u003C 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\u0027s 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) =\u003E !(x is GameEditorSession) \u0026\u0026 !x.IsPrefabSession).ToArray();\r\n            foreach (SceneEditorSession sceneEditorSession3 in array)\r\n            {\r\n                string text = SceneAdapter.PathOf(sceneEditorSession3);\r\n                if (sceneEditorSession3.IsPlaying \u0026\u0026 !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(\u0022scenes/\u0022))\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(\u0022reopened-pending:\u0022 \u002B text, \u0022\\\u0022\u0022 \u002B text \u002B \u0022\\\u0022 is synchronized again once the host answers the edit you sent before it was closed. Your edit remains in the recovery outbox.\u0022);\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(\u0022Unsent changes to the reopened scene were backed up before host synchronization.\u0022);\r\n                        }\r\n                        catch (Exception error)\r\n                        {\r\n                            NoticeOnce(\u0022reopened-backup:\u0022 \u002B text, \u0022\\\u0022\u0022 \u002B text \u002B \u0022\\\u0022 is not synchronized because a recovery copy of its unsaved changes could not be written (\u0022 \u002B\r\n                                ApplyRecovery.ShortReason(error.Message) \u002B \u0022). Save the scene, then reopen it.\u0022);\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\u0027s copy when this editor is the scene\u0027s first subscriber; otherwise the snapshot replaces them.\r\n                    wire.Send(new Packet\r\n                    {\r\n                        Kind = \u0022subscribe\u0022,\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(\u0022The file of \\\u0022\u0022 \u002B text \u002B \u0022\\\u0022 was changed outside collaboration. The open scene stays as it is for everyone; the file\u0027s version was copied to \u0022 \u002B diskBackup \u002B \u0022.\u0022);\r\n                        }\r\n                    }\r\n                    else if (stampAction == DiskStampAction.PauseScene)\r\n                    {\r\n                        PauseScene(text, value, \u0022Synchronization of \\\u0022\u0022 \u002B text \u002B \u0022\\\u0022 is paused because its file was changed outside collaboration and a copy of that file could not be written (\u0022 \u002B\r\n                            ApplyRecovery.ShortReason(diskError) \u002B \u0022). Your changes to this scene are kept locally. Reopen the scene to try again.\u0022);\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\u0027s 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\u003Cstring, ObjectState\u003E 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, \u0022Synchronization of \\\u0022\u0022 \u002B text \u002B \u0022\\\u0022 is paused because this editor could not read the scene (\u0022 \u002B ApplyRecovery.ShortReason(error.Message) \u002B\r\n                            \u0022). Your changes to this scene are kept locally. Reopen the scene to try again.\u0022);\r\n                        continue;\r\n                    }\r\n                    if (settingsChange != null)\r\n                    {\r\n                        SendSettings(text, value, settingsChange);\r\n                    }\r\n                    List\u003Cstring\u003E scanNotices = new List\u003Cstring\u003E { 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 \u0026\u0026 gameObject.IsValid \u0026\u0026 !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\u0027s state (SiblingOrder.Key), so a reorder is an ordinary change below.\r\n                    List\u003CChange\u003E list = StateRules.Diff(value.Known, dictionary);\r\n                    if (list.Count \u003E 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\u003CHeldChange\u003E held = new List\u003CHeldChange\u003E();\r\n                    List\u003CChange\u003E 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 \u0026\u0026 !Editor.Application.MouseButtons.HasFlag(MouseButtons.Left);\r\n                    if (sendable.Count == 0 \u0026\u0026 (!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\u003Cstring, object\u003E trackedByScene = ((IEnumerable\u003CKeyValuePair\u003Cstring, Tracked\u003E\u003E)scenes).ToDictionary((Func\u003CKeyValuePair\u003Cstring, Tracked\u003E, string\u003E)((KeyValuePair\u003Cstring, Tracked\u003E pair) =\u003E pair.Key), (Func\u003CKeyValuePair\u003Cstring, Tracked\u003E, object\u003E)((KeyValuePair\u003Cstring, Tracked\u003E pair) =\u003E pair.Value));\r\n            ScenePacketRouteResult scenePacketRouteResult = ScenePacketRouting.RouteTrackedPacket(result.Kind, result.Scene, trackedByScene, (object target) =\u003E ((Tracked)target).Session.IsPlaying, (object target) =\u003E SceneAdapter.PathOf(((Tracked)target).Session), RuntimeLifecycle, (object target) =\u003E 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 \u0026\u0026 routed.Session.Scene.IsValid() \u0026\u0026 SceneEditorSession.All.Contains(routed.Session) \u0026\u0026\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 ?? \u0022A scene update named no valid scene.\u0022) \u002B\r\n                            \u0022 The host is not speaking this protocol. Leave the session and reconnect to the correct host.\u0022);\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 == \u0022snapshot\u0022)\r\n        {\r\n            lastRevision = result.Revision;\r\n        }\r\n        else if ((result.Kind == \u0022accepted\u0022 || result.Kind == SettingsMerge.PacketKind) \u0026\u0026 result.Revision \u003E 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\u0027s 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) =\u003E 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, \u0022ProjectSettings\u0022, \u0022collaboration.json\u0022), 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 \u0026\u0026 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\u0027s .version file (1.0.\u003Cbuild number\u003E), 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 \u0026\u0026 DateTime.UtcNow \u003C 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) \u0026\u0026 LocalSteamIdentity.TryGet(out var identity4, out var _, out var _) \u0026\u0026 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 \u0026\u0026 !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\u003CGameEditorSession\u003E().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\u003Cstring, Dictionary\u003Cstring, string\u003E\u003E heldReasons = new Dictionary\u003Cstring, Dictionary\u003Cstring, string\u003E\u003E();\r\n\r\n    private void NoteHeldChanges(string scene, List\u003CHeldChange\u003E held, List\u003Cstring\u003E notices)\r\n    {\r\n        if (!heldReasons.TryGetValue(scene, out Dictionary\u003Cstring, string\u003E shown))\r\n        {\r\n            shown = new Dictionary\u003Cstring, string\u003E();\r\n            heldReasons[scene] = shown;\r\n        }\r\n        foreach (string id in shown.Keys.Except(held.Select((HeldChange x) =\u003E 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(\u0022Collaboration: \u0022 \u002B 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\u003Cstring\u003E unknownReservedKeys = new HashSet\u003Cstring\u003E(StringComparer.Ordinal);\r\n\r\n    /// \u003Csummary\u003EHow many distinct reserved (\u003Cc\u003E__\u003C/c\u003E) property names outside the known set this editor has sent.\u003C/summary\u003E\r\n    public int UnknownReservedKeyCount =\u003E unknownReservedKeys.Count;\r\n\r\n    private void CountUnknownReservedKeys(List\u003CChange\u003E 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(\u0022Collaboration: the scene uses the reserved property name \\\u0022\u0022 \u002B key \u002B \u0022\\\u0022, which is not in the known set; it is synchronised as is.\u0022);\r\n                }\r\n            }\r\n        }\r\n    }\r\n\r\n    private void SendEdit(string scene, Tracked tracked, List\u003CChange\u003E changes, bool final)\r\n    {\r\n        tracked.Pending = new Packet\r\n        {\r\n            Kind = \u0022edit\u0022,\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 = \u0022Connected \u2022 edit awaiting acknowledgement\u0022;\r\n    }\r\n\r\n    // Sends the scene\u0027s 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 = \u0022Connected \u2022 scene settings awaiting acknowledgement\u0022;\r\n    }\r\n\r\n    // A refused settings change is not retried: the scene takes back the host\u0027s 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(\u0022Scene settings change refused\u0022, 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() \u0026\u0026 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\u003Cstring\u003E skipped = SceneSettingsAdapter.Apply(tracked.Session.Scene, settings);\r\n            if (skipped.Count \u003E 0)\r\n            {\r\n                SetActionNotice(\u0022Some scene settings could not be applied in this editor and were left unchanged: \u0022 \u002B string.Join(\u0022; \u0022, 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\u0027s 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 \u002B \u0022:\u0022 \u002B 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(\u0022The host sent a scene settings change without settings.\u0022);\r\n        }\r\n        bool own = p.Peer == Peer;\r\n        if (own \u0026\u0026 tracked.SettingsPending?.Id == p.Id)\r\n        {\r\n            tracked.SettingsPending = null;\r\n        }\r\n        List\u003Cstring\u003E notices = new List\u003Cstring\u003E { p.Notice };\r\n        JsonObject target = p.Settings;\r\n        if (tracked.Session.Scene.IsValid() \u0026\u0026 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 \u0026\u0026 tracked.Settings != null \u0026\u0026 SettingsMerge.TryMerge(p.Settings, tracked.Settings, local, out JsonObject kept, out List\u003Cstring\u003E 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 = \u0022Connected \u2022 revision \u0022 \u002B p.Revision;\r\n    }\r\n\r\n    private Tracked TrackedFor(string scene)\r\n    {\r\n        Dictionary\u003Cstring, object\u003E trackedByScene = scenes.ToDictionary((KeyValuePair\u003Cstring, Tracked\u003E pair) =\u003E pair.Key, (KeyValuePair\u003Cstring, Tracked\u003E pair) =\u003E (object)pair.Value);\r\n        return (scene != null \u0026\u0026 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, \u0022.collaboration\u0022, \u0022outbox\u0022, Peer \u002B \u0022-\u0022 \u002B id \u002B \u0022.json\u0022);\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 \u002B \u0022.tmp\u0022, 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 \u002B \u0022.tmp\u0022, 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\u0027s 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\u0027s 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(\u0022A collaborator\u0027s scene keeps drifting\u0022, 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\u0027s snapshot replaces it, and returns the file\u0027s path. The folder,\r\n    // the engine\u0027s own scene serialisation and the write wrapper are the ones Preserve uses; the name is RecoveryBackup\u0027s. 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, \u0022.collaboration\u0022, \u0022recovery\u0022);\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) \u0026\u0026 counter \u003C 100; counter\u002B\u002B)\r\n            {\r\n                path = Path.Combine(folder, name.Substring(0, name.Length - \u0022.scene\u0022.Length) \u002B \u0022-\u0022 \u002B counter \u002B \u0022.scene\u0022);\r\n            }\r\n            string target = path;\r\n            RecoveryPathOutcome outcome = RecoveryPathRules.PublishAfterWrite(target, () =\u003E\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 ?? \u0022\u0022))\r\n        {\r\n            GlobalGameNamespace.Log.Info(\u0022Collaboration: an update for \\\u0022\u0022 \u002B scene \u002B \u0022\\\u0022, which is not open in this editor, was ignored. Reopening the scene synchronizes it again.\u0022);\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\u0027s file on disk to the recovery folder under a name never used before and returns the copy\u0027s 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, \u0022Assets\u0022, scene);\r\n        if (!File.Exists(disk))\r\n        {\r\n            return \u0022(no file on disk)\u0022;\r\n        }\r\n        string folder = Path.Combine(SceneAdapter.Root, \u0022.collaboration\u0022, \u0022recovery\u0022);\r\n        Directory.CreateDirectory(folder);\r\n        string name = RecoveryBackup.BuildFileName(scene, DateTime.UtcNow);\r\n        string stem = name.EndsWith(\u0022.scene\u0022, StringComparison.Ordinal) ? name.Substring(0, name.Length - \u0022.scene\u0022.Length) : name;\r\n        string path = Path.Combine(folder, stem \u002B \u0022-disk.scene\u0022);\r\n        for (int counter = 2; File.Exists(path) \u0026\u0026 counter \u003C 100; counter\u002B\u002B)\r\n        {\r\n            path = Path.Combine(folder, stem \u002B \u0022-disk-\u0022 \u002B counter \u002B \u0022.scene\u0022);\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(\u0022Scene synchronization paused\u0022, notice, Theme.Primary, 15f);\r\n    }\r\n\r\n    private static string BackupFailedPauseNotice(string scene, string reason)\r\n    {\r\n        return \u0022Synchronization of \\\u0022\u0022 \u002B scene \u002B \u0022\\\u0022 is paused because a recovery copy of the scene could not be written (\u0022 \u002B ApplyRecovery.ShortReason(reason) \u002B\r\n            \u0022). Your changes to this scene are kept locally. Free disk space or check folder permissions, then reopen the scene to try again.\u0022;\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 = \u0022subscribe\u0022,\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\u0027s change or deletion.\r\n    private Dictionary\u003Cstring, ObjectState\u003E CaptureTolerant(string scene, Tracked tracked)\r\n    {\r\n        HashSet\u003Cstring\u003E failures = new HashSet\u003Cstring\u003E(StringComparer.Ordinal);\r\n        Dictionary\u003Cstring, ObjectState\u003E 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(\u0022\\\u0022\u0022 \u002B name \u002B \u0022\\\u0022 in \\\u0022\u0022 \u002B scene \u002B \u0022\\\u0022 could not be read by this editor. Its last synchronized state is kept for everyone and it is not sent as a change.\u0022);\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\u003Cstring, ObjectState\u003E CaptureShown(string scene, Tracked tracked)\r\n    {\r\n        return SceneAdapter.Capture(tracked.Session.Scene, tracked.Known, new HashSet\u003Cstring\u003E(StringComparer.Ordinal));\r\n    }\r\n\r\n    private static Func\u003CDictionary\u003Cstring, ObjectState\u003E, Exception\u003E TryApply(Tracked tracked)\r\n    {\r\n        return candidate =\u003E\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\u0027s: 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\u0027s engine, not the session.\r\n    private ApplyRecoveryDecision RecoverScene(string scene, Tracked tracked, Exception error, Dictionary\u003Cstring, ObjectState\u003E before, bool snapshot)\r\n    {\r\n        ApplyRecoveryDecision decision = tracked.Recovery.Failed(DateTime.UtcNow, snapshot);\r\n        GlobalGameNamespace.Log.Warning(\u0022Collaboration: applying a host update to \\\u0022\u0022 \u002B scene \u002B \u0022\\\u0022 failed (\u0022 \u002B decision \u002B \u0022): \u0022 \u002B CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(\u0022\u0022, 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\u0027s 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(\u0022An update to \\\u0022\u0022 \u002B scene \u002B \u0022\\\u0022 could not be applied in your editor (\u0022 \u002B ApplyRecovery.ShortReason(error?.Message) \u002B \u0022). A copy of your scene was saved to \u0022 \u002B\r\n            backup \u002B \u0022. The scene is being synchronized again; your unsent changes are kept and sent afterwards.\u0022);\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\u003Cstring, ObjectState\u003E 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\u0027s edits as a scan reads them.\r\n        Dictionary\u003Cstring, ObjectState\u003E live = CaptureShown(scene, tracked);\r\n        Dictionary\u003Cstring, ObjectState\u003E heldBase = new Dictionary\u003Cstring, ObjectState\u003E(tracked.Known);\r\n        Dictionary\u003Cstring, ObjectState\u003E held = tracked.HeldLocal ?? CaptureTolerant(scene, tracked);\r\n        Dictionary\u003Cstring, ObjectState\u003E 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\u0027s JSON of every skipped object\r\n            // becomes Known with the rest, so nothing is lost and nothing is sent back.\r\n            Dictionary\u003Cstring, string\u003E reasons = new Dictionary\u003Cstring, string\u003E(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\u003Cstring\u003E skippedNotices = new List\u003Cstring\u003E();\r\n            foreach (KeyValuePair\u003Cstring, string\u003E 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\u003Cstring\u003E(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(\u0022Scene objects not shown\u0022, joined, Theme.Primary, 15f);\r\n            }\r\n        }\r\n        if (resyncing \u0026\u0026 !tracked.DiscardLocalOnResync)\r\n        {\r\n            List\u003Cstring\u003E notices = new List\u003Cstring\u003E();\r\n            Dictionary\u003Cstring, ObjectState\u003E replayed = ApplyRecovery.ReplayHeld(heldBase, held, target, shown, tracked.SkippedObjects, notices);\r\n            if (StateRules.Diff(shown, replayed).Count \u003E 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\u0027s 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(\u0022Your unsent changes to \\\u0022\u0022 \u002B scene \u002B \u0022\\\u0022 could not be reapplied on top of the host\u0027s scene (\u0022 \u002B ApplyRecovery.ShortReason(replayError.Message) \u002B\r\n                        \u0022). They are kept in the recovery copy.\u0022);\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 \u0026\u0026 projectFiles.Handle( p, packet =\u003E wire.Send( packet ), OpenFileScenes() ))\r\n        {\r\n            return;\r\n        }\r\n        if (play != null \u0026\u0026 play.Handle(p))\r\n        {\r\n            return;\r\n        }\r\n        if (p.Kind == \u0022welcome\u0022)\r\n        {\r\n            if (restoring \u0026\u0026 hostSession != null \u0026\u0026 hostSession != p.Session)\r\n            {\r\n                Fail(\u0022Host session ended or restarted. Preserve recovery data, sync saved scenes and start a new session.\u0022);\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 \u0026\u0026 p.Features.Contains( BlobLimits.Feature ))\r\n            {\r\n                BlobHub.Store.AttachDisk( SceneAdapter.Root, BlobLimits.DiskCacheBytes );\r\n                blobs = new BlobExchange( BlobHub.Store, packet =\u003E wire.Send( packet ), () =\u003E 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 \u0026\u0026 p.Features.Contains( FileSyncLimits.Feature ))\r\n            {\r\n                projectFiles = new FileSyncClient( Project.Current.RootDirectory.FullName ) { FileChanged = RefreshAsset };\r\n                projectFiles.Begin( packet =\u003E 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 \u0026\u0026 p.Features != null \u0026\u0026 p.Features.Contains(PlayRelayPolicy.Feature)\r\n                ? new SharedPlayClient(packet =\u003E wire.Send(packet), Peer, SetActionNotice)\r\n                : null;\r\n            Connected = true;\r\n            Status = \u0022Connected \u2022 live\u0022;\r\n            reconnectRecovery = false;\r\n            if (!restoring)\r\n            {\r\n                return;\r\n            }\r\n            {\r\n                foreach (KeyValuePair\u003Cstring, Tracked\u003E 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 = \u0022subscribe\u0022,\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 == \u0022fatal\u0022 || p.Kind == \u0022reject\u0022)\r\n        {\r\n            string text = CollaborationTransportDiagnostics.BoundedStoredDiagnostic(p.Error);\r\n            if (p.Kind == \u0022reject\u0022 \u0026\u0026 p.Id != null \u0026\u0026 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 \u0026\u0026 refusedScene.SettingsPending.Id == p.Id)\r\n                {\r\n                    RefuseSettings(refusedScene, \u0022The host refused your change to the scene settings: \u0022 \u002B text);\r\n                }\r\n                return;\r\n            }\r\n            if (p.Kind == \u0022reject\u0022 \u0026\u0026 p.Scene == null)\r\n            {\r\n                Fail(text);\r\n                return;\r\n            }\r\n            if (restoring \u0026\u0026 p.Kind == \u0022reject\u0022)\r\n            {\r\n                RecoveryNotice = CollaborationDiagnostics.Bounded(\u0022Rejected offline edit retained in \u0022 \u002B RecoveryPath \u002B \u0022: \u0022 \u002B text);\r\n            }\r\n            else if (p.Kind == \u0022fatal\u0022)\r\n            {\r\n                Fail(text);\r\n            }\r\n            if (p.Kind == \u0022reject\u0022)\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 = \u0022The host refused your change to \\\u0022\u0022 \u002B p.Scene \u002B \u0022\\\u0022 (\u0022 \u002B text \u002B \u0022). The scene is synchronized from the host again; your version was saved to \u0022 \u002B refusedBackup \u002B \u0022.\u0022;\r\n                SetActionNotice(refused);\r\n                ShowToast(\u0022Change refused by the host\u0022, refused, Theme.Primary, 15f);\r\n            }\r\n        }\r\n        else if (p.Kind == \u0022presence\u0022)\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 == \u0022left\u0022)\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 == \u0022pong\u0022)\r\n            {\r\n                return;\r\n            }\r\n            if (p.Scene == null)\r\n            {\r\n                throw new InvalidOperationException(\u0022The host sent a scene update without a scene. The host is not speaking this protocol. Leave the session and reconnect to the correct host.\u0022);\r\n            }\r\n            if (!ScenePacketRouting.TryResolveTracked(p.Scene, ((IEnumerable\u003CKeyValuePair\u003Cstring, Tracked\u003E\u003E)scenes).ToDictionary((Func\u003CKeyValuePair\u003Cstring, Tracked\u003E, string\u003E)((KeyValuePair\u003Cstring, Tracked\u003E pair) =\u003E pair.Key), (Func\u003CKeyValuePair\u003Cstring, Tracked\u003E, object\u003E)((KeyValuePair\u003Cstring, Tracked\u003E pair) =\u003E 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 == \u0022snapshot\u0022)\r\n            {\r\n                Dictionary\u003Cstring, ObjectState\u003E 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 \u0026\u0026 tracked2.Ready \u0026\u0026 (tracked2.Pending != null || tracked2.SettingsPending != null))\r\n                    {\r\n                        // A resync that arrives while an edit awaits the host\u0027s 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\u0027s 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 \u0026\u0026 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 \u003E 0, unsentSettings))\r\n                    {\r\n                        // Unsent edits are about to be replaced by the host\u0027s scene: they are written to a file first, and if that fails\r\n                        // the host\u0027s 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 \u0026\u0026 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\u0027s settings replace this editor\u0027s, as its objects do. A host holding none (its first subscriber predates settings\r\n                // sync) leaves the scene\u0027s 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) =\u003E 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(\u0022Scene restored from the host\u0022, 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(\u0022The host sent a scene settings change without settings. The host is not speaking this protocol. Leave the session and reconnect to the correct host.\u0022);\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 \u002B \u0022:\u0022 \u002B 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 != \u0022accepted\u0022)\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 \u002B \u0022:\u0022 \u002B 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 \u002B \u0022:\u0022 \u002B p.Id))\r\n                {\r\n                    ForgetPending(tracked2, p.Id);\r\n                    return;\r\n                }\r\n                AcceptedOperations\u002B\u002B;\r\n                // Sentences for the user gathered while this packet is processed: the host\u0027s own notice first, then ours.\r\n                List\u003Cstring\u003E rebaseNotices = new List\u003Cstring\u003E { 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 \u0026\u0026 tracked2.Pending.Id == p.Id) ? tracked2.Pending : null;\r\n                    bool liveScene = tracked2.Session.Scene.IsValid() \u0026\u0026 SceneEditorSession.All.Contains(tracked2.Session);\r\n                    Dictionary\u003Cstring, ObjectState\u003E 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) =\u003E 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\u0027s 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() \u0026\u0026 SceneEditorSession.All.Contains(tracked2.Session);\r\n                    Dictionary\u003Cstring, ObjectState\u003E dictionary2 = (flag ? CaptureShown(p.Scene, tracked2) : new Dictionary\u003Cstring, ObjectState\u003E(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\u003Cstring, ObjectState\u003E shownBefore = new Dictionary\u003Cstring, ObjectState\u003E(dictionary2);\r\n                    Dictionary\u003Cstring, ObjectState\u003E knownPrior = new Dictionary\u003Cstring, ObjectState\u003E(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 \u0026\u0026 tracked2.SkippedObjects.Contains(change2.Id))\r\n                        {\r\n                            // An object this editor cannot show stays as it is shown here; the host\u0027s 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\u0027s\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\u0027s: 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\u0027s 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 = \u0022Connected \u2022 revision \u0022 \u002B 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(\u0022Connect before starting a continuous edit\u0022);\r\n        }\r\n        continuousEdits\u002B\u002B;\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 \u003E 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    /// \u003Csummary\u003EWhy a save is not possible right now, in words, or null when it is possible. Mirrors \u003Csee cref=\u0022CanSave\u0022/\u003E condition for condition.\u003C/summary\u003E\r\n    public string WhyCannotSave()\r\n    {\r\n        if (sourceBaseline != SceneAdapter.Baseline(includeScenes: false))\r\n        {\r\n            return \u0022the project\u0027s source or assets changed since the session started\u0022;\r\n        }\r\n        if (!Connected || Error != null)\r\n        {\r\n            return \u0022the session is not connected\u0022;\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 \u0022the active scene is not part of the session (active: \u0022 \u002B (text ?? \u0022none\u0022) \u002B \u0022)\u0022;\r\n        }\r\n        if (!value.Ready)\r\n        {\r\n            return \u0022the host has not finished sending the scene\u0022;\r\n        }\r\n        if (value.Pending != null)\r\n        {\r\n            return \u0022an edit is still waiting for the host to acknowledge it\u0022;\r\n        }\r\n        if (value.Group.Count \u003E 0)\r\n        {\r\n            return \u0022a multi-part edit is still being grouped\u0022;\r\n        }\r\n        if (value.SettingsPending != null)\r\n        {\r\n            return \u0022a change to the scene settings is still waiting for the host to acknowledge it\u0022;\r\n        }\r\n        if (HasUnsentSettings(value))\r\n        {\r\n            return \u0022a change to the scene settings has not been sent to the host yet\u0022;\r\n        }\r\n        List\u003CChange\u003E difference = StateRules.Diff(value.Known, SceneAdapter.Capture(value.Session.Scene));\r\n        if (difference.Count \u003E 0)\r\n        {\r\n            Change first = difference[0];\r\n            string name = (first.After ?? first.Before)?.Data?[\u0022Name\u0022]?.GetValue\u003Cstring\u003E() ?? first.Id;\r\n            return difference.Count \u002B \u0022 object\u0022 \u002B (difference.Count == 1 ? \u0022\u0022 : \u0022s\u0022) \u002B \u0022 differ from the host\u0027s copy (first: \u0022 \u002B name \u002B \u0022)\u0022;\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 \u0022Source/assets changed during collaboration; end the session and sync in Git before saving a checkpoint\u0022;\r\n        }\r\n        return \u0022Wait for all edits to be acknowledged before saving\u0022;\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(\u0022Auto-saved the synchronized scene at \u0022 \u002B DateTime.Now.ToString(\u0022HH:mm\u0022) \u002B \u0022.\u0022);\r\n        }\r\n        catch (Exception ex)\r\n        {\r\n            GlobalGameNamespace.Log.Error(\u0022Collaboration auto-save: \u0022 \u002B CollaborationTransportDiagnostics.BoundedExceptionDiagnostic(string.Empty, ex));\r\n            SetSaveNotice(\u0022The automatic save did not run: \u0022 \u002B 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 \u003C 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) =\u003E string.Equals(PresenceText(x.Presence, \u0022state\u0022, 32), \u0022editing\u0022, StringComparison.Ordinal)), SceneEditorSession.All.Any((SceneEditorSession x) =\u003E 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(\u0022Connection restored\u0022, \u0022Automatic recovery reconnected this session.\u0022, 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(\u0022This editor session remains fail-stop; automatic recovery cannot continue.\u0022);\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(\u0022Connection lost\u0022, \u0022Select Recover \u0026 reconnect, or press CTRL\u002BALT\u002BR, to restore this session.\u0022, Theme.Primary, 15f);\r\n                    }\r\n                }\r\n            }\r\n            else\r\n            {\r\n                if (recoverDeclined || (recoverNextAttempt != default(DateTime) \u0026\u0026 DateTime.UtcNow \u003C recoverNextAttempt))\r\n                {\r\n                    return;\r\n                }\r\n                if (recoverAttempts \u003E= 3)\r\n                {\r\n                    DeclineAutoRecover(\u0022Three automatic attempts did not reconnect this session.\u0022);\r\n                    return;\r\n                }\r\n                bool isHost = IsHost;\r\n                bool flag4 = !isHost \u0026\u0026 HasUnsavedTrackedChanges();\r\n                bool flag5 = reconnectQueued || reconnectTokenTask != null || connecting != null;\r\n                if (isHost)\r\n                {\r\n                    DeclineAutoRecover(\u0022A hosted session is never recovered automatically.\u0022);\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 \u003C= 0)\r\n                    {\r\n                        DeclineAutoRecover(\u0022Automatic recovery has no endpoint to redial.\u0022);\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\u002B\u002B;\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(\u0022Automatic recovery stopped\u0022, ex.Message, Theme.Red, 6f);\r\n                            GlobalGameNamespace.Log.Error(\u0022Collaboration auto-recover: \u0022 \u002B 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(\u0022Automatic recovery is not possible in this session\u0027s current state.\u0022);\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 \u0026\u0026 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(\u0022Automatic recovery stopped\u0022, 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(\u0022Collaboration notice: \u0022 \u002B 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(\u0022No acknowledged local operation available to undo\u0022);\r\n        }\r\n        List\u003CChange\u003E list = (from c in value.Undo.Peek()\r\n            select new Change(c.Id, c.After, c.Before)).ToList();\r\n        Dictionary\u003Cstring, ObjectState\u003E 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(\u0022Undo refused: object changed since your edit\u0022);\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 \u0022apply, capture, Initialize, MirrorNativeUndo\u0022. 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 \u0026\u0026 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 \u0026\u0026 tracked.Session.Scene.IsValid() \u0026\u0026 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(\u0022Collaboration undo reset: \u0022 \u002B 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\u003CChange\u003E changes = tracked.Undo.Peek();\r\n            if (changes != null \u0026\u0026 changes.Count != 0)\r\n            {\r\n                undoSystem.Insert(\u0022Collaboration Undo\u0022, () =\u003E\r\n                {\r\n                    TryUndo();\r\n                }, () =\u003E\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(\u0022Collaboration undo mirror: \u0022 \u002B 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(\u0022Collaboration undo mirror: \u0022 \u002B 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) \u0026\u0026 string.Equals(result?.Name, \u0022Collaboration Undo\u0022, 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\u003CChange\u003E 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(\u0022Redo is not available for this collaboration operation.\u0022);\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(\u0022Redo is not available for this collaboration operation.\u0022);\r\n                return false;\r\n            }\r\n            Dictionary\u003Cstring, ObjectState\u003E 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(\u0022Nothing to redo: this collaboration operation is still applied.\u0022);\r\n                    return false;\r\n                }\r\n                SetUndoNotice(\u0022Redo refused: the scene changed since your edit.\u0022);\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 \u003C 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 \u003C 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 \u0022local now\u0022 for arrival times; the two uses never\r\n    // meet, because a receiver only compares one sender\u0027s stamps with its own arrival times.\r\n    private readonly SessionClock presenceClock = new SessionClock();\r\n\r\n    private readonly Dictionary\u003Cstring, PeerSmoothing\u003E peerSmoothing = new Dictionary\u003Cstring, PeerSmoothing\u003E();\r\n\r\n    /// \u003Csummary\u003E\r\n    /// Feeds a received presence to its peer\u0027s 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    /// \u003C/summary\u003E\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    /// \u003Csummary\u003EForgets smoothers of peers that are no longer in the roster (the roster is cleared on leave and reconnect).\u003C/summary\u003E\r\n    private void PrunePeerSmoothing()\r\n    {\r\n        if (peerSmoothing.Count \u003C= Roster.Count)\r\n        {\r\n            return;\r\n        }\r\n        foreach (string gone in peerSmoothing.Keys.Where((string key) =\u003E !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\u003CGameEditorSession\u003E().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 \u0026\u0026 gameCamera != null \u0026\u0026 gameCamera.IsValid())\r\n        {\r\n            // While playing, the collaborator\u0027s 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[\u0022position\u0022] = JsonSerializer.SerializeToNode(gameCamera.WorldPosition);\r\n            jsonObject[\u0022rotation\u0022] = JsonSerializer.SerializeToNode(gameCamera.WorldRotation);\r\n            float gameFov = gameCamera.FieldOfView;\r\n            if (gameFov \u003E 0f \u0026\u0026 gameFov \u003C 180f)\r\n            {\r\n                jsonObject[\u0022fov\u0022] = gameFov;\r\n            }\r\n        }\r\n        else if (!playing \u0026\u0026 sceneViewportWidget != null \u0026\u0026 SceneViewWidget.Current.Session == active)\r\n        {\r\n            jsonObject[\u0022position\u0022] = JsonSerializer.SerializeToNode(sceneViewportWidget.State.CameraPosition);\r\n            jsonObject[\u0022rotation\u0022] = 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 \u003E 0f \u0026\u0026 valueOrDefault \u003C 180f)\r\n                {\r\n                    jsonObject[\u0022fov\u0022] = num.Value;\r\n                }\r\n            }\r\n        }\r\n        GameObject[] array = (playing ? null : active?.Selection.OfType\u003CGameObject\u003E().ToArray()) ?? Array.Empty\u003CGameObject\u003E();\r\n        jsonObject[\u0022selection\u0022] = JsonSerializer.SerializeToNode(array.Select((GameObject x) =\u003E x.Id.ToString()).ToArray());\r\n        // The left mouse button is held down for gameplay too, so it only means \u0022editing\u0022 outside play.\r\n        bool flag = !playing \u0026\u0026 (continuousEdits \u003E 0 || Editor.Application.MouseButtons.HasFlag(MouseButtons.Left));\r\n        JsonObject jsonObject2 = jsonObject;\r\n        string text = PlaySpectateRules.PresenceState(saving, playing, flag);\r\n        jsonObject2[\u0022state\u0022] = text;\r\n        if (flag \u0026\u0026 array.Length != 0)\r\n        {\r\n            jsonObject[\u0022editing\u0022] = array[0].Id.ToString();\r\n        }\r\n        string text2 = BoundPresenceText(EditorToolManager.CurrentModeName, 40);\r\n        if (text2 != null)\r\n        {\r\n            jsonObject[\u0022tool\u0022] = text2;\r\n        }\r\n        string presenceScene = SceneAdapter.PathOf(playing \u0026\u0026 source != null ? source : active);\r\n        // What \u0022changed\u0022 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 \u002B \u0022|\u0022 \u002B jsonObject.ToJsonString();\r\n        TimeSpan sinceLast = DateTime.UtcNow - lastPresenceSent;\r\n        bool changed = !string.Equals(fingerprint, lastPresenceFingerprint, StringComparison.Ordinal);\r\n        if (!force \u0026\u0026 !SendCadence.ShouldSendPresence(changed, sinceLast, text == \u0022editing\u0022))\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 = \u0022presence\u0022,\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 \u003C= 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 \u0026\u0026 jsonValue.TryGetValue\u003Cstring\u003E(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?[\u0022selection\u0022]?.Deserialize\u003Cstring[]\u003E() ?? Array.Empty\u003Cstring\u003E();\r\n        }\r\n        catch (Exception)\r\n        {\r\n            return Array.Empty\u003Cstring\u003E();\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?[\u0022position\u0022] == null || presence[\u0022rotation\u0022] == null)\r\n        {\r\n            return false;\r\n        }\r\n        try\r\n        {\r\n            position = presence[\u0022position\u0022].Deserialize\u003CVector3\u003E();\r\n            rotation = presence[\u0022rotation\u0022].Deserialize\u003CRotation\u003E();\r\n            if (presence[\u0022fov\u0022] is JsonValue jsonValue \u0026\u0026 jsonValue.TryGetValue\u003Cfloat\u003E(out var value) \u0026\u0026 value \u003E 0f \u0026\u0026 value \u003C 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 \u002B 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 \u002B vector2 - vector3 - position).Normal), new Ray(position, (vector \u002B vector2 \u002B vector3 - position).Normal), new Ray(position, (vector - vector2 \u002B 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 \u0022scene: \u0022 \u002B (BoundPresenceText(peer.Scene, 96) ?? \u0022no saved scene\u0022);\r\n        }\r\n        string text = PresenceText(peer.Presence, \u0022state\u0022, 32) ?? \u0022idle\u0022;\r\n        if (string.Equals(text, \u0022playing\u0022, StringComparison.Ordinal))\r\n        {\r\n            return PlaySpectateRules.StatusText(text);\r\n        }\r\n        if (!string.Equals(text, \u0022editing\u0022, StringComparison.Ordinal) || edited == null || objects == null)\r\n        {\r\n            return text;\r\n        }\r\n        GameObject gameObject = Array.Find(objects, (GameObject x) =\u003E 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 \u002B \u0022 \u00B7 \u0022 \u002B 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\u003Cstring, Packet\u003E 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) ?? \u0022(unknown name)\u0022;\r\n            string[] array2 = ReadSelection(value.Presence);\r\n            string text3 = PresenceText(value.Presence, \u0022editing\u0022, 64);\r\n            if ((array2.Length != 0 || (flag \u0026\u0026 text3 != null)) \u0026\u0026 array == null)\r\n            {\r\n                array = SceneAdapter.Walk(scene).ToArray();\r\n            }\r\n            using (Gizmo.Scope(\u0022collaboration-\u0022 \u002B value.Peer, Transform.Zero))\r\n            {\r\n                bool flag2 = (utcNow \u003C focusedUntil \u0026\u0026 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\u0027s 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 \u003C= PresenceStyle.FarDistance;\r\n                    string presenceState = PresenceText(value.Presence, \u0022state\u0022, 32) ?? \u0022idle\u0022;\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 \u0026 flag,\r\n                        Frustum = (fieldOfView.HasValue \u0026 flag) ? PeerFrustum(position, rotation, fieldOfView.Value) : null\r\n                    });\r\n                    DrawnMarkers\u002B\u002B;\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) ?? \u0022(unnamed)\u0022, new Transform(gameObject.WorldPosition \u002B Vector3.Up * 16f), \u0022Inter\u0022);\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 \u002B Vector3.Up * 24f, 3.0, texture2, worldspace: true);\r\n                        }\r\n                        DrawnSelections\u002B\u002B;\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(\u0022That collaborator is no longer connected.\u0022);\r\n            return;\r\n        }\r\n        if (!string.Equals(value.Scene, SceneAdapter.PathOf(SceneEditorSession.Active), StringComparison.Ordinal))\r\n        {\r\n            RefuseFocus(\u0022That collaborator is in \u0022 \u002B (BoundPresenceText(value.Scene, 96) ?? \u0022no saved scene\u0022) \u002B \u0022. Open the scene first.\u0022);\r\n            return;\r\n        }\r\n        SceneViewportWidget sceneViewportWidget = SceneViewWidget.Current?.LastSelectedViewportWidget;\r\n        if (sceneViewportWidget?.State == null)\r\n        {\r\n            RefuseFocus(\u0022No scene view is open to move.\u0022);\r\n            return;\r\n        }\r\n        if (value.Presence?[\u0022position\u0022] == null || value.Presence[\u0022rotation\u0022] == null)\r\n        {\r\n            RefuseFocus(\u0022That collaborator has not reported a camera position yet.\u0022);\r\n            return;\r\n        }\r\n        Vector3 cameraPosition;\r\n        Rotation cameraRotation;\r\n        try\r\n        {\r\n            cameraPosition = value.Presence[\u0022position\u0022].Deserialize\u003CVector3\u003E();\r\n            cameraRotation = value.Presence[\u0022rotation\u0022].Deserialize\u003CRotation\u003E();\r\n        }\r\n        catch (Exception)\r\n        {\r\n            RefuseFocus(\u0022That collaborator reported a camera position this build cannot read.\u0022);\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\u003Cstring, DiscoveredHost\u003E hosts = new Dictionary\u003Cstring, DiscoveredHost\u003E(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\u003CDiscoveredHost\u003E Hosts\r\n    {\r\n        get\r\n        {\r\n            Prune(hosts, DateTime.UtcNow, HostExpiry);\r\n            return new List\u003CDiscoveredHost\u003E(hosts.Values);\r\n        }\r\n    }\r\n\r\n    public string Failure =\u003E failure;\r\n\r\n    public bool CanHost =\u003E socket != null \u0026\u0026 boundToRendezvousPort \u0026\u0026 !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, () =\u003E socket.Client.Poll(0, SelectMode.SelectRead) \u0026\u0026 socket.Available \u003E 0, () =\u003E\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) =\u003E\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 \u003C nextQueryUtc)\r\n        {\r\n            return;\r\n        }\r\n        nextQueryUtc = utcNow \u002B 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, \u0022host\u0022, secretHex, manifest8);\r\n        if (socket == null || disposed)\r\n        {\r\n            failure = CollaborationDiagnostics.Bounded(\u0022LAN discovery is unavailable: the rendezvous socket could not be opened on this machine.\u0022);\r\n        }\r\n        else if (!boundToRendezvousPort)\r\n        {\r\n            failure = CollaborationDiagnostics.Bounded($\u0022LAN discovery cannot answer on this machine: UDP port {27846} is already in use.\u0022);\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 \u003E 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 \u0026\u0026 text2 == projectIdHash8)\r\n            {\r\n                Reply(from);\r\n            }\r\n        }\r\n        else if (DiscoveryProtocol.TryParseReply(text, out reply) \u0026\u0026 !(reply.ProjectIdHash8 != projectIdHash8) \u0026\u0026 !hosting)\r\n        {\r\n            string text3 = from.Address.ToString();\r\n            if (hosts.Count \u003E= 16 \u0026\u0026 !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\u003Cstring, DiscoveredHost\u003E host in hosts)\r\n        {\r\n            if ((object)discoveredHost == null || host.Value.LastSeenUtc \u003C 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\u003CT\u003E(int cap, Func\u003Cbool\u003E available, Func\u003CT\u003E receive, Action\u003CT\u003E handle)\r\n    {\r\n        int i;\r\n        for (i = 0; i \u003C cap; i\u002B\u002B)\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\u003Cstring, DiscoveredHost\u003E 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\u003Cstring\u003E list = null;\r\n        foreach (KeyValuePair\u003Cstring, DiscoveredHost\u003E host in hosts)\r\n        {\r\n            if (nowUtc - host.Value.LastSeenUtc \u003E maxAge)\r\n            {\r\n                (list ?? (list = new List\u003Cstring\u003E())).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(\u0022LAN discovery: \u0022, 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\u003Cbool\u003E 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\u003Cbool\u003E 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 \u003C TimeSpan.FromSeconds(30L))\r\n        {\r\n            return false;\r\n        }\r\n        if (input.LastAutoSave != default(DateTime) \u0026\u0026 input.Now - input.LastAutoSave \u003C 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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\npublic static class BlobLimits\n{\n    /// \u003Csummary\u003EAdvertised by the host on welcome. A guest sends geometry placeholders only to a host that holds this feature.\u003C/summary\u003E\n    public const string Feature = \u0022blobs-1\u0022;\n\n    /// \u003Csummary\u003EThe largest geometry (decoded bytes) that is synchronized. The largest mesh in the installed sample scenes is 576 KB.\u003C/summary\u003E\n    public const int MaxBlobBytes = 32 * 1024 * 1024;\n\n    /// \u003Csummary\u003EGeometry that has started to arrive and is not complete yet, summed over one endpoint (every member on a host).\u003C/summary\u003E\n    public const long MaxPendingBytes = 64L * 1024 * 1024;\n\n    /// \u003Csummary\u003EScene packets one endpoint holds while the geometry they name is on its way.\u003C/summary\u003E\n    public const int MaxPendingPackets = 32;\n\n    /// \u003Csummary\u003EHow long a held packet waits for its geometry before it is discarded and the scene is synchronized again.\u003C/summary\u003E\n    public const int WaitSeconds = 60;\n\n    /// \u003Csummary\u003EHashes one blob-need may name; the remainder of a longer request is ignored.\u003C/summary\u003E\n    public const int MaxHashesPerRequest = 64;\n\n    /// \u003Csummary\u003E\n    /// Shortest text value that is moved out of an object\u0027s 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    /// \u003C/summary\u003E\n    public const int MinExternalChars = 4096;\n\n    /// \u003Csummary\u003EOne new version of an object\u0027s geometry per this many milliseconds, so dragging a vertex does not flood the session.\u003C/summary\u003E\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/// \u003Csummary\u003E\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 \u003Cc\u003E.collaboration/blobs/\u0026lt;sha256\u0026gt;\u003C/c\u003E. 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/// \u003C/summary\u003E\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\u003Cstring, Entry\u003E 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 \u003E= \u00270\u0027 \u0026\u0026 c \u003C= \u00279\u0027 ) || ( c \u003E= \u0027a\u0027 \u0026\u0026 c \u003C= \u0027f\u0027 ) ) ) return false;\n        }\n        return true;\n    }\n\n    /// \u003Csummary\u003E\n    /// Starts keeping blobs on disk under \u003Cparamref name=\u0022root\u0022/\u003E, 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    /// \u003C/summary\u003E\n    public void AttachDisk( string root, long cap )\n    {\n        string folder;\n        try\n        {\n            folder = Path.Combine( root, \u0022.collaboration\u0022, \u0022blobs\u0022 );\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\u003Cstring, Entry\u003E pair in memory ) WriteFile( pair.Key, pair.Value.Bytes );\n        }\n    }\n\n    /// \u003Csummary\u003EStores \u003Cparamref name=\u0022bytes\u0022/\u003E under their own hash. Null (and \u003Cparamref name=\u0022error\u0022/\u003E) when they are empty or over the limit.\u003C/summary\u003E\n    public string Put( byte[] bytes, out string error )\n    {\n        error = null;\n        if ( bytes == null || bytes.Length == 0 )\n        {\n            error = \u0022Empty geometry is not stored.\u0022;\n            return null;\n        }\n        if ( bytes.Length \u003E 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    /// \u003Csummary\u003EStores \u003Cparamref name=\u0022bytes\u0022/\u003E only if they hash to \u003Cparamref name=\u0022hash\u0022/\u003E; anything else is refused and nothing is written.\u003C/summary\u003E\n    public bool Put( string hash, byte[] bytes, out string error )\n    {\n        error = null;\n        if ( !ValidHash( hash ) )\n        {\n            error = \u0022The geometry was offered under a name that is not a SHA-256 digest.\u0022;\n            return false;\n        }\n        if ( bytes == null || bytes.Length == 0 )\n        {\n            error = \u0022Empty geometry is not stored.\u0022;\n            return false;\n        }\n        if ( bytes.Length \u003E BlobLimits.MaxBlobBytes )\n        {\n            error = TooLarge();\n            return false;\n        }\n        if ( HashOf( bytes ) != hash )\n        {\n            error = \u0022The geometry did not match the digest it was offered under and was discarded.\u0022;\n            return false;\n        }\n        return Store( hash, bytes );\n    }\n\n    /// \u003Csummary\u003E\n    /// Records bytes the caller has just hashed itself (\u003Csee cref=\u0022BlobRules.Externalize\u0022/\u003E 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    /// \u003C/summary\u003E\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 = \u002B\u002Bclock;\n                return;\n            }\n            if ( bytes.Length \u003E 0 \u0026\u0026 bytes.Length \u003C= BlobLimits.MaxBlobBytes ) Store( hash, bytes );\n        }\n    }\n\n    public static string TooLarge() =\u003E \u0022The geometry is larger than the 32 MiB collaboration limit and was not synchronized.\u0022;\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 \u0026\u0026 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 = \u002B\u002Bclock;\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\u0027s edit) is never served: it is removed so\n            // the geometry is fetched again.\n            if ( read.Length == 0 || read.Length \u003E 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    /// \u003Csummary\u003EDeletes the oldest files until the disk cache is within its cap. Called by the session from time to time, never on the capture path.\u003C/summary\u003E\n    public void Prune()\n    {\n        lock ( gate )\n        {\n            if ( directory == null || diskCap \u003C= 0 ) return;\n            List\u003CFileInfo\u003E files;\n            try\n            {\n                files = new DirectoryInfo( directory ).GetFiles().Where( x =\u003E 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 =\u003E x.Length );\n            foreach ( FileInfo file in files.OrderBy( x =\u003E x.LastWriteTimeUtc ) )\n            {\n                if ( total \u003C= 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 = \u002B\u002Bclock;\n                if ( directory != null \u0026\u0026 !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 = \u002B\u002Bclock };\n        MemoryBytes \u002B= bytes.Length;\n        while ( MemoryBytes \u003E memoryCap \u0026\u0026 memory.Count \u003E 1 )\n        {\n            string oldest = null;\n            long oldestTouch = long.MaxValue;\n            foreach ( KeyValuePair\u003Cstring, Entry\u003E pair in memory )\n            {\n                if ( pair.Key != hash \u0026\u0026 pair.Value.Touched \u003C 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 \u002B \u0022.\u0022 \u002B Guid.NewGuid().ToString( \u0022N\u0022 ) \u002B \u0022.tmp\u0022 );\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/// \u003Csummary\u003E\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\u0027s attention (a relay that is\n/// missing, a router that did not answer, a scene that is playing). The host\u0027s 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/// \u003C/summary\u003E\npublic static class ConnectCopy\n{\n    public const string Idle = \u0022Host a session, or paste a friend\u0027s code to join.\u0022;\n\n    public const string ShareCode = \u0022Share this code with your friend. They paste it under Join.\u0022;\n\n    public const string ReadyToJoin = \u0022Ready to join with this code. The local network is tried first, then the relay in the code, then the host\u0027s address.\u0022;\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\u003Cstring\u003E();\n        if (connected \u0026\u0026 !isHost \u0026\u0026 !string.IsNullOrWhiteSpace(route))\n        {\n            lines.Add(route);\n        }\n        else if (!connected \u0026\u0026 !string.IsNullOrWhiteSpace(typedCode))\n        {\n            lines.Add(ReadyToJoin);\n        }\n        else if (isHost \u0026\u0026 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(\u0022\\n\u0022, lines);\n    }\n\n    private static void Add(List\u003Cstring\u003E 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\u003Cstring\u003E GameObjectFields = new HashSet\u003Cstring\u003E(StringComparer.Ordinal) { \u0022__guid\u0022, \u0022Name\u0022, \u0022Position\u0022, \u0022Rotation\u0022, \u0022Scale\u0022, \u0022Enabled\u0022, \u0022Tags\u0022, \u0022Components\u0022 };\r\n\r\n    private static readonly Dictionary\u003Cstring, HashSet\u003Cstring\u003E\u003E ComponentFields = new Dictionary\u003Cstring, HashSet\u003Cstring\u003E\u003E(StringComparer.Ordinal)\r\n    {\r\n        [\u0022Sandbox.ModelRenderer\u0022] = new HashSet\u003Cstring\u003E(StringComparer.Ordinal) { \u0022Tint\u0022 },\r\n        [\u0022Sandbox.CameraComponent\u0022] = new HashSet\u003Cstring\u003E(StringComparer.Ordinal) { \u0022FieldOfView\u0022, \u0022Orthographic\u0022, \u0022OrthographicHeight\u0022, \u0022ZNear\u0022, \u0022ZFar\u0022, \u0022BackgroundColor\u0022 },\r\n        [\u0022Sandbox.DirectionalLight\u0022] = new HashSet\u003Cstring\u003E(StringComparer.Ordinal) { \u0022LightColor\u0022, \u0022SkyColor\u0022, \u0022Shadows\u0022 },\r\n        [\u0022Sandbox.PointLight\u0022] = new HashSet\u003Cstring\u003E(StringComparer.Ordinal) { \u0022LightColor\u0022, \u0022Radius\u0022, \u0022Shadows\u0022 },\r\n        [\u0022Sandbox.BoxCollider\u0022] = new HashSet\u003Cstring\u003E(StringComparer.Ordinal) { \u0022Center\u0022, \u0022Scale\u0022, \u0022IsTrigger\u0022 }\r\n    };\r\n\r\n    public static bool IsValidRunId(string runId)\r\n    {\r\n        return !string.IsNullOrWhiteSpace(runId) \u0026\u0026 runId.Length \u003C= 128 \u0026\u0026 runId == runId.Trim() \u0026\u0026 !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(\u0022object is missing\u0022);\r\n        }\r\n        if (!string.IsNullOrEmpty(state.Parent) \u0026\u0026 !TryGetCanonicalId(state.Parent, out var canonicalId))\r\n        {\r\n            throw InvalidIdentity(\u0022parent id \u0022 \u002B state.Parent \u002B \u0022 is not a GUID\u0022);\r\n        }\r\n        if (!TryGetCanonicalId(state.Id, out var canonicalId2) || state.Data == null || !TryGetString(state.Data, \u0022__guid\u0022, out var value) || !TryGetCanonicalId(value, out var canonicalId3) || !string.Equals(canonicalId2, canonicalId3, StringComparison.Ordinal))\r\n        {\r\n            throw InvalidIdentity(state.Id \u002B \u0022 does not match its serialized __guid\u0022);\r\n        }\r\n        if (state.Data.Any((KeyValuePair\u003Cstring, JsonNode\u003E field) =\u003E field.Key.Contains(\u0022Prefab\u0022, StringComparison.OrdinalIgnoreCase)))\r\n        {\r\n            throw NotAllowlisted(\u0022GameObject field \u0027\u0022 \u002B FindPrefabField(state.Data) \u002B \u0022\u0027 is prefab data\u0022);\r\n        }\r\n        bool value2;\r\n        foreach (KeyValuePair\u003Cstring, JsonNode\u003E datum in state.Data)\r\n        {\r\n            if (!GameObjectFields.Contains(datum.Key))\r\n            {\r\n                throw NotAllowlisted(\u0022GameObject field \u0027\u0022 \u002B datum.Key \u002B \u0022\u0027\u0022);\r\n            }\r\n            switch (datum.Key)\r\n            {\r\n            case \u0022__guid\u0022:\r\n            case \u0022Name\u0022:\r\n            case \u0022Components\u0022:\r\n                value2 = true;\r\n                break;\r\n            default:\r\n                value2 = false;\r\n                break;\r\n            }\r\n            if (!value2 \u0026\u0026 !IsScalar(datum.Value))\r\n            {\r\n                throw NotAllowlisted(\u0022GameObject field \u0027\u0022 \u002B datum.Key \u002B \u0022\u0027 is not a scalar JsonValue\u0022);\r\n            }\r\n        }\r\n        if (!TryGetString(state.Data, \u0022Name\u0022, out canonicalId))\r\n        {\r\n            throw InvalidShape(\u0022GameObject field \u0027Name\u0027 must be a non-null JSON string\u0022);\r\n        }\r\n        if (state.Data.TryGetPropertyValue(\u0022Enabled\u0022, out JsonNode jsonNode) \u0026\u0026 (!(jsonNode is JsonValue jsonValue) || !jsonValue.TryGetValue\u003Cbool\u003E(out value2)))\r\n        {\r\n            throw InvalidShape(\u0022GameObject field \u0027Enabled\u0027 must be a JSON boolean\u0022);\r\n        }\r\n        if (state.Data.TryGetPropertyValue(\u0022Tags\u0022, out JsonNode _) \u0026\u0026 !TryGetString(state.Data, \u0022Tags\u0022, out canonicalId))\r\n        {\r\n            throw InvalidShape(\u0022GameObject field \u0027Tags\u0027 must be a JSON string\u0022);\r\n        }\r\n        if (!(state.Data[\u0022Components\u0022] is JsonArray jsonArray))\r\n        {\r\n            throw InvalidShape(\u0022GameObject field \u0027Components\u0027 must be a JSON array\u0022);\r\n        }\r\n        HashSet\u003Cstring\u003E hashSet = new HashSet\u003Cstring\u003E(StringComparer.Ordinal);\r\n        foreach (JsonNode item in jsonArray)\r\n        {\r\n            if (!(item is JsonObject jsonObject))\r\n            {\r\n                throw InvalidShape(\u0022Components entries must be JSON objects\u0022);\r\n            }\r\n            if (!TryGetString(jsonObject, \u0022__type\u0022, out var value3) || string.IsNullOrWhiteSpace(value3))\r\n            {\r\n                throw InvalidShape(\u0022Component field \u0027__type\u0027 must be a non-empty JSON string\u0022);\r\n            }\r\n            if (!ComponentFields.TryGetValue(value3, out var value4))\r\n            {\r\n                throw new InvalidOperationException(\u0022Runtime component type is not allowlisted: \u0022 \u002B value3);\r\n            }\r\n            if (!TryGetString(jsonObject, \u0022__guid\u0022, out var value5) || !TryGetCanonicalId(value5, out var canonicalId4))\r\n            {\r\n                throw InvalidIdentity(\u0022component \u0022 \u002B value3 \u002B \u0022 has an invalid __guid\u0022);\r\n            }\r\n            if (string.Equals(canonicalId4, canonicalId2, StringComparison.Ordinal))\r\n            {\r\n                throw InvalidIdentity(\u0022component \u0022 \u002B value3 \u002B \u0022 reuses its object __guid \u0022 \u002B value5);\r\n            }\r\n            if (!hashSet.Add(canonicalId4))\r\n            {\r\n                throw InvalidIdentity(\u0022component \u0022 \u002B value3 \u002B \u0022 duplicates __guid \u0022 \u002B value5);\r\n            }\r\n            foreach (KeyValuePair\u003Cstring, JsonNode\u003E item2 in jsonObject)\r\n            {\r\n                canonicalId = item2.Key;\r\n                if ((!(canonicalId == \u0022__type\u0022) \u0026\u0026 !(canonicalId == \u0022__guid\u0022)) || 1 == 0)\r\n                {\r\n                    if (item2.Key.Contains(\u0022Prefab\u0022, StringComparison.OrdinalIgnoreCase))\r\n                    {\r\n                        throw NotAllowlisted($\u0022component field \u0027{value3}.{item2.Key}\u0027 is prefab data\u0022);\r\n                    }\r\n                    if (!value4.Contains(item2.Key))\r\n                    {\r\n                        throw NotAllowlisted($\u0022component field \u0027{value3}.{item2.Key}\u0027\u0022);\r\n                    }\r\n                    if (!IsScalar(item2.Value))\r\n                    {\r\n                        throw NotAllowlisted($\u0022component field \u0027{value3}.{item2.Key}\u0027 is not a scalar JsonValue\u0022);\r\n                    }\r\n                }\r\n            }\r\n        }\r\n    }\r\n\r\n    public static void ValidateSnapshot(string scenePath, IReadOnlyDictionary\u003Cstring, ObjectState\u003E objects)\r\n    {\r\n        if (!IsSafeScenePath(scenePath))\r\n        {\r\n            throw new InvalidOperationException(\u0022Runtime scene path is invalid: \u0022 \u002B (scenePath ?? \u0022\u003Cnull\u003E\u0022));\r\n        }\r\n        if (objects == null)\r\n        {\r\n            throw InvalidIdentity(\u0022snapshot is missing\u0022);\r\n        }\r\n        HashSet\u003Cstring\u003E hashSet = new HashSet\u003Cstring\u003E(StringComparer.Ordinal);\r\n        HashSet\u003Cstring\u003E hashSet2 = new HashSet\u003Cstring\u003E(StringComparer.Ordinal);\r\n        Dictionary\u003Cstring, ObjectState\u003E dictionary = new Dictionary\u003Cstring, ObjectState\u003E(StringComparer.Ordinal);\r\n        foreach (KeyValuePair\u003Cstring, ObjectState\u003E @object in objects)\r\n        {\r\n            if ((object)@object.Value == null)\r\n            {\r\n                throw InvalidIdentity(\u0022snapshot contains missing object id \u0022 \u002B @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(\u0022snapshot key \u0022 \u002B @object.Key \u002B \u0022 does not match object id \u0022 \u002B @object.Value.Id);\r\n            }\r\n            if (!hashSet.Add(canonicalId))\r\n            {\r\n                throw InvalidIdentity(\u0022snapshot contains duplicate object id \u0022 \u002B @object.Key);\r\n            }\r\n            if (!hashSet2.Add(canonicalId2))\r\n            {\r\n                throw InvalidIdentity(\u0022snapshot contains duplicate object id \u0022 \u002B @object.Key);\r\n            }\r\n            if (!dictionary.TryAdd(canonicalId2, @object.Value))\r\n            {\r\n                throw InvalidIdentity(\u0022snapshot contains duplicate object id \u0022 \u002B @object.Key);\r\n            }\r\n            JsonArray jsonArray = @object.Value.Data[\u0022Components\u0022].AsArray();\r\n            foreach (JsonNode item in jsonArray)\r\n            {\r\n                string value = item[\u0022__guid\u0022].GetValue\u003Cstring\u003E();\r\n                if (!TryGetCanonicalId(value, out var canonicalId3))\r\n                {\r\n                    throw InvalidIdentity(\u0022component \u0022 \u002B value \u002B \u0022 is not a GUID\u0022);\r\n                }\r\n                if (!hashSet2.Add(canonicalId3))\r\n                {\r\n                    throw InvalidIdentity(\u0022snapshot contains duplicate object/component id \u0022 \u002B value);\r\n                }\r\n            }\r\n        }\r\n        foreach (KeyValuePair\u003Cstring, ObjectState\u003E object2 in objects)\r\n        {\r\n            if (!TryGetCanonicalId(object2.Key, out var canonicalId4))\r\n            {\r\n                throw InvalidIdentity(\u0022snapshot key \u0022 \u002B object2.Key \u002B \u0022 is not a GUID\u0022);\r\n            }\r\n            HashSet\u003Cstring\u003E hashSet3 = new HashSet\u003Cstring\u003E(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(\u0022Runtime parent does not exist: \u0022 \u002B parent);\r\n                }\r\n                if ((object)value2 == null)\r\n                {\r\n                    throw InvalidIdentity(\u0022Runtime parent is missing: \u0022 \u002B parent);\r\n                }\r\n                if (!hashSet3.Add(canonicalId5))\r\n                {\r\n                    throw new InvalidOperationException(\u0022Runtime parent cycle detected at \u0022 \u002B 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\u003Cstring\u003E value;\r\n        return ComponentFields.TryGetValue(type, out value) \u0026\u0026 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(\u0022scenes/\u0022, StringComparison.Ordinal) || scenePath.Contains(\u0027\\\\\u0027) || scenePath.Contains(\u0022..\u0022, StringComparison.Ordinal) || Path.IsPathRooted(scenePath) || scenePath.Any(char.IsControl))\r\n        {\r\n            return false;\r\n        }\r\n        string[] source = scenePath.Split(\u0027/\u0027);\r\n        if (source.Any((string segment) =\u003E\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 == \u0022.\u0022 || segment == \u0022..\u0022) ? 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(\u0022.scene\u0022, 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(\u0022D\u0022);\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\u003Cstring, JsonNode\u003E field) =\u003E field.Key.Contains(\u0022Prefab\u0022, 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 \u003C= 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) \u0026\u0026 jsonNode is JsonValue jsonValue \u0026\u0026 jsonValue.TryGetValue\u003Cstring\u003E(out value);\r\n    }\r\n\r\n    private static InvalidOperationException NotAllowlisted(string detail)\r\n    {\r\n        return new InvalidOperationException(\u0022Runtime state is not allowlisted: \u0022 \u002B detail);\r\n    }\r\n\r\n    private static InvalidOperationException InvalidIdentity(string detail)\r\n    {\r\n        return new InvalidOperationException(\u0022Runtime object identity is invalid: \u0022 \u002B detail);\r\n    }\r\n\r\n    private static InvalidOperationException InvalidShape(string detail)\r\n    {\r\n        return new InvalidOperationException(\u0022Runtime state shape is invalid: \u0022 \u002B 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 \u002B 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 \u002B 8f));\r\n            float x = localRect.Left \u002B 36f;\r\n            Paint.SetPen(in Theme.Text, 0f, PenStyle.Solid);\r\n            if (width \u003E 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 \u002B width;\r\n            float width2 = localRect.Right - x2 - 4f;\r\n            if (text2.Length \u003E 0 \u0026\u0026 width2 \u003E 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\u003CReadinessItem\u003E lastList;\r\n\r\n    private string signature;\r\n\r\n    protected override bool FieldsAssigned =\u003E rows != null \u0026\u0026 notice != null;\r\n\r\n    public ReadinessCard(Widget parent)\r\n        : base(parent, \u0022Readiness\u0022)\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(\u0022\u0022, 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\u003CReadinessItem\u003E 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\u003CReadinessItem\u003E 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(\u0027|\u0027).Append(item.Level)\r\n                    .Append(\u0027|\u0027)\r\n                    .Append(item.Title)\r\n                    .Append(\u0027|\u0027)\r\n                    .Append(item.Detail)\r\n                    .Append(\u0027|\u0027)\r\n                    .Append(item.Fix)\r\n                    .Append(\u0027\\n\u0027);\r\n            }\r\n        }\r\n        return stringBuilder.ToString();\r\n    }\r\n\r\n    private void Rebuild(IReadOnlyList\u003CReadinessItem\u003E 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(\u0022The readiness checklist has not been evaluated yet.\u0022, 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, \u0022Update\u0022, StringComparison.Ordinal))\r\n        {\r\n            return new Button(\u0022Update\u0022, parent)\r\n            {\r\n                ToolTip = \u0022Install the current copy of the collaboration library from Asset Party.\u0022,\r\n                Clicked = () =\u003E\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 = \u0022This build has no action for this item.\u0022\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(\u0022The collaboration library ident is not configured in ProjectSettings/collaboration.json.\u0022);\r\n                return;\r\n            }\r\n            SetNotice(\u0022Requesting the current copy of \u0022 \u002B ident \u002B \u0022 from Asset Party\u2026\u0022);\r\n            Package package = await Package.FetchAsync(ident, partial: false);\r\n            if (package?.Revision == null)\r\n            {\r\n                SetNotice(\u0022The package \u0022 \u002B ident \u002B \u0022 could not be fetched from Asset Party.\u0022);\r\n                return;\r\n            }\r\n            await LibrarySystem.Install(ident, package.Revision.VersionId);\r\n            SetNotice(\u0022The collaboration library is being installed. The checklist reports the installed version when it finishes.\u0022);\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(\u0027.\u0027))\r\n        {\r\n            return text;\r\n        }\r\n        string org = config.Org;\r\n        return string.IsNullOrWhiteSpace(org) ? text : (org \u002B \u0022.\u0022 \u002B 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 \u003E 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/// \u003Csummary\u003ESends Cloudflare requests through the engine\u0027s HTTP client, which is the only network access an editor script has.\u003C/summary\u003E\npublic sealed class SandboxCloudflareTransport : ICloudflareTransport\n{\n    private const int MaxBodyChars = 262144;\n\n    public async Task\u003CCloudflareResponse\u003E 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 \u002B \u0022: \u0022 \u002B 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 \u0026\u0026 text.Length \u003C= 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/// \u003Csummary\u003E\n/// The Relay card\u0027s machinery. Two buttons drive it: one opens Cloudflare\u0027s token page, the other reads the token from the clipboard, deploys the relay to the\n/// person\u0027s 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/// \u003C/summary\u003E\npublic static class RelaySetup\n{\n    private static Task\u003CDeployResult\u003E 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\u0027s 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\u0027s choice and not the project\u0027s.\n    private static bool tunnelSettingLoaded;\n\n    private static bool tunnelEnabled = true;\n\n    private static string TunnelSettingPath =\u003E Path.Combine(SceneAdapter.Root, \u0022.collaboration\u0022, LocalTunnelSetting.FileName);\n\n    /// \u003Csummary\u003E\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    /// \u003C/summary\u003E\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 = \u0022The internet relay preference in .collaboration/\u0022 \u002B LocalTunnelSetting.FileName \u002B \u0022 could not be read (\u0022 \u002B ex.Message \u002B \u0022), so the internet relay stays off.\u0022;\n                }\n                if (problem != null)\n                {\n                    GlobalGameNamespace.Log.Error(\u0022Collaboration relay: \u0022 \u002B 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 =\u003E task != null \u0026\u0026 !task.IsCompleted;\n\n    public static string Progress =\u003E progress;\n\n    /// \u003Csummary\u003EThe relay this computer set up, or null. Read once and kept, because the dock asks every frame.\u003C/summary\u003E\n    public static string LocalRelay\n    {\n        get\n        {\n            EnsureLocalLoaded();\n            return localRelay;\n        }\n    }\n\n    /// \u003Csummary\u003EWhy the local file could not be used, or null.\u003C/summary\u003E\n    public static string LocalProblem\n    {\n        get\n        {\n            EnsureLocalLoaded();\n            return localProblem;\n        }\n    }\n\n    private static string LocalFilePath =\u003E Path.Combine(SceneAdapter.Root, \u0022.collaboration\u0022, 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 = \u0022The relay address file could not be read: \u0022 \u002B ex.Message;\n        }\n        if (localProblem != null)\n        {\n            GlobalGameNamespace.Log.Error(\u0022Collaboration relay: \u0022 \u002B 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 \u0026\u0026 view.Mode == RelaySetupMode.Missing)\n        {\n            return view with { Text = localProblem \u002B \u0022 \u0022 \u002B view.Text, IsProblem = true };\n        }\n        return view;\n    }\n\n    /// \u003Csummary\u003EOpens the Cloudflare page that creates the token, with the two permissions the relay needs already chosen.\u003C/summary\u003E\n    public static void OpenTokenPage()\n    {\n        // The engine\u0027s 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    /// \u003Csummary\u003EReads the token from the clipboard and deploys. The clipboard is checked before anything is sent anywhere.\u003C/summary\u003E\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 = \u0022Starting\u2026\u0022;\n        var deployer = new RelayDeployer(new SandboxCloudflareTransport(), RelayWorkerSource.Text);\n        task = deployer.RunAsync(token, text =\u003E 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\u003CDeployResult\u003E 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, \u0022Setup stopped unexpectedly: \u0022 \u002B CloudflareApi.Redact(finished.Exception?.GetBaseException().Message, token) \u002B \u0022 Nothing was changed on this computer.\u0022)\n                : finished.Result;\n        }\n        catch (Exception ex)\n        {\n            result = new DeployResult(false, null, \u0022Setup stopped unexpectedly: \u0022 \u002B 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 \u0026\u0026 !result.Cancelled)\n        {\n            GlobalGameNamespace.Log.Error(\u0022Collaboration relay setup: \u0022 \u002B 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 \u002B \u0022.tmp\u0022;\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 \u0022The relay is running at \u0022 \u002B RelaySetupRules.HostOf(relayUrl) \u002B \u0022, but its address could not be saved on this computer (\u0022 \u002B ex.Message \u002B \u0022). Put it in .collaboration/\u0022 \u002B LocalRelayFile.FileName \u002B \u0022 yourself, or set RelayUrl in ProjectSettings/collaboration.json.\u0022;\n        }\n    }\n\n    /// \u003Csummary\u003EThe 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.\u003C/summary\u003E\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 \u0026\u0026 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/// \u003Csummary\u003E\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\u0027s 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\u0027s 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\u0027s 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\u0027s \u0022Registered tunnel connection\u0022 line or a successful self-check first.\n/// \u003C/summary\u003E\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\u0027s Dispose call.\n    private static readonly object LiveLock = new();\n    private static readonly System.Collections.Generic.HashSet\u003CCloudflaredTool\u003E Live = new();\n\n    private readonly ConcurrentQueue\u003Cstring\u003E output = new();\n    private CancellationTokenSource downloadCancel;\n    private Task downloadTask;\n    private Task\u003Cbyte[]\u003E 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() =\u003E AppDomain.CurrentDomain.ProcessExit \u002B= ( _, _ ) =\u003E CloseAll();\n\n    [EditorEvent.Hotload]\n    private static void OnHotload() =\u003E CloseAll();\n\n    private static void CloseAll()\n    {\n        CloudflaredTool[] items;\n        lock ( LiveLock ) items = new System.Collections.Generic.List\u003CCloudflaredTool\u003E( 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; } = \u0022The tunnel is not running.\u0022;\n    public string RelayUrl { get; private set; }\n    /// \u003Csummary\u003E\n    /// True once cloudflared reported a registered edge connection. The address is printed before that, and asking for it then gets \u0022no such name\u0022\n    /// cached by resolvers for a minute, so HostingPlan probes only after this has been seen.\n    /// \u003C/summary\u003E\n    public bool RegisteredConnectionSeen { get; private set; }\n    public string Error { get; private set; }\n\n    /// \u003Csummary\u003EIdempotent while starting or running. A failed attempt can be retried after Dispose by constructing a new tool.\u003C/summary\u003E\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, \u0022.collaboration\u0022, \u0022tools\u0022, \u0022cloudflared.exe\u0022 );\n            partPath = toolPath \u002B \u0022.part\u0022;\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( \u0022The tunnel tool could not be prepared. Check that the project folder is writable and retry hosting.\u0022 ); }\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\u0027s thread is the engine\u0027s choice, not ours.\n    private async Task DownloadAsync( CancellationToken token )\n    {\n        using Stream response = await Http.RequestStreamAsync( CloudflaredPin.Url, \u0022GET\u0022, 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 \u003E MaxBytes - total ) throw new InvalidDataException( \u0022The tunnel tool download exceeded 120 MB.\u0022 );\n            await file.WriteAsync( chunk.AsMemory( 0, count ), token );\n            total \u002B= 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 \u003C= CloudflaredPin.SizeBytes\n            ? $\u0022Downloading the tunnel tool ({mb} of {of} MB).\u0022\n            : $\u0022Downloading the tunnel tool ({mb} MB).\u0022;\n    }\n\n    private void BeginVerify()\n    {\n        State = Phase.Verifying;\n        Status = \u0022Verifying the tunnel tool.\u0022;\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 \u003E 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 \u003E MaxBytes )\n            {\n                File.Delete( partPath );\n                Fail( \u0022The tunnel tool download had an invalid size. Retry hosting to download it again.\u0022 );\n                return;\n            }\n            readTask = File.ReadAllBytesAsync( partPath );\n        }\n    }\n\n    private static async Task\u003Cbyte[]\u003E 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 \u0026\u0026 !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 \u0026\u0026 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 \u0026\u0026 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 \u002B \u0022 Retry hosting to download it again.\u0022 ); }\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 = \u0022The tunnel is running.\u0022;\n                }\n                if ( process.HasExited ) Fail( \u0022The tunnel stopped unexpectedly. Retry hosting to restart it.\u0022 );\n                else if ( State == Phase.WaitingForAddress \u0026\u0026 DateTime.UtcNow - startedAt \u003E AddressTimeout )\n                    Fail( \u0022The tunnel did not provide a public address within 25 seconds. Check the network connection and retry hosting.\u0022 );\n            }\n        }\n        catch ( OperationCanceledException ) { Fail( \u0022The tunnel tool download timed out after five minutes. Check the network connection and retry hosting.\u0022 ); }\n        catch ( InvalidDataException ) { Fail( \u0022The tunnel tool download exceeded 120 MB and was refused. Retry hosting after checking the network connection.\u0022 ); }\n        catch ( Exception ) { Fail( \u0022The tunnel could not start. Check the network and project folder permissions, then retry hosting.\u0022 ); }\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( \u0022SystemRoot\u0022 );\n        if ( !string.IsNullOrEmpty( systemRoot ) ) process.StartInfo.Environment[\u0022SystemRoot\u0022] = systemRoot;\n        // Never echo cloudflared\u0027s output into the dock: it may include machine-specific paths or diagnostic material.\n        process.OutputDataReceived \u002B= ( _, e ) =\u003E { if ( e.Data != null ) output.Enqueue( e.Data ); };\n        process.ErrorDataReceived \u002B= ( _, e ) =\u003E { 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 = \u0022Waiting for the tunnel address.\u0022;\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        (\u0022stun.l.google.com\u0022, 19302),\r\n        (\u0022stun1.l.google.com\u0022, 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\u003CIPAddress[]\u003E 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 =\u003E 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(\u0022Public address lookup: \u0022, 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 \u003E= OverallDeadline || index \u003E= 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() \u0026\u0026 utcNow - sentAt \u003E= 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\u002B\u002B;\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 \u003C 20)\r\n        {\r\n            return false;\r\n        }\r\n        for (int i = 0; i \u003C 12; i\u002B\u002B)\r\n        {\r\n            if (bytes[8 \u002B 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\u002B\u002B;\r\n        if (index \u003E= 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) ? \u0022The public address could not be determined: the STUN servers could not be resolved on this network.\u0022 : \u0022The public address could not be determined: no STUN server answered within two seconds (UDP may be blocked).\u0022);\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 \u003E\u003E 24);\r\n        array[5] = (byte)(num \u003E\u003E 16);\r\n        array[6] = (byte)(num \u003E\u003E 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 \u003C 20)\r\n        {\r\n            error = \u0022The datagram is shorter than a STUN header.\u0022;\r\n            return false;\r\n        }\r\n        if (ReadUInt16(response, 0) != 257)\r\n        {\r\n            error = \u0022The datagram is not a STUN Binding Success Response.\u0022;\r\n            return false;\r\n        }\r\n        if (ReadUInt32(response, 4) != 554869826)\r\n        {\r\n            error = \u0022The datagram does not carry the STUN magic cookie.\u0022;\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 = \u0022The STUN response declares a length its datagram does not have.\u0022;\r\n            return false;\r\n        }\r\n        int i = 20;\r\n        ushort num4;\r\n        for (int num2 = 20 \u002B num; i \u002B 4 \u003C= num2; i \u002B= 4 \u002B ((num4 \u002B 3) \u0026 -4))\r\n        {\r\n            ushort num3 = ReadUInt16(response, i);\r\n            num4 = ReadUInt16(response, i \u002B 2);\r\n            if (i \u002B 4 \u002B num4 \u003E num2)\r\n            {\r\n                error = \u0022The STUN response carries a truncated attribute.\u0022;\r\n                return false;\r\n            }\r\n            bool flag = ((num3 == 1 || num3 == 32) ? true : false);\r\n            if (flag \u0026\u0026 num4 \u003E= 8 \u0026\u0026 response[i \u002B 5] == 1)\r\n            {\r\n                byte[] array = new byte[4];\r\n                Array.Copy(response, i \u002B 8, array, 0, 4);\r\n                if (num3 == 32)\r\n                {\r\n                    uint num5 = 554869826u;\r\n                    array[0] ^= (byte)(num5 \u003E\u003E 24);\r\n                    array[1] ^= (byte)(num5 \u003E\u003E 16);\r\n                    array[2] ^= (byte)(num5 \u003E\u003E 8);\r\n                    array[3] ^= (byte)num5;\r\n                }\r\n                address = array[0] \u002B \u0022.\u0022 \u002B array[1] \u002B \u0022.\u0022 \u002B array[2] \u002B \u0022.\u0022 \u002B array[3];\r\n                return true;\r\n            }\r\n        }\r\n        error = \u0022The STUN response carries no IPv4 mapped address.\u0022;\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] \u003C\u003C 8) | bytes[offset \u002B 1]);\r\n    }\r\n\r\n    private static uint ReadUInt32(byte[] bytes, int offset)\r\n    {\r\n        return (uint)((bytes[offset] \u003C\u003C 24) | (bytes[offset \u002B 1] \u003C\u003C 16) | (bytes[offset \u002B 2] \u003C\u003C 8) | bytes[offset \u002B 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/// \u003Csummary\u003E\n/// What a project file contributes to the baseline and the manifest. Everything is its own bytes except the engine\u0027s \u003Cc\u003E.meta\u003C/c\u003E files, which carry\n/// bookkeeping the engine rewrites by itself: it sets \u003Cc\u003EsceneCompileDirty\u003C/c\u003E the moment a scene is edited. That flag says nothing about the project, but\n/// hashed as raw bytes it made a collaborator\u0027s first edit change the baseline, so Save refused with \u0022Source/assets changed during collaboration\u0022 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/// \u003C/summary\u003E\npublic static class MetaFileRules\n{\n    private static readonly string[] IgnoredFields = { \u0022sceneCompileDirty\u0022 };\n\n    /// \u003Csummary\u003EThe bytes to hash for a file. Anything that is not a readable JSON-object .meta file is returned exactly as given.\u003C/summary\u003E\n    public static byte[] Canonical(string path, byte[] content)\n    {\n        if (content == null || string.IsNullOrEmpty(path) || !path.EndsWith(\u0022.meta\u0022, StringComparison.OrdinalIgnoreCase))\n        {\n            return content;\n        }\n        try\n        {\n            ReadOnlySpan\u003Cbyte\u003E body = content;\n            if (body.Length \u003E= 3 \u0026\u0026 body[0] == 0xEF \u0026\u0026 body[1] == 0xBB \u0026\u0026 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\u003Cstring, JsonNode\u003E x) =\u003E x.Key, StringComparer.Ordinal))\n        {\n            result[pair.Key] = pair.Value == null ? null : JsonNode.Parse(pair.Value.ToJsonString());\n        }\n    }\n}\n"}]}